Architecture
A Tynd app has three surfaces:
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ Frontend │ │ Rust Host │ │ TS Backend │
│ (WebView) │◄──IPC─►│ (native layer) │◄──IPC─►│ (Bun or QuickJS) │
│ │ │ │ │ │
│ • Your UI │ │ • Window + event loop │ │ • Your exported fns │
│ • @tynd/core/client │ │ • 26 OS APIs (Rust) │ │ • createEmitter │
│ • createBackend<T>() │ │ • tynd:// + tynd-bin:// │ │ • app.start(config) │
│ • OS calls (direct) │ │ • Streaming RPC │ │ • Lifecycle hooks │
└──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘Each surface has a single, narrow responsibility. The glue between them is typed, zero-codegen, and transport-agnostic (no HTTP, no WebSocket, no TCP).
The three surfaces
1. Frontend — your UI
A pure SPA. Whatever framework you picked (React, Vue, Svelte, Solid, Preact, Lit, Angular) emits static assets into dist/. Tynd serves those through the tynd://localhost/ custom scheme — window.location.origin is tynd://localhost (no http://localhost:xxxx, no TCP port, no firewall prompt).
Three imports:
import { createBackend } from "@tynd/core/client"— typed RPC proxy to your backend.import { dialog, tyndWindow, fs, … } from "@tynd/core/client"— direct OS calls to the Rust host.import * as tynd from "@tynd/core/client"— everything above plus the Web platform globals (fetch,WebSocket,crypto,URL, …) re-exported on the same namespace.
2. Rust host — the native layer
Built from two crates:
tynd-host(library) — the native event loop, the IPC bridge, and the 26 OS APIs. Shared between both runtimes.tynd-full/tynd-lite(binaries) — one of them is the actual executable.fullspawns a Bun subprocess;liteembeds QuickJS in-process.
OS APIs (dialog, tyndWindow, clipboard, shell, notification, tray, menu, fs, http, websocket, sql, process, sidecar, store, terminal, compute, workers, keyring, autolaunch, monitor, single-instance, shortcuts, updater, app, os, path) all live in Rust. They’re called from the frontend directly — no round-trip through the TypeScript backend.
3. Backend — your TypeScript
Your exported functions, your typed emitters, your lifecycle hooks. Runs in one of two environments:
full— a Bun subprocess. You get full Bun + Node.js globals (fetch,Bun.*,node:*), JIT, and native npm bindings.lite— embedded QuickJS inside the Rust host. You get Web standards only (WHATWG + W3C + TC39), plus all 26 Tynd OS APIs. NoBuffer, noprocess.*, noBun.*.
Same TypeScript, same frontend. The only difference is what JS-level globals you can reach inside backend code. See Runtime Modes.
Two topology variants
Full mode
Frontend (WebView) ──postMessage──► Rust host (IPC handler) ──stdin JSON──► Bun backend
▲ ▲ │
│ evaluate_script │ stdout JSON ◄───────────────┘- Frontend → backend calls travel over the WebView’s
window.ipc.postMessage→ Rust parses → stdin JSON → Bun reads a line, dispatches, writes stdout JSON → Rust evaluates the response on the originating webview. - OS calls take a shortcut: frontend → Rust (direct), Rust → frontend (direct). Backend never sees them.
Lite mode
Frontend (WebView) ──postMessage──► Rust host (IPC handler) ──direct QuickJS call──► Embedded backend
▲ ▲ │
│ evaluate_script │ return value ◄────────────────────────┘- No subprocess. The backend runs in QuickJS, in-process with the Rust host.
- Events emit via
globalThis.__tynd_emit__(name, json)— injected by Rust. - Window/frontend config is read from
globalThis.__tynd_config__after backend eval (QuickJS can’t read env vars).
What’s shared, what’s runtime-specific
Shared (identical lite + full):
- All 26 OS APIs (
@tynd/core/client) — they’re Rust, independent of the JS runtime. - The RPC wire format (
{ type: "call", id, fn, args }/{ type: "return", id, value }). - The streaming RPC mechanism (per-stream credit, yield batching, cancellation on window close).
- The
tynd://frontend scheme + thetynd-bin://binary IPC scheme. - TYNDPKG packing (how your built binary embeds the frontend + backend + assets + sidecars).
Runtime-specific:
- JS-level globals inside backend code — see Runtimes.
- Bun vs QuickJS binary packing: full packs Bun + zstd; lite packs QuickJS directly into the host.
workers— lite uses an isolated QuickJS runtime on a fresh thread; full usesBun.Worker. Same API surface.
Why this split matters
- No HTTP, no TCP. Frontend assets and IPC ride a native custom scheme. No loopback port, no firewall prompt on first launch, no MITM window for an attacker on the same machine.
- No Rust in your code. You never learn, write, or maintain Rust — the host is prebuilt and downloaded by
@tynd/host’s postinstall. - OS APIs are uniformly fast. Every OS call runs on a fresh Rust thread (or, for long-lived resources like PTY and workers, a dedicated thread). The JS event loop is never blocked on a native call.
- Migration between runtimes is a one-line diff. Change
runtime: "lite"toruntime: "full"intynd.config.ts, re-runtynd build. Same source, same API surface, different binary.
Next
- Runtime Modes — lite vs full, parity table.
- IPC Model — call / event / stream flow in detail.
- Security — CSP, scheme allowlists, structural model.