From 8959ad6e6845933dd01dbaa96929243d68bc4d48 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 31 Oct 2026 20:40:32 +0000 Subject: [PATCH] feat(chord): Chord v2 slice 10, HTTP and workers chord/next/http: a Web fetch handler built from the registry Http (routes, wraps, sockets, hosts, relays, CSP sources) and Authenticators, responses as descriptions, a security baseline on by default, Chord's own Node listener as a host-lifetime object, nodeRequest for apps mounting it, links and plugin sockets. chord/next/workers: a forked worker per address, started on demand, stopped when idle, never restarted by itself, plugins built by the server and shipped. Plugins: kinds with platform and host packages, createPluginRuntime, scripts(kind), rollout results per host, imports of other plugins by name. Kernel: RouteHandle (retry, suspend), no route backoff, anonymous host.root, origins only from relays. ws is an optional peer dependency. --- package-lock.json | 6 +- packages/chord/CHANGELOG.md | 1 + packages/chord/docs/next/benchmarks.md | 43 + packages/chord/package.json | 6 +- packages/chord/src/next-node/http/README.md | 166 ++++ .../chord/src/next-node/http/admission.ts | 178 ++++ .../chord/src/next-node/http/authenticate.ts | 54 ++ packages/chord/src/next-node/http/cookies.ts | 71 ++ packages/chord/src/next-node/http/file.ts | 113 +++ packages/chord/src/next-node/http/headers.ts | 142 +++ packages/chord/src/next-node/http/http.ts | 565 ++++++++++++ packages/chord/src/next-node/http/incoming.ts | 43 + packages/chord/src/next-node/http/index.ts | 34 + packages/chord/src/next-node/http/node.ts | 317 +++++++ packages/chord/src/next-node/http/request.ts | 145 ++++ packages/chord/src/next-node/http/response.ts | 258 ++++++ packages/chord/src/next-node/http/router.ts | 412 +++++++++ packages/chord/src/next-node/http/upgrades.ts | 245 ++++++ .../chord/src/next-node/plugins/README.md | 72 +- packages/chord/src/next-node/plugins/build.ts | 68 +- .../chord/src/next-node/plugins/evaluate.ts | 11 +- packages/chord/src/next-node/plugins/index.ts | 3 + .../chord/src/next-node/plugins/plugins.ts | 366 ++++---- .../chord/src/next-node/plugins/runtime.ts | 215 +++++ .../chord/src/next-node/workers/README.md | 95 ++ .../chord/src/next-node/workers/control.ts | 121 +++ packages/chord/src/next-node/workers/index.ts | 20 + packages/chord/src/next-node/workers/lock.ts | 142 +++ packages/chord/src/next-node/workers/main.ts | 293 +++++++ .../chord/src/next-node/workers/process.ts | 150 ++++ .../chord/src/next-node/workers/workers.ts | 742 ++++++++++++++++ packages/chord/src/next/host/README.md | 23 +- packages/chord/src/next/host/host.ts | 36 +- packages/chord/src/next/host/index.ts | 2 +- packages/chord/src/next/host/root.ts | 54 +- packages/chord/src/next/host/route.ts | 170 +++- packages/chord/src/next/remote/README.md | 8 +- packages/chord/src/next/remote/host-link.ts | 16 +- packages/chord/src/next/remote/internal.ts | 2 +- packages/chord/src/next/remote/path.ts | 30 +- packages/chord/src/next/remote/provider.ts | 13 + packages/chord/src/next/remote/relay.ts | 5 + .../chord/src/next/remote/remote-object.ts | 10 + .../fixtures/plugins/files/worker.ts | 2 +- .../fixtures/plugins/notes/server.ts | 2 +- .../fixtures/plugins/notes/worker.ts | 2 +- .../test/next-node/fixtures/workers/child.ts | 12 + .../next-node/fixtures/workers/control.ts | 47 + .../fixtures/workers/plugins/notes/label.ts | 3 + .../workers/plugins/notes/package.json | 5 + .../fixtures/workers/plugins/notes/worker.ts | 17 + .../test/next-node/fixtures/workers/worker.ts | 146 ++++ .../test/next-node/http/admission.test.ts | 154 ++++ .../test/next-node/http/authenticate.test.ts | 58 ++ .../chord/test/next-node/http/cookies.test.ts | 56 ++ .../chord/test/next-node/http/file.test.ts | 135 +++ .../chord/test/next-node/http/headers.test.ts | 106 +++ .../chord/test/next-node/http/http.test.ts | 818 ++++++++++++++++++ .../chord/test/next-node/http/node.test.ts | 215 +++++ .../chord/test/next-node/http/request.test.ts | 119 +++ .../test/next-node/http/response.test.ts | 245 ++++++ .../chord/test/next-node/http/router.test.ts | 228 +++++ packages/chord/test/next-node/http/support.ts | 204 +++++ .../test/next-node/http/upgrades.test.ts | 458 ++++++++++ .../test/next-node/plugins/build.test.ts | 41 +- .../test/next-node/plugins/plugins.test.ts | 167 +++- .../test/next-node/plugins/runtime.test.ts | 126 +++ .../chord/test/next-node/plugins/support.ts | 12 +- .../test/next-node/workers/control.test.ts | 55 ++ .../chord/test/next-node/workers/lock.test.ts | 62 ++ .../chord/test/next-node/workers/main.test.ts | 87 ++ .../test/next-node/workers/process.test.ts | 46 + .../chord/test/next-node/workers/support.ts | 151 ++++ .../test/next-node/workers/workers.test.ts | 605 +++++++++++++ packages/chord/test/next/bench/http.ts | 120 +++ packages/chord/test/next/bench/plugins.ts | 8 +- packages/chord/test/next/bench/workers.ts | 107 +++ .../chord/test/next/examples/12-routes.ts | 4 +- .../test/next/examples/35-plugins/main.ts | 27 +- .../35-plugins/plugins/clock/server.ts | 2 +- .../35-plugins/plugins/notes/server.ts | 2 +- .../35-plugins/plugins/notes/worker.ts | 2 +- .../next/examples/36-plugin-failures/main.ts | 7 +- .../plugins/forecast/server.ts | 2 +- .../plugins/summary/server.ts | 2 +- .../next/examples/37-plugin-imports/main.ts | 23 +- .../plugins/report/server.ts | 4 +- .../37-plugin-imports/plugins/ruler/server.ts | 2 +- .../examples/38-watch-and-rollout/main.ts | 16 +- .../plugins/contracts/package.json | 2 +- .../plugins/notes/server.ts | 2 +- .../plugins/notes/worker.ts | 2 +- .../plugins/old/server.ts | 2 +- .../plugins/tags/server.ts | 2 +- .../plugins/tags/worker.ts | 2 +- .../next/examples/39-browser-build/main.ts | 6 +- .../chord/test/next/examples/40-http/main.ts | 56 ++ .../40-http/plugins/notes/files/hello.md | 3 + .../40-http/plugins/notes/package.json | 5 + .../examples/40-http/plugins/notes/server.ts | 29 + .../test/next/examples/41-workers/main.ts | 68 ++ .../41-workers/plugins/sessions/contract.ts | 13 + .../41-workers/plugins/sessions/package.json | 5 + .../41-workers/plugins/sessions/worker.ts | 11 + .../test/next/examples/41-workers/worker.ts | 22 + .../next/examples/42-worker-crash/main.ts | 67 ++ .../plugins/sessions/contract.ts | 13 + .../plugins/sessions/package.json | 5 + .../plugins/sessions/worker.ts | 17 + .../next/examples/42-worker-crash/worker.ts | 22 + .../test/next/examples/43-rollout/main.ts | 85 ++ .../43-rollout/plugins/notes/contract.ts | 16 + .../43-rollout/plugins/notes/label.ts | 3 + .../43-rollout/plugins/notes/package.json | 5 + .../43-rollout/plugins/notes/server.ts | 7 + .../43-rollout/plugins/notes/worker.ts | 9 + .../test/next/examples/43-rollout/worker.ts | 22 + .../test/next/examples/44-http-wraps/main.ts | 37 + .../44-http-wraps/plugins/audit/package.json | 5 + .../44-http-wraps/plugins/audit/server.ts | 24 + .../44-http-wraps/plugins/notes/package.json | 5 + .../44-http-wraps/plugins/notes/server.ts | 20 + .../next/examples/45-plugin-socket/main.ts | 64 ++ .../plugins/echo/package.json | 5 + .../45-plugin-socket/plugins/echo/server.ts | 16 + .../next/examples/46-allowed-hosts/main.ts | 61 ++ .../plugins/tunnel/package.json | 5 + .../46-allowed-hosts/plugins/tunnel/server.ts | 31 + .../next/examples/47-http-own-server/main.ts | 70 ++ .../plugins/notes/package.json | 5 + .../plugins/notes/server.ts | 12 + .../next/examples/48-http-baseline/main.ts | 84 ++ .../plugins/notes/package.json | 5 + .../48-http-baseline/plugins/notes/server.ts | 33 + .../chord/test/next/host/revocation.test.ts | 7 + packages/chord/test/next/host/root.test.ts | 35 +- packages/chord/test/next/host/route.test.ts | 221 +++-- .../chord/test/next/remote/host-link.test.ts | 78 +- packages/chord/test/next/remote/path.test.ts | 20 + .../chord/test/next/remote/provider.test.ts | 24 +- packages/chord/test/next/remote/relay.test.ts | 65 +- packages/chord/test/next/support.ts | 7 +- tsconfig.json | 9 + 143 files changed, 11066 insertions(+), 561 deletions(-) create mode 100644 packages/chord/src/next-node/http/README.md create mode 100644 packages/chord/src/next-node/http/admission.ts create mode 100644 packages/chord/src/next-node/http/authenticate.ts create mode 100644 packages/chord/src/next-node/http/cookies.ts create mode 100644 packages/chord/src/next-node/http/file.ts create mode 100644 packages/chord/src/next-node/http/headers.ts create mode 100644 packages/chord/src/next-node/http/http.ts create mode 100644 packages/chord/src/next-node/http/incoming.ts create mode 100644 packages/chord/src/next-node/http/index.ts create mode 100644 packages/chord/src/next-node/http/node.ts create mode 100644 packages/chord/src/next-node/http/request.ts create mode 100644 packages/chord/src/next-node/http/response.ts create mode 100644 packages/chord/src/next-node/http/router.ts create mode 100644 packages/chord/src/next-node/http/upgrades.ts create mode 100644 packages/chord/src/next-node/plugins/runtime.ts create mode 100644 packages/chord/src/next-node/workers/README.md create mode 100644 packages/chord/src/next-node/workers/control.ts create mode 100644 packages/chord/src/next-node/workers/index.ts create mode 100644 packages/chord/src/next-node/workers/lock.ts create mode 100644 packages/chord/src/next-node/workers/main.ts create mode 100644 packages/chord/src/next-node/workers/process.ts create mode 100644 packages/chord/src/next-node/workers/workers.ts create mode 100644 packages/chord/test/next-node/fixtures/workers/child.ts create mode 100644 packages/chord/test/next-node/fixtures/workers/control.ts create mode 100644 packages/chord/test/next-node/fixtures/workers/plugins/notes/label.ts create mode 100644 packages/chord/test/next-node/fixtures/workers/plugins/notes/package.json create mode 100644 packages/chord/test/next-node/fixtures/workers/plugins/notes/worker.ts create mode 100644 packages/chord/test/next-node/fixtures/workers/worker.ts create mode 100644 packages/chord/test/next-node/http/admission.test.ts create mode 100644 packages/chord/test/next-node/http/authenticate.test.ts create mode 100644 packages/chord/test/next-node/http/cookies.test.ts create mode 100644 packages/chord/test/next-node/http/file.test.ts create mode 100644 packages/chord/test/next-node/http/headers.test.ts create mode 100644 packages/chord/test/next-node/http/http.test.ts create mode 100644 packages/chord/test/next-node/http/node.test.ts create mode 100644 packages/chord/test/next-node/http/request.test.ts create mode 100644 packages/chord/test/next-node/http/response.test.ts create mode 100644 packages/chord/test/next-node/http/router.test.ts create mode 100644 packages/chord/test/next-node/http/support.ts create mode 100644 packages/chord/test/next-node/http/upgrades.test.ts create mode 100644 packages/chord/test/next-node/plugins/runtime.test.ts create mode 100644 packages/chord/test/next-node/workers/control.test.ts create mode 100644 packages/chord/test/next-node/workers/lock.test.ts create mode 100644 packages/chord/test/next-node/workers/main.test.ts create mode 100644 packages/chord/test/next-node/workers/process.test.ts create mode 100644 packages/chord/test/next-node/workers/support.ts create mode 100644 packages/chord/test/next-node/workers/workers.test.ts create mode 100644 packages/chord/test/next/bench/http.ts create mode 100644 packages/chord/test/next/bench/workers.ts create mode 100644 packages/chord/test/next/examples/40-http/main.ts create mode 100644 packages/chord/test/next/examples/40-http/plugins/notes/files/hello.md create mode 100644 packages/chord/test/next/examples/40-http/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/40-http/plugins/notes/server.ts create mode 100644 packages/chord/test/next/examples/41-workers/main.ts create mode 100644 packages/chord/test/next/examples/41-workers/plugins/sessions/contract.ts create mode 100644 packages/chord/test/next/examples/41-workers/plugins/sessions/package.json create mode 100644 packages/chord/test/next/examples/41-workers/plugins/sessions/worker.ts create mode 100644 packages/chord/test/next/examples/41-workers/worker.ts create mode 100644 packages/chord/test/next/examples/42-worker-crash/main.ts create mode 100644 packages/chord/test/next/examples/42-worker-crash/plugins/sessions/contract.ts create mode 100644 packages/chord/test/next/examples/42-worker-crash/plugins/sessions/package.json create mode 100644 packages/chord/test/next/examples/42-worker-crash/plugins/sessions/worker.ts create mode 100644 packages/chord/test/next/examples/42-worker-crash/worker.ts create mode 100644 packages/chord/test/next/examples/43-rollout/main.ts create mode 100644 packages/chord/test/next/examples/43-rollout/plugins/notes/contract.ts create mode 100644 packages/chord/test/next/examples/43-rollout/plugins/notes/label.ts create mode 100644 packages/chord/test/next/examples/43-rollout/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/43-rollout/plugins/notes/server.ts create mode 100644 packages/chord/test/next/examples/43-rollout/plugins/notes/worker.ts create mode 100644 packages/chord/test/next/examples/43-rollout/worker.ts create mode 100644 packages/chord/test/next/examples/44-http-wraps/main.ts create mode 100644 packages/chord/test/next/examples/44-http-wraps/plugins/audit/package.json create mode 100644 packages/chord/test/next/examples/44-http-wraps/plugins/audit/server.ts create mode 100644 packages/chord/test/next/examples/44-http-wraps/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts create mode 100644 packages/chord/test/next/examples/45-plugin-socket/main.ts create mode 100644 packages/chord/test/next/examples/45-plugin-socket/plugins/echo/package.json create mode 100644 packages/chord/test/next/examples/45-plugin-socket/plugins/echo/server.ts create mode 100644 packages/chord/test/next/examples/46-allowed-hosts/main.ts create mode 100644 packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/package.json create mode 100644 packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts create mode 100644 packages/chord/test/next/examples/47-http-own-server/main.ts create mode 100644 packages/chord/test/next/examples/47-http-own-server/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts create mode 100644 packages/chord/test/next/examples/48-http-baseline/main.ts create mode 100644 packages/chord/test/next/examples/48-http-baseline/plugins/notes/package.json create mode 100644 packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts diff --git a/package-lock.json b/package-lock.json index f2129fd85..02e687f30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5568,11 +5568,15 @@ "node": ">=22.19.0" }, "peerDependencies": { - "esbuild": "0.28.2" + "esbuild": "0.28.2", + "ws": "8.21.0" }, "peerDependenciesMeta": { "esbuild": { "optional": true + }, + "ws": { + "optional": true } } }, diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index ceb4cddd1..326d4b8de 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -17,6 +17,7 @@ - Chord v2 has a package export again, `@earendil-works/chord/next` (experimental): every layer's API for apps (contracts, data, documents, hosts, jobs, links, state, the store), and, marked internal, the store's surface for adapters (`StoreOwnerTables`, `OwnerTable`, the documents engine's reads and writes, the commit step, `applyBatch`). v2's store gains one id allocator for every id in it, claims per table and per document namespace, Durable's Storage semantics for a refused commit (it rejects that commit only; an unknown outcome stops the store), document histories `none`, `latest` and `rewindable` with reads at a point, archives carrying document revisions, and batch apply; v2 objects gain `self.follow` (another runtime's synchronous observers as sources) and persisted jobs (`persist: { resume }`, a stored `abort(id, ctx)`, deferred state changes). - Chord v2 (`@earendil-works/chord/next`) gains registries, writables, wake signals and root scopes (slice 8): `defineRegistry(name, { version })` tokens shared by plugins, served by the defining plugin's facet with `host.serve(R, { compose, accepts? })`; contributions from facets (`defineFacet((host, self) => self.contribute(R, value))`) and host-lifetime objects (`StoreSelf.contribute`), plain values or Chord watchables, in a table sorted by plugin name and composed lazily (once at the end of `load`'s setups, then when read or followed, in the batch that changed the table); readers through `self.registry(R)` (a declared dependency: an object activates once the registry has a value, and fails `transient` with the new reason `unavailable/undeclared` when nobody serves it) and `host.registry(R, ctx)`, a `RegistryValue` with `observe(observer, ctx)`; older contributions and readers adapted by `accepts`; the helpers `keyedEntries` and `orderedEntries`; the `onError` kinds `registry_conflict`, `registry_failed`, `contribution_failed` and `contribution_refused`. `self.writable(initial)` is an in-process value an attempt sets (identity compared); native sources (writables, registry values, `ref.state`) are derive sources. `self.retryOn(...sources)` retries a failed object when a registry value changes. `host.root(fn, context, { principal? })` runs code outside objects with a handler's `ctx`, sealed when `fn` settles, `context` aborts or the host closes. - Chord v2 gains plugins (slice 7c): `src/next-node/plugins` (Node, to be exported as `@earendil-works/chord/next/plugins`) finds plugin directories, builds each facet with esbuild (now an optional peer dependency of `@earendil-works/chord`, imported only there), evaluates it with `vm.Script`, maps plugin stack traces (`mapStack`), installs plugin dependencies, watches plugin and base code, and rolls reloads out across hosts. `host.load` and `host.reload` work per plugin: a plugin whose setup, factory or declarations fail is left out at `load` (`{ failed }`) or kept on its old code at `reload` (`failed` in the report, `reload(facets, ctx, { failed })` for build and evaluation failures), with a closure rule keeping a consumer old when its provider was kept old; an abort after a reload's commit rejects with `details.committed`. +- Chord v2 gains HTTP and workers (slice 10): `src/next-node/http` (to be exported as `@earendil-works/chord/next/http`) builds a Web-standard `fetch` handler from the routes, wraps, sockets, hosts, relays and CSP sources plugins contribute to the registry `Http`, with authenticators from the registry `Authenticators`, responses as descriptions (`json`, `html`, `bytes` with ranges, `redirect`, `error`; `fileResponse`), and a security baseline on by default (provenance by the socket and `Host`, foreign requests anonymous, `Origin` checks, default headers with a per-response CSP nonce, signed cookies, safe downloads); Chord's own Node listener as a host-lifetime object, `nodeRequest` for apps mounting it on their own Node server, the links endpoint and plugin sockets with a heartbeat, closed at once when their contribution leaves. `ws` is an optional peer dependency, imported only there. `src/next-node/workers` (`createWorkers`, `workerMain`) runs a forked worker per address of a host kind, started on demand, stopped when idle, never restarted by itself (a failure stays until `retry` or a rollout), its plugins built by the server and shipped. The plugins component defines one definition per facet kind (`kinds: { name: { platform, hostPackages } }`, modules given at `plugins.load(host, { kind, core, modules })`), splits loading into `createPluginRuntime()` (no esbuild), offers `scripts(kind)`, rolls out with one result per host (`RolloutStep` returns `{ hosts }`), and resolves cross-plugin imports by plugin name (`@app/notes/contract`). Kernel: `host.route` returns a `RouteHandle` (`retry`, `suspend`) and a route has no backoff timer (a failed `resolve` fails its holders `waiting`); `host.root(fn, ctx, { principal: undefined })` runs anonymous; a link reads `origin` only on parent and route links (a protocol error on a child link) and drops a per-origin record once it is idle. - Chord v2, experimental and not exported yet: the next object runtime in `src/next` (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store), with its tests and runnable examples in `test/next` and its design in `docs/next` (`design.md`, the decision log `decisions.md`, the slice plan `slices.md`). Its API changes with every slice until it gets an entry point. - `NativeWatchable`: an in-process value that changes and can be watched (`value`, `watch(context)` returning a `NativeWatchHandle`), the in-process counterpart of `Watchable`. - `WatchHandle` and `WatchEnd`, the one watch API, moved from Durable: a serialized observation with `value`, `start(listener)`, `stop()`, and `closed`; the listener gets a `WatchChange` (`{ value, ops, retired }`) and the context. `WatchEnd` reasons are `stopped`, `cancelled`, `ended`, `listener_error`, and `failed`. diff --git a/packages/chord/docs/next/benchmarks.md b/packages/chord/docs/next/benchmarks.md index d425f4154..ea2de1efa 100644 --- a/packages/chord/docs/next/benchmarks.md +++ b/packages/chord/docs/next/benchmarks.md @@ -914,3 +914,46 @@ derive commit 10.34-10.36 → 10.43-10.49 µs (14,899 → 14,901 B), activation 0.026-0.030 → 0.028-0.033 ms (pinned 0.030-0.035 → 0.030-0.032 ms); retained per hold equal (one binary hold 176.2 → 170.1 KiB). `reload.ts`: swap of 50 / 1,000 objects 1.44-1.73 / 30.83-32.71 → 1.52-1.53 / 30.55-33.25 ms, restart 1.01-1.05 / 15.54-16.87 → 0.95-0.99 / 15.90-16.02 ms until the reload resolves. + +## Slice 10: HTTP and workers (2026-10-31) + +`test/next/bench/http.ts` and `test/next/bench/workers.ts`, Node 24.4.1, CPU 6 (the forked workers inherit the pin). + +| Measurement | Result | +| --- | --- | +| keep-alive round trip, a JSON body: plain Node handler / a route (same process as the client) | build: 42.0-42.5 / 46.4-47.0 µs: +4.2-4.9 µs (9.9-11.7%); fix round 1: 42.5-42.9 / 62.8-64.4 µs: +20.3-21.5 µs | +| the same after 30,000 warm-up requests (fix round 1) | 30.9-31.3 / 43.4-44.0 µs: +12.1-12.9 µs; with an authenticator reading `request.headers`: +15.9 µs | +| the same, server and client in two processes (CPU 6 and 8; build) | 36.4-37.0 / 41.7-42.4 µs: +5 µs | +| 100 MiB file: `pipeline` of a read stream / `fileResponse` | build (64 KiB reads): 602-618 / 487-565 MiB/s; fix round 1 (256 KiB, pulled): 477-751 / 560-611 MiB/s | +| a worker's cold start with one shipped script: fork to `started` / to `ready` (median of 7) | 531-532 / 538-539 ms | +| the server's heap per idle worker (20 workers, one reference each) | 49.5 KiB | + +A request's overhead was 16 µs at first: `host.root` cost about 7 µs of it per request (a linked abort signal, a +`Promise.race` and an `awaitWithContext` per scope). Root scopes now take the caller's signal with one listener and +settle one promise (`root.ts`; behaviour and tests unchanged): 4.2-4.9 µs, about the bar of 10% of a round trip. The +rest is the request's scope (its abort controller, `CallContext`), admission and the response's header merge. The cold +start is mostly Node's startup stripping the types of the source tree it imports; the start timer (60 s) is far from +it. + +**Fix round 1** (the `fetch` core, the security baseline, Chord's listener as a native object). A route's overhead in +the standard run grew from +4.5 µs to +20-21 µs (+41 µs before the Node fast path: the listener reads Node's parsed +headers and makes a Web `Request` only when something reads `request.headers` or the body or a relay verifies; whole +bodies are written as strings, in one write with the head). Warmed, +12-13 µs; a CPU profile of the warmed route path +(per request, `test/next/bench/http.ts`'s route): the listener's request function 13.6 µs inclusive against the plain +handler's 8.9 µs, of which writing the answer 8.3 µs (the plain handler's `writeHead` and `end`: 9.1 µs); `serve` 4.2 +µs (the root scope 2.1, the URL parse 0.9, default headers 0.6, admission and routing 1.1, the answer 0.7); the root +scope's seal in a later microtask 0.9 µs. The baseline's extra headers cost 0.4 µs (a plain handler sending them). +The rest, about 5 µs, sits in no function of the component: Node's tick and microtask machinery (33 µs inclusive +against 18 µs for the plain handler). Opus's two trims are in (`lookup` decodes only segments with `%`; one abort +controller per request: the Node side's own is gone). Kernel rows, HEAD `bc9a20f84` → fix round 1, CPU 6, alternating: +binary 1 MiB call median 0.182-0.220 → 0.196 ms; reconnect of 1 / 100 references 1.02-1.10 / 79.1-80.3 → 1.04-1.05 / +81.3-81.5 ms; watched `writable.set` 0.836-0.868 → 0.861-0.863 µs; watched derive commit 10.42-10.58 → 10.36-10.60 µs; +activation churn 58.9-60.5 → 59.1-60.8 µs. + +**Kernel hot paths** (routes without a timer, `RouteHandle`, `Path` events, root scopes), HEAD `bc9a20f84` → build at +paths of equal length, CPU 6, alternating runs. `calls.ts`: local call 2.70-2.83 → 2.70-2.83 µs (6,660 B both), +watched derive commit 10.36-10.60 → 10.34-10.79 µs, activation churn 59.43-61.30 → 59.48-60.72 µs. `links.ts`: commit +to frame and encode/decode alike; reconnect of 1 / 100 references 0.94-1.15 / 80.8-84.0 → 0.94-1.15 / 80.6-86.5 ms; +1,000 parked calls 24.4-25.2 → 24.3-24.6 ms. `reload.ts`: swap of 50 / 1,000 objects 1.44-1.55 / 30.78-31.42 → +1.32-1.56 / 31.20-31.83 ms, restart 0.96-0.98 / 15.75-16.34 → 0.95-1.10 / 15.91-15.94 ms until the reload resolves. +`registries.ts` alike (one of 100 contributions set 1.47-2.45 → 1.48-3.41 µs, run to run). diff --git a/packages/chord/package.json b/packages/chord/package.json index f2be5a564..908005452 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -94,11 +94,15 @@ "typebox": "1.3.27" }, "peerDependencies": { - "esbuild": "0.28.2" + "esbuild": "0.28.2", + "ws": "8.21.0" }, "peerDependenciesMeta": { "esbuild": { "optional": true + }, + "ws": { + "optional": true } }, "devDependencies": { diff --git a/packages/chord/src/next-node/http/README.md b/packages/chord/src/next-node/http/README.md new file mode 100644 index 000000000..266ba00d0 --- /dev/null +++ b/packages/chord/src/next-node/http/README.md @@ -0,0 +1,166 @@ +# http + +The HTTP component (slice 10 §2, design §23; decisions "The HTTP core is a fetch handler", "The HTTP server is a native +object", "The HTTP component's security baseline", "Slice 10 fix round, allowed hosts only through contributions", +"Settings are arguments, extensions are registries", "A CSP nonce per response"): the server's HTTP and WebSocket +entry. Plugins contribute routes, wraps, sockets, allowed hosts, framers, relays and +CSP sources to the registry `Http`; routes return response descriptions. The core is one Web-standard handler, +`fetch(Request) → Response`; Chord's own Node server (the listener) is the easy path, an app with a server of its own +mounts the handler. Node-only; `ws` is an optional peer dependency of `@earendil-works/chord`, imported only here. +Exported as `@earendil-works/chord/next/http` once v2 has its entry point. + +## For apps (`index.ts`) + +```ts +// the app's settings are arguments, fixed for the component's life (changing one is a restart) +const http = createHttp(server, { + listen: { host: "127.0.0.1", port: 8788 }, // Chord's own listener; port 0 picks one; none: no listener + links: { path: "/chord", options: (request, principal) => ({ limits, maxPayload }) }, // or a value + closeTimeout: 30_000, + onError: (error) => log(error), // HttpError +}, ctx); +// the app's core facet: who someone is (a registry: plugins may add authenticators too) +const app = defineFacet((_host, self) => { + self.contribute(Authenticators, (request, ctx) => principalFromCookie(readCookie(request, "session", secret))); +}); +const core = [{ name: "chord.http", facet: http.facet }, { name: "@app/core", facet: app }]; +await plugins.load(server, { kind: "server", core, modules }, ctx); +const { port } = await http.listening(ctx); +… +await http.close(ctx); // first: drains running requests (host.close would seal their scopes at once) +await server.close(ctx); +``` + +```ts +// a plugin's server facet +self.contribute(Http, { + routes: { + "GET /notes/:id": async (request, ctx) => ({ kind: "json", value: … }), + "GET /": { page: true, handler: async (request) => ({ kind: "html", body: `` }; + }, + }, + framers: ["https://shell.example.com"], + csp: { "img-src": ["https://cdn.example.com"] }, + }), + }); + const page = await send(server.port, "GET", "/page"); + expect(page.headers).toMatchObject({ + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }); + expect(page.headers["x-frame-options"]).toBeUndefined(); + expect(page.headers["content-security-policy"]).toBe( + `default-src 'none'; script-src 'self' 'nonce-${nonce}'; connect-src 'self' ws://127.0.0.1:${server.port}; base-uri 'none'; form-action 'self'; frame-ancestors https://shell.example.com; img-src https://cdn.example.com`, + ); + // Fable's recheck: a nonce read from one page must not run a script injected into another. + const first = nonce; + await send(server.port, "GET", "/page"); + expect(nonce).not.toBe(first); + const missing = await send(server.port, "GET", "/nope"); + expect([missing.status, missing.headers["x-content-type-options"]]).toEqual([404, "nosniff"]); + expect(missing.headers["content-security-policy"]).toMatch(/'nonce-[A-Za-z0-9+/]{22}=='/); + expect(missing.headers["content-security-policy"]).not.toContain(nonce); + await server.close(); + const plain = await served({}); + expect((await send(plain.port, "GET", "/")).headers["x-frame-options"]).toBe("DENY"); + await plain.close(); + }); + + it("lets a relay's verified request through with its framer", async () => { + const server = await served({ + "@app/who": whoami, + "@app/relay": contributing({ + relays: [ + { + name: "radius", + verify: (request) => + request.headers.get("x-relay") === "secret" + ? { origin: "https://me.relay.example", framer: "https://relay.example" } + : undefined, + }, + ], + }), + }); + const forwarded = { "x-forwarded-for": "203.0.113.9" }; + expect((await send(server.port, "GET", "/whoami", { headers: forwarded })).status).toBe(403); + const relayed = await send(server.port, "GET", "/whoami", { headers: { ...forwarded, "x-relay": "secret" } }); + expect(relayed.status).toBe(200); + expect(relayed.headers["content-security-policy"]).toContain("frame-ancestors https://relay.example"); + await server.close(); + }); +}); + +describe("routes and wraps failing", () => { + it("answers a throwing route 500 without its message, a throwing wrap 500 naming its plugin, an HttpFailure its status", async () => { + const server = await served({ + "@app/a": contributing({ + routes: { + "GET /throws": async () => { + throw new Error("secret"); + }, + "GET /failure": async () => { + throw new HttpFailure(422, "no"); + }, + }, + }), + "@app/b": contributing({ + routes: { "GET /wrapped": async () => ({ kind: "json", value: 1 }) }, + wraps: [ + { + name: "broken", + phase: "headers", + wrap: async () => { + throw new Error("wrap"); + }, + }, + ], + }), + }); + const thrown = await send(server.port, "GET", "/throws"); + expect([thrown.status, thrown.body.toString()]).toEqual([500, "Internal Server Error"]); + expect((await send(server.port, "GET", "/failure")).status).toBe(422); + expect((await send(server.port, "GET", "/wrapped")).status).toBe(500); + expect(server.errors).toMatchObject([ + { kind: "route_failed", pattern: "GET /throws", plugin: "@app/a", error: { message: "secret" } }, + { kind: "wrap_failed", name: "broken", plugin: "@app/b", error: { message: "wrap" } }, + ]); + await server.close(); + }); + + it("runs wraps outermost first around the route, in its request's scope", async () => { + const order: string[] = []; + const server = await served({ + "@app/a": contributing({ + routes: { + "GET /r": async (_request, ctx) => { + order.push(`route as ${ctx.principal?.kind ?? "anonymous"}`); + return { kind: "json", value: 1 }; + }, + }, + wraps: [ + { + name: "inner", + phase: "transform", + wrap: async (request, next) => { + order.push("transform"); + return next(request); + }, + }, + { + name: "outer", + phase: "observe", + wrap: async (request, next) => { + order.push("observe"); + const response = await next(request); + return { ...response, headers: { "X-Observed": "1" } }; + }, + }, + ], + }), + }); + const answer = await send(server.port, "GET", "/r", { headers: { "x-principal": "owner" } }); + expect([order, answer.headers["x-observed"]]).toEqual([["observe", "transform", "route as owner"], "1"]); + await server.close(); + }); +}); + +describe("reloads and close", () => { + it("a reload swapping a route leaves a running request on the old router; the next uses the new one", async () => { + const gate = Promise.withResolvers(); + const old = contributing({ + routes: { + "GET /v": async () => { + await gate.promise; + return { kind: "html", body: "old" }; + }, + }, + }); + const server = await served({ "@app/v": old }); + const running = send(server.port, "GET", "/v"); + await new Promise((resolve) => setTimeout(resolve, 50)); + const next = contributing({ routes: { "GET /v": async () => ({ kind: "html", body: "new" }) } }); + await server.host.reload(server.facets({ "@app/v": next }), context); + expect((await send(server.port, "GET", "/v")).body.toString()).toBe("new"); + gate.resolve(); + expect((await running).body.toString()).toBe("old"); + await server.close(); + }); + + it("close refuses new requests (503), waits for running ones up to closeTimeout, then seals them (503)", async () => { + let sealed: AbortSignal | undefined; + const server = await served( + { + "@app/slow": contributing({ + routes: { + "GET /quick": async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + return { kind: "html", body: "done" }; + }, + "GET /stuck": async (_request, ctx: Ctx) => { + sealed = ctx.abortSignal; + return new Promise(() => {}); + }, + }, + }), + }, + { config: { listen: { host: "127.0.0.1", port: 0 }, closeTimeout: 100 } }, + ); + const quick = send(server.port, "GET", "/quick"); + const stuck = send(server.port, "GET", "/stuck"); + await new Promise((resolve) => setTimeout(resolve, 10)); + const closing = server.http.close(context); + expect((await server.http.fetch(loopback("/quick"), LOOPBACK)).status).toBe(503); + expect((await quick).body.toString()).toBe("done"); + await closing; + expect(sealed?.aborted).toBe(true); + await server.host.close(context); + expect((await stuck).status).toBe(503); + await expect(send(server.port, "GET", "/quick")).rejects.toThrow(); + }); + + // Astra's build review: a request authenticating when close began ran its route afterwards. + it("never runs a route for a request still authenticating when close sealed it", async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + let calls = 0; + const server = await served( + { + "@app/r": contributing({ + routes: { + "GET /": async () => { + calls++; + return { kind: "json", value: 1 }; + }, + }, + }), + }, + { + config: { listen: { host: "127.0.0.1", port: 0 }, closeTimeout: 20 }, + authenticate: async () => { + entered.resolve(); + await gate.promise; + return { kind: "owner" }; + }, + }, + ); + const answer = server.http.fetch(loopback("/"), LOOPBACK); + await entered.promise; + await server.http.close(context); + gate.resolve(); + expect([(await answer).status, calls]).toEqual([503, 0]); + await server.close(); + }); +}); + +describe("mounting", () => { + it("without a connection a request is never local", async () => { + const server = await served({ "@app/who": whoami }, { config: { listen: undefined } }); + expect((await server.http.fetch(loopback("/whoami"))).status).toBe(403); + await server.close(); + }); +}); + +describe("cancellation", () => { + // Sol's second recheck C: after cancellation the chain still started the next authenticator. + it("starts no further authenticator once close sealed the request", async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + let later = 0; + const server = await served( + { + "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }), + "@app/z-later": defineFacet((_host, self) => + self.contribute(Authenticators, () => { + later++; + return { kind: "owner" }; + }), + ), + }, + { + config: { listen: undefined, closeTimeout: 10 }, + authenticate: async () => { + entered.resolve(); + await gate.promise; + return undefined; + }, + }, + ); + const answer = server.http.fetch(loopback("/"), LOOPBACK); + await entered.promise; + await server.http.close(context); + expect((await answer).status).toBe(503); + gate.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(later).toBe(0); + await server.close(); + }); + + it("asks no authenticator for a request aborted before it arrived", async () => { + let calls = 0; + const server = await served( + { "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }) }, + { + config: { listen: undefined }, + authenticate: () => { + calls++; + return undefined; + }, + }, + ); + await server.http.fetch(loopback("/", { signal: AbortSignal.abort() }), LOOPBACK); + expect(calls).toBe(0); + await server.close(); + }); + + // Sol's third recheck: a request aborted while its route returned the `bytes` description got a stream born sealed, + // which never errored; the request never finished. + it.each(["inside the route", "after the route returned"] as const)( + "ends a request whose bytes Response was born sealed (aborted %s)", + async (when) => { + const abort = new AbortController(); + const entered = Promise.withResolvers(); + let reads = 0; + let bornSealed: boolean | undefined; + const server = await served( + { + "@app/b": contributing({ + routes: { + "GET /": async (_request, ctx) => { + entered.resolve(); + if (when === "inside the route") abort.abort(new Error("client gone")); + bornSealed = ctx.abortSignal?.aborted; + return { + kind: "bytes", + mime: "application/octet-stream", + read: async () => { + reads++; + return new Uint8Array([1]); + }, + }; + }, + }, + }), + }, + { config: { listen: undefined, closeTimeout: 1_000 } }, + ); + const answer = server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK); + await entered.promise; + if (when === "after the route returned") abort.abort(new Error("client gone")); + await answer; + await new Promise((resolve) => setImmediate(resolve)); + const started = Date.now(); + await server.http.close(context); + expect([Date.now() - started < 500, reads, bornSealed]).toEqual([true, 0, when === "inside the route"]); + await server.close(); + }, + ); + + // Sol's second recheck B: an aborted `Response` nobody touched again kept its request: close waited its bound. + it("ends a request whose bytes Response was aborted and dropped without another read", async () => { + const server = await served( + { + "@app/b": contributing({ + routes: { + "GET /": async () => ({ + kind: "bytes", + mime: "application/octet-stream", + read: async () => new Uint8Array([1]), + }), + }, + }), + }, + { config: { listen: undefined, closeTimeout: 1_000 } }, + ); + const abort = new AbortController(); + await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK); + abort.abort(new Error("client gone")); + await new Promise((resolve) => setImmediate(resolve)); + const started = Date.now(); + await server.http.close(context); + expect(Date.now() - started).toBeLessThan(500); + await server.close(); + }); + + // Astra's recheck R4, Sol's N1: a close before the first resolution started a stopped watch; its throw ended the process. + it("a close before the publication leaves nothing running: the waiting request answers 503", async () => { + const server = await served({}, { load: false, config: { listen: undefined } }); + const waiting = server.http.fetch(loopback("/x"), LOOPBACK); + await server.http.close(withAbortSignal(AbortSignal.abort(), context)); + await server.load(); + expect((await waiting).status).toBe(503); + await new Promise((resolve) => setTimeout(resolve, 50)); + await server.close(); + }); + + it("a close while the first request resolves the registries starts no follow", async () => { + const server = await served({ "@app/who": whoami }, { config: { listen: undefined, closeTimeout: 10 } }); + const pending = server.http.fetch(loopback("/whoami"), LOOPBACK); + await server.http.close(withAbortSignal(AbortSignal.timeout(20), context)); + expect([200, 503]).toContain((await pending).status); + await new Promise((resolve) => setTimeout(resolve, 50)); + await server.close(); + }); + + // Sol's recheck N9. + it("a close whose context is aborted already does not wait", async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const server = await served( + { + "@app/wait": contributing({ + routes: { + "GET /wait": async () => { + entered.resolve(); + await gate.promise; + return { kind: "html", body: "done" }; + }, + }, + }), + }, + { config: { listen: undefined, closeTimeout: 1000 } }, + ); + const answer = server.http.fetch(loopback("/wait"), LOOPBACK); + await entered.promise; + const started = Date.now(); + await server.http.close(withAbortSignal(AbortSignal.abort(), context)); + expect(Date.now() - started).toBeLessThan(500); + gate.resolve(); + await answer; + await server.close(); + }); + + // Sol's recheck N5: an authenticator ignoring its signal kept fetch pending after close returned. + it("close settles a request whose authenticator ignores its signal (503)", async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const server = await served( + { "@app/r": contributing({ routes: { "GET /": async () => ({ kind: "html", body: "ok" }) } }) }, + { + config: { listen: undefined, closeTimeout: 10 }, + authenticate: async () => { + entered.resolve(); + await gate.promise; + return { kind: "owner" }; + }, + }, + ); + const answer = server.http.fetch(loopback("/"), LOOPBACK); + await entered.promise; + await server.http.close(context); + const settled = await Promise.race([answer.then((response) => response.status), sleepThen(30, "pending")]); + expect(settled).toBe(503); + gate.resolve(); + await server.close(); + }); + + // Astra's recheck R2, Sol's N3: a held `Response` went on calling `read` after its scope was sealed. + it.each(["the request aborted", "the component closed"] as const)("reads no bytes after %s", async (kind) => { + let reads = 0; + let readAfterSeal = false; + const abort = new AbortController(); + const server = await served( + { + "@app/bytes": contributing({ + routes: { + "GET /stream": async () => ({ + kind: "bytes", + mime: "application/octet-stream", + read: async (_offset, _length, ctx) => { + reads++; + readAfterSeal ||= ctx.abortSignal?.aborted ?? false; + return new Uint8Array([reads]); + }, + }), + }, + }), + }, + { config: { listen: undefined, closeTimeout: 10 } }, + ); + const response = await server.http.fetch(loopback("/stream", { signal: abort.signal }), LOOPBACK); + const reader = response.body!.getReader(); + await reader.read(); + if (kind === "the request aborted") abort.abort(new Error("client gone")); + else await server.http.close(context); + await new Promise((resolve) => setImmediate(resolve)); + await expect(reader.read()).rejects.toThrow(); + expect({ reads, readAfterSeal }).toEqual({ reads: 1, readAfterSeal: false }); + await server.close(); + }); + + // Sol's recheck N4. + it("a body that errors after valid JSON fails the request (400); the route's side effect never runs", async () => { + let writes = 0; + const server = await served( + { + "@app/body": contributing({ + routes: { + "POST /body": async (request) => { + const value = await request.json(); + writes++; + return { kind: "json", value }; + }, + }, + }), + }, + { config: { listen: undefined } }, + ); + let pulls = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulls++ === 0) controller.enqueue(new TextEncoder().encode("{}")); + else controller.error(new Error("incoming body checksum failed")); + }, + }); + const init = { method: "POST", body, duplex: "half" as const }; + const response = await server.http.fetch(loopback("/body", init), LOOPBACK); + expect([response.status, writes]).toEqual([400, 0]); + await server.close(); + }); +}); + +describe("answers", () => { + // Sol's recheck N8: `Response` threw on a body for these statuses. + it.each([204, 205, 304])("answers %s without a body", async (status) => { + const server = await served( + { "@app/s": contributing({ routes: { "GET /": async () => ({ kind: "json", value: null, status }) } }) }, + { config: { listen: undefined } }, + ); + const response = await server.http.fetch(loopback("/"), LOOPBACK); + expect([response.status, response.body, response.headers.has("content-length")]).toEqual([status, null, false]); + await server.close(); + }); + + // Fable's recheck N3: the kernel's `forbidden` reached the client as a reported 500. + it("answers a forbidden escaping a route 403, unreported", async () => { + const server = await served( + { + "@app/r": contributing({ + routes: { + "GET /": async () => { + throw new ChordError({ code: "forbidden", message: "no" }); + }, + }, + }), + }, + { config: { listen: undefined } }, + ); + expect((await server.http.fetch(loopback("/"), LOOPBACK)).status).toBe(403); + expect(server.errors).toEqual([]); + await server.close(); + }); + + // Opus's recheck R2: one value per header name allowed one cookie per response. + it("sends several Set-Cookie headers", async () => { + const server = await served({ + "@app/r": contributing({ + routes: { "GET /": async () => ({ kind: "json", value: 1, headers: { "Set-Cookie": ["a=1", "b=2"] } }) }, + }), + }); + expect((await send(server.port, "GET", "/")).headers["set-cookie"]).toEqual(["a=1", "b=2"]); + const fetched = await server.http.fetch( + new Request(`http://127.0.0.1:${server.port}/`, { headers: { host: `127.0.0.1:${server.port}` } }), + { localAddress: "127.0.0.1", localPort: server.port }, + ); + expect(fetched.headers.getSetCookie()).toEqual(["a=1", "b=2"]); + await server.close(); + }); +}); + +describe("GC", () => { + // Sol's second recheck B, her GC probe: 8 of 8 such requests kept their scopes. + it("collects the scopes of aborted bytes Responses dropped without another read", async () => { + const refs: WeakRef[] = []; + const server = await served( + { + "@app/b": contributing({ + routes: { + "GET /": async (_request, ctx) => { + refs.push(new WeakRef(ctx)); + return { kind: "bytes", mime: "application/octet-stream", read: async () => new Uint8Array([1]) }; + }, + }, + }), + }, + { config: { listen: undefined } }, + ); + for (let i = 0; i < 8; i++) { + const abort = new AbortController(); + await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK); + abort.abort(new Error("client gone")); + } + await expectCollected(...refs); + await server.close(); + }); + + it("collects the scopes of bytes Responses born sealed (aborted while the route returned)", async () => { + const refs: WeakRef[] = []; + const aborts: AbortController[] = []; + const server = await served( + { + "@app/b": contributing({ + routes: { + "GET /": async (_request, ctx) => { + refs.push(new WeakRef(ctx)); + aborts.at(-1)?.abort(new Error("client gone")); + return { kind: "bytes", mime: "application/octet-stream", read: async () => new Uint8Array([1]) }; + }, + }, + }), + }, + { config: { listen: undefined } }, + ); + // In a function of its own: the test's frame keeps no last request. + const fetchAborted = async (): Promise => { + const abort = new AbortController(); + aborts.push(abort); + await server.http.fetch(loopback("/", { signal: abort.signal }), LOOPBACK); + }; + for (let i = 0; i < 8; i++) await fetchAborted(); + aborts.length = 0; + await expectCollected(...refs); + await server.close(); + }); + + it("collects requests' scopes and requests once answered", async () => { + const refs: WeakRef[] = []; + const server = await served({ + "@app/a": contributing({ + routes: { + "GET /r": async (request, ctx) => { + refs.push(new WeakRef(ctx), new WeakRef(request)); + return { kind: "json", value: 1 }; + }, + }, + }), + }); + for (let i = 0; i < 5; i++) await send(server.port, "GET", "/r"); + await expectCollected(...refs); + await server.close(); + }); +}); diff --git a/packages/chord/test/next-node/http/node.test.ts b/packages/chord/test/next-node/http/node.test.ts new file mode 100644 index 000000000..8349bd751 --- /dev/null +++ b/packages/chord/test/next-node/http/node.test.ts @@ -0,0 +1,215 @@ +// Tests of Chord's own Node server (the listener's native): request targets (origin-form only; a malformed or +// double-slash path never crashes or reroutes), raw `Host` headers, Web requests made only when read, the request's scope lasting until its response is +// flushed (and `close` waiting for it), and a client's disconnect aborting the scope. + +import { once } from "node:events"; +import { createServer, request as httpRequest, type IncomingMessage } from "node:http"; +import { connect } from "node:net"; +import { describe, expect, it } from "vitest"; +import { nodeRequest } from "../../../src/next-node/http/index.ts"; +import { context, contributing, served } from "./support.ts"; + +/** What `port` answers a raw request `text` with: its status line and body. */ +async function raw(port: number, text: string): Promise<{ readonly status: string; readonly body: string }> { + const socket = connect(port, "127.0.0.1"); + let out = ""; + socket.on("data", (chunk) => { + out += chunk.toString("latin1"); + }); + socket.on("error", () => {}); + await once(socket, "connect"); + socket.write(text); + await once(socket, "close"); + const [head = "", body = ""] = out.split("\r\n\r\n"); + return { status: head.split("\r\n")[0] ?? "", body }; +} + +/** A route answering its path and params, under `GET /who` and `GET /n/:name`. */ +const echo = contributing({ + routes: { + "GET /who": async (request) => ({ kind: "json", value: request.path }), + "GET /n/:name": async (request) => ({ kind: "json", value: request.params }), + }, +}); + +describe("targets", () => { + // Astra's build review, Fable's security audit C1: `GET //[` threw out of the request handler and ended the process. + it("answers a malformed or double-slash path without crashing or rerouting it (Opus's audit H5)", async () => { + const server = await served({ "@app/echo": echo }); + const host = `Host: 127.0.0.1:${server.port}`; + const get = (target: string) => + raw(server.port, `GET ${target} HTTP/1.1\r\n${host}\r\nConnection: close\r\n\r\n`); + expect((await get("//[")).status).toBe("HTTP/1.1 404 Not Found"); + expect((await get("//evil.example/who")).status).toBe("HTTP/1.1 404 Not Found"); + expect(await get("/a/../who")).toEqual({ status: "HTTP/1.1 200 OK", body: '"/who"' }); + // The URL parser resolves `%2e%2e` as a dot segment: `/n/%2e%2e` is `/`. + expect((await get("/n/%2e%2e")).status).toBe("HTTP/1.1 404 Not Found"); + expect((await get("/n/a%5Cb")).status).toBe("HTTP/1.1 400 Bad Request"); + expect((await get("/n/..%2F..%2Fsecret")).status).toBe("HTTP/1.1 400 Bad Request"); + expect((await get("/who")).status).toBe("HTTP/1.1 200 OK"); + await server.close(); + }); + + it("refuses an absolute-form target, two Host headers and none (403)", async () => { + const server = await served({ "@app/echo": echo }); + const host = `Host: 127.0.0.1:${server.port}`; + const answers = await Promise.all([ + raw(server.port, `GET http://evil.example/who HTTP/1.1\r\n${host}\r\nConnection: close\r\n\r\n`), + raw(server.port, `GET /who HTTP/1.1\r\n${host}\r\nHost: evil.example\r\nConnection: close\r\n\r\n`), + raw(server.port, "GET /who HTTP/1.0\r\n\r\n"), + ]); + expect(answers.map(({ status }) => status)).toEqual([ + "HTTP/1.1 403 Forbidden", + "HTTP/1.1 403 Forbidden", + "HTTP/1.1 403 Forbidden", + ]); + await server.close(); + }); + + it("refuses a Host with userinfo (403) and a method a Web Request cannot carry (501)", async () => { + const server = await served({ "@app/echo": echo }); + const answers = await Promise.all([ + raw(server.port, `GET /who HTTP/1.1\r\nHost: attacker@127.0.0.1:${server.port}\r\nConnection: close\r\n\r\n`), + raw(server.port, `TRACE /who HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nConnection: close\r\n\r\n`), + ]); + // `TRACK` never reaches Chord: Node's parser refuses it (400); `CONNECT` goes to Node's `connect` event. + expect(answers.map(({ status }) => status)).toEqual(["HTTP/1.1 403 Forbidden", "HTTP/1.1 501 Not Implemented"]); + await server.close(); + }); +}); + +describe("mounting on an app's own Node server", () => { + // Sol's recheck N6: a hand-made `Request` threw unhandled on `Host: [` or an absolute-form target (the process + // ended) and kept only the first of two `Host`s. + it("converts with the listener's checks, never throwing: bad targets, userinfo, TRACE refused; duplicate Hosts kept", async () => { + const server = await served({ "@app/echo": echo }, { config: { listen: undefined } }); + const own = createServer(async (message, res) => { + const converted = nodeRequest(message, res); + if (converted.kind === "refused") return void res.writeHead(converted.status).end(); + const response = await server.http.fetch(converted.request, converted.connection); + for (const [name, value] of response.headers) res.appendHeader(name, value); + res.writeHead(response.status).end(Buffer.from(await response.arrayBuffer())); + }); + await new Promise((resolve) => own.listen(0, "127.0.0.1", resolve)); + const address = own.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + const host = `Host: 127.0.0.1:${port}`; + const get = (text: string) => raw(port, `${text}Connection: close\r\n\r\n`); + expect(await get(`GET /who HTTP/1.1\r\n${host}\r\n`)).toEqual({ status: "HTTP/1.1 200 OK", body: '"/who"' }); + expect((await get("GET /who HTTP/1.1\r\nHost: [\r\n")).status).toBe("HTTP/1.1 400 Bad Request"); + expect((await get(`GET http://evil.example/who HTTP/1.1\r\n${host}\r\n`)).status).toBe("HTTP/1.1 403 Forbidden"); + expect((await get(`GET /who HTTP/1.1\r\n${host}\r\nHost: evil.example\r\n`)).status).toBe( + "HTTP/1.1 403 Forbidden", + ); + // Sol's second recheck A: both threw out of the conversion; the app's callback ended the process. + expect((await get(`TRACE /who HTTP/1.1\r\n${host}\r\n`)).status).toBe("HTTP/1.1 501 Not Implemented"); + expect((await get(`GET /who HTTP/1.1\r\nHost: attacker@127.0.0.1:${port}\r\n`)).status).toBe( + "HTTP/1.1 403 Forbidden", + ); + expect((await get(`GET /who HTTP/1.1\r\n${host}\r\n`)).status).toBe("HTTP/1.1 200 OK"); + await new Promise((resolve) => own.close(resolve)); + await server.close(); + }); +}); + +describe("Web requests", () => { + it("makes a Web Request only when something reads the headers or the body (here: no authenticator does)", async () => { + const server = await served( + { + "@app/r": contributing({ + routes: { + "GET /plain": async (request) => ({ kind: "json", value: request.path }), + "GET /headers": async (request) => ({ kind: "json", value: request.headers.get("x-thing") }), + "POST /body": async (request) => ({ kind: "json", value: await request.json() }), + }, + }), + }, + { authenticate: () => undefined }, + ); + const original = globalThis.Request; + let made = 0; + globalThis.Request = class extends original { + constructor(...args: ConstructorParameters) { + super(...args); + made++; + } + }; + try { + const host = `Host: 127.0.0.1:${server.port}\r\nConnection: close`; + await raw(server.port, `GET /plain HTTP/1.1\r\n${host}\r\n\r\n`); + expect(made).toBe(0); + const read = await raw(server.port, `GET /headers HTTP/1.1\r\n${host}\r\nX-Thing: t\r\n\r\n`); + const posted = await raw(server.port, `POST /body HTTP/1.1\r\n${host}\r\nContent-Length: 3\r\n\r\n"a"`); + expect([read.body, posted.body, made]).toEqual(['"t"', '"a"', 2]); + } finally { + globalThis.Request = original; + } + await server.close(); + }); +}); + +describe("the request's scope", () => { + // Sol's build review: the scope sealed, and `close` stopped waiting, before a large response was flushed. + it("lasts until the response is flushed; close waits for it", async () => { + const size = 32 * 1024 * 1024; + let signal: AbortSignal | undefined; + const server = await served( + { + "@app/large": contributing({ + routes: { + "GET /large": async (_request, ctx) => { + signal = ctx.abortSignal; + return { kind: "html", body: "x".repeat(size) }; + }, + }, + }), + }, + { config: { listen: { host: "127.0.0.1", port: 0 }, closeTimeout: 2000 } }, + ); + const request = httpRequest({ host: "127.0.0.1", port: server.port, path: "/large", agent: false }); + const response = await new Promise((resolve) => { + request.on("response", (arrived: IncomingMessage) => { + arrived.pause(); + resolve(arrived); + }); + request.end(); + }); + let length = 0; + response.on("data", (chunk: Buffer) => { + length += chunk.length; + }); + response.pause(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const sealedBeforeFlush = signal?.aborted; + const closing = server.http.close(context); + await new Promise((resolve) => setTimeout(resolve, 20)); + response.resume(); + await once(response, "end"); + await closing; + expect({ sealedBeforeFlush, length }).toEqual({ sealedBeforeFlush: false, length: size }); + await server.close(); + }); + + it("is aborted when the client disconnects", async () => { + const entered = Promise.withResolvers(); + const server = await served({ + "@app/slow": contributing({ + routes: { + "GET /slow": async (_request, ctx) => { + entered.resolve(ctx.abortSignal!); + return new Promise(() => {}); + }, + }, + }), + }); + const request = httpRequest({ host: "127.0.0.1", port: server.port, path: "/slow", agent: false }); + request.on("error", () => {}); + request.end(); + const signal = await entered.promise; + const aborted = new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })); + request.destroy(); + await aborted; + expect(signal.aborted).toBe(true); + await server.close(); + }); +}); diff --git a/packages/chord/test/next-node/http/request.test.ts b/packages/chord/test/next-node/http/request.test.ts new file mode 100644 index 000000000..a48a2429e --- /dev/null +++ b/packages/chord/test/next-node/http/request.test.ts @@ -0,0 +1,119 @@ +// Tests of requests (slice 10 §2, D4): the summary a route gets, `json` and `body` with their limits and statuses +// (400, 413, an `HttpFailure` escaping a route answering its status), and a body nobody read (drained or closed). + +import { Agent, request as httpRequest } from "node:http"; +import { describe, expect, it } from "vitest"; +import type { HttpRequest } from "../../../src/next-node/http/index.ts"; +import { contributing, send, served } from "./support.ts"; + +describe("the request a route gets", () => { + it("has its method, path, params, query, headers, origin, provenance and the response's nonce", async () => { + let seen: HttpRequest | undefined; + const server = await served({ + "@app/a": contributing({ + routes: { + "GET /notes/:id": async (request) => { + seen = request; + return { kind: "json", value: null }; + }, + }, + }), + }); + await send(server.port, "GET", "/notes/a%20b?x=1&x=2", { headers: { "X-Thing": "t" } }); + expect(seen).toMatchObject({ + method: "GET", + path: "/notes/a%20b", + params: { id: "a b" }, + origin: `http://127.0.0.1:${server.port}`, + provenance: { kind: "local" }, + }); + expect([seen?.query.getAll("x"), seen?.headers.get("x-thing")]).toEqual([["1", "2"], "t"]); + expect(seen?.nonce).toMatch(/^[A-Za-z0-9+/]{22}==$/); + await server.close(); + }); +}); + +describe("bodies", () => { + /** A server whose `POST /json` answers the JSON it read (limit 16), `POST /body` the bytes it read (limit 16). */ + const reading = () => + served({ + "@app/a": contributing({ + routes: { + "POST /json": async (request) => ({ kind: "json", value: await request.json(16) }), + "POST /body": async (request) => { + let bytes = 0; + for await (const chunk of request.body(16)) bytes += chunk.byteLength; + return { kind: "json", value: bytes }; + }, + }, + }), + }); + + it("parses JSON within its limit; malformed is 400, larger is 413 (declared or streamed)", async () => { + const server = await reading(); + const { port } = server; + expect((await send(port, "POST", "/json", { body: '{"a":1}' })).body.toString()).toBe('{"a":1}'); + expect((await send(port, "POST", "/json", { body: "{nope" })).status).toBe(400); + expect((await send(port, "POST", "/json", { body: JSON.stringify({ long: "x".repeat(40) }) })).status).toBe(413); + const chunked = await send(port, "POST", "/body", { + headers: { "transfer-encoding": "chunked" }, + body: "y".repeat(40), + }); + expect(chunked.status).toBe(413); + expect((await send(port, "POST", "/body", { body: "y".repeat(10) })).body.toString()).toBe("10"); + expect(server.errors).toEqual([]); + await server.close(); + }); + + // Sol's and Astra's build reviews: an unfinished chunked upload past the limit got no answer. + it("answers 413 before a chunked upload past the limit has ended", async () => { + const server = await reading(); + const request = httpRequest({ + host: "127.0.0.1", + port: server.port, + path: "/json", + method: "POST", + headers: { "transfer-encoding": "chunked" }, + agent: false, + }); + request.on("error", () => {}); + const status = new Promise((resolve) => { + request.on("response", (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + }); + request.write(`"${"x".repeat(40)}`); + expect(await status).toBe(413); + request.destroy(); + await server.close(); + }); + + it("drains a small body nobody read and keeps the connection; a large one closes it", async () => { + const server = await served({ + "@app/a": contributing({ routes: { "POST /ignore": async () => ({ kind: "json", value: 1 }) } }), + }); + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + const post = (body: string) => + new Promise<{ connection: string | undefined; socket: unknown }>((resolve, reject) => { + const request = httpRequest( + { host: "127.0.0.1", port: server.port, method: "POST", path: "/ignore", agent }, + (response) => { + response.resume(); + response.on("end", () => + resolve({ connection: response.headers.connection, socket: request.socket }), + ); + }, + ); + request.on("error", reject); + request.end(body); + }); + const first = await post("x".repeat(1000)); + const second = await post("x".repeat(1000)); + expect(first.connection).toBe("keep-alive"); + expect(second.socket).toBe(first.socket); + expect((await post("x".repeat(100_000))).connection).toBe("close"); + agent.destroy(); + await server.close(); + }); +}); diff --git a/packages/chord/test/next-node/http/response.test.ts b/packages/chord/test/next-node/http/response.test.ts new file mode 100644 index 000000000..3c953ba85 --- /dev/null +++ b/packages/chord/test/next-node/http/response.test.ts @@ -0,0 +1,245 @@ +// Tests of responses (slice 10 §2, D5, R6; security baseline (4), (6)): every kind written by a running server, the +// response's headers over the default ones (an invalid one reported, 500), ranges (206, 416, multi-range), `HEAD`, +// attachments and inline bodies, safe download names, bodies without `size`, a short or throwing read, a disconnect +// stopping the reads. + +import { request as httpRequest } from "node:http"; +import { describe, expect, it } from "vitest"; +import type { Ctx } from "../../../src/next/host/index.ts"; +import type { HttpResponse } from "../../../src/next-node/http/index.ts"; +import { contributing, send, served } from "./support.ts"; + +/** A server answering `GET /r` with `response`. */ +async function answering(response: HttpResponse) { + return served({ "@app/r": contributing({ routes: { "GET /r": async () => response } }) }); +} + +/** A `bytes` response over `data` that records its reads. */ +function bytesOf(data: Buffer, extra: Partial> = {}) { + const reads: [number, number][] = []; + const response: HttpResponse = { + kind: "bytes", + mime: "application/octet-stream", + size: data.length, + read: async (offset, length) => { + reads.push([offset, length]); + return data.subarray(offset, offset + length); + }, + ...extra, + }; + return { response, reads }; +} + +describe("descriptions", () => { + it("writes json, html, redirect and error, the default headers first, the response's replacing same-named ones", async () => { + const cases: [HttpResponse, number, string, string | undefined][] = [ + [{ kind: "json", value: { a: 1 } }, 200, '{"a":1}', "application/json; charset=utf-8"], + [ + { kind: "html", body: "

x

", status: 201, headers: { "content-security-policy": "own" } }, + 201, + "

x

", + "text/html; charset=utf-8", + ], + [{ kind: "redirect", to: "/next", status: 303 }, 303, "", undefined], + [{ kind: "error", status: 418 }, 418, "I'm a Teapot", "text/plain; charset=utf-8"], + [{ kind: "error", status: 409, message: "taken" }, 409, "taken", "text/plain; charset=utf-8"], + ]; + for (const [response, status, body, type] of cases) { + const server = await answering(response); + const answer = await send(server.port, "GET", "/r"); + expect([answer.status, answer.body.toString(), answer.headers["content-type"]]).toEqual([status, body, type]); + expect([answer.headers["cache-control"], answer.headers["x-content-type-options"]]).toEqual([ + "no-store", + "nosniff", + ]); + expect(answer.headers["content-security-policy"]).toMatch( + response.headers === undefined ? /^default-src 'none'/ : /^own$/, + ); + if (response.kind === "redirect") expect(answer.headers.location).toBe("/next"); + await server.close(); + } + }); + + it("answers 500 for a header with a CR or LF and a value that is no JSON, reported response_failed", async () => { + const server = await served({ + "@app/r": contributing({ + routes: { + "GET /header": async () => ({ kind: "json", value: 1, headers: { "X-Split": "a\r\nSet-Cookie: x=1" } }), + "GET /bigint": async () => ({ kind: "json", value: { n: 1n } as never }), + }, + }), + }); + for (const path of ["/header", "/bigint"]) { + const answer = await send(server.port, "GET", path); + expect([answer.status, answer.headers["set-cookie"], answer.headers["x-content-type-options"]]).toEqual([ + 500, + undefined, + "nosniff", + ]); + } + expect(server.errors).toMatchObject([ + { kind: "response_failed", pattern: "GET /header" }, + { kind: "response_failed", pattern: "GET /bigint" }, + ]); + await server.close(); + }); +}); + +describe("bytes", () => { + const data = Buffer.from("0123456789".repeat(60_000)); + + it("sends Content-Length, reads 256 KiB pieces, answers a single range 206 and an unsatisfiable one 416", async () => { + const { response, reads } = bytesOf(data, { ranges: true }); + const server = await answering(response); + const whole = await send(server.port, "GET", "/r"); + expect([whole.status, whole.headers["content-length"], whole.headers["accept-ranges"]]).toEqual([ + 200, + "600000", + "bytes", + ]); + expect(whole.body.equals(data)).toBe(true); + expect(reads).toEqual([ + [0, 262144], + [262144, 262144], + [524288, 75712], + ]); + const range = await send(server.port, "GET", "/r", { headers: { range: "bytes=10-19" } }); + expect([range.status, range.body.toString(), range.headers["content-range"]]).toEqual([ + 206, + "0123456789", + "bytes 10-19/600000", + ]); + const suffix = await send(server.port, "GET", "/r", { headers: { range: "bytes=-3" } }); + expect([suffix.status, suffix.body.toString()]).toEqual([206, "789"]); + const past = await send(server.port, "GET", "/r", { headers: { range: "bytes=600000-" } }); + expect([past.status, past.headers["content-range"]]).toEqual([416, "bytes */600000"]); + const multi = await send(server.port, "GET", "/r", { headers: { range: "bytes=0-1,5-6" } }); + expect([multi.status, multi.body.length]).toEqual([200, 600000]); + await server.close(); + }); + + it("ignores ranges unless asked; HEAD sends headers only and reads nothing", async () => { + const { response, reads } = bytesOf(data); + const server = await answering(response); + const ranged = await send(server.port, "GET", "/r", { headers: { range: "bytes=0-1" } }); + expect([ranged.status, ranged.headers["accept-ranges"]]).toEqual([200, undefined]); + reads.length = 0; + const head = await send(server.port, "HEAD", "/r"); + expect([head.status, head.headers["content-length"], head.body.length, reads]).toEqual([200, "600000", 0, []]); + await server.close(); + }); + + it("names a download with an ASCII fallback and the UTF-8 name, control and bidi characters dropped", async () => { + const server = await answering(bytesOf(Buffer.from("x"), { download: 'Grüße "1"\r\n\u202etxt.exe' }).response); + const answer = await send(server.port, "GET", "/r"); + expect(answer.headers["content-disposition"]).toBe( + `attachment; filename="Gr__e _1_txt.exe"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%221%22txt.exe`, + ); + await server.close(); + }); + + it("is an attachment unless inline; an inline one gets a sandbox CSP", async () => { + const attached = await answering(bytesOf(Buffer.from("x")).response); + expect((await send(attached.port, "GET", "/r")).headers["content-disposition"]).toBe("attachment"); + await attached.close(); + const shown = await answering(bytesOf(Buffer.from("x"), { inline: true }).response); + const answer = await send(shown.port, "GET", "/r"); + expect(answer.headers["content-disposition"]).toBe("inline"); + expect(answer.headers["content-security-policy"]).toMatch(/; sandbox$/); + await shown.close(); + }); + + it("refuses a foreign request (another site's) 403, reading nothing", async () => { + const { response, reads } = bytesOf(Buffer.from("secret")); + const server = await answering(response); + const foreign = await send(server.port, "GET", "/r", { headers: { "sec-fetch-site": "cross-site" } }); + expect([foreign.status, reads]).toEqual([403, []]); + expect((await send(server.port, "GET", "/r", { headers: { "sec-fetch-site": "same-origin" } })).status).toBe(200); + await server.close(); + }); + + it("without a size: chunked, read with growing offsets until an empty chunk, no ranges (R6)", async () => { + const pieces = [Buffer.from("ab"), Buffer.from("cde"), Buffer.alloc(0)]; + const offsets: number[] = []; + const server = await answering({ + kind: "bytes", + mime: "text/plain", + ranges: true, + read: async (offset) => { + offsets.push(offset); + return pieces.shift()!; + }, + }); + const answer = await send(server.port, "GET", "/r", { headers: { range: "bytes=0-1" } }); + expect([ + answer.status, + answer.body.toString(), + answer.headers["transfer-encoding"], + answer.headers["accept-ranges"], + ]).toEqual([200, "abcde", "chunked", undefined]); + expect(offsets).toEqual([0, 2, 5]); + await server.close(); + }); + + it("destroys the connection on a short or a throwing read, reported response_failed", async () => { + const server = await served({ + "@app/r": contributing({ + routes: { + "GET /short": async () => ({ + kind: "bytes", + mime: "text/plain", + size: 10, + read: async () => Buffer.from("abc"), + }), + // Astra's build review: a throwing read hung the response, unreported. + "GET /throws": async () => ({ + kind: "bytes", + mime: "text/plain", + size: 10, + read: async () => { + throw new Error("read failed"); + }, + }), + }, + }), + }); + await expect(send(server.port, "GET", "/short")).rejects.toThrow(); + await expect(send(server.port, "GET", "/throws")).rejects.toThrow(); + expect(server.errors).toMatchObject([ + { kind: "response_failed", pattern: "GET /short" }, + { kind: "response_failed", pattern: "GET /throws", error: { message: "read failed" } }, + ]); + await server.close(); + }); + + it("stops reading when the client disconnects: the read's context is aborted", async () => { + let reads = 0; + let signal: AbortSignal | undefined; + const server = await answering({ + kind: "bytes", + mime: "application/octet-stream", + size: 1024 * 1024 * 1024, + read: async (_offset, length, ctx: Ctx) => { + reads++; + signal = ctx.abortSignal; + return Buffer.alloc(length); + }, + }); + await new Promise((resolve) => { + const request = httpRequest({ host: "127.0.0.1", port: server.port, path: "/r", agent: false }, (response) => { + response.once("data", () => { + request.destroy(); + resolve(); + }); + }); + request.end(); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + const after = reads; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(signal?.aborted).toBe(true); + expect(reads).toBe(after); + expect(after).toBeLessThan(1000); + await server.close(); + }); +}); diff --git a/packages/chord/test/next-node/http/router.test.ts b/packages/chord/test/next-node/http/router.test.ts new file mode 100644 index 000000000..9854820c6 --- /dev/null +++ b/packages/chord/test/next-node/http/router.test.ts @@ -0,0 +1,228 @@ +// Tests of the router (slice 10 §2, D2, D8): patterns, decoding (one rule for `:name` and rest segments), precedence, +// `HEAD` per path, each route's own param names, route options, conflicts, wraps resolved by phase, contributor order, +// scope and route, allowed hosts, framers, relays, CSP sources, sockets. + +import { describe, expect, it } from "vitest"; +import type { RegistryConflict, RegistryTable } from "../../../src/next/registries/index.ts"; +import { + composeRouter, + type HttpContribution, + type HttpRoute, + type HttpWrap, + type RouteMatch, + type Router, +} from "../../../src/next-node/http/router.ts"; + +/** A route named `name` (its handler answers its name). */ +const route = + (name: string): HttpRoute => + async () => ({ kind: "html", body: name }); + +/** The router of `entries` (plugin, contribution), with the conflicts it reported. */ +function compose(...entries: [string, HttpContribution][]): { router: Router; conflicts: RegistryConflict[] } { + const conflicts: RegistryConflict[] = []; + const table: RegistryTable = entries.map(([plugin, value]) => ({ plugin, version: 1, value })); + return { router: composeRouter(table, { report: (conflict) => conflicts.push(conflict) }), conflicts }; +} + +/** The matched route's pattern and params, or what `route` answered. */ +function matched(router: Router, method: string, path: string) { + const match = router.route(method, path); + if (match === undefined || match === "bad_path") return match; + return { pattern: match.pattern, params: match.params }; +} + +describe("patterns and precedence (D8)", () => { + const { router } = compose([ + "@app/a", + { + routes: { + "GET /notes": route("list"), + "GET /notes/:id": route("one"), + "GET /notes/new": route("new"), + "GET /notes/:id/export": route("export"), + "GET /files/*": route("files"), + "GET /files/special/:name": route("special"), + "GET /": route("root"), + }, + }, + ]); + + it("matches exact segments, :name and a trailing *, the most specific from the left", () => { + expect(matched(router, "GET", "/notes")).toEqual({ pattern: "GET /notes", params: {} }); + expect(matched(router, "GET", "/notes/new")).toEqual({ pattern: "GET /notes/new", params: {} }); + expect(matched(router, "GET", "/notes/7")).toEqual({ pattern: "GET /notes/:id", params: { id: "7" } }); + expect(matched(router, "GET", "/notes/7/export")).toMatchObject({ params: { id: "7" } }); + expect(matched(router, "GET", "/files/special/x")).toEqual({ + pattern: "GET /files/special/:name", + params: { name: "x" }, + }); + // The exact branch fails deeper: the rest takes it. + expect(matched(router, "GET", "/files/special/x/y")).toEqual({ + pattern: "GET /files/*", + params: { "*": "special/x/y" }, + }); + expect(matched(router, "GET", "/")).toEqual({ pattern: "GET /", params: {} }); + }); + + it("a /* matches its prefix with an empty rest; :name needs a non-empty segment", () => { + expect(matched(router, "GET", "/files")).toEqual({ pattern: "GET /files/*", params: { "*": "" } }); + expect(matched(router, "GET", "/notes/")).toBeUndefined(); + }); + + // Opus's build review F4, security audit H5: `%2F` in `:name` reached a file path. + it("percent-decodes :name and rest segments; one decoding to /, \\, NUL, . or .. is a bad path", () => { + expect(matched(router, "GET", "/notes/a%20b")).toMatchObject({ params: { id: "a b" } }); + expect(matched(router, "GET", "/files/a%20b/c")).toMatchObject({ params: { "*": "a b/c" } }); + for (const bad of ["a%2Fb", "..%2F..%2Fsecret", "a%5Cb", "a%00b", "%2e%2e", "%2E", ".", ".."]) { + expect(router.route("GET", `/notes/${bad}`)).toBe("bad_path"); + expect(router.route("GET", `/files/x/${bad}`)).toBe("bad_path"); + } + expect(router.route("GET", "/notes/%E0%A4%A")).toBe("bad_path"); + }); + + it("falls back to the GET route for HEAD; another method or path has none", () => { + expect(matched(router, "HEAD", "/notes")).toEqual({ pattern: "GET /notes", params: {} }); + expect(router.route("POST", "/notes")).toBeUndefined(); + expect(router.route("GET", "/nope")).toBeUndefined(); + }); + + // Opus's build review F2, Astra's: one explicit HEAD route anywhere hid every GET fallback. + it("falls back to GET per path when another path has an explicit HEAD route", () => { + const { router } = compose([ + "@app/a", + { routes: { "HEAD /download": route("head"), "GET /download": route("get"), "GET /notes": route("notes") } }, + ]); + expect(matched(router, "HEAD", "/download")).toMatchObject({ pattern: "HEAD /download" }); + expect(matched(router, "HEAD", "/notes")).toMatchObject({ pattern: "GET /notes" }); + }); + + // Opus's build review F3, Astra's: the first route's param name was used for every route at that position. + it("names each route's params by its own pattern", () => { + const { router } = compose(["@app/a", { routes: { "GET /:id/a": route("a"), "GET /:name/b": route("b") } }]); + expect(matched(router, "GET", "/v/a")).toEqual({ pattern: "GET /:id/a", params: { id: "v" } }); + expect(matched(router, "GET", "/v/b")).toEqual({ pattern: "GET /:name/b", params: { name: "v" } }); + }); + + it("takes a route with options: page and crossSite", () => { + const { router } = compose([ + "@app/a", + { + routes: { + "GET /p": { handler: route("p"), page: true }, + "POST /hook": { handler: route("h"), crossSite: true }, + }, + }, + ]); + expect(router.route("GET", "/p")).toMatchObject({ page: true, crossSite: false }); + expect(router.route("POST", "/hook")).toMatchObject({ page: false, crossSite: true }); + expect(router.route("GET", "/notes")).toBeUndefined(); + }); +}); + +describe("conflicts", () => { + it("reports one pattern from two contributions (also with other param names); the first by plugin name wins", () => { + const { router, conflicts } = compose( + ["@app/a", { routes: { "GET /x/:id": route("a") } }], + ["@app/b", { routes: { "GET /x/:key": route("b"), BAD: route("bad") }, sockets: { nope: () => {} } }], + ); + expect((router.route("GET", "/x/1") as RouteMatch).plugin).toBe("@app/a"); + expect(conflicts).toEqual([ + { message: 'route "GET /x/:key" is contributed twice', plugins: ["@app/a", "@app/b"] }, + { message: 'route "BAD" is no "METHOD /pattern"', plugins: ["@app/b"] }, + { message: 'socket "nope" is no pattern', plugins: ["@app/b"] }, + ]); + }); + + it("refuses a route for a method a Web Request cannot carry, reported", () => { + const { router, conflicts } = compose([ + "@app/a", + { routes: { "TRACE /x": route("t"), "CONNECT /y": route("c") } }, + ]); + expect(router.route("TRACE", "/x")).toBeUndefined(); + expect(conflicts).toEqual([ + { message: 'route "TRACE /x": TRACE is served for no resource (501)', plugins: ["@app/a"] }, + { message: 'route "CONNECT /y": CONNECT is served for no resource (501)', plugins: ["@app/a"] }, + ]); + }); +}); + +describe("wraps (D2)", () => { + const wrap = (name: string, phase: HttpWrap["phase"], extra: Partial = {}): HttpWrap => ({ + name, + phase, + wrap: (request, next) => next(request), + ...extra, + }); + + it("orders by phase (outermost first), then contributor order; own wraps around their plugin's routes only", () => { + const { router } = compose( + [ + "@app/a", + { + routes: { "GET /a": route("a") }, + wraps: [wrap("a-transform", "transform"), wrap("a-observe", "observe")], + }, + ], + [ + "@app/b", + { + routes: { "GET /b": route("b") }, + wraps: [wrap("b-all", "headers", { scope: "all" }), wrap("b-own", "observe")], + }, + ], + ); + const names = (path: string) => (router.route("GET", path) as RouteMatch).wraps.map(({ wrap }) => wrap.name); + expect(names("/a")).toEqual(["a-observe", "b-all", "a-transform"]); + expect(names("/b")).toEqual(["b-own", "b-all"]); + }); + + it("narrows to one route; a route nobody contributes is a reported conflict", () => { + const { router, conflicts } = compose([ + "@app/a", + { + routes: { "GET /a": route("a"), "GET /b": route("b") }, + wraps: [wrap("only-a", "observe", { route: "GET /a" }), wrap("ghost", "observe", { route: "GET /c" })], + }, + ]); + expect((router.route("GET", "/a") as RouteMatch).wraps.map(({ wrap }) => wrap.name)).toEqual(["only-a"]); + expect((router.route("GET", "/b") as RouteMatch).wraps).toEqual([]); + expect(conflicts).toEqual([ + { message: 'wrap "ghost" names route "GET /c", which nobody contributes', plugins: ["@app/a"] }, + ]); + }); +}); + +describe("hosts and sockets", () => { + it("collects hosts lowercased, framers, relays and CSP sources; matches sockets with their largest frame", () => { + const terminal = () => {}; + const relay = { name: "radius", verify: () => undefined }; + const contribution: HttpContribution = { + hosts: ["Notes.Example.com"], + sockets: { "/terminal/:id": terminal }, + maxPayload: 10, + framers: ["https://frame.example.com"], + relays: [relay], + csp: { "img-src": ["https://cdn.example.com"] }, + }; + const { router } = compose( + ["@app/a", contribution], + ["@app/b", { hosts: ["b.example.com"], csp: { "img-src": ["data:"] } }], + ); + expect([...router.hosts]).toEqual(["notes.example.com", "b.example.com"]); + expect([[...router.framers], router.relays, [...router.csp]]).toEqual([ + ["https://frame.example.com"], + [relay], + [["img-src", ["https://cdn.example.com", "data:"]]], + ]); + expect(router.socket("/terminal/7")).toMatchObject({ + pattern: "/terminal/:id", + plugin: "@app/a", + params: { id: "7" }, + maxPayload: 10, + }); + expect(router.socket("/terminal/..")).toBe("bad_path"); + expect(router.socket("/nope")).toBeUndefined(); + expect(router.socketHandlers.has(terminal)).toBe(true); + }); +}); diff --git a/packages/chord/test/next-node/http/support.ts b/packages/chord/test/next-node/http/support.ts new file mode 100644 index 000000000..8a4b82b59 --- /dev/null +++ b/packages/chord/test/next-node/http/support.ts @@ -0,0 +1,204 @@ +// Test support for the HTTP component (slice 10 §2): a server host with the component, the app's configuration and +// authenticator, and plugin facets contributing to `Http`; raw requests (any `Host`, any body) and WebSocket clients +// against its listener. + +import { request as httpRequest, type IncomingHttpHeaders } from "node:http"; +import { WebSocket } from "ws"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import type { Address } from "../../../src/next/contracts/index.ts"; +import { createHost, defineFacet, type Facet, type Host, type HostError } from "../../../src/next/host/index.ts"; +import { + type Authenticator, + Authenticators, + createHttp, + Http, + type HttpComponent, + type HttpContribution, + type HttpError, + type HttpOptions, +} from "../../../src/next-node/http/index.ts"; + +/** The principals of these tests. */ +export type TestPrincipal = + | { readonly kind: "server" } + | { readonly kind: "owner" } + | { readonly kind: "guest"; readonly id: string }; + +/** A running server: its host, its component, its port once loaded, what both reported. */ +export type Served = { + readonly host: Host; + readonly http: HttpComponent; + /** The listener's port (after `load`). */ + readonly port: number; + readonly errors: HttpError[]; + readonly hostErrors: HostError[]; + /** The core facets and the plugins, as loaded. */ + facets(plugins?: Readonly>>): { name: string; facet: Facet }[]; + close(): Promise; +}; + +/** A plugin facet contributing `contribution` to `Http`. */ +export function contributing(contribution: HttpContribution): Facet { + return defineFacet((_host, self) => self.contribute(Http, contribution)); +} + +/** + * Builds the component on a new server host (listening on loopback, a free port), then loads it with the app's + * authenticator (`@app/auth`: the `x-principal` header names one, else anonymous) and `plugins` (by name) unless + * `load` is false; `config` overrides the settings (`listen: undefined`: no listener). + */ +export async function served( + plugins: Readonly>> = {}, + options: { + readonly config?: Omit; + readonly authenticate?: Authenticator; + readonly load?: boolean; + readonly readyTimeout?: number; + readonly address?: Address; + } = {}, +): Promise }> { + const errors: HttpError[] = []; + const hostErrors: HostError[] = []; + const host = createHost({ + principal: { kind: "server" }, + onError: (error) => hostErrors.push(error), + ...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }), + ...(options.address === undefined ? {} : { address: options.address }), + }); + const config: Omit = { listen: { host: "127.0.0.1", port: 0 }, ...options.config }; + const http = createHttp(host, { ...config, onError: (error) => errors.push(error) }, context); + const authenticate = options.authenticate ?? ((request) => principalOf(request.headers)); + const core = [ + { name: "chord.http", facet: http.facet }, + { + name: "@app/auth", + facet: defineFacet((_host, self) => self.contribute(Authenticators, authenticate)), + }, + ]; + const facets = (more = plugins) => [...core, ...Object.entries(more).map(([name, facet]) => ({ name, facet }))]; + let port = 0; + const load = async (): Promise => { + await host.load(facets(), context); + if (config.listen !== undefined) port = (await http.listening(context)).port; + }; + if (options.load !== false) await load(); + return { + host, + http, + get port() { + return port; + }, + errors, + hostErrors, + facets, + load, + close: async () => { + await http.close(context); + await host.close(context); + }, + }; +} + +/** The principal an `x-principal` header names: `owner`, `guest:`; none without it. */ +function principalOf(headers: Headers): TestPrincipal | undefined { + const named = headers.get("x-principal"); + if (named === "owner") return { kind: "owner" }; + if (named?.startsWith("guest:")) return { kind: "guest", id: named.slice(6) }; + return undefined; +} + +/** A response as a test reads it. */ +export type Answer = { readonly status: number; readonly headers: IncomingHttpHeaders; readonly body: Buffer }; + +/** + * A request to `port` with raw control: `Host` defaults to `127.0.0.1:`; `body` is sent as is; `signal` aborts it. + */ +export function send( + port: number, + method: string, + path: string, + options: { + readonly headers?: Readonly>; + readonly body?: string | Buffer; + readonly agent?: false; + } = {}, +): Promise { + return new Promise((resolve, reject) => { + const request = httpRequest( + { + host: "127.0.0.1", + port, + method, + path, + agent: false, + headers: { host: `127.0.0.1:${port}`, ...options.headers }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => + resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(chunks) }), + ); + response.on("error", reject); + }, + ); + request.on("error", reject); + request.end(options.body); + }); +} + +/** A WebSocket client to `path` on `port` (`Host` loopback), with `headers`; resolves once open, rejects when refused. */ +export function socketTo( + port: number, + path: string, + headers: Readonly> = {}, + options: { readonly maxPayload?: number } = {}, +): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}${path}`, { headers, ...options }); + // Frames sent at once by the server arrive with the upgrade: queued from the start. + inboxes.set(socket, messagesOf(socket)); + socket.once("open", () => resolve(socket)); + socket.once("unexpected-response", (_request, response) => { + reject(new Error(`refused ${response.statusCode}`)); + socket.terminate(); + }); + socket.once("error", reject); + }); +} + +/** The messages of every socket `socketTo` opened, queued from its start. */ +const inboxes = new WeakMap }>(); + +/** The next text message of a socket `socketTo` opened. */ +export function nextMessage(socket: WebSocket): Promise { + return inboxes.get(socket)!.next(); +} + +/** The text messages `socket` receives, from now on, in order: `next()` waits for the next one. */ +export function messagesOf(socket: WebSocket): { next(): Promise } { + const queued: string[] = []; + const waiting: ((text: string) => void)[] = []; + socket.on("message", (data) => { + const text = String(data); + const waiter = waiting.shift(); + if (waiter === undefined) queued.push(text); + else waiter(text); + }); + return { + next: () => { + const text = queued.shift(); + return text === undefined ? new Promise((resolve) => waiting.push(resolve)) : Promise.resolve(text); + }, + }; +} + +/** The close code and reason of `socket`'s close. */ +export function closeOf(socket: WebSocket): Promise<{ readonly code: number; readonly reason: string }> { + return new Promise((resolve) => { + if (socket.readyState === socket.CLOSED) resolve({ code: 1006, reason: "" }); + socket.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); +} + +export { context }; diff --git a/packages/chord/test/next-node/http/upgrades.test.ts b/packages/chord/test/next-node/http/upgrades.test.ts new file mode 100644 index 000000000..f4de24124 --- /dev/null +++ b/packages/chord/test/next-node/http/upgrades.test.ts @@ -0,0 +1,458 @@ +// Tests of upgraded connections (slice 10 §2, D6, Q6; decisions "allowed hosts only through contributions", "plugin +// sockets get a heartbeat"): the links endpoint (`host.accept`, options per socket, refused sockets, the 1 MiB frame +// default, the output cap), plugin sockets (handed to their handler, an oversized frame closing only that socket, a +// throwing handler), connections terminated at once when their socket handler or host leaves (a raw peer can act no +// more), an upgrade checked again after authenticating, the heartbeat, close bounded for a peer that never answers, +// and nothing kept of a closed socket or its `ws` server. + +import { once } from "node:events"; +import { connect, type Socket } from "node:net"; +import { describe, expect, it, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import { arr, hostKind, local, method, remote, str } from "../../../src/next/contracts/index.ts"; +import { createHost, defineFacet } from "../../../src/next/host/index.ts"; +import { webSocketLink } from "../../../src/next/link/index.ts"; +import { Http, type HttpContribution } from "../../../src/next-node/http/index.ts"; +import { limitedSocket } from "../../../src/next-node/http/upgrades.ts"; +import { expectCollected, settledHeap } from "../../next/support.ts"; +import { closeOf, context, contributing, nextMessage, send, served, socketTo, type TestPrincipal } from "./support.ts"; + +const Server = hostKind("server"); + +/** Who the server sees calling: served at the server's address. */ +const Who = remote("test.who", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { main: { methods: { who: method(undefined, str()) } } }, +}); + +const serveWho = defineFacet((host) => + host.serve(Who, () => ({ + variant: "main", + members: { who: async (ctx) => JSON.stringify(ctx.principal ?? null) }, + })), +); + +/** A plugin socket that echoes text, with its handler's request params and principal sent first. */ +const echo = contributing({ + sockets: { + "/echo/:room": (socket, request, ctx) => { + socket.send(`${request.params.room} as ${ctx.principal?.kind ?? "nobody"}`); + socket.on("message", (data) => socket.send(String(data))); + }, + }, + maxPayload: 64, +}); + +describe("the links endpoint", () => { + it("accepts a link as the authenticated principal, with the options links.options gave that socket", async () => { + const options: unknown[] = []; + const server = await served( + { "@app/who": serveWho }, + { + address: Server(), + config: { + links: { + path: "/chord", + options: (request, principal) => { + options.push([request.path, principal]); + return { maxPayload: 1024 * 1024 }; + }, + }, + }, + }, + ); + const tab = createHost({ onError: () => {} }); + await tab.load([], context); + // The link wraps the socket at once: the server's `hello` comes with the upgrade. + const socket = new WebSocket(`ws://127.0.0.1:${server.port}/chord`, { headers: { "x-principal": "owner" } }); + tab.connect(webSocketLink(socket as never), { principal: { kind: "server" } }, context); + const who = await tab.get(Who, {}, context); + expect(await who.who(context)).toBe('{"kind":"owner"}'); + expect(options).toEqual([["/chord", { kind: "owner" }]]); + await tab.close(context); + await server.close(); + }); + + it("refuses a socket without a principal, from another Origin or Host, foreign, or whose links.options throws (403)", async () => { + const server = await served( + { "@app/hosts": contributing({ hosts: ["a.example.com"] }) }, + { + config: { + links: { + path: "/chord", + options: (request) => { + if (request.headers.has("x-fail")) throw new Error("options"); + return {}; + }, + }, + }, + }, + ); + const { port } = server; + const owner = { "x-principal": "owner" }; + const refusals = [ + socketTo(port, "/chord"), + socketTo(port, "/chord", { ...owner, origin: "http://evil.com" }), + socketTo(port, "/chord", { ...owner, origin: "null" }), + socketTo(port, "/chord", { ...owner, origin: `HTTP://127.0.0.1:${port}` }), + socketTo(port, "/chord", { ...owner, host: "evil.com" }), + socketTo(port, "/chord", { ...owner, "sec-fetch-site": "cross-site" }), + socketTo(port, "/chord", { ...owner, "x-fail": "1" }), + ]; + for (const refusal of refusals) await expect(refusal).rejects.toThrow("refused 403"); + const same = await socketTo(port, "/chord", { ...owner, origin: `http://127.0.0.1:${port}` }); + same.close(); + expect(server.errors).toEqual([{ kind: "hook_failed", hook: "links.options", error: new Error("options") }]); + await server.close(); + }); + + it("takes frames of at most 1 MiB unless links.options says otherwise (Opus's audit H2)", async () => { + const server = await served({}, { config: { links: { path: "/chord" } } }); + const socket = await socketTo(server.port, "/chord", { "x-principal": "owner" }); + const closed = closeOf(socket); + socket.send(Buffer.alloc(1024 * 1024 + 1)); + expect((await closed).code).toBe(1009); + await server.close(); + }); + + it("cuts off a client that stopped reading: past 4 MiB unsent beyond the frame being sent", () => { + const sent: number[] = []; + let terminated = 0; + const fake = { + OPEN: 1, + readyState: 1, + bufferedAmount: 0, + binaryType: "nodebuffer", + send: (data: Uint8Array) => void sent.push(data.byteLength), + terminate: () => void terminated++, + }; + const limited = limitedSocket(fake as never); + limited.send(new Uint8Array(10)); + fake.bufferedAmount = 5 * 1024 * 1024; + limited.send(new Uint8Array(10)); + expect([sent, terminated]).toEqual([[10], 1]); + }); +}); + +describe("plugin sockets", () => { + it("hands the socket and its request to the handler in a scope with its principal; no principal is refused", async () => { + const server = await served({ "@app/echo": echo }); + await expect(socketTo(server.port, "/echo/lobby")).rejects.toThrow("refused 403"); + await expect(socketTo(server.port, "/nope", { "x-principal": "owner" })).rejects.toThrow("refused 404"); + const socket = await socketTo(server.port, "/echo/lobby", { "x-principal": "owner" }); + expect(await nextMessage(socket)).toBe("lobby as owner"); + socket.send("hi"); + expect(await nextMessage(socket)).toBe("hi"); + socket.close(); + await server.close(); + }); + + it("an oversized frame closes only that socket (1009), never the server", async () => { + const server = await served({ "@app/echo": echo }); + const [big, other] = await Promise.all([ + socketTo(server.port, "/echo/a", { "x-principal": "owner" }), + socketTo(server.port, "/echo/b", { "x-principal": "owner" }), + ]); + await Promise.all([nextMessage(big), nextMessage(other)]); + const closed = closeOf(big); + big.send("x".repeat(100)); + expect((await closed).code).toBe(1009); + other.send("still here"); + expect(await nextMessage(other)).toBe("still here"); + other.close(); + await server.close(); + }); + + it("a throwing handler is reported socket_failed and closes its socket (1011)", async () => { + const server = await served({ + "@app/bad": contributing({ + sockets: { + "/bad": () => { + throw new Error("handler"); + }, + }, + }), + }); + const socket = await socketTo(server.port, "/bad", { "x-principal": "owner" }); + expect((await closeOf(socket)).code).toBe(1011); + expect(server.errors).toMatchObject([{ kind: "socket_failed", pattern: "/bad", plugin: "@app/bad" }]); + await server.close(); + }); +}); + +describe("revocation", () => { + it("terminates a plugin's sockets at once when a reload removes its contribution, sealing their scopes; others stay (Q6)", async () => { + let signal: AbortSignal | undefined; + const removed = contributing({ + sockets: { + "/removed": (_socket, _request, ctx) => { + signal = ctx.abortSignal; + }, + }, + }); + const other = contributing({ sockets: { "/other": () => {} } }); + const server = await served({ "@app/removed": removed, "@app/other": other }); + const doomed = await socketTo(server.port, "/removed", { "x-principal": "owner" }); + const kept = await socketTo(server.port, "/other", { "x-principal": "owner" }); + const closed = closeOf(doomed); + await server.host.reload(server.facets({ "@app/other": other }), context); + expect(signal?.aborted).toBe(true); + expect((await closed).code).toBe(1006); + expect(kept.readyState).toBe(kept.OPEN); + kept.close(); + await server.close(); + }); + + it("terminates links and sockets through a host no longer contributed; loopback ones stay", async () => { + const server = await served( + { "@app/echo": echo, "@app/hosts": contributing({ hosts: ["a.example.com"] }) }, + { config: { links: { path: "/chord" } } }, + ); + const through = { "x-principal": "owner", host: "a.example.com" }; + const link = await socketTo(server.port, "/chord", through); + const socket = await socketTo(server.port, "/echo/a", through); + const loopback = await socketTo(server.port, "/echo/b", { "x-principal": "owner" }); + const closes = [closeOf(link), closeOf(socket)]; + await server.host.reload(server.facets({ "@app/echo": echo }), context); + expect((await Promise.all(closes)).map(({ code }) => code)).toEqual([1006, 1006]); + expect(loopback.readyState).toBe(loopback.OPEN); + expect((await send(server.port, "GET", "/", { headers: { host: "a.example.com" } })).status).toBe(403); + loopback.close(); + await server.close(); + }); + + it("keeps a writable contribution's own sockets when only its hosts change", async () => { + const Tunnel = local("app.tunnel", { version: 1, params: {}, members: { name: method(arr(str())) } }); + const handler = (): void => {}; + const tunnel = defineFacet((host) => + host.serve(Tunnel, (_, self) => { + const contribution = self.writable({ hosts: [], sockets: { "/own": handler } }); + self.contribute(Http, contribution); + return { + name: async (hosts) => { + contribution.set({ hosts, sockets: { "/own": handler } }); + return null; + }, + }; + }), + ); + const server = await served({ "@app/tunnel": tunnel }); + const handle = await server.host.get(Tunnel, {}, context); + const socket = await socketTo(server.port, "/own", { "x-principal": "owner" }); + await handle.name(["t.example.com"], context); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(socket.readyState).toBe(socket.OPEN); + socket.close(); + await server.close(); + }); + + // Astra's security audit A1: a policy close left the peer able to invoke the plugin's handlers. + it("a raw peer whose host or contribution left can send nothing more to the handler", async () => { + const calls: string[] = []; + const sockets = contributing({ + sockets: { "/echo": (socket) => void socket.on("message", (data) => calls.push(String(data))) }, + }); + const tunnel = contributing({ hosts: ["t.example"] }); + const server = await served({ "@app/sockets": sockets, "@app/tunnel": tunnel }); + const throughHost = await rawSocket(server.port, "t.example", "/echo"); + const local = await rawSocket(server.port, `localhost:${server.port}`, "/echo"); + await server.host.reload(server.facets({ "@app/sockets": sockets }), context); + throughHost.write(frame("after the host left")); + await new Promise((resolve) => setTimeout(resolve, 50)); + await server.host.reload(server.facets({}), context); + local.write(frame("after the contribution left")); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(calls).toEqual([]); + throughHost.destroy(); + local.destroy(); + await server.close(); + }); + + // Astra's security audit A2: an upgrade authenticating across the removal of its host was still handed out. + it("checks an upgrade again after authenticating: one whose host left meanwhile is refused", async () => { + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + const sockets = contributing({ sockets: { "/echo": () => {} } }); + const server = await served( + { "@app/sockets": sockets, "@app/tunnel": contributing({ hosts: ["t.example"] }) }, + { + authenticate: async () => { + started.resolve(); + await gate.promise; + return { kind: "owner" }; + }, + }, + ); + const opening = socketTo(server.port, "/echo", { host: "t.example" }); + await started.promise; + await server.host.reload(server.facets({ "@app/sockets": sockets }), context); + gate.resolve(); + await expect(opening).rejects.toThrow("refused 403"); + await server.close(); + }); +}); + +describe("cancellation", () => { + // Astra's recheck R1, Sol's N5: a client gone while its upgrade authenticated left that work running. + // Fable's second recheck: only a reset (RST) was seen; a clean disconnect (FIN, a tab closing) was not. + it.each(["ends its connection", "resets its connection"] as const)( + "aborts an upgrade's authentication when its client %s", + async (how) => { + const entered = Promise.withResolvers(); + let signal: AbortSignal | undefined; + const server = await served( + { "@app/s": contributing({ sockets: { "/s": () => {} } }) }, + { + authenticate: async (_request, ctx) => { + signal = ctx.abortSignal; + entered.resolve(); + return new Promise(() => {}); + }, + }, + ); + const socket = connect(server.port, "127.0.0.1"); + socket.on("error", () => {}); + await once(socket, "connect"); + socket.write( + `GET /s HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`, + ); + await entered.promise; + const aborted = new Promise((resolve) => signal?.addEventListener("abort", resolve, { once: true })); + if (how === "ends its connection") socket.end(); + else socket.resetAndDestroy(); + await aborted; + expect(signal?.aborted).toBe(true); + socket.destroy(); + await server.close(); + }, + ); +}); + +describe("heartbeat", () => { + it("pings a plugin socket after 15 s of silence and terminates it 15 s later without an answer", async () => { + const server = await served({ + "@app/quiet": contributing({ + sockets: { "/quiet": (socket) => void socket.on("message", (data) => socket.send(data)) }, + }), + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const silent = new WebSocket(`ws://127.0.0.1:${server.port}/quiet`, { + headers: { "x-principal": "owner" }, + autoPong: false, + }); + const answering = new WebSocket(`ws://127.0.0.1:${server.port}/quiet`, { + headers: { "x-principal": "owner" }, + }); + await Promise.all([once(silent, "open"), once(answering, "open")]); + const pinged = Promise.all([once(silent, "ping"), once(answering, "ping")]); + await vi.advanceTimersByTimeAsync(15_000); + await pinged; + // The answering client's pong travels for real (only timers are fake): an echo after it shows it arrived. + const echoed = once(answering, "message"); + answering.send("after the pong"); + await echoed; + const closed = closeOf(silent); + await vi.advanceTimersByTimeAsync(15_000); + expect((await closed).code).toBe(1006); + expect(answering.readyState).toBe(answering.OPEN); + answering.close(); + } finally { + vi.useRealTimers(); + } + await server.close(); + }); +}); + +describe("close", () => { + // Sol's build review: close waited on a peer that never acknowledged its close frame. + it("terminates a socket that never acknowledges its close at closeTimeout", async () => { + const server = await served( + { "@app/s": contributing({ sockets: { "/s": () => {} } }) }, + { config: { closeTimeout: 20 } }, + ); + const raw = await rawSocket(server.port, `127.0.0.1:${server.port}`, "/s"); + raw.pause(); + const started = Date.now(); + await server.http.close(context); + expect(Date.now() - started).toBeLessThan(500); + raw.destroy(); + await server.close(); + }); +}); + +describe("GC", () => { + // Sol's build review: a `ws` server was kept per frame size for the component's life. + it("keeps no ws server once its socket closed", async () => { + const refs: WeakRef[] = []; + const original = WebSocketServer.prototype.handleUpgrade; + WebSocketServer.prototype.handleUpgrade = function (this: WebSocketServer, ...args: Parameters) { + refs.push(new WeakRef(this)); + return original.apply(this, args); + }; + let maxPayload = 1024; + const server = await served( + {}, + { config: { links: { path: "/chord", options: () => ({ maxPayload: maxPayload++ }) } } }, + ); + try { + for (let i = 0; i < 8; i++) { + const socket = await socketTo(server.port, "/chord", { "x-principal": "owner" }); + const closed = closeOf(socket); + socket.close(); + await closed; + } + await settledHeap(); + await expectCollected(...refs); + } finally { + WebSocketServer.prototype.handleUpgrade = original; + await server.close(); + } + }); + + it("keeps nothing of a closed plugin socket", async () => { + const refs: WeakRef[] = []; + const server = await served({ + "@app/kept": contributing({ + sockets: { + "/kept": (socket, request, ctx) => { + refs.push(new WeakRef(socket), new WeakRef(request), new WeakRef(ctx)); + }, + }, + }), + }); + const socket = await socketTo(server.port, "/kept", { "x-principal": "owner" }); + const closed = closeOf(socket); + socket.close(); + await closed; + await new Promise((resolve) => setTimeout(resolve, 50)); + await expectCollected(...refs); + await server.close(); + }); +}); + +/** A raw TCP client upgraded to the plugin socket `path` through `host` as the owner. */ +async function rawSocket(port: number, host: string, path: string): Promise { + const socket = connect(port, "127.0.0.1"); + await once(socket, "connect"); + socket.on("error", () => {}); + socket.write( + `GET ${path} HTTP/1.1\r\nHost: ${host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==\r\nSec-WebSocket-Version: 13\r\nx-principal: owner\r\n\r\n`, + ); + const [answer] = await once(socket, "data"); + expect(String(answer)).toContain("101 Switching Protocols"); + return socket; +} + +/** A masked client text frame. */ +function frame(text: string): Buffer { + const bytes = Buffer.from(text); + const mask = Buffer.from([1, 2, 3, 4]); + return Buffer.concat([ + Buffer.from([0x81, 0x80 | bytes.length]), + mask, + Buffer.from(bytes.map((byte, index) => byte ^ mask[index % 4]!)), + ]); +} diff --git a/packages/chord/test/next-node/plugins/build.test.ts b/packages/chord/test/next-node/plugins/build.test.ts index 623e85873..8525cfadf 100644 --- a/packages/chord/test/next-node/plugins/build.test.ts +++ b/packages/chord/test/next-node/plugins/build.test.ts @@ -222,7 +222,7 @@ describe("building a facet: the import table", () => { }); await writeFiles(root, { "base.ts": "export const base = 1;" }); await writePlugin(root, "copier", "@test/copier", { - "worker.ts": 'import { y } from "../shared/shared.ts";\nexport default { setup: () => y };', + "worker.ts": 'import { y } from "@test/shared/shared";\nexport default { setup: () => y };', }); await writePlugin(root, "shared", "@test/shared", { "shared.ts": 'import y from "some-package";\nexport { y };', @@ -231,7 +231,7 @@ describe("building a facet: the import table", () => { const out = plugins.find(({ name }) => name === "@test/out")!; expect(errors(await buildFacet(out, "worker", { ...NODE, plugins }, context))).toEqual([ 'worker.ts:1:22: "../base.ts": outside the plugin (base code is reached through a host package, packages through dependencies)', - 'worker.ts:2:15: "left-out": neither a host package nor in the dependencies of plugin "@test/out"', + 'worker.ts:2:15: "left-out": neither a host package, in the dependencies of plugin "@test/out" nor a discovered plugin "left-out"', ]); const copier = plugins.find(({ name }) => name === "@test/copier")!; expect(errors(await buildFacet(copier, "worker", { ...NODE, plugins }, context))).toEqual([ @@ -239,6 +239,43 @@ describe("building a facet: the import table", () => { ]); }); + it("imports another plugin's file by name from any root; refuses relative paths into it, names a missing plugin", async () => { + const first = await tempRoot(); + const second = await tempRoot(); + await writePlugin(second, "elsewhere", "@test/contracts", { + "contract.ts": 'export const name = "contract";', + "other.ts": 'import { name } from "./contract.ts";\nexport const other = name;', + }); + await writePlugin(first, "user", "@test/user", { + "worker.ts": [ + 'import { name } from "@test/contracts/contract";', + 'import { other } from "@test/contracts/other";', + "export default { setup: () => [name, other] };", + ].join("\n"), + }); + await writePlugin(first, "relative", "@test/relative", { + "worker.ts": 'import { name } from "../user/worker.ts";\nexport default { setup: () => name };', + }); + await writePlugin(first, "missing", "@test/missing", { + "worker.ts": 'import { name } from "@test/absent/contract";\nexport default { setup: () => name };', + }); + const { plugins } = await discoverPlugins([first, second], { kinds: KINDS }, context); + const build = (name: string) => + buildFacet(plugins.find((each) => each.name === name)!, "worker", { ...NODE, plugins }, context); + const user = await build("@test/user"); + if (user.kind === "failed") throw new Error(user.errors.join("\n")); + expect(user.copied.map((file) => file.slice(second.length + 1)).sort()).toEqual([ + "elsewhere/contract.ts", + "elsewhere/other.ts", + ]); + expect(errors(await build("@test/relative"))).toEqual([ + 'worker.ts:1:22: "../user/worker.ts": a relative path into plugin "@test/user" (import its files by name: "@test/user/…")', + ]); + expect(errors(await build("@test/missing"))).toEqual([ + 'worker.ts:1:22: "@test/absent/contract": neither a host package, in the dependencies of plugin "@test/missing" nor a discovered plugin "@test/absent"', + ]); + }); + it("returns a syntax error and a missing facet as failed results, never a throw", async () => { const root = await tempRoot(); await writePlugin(root, "syntax", "@test/syntax", { "worker.ts": "export default {" }); diff --git a/packages/chord/test/next-node/plugins/plugins.test.ts b/packages/chord/test/next-node/plugins/plugins.test.ts index b9c435d10..b202b3e82 100644 --- a/packages/chord/test/next-node/plugins/plugins.test.ts +++ b/packages/chord/test/next-node/plugins/plugins.test.ts @@ -12,7 +12,12 @@ import { createHost, defineFacet, type Host, type ReloadReport } from "../../../ import type { ChordHost } from "../../../src/next/host/internal.ts"; import { memoryLinkPair } from "../../../src/next/link/index.ts"; import { constant } from "../../../src/next/state/index.ts"; -import { createPlugins, type Plugins, type RolloutStep } from "../../../src/next-node/plugins/index.ts"; +import { + createPlugins, + type Plugins, + type RolloutHost, + type RolloutStep, +} from "../../../src/next-node/plugins/index.ts"; import { changesOf, delivered, @@ -22,7 +27,7 @@ import { transported, valuesOf, } from "../../next/support.ts"; -import { HOST_PACKAGES, KINDS, tempRoot, writeFiles, writePackage, writePlugin } from "./support.ts"; +import { HOST_PACKAGES, kindsOf, tempRoot, writeFiles, writePackage, writePlugin } from "./support.ts"; /** The fixtures' contracts, as the `contracts` fixture declares them. */ const Worker = hostKind("test.worker"); @@ -46,15 +51,20 @@ async function itemPlugin(root: string, dir: string, label: string): Promise reported.push(e), }, context, @@ -81,7 +91,11 @@ describe("per plugin at startup", () => { const { plugins } = component(root); const host = createHost({ onError: () => {} }); const core = defineFacet((h) => h.serve(Item, () => ({ label: constant("core") }))); - const report = await plugins.load(host, { kind: "worker", core: [{ name: "@app/core", facet: core }] }, context); + const report = await plugins.load( + host, + { kind: "worker", core: [{ name: "@app/core", facet: core }], modules: HOST_PACKAGES }, + context, + ); expect(report.failed.map(({ plugin, error }) => [plugin, (error as Error).message])).toEqual([ [ "@test/syntax", @@ -105,7 +119,7 @@ describe("per plugin at reload", () => { for (const dir of ["a", "b", "c"]) await itemPlugin(root, dir, `${dir}1`); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const label = async (dir: string) => (await host.get(local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }), {}, context)) .label.value; @@ -180,7 +194,7 @@ describe("per plugin at reload", () => { await writePlugin(root, "consumer", "@test/consumer", { "worker.ts": consumer(1) }); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const C = local("test.consumer", { version: 1, params: {}, members: { label: str() } }); const shown = await host.get(C, {}, context); await writeFiles(join(root, "provider"), { "worker.ts": `${provider(2)}\nexport const broken = ;` }); @@ -201,14 +215,19 @@ describe("per plugin at reload", () => { }); describe("rollout", () => { - /** A step recording its name into `order`, answering `report`, or rejecting with `error`. */ - const step = (name: string, order: string[], outcome: { report?: ReloadReport; error?: Error }): RolloutStep => ({ + /** A step recording its name into `order`, answering `report` for one host (or `hosts`), or rejecting with `error`. */ + const step = ( + name: string, + order: string[], + outcome: { report?: ReloadReport; hosts?: readonly RolloutHost[]; error?: Error }, + kinds: readonly string[] = ["worker"], + ): RolloutStep => ({ name, - kinds: ["worker"], + kinds, reload: async () => { order.push(name); if (outcome.error !== undefined) throw outcome.error; - return outcome.report!; + return { hosts: outcome.hosts ?? [{ host: name, report: outcome.report! }] }; }, }); const report = (failed: readonly string[]): ReloadReport => ({ @@ -244,6 +263,56 @@ describe("rollout", () => { plugins.close(); }); + it("counts a host's error as the step's built plugins failing there; a host stopped cleanly dirties nothing (D12)", async () => { + const root = await tempRoot(); + await itemPlugin(root, "a", "a1"); + await itemPlugin(root, "b", "b1"); + const { plugins } = component(root); + await plugins.build([], context); + await writeFile(join(root, "a", "worker.ts"), "// a\n"); + await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 }); + const stopped: RolloutHost[] = [{ host: "session|S1", stopped: true }]; + await plugins.rollout( + [step("workers", [], { hosts: stopped })], + await plugins.build(["@test/a"], context), + context, + ); + expect(plugins.state.value).toMatchObject({ dirty: [] }); + await writeFile(join(root, "a", "worker.ts"), "// a again\n"); + await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 }); + const crashed: RolloutHost[] = [ + { host: "session|S1", report: report([]) }, + { host: "session|S2", error: new Error("crashed") }, + ]; + const result = await plugins.rollout( + [step("workers", [], { hosts: crashed })], + await plugins.build(["@test/a"], context), + context, + ); + expect(result.steps).toEqual([{ name: "workers", hosts: crashed }]); + expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }); + plugins.close(); + }); + + it("keeps dirty a plugin whose built kind no step shipped", async () => { + const root = await tempRoot(); + await writePlugin(root, "a", "@test/a", { "worker.ts": "export default { setup: () => {} };", "server.ts": "" }); + const { plugins } = component(root); + await plugins.build([], context); + await writeFile(join(root, "a", "worker.ts"), "export default { setup: () => {} }; // edited\n"); + await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 }); + const built = await plugins.build(["@test/a"], context); + await plugins.rollout([step("worker", [], { report: report([]) })], built, context); + expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }); + await plugins.rollout( + [step("worker", [], { report: report([]) }), step("server", [], { report: report([]) }, ["server"])], + built, + context, + ); + expect(plugins.state.value).toMatchObject({ dirty: [] }); + plugins.close(); + }); + it("stops at a step that rejects, clearing no mark; a change during the build keeps its mark", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); @@ -275,6 +344,38 @@ describe("rollout", () => { }); }); +describe("current sets (slice 10 D11)", () => { + it("scripts(kind): built on first use; a build changes no set; a rollout applies it right before a step of its kind", async () => { + const root = await tempRoot(); + await itemPlugin(root, "a", "a1"); + await writePlugin(root, "b", "@test/b", { "server.ts": "export default { setup: () => {} };" }); + const { plugins } = component(root); + const digests = async () => + (await plugins.scripts("worker", context)).map(({ plugin, built }) => [ + plugin.name, + built.kind === "script" ? built.digest.slice(0, 8) : built.kind, + ]); + const first = await digests(); + expect(first.map(([name]) => name)).toEqual(["@test/a"]); + await itemPlugin(root, "a", "a2"); + const built = await plugins.build(["@test/a"], context); + expect(await digests()).toEqual(first); + const rejecting: RolloutStep = { + name: "server", + kinds: ["server"], + reload: async () => { + throw new Error("closed"); + }, + }; + const shipped: RolloutStep = { name: "workers", kinds: ["worker"], reload: async () => ({ hosts: [] }) }; + await plugins.rollout([rejecting, shipped], built, context); + expect(await digests()).toEqual(first); + await plugins.rollout([shipped], built, context); + expect(await digests()).not.toEqual(first); + plugins.close(); + }); +}); + describe("installs through the component", () => { it("clears the plugin's install mark and dirties it", async () => { const root = await tempRoot(); @@ -285,8 +386,7 @@ describe("installs through the component", () => { const plugins = createPlugins( { roots: [root], - kinds: KINDS, - hostPackages: HOST_PACKAGES, + kinds: kindsOf(HOST_PACKAGES), install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] }, onError: (error) => reported.push(error), }, @@ -321,12 +421,10 @@ describe("GC", () => { "export default defineFacet((host) => host.serve(Item, () => ({ label: constant(state.label) })));", ].join("\n"); await writePlugin(root, "gc", "@test/gc", { "worker.ts": source("a") }); - const plugins = createPlugins( - { roots: [root], kinds: KINDS, hostPackages: { ...HOST_PACKAGES, "@test/probe": probe }, onError: () => {} }, - context, - ); + const modules = { ...HOST_PACKAGES, "@test/probe": probe }; + const plugins = createPlugins({ roots: [root], kinds: kindsOf(modules), onError: () => {} }, context); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules }, context); const first = await plugins.build(["@test/gc"], context); await writeFiles(join(root, "gc"), { "worker.ts": source("b") }); const second = await plugins.build(["@test/gc"], context); @@ -358,8 +456,8 @@ describe("end to end: a server and a worker", () => { { authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) }, context, ); - await plugins.load(worker, { kind: "worker", core: [] }, context); - await plugins.load(server, { kind: "server", core: [] }, context); + await plugins.load(worker, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); + await plugins.load(server, { kind: "server", core: [], modules: HOST_PACKAGES }, context); const notes = await server.get(Notes, {}, context); const index = await server.get(Index, {}, context); expect([notes.label.value, index.label.value]).toEqual(["v1", "v1"]); @@ -370,7 +468,9 @@ describe("end to end: a server and a worker", () => { await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n'); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 }); const rolled = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context); - expect(rolled.steps.map(({ name, report }) => [name, report.plugins.changed, report.objects])).toEqual([ + expect( + rolled.steps.map(({ name, hosts }) => [name, reportOf(hosts).plugins.changed, reportOf(hosts).objects]), + ).toEqual([ ["server", ["@test/notes"], [{ object: "test.index", kind: "swapped" }]], ["worker", ["@test/notes"], [{ object: "test.notes", kind: "swapped" }]], ]); @@ -383,7 +483,7 @@ describe("end to end: a server and a worker", () => { await writeFile(join(root, "notes", "label.ts"), "export const label = ;\n"); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 }); const broken = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context); - expect(broken.steps.map(({ name, report }) => [name, failedNames(report)])).toEqual([ + expect(broken.steps.map(({ name, hosts }) => [name, failedNames(reportOf(hosts))])).toEqual([ ["server", ["@test/notes"]], ["worker", ["@test/notes"]], ]); @@ -403,7 +503,7 @@ describe("fix round 1", () => { await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await itemPlugin(root, "a", "a2"); const builtA = await plugins.build(["@test/a"], context); await itemPlugin(root, "b", "b2"); @@ -424,7 +524,7 @@ describe("fix round 1", () => { await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await itemPlugin(root, "a", "a2"); const first = await plugins.build(["@test/a"], context); await rm(join(root, "b"), { recursive: true }); @@ -444,7 +544,7 @@ describe("fix round 1", () => { await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const Item = local("test.a", { version: 1, params: {}, members: { label: str() } }); const item = await host.get(Item, {}, context); await itemPlugin(root, "a", "a2"); @@ -476,7 +576,7 @@ describe("fix round 1", () => { await itemPlugin(root, "a", "a1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); // The plugin keeps a server facet only. await rm(join(root, "a", "worker.ts")); await writeFiles(join(root, "a"), { @@ -500,7 +600,7 @@ describe("fix round 1", () => { await itemPlugin(root, "a", "a1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const item = await host.get(local("test.a", { version: 1, params: {}, members: { label: str() } }), {}, context); await itemPlugin(root, "a", "a2"); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 }); @@ -526,8 +626,7 @@ describe("fix round 1", () => { const plugins = createPlugins( { roots: [root], - kinds: KINDS, - hostPackages: HOST_PACKAGES, + kinds: kindsOf(HOST_PACKAGES), install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] }, onError: () => {}, }, @@ -567,12 +666,10 @@ describe("the reload queues and garbage collection", () => { "});", ].join("\n"); const dir = await writePlugin(root, "failed", "@test/failed", { "worker.ts": source(false) }); - const plugins = createPlugins( - { roots: [root], kinds: KINDS, hostPackages: { ...HOST_PACKAGES, "@test/probe": probe }, onError: () => {} }, - context, - ); + const modules = { ...HOST_PACKAGES, "@test/probe": probe }; + const plugins = createPlugins({ roots: [root], kinds: kindsOf(modules), onError: () => {} }, context); const host = createHost({ onError: () => {} }); - await plugins.load(host, { kind: "worker", core: [] }, context); + await plugins.load(host, { kind: "worker", core: [], modules }, context); await writeFiles(dir, { "worker.ts": source(true) }); const built = await plugins.build(["@test/failed"], context); // The report (its failure's error holds the new facet's closure) is dropped here; the queues must not keep it. diff --git a/packages/chord/test/next-node/plugins/runtime.test.ts b/packages/chord/test/next-node/plugins/runtime.test.ts new file mode 100644 index 000000000..52b850d10 --- /dev/null +++ b/packages/chord/test/next-node/plugins/runtime.test.ts @@ -0,0 +1,126 @@ +// Tests of the plugin runtime (slice 10 §3, D11): loading and reloading hosts with complete sets of shipped scripts +// (unchanged digests kept as the same facet, removed = loaded minus shipped, failed builds and evaluations kept old), +// and that importing it loads no esbuild. + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { local, str } from "../../../src/next/contracts/index.ts"; +import { createHost } from "../../../src/next/host/index.ts"; +import { + buildFacet, + createPluginRuntime, + discoverPlugins, + type PluginScript, +} from "../../../src/next-node/plugins/index.ts"; +import { HOST_PACKAGES, tempRoot, writePlugin } from "./support.ts"; + +/** Writes plugin `@test/` serving `test.` labelled `label` from its worker facet (or `source` as it is). */ +async function plugin(root: string, dir: string, label: string, source?: string): Promise { + await writePlugin(root, dir, `@test/${dir}`, { + "worker.ts": + source ?? + [ + 'import { constant, defineFacet, local, str } from "@earendil-works/chord/next";', + `const Item = local("test.${dir}", { version: 1, params: {}, members: { label: str() } });`, + `export default defineFacet((host) => host.serve(Item, () => ({ label: constant(${JSON.stringify(label)}) })));`, + ].join("\n"), + }); +} + +/** The worker scripts of every plugin under `root`, built now: what the server would ship. */ +async function shipped(root: string): Promise { + const { plugins } = await discoverPlugins([root], { kinds: ["worker"] }, context); + const options = { hostPackages: Object.keys(HOST_PACKAGES), platform: "node", plugins } as const; + return Promise.all( + plugins.map(async (each) => ({ plugin: each, built: await buildFacet(each, "worker", options, context) })), + ); +} + +/** The label `test.` shows on `host`. */ +async function label(host: ReturnType, dir: string): Promise { + const Item = local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }); + return (await host.get(Item, {}, context)).label.value; +} + +describe("loading and reloading with complete sets", () => { + it("loads a set and leaves out a plugin whose build or evaluation failed, reported with the host's report", async () => { + const root = await tempRoot(); + await plugin(root, "a", "a1"); + await plugin(root, "broken", "", "export const = ;"); + await plugin(root, "throws", "", 'throw new Error("at import");'); + const runtime = createPluginRuntime(); + const host = createHost({ onError: () => {} }); + const report = await runtime.load( + host, + { kind: "worker", core: [], scripts: await shipped(root), modules: HOST_PACKAGES }, + context, + ); + expect(report.failed.map(({ plugin }) => plugin).sort()).toEqual(["@test/broken", "@test/throws"]); + expect(await label(host, "a")).toBe("a1"); + await host.close(context); + }); + + it("reloads a complete set: same digests unchanged, a changed one applied, a loaded one not shipped removed", async () => { + const root = await tempRoot(); + await plugin(root, "a", "a1"); + await plugin(root, "b", "b1"); + await plugin(root, "c", "c1"); + const runtime = createPluginRuntime(); + const host = createHost({ onError: () => {} }); + await runtime.load( + host, + { kind: "worker", core: [], scripts: await shipped(root), modules: HOST_PACKAGES }, + context, + ); + await plugin(root, "b", "b2"); + const next = (await shipped(root)).filter(({ plugin }) => plugin.name !== "@test/c"); + const report = await runtime.reload(host, next, context); + expect(report.plugins).toEqual({ added: [], changed: ["@test/b"], removed: ["@test/c"] }); + expect([await label(host, "a"), await label(host, "b")]).toEqual(["a1", "b2"]); + await host.close(context); + }); + + it("keeps the loaded facet of a plugin whose new build or evaluation failed; an added failing one is left out", async () => { + const root = await tempRoot(); + await plugin(root, "a", "a1"); + const runtime = createPluginRuntime(); + const host = createHost({ onError: () => {} }); + await runtime.load( + host, + { kind: "worker", core: [], scripts: await shipped(root), modules: HOST_PACKAGES }, + context, + ); + await plugin(root, "a", "", "export const = ;"); + await plugin(root, "new", "", 'throw new Error("at import");'); + const report = await runtime.reload(host, await shipped(root), context); + expect(report.failed.map(({ plugin }) => plugin).sort()).toEqual(["@test/a", "@test/new"]); + expect(report.plugins).toEqual({ added: [], changed: [], removed: [] }); + expect(await label(host, "a")).toBe("a1"); + await host.close(context); + }); +}); + +describe("the runtime's imports", () => { + it("loads no esbuild: a worker that only evaluates never imports it", () => { + const runtime = fileURLToPath(new URL("../../../src/next-node/plugins/runtime.ts", import.meta.url)); + // A hook refusing esbuild, then the runtime imported. + const script = [ + 'import { registerHooks } from "node:module";', + "registerHooks({ resolve(specifier, context, next) {", + ' if (specifier === "esbuild") throw new Error("esbuild imported");', + " return next(specifier, context);", + "} });", + `const { createPluginRuntime } = await import(${JSON.stringify(runtime)});`, + "createPluginRuntime();", + 'console.log("ok");', + ].join("\n"); + const output = execFileSync( + process.execPath, + ["--experimental-strip-types", "--no-warnings", "--input-type=module", "--eval", script], + { encoding: "utf8" }, + ); + expect(output.trim()).toBe("ok"); + }); +}); diff --git a/packages/chord/test/next-node/plugins/support.ts b/packages/chord/test/next-node/plugins/support.ts index 86e444156..f4c1de6fc 100644 --- a/packages/chord/test/next-node/plugins/support.ts +++ b/packages/chord/test/next-node/plugins/support.ts @@ -7,7 +7,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach } from "vitest"; import * as chordNext from "../../../src/next/index.ts"; -import type { HostPackages } from "../../../src/next-node/plugins/index.ts"; +import type { HostPackages, KindDefinition } from "../../../src/next-node/plugins/index.ts"; /** The fixture plugins' directory. */ export const FIXTURES = fileURLToPath(new URL("../fixtures/plugins/", import.meta.url)); @@ -18,6 +18,16 @@ export const HOST_PACKAGES: HostPackages = { "@earendil-works/chord/next": chord /** The facet kinds of the fixtures' app. */ export const KINDS = ["server", "worker", "web"] as const; +/** The fixtures' app's kinds as the component takes them: Node servers and workers, a browser web, `modules`' names. */ +export function kindsOf(modules: HostPackages): Readonly> { + const hostPackages = Object.keys(modules); + return { + server: { platform: "node", hostPackages }, + worker: { platform: "node", hostPackages }, + web: { platform: "browser", hostPackages }, + }; +} + const made: string[] = []; afterEach(async () => { diff --git a/packages/chord/test/next-node/workers/control.test.ts b/packages/chord/test/next-node/workers/control.test.ts new file mode 100644 index 000000000..a7054269a --- /dev/null +++ b/packages/chord/test/next-node/workers/control.test.ts @@ -0,0 +1,55 @@ +// Tests of the control channel's crossings (slice 10 §3): errors cross as `{ message, code?, reason? }` and back, a +// reload report with its errors and parked causes. + +import { describe, expect, it } from "vitest"; +import { ChordError, isChordError } from "../../../src/next/data/index.ts"; +import { + controlOf, + crossed, + crossedReport, + envelope, + uncrossed, + uncrossedReport, +} from "../../../src/next-node/workers/control.ts"; + +describe("errors", () => { + it("cross as message, code and reason, never a stack or cause; come back as ChordErrors when they had a code", () => { + const chord = new ChordError({ code: "unavailable", message: "gone" }, { cause: new Error("inner") }); + expect(crossed(chord)).toEqual({ message: "gone", code: "unavailable" }); + expect(crossed(new Error("plain"))).toEqual({ message: "plain" }); + expect(crossed("text")).toEqual({ message: "text" }); + const back = uncrossed({ message: "gone", code: "released", reason: "closed" }); + expect(isChordError(back, "released") && back.reason).toBe("closed"); + expect(uncrossed({ message: "odd", code: "no-such-code" })).toMatchObject({ message: "odd" }); + }); + + it("crosses a report's failures and parked causes and back", () => { + const report = crossedReport({ + plugins: { added: ["@a/x"], changed: [], removed: [] }, + failed: [{ plugin: "@a/y", error: new Error("broken") }], + objects: [ + { object: "a.one", kind: "swapped" }, + { + object: "a.two", + kind: "parked", + cause: { kind: "waiting", error: new ChordError({ code: "unavailable", message: "w" }) }, + }, + ], + }); + expect(JSON.parse(JSON.stringify(report))).toEqual(report); + const back = uncrossedReport(report); + expect(back.failed[0]!.error).toBeInstanceOf(Error); + expect(back.objects[1]).toMatchObject({ + kind: "parked", + cause: { kind: "waiting", error: { code: "unavailable" } }, + }); + }); +}); + +describe("envelopes", () => { + it("tells control messages from anything else on the channel", () => { + expect(controlOf(envelope({ kind: "started" }))).toEqual({ kind: "started" }); + expect(controlOf(new Uint8Array(1))).toBeUndefined(); + expect(controlOf({ kind: "started" })).toBeUndefined(); + }); +}); diff --git a/packages/chord/test/next-node/workers/lock.test.ts b/packages/chord/test/next-node/workers/lock.test.ts new file mode 100644 index 000000000..de1564720 --- /dev/null +++ b/packages/chord/test/next-node/workers/lock.test.ts @@ -0,0 +1,62 @@ +// Tests of process locks (slice 10 §3): one holder at a time, a waiter taking the lock when the holder releases it +// (told by its connection's close, no polling), a waiter's abort. + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context, withAbortSignal } from "../../../src/context/index.ts"; +import { processLock } from "../../../src/next-node/workers/index.ts"; +import { lockAt } from "../../../src/next-node/workers/lock.ts"; + +describe("processLock", () => { + it("makes a second taker wait until the holder releases, then takes it", async () => { + const name = `chord-test-lock-${process.pid}-${Date.now()}`; + const first = await processLock(name, context); + let taken = false; + const second = processLock(name, context).then((lock) => { + taken = true; + return lock; + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(taken).toBe(false); + const released = Date.now(); + await first.release(); + await (await second).release(); + expect(taken).toBe(true); + expect(Date.now() - released).toBeLessThan(100); + }); + + it("stops waiting when the waiter's context aborts", async () => { + const name = `chord-test-lock-abort-${process.pid}-${Date.now()}`; + const held = await processLock(name, context); + const abort = new AbortController(); + const waiting = processLock(name, withAbortSignal(abort.signal, context)); + abort.abort(new Error("gave up")); + await expect(waiting).rejects.toThrow("gave up"); + await held.release(); + await (await processLock(name, context)).release(); + }); +}); + +describe("socket file locks (macOS, the BSDs)", () => { + it("fail closed on a stale lock file, naming it: two takers never both take it over (Sol)", async () => { + const dir = await mkdtemp(join(tmpdir(), "chord-lock-file-")); + const address = join(dir, "lock.sock"); + // What a crashed holder leaves: a path nobody accepts on. + await writeFile(address, ""); + const takers = [lockAt({ kind: "file", address }, context), lockAt({ kind: "file", address }, context)]; + for (const taker of takers) await expect(taker).rejects.toThrow(`lock: ${address} is a stale lock file`); + await rm(dir, { recursive: true, force: true }); + }); + + it("are taken, waited for and released like the others", async () => { + const dir = await mkdtemp(join(tmpdir(), "chord-lock-file-")); + const address = join(dir, "lock.sock"); + const first = await lockAt({ kind: "file", address }, context); + const second = lockAt({ kind: "file", address }, context); + await first.release(); + await (await second).release(); + await rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/chord/test/next-node/workers/main.test.ts b/packages/chord/test/next-node/workers/main.test.ts new file mode 100644 index 000000000..017c9df04 --- /dev/null +++ b/packages/chord/test/next-node/workers/main.test.ts @@ -0,0 +1,87 @@ +// Tests of the worker's side (slice 10 §3): the start message, the lock, `started` on `connect`, `ready`; shutdown +// during startup, the parent's disconnect and SIGTERM each run the closers and exit; `fatal` redacted. + +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { hostKind, str } from "../../../src/next/contracts/index.ts"; +import type { FromWorker } from "../../../src/next-node/workers/control.ts"; +import { type Exit, WorkerProcess } from "../../../src/next-node/workers/process.ts"; +import type { WorkerBehaviour } from "../fixtures/workers/control.ts"; +import { until } from "./support.ts"; + +const ENTRY = fileURLToPath(new URL("../fixtures/workers/worker.ts", import.meta.url)); + +const Session = hostKind("session", { id: str() }); + +/** The fixture worker forked and started with `behaviour`; its control messages and exit collected. */ +function started(behaviour: WorkerBehaviour) { + const control: FromWorker[] = []; + let exit: Exit | undefined; + const child = new WorkerProcess( + { + entry: ENTRY, + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + onOutput: () => {}, + }, + { + control: (message) => control.push(message), + linkLost: () => {}, + linkClosed: () => {}, + exited: (value) => { + exit = value; + }, + }, + ); + child.send({ + kind: "start", + address: Session({ id: "S1" }), + start: behaviour, + scripts: [], + lock: `chord-test-main:${process.pid}:${Date.now()}:${Math.random()}`, + }); + return { child, control, exited: () => exit }; +} + +describe("the worker's side", () => { + it("sends started when the app connects, then ready with its load report and idle state", async () => { + const { child, control } = started({}); + await until(() => control.some(({ kind }) => kind === "ready")); + expect(control.map(({ kind }) => kind)).toEqual(["started", "ready"]); + expect(control[1]).toMatchObject({ idle: true, report: { plugins: { added: [] }, failed: [] } }); + child.kill(); + }); + + it("shuts down during its startup: aborts it, runs the closers registered so far in reverse order, exits 0", async () => { + const dir = await mkdtemp(join(tmpdir(), "chord-main-")); + const { child, control, exited } = started({ hang: true, log: join(dir, "log") }); + await until(() => control.some(({ kind }) => kind === "started")); + child.send({ kind: "shutdown" }); + await until(() => exited() !== undefined); + expect(exited()).toEqual({ code: 0, signal: null }); + expect(await readFile(join(dir, "log"), "utf8")).toBe("store\nhost\n"); + await rm(dir, { recursive: true, force: true }); + }); + + it("shuts down on SIGTERM and on the parent's disconnect", async () => { + const term = started({}); + await until(() => term.control.some(({ kind }) => kind === "ready")); + process.kill(term.child.pid, "SIGTERM"); + await until(() => term.exited() !== undefined); + expect(term.exited()).toEqual({ code: 0, signal: null }); + const gone = started({}); + await until(() => gone.control.some(({ kind }) => kind === "ready")); + gone.child.link.close(); + await until(() => gone.exited() !== undefined); + expect(gone.exited()).toEqual({ code: 0, signal: null }); + }); + + it("sends fatal with the redacted error and exits 1", async () => { + const { control, exited } = started({ fatal: "no secret-key-42 here" }); + await until(() => exited() !== undefined); + expect(control).toEqual([{ kind: "fatal", error: { message: "no [redacted] here" } }]); + expect(exited()?.code).toBe(1); + }); +}); diff --git a/packages/chord/test/next-node/workers/process.test.ts b/packages/chord/test/next-node/workers/process.test.ts new file mode 100644 index 000000000..b84589f6e --- /dev/null +++ b/packages/chord/test/next-node/workers/process.test.ts @@ -0,0 +1,46 @@ +// Tests of worker processes (slice 10 §3): forked with their output piped (the stderr tail kept), the link made at the +// fork (an early frame queued), control messages told, and the whole tree killed. + +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import type { FromWorker } from "../../../src/next-node/workers/control.ts"; +import { WorkerProcess } from "../../../src/next-node/workers/process.ts"; +import { until } from "./support.ts"; + +const CHILD = fileURLToPath(new URL("../fixtures/workers/child.ts", import.meta.url)); + +/** Whether process `pid` lives. */ +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +describe("a worker process", () => { + it("pipes its output, keeps its stderr tail, queues an early frame on its link, tells control messages", async () => { + const output: string[] = []; + const control: FromWorker[] = []; + const child = new WorkerProcess( + { + entry: CHILD, + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + onOutput: (stream, chunk) => output.push(`${stream}: ${Buffer.from(chunk).toString().trim()}`), + }, + { control: (message) => control.push(message), linkLost: () => {}, linkClosed: () => {}, exited: () => {} }, + ); + await until(() => control.length > 0 && output.length >= 2); + const frames: number[][] = []; + child.link.onMessage((message) => frames.push([...message])); + expect(frames).toEqual([[1, 2, 3]]); + expect(control).toEqual([{ kind: "started" }]); + expect(child.stderrTail).toBe("an error line\n"); + const grandchild = Number(/grandchild (\d+)/.exec(output.join("\n"))![1]); + expect(alive(grandchild)).toBe(true); + child.kill(); + expect(await child.exited).toEqual({ code: null, signal: "SIGKILL" }); + await until(() => !alive(grandchild), 2_000); + }); +}); diff --git a/packages/chord/test/next-node/workers/support.ts b/packages/chord/test/next-node/workers/support.ts new file mode 100644 index 000000000..4931905d5 --- /dev/null +++ b/packages/chord/test/next-node/workers/support.ts @@ -0,0 +1,151 @@ +// Test support for the workers component (slice 10 §3, §4): a server routing `Session` to forked fixture workers, the +// fixture plugins copied into a temporary root, each address's start data (its behaviour) chosen by the test. + +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach } from "vitest"; +import { type Context, BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import type { JsonValue } from "../../../src/json.ts"; +import { hostKind, remote, str } from "../../../src/next/contracts/index.ts"; +import { createHost, type Host, type HostError } from "../../../src/next/host/index.ts"; +import * as chordNext from "../../../src/next/index.ts"; +import { createPlugins, type Plugins } from "../../../src/next-node/plugins/index.ts"; +import { createWorkers, type Workers, type WorkersError } from "../../../src/next-node/workers/index.ts"; +import { Session, type WorkerBehaviour } from "../fixtures/workers/control.ts"; + +/** The server's own kind. */ +export const Server = hostKind("server"); + +/** The fixture plugin's notes at a worker. */ +export const Notes = remote("test.notes", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Session({ id }), + variants: { main: { state: { label: str() } } }, +}); + +/** The principals of these tests. */ +export type TestPrincipal = + | { readonly kind: "server" } + | { readonly kind: "worker"; readonly id: string } + | { readonly kind: "guest"; readonly id: string }; + +const ENTRY = fileURLToPath(new URL("../fixtures/workers/worker.ts", import.meta.url)); + +const PLUGINS = fileURLToPath(new URL("../fixtures/workers/plugins/", import.meta.url)); + +const made: { readonly root: string; readonly close: () => Promise }[] = []; + +afterEach(async () => { + for (const { root, close } of made.splice(0)) { + await close(); + await rm(root, { recursive: true, force: true }); + } +}); + +/** A server with workers; closed after the test. */ +export type Fleet = { + readonly server: Host; + readonly workers: Workers; + readonly plugins: Plugins; + /** The copy of the fixture plugins. */ + readonly root: string; + readonly errors: WorkersError[]; + readonly hostErrors: HostError[]; + /** The addresses `start` ran for, in order. */ + readonly starts: string[]; + /** The workers' stdout lines, each with its address key. */ + readonly output: string[]; + /** Each address's behaviour (its start data); none: a plain worker. */ + readonly behaviours: Map; + /** Writes `label` into the copy's notes plugin. */ + label(label: string): Promise; +}; + +/** Starts a server routing `Session` to fixture workers; `options` override the workers' defaults. */ +export async function fleet( + options: { + readonly idleMs?: number; + readonly heartbeat?: { readonly ping?: number; readonly close?: number }; + readonly start?: (address: { readonly key: string }, context: Context) => Promise; + readonly principal?: () => TestPrincipal; + readonly authorize?: () => boolean; + } = {}, +): Promise { + const root = await mkdtemp(join(tmpdir(), "chord-workers-")); + await cp(PLUGINS, root, { recursive: true }); + const errors: WorkersError[] = []; + const hostErrors: HostError[] = []; + const starts: string[] = []; + const behaviours = new Map(); + const output: string[] = []; + const modules = { "@earendil-works/chord/next": chordNext }; + const plugins = createPlugins( + { roots: [root], kinds: { worker: { platform: "node", hostPackages: Object.keys(modules) } }, onError: () => {} }, + context, + ); + const server = createHost({ + address: Server(), + principal: { kind: "server" }, + onError: (error) => hostErrors.push(error), + ...(options.heartbeat === undefined ? {} : { heartbeat: options.heartbeat }), + }); + const workers = createWorkers( + server, + Session, + { + kind: "worker", + entry: ENTRY, + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + authorize: options.authorize ?? (() => true), + principal: options.principal ?? ((address) => ({ kind: "worker", id: address.params.id }) as const), + start: async (address, startContext) => { + starts.push(address.key); + if (options.start !== undefined) return options.start(address, startContext); + return (behaviours.get(address.params.id) ?? {}) as JsonValue; + }, + lock: (address) => `chord-test:${root}:${address.key}`, + idleMs: () => options.idleMs ?? 60_000, + plugins, + onOutput: (address, stream, chunk) => { + if (stream === "stdout") output.push(`${address.key} ${Buffer.from(chunk).toString().trim()}`); + }, + onError: (error) => errors.push(error), + }, + context, + ); + await server.load([], context); + // The worker kind's set made now (discovery, builds): the watcher marks edits from then on. + await plugins.scripts("worker", context); + const close = async () => { + await workers.close(context); + await server.close(context); + plugins.close(); + }; + made.push({ root, close }); + return { + server, + workers, + plugins, + root, + errors, + hostErrors, + starts, + output, + behaviours, + label: (label) => writeFile(join(root, "notes", "label.ts"), `export const label = ${JSON.stringify(label)};\n`), + }; +} + +/** Waits until `ready()` holds, polling every 10 ms, for at most `ms`. */ +export async function until(ready: () => boolean, ms = 10_000): Promise { + const deadline = Date.now() + ms; + while (!ready()) { + if (Date.now() > deadline) throw new Error("until: timed out"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +export { context, Session }; diff --git a/packages/chord/test/next-node/workers/workers.test.ts b/packages/chord/test/next-node/workers/workers.test.ts new file mode 100644 index 000000000..d7c980784 --- /dev/null +++ b/packages/chord/test/next-node/workers/workers.test.ts @@ -0,0 +1,605 @@ +// Tests of the workers component (slice 10 §3, §4): starts on demand (the link made at the fork, `started` on +// `connect`, the start timer covering only the start), failures that stay (a crash, a `fatal`, a lost link, a failed +// start: no fork for a new hold, holders `failed { waiting }`) until `retry` or a rollout, idle stops and `busy`, `stop` +// and its suspension, the per-address queue, `close`, the rollout step, and nothing kept of 1,000 addresses. + +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withAbortSignal } from "../../../src/context/index.ts"; +import { createHost } from "../../../src/next/host/index.ts"; +import { memoryLinkPair } from "../../../src/next/link/index.ts"; +import type { RolloutHost } from "../../../src/next-node/plugins/index.ts"; +import { expectCollected } from "../../next/support.ts"; +import { Control } from "../fixtures/workers/control.ts"; +import { context, fleet, Notes, Session, type TestPrincipal, until } from "./support.ts"; + +/** A step's hosts as `[host, what]`. */ +function listed(hosts: readonly RolloutHost[]) { + return hosts.map((entry) => [ + entry.host, + "report" in entry ? entry.report.plugins : "stopped" in entry ? "stopped" : (entry.error as Error).message, + ]); +} + +/** The state entry of `id`. */ +const stateOf = (state: unknown, id: string) => + (state as Record)[`session|${id}`]; + +describe("starts on demand", () => { + it("starts a worker for the first hold: starting, then running; its link binds the holder", async () => { + const { server, workers, starts } = await fleet(); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1") !== undefined); + expect(stateOf(workers.state.value, "S1")?.status).toBe("starting"); + await until(() => notes.state.value.kind === "live"); + expect(notes.label.value).toBe("S1 v1"); + await until(() => stateOf(workers.state.value, "S1")?.status === "running"); + expect(starts).toEqual(["session|S1"]); + notes.release(); + }); + + it("binds through a load longer than the heartbeat: the link is made at the fork, the worker connects first", async () => { + const { server, behaviours } = await fleet({ heartbeat: { ping: 50, close: 200 } }); + behaviours.set("S1", { loadMs: 600 }); + const notes = await server.get(Notes, { id: "S1" }, context); + expect(notes.label.value).toBe("S1 v1"); + notes.release(); + }); +}); + +describe("failures stay until retry or a rollout (R2)", () => { + it("a start that throws fails the address: holders fail waiting with it, a new hold forks nothing; retry starts it", async () => { + let fail = true; + const { server, workers, errors, starts } = await fleet({ + start: async () => { + if (fail) throw new Error("no data dir"); + return {}; + }, + }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => notes.state.value.kind === "failed"); + expect(notes.state.value).toMatchObject({ + kind: "failed", + cause: { kind: "waiting", error: { code: "unavailable", message: "worker failed to start" } }, + }); + expect(stateOf(workers.state.value, "S1")).toMatchObject({ + status: "failed", + error: { message: "worker failed to start" }, + }); + const again = server.hold(Notes, { id: "S1" }, context); + await until(() => again.state.value.kind === "failed"); + expect(starts).toEqual(["session|S1"]); + // The full error only for the app's logs (decision "Slice 10 fix round, worker errors in public"). + expect(errors).toMatchObject([ + { kind: "worker_failed", address: "session|S1", error: { message: "no data dir" } }, + ]); + fail = false; + await workers.retry(Session({ id: "S1" }), context); + await until(() => notes.state.value.kind === "live" && again.state.value.kind === "live"); + expect(stateOf(workers.state.value, "S1")?.status).not.toBe("failed"); + notes.release(); + again.release(); + }); + + it("a crash after ready stays failed (worker_exited, worker_failed), nothing restarts", async () => { + const { server, workers, errors, starts } = await fleet(); + const notes = await server.get(Notes, { id: "S1" }, context); + const control = await server.get(Control, { id: "S1" }, context); + await control.crash(7, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed"); + expect(stateOf(workers.state.value, "S1")?.error?.message).toBe("worker exited (code 7)"); + expect(errors.map(({ kind }) => kind)).toEqual(["worker_exited", "worker_failed"]); + await until(() => notes.state.value.kind === "failed"); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(starts).toEqual(["session|S1"]); + notes.release(); + control.release(); + }); + + it("a fatal crosses redacted: the secret never reaches the public state", async () => { + const { server, workers, errors, behaviours } = await fleet(); + behaviours.set("S1", { fatal: "boom secret-token-123" }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed"); + expect(stateOf(workers.state.value, "S1")?.error?.message).toBe("boom [redacted]"); + // Public: the state; the report's stderr tail is the app's to redact (a later test). + expect(JSON.stringify(workers.state.value)).not.toContain("secret"); + expect(errors.find(({ kind }) => kind === "worker_failed")).toMatchObject({ + error: { message: "boom [redacted]" }, + }); + notes.release(); + }); + + it("a link lost while its child lives (a blocked event loop past the heartbeat): killed after the grace, failed", async () => { + const { server, workers, starts, errors } = await fleet({ heartbeat: { ping: 100, close: 300 } }); + const notes = await server.get(Notes, { id: "S1" }, context); + const control = await server.get(Control, { id: "S1" }, context); + await control.block(20_000, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "stopping", 5_000); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed", 10_000); + expect(stateOf(workers.state.value, "S1")?.error?.message).toBe("worker exited (SIGKILL)"); + expect(errors.find(({ kind }) => kind === "worker_failed")).toMatchObject({ + error: { message: "worker: link lost" }, + }); + expect(starts).toEqual(["session|S1"]); + notes.release(); + control.release(); + }, 20_000); + + it("an exit before started fails the start; a worker that never connects is killed at the start timer", async () => { + const { server, workers, behaviours, errors } = await fleet(); + behaviours.set("S1", { exit: 3 }); + const exited = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed"); + expect(stateOf(workers.state.value, "S1")?.error?.message).toBe("worker failed to start"); + expect(errors.find(({ kind }) => kind === "worker_failed")).toMatchObject({ + error: { message: expect.stringContaining("exited before it was ready (code 3)") }, + }); + exited.release(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + behaviours.set("S2", { connect: false }); + const silent = server.hold(Notes, { id: "S2" }, context); + await vi.waitFor(() => expect(stateOf(workers.state.value, "S2")?.status).toBe("starting"), { + timeout: 5_000, + }); + await vi.advanceTimersByTimeAsync(60_000); + await vi.waitFor(() => expect(stateOf(workers.state.value, "S2")?.status).toBe("failed"), { timeout: 5_000 }); + expect(stateOf(workers.state.value, "S2")?.error?.message).toBe("worker failed to start"); + expect(errors.findLast(({ kind }) => kind === "worker_failed")).toMatchObject({ + error: { message: expect.stringContaining("did not start within 60 s") }, + }); + silent.release(); + } finally { + vi.useRealTimers(); + } + }); + + it("the start timer covers only the start: a slow load after connecting is not killed (Q4)", async () => { + const { server, workers, behaviours } = await fleet(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + behaviours.set("S1", { hang: true }); + const notes = server.hold(Notes, { id: "S1" }, context); + await vi.waitFor(() => expect(stateOf(workers.state.value, "S1")?.status).toBe("starting"), { + timeout: 5_000, + }); + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(120_000); + expect(stateOf(workers.state.value, "S1")?.status).toBe("starting"); + expect(notes.state.value.kind).toBe("loading"); + notes.release(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("keep-alive (D9, R3, Q5)", () => { + it("stops a worker idle since idleMs; a hold keeps one running; busy keeps it too", async () => { + const { server, workers } = await fleet({ idleMs: 200 }); + const held = await server.get(Notes, { id: "held" }, context); + const released = await server.get(Notes, { id: "free" }, context); + const control = await server.get(Control, { id: "busy" }, context); + await control.busy(1_500, context); + control.release(); + released.release(); + await until(() => stateOf(workers.state.value, "free") === undefined, 5_000); + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(stateOf(workers.state.value, "held")?.status).toBe("running"); + expect(stateOf(workers.state.value, "busy")?.status).toBe("running"); + await until(() => stateOf(workers.state.value, "busy") === undefined, 5_000); + held.release(); + }, 20_000); + + it("1,000 sequential local calls in a worker nobody holds send no message (Sol)", async () => { + const { server, behaviours, output } = await fleet(); + behaviours.set("S1", { localCalls: 1_000 }); + // Released at once: the calls run 300 ms after the load, with no hold arriving (no reference keeps it busy). + (await server.get(Notes, { id: "S1" }, context)).release(); + await until(() => output.some((line) => line.startsWith("session|S1 local calls sent"))); + expect(output).toContain("session|S1 local calls sent 0"); + }); +}); + +describe("stop", () => { + it("suspends while held: holders fail waiting suspended, nothing starts; forgotten with the last holder (Q3)", async () => { + const { server, workers, starts } = await fleet(); + const notes = await server.get(Notes, { id: "S1" }, context); + await workers.stop(Session({ id: "S1" }), context); + expect(stateOf(workers.state.value, "S1")).toBeUndefined(); + await until(() => notes.state.value.kind === "failed"); + expect(notes.state.value).toMatchObject({ + cause: { kind: "waiting", error: { message: "session|S1: suspended" } }, + }); + expect(starts).toEqual(["session|S1"]); + notes.release(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const again = await server.get(Notes, { id: "S1" }, context); + expect(starts).toEqual(["session|S1", "session|S1"]); + again.release(); + }); + + it("a stop during a start waits for it, then stops; a start during a stop waits for its exit", async () => { + const { server, workers, behaviours, starts } = await fleet(); + behaviours.set("S1", { loadMs: 300 }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1") !== undefined); + await workers.stop(Session({ id: "S1" }), context); + expect(stateOf(workers.state.value, "S1")).toBeUndefined(); + notes.release(); + await new Promise((resolve) => setTimeout(resolve, 20)); + behaviours.set("S1", { log: undefined }); + const again = await server.get(Notes, { id: "S1" }, context); + const stopping = workers.stop(Session({ id: "S1" }), context); + again.release(); + await stopping; + const third = await server.get(Notes, { id: "S1" }, context); + expect(starts).toHaveLength(3); + third.release(); + }); + + it("runs the worker's closers in reverse order at its stop", async () => { + const dir = await mkdtemp(join(tmpdir(), "chord-workers-log-")); + const { server, workers, behaviours } = await fleet(); + behaviours.set("S1", { log: join(dir, "log"), closeMs: 50 }); + const notes = await server.get(Notes, { id: "S1" }, context); + notes.release(); + await workers.stop(Session({ id: "S1" }), context); + expect(await readFile(join(dir, "log"), "utf8")).toBe("store\nhost\n"); + await rm(dir, { recursive: true, force: true }); + }); +}); + +describe("close", () => { + it("cancels a start waiting in start(address): nothing forks; new starts reject; no worker_failed at shutdown", async () => { + let waiting = false; + const { server, workers, errors } = await fleet({ + start: async (address, startContext) => { + if (address.key === "session|wait") { + waiting = true; + await new Promise((_, reject) => + startContext.abortSignal?.addEventListener("abort", () => reject(startContext.abortSignal?.reason)), + ); + } + return {}; + }, + }); + const running = await server.get(Notes, { id: "S1" }, context); + const waits = server.hold(Notes, { id: "wait" }, context); + await until(() => waiting); + await workers.close(context); + expect(workers.state.value).toEqual({}); + expect(errors).toEqual([]); + const late = server.hold(Notes, { id: "late" }, context); + await until(() => late.state.value.kind === "failed"); + expect(late.state.value).toMatchObject({ cause: { error: { code: "released", reason: "closed" } } }); + for (const ref of [running, waits, late]) ref.release(); + }); +}); + +describe("the rollout step (§4)", () => { + it("ships the current set to running workers, retries held failed ones, lists each", async () => { + const { server, workers, plugins, label } = await fleet(); + const one = await server.get(Notes, { id: "S1" }, context); + const two = await server.get(Notes, { id: "S2" }, context); + const control = await server.get(Control, { id: "S3" }, context); + const failed = await server.get(Notes, { id: "S3" }, context); + await control.crash(1, context); + await until(() => stateOf(workers.state.value, "S3")?.status === "failed"); + await label("v2"); + await until(() => plugins.state.value.dirty.includes("@test/notes")); + const result = await plugins.rollout( + [workers.step("workers")], + await plugins.build(plugins.state.value.dirty, context), + context, + ); + const hosts = result.steps[0]!.hosts; + expect(listed(hosts).sort()).toEqual([ + ["session|S1", { added: [], changed: ["@test/notes"], removed: [] }], + ["session|S2", { added: [], changed: ["@test/notes"], removed: [] }], + ["session|S3", { added: ["@test/notes"], changed: [], removed: [] }], + ]); + await until(() => one.label.value === "S1 v2" && two.label.value === "S2 v2" && failed.label.value === "S3 v2"); + expect(plugins.state.value.dirty).toEqual([]); + for (const ref of [one, two, failed, control]) ref.release(); + }); + + it("lists a worker stopped cleanly during it as stopped and one that crashed with its error (it stays dirty)", async () => { + const { server, workers, plugins, label } = await fleet(); + const one = await server.get(Notes, { id: "S1" }, context); + const control = await server.get(Control, { id: "S2" }, context); + await label("v2"); + await until(() => plugins.state.value.dirty.includes("@test/notes")); + const built = await plugins.build(plugins.state.value.dirty, context); + // Both workers block their event loops: their reloads wait; S1 is stopped meanwhile, S2 crashes before reading it. + const first = await server.get(Control, { id: "S1" }, context); + await first.block(400, context); + await control.blockThenCrash(400, context); + // Their event loops are blocked once their timers ran. + await new Promise((resolve) => setTimeout(resolve, 50)); + const rolling = plugins.rollout([workers.step("workers")], built, context); + await new Promise((resolve) => setTimeout(resolve, 50)); + const stopped = workers.stop(Session({ id: "S1" }), context); + const result = await rolling; + await stopped; + expect(listed(result.steps[0]!.hosts).sort()).toEqual([ + ["session|S1", "stopped"], + ["session|S2", "worker: its link is gone"], + ]); + expect(plugins.state.value.dirty).toEqual(["@test/notes"]); + for (const ref of [one, first, control]) ref.release(); + }); + + it("a worker starting during it gets the new set: a start after the rollout loads it", async () => { + const { server, workers, plugins, label } = await fleet(); + await label("v2"); + await until(() => plugins.state.value.dirty.includes("@test/notes")); + await plugins.rollout( + [workers.step("workers")], + await plugins.build(plugins.state.value.dirty, context), + context, + ); + const notes = await server.get(Notes, { id: "S9" }, context); + expect(notes.label.value).toBe("S9 v2"); + notes.release(); + }); + + it("an aborted wait lists the worker aborted; its late reply keeps the child's slot until it arrives", async () => { + const { server, workers, plugins, label } = await fleet(); + const control = await server.get(Control, { id: "S1" }, context); + const notes = await server.get(Notes, { id: "S1" }, context); + await label("v2"); + await until(() => plugins.state.value.dirty.includes("@test/notes")); + const built = await plugins.build(plugins.state.value.dirty, context); + await control.block(300, context); + await new Promise((resolve) => setTimeout(resolve, 50)); + const abort = new AbortController(); + const step = workers.step("workers"); + const waited = plugins.rollout([step], built, withAbortSignal(abort.signal, context)); + setTimeout(() => abort.abort(new Error("stop waiting")), 50); + const first = await waited; + expect(listed(first.steps[0]!.hosts)).toEqual([["session|S1", "stop waiting"]]); + expect(plugins.state.value.dirty).toEqual(["@test/notes"]); + await label("v3"); + await until(() => plugins.state.value.dirty.includes("@test/notes")); + const next = await plugins.rollout([step], await plugins.build(plugins.state.value.dirty, context), context); + expect(listed(next.steps[0]!.hosts)).toEqual([ + ["session|S1", { added: [], changed: ["@test/notes"], removed: [] }], + ]); + await until(() => notes.label.value === "S1 v3"); + control.release(); + notes.release(); + }); +}); + +describe("GC", () => { + // Real forks are costly (100 took the suite from 103 s to 700 s on one CPU at 1,000): 100 here; the 1,000 failed and + // retried addresses below cover the per-address records and suspensions at scale. + it("100 addresses started and stopped leave no state, no record, no suspension (Astra, Sol)", async () => { + const refs: WeakRef[] = []; + const { server, workers } = await fleet({ + start: async (address) => { + refs.push(new WeakRef(address)); + return {}; + }, + }); + // Twenty at a time: each a real fork, start, hold, stop and exit. + for (let batch = 0; batch < 5; batch++) { + await Promise.all( + Array.from({ length: 20 }, async (_, index) => { + const id = `R${batch * 20 + index}`; + const notes = await server.get(Notes, { id }, context); + notes.release(); + await workers.stop(Session({ id }), context); + }), + ); + } + expect(workers.state.value).toEqual({}); + await expectCollected(...refs); + }, 120_000); + + it("1,000 failed and retried addresses leave no state, no record and no suspension", async () => { + const refs: WeakRef[] = []; + const { server, workers } = await fleet({ + start: async (address) => { + refs.push(new WeakRef(address)); + throw new Error("no worker"); + }, + }); + for (let i = 0; i < 1_000; i++) { + const id = `A${i}`; + const ref = server.hold(Notes, { id }, context); + await until(() => ref.state.value.kind === "failed"); + ref.release(); + await workers.stop(Session({ id }), context); + await workers.retry(Session({ id }), context); + } + expect(workers.state.value).toEqual({}); + await expectCollected(...refs.slice(0, 50)); + }, 120_000); +}); + +describe("fix round 2", () => { + // Astra's recheck R3: a throwing `principal(address)` reached the guest with its message, the child left starting. + it("a throwing principal hook fails the start with the public message; the error stays private", async () => { + const { server, workers, errors } = await fleet({ + principal: () => { + throw new Error("secret token=sk-1 at /home/owner/private"); + }, + }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => notes.state.value.kind === "failed"); + expect(JSON.stringify(notes.state.value)).not.toContain("secret"); + expect(notes.state.value).toMatchObject({ + kind: "failed", + cause: { error: { message: "worker failed to start" } }, + }); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed"); + expect(stateOf(workers.state.value, "S1")).toMatchObject({ error: { message: "worker failed to start" } }); + // Opus's second recheck: the component's own kill of a failed start is no `worker_exited`. + await until(() => errors.length > 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(errors).toMatchObject([ + { kind: "worker_failed", address: "session|S1", error: { message: expect.stringContaining("secret") } }, + ]); + notes.release(); + }); + + it("a throwing authorize refuses the hold (forbidden); the error stays private, reported authorize_failed", async () => { + const { server, errors, starts } = await fleet({ + authorize: () => { + throw new Error("secret lookup"); + }, + }); + const remote = createHost({ onError: () => {} }); + const [up, down] = memoryLinkPair(); + server.accept(up, { principal: { kind: "guest", id: "g" } }, context); + remote.connect(down, { principal: { kind: "server" } }, context); + await remote.load([], context); + const notes = remote.hold(Notes, { id: "S1" }, context); + await until(() => notes.state.value.kind !== "loading"); + expect(JSON.stringify(notes.state.value)).not.toContain("secret"); + expect(notes.state.value).toMatchObject({ kind: "failed", cause: { error: { code: "forbidden" } } }); + expect([errors, starts]).toMatchObject([[{ kind: "authorize_failed", address: "session|S1" }], []]); + notes.release(); + await remote.close(context); + }); +}); + +describe("fix round 1", () => { + it("close during a forked start that never connects returns at once, no failure (Opus F1)", async () => { + const { server, workers, behaviours, errors } = await fleet(); + behaviours.set("S1", { connect: false }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "starting"); + const started = Date.now(); + await workers.close(context); + expect(Date.now() - started).toBeLessThan(5_000); + expect(errors).toEqual([]); + notes.release(); + }); + + it("close during an app's start that ignores its signal returns at once; nothing forks after it (Sol)", async () => { + let forked = false; + const { server, workers, errors } = await fleet({ start: () => new Promise(() => {}) }); + const notes = server.hold(Notes, { id: "S1" }, context); + await new Promise((resolve) => setTimeout(resolve, 50)); + await workers.close(context); + forked = stateOf(workers.state.value, "S1") !== undefined; + expect([forked, errors]).toEqual([false, []]); + notes.release(); + }); + + it("a stop during the worker's startup is a stop: its closers run, nothing fails (Astra)", async () => { + const dir = await mkdtemp(join(tmpdir(), "chord-workers-log-")); + const { server, workers, behaviours, errors } = await fleet(); + behaviours.set("S1", { loadMs: 5_000, closeMs: 200, log: join(dir, "log") }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1") !== undefined); + await new Promise((resolve) => setTimeout(resolve, 300)); + await workers.stop(Session({ id: "S1" }), context); + expect(errors).toEqual([]); + expect(await readFile(join(dir, "log"), "utf8")).toBe("store\nhost\n"); + notes.release(); + await rm(dir, { recursive: true, force: true }); + }); + + it("a reload queued behind one the child never answers settles when the child exits (Astra)", async () => { + const { server, workers, plugins, label } = await fleet(); + const control = await server.get(Control, { id: "S1" }, context); + await label("v2"); + await until(() => plugins.state.value.dirty.length > 0); + const built = await plugins.build(plugins.state.value.dirty, context); + await control.blockThenCrash(600, context); + await new Promise((resolve) => setTimeout(resolve, 50)); + const first = plugins.rollout([workers.step("one")], built, context); + await new Promise((resolve) => setTimeout(resolve, 50)); + const second = plugins.rollout([workers.step("two")], built, context); + const outcomes = await Promise.all([first, second]); + expect(outcomes.map((result) => listed(result.steps[0]!.hosts))).toEqual([ + [["session|S1", "worker: its link is gone"]], + [["session|S1", "worker: its link is gone"]], + ]); + control.release(); + }); + + it("a plugin whose evaluation failed is shipped again by the next rollout and stays dirty (Astra)", async () => { + const { server, workers, plugins, root } = await fleet(); + const notes = await server.get(Notes, { id: "S1" }, context); + await writeFile( + join(root, "notes", "label.ts"), + 'throw new Error("evaluation failed");\nexport const label = "v2";\n', + ); + await until(() => plugins.state.value.dirty.length > 0); + const built = await plugins.build(plugins.state.value.dirty, context); + const failed = (result: { steps: readonly { hosts: readonly RolloutHost[] }[] }) => { + const [entry] = result.steps[0]!.hosts; + return entry !== undefined && "report" in entry ? entry.report.failed.map(({ plugin }) => plugin) : entry; + }; + expect(failed(await plugins.rollout([workers.step("one")], built, context))).toEqual(["@test/notes"]); + expect(failed(await plugins.rollout([workers.step("two")], built, context))).toEqual(["@test/notes"]); + expect(plugins.state.value.dirty).toEqual(["@test/notes"]); + expect(notes.label.value).toBe("S1 v1"); + notes.release(); + }); + + it("two rollouts started together each ship their own build; the later one stays (Astra)", async () => { + const { server, workers, plugins, label } = await fleet(); + const notes = await server.get(Notes, { id: "S1" }, context); + await label("v2"); + const v2 = await plugins.build(["@test/notes"], context); + await label("v3"); + const v3 = await plugins.build(["@test/notes"], context); + const results = await Promise.all([ + plugins.rollout([workers.step("v2")], v2, context), + plugins.rollout([workers.step("v3")], v3, context), + ]); + for (const result of results) { + expect(listed(result.steps[0]!.hosts)).toEqual([ + ["session|S1", { added: [], changed: ["@test/notes"], removed: [] }], + ]); + } + await until(() => notes.label.value === "S1 v3"); + notes.release(); + }); + + it("reaps a worker that closed its own IPC channel and lives on: killed after the grace, failed (Sol)", async () => { + const { server, workers, behaviours } = await fleet(); + behaviours.set("S1", { disconnectAndBlock: true }); + const notes = await server.get(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed", 10_000); + expect(stateOf(workers.state.value, "S1")?.error?.message).toBe("worker exited (SIGKILL)"); + await until(() => notes.state.value.kind === "failed"); + notes.release(); + }, 15_000); + + it("keeps no reload report once consumed: the reload queue settles to nothing (Sol)", async () => { + const env = await fleet(); + const notes = await env.server.get(Notes, { id: "S1" }, context); + const report = await (async () => { + await env.label("v2"); + const built = await env.plugins.build(["@test/notes"], context); + const result = await env.plugins.rollout([env.workers.step("workers")], built, context); + const [entry] = result.steps[0]!.hosts; + if (entry === undefined || !("report" in entry)) throw new Error("no report"); + return new WeakRef(entry.report); + })(); + await expectCollected(report); + notes.release(); + }); + + it("keeps a worker's stderr out of the public state: only worker_failed carries its tail (Astra)", async () => { + const { server, workers, behaviours, errors } = await fleet(); + behaviours.set("S1", { fatal: "boom secret-token-123" }); + const notes = server.hold(Notes, { id: "S1" }, context); + await until(() => stateOf(workers.state.value, "S1")?.status === "failed"); + expect(JSON.stringify(workers.state.value)).not.toContain("secret"); + expect(JSON.stringify(notes.state.value)).not.toContain("secret"); + const failure = errors.find(({ kind }) => kind === "worker_failed"); + expect(failure).toMatchObject({ stderrTail: expect.stringContaining("secret-token-123") }); + notes.release(); + }); +}); diff --git a/packages/chord/test/next/bench/http.ts b/packages/chord/test/next/bench/http.ts new file mode 100644 index 000000000..21f3b2274 --- /dev/null +++ b/packages/chord/test/next/bench/http.ts @@ -0,0 +1,120 @@ +// Benchmark of the HTTP component (slice 10 §7): a route's overhead per request against a plain Node handler (keep-alive +// round trips, median µs per request over bursts), and `bytes` throughput for a 100 MiB file (`fileResponse`) against +// `pipeline` of a read stream. +// Run by hand from packages/chord (not in CI), pinned: +// taskset -c 6 node --conditions=source --experimental-strip-types --expose-gc test/next/bench/http.ts +import { createReadStream } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { Agent, createServer, request } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { pipeline } from "node:stream/promises"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { createHost, defineFacet } from "../../../src/next/host/index.ts"; +import { createHttp, fileResponse, Http } from "../../../src/next-node/http/index.ts"; + +const BURSTS = 7; +const REQUESTS = 2_000; +const FILE = 100 * 1024 * 1024; + +/** GETs `path` on `port` over `agent`, reading the whole body; resolves with its length. */ +function get(port: number, path: string, agent: Agent): Promise { + return new Promise((resolve, reject) => { + const req = request({ host: "127.0.0.1", port, path, agent }, (res) => { + let length = 0; + res.on("data", (chunk: Buffer) => { + length += chunk.length; + }); + res.on("end", () => resolve(length)); + res.on("error", reject); + }); + req.on("error", reject); + req.end(); + }); +} + +/** The median µs per request of `REQUESTS` sequential keep-alive requests, over bursts. */ +async function perRequest(port: number, path: string): Promise { + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + for (let i = 0; i < 200; i++) await get(port, path, agent); + const times: number[] = []; + for (let burst = 0; burst < BURSTS; burst++) { + const start = performance.now(); + for (let i = 0; i < REQUESTS; i++) await get(port, path, agent); + times.push(((performance.now() - start) * 1000) / REQUESTS); + } + agent.destroy(); + return median(times); +} + +/** MiB/s of downloading `path` once (after one warm-up). */ +async function throughput(port: number, path: string): Promise { + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + await get(port, path, agent); + const times: number[] = []; + for (let burst = 0; burst < 3; burst++) { + const start = performance.now(); + await get(port, path, agent); + times.push(performance.now() - start); + } + agent.destroy(); + return FILE / 1024 / 1024 / (median(times) / 1000); +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} + +const dir = await mkdtemp(join(tmpdir(), "chord-bench-http-")); +const file = join(dir, "big.bin"); +await writeFile(file, Buffer.alloc(FILE, 1)); + +// A plain Node server: the same JSON body, the same file through `pipeline`. +const plain = createServer((req, res) => { + if (req.url === "/file") { + res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": String(FILE) }); + void pipeline(createReadStream(file), res).catch(() => {}); + return; + } + const body = JSON.stringify({ ok: true }); + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Length": String(body.length) }); + res.end(body); +}); +await new Promise((resolve) => plain.listen(0, "127.0.0.1", resolve)); +const plainPort = (plain.address() as { port: number }).port; + +// The component with Chord's own listener: one plugin with the same route and the file. +const host = createHost({ principal: { kind: "server" }, onError: () => {} }); +const http = createHttp(host, { listen: { host: "127.0.0.1", port: 0 }, onError: () => {} }, context); +const plugin = defineFacet((_host, self) => { + self.contribute(Http, { + routes: { + "GET /json": async () => ({ kind: "json", value: { ok: true } }), + "GET /file": async () => fileResponse(file), + }, + }); +}); +await host.load( + [ + { name: "chord.http", facet: http.facet }, + { name: "@bench/routes", facet: plugin }, + ], + context, +); +const { port } = await http.listening(context); + +const plainRequest = await perRequest(plainPort, "/json"); +const routeRequest = await perRequest(port, "/json"); +console.log( + `request round trip: plain ${plainRequest.toFixed(1)} µs, route ${routeRequest.toFixed(1)} µs (overhead ${(routeRequest - plainRequest).toFixed(1)} µs, ${(((routeRequest - plainRequest) / plainRequest) * 100).toFixed(1)}%)`, +); +const plainBytes = await throughput(plainPort, "/file"); +const routeBytes = await throughput(port, "/file"); +console.log(`100 MiB file: pipeline ${plainBytes.toFixed(0)} MiB/s, fileResponse ${routeBytes.toFixed(0)} MiB/s`); + +await http.close(context); +await host.close(context); +plain.close(); +await rm(dir, { recursive: true, force: true }); diff --git a/packages/chord/test/next/bench/plugins.ts b/packages/chord/test/next/bench/plugins.ts index 3cd231ebb..b80a56ee9 100644 --- a/packages/chord/test/next/bench/plugins.ts +++ b/packages/chord/test/next/bench/plugins.ts @@ -25,6 +25,7 @@ const RELOADS = 100; const gc = (globalThis as { gc?: () => void }).gc!; const HOST_PACKAGES = { "@earendil-works/chord/next": chordNext }; const KINDS = ["server", "worker"]; +const NODE = { platform: "node", hostPackages: Object.keys(HOST_PACKAGES) } as const; /** A plugin's worker facet: a store over a document, a derive, a method, a helper module and the shared contract. */ function worker(index: number, label: string): string { @@ -111,12 +112,9 @@ for (let run = 0; run < RUNS; run++) { console.log(`evaluation per facet: ${median(evaluations).toFixed(2)} ms (median of ${RUNS})`); // The heap after 100 reloads of one plugin on a host, against after a warm-up (two builds alternating). -const component = createPlugins( - { roots: [root], kinds: KINDS, hostPackages: HOST_PACKAGES, onError: () => {} }, - context, -); +const component = createPlugins({ roots: [root], kinds: { server: NODE, worker: NODE }, onError: () => {} }, context); const host = createHost({ onError: () => {} }); -await component.load(host, { kind: "worker", core: [] }, context); +await component.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await writeFile(join(root, "p0", "worker.ts"), worker(0, "a")); const first = await component.build(["@bench/p0"], context); await writeFile(join(root, "p0", "worker.ts"), worker(0, "b")); diff --git a/packages/chord/test/next/bench/workers.ts b/packages/chord/test/next/bench/workers.ts new file mode 100644 index 000000000..077b6df10 --- /dev/null +++ b/packages/chord/test/next/bench/workers.ts @@ -0,0 +1,107 @@ +// Benchmark of the workers component (slice 10 §7): a worker's cold start with shipped scripts (fork to `started`, fork +// to `ready`, medians over runs) and the server's memory per idle worker (heap after a GC, 20 workers running idle). +// Run by hand from packages/chord (not in CI), pinned: +// taskset -c 6 node --conditions=source --experimental-strip-types --expose-gc test/next/bench/workers.ts +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { hostKind, str } from "../../../src/next/contracts/index.ts"; +import { createHost } from "../../../src/next/host/index.ts"; +import * as chordNext from "../../../src/next/index.ts"; +import { createPlugins } from "../../../src/next-node/plugins/index.ts"; +import type { FromWorker } from "../../../src/next-node/workers/control.ts"; +import { createWorkers } from "../../../src/next-node/workers/index.ts"; +import { WorkerProcess } from "../../../src/next-node/workers/process.ts"; +import { Control } from "../../next-node/fixtures/workers/control.ts"; + +const RUNS = 7; +const WORKERS = 20; +const ENTRY = fileURLToPath(new URL("../../next-node/fixtures/workers/worker.ts", import.meta.url)); +const ROOT = fileURLToPath(new URL("../../next-node/fixtures/workers/plugins/", import.meta.url)); +const EXEC_ARGV = ["--conditions=source", "--experimental-strip-types", "--no-warnings"]; + +const gc = (globalThis as { gc?: () => void }).gc!; +const Session = hostKind("session", { id: str() }); +const modules = { "@earendil-works/chord/next": chordNext }; +const plugins = createPlugins( + { roots: [ROOT], kinds: { worker: { platform: "node", hostPackages: Object.keys(modules) } }, onError: () => {} }, + context, +); +const scripts = await plugins.scripts("worker", context); + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]!; +} + +// Cold starts: a fork with the shipped scripts, timed to `started` and to `ready`. +const started: number[] = []; +const ready: number[] = []; +for (let run = 0; run < RUNS; run++) { + const forked = performance.now(); + const times: { started?: number; ready?: number } = {}; + const done = Promise.withResolvers(); + const child = new WorkerProcess( + { entry: ENTRY, execArgv: EXEC_ARGV, onOutput: () => {} }, + { + control: (message: FromWorker) => { + if (message.kind === "started") times.started = performance.now() - forked; + if (message.kind === "ready") { + times.ready = performance.now() - forked; + done.resolve(); + } + }, + linkLost: () => {}, + linkClosed: () => {}, + exited: () => {}, + }, + ); + child.send({ + kind: "start", + address: Session({ id: `R${run}` }), + start: {}, + scripts, + lock: `bench:${process.pid}:${run}`, + }); + await done.promise; + started.push(times.started!); + ready.push(times.ready!); + child.kill(); + await child.exited; +} +console.log( + `cold start (${scripts.length} shipped script): fork to started ${median(started).toFixed(0)} ms, to ready ${median(ready).toFixed(0)} ms (median of ${RUNS})`, +); + +// Memory per idle worker on the server: its records, process handle, link and remote references. +const server = createHost({ address: hostKind("server")(), principal: { kind: "server" }, onError: () => {} }); +const workers = createWorkers( + server, + Session, + { + kind: "worker", + entry: ENTRY, + execArgv: EXEC_ARGV, + authorize: () => true, + principal: () => ({ kind: "worker" }), + start: () => ({}), + lock: (address) => `bench:${process.pid}:${address.key}`, + plugins, + onOutput: () => {}, + onError: () => {}, + }, + context, +); +await server.load([], context); +gc(); +const before = process.memoryUsage().heapUsed; +const held = []; +for (let i = 0; i < WORKERS; i++) held.push(await server.get(Control, { id: `W${i}` }, context)); +gc(); +const after = process.memoryUsage().heapUsed; +console.log( + `server heap per idle worker: ${((after - before) / WORKERS / 1024).toFixed(1)} KiB (${WORKERS} workers, one reference each)`, +); +for (const ref of held) ref.release(); +await workers.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/12-routes.ts b/packages/chord/test/next/examples/12-routes.ts index 7f6516887..e9dedbbb5 100644 --- a/packages/chord/test/next/examples/12-routes.ts +++ b/packages/chord/test/next/examples/12-routes.ts @@ -144,8 +144,8 @@ await guest.get(Notes, { session: "S2" }, context).catch((error) => { const lobby = await guest.get(Lobby, {}, context); console.log("guest via the lobby:", await lobby.peek({ session: "S1" }, context)); -// 5. The worker drops: references go loading, the route resolves the address again after its backoff (1 s), and -// the new worker (a fresh run, empty notes here) binds them again. +// 5. The worker drops: references go loading, the route resolves the address again at once (its link had completed +// its handshake), and the new worker (a fresh run, empty notes here) binds them again. const states: string[] = []; const watch = await notes.state.watch(context); watch.start(async (call) => { diff --git a/packages/chord/test/next/examples/35-plugins/main.ts b/packages/chord/test/next/examples/35-plugins/main.ts index bdfaa89d9..e5fee1544 100644 --- a/packages/chord/test/next/examples/35-plugins/main.ts +++ b/packages/chord/test/next/examples/35-plugins/main.ts @@ -29,13 +29,15 @@ async function until(ready: () => boolean): Promise { while (!ready()) await new Promise((resolve) => setTimeout(resolve, 10)); } -// 1. The component: the roots, the facet kinds, and the host packages every script gets (this process's Chord: a -// plugin's `import { defineFacet } from "@earendil-works/chord/next"` is the app's own instance). +// 1. The component: the roots, and the facet kinds, each built for Node with Chord as a host package (kept external in +// the builds). The hosts give the modules themselves when they load (this process's Chord: a plugin's +// `import { defineFacet } from "@earendil-works/chord/next"` is the app's own instance). +const modules = { "@earendil-works/chord/next": chordNext }; +const node = { platform: "node", hostPackages: Object.keys(modules) } as const; const plugins = createPlugins( { roots: [root], - kinds: ["server", "worker"], - hostPackages: { "@earendil-works/chord/next": chordNext }, + kinds: { server: node, worker: node }, onError: (error) => console.log("plugins:", error.kind), }, context, @@ -52,8 +54,8 @@ server.route( { authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) }, context, ); -const workerLoad = await plugins.load(worker, { kind: "worker", core: [] }, context); -const serverLoad = await plugins.load(server, { kind: "server", core: [] }, context); +const workerLoad = await plugins.load(worker, { kind: "worker", core: [], modules }, context); +const serverLoad = await plugins.load(server, { kind: "server", core: [], modules }, context); console.log( "1. discovered:", plugins.discovery.plugins.map(({ name, facets }) => `${name} [${Object.keys(facets)}]`).join(", "), @@ -84,8 +86,17 @@ console.log( built.facets.map(({ plugin, facet, kind }) => `${plugin}/${facet}: ${kind}`), ); const rolled = await plugins.rollout(steps, built, context); -for (const { name, report } of rolled.steps) { - console.log(` ${name}:`, JSON.stringify(report.plugins), "objects", JSON.stringify(report.objects)); +for (const { name, hosts } of rolled.steps) { + for (const host of hosts) { + if ("report" in host) { + console.log( + ` ${name}:`, + JSON.stringify(host.report.plugins), + "objects", + JSON.stringify(host.report.objects), + ); + } + } } await until(() => notes.label.value === "v2"); console.log("4. notes:", notes.label.value, "count", notes.count.value, "| index:", index.label.value); diff --git a/packages/chord/test/next/examples/35-plugins/plugins/clock/server.ts b/packages/chord/test/next/examples/35-plugins/plugins/clock/server.ts index 5bc4b6ee3..29f0d3d98 100644 --- a/packages/chord/test/next/examples/35-plugins/plugins/clock/server.ts +++ b/packages/chord/test/next/examples/35-plugins/plugins/clock/server.ts @@ -2,7 +2,7 @@ // this plugin, so a reload leaves it as it is: same script, same facet, same object. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { Clock } from "../contracts/contract.ts"; +import { Clock } from "@example/contracts/contract"; const evaluatedAt = new Date().toISOString(); diff --git a/packages/chord/test/next/examples/35-plugins/plugins/notes/server.ts b/packages/chord/test/next/examples/35-plugins/plugins/notes/server.ts index ed3ded889..141e104d7 100644 --- a/packages/chord/test/next/examples/35-plugins/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/35-plugins/plugins/notes/server.ts @@ -1,7 +1,7 @@ // The notes plugin's server facet: serves `Index` with the same label. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { Index } from "../contracts/contract.ts"; +import { Index } from "@example/contracts/contract"; import { label } from "./label.ts"; export default defineFacet((host) => host.serve(Index, () => ({ label: constant(label) }))); diff --git a/packages/chord/test/next/examples/35-plugins/plugins/notes/worker.ts b/packages/chord/test/next/examples/35-plugins/plugins/notes/worker.ts index b4f4c8ae4..012486c94 100644 --- a/packages/chord/test/next/examples/35-plugins/plugins/notes/worker.ts +++ b/packages/chord/test/next/examples/35-plugins/plugins/notes/worker.ts @@ -1,7 +1,7 @@ // The notes plugin's worker facet: serves `Notes`, its count kept in a document. import { constant, defineFacet, defineState, int, obj } from "@earendil-works/chord/next"; -import { Notes } from "../contracts/contract.ts"; +import { Notes } from "@example/contracts/contract"; import { label } from "./label.ts"; const Count = defineState("count", { schema: obj({ n: int() }), version: 1, initial: () => ({ n: 0 }) }); diff --git a/packages/chord/test/next/examples/36-plugin-failures/main.ts b/packages/chord/test/next/examples/36-plugin-failures/main.ts index 1fe16092c..103b1b3fb 100644 --- a/packages/chord/test/next/examples/36-plugin-failures/main.ts +++ b/packages/chord/test/next/examples/36-plugin-failures/main.ts @@ -63,11 +63,12 @@ const Counter = local("example.counter", { version: 1, params: {}, members: { st const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } }); const Status = local("example.status", { version: 1, params: {}, members: { text: str() } }); +// The host packages: this process's Chord. +const modules = { "@earendil-works/chord/next": chordNext }; const plugins = createPlugins( { roots: [root], - kinds: ["server"], - hostPackages: { "@earendil-works/chord/next": chordNext }, + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, onError: (error) => console.log("plugins:", error.kind), }, context, @@ -76,7 +77,7 @@ const host = createHost({ onError: () => {} }); // 1. Startup: the weather plugin's setup throws; it is left out and reported, the other plugins run. A reference to // what it would serve waits (`failed`, cause `waiting`: not served here), and so does its consumer's object. -const loaded = await plugins.load(host, { kind: "server", core: [] }, context); +const loaded = await plugins.load(host, { kind: "server", core: [], modules }, context); for (const { plugin, error } of loaded.failed) console.log(`1. left out ${plugin}: ${(error as Error).message}`); const weather = host.hold(Weather, {}, context); const forecast = host.hold(Forecast, {}, context); diff --git a/packages/chord/test/next/examples/36-plugin-failures/plugins/forecast/server.ts b/packages/chord/test/next/examples/36-plugin-failures/plugins/forecast/server.ts index 54a92d46f..c7a04a1d8 100644 --- a/packages/chord/test/next/examples/36-plugin-failures/plugins/forecast/server.ts +++ b/packages/chord/test/next/examples/36-plugin-failures/plugins/forecast/server.ts @@ -1,7 +1,7 @@ // The forecast plugin's server facet: a consumer of the weather plugin's `Weather`. import { defineFacet, local, str } from "@earendil-works/chord/next"; -import { Weather } from "../weather/contract.ts"; +import { Weather } from "@example/weather/contract"; const Forecast = local("example.forecast", { version: 1, params: {}, members: { text: str() } }); diff --git a/packages/chord/test/next/examples/36-plugin-failures/plugins/summary/server.ts b/packages/chord/test/next/examples/36-plugin-failures/plugins/summary/server.ts index 3aad5f3e1..6db7ff40f 100644 --- a/packages/chord/test/next/examples/36-plugin-failures/plugins/summary/server.ts +++ b/packages/chord/test/next/examples/36-plugin-failures/plugins/summary/server.ts @@ -2,7 +2,7 @@ // contract declares. import { defineFacet, local, str } from "@earendil-works/chord/next"; -import { Price } from "../prices/contract.ts"; +import { Price } from "@example/prices/contract"; const Summary = local("example.summary", { version: 1, params: {}, members: { text: str() } }); diff --git a/packages/chord/test/next/examples/37-plugin-imports/main.ts b/packages/chord/test/next/examples/37-plugin-imports/main.ts index 8ed51b7d2..7ec709c46 100644 --- a/packages/chord/test/next/examples/37-plugin-imports/main.ts +++ b/packages/chord/test/next/examples/37-plugin-imports/main.ts @@ -29,17 +29,18 @@ for (const dir of ["plugins", "app", "deps"]) { } const root = join(copy, "plugins"); -// 1. The component. Its host packages: Chord, the app's ES module and a CommonJS module of the app. Its npm command -// (here a stand-in): what `installDependencies` runs in a plugin's directory. +// 1. The component. Its host packages: Chord, the app's ES module and a CommonJS module of the app (their names kept +// external in the server kind's builds, the modules given at load). Its npm command (here a stand-in): what +// `installDependencies` runs in a plugin's directory. +const modules = { + "@earendil-works/chord/next": chordNext, + "@example/app": app, + "@example/legacy": createRequire(import.meta.url)("./app/legacy.cjs"), +}; const plugins = createPlugins( { roots: [root], - kinds: ["server"], - hostPackages: { - "@earendil-works/chord/next": chordNext, - "@example/app": app, - "@example/legacy": createRequire(import.meta.url)("./app/legacy.cjs"), - }, + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, install: { npmCommand: [process.execPath, fileURLToPath(new URL("./fake-npm.mjs", import.meta.url))] }, onError: (error) => console.log("plugins:", error.kind), }, @@ -49,7 +50,7 @@ const plugins = createPlugins( // 2. Startup: every plugin whose build fails is left out with its errors (one per line: file, line, column, why). The // report plugin's dependency is not installed yet. const host = createHost({ onError: () => {} }); -const loaded = await plugins.load(host, { kind: "server", core: [] }, context); +const loaded = await plugins.load(host, { kind: "server", core: [], modules }, context); console.log("1. left out at startup:"); for (const { plugin, error } of loaded.failed) { const [first, ...errors] = (error as Error).message.split("\n"); @@ -67,7 +68,9 @@ for (const facet of built.facets) { console.log(` built ${facet.plugin}/${facet.facet}: read ${files.join(", ")}; copied ${facet.copied.length} file`); } const report = await plugins.reload(host, built, context); -console.log(" reload:", JSON.stringify(report.plugins), "failed", report.failed.length); +// The reload passes the complete set: the refused plugins fail again and stay out. +const stillRefused = report.failed.map(({ plugin }) => plugin).join(", "); +console.log(" reload:", JSON.stringify(report.plugins), "| still refused:", stillRefused); const text = (await host.get(Report, {}, context)).text.value; console.log("3. the report:", text); diff --git a/packages/chord/test/next/examples/37-plugin-imports/plugins/report/server.ts b/packages/chord/test/next/examples/37-plugin-imports/plugins/report/server.ts index 41201eeea..601072036 100644 --- a/packages/chord/test/next/examples/37-plugin-imports/plugins/report/server.ts +++ b/packages/chord/test/next/examples/37-plugin-imports/plugins/report/server.ts @@ -6,12 +6,12 @@ import { pluginFile } from "@earendil-works/chord/next/plugins"; // Host packages: an ES module (default and named imports) and a CommonJS one, the app's own instances. import app, { currency } from "@example/app"; import legacy from "@example/legacy"; +// A contract of another plugin: a copy built into this script. +import { Report } from "@example/shapes/contract"; // An npm dependency: inlined into this script by the build (installed first). import { greet } from "example-greeting"; // A `chord.external` package: left external, loaded from this plugin's node_modules when the script runs. import { where } from "example-native"; -// A contract of another plugin: a copy built into this script. -import { Report } from "../shapes/contract.ts"; // A file of this plugin, read at run time by its absolute path. const rates = JSON.parse(readFileSync(pluginFile("data/rates.json"), "utf8")) as { readonly vat: number }; diff --git a/packages/chord/test/next/examples/37-plugin-imports/plugins/ruler/server.ts b/packages/chord/test/next/examples/37-plugin-imports/plugins/ruler/server.ts index ec9a0dd74..a67049cac 100644 --- a/packages/chord/test/next/examples/37-plugin-imports/plugins/ruler/server.ts +++ b/packages/chord/test/next/examples/37-plugin-imports/plugins/ruler/server.ts @@ -2,7 +2,7 @@ // copied file may import only host packages and files of its own plugin. import { defineFacet } from "@earendil-works/chord/next"; -import { Millimetres } from "../shapes/units.ts"; +import { Millimetres } from "@example/shapes/units"; export default defineFacet(() => { console.log(Millimetres); diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/main.ts b/packages/chord/test/next/examples/38-watch-and-rollout/main.ts index 7eeb92f1e..53313cf75 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/main.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/main.ts @@ -48,17 +48,21 @@ const line = (report: ReloadReport) => /** A rollout, step by step. */ function print(result: RolloutResult): void { - for (const { name, report } of result.steps) console.log(` ${name}:`, line(report)); + for (const { name, hosts } of result.steps) { + for (const host of hosts) if ("report" in host) console.log(` ${name}:`, line(host.report)); + } if (result.stopped !== undefined) { console.log(` stopped at ${result.stopped.step}: ${(result.stopped.error as Error).message}`); } } +// The host packages: this process's Chord, kept external in Node builds. +const modules = { "@earendil-works/chord/next": chordNext }; +const node = { platform: "node", hostPackages: Object.keys(modules) } as const; const plugins = createPlugins( { roots: [root], - kinds: ["server", "worker"], - hostPackages: { "@earendil-works/chord/next": chordNext }, + kinds: { server: node, worker: node }, // The app's own code: a change marks `base`. base: [join(copy, "app")], // A stand-in for npm (no network; these plugins have no dependencies). @@ -69,8 +73,8 @@ const plugins = createPlugins( ); const server = createHost({ onError: () => {} }); const worker = createHost({ onError: () => {} }); -await plugins.load(server, { kind: "server", core: [] }, context); -await plugins.load(worker, { kind: "worker", core: [] }, context); +await plugins.load(server, { kind: "server", core: [], modules }, context); +await plugins.load(worker, { kind: "worker", core: [], modules }, context); const steps = [plugins.step("server", server as Host), plugins.step("worker", worker as Host)]; console.log("1. loaded:", plugins.discovery.plugins.map(({ name }) => name).join(", "), "| marks:", marks()); @@ -79,7 +83,7 @@ await edit("plugins/notes/label.ts", "notes v1", "notes v2"); await dirty("@example/notes"); console.log("2. a file of notes edited:", marks()); await edit("plugins/contracts/contract.ts", "one type per plugin", "one type per plugin (edited)"); -await dirty("@example/contracts", "@example/tags"); +await dirty("@example/shared", "@example/tags"); console.log(" the shared contract edited:", marks()); await edit("plugins/tags/package.json", '"private": true', '"private": true,\n\t"description": "tags"'); await until(() => plugins.state.value.install.includes("@example/tags")); diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/package.json b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/package.json index d1c133527..dfc4cefdc 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/package.json +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/package.json @@ -1,5 +1,5 @@ { - "name": "@example/contracts", + "name": "@example/shared", "private": true, "type": "module" } diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/server.ts b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/server.ts index 23a936014..5670a4766 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/server.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/server.ts @@ -1,7 +1,7 @@ // The notes plugin's server facet: its label. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { labelOf } from "../contracts/contract.ts"; +import { labelOf } from "@example/shared/contract"; import { label } from "./label.ts"; export default defineFacet((host) => diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/worker.ts b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/worker.ts index e01fc1159..58d814c27 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/worker.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/notes/worker.ts @@ -1,7 +1,7 @@ // The notes plugin's worker facet: its label. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { labelOf } from "../contracts/contract.ts"; +import { labelOf } from "@example/shared/contract"; import { label } from "./label.ts"; export default defineFacet((host) => diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/old/server.ts b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/old/server.ts index afb0165f9..3d87897be 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/old/server.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/old/server.ts @@ -1,6 +1,6 @@ // The old plugin's server facet; the example deletes the plugin's directory. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { labelOf } from "../contracts/contract.ts"; +import { labelOf } from "@example/shared/contract"; export default defineFacet((host) => host.serve(labelOf("old"), () => ({ text: constant("old on the server") }))); diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/server.ts b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/server.ts index 8b8ce9d68..64210746e 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/server.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/server.ts @@ -1,6 +1,6 @@ // The tags plugin's server facet. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { labelOf } from "../contracts/contract.ts"; +import { labelOf } from "@example/shared/contract"; export default defineFacet((host) => host.serve(labelOf("tags"), () => ({ text: constant("tags on the server") }))); diff --git a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/worker.ts b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/worker.ts index 7cbeb3ae4..796e053a3 100644 --- a/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/worker.ts +++ b/packages/chord/test/next/examples/38-watch-and-rollout/plugins/tags/worker.ts @@ -1,6 +1,6 @@ // The tags plugin's worker facet. import { constant, defineFacet } from "@earendil-works/chord/next"; -import { labelOf } from "../contracts/contract.ts"; +import { labelOf } from "@example/shared/contract"; export default defineFacet((host) => host.serve(labelOf("tags"), () => ({ text: constant("tags on the worker") }))); diff --git a/packages/chord/test/next/examples/39-browser-build/main.ts b/packages/chord/test/next/examples/39-browser-build/main.ts index 484951d89..f7a05af4d 100644 --- a/packages/chord/test/next/examples/39-browser-build/main.ts +++ b/packages/chord/test/next/examples/39-browser-build/main.ts @@ -1,4 +1,4 @@ -// A browser build (slice 7c §2): a facet of a browser kind (`browserKinds: ["web"]`) is built for the browser. Its +// A browser build (slice 7c §2): a facet of a browser kind (`web: { platform: "browser", … }`) is built for the browser. Its // stylesheets become one CSS file (`css`), an asset imported by URL (`./logo.svg?url`) a content-hashed file whose // import evaluates to its URL under the app's `publicPath`. The build is plain data: script, stylesheet and assets, // what the app serves to its pages (slice 11 ships it); a page evaluates the script with its host packages, as Node @@ -20,10 +20,8 @@ const hostPackages = { "@earendil-works/chord/next": chordNext }; const plugins = createPlugins( { roots: [root], - kinds: ["web"], - browserKinds: ["web"], + kinds: { web: { platform: "browser", hostPackages: Object.keys(hostPackages) } }, publicPath: "/plugin-assets/", - hostPackages, onError: (error) => console.log("plugins:", error.kind), }, context, diff --git a/packages/chord/test/next/examples/40-http/main.ts b/packages/chord/test/next/examples/40-http/main.ts new file mode 100644 index 000000000..78dbca808 --- /dev/null +++ b/packages/chord/test/next/examples/40-http/main.ts @@ -0,0 +1,56 @@ +// HTTP (slice 10): a server answers HTTP requests with routes that plugins contribute. The notes plugin (`plugins/`) +// contributes a page, a JSON route and a file download; the server's own code only builds the component, says where +// Chord's own listener listens, and loads the plugins. Every answer carries the security baseline's headers. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/40-http/main.ts +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +// 1. A server host and its HTTP component; its settings are arguments: Chord's own listener listens on a free +// loopback port. Nobody signs in here: no authenticator, every request is anonymous. +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 }, onError: console.error }, context); + +// 2. The plugins: the component's facet (its registries and the listener) goes first, then every plugin's server +// facet. The listener opens once they are published. +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }], modules }, context); +const { port } = await http.listening(context); + +// 3. Some requests, printed like curl would. +async function curl(path: string, headers: Record = {}): Promise { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { headers }); + const range = headers.Range === undefined ? "" : ` (Range: ${headers.Range})`; + console.log(`GET ${path}${range} -> ${response.status} ${response.headers.get("content-type")}`); + const download = response.headers.get("content-disposition"); + if (download !== null) console.log(` ${download}`); + console.log(` ${JSON.stringify((await response.text()).replace(/nonce="[^"]+"/, 'nonce="…"'))}`); +} + +await curl("/"); +await curl("/notes/hello"); +await curl("/notes/hello/download", { Range: "bytes=0-6" }); +await curl("/notes/nope/download"); +await curl("/nowhere"); +const baseline = await fetch(`http://127.0.0.1:${port}/notes/hello`); +for (const name of ["cache-control", "x-content-type-options", "x-frame-options"]) { + console.log(` ${name}: ${baseline.headers.get(name)}`); +} + +// 4. Close the component first: it refuses new requests and waits for running ones (the host's close would seal them +// at once). Then the host. +await http.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/40-http/plugins/notes/files/hello.md b/packages/chord/test/next/examples/40-http/plugins/notes/files/hello.md new file mode 100644 index 000000000..13cbb3050 --- /dev/null +++ b/packages/chord/test/next/examples/40-http/plugins/notes/files/hello.md @@ -0,0 +1,3 @@ +# Hello + +A note served as a download. diff --git a/packages/chord/test/next/examples/40-http/plugins/notes/package.json b/packages/chord/test/next/examples/40-http/plugins/notes/package.json new file mode 100644 index 000000000..d59f5a427 --- /dev/null +++ b/packages/chord/test/next/examples/40-http/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/40-http/plugins/notes/server.ts b/packages/chord/test/next/examples/40-http/plugins/notes/server.ts new file mode 100644 index 000000000..48e6b9388 --- /dev/null +++ b/packages/chord/test/next/examples/40-http/plugins/notes/server.ts @@ -0,0 +1,29 @@ +// The notes plugin's server facet: a page, a JSON route and a file download, contributed to the HTTP component's +// registry. + +import { defineFacet } from "@earendil-works/chord/next"; +import { fileResponse, Http } from "@earendil-works/chord/next/http"; +import { pluginFile } from "@earendil-works/chord/next/plugins"; + +export default defineFacet((_host, self) => + self.contribute(Http, { + routes: { + // A page: a link from another site may open it. Its inline script carries the server's nonce (the CSP). + "GET /": { + page: true, + handler: async (request) => ({ + kind: "html", + body: `

Notes

`, + }), + }, + // `:name` is one path segment, decoded. + "GET /notes/:name": async (request) => ({ kind: "json", value: { note: request.params.name } }), + // A file of this plugin, saved under its name, with ranges. + "GET /notes/:name/download": async (request) => + fileResponse(pluginFile(`files/${request.params.name}.md`), { + download: `${request.params.name}.md`, + ranges: true, + }), + }, + }), +); diff --git a/packages/chord/test/next/examples/41-workers/main.ts b/packages/chord/test/next/examples/41-workers/main.ts new file mode 100644 index 000000000..35460577b --- /dev/null +++ b/packages/chord/test/next/examples/41-workers/main.ts @@ -0,0 +1,68 @@ +// Workers (slice 10): the server routes a session's address to its own worker process. The first reference to a +// session starts its worker; once nobody uses it for `idleMs`, the worker stops. The worker's code is the sessions +// plugin (`plugins/`), built by the server and shipped to the worker. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/41-workers/main.ts +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { hostKind } from "../../../../src/next/contracts/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; +import { createWorkers } from "../../../../src/next-node/workers/index.ts"; +import { Greeting, Session } from "./plugins/sessions/contract.ts"; + +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { worker: { platform: "node", hostPackages: ["@earendil-works/chord/next"] } }, + onError: console.error, + }, + context, +); + +// 1. The server, and its workers: one forked process per session, stopped after 300 ms without use. +const server = createHost({ address: hostKind("server")(), principal: { kind: "server" }, onError: console.error }); +const workers = createWorkers( + server, + Session, + { + kind: "worker", + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + authorize: () => true, + principal: (address) => ({ kind: "worker", session: address.params.id }), + start: () => ({}), + lock: (address) => `example-41:${process.pid}:${address.key}`, + idleMs: () => 300, + plugins, + onError: console.error, + }, + context, +); +await server.load([], context); + +// 2. Every change of the workers' state, printed. +const watch = await workers.state.watch(context); +watch.start(async (call) => { + if (call.kind !== "value") return; + const entries = Object.entries(call.value).map(([key, state]) => `${key} ${state.status}`); + console.log(` workers: ${entries.join(", ") || "none"}`); +}); + +// 3. The first reference starts the worker; the greeting comes from that process. +console.log("1. getting S1's greeting"); +const greeting = await server.get(Greeting, { id: "S1" }, context); +console.log(`2. "${greeting.text.value}" (worker pid ${greeting.pid.value}, server pid ${process.pid})`); + +// 4. Releasing it leaves the worker idle: 300 ms later it stops. +greeting.release(); +console.log("3. released; waiting for the idle stop"); +while (Object.keys(workers.state.value).length > 0) { + await new Promise((resolve) => setTimeout(resolve, 50)); +} +console.log("4. stopped"); + +await watch.stop(); +await workers.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/41-workers/plugins/sessions/contract.ts b/packages/chord/test/next/examples/41-workers/plugins/sessions/contract.ts new file mode 100644 index 000000000..0ef89bfff --- /dev/null +++ b/packages/chord/test/next/examples/41-workers/plugins/sessions/contract.ts @@ -0,0 +1,13 @@ +// A session lives in its own worker process: `Session({ id })` is its address. + +import { hostKind, int, remote, str } from "@earendil-works/chord/next"; + +export const Session = hostKind("session", { id: str() }); + +/** A session's greeting, served by the worker at the session's address. */ +export const Greeting = remote("example.greeting", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Session({ id }), + variants: { main: { state: { text: str(), pid: int() } } }, +}); diff --git a/packages/chord/test/next/examples/41-workers/plugins/sessions/package.json b/packages/chord/test/next/examples/41-workers/plugins/sessions/package.json new file mode 100644 index 000000000..3819a6f05 --- /dev/null +++ b/packages/chord/test/next/examples/41-workers/plugins/sessions/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/sessions", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/41-workers/plugins/sessions/worker.ts b/packages/chord/test/next/examples/41-workers/plugins/sessions/worker.ts new file mode 100644 index 000000000..79ee9efca --- /dev/null +++ b/packages/chord/test/next/examples/41-workers/plugins/sessions/worker.ts @@ -0,0 +1,11 @@ +// The sessions plugin's worker facet: each worker greets with its process id. + +import { constant, defineFacet } from "@earendil-works/chord/next"; +import { Greeting } from "./contract.ts"; + +export default defineFacet((host) => + host.serve(Greeting, ({ id }) => ({ + variant: "main", + members: { text: constant(`hello from session ${id}`), pid: constant(process.pid) }, + })), +); diff --git a/packages/chord/test/next/examples/41-workers/worker.ts b/packages/chord/test/next/examples/41-workers/worker.ts new file mode 100644 index 000000000..2fb4fccaa --- /dev/null +++ b/packages/chord/test/next/examples/41-workers/worker.ts @@ -0,0 +1,22 @@ +// The worker process: a host at the session's address, connected to the server, loading the plugins it was shipped. + +import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import { workerMain } from "../../../../src/next-node/workers/index.ts"; + +workerMain( + async ({ address, parent, plugins, onClose }, context) => { + const host = createHost({ address, principal: { kind: "worker" }, onError: console.error }); + onClose(() => host.close(BACKGROUND_CONTEXT)); + // Connect first: the server sees the worker started; then load. + host.connect(parent, { principal: { kind: "server" }, trusted: true }, context); + await plugins.load(host, { kind: "worker", core: [] }, context); + return {}; + }, + { + hostPackages: { "@earendil-works/chord/next": chordNext }, + redact: (error) => ({ message: String(error) }), + onError: console.error, + }, +); diff --git a/packages/chord/test/next/examples/42-worker-crash/main.ts b/packages/chord/test/next/examples/42-worker-crash/main.ts new file mode 100644 index 000000000..37297f924 --- /dev/null +++ b/packages/chord/test/next/examples/42-worker-crash/main.ts @@ -0,0 +1,67 @@ +// A worker crash (slice 10): workers never restart by themselves. A crashed worker's address stays failed, its +// holders see `failed` with the error, and nothing forks until the app retries (a Retry button) or a rollout ships new +// code. Here: a session crashes, stays failed for a while, then Retry brings it back. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/42-worker-crash/main.ts +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { hostKind } from "../../../../src/next/contracts/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; +import { createWorkers } from "../../../../src/next-node/workers/index.ts"; +import { Process, Session } from "./plugins/sessions/contract.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { worker: { platform: "node", hostPackages: ["@earendil-works/chord/next"] } }, + onError: console.error, + }, + context, +); +const server = createHost({ address: hostKind("server")(), principal: { kind: "server" }, onError: () => {} }); +const workers = createWorkers( + server, + Session, + { + kind: "worker", + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + authorize: () => true, + principal: (address) => ({ kind: "worker", session: address.params.id }), + start: () => ({}), + lock: (address) => `example-42:${process.pid}:${address.key}`, + plugins, + onError: (error) => console.log(` reported: ${error.kind}`), + }, + context, +); +await server.load([], context); + +// 1. A held session (a tab showing it): its worker runs. +const session = server.hold(Process, { id: "S1" }, context); +const state = () => session.state.value.kind; +while (state() !== "live") await sleep(10); +console.log(`1. S1 runs in process ${session.pid.value}`); + +// 2. It crashes. The address is failed, and the holder sees why; it stays on the address's path. +await session.crash(context); +while (state() !== "failed") await sleep(10); +const failed = workers.state.value["session|S1"]; +console.log(`2. crashed: ${failed?.status === "failed" ? failed.error.message : failed?.status}`); + +// 3. No restart loop: a second later it is still failed, and no other worker was started. +await sleep(1_000); +console.log(`3. a second later: ${workers.state.value["session|S1"]?.status}, the holder: ${state()}`); + +// 4. Retry (the app's button): the worker starts again and the same holder binds to it. +await workers.retry(Session({ id: "S1" }), context); +while (state() !== "live") await sleep(10); +console.log(`4. after Retry: S1 runs in process ${session.pid.value}`); + +session.release(); +await workers.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/contract.ts b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/contract.ts new file mode 100644 index 000000000..0a995c248 --- /dev/null +++ b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/contract.ts @@ -0,0 +1,13 @@ +// A session lives in its own worker process: `Session({ id })` is its address. + +import { hostKind, int, method, remote, str } from "@earendil-works/chord/next"; + +export const Session = hostKind("session", { id: str() }); + +/** A session's process: its pid, and a way to make it crash. */ +export const Process = remote("example.process", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Session({ id }), + variants: { main: { state: { pid: int() }, methods: { crash: method() } } }, +}); diff --git a/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/package.json b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/package.json new file mode 100644 index 000000000..3819a6f05 --- /dev/null +++ b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/sessions", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/worker.ts b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/worker.ts new file mode 100644 index 000000000..cb8cae63c --- /dev/null +++ b/packages/chord/test/next/examples/42-worker-crash/plugins/sessions/worker.ts @@ -0,0 +1,17 @@ +// The sessions plugin's worker facet: its process id, and `crash`, which ends the process (a bug, an out-of-memory). + +import { constant, defineFacet } from "@earendil-works/chord/next"; +import { Process } from "./contract.ts"; + +export default defineFacet((host) => + host.serve(Process, () => ({ + variant: "main", + members: { + pid: constant(process.pid), + crash: async () => { + setTimeout(() => process.exit(13), 10); + return null; + }, + }, + })), +); diff --git a/packages/chord/test/next/examples/42-worker-crash/worker.ts b/packages/chord/test/next/examples/42-worker-crash/worker.ts new file mode 100644 index 000000000..2fb4fccaa --- /dev/null +++ b/packages/chord/test/next/examples/42-worker-crash/worker.ts @@ -0,0 +1,22 @@ +// The worker process: a host at the session's address, connected to the server, loading the plugins it was shipped. + +import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import { workerMain } from "../../../../src/next-node/workers/index.ts"; + +workerMain( + async ({ address, parent, plugins, onClose }, context) => { + const host = createHost({ address, principal: { kind: "worker" }, onError: console.error }); + onClose(() => host.close(BACKGROUND_CONTEXT)); + // Connect first: the server sees the worker started; then load. + host.connect(parent, { principal: { kind: "server" }, trusted: true }, context); + await plugins.load(host, { kind: "worker", core: [] }, context); + return {}; + }, + { + hostPackages: { "@earendil-works/chord/next": chordNext }, + redact: (error) => ({ message: String(error) }), + onError: console.error, + }, +); diff --git a/packages/chord/test/next/examples/43-rollout/main.ts b/packages/chord/test/next/examples/43-rollout/main.ts new file mode 100644 index 000000000..5fd443756 --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/main.ts @@ -0,0 +1,85 @@ +// A rollout across the server and its workers (slice 10): an edit of a plugin is built once by the server and rolled +// out step by step, the server first, then every running worker; each host reports what changed. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/43-rollout/main.ts +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { hostKind } from "../../../../src/next/contracts/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import { createPlugins, type RolloutHost } from "../../../../src/next-node/plugins/index.ts"; +import { createWorkers } from "../../../../src/next-node/workers/index.ts"; +import { Banner, Label, Session } from "./plugins/notes/contract.ts"; + +/** Waits until `done()` holds (a watcher's mark, a frame from a worker), checking every 10 ms. */ +async function waitUntil(done: () => boolean): Promise { + while (!done()) await new Promise((resolve) => setTimeout(resolve, 10)); +} + +/** One line for what a rollout step did on one of its hosts. */ +function describe(host: RolloutHost): string { + if ("report" in host) return `changed ${host.report.plugins.changed.join(", ")}`; + if ("stopped" in host) return "stopped"; + return "failed"; +} + +// The example edits a plugin: it works on a copy of `plugins/`. +const root = await mkdtemp(join(tmpdir(), "chord-example-43-")); +await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true }); + +const node = { platform: "node", hostPackages: ["@earendil-works/chord/next"] } as const; +const plugins = createPlugins( + { roots: [root], kinds: { server: node, worker: node }, onError: console.error }, + context, +); +const server = createHost({ address: hostKind("server")(), principal: { kind: "server" }, onError: console.error }); +const workers = createWorkers( + server, + Session, + { + kind: "worker", + entry: fileURLToPath(new URL("./worker.ts", import.meta.url)), + execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"], + authorize: () => true, + principal: (address) => ({ kind: "worker", session: address.params.id }), + start: () => ({}), + lock: (address) => `example-43:${process.pid}:${address.key}`, + plugins, + onError: console.error, + }, + context, +); +await plugins.load(server, { kind: "server", core: [], modules: { "@earendil-works/chord/next": chordNext } }, context); + +// 1. The server's banner and two running sessions. +const banner = await server.get(Banner, {}, context); +const s1 = await server.get(Label, { id: "S1" }, context); +const s2 = await server.get(Label, { id: "S2" }, context); +console.log(`1. ${banner.text.value} | ${s1.text.value} | ${s2.text.value}`); + +// 2. An edit: the watcher marks the plugin; one build serves both kinds. +await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n'); +await waitUntil(() => plugins.state.value.dirty.includes("@example/notes")); +const built = await plugins.build(plugins.state.value.dirty, context); + +// 3. The rollout: the server, then the workers (each running one reloads in parallel). +const result = await plugins.rollout([plugins.step("server", server), workers.step("workers")], built, context); +for (const step of result.steps) { + for (const host of step.hosts) console.log(`2. ${step.name}: ${host.host} ${describe(host)}`); +} + +// 4. Every host shows the new label (the workers' frames arrive over their links). +await waitUntil(() => s2.text.value === "S2: v2"); +console.log(`3. ${banner.text.value} | ${s1.text.value} | ${s2.text.value}`); +console.log(`4. still dirty: ${JSON.stringify(plugins.state.value.dirty)}`); + +banner.release(); +s1.release(); +s2.release(); +await workers.close(context); +await server.close(context); +plugins.close(); +await rm(root, { recursive: true, force: true }); diff --git a/packages/chord/test/next/examples/43-rollout/plugins/notes/contract.ts b/packages/chord/test/next/examples/43-rollout/plugins/notes/contract.ts new file mode 100644 index 000000000..86d192d7e --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/plugins/notes/contract.ts @@ -0,0 +1,16 @@ +// The notes' contracts: a label on the server, and one in each session's worker. + +import { hostKind, local, remote, str } from "@earendil-works/chord/next"; + +export const Session = hostKind("session", { id: str() }); + +/** The label as the server shows it. */ +export const Banner = local("example.banner", { version: 1, params: {}, members: { text: str() } }); + +/** The label as a session's worker shows it. */ +export const Label = remote("example.label", { + version: 1, + params: { id: str() }, + hostAddress: ({ id }) => Session({ id }), + variants: { main: { state: { text: str() } } }, +}); diff --git a/packages/chord/test/next/examples/43-rollout/plugins/notes/label.ts b/packages/chord/test/next/examples/43-rollout/plugins/notes/label.ts new file mode 100644 index 000000000..c8c560a6e --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/plugins/notes/label.ts @@ -0,0 +1,3 @@ +// The label every host shows; the example edits it. + +export const label = "v1"; diff --git a/packages/chord/test/next/examples/43-rollout/plugins/notes/package.json b/packages/chord/test/next/examples/43-rollout/plugins/notes/package.json new file mode 100644 index 000000000..d59f5a427 --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/43-rollout/plugins/notes/server.ts b/packages/chord/test/next/examples/43-rollout/plugins/notes/server.ts new file mode 100644 index 000000000..ca8c22e00 --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/plugins/notes/server.ts @@ -0,0 +1,7 @@ +// The notes plugin's server facet. + +import { constant, defineFacet } from "@earendil-works/chord/next"; +import { Banner } from "./contract.ts"; +import { label } from "./label.ts"; + +export default defineFacet((host) => host.serve(Banner, () => ({ text: constant(`server: ${label}`) }))); diff --git a/packages/chord/test/next/examples/43-rollout/plugins/notes/worker.ts b/packages/chord/test/next/examples/43-rollout/plugins/notes/worker.ts new file mode 100644 index 000000000..972e91925 --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/plugins/notes/worker.ts @@ -0,0 +1,9 @@ +// The notes plugin's worker facet. + +import { constant, defineFacet } from "@earendil-works/chord/next"; +import { Label } from "./contract.ts"; +import { label } from "./label.ts"; + +export default defineFacet((host) => + host.serve(Label, ({ id }) => ({ variant: "main", members: { text: constant(`${id}: ${label}`) } })), +); diff --git a/packages/chord/test/next/examples/43-rollout/worker.ts b/packages/chord/test/next/examples/43-rollout/worker.ts new file mode 100644 index 000000000..2fb4fccaa --- /dev/null +++ b/packages/chord/test/next/examples/43-rollout/worker.ts @@ -0,0 +1,22 @@ +// The worker process: a host at the session's address, connected to the server, loading the plugins it was shipped. + +import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import { workerMain } from "../../../../src/next-node/workers/index.ts"; + +workerMain( + async ({ address, parent, plugins, onClose }, context) => { + const host = createHost({ address, principal: { kind: "worker" }, onError: console.error }); + onClose(() => host.close(BACKGROUND_CONTEXT)); + // Connect first: the server sees the worker started; then load. + host.connect(parent, { principal: { kind: "server" }, trusted: true }, context); + await plugins.load(host, { kind: "worker", core: [] }, context); + return {}; + }, + { + hostPackages: { "@earendil-works/chord/next": chordNext }, + redact: (error) => ({ message: String(error) }), + onError: console.error, + }, +); diff --git a/packages/chord/test/next/examples/44-http-wraps/main.ts b/packages/chord/test/next/examples/44-http-wraps/main.ts new file mode 100644 index 000000000..af970636c --- /dev/null +++ b/packages/chord/test/next/examples/44-http-wraps/main.ts @@ -0,0 +1,37 @@ +// Wraps (slice 10): plugins wrap routes, their own or everyone's, in phases (`observe`, `headers`, `transform`, +// outermost first). Here the notes plugin wraps its own route to add a header, and an audit plugin wraps every route +// to log it; the audit plugin needs no route of its own. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/44-http-wraps/main.ts +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 }, onError: console.error }, context); +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }], modules }, context); +const { port } = await http.listening(context); + +for (const path of ["/notes", "/missing"]) { + console.log(`GET ${path}`); + const response = await fetch(`http://127.0.0.1:${port}${path}`); + console.log(` -> ${response.status}, Cache-Control: ${response.headers.get("cache-control")}`); +} +// The 404 has no route, so no wrap ran for it: the audit log shows only /notes, and it keeps the default +// `Cache-Control: no-store` that the notes wrap replaced on its own route. + +await http.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/44-http-wraps/plugins/audit/package.json b/packages/chord/test/next/examples/44-http-wraps/plugins/audit/package.json new file mode 100644 index 000000000..10a68118f --- /dev/null +++ b/packages/chord/test/next/examples/44-http-wraps/plugins/audit/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/audit", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/44-http-wraps/plugins/audit/server.ts b/packages/chord/test/next/examples/44-http-wraps/plugins/audit/server.ts new file mode 100644 index 000000000..e4259d470 --- /dev/null +++ b/packages/chord/test/next/examples/44-http-wraps/plugins/audit/server.ts @@ -0,0 +1,24 @@ +// The audit plugin: no routes of its own, one wrap around every route (`scope: "all"`), outermost (`observe`). + +import { defineFacet } from "@earendil-works/chord/next"; +import { Http } from "@earendil-works/chord/next/http"; + +export default defineFacet((_host, self) => + self.contribute(Http, { + wraps: [ + { + name: "log", + phase: "observe", + scope: "all", + wrap: async (request, next, ctx) => { + const response = await next(request); + const status = "status" in response ? response.status : 200; + console.log( + ` audit: ${ctx.principal === undefined ? "anonymous" : "someone"} ${request.method} ${request.path} ${status}`, + ); + return response; + }, + }, + ], + }), +); diff --git a/packages/chord/test/next/examples/44-http-wraps/plugins/notes/package.json b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/package.json new file mode 100644 index 000000000..d59f5a427 --- /dev/null +++ b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts new file mode 100644 index 000000000..0c2a83103 --- /dev/null +++ b/packages/chord/test/next/examples/44-http-wraps/plugins/notes/server.ts @@ -0,0 +1,20 @@ +// The notes plugin: a route, and its own wrap (around its own routes only) adding a header. + +import { defineFacet } from "@earendil-works/chord/next"; +import { Http } from "@earendil-works/chord/next/http"; + +export default defineFacet((_host, self) => + self.contribute(Http, { + routes: { "GET /notes": async () => ({ kind: "json", value: ["buy milk", "call mom"] }) }, + wraps: [ + { + name: "cache", + phase: "headers", + wrap: async (request, next) => { + const response = await next(request); + return { ...response, headers: { ...response.headers, "Cache-Control": "max-age=60" } }; + }, + }, + ], + }), +); diff --git a/packages/chord/test/next/examples/45-plugin-socket/main.ts b/packages/chord/test/next/examples/45-plugin-socket/main.ts new file mode 100644 index 000000000..9f10cd8b8 --- /dev/null +++ b/packages/chord/test/next/examples/45-plugin-socket/main.ts @@ -0,0 +1,64 @@ +// A plugin socket (slice 10): a plugin contributes a WebSocket endpoint for its own protocol (a terminal, voice). The +// server checks the origin and authenticates first: without a principal the upgrade is refused before plugin code. +// Here the app's authenticator reads a principal from a header (a real app: a signed cookie, `readCookie`). +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/45-plugin-socket/main.ts +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { WebSocket } from "ws"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { createHost, defineFacet } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +type Principal = { readonly kind: "server" } | { readonly kind: "user"; readonly name: string }; +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 }, onError: console.error }, context); +// The app's core facet: who someone is. +const app = defineFacet((_host, self) => { + self.contribute(chordHttp.Authenticators, (request) => { + const name = request.headers.get("x-user"); + return name === null ? undefined : { kind: "user", name }; + }); +}); +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +const core = [ + { name: "chord.http", facet: http.facet }, + { name: "@example/app", facet: app }, +]; +await plugins.load(server, { kind: "server", core, modules }, context); +const { port } = await http.listening(context); + +// 1. Without a principal: refused (403), the plugin never sees it. +const refused = new WebSocket(`ws://127.0.0.1:${port}/echo/lobby`); +const [, response] = await once(refused, "unexpected-response"); +console.log(`1. anonymous: refused ${response.statusCode}`); + +// 2. As ada: the plugin's handler greets, then echoes. +const socket = new WebSocket(`ws://127.0.0.1:${port}/echo/lobby`, { headers: { "x-user": "ada" } }); +const messages: string[] = []; +socket.on("message", (data) => messages.push(String(data))); +await once(socket, "open"); +socket.send("hello"); +// The greeting and the echo. +while (messages.length < 2) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +for (const message of messages) console.log(`2. ${message}`); + +// 3. Closing the component closes its sockets (1001, going away); then the host. +const closed = once(socket, "close"); +await http.close(context); +await server.close(context); +const [code] = await closed; +console.log(`3. closed with ${code}`); +plugins.close(); diff --git a/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/package.json b/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/package.json new file mode 100644 index 000000000..ed606991b --- /dev/null +++ b/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/echo", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/server.ts b/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/server.ts new file mode 100644 index 000000000..abf014eb3 --- /dev/null +++ b/packages/chord/test/next/examples/45-plugin-socket/plugins/echo/server.ts @@ -0,0 +1,16 @@ +// The echo plugin: a WebSocket of its own protocol at `/echo/:room`, after the server checked who is connecting. + +import { defineFacet } from "@earendil-works/chord/next"; +import { Http } from "@earendil-works/chord/next/http"; + +export default defineFacet((_host, self) => + self.contribute(Http, { + sockets: { + "/echo/:room": (socket, request, ctx) => { + const who = JSON.stringify(ctx.principal); + socket.send(`welcome to ${request.params.room}, ${who}`); + socket.on("message", (data) => socket.send(`echo: ${String(data)}`)); + }, + }, + }), +); diff --git a/packages/chord/test/next/examples/46-allowed-hosts/main.ts b/packages/chord/test/next/examples/46-allowed-hosts/main.ts new file mode 100644 index 000000000..67c346516 --- /dev/null +++ b/packages/chord/test/next/examples/46-allowed-hosts/main.ts @@ -0,0 +1,61 @@ +// Allowed hosts (slice 10): the server answers only requests on loopback or through a host a plugin contributes. A +// plugin can allow a host at runtime, a tunnel naming its public host, and take it back; links and sockets through a +// host no longer allowed are terminated at once. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/46-allowed-hosts/main.ts +import { request } from "node:http"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { local, method, str } from "../../../../src/next/contracts/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +/** The tunnel plugin's object, as the app's code declares it (the same contract). */ +const Tunnel = local("example.tunnel", { version: 1, params: {}, members: { open: method(str()), close: method() } }); + +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 }, onError: console.error }, context); +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }], modules }, context); +const { port } = await http.listening(context); + +/** GET /hello through `host` (as a tunnel would forward it), printed. */ +async function hello(host: string): Promise { + const answer = await new Promise((resolve) => { + const req = request({ host: "127.0.0.1", port, path: "/hello", headers: { host } }, (res) => { + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve(`${res.statusCode} ${body}`)); + }); + req.end(); + }); + console.log(` GET /hello via ${host} -> ${answer}`); +} + +const tunnel = await server.get(Tunnel, {}, context); +console.log("1. no tunnel yet"); +await hello(`localhost:${port}`); +await hello("notes.example.net"); +console.log("2. the tunnel opens"); +await tunnel.open("notes.example.net", context); +await hello("notes.example.net"); +console.log("3. the tunnel closes"); +await tunnel.close(context); +await hello("notes.example.net"); + +tunnel.release(); +await http.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/package.json b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/package.json new file mode 100644 index 000000000..7681eec3f --- /dev/null +++ b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/tunnel", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts new file mode 100644 index 000000000..2e1239c10 --- /dev/null +++ b/packages/chord/test/next/examples/46-allowed-hosts/plugins/tunnel/server.ts @@ -0,0 +1,31 @@ +// The tunnel plugin: when its tunnel opens, it allows the tunnel's public host (requests through it are https); when +// it closes, it takes the host back. Its contribution is a writable, so the router follows each change. + +import { defineFacet, local, method, str } from "@earendil-works/chord/next"; +import { Http, type HttpContribution } from "@earendil-works/chord/next/http"; + +export const Tunnel = local("example.tunnel", { + version: 1, + params: {}, + members: { open: method(str()), close: method() }, +}); + +export default defineFacet((host, self) => { + self.contribute(Http, { + routes: { "GET /hello": async (request) => ({ kind: "json", value: { origin: request.origin } }) }, + }); + host.serve(Tunnel, (_params, tunnel) => { + const allowed = tunnel.writable({ hosts: [] }); + tunnel.contribute(Http, allowed); + return { + open: async (name) => { + allowed.set({ hosts: [name] }); + return null; + }, + close: async () => { + allowed.set({ hosts: [] }); + return null; + }, + }; + }); +}); diff --git a/packages/chord/test/next/examples/47-http-own-server/main.ts b/packages/chord/test/next/examples/47-http-own-server/main.ts new file mode 100644 index 000000000..6595b2517 --- /dev/null +++ b/packages/chord/test/next/examples/47-http-own-server/main.ts @@ -0,0 +1,70 @@ +// Your own server (slice 10): the HTTP component is a Web-standard `fetch` handler, so an app with a server of its own +// mounts it instead of Chord's listener. Here a plain Node server converts every request with `nodeRequest` (the +// listener's checks: a malformed target refused, a second `Host` kept for admission to refuse; and the connection: +// loopback requests count as local only with it), passes it to `http.fetch`, and every upgrade to `http.upgrade`. +// With Hono the same is one line: `app.all("/*", (c) => http.fetch(c.req.raw, connection))`. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/47-http-own-server/main.ts +import { createServer, request } from "node:http"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { createHost } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +// 1. The component, without a listener: no `listen` setting. +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { onError: console.error }, context); +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +await plugins.load(server, { kind: "server", core: [{ name: "chord.http", facet: http.facet }], modules }, context); + +// 2. The app's own server: a Web `Request` in, a Web `Response` out (each header line kept: several `Set-Cookie`). A +// real app adds its own routes around it. +const own = createServer(async (message, res) => { + const converted = chordHttp.nodeRequest(message, res); + if (converted.kind === "refused") return void res.writeHead(converted.status).end(); + const response = await http.fetch(converted.request, converted.connection); + for (const [name, value] of response.headers) res.appendHeader(name, value); + res.writeHead(response.status).end(Buffer.from(await response.arrayBuffer())); +}); +own.on("upgrade", http.upgrade); +await new Promise((resolve) => own.listen(0, "127.0.0.1", resolve)); +const address = own.address(); +const port = typeof address === "object" && address !== null ? address.port : 0; + +// 3. Requests through the app's server get the same routes, checks and headers. +for (const path of ["/notes", "/nowhere"]) { + const response = await fetch(`http://127.0.0.1:${port}${path}`); + console.log(`GET ${path} -> ${response.status} ${await response.text()}`); + console.log(` x-content-type-options: ${response.headers.get("x-content-type-options")}`); +} + +// 4. What a Web `Request` cannot carry is refused at the conversion, as Chord's listener refuses it; the app's server +// lives on (`TRACE`: 501; a `Host` with userinfo: 403). +for (const [method, host] of [ + ["TRACE", `127.0.0.1:${port}`], + ["GET", `attacker@127.0.0.1:${port}`], +] as const) { + const status = await new Promise((resolve) => { + request({ host: "127.0.0.1", port, method, path: "/notes", headers: { host } }, (response) => { + response.resume(); + resolve(response.statusCode); + }).end(); + }); + console.log(`${method} /notes (Host: ${host}) -> ${status}`); +} + +// 5. The app closes its server and the component (it refuses, then waits for running requests), then the host. +await new Promise((resolve) => own.close(resolve)); +await http.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/47-http-own-server/plugins/notes/package.json b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/package.json new file mode 100644 index 000000000..d59f5a427 --- /dev/null +++ b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts new file mode 100644 index 000000000..2b7a0abd9 --- /dev/null +++ b/packages/chord/test/next/examples/47-http-own-server/plugins/notes/server.ts @@ -0,0 +1,12 @@ +// The notes plugin's server facet: one route, the same whichever server runs it. + +import { defineFacet } from "@earendil-works/chord/next"; +import { Http } from "@earendil-works/chord/next/http"; + +export default defineFacet((_host, self) => + self.contribute(Http, { + routes: { + "GET /notes": async (request) => ({ kind: "json", value: { notes: ["buy milk"], via: request.origin } }), + }, + }), +); diff --git a/packages/chord/test/next/examples/48-http-baseline/main.ts b/packages/chord/test/next/examples/48-http-baseline/main.ts new file mode 100644 index 000000000..764fcce21 --- /dev/null +++ b/packages/chord/test/next/examples/48-http-baseline/main.ts @@ -0,0 +1,84 @@ +// The security baseline (slice 10): on by default, the app decides only who someone is. Here the app signs people in +// with a signed cookie (`cookie`, `readCookie`) and the notes plugin contributes a page, routes and a webhook. Requests +// another site's page makes in a browser (Fetch Metadata says so) are never authenticated, a foreign `Origin` is +// refused, but a link to a page still opens it signed in, and a cross-site webhook runs anonymous. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/examples/48-http-baseline/main.ts +import { request } from "node:http"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../../src/context/index.ts"; +import { createHost, defineFacet } from "../../../../src/next/host/index.ts"; +import * as chordNext from "../../../../src/next/index.ts"; +import * as chordHttp from "../../../../src/next-node/http/index.ts"; +import { createPlugins } from "../../../../src/next-node/plugins/index.ts"; + +type Principal = { readonly kind: "server" } | { readonly kind: "user"; readonly name: string }; +const secret = "a long random secret"; +const server = createHost({ principal: { kind: "server" }, onError: console.error }); +const http = chordHttp.createHttp(server, { listen: { host: "127.0.0.1", port: 0 }, onError: console.error }, context); + +// 1. The app's core facet: a sign-in route setting the cookie, and an authenticator reading it back. +const app = defineFacet((_host, self) => { + self.contribute(chordHttp.Http, { + routes: { + "POST /sign-in": async (request) => { + const set = chordHttp.cookie("user", String(await request.json()), { secret, request, sameSite: "Lax" }); + return { kind: "json", value: "signed in", headers: { "Set-Cookie": set } }; + }, + }, + }); + self.contribute(chordHttp.Authenticators, (request) => { + const name = chordHttp.readCookie(request, "user", secret); + return name === undefined ? undefined : { kind: "user", name }; + }); +}); +const modules = { "@earendil-works/chord/next": chordNext, "@earendil-works/chord/next/http": chordHttp }; +const plugins = createPlugins( + { + roots: [fileURLToPath(new URL("./plugins/", import.meta.url))], + kinds: { server: { platform: "node", hostPackages: Object.keys(modules) } }, + onError: console.error, + }, + context, +); +const core = [ + { name: "chord.http", facet: http.facet }, + { name: "@example/app", facet: app }, +]; +await plugins.load(server, { kind: "server", core, modules }, context); +const { port } = await http.listening(context); +const base = `http://127.0.0.1:${port}`; + +// 2. Ada signs in; her browser keeps the cookie. +const signIn = await fetch(`${base}/sign-in`, { method: "POST", body: '"ada"' }); +const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!; +console.log(`1. signed in: ${cookie.slice(0, 12)}…`); + +/** A request from Ada's browser: her cookie, and what Fetch Metadata says about who made it (`fetch` sets its own). */ +async function browser(label: string, method: string, path: string, headers: Record = {}, body = "") { + const answer = await new Promise((resolve) => { + const sent = request({ host: "127.0.0.1", port, method, path, headers: { cookie, ...headers } }, (response) => { + let text = ""; + response.on("data", (chunk) => { + text += chunk; + }); + response.on("end", () => resolve(`${response.statusCode} ${text}`)); + }); + sent.end(body); + }); + console.log(`${label}: ${answer}`); +} + +const ours = { "sec-fetch-site": "same-origin", origin: base }; +const evil = { "sec-fetch-site": "cross-site", origin: "https://evil.example" }; +await browser("2. our page reads the notes", "GET", "/notes", ours); +await browser("3. our page adds a note", "POST", "/notes", ours, '"call mom"'); +await browser("4. evil.example posts a note", "POST", "/notes", evil, '"pwned"'); +await browser("5. evil.example reads the notes", "GET", "/notes", { "sec-fetch-site": "cross-site" }); +const link = { "sec-fetch-site": "cross-site", "sec-fetch-mode": "navigate", "sec-fetch-dest": "document" }; +await browser("6. a link on evil.example to the page", "GET", "/", link); +await browser("7. a build server's webhook", "POST", "/hooks/build", { origin: "https://ci.example" }); + +await http.close(context); +await server.close(context); +plugins.close(); diff --git a/packages/chord/test/next/examples/48-http-baseline/plugins/notes/package.json b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/package.json new file mode 100644 index 000000000..d59f5a427 --- /dev/null +++ b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@example/notes", + "private": true, + "type": "module" +} diff --git a/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts new file mode 100644 index 000000000..0980b62f1 --- /dev/null +++ b/packages/chord/test/next/examples/48-http-baseline/plugins/notes/server.ts @@ -0,0 +1,33 @@ +// The notes plugin's server facet: the notes page (a link from another site may open it), the notes as JSON, adding +// a note, and a webhook another site calls (cross-site: it always runs anonymous). + +import { defineFacet } from "@earendil-works/chord/next"; +import { Http } from "@earendil-works/chord/next/http"; + +const notes = ["buy milk"]; + +export default defineFacet((_host, self) => + self.contribute(Http, { + routes: { + "GET /": { + page: true, + handler: async (_request, ctx) => ({ kind: "html", body: `hi ${who(ctx.principal)}` }), + }, + "GET /notes": async (_request, ctx) => ({ kind: "json", value: { notes, as: who(ctx.principal) } }), + "POST /notes": async (request, ctx) => { + if (ctx.principal === undefined) return { kind: "error", status: 401 }; + notes.push(String(await request.json())); + return { kind: "json", value: notes }; + }, + "POST /hooks/build": { + crossSite: true, + handler: async (_request, ctx) => ({ kind: "json", value: who(ctx.principal) }), + }, + }, + }), +); + +/** Who a principal is, for the answers. */ +function who(principal: unknown): string { + return principal === undefined ? "anonymous" : JSON.stringify(principal); +} diff --git a/packages/chord/test/next/host/revocation.test.ts b/packages/chord/test/next/host/revocation.test.ts index 22bc7a9de..a73b222f8 100644 --- a/packages/chord/test/next/host/revocation.test.ts +++ b/packages/chord/test/next/host/revocation.test.ts @@ -536,6 +536,11 @@ describe("revocation of this host's own requests over its paths (fix round 1)", it("ends a guest's reference over a route link that dropped, and nothing holds it on the next one", async () => { const routed: RawPeer[] = []; + // The drop resolves again at once (slice 10 R2); the next link comes only after the revocation. + let next: () => void = () => {}; + const revoked = new Promise((resolve) => { + next = resolve; + }); const host = createHost({ principal: { kind: "owner" }, revokedBy: guestRevokedBy, @@ -546,6 +551,7 @@ describe("revocation of this host's own requests over its paths (fix round 1)", { authorize: () => true, resolve: async () => { + if (routed.length > 0) await revoked; const [a, b] = memoryLinkPair(); routed.push(new RawPeer(b)); return { link: a, principal: { kind: "owner" }, codec: testCodec() }; @@ -569,6 +575,7 @@ describe("revocation of this host's own requests over its paths (fix round 1)", first.close(); await transported(); host.revoke("g", context); + next(); await transported(); expect(states).toEqual(["app.remote: revoked"]); expect(routed.flatMap((peer, i) => (i === 0 ? [] : peer.received.filter(({ kind }) => kind === "hold")))).toEqual( diff --git a/packages/chord/test/next/host/root.test.ts b/packages/chord/test/next/host/root.test.ts index 76c1c2b7e..0847d0477 100644 --- a/packages/chord/test/next/host/root.test.ts +++ b/packages/chord/test/next/host/root.test.ts @@ -1,7 +1,7 @@ import { getEventListeners } from "node:events"; import { describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT as context, withAbortSignal, withCancel } from "../../../src/context/index.ts"; -import { int, local, method } from "../../../src/next/contracts/index.ts"; +import { hostKind, int, local, method, remote, str } from "../../../src/next/contracts/index.ts"; import { type Ctx, createHost, defineFacet, sleep } from "../../../src/next/host/index.ts"; import { constant } from "../../../src/next/state/index.ts"; import { expectCollected, flushed, loaded, rejection, type TestPrincipal } from "../support.ts"; @@ -33,6 +33,39 @@ describe("host.root", () => { await host.close(context); }); + it("runs anonymous with an explicit undefined principal; only an omitted one takes the host's (slice 10 §6)", async () => { + const host = createHost({ principal: { kind: "server" }, onError: () => {} }); + await host.load([], context); + expect(await host.root(async (ctx) => ctx.principal, context, { principal: undefined })).toBeUndefined(); + expect(await host.root(async (ctx) => ctx.principal, context, {})).toEqual({ kind: "server" }); + await host.close(context); + }); + + // Opus's build review F5: an anonymous scope's `ctx.get` of a remote type served here failed on a WeakMap key. + it("refuses an anonymous scope a remote type served here (forbidden); a local one it gets", async () => { + const Server = hostKind("server"); + const Secrets = remote("app.secrets", { + version: 1, + params: {}, + hostAddress: () => Server(), + variants: { main: { methods: { read: method(undefined, str()) } } }, + }); + const host = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); + const facet = defineFacet((h) => { + serveInfo(h); + h.serve(Secrets, () => ({ variant: "main", members: { read: async () => "secret" } })); + }); + await host.load([{ name: "@app/secrets", facet }], context); + const anonymous = { principal: undefined }; + const refused = await rejection(host.root(async (ctx) => ctx.get(Secrets, {}), context, anonymous)); + expect(refused).toMatchObject({ + code: "forbidden", + message: "app.secrets: an anonymous caller gets no remote object", + }); + expect(await host.root(async (ctx) => (await ctx.get(Info, { id: 3 })).id.value, context, anonymous)).toBe(3); + await host.close(context); + }); + it("seals the context when fn settles: its references released, its signal aborted, `ctx.get` refused `released`", async () => { const { host } = await loaded(serveInfo); let kept: Ctx | undefined; diff --git a/packages/chord/test/next/host/route.test.ts b/packages/chord/test/next/host/route.test.ts index 756ae9429..3d9fbf1f8 100644 --- a/packages/chord/test/next/host/route.test.ts +++ b/packages/chord/test/next/host/route.test.ts @@ -119,39 +119,71 @@ forEachCodec(() => { }); }); - describe("resolve failures and re-resolves", () => { + describe("resolve failures and re-resolves (no timer, slice 10 R2)", () => { fakeTimers(); - it("a rejected resolve leaves the holds loading, is reported, and resolves again after the address's backoff (race 2)", async () => { - let failures = 2; - const { tab, resolves, errors } = await relayed({ + it("a rejected resolve fails its holders waiting with its error, is reported, and runs no timer (race 2)", async () => { + let attempts = 0; + const { tab, server, errors } = await relayed({ + resolve: async () => { + attempts++; + throw new Error("no worker"); + }, + }); + // One relayed from the tab, one the server's own. + const notes = tab.hold(PlacedNotes, { session: "S1" }, context); + const own = server.hold(PlacedNotes, { session: "S1" }, context); + await transported(); + for (const ref of [notes, own]) { + expect(ref.state.value).toMatchObject({ + kind: "failed", + cause: { kind: "waiting", error: { code: "unavailable", message: "session|S1: no worker" } }, + }); + } + expect(errors.server).toMatchObject([{ kind: "resolve_failed", address: "session|S1" }]); + await elapse(120_000); + expect(attempts).toBe(1); + }); + + it("a new hold resolves a failed address again; the earlier holders bind on its link without a re-hold (Q1)", async () => { + let fail = true; + const { tab, resolves } = await relayed({ resolve: async (_address, worker) => { - if (failures-- > 0) throw new Error("no worker yet"); + if (fail) throw new Error("no worker"); + return worker(); + }, + }); + const first = tab.hold(PlacedNotes, { session: "S1" }, context); + await transported(); + expect(first.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } }); + fail = false; + const second = tab.hold(PlacedNotes, { session: "S1" }, context); + await transported(); + expect(resolves).toEqual(["session|S1"]); + expect([first.state.value, second.state.value]).toEqual([{ kind: "live" }, { kind: "live" }]); + }); + + it("RouteHandle.retry resolves a failed address now; a retry of an address nobody holds keeps nothing", async () => { + let fail = true; + const { tab, route, resolves } = await relayed({ + resolve: async (_address, worker) => { + if (fail) throw new Error("no worker"); return worker(); }, }); const notes = tab.hold(PlacedNotes, { session: "S1" }, context); await transported(); - expect(notes.state.value).toEqual({ kind: "loading" }); - expect(errors.server).toMatchObject([{ kind: "resolve_failed", address: "session|S1" }]); - await elapse(999); - expect(errors.server).toHaveLength(1); - await elapse(1); - // The second failure doubles the window. - expect(errors.server).toHaveLength(2); - await elapse(1999); - expect(notes.state.value).toEqual({ kind: "loading" }); - await elapse(1); - expect(resolves).toEqual(["session|S1"]); + fail = false; + route.retry(Session({ id: "S1" })); + route.retry(Session({ id: "S9" })); + await transported(); expect(notes.state.value).toEqual({ kind: "live" }); + expect(resolves).toEqual(["session|S1"]); }); - it("a returned link that is closed already starts the backoff", async () => { - let closed = true; - const { tab, resolves } = await relayed({ - resolve: async (address, worker) => { - if (!closed) return worker(); - closed = false; + it("a returned link closed already fails the holders", async () => { + const { tab } = await relayed({ + resolve: async (address) => { const [a, b] = memoryLinkPair(); b.close(); await flushed(); @@ -160,20 +192,35 @@ forEachCodec(() => { }); const notes = tab.hold(PlacedNotes, { session: "S1" }, context); await transported(); - expect(notes.state.value).toEqual({ kind: "loading" }); - await elapse(1000); - expect(resolves).toEqual(["session|S1"]); - expect(notes.state.value).toEqual({ kind: "live" }); + expect(notes.state.value).toMatchObject({ + kind: "failed", + cause: { kind: "waiting", error: { message: "session|S1: the link closed before its handshake" } }, + }); }); - it("a dropped link resolves again while held: references go loading, then live on a fresh worker (race 6)", async () => { + it("a link closing before its handshake fails its holders: no re-resolve loop over a worker dying at start", async () => { + let attempts = 0; + const { tab } = await relayed({ + resolve: async (address) => { + attempts++; + const [a, b] = memoryLinkPair(); + setTimeout(() => b.close(), 10); + return { link: a, principal: { kind: "worker", session: address.params.id } }; + }, + }); + const notes = tab.hold(PlacedNotes, { session: "S1" }, context); + await elapse(10); + expect(notes.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } }); + await elapse(120_000); + expect(attempts).toBe(1); + }); + + it("a dropped link that completed its handshake resolves again at once, once (race 6)", async () => { const { tab, resolves, routeLinks, workers } = await relayed(); const notes = await tab.get(PlacedNotes, { session: "S1" }, context); const first = workers.get("session|S1")!; routeLinks[0]!.close(); await transported(); - expect(notes.state.value).toEqual({ kind: "loading" }); - await elapse(1000); expect(resolves).toEqual(["session|S1", "session|S1"]); expect(workers.get("session|S1")).not.toBe(first); expect(notes.state.value).toEqual({ kind: "live" }); @@ -182,42 +229,13 @@ forEachCodec(() => { expect(notes.notes.value).toEqual([{ text: "fresh" }]); }); - it("only a link that completed its handshake resets the backoff", async () => { - let silent = 0; - const { tab, resolves, routeLinks } = await relayed({ - resolve: async (address, worker) => { - if (silent-- <= 0) return worker(); - // A link whose other end never greets and closes. - const [a, b] = memoryLinkPair(); - setTimeout(() => b.close(), 10); - return { link: a, principal: { kind: "worker", session: address.params.id } }; - }, - }); - const notes = await tab.get(PlacedNotes, { session: "S1" }, context); - silent = 1; - routeLinks[0]!.close(); - await elapse(1000); - // The silent link closed before its handshake: the window doubles. - await elapse(10); - await elapse(1999); - expect(resolves).toEqual(["session|S1"]); - await elapse(1); - expect(resolves).toEqual(["session|S1", "session|S1"]); - expect(notes.state.value).toEqual({ kind: "live" }); - // That link greeted: the next drop starts again at 1 s. - routeLinks.at(-1)!.close(); - await elapse(1000); - expect(resolves).toHaveLength(3); - }); - - it("a link whose peer never greets closes after 30 s of silence; the address backs off, then resolves again while held", async () => { + it("a link whose peer never greets closes after 30 s of silence and fails its holders", async () => { // The heartbeat's timer too, before the hosts start it. vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date"], now: 0 }); - let silent = true; - const { tab, resolves } = await relayed({ - resolve: async (address, worker) => { - if (!silent) return worker(); - silent = false; + let attempts = 0; + const { tab } = await relayed({ + resolve: async (address) => { + attempts++; // Its other end is never read: no hello, no close. const [a] = memoryLinkPair(); return { link: a, principal: { kind: "worker", session: address.params.id } }; @@ -225,14 +243,12 @@ forEachCodec(() => { }); const notes = tab.hold(PlacedNotes, { session: "S1" }, context); await elapse(29_000); - expect(resolves).toEqual([]); expect(notes.state.value).toEqual({ kind: "loading" }); - // The heartbeat checks every 1.5 s: the silent link closes within it, then its address waits 1 s. + // The heartbeat checks every 1.5 s: the silent link closes within it. await elapse(2_500); - await elapse(1_000); - expect(resolves).toEqual(["session|S1"]); - await transported(); - expect(notes.state.value).toEqual({ kind: "live" }); + expect(notes.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } }); + await elapse(120_000); + expect(attempts).toBe(1); }); it("an address without holds is not resolved again after its link drops", async () => { @@ -276,6 +292,42 @@ forEachCodec(() => { }); }); + describe("RouteHandle.suspend (R1, Q3)", () => { + it("resolves nothing while held: the holders fail waiting suspended once the link is gone; retry lifts it", async () => { + const { tab, route, resolves, routeLinks } = await relayed(); + const notes = await tab.get(PlacedNotes, { session: "S1" }, context); + route.suspend(Session({ id: "S1" })); + expect(notes.state.value).toEqual({ kind: "live" }); + routeLinks[0]!.close(); + await transported(); + expect(notes.state.value).toMatchObject({ + kind: "failed", + cause: { kind: "waiting", error: { code: "unavailable", message: "session|S1: suspended" } }, + }); + const another = tab.hold(PlacedNotes, { session: "S1" }, context); + await transported(); + expect(another.state.value).toMatchObject({ kind: "failed", cause: { kind: "waiting" } }); + expect(resolves).toEqual(["session|S1"]); + route.retry(Session({ id: "S1" })); + await transported(); + expect(resolves).toEqual(["session|S1", "session|S1"]); + expect([notes.state.value, another.state.value]).toEqual([{ kind: "live" }, { kind: "live" }]); + }); + + it("goes with the last holder: a later hold resolves on demand; an unheld address is never suspended", async () => { + const { tab, route, resolves, routeLinks } = await relayed(); + const notes = await tab.get(PlacedNotes, { session: "S1" }, context); + route.suspend(Session({ id: "S1" })); + routeLinks[0]!.close(); + await transported(); + notes.release(); + await transported(); + route.suspend(Session({ id: "S1" })); + await tab.get(PlacedNotes, { session: "S1" }, context); + expect(resolves).toEqual(["session|S1", "session|S1"]); + }); + }); + describe("host.close during a resolve (race 7)", () => { it("aborts the resolve's context and closes a link returned afterwards", async () => { let routing: AbortSignal | undefined; @@ -375,6 +427,37 @@ forEachCodec(() => { }); describe("GC", () => { + it("a failed or suspended address keeps no route state once its holders are released", async () => { + const { refs, server } = await (async () => { + const refs: WeakRef[] = []; + const { tab, server, route, routeLinks, workers, errors } = await relayed({ + authorize: (_principal, address) => { + refs.push(new WeakRef(address)); + return true; + }, + resolve: async (address, worker) => { + if (address.params.id === "failed") throw new Error("no worker"); + return worker(); + }, + }); + const failed = tab.hold(PlacedNotes, { session: "failed" }, context); + const held = await tab.get(PlacedNotes, { session: "held" }, context); + route.suspend(Session({ id: "held" })); + routeLinks[0]!.close(); + await transported(); + for (const ref of [failed, held]) ref.release(); + await transported(); + // The reported error's stack keeps the test's `resolve` frame (and its address) alive, the worker host its + // own address: not the route's. + errors.server.length = 0; + routeLinks.length = 0; + workers.clear(); + return { refs, server }; + })(); + await expectCollected(...refs); + expect(server).toBeDefined(); + }); + it("a closed route link and its released references keep no route state", async () => { const { refs, tab } = await (async () => { const { tab, routeLinks, workers } = await relayed(); diff --git a/packages/chord/test/next/remote/host-link.test.ts b/packages/chord/test/next/remote/host-link.test.ts index c5205ea12..a3da2f05d 100644 --- a/packages/chord/test/next/remote/host-link.test.ts +++ b/packages/chord/test/next/remote/host-link.test.ts @@ -3,7 +3,7 @@ import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { int, local, method, remote, str } from "../../../src/next/contracts/index.ts"; import { createHost, defineFacet, type FacetHost, type HostError } from "../../../src/next/host/index.ts"; import { type Compression, memoryLinkPair, type PartLimits } from "../../../src/next/link/index.ts"; -import { jsonPart } from "../../../src/next/link/internal.ts"; +import { jsonPart, type Message } from "../../../src/next/link/internal.ts"; import { constant } from "../../../src/next/state/index.ts"; import { nodeCompression } from "../../../src/next-node/compression.ts"; import { @@ -19,12 +19,15 @@ import { partValue, RAW_PEER, RawPeer, + rawLinked, rawWorkers, rejection, relayed, + SERVER_ADDRESS, Server, Session, serveNotes, + settledHeap, type TestPrincipal, testCodec, transported, @@ -431,3 +434,76 @@ forEachCodec(() => { }); }); }); + +describe("origins only from relays (decision, slice 10 security audit K1/K2)", () => { + const { name: type, version, fingerprint } = Notes; + const hold = { + kind: "hold", + hold: 0, + type, + version, + fingerprint, + params: jsonPart({ session: "S1" }), + address: SERVER_ADDRESS, + }; + const withOrigin: Record = { + hold: { ...hold, origin: 1 } as Message, + call: { kind: "call", origin: 1, call: 0, hold: 0, method: EDITOR.methods.wait }, + release: { kind: "release", origin: 1, hold: 0 }, + abort: { kind: "abort", origin: 1, call: 0 }, + retry: { kind: "retry", origin: 1, hold: 0, hops: 1 } as Message, + ack: { kind: "ack", origin: 1 }, + closed: { kind: "closed", origin: 1 }, + }; + + for (const [kind, message] of Object.entries(withOrigin)) { + it(`closes a child link sending ${kind} with an origin (a protocol error)`, async () => { + const { peer, errors } = await rawLinked((h) => void serveNotes(h)); + peer.send(message); + await transported(); + expect(peer.closed).toBe(true); + expect(errors).toMatchObject([ + { kind: "protocol_error", error: { message: `${kind}: an origin on a child link` } }, + ]); + }); + } + + it("honours origins on a parent link (a relay's): one window of calls per origin", async () => { + let probe!: ReturnType; + const { peer } = await rawLinked( + (h) => { + probe = serveNotes(h); + }, + { role: "parent" }, + ); + peer.send({ ...hold, origin: 1 } as Message); + peer.send({ ...hold, origin: 2 } as Message); + await transported(); + for (const origin of [1, 2]) peer.send({ kind: "call", origin, call: 0, hold: 0, method: EDITOR.methods.wait }); + await transported(); + expect([peer.closed, probe.waits.length]).toEqual([false, 2]); + }); + + it("keeps no record of an origin that holds nothing: 20,000 fresh origins held and released", async () => { + const { peer } = await rawLinked((h) => void serveNotes(h), { role: "parent" }); + const churn = async (from: number, to: number) => { + for (let origin = from; origin <= to; origin++) { + peer.send({ ...hold, origin } as Message); + peer.send({ kind: "release", origin, hold: 0 }); + if (origin % 1_000 === 0) { + await transported(); + peer.received.length = 0; + } + } + await transported(); + peer.received.length = 0; + }; + // A warm-up first: what a first run allocates for good (compiled code, caches) is no record. + await churn(1, 2_000); + const before = await settledHeap(); + await churn(2_001, 22_000); + // About 1 KiB per origin before: 20 MiB. + expect((await settledHeap()) - before).toBeLessThan(512 * 1024); + expect(peer.closed).toBe(false); + }, 60_000); +}); diff --git a/packages/chord/test/next/remote/path.test.ts b/packages/chord/test/next/remote/path.test.ts index c3efb197d..ff58b786c 100644 --- a/packages/chord/test/next/remote/path.test.ts +++ b/packages/chord/test/next/remote/path.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ChordError } from "../../../src/next/data/index.ts"; import type { HostLink } from "../../../src/next/remote/host-link.ts"; import { Path, type Upstream } from "../../../src/next/remote/path.ts"; @@ -67,4 +68,23 @@ describe("a path", () => { path.connected(linkOf("2")); expect(log).toEqual(["a@1", "b@1"]); }); + + it("says what changed: a holder attached without a link, a drop, the last release; fails its holders waiting", () => { + const events: string[] = []; + const failed: string[] = []; + const path = new Path((event) => events.push(event.kind)); + const a = { + attach: () => {}, + dropped: () => {}, + unreachable: (error: { readonly message: string }) => void failed.push(error.message), + } as unknown as Upstream; + path.attach(a); + path.unreachable(new ChordError({ code: "unavailable", message: "no worker" })); + const link = linkOf("1"); + path.connected(link); + path.disconnected(link); + path.detach(a); + expect(events).toEqual(["attached", "dropped", "released"]); + expect(failed).toEqual(["no worker"]); + }); }); diff --git a/packages/chord/test/next/remote/provider.test.ts b/packages/chord/test/next/remote/provider.test.ts index dc9f30aed..5abb539e3 100644 --- a/packages/chord/test/next/remote/provider.test.ts +++ b/packages/chord/test/next/remote/provider.test.ts @@ -74,13 +74,13 @@ const READER = wireNames(Notes, "reader"); const { add, fail, wait } = EDITOR.methods as { add: number; fail: number; wait: number }; /** A server serving `Notes` and a raw consumer peer stamped `child`. */ -async function notesServer(child: TestPrincipal = { kind: "owner" }) { +async function notesServer(child: TestPrincipal = { kind: "owner" }, role: "child" | "parent" = "child") { let probe: NotesProbe | undefined; const linked = await rawLinked( (h) => { probe = serveNotes(h); }, - { child }, + { child, role }, ); return { ...linked, probe: probe! }; } @@ -551,7 +551,7 @@ function serveGrid(h: FacetHost): void { } /** A server serving `Notes` and `Grid` to a raw peer that acks only when told, the link's context counting. */ -async function slowConsumer() { +async function slowConsumer(role: "child" | "parent" = "child") { const counting = await metered(); const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {} }); await server.load( @@ -567,7 +567,9 @@ async function slowConsumer() { context, ); const [a, b] = memoryLinkPair(); - server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, counting.context); + const options = { principal: { kind: "owner" }, codec: testCodec() } as const; + if (role === "parent") server.connect(a, options, counting.context); + else server.accept(a, options, counting.context); const peer = new RawPeer(b, { ack: false }); await transported(); return { server, peer, counts: counting.counts }; @@ -667,9 +669,9 @@ forEachCodec(() => { }); }); - describe("origins at the provider", () => { + describe("origins at the provider (a relay's: on its parent link)", () => { it("key holds and calls by (origin, id): the same ids on two origins are two holds; answers carry the origin", async () => { - const { peer } = await slowConsumer(); + const { peer } = await slowConsumer("parent"); hold(peer, 0, Notes, { session: "S1" }); hold(peer, 0, Notes, { session: "S2" }, {}, 3); const [own, relayed] = [await peer.next("bound"), await peer.next("bound")]; @@ -685,7 +687,7 @@ forEachCodec(() => { }); it("keep a window per origin: a stalled origin folds its commits, another keeps receiving each", async () => { - const { server, peer } = await slowConsumer(); + const { server, peer } = await slowConsumer("parent"); hold(peer, 0, Notes, { session: "S1" }); hold(peer, 0, Notes, { session: "S1" }, {}, 1); await peer.next("bound"); @@ -710,7 +712,7 @@ forEachCodec(() => { }); it("count calls per origin against N: 256 on each of two origins run, one more on one closes the link", async () => { - const { peer, errors } = await notesServer(); + const { peer, errors } = await notesServer({ kind: "owner" }, "parent"); hold(peer, 0, Notes, { session: "S1" }); hold(peer, 0, Notes, { session: "S1" }, {}, 1); await peer.next("bound"); @@ -730,7 +732,7 @@ forEachCodec(() => { }); it("ignore a call on a hold they do not know before counting it (Q1)", async () => { - const { peer, errors } = await notesServer(); + const { peer, errors } = await notesServer({ kind: "owner" }, "parent"); hold(peer, 0, Notes, { session: "S1" }); await peer.next("bound"); for (let id = 0; id < 256; id++) call(peer, id, 0, wait); @@ -742,7 +744,7 @@ forEachCodec(() => { }); it("closed releases the origin's holds and aborts its calls; another origin keeps its own", async () => { - const { peer, probe, server } = await notesServer(); + const { peer, probe, server } = await notesServer({ kind: "owner" }, "parent"); hold(peer, 0, Notes, { session: "S1" }); hold(peer, 0, Notes, { session: "S2" }, {}, 2); await peer.next("bound"); @@ -824,7 +826,7 @@ forEachCodec(() => { push.call(this, ops); }; try { - const { server, peer } = await slowConsumer(); + const { server, peer } = await slowConsumer("parent"); hold(peer, 0, Notes, { session: "S1" }, {}, 1); await peer.next("bound"); for (const text of ["a", "b", "c"]) await addNote(server, "S1", text); diff --git a/packages/chord/test/next/remote/relay.test.ts b/packages/chord/test/next/remote/relay.test.ts index 94148dccd..6f08a6f14 100644 --- a/packages/chord/test/next/remote/relay.test.ts +++ b/packages/chord/test/next/remote/relay.test.ts @@ -22,6 +22,7 @@ import { rejection, relayed, Server, + Session, serveNotes, type TestPrincipal, testCodec, @@ -352,8 +353,8 @@ forEachCodec(() => { first.close(); expect(await rejection(call)).toMatchObject({ code: "unavailable" }); await transported(); + // The link had completed its handshake: its address resolves again at once (no timer, slice 10 R2). expect(notes.state.value).toEqual({ kind: "loading" }); - await vi.advanceTimersByTimeAsync(1000); const second = await workerAt(workers, 1); const again = await second.next("hold"); expect(again).toMatchObject({ hold: held.hold, origin: held.origin }); @@ -366,7 +367,13 @@ forEachCodec(() => { }); it("answers a call that crossed the loading notice unavailable (race 13)", async () => { - const { raw, workers } = await rawRelay(); + // The address's next resolve never settles: the relayed hold has no upstream link when the call arrives. + const workers: RawPeer[] = []; + const resolve = rawWorkers(workers); + const relay = await relayed({ + resolve: (address) => (workers.length === 0 ? resolve(address) : new Promise(() => {})), + }); + const raw = rawTab(relay.server); holdPlaced(raw, 0); const worker = await workerAt(workers); bindPlaced(worker, await worker.next("hold")); @@ -381,27 +388,48 @@ forEachCodec(() => { describe("a parent link that is down (audit)", () => { it("holds a hold arriving meanwhile on the parent path: it waits loading and binds on the next parent link", async () => { + // The middle host relays its tab's holds to its parent, which reaches it over a route: origins arrive only on + // parent and route links (decision "Origins only from relays"). + const middle = createHost({ + address: Session({ id: "M" }), + principal: { kind: "worker", session: "M" }, + onError: () => {}, + }); + await middle.load([], context); const server = createHost({ address: Server(), principal: { kind: "server" }, onError: () => {}, }); + const links: Link[] = []; + let gate = Promise.resolve(); + server.route( + Session, + { + authorize: () => true, + resolve: async () => { + await gate; + const [a, b] = memoryLinkPair(); + middle.connect(b, { principal: { kind: "server" }, codec: testCodec() }, context); + links.push(a); + return { link: a, principal: { kind: "worker", session: "M" }, codec: testCodec() }; + }, + }, + context, + ); await server.load( [{ name: "@test/app", facet: defineFacet((h) => void serveNotes(h)) }], context, ); - const middle = createHost({ principal: { kind: "server" }, onError: () => {} }); - await middle.load([], context); - /** Connects the middle host to the server over a new link; returns the server's end. */ - const up = (): Link => { - const [a, b] = memoryLinkPair(); - server.accept(a, { principal: { kind: "owner" }, codec: testCodec() }, context); - middle.connect(b, { principal: { kind: "server" }, codec: testCodec() }, context); - return a; - }; - const first = up(); + // The server holds something at the middle's address: its route link exists while it does. + const anchor = server.hold(PlacedNotes, { session: "M" }, context); await transported(); - first.close(); + // The next resolve waits: the middle's parent path is down meanwhile. + let admit: () => void = () => {}; + gate = new Promise((resolve) => { + admit = resolve; + }); + links[0]!.close(); await transported(); const tab = createHost({ onError: () => {} }); await tab.load([], context); @@ -411,9 +439,11 @@ forEachCodec(() => { const notes = tab.hold(Notes, { session: "S1" }, context); await transported(); expect(notes.state.value).toEqual({ kind: "loading" }); - up(); + admit(); + await transported(); await transported(); expect(notes.state.value).toEqual({ kind: "live" }); + anchor.release(); }); }); @@ -447,9 +477,12 @@ forEachCodec(() => { bindPlaced(worker, sent); worker.send(countState([sent], 1)); await transported(); - // The relay no longer relays the hold: dropped there (Q1), each message acknowledged to the worker at once. + // The origin held nothing more: its record went and the worker was told `closed` (decision "Origins only from + // relays"); what crossed it is dropped (Q1). expect(raw.kinds()).toEqual(["hello"]); - expect(worker.received.filter(({ kind }) => kind === "ack")).toHaveLength(2); + expect(worker.received.filter(({ kind }) => kind === "closed")).toEqual([ + { kind: "closed", origin: sent.origin }, + ]); }); }); diff --git a/packages/chord/test/next/support.ts b/packages/chord/test/next/support.ts index 7037ab438..20732c04f 100644 --- a/packages/chord/test/next/support.ts +++ b/packages/chord/test/next/support.ts @@ -40,6 +40,7 @@ import { type HostError, type PluginFailure, type RefState, + type RouteHandle, type Self, type StoreSelf, } from "../../src/next/host/index.ts"; @@ -770,6 +771,8 @@ export type Resolved = { export type Relayed = { readonly tab: Host; readonly server: Host; + /** The server's route to `Session`'s addresses. */ + readonly route: RouteHandle; /** The worker of each resolved session address, the latest one per key. */ readonly workers: Map>; /** The server's end of each resolved route link, in order. */ @@ -851,7 +854,7 @@ export async function relayed( return { link: a, principal, trusted: options.trusted, codec }; }; const resolve = options.resolve ?? (async (_address, made) => made()); - server.route( + const route = server.route( Session, { authorize: options.authorize ?? (() => true), @@ -870,7 +873,7 @@ export async function relayed( const accepted = { principal: options.tab ?? { kind: "owner" }, codec: tabCodec, limits: options.limits } as const; server.accept(a, accepted, context); tab.connect(b, { principal: { kind: "server" }, codec: tabCodec }, context); - return { tab, server, workers, routeLinks, tabLink: a, resolves, errors }; + return { tab, server, route, workers, routeLinks, tabLink: a, resolves, errors }; } /** diff --git a/tsconfig.json b/tsconfig.json index b49e773ff..7a9cef85c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,15 @@ "@earendil-works/chord/delta": ["./packages/chord/src/delta/index.ts"], "@earendil-works/chord/next": ["./packages/chord/src/next/index.ts"], "@earendil-works/chord/next/plugins": ["./packages/chord/src/next-node/plugins/index.ts"], + "@earendil-works/chord/next/plugins/runtime": ["./packages/chord/src/next-node/plugins/runtime.ts"], + "@test/contracts/*": ["./packages/chord/test/next-node/fixtures/plugins/contracts/*.ts"], + "@example/contracts/*": ["./packages/chord/test/next/examples/35-plugins/plugins/contracts/*.ts"], + "@example/weather/*": ["./packages/chord/test/next/examples/36-plugin-failures/plugins/weather/*.ts"], + "@example/prices/*": ["./packages/chord/test/next/examples/36-plugin-failures/plugins/prices/*.ts"], + "@example/shapes/*": ["./packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/*.ts"], + "@example/shared/*": ["./packages/chord/test/next/examples/38-watch-and-rollout/plugins/contracts/*.ts"], + "@earendil-works/chord/next/http": ["./packages/chord/src/next-node/http/index.ts"], + "@earendil-works/chord/next/workers": ["./packages/chord/src/next-node/workers/index.ts"], "@earendil-works/chord/node": ["./packages/chord/src/node.ts"], "@earendil-works/chord/output": ["./packages/chord/src/output.ts"], "@earendil-works/chord/telemetry-sqlite": ["./packages/chord/src/telemetry-sqlite/index.ts"],