From e62c92739736eef9ba79db3676efdaf76bf4b51f Mon Sep 17 00:00:00 2001 From: grjte Date: Tue, 15 Sep 2026 15:36:12 +0100 Subject: [PATCH 1/2] feat: patchwork-worker library for connecting to generic workers from any context (iso or not) via providers --- libraries/patchwork-worker/.gitignore | 3 + libraries/patchwork-worker/README.md | 251 ++++ libraries/patchwork-worker/client.js | 106 ++ libraries/patchwork-worker/client.test.js | 97 ++ libraries/patchwork-worker/connect.js | 259 ++++ libraries/patchwork-worker/connect.test.js | 417 ++++++ libraries/patchwork-worker/index.js | 75 + libraries/patchwork-worker/package.json | 57 + libraries/patchwork-worker/pnpm-lock.yaml | 1313 +++++++++++++++++ .../patchwork-worker/pnpm-workspace.yaml | 11 + libraries/patchwork-worker/serve.js | 265 ++++ libraries/patchwork-worker/serve.test.js | 267 ++++ libraries/patchwork-worker/session.js | 241 +++ libraries/patchwork-worker/tsconfig.json | 16 + libraries/patchwork-worker/types/client.d.ts | 43 + libraries/patchwork-worker/types/connect.d.ts | 128 ++ libraries/patchwork-worker/types/index.d.ts | 40 + libraries/patchwork-worker/types/serve.d.ts | 61 + libraries/patchwork-worker/types/session.d.ts | 80 + libraries/patchwork-worker/vitest.config.ts | 9 + 20 files changed, 3739 insertions(+) create mode 100644 libraries/patchwork-worker/.gitignore create mode 100644 libraries/patchwork-worker/README.md create mode 100644 libraries/patchwork-worker/client.js create mode 100644 libraries/patchwork-worker/client.test.js create mode 100644 libraries/patchwork-worker/connect.js create mode 100644 libraries/patchwork-worker/connect.test.js create mode 100644 libraries/patchwork-worker/index.js create mode 100644 libraries/patchwork-worker/package.json create mode 100644 libraries/patchwork-worker/pnpm-lock.yaml create mode 100644 libraries/patchwork-worker/pnpm-workspace.yaml create mode 100644 libraries/patchwork-worker/serve.js create mode 100644 libraries/patchwork-worker/serve.test.js create mode 100644 libraries/patchwork-worker/session.js create mode 100644 libraries/patchwork-worker/tsconfig.json create mode 100644 libraries/patchwork-worker/types/client.d.ts create mode 100644 libraries/patchwork-worker/types/connect.d.ts create mode 100644 libraries/patchwork-worker/types/index.d.ts create mode 100644 libraries/patchwork-worker/types/serve.d.ts create mode 100644 libraries/patchwork-worker/types/session.d.ts create mode 100644 libraries/patchwork-worker/vitest.config.ts diff --git a/libraries/patchwork-worker/.gitignore b/libraries/patchwork-worker/.gitignore new file mode 100644 index 00000000..f928cfcf --- /dev/null +++ b/libraries/patchwork-worker/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.pushwork diff --git a/libraries/patchwork-worker/README.md b/libraries/patchwork-worker/README.md new file mode 100644 index 00000000..7e0cf955 --- /dev/null +++ b/libraries/patchwork-worker/README.md @@ -0,0 +1,251 @@ +# @grjte/patchwork-worker + +Run a worker in the host realm and hand any consumer a transferable stream pair — +the same code inside or outside a Patchwork isolation boundary. + +```js +// consumer (may be inside the sandbox) +import { openSession } from "@grjte/patchwork-worker/connect.js"; +const session = openSession("llm", { element }); +const { promise } = session.request( + { op: "generate", messages }, + { + terminal: { + result: (f) => f.text, + error: (f) => { + throw new Error(f.message); + }, + }, + onFrame: (f) => f.type === "token" && ui.append(f.delta), + } +); +``` + +```js +// a package that owns a worker — declarative, nothing imported until first use +export const plugins = [ + { + type: "patchwork:worker", + id: "search-index", + name: "Search Index", + async load() { + return makeWorkerSpec(); + }, + }, // -> WorkerSpec {createWorker, open?, handle, abort?} +]; +``` + +The package is service-agnostic: it knows how to run _a_ worker in the host and +stream to _a_ consumer, and nothing about what any particular worker computes. A +concrete service (an LLM, a transcription engine, a search indexer) plugs in +through the **plugin registry**: it registers a plugin of type `patchwork:worker` +whose `id` names the worker `kind` and whose `load()` resolves to a **WorkerSpec**. +That registration is the whole coupling — the host provider (the +`patchwork-worker-provider` component, shipped by the `providers` package) +discovers workers by looking them up in the `patchwork:worker` registry by +`kind`, so a consumer that connects to `"llm"` reaches whatever package +registered a `patchwork:worker` plugin with `id: "llm"`. Nothing is imported +until a consumer actually connects. + +This package is a plain library: it registers no plugins and is consumed as a +dependency, never installed as a module. + +For the LLM service built on top of this — its op vocabulary, config/secret +handling, and how it behaves in each topology — see +[`../../llm-host/README.md`](../../llm-host/README.md). + +## Why this exists + +A Web Worker normally runs in the realm of the tool that constructed it. If a tool runs inside a +`null`-origin iframe, such as the Patchwork isolation package uses, then it +has no shared model cache, no WebGPU device the host set up, and — crucially — no +access to host-only state a worker may need (a settings doc, an API key), which is +deliberately denylisted from the sandbox. + +So instead of constructing the worker in the tool's realm, this package runs it in +the **host realm** and transfers only its `{readable, writable}` stream pair to the +consumer. The worker (and anything host-only it resolves) never leaves the host; +only structured-cloneable frames cross. Because the consumer's code is just "open a +connection, write request frames, read event frames," it is **identical in and out +of isolation** — the only difference is who answers the connection request and +whether the streams are transferred within one realm or across the boundary. + +## The three roles + +``` + worker owner (host realm) consumer (any realm) + ------------------------- -------------------- + {type:"patchwork:worker", const {readable, writable, disconnect} + id:"llm", load: () => spec} = await connectWorker("llm", req, {element}) + spec = {createWorker, open?, handle, abort?} + → serveWorkerSpec(spec, req, {element}) + -> {readable, writable} + + writable <--- request frames {id, op, ...} ---- (consumer writes) + readable ---- event frames {id, type, ...} ---> (consumer reads) +``` + +- **The consumer** calls `connectWorker(kind, request, {element})` (or the + higher-level `openSession(kind)` for request/response multiplexing) and gets a + stream pair. This is the only part a sandboxed tool runs, and the only file it + loads (`connect.js`). +- **The provider** (`patchwork-worker-provider`, a `patchwork:component` shipped + by the sibling `providers` package and mounted by the host frame) answers the + connection request: it looks up `kind` in the `patchwork:worker` plugin + registry — i.e. finds the registered plugin whose `id` equals `kind` — loads + its `WorkerSpec`, and hands that to `serveWorkerSpec`, which it imports from + `@grjte/patchwork-worker/serve.js`. +- **`serveWorkerSpec`** owns everything kind-agnostic: the stream pair and its + controller lifecycle, **one dedicated worker per connection** (terminated on + teardown — no sharing, no reuse), request-id demux, the reserved `op:"abort"`, + and a bounded `open` warm-up. The spec supplies only the service's op vocabulary. + +### The paired client plugin + +A service package usually also owns the *consume* half of its protocol — the code +that turns `generate(...)` into request frames. Rather than have every tool import +that code from the service package (a build-time dependency on a registry +package, which the repo rules forbid), the package registers it as a second +plugin with the **same id** as its worker: + +```js +export const plugins = [ + {type: "patchwork:worker", id: "llm", load: () => spec}, // serve half + {type: "patchwork:worker-client", id: "llm", load: () => (session) => api}, // consume half +]; +``` + +A consumer then calls, from `@grjte/patchwork-worker/client.js`: + +```js +const llm = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "chat"}}); +await llm.generate(messages, {element, ...}); +``` + +`connectWorkerClient` resolves the `patchwork:worker-client` plugin for the kind +from the registry (bounded wait, so a missing service rejects instead of hanging), +opens a session for the same kind, and returns `factory(session)`. Both halves of +the protocol ship in one package and cannot drift; the tool's only tie to the +service is the kind string. Under isolation the registry is mirrored into the +iframe, so the same call works in both realms. + +## The WorkerSpec contract + +A service implements this; `serveWorkerSpec` drives it. + +``` + createWorker() construct the compute Worker. Typically stays in the + service's own library, because + new URL("./worker.js", import.meta.url) must resolve + against that module's URL. Called at most once per + connection, lazily, on the first frame that needs it. + + open(ctx)? per-connection warm-up (e.g. resolving a settings doc). + ctx = {element} — the provider's mount point, so the + worker can reach host-realm context without the transport + knowing about it. BOUNDED by the transport (a 5s race): + a never-settling open can't wedge frames forever, so the + spec must make falling-through safe. Its resolved value + reaches handle as `io.state`. + + handle(frame, io) turn ONE consumer request frame into worker traffic. + Everything service-specific lives here. May return an + opaque abort token, stored per request. + io = {post, emit, on, workerId, state, ctx} + post(msg, transfer?) send to the worker + emit(frame) enqueue a frame onto the consumer's + readable (auto-tagged with the + caller id) + on(fn) handle worker messages for this + request; return truthy from fn when + the request is complete + workerId the id to tag worker payloads with + state whatever open() resolved (or null) + + abort(token, post)? cancel one in-flight request — the spec sends whatever + worker-specific payload stops it. Reached via the reserved + transport op {id, op:"abort"}, which session.js sends. +``` + +`serveWorkerSpec` owns the id lifecycle so an `op:"abort"` that races in while a +request is still being set up is honoured once its token lands, rather than being +dropped. + +## Frames + +Frames are **opaque to this package** — any structured-cloneable object. The +transport only reads `frame.id` (to demux) and `frame.op === "abort"` (the one +reserved op). Everything else is the service's own vocabulary. A connection is +multiplexed by `id`, so one stream pair can carry many overlapping requests: + +``` + request (consumer -> worker): {id, op, ...service-specific...} + events (worker -> consumer): {id, type, ...service-specific...} + a service marks its own terminal frame types; + abort is {id, op:"abort"} (reserved) +``` + +## Discovery + handoff go through patchwork-providers + +The worker channel is an ordinary `patchwork:subscribe` whose `kind` is the `id` of +a registered `patchwork:worker` plugin. The consumer calls `subscribe()`; the +provider answers with `accept()`; the stream pair rides back in the value with the +streams named in the transfer list (**moved, not cloned** — a `ReadableStream` +can't be structured-cloned). It is the same relay as any other provider — a worker +connection is just a subscription whose value happens to carry transferred streams. + +``` + consumer answering side + -------- -------------- + subscribe(el, {type:"patchwork:worker-channel", ────────► (mounted provider element + kind, request}) answers via accept()) + serveWorkerSpec(spec) -> streams + listener({readable, writable}) ◄───────── respond({readable,writable}, + (streams TRANSFERRED, not cloned) [readable,writable]) // TRANSFER +``` + +Because it is an ordinary provider subscription and the streams are transferable, a +consumer's code is the same whether the provider that answers is in its own realm or +across an isolation boundary — the transport doesn't know or care which. How +isolation relays this subscription (and what gates it) is documented by the +isolation package. + +If nothing answers, `connectWorker` resolves **`null`** — either a provider claimed +the subscription and answered a `null` value (an explicit refusal), or the bounded +discovery wait expired unclaimed. There is deliberately **no local-worker +fallback**: if a consumer could construct its own worker when discovery failed, a +tool that imports a service package would register that package's worker as an +import side-effect and silently serve itself, defeating the point of running the +worker in the host. A consumer treats `null` as "worker unavailable" and degrades. + +## Files + +- `connect.js` — the consumer transport: `connectWorker` (discovery + handoff) and + `openSession` (request/response multiplexing over the pair). **The only file a + sandboxed tool loads.** +- `session.js` — `openSession` internals: id tagging, demux, abort, reconnect. +- `serve.js` — `serveWorkerSpec`: streams, per-connection worker, id demux, abort. + The serve half, imported by the host provider as + `@grjte/patchwork-worker/serve.js`. +- `client.js` — `connectWorkerClient`: resolves the paired + `patchwork:worker-client` plugin from the registry and binds its factory to + `openSession(kind)`. Imports `@inkandswitch/patchwork-plugins` statically and is + therefore **not re-exported from index.js** (see below); consumers import the + subpath `@grjte/patchwork-worker/client.js`. +- `index.js` — barrel over connect.js plus the `WORKER_PLUGIN_TYPE` / + `WORKER_CLIENT_PLUGIN_TYPE` strings. No `plugins` array: this package is a + library, not a module. Its static graph must stay free of + `@inkandswitch/patchwork-plugins`, because the module loader evaluates package + entries in a Worker where that import cannot resolve — `connect.test.js` + enforces this. + +## ⚠ Nothing in this package may hold host-only state or secrets + +`connect.js` is fetched into the sandbox, and the isolation registry marker is per +package, so an isolated tool can reach any file here. Any host-only state or secret a +worker needs therefore lives in that worker's own service package (for the LLM, the API +key lives in the settings doc that `@grjte/llm-host` resolves — see its `README.md`), and +the host-realm provider that runs workers lives in the `providers` package. This package +holds no state and imports nothing service-specific. `client.js` touches only the plugin +registry (descriptors and lazy loaders); the factory it returns is the *service package's* +code, fetched by the same loader that fetches any tool. diff --git a/libraries/patchwork-worker/client.js b/libraries/patchwork-worker/client.js new file mode 100644 index 00000000..be2ee2f3 --- /dev/null +++ b/libraries/patchwork-worker/client.js @@ -0,0 +1,106 @@ +/** + * connectWorkerClient — resolve a worker's typed client from the plugin registry + * and bind it to a session, so a consumer never imports the service package. + * + * The transport pairs two plugin types by `id` (the worker `kind`): + * + * {type: "patchwork:worker", id: "llm", load: () => spec} + * {type: "patchwork:worker-client", id: "llm", load: () => (session) => clientApi} + * + * The first is the SERVE half: the host-realm provider runs its WorkerSpec. The + * second is the CONSUME half: a factory that turns an open `openSession(kind)` + * session into the service's API (`{generate}` for the LLM, say). Both ship from + * the service package, so the frame vocabulary they share can never drift apart + * across releases — and a tool that calls + * + * const llm = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "chat"}}) + * await llm.generate(messages, {element, ...}) + * + * has no build-time dependency on that package at all. The lookup is late-bound + * through the registry (AGENTS.md's sanctioned coupling) and degrades to a + * rejection if nothing registered the kind. + * + * Works in both realms. Inside an isolation iframe the plugin registry is + * mirrored from the host (every registry type, ungated), the plugin's `load()` + * fetches the service package's entry through the iframe module loader, and the + * session is served by the host provider across the boundary — the same call. + * + * ⚠ This file is deliberately NOT re-exported from index.js. It imports + * `@inkandswitch/patchwork-plugins` statically, whose graph reaches `window`; + * index.js -> connect.js is the graph the module loader evaluates in a Worker + * when it reads a package's `plugins`, and a bare import there kills it (see + * connect.js). Nothing evaluates this file except a consumer that asked for it: + * in the host it resolves through the bootloader importmap, in the iframe through + * the es-module-shims importmap (which already loads patchwork-plugins for the + * iframe's own registry). connect.test.js's "package shape" tests enforce the + * split. + */ + +import {getRegistry} from "@inkandswitch/patchwork-plugins" +import {openSession} from "./connect.js" + +/** + * The plugin type a service package registers to offer a typed client for its + * worker. Paired with `patchwork:worker` by `id`. Also exported (as a bare + * string) from index.js so naming it costs no import. + */ +export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" + +/** + * How long to wait for the client plugin to be registered AND loaded before + * giving up. `loadWhenReady` is unbounded by design (it waits for a late + * registration — in the iframe, entries arrive via the bridge after the tool + * may already have mounted), so this is the only thing standing between a + * missing service package and a consumer that awaits forever. + */ +const LOAD_TIMEOUT_MS = 10000 + +/** + * @typedef {ReturnType} WorkerSession + * @typedef {(session: WorkerSession) => any} WorkerClientFactory + * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin + */ + +/** + * Resolve the `patchwork:worker-client` plugin for `kind`, open a session for + * the same kind, and return `factory(session)` — the service's client API. + * + * Rejects if no plugin for `kind` is registered and loaded within `timeoutMs`, + * or if the plugin did not resolve to a function. Callers should not cache a + * rejected result: a later call may succeed once the service package registers. + * + * @param {string} kind + * @param {{ + * sessionOpts?: {element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}, + * timeoutMs?: number, + * }} [opts] + * @returns {Promise} + */ +export async function connectWorkerClient(kind, opts = {}) { + const registry = getRegistry(WORKER_CLIENT_PLUGIN_TYPE) + const timeoutMs = opts.timeoutMs ?? LOAD_TIMEOUT_MS + + /** @type {ReturnType | undefined} */ + let timer + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out loading worker-client plugin "${kind}"`)), + timeoutMs + ) + }) + let plugin + try { + // `loadWhenReady` (not `load`): a plugin registered-but-not-yet-loaded, or + // registered after this call, still resolves instead of being reported + // missing. The race bounds it. + plugin = await Promise.race([registry.loadWhenReady(kind), timeout]) + } finally { + clearTimeout(timer) + } + + const factory = /** @type {any} */ (plugin)?.module + if (typeof factory !== "function") { + throw new Error(`worker-client plugin "${kind}" did not resolve to a factory`) + } + return factory(openSession(kind, opts.sessionOpts)) +} diff --git a/libraries/patchwork-worker/client.test.js b/libraries/patchwork-worker/client.test.js new file mode 100644 index 00000000..ec4cd1e6 --- /dev/null +++ b/libraries/patchwork-worker/client.test.js @@ -0,0 +1,97 @@ +import {describe, it, expect, afterEach, vi} from "vitest" +import {existsSync, readFileSync} from "node:fs" +import {join} from "node:path" + +// connectWorkerClient resolves the client factory from the host plugin registry. +// Stub it before importing, so these tests exercise the helper's own logic +// (bounded wait / late registration / shape check) without the real platform. +const registry = { + plugins: new Map(), + waiters: [], + has(id) { + return this.plugins.has(id) + }, + async loadWhenReady(id) { + // Mirrors the real registry: waits for a late registration rather than + // reporting the plugin missing. + if (this.plugins.has(id)) return {module: this.plugins.get(id)} + return new Promise((resolve) => { + this.waiters.push({id, resolve}) + }) + }, + register(id, factory) { + this.plugins.set(id, factory) + for (const w of this.waiters) { + if (w.id === id) w.resolve({module: factory}) + } + this.waiters = this.waiters.filter((w) => w.id !== id) + }, + reset() { + this.plugins.clear() + this.waiters = [] + }, +} + +vi.mock("@inkandswitch/patchwork-plugins", () => ({ + getRegistry: () => registry, +})) + +const {connectWorkerClient, WORKER_CLIENT_PLUGIN_TYPE} = await import("./client.js") + +afterEach(() => { + registry.reset() +}) + +describe("connectWorkerClient", () => { + it("resolves the registered factory bound to a session for the same kind", async () => { + let seen = null + registry.register("llm", (session) => { + seen = session + return {generate: () => "ok"} + }) + const client = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "t"}}) + expect(client.generate()).toBe("ok") + // The factory receives an openSession() session: request + notify, nothing else. + expect(typeof seen.request).toBe("function") + expect(typeof seen.notify).toBe("function") + }) + + it("waits for a plugin registered after the call", async () => { + const pending = connectWorkerClient("llm", {timeoutMs: 1000}) + await new Promise((r) => setTimeout(r, 10)) + registry.register("llm", () => ({late: true})) + await expect(pending).resolves.toEqual({late: true}) + }) + + it("rejects after the bounded wait when nothing registers the kind", async () => { + await expect(connectWorkerClient("nobody", {timeoutMs: 50})).rejects.toThrow(/timed out/) + }) + + it("rejects when the plugin does not resolve to a factory", async () => { + registry.register("llm", {not: "a function"}) + await expect(connectWorkerClient("llm", {timeoutMs: 50})).rejects.toThrow( + /did not resolve to a factory/ + ) + }) + + it("names the paired plugin type", () => { + expect(WORKER_CLIENT_PLUGIN_TYPE).toBe("patchwork:worker-client") + }) +}) + +describe("package shape (client)", () => { + const dir = process.cwd() + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) + + it("exposes client.js by subpath, outside the entry graph", () => { + expect(existsSync(join(dir, "client.js"))).toBe(true) + expect(pkg.exports["./client.js"].default).toBe("./client.js") + expect(pkg.files).toContain("client.js") + // index.js names the type as a bare string but must not import this file: + // its static graph is evaluated in the module-loader Worker, where + // @inkandswitch/patchwork-plugins cannot load. + const entry = readFileSync(join(dir, "index.js"), "utf8") + expect(entry).not.toMatch(/from\s+["']\.\/client\.js["']/) + expect(entry).toMatch(/WORKER_CLIENT_PLUGIN_TYPE\s*=\s*"patchwork:worker-client"/) + }) +}) diff --git a/libraries/patchwork-worker/connect.js b/libraries/patchwork-worker/connect.js new file mode 100644 index 00000000..7ea5c09e --- /dev/null +++ b/libraries/patchwork-worker/connect.js @@ -0,0 +1,259 @@ +/** + * connectWorker — a generic, transferable-stream connection to a worker-backed + * service. + * + * A tool asks to connect to a worker doing some kind of work and, for the + * lifetime of that connection, holds a `WritableStream` (to send request frames) + * and a `ReadableStream` (to receive event frames). The worker itself, and any + * privileged setup it needs (config/secrets), live on whichever side answers the + * connection — the SAME realm when there's no isolation boundary, or the host + * realm when the consumer runs inside a sandboxed iframe. Either way the consumer + * sees an identical `{readable, writable}` pair; the streams are transferable, so + * they cross the isolation boundary unchanged. + * + * This file is the CONSUMER half. `connectWorker(kind, request, opts?)` returns + * `{readable, writable, disconnect}`; a provider answers via the discovery event + * (in-realm, or — across isolation — a bridge that produces host-realm streams). + * If nothing answers, it rejects. There is no local fallback. + * + * The SERVING half is `serveWorkerSpec` (./serve.js), driven by the host-realm + * `patchwork-worker-provider` component (shipped by the `providers` package), + * which resolves a WorkerSpec for the requested `kind` from the + * `patchwork:worker` plugin registry. Consumers never import serve.js — chat + * loads only this file into the sandbox. + * + * This module is service-agnostic: it knows nothing about LLMs. It only moves a + * request out and a stream of events back, and lets something in between (a + * middlebox) sit on the streams. The LLM is the first consumer: `@grjte/llm-host` + * registers a `patchwork:worker` plugin with id "llm". + * + * Frame shapes are the service's concern; connect.js treats them as opaque + * structured-cloneable values. For the LLM these reuse the worker's existing + * message vocabulary (token / prediction / stats / result / error / …), tagged + * with an `id` so many requests can multiplex over one connection. + * + * Discovery/handoff goes through patchwork-providers: this file calls + * `subscribe()` and the host provider answers with `accept()`. The stream pair + * rides in the value; `respond`'s transfer list moves rather than clones it, so + * the streams stay live across the isolation boundary. + */ + +import {createOpenSession} from "./session.js" + +/** + * The selector type used to discover a worker-connection provider. A consumer + * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind, request }` + * carrying a MessagePort in `detail.port`. The answering side replies over that + * port with exactly one of: + * + * {readable, writable} — success; the pair is TRANSFERRED, not cloned + * null — refused; fail fast + * + * Both arrive in the standard providers envelope (`{type:"change", value}`), + * because the answering side responds through `accept()`. + * + * Refusal is a `null` VALUE rather than its own message type: `accept()` owns + * the envelope, so there is no second type to use. That is the trade for + * speaking the canonical protocol, and it matches what every other provider in + * the repo now answers when it cannot serve. + * + * Silence is also a valid outcome (nothing is mounted to answer), which the + * consumer's bounded discovery timeout covers. Answering sides that KNOW they're + * refusing should respond `null` rather than staying silent, so the consumer + * doesn't wait out the timeout for an answer that already exists. + * + * Deliberately a STRING, not a Symbol. Comparisons against it are `===` on the + * value (here, in the host worker provider, and as an inlined literal in the + * isolation iframe bridge), so it keeps working even if this module is somehow + * evaluated more than once. A Symbol would silently stop matching. + */ +export const CHANNEL_SELECTOR = "patchwork:worker-channel" + +/** + * BACKSTOP: how long to wait for a provider to answer before giving up. + * + * Not control flow. A mounted provider either serves the kind (streams) or + * refuses it (`worker-unavailable`), and both are immediate — so in a healthy + * frame this timer never fires. It exists because an unclaimed + * `patchwork:subscribe` never settles by design (upstream removed the + * `` that used to answer `null`, so a subscription can wait + * for a provider that mounts later). Without a bound, a missing provider would + * hang the caller forever instead of erroring. + * + * If you find yourself tuning this number, something upstream is wrong: the + * frame should be gating its subtree on the provider being mounted. + */ +const DISCOVERY_TIMEOUT_MS = 8000 + +/** @typedef {{ readable: ReadableStream, writable: WritableStream }} WorkerStreams */ +/** @typedef {WorkerStreams & { disconnect: () => void }} WorkerConnection */ +/** + * What a `patchwork:worker` plugin's `load()` must resolve to: a WorkerSpec that + * `serveWorkerSpec` (serve.js) drives. The transport owns the streams, ids, and + * per-connection worker; the spec owns only its op vocabulary. `open(ctx)` gets + * the provider's mount point, so a worker can resolve host-realm context (a + * settings doc, say) without the provider knowing about that service. + * + * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather + * than importing serve.js, because this file is the sandbox-loaded consumer half + * and must not pull the host-only serve module into its graph. Types erase, so + * this costs nothing at runtime.) + * @typedef {{ + * createWorker: () => Worker | Promise, + * open?: (ctx: {element?: HTMLElement}) => any, + * handle: (frame: any, io: any) => any, + * abort?: (token: any, post: (msg: any, transfer?: Transferable[]) => void) => void, + * }} WorkerSpec + */ + +/** + * The descriptor a package puts in its `plugins` array to offer a worker. + * @typedef {{type: "patchwork:worker", id: string, name?: string, load: () => Promise}} WorkerPlugin + */ + +/** + * Open a connection to a worker of `kind`. + * + * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` in the + * DOM subtree of `opts.element` answers, transferring streams back over the + * port. In the host realm that's a mounted worker provider; inside isolation + * it's the providers-bridge, which relays to the host and transfers the host's + * streams across the boundary. Either way the consumer gets the same + * `{readable, writable, disconnect}` and never learns which answered. + * + * There is deliberately NO fallback to a locally-registered worker. Inside the + * sandbox that fallback was a hole: any in-boundary tool that imported a service + * package would register its worker as an import side-effect, and a connection + * that should have been refused would instead run the worker in the opaque + * origin — no shared model cache, no host config, and no signal that the + * isolation boundary had been bypassed. Serving is the provider's job; a + * consumer with no provider ancestor fails loudly instead. + * + * @param {string} kind + * @param {any} request the opening request (service-specific; carried to `run`) + * @param {{ element?: HTMLElement | null, signal?: AbortSignal }} [opts] + * @returns {Promise} + */ +export async function connectWorker(kind, request, opts = {}) { + const el = opts.element ?? discoveryElement() + if (!el) { + throw new Error( + `no worker available for kind "${kind}": no element to discover a provider from` + ) + } + const streams = await discoverViaProvider(el, kind, request) + if (!streams) { + throw new Error(`no worker available for kind "${kind}"`) + } + return withDisconnect(streams) +} + +/** Wrap a {readable, writable} with a disconnect() that tears both ends down. */ +function withDisconnect(streams) { + return { + ...streams, + disconnect() { + try { + streams.readable.cancel?.() + } catch {} + try { + streams.writable.abort?.() + } catch {} + }, + } +} + +/** + * Ask a `patchwork:worker-channel` provider to open a connection, via providers + * `subscribe()`. The answering side responds through `accept()` with the stream + * pair in the value and both streams named in the transfer list, so they are + * moved rather than cloned. Resolves null on an explicit refusal, or if nothing + * answers within the discovery timeout. + * + * @param {HTMLElement} element + * @param {string} kind + * @param {any} request + * @returns {Promise} + */ +function discoverViaProvider(element, kind, request) { + return new Promise((resolve) => { + let settled = false + /** @type {(() => void) | null} */ + let unsubscribe = null + + const finish = (/** @type {WorkerStreams | null} */ v) => { + if (settled) return + settled = true + clearTimeout(timer) + // One-shot: the provider may hold the subscription open, but we only ever + // want the first answer. + try { + unsubscribe?.() + } catch {} + resolve(v) + } + const timer = setTimeout(() => finish(null), DISCOVERY_TIMEOUT_MS) + + // patchwork-providers is imported DYNAMICALLY, and that is load-bearing. + // This file is in the entry graph (index.js -> connect.js), and the module + // loader evaluates the entry in a WORKER to read `plugins`. Every static + // import here becomes part of that evaluation; a bare specifier the worker + // cannot resolve kills the whole package with "ReferenceError: window is + // not defined". Before this migration connect.js had ONLY relative + // imports — keep it that way. + // + // `subscribe` is typed `T extends JSONValue`, but a transferred stream pair + // is not JSON — the cast is that constraint biting. `accept` + // is deliberately unconstrained for exactly this case; the consumer half + // was never widened to match. Worth fixing upstream. + void import("@inkandswitch/patchwork-providers") + .then(({subscribe}) => { + if (settled) return + unsubscribe = /** @type {any} */ (subscribe)( + element, + {type: CHANNEL_SELECTOR, kind, request}, + (/** @type {WorkerStreams | null} */ value) => { + // `null` is an explicit refusal — settle now rather than burning + // the full discovery timeout for an answer that already exists. + if (value && value.readable && value.writable) { + finish({readable: value.readable, writable: value.writable}) + } else { + finish(null) + } + } + ) + // A provider can answer during the dispatch above, in which case + // `finish` ran while `unsubscribe` was still null. Tear down now. + if (settled) { + try { + unsubscribe?.() + } catch {} + } + }) + .catch((err) => { + console.error("[patchwork-worker] failed to load patchwork-providers:", err) + finish(null) + }) + }) +} + +// A DOM node inside a mounted is needed to dispatch the +// discovery `patchwork:subscribe`. Consumers pass one via opts.element; when they +// don't, remember the most recent element any caller supplied (mirrors config.js's +// lastElement bootstrap), so elementless callers can still discover. +/** @type {HTMLElement | null} */ +let lastElement = null + +/** Record an element for elementless discovery (call from a UI that has one). */ +export function rememberDiscoveryElement(element) { + if (element) lastElement = element +} + +function discoveryElement() { + return lastElement +} + +// --- Session layer ---------------------------------------------------------- +// session.js takes `connectWorker` as a parameter rather than importing it, so +// the dependency runs one way and there's no import cycle. +export const openSession = createOpenSession(connectWorker) diff --git a/libraries/patchwork-worker/connect.test.js b/libraries/patchwork-worker/connect.test.js new file mode 100644 index 00000000..779d8202 --- /dev/null +++ b/libraries/patchwork-worker/connect.test.js @@ -0,0 +1,417 @@ +import {describe, it, expect, afterEach, vi} from "vitest" +import {existsSync, readFileSync} from "node:fs" +import {join} from "node:path" +import {accept} from "@inkandswitch/patchwork-providers" +import {connectWorker, rememberDiscoveryElement, openSession} from "./connect.js" + +// A minimal stand-in for the worker provider: answers worker-channel +// subscriptions for the kinds it knows, and refuses the ones it doesn't. Uses +// the real `accept()`, so these tests exercise the actual providers envelope +// rather than a hand-rolled imitation of it. +function serveKinds(kinds) { + const listener = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || !port) return + const run = kinds[selector.kind] + if (!run) return // decline, do NOT claim — let it bubble + accept(e, (respond) => { + Promise.resolve(run(selector.request)) + .then((streams) => { + respond({readable: streams.readable, writable: streams.writable}, [ + streams.readable, + streams.writable, + ]) + }) + .catch(() => respond(null)) + }) + } + document.addEventListener("patchwork:subscribe", listener) + return () => document.removeEventListener("patchwork:subscribe", listener) +} + +/** Echoes one token + a terminal result for every request frame written. */ +function echoWorker() { + let controller + const readable = new ReadableStream({start: (c) => (controller = c)}) + const writable = new WritableStream({ + write(frame) { + if (frame.op === "abort") return + controller.enqueue({id: frame.id, type: "token", delta: "hi", text: "hi"}) + controller.enqueue({id: frame.id, type: "result", text: "hi"}) + }, + }) + return {writable, readable} +} + +function mountElement() { + const el = document.createElement("div") + document.body.appendChild(el) + return el +} + +let cleanups = [] +afterEach(() => { + cleanups.forEach((f) => f()) + cleanups = [] + rememberDiscoveryElement(null) +}) + +async function readN(readable, n) { + const reader = readable.getReader() + const out = [] + while (out.length < n) { + const {value, done} = await reader.read() + if (done) break + out.push(value) + } + reader.releaseLock() + return out +} + +describe("connectWorker", () => { + it("connects when a provider answers, and streams frames", async () => { + const kind = "echo-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + const el = mountElement() + cleanups.push(() => el.remove()) + + const conn = await connectWorker(kind, {}, {element: el}) + expect(conn.readable).toBeInstanceOf(ReadableStream) + expect(typeof conn.disconnect).toBe("function") + + const writer = conn.writable.getWriter() + await writer.write({op: "generate", id: "x", text: "hi"}) + writer.releaseLock() + + const frames = await readN(conn.readable, 2) + expect(frames[0]).toMatchObject({id: "x", type: "token"}) + expect(frames[1]).toMatchObject({id: "x", type: "result"}) + }) + + it("multiplexes many request ids over one connection", async () => { + const kind = "echo-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + const el = mountElement() + cleanups.push(() => el.remove()) + + const conn = await connectWorker(kind, {}, {element: el}) + const writer = conn.writable.getWriter() + await writer.write({op: "generate", id: "a"}) + await writer.write({op: "generate", id: "b"}) + writer.releaseLock() + + const ids = (await readN(conn.readable, 4)).map((f) => f.id) + expect(ids.filter((x) => x === "a")).toHaveLength(2) + expect(ids.filter((x) => x === "b")).toHaveLength(2) + }) + + it("fails fast on an explicit refusal", async () => { + // A refusal the provider already knows about must not cost the full + // discovery timeout — this is the path the isolation host bridge uses when + // the worker-channel selector isn't in shared-providers. + const el = mountElement() + cleanups.push(() => el.remove()) + const refuse = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || !port) return + accept(e, (respond) => respond(null)) + } + document.addEventListener("patchwork:subscribe", refuse) + cleanups.push(() => document.removeEventListener("patchwork:subscribe", refuse)) + + const started = Date.now() + await expect(connectWorker("nope", {}, {element: el})).rejects.toThrow( + /no worker available/ + ) + expect(Date.now() - started).toBeLessThan(1000) + }) + + it("rejects without an element to discover from", async () => { + await expect(connectWorker("anything", {})).rejects.toThrow(/no element/) + }) + + it("times out when nothing answers at all", async () => { + // Nothing is mounted, so this waits out DISCOVERY_TIMEOUT_MS. That timeout + // is a backstop, not control flow: a mounted provider either serves or + // refuses, and both are immediate. + const el = mountElement() + cleanups.push(() => el.remove()) + await expect(connectWorker("nobody-" + Math.random(), {}, {element: el})).rejects.toThrow( + /no worker available/ + ) + }, 12000) +}) + +describe("openSession", () => { + const TERMINAL = { + result: (f) => f.text, + error: (f) => { + throw new Error(f.message) + }, + } + + it("resolves on the terminal frame and streams the rest", async () => { + const kind = "echo-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + const el = mountElement() + cleanups.push(() => el.remove()) + + const session = openSession(kind, {element: el}) + const seen = [] + const {promise} = session.request( + {op: "generate"}, + {terminal: TERMINAL, onFrame: (f) => seen.push(f.type)} + ) + await expect(promise).resolves.toBe("hi") + expect(seen).toEqual(["token"]) + }) + + it("does not cache a failed connection — a later request reconnects", async () => { + // The regression this layer exists to prevent: one early failure used to + // poison the module-level promise, so every later request failed instantly + // with the stale error even once a provider was available. + const kind = "echo-" + Math.random() + const el = mountElement() + cleanups.push(() => el.remove()) + const session = openSession(kind, {element: el}) + + const refuse = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || !port) return + accept(e, (respond) => respond(null)) + } + document.addEventListener("patchwork:subscribe", refuse) + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + await expect(first.promise).rejects.toThrow(/no worker available/) + document.removeEventListener("patchwork:subscribe", refuse) + + cleanups.push(serveKinds({[kind]: echoWorker})) + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + await expect(second.promise).resolves.toBe("hi") + }) + + it("fails in-flight requests when the connection drops", async () => { + // The hang this guards: pump()'s finally used to clear the connection but + // leave `handlers` populated, so a request awaiting a terminal frame that + // could no longer arrive never settled — no rejection, no timeout. Chat + // awaits generation with no deadline, so that wedged the UI silently. + const kind = "drop-" + Math.random() + let controller + const listener = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return + accept(e, (respond) => { + const readable = new ReadableStream({start: (c) => (controller = c)}) + const writable = new WritableStream({write() {}}) + respond({readable, writable}, [readable, writable]) + }) + } + document.addEventListener("patchwork:subscribe", listener) + cleanups.push(() => document.removeEventListener("patchwork:subscribe", listener)) + + const el = mountElement() + cleanups.push(() => el.remove()) + const session = openSession(kind, {element: el}) + const {promise} = session.request({op: "generate"}, {terminal: TERMINAL}) + + // Let the connection establish, then end the stream with no terminal frame. + await new Promise((r) => setTimeout(r, 20)) + controller.close() + + await expect(promise).rejects.toThrow(/closed before the request completed/) + }) + + it("rejects rather than silently dropping a frame when the writer is gone", async () => { + // `await writer?.write(frame)` used to resolve successfully having written + // nothing if the connection ended mid-send, leaving the request unsettled. + const kind = "gone-" + Math.random() + let controller + const listener = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return + accept(e, (respond) => { + const readable = new ReadableStream({start: (c) => (controller = c)}) + const writable = new WritableStream({write() {}}) + respond({readable, writable}, [readable, writable]) + }) + } + document.addEventListener("patchwork:subscribe", listener) + cleanups.push(() => document.removeEventListener("patchwork:subscribe", listener)) + + const el = mountElement() + cleanups.push(() => el.remove()) + const session = openSession(kind, {element: el}) + + // Establish, then drop the connection so the cached writer is gone. + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + await new Promise((r) => setTimeout(r, 20)) + controller.close() + await expect(first.promise).rejects.toThrow() + + // A later request reconnects rather than writing into the dead one. + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + await new Promise((r) => setTimeout(r, 20)) + controller.close() + await expect(second.promise).rejects.toThrow() + }) + + it("does not open a connection to abort a request that was never sent", async () => { + // An already-aborted signal used to fire the abort path, which called send() + // — opening a whole connection (up to the discovery timeout) purely to + // cancel something that never started. + const el = mountElement() + cleanups.push(() => el.remove()) + let dispatches = 0 + const count = (e) => { + if (e.detail?.selector?.type === "patchwork:worker-channel") dispatches++ + } + document.addEventListener("patchwork:subscribe", count) + cleanups.push(() => document.removeEventListener("patchwork:subscribe", count)) + + const session = openSession("never-" + Math.random(), {element: el}) + const {promise} = session.request( + {op: "generate"}, + {terminal: TERMINAL, signal: AbortSignal.abort()} + ) + await expect(promise).rejects.toThrow(/Aborted/) + expect(dispatches).toBe(0) + }) + + it("rejects when the signal is already aborted", async () => { + const el = mountElement() + cleanups.push(() => el.remove()) + const session = openSession("x", {element: el}) + const {promise} = session.request( + {op: "generate"}, + {terminal: TERMINAL, signal: AbortSignal.abort()} + ) + await expect(promise).rejects.toThrow(/Aborted/) + }) +}) + +describe("providers envelope", () => { + it("carries the stream pair through the value, alive on arrival", async () => { + // The premise the whole migration rests on: respond()'s transfer list goes + // on the OUTER postMessage, so streams nested in `value` are MOVED, not + // structured-cloned (which would throw DataCloneError). + const kind = "envelope-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + const el = mountElement() + cleanups.push(() => el.remove()) + + const conn = await connectWorker(kind, {}, {element: el}) + expect(conn.readable).toBeInstanceOf(ReadableStream) + expect(conn.writable).toBeInstanceOf(WritableStream) + + // Live, not a detached husk: a round trip still works. + const writer = conn.writable.getWriter() + await writer.write({id: "1", op: "generate"}) + writer.releaseLock() + const {value} = await conn.readable.getReader().read() + expect(value).toMatchObject({id: "1", type: "token"}) + }) + + it("rejects on a null value rather than hanging", async () => { + // A claimed subscription that answers `null` must fail fast — not wait out + // the 8s discovery backstop. + const el = mountElement() + cleanups.push(() => el.remove()) + const refuse = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || !port) return + accept(e, (respond) => respond(null)) + } + document.addEventListener("patchwork:subscribe", refuse) + cleanups.push(() => document.removeEventListener("patchwork:subscribe", refuse)) + + const started = Date.now() + await expect(connectWorker("nope", {}, {element: el})).rejects.toThrow( + /no worker available/ + ) + expect(Date.now() - started).toBeLessThan(1000) + }) + + it("re-discovers on the next request after a refusal", async () => { + // `null` means "asked, got nothing" — the session drops the connection so a + // later request tries again, rather than staying dead forever. + const TERMINAL = { + result: (f) => f.text, + error: (f) => { + throw new Error(f.message) + }, + } + const kind = "retry-" + Math.random() + const el = mountElement() + cleanups.push(() => el.remove()) + + const refuse = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return + accept(e, (respond) => respond(null)) + } + document.addEventListener("patchwork:subscribe", refuse) + + const session = openSession(kind, {element: el}) + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + await expect(first.promise).rejects.toThrow(/no worker available/) + + // The provider shows up late; the next request must find it. + document.removeEventListener("patchwork:subscribe", refuse) + cleanups.push(serveKinds({[kind]: echoWorker})) + + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + await expect(second.promise).resolves.toBe("hi") + }) +}) + +describe("package shape", () => { + // The entry-point split that caused a real outage: consumers bake a subpath + // literal at BUILD time (resolved from `exports`), separate from the automerge + // pin. Two entry points to one state-holding module means two module + // instances. Here connect.js holds no registry at all — the rendezvous lives + // in the host plugin registry — but the consumer/provider split still has to + // stay honest. + const dir = process.cwd() + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) + + it("keeps the consumer transport importable on its own", () => { + // chat loads ONLY this file into the sandbox. + expect(existsSync(join(dir, "connect.js"))).toBe(true) + expect(pkg.exports["./connect.js"].default).toBe("./connect.js") + expect(pkg.files).toContain("connect.js") + }) + + it("keeps the consumer transport free of the plugin registry", () => { + // The host provider (providers package) pulls in the plugin registry; + // connect.js must not, or the sandbox would drag the host registry in with + // the transport. + const src = readFileSync(join(dir, "connect.js"), "utf8") + expect(src).not.toMatch(/patchwork-plugins/) + }) + + it("exposes the serve half by subpath for the host provider", () => { + // The provider lives in another package and imports serveWorkerSpec through + // `exports`, so this entry is load-bearing the same way ./connect.js is. + expect(existsSync(join(dir, "serve.js"))).toBe(true) + expect(pkg.exports["./serve.js"].default).toBe("./serve.js") + expect(pkg.files).toContain("serve.js") + }) + + it("is a library: no plugins, no provider, no host-only imports in the entry", () => { + // The provider moved to the providers package. Nothing here registers with + // the module loader, and the entry stays free of @inkandswitch/patchwork-plugins + // (whose graph reaches `window.location.origin`). + expect(pkg.exports["./provider.js"]).toBeUndefined() + expect(existsSync(join(dir, "provider.js"))).toBe(false) + const src = readFileSync(join(dir, "index.js"), "utf8") + expect(src).not.toMatch(/^\s*export\s+const\s+plugins\b/m) + expect(src).not.toMatch(/^\s*import[^\n]*patchwork-plugins/m) + }) + + it("exposes no worker registry from the transport", () => { + // Workers are resolved from the patchwork:worker plugin registry by the + // provider — there is deliberately no module-level Map here to split. + const src = readFileSync(join(dir, "connect.js"), "utf8") + expect(src).not.toMatch(/localWorkers/) + }) +}) diff --git a/libraries/patchwork-worker/index.js b/libraries/patchwork-worker/index.js new file mode 100644 index 00000000..76d0da97 --- /dev/null +++ b/libraries/patchwork-worker/index.js @@ -0,0 +1,75 @@ +/** + * @grjte/patchwork-worker — run a worker in the host realm, hand any consumer a + * transferable stream pair, and have it work the same inside or outside a + * Patchwork isolation boundary. + * + * This is a plain library. It registers no plugins; it is consumed as a + * dependency, never installed as a module. Four pieces: + * + * connect.js the CONSUMER transport. `connectWorker(kind, request, {element})` + * returns `{readable, writable, disconnect}`. This is the only file + * a sandboxed tool loads. + * session.js `openSession(kind)` — request/response multiplexing over that + * stream pair: id tagging, demux, abort, reconnect. + * serve.js `serveWorkerSpec(spec, request, ctx)` — the SERVING half: runs + * one worker per connection and owns the stream pair + id demux. + * client.js `connectWorkerClient(kind, opts)` — resolves the service's + * typed client from the `patchwork:worker-client` registry and + * binds it to `openSession(kind)`. Subpath-only; NOT re-exported + * here (its patchwork-plugins import cannot load in the module + * loader's Worker, which evaluates this entry). + * + * The host-realm PROVIDER that answers `patchwork:worker-channel` subscriptions + * (`patchwork-worker-provider`, a `patchwork:component`) ships from the + * `providers` package. It resolves a WorkerSpec for the requested `kind` from the + * `patchwork:worker` plugin registry and drives it with `serveWorkerSpec`. + * + * Offering a worker is declarative — a package registers a PAIR of plugins under + * one id (the worker `kind`), and nothing imports it until someone connects: + * + * export const plugins = [ + * {type: "patchwork:worker", id: "llm", name: "LLM", + * async load() { return makeLLMWorkerSpec(...) }}, // serve half + * {type: "patchwork:worker-client", id: "llm", name: "LLM client", + * async load() { return makeLLMClient }}, // consume half + * ] + * + * where the worker's `load()` resolves to a WorkerSpec — `{createWorker, open?, + * handle, abort?}` — and the client's resolves to `(session) => clientApi`. A + * tool then calls `connectWorkerClient("llm")` and gets the API without a + * build-time dependency on the service package. + * + * ⚠ Nothing in this package may hold host-only state or secrets. connect.js is + * fetched INTO the sandbox, and the isolation registry marker is per package, so + * an isolated tool can reach any file here. Host-realm work belongs in the + * provider (providers package) or in the service package that owns the worker. + */ + +export { + connectWorker, + rememberDiscoveryElement, + openSession, + CHANNEL_SELECTOR, +} from "./connect.js" + +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + * @typedef {import("./connect.js").WorkerSpec} WorkerSpec + * @typedef {import("./connect.js").WorkerPlugin} WorkerPlugin + * @typedef {import("./connect.js").WorkerStreams} WorkerStreams + * @typedef {import("./connect.js").WorkerConnection} WorkerConnection + */ + +/** + * The plugin type a package registers to offer a worker. A plain string, so + * naming it costs no import. + */ +export const WORKER_PLUGIN_TYPE = "patchwork:worker" + +/** + * The paired plugin type a package registers to offer a typed client for its + * worker (see ./client.js). Same id as the worker. A plain string here so the + * entry never imports client.js. + */ +export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" diff --git a/libraries/patchwork-worker/package.json b/libraries/patchwork-worker/package.json new file mode 100644 index 00000000..c9f9328d --- /dev/null +++ b/libraries/patchwork-worker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@grjte/patchwork-worker", + "version": "0.0.1", + "description": "Run a worker in the host realm and hand any consumer a transferable stream pair — the same code inside or outside a Patchwork isolation boundary. Owns the patchwork:worker plugin type and the connect/session/serve transport; the host-realm provider that drives it ships from the providers package.", + "type": "module", + "main": "index.js", + "types": "./types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./index.js" + }, + "./connect.js": { + "types": "./types/connect.d.ts", + "default": "./connect.js" + }, + "./session.js": { + "types": "./types/session.d.ts", + "default": "./session.js" + }, + "./serve.js": { + "types": "./types/serve.d.ts", + "default": "./serve.js" + }, + "./client.js": { + "types": "./types/client.d.ts", + "default": "./client.js" + } + }, + "files": [ + "types", + "index.js", + "connect.js", + "session.js", + "serve.js", + "client.js", + "README.md" + ], + "author": "grjte", + "license": "MIT", + "scripts": { + "build": "pnpm build:types", + "build:types": "tsc", + "push": "pnpm build && pushwork sync", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@inkandswitch/patchwork-plugins": "^0.0.11", + "@inkandswitch/patchwork-providers": "^0.5.1" + }, + "devDependencies": { + "happy-dom": "^15.11.7", + "typescript": "^5.9.3", + "vitest": "^3.2.7" + } +} diff --git a/libraries/patchwork-worker/pnpm-lock.yaml b/libraries/patchwork-worker/pnpm-lock.yaml new file mode 100644 index 00000000..06c1f2f5 --- /dev/null +++ b/libraries/patchwork-worker/pnpm-lock.yaml @@ -0,0 +1,1313 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: + pnpm-plugin-patchwork: + specifier: 0.4.1 + version: 0.4.1 + +packages: + + pnpm-plugin-patchwork@0.4.1: + resolution: {integrity: sha512-IzQJkoeQaSaYSa1bRko3KUJ+6nBmYg8Qxuyy0llnhPfAGSDP/+gM/Z7R0HGLtTpCE3K57CXS9DnAItqb/pNIZQ==} + +snapshots: + + pnpm-plugin-patchwork@0.4.1: {} + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@inkandswitch/patchwork-plugins': + specifier: ^0.0.11 + version: 0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)) + '@inkandswitch/patchwork-providers': + specifier: ^0.5.1 + version: 0.5.1(@automerge/automerge-repo@2.6.0-alpha.3) + devDependencies: + happy-dom: + specifier: ^15.11.7 + version: 15.11.7 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7) + +packages: + + '@automerge/automerge-repo@2.6.0-alpha.3': + resolution: {integrity: sha512-Rn/KdoVHUQwYU0TXqHyy9PdBgVE009JJWnh2YxT2blk5EnbZckh+RfGfu6ngjMGw0DZcZtJLvK3dKDNdAvtQVA==} + engines: {node: '>=22.13'} + + '@automerge/automerge@3.4.1': + resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inkandswitch/patchwork-filesystem@0.0.8': + resolution: {integrity: sha512-gfS7OHC2W1xG0EWl5yQMPQl+Ra/hLsgus9X0VmwHvsovFSR9tun7Clz3XcIyAqEU7VpEtCkGrxHkMgw54SJxlg==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + + '@inkandswitch/patchwork-plugins@0.0.11': + resolution: {integrity: sha512-ElwDEixpZN64gdoE7EU8QFz4WzvyJt6j4Zn0pTezsW6SzGlx+Rc6Ox+7POK7gqEzQEcl3Iqo8+QdYRucYllRFw==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.0.8 + + '@inkandswitch/patchwork-providers@0.5.1': + resolution: {integrity: sha512-KOZghTF4CVSK0jH9F/4JHtiACJsyQRluVCtfKrbwQPxQKhnuHIsbQHyBQu/Bsx9QMDFGdGcX7rs9jBj7qiQ6WQ==} + peerDependencies: + '@automerge/automerge-repo': '*' + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + +snapshots: + + '@automerge/automerge-repo@2.6.0-alpha.3': + dependencies: + '@automerge/automerge': 3.4.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + uuid: 14.0.2 + xstate: 5.32.6 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge@3.4.1': {} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)': + dependencies: + '@automerge/automerge': 3.4.1 + '@automerge/automerge-repo': 2.6.0-alpha.3 + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1))': + dependencies: + '@automerge/automerge': 3.4.1 + '@automerge/automerge-repo': 2.6.0-alpha.3 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + eventemitter3: 5.0.4 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-providers@0.5.1(@automerge/automerge-repo@2.6.0-alpha.3)': + dependencies: + '@automerge/automerge-repo': 2.6.0-alpha.3 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/hashes@1.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + base-x@5.0.1: {} + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cac@6.7.14: {} + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + entities@4.5.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve.exports@2.0.3: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uuid@14.0.2: {} + + vite-node@3.2.4(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@20.19.43): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@20.19.43)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@20.19.43) + vite-node: 3.2.4(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + happy-dom: 15.11.7 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xstate@5.32.6: {} diff --git a/libraries/patchwork-worker/pnpm-workspace.yaml b/libraries/patchwork-worker/pnpm-workspace.yaml new file mode 100644 index 00000000..b6a60e31 --- /dev/null +++ b/libraries/patchwork-worker/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +# pnpm 11 reads its settings from this file. There is no root workspace, so +# every package carries the settings it needs rather than inheriting them. +minimumReleaseAge: 0 +verifyDepsBeforeRun: false +allowBuilds: + "@swc/core": true + cbor-extract: true + core-js: true + esbuild: true +configDependencies: + pnpm-plugin-patchwork: "0.4.1+sha512-IzQJkoeQaSaYSa1bRko3KUJ+6nBmYg8Qxuyy0llnhPfAGSDP/+gM/Z7R0HGLtTpCE3K57CXS9DnAItqb/pNIZQ==" diff --git a/libraries/patchwork-worker/serve.js b/libraries/patchwork-worker/serve.js new file mode 100644 index 00000000..df7c1134 --- /dev/null +++ b/libraries/patchwork-worker/serve.js @@ -0,0 +1,265 @@ +/** + * The SERVING half of a worker connection — the mirror of `openSession`. + * + * `openSession` (session.js) is the consumer side: it owns a `{readable, + * writable}` pair, mints request ids, multiplexes many requests over one + * connection, and routes event frames back to the right caller. `serveWorkerSpec` + * is the same machinery on the OTHER end. A worker library supplies a small + * `WorkerSpec` — how to construct its worker, an optional per-connection warm-up, + * and a `handle(frame)` that turns one consumer request into worker traffic — and + * this file owns everything kind-agnostic around it: + * + * - the `{readable, writable}` stream pair and its controller lifecycle + * - ONE dedicated Worker per connection, terminated on teardown (no reuse, no + * sharing — a worker belongs to the connection that opened it and dies with it) + * - id minting and demux for requests multiplexed within the connection + * - the reserved `op:"abort"` (session.js:168 already sends it, so it is part of + * the transport protocol, not any service's vocabulary) + * - teardown on cancel/close/abort of either stream + * - a bounded `open` hook (the same never-settling-primitive guard the rest of + * this package applies with LOAD_TIMEOUT_MS / DISCOVERY_TIMEOUT_MS) + * + * The spec knows nothing about streams, ids, or workers-as-transport; the + * transport knows nothing about the service's op vocabulary, config, or secrets. + */ + +/** + * How long `spec.open` may take before the transport gives up and serves frames + * anyway. Bounds an upstream primitive that can hang: a settings-doc warm resolves + * through patchwork-providers' `request()`, which never settles if no provider + * answers. Falling through is the spec's responsibility to make safe (the LLM + * re-checks and retries); wedging every frame forever is strictly worse. + */ +const OPEN_TIMEOUT_MS = 5000 + +/** + * @typedef {(msg: any, transfer?: Transferable[]) => void} Post + * @typedef {(frame: any) => void} Emit + * + * @typedef {Object} IO what a spec's `handle` is given + * @property {Post} post send a message to the worker (transfer supported) + * @property {Emit} emit enqueue a frame onto THIS consumer's readable + * @property {(fn: (msg:any)=>boolean|void) => void} on + * register a handler for worker messages tagged with `workerId`; return a truthy + * value from `fn` when the request is complete and the transport should clean up + * @property {string} workerId the transport-minted id to tag worker payloads with + * @property {any} state whatever `spec.open` resolved (or null) + * @property {{element?: HTMLElement}} ctx host-realm context from the provider + * + * @typedef {Object} WorkerSpec + * @property {() => Worker | Promise} createWorker + * @property {(ctx: {element?: HTMLElement}) => any} [open] + * @property {(frame: any, io: IO) => any} handle + * returns an opaque abort token (or nothing) stored per request + * @property {(token: any, post: Post) => void} [abort] + */ + +let idSeq = 0 +function nextWorkerId() { + return "wk-" + ++idSeq + "-" + (performance.now() | 0) +} + +/** + * Serve one worker connection from a spec. Returns the `{readable, writable}` the + * provider transfers to the consumer. One Worker is created for this connection + * and terminated when either stream ends. + * + * @param {WorkerSpec} spec + * @param {any} _request the opening request (reserved; specs read per-frame data instead) + * @param {{element?: HTMLElement}} [ctx] + * @returns {{readable: ReadableStream, writable: WritableStream}} + */ +export function serveWorkerSpec(spec, _request, ctx = {}) { + /** @type {Worker | null} */ + let worker = null + /** @type {Promise | null} */ + let workerReady = null + // worker message id -> handler. One connection, so one flat map is enough. + /** @type {Mapboolean|void>} */ + const handlers = new Map() + // caller id -> the abort token the spec returned, so `op:"abort"` can cancel it. + // A request is entered here SYNCHRONOUSLY at the value `PENDING` the moment its + // frame arrives, before any await — so an `op:"abort"` that races in while the + // spec is still resolving (`await openState` / `await spec.handle`) finds the + // request and is honoured once the token lands, rather than being silently + // dropped. The value becomes the real token (or `undefined`) when `handle` + // returns; see the `PENDING`/`aborted` handling in `handleFrame`. + /** @type {Map} */ + const tokens = new Map() + // caller ids aborted while still `PENDING` — the token wasn't available yet, so + // the abort is deferred to when the handler stores it. + /** @type {Set} */ + const aborted = new Set() + const PENDING = Symbol("pending") + + /** @type {ReadableStreamDefaultController | null} */ + let controller = null + let closed = false + + const emit = (/** @type {any} */ frame) => { + try { + controller?.enqueue(frame) + } catch {} + } + + // The connection's one warm-up, bounded so a never-settling `open` can't wedge + // every frame. Started eagerly; frames await it before dispatch. + const openState = spec.open + ? Promise.race([ + Promise.resolve(spec.open(ctx)), + new Promise((resolve) => setTimeout(resolve, OPEN_TIMEOUT_MS, null)), + ]).catch(() => null) + : Promise.resolve(null) + + const post = (/** @type {any} */ msg, /** @type {Transferable[]=} */ transfer) => { + void getWorker().then((w) => w.postMessage(msg, transfer || [])) + } + + /** Lazily construct the worker (once) and wire its message pump. */ + function getWorker() { + if (workerReady) return workerReady + workerReady = Promise.resolve(spec.createWorker()).then((w) => { + worker = w + w.onmessage = (/** @type {MessageEvent} */ ev) => dispatch(ev.data) + return w + }) + return workerReady + } + + /** + * Route a worker message. A message with an `id` goes to that request's + * handler (which reports terminal by returning truthy). A message with no `id` + * (status, log, progress with no request attached) is emitted straight onto the + * connection's readable — with one worker per connection there is nothing to + * fan out to. + */ + function dispatch(/** @type {any} */ msg) { + if (!msg || closed) return + if (msg.id != null) { + const h = handlers.get(msg.id) + if (h && h(msg)) handlers.delete(msg.id) + return + } + emit(msg) + } + + async function handleFrame(/** @type {any} */ frame) { + if (!frame || typeof frame !== "object") return + const id = frame.id + const op = frame.op + + // Reserved transport op. Cancel one in-flight request: let the spec send + // whatever the worker needs, then forget it. If the request is still + // `PENDING` (its handler hasn't returned a token yet — we're mid-`await`), + // defer: record the id and let the handler abort as soon as it stores it. + if (op === "abort") { + if (!tokens.has(id)) return // unknown / already-finished request + const token = tokens.get(id) + if (token === PENDING) { + aborted.add(id) + return + } + try { + spec.abort?.(token, post) + } catch {} + tokens.delete(id) + return + } + + // Claim the id SYNCHRONOUSLY, before the first await, so an abort racing in + // during `await openState` / `await spec.handle` isn't dropped (H1). + tokens.set(id, PENDING) + + const state = await openState + const workerId = nextWorkerId() + + /** @type {IO} */ + const io = { + post, + // Frames the spec emits carry the WORKER id; re-tag with the caller id. + emit: (f) => emit({...f, id}), + on: (fn) => { + handlers.set(workerId, (msg) => { + const done = fn(msg) + if (done) { + tokens.delete(id) + aborted.delete(id) + } + return done + }) + }, + workerId, + state, + ctx, + } + + try { + const token = await spec.handle(frame, io) + // An abort arrived while this request was still PENDING — honour it now + // that the token exists, and don't store it (the request is cancelled). + if (aborted.has(id)) { + aborted.delete(id) + tokens.delete(id) + handlers.delete(workerId) + try { + spec.abort?.(token, post) + } catch {} + return + } + // Store the abort token even if undefined, so `op:"abort"` can find the + // request (a spec that never aborts simply returns nothing). + tokens.set(id, token) + } catch (e) { + const err = /** @type {any} */ (e) + emit({id, type: "error", message: err?.message || String(e)}) + handlers.delete(workerId) + tokens.delete(id) + aborted.delete(id) + } + } + + function teardown() { + if (closed) return + closed = true + handlers.clear() + tokens.clear() + aborted.clear() + // Close the readable so a consumer still reading it sees end-of-stream rather + // than hanging forever (H2). Teardown can be driven from the WRITABLE side + // (close/abort) or worker death, where the readable was never cancelled; a + // bare `controller = null` would strand that reader. `close()` throws if the + // stream was already closed/cancelled (the readable-cancel path), so guard it. + try { + controller?.close() + } catch {} + controller = null + // The worker belongs to this connection alone — kill it. + try { + worker?.terminate() + } catch {} + worker = null + } + + const readable = new ReadableStream({ + start(c) { + controller = c + }, + cancel() { + teardown() + }, + }) + + const writable = new WritableStream({ + write(frame) { + void handleFrame(frame) + }, + close() { + teardown() + }, + abort() { + teardown() + }, + }) + + return {readable, writable} +} diff --git a/libraries/patchwork-worker/serve.test.js b/libraries/patchwork-worker/serve.test.js new file mode 100644 index 00000000..5a1cbcad --- /dev/null +++ b/libraries/patchwork-worker/serve.test.js @@ -0,0 +1,267 @@ +import {describe, it, expect, vi} from "vitest" +import {serveWorkerSpec} from "./serve.js" + +/** + * A fake Worker: records posted messages, lets the test push replies back, and + * tracks termination. No real Worker (happy-dom has none) — we are testing the + * transport, so the worker is a stub. + */ +function fakeWorker() { + const w = { + posted: [], + terminated: false, + /** @type {((ev:{data:any})=>void)|null} */ + onmessage: null, + postMessage(msg, transfer) { + w.posted.push({msg, transfer}) + }, + terminate() { + w.terminated = true + }, + /** push a message from the "worker" back to the transport */ + reply(data) { + w.onmessage?.({data}) + }, + } + return w +} + +/** Drive a served connection: write frames to writable, read frames off readable. */ +function drive(streams) { + const writer = streams.writable.getWriter() + const reader = streams.readable.getReader() + const frames = [] + ;(async () => { + try { + for (;;) { + const {value, done} = await reader.read() + if (done) break + frames.push(value) + } + } catch {} + })() + return { + write: (f) => writer.write(f), + close: () => writer.close(), + cancelRead: () => reader.cancel(), + frames, + settle: async () => { + for (let i = 0; i < 20; i++) await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) + }, + } +} + +describe("serveWorkerSpec", () => { + it("routes a request to the worker and its reply back, re-tagged with the caller id", async () => { + const w = fakeWorker() + const spec = { + createWorker: () => w, + handle(frame, io) { + io.on((msg) => { + if (msg.type === "result") { + io.emit({type: "result", text: msg.text}) + return true + } + }) + io.post({type: "generate", id: io.workerId, text: frame.text}) + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "generate", text: "hi"}) + await conn.settle() + + // The worker saw the request… + expect(w.posted).toHaveLength(1) + const workerId = w.posted[0].msg.id + expect(w.posted[0].msg.text).toBe("hi") + + // …and its reply comes back tagged with the CALLER id, not the worker id. + w.reply({id: workerId, type: "result", text: "done"}) + await conn.settle() + expect(conn.frames).toContainEqual({id: "a", type: "result", text: "done"}) + }) + + it("gives each connection its own worker", async () => { + const workers = [] + const spec = { + createWorker: () => { + const w = fakeWorker() + workers.push(w) + return w + }, + handle: (frame, io) => io.post({type: "x", id: io.workerId}), + } + const a = drive(serveWorkerSpec(spec, {})) + const b = drive(serveWorkerSpec(spec, {})) + await a.write({id: "1", op: "go"}) + await b.write({id: "2", op: "go"}) + await a.settle() + expect(workers).toHaveLength(2) + expect(workers[0]).not.toBe(workers[1]) + }) + + it("terminates the worker when the writable closes", async () => { + const w = fakeWorker() + const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + expect(w.terminated).toBe(false) + await conn.close() + await conn.settle() + expect(w.terminated).toBe(true) + }) + + it("terminates the worker when the readable is cancelled", async () => { + const w = fakeWorker() + const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + await conn.cancelRead() + await conn.settle() + expect(w.terminated).toBe(true) + }) + + it("routes op:abort to spec.abort with the stored token", async () => { + const w = fakeWorker() + const aborted = [] + const spec = { + createWorker: () => w, + handle(frame, io) { + io.on(() => false) // never terminal on its own + io.post({type: "generate", id: io.workerId}) + return {sessionKey: frame.id} // the abort token + }, + abort(token, post) { + aborted.push(token) + post({type: "abort", sessionKey: token.sessionKey}) + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "generate"}) + await conn.settle() + await conn.write({id: "a", op: "abort"}) + await conn.settle() + + expect(aborted).toEqual([{sessionKey: "a"}]) + // spec.abort posted the worker-specific abort payload + expect(w.posted.some((p) => p.msg.type === "abort" && p.msg.sessionKey === "a")).toBe(true) + }) + + it("honours an abort that races in while the request is still resolving (H1)", async () => { + // The abort arrives while `handle` is suspended on a slow `open`, i.e. before + // the abort token has been stored. The old design read `tokens.get(id)` → + // undefined and dropped the abort silently. It must now be deferred and fire + // once the token lands. + const w = fakeWorker() + const aborted = [] + let releaseOpen + const spec = { + createWorker: () => w, + open: () => new Promise((r) => (releaseOpen = r)), // gate handle until we say + handle(frame, io) { + io.on(() => false) + io.post({type: "generate", id: io.workerId}) + return {sessionKey: frame.id} + }, + abort(token) { + aborted.push(token) + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "generate"}) // suspends inside handleFrame on open + await conn.write({id: "a", op: "abort"}) // races in BEFORE the token exists + await conn.settle() + expect(aborted).toEqual([]) // deferred: nothing to abort yet + + releaseOpen(null) // let handle finish and store the token + await conn.settle() + expect(aborted).toEqual([{sessionKey: "a"}]) // fired once the token landed + // …and the request is forgotten, so a duplicate abort is a no-op. + await conn.write({id: "a", op: "abort"}) + await conn.settle() + expect(aborted).toEqual([{sessionKey: "a"}]) + }) + + it("closes the readable when the writable closes, so a reader sees end-of-stream (H2)", async () => { + const w = fakeWorker() + const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + const streams = serveWorkerSpec(spec, {}) + const reader = streams.readable.getReader() + const writer = streams.writable.getWriter() + await writer.write({id: "a", op: "go"}) + // A reader blocked on read() must be released by teardown, not hang forever. + const pending = reader.read() + await writer.close() + const {done} = await pending + expect(done).toBe(true) + }) + + it("emits id-less worker messages straight onto the readable", async () => { + const w = fakeWorker() + const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + w.reply({type: "status", message: "downloading model"}) // no id + await conn.settle() + expect(conn.frames).toContainEqual({type: "status", message: "downloading model"}) + }) + + it("does not duplicate a status frame per in-flight request", async () => { + // The old design fanned an id-less status to every in-flight id, so N + // requests saw the same text N times. One worker per connection emits it once. + const w = fakeWorker() + const spec = { + createWorker: () => w, + handle: (f, io) => { + io.on(() => false) + io.post({id: io.workerId}) + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.write({id: "b", op: "go"}) + await conn.settle() + w.reply({type: "status", message: "one"}) + await conn.settle() + const statuses = conn.frames.filter((f) => f.type === "status") + expect(statuses).toEqual([{type: "status", message: "one"}]) + }) + + it("emits {type:error} tagged with the caller id when handle throws", async () => { + const w = fakeWorker() + const spec = { + createWorker: () => w, + handle() { + throw new Error("boom") + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + expect(conn.frames).toContainEqual({id: "a", type: "error", message: "boom"}) + }) + + it("falls through when open never settles rather than wedging frames", async () => { + const w = fakeWorker() + vi.useFakeTimers() + const spec = { + createWorker: () => w, + open: () => new Promise(() => {}), // never resolves + handle: (frame, io) => { + io.emit({type: "ran", state: io.state}) + }, + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + // Advance past the OPEN_TIMEOUT so the bounded race resolves null. + await vi.advanceTimersByTimeAsync(5001) + vi.useRealTimers() + await conn.settle() + // handle ran anyway, with state === null (the timeout value) + expect(conn.frames).toContainEqual({id: "a", type: "ran", state: null}) + }) +}) diff --git a/libraries/patchwork-worker/session.js b/libraries/patchwork-worker/session.js new file mode 100644 index 00000000..ede58c0b --- /dev/null +++ b/libraries/patchwork-worker/session.js @@ -0,0 +1,241 @@ +/** + * openSession — request/response multiplexing over a worker connection. + * + * `connectWorker` gives you a raw `{readable, writable}` pair. Every consumer + * then writes the same layer on top of it: open the connection lazily, keep one + * writer, pump the readable, tag each request with an id, route event frames + * back to the right in-flight caller, and settle on a terminal frame. That layer + * had been written three times (chat's llm-client, patchwork-llm's client, and + * the mirror-image demux inside patchwork-llm's own service), which is also why + * the same reconnect bug existed in three places. + * + * This module owns it once. It stays service-agnostic: frames are opaque, and + * the caller says which `type` values are terminal. + * + * const session = openSession("llm", {element}) + * const {promise, abort} = session.request( + * {op: "generate", messages}, + * { + * terminal: {result: (f) => f.text, error: (f) => { throw new Error(f.message) }}, + * onFrame: (f) => { if (f.type === "token") ui.append(f.delta) }, + * signal, + * } + * ) + * + * Connection lifetime: opened on the first request, shared by every request + * after it, and DROPPED whenever it fails or ends — so the next request + * reconnects instead of replaying a dead or rejected connection forever. + * + * NOTE: this module deliberately does NOT import ./connect.js. `connectWorker` + * is injected by `createOpenSession` instead, so the dependency runs one way + * (connect.js -> session.js) and the package keeps a single entry point. See the + * entry-point note in connect.js for why a second entry point is a hazard here. + */ + +/** + * @typedef {Object} RequestOpts + * @property {Recordany>} terminal frame.type -> settle. The + * return value resolves the request; throw to reject it. Any type listed here + * ends the request. + * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id + * @property {AbortSignal} [signal] aborting sends {op:"abort", id} and rejects + * @property {HTMLElement} [element] discovery element, if not set on the session + */ + +/** + * Build the `openSession` export, bound to a `connectWorker` implementation. + * Called once from connect.js; consumers use the resulting `openSession`. + * + * @param {(kind: string, request: any, opts?: any) => Promise} connectWorker + */ +export function createOpenSession(connectWorker) { + /** + * Open a lazily-connected, multiplexed session for a worker `kind`. + * + * @param {string} kind + * @param {{element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}} [sessionOpts] + */ + return function openSession(kind, sessionOpts = {}) { + const idPrefix = sessionOpts.idPrefix || kind + const log = sessionOpts.onLog || (() => {}) + + /** @type {Promise|null} */ + let connectionPromise = null + /** id -> {onFrame, onClosed} for every request still in flight */ + const handlers = new Map() + let idSeq = 0 + + const nextId = () => idPrefix + "-" + ++idSeq + "-" + (performance.now() | 0) + + /** + * Drop the cached connection, and FAIL everything still in flight on it. + * + * Uncaching matters because a single failure (requesting before the provider + * has mounted, say) would otherwise be cached and every later request would + * fail instantly with the stale error, and a normally-closed connection would + * never reopen. + * + * Failing the in-flight requests matters more. They are waiting on frames + * that can no longer arrive: the stream they were reading is gone. Leaving + * them in `handlers` orphans each promise forever — no rejection, no + * timeout, and their abort listeners stay attached to whatever signal the + * caller passed. A consumer that awaits generation with no deadline (chat + * does) wedges permanently with no error to show. + */ + function reset(cause) { + connectionPromise = null + const inFlight = [...handlers.values()] + handlers.clear() + for (const h of inFlight) h.onClosed(cause) + } + + function ensureConnection(element) { + if (connectionPromise) return connectionPromise + const el = element ?? sessionOpts.element + connectionPromise = (async () => { + const conn = await connectWorker(kind, {}, {element: el}) + // Keep the writer ON the connection, not in closure state: `send` + // awaits `ensureConnection` and the connection can end during that + // await, so a shared `writer` variable may be null — or belong to a + // newer connection — by the time the write lands. + conn.writer = conn.writable.getWriter() + void pump(conn.readable) + return conn + })() + // Don't let an unawaited rejection surface as unhandled; just uncache it. + connectionPromise.catch((e) => reset(e)) + return connectionPromise + } + + async function pump(readable) { + const reader = readable.getReader() + /** @type {any} */ + let cause = null + try { + while (true) { + const {value, done} = await reader.read() + if (done) break + const h = value && value.id != null && handlers.get(value.id) + if (h) h.onFrame(value) + } + } catch (e) { + cause = e + log("connection readable errored", e) + } finally { + reset(cause) + } + } + + /** Write one frame, opening the connection if needed. */ + async function send(frame, element) { + const conn = await ensureConnection(element) + // Don't use `?.` here: silently resolving without writing would leave the + // caller's request unsettled with no error to explain it. + if (!conn.writer) throw new Error(`worker connection for "${kind}" is closed`) + await conn.writer.write(frame) + } + + /** + * Issue one request. Returns the settle promise plus an `abort()`. + * + * @param {any} frame the request frame (an `id` is added) + * @param {RequestOpts} opts + */ + function request(frame, opts) { + const id = nextId() + const terminal = opts.terminal || {} + + let settled = false + /** @type {(v:any)=>void} */ + let resolveFn + /** @type {(e:any)=>void} */ + let rejectFn + + const cleanup = () => { + handlers.delete(id) + opts.signal?.removeEventListener("abort", onAbort) + } + + // Only tell the service to stop if we actually asked it to start. An + // already-aborted signal would otherwise open a whole connection — up to + // the full discovery timeout — purely to abort a request that was never + // sent. + let sent = false + + function onAbort() { + if (settled) return + settled = true + if (sent) void send({op: "abort", id}, opts.element).catch(() => {}) + cleanup() + rejectFn(new DOMException("Aborted", "AbortError")) + } + + const promise = new Promise((resolve, reject) => { + resolveFn = resolve + rejectFn = reject + }) + + handlers.set(id, { + onFrame(f) { + if (settled) return + const settle = terminal[f.type] + if (settle) { + settled = true + cleanup() + try { + resolveFn(settle(f)) + } catch (e) { + rejectFn(e) + } + return + } + try { + opts.onFrame?.(f) + } catch (e) { + log("onFrame threw", e) + } + }, + // The connection ended before a terminal frame arrived. Nothing more + // can come, so fail rather than wait forever. + onClosed(cause) { + if (settled) return + settled = true + cleanup() + rejectFn( + cause instanceof Error + ? cause + : new Error(`worker connection for "${kind}" closed before the request completed`) + ) + }, + }) + + if (opts.signal) { + if (opts.signal.aborted) { + onAbort() + return {promise, abort: onAbort} + } + opts.signal.addEventListener("abort", onAbort) + } + + send({...frame, id}, opts.element).then( + () => { + sent = true + }, + (e) => { + if (settled) return + settled = true + cleanup() + rejectFn(e) + } + ) + + return {promise, abort: onAbort} + } + + return { + request, + /** Send a fire-and-forget frame (no id correlation, no reply expected). */ + notify: (frame, element) => send(frame, element).catch(() => {}), + } + } +} diff --git a/libraries/patchwork-worker/tsconfig.json b/libraries/patchwork-worker/tsconfig.json new file mode 100644 index 00000000..96190789 --- /dev/null +++ b/libraries/patchwork-worker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNEXT", + "module": "ESNEXT", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["index.js", "connect.js", "session.js", "serve.js", "client.js"] +} diff --git a/libraries/patchwork-worker/types/client.d.ts b/libraries/patchwork-worker/types/client.d.ts new file mode 100644 index 00000000..3ae92db0 --- /dev/null +++ b/libraries/patchwork-worker/types/client.d.ts @@ -0,0 +1,43 @@ +/** + * @typedef {ReturnType} WorkerSession + * @typedef {(session: WorkerSession) => any} WorkerClientFactory + * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin + */ +/** + * Resolve the `patchwork:worker-client` plugin for `kind`, open a session for + * the same kind, and return `factory(session)` — the service's client API. + * + * Rejects if no plugin for `kind` is registered and loaded within `timeoutMs`, + * or if the plugin did not resolve to a function. Callers should not cache a + * rejected result: a later call may succeed once the service package registers. + * + * @param {string} kind + * @param {{ + * sessionOpts?: {element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}, + * timeoutMs?: number, + * }} [opts] + * @returns {Promise} + */ +export function connectWorkerClient(kind: string, opts?: { + sessionOpts?: { + element?: HTMLElement; + idPrefix?: string; + onLog?: (...a: any[]) => void; + }; + timeoutMs?: number; +}): Promise; +/** + * The plugin type a service package registers to offer a typed client for its + * worker. Paired with `patchwork:worker` by `id`. Also exported (as a bare + * string) from index.js so naming it costs no import. + */ +export const WORKER_CLIENT_PLUGIN_TYPE: "patchwork:worker-client"; +export type WorkerSession = ReturnType; +export type WorkerClientFactory = (session: WorkerSession) => any; +export type WorkerClientPlugin = { + type: "patchwork:worker-client"; + id: string; + name?: string; + load: () => Promise; +}; +import { openSession } from "./connect.js"; diff --git a/libraries/patchwork-worker/types/connect.d.ts b/libraries/patchwork-worker/types/connect.d.ts new file mode 100644 index 00000000..ac147340 --- /dev/null +++ b/libraries/patchwork-worker/types/connect.d.ts @@ -0,0 +1,128 @@ +/** @typedef {{ readable: ReadableStream, writable: WritableStream }} WorkerStreams */ +/** @typedef {WorkerStreams & { disconnect: () => void }} WorkerConnection */ +/** + * What a `patchwork:worker` plugin's `load()` must resolve to: a WorkerSpec that + * `serveWorkerSpec` (serve.js) drives. The transport owns the streams, ids, and + * per-connection worker; the spec owns only its op vocabulary. `open(ctx)` gets + * the provider's mount point, so a worker can resolve host-realm context (a + * settings doc, say) without the provider knowing about that service. + * + * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather + * than importing serve.js, because this file is the sandbox-loaded consumer half + * and must not pull the host-only serve module into its graph. Types erase, so + * this costs nothing at runtime.) + * @typedef {{ + * createWorker: () => Worker | Promise, + * open?: (ctx: {element?: HTMLElement}) => any, + * handle: (frame: any, io: any) => any, + * abort?: (token: any, post: (msg: any, transfer?: Transferable[]) => void) => void, + * }} WorkerSpec + */ +/** + * The descriptor a package puts in its `plugins` array to offer a worker. + * @typedef {{type: "patchwork:worker", id: string, name?: string, load: () => Promise}} WorkerPlugin + */ +/** + * Open a connection to a worker of `kind`. + * + * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` in the + * DOM subtree of `opts.element` answers, transferring streams back over the + * port. In the host realm that's a mounted worker provider; inside isolation + * it's the providers-bridge, which relays to the host and transfers the host's + * streams across the boundary. Either way the consumer gets the same + * `{readable, writable, disconnect}` and never learns which answered. + * + * There is deliberately NO fallback to a locally-registered worker. Inside the + * sandbox that fallback was a hole: any in-boundary tool that imported a service + * package would register its worker as an import side-effect, and a connection + * that should have been refused would instead run the worker in the opaque + * origin — no shared model cache, no host config, and no signal that the + * isolation boundary had been bypassed. Serving is the provider's job; a + * consumer with no provider ancestor fails loudly instead. + * + * @param {string} kind + * @param {any} request the opening request (service-specific; carried to `run`) + * @param {{ element?: HTMLElement | null, signal?: AbortSignal }} [opts] + * @returns {Promise} + */ +export function connectWorker(kind: string, request: any, opts?: { + element?: HTMLElement | null; + signal?: AbortSignal; +}): Promise; +/** Record an element for elementless discovery (call from a UI that has one). */ +export function rememberDiscoveryElement(element: any): void; +/** + * The selector type used to discover a worker-connection provider. A consumer + * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind, request }` + * carrying a MessagePort in `detail.port`. The answering side replies over that + * port with exactly one of: + * + * {readable, writable} — success; the pair is TRANSFERRED, not cloned + * null — refused; fail fast + * + * Both arrive in the standard providers envelope (`{type:"change", value}`), + * because the answering side responds through `accept()`. + * + * Refusal is a `null` VALUE rather than its own message type: `accept()` owns + * the envelope, so there is no second type to use. That is the trade for + * speaking the canonical protocol, and it matches what every other provider in + * the repo now answers when it cannot serve. + * + * Silence is also a valid outcome (nothing is mounted to answer), which the + * consumer's bounded discovery timeout covers. Answering sides that KNOW they're + * refusing should respond `null` rather than staying silent, so the consumer + * doesn't wait out the timeout for an answer that already exists. + * + * Deliberately a STRING, not a Symbol. Comparisons against it are `===` on the + * value (here, in the host worker provider, and as an inlined literal in the + * isolation iframe bridge), so it keeps working even if this module is somehow + * evaluated more than once. A Symbol would silently stop matching. + */ +export const CHANNEL_SELECTOR: "patchwork:worker-channel"; +export const openSession: (kind: string, sessionOpts?: { + element?: HTMLElement; + idPrefix?: string; + onLog?: (...a: any[]) => void; +}) => { + request: (frame: any, opts: RequestOpts) => { + promise: Promise; + abort: () => void; + }; + notify: (frame: any, element: any) => Promise; +}; +export type WorkerStreams = { + readable: ReadableStream; + writable: WritableStream; +}; +export type WorkerConnection = WorkerStreams & { + disconnect: () => void; +}; +/** + * What a `patchwork:worker` plugin's `load()` must resolve to: a WorkerSpec that + * `serveWorkerSpec` (serve.js) drives. The transport owns the streams, ids, and + * per-connection worker; the spec owns only its op vocabulary. `open(ctx)` gets + * the provider's mount point, so a worker can resolve host-realm context (a + * settings doc, say) without the provider knowing about that service. + * + * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather + * than importing serve.js, because this file is the sandbox-loaded consumer half + * and must not pull the host-only serve module into its graph. Types erase, so + * this costs nothing at runtime.) + */ +export type WorkerSpec = { + createWorker: () => Worker | Promise; + open?: (ctx: { + element?: HTMLElement; + }) => any; + handle: (frame: any, io: any) => any; + abort?: (token: any, post: (msg: any, transfer?: Transferable[]) => void) => void; +}; +/** + * The descriptor a package puts in its `plugins` array to offer a worker. + */ +export type WorkerPlugin = { + type: "patchwork:worker"; + id: string; + name?: string; + load: () => Promise; +}; diff --git a/libraries/patchwork-worker/types/index.d.ts b/libraries/patchwork-worker/types/index.d.ts new file mode 100644 index 00000000..5bce78e3 --- /dev/null +++ b/libraries/patchwork-worker/types/index.d.ts @@ -0,0 +1,40 @@ +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + * @typedef {import("./connect.js").WorkerSpec} WorkerSpec + * @typedef {import("./connect.js").WorkerPlugin} WorkerPlugin + * @typedef {import("./connect.js").WorkerStreams} WorkerStreams + * @typedef {import("./connect.js").WorkerConnection} WorkerConnection + */ +/** + * The plugin type a package registers to offer a worker. A plain string, so + * naming it costs no import. + */ +export const WORKER_PLUGIN_TYPE: "patchwork:worker"; +/** + * The paired plugin type a package registers to offer a typed client for its + * worker (see ./client.js). Same id as the worker. A plain string here so the + * entry never imports client.js. + */ +export const WORKER_CLIENT_PLUGIN_TYPE: "patchwork:worker-client"; +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + */ +export type WorkerSpec = import("./connect.js").WorkerSpec; +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + */ +export type WorkerPlugin = import("./connect.js").WorkerPlugin; +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + */ +export type WorkerStreams = import("./connect.js").WorkerStreams; +/** + * Types a package registering a worker needs, re-exported so it can type its + * `load()` without reaching into the subpath. + */ +export type WorkerConnection = import("./connect.js").WorkerConnection; +export { connectWorker, rememberDiscoveryElement, openSession, CHANNEL_SELECTOR } from "./connect.js"; diff --git a/libraries/patchwork-worker/types/serve.d.ts b/libraries/patchwork-worker/types/serve.d.ts new file mode 100644 index 00000000..dfa1519e --- /dev/null +++ b/libraries/patchwork-worker/types/serve.d.ts @@ -0,0 +1,61 @@ +/** + * Serve one worker connection from a spec. Returns the `{readable, writable}` the + * provider transfers to the consumer. One Worker is created for this connection + * and terminated when either stream ends. + * + * @param {WorkerSpec} spec + * @param {any} _request the opening request (reserved; specs read per-frame data instead) + * @param {{element?: HTMLElement}} [ctx] + * @returns {{readable: ReadableStream, writable: WritableStream}} + */ +export function serveWorkerSpec(spec: WorkerSpec, _request: any, ctx?: { + element?: HTMLElement; +}): { + readable: ReadableStream; + writable: WritableStream; +}; +export type Post = (msg: any, transfer?: Transferable[]) => void; +export type Emit = (frame: any) => void; +/** + * what a spec's `handle` is given + */ +export type IO = { + /** + * send a message to the worker (transfer supported) + */ + post: Post; + /** + * enqueue a frame onto THIS consumer's readable + */ + emit: Emit; + /** + * register a handler for worker messages tagged with `workerId`; return a truthy + * value from `fn` when the request is complete and the transport should clean up + */ + on: (fn: (msg: any) => boolean | void) => void; + /** + * the transport-minted id to tag worker payloads with + */ + workerId: string; + /** + * whatever `spec.open` resolved (or null) + */ + state: any; + /** + * host-realm context from the provider + */ + ctx: { + element?: HTMLElement; + }; +}; +export type WorkerSpec = { + createWorker: () => Worker | Promise; + open?: ((ctx: { + element?: HTMLElement; + }) => any) | undefined; + /** + * returns an opaque abort token (or nothing) stored per request + */ + handle: (frame: any, io: IO) => any; + abort?: ((token: any, post: Post) => void) | undefined; +}; diff --git a/libraries/patchwork-worker/types/session.d.ts b/libraries/patchwork-worker/types/session.d.ts new file mode 100644 index 00000000..4c0484ad --- /dev/null +++ b/libraries/patchwork-worker/types/session.d.ts @@ -0,0 +1,80 @@ +/** + * openSession — request/response multiplexing over a worker connection. + * + * `connectWorker` gives you a raw `{readable, writable}` pair. Every consumer + * then writes the same layer on top of it: open the connection lazily, keep one + * writer, pump the readable, tag each request with an id, route event frames + * back to the right in-flight caller, and settle on a terminal frame. That layer + * had been written three times (chat's llm-client, patchwork-llm's client, and + * the mirror-image demux inside patchwork-llm's own service), which is also why + * the same reconnect bug existed in three places. + * + * This module owns it once. It stays service-agnostic: frames are opaque, and + * the caller says which `type` values are terminal. + * + * const session = openSession("llm", {element}) + * const {promise, abort} = session.request( + * {op: "generate", messages}, + * { + * terminal: {result: (f) => f.text, error: (f) => { throw new Error(f.message) }}, + * onFrame: (f) => { if (f.type === "token") ui.append(f.delta) }, + * signal, + * } + * ) + * + * Connection lifetime: opened on the first request, shared by every request + * after it, and DROPPED whenever it fails or ends — so the next request + * reconnects instead of replaying a dead or rejected connection forever. + * + * NOTE: this module deliberately does NOT import ./connect.js. `connectWorker` + * is injected by `createOpenSession` instead, so the dependency runs one way + * (connect.js -> session.js) and the package keeps a single entry point. See the + * entry-point note in connect.js for why a second entry point is a hazard here. + */ +/** + * @typedef {Object} RequestOpts + * @property {Recordany>} terminal frame.type -> settle. The + * return value resolves the request; throw to reject it. Any type listed here + * ends the request. + * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id + * @property {AbortSignal} [signal] aborting sends {op:"abort", id} and rejects + * @property {HTMLElement} [element] discovery element, if not set on the session + */ +/** + * Build the `openSession` export, bound to a `connectWorker` implementation. + * Called once from connect.js; consumers use the resulting `openSession`. + * + * @param {(kind: string, request: any, opts?: any) => Promise} connectWorker + */ +export function createOpenSession(connectWorker: (kind: string, request: any, opts?: any) => Promise): (kind: string, sessionOpts?: { + element?: HTMLElement; + idPrefix?: string; + onLog?: (...a: any[]) => void; +}) => { + request: (frame: any, opts: RequestOpts) => { + promise: Promise; + abort: () => void; + }; + /** Send a fire-and-forget frame (no id correlation, no reply expected). */ + notify: (frame: any, element: any) => Promise; +}; +export type RequestOpts = { + /** + * frame.type -> settle. The + * return value resolves the request; throw to reject it. Any type listed here + * ends the request. + */ + terminal: Record any>; + /** + * every non-terminal frame for this id + */ + onFrame?: ((frame: any) => void) | undefined; + /** + * aborting sends {op:"abort", id} and rejects + */ + signal?: AbortSignal | undefined; + /** + * discovery element, if not set on the session + */ + element?: HTMLElement | undefined; +}; diff --git a/libraries/patchwork-worker/vitest.config.ts b/libraries/patchwork-worker/vitest.config.ts new file mode 100644 index 00000000..f21db690 --- /dev/null +++ b/libraries/patchwork-worker/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, +}); From cb3e9a7b0f65093e4d6103e6c65e4af5e0e13bbf Mon Sep 17 00:00:00 2001 From: grjte Date: Mon, 21 Sep 2026 11:29:05 +0100 Subject: [PATCH 2/2] update: patchwork-worker after review --- libraries/patchwork-worker/README.md | 235 +++++----- libraries/patchwork-worker/client.js | 46 +- libraries/patchwork-worker/client.test.js | 28 +- libraries/patchwork-worker/connect.js | 174 +++---- libraries/patchwork-worker/connect.test.js | 424 +++++++----------- libraries/patchwork-worker/index.js | 71 ++- libraries/patchwork-worker/package.json | 2 +- libraries/patchwork-worker/pnpm-lock.yaml | 252 +++++------ libraries/patchwork-worker/serve.js | 105 +++-- libraries/patchwork-worker/serve.test.js | 234 +++++----- libraries/patchwork-worker/session.js | 167 ++++--- libraries/patchwork-worker/tsconfig.json | 2 +- libraries/patchwork-worker/types/client.d.ts | 19 +- libraries/patchwork-worker/types/connect.d.ts | 83 ++-- libraries/patchwork-worker/types/index.d.ts | 42 +- libraries/patchwork-worker/types/serve.d.ts | 11 +- libraries/patchwork-worker/types/session.d.ts | 96 ++-- 17 files changed, 968 insertions(+), 1023 deletions(-) diff --git a/libraries/patchwork-worker/README.md b/libraries/patchwork-worker/README.md index 7e0cf955..15aa4e62 100644 --- a/libraries/patchwork-worker/README.md +++ b/libraries/patchwork-worker/README.md @@ -1,37 +1,35 @@ # @grjte/patchwork-worker -Run a worker in the host realm and hand any consumer a transferable stream pair — -the same code inside or outside a Patchwork isolation boundary. +Run a worker in the host realm and hand any consumer a transferable stream pair, +so a plugin that needs a worker is written once and works the same whether or not +it runs behind an isolation boundary. ```js -// consumer (may be inside the sandbox) -import { openSession } from "@grjte/patchwork-worker/connect.js"; -const session = openSession("llm", { element }); -const { promise } = session.request( - { op: "generate", messages }, - { - terminal: { - result: (f) => f.text, - error: (f) => { - throw new Error(f.message); - }, - }, - onFrame: (f) => f.type === "token" && ui.append(f.delta), - } -); +// consumer — the same code in any realm +import { connectWorkerClient } from "@grjte/patchwork-worker/client.js"; +const search = await connectWorkerClient("search-index"); +const hits = await search.query("patchwork", { element }); ``` ```js // a package that owns a worker — declarative, nothing imported until first use export const plugins = [ { - type: "patchwork:worker", + type: "patchwork:worker", // serve half: how to run the worker id: "search-index", name: "Search Index", async load() { - return makeWorkerSpec(); + return makeWorkerSpec(); // -> WorkerSpec {createWorker, open?, handle, abort?} }, - }, // -> WorkerSpec {createWorker, open?, handle, abort?} + }, + { + type: "patchwork:worker-client", // consume half: the typed client + id: "search-index", + name: "Search Index client", + async load() { + return makeSearchClient; // -> (session) => {query, ...} + }, + }, ]; ``` @@ -39,36 +37,39 @@ The package is service-agnostic: it knows how to run _a_ worker in the host and stream to _a_ consumer, and nothing about what any particular worker computes. A concrete service (an LLM, a transcription engine, a search indexer) plugs in through the **plugin registry**: it registers a plugin of type `patchwork:worker` -whose `id` names the worker `kind` and whose `load()` resolves to a **WorkerSpec**. -That registration is the whole coupling — the host provider (the +whose `id` names the worker `kind` and whose `load()` resolves to a **WorkerSpec**, +and usually a paired `patchwork:worker-client` plugin with the same `id`. That +registration is the whole coupling. The host provider (the `patchwork-worker-provider` component, shipped by the `providers` package) -discovers workers by looking them up in the `patchwork:worker` registry by -`kind`, so a consumer that connects to `"llm"` reaches whatever package -registered a `patchwork:worker` plugin with `id: "llm"`. Nothing is imported -until a consumer actually connects. +discovers workers by looking them up in the `patchwork:worker` registry by `kind`, +so a consumer that connects to `"search-index"` reaches whatever package +registered under that id. Nothing is imported until a consumer actually connects. This package is a plain library: it registers no plugins and is consumed as a dependency, never installed as a module. -For the LLM service built on top of this — its op vocabulary, config/secret -handling, and how it behaves in each topology — see -[`../../llm-host/README.md`](../../llm-host/README.md). +**Current example.** The first service built on this is the LLM: `llm-host` +registers the `"llm"` worker pair, and `chat` consumes it. Its op vocabulary and +config handling are its own concern and are documented in +[`../../llm-host/README.md`](../../llm-host/README.md). Nothing in this package is +specific to it. ## Why this exists -A Web Worker normally runs in the realm of the tool that constructed it. If a tool runs inside a -`null`-origin iframe, such as the Patchwork isolation package uses, then it -has no shared model cache, no WebGPU device the host set up, and — crucially — no -access to host-only state a worker may need (a settings doc, an API key), which is -deliberately denylisted from the sandbox. - -So instead of constructing the worker in the tool's realm, this package runs it in -the **host realm** and transfers only its `{readable, writable}` stream pair to the -consumer. The worker (and anything host-only it resolves) never leaves the host; -only structured-cloneable frames cross. Because the consumer's code is just "open a -connection, write request frames, read event frames," it is **identical in and out -of isolation** — the only difference is who answers the connection request and -whether the streams are transferred within one realm or across the boundary. +A Web Worker normally runs in the realm of the code that constructed it. When a +plugin runs in a sandboxed realm, that realm may lack things a worker needs: a +warm cache, a GPU device the host already set up, or host-only state such as a +settings document or a credential that must never enter the sandbox. + +So instead of constructing the worker in the consumer's realm, this package runs +it in the **host realm** and transfers only its `{readable, writable}` stream pair +to the consumer. The worker, and anything host-only it resolves, never leaves the +host; only structured-cloneable frames cross. Because the consumer's code is just +"open a connection, write request frames, read event frames," it is **identical +in and out of isolation**. The only difference is who answers the connection +request and whether the streams are handed over within one realm or transferred +across a boundary. How a given isolation mechanism relays the request and +transfers the streams is that mechanism's concern, not this package's. ## The three roles @@ -76,58 +77,52 @@ whether the streams are transferred within one realm or across the boundary. worker owner (host realm) consumer (any realm) ------------------------- -------------------- {type:"patchwork:worker", const {readable, writable, disconnect} - id:"llm", load: () => spec} = await connectWorker("llm", req, {element}) + id:"kind", load: () => spec} = await connectWorker("kind", {element}) spec = {createWorker, open?, handle, abort?} - → serveWorkerSpec(spec, req, {element}) + → serveWorkerSpec(spec, {element}) -> {readable, writable} writable <--- request frames {id, op, ...} ---- (consumer writes) readable ---- event frames {id, type, ...} ---> (consumer reads) ``` -- **The consumer** calls `connectWorker(kind, request, {element})` (or the - higher-level `openSession(kind)` for request/response multiplexing) and gets a - stream pair. This is the only part a sandboxed tool runs, and the only file it - loads (`connect.js`). +- **The consumer** calls `connectWorker(kind, {element})`, or the higher-level + `openSession(kind)` for request/response multiplexing, or (usually) + `connectWorkerClient(kind)` for the service's typed client, and gets a stream + pair. This is the only part that runs in the consumer's realm; it loads + `connect.js` and `client.js`, never `serve.js`. - **The provider** (`patchwork-worker-provider`, a `patchwork:component` shipped by the sibling `providers` package and mounted by the host frame) answers the connection request: it looks up `kind` in the `patchwork:worker` plugin - registry — i.e. finds the registered plugin whose `id` equals `kind` — loads - its `WorkerSpec`, and hands that to `serveWorkerSpec`, which it imports from - `@grjte/patchwork-worker/serve.js`. + registry, loads its `WorkerSpec`, and hands that to `serveWorkerSpec`, which it + imports from `@grjte/patchwork-worker/serve.js`. - **`serveWorkerSpec`** owns everything kind-agnostic: the stream pair and its controller lifecycle, **one dedicated worker per connection** (terminated on - teardown — no sharing, no reuse), request-id demux, the reserved `op:"abort"`, + teardown; no sharing, no reuse), request-id demux, the reserved `op:"abort"`, and a bounded `open` warm-up. The spec supplies only the service's op vocabulary. ### The paired client plugin -A service package usually also owns the *consume* half of its protocol — the code -that turns `generate(...)` into request frames. Rather than have every tool import -that code from the service package (a build-time dependency on a registry -package, which the repo rules forbid), the package registers it as a second -plugin with the **same id** as its worker: - -```js -export const plugins = [ - {type: "patchwork:worker", id: "llm", load: () => spec}, // serve half - {type: "patchwork:worker-client", id: "llm", load: () => (session) => api}, // consume half -]; -``` +A service package usually also owns the *consume* half of its protocol: the code +that turns a method call into request frames and reads the events back. Rather +than have every consumer import that code from the service package (a build-time +dependency on a registry package, which the repo rules forbid), the package +registers it as a second plugin with the **same id** as its worker, whose `load()` +resolves to a factory `(session) => clientApi`. A consumer then calls, from `@grjte/patchwork-worker/client.js`: ```js -const llm = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "chat"}}); -await llm.generate(messages, {element, ...}); +const api = await connectWorkerClient("kind", { sessionOpts: { idPrefix: "mytool" } }); ``` `connectWorkerClient` resolves the `patchwork:worker-client` plugin for the kind from the registry (bounded wait, so a missing service rejects instead of hanging), opens a session for the same kind, and returns `factory(session)`. Both halves of -the protocol ship in one package and cannot drift; the tool's only tie to the -service is the kind string. Under isolation the registry is mirrored into the -iframe, so the same call works in both realms. +the protocol ship in one package and cannot drift; the consumer's only tie to the +service is the kind string. The lookup is an ordinary registry lookup, so it works +in whatever realm the consumer runs in, provided that realm has a plugin registry +that knows about the service. ## The WorkerSpec contract @@ -140,7 +135,7 @@ A service implements this; `serveWorkerSpec` drives it. against that module's URL. Called at most once per connection, lazily, on the first frame that needs it. - open(ctx)? per-connection warm-up (e.g. resolving a settings doc). + open(ctx)? per-connection warm-up (e.g. resolving host-side config). ctx = {element} — the provider's mount point, so the worker can reach host-realm context without the transport knowing about it. BOUNDED by the transport (a 5s race): @@ -158,7 +153,10 @@ A service implements this; `serveWorkerSpec` drives it. caller id) on(fn) handle worker messages for this request; return truthy from fn when - the request is complete + the request is complete. A handle + that never calls on() is + fire-and-forget: nothing is tracked + for it and it cannot be aborted. workerId the id to tag worker payloads with state whatever open() resolved (or null) @@ -173,7 +171,7 @@ dropped. ## Frames -Frames are **opaque to this package** — any structured-cloneable object. The +Frames are **opaque to this package**: any structured-cloneable object. The transport only reads `frame.id` (to demux) and `frame.op === "abort"` (the one reserved op). Everything else is the service's own vocabulary. A connection is multiplexed by `id`, so one stream pair can carry many overlapping requests: @@ -185,67 +183,88 @@ multiplexed by `id`, so one stream pair can carry many overlapping requests: abort is {id, op:"abort"} (reserved) ``` -## Discovery + handoff go through patchwork-providers +## Discovery and handoff go through patchwork-providers The worker channel is an ordinary `patchwork:subscribe` whose `kind` is the `id` of a registered `patchwork:worker` plugin. The consumer calls `subscribe()`; the provider answers with `accept()`; the stream pair rides back in the value with the -streams named in the transfer list (**moved, not cloned** — a `ReadableStream` -can't be structured-cloned). It is the same relay as any other provider — a worker +streams named in the transfer list (**moved, not cloned**, since a `ReadableStream` +can't be structured-cloned). It is the same relay as any other provider: a worker connection is just a subscription whose value happens to carry transferred streams. ``` consumer answering side -------- -------------- subscribe(el, {type:"patchwork:worker-channel", ────────► (mounted provider element - kind, request}) answers via accept()) + kind}) answers via accept()) serveWorkerSpec(spec) -> streams listener({readable, writable}) ◄───────── respond({readable,writable}, (streams TRANSFERRED, not cloned) [readable,writable]) // TRANSFER ``` -Because it is an ordinary provider subscription and the streams are transferable, a -consumer's code is the same whether the provider that answers is in its own realm or -across an isolation boundary — the transport doesn't know or care which. How -isolation relays this subscription (and what gates it) is documented by the -isolation package. +Because it is an ordinary provider subscription and the streams are transferable, +a consumer's code is the same whether the provider that answers is in its own +realm or on the far side of an isolation boundary. The transport doesn't know or +care which. An isolation mechanism that wants to support workers needs to do two +things: relay this subscription to a realm where the provider is mounted, and +transfer the answered stream pair back. Everything else is unchanged. -If nothing answers, `connectWorker` resolves **`null`** — either a provider claimed -the subscription and answered a `null` value (an explicit refusal), or the bounded -discovery wait expired unclaimed. There is deliberately **no local-worker +If nothing answers, `connectWorker` **rejects**: either a provider claimed the +subscription and answered a `null` value (an explicit refusal, immediate), or the +bounded discovery wait expired unclaimed. There is deliberately **no local-worker fallback**: if a consumer could construct its own worker when discovery failed, a -tool that imports a service package would register that package's worker as an -import side-effect and silently serve itself, defeating the point of running the -worker in the host. A consumer treats `null` as "worker unavailable" and degrades. +consumer that imports a service package would register that package's worker as +an import side-effect and silently serve itself, defeating the point of running +the worker in the host. A consumer treats the rejection as "worker unavailable" +and degrades. + +## Sessions, broadcasts, and failure + +`openSession(kind)` opens the connection lazily on the first `request()`, shares +it across requests, and drops it whenever it fails or ends so the next request +reconnects. `session.close()` drops it on purpose: the serve half sees its +streams end and terminates the dedicated worker. + +Frames the worker posts **without an `id`** (progress, a status line, its own +error events) are connection-wide broadcasts. The serve half puts them on the +readable once; the session delivers each to every in-flight request's `onFrame`, +so a caller sees the worker's status exactly as it would from a same-realm worker. + +A request never hangs on a dead worker: if the connection ends, the worker +crashes, or the worker cannot be constructed, the serve half tears down, the +readable closes, and every in-flight request rejects. Aborting a request +(`abort()` or an `AbortSignal`) sends the reserved `op:"abort"` frame so the +service can cancel the matching worker work. ## Files -- `connect.js` — the consumer transport: `connectWorker` (discovery + handoff) and - `openSession` (request/response multiplexing over the pair). **The only file a - sandboxed tool loads.** -- `session.js` — `openSession` internals: id tagging, demux, abort, reconnect. +- `connect.js` — the consumer transport: `connectWorker` (discovery + handoff), + `openSession` (request/response multiplexing over the pair), and the protocol + constants (`CHANNEL_SELECTOR`, `WORKER_PLUGIN_TYPE`, `WORKER_CLIENT_PLUGIN_TYPE`). +- `session.js` — `openSession` internals: id tagging, demux, broadcast fan-out, + abort, reconnect, `close()`. - `serve.js` — `serveWorkerSpec`: streams, per-connection worker, id demux, abort. The serve half, imported by the host provider as `@grjte/patchwork-worker/serve.js`. - `client.js` — `connectWorkerClient`: resolves the paired `patchwork:worker-client` plugin from the registry and binds its factory to - `openSession(kind)`. Imports `@inkandswitch/patchwork-plugins` statically and is - therefore **not re-exported from index.js** (see below); consumers import the - subpath `@grjte/patchwork-worker/client.js`. -- `index.js` — barrel over connect.js plus the `WORKER_PLUGIN_TYPE` / - `WORKER_CLIENT_PLUGIN_TYPE` strings. No `plugins` array: this package is a - library, not a module. Its static graph must stay free of - `@inkandswitch/patchwork-plugins`, because the module loader evaluates package - entries in a Worker where that import cannot resolve — `connect.test.js` - enforces this. + `openSession(kind)`. The one file here that touches the plugin registry. +- `index.js` — barrel over connect.js and client.js. No `plugins` array: this + package is a library, not a module. + +Layering, by convention rather than test: `connect.js`/`session.js` stay free of +the plugin registry; `client.js` never imports `serve.js`; `serve.js` is +host-only and imports nothing from the consumer side. ## ⚠ Nothing in this package may hold host-only state or secrets -`connect.js` is fetched into the sandbox, and the isolation registry marker is per -package, so an isolated tool can reach any file here. Any host-only state or secret a -worker needs therefore lives in that worker's own service package (for the LLM, the API -key lives in the settings doc that `@grjte/llm-host` resolves — see its `README.md`), and -the host-realm provider that runs workers lives in the `providers` package. This package -holds no state and imports nothing service-specific. `client.js` touches only the plugin -registry (descriptors and lazy loaders); the factory it returns is the *service package's* -code, fetched by the same loader that fetches any tool. +The consumer half (`connect.js`, `client.js`) is loaded into whatever realm the +consumer runs in, including a sandboxed one, and a consumer that can load one +file of a package should be assumed able to load any of them. So nothing here may +hold state or secrets that a sandboxed consumer must not see. Any host-only state +or credential a worker needs lives in that worker's own service package, resolved +by its `WorkerSpec` in the host realm, and the host-realm provider that runs +workers lives in the `providers` package. This package holds no state and imports +nothing service-specific. `client.js` touches only the plugin registry +(descriptors and lazy loaders); the factory it returns is the *service package's* +code, fetched by the same loader that fetches any plugin. diff --git a/libraries/patchwork-worker/client.js b/libraries/patchwork-worker/client.js index be2ee2f3..aaa230c8 100644 --- a/libraries/patchwork-worker/client.js +++ b/libraries/patchwork-worker/client.js @@ -10,53 +10,39 @@ * The first is the SERVE half: the host-realm provider runs its WorkerSpec. The * second is the CONSUME half: a factory that turns an open `openSession(kind)` * session into the service's API (`{generate}` for the LLM, say). Both ship from - * the service package, so the frame vocabulary they share can never drift apart - * across releases — and a tool that calls + * the service package, so the frame vocabulary they share cannot drift apart — + * and a tool that calls * * const llm = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "chat"}}) * await llm.generate(messages, {element, ...}) * - * has no build-time dependency on that package at all. The lookup is late-bound - * through the registry (AGENTS.md's sanctioned coupling) and degrades to a - * rejection if nothing registered the kind. + * has no build-time dependency on that package. The lookup is late-bound through + * the registry and rejects if nothing registered the kind. * - * Works in both realms. Inside an isolation iframe the plugin registry is - * mirrored from the host (every registry type, ungated), the plugin's `load()` - * fetches the service package's entry through the iframe module loader, and the - * session is served by the host provider across the boundary — the same call. + * Works in any realm that has a plugin registry the service is registered in: + * the lookup is an ordinary registry lookup, and the session is served by the + * host provider wherever it is mounted, in-realm or across an isolation + * boundary — the same call either way. * - * ⚠ This file is deliberately NOT re-exported from index.js. It imports - * `@inkandswitch/patchwork-plugins` statically, whose graph reaches `window`; - * index.js -> connect.js is the graph the module loader evaluates in a Worker - * when it reads a package's `plugins`, and a bare import there kills it (see - * connect.js). Nothing evaluates this file except a consumer that asked for it: - * in the host it resolves through the bootloader importmap, in the iframe through - * the es-module-shims importmap (which already loads patchwork-plugins for the - * iframe's own registry). connect.test.js's "package shape" tests enforce the - * split. + * Layering: this is the one file in the package that touches the plugin + * registry. connect.js / session.js stay registry-free; serve.js is host-only + * and is never imported here. */ import {getRegistry} from "@inkandswitch/patchwork-plugins" -import {openSession} from "./connect.js" - -/** - * The plugin type a service package registers to offer a typed client for its - * worker. Paired with `patchwork:worker` by `id`. Also exported (as a bare - * string) from index.js so naming it costs no import. - */ -export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" +import {openSession, WORKER_CLIENT_PLUGIN_TYPE} from "./connect.js" /** * How long to wait for the client plugin to be registered AND loaded before * giving up. `loadWhenReady` is unbounded by design (it waits for a late - * registration — in the iframe, entries arrive via the bridge after the tool - * may already have mounted), so this is the only thing standing between a + * registration — in a sandboxed realm the service may be registered after the + * consumer has already mounted), so this is the only thing standing between a * missing service package and a consumer that awaits forever. */ const LOAD_TIMEOUT_MS = 10000 /** - * @typedef {ReturnType} WorkerSession + * @typedef {import("./session.js").Session} WorkerSession * @typedef {(session: WorkerSession) => any} WorkerClientFactory * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin */ @@ -71,7 +57,7 @@ const LOAD_TIMEOUT_MS = 10000 * * @param {string} kind * @param {{ - * sessionOpts?: {element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}, + * sessionOpts?: import("./session.js").SessionOpts, * timeoutMs?: number, * }} [opts] * @returns {Promise} diff --git a/libraries/patchwork-worker/client.test.js b/libraries/patchwork-worker/client.test.js index ec4cd1e6..711a852f 100644 --- a/libraries/patchwork-worker/client.test.js +++ b/libraries/patchwork-worker/client.test.js @@ -1,6 +1,4 @@ import {describe, it, expect, afterEach, vi} from "vitest" -import {existsSync, readFileSync} from "node:fs" -import {join} from "node:path" // connectWorkerClient resolves the client factory from the host plugin registry. // Stub it before importing, so these tests exercise the helper's own logic @@ -36,7 +34,7 @@ vi.mock("@inkandswitch/patchwork-plugins", () => ({ getRegistry: () => registry, })) -const {connectWorkerClient, WORKER_CLIENT_PLUGIN_TYPE} = await import("./client.js") +const {connectWorkerClient} = await import("./client.js") afterEach(() => { registry.reset() @@ -51,9 +49,9 @@ describe("connectWorkerClient", () => { }) const client = await connectWorkerClient("llm", {sessionOpts: {idPrefix: "t"}}) expect(client.generate()).toBe("ok") - // The factory receives an openSession() session: request + notify, nothing else. + // The factory receives an openSession() session. expect(typeof seen.request).toBe("function") - expect(typeof seen.notify).toBe("function") + expect(typeof seen.close).toBe("function") }) it("waits for a plugin registered after the call", async () => { @@ -74,24 +72,4 @@ describe("connectWorkerClient", () => { ) }) - it("names the paired plugin type", () => { - expect(WORKER_CLIENT_PLUGIN_TYPE).toBe("patchwork:worker-client") - }) -}) - -describe("package shape (client)", () => { - const dir = process.cwd() - const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) - - it("exposes client.js by subpath, outside the entry graph", () => { - expect(existsSync(join(dir, "client.js"))).toBe(true) - expect(pkg.exports["./client.js"].default).toBe("./client.js") - expect(pkg.files).toContain("client.js") - // index.js names the type as a bare string but must not import this file: - // its static graph is evaluated in the module-loader Worker, where - // @inkandswitch/patchwork-plugins cannot load. - const entry = readFileSync(join(dir, "index.js"), "utf8") - expect(entry).not.toMatch(/from\s+["']\.\/client\.js["']/) - expect(entry).toMatch(/WORKER_CLIENT_PLUGIN_TYPE\s*=\s*"patchwork:worker-client"/) - }) }) diff --git a/libraries/patchwork-worker/connect.js b/libraries/patchwork-worker/connect.js index 7ea5c09e..0fd21766 100644 --- a/libraries/patchwork-worker/connect.js +++ b/libraries/patchwork-worker/connect.js @@ -2,86 +2,86 @@ * connectWorker — a generic, transferable-stream connection to a worker-backed * service. * - * A tool asks to connect to a worker doing some kind of work and, for the - * lifetime of that connection, holds a `WritableStream` (to send request frames) - * and a `ReadableStream` (to receive event frames). The worker itself, and any + * A tool asks to connect to a worker of some `kind` and, for the lifetime of + * that connection, holds a `WritableStream` (to send request frames) and a + * `ReadableStream` (to receive event frames). The worker itself, and any * privileged setup it needs (config/secrets), live on whichever side answers the * connection — the SAME realm when there's no isolation boundary, or the host - * realm when the consumer runs inside a sandboxed iframe. Either way the consumer - * sees an identical `{readable, writable}` pair; the streams are transferable, so - * they cross the isolation boundary unchanged. + * realm when the consumer runs in a sandboxed realm. Either way the consumer sees + * an identical `{readable, writable}` pair; the streams are transferable, so they + * cross an isolation boundary unchanged. * - * This file is the CONSUMER half. `connectWorker(kind, request, opts?)` returns + * This file is the CONSUMER half. `connectWorker(kind, {element})` returns * `{readable, writable, disconnect}`; a provider answers via the discovery event - * (in-realm, or — across isolation — a bridge that produces host-realm streams). - * If nothing answers, it rejects. There is no local fallback. + * (in-realm, or relayed from the host realm by whatever isolation mechanism is + * in use). If nothing answers, it rejects. There is no local fallback. * * The SERVING half is `serveWorkerSpec` (./serve.js), driven by the host-realm * `patchwork-worker-provider` component (shipped by the `providers` package), * which resolves a WorkerSpec for the requested `kind` from the - * `patchwork:worker` plugin registry. Consumers never import serve.js — chat - * loads only this file into the sandbox. + * `patchwork:worker` plugin registry. Consumers never import serve.js. * - * This module is service-agnostic: it knows nothing about LLMs. It only moves a - * request out and a stream of events back, and lets something in between (a - * middlebox) sit on the streams. The LLM is the first consumer: `@grjte/llm-host` - * registers a `patchwork:worker` plugin with id "llm". - * - * Frame shapes are the service's concern; connect.js treats them as opaque - * structured-cloneable values. For the LLM these reuse the worker's existing - * message vocabulary (token / prediction / stats / result / error / …), tagged - * with an `id` so many requests can multiplex over one connection. + * This module is service-agnostic: it knows nothing about LLMs. Frame shapes are + * the service's concern; connect.js treats them as opaque structured-cloneable + * values, tagged with an `id` so many requests can multiplex over one connection. * * Discovery/handoff goes through patchwork-providers: this file calls * `subscribe()` and the host provider answers with `accept()`. The stream pair * rides in the value; `respond`'s transfer list moves rather than clones it, so * the streams stay live across the isolation boundary. + * + * Layering: this file stays free of the plugin registry so the sandbox-loaded + * transport carries no more than it needs; the registry lookup lives in + * ./client.js. */ import {createOpenSession} from "./session.js" +// --- Protocol constants ----------------------------------------------------- +// Deliberately STRINGS, not Symbols. Comparisons against them are `===` on the +// value (here, in the host worker provider, and possibly as inlined literals in +// code that relays the subscription across a boundary), so they keep working +// even if this module is evaluated more than once. A Symbol would silently stop +// matching. + /** * The selector type used to discover a worker-connection provider. A consumer - * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind, request }` + * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind }` * carrying a MessagePort in `detail.port`. The answering side replies over that - * port with exactly one of: + * port, in the standard providers envelope (`{type:"change", value}`), with + * exactly one of: * * {readable, writable} — success; the pair is TRANSFERRED, not cloned * null — refused; fail fast * - * Both arrive in the standard providers envelope (`{type:"change", value}`), - * because the answering side responds through `accept()`. - * - * Refusal is a `null` VALUE rather than its own message type: `accept()` owns - * the envelope, so there is no second type to use. That is the trade for - * speaking the canonical protocol, and it matches what every other provider in - * the repo now answers when it cannot serve. - * * Silence is also a valid outcome (nothing is mounted to answer), which the * consumer's bounded discovery timeout covers. Answering sides that KNOW they're - * refusing should respond `null` rather than staying silent, so the consumer - * doesn't wait out the timeout for an answer that already exists. - * - * Deliberately a STRING, not a Symbol. Comparisons against it are `===` on the - * value (here, in the host worker provider, and as an inlined literal in the - * isolation iframe bridge), so it keeps working even if this module is somehow - * evaluated more than once. A Symbol would silently stop matching. + * refusing should respond `null` rather than staying silent. */ export const CHANNEL_SELECTOR = "patchwork:worker-channel" +/** + * The plugin type a service package registers to offer a worker. Its `id` is + * the worker `kind`; `load()` resolves to a WorkerSpec (see ./serve.js). + */ +export const WORKER_PLUGIN_TYPE = "patchwork:worker" + +/** + * The paired plugin type a service package registers to offer a typed client + * for its worker. Same `id` as the worker; `load()` resolves to a factory + * `(session) => clientApi` (see ./client.js). + */ +export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" + /** * BACKSTOP: how long to wait for a provider to answer before giving up. * * Not control flow. A mounted provider either serves the kind (streams) or - * refuses it (`worker-unavailable`), and both are immediate — so in a healthy - * frame this timer never fires. It exists because an unclaimed - * `patchwork:subscribe` never settles by design (upstream removed the - * `` that used to answer `null`, so a subscription can wait - * for a provider that mounts later). Without a bound, a missing provider would - * hang the caller forever instead of erroring. - * - * If you find yourself tuning this number, something upstream is wrong: the - * frame should be gating its subtree on the provider being mounted. + * refuses it (`null`), and both are immediate — so in a healthy frame this timer + * never fires. It exists because an unclaimed `patchwork:subscribe` never + * settles by design (a subscription may wait for a provider that mounts later). + * Without a bound, a missing provider would hang the caller forever instead of + * erroring. */ const DISCOVERY_TIMEOUT_MS = 8000 @@ -94,10 +94,9 @@ const DISCOVERY_TIMEOUT_MS = 8000 * the provider's mount point, so a worker can resolve host-realm context (a * settings doc, say) without the provider knowing about that service. * - * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather - * than importing serve.js, because this file is the sandbox-loaded consumer half - * and must not pull the host-only serve module into its graph. Types erase, so - * this costs nothing at runtime.) + * (Shape mirrored from serve.js's `WorkerSpec`, kept as a local typedef so this + * sandbox-loaded file does not import the host-only serve module. Types erase, + * so this costs nothing at runtime.) * @typedef {{ * createWorker: () => Worker | Promise, * open?: (ctx: {element?: HTMLElement}) => any, @@ -114,14 +113,14 @@ const DISCOVERY_TIMEOUT_MS = 8000 /** * Open a connection to a worker of `kind`. * - * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` in the - * DOM subtree of `opts.element` answers, transferring streams back over the - * port. In the host realm that's a mounted worker provider; inside isolation - * it's the providers-bridge, which relays to the host and transfers the host's - * streams across the boundary. Either way the consumer gets the same - * `{readable, writable, disconnect}` and never learns which answered. + * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` above + * `element` answers, transferring streams back over the port. In the host realm + * that's a mounted worker provider; in a sandboxed realm it's whatever relays + * the subscription to the host and transfers the host's streams back across the + * boundary. The consumer gets the same `{readable, writable, disconnect}` either + * way and never learns which answered. * - * There is deliberately NO fallback to a locally-registered worker. Inside the + * There is deliberately NO fallback to a locally-constructed worker. Inside the * sandbox that fallback was a hole: any in-boundary tool that imported a service * package would register its worker as an import side-effect, and a connection * that should have been refused would instead run the worker in the opaque @@ -130,52 +129,51 @@ const DISCOVERY_TIMEOUT_MS = 8000 * consumer with no provider ancestor fails loudly instead. * * @param {string} kind - * @param {any} request the opening request (service-specific; carried to `run`) - * @param {{ element?: HTMLElement | null, signal?: AbortSignal }} [opts] + * @param {{ element: HTMLElement }} opts a node inside a mounted , + * to dispatch the discovery subscribe from * @returns {Promise} */ -export async function connectWorker(kind, request, opts = {}) { - const el = opts.element ?? discoveryElement() +export async function connectWorker(kind, opts) { + const el = opts?.element if (!el) { throw new Error( `no worker available for kind "${kind}": no element to discover a provider from` ) } - const streams = await discoverViaProvider(el, kind, request) + const streams = await discoverViaProvider(el, kind) if (!streams) { throw new Error(`no worker available for kind "${kind}"`) } return withDisconnect(streams) } -/** Wrap a {readable, writable} with a disconnect() that tears both ends down. */ +/** + * Wrap a {readable, writable} with a disconnect() that tears both ends down. + * Only valid while the caller holds no reader/writer lock (a locked stream + * rejects cancel/abort); a consumer that has taken locks releases through them. + * @param {WorkerStreams} streams + * @returns {WorkerConnection} + */ function withDisconnect(streams) { return { ...streams, disconnect() { - try { - streams.readable.cancel?.() - } catch {} - try { - streams.writable.abort?.() - } catch {} + streams.readable.cancel().catch(() => {}) + streams.writable.abort().catch(() => {}) }, } } /** * Ask a `patchwork:worker-channel` provider to open a connection, via providers - * `subscribe()`. The answering side responds through `accept()` with the stream - * pair in the value and both streams named in the transfer list, so they are - * moved rather than cloned. Resolves null on an explicit refusal, or if nothing - * answers within the discovery timeout. + * `subscribe()`. Resolves null on an explicit refusal, or if nothing answers + * within the discovery timeout. * * @param {HTMLElement} element * @param {string} kind - * @param {any} request * @returns {Promise} */ -function discoverViaProvider(element, kind, request) { +function discoverViaProvider(element, kind) { return new Promise((resolve) => { let settled = false /** @type {(() => void) | null} */ @@ -194,14 +192,6 @@ function discoverViaProvider(element, kind, request) { } const timer = setTimeout(() => finish(null), DISCOVERY_TIMEOUT_MS) - // patchwork-providers is imported DYNAMICALLY, and that is load-bearing. - // This file is in the entry graph (index.js -> connect.js), and the module - // loader evaluates the entry in a WORKER to read `plugins`. Every static - // import here becomes part of that evaluation; a bare specifier the worker - // cannot resolve kills the whole package with "ReferenceError: window is - // not defined". Before this migration connect.js had ONLY relative - // imports — keep it that way. - // // `subscribe` is typed `T extends JSONValue`, but a transferred stream pair // is not JSON — the cast is that constraint biting. `accept` // is deliberately unconstrained for exactly this case; the consumer half @@ -211,7 +201,7 @@ function discoverViaProvider(element, kind, request) { if (settled) return unsubscribe = /** @type {any} */ (subscribe)( element, - {type: CHANNEL_SELECTOR, kind, request}, + {type: CHANNEL_SELECTOR, kind}, (/** @type {WorkerStreams | null} */ value) => { // `null` is an explicit refusal — settle now rather than burning // the full discovery timeout for an answer that already exists. @@ -237,22 +227,6 @@ function discoverViaProvider(element, kind, request) { }) } -// A DOM node inside a mounted is needed to dispatch the -// discovery `patchwork:subscribe`. Consumers pass one via opts.element; when they -// don't, remember the most recent element any caller supplied (mirrors config.js's -// lastElement bootstrap), so elementless callers can still discover. -/** @type {HTMLElement | null} */ -let lastElement = null - -/** Record an element for elementless discovery (call from a UI that has one). */ -export function rememberDiscoveryElement(element) { - if (element) lastElement = element -} - -function discoveryElement() { - return lastElement -} - // --- Session layer ---------------------------------------------------------- // session.js takes `connectWorker` as a parameter rather than importing it, so // the dependency runs one way and there's no import cycle. diff --git a/libraries/patchwork-worker/connect.test.js b/libraries/patchwork-worker/connect.test.js index 779d8202..81fad3c0 100644 --- a/libraries/patchwork-worker/connect.test.js +++ b/libraries/patchwork-worker/connect.test.js @@ -2,12 +2,12 @@ import {describe, it, expect, afterEach, vi} from "vitest" import {existsSync, readFileSync} from "node:fs" import {join} from "node:path" import {accept} from "@inkandswitch/patchwork-providers" -import {connectWorker, rememberDiscoveryElement, openSession} from "./connect.js" +import {connectWorker, openSession} from "./connect.js" +import {serveWorkerSpec} from "./serve.js" // A minimal stand-in for the worker provider: answers worker-channel // subscriptions for the kinds it knows, and refuses the ones it doesn't. Uses -// the real `accept()`, so these tests exercise the actual providers envelope -// rather than a hand-rolled imitation of it. +// the real `accept()`, so these tests exercise the actual providers envelope. function serveKinds(kinds) { const listener = (e) => { const {selector, port} = e.detail ?? {} @@ -15,7 +15,7 @@ function serveKinds(kinds) { const run = kinds[selector.kind] if (!run) return // decline, do NOT claim — let it bubble accept(e, (respond) => { - Promise.resolve(run(selector.request)) + Promise.resolve(run()) .then((streams) => { respond({readable: streams.readable, writable: streams.writable}, [ streams.readable, @@ -29,6 +29,18 @@ function serveKinds(kinds) { return () => document.removeEventListener("patchwork:subscribe", listener) } +/** A provider that refuses every worker-channel subscription with `null`. */ +function refuseAll(kind) { + const refuse = (e) => { + const {selector, port} = e.detail ?? {} + if (selector?.type !== "patchwork:worker-channel" || !port) return + if (kind && selector.kind !== kind) return + accept(e, (respond) => respond(null)) + } + document.addEventListener("patchwork:subscribe", refuse) + return () => document.removeEventListener("patchwork:subscribe", refuse) +} + /** Echoes one token + a terminal result for every request frame written. */ function echoWorker() { let controller @@ -43,9 +55,19 @@ function echoWorker() { return {writable, readable} } +/** A hand-driven stream pair: the test controls the readable and sees writes. */ +function manualWorker() { + let controller + const written = [] + const readable = new ReadableStream({start: (c) => (controller = c)}) + const writable = new WritableStream({write: (f) => void written.push(f)}) + return {readable, writable, written, push: (f) => controller.enqueue(f), end: () => controller.close()} +} + function mountElement() { const el = document.createElement("div") document.body.appendChild(el) + cleanups.push(() => el.remove()) return el } @@ -53,7 +75,7 @@ let cleanups = [] afterEach(() => { cleanups.forEach((f) => f()) cleanups = [] - rememberDiscoveryElement(null) + vi.useRealTimers() }) async function readN(readable, n) { @@ -68,21 +90,28 @@ async function readN(readable, n) { return out } +const tick = (ms = 20) => new Promise((r) => setTimeout(r, ms)) + +const TERMINAL = { + result: (f) => f.text, + error: (f) => { + throw new Error(f.message) + }, +} + describe("connectWorker", () => { - it("connects when a provider answers, and streams frames", async () => { + it("connects when a provider answers, and the transferred streams are live", async () => { const kind = "echo-" + Math.random() cleanups.push(serveKinds({[kind]: echoWorker})) - const el = mountElement() - cleanups.push(() => el.remove()) - const conn = await connectWorker(kind, {}, {element: el}) + const conn = await connectWorker(kind, {element: mountElement()}) expect(conn.readable).toBeInstanceOf(ReadableStream) + expect(conn.writable).toBeInstanceOf(WritableStream) expect(typeof conn.disconnect).toBe("function") const writer = conn.writable.getWriter() - await writer.write({op: "generate", id: "x", text: "hi"}) + await writer.write({op: "generate", id: "x"}) writer.releaseLock() - const frames = await readN(conn.readable, 2) expect(frames[0]).toMatchObject({id: "x", type: "token"}) expect(frames[1]).toMatchObject({id: "x", type: "result"}) @@ -91,10 +120,8 @@ describe("connectWorker", () => { it("multiplexes many request ids over one connection", async () => { const kind = "echo-" + Math.random() cleanups.push(serveKinds({[kind]: echoWorker})) - const el = mountElement() - cleanups.push(() => el.remove()) - const conn = await connectWorker(kind, {}, {element: el}) + const conn = await connectWorker(kind, {element: mountElement()}) const writer = conn.writable.getWriter() await writer.write({op: "generate", id: "a"}) await writer.write({op: "generate", id: "b"}) @@ -106,21 +133,12 @@ describe("connectWorker", () => { }) it("fails fast on an explicit refusal", async () => { - // A refusal the provider already knows about must not cost the full - // discovery timeout — this is the path the isolation host bridge uses when - // the worker-channel selector isn't in shared-providers. - const el = mountElement() - cleanups.push(() => el.remove()) - const refuse = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || !port) return - accept(e, (respond) => respond(null)) - } - document.addEventListener("patchwork:subscribe", refuse) - cleanups.push(() => document.removeEventListener("patchwork:subscribe", refuse)) - + // A refusal the provider already knows about must not cost the discovery + // timeout — this is the path the isolation bridge uses when the worker + // channel isn't in shared-providers. + cleanups.push(refuseAll()) const started = Date.now() - await expect(connectWorker("nope", {}, {element: el})).rejects.toThrow( + await expect(connectWorker("nope", {element: mountElement()})).rejects.toThrow( /no worker available/ ) expect(Date.now() - started).toBeLessThan(1000) @@ -131,32 +149,22 @@ describe("connectWorker", () => { }) it("times out when nothing answers at all", async () => { - // Nothing is mounted, so this waits out DISCOVERY_TIMEOUT_MS. That timeout - // is a backstop, not control flow: a mounted provider either serves or - // refuses, and both are immediate. - const el = mountElement() - cleanups.push(() => el.remove()) - await expect(connectWorker("nobody-" + Math.random(), {}, {element: el})).rejects.toThrow( - /no worker available/ - ) - }, 12000) + // Nothing is mounted, so this waits out DISCOVERY_TIMEOUT_MS — a backstop, + // not control flow. Fake timers so the suite doesn't wait 8 s for real. + vi.useFakeTimers({toFake: ["setTimeout", "clearTimeout"]}) + const pending = connectWorker("nobody-" + Math.random(), {element: mountElement()}) + const assertion = expect(pending).rejects.toThrow(/no worker available/) + await vi.advanceTimersByTimeAsync(8001) + await assertion + }) }) describe("openSession", () => { - const TERMINAL = { - result: (f) => f.text, - error: (f) => { - throw new Error(f.message) - }, - } - it("resolves on the terminal frame and streams the rest", async () => { const kind = "echo-" + Math.random() cleanups.push(serveKinds({[kind]: echoWorker})) - const el = mountElement() - cleanups.push(() => el.remove()) - const session = openSession(kind, {element: el}) + const session = openSession(kind, {element: mountElement()}) const seen = [] const {promise} = session.request( {op: "generate"}, @@ -167,100 +175,136 @@ describe("openSession", () => { }) it("does not cache a failed connection — a later request reconnects", async () => { - // The regression this layer exists to prevent: one early failure used to - // poison the module-level promise, so every later request failed instantly - // with the stale error even once a provider was available. + // One early failure must not poison the session: once a provider shows up, + // the next request must find it. const kind = "echo-" + Math.random() - const el = mountElement() - cleanups.push(() => el.remove()) - const session = openSession(kind, {element: el}) - - const refuse = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || !port) return - accept(e, (respond) => respond(null)) - } - document.addEventListener("patchwork:subscribe", refuse) + const session = openSession(kind, {element: mountElement()}) + + const stopRefusing = refuseAll(kind) const first = session.request({op: "generate"}, {terminal: TERMINAL}) await expect(first.promise).rejects.toThrow(/no worker available/) - document.removeEventListener("patchwork:subscribe", refuse) + stopRefusing() cleanups.push(serveKinds({[kind]: echoWorker})) const second = session.request({op: "generate"}, {terminal: TERMINAL}) await expect(second.promise).resolves.toBe("hi") }) - it("fails in-flight requests when the connection drops", async () => { - // The hang this guards: pump()'s finally used to clear the connection but - // leave `handlers` populated, so a request awaiting a terminal frame that - // could no longer arrive never settled — no rejection, no timeout. Chat - // awaits generation with no deadline, so that wedged the UI silently. + it("fails in-flight requests when the connection drops, then reconnects", async () => { + // A request awaiting a terminal frame that can no longer arrive must reject, + // not hang; and the dead connection must not be reused. const kind = "drop-" + Math.random() - let controller - const listener = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return - accept(e, (respond) => { - const readable = new ReadableStream({start: (c) => (controller = c)}) - const writable = new WritableStream({write() {}}) - respond({readable, writable}, [readable, writable]) + const workers = [] + cleanups.push( + serveKinds({ + [kind]: () => { + const w = manualWorker() + workers.push(w) + return w + }, }) - } - document.addEventListener("patchwork:subscribe", listener) - cleanups.push(() => document.removeEventListener("patchwork:subscribe", listener)) + ) + const session = openSession(kind, {element: mountElement()}) - const el = mountElement() - cleanups.push(() => el.remove()) - const session = openSession(kind, {element: el}) - const {promise} = session.request({op: "generate"}, {terminal: TERMINAL}) + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + await tick() + workers[0].end() // stream ends with no terminal frame + await expect(first.promise).rejects.toThrow(/closed before the request completed/) - // Let the connection establish, then end the stream with no terminal frame. - await new Promise((r) => setTimeout(r, 20)) - controller.close() + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + await tick() + expect(workers).toHaveLength(2) // a new connection, not the dead one + expect(workers[1].written[0]).toMatchObject({op: "generate"}) + workers[1].push({id: workers[1].written[0].id, type: "result", text: "ok"}) + await expect(second.promise).resolves.toBe("ok") + }) - await expect(promise).rejects.toThrow(/closed before the request completed/) + it("fans id-less broadcast frames out to every in-flight request", async () => { + // The LLM worker posts model-download progress and its own errors as + // `{type:"status"}` with no id. Every caller waiting on that worker hears it. + const kind = "bcast-" + Math.random() + let w + cleanups.push(serveKinds({[kind]: () => (w = manualWorker())})) + const session = openSession(kind, {element: mountElement()}) + + const seenA = [] + const seenB = [] + const a = session.request({op: "generate"}, {terminal: TERMINAL, onFrame: (f) => seenA.push(f)}) + const b = session.request({op: "generate"}, {terminal: TERMINAL, onFrame: (f) => seenB.push(f)}) + await tick() + w.push({type: "status", message: "Downloading model weights… 40%"}) + await tick() + expect(seenA).toEqual([{type: "status", message: "Downloading model weights… 40%"}]) + expect(seenB).toEqual([{type: "status", message: "Downloading model weights… 40%"}]) + + // A settled request no longer hears broadcasts. + w.push({id: w.written[0].id, type: "result", text: "done"}) + await expect(a.promise).resolves.toBe("done") + w.push({type: "status", message: "later"}) + await tick() + expect(seenA).toHaveLength(1) + expect(seenB).toHaveLength(2) + w.push({id: w.written[1].id, type: "result", text: "done"}) + await b.promise }) - it("rejects rather than silently dropping a frame when the writer is gone", async () => { - // `await writer?.write(frame)` used to resolve successfully having written - // nothing if the connection ended mid-send, leaving the request unsettled. - const kind = "gone-" + Math.random() - let controller - const listener = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return - accept(e, (respond) => { - const readable = new ReadableStream({start: (c) => (controller = c)}) - const writable = new WritableStream({write() {}}) - respond({readable, writable}, [readable, writable]) - }) + it("close() tears the served connection down and the next request reconnects", async () => { + // End to end through serveWorkerSpec: closing the session ends both streams, + // so the host side terminates its dedicated worker. + const kind = "close-" + Math.random() + const workers = [] + const spec = { + createWorker: () => { + const fake = { + terminated: false, + onmessage: null, + postMessage() {}, + terminate() { + fake.terminated = true + }, + } + workers.push(fake) + return fake + }, + handle: (frame, io) => { + io.on(() => false) + io.post({id: io.workerId}) + }, } - document.addEventListener("patchwork:subscribe", listener) - cleanups.push(() => document.removeEventListener("patchwork:subscribe", listener)) + cleanups.push(serveKinds({[kind]: () => serveWorkerSpec(spec, {})})) + const session = openSession(kind, {element: mountElement()}) - const el = mountElement() - cleanups.push(() => el.remove()) - const session = openSession(kind, {element: el}) - - // Establish, then drop the connection so the cached writer is gone. const first = session.request({op: "generate"}, {terminal: TERMINAL}) - await new Promise((r) => setTimeout(r, 20)) - controller.close() - await expect(first.promise).rejects.toThrow() + await tick() + expect(workers).toHaveLength(1) + session.close() + await expect(first.promise).rejects.toThrow(/was closed/) + await tick() + expect(workers[0].terminated).toBe(true) - // A later request reconnects rather than writing into the dead one. const second = session.request({op: "generate"}, {terminal: TERMINAL}) - await new Promise((r) => setTimeout(r, 20)) - controller.close() - await expect(second.promise).rejects.toThrow() + second.promise.catch(() => {}) + await tick() + expect(workers).toHaveLength(2) + session.close() + }) + + it("forwards an abort that fires while the connection is still opening", async () => { + const kind = "abort-" + Math.random() + let w + cleanups.push(serveKinds({[kind]: () => (w = manualWorker())})) + const session = openSession(kind, {element: mountElement()}) + + const {promise, abort} = session.request({op: "generate"}, {terminal: TERMINAL}) + abort() // before discovery has resolved + await expect(promise).rejects.toThrow(/Aborted/) + await tick() + // The request frame was already on its way; the abort follows it. + expect(w.written.map((f) => f.op)).toEqual(["generate", "abort"]) + expect(w.written[1].id).toBe(w.written[0].id) }) it("does not open a connection to abort a request that was never sent", async () => { - // An already-aborted signal used to fire the abort path, which called send() - // — opening a whole connection (up to the discovery timeout) purely to - // cancel something that never started. - const el = mountElement() - cleanups.push(() => el.remove()) let dispatches = 0 const count = (e) => { if (e.detail?.selector?.type === "patchwork:worker-channel") dispatches++ @@ -268,7 +312,7 @@ describe("openSession", () => { document.addEventListener("patchwork:subscribe", count) cleanups.push(() => document.removeEventListener("patchwork:subscribe", count)) - const session = openSession("never-" + Math.random(), {element: el}) + const session = openSession("never-" + Math.random(), {element: mountElement()}) const {promise} = session.request( {op: "generate"}, {terminal: TERMINAL, signal: AbortSignal.abort()} @@ -276,142 +320,18 @@ describe("openSession", () => { await expect(promise).rejects.toThrow(/Aborted/) expect(dispatches).toBe(0) }) - - it("rejects when the signal is already aborted", async () => { - const el = mountElement() - cleanups.push(() => el.remove()) - const session = openSession("x", {element: el}) - const {promise} = session.request( - {op: "generate"}, - {terminal: TERMINAL, signal: AbortSignal.abort()} - ) - await expect(promise).rejects.toThrow(/Aborted/) - }) -}) - -describe("providers envelope", () => { - it("carries the stream pair through the value, alive on arrival", async () => { - // The premise the whole migration rests on: respond()'s transfer list goes - // on the OUTER postMessage, so streams nested in `value` are MOVED, not - // structured-cloned (which would throw DataCloneError). - const kind = "envelope-" + Math.random() - cleanups.push(serveKinds({[kind]: echoWorker})) - const el = mountElement() - cleanups.push(() => el.remove()) - - const conn = await connectWorker(kind, {}, {element: el}) - expect(conn.readable).toBeInstanceOf(ReadableStream) - expect(conn.writable).toBeInstanceOf(WritableStream) - - // Live, not a detached husk: a round trip still works. - const writer = conn.writable.getWriter() - await writer.write({id: "1", op: "generate"}) - writer.releaseLock() - const {value} = await conn.readable.getReader().read() - expect(value).toMatchObject({id: "1", type: "token"}) - }) - - it("rejects on a null value rather than hanging", async () => { - // A claimed subscription that answers `null` must fail fast — not wait out - // the 8s discovery backstop. - const el = mountElement() - cleanups.push(() => el.remove()) - const refuse = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || !port) return - accept(e, (respond) => respond(null)) - } - document.addEventListener("patchwork:subscribe", refuse) - cleanups.push(() => document.removeEventListener("patchwork:subscribe", refuse)) - - const started = Date.now() - await expect(connectWorker("nope", {}, {element: el})).rejects.toThrow( - /no worker available/ - ) - expect(Date.now() - started).toBeLessThan(1000) - }) - - it("re-discovers on the next request after a refusal", async () => { - // `null` means "asked, got nothing" — the session drops the connection so a - // later request tries again, rather than staying dead forever. - const TERMINAL = { - result: (f) => f.text, - error: (f) => { - throw new Error(f.message) - }, - } - const kind = "retry-" + Math.random() - const el = mountElement() - cleanups.push(() => el.remove()) - - const refuse = (e) => { - const {selector, port} = e.detail ?? {} - if (selector?.type !== "patchwork:worker-channel" || selector.kind !== kind) return - accept(e, (respond) => respond(null)) - } - document.addEventListener("patchwork:subscribe", refuse) - - const session = openSession(kind, {element: el}) - const first = session.request({op: "generate"}, {terminal: TERMINAL}) - await expect(first.promise).rejects.toThrow(/no worker available/) - - // The provider shows up late; the next request must find it. - document.removeEventListener("patchwork:subscribe", refuse) - cleanups.push(serveKinds({[kind]: echoWorker})) - - const second = session.request({op: "generate"}, {terminal: TERMINAL}) - await expect(second.promise).resolves.toBe("hi") - }) }) describe("package shape", () => { - // The entry-point split that caused a real outage: consumers bake a subpath - // literal at BUILD time (resolved from `exports`), separate from the automerge - // pin. Two entry points to one state-holding module means two module - // instances. Here connect.js holds no registry at all — the rendezvous lives - // in the host plugin registry — but the consumer/provider split still has to - // stay honest. - const dir = process.cwd() - const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) - - it("keeps the consumer transport importable on its own", () => { - // chat loads ONLY this file into the sandbox. - expect(existsSync(join(dir, "connect.js"))).toBe(true) - expect(pkg.exports["./connect.js"].default).toBe("./connect.js") - expect(pkg.files).toContain("connect.js") - }) - - it("keeps the consumer transport free of the plugin registry", () => { - // The host provider (providers package) pulls in the plugin registry; - // connect.js must not, or the sandbox would drag the host registry in with - // the transport. - const src = readFileSync(join(dir, "connect.js"), "utf8") - expect(src).not.toMatch(/patchwork-plugins/) - }) - - it("exposes the serve half by subpath for the host provider", () => { - // The provider lives in another package and imports serveWorkerSpec through - // `exports`, so this entry is load-bearing the same way ./connect.js is. - expect(existsSync(join(dir, "serve.js"))).toBe(true) - expect(pkg.exports["./serve.js"].default).toBe("./serve.js") - expect(pkg.files).toContain("serve.js") - }) - - it("is a library: no plugins, no provider, no host-only imports in the entry", () => { - // The provider moved to the providers package. Nothing here registers with - // the module loader, and the entry stays free of @inkandswitch/patchwork-plugins - // (whose graph reaches `window.location.origin`). - expect(pkg.exports["./provider.js"]).toBeUndefined() - expect(existsSync(join(dir, "provider.js"))).toBe(false) - const src = readFileSync(join(dir, "index.js"), "utf8") - expect(src).not.toMatch(/^\s*export\s+const\s+plugins\b/m) - expect(src).not.toMatch(/^\s*import[^\n]*patchwork-plugins/m) - }) - - it("exposes no worker registry from the transport", () => { - // Workers are resolved from the patchwork:worker plugin registry by the - // provider — there is deliberately no module-level Map here to split. - const src = readFileSync(join(dir, "connect.js"), "utf8") - expect(src).not.toMatch(/localWorkers/) + it("publishes every exported subpath", () => { + const dir = process.cwd() + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) + expect(pkg.files).toContain("types") + for (const [subpath, target] of Object.entries(pkg.exports)) { + const file = target.default + expect(existsSync(join(dir, file)), `${subpath} -> ${file}`).toBe(true) + expect(pkg.files, `${file} in files`).toContain(file.replace(/^\.\//, "")) + expect(existsSync(join(dir, target.types)), `${subpath} types`).toBe(true) + } }) }) diff --git a/libraries/patchwork-worker/index.js b/libraries/patchwork-worker/index.js index 76d0da97..e2916d3a 100644 --- a/libraries/patchwork-worker/index.js +++ b/libraries/patchwork-worker/index.js @@ -3,29 +3,27 @@ * transferable stream pair, and have it work the same inside or outside a * Patchwork isolation boundary. * - * This is a plain library. It registers no plugins; it is consumed as a + * This is a plain library: it registers no plugins and is consumed as a * dependency, never installed as a module. Four pieces: * - * connect.js the CONSUMER transport. `connectWorker(kind, request, {element})` - * returns `{readable, writable, disconnect}`. This is the only file - * a sandboxed tool loads. - * session.js `openSession(kind)` — request/response multiplexing over that - * stream pair: id tagging, demux, abort, reconnect. - * serve.js `serveWorkerSpec(spec, request, ctx)` — the SERVING half: runs - * one worker per connection and owns the stream pair + id demux. - * client.js `connectWorkerClient(kind, opts)` — resolves the service's - * typed client from the `patchwork:worker-client` registry and - * binds it to `openSession(kind)`. Subpath-only; NOT re-exported - * here (its patchwork-plugins import cannot load in the module - * loader's Worker, which evaluates this entry). + * connect.js the CONSUMER transport. `connectWorker(kind, {element})` returns + * `{readable, writable, disconnect}`; `openSession(kind)` layers + * request/response multiplexing on top. Also home to the protocol + * constants (`CHANNEL_SELECTOR`, the two plugin type strings). + * session.js `openSession` internals: id tagging, demux, broadcast fan-out, + * abort, reconnect, close. + * serve.js `serveWorkerSpec(spec, ctx)` — the SERVING half: runs one worker + * per connection and owns the stream pair + id demux. Host-only; + * driven by the `patchwork-worker-provider` component (shipped by + * the `providers` package), which resolves a WorkerSpec for the + * requested `kind` from the `patchwork:worker` plugin registry. + * client.js `connectWorkerClient(kind)` — resolves the service's typed client + * from the `patchwork:worker-client` registry and binds it to + * `openSession(kind)`. The one file here that touches the registry. * - * The host-realm PROVIDER that answers `patchwork:worker-channel` subscriptions - * (`patchwork-worker-provider`, a `patchwork:component`) ships from the - * `providers` package. It resolves a WorkerSpec for the requested `kind` from the - * `patchwork:worker` plugin registry and drives it with `serveWorkerSpec`. - * - * Offering a worker is declarative — a package registers a PAIR of plugins under - * one id (the worker `kind`), and nothing imports it until someone connects: + * Offering a worker is declarative — a service package registers a PAIR of + * plugins under one id (the worker `kind`), and nothing is imported until someone + * connects: * * export const plugins = [ * {type: "patchwork:worker", id: "llm", name: "LLM", @@ -39,37 +37,30 @@ * tool then calls `connectWorkerClient("llm")` and gets the API without a * build-time dependency on the service package. * - * ⚠ Nothing in this package may hold host-only state or secrets. connect.js is - * fetched INTO the sandbox, and the isolation registry marker is per package, so - * an isolated tool can reach any file here. Host-realm work belongs in the - * provider (providers package) or in the service package that owns the worker. + * ⚠ Nothing in this package may hold host-only state or secrets. connect.js and + * client.js are loaded into the consumer's realm, sandboxed or not, and a + * consumer that can load one file of a package should be assumed able to load + * any of them. Host-realm work belongs in the provider (providers package) or in + * the service package that owns the worker. */ export { connectWorker, - rememberDiscoveryElement, openSession, CHANNEL_SELECTOR, + WORKER_PLUGIN_TYPE, + WORKER_CLIENT_PLUGIN_TYPE, } from "./connect.js" +export {connectWorkerClient} from "./client.js" + /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. + * Types a service package needs to type its `plugins` entries. * @typedef {import("./connect.js").WorkerSpec} WorkerSpec * @typedef {import("./connect.js").WorkerPlugin} WorkerPlugin * @typedef {import("./connect.js").WorkerStreams} WorkerStreams * @typedef {import("./connect.js").WorkerConnection} WorkerConnection + * @typedef {import("./client.js").WorkerClientPlugin} WorkerClientPlugin + * @typedef {import("./client.js").WorkerClientFactory} WorkerClientFactory + * @typedef {import("./session.js").Session} Session */ - -/** - * The plugin type a package registers to offer a worker. A plain string, so - * naming it costs no import. - */ -export const WORKER_PLUGIN_TYPE = "patchwork:worker" - -/** - * The paired plugin type a package registers to offer a typed client for its - * worker (see ./client.js). Same id as the worker. A plain string here so the - * entry never imports client.js. - */ -export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" diff --git a/libraries/patchwork-worker/package.json b/libraries/patchwork-worker/package.json index c9f9328d..1694ecc6 100644 --- a/libraries/patchwork-worker/package.json +++ b/libraries/patchwork-worker/package.json @@ -1,6 +1,6 @@ { "name": "@grjte/patchwork-worker", - "version": "0.0.1", + "version": "0.0.2", "description": "Run a worker in the host realm and hand any consumer a transferable stream pair — the same code inside or outside a Patchwork isolation boundary. Owns the patchwork:worker plugin type and the connect/session/serve transport; the host-realm provider that drives it ships from the providers package.", "type": "module", "main": "index.js", diff --git a/libraries/patchwork-worker/pnpm-lock.yaml b/libraries/patchwork-worker/pnpm-lock.yaml index 06c1f2f5..22dc9432 100644 --- a/libraries/patchwork-worker/pnpm-lock.yaml +++ b/libraries/patchwork-worker/pnpm-lock.yaml @@ -31,10 +31,10 @@ importers: dependencies: '@inkandswitch/patchwork-plugins': specifier: ^0.0.11 - version: 0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)) + version: 0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0)) '@inkandswitch/patchwork-providers': specifier: ^0.5.1 - version: 0.5.1(@automerge/automerge-repo@2.6.0-alpha.3) + version: 0.5.2(@automerge/automerge-repo@2.6.0-alpha.3) devDependencies: happy-dom: specifier: ^15.11.7 @@ -52,8 +52,8 @@ packages: resolution: {integrity: sha512-Rn/KdoVHUQwYU0TXqHyy9PdBgVE009JJWnh2YxT2blk5EnbZckh+RfGfu6ngjMGw0DZcZtJLvK3dKDNdAvtQVA==} engines: {node: '>=22.13'} - '@automerge/automerge@3.4.1': - resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} + '@automerge/automerge@3.5.0': + resolution: {integrity: sha512-ejbqJWfXWM2QbTcUK/5ugYiQiyOP7LYVAp/RuSvthCc07oek0FClMxEuouuhjrLjVPDSI34RP1ftGc45PF7WRA==} '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} @@ -254,8 +254,8 @@ packages: '@automerge/automerge-repo': '*' '@inkandswitch/patchwork-filesystem': ^0.0.8 - '@inkandswitch/patchwork-providers@0.5.1': - resolution: {integrity: sha512-KOZghTF4CVSK0jH9F/4JHtiACJsyQRluVCtfKrbwQPxQKhnuHIsbQHyBQu/Bsx9QMDFGdGcX7rs9jBj7qiQ6WQ==} + '@inkandswitch/patchwork-providers@0.5.2': + resolution: {integrity: sha512-jTR6aOPX4o5uj2OA45hcJ0A/K5fhQJPtEY+B2z9xaBWCgktZ/OLKWV2IQeQ2E98r++gu7BV/2f8tP+KwTLWJJA==} peerDependencies: '@automerge/automerge-repo': '*' @@ -273,141 +273,141 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@rollup/rollup-android-arm-eabi@4.63.1': - resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.63.1': - resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.63.1': - resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.63.1': - resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.63.1': - resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.63.1': - resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': - resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.63.1': - resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.63.1': - resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.63.1': - resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.63.1': - resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.63.1': - resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.63.1': - resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.63.1': - resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.63.1': - resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.63.1': - resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.63.1': - resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.63.1': - resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.63.1': - resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.63.1': - resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.63.1': - resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.63.1': - resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.63.1': - resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.63.1': - resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.63.1': - resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} cpu: [x64] os: [win32] @@ -562,8 +562,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -593,8 +593,8 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - rollup@4.63.1: - resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -734,25 +734,25 @@ packages: engines: {node: '>=8'} hasBin: true - xstate@5.32.6: - resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + xstate@5.33.2: + resolution: {integrity: sha512-8tC7yXgeCvpT8gKEeEje6ikJJG1wpnoLiXe+HfECW8m10ubMd3QxKOwWA8KxPJVBcLwfdRoMKYxIBGYKmo37/A==} snapshots: '@automerge/automerge-repo@2.6.0-alpha.3': dependencies: - '@automerge/automerge': 3.4.1 + '@automerge/automerge': 3.5.0 bs58check: 4.0.0 cbor-x: 1.6.6 debug: 4.4.3 eventemitter3: 5.0.4 fast-sha256: 1.3.0 uuid: 14.0.2 - xstate: 5.32.6 + xstate: 5.33.2 transitivePeerDependencies: - supports-color - '@automerge/automerge@3.4.1': {} + '@automerge/automerge@3.5.0': {} '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': optional: true @@ -850,9 +850,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)': + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0)': dependencies: - '@automerge/automerge': 3.4.1 + '@automerge/automerge': 3.5.0 '@automerge/automerge-repo': 2.6.0-alpha.3 '@types/debug': 4.1.13 '@types/node': 20.19.43 @@ -861,11 +861,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1))': + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0))': dependencies: - '@automerge/automerge': 3.4.1 + '@automerge/automerge': 3.5.0 '@automerge/automerge-repo': 2.6.0-alpha.3 - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.4.1) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-alpha.3)(@automerge/automerge@3.5.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3 @@ -874,7 +874,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-providers@0.5.1(@automerge/automerge-repo@2.6.0-alpha.3)': + '@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-alpha.3)': dependencies: '@automerge/automerge-repo': 2.6.0-alpha.3 @@ -885,79 +885,79 @@ snapshots: '@noble/hashes@1.8.0': {} - '@rollup/rollup-android-arm-eabi@4.63.1': + '@rollup/rollup-android-arm-eabi@4.63.4': optional: true - '@rollup/rollup-android-arm64@4.63.1': + '@rollup/rollup-android-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-arm64@4.63.1': + '@rollup/rollup-darwin-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-x64@4.63.1': + '@rollup/rollup-darwin-x64@4.63.4': optional: true - '@rollup/rollup-freebsd-arm64@4.63.1': + '@rollup/rollup-freebsd-arm64@4.63.4': optional: true - '@rollup/rollup-freebsd-x64@4.63.1': + '@rollup/rollup-freebsd-x64@4.63.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.63.1': + '@rollup/rollup-linux-arm-musleabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.63.1': + '@rollup/rollup-linux-arm64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.63.1': + '@rollup/rollup-linux-arm64-musl@4.63.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.63.1': + '@rollup/rollup-linux-loong64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.63.1': + '@rollup/rollup-linux-loong64-musl@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.63.1': + '@rollup/rollup-linux-ppc64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.63.1': + '@rollup/rollup-linux-ppc64-musl@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.63.1': + '@rollup/rollup-linux-riscv64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.63.1': + '@rollup/rollup-linux-riscv64-musl@4.63.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.63.1': + '@rollup/rollup-linux-s390x-gnu@4.63.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.63.1': + '@rollup/rollup-linux-x64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-x64-musl@4.63.1': + '@rollup/rollup-linux-x64-musl@4.63.4': optional: true - '@rollup/rollup-openbsd-x64@4.63.1': + '@rollup/rollup-openbsd-x64@4.63.4': optional: true - '@rollup/rollup-openharmony-arm64@4.63.1': + '@rollup/rollup-openharmony-arm64@4.63.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.63.1': + '@rollup/rollup-win32-arm64-msvc@4.63.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.63.1': + '@rollup/rollup-win32-ia32-msvc@4.63.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.63.1': + '@rollup/rollup-win32-x64-gnu@4.63.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.63.1': + '@rollup/rollup-win32-x64-msvc@4.63.4': optional: true '@types/chai@5.2.3': @@ -1137,7 +1137,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@3.3.19: {} node-gyp-build-optional-packages@5.1.1: dependencies: @@ -1154,42 +1154,42 @@ snapshots: postcss@8.5.28: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 resolve.exports@2.0.3: {} - rollup@4.63.1: + rollup@4.63.4: dependencies: '@types/estree': 1.0.9 optionalDependencies: '@napi-rs/lzma-linux-x64-gnu': 1.5.1 - '@rollup/rollup-android-arm-eabi': 4.63.1 - '@rollup/rollup-android-arm64': 4.63.1 - '@rollup/rollup-darwin-arm64': 4.63.1 - '@rollup/rollup-darwin-x64': 4.63.1 - '@rollup/rollup-freebsd-arm64': 4.63.1 - '@rollup/rollup-freebsd-x64': 4.63.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 - '@rollup/rollup-linux-arm-musleabihf': 4.63.1 - '@rollup/rollup-linux-arm64-gnu': 4.63.1 - '@rollup/rollup-linux-arm64-musl': 4.63.1 - '@rollup/rollup-linux-loong64-gnu': 4.63.1 - '@rollup/rollup-linux-loong64-musl': 4.63.1 - '@rollup/rollup-linux-ppc64-gnu': 4.63.1 - '@rollup/rollup-linux-ppc64-musl': 4.63.1 - '@rollup/rollup-linux-riscv64-gnu': 4.63.1 - '@rollup/rollup-linux-riscv64-musl': 4.63.1 - '@rollup/rollup-linux-s390x-gnu': 4.63.1 - '@rollup/rollup-linux-x64-gnu': 4.63.1 - '@rollup/rollup-linux-x64-musl': 4.63.1 - '@rollup/rollup-openbsd-x64': 4.63.1 - '@rollup/rollup-openharmony-arm64': 4.63.1 - '@rollup/rollup-win32-arm64-msvc': 4.63.1 - '@rollup/rollup-win32-ia32-msvc': 4.63.1 - '@rollup/rollup-win32-x64-gnu': 4.63.1 - '@rollup/rollup-win32-x64-msvc': 4.63.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 fsevents: 2.3.3 siginfo@2.0.0: {} @@ -1252,7 +1252,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 postcss: 8.5.28 - rollup: 4.63.1 + rollup: 4.63.4 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.43 @@ -1310,4 +1310,4 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - xstate@5.32.6: {} + xstate@5.33.2: {} diff --git a/libraries/patchwork-worker/serve.js b/libraries/patchwork-worker/serve.js index df7c1134..2f648cc7 100644 --- a/libraries/patchwork-worker/serve.js +++ b/libraries/patchwork-worker/serve.js @@ -4,20 +4,25 @@ * `openSession` (session.js) is the consumer side: it owns a `{readable, * writable}` pair, mints request ids, multiplexes many requests over one * connection, and routes event frames back to the right caller. `serveWorkerSpec` - * is the same machinery on the OTHER end. A worker library supplies a small - * `WorkerSpec` — how to construct its worker, an optional per-connection warm-up, - * and a `handle(frame)` that turns one consumer request into worker traffic — and + * is the same machinery on the OTHER end. A service supplies a small `WorkerSpec` + * — how to construct its worker, an optional per-connection warm-up, and a + * `handle(frame, io)` that turns one consumer request into worker traffic — and * this file owns everything kind-agnostic around it: * * - the `{readable, writable}` stream pair and its controller lifecycle * - ONE dedicated Worker per connection, terminated on teardown (no reuse, no * sharing — a worker belongs to the connection that opened it and dies with it) * - id minting and demux for requests multiplexed within the connection - * - the reserved `op:"abort"` (session.js:168 already sends it, so it is part of - * the transport protocol, not any service's vocabulary) - * - teardown on cancel/close/abort of either stream - * - a bounded `open` hook (the same never-settling-primitive guard the rest of - * this package applies with LOAD_TIMEOUT_MS / DISCOVERY_TIMEOUT_MS) + * - the reserved `op:"abort"` (session.js sends it, so it is part of the + * transport protocol, not any service's vocabulary) + * - teardown on cancel/close/abort of either stream, on worker death, and on a + * worker that cannot be constructed + * - a bounded `open` hook + * + * Frames the worker posts with NO `id` (status, progress) are emitted straight + * onto the connection's readable; the consumer side fans them out to its + * in-flight requests. With one worker per connection there is nothing else to + * route them to. * * The spec knows nothing about streams, ids, or workers-as-transport; the * transport knows nothing about the service's op vocabulary, config, or secrets. @@ -27,8 +32,8 @@ * How long `spec.open` may take before the transport gives up and serves frames * anyway. Bounds an upstream primitive that can hang: a settings-doc warm resolves * through patchwork-providers' `request()`, which never settles if no provider - * answers. Falling through is the spec's responsibility to make safe (the LLM - * re-checks and retries); wedging every frame forever is strictly worse. + * answers. Falling through is the spec's responsibility to make safe; wedging + * every frame forever is strictly worse. */ const OPEN_TIMEOUT_MS = 5000 @@ -41,7 +46,9 @@ const OPEN_TIMEOUT_MS = 5000 * @property {Emit} emit enqueue a frame onto THIS consumer's readable * @property {(fn: (msg:any)=>boolean|void) => void} on * register a handler for worker messages tagged with `workerId`; return a truthy - * value from `fn` when the request is complete and the transport should clean up + * value from `fn` when the request is complete and the transport should clean up. + * A request whose handle never calls `on` is fire-and-forget: nothing is + * tracked for it and `op:"abort"` cannot target it. * @property {string} workerId the transport-minted id to tag worker payloads with * @property {any} state whatever `spec.open` resolved (or null) * @property {{element?: HTMLElement}} ctx host-realm context from the provider @@ -50,7 +57,7 @@ const OPEN_TIMEOUT_MS = 5000 * @property {() => Worker | Promise} createWorker * @property {(ctx: {element?: HTMLElement}) => any} [open] * @property {(frame: any, io: IO) => any} handle - * returns an opaque abort token (or nothing) stored per request + * returns an opaque abort token (or nothing) stored per tracked request * @property {(token: any, post: Post) => void} [abort] */ @@ -62,14 +69,13 @@ function nextWorkerId() { /** * Serve one worker connection from a spec. Returns the `{readable, writable}` the * provider transfers to the consumer. One Worker is created for this connection - * and terminated when either stream ends. + * and terminated when either stream ends or the worker dies. * * @param {WorkerSpec} spec - * @param {any} _request the opening request (reserved; specs read per-frame data instead) * @param {{element?: HTMLElement}} [ctx] * @returns {{readable: ReadableStream, writable: WritableStream}} */ -export function serveWorkerSpec(spec, _request, ctx = {}) { +export function serveWorkerSpec(spec, ctx = {}) { /** @type {Worker | null} */ let worker = null /** @type {Promise | null} */ @@ -77,17 +83,15 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { // worker message id -> handler. One connection, so one flat map is enough. /** @type {Mapboolean|void>} */ const handlers = new Map() - // caller id -> the abort token the spec returned, so `op:"abort"` can cancel it. - // A request is entered here SYNCHRONOUSLY at the value `PENDING` the moment its + // caller id -> the abort token the spec returned, so `op:"abort"` can cancel + // it. A request is entered here SYNCHRONOUSLY at `PENDING` the moment its // frame arrives, before any await — so an `op:"abort"` that races in while the // spec is still resolving (`await openState` / `await spec.handle`) finds the - // request and is honoured once the token lands, rather than being silently - // dropped. The value becomes the real token (or `undefined`) when `handle` - // returns; see the `PENDING`/`aborted` handling in `handleFrame`. + // request and is honoured once the token lands, rather than being dropped. /** @type {Map} */ const tokens = new Map() - // caller ids aborted while still `PENDING` — the token wasn't available yet, so - // the abort is deferred to when the handler stores it. + // caller ids aborted while still `PENDING` — the abort is deferred to when the + // handler stores its token. /** @type {Set} */ const aborted = new Set() const PENDING = Symbol("pending") @@ -111,27 +115,39 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { ]).catch(() => null) : Promise.resolve(null) - const post = (/** @type {any} */ msg, /** @type {Transferable[]=} */ transfer) => { - void getWorker().then((w) => w.postMessage(msg, transfer || [])) + /** @type {Post} */ + const post = (msg, transfer) => { + if (closed) return + void getWorker() + .then((w) => w.postMessage(msg, transfer || [])) + .catch(() => {}) // a failed construction has already torn the connection down } - /** Lazily construct the worker (once) and wire its message pump. */ + /** + * Lazily construct the worker (once) and wire its message pump. A worker that + * cannot be constructed, or that later dies, ends the connection: the + * consumer's in-flight requests reject when its readable closes, instead of + * waiting on a worker that will never answer. + */ function getWorker() { if (workerReady) return workerReady - workerReady = Promise.resolve(spec.createWorker()).then((w) => { - worker = w - w.onmessage = (/** @type {MessageEvent} */ ev) => dispatch(ev.data) - return w - }) + workerReady = Promise.resolve() + .then(() => spec.createWorker()) + .then((w) => { + worker = w + w.onmessage = (/** @type {MessageEvent} */ ev) => dispatch(ev.data) + w.onerror = teardown + w.onmessageerror = teardown + return w + }) + workerReady.catch(teardown) return workerReady } /** * Route a worker message. A message with an `id` goes to that request's * handler (which reports terminal by returning truthy). A message with no `id` - * (status, log, progress with no request attached) is emitted straight onto the - * connection's readable — with one worker per connection there is nothing to - * fan out to. + * is a connection-wide broadcast and goes straight onto the readable. */ function dispatch(/** @type {any} */ msg) { if (!msg || closed) return @@ -150,8 +166,8 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { // Reserved transport op. Cancel one in-flight request: let the spec send // whatever the worker needs, then forget it. If the request is still - // `PENDING` (its handler hasn't returned a token yet — we're mid-`await`), - // defer: record the id and let the handler abort as soon as it stores it. + // `PENDING` (its handler hasn't returned a token yet), defer: record the id + // and let the handler abort as soon as it stores the token. if (op === "abort") { if (!tokens.has(id)) return // unknown / already-finished request const token = tokens.get(id) @@ -167,11 +183,13 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { } // Claim the id SYNCHRONOUSLY, before the first await, so an abort racing in - // during `await openState` / `await spec.handle` isn't dropped (H1). + // during `await openState` / `await spec.handle` isn't dropped. tokens.set(id, PENDING) const state = await openState + if (closed) return const workerId = nextWorkerId() + let tracked = false /** @type {IO} */ const io = { @@ -179,6 +197,7 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { // Frames the spec emits carry the WORKER id; re-tag with the caller id. emit: (f) => emit({...f, id}), on: (fn) => { + tracked = true handlers.set(workerId, (msg) => { const done = fn(msg) if (done) { @@ -206,9 +225,10 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { } catch {} return } - // Store the abort token even if undefined, so `op:"abort"` can find the - // request (a spec that never aborts simply returns nothing). - tokens.set(id, token) + // Track the request only if the spec is listening for a reply; a + // fire-and-forget handle has nothing to abort and must not accumulate. + if (tracked) tokens.set(id, token) + else tokens.delete(id) } catch (e) { const err = /** @type {any} */ (e) emit({id, type: "error", message: err?.message || String(e)}) @@ -225,10 +245,9 @@ export function serveWorkerSpec(spec, _request, ctx = {}) { tokens.clear() aborted.clear() // Close the readable so a consumer still reading it sees end-of-stream rather - // than hanging forever (H2). Teardown can be driven from the WRITABLE side - // (close/abort) or worker death, where the readable was never cancelled; a - // bare `controller = null` would strand that reader. `close()` throws if the - // stream was already closed/cancelled (the readable-cancel path), so guard it. + // than hanging forever. Teardown can be driven from the WRITABLE side + // (close/abort) or worker death, where the readable was never cancelled. + // `close()` throws if the stream was already closed/cancelled, so guard it. try { controller?.close() } catch {} diff --git a/libraries/patchwork-worker/serve.test.js b/libraries/patchwork-worker/serve.test.js index 5a1cbcad..c4c03474 100644 --- a/libraries/patchwork-worker/serve.test.js +++ b/libraries/patchwork-worker/serve.test.js @@ -1,4 +1,4 @@ -import {describe, it, expect, vi} from "vitest" +import {describe, it, expect, vi, afterEach} from "vitest" import {serveWorkerSpec} from "./serve.js" /** @@ -12,6 +12,10 @@ function fakeWorker() { terminated: false, /** @type {((ev:{data:any})=>void)|null} */ onmessage: null, + /** @type {((ev:any)=>void)|null} */ + onerror: null, + /** @type {((ev:any)=>void)|null} */ + onmessageerror: null, postMessage(msg, transfer) { w.posted.push({msg, transfer}) }, @@ -22,6 +26,10 @@ function fakeWorker() { reply(data) { w.onmessage?.({data}) }, + /** simulate the worker crashing */ + crash() { + w.onerror?.({message: "boom"}) + }, } return w } @@ -31,6 +39,7 @@ function drive(streams) { const writer = streams.writable.getWriter() const reader = streams.readable.getReader() const frames = [] + let ended = false ;(async () => { try { for (;;) { @@ -39,12 +48,14 @@ function drive(streams) { frames.push(value) } } catch {} + ended = true })() return { write: (f) => writer.write(f), close: () => writer.close(), cancelRead: () => reader.cancel(), frames, + ended: () => ended, settle: async () => { for (let i = 0; i < 20; i++) await Promise.resolve() await new Promise((r) => setTimeout(r, 0)) @@ -52,88 +63,112 @@ function drive(streams) { } } +/** A spec that posts one message per request and waits for its reply. */ +function echoSpec(w) { + return { + createWorker: () => w, + handle(frame, io) { + io.on((msg) => { + io.emit({type: "result", text: msg.text}) + return true + }) + io.post({type: "generate", id: io.workerId, text: frame.text}) + return {sessionKey: frame.id} + }, + } +} + +afterEach(() => vi.useRealTimers()) + describe("serveWorkerSpec", () => { it("routes a request to the worker and its reply back, re-tagged with the caller id", async () => { const w = fakeWorker() - const spec = { - createWorker: () => w, - handle(frame, io) { - io.on((msg) => { - if (msg.type === "result") { - io.emit({type: "result", text: msg.text}) - return true - } - }) - io.post({type: "generate", id: io.workerId, text: frame.text}) - }, - } - const conn = drive(serveWorkerSpec(spec, {})) - await conn.write({id: "a", op: "generate", text: "hi"}) + const conn = drive(serveWorkerSpec(echoSpec(w), {})) + await conn.write({id: "req-1", op: "generate", text: "hi"}) await conn.settle() - - // The worker saw the request… expect(w.posted).toHaveLength(1) - const workerId = w.posted[0].msg.id - expect(w.posted[0].msg.text).toBe("hi") - - // …and its reply comes back tagged with the CALLER id, not the worker id. - w.reply({id: workerId, type: "result", text: "done"}) + const {msg} = w.posted[0] + expect(msg.type).toBe("generate") + expect(msg.id).not.toBe("req-1") // worker-side id is transport-minted + w.reply({id: msg.id, type: "result", text: "hi"}) await conn.settle() - expect(conn.frames).toContainEqual({id: "a", type: "result", text: "done"}) + expect(conn.frames).toEqual([{id: "req-1", type: "result", text: "hi"}]) }) - it("gives each connection its own worker", async () => { + it("gives each connection its own worker, and passes ctx to the spec", async () => { const workers = [] + let seenCtx const spec = { createWorker: () => { const w = fakeWorker() workers.push(w) return w }, - handle: (frame, io) => io.post({type: "x", id: io.workerId}), + handle: (frame, io) => { + seenCtx = io.ctx + io.post({type: "x", id: io.workerId}) + }, } - const a = drive(serveWorkerSpec(spec, {})) + const el = document.createElement("div") + const a = drive(serveWorkerSpec(spec, {element: el})) const b = drive(serveWorkerSpec(spec, {})) await a.write({id: "1", op: "go"}) await b.write({id: "2", op: "go"}) await a.settle() expect(workers).toHaveLength(2) expect(workers[0]).not.toBe(workers[1]) + expect(seenCtx).toEqual({}) }) - it("terminates the worker when the writable closes", async () => { + it("tears down from either stream end: worker terminated, readable closed", async () => { + // Writable closed: a reader blocked on read() is released with done=true. + const w1 = fakeWorker() + const a = drive(serveWorkerSpec(echoSpec(w1), {})) + await a.write({id: "a", op: "go"}) + await a.settle() + expect(w1.terminated).toBe(false) + await a.close() + await a.settle() + expect(w1.terminated).toBe(true) + expect(a.ended()).toBe(true) + + // Readable cancelled. + const w2 = fakeWorker() + const b = drive(serveWorkerSpec(echoSpec(w2), {})) + await b.write({id: "b", op: "go"}) + await b.settle() + await b.cancelRead() + await b.settle() + expect(w2.terminated).toBe(true) + }) + + it("tears down when the worker dies, so a waiting consumer sees end-of-stream", async () => { const w = fakeWorker() - const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} - const conn = drive(serveWorkerSpec(spec, {})) + const conn = drive(serveWorkerSpec(echoSpec(w), {})) await conn.write({id: "a", op: "go"}) await conn.settle() - expect(w.terminated).toBe(false) - await conn.close() + w.crash() await conn.settle() expect(w.terminated).toBe(true) + expect(conn.ended()).toBe(true) }) - it("terminates the worker when the readable is cancelled", async () => { - const w = fakeWorker() - const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + it("tears down when the worker cannot be constructed", async () => { + const spec = { + createWorker: () => Promise.reject(new Error("no worker for you")), + handle: (frame, io) => io.post({id: io.workerId}), + } const conn = drive(serveWorkerSpec(spec, {})) await conn.write({id: "a", op: "go"}) await conn.settle() - await conn.cancelRead() - await conn.settle() - expect(w.terminated).toBe(true) + expect(conn.ended()).toBe(true) }) - it("routes op:abort to spec.abort with the stored token", async () => { + it("routes op:abort to spec.abort with the stored token, once", async () => { const w = fakeWorker() const aborted = [] const spec = { - createWorker: () => w, - handle(frame, io) { - io.on(() => false) // never terminal on its own - io.post({type: "generate", id: io.workerId}) - return {sessionKey: frame.id} // the abort token - }, + ...echoSpec(w), abort(token, post) { aborted.push(token) post({type: "abort", sessionKey: token.sessionKey}) @@ -143,98 +178,86 @@ describe("serveWorkerSpec", () => { await conn.write({id: "a", op: "generate"}) await conn.settle() await conn.write({id: "a", op: "abort"}) + await conn.write({id: "a", op: "abort"}) // duplicate: already forgotten + await conn.write({id: "zzz", op: "abort"}) // unknown: ignored await conn.settle() - expect(aborted).toEqual([{sessionKey: "a"}]) - // spec.abort posted the worker-specific abort payload expect(w.posted.some((p) => p.msg.type === "abort" && p.msg.sessionKey === "a")).toBe(true) }) - it("honours an abort that races in while the request is still resolving (H1)", async () => { - // The abort arrives while `handle` is suspended on a slow `open`, i.e. before - // the abort token has been stored. The old design read `tokens.get(id)` → - // undefined and dropped the abort silently. It must now be deferred and fire - // once the token lands. + it("honours an abort that races in while the request is still resolving", async () => { + // The abort arrives while `handle` is suspended on a slow `open`, before the + // abort token exists. It must be deferred and fire once the token lands. const w = fakeWorker() const aborted = [] let releaseOpen const spec = { - createWorker: () => w, - open: () => new Promise((r) => (releaseOpen = r)), // gate handle until we say - handle(frame, io) { - io.on(() => false) - io.post({type: "generate", id: io.workerId}) - return {sessionKey: frame.id} - }, - abort(token) { - aborted.push(token) - }, + ...echoSpec(w), + open: () => new Promise((r) => (releaseOpen = r)), + abort: (token) => void aborted.push(token), } const conn = drive(serveWorkerSpec(spec, {})) - await conn.write({id: "a", op: "generate"}) // suspends inside handleFrame on open - await conn.write({id: "a", op: "abort"}) // races in BEFORE the token exists + await conn.write({id: "a", op: "generate"}) // suspends on open + await conn.write({id: "a", op: "abort"}) // before the token exists await conn.settle() - expect(aborted).toEqual([]) // deferred: nothing to abort yet + expect(aborted).toEqual([]) - releaseOpen(null) // let handle finish and store the token + releaseOpen(null) await conn.settle() - expect(aborted).toEqual([{sessionKey: "a"}]) // fired once the token landed - // …and the request is forgotten, so a duplicate abort is a no-op. + expect(aborted).toEqual([{sessionKey: "a"}]) await conn.write({id: "a", op: "abort"}) await conn.settle() expect(aborted).toEqual([{sessionKey: "a"}]) }) - it("closes the readable when the writable closes, so a reader sees end-of-stream (H2)", async () => { + it("does not track a fire-and-forget request (no io.on), so op:abort ignores it", async () => { const w = fakeWorker() - const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} - const streams = serveWorkerSpec(spec, {}) - const reader = streams.readable.getReader() - const writer = streams.writable.getWriter() - await writer.write({id: "a", op: "go"}) - // A reader blocked on read() must be released by teardown, not hang forever. - const pending = reader.read() - await writer.close() - const {done} = await pending - expect(done).toBe(true) - }) - - it("emits id-less worker messages straight onto the readable", async () => { - const w = fakeWorker() - const spec = {createWorker: () => w, handle: (f, io) => io.post({id: io.workerId})} + const aborted = [] + const spec = { + createWorker: () => w, + handle(frame, io) { + io.post({type: "preload", id: io.workerId}) + return {sessionKey: frame.id} // a token, but nothing is listening + }, + abort: (token) => void aborted.push(token), + } const conn = drive(serveWorkerSpec(spec, {})) - await conn.write({id: "a", op: "go"}) + await conn.write({id: "a", op: "preload"}) await conn.settle() - w.reply({type: "status", message: "downloading model"}) // no id + await conn.write({id: "a", op: "abort"}) await conn.settle() - expect(conn.frames).toContainEqual({type: "status", message: "downloading model"}) + expect(aborted).toEqual([]) }) - it("does not duplicate a status frame per in-flight request", async () => { - // The old design fanned an id-less status to every in-flight id, so N - // requests saw the same text N times. One worker per connection emits it once. - const w = fakeWorker() + it("creates no worker for a request that was still pending at teardown", async () => { + const createWorker = vi.fn(() => fakeWorker()) + let releaseOpen const spec = { - createWorker: () => w, - handle: (f, io) => { - io.on(() => false) - io.post({id: io.workerId}) - }, + createWorker, + open: () => new Promise((r) => (releaseOpen = r)), + handle: (frame, io) => io.post({id: io.workerId}), } const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) // suspends on open + await conn.close() // teardown while pending + releaseOpen(null) + await conn.settle() + expect(createWorker).not.toHaveBeenCalled() + }) + + it("emits id-less worker messages straight onto the readable", async () => { + const w = fakeWorker() + const conn = drive(serveWorkerSpec(echoSpec(w), {})) await conn.write({id: "a", op: "go"}) - await conn.write({id: "b", op: "go"}) await conn.settle() - w.reply({type: "status", message: "one"}) + w.reply({type: "status", message: "downloading model"}) // no id await conn.settle() - const statuses = conn.frames.filter((f) => f.type === "status") - expect(statuses).toEqual([{type: "status", message: "one"}]) + expect(conn.frames).toContainEqual({type: "status", message: "downloading model"}) }) it("emits {type:error} tagged with the caller id when handle throws", async () => { - const w = fakeWorker() const spec = { - createWorker: () => w, + createWorker: () => fakeWorker(), handle() { throw new Error("boom") }, @@ -246,22 +269,17 @@ describe("serveWorkerSpec", () => { }) it("falls through when open never settles rather than wedging frames", async () => { - const w = fakeWorker() vi.useFakeTimers() const spec = { - createWorker: () => w, + createWorker: () => fakeWorker(), open: () => new Promise(() => {}), // never resolves - handle: (frame, io) => { - io.emit({type: "ran", state: io.state}) - }, + handle: (frame, io) => void io.emit({type: "ran", state: io.state}), } const conn = drive(serveWorkerSpec(spec, {})) await conn.write({id: "a", op: "go"}) - // Advance past the OPEN_TIMEOUT so the bounded race resolves null. await vi.advanceTimersByTimeAsync(5001) vi.useRealTimers() await conn.settle() - // handle ran anyway, with state === null (the timeout value) expect(conn.frames).toContainEqual({id: "a", type: "ran", state: null}) }) }) diff --git a/libraries/patchwork-worker/session.js b/libraries/patchwork-worker/session.js index ede58c0b..9e4b1a17 100644 --- a/libraries/patchwork-worker/session.js +++ b/libraries/patchwork-worker/session.js @@ -1,16 +1,12 @@ /** * openSession — request/response multiplexing over a worker connection. * - * `connectWorker` gives you a raw `{readable, writable}` pair. Every consumer - * then writes the same layer on top of it: open the connection lazily, keep one - * writer, pump the readable, tag each request with an id, route event frames - * back to the right in-flight caller, and settle on a terminal frame. That layer - * had been written three times (chat's llm-client, patchwork-llm's client, and - * the mirror-image demux inside patchwork-llm's own service), which is also why - * the same reconnect bug existed in three places. - * - * This module owns it once. It stays service-agnostic: frames are opaque, and - * the caller says which `type` values are terminal. + * `connectWorker` gives you a raw `{readable, writable}` pair. This module owns + * the layer every consumer needs on top of it: open the connection lazily, keep + * one writer, pump the readable, tag each request with an id, route event frames + * back to the right in-flight caller, and settle on a terminal frame. It stays + * service-agnostic: frames are opaque, and the caller says which `type` values + * are terminal. * * const session = openSession("llm", {element}) * const {promise, abort} = session.request( @@ -22,14 +18,19 @@ * } * ) * + * Frame routing: a frame with an `id` goes to that request's `onFrame` (or its + * terminal handler). A frame with NO id is a connection-wide broadcast from the + * worker — a status or progress message not tied to one request — and is + * delivered to every in-flight request's `onFrame`, so a caller sees the + * worker's status the same way it would from a same-realm worker. + * * Connection lifetime: opened on the first request, shared by every request * after it, and DROPPED whenever it fails or ends — so the next request * reconnects instead of replaying a dead or rejected connection forever. + * `close()` drops it on purpose (and terminates the host worker behind it). * - * NOTE: this module deliberately does NOT import ./connect.js. `connectWorker` - * is injected by `createOpenSession` instead, so the dependency runs one way - * (connect.js -> session.js) and the package keeps a single entry point. See the - * entry-point note in connect.js for why a second entry point is a hazard here. + * This module does not import ./connect.js. `connectWorker` is injected by + * `createOpenSession`, so the dependency runs one way (connect.js -> session.js). */ /** @@ -37,31 +38,47 @@ * @property {Recordany>} terminal frame.type -> settle. The * return value resolves the request; throw to reject it. Any type listed here * ends the request. - * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id + * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id, + * plus every id-less broadcast frame received while the request is in flight * @property {AbortSignal} [signal] aborting sends {op:"abort", id} and rejects * @property {HTMLElement} [element] discovery element, if not set on the session + * + * @typedef {Object} SessionOpts + * @property {HTMLElement} [element] default discovery element for every request + * @property {string} [idPrefix] request id prefix (defaults to the kind) + * @property {(...a:any[])=>void} [onLog] + * + * @typedef {{readable: ReadableStream, writable: WritableStream, disconnect: () => void}} Connection + * @typedef {Connection & {writer: WritableStreamDefaultWriter, reader: ReadableStreamDefaultReader}} OpenConnection + * + * @typedef {Object} Session + * @property {(frame: any, opts: RequestOpts) => {promise: Promise, abort: () => void}} request + * @property {() => void} close drop the connection (terminating the worker behind + * it) and reject every in-flight request; the next request reconnects */ /** * Build the `openSession` export, bound to a `connectWorker` implementation. * Called once from connect.js; consumers use the resulting `openSession`. * - * @param {(kind: string, request: any, opts?: any) => Promise} connectWorker + * @param {(kind: string, opts: {element: HTMLElement}) => Promise} connectWorker */ export function createOpenSession(connectWorker) { /** * Open a lazily-connected, multiplexed session for a worker `kind`. * * @param {string} kind - * @param {{element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}} [sessionOpts] + * @param {SessionOpts} [sessionOpts] + * @returns {Session} */ return function openSession(kind, sessionOpts = {}) { const idPrefix = sessionOpts.idPrefix || kind const log = sessionOpts.onLog || (() => {}) - /** @type {Promise|null} */ + /** @type {Promise|null} */ let connectionPromise = null - /** id -> {onFrame, onClosed} for every request still in flight */ + /** id -> handlers for every request still in flight + * @type {Mapvoid, onClosed: (cause:any)=>void}>} */ const handlers = new Map() let idSeq = 0 @@ -76,11 +93,10 @@ export function createOpenSession(connectWorker) { * never reopen. * * Failing the in-flight requests matters more. They are waiting on frames - * that can no longer arrive: the stream they were reading is gone. Leaving - * them in `handlers` orphans each promise forever — no rejection, no - * timeout, and their abort listeners stay attached to whatever signal the - * caller passed. A consumer that awaits generation with no deadline (chat - * does) wedges permanently with no error to show. + * that can no longer arrive. Leaving them in `handlers` would orphan each + * promise forever — no rejection, no timeout — and a consumer awaiting with + * no deadline would wedge with no error to show. + * @param {any} cause */ function reset(cause) { connectionPromise = null @@ -89,34 +105,47 @@ export function createOpenSession(connectWorker) { for (const h of inFlight) h.onClosed(cause) } + /** @param {HTMLElement | undefined} element */ function ensureConnection(element) { if (connectionPromise) return connectionPromise const el = element ?? sessionOpts.element connectionPromise = (async () => { - const conn = await connectWorker(kind, {}, {element: el}) - // Keep the writer ON the connection, not in closure state: `send` - // awaits `ensureConnection` and the connection can end during that - // await, so a shared `writer` variable may be null — or belong to a - // newer connection — by the time the write lands. - conn.writer = conn.writable.getWriter() - void pump(conn.readable) - return conn + const conn = await connectWorker(kind, {element: /** @type {HTMLElement} */ (el)}) + // Keep the reader and writer ON the connection, not in closure state: + // `send` awaits `ensureConnection` and the connection can end during + // that await, so shared variables may be null — or belong to a newer + // connection — by the time they are used. Holding both locks also + // means `close()` must release through them (a locked stream rejects + // cancel/abort from anyone else). + const open = /** @type {OpenConnection} */ ( + Object.assign(conn, { + writer: conn.writable.getWriter(), + reader: conn.readable.getReader(), + }) + ) + void pump(open.reader) + return open })() // Don't let an unawaited rejection surface as unhandled; just uncache it. connectionPromise.catch((e) => reset(e)) return connectionPromise } - async function pump(readable) { - const reader = readable.getReader() + /** @param {ReadableStreamDefaultReader} reader */ + async function pump(reader) { /** @type {any} */ let cause = null try { while (true) { const {value, done} = await reader.read() if (done) break - const h = value && value.id != null && handlers.get(value.id) - if (h) h.onFrame(value) + if (!value) continue + if (value.id != null) { + handlers.get(value.id)?.onFrame(value) + } else { + // Connection-wide broadcast: every in-flight request hears it. + for (const h of [...handlers.values()]) h.onFrame(value) + } } } catch (e) { cause = e @@ -126,12 +155,13 @@ export function createOpenSession(connectWorker) { } } - /** Write one frame, opening the connection if needed. */ + /** + * Write one frame, opening the connection if needed. + * @param {any} frame + * @param {HTMLElement | undefined} element + */ async function send(frame, element) { const conn = await ensureConnection(element) - // Don't use `?.` here: silently resolving without writing would leave the - // caller's request unsettled with no error to explain it. - if (!conn.writer) throw new Error(`worker connection for "${kind}" is closed`) await conn.writer.write(frame) } @@ -147,25 +177,24 @@ export function createOpenSession(connectWorker) { let settled = false /** @type {(v:any)=>void} */ - let resolveFn + let resolveFn = () => {} /** @type {(e:any)=>void} */ - let rejectFn + let rejectFn = () => {} const cleanup = () => { handlers.delete(id) opts.signal?.removeEventListener("abort", onAbort) } - // Only tell the service to stop if we actually asked it to start. An - // already-aborted signal would otherwise open a whole connection — up to - // the full discovery timeout — purely to abort a request that was never - // sent. - let sent = false - function onAbort() { if (settled) return settled = true - if (sent) void send({op: "abort", id}, opts.element).catch(() => {}) + // Only tell the service to stop if a connection exists or is opening: + // an already-aborted signal must not open a whole connection (up to the + // full discovery timeout) purely to abort a request that was never + // sent. If the request frame is still in flight the abort simply + // follows it; the serve half ignores aborts for ids it doesn't know. + if (connectionPromise) void send({op: "abort", id}, opts.element).catch(() => {}) cleanup() rejectFn(new DOMException("Aborted", "AbortError")) } @@ -217,25 +246,35 @@ export function createOpenSession(connectWorker) { opts.signal.addEventListener("abort", onAbort) } - send({...frame, id}, opts.element).then( - () => { - sent = true - }, - (e) => { - if (settled) return - settled = true - cleanup() - rejectFn(e) - } - ) + send({...frame, id}, opts.element).catch((e) => { + if (settled) return + settled = true + cleanup() + rejectFn(e) + }) return {promise, abort: onAbort} } - return { - request, - /** Send a fire-and-forget frame (no id correlation, no reply expected). */ - notify: (frame, element) => send(frame, element).catch(() => {}), + /** + * Close the connection on purpose. The serve half tears down its worker when + * the streams end; in-flight requests reject; the next request reconnects. + */ + function close() { + const pending = connectionPromise + if (!pending) return + reset(new Error(`worker connection for "${kind}" was closed`)) + pending + .then((conn) => { + // Release through the locks this session holds: cancelling the + // reader ends the pump; aborting the writer ends the serve half, + // which terminates the worker. + conn.reader.cancel().catch(() => {}) + conn.writer.abort().catch(() => {}) + }) + .catch(() => {}) } + + return {request, close} } } diff --git a/libraries/patchwork-worker/tsconfig.json b/libraries/patchwork-worker/tsconfig.json index 96190789..e7de32b1 100644 --- a/libraries/patchwork-worker/tsconfig.json +++ b/libraries/patchwork-worker/tsconfig.json @@ -4,7 +4,7 @@ "module": "ESNEXT", "moduleResolution": "bundler", "allowJs": true, - "checkJs": false, + "checkJs": true, "declaration": true, "emitDeclarationOnly": true, "outDir": "types", diff --git a/libraries/patchwork-worker/types/client.d.ts b/libraries/patchwork-worker/types/client.d.ts index 3ae92db0..2eb958cf 100644 --- a/libraries/patchwork-worker/types/client.d.ts +++ b/libraries/patchwork-worker/types/client.d.ts @@ -1,5 +1,5 @@ /** - * @typedef {ReturnType} WorkerSession + * @typedef {import("./session.js").Session} WorkerSession * @typedef {(session: WorkerSession) => any} WorkerClientFactory * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin */ @@ -13,26 +13,16 @@ * * @param {string} kind * @param {{ - * sessionOpts?: {element?: HTMLElement, idPrefix?: string, onLog?: (...a:any[])=>void}, + * sessionOpts?: import("./session.js").SessionOpts, * timeoutMs?: number, * }} [opts] * @returns {Promise} */ export function connectWorkerClient(kind: string, opts?: { - sessionOpts?: { - element?: HTMLElement; - idPrefix?: string; - onLog?: (...a: any[]) => void; - }; + sessionOpts?: import("./session.js").SessionOpts; timeoutMs?: number; }): Promise; -/** - * The plugin type a service package registers to offer a typed client for its - * worker. Paired with `patchwork:worker` by `id`. Also exported (as a bare - * string) from index.js so naming it costs no import. - */ -export const WORKER_CLIENT_PLUGIN_TYPE: "patchwork:worker-client"; -export type WorkerSession = ReturnType; +export type WorkerSession = import("./session.js").Session; export type WorkerClientFactory = (session: WorkerSession) => any; export type WorkerClientPlugin = { type: "patchwork:worker-client"; @@ -40,4 +30,3 @@ export type WorkerClientPlugin = { name?: string; load: () => Promise; }; -import { openSession } from "./connect.js"; diff --git a/libraries/patchwork-worker/types/connect.d.ts b/libraries/patchwork-worker/types/connect.d.ts index ac147340..c71eb90a 100644 --- a/libraries/patchwork-worker/types/connect.d.ts +++ b/libraries/patchwork-worker/types/connect.d.ts @@ -7,10 +7,9 @@ * the provider's mount point, so a worker can resolve host-realm context (a * settings doc, say) without the provider knowing about that service. * - * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather - * than importing serve.js, because this file is the sandbox-loaded consumer half - * and must not pull the host-only serve module into its graph. Types erase, so - * this costs nothing at runtime.) + * (Shape mirrored from serve.js's `WorkerSpec`, kept as a local typedef so this + * sandbox-loaded file does not import the host-only serve module. Types erase, + * so this costs nothing at runtime.) * @typedef {{ * createWorker: () => Worker | Promise, * open?: (ctx: {element?: HTMLElement}) => any, @@ -25,14 +24,14 @@ /** * Open a connection to a worker of `kind`. * - * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` in the - * DOM subtree of `opts.element` answers, transferring streams back over the - * port. In the host realm that's a mounted worker provider; inside isolation - * it's the providers-bridge, which relays to the host and transfers the host's - * streams across the boundary. Either way the consumer gets the same - * `{readable, writable, disconnect}` and never learns which answered. + * A `patchwork:subscribe` provider for `{type: CHANNEL_SELECTOR, kind}` above + * `element` answers, transferring streams back over the port. In the host realm + * that's a mounted worker provider; in a sandboxed realm it's whatever relays + * the subscription to the host and transfers the host's streams back across the + * boundary. The consumer gets the same `{readable, writable, disconnect}` either + * way and never learns which answered. * - * There is deliberately NO fallback to a locally-registered worker. Inside the + * There is deliberately NO fallback to a locally-constructed worker. Inside the * sandbox that fallback was a hole: any in-boundary tool that imported a service * package would register its worker as an import side-effect, and a connection * that should have been refused would instead run the worker in the opaque @@ -41,55 +40,40 @@ * consumer with no provider ancestor fails loudly instead. * * @param {string} kind - * @param {any} request the opening request (service-specific; carried to `run`) - * @param {{ element?: HTMLElement | null, signal?: AbortSignal }} [opts] + * @param {{ element: HTMLElement }} opts a node inside a mounted , + * to dispatch the discovery subscribe from * @returns {Promise} */ -export function connectWorker(kind: string, request: any, opts?: { - element?: HTMLElement | null; - signal?: AbortSignal; +export function connectWorker(kind: string, opts: { + element: HTMLElement; }): Promise; -/** Record an element for elementless discovery (call from a UI that has one). */ -export function rememberDiscoveryElement(element: any): void; /** * The selector type used to discover a worker-connection provider. A consumer - * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind, request }` + * dispatches a `patchwork:subscribe` for `{ type: CHANNEL_SELECTOR, kind }` * carrying a MessagePort in `detail.port`. The answering side replies over that - * port with exactly one of: + * port, in the standard providers envelope (`{type:"change", value}`), with + * exactly one of: * * {readable, writable} — success; the pair is TRANSFERRED, not cloned * null — refused; fail fast * - * Both arrive in the standard providers envelope (`{type:"change", value}`), - * because the answering side responds through `accept()`. - * - * Refusal is a `null` VALUE rather than its own message type: `accept()` owns - * the envelope, so there is no second type to use. That is the trade for - * speaking the canonical protocol, and it matches what every other provider in - * the repo now answers when it cannot serve. - * * Silence is also a valid outcome (nothing is mounted to answer), which the * consumer's bounded discovery timeout covers. Answering sides that KNOW they're - * refusing should respond `null` rather than staying silent, so the consumer - * doesn't wait out the timeout for an answer that already exists. - * - * Deliberately a STRING, not a Symbol. Comparisons against it are `===` on the - * value (here, in the host worker provider, and as an inlined literal in the - * isolation iframe bridge), so it keeps working even if this module is somehow - * evaluated more than once. A Symbol would silently stop matching. + * refusing should respond `null` rather than staying silent. */ export const CHANNEL_SELECTOR: "patchwork:worker-channel"; -export const openSession: (kind: string, sessionOpts?: { - element?: HTMLElement; - idPrefix?: string; - onLog?: (...a: any[]) => void; -}) => { - request: (frame: any, opts: RequestOpts) => { - promise: Promise; - abort: () => void; - }; - notify: (frame: any, element: any) => Promise; -}; +/** + * The plugin type a service package registers to offer a worker. Its `id` is + * the worker `kind`; `load()` resolves to a WorkerSpec (see ./serve.js). + */ +export const WORKER_PLUGIN_TYPE: "patchwork:worker"; +/** + * The paired plugin type a service package registers to offer a typed client + * for its worker. Same `id` as the worker; `load()` resolves to a factory + * `(session) => clientApi` (see ./client.js). + */ +export const WORKER_CLIENT_PLUGIN_TYPE: "patchwork:worker-client"; +export const openSession: (kind: string, sessionOpts?: import("./session.js").SessionOpts) => import("./session.js").Session; export type WorkerStreams = { readable: ReadableStream; writable: WritableStream; @@ -104,10 +88,9 @@ export type WorkerConnection = WorkerStreams & { * the provider's mount point, so a worker can resolve host-realm context (a * settings doc, say) without the provider knowing about that service. * - * (Shape mirrored from serve.js's `WorkerSpec`; kept as a local typedef rather - * than importing serve.js, because this file is the sandbox-loaded consumer half - * and must not pull the host-only serve module into its graph. Types erase, so - * this costs nothing at runtime.) + * (Shape mirrored from serve.js's `WorkerSpec`, kept as a local typedef so this + * sandbox-loaded file does not import the host-only serve module. Types erase, + * so this costs nothing at runtime.) */ export type WorkerSpec = { createWorker: () => Worker | Promise; diff --git a/libraries/patchwork-worker/types/index.d.ts b/libraries/patchwork-worker/types/index.d.ts index 5bce78e3..9c3c288c 100644 --- a/libraries/patchwork-worker/types/index.d.ts +++ b/libraries/patchwork-worker/types/index.d.ts @@ -1,40 +1,30 @@ +export { connectWorkerClient } from "./client.js"; /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. - * @typedef {import("./connect.js").WorkerSpec} WorkerSpec - * @typedef {import("./connect.js").WorkerPlugin} WorkerPlugin - * @typedef {import("./connect.js").WorkerStreams} WorkerStreams - * @typedef {import("./connect.js").WorkerConnection} WorkerConnection + * Types a service package needs to type its `plugins` entries. */ +export type WorkerSpec = import("./connect.js").WorkerSpec; /** - * The plugin type a package registers to offer a worker. A plain string, so - * naming it costs no import. + * Types a service package needs to type its `plugins` entries. */ -export const WORKER_PLUGIN_TYPE: "patchwork:worker"; +export type WorkerPlugin = import("./connect.js").WorkerPlugin; /** - * The paired plugin type a package registers to offer a typed client for its - * worker (see ./client.js). Same id as the worker. A plain string here so the - * entry never imports client.js. + * Types a service package needs to type its `plugins` entries. */ -export const WORKER_CLIENT_PLUGIN_TYPE: "patchwork:worker-client"; +export type WorkerStreams = import("./connect.js").WorkerStreams; /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. + * Types a service package needs to type its `plugins` entries. */ -export type WorkerSpec = import("./connect.js").WorkerSpec; +export type WorkerConnection = import("./connect.js").WorkerConnection; /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. + * Types a service package needs to type its `plugins` entries. */ -export type WorkerPlugin = import("./connect.js").WorkerPlugin; +export type WorkerClientPlugin = import("./client.js").WorkerClientPlugin; /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. + * Types a service package needs to type its `plugins` entries. */ -export type WorkerStreams = import("./connect.js").WorkerStreams; +export type WorkerClientFactory = import("./client.js").WorkerClientFactory; /** - * Types a package registering a worker needs, re-exported so it can type its - * `load()` without reaching into the subpath. + * Types a service package needs to type its `plugins` entries. */ -export type WorkerConnection = import("./connect.js").WorkerConnection; -export { connectWorker, rememberDiscoveryElement, openSession, CHANNEL_SELECTOR } from "./connect.js"; +export type Session = import("./session.js").Session; +export { connectWorker, openSession, CHANNEL_SELECTOR, WORKER_PLUGIN_TYPE, WORKER_CLIENT_PLUGIN_TYPE } from "./connect.js"; diff --git a/libraries/patchwork-worker/types/serve.d.ts b/libraries/patchwork-worker/types/serve.d.ts index dfa1519e..2903e907 100644 --- a/libraries/patchwork-worker/types/serve.d.ts +++ b/libraries/patchwork-worker/types/serve.d.ts @@ -1,14 +1,13 @@ /** * Serve one worker connection from a spec. Returns the `{readable, writable}` the * provider transfers to the consumer. One Worker is created for this connection - * and terminated when either stream ends. + * and terminated when either stream ends or the worker dies. * * @param {WorkerSpec} spec - * @param {any} _request the opening request (reserved; specs read per-frame data instead) * @param {{element?: HTMLElement}} [ctx] * @returns {{readable: ReadableStream, writable: WritableStream}} */ -export function serveWorkerSpec(spec: WorkerSpec, _request: any, ctx?: { +export function serveWorkerSpec(spec: WorkerSpec, ctx?: { element?: HTMLElement; }): { readable: ReadableStream; @@ -30,7 +29,9 @@ export type IO = { emit: Emit; /** * register a handler for worker messages tagged with `workerId`; return a truthy - * value from `fn` when the request is complete and the transport should clean up + * value from `fn` when the request is complete and the transport should clean up. + * A request whose handle never calls `on` is fire-and-forget: nothing is + * tracked for it and `op:"abort"` cannot target it. */ on: (fn: (msg: any) => boolean | void) => void; /** @@ -54,7 +55,7 @@ export type WorkerSpec = { element?: HTMLElement; }) => any) | undefined; /** - * returns an opaque abort token (or nothing) stored per request + * returns an opaque abort token (or nothing) stored per tracked request */ handle: (frame: any, io: IO) => any; abort?: ((token: any, post: Post) => void) | undefined; diff --git a/libraries/patchwork-worker/types/session.d.ts b/libraries/patchwork-worker/types/session.d.ts index 4c0484ad..105e9e2e 100644 --- a/libraries/patchwork-worker/types/session.d.ts +++ b/libraries/patchwork-worker/types/session.d.ts @@ -1,16 +1,12 @@ /** * openSession — request/response multiplexing over a worker connection. * - * `connectWorker` gives you a raw `{readable, writable}` pair. Every consumer - * then writes the same layer on top of it: open the connection lazily, keep one - * writer, pump the readable, tag each request with an id, route event frames - * back to the right in-flight caller, and settle on a terminal frame. That layer - * had been written three times (chat's llm-client, patchwork-llm's client, and - * the mirror-image demux inside patchwork-llm's own service), which is also why - * the same reconnect bug existed in three places. - * - * This module owns it once. It stays service-agnostic: frames are opaque, and - * the caller says which `type` values are terminal. + * `connectWorker` gives you a raw `{readable, writable}` pair. This module owns + * the layer every consumer needs on top of it: open the connection lazily, keep + * one writer, pump the readable, tag each request with an id, route event frames + * back to the right in-flight caller, and settle on a terminal frame. It stays + * service-agnostic: frames are opaque, and the caller says which `type` values + * are terminal. * * const session = openSession("llm", {element}) * const {promise, abort} = session.request( @@ -22,42 +18,52 @@ * } * ) * + * Frame routing: a frame with an `id` goes to that request's `onFrame` (or its + * terminal handler). A frame with NO id is a connection-wide broadcast from the + * worker — a status or progress message not tied to one request — and is + * delivered to every in-flight request's `onFrame`, so a caller sees the + * worker's status the same way it would from a same-realm worker. + * * Connection lifetime: opened on the first request, shared by every request * after it, and DROPPED whenever it fails or ends — so the next request * reconnects instead of replaying a dead or rejected connection forever. + * `close()` drops it on purpose (and terminates the host worker behind it). * - * NOTE: this module deliberately does NOT import ./connect.js. `connectWorker` - * is injected by `createOpenSession` instead, so the dependency runs one way - * (connect.js -> session.js) and the package keeps a single entry point. See the - * entry-point note in connect.js for why a second entry point is a hazard here. + * This module does not import ./connect.js. `connectWorker` is injected by + * `createOpenSession`, so the dependency runs one way (connect.js -> session.js). */ /** * @typedef {Object} RequestOpts * @property {Recordany>} terminal frame.type -> settle. The * return value resolves the request; throw to reject it. Any type listed here * ends the request. - * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id + * @property {(frame:any)=>void} [onFrame] every non-terminal frame for this id, + * plus every id-less broadcast frame received while the request is in flight * @property {AbortSignal} [signal] aborting sends {op:"abort", id} and rejects * @property {HTMLElement} [element] discovery element, if not set on the session + * + * @typedef {Object} SessionOpts + * @property {HTMLElement} [element] default discovery element for every request + * @property {string} [idPrefix] request id prefix (defaults to the kind) + * @property {(...a:any[])=>void} [onLog] + * + * @typedef {{readable: ReadableStream, writable: WritableStream, disconnect: () => void}} Connection + * @typedef {Connection & {writer: WritableStreamDefaultWriter, reader: ReadableStreamDefaultReader}} OpenConnection + * + * @typedef {Object} Session + * @property {(frame: any, opts: RequestOpts) => {promise: Promise, abort: () => void}} request + * @property {() => void} close drop the connection (terminating the worker behind + * it) and reject every in-flight request; the next request reconnects */ /** * Build the `openSession` export, bound to a `connectWorker` implementation. * Called once from connect.js; consumers use the resulting `openSession`. * - * @param {(kind: string, request: any, opts?: any) => Promise} connectWorker + * @param {(kind: string, opts: {element: HTMLElement}) => Promise} connectWorker */ -export function createOpenSession(connectWorker: (kind: string, request: any, opts?: any) => Promise): (kind: string, sessionOpts?: { - element?: HTMLElement; - idPrefix?: string; - onLog?: (...a: any[]) => void; -}) => { - request: (frame: any, opts: RequestOpts) => { - promise: Promise; - abort: () => void; - }; - /** Send a fire-and-forget frame (no id correlation, no reply expected). */ - notify: (frame: any, element: any) => Promise; -}; +export function createOpenSession(connectWorker: (kind: string, opts: { + element: HTMLElement; +}) => Promise): (kind: string, sessionOpts?: SessionOpts) => Session; export type RequestOpts = { /** * frame.type -> settle. The @@ -66,7 +72,8 @@ export type RequestOpts = { */ terminal: Record any>; /** - * every non-terminal frame for this id + * every non-terminal frame for this id, + * plus every id-less broadcast frame received while the request is in flight */ onFrame?: ((frame: any) => void) | undefined; /** @@ -78,3 +85,34 @@ export type RequestOpts = { */ element?: HTMLElement | undefined; }; +export type SessionOpts = { + /** + * default discovery element for every request + */ + element?: HTMLElement | undefined; + /** + * request id prefix (defaults to the kind) + */ + idPrefix?: string | undefined; + onLog?: ((...a: any[]) => void) | undefined; +}; +export type Connection = { + readable: ReadableStream; + writable: WritableStream; + disconnect: () => void; +}; +export type OpenConnection = Connection & { + writer: WritableStreamDefaultWriter; + reader: ReadableStreamDefaultReader; +}; +export type Session = { + request: (frame: any, opts: RequestOpts) => { + promise: Promise; + abort: () => void; + }; + /** + * drop the connection (terminating the worker behind + * it) and reject every in-flight request; the next request reconnects + */ + close: () => void; +};