How a request reaches a plugin's route, who it acts as, and how a worker process per session starts, stops and fails
Written by the redesign session from slice-10.md, the decision entries "Slice 10's points", "Origins only from
relays", "The HTTP component's security baseline" and their neighbours in decisions.md, design §3 and §23, and the
build after its four fix rounds (security baseline, fetch core, error boundaries).
Mario runs a small chat app on his laptop. Each conversation is a session, and each session runs in its own process, a worker, so a session that crashes or eats memory takes nothing else down. A browser tab talks to the server; the server talks to the workers. Plugins add features: one adds a chat panel, one adds file downloads, one adds a terminal. Mario edits plugins while the app runs, and sometimes an agent edits them for him.
Three things have to work for him without thought. A plugin adds an HTTP route, and it is served safely: no other website open in his browser can read his data or act as him through it. A session's worker exists exactly while someone uses it. And when a worker crashes, nothing restarts it in a loop: he sees why it failed and decides.
Slice 10 builds two components for this: chord/next/http (the server's HTTP and WebSocket entry) and
chord/next/workers (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
stays portable.
Http; plugins
contribute routes, wraps (middleware), WebSocket handlers and allowed hosts to it, and every reload swaps them.HEAD, download headers and backpressure done once for every plugin.A route is a function a plugin contributes for a method and path pattern. A wrap runs around routes. A
socket is a WebSocket a plugin handles with its own protocol; a link is Chord's own WebSocket at /chord,
over which tabs hold objects. Admission is what the component checks before any plugin code. Provenance is
where a request came from (local, an allowed host, a relay). A request is foreign when a browser sent it for
another website. A worker is a process serving one address (Session({ id })). A worker's start message
carries its start data and its plugins' scripts. A rollout applies a build to the server and then the workers.
A server, workers, two plugins: the files a reader needs to follow the rest.
The whole app is four files of its own plus its plugins. The server creates its host, the HTTP component and the workers component, then loads its plugins:
// server.ts
const server = createHost<P>({ address: Server(), principal: { kind: "server" }, onError });
const plugins = createPlugins({
roots: ["./plugins"],
kinds: {
server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
},
onError,
}, ctx);
const http = createHttp(server, { listen: { host: "127.0.0.1", port: 8788 }, onError }, ctx); // settings are arguments
const workers = createWorkers(server, Session, {
kind: "worker", entry: "./worker.ts", plugins, principal: (a) => ({ kind: "worker", session: a.params.id }),
start: (address, ctx) => ({ id: address.params.id, dir: sessionDir(address) }),
lock: (address) => `chat:${dataDir}:${address.key}`, authorize: mayReach, onError,
}, ctx);
await plugins.load(server, { kind: "server", core: [core, { name: "chord.http", facet: http.facet }], modules }, ctx);
// `core` contributes the app's authenticator: self.contribute(Authenticators, authenticate)
// worker.ts: one per session
await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(join(start.dir, "session.sqlite"))), ctx);
onClose((ctx) => store.close(ctx));
const worker = createHost<P>({ address, principal: { kind: "worker", session: start.id }, store, onError });
onClose((ctx) => worker.close(ctx));
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);
await plugins.load(worker, { kind: "worker", core: [workerCore(start)] }, ctx);
return { busy: { tasks: 0 } };
}, { hostPackages: modules, redact: (error) => ({ message: "worker failed" }), onError });
A plugin is a directory with a package.json and one file per kind it needs. The chat plugin keeps its messages in a
worker (each session has its own) and contributes one route on the server for exporting them:
// plugins/chat/server.ts
export default defineFacet((host, self) => {
self.contribute(Http, {
routes: {
"GET /s/:session/export": async (request, ctx) => {
if (!(await mayView(ctx.principal, request.params.session, ctx))) return { kind: "error", status: 403 };
const chat = await ctx.get(Chat, { session: request.params.session }); // reaches the session's worker
return { kind: "json", value: await chat.export(ctx) };
},
},
});
});
ctx.get(Chat, { session }) is the one line that connects the two halves: Chat's host address is the session's
worker, the server's route for Session finds (or starts) that worker, and the call goes over its link.
What happens between a TCP connection and the route's response, in order.
Every request takes the same path. The order matters: nothing a plugin wrote runs before the component decided that the request may run at all, and as whom.
authenticate is the component's own; plugins start at the router.Provenance answers "where from". A request is local only if three things hold: the socket arrived on a loopback
address, the Host header names loopback with the port, and no proxy header (Forwarded, X-Forwarded-*) is present.
Anything else must name a host the app allowed (a tunnel's public name) and is then treated as remote https. Anything
else is refused before a single plugin line runs. The security audit found that checking the Host header alone let
anyone reaching the port claim to be local: the socket address is the part a remote client cannot fake.
Foreign answers "did another website make the browser send this". Browsers say so in Sec-Fetch-Site. A foreign
request never reaches authenticate: it runs anonymous, whatever cookies it carries. That one rule stops a page on
another site from reading Mario's data through an <img> or <script src>, from driving his session through a hidden
form or fetch, and from opening his WebSocket. The single exception is a top-level navigation (Mario clicks a link
to the app from his mail) to a route marked page: true.
Origin closes what is left: a request whose Origin header names another site is refused, and a state-changing
request from a browser must name the app's own origin. A webhook that other sites must reach opts out
(crossSite: true) and then always runs anonymous, checking its own signature.
Then the app's authenticate says who the request is. The router picks the most specific route for the method
and path, from the router as it was when the request started. Wraps run around it, and the route returns a
description that the component writes.
Five kinds of response, and why routes never touch Node's response object.
In p2 every plugin wrote Node's ServerResponse itself, so each one implemented ranges, download headers and HEAD
by hand, or forgot to. In Chord a route returns data:
type HttpResponse = { readonly headers?: Readonly<Record<string, string>> } & (
| { kind: "json"; value: JsonValue; status?: number }
| { kind: "html"; body: string; status?: number }
| { kind: "bytes"; mime: string; size?: number; read(offset: number, length: number, ctx: Ctx): Promise<Uint8Array>;
download?: string; ranges?: boolean }
| { kind: "redirect"; to: string; status?: 302 | 303 | 307 }
| { kind: "error"; status: number; message?: string });
The component turns a bytes response with a size into Content-Length, a range request into 206 (or 416 when it
cannot be satisfied), HEAD into headers only, and download into a safe Content-Disposition. It reads in 64 KiB
pieces and waits whenever the client is slower, so a large file never piles up in memory, and it stops reading the
moment the client disconnects. Without a size it sends the body chunked until read returns an empty piece: a zip
built on the fly, or an export paged from a worker one call per piece.
fileResponse(path, options) is a bytes response over a file: it opens the file only when the first byte is
needed and closes it when the request ends, so a HEAD or a disconnect leaves no open handle behind. Request bodies
work the same way in reverse: await request.json() reads at most 1 MiB and parses it; request.body(limit) yields
chunks for an upload streamed to disk. A body past its limit gets 413; nothing is read until a route asks.
A route that throws is a 500 without its message: error text often holds paths or tokens, so it goes to the server's
onError, never to the client.
The app's one security function, and everything the component does so the app does not have to.
The app writes one function about identity and contributes it to the registry Authenticators (plugins may add
more, a device pairing for instance; the first principal wins):
const authenticate: Authenticator = async (request, ctx) => {
const device = readCookie(request, "device", secret); // signature and expiry checked
if (device) return { kind: "owner", access: "device", device };
const guest = readCookie(request, "guest", secret);
if (guest) return { kind: "guest", grant: guest };
if (request.provenance.kind === "local") return { kind: "owner", access: "local" };
return undefined; // anonymous
};
self.contribute(Authenticators, authenticate); // in the app's core facet
It never parses Host, never checks Origin and never sees a foreign request: the component already did all of it.
It may read request.provenance (local, remote with the host, or relayed through a verified relay such as
Radius). Cookies are written with cookie(name, value, { secret, request, maxAge }), which sets HttpOnly,
SameSite, Secure on a remote origin, and signs the value with its expiry.
Every response, the component's own 404s and 403s included, carries the same headers: no caching, no MIME sniffing, no
referrer, no framing (X-Frame-Options: DENY and frame-ancestors 'none', except for framers the app allowed), and a
Content Security Policy with a fresh script nonce per response (request.nonce), so an injected <script> does not run.
| Attack | What stops it |
|---|---|
| a website points its domain at 127.0.0.1 (DNS rebinding) | provenance: Host must be loopback or allowed |
| a request from the LAN or a proxy claims to be local | provenance: the socket's own address, no proxy headers |
another site reads private data through <img>, <script src>, fetch |
foreign requests run anonymous |
| another site posts a form or opens the WebSocket as Mario | foreign Origin refused; anonymous anyway |
| another site frames the app to trick clicks | frame-ancestors 'none', X-Frame-Options: DENY |
a path like /files/..%2F..%2Fsecret |
decoded /, ., .., \ in a path segment are 400 |
| a crafted request target crashes the server | the target is parsed by the component, a failure is 400 |
The core is a Web fetch handler, so an app with its own server (Hono, Express, plain Node with nodeRequest) mounts
it and gets the same checks; without the socket's addresses a mounted request is never local.
Chord's own WebSocket for tabs, a plugin's own WebSocket for its protocol, and what closes them.
A tab holds objects over /chord, a link: the component authenticates the upgrade and calls host.accept with
the principal. A link has Chord's heartbeat: silent for 30 seconds, it closes.
A plugin with its own protocol, a terminal for instance, contributes a socket:
self.contribute(Http, { sockets: { "/terminal": (socket, request, ctx) => terminals.attach(socket, ctx) } });
The component upgrades after admission and authenticate (no principal: refused before the plugin sees it) and hands
the plugin a ready WebSocket. It pings it after 15 seconds of silence and terminates it after 30, so a phone that
vanished does not hold a terminal open for hours.
What was removed is closed at once. When a reload changes or removes the plugin that handed out a socket, the socket is terminated: old code does not keep serving it. When an allowed host is removed, every link and socket that came through it is terminated. "Terminated" means immediately, not a polite close a hostile client could ignore.
Started by the first use, connected before it loads, stopped when nobody needs it.
Nothing starts a worker but a use of its session. A tab opens session S1 and holds Chat { session: S1 }:
started comes as soon as the worker connected; the server then talks to it while it loads. The 60-second start
timer covers only fork, lock and connect: a session with a long migration loads as long as it needs, its objects
loading meanwhile. ready comes after the load and lets idle stops begin.
The lock makes sure one live worker serves an address, even across a server restart while an old worker still closes its store: the new one waits for the old one to let go.
Idle means nothing uses the worker: no holds arriving over its link, no jobs, no lingering objects, and none of the
app's own busy counts (its running agent tasks). Local calls inside the worker do not count; they come from work that
is counted elsewhere. The worker reports only when that flips between idle and busy. After five idle minutes the
server sends shutdown; the worker closes its host and store in reverse order of opening and exits.
workers.stop(address) is the archive button: it stops the worker and keeps it stopped while a tab still holds the
session (the tab sees "suspended"). With the last holder gone, the next use starts it again.
No automatic restarts: why, and how a failure is shown and cleared.
A worker crashes: an out-of-memory kill, a bug in a native addon. The obvious design restarts it. Mario rejected that on 2026-10-30: a crash usually has a reason, and the same code on the same data crashes again, while the agent task that caused it is resumed by the restart and runs into it again. A timer only makes the loop slower.
Worker S1 exits unasked. The server records the session as failed with the error, and reports the full error and the
end of the worker's stderr to its own onError, for Mario's logs.
Every tab holding S1's objects sees failed { waiting } with a fixed message ("worker exited (SIGSEGV)"), never the
raw error: it may hold paths or secrets, and guests see it too. A new tab opening S1 sees the same and forks nothing.
Mario presses Retry (workers.retry(Session({ id: "S1" }))). The server starts the worker; the tabs' references bind
to it without reloading the page.
Mario fixes the plugin and rolls out. The rollout clears the failures from before it and starts the failed sessions that tabs still hold, once, with the new code. A session that fails again stays failed.
Every way a worker can fail ends the same: a crash, a fatal from the worker, a start that throws or times out, and a
link lost while the process lives (the heartbeat after a blocked event loop), which the server ends by killing the
process after five seconds.
One build on the server, scripts sent to every worker, a result per host.
Building a plugin takes esbuild a few milliseconds per facet, but a cold build of twenty plugins takes a quarter of a second. Doing that in every worker that starts would slow every session's start and let workers see different code. So the server builds every plugin, once, and a worker only evaluates.
A worker's start message carries the complete current set of worker scripts. When Mario edits a plugin and rolls out, the rollout runs its steps in order:
const built = await plugins.build(plugins.state.value.dirty, ctx);
const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, ctx);
// result.steps[1].hosts: [{ host: "session|S1", report }, { host: "session|S2", stopped: true }, { host: "session|S3", error }]
The workers step sends the complete new set to every running worker in parallel; each reloads per plugin (a broken
plugin keeps its old code, as on the server) and answers with its report. A worker stopped cleanly during the step
is listed stopped and gets the new set at its next start. A plugin's "dirty" mark clears only when it was applied
on every host of every step, so a plugin that failed anywhere stays marked for the next try.
What plugins can change while the app runs, and what needs a restart.
The app is meant to modify itself, so every option that is not needed to start the process is changeable by plugins.
Routes, wraps, sockets, allowed hosts and trusted framers are contributions to Http; a contribution that changes is a
writable, and the router recomposes on each change:
const hosts = self.writable({ hosts: [] as string[] });
self.contribute(Http, hosts);
self.on(tunnel.publicHost, ({ value }) => hosts.set({ hosts: value ? [value] : [] }));
Authenticators, framers and extra CSP sources are contributions too; functions the workers component calls at each
use (a worker's start data, its idle time) can read the app's own registries, which plugins contribute to. What needs a restart is only the
bootstrap: the port, the /chord path, the worker's entry file and its Node flags.
What the reviews found, and what comes after this slice.
Four build reviews, three security audits and three rounds of rechecks shaped the slice. They found a malformed request
crashing the server, Host-only provenance, origins invented by a tab (a kernel hole since slice 4a, now refused on tab
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
what it buys: a scope per request, admission and the baseline headers.
Next comes the app kit (chord/next/app), which wires these components with Chord's conventions so an app writes
startApp and startWorker, then slice 11: the browser page and the terminal UI as hosts.
| Call | What it does |
|---|---|
createHttp(host, { listen?, links?, closeTimeout?, onError }, ctx) |
the component: facet, fetch, upgrade, close |
Authenticators contribution |
(request, ctx) => P | undefined: who a request is |
nodeRequest(message, res) |
a Node request as a Web Request, with the listener's checks |
http.facet |
the facet serving Http; load it with the plugins (in core) |
Http contribution |
{ routes?, wraps?, sockets?, maxPayload?, hosts?, framers?, relays?, csp? } |
| route | (request, ctx) => Promise<HttpResponse>, or { handler, page?, crossSite? } |
fileResponse(path, options), cookie(name, value, options) |
a file as bytes; a safe cookie |
createWorkers(host, Kind, { kind, entry, start, lock, principal, authorize, plugins, idleMs?, onError }, ctx) |
a worker per address |
workers.state, stop, retry, close, step(name) |
status per address; archive; Retry; shutdown; a rollout step |
workerMain(fn, { hostPackages, redact, onError }) |
the worker process's side |
RouteHandle.retry(address), suspend(address) |
the kernel's route, as host.route returns it |
Serving and Spawning · first edition · 2026-10-31 · Chord v2 slice 10