docs(chord): slice 10 explainer (HTTP and workers)

This commit is contained in:
Mario Zechner 2026-10-31 13:30:29 +00:00
parent 07309b63eb
commit b79f21095d
2 changed files with 884 additions and 0 deletions

View file

@ -0,0 +1,377 @@
---
title: Serving and Spawning: HTTP and workers in Chord v2
cover: Serving / and Spawning
kicker: An explainer · Chord v2 slice 10 · 2026-10-31
subtitle: How a request reaches a plugin's route, who it acts as, and how a worker process per session starts, stops and fails
stats:
- 1 | registry plugins contribute routes, wraps, sockets and hosts to
- 0 | automatic worker restarts
- 5 min | idle before a worker stops (default)
- 6 | security rules on by default
note: |
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 in `/home/badlogic/pi-7b`. The build exists; its fix round (the security baseline among it) is next. Where the
build and the decisions differ, this book says what is decided.
colophon: Serving and Spawning · first edition · 2026-10-31 · Chord v2 slice 10
---
# Before we begin {.preface}
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.
## Eight things to know
:::findings
1. **Plugins contribute routes, Chord serves them.** The HTTP component defines one registry, `Http`; plugins
contribute routes, wraps (middleware), WebSocket handlers and allowed hosts to it, and every reload swaps them.
2. **A route returns a description.** JSON, HTML, bytes, a redirect or an error; the component writes it, with ranges,
`HEAD`, download headers and backpressure done once for every plugin.
3. **Security is built in.** Where a request came from, whether another website sent it, and which headers every
response carries are decided by the component before any plugin code runs. The app only says who someone is.
4. **A worker starts on the first use of its session** and stops after five idle minutes. The server forks it; the
worker connects back and loads its plugins.
5. **A worker never restarts by itself.** A crash or a failed start leaves the session failed with its error until a
person presses Retry or rolls out new code.
6. **Workers never build plugins.** The server builds every plugin once; a worker evaluates the scripts it is sent.
7. **A rollout reaches every host.** The server reloads first, then every running worker, each reporting its result.
8. **Everything but the bootstrap is changeable by plugins.** Routes, hosts, start data, idle times: contributions or
functions read at each use.
:::
## Words used throughout
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.
# The App We Build {#app lede="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:
```ts
// 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 = await httpServer(server, { listen: { host: "127.0.0.1", port: 8788 }, authenticate, onError }, ctx);
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);
```
```ts
// 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:
```ts
// 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.
# A Request, Start to End {#request lede="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.
:::flow wide animate | One request through the HTTP component. Everything left of `authenticate` is the component's own; plugins start at the router.
row "Admission" accent
box prov "Provenance" "local, allowed host or relay; else 403"
box foreign "Foreign?" "another website's browser request"
box origin "Origin" "foreign Origin 403; unsafe methods need ours"
row "Who" blue
box auth "authenticate" "the app: cookies, devices"
box anon "anonymous" "foreign or no credential" muted
row "Plugins"
box router "Router" "pattern → route, as of now"
box wraps "Wraps" "observe, headers, transform"
box route "Route" "returns a description"
row "Answer" muted
box write "Write" "headers, ranges, chunks, backpressure"
arrow prov -> foreign
arrow foreign -> origin
arrow origin -> auth
arrow origin -> anon "foreign"
arrow auth -> router
arrow router -> wraps
arrow wraps -> route
arrow route -> write
:::
**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.
# What a Route Returns {#responses lede="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:
```ts
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.
# Who Is Asking {#who lede="The app's one security function, and everything the component does so the app does not have to."}
The app writes exactly one function about identity:
```ts
async function authenticate(request: AuthRequest, ctx: Context): Promise<P | undefined> {
const device = verifyCookie(request.cookies.device, secret);
if (device) return { kind: "owner", access: "device", device: device.id };
const guest = verifyCookie(request.cookies.guest, secret);
if (guest) return { kind: "guest", session: guest.session, grant: guest.id };
if (request.provenance.kind === "local") return { kind: "owner", access: "local" };
return undefined; // anonymous
}
```
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 the component's helper, which sets `HttpOnly`, `SameSite`, `Secure` on a remote
origin and a signature.
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 per-server script 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 |
:::note | Decided, not yet built
The security baseline was decided on 2026-10-31 after the security audit and comes with the build's fix round. The
build as reviewed checks `Host` and `Origin` on upgrades only.
:::
# Sockets and Links {#sockets lede="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**:
```ts
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.
# A Worker's Life {#worker lede="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 }`:
:::sequence animate | The first hold of a session starts its worker. The link exists before the worker loads, so it serves while loading.
actor tab "tab"
actor server "server"
actor worker "worker S1" "forked" accent
tab -> server: hold Chat { session: S1 }
server -> server: route Session(S1): resolve
server => worker: fork, start message (start data, scripts)
worker -> worker: take the lock for S1
worker --> server: started (connected)
server -> worker: hold Chat (relayed for the tab)
worker -> worker: load plugins
worker --> server: bound (Chat's state)
server --> tab: bound
worker --> server: ready { idle: false }
:::
`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.
# When a Worker Fails {#failure lede="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.
:::steps | A crash and its recovery
+++ The crash
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.
+++ Holders see it
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.
+++ Retry
Mario presses Retry (`workers.retry(Session({ id: "S1" }))`). The server starts the worker; the tabs' references bind
to it without reloading the page.
+++ Or a fix
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.
# Shipping Plugins to Workers {#ship lede="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:
```ts
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.
# Changing It at Runtime {#runtime lede="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:
```ts
const hosts = self.writable({ hosts: [] as string[] });
self.contribute(Http, hosts);
self.on(tunnel.publicHost, ({ value }) => hosts.set({ hosts: value ? [value] : [] }));
```
Functions the components call at each use (`authenticate`, the security headers' extra sources, 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's Next {.epilogue lede="The fix round, and the decisions it carries."}
The build exists and passed its tests; four build reviews and three security audits found the issues the fix round now
addresses: a malformed request crashing the server, the `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, several close and
shutdown races in workers and HTTP, and two leaks. With it come the decisions of 2026-10-31: the security baseline,
relays, allowed hosts only through contributions, public worker errors as fixed messages.
Still to decide: `fileResponse` and symbolic links leading out of a served directory; the worker lock on macOS, where
a stale lock file cannot be taken over safely; and a route's overhead, measured at 11 to 19 percent of a plain Node
handler against a bar of 10 percent.
# API in Brief {.appendix}
| Call | What it does |
| --- | --- |
| `httpServer(host, { listen, authenticate, links?, closeTimeout?, onError }, ctx)` | listens, admits, serves `Http` |
| `http.facet` | the facet serving `Http`; load it with the plugins (in `core`) |
| `Http` contribution | `{ routes?, wraps?, sockets?, hosts?, framers?, relays? }` |
| 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 |

File diff suppressed because one or more lines are too long