# Slice 10: HTTP and workers (spec) Status: **built** (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, `/tmp/s10-spec-review-{fable,opus}.md`, round 2 by Fable, Opus, Astra and Sol, `/tmp/s10-spec-review2-*.md`, folded in). Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/http` (the server's HTTP and WebSocket entry, routes contributed by plugins through a registry, responses as descriptions) and `chord/workers` (a worker process per host address, started on demand, stopped when idle, its plugins shipped from the server). Both live in `packages/chord/src/node/` (Node-only); the kernel stays portable. The web page host and the TUI are slice 11, telemetry slice 9. Read in full before writing code: this spec; `decisions.md`; `design.md` (§3 hosts, routes, `host.activity`, §17, §18, §21 "Loading plugins and the reload loop", §23 in full); `slices.md`; `slice-7c.md` and `src/node/plugins/README.md`; the 7b brief `slice-7b/book.md` (chapters "Assets and Files", "HTTP", "A Mini p2 from Chord"); p2's `src/hosts/server.ts`, `src/runtime/server.ts`, `src/hosts/worker.ts`, `src/hosts/socket-limits.ts`, `src/hosts/principals.ts`, `plugins/files/server.ts`, `plugins/terminals/server.ts`, `plugins/radius/server.ts`; Chord v1's `src/old/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`). ## 1. What p2 has, and what moves into Chord p2's server host (`src/hosts/server.ts`, 742 lines; `src/runtime/server.ts`, 720) does, besides p2's own policy: | p2 today | Moves to | | --- | --- | | a Node `http` server on loopback, requests parked until `load` resolved, upgrades captured before listening | `http` | | `Routes` (`"METHOD /path"`, `/prefix/*`), handlers writing Node's `ServerResponse` themselves (ranges, download headers, `HEAD` by hand in `files`) | `http`: routes returning descriptions | | `Upgrades` (a WebSocket by path for terminals and voice), `/chord` (`acceptWebSockets` with per-socket limits) | `http` | | `Authenticators`, `RequestResolvers` (cookies, devices, the Radius relay), Fetch Metadata, CSP and the loopback cookie | the app (p3's base code), through `http`'s `authenticate` and `headers` (D3) | | loopback and allowed hosts, the `Origin` check on upgrades | `http` (R4), allowed hosts contributable by plugins | | `SessionHosts`: a worker per session, `createHostManager` + `forkDriver`, a kernel lock per session, idle stop from reported counts, crash notices, sticky failed starts, ready timeout | `workers` (failures stay until Retry or a rollout, R2) | | relocation and pause of a worker, the catalog, `WorkerInitValues` | the app (built on `workers`' `stop` and start data) | | `hosts/worker.ts`: the child's control channel, lock, start data, connect, load, report counts, shutdown | `workers`' child side | | the dev reload across server and workers, worker plugin lists | `plugins` (7c) plus `workers`' rollout step (§4) | Every option that is not bootstrap is changeable by plugins (decision "The app kit is malleable by plugins"): either a contribution to `Http` or a function the component calls per use, which the app backs with its own registries; nothing is copied out of a contribution or an option at startup. Bootstrap (changed by a restart): the listen address and port, the links path, the worker entry with its `execArgv` and `env`, the host kind workers serve. ## 2. `chord/http` As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object", "Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23 states every rule, `src/node/http/README.md` the API. ```ts const http = createHttp(server, { listen: { host: "127.0.0.1", port: 8788 }, // settings: arguments, a restart to change links: { path: "/chord", options: (request, principal) => ({ limits: linkLimits(principal), maxPayload: 64 * MiB }) }, closeTimeout: 30_000, onError: (error) => log(error), // `HttpError`, below }, ctx); const app = defineFacet((_host, self) => { self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret))); self.contribute(Http, { hosts: ["notes.example.com"], relays: [radiusRelay] }); }); await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }], modules }, ctx); … await http.close(ctx); // first: no new requests, sockets closed, running requests waited for up to `closeTimeout` await workers.close(ctx); await server.close(ctx); ``` - **The core** is `http.fetch(request, { localAddress?, localPort?, remoteAddress? })`, a Web `Request` to a Web `Response`; `http.upgrade` is Node's upgrade adapter; `nodeRequest(message, res)` converts a Node request with the listener's checks (`{ kind: "request", request, connection } | { kind: "refused", status }`, never a throw) for apps mounting on their own Node server. Chord's own listener (with `listen`) is a host-lifetime object of `chord.http` whose native is a Node server, on a fast path that skips the Web `Request` unless a route or authenticator reads it. - **Registries:** `Http` (`"chord.http"`, version 1: `routes`, `wraps`, `sockets`, `maxPayload`, `hosts`, `framers`, `relays`, `csp`; a value or a writable) and `Authenticators` (in contributor order, the first principal wins). Requests before the publication wait for it (503 past `readyTimeout`). - **Admission, foreign requests, origins, default headers, cookies, downloads:** the security baseline (design §23). Refusals before any plugin code: a non-loopback socket without an allowed host, forwarded headers without a verified relay, duplicate or missing `Host`, userinfo in `Host`, absolute-form targets (403), unparsable targets (400), `TRACE`/`TRACK`/`CONNECT` (501; a route for them refused at compose). - **A request's scope, `HttpRequest`, patterns, wraps, responses, `fileResponse`, sockets, links, revocation, `close`:** design §23. The spec's earlier text (an `httpServer` that listens first, `allowedHosts`, one `authenticate` and a `headers` option, 64 KiB reads) is superseded by those decisions. - **Errors** (`onError`, `HttpError`): `route_failed { pattern, plugin, error }`, `wrap_failed { name, plugin, error }`, `socket_failed { pattern, plugin, error }`, `response_failed { pattern, error }`, `hook_failed { hook, plugin?, error }` (`authenticate`, `relay`, `links.options`). - `ws` is an optional peer dependency (D7), imported only here. ## 3. `chord/workers` ```ts // server.ts const workers = createWorkers(server, Session, { kind: "worker", // the facet kind it ships entry: "./worker.ts", execArgv: ["--import", resolver], env: { … }, // bootstrap authorize: (principal, address) => app.mayReach(principal, address), // the route's `authorize` (synchronous) principal: (address) => ({ kind: "worker", session: address.params.id }), start: (address, ctx) => app.startData(address, ctx), // JSON, before each start lock: (address) => `app:${realpath(dataDir)}:worker:${address.key}`, // one live worker per address idleMs: () => app.idleMs(), plugins, // the plugins component onOutput: (address, stream, chunk) => logs.append(address.key, stream, chunk), onError: (error) => log(error), // `WorkersError`, below }, ctx); workers.state // JSON watchable (R5) await workers.stop(Session({ id }), ctx); // archive: suspended while held (Q3), then stopped await workers.retry(Session({ id }), ctx); // the Retry button: a failed or suspended address resolves again await workers.close(ctx); // no new starts, every child stopped ``` ```ts // worker.ts 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)); // closers run in reverse order const worker = createHost
({ address, principal: { kind: "worker", session: start.id }, store, onError });
onClose((ctx) => worker.close(ctx));
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx); // `started` goes out now: connect
// before anything slow (a store opens in ms)
await plugins.load(worker, { kind: "worker", core: [{ name: "@app/core", facet: coreWorker(start) }] }, ctx);
return { busy: harness.activeTasks }; // extra keep-alive counts
}, {
hostPackages: { "@earendil-works/chord": chord, "@app/core": appCore }, // the child's own instances
redact: (error) => ({ message: redactSecrets(String(error)), ...codeOf(error) }), // what crosses as `fatal`
onError: (error) => log(error),
});
```
- **The route:** `createWorkers` calls `host.route(Kind, { authorize, resolve }, ctx)` itself and keeps the
`RouteHandle` (`retry`, `suspend`: D10b, R1). `authorize` is synchronous (the kernel's): a plugin backing it keeps an
index.
- **Per address, events and operations:** a child's events (its exit, its link's close, `started`, `ready`, `fatal`)
are observed as they happen and recorded at once on that child (a late event of an old child never changes a new
one); the operations (start, stop) run in one queue per address: a start waits for the previous child's exit, a
stop during a start waits for it then stops, and a start queued behind an exit checks the address's state after it
(a crash recorded meanwhile makes it reject with that failure). Stops record their intent before `shutdown`, so the
exit they cause is no crash.
- **A start** (`resolve(address)`): calls `start(address, ctx)` (abortable; a throw rejects the start), checks that the
component is not closing, forks `entry` (`serialization: "advanced"`, a detached process group, stdout and stderr
piped: the stderr tail kept, mirrored to the server's when there is no `onOutput`), makes `ipcLink(child)` at once
(so the child's `hello` and early holds are queued, never lost), and sends the start message `{ address, start,
scripts, lock }` (`scripts`: the worker kind's complete current set, read when the message is sent). The child takes
the lock (when an old worker still holds it, it waits by connecting to the lock's socket and trying again when that
connection closes: no polling), and sends `started` when the app's host sets its listener on `parent` (`parent` is
a thin wrapper of the IPC link, so `connect` is the signal: the app connects before anything slow). `resolve` then
returns `{ link, principal: principal(address), trusted: true }`, and the handshake and the worker's server traffic
run during its `load`. The start timer (60 s from the fork) covers fork, lock and connect only (Q4): past it the tree is
killed and the start fails. `ready` (after the app's function returned, carrying the idle state and its load report) sets `state` to
`running` and lets idle stops begin; a `load` that never ends shows `starting` until `stop`.
- **Workers never restart by themselves** (R2): a crash (an exit nobody asked for), a `fatal`, a link lost while its
child lives, or a failed start (`start` threw, the start timer, a lock not taken in time) leaves the address `failed`
with its error (`worker_failed` reported once). A link lost while its child lives (the heartbeat after a blocked
event loop, a protocol error) starts the stop: the grace (5 s), then the tree is killed; its exit is the crash ("link
lost"). `resolve` of a failed address rejects at once with that error; its holders and new holds see `failed {
waiting }` with it as cause (Q1) and stay on the address's path, nothing forks. `workers.retry(address)`
(`RouteHandle.retry`) or a rollout reaching workers clears it (Q2, §4). An idle stop is no failure: the next hold
starts the worker on demand.
- **Keep-alive** (D9, R3, Q5): the child's idle state is all of `host.activity`'s `references`, `jobs` and `lingering`
and the app's `busy` (a JSON watchable of numbers) at zero, not `calls` (a remote call runs on a hold; local calls
come from routines, jobs and tasks counted elsewhere). `ready { idle }` carries the first state, then `{ idle }` on
each flip; a rollout applying in the child counts as busy. The server stops a worker idle since `idleMs()` (5 min by
default; checked every second), never before `ready`. A stop (idle, `stop`, `close`): `shutdown`; the child aborts its
`ctx`, runs the closers registered with `onClose` in reverse order and exits, forcing its own exit after 3 s; the
server kills the tree after 5 s.
- **`state`** (R5, decision "worker errors in public"): per address key `{ status: "starting" | "running" |
"stopping", pid, startedAt }` or `{ status: "failed", error: { message, code?, reason? }, at }` with a public error
(a fixed message, or the child's redacted `fatal`); no entry when stopped or never started. The full error and the
stderr tail go only to `onError` (`worker_failed`); every app hook of a start (`start`, `principal`, `lock`, the
fork) is inside that boundary.
- **`stop(address, ctx)`** (R1, Q3): `RouteHandle.suspend(address)`, then the stop. The suspension lasts while the
address has holders (they see `failed { waiting }`, "suspended"); with the last holder it goes, and a later hold
starts the worker on demand; `retry(address)` lifts it at once. Whether an archived session may be opened is the
app's (`authorize`, its views).
- **`close(ctx)`:** cancels active and queued starts first (a `start(address)` waiting on the app's barrier is
aborted; nothing forks after it), then stops every child (grace, then kill), clears the timers; new starts reject
`released/closed`; the caller's cancellation ends only its own wait. `createWorkers`'s `ctx` aborting (the host
closing aborts the route's context) closes it too, and from then on every exit is a stop, never a crash (no
`worker_failed` at shutdown).
- **The child** (`workerMain(fn, { hostPackages, redact, onError })`): reads the start message, takes the lock, makes
`ipcLink(process)` and its wrapper `parent`, creates its plugin runtime with `hostPackages` (the child's own module
instances; functions cannot cross IPC), calls `fn({ address, start, parent, plugins, onClose }, ctx)`. `shutdown`,
the parent's disconnect, `SIGTERM` and `SIGINT` alike abort `ctx` and run the closers registered so far, also while
`fn` still runs (its startup is cancelled through `ctx`). An uncaught error, or `fn` throwing, sends `fatal` with
`redact(error)` (never the original message) and exits; after `started` that is a crash like any other.
- **Control messages** (JSON beside the link's `Uint8Array` frames; the IPC link skips them): parent to child `start`,
`shutdown`, `reload { scripts }`; child to parent `started`, `ready { idle }`, `idle { idle }`, `fatal { error }`,
`reloaded { report } | { error }`. One `reload` is outstanding per child at a time: a reload whose caller aborted
keeps the child's slot until its `reloaded` or the child's exit, and the next waits for it. Errors cross as `{
message, code?, reason? }`.
- **Errors** (`onError`, `WorkersError`, which includes `BatchError` for a `state` watch listener that throws): `worker_failed { address, error, stderrTail? }` (private),
`authorize_failed { address, error }` (a throwing `authorize`: the hold is refused `forbidden`), `worker_exited {
address, code, signal }` (only for an exit nobody asked for: not after a stop or a start the component failed).
### Plugins in workers (D11)
Workers never build. The plugins component (7c) gets `scripts(kind, ctx)`: the complete current set of one kind (every
discovered plugin's facet of it, built on first use, then updated by every rollout that ships that kind), each with its
plugin record (`name`, `dir`, `chord.external`). The component's per-host loading (its loaded map, `#prepare`, `adopt`,
the queue per host) moves into a file without esbuild or the watcher, `createPluginRuntime()`
with `load(host, { kind, core, scripts, modules }, ctx)` (`modules`: the host packages' module objects of this process) and `reload(host, scripts, ctx)` (a complete set: removed = loaded −
shipped; same digests unchanged); `createPlugins` uses it, and the child's `plugins` is a `WorkerPlugins` over one
(`load(host, { kind, core })` loads the start message's scripts; `reload` messages wait until `fn` returned); `import`
of `chord/plugins/runtime` loads no esbuild. Each kind is defined once in `createPlugins({ kinds: {