A process doesn't know where it runs. Move it into a worker, another tab, or a server, and the code that uses it stays the same.
The guide ended with the registry: lookup(name, args) gets or starts a process by name. A registry can also be served from somewhere else, such as a Web Worker, another tab, or a server, and reached through a connection. A connection has the same lookup, so choosing where state lives takes one line:
const shop = registry({ cart: define(cart) }) // this tab
const shop = connect<Shop>(portTransport(new Worker(url))) // another thread
const shop = connect<Shop>(broadcastChannelTransport('shop')) // another tab
const shop = connect<Shop>(webSocketTransport('wss://…')) // another machine
A transport is anything that carries messages in order: a worker port, a BroadcastChannel between tabs, or a WebSocket. Everything that uses shop stays the same:
| What stays the same | Why |
|---|---|
| The process | It has no transport-specific code. The shared-cart example runs one definition both in the tab and on a Node server. |
cast and call | They work the same way and keep the same types. |
| Precise updates | A remote handle receives patches and applies them locally, so a binding on items[3].done still sleeps through a change to total. |
| Loading and failure | pending, stale, and error are the same reads. If the connection drops, the last value stays readable and stale becomes true. |
| Cleanup | Disposing the handle ends the subscription, and the host can let the process go once nobody is watching it. |
Two things. Values that cross a connection must be JSON: objects, arrays, strings, numbers, booleans, and null. A Date or a class instance works locally but doesn't survive the trip. And calls can now fail or arrive late for reasons that have nothing to do with the process, so handle the rejection.
A long computation on the main thread freezes the page. Moving its process into a worker keeps the page responsive without changing how the page reads it. Inside the worker, serving a registry takes one line after the imports:
import { define, registry } from '@nonchalant/core'
import { expose, portTransport, workerEndpoint } from '@nonchalant/wire'
import { primes } from './primes.ts'
expose(registry({ primes: define(primes) }), portTransport(workerEndpoint()))
The demo gives the worker deliberately heavy work: testing numbers above a trillion for primality, in 30 ms batches. The hand beside it is animated by the page's own thread, and keeps sweeping smoothly while the worker grinds.
The worker example goes further: it moves the calculation between the main thread and a worker while it runs, with a frame meter to show the difference.
Open the multi-tab example in two tabs. They share state over a BroadcastChannel: one tab hosts the processes and the others connect to it. When the hosting tab closes, another one takes over. No server is involved.
The chat example puts its rooms on a Node server. A room is a plain process that keeps the last 50 messages. It knows nothing about sockets, sessions, or servers:
export const room: Proc<RoomState, RoomMsg, { name: string }> = async function* (self) {
let lines: ChatLine[] = []
let nextId = 1
yield { lines }
for await (const msg of self) {
lines = [...lines.slice(-49), { id: nextId++, from: msg.from, text: msg.text }]
yield { lines }
}
}
The server serves a registry of rooms over WebSockets, and each tab connects and looks up the room it wants:
// the server (examples/chat/server.ts)
const host = await serve({ room: define(room, { evict: 3_600_000 }) }, {
port: 4322,
allowedOrigins: loopback,
scope: (request, reg) => {
const from = nickname(request) // identity fixed when the socket opens
return {
lookup: (name: string, args: unknown) => {
if (name !== 'room') throw new Error(`not exposed: ${name}`)
return reg.lookup('room', roomArgs(args)) // arguments checked before any room starts
},
admit: (_name, msg) => admitPost(msg, from), // every post rebuilt with its real sender
}
},
maxWatchesPerConnection: 64,
})
// a tab
const conn = connect<ChatSchema>(webSocketTransport('ws://127.0.0.1:4322/'))
const lobby = conn.lookup('room', { name: 'lobby' }) // get-or-spawn, on the server
The name and arguments are the address, so every tab that looks up room with { name: 'lobby' } reaches the same process. The first lookup starts the room, and the registry disposes it an hour after the last tab leaves. An idle room is a suspended generator, which costs little memory, so one Node process can host many.
flowchart LR T1["tab A"] -- "lookup room lobby" --> H T2["tab B"] -- "lookup room lobby" --> H T3["tab C"] -- "lookup room dev" --> H H["the host: one session per socket"] --> REG["registry: a name plus its args is the address"] REG --> P1[["room lobby"]] REG --> P2[["room dev"]] P1 -- "yield, diffed to a patch" --> H H -- "patch" --> T1 H -- "patch" --> T2
The scope function runs once per connection and decides what that connection can reach. Here it fixes the sender's nickname when the socket opens, checks the room name before any room starts, and uses admit to rebuild each incoming post with that nickname. A client can't post as someone else, and a malformed message can't crash a room.
pnpm chat-server, then pnpm dev, and open /examples/chat/ in several tabs. pnpm cart-server does the same for the shared cart, which switches between local and hosted with one commented line.The protocol has eight message types. Four go from client to host (lookup, cast, call, exit) and four come back (yield, reply, done, raise). A yield carries a patch, the paths that changed and their new values, and never HTML or code. So a message's size follows the size of the change rather than the size of the state, and rendering and local interaction stay in the browser.
After a disconnect, the client keeps the last value, marked stale. When the transport reconnects, it repeats its lookups and receives a full snapshot, which is diffed against what it kept, so bindings whose values didn't change stay asleep.
The protocol doesn't depend on JavaScript. packages/wire/spec/ holds conformance vectors, so a host written in another language can prove it speaks the same protocol. The protocol document has the details.
The Node host in @nonchalant/host is open by default so the examples need no setup. Before exposing one to the internet, configure its guards:
| Option | What it prevents |
|---|---|
allowedOrigins, authorize | Other websites opening a socket with a visitor's cookies, and connections without a valid credential. |
scope | A client reaching processes or data that aren't theirs. It decides, per connection, what can be looked up, and can supply the arguments itself. |
admit | Malformed or forged messages. It screens every cast and call before delivery, and can rebuild them. |
lookupRate, totalLookupRate, maxWatchesPerConnection, maxEntries | One client, or many together, filling server memory by looking up endless different arguments. |
maxBufferedBytes, heartbeatMs, maxPayloadBytes | Clients that stop reading, connections that silently died, and oversized messages. |
The set of names a host serves limits what can be looked up, but it doesn't say who is asking: authorization is up to these options and your processes. Hosting safely covers each option in detail.