From 1b2976b0ea0d06a30f324dc9274dad44906531ad Mon Sep 17 00:00:00 2001 From: grjte Date: Tue, 15 Sep 2026 15:36:12 +0100 Subject: [PATCH 01/11] 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 51ca872e407fc944f6683c7bb407503c5808fffe Mon Sep 17 00:00:00 2001 From: grjte Date: Mon, 21 Sep 2026 11:29:05 +0100 Subject: [PATCH 02/11] 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; +}; From dbf3dfdb0b25bd47e30431d4b850e57ab47ae2c4 Mon Sep 17 00:00:00 2001 From: chee Date: Fri, 25 Sep 2026 09:19:21 +0100 Subject: [PATCH 03/11] Update handle type in WorkerSpec typedef Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libraries/patchwork-worker/connect.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/patchwork-worker/connect.js b/libraries/patchwork-worker/connect.js index 0fd21766..d8cc690b 100644 --- a/libraries/patchwork-worker/connect.js +++ b/libraries/patchwork-worker/connect.js @@ -100,7 +100,7 @@ const DISCOVERY_TIMEOUT_MS = 8000 * @typedef {{ * createWorker: () => Worker | Promise, * open?: (ctx: {element?: HTMLElement}) => any, - * handle: (frame: any, io: any) => any, + * handle: (frame: any, io: import("./serve.js").IO) => any, * abort?: (token: any, post: (msg: any, transfer?: Transferable[]) => void) => void, * }} WorkerSpec */ From f5df967b29156306bbb0e7ee1cd3c01dcd92f73c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:39:53 +0000 Subject: [PATCH 04/11] Use extensionless patchwork-worker subpath exports Co-authored-by: chee <178266+chee@users.noreply.github.com> --- libraries/patchwork-worker/README.md | 8 ++++---- libraries/patchwork-worker/package.json | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libraries/patchwork-worker/README.md b/libraries/patchwork-worker/README.md index 15aa4e62..2e33d179 100644 --- a/libraries/patchwork-worker/README.md +++ b/libraries/patchwork-worker/README.md @@ -6,7 +6,7 @@ it runs behind an isolation boundary. ```js // consumer — the same code in any realm -import { connectWorkerClient } from "@grjte/patchwork-worker/client.js"; +import { connectWorkerClient } from "@grjte/patchwork-worker/client"; const search = await connectWorkerClient("search-index"); const hits = await search.query("patchwork", { element }); ``` @@ -95,7 +95,7 @@ transfers the streams is that mechanism's concern, not this package's. 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, loads its `WorkerSpec`, and hands that to `serveWorkerSpec`, which it - imports from `@grjte/patchwork-worker/serve.js`. + imports from `@grjte/patchwork-worker/serve`. - **`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"`, @@ -110,7 +110,7 @@ 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`: +A consumer then calls, from `@grjte/patchwork-worker/client`: ```js const api = await connectWorkerClient("kind", { sessionOpts: { idPrefix: "mytool" } }); @@ -245,7 +245,7 @@ service can cancel the matching worker work. 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`. + `@grjte/patchwork-worker/serve`. - `client.js` — `connectWorkerClient`: resolves the paired `patchwork:worker-client` plugin from the registry and binds its factory to `openSession(kind)`. The one file here that touches the plugin registry. diff --git a/libraries/patchwork-worker/package.json b/libraries/patchwork-worker/package.json index 1694ecc6..2ecc47f5 100644 --- a/libraries/patchwork-worker/package.json +++ b/libraries/patchwork-worker/package.json @@ -10,19 +10,19 @@ "types": "./types/index.d.ts", "default": "./index.js" }, - "./connect.js": { + "./connect": { "types": "./types/connect.d.ts", "default": "./connect.js" }, - "./session.js": { + "./session": { "types": "./types/session.d.ts", "default": "./session.js" }, - "./serve.js": { + "./serve": { "types": "./types/serve.d.ts", "default": "./serve.js" }, - "./client.js": { + "./client": { "types": "./types/client.d.ts", "default": "./client.js" } From 7e1e6e6ec956544a64742a1808a396ddf19ae0fe Mon Sep 17 00:00:00 2001 From: grjte Date: Tue, 15 Sep 2026 15:38:06 +0100 Subject: [PATCH 05/11] feat: add provider for patchwork workers --- providers/package.json | 6 +- providers/pnpm-lock.yaml | 1040 +++++++++++++------------- providers/src/WorkerProvider.test.ts | 183 +++++ providers/src/WorkerProvider.ts | 175 +++++ providers/src/index.ts | 9 + providers/vite.config.ts | 2 + 6 files changed, 892 insertions(+), 523 deletions(-) create mode 100644 providers/src/WorkerProvider.test.ts create mode 100644 providers/src/WorkerProvider.ts diff --git a/providers/package.json b/providers/package.json index 02dcbc00..3f8d09b3 100644 --- a/providers/package.json +++ b/providers/package.json @@ -10,13 +10,16 @@ "sync": "vite build && pushwork sync", "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", "@automerge/automerge-repo": "^2.6.0-subduction.26", + "@grjte/patchwork-worker": "^0.0.2", "@inkandswitch/patchwork-comments": "^0.1.2", "@inkandswitch/patchwork-elements": "^6.0.2", + "@inkandswitch/patchwork-filesystem": "^0.2.9", "@inkandswitch/patchwork-plugins": "^1.2.5", "@inkandswitch/patchwork-providers": "^0.5.2" }, @@ -27,6 +30,7 @@ "eslint": "^9.34.0", "globals": "^16.4.0", "happy-dom": "^15.11.7", + "typescript": "^5.6.0", "typescript-eslint": "^8.41.0", "vite": "^7.1.9", "vitest": "^3.2.4" diff --git a/providers/pnpm-lock.yaml b/providers/pnpm-lock.yaml index 0ed3fc55..96821c6f 100644 --- a/providers/pnpm-lock.yaml +++ b/providers/pnpm-lock.yaml @@ -13,47 +13,56 @@ importers: version: 3.3.0-fragments.1 '@automerge/automerge-repo': specifier: ^2.6.0-subduction.26 - version: 2.6.0-subduction.26(supports-color@7.2.0) + version: 2.6.0-subduction.48(supports-color@7.2.0) + '@grjte/patchwork-worker': + specifier: ^0.0.2 + version: 0.0.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) '@inkandswitch/patchwork-comments': specifier: ^0.1.2 - version: 0.1.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)) + version: 0.1.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)) '@inkandswitch/patchwork-elements': specifier: ^6.0.2 - version: 6.0.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0) + version: 6.0.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': + specifier: ^0.2.9 + version: 0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) '@inkandswitch/patchwork-plugins': specifier: ^1.2.5 - version: 1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + version: 1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) '@inkandswitch/patchwork-providers': specifier: ^0.5.2 - version: 0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)) + version: 0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)) devDependencies: '@eslint/js': specifier: ^9.37.0 - version: 9.39.4 + version: 9.39.5 '@inkandswitch/patchwork-bootloader': specifier: ^0.7.2 - version: 0.7.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + version: 0.7.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) '@typescript-eslint/parser': specifier: ^8.40.0 - version: 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + version: 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) eslint: specifier: ^9.34.0 - version: 9.39.4(supports-color@7.2.0) + version: 9.39.5(supports-color@7.2.0) globals: specifier: ^16.4.0 version: 16.5.0 happy-dom: specifier: ^15.11.7 version: 15.11.7 + typescript: + specifier: ^5.6.0 + version: 5.9.3 typescript-eslint: specifier: ^8.41.0 - version: 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + version: 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) vite: specifier: ^7.1.9 - version: 7.3.3(@types/node@20.19.41) + version: 7.3.6(@types/node@20.19.43) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.41)(happy-dom@15.11.7)(supports-color@7.2.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7)(supports-color@7.2.0) packages: @@ -73,16 +82,10 @@ packages: resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==} engines: {node: '>=22.13'} - '@automerge/automerge-repo@2.6.0-subduction.26': - resolution: {integrity: sha512-GbL4YA7OBNJPyrMWbof649bqPtTmkxmDwh8a/H+MX8Aqq9NzftxRosYNJP2b+O4SpaphwUdT/dKnvtc/4cHOPw==} - '@automerge/automerge-repo@2.6.0-subduction.48': resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} engines: {node: '>=22.13'} - '@automerge/automerge-subduction@0.13.0': - resolution: {integrity: sha512-7XlCIS0GH3ctboE8UPmD9rfiv203Q8n5Lx63xE6xCgtlpwecDKqzLDp77e9hUEo76j17Y8bYQyfsROqaByLf5A==} - '@automerge/automerge-subduction@0.16.1': resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} @@ -128,170 +131,170 @@ packages: '@codemirror/language@6.12.4': resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} - '@codemirror/state@6.7.5': - resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} - '@codemirror/view@6.43.12': - resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + '@codemirror/view@6.43.13': + resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@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.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@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.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@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.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -312,12 +315,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -328,6 +331,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@grjte/patchwork-worker@0.0.2': + resolution: {integrity: sha512-a7vJ0O2ZNyOUzCyK32PwaGJbVxUFqE/lHhRGadR3lA1XWpbIJ059B4qDpthn8R4eUzGPAIqFy2k0lawaLsKktA==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -394,6 +400,13 @@ packages: '@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-plugins@1.2.5': resolution: {integrity: sha512-4R/N6W4v4Xyfp47RaG7WesN0jUdNsQmZshJt9pHfsTbAFEbvNvgnmC2JF9mzSL5CagCy1njACIoasOVvkv/A/A==} peerDependencies: @@ -406,8 +419,8 @@ packages: peerDependencies: '@automerge/automerge-repo': '*' - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@keyhive/keyhive@0.1.0-alpha.8': resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==} @@ -424,145 +437,152 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@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.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + '@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.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + '@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.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + '@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.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + '@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.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + '@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.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + '@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.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + '@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.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + '@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.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + '@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.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + '@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.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + '@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.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + '@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.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + '@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.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + '@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.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + '@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.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + '@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.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} cpu: [x64] os: [win32] @@ -575,9 +595,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -587,69 +604,69 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.41': - resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} '@types/serviceworker@0.0.153': resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} - '@typescript-eslint/eslint-plugin@8.59.4': - resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.4 + '@typescript-eslint/parser': ^8.70.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.4': - resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + '@typescript-eslint/parser@8.70.1': + resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.4': - resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + '@typescript-eslint/project-service@8.70.1': + resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.59.4': - resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + '@typescript-eslint/scope-manager@8.70.1': + resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.59.4': - resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.4': - resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + '@typescript-eslint/type-utils@8.70.1': + resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.59.4': - resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + '@typescript-eslint/types@8.70.1': + resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.59.4': - resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + '@typescript-eslint/typescript-estree@8.70.1': + resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.4': - resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + '@typescript-eslint/utils@8.70.1': + resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.59.4': - resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + '@typescript-eslint/visitor-keys@8.70.1': + resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitest/expect@3.2.7': @@ -686,8 +703,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -712,28 +729,19 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base-x@4.0.1: - resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} - base-x@5.0.1: resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - bs58@5.0.0: - resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} - bs58check@3.0.1: - resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} - bs58check@4.0.0: resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} @@ -749,8 +757,8 @@ packages: resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} hasBin: true - cbor-x@1.6.4: - resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -811,8 +819,8 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -836,9 +844,10 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -909,8 +918,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -941,8 +950,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -972,8 +981,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true json-buffer@3.0.1: @@ -1005,8 +1014,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -1015,8 +1024,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1061,12 +1070,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1085,13 +1094,13 @@ packages: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -1153,8 +1162,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@1.1.1: @@ -1165,8 +1174,8 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} ts-api-utils@2.5.0: @@ -1179,15 +1188,15 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.59.4: - resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} + typescript-eslint@8.70.1: + resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -1197,13 +1206,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} - hasBin: true - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} hasBin: true vite-node@3.2.4: @@ -1211,8 +1215,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.3: - resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1304,8 +1308,8 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -1316,8 +1320,8 @@ packages: utf-8-validate: optional: true - xstate@5.32.0: - resolution: {integrity: sha512-zsk73aWGmxn9z34P0kbiod5JwTvdYRW3+IDxITq8sd9+VWwMyW7BUzpplnYy9mIEXa6V8IMDv7Hy4m0mhT5+2Q==} + xstate@5.33.2: + resolution: {integrity: sha512-8tC7yXgeCvpT8gKEeEje6ikJJG1wpnoLiXe+HfECW8m10ubMd3QxKOwWA8KxPJVBcLwfdRoMKYxIBGYKmo37/A==} yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} @@ -1345,10 +1349,10 @@ snapshots: '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) - cbor-x: 1.6.4 + cbor-x: 1.6.6 debug: 4.4.3(supports-color@7.2.0) eventemitter3: 5.0.4 - ws: 8.21.0 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - supports-color @@ -1362,44 +1366,24 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)': - dependencies: - '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-subduction': 0.13.0 - bs58check: 3.0.1 - cbor-x: 1.6.4 - debug: 4.4.3(supports-color@7.2.0) - eventemitter3: 5.0.4 - fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - uuid: 9.0.1 - ws: 8.21.0 - xstate: 5.32.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.2 '@automerge/automerge-subduction': 0.16.1 bs58check: 4.0.0 - cbor-x: 1.6.4 + cbor-x: 1.6.6 debug: 4.4.3(supports-color@7.2.0) eventemitter3: 5.0.4 fast-sha256: 1.3.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - uuid: 14.0.0 - ws: 8.21.0 - xstate: 5.32.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.33.2 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-subduction@0.13.0': {} - '@automerge/automerge-subduction@0.16.1': {} '@automerge/automerge@3.3.0-fragments.1': {} @@ -1427,111 +1411,111 @@ snapshots: '@codemirror/commands@6.11.1': dependencies: '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.13 '@lezer/common': 1.5.2 '@codemirror/language@6.12.4': dependencies: - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.13 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 style-mod: 4.1.4 - '@codemirror/state@6.7.5': + '@codemirror/state@6.7.6': dependencies: '@marijn/find-cluster-break': 1.0.4 - '@codemirror/view@6.43.12': + '@codemirror/view@6.43.13': dependencies: - '@codemirror/state': 6.7.5 + '@codemirror/state': 6.7.6 crelt: 1.0.7 style-mod: 4.1.4 w3c-keyname: 2.2.8 - '@esbuild/aix-ppc64@0.27.7': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@7.2.0))': dependencies: - eslint: 9.39.4(supports-color@7.2.0) + eslint: 9.39.5(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1552,7 +1536,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': dependencies: ajv: 6.15.0 debug: 4.4.3(supports-color@7.2.0) @@ -1560,13 +1544,13 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -1575,6 +1559,16 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@grjte/patchwork-worker@0.0.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)) + transitivePeerDependencies: + - '@automerge/automerge' + - '@automerge/automerge-repo' + - '@inkandswitch/patchwork-filesystem' + - supports-color + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -1591,7 +1585,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': + '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': dependencies: '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48(supports-color@7.2.0) '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48(supports-color@7.2.0) @@ -1600,12 +1594,12 @@ snapshots: '@automerge/automerge-subduction': 0.16.1 '@codemirror/commands': 6.11.1 '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@inkandswitch/patchwork-elements': 6.0.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)) + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.13 + '@inkandswitch/patchwork-elements': 6.0.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)) '@keyhive/keyhive': 0.1.0-alpha.8 '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) @@ -1615,7 +1609,7 @@ snapshots: tinyargs: 0.1.4 optionalDependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) transitivePeerDependencies: - '@types/react' - bufferutil @@ -1623,49 +1617,62 @@ snapshots: - supports-color - utf-8-validate - '@inkandswitch/patchwork-comments@0.1.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))': + '@inkandswitch/patchwork-comments@0.1.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) - '@inkandswitch/patchwork-elements@6.0.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0)': + '@inkandswitch/patchwork-elements@6.0.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0))(@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)))(solid-js@1.9.15)(supports-color@7.2.0)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) - '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) - '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0)) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': 1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': 0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0)) debug: 4.4.3(supports-color@7.2.0) optionalDependencies: solid-js: 1.9.15 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': + '@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 debug: 4.4.3(supports-color@7.2.0) + eventemitter3: 5.0.4 resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': + '@inkandswitch/patchwork-plugins@1.2.5(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) - '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))(@automerge/automerge@3.3.0-fragments.1)(supports-color@7.2.0) '@types/debug': 4.1.13 - '@types/node': 20.19.41 + '@types/node': 20.19.43 debug: 4.4.3(supports-color@7.2.0) eventemitter3: 5.0.4 resolve.exports: 2.0.3 transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.26(supports-color@7.2.0))': + '@inkandswitch/patchwork-providers@0.5.2(@automerge/automerge-repo@2.6.0-subduction.48(supports-color@7.2.0))': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.26(supports-color@7.2.0) + '@automerge/automerge-repo': 2.6.0-subduction.48(supports-color@7.2.0) - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@keyhive/keyhive@0.1.0-alpha.8': {} @@ -1681,81 +1688,84 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@noble/hashes@1.8.0': {} - '@rollup/rollup-android-arm-eabi@4.60.4': + '@rollup/rollup-android-arm-eabi@4.63.4': optional: true - '@rollup/rollup-android-arm64@4.60.4': + '@rollup/rollup-android-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-arm64@4.60.4': + '@rollup/rollup-darwin-arm64@4.63.4': optional: true - '@rollup/rollup-darwin-x64@4.60.4': + '@rollup/rollup-darwin-x64@4.63.4': optional: true - '@rollup/rollup-freebsd-arm64@4.60.4': + '@rollup/rollup-freebsd-arm64@4.63.4': optional: true - '@rollup/rollup-freebsd-x64@4.60.4': + '@rollup/rollup-freebsd-x64@4.63.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.4': + '@rollup/rollup-linux-arm-musleabihf@4.63.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.4': + '@rollup/rollup-linux-arm64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.4': + '@rollup/rollup-linux-arm64-musl@4.63.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.4': + '@rollup/rollup-linux-loong64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.4': + '@rollup/rollup-linux-loong64-musl@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.4': + '@rollup/rollup-linux-ppc64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.4': + '@rollup/rollup-linux-ppc64-musl@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.4': + '@rollup/rollup-linux-riscv64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.4': + '@rollup/rollup-linux-riscv64-musl@4.63.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.4': + '@rollup/rollup-linux-s390x-gnu@4.63.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.4': + '@rollup/rollup-linux-x64-gnu@4.63.4': optional: true - '@rollup/rollup-linux-x64-musl@4.60.4': + '@rollup/rollup-linux-x64-musl@4.63.4': optional: true - '@rollup/rollup-openbsd-x64@4.60.4': + '@rollup/rollup-openbsd-x64@4.63.4': optional: true - '@rollup/rollup-openharmony-arm64@4.60.4': + '@rollup/rollup-openharmony-arm64@4.63.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.4': + '@rollup/rollup-win32-arm64-msvc@4.63.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.4': + '@rollup/rollup-win32-ia32-msvc@4.63.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.4': + '@rollup/rollup-win32-x64-gnu@4.63.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.4': + '@rollup/rollup-win32-x64-msvc@4.63.4': optional: true '@types/chai@5.2.3': @@ -1769,109 +1779,107 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.8': {} - '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} '@types/ms@2.1.0': {} - '@types/node@20.19.41': + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 '@types/serviceworker@0.0.153': {} - '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.4 - eslint: 9.39.4(supports-color@7.2.0) - ignore: 7.0.5 + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 + eslint: 9.39.5(supports-color@7.2.0) + ignore: 7.0.10 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.4(supports-color@7.2.0) - typescript: 6.0.3 + eslint: 9.39.5(supports-color@7.2.0) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.4(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) - '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) + '@typescript-eslint/types': 8.70.1 debug: 4.4.3(supports-color@7.2.0) - typescript: 6.0.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.59.4': + '@typescript-eslint/scope-manager@8.70.1': dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@5.9.3)': dependencies: - typescript: 6.0.3 + typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.4(supports-color@7.2.0) - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + eslint: 9.39.5(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.59.4': {} + '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.59.4(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.4(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 debug: 4.4.3(supports-color@7.2.0) - minimatch: 10.2.5 - semver: 7.8.1 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@7.2.0)) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(supports-color@7.2.0)(typescript@6.0.3) - eslint: 9.39.4(supports-color@7.2.0) - typescript: 6.0.3 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.5(supports-color@7.2.0) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.59.4': + '@typescript-eslint/visitor-keys@8.70.1': dependencies: - '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 '@vitest/expect@3.2.7': @@ -1882,13 +1890,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.3(@types/node@20.19.41))': + '@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.3(@types/node@20.19.41) + vite: 7.3.6(@types/node@20.19.43) '@vitest/pretty-format@3.2.7': dependencies: @@ -1908,7 +1916,7 @@ snapshots: '@vitest/spy@3.2.7': dependencies: - tinyspy: 4.0.4 + tinyspy: 4.0.6 '@vitest/utils@3.2.7': dependencies: @@ -1916,11 +1924,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.16.0 + acorn: 8.18.0 - acorn@8.16.0: {} + acorn@8.18.0: {} ajv@6.15.0: dependencies: @@ -1941,32 +1949,21 @@ snapshots: balanced-match@4.0.4: {} - base-x@4.0.1: {} - base-x@5.0.1: {} - brace-expansion@1.1.14: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 - bs58@5.0.0: - dependencies: - base-x: 4.0.1 - bs58@6.0.0: dependencies: base-x: 5.0.1 - bs58check@3.0.1: - dependencies: - '@noble/hashes': 1.8.0 - bs58: 5.0.0 - bs58check@4.0.0: dependencies: '@noble/hashes': 1.8.0 @@ -1988,7 +1985,7 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64': 2.2.2 optional: true - cbor-x@1.6.4: + cbor-x@1.6.6: optionalDependencies: cbor-extract: 2.2.2 @@ -2042,34 +2039,34 @@ snapshots: es-module-lexer@1.7.0: {} - esbuild@0.27.7: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@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 escape-string-regexp@4.0.0: {} @@ -2084,15 +2081,15 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(supports-color@7.2.0): + eslint@9.39.5(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5(supports-color@7.2.0) - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 @@ -2125,8 +2122,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -2157,9 +2154,9 @@ snapshots: fast-sha256@1.3.0: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 file-entry-cache@8.0.0: dependencies: @@ -2172,10 +2169,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} fsevents@2.3.3: optional: true @@ -2198,7 +2195,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.10: {} import-fresh@3.3.1: dependencies: @@ -2215,13 +2212,13 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.21.0): + isomorphic-ws@5.0.0(ws@8.21.3): dependencies: - ws: 8.21.0 + ws: 8.21.3 js-tokens@9.0.1: {} - js-yaml@4.1.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -2250,19 +2247,19 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.21 ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.19: {} natural-compare@1.4.0: {} @@ -2302,11 +2299,11 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} + picomatch@4.0.7: {} - postcss@8.5.15: + postcss@8.5.28: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2318,38 +2315,39 @@ snapshots: resolve.exports@2.0.3: {} - rollup@4.60.4: + rollup@4.63.4: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 + '@napi-rs/lzma-linux-x64-gnu': 1.5.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 - semver@7.8.1: {} + semver@7.8.5: {} seroval-plugins@1.5.6(seroval@1.5.6): dependencies: @@ -2395,37 +2393,37 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@1.1.1: {} tinyrainbow@2.0.0: {} - tinyspy@4.0.4: {} + tinyspy@4.0.6: {} - ts-api-utils@2.5.0(typescript@6.0.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: - typescript: 6.0.3 + typescript: 5.9.3 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + typescript-eslint@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.59.4(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - eslint: 9.39.4(supports-color@7.2.0) - typescript: 6.0.3 + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 9.39.5(supports-color@7.2.0) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - typescript@6.0.3: {} + typescript@5.9.3: {} undici-types@6.21.0: {} @@ -2433,17 +2431,15 @@ snapshots: dependencies: punycode: 2.3.1 - uuid@14.0.0: {} - - uuid@9.0.1: {} + uuid@14.0.2: {} - vite-node@3.2.4(@types/node@20.19.41)(supports-color@7.2.0): + vite-node@3.2.4(@types/node@20.19.43)(supports-color@7.2.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3(@types/node@20.19.41) + vite: 7.3.6(@types/node@20.19.43) transitivePeerDependencies: - '@types/node' - jiti @@ -2458,23 +2454,23 @@ snapshots: - tsx - yaml - vite@7.3.3(@types/node@20.19.41): + vite@7.3.6(@types/node@20.19.43): dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.4 - tinyglobby: 0.2.16 + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.4 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 20.19.41 + '@types/node': 20.19.43 fsevents: 2.3.3 - vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.41)(happy-dom@15.11.7)(supports-color@7.2.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7)(supports-color@7.2.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.3(@types/node@20.19.41)) + '@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 @@ -2485,19 +2481,19 @@ snapshots: expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.3(@types/node@20.19.41) - vite-node: 3.2.4(@types/node@20.19.41)(supports-color@7.2.0) + vite: 7.3.6(@types/node@20.19.43) + vite-node: 3.2.4(@types/node@20.19.43)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 - '@types/node': 20.19.41 + '@types/node': 20.19.43 happy-dom: 15.11.7 transitivePeerDependencies: - jiti @@ -2530,8 +2526,8 @@ snapshots: word-wrap@1.2.5: {} - ws@8.21.0: {} + ws@8.21.3: {} - xstate@5.32.0: {} + xstate@5.33.2: {} yocto-queue@0.1.0: {} diff --git a/providers/src/WorkerProvider.test.ts b/providers/src/WorkerProvider.test.ts new file mode 100644 index 00000000..a1995141 --- /dev/null +++ b/providers/src/WorkerProvider.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; + +// The provider resolves workers from the host plugin registry. Stub it before +// importing, so these tests exercise the provider's own logic (decline / claim / +// always-settle) without needing the real platform. +type Waiter = { id: string; resolve: (v: { module: unknown }) => void }; +const registry = { + plugins: new Map(), + waiters: [] as Waiter[], + has(id: string) { + return this.plugins.has(id); + }, + async loadWhenReady(id: string): Promise<{ module: unknown }> { + // 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: string, spec: unknown) { + this.plugins.set(id, spec); + for (const w of this.waiters) { + if (w.id === id) w.resolve({ module: spec }); + } + this.waiters = this.waiters.filter((w) => w.id !== id); + }, + reset() { + this.plugins.clear(); + this.waiters = []; + }, +}; + +vi.mock("@inkandswitch/patchwork-plugins", () => ({ + getRegistry: () => registry, +})); + +const { WorkerProvider } = await import("./WorkerProvider.js"); + +/** A minimal WorkerSpec whose worker is a stub — enough to exercise the provider, + * which hands the spec to serveWorkerSpec rather than calling a run function. */ +function fakeSpec(overrides: Record = {}) { + return { + createWorker: () => ({ + onmessage: null, + postMessage() {}, + terminate() {}, + }), + handle() {}, + ...overrides, + }; +} + +/** Dispatch a worker-channel subscribe and report what came back. */ +function ask(el: HTMLElement, kind: string) { + const channel = new MessageChannel(); + const replies: any[] = []; + let reachedDocument = false; + + channel.port1.addEventListener("message", (e) => replies.push(e.data)); + channel.port1.start(); + + // The provider listens at `document`, and stopPropagation() does not stop + // other listeners on the SAME node — so a document-level sentinel always + // fires. Read `cancelBubble` instead: it's the observable record of whether + // something called stopPropagation() during this dispatch. + let claimed = false; + const sentinel = (e: Event) => { + if ((e as CustomEvent).detail?.port !== channel.port2) return; + reachedDocument = true; + // Runs after the provider's listener (registered first), so this reflects + // whether the provider claimed it. + claimed = e.cancelBubble === true; + }; + document.addEventListener("patchwork:subscribe", sentinel); + el.dispatchEvent( + new CustomEvent("patchwork:subscribe", { + detail: { selector: { type: "patchwork:worker-channel", kind, request: {} }, port: channel.port2 }, + bubbles: true, + composed: true, + }) + ); + document.removeEventListener("patchwork:subscribe", sentinel); + + return { + replies, + /** Did the provider decline (leave the event unclaimed for others)? */ + declined: () => reachedDocument && !claimed, + settled: async () => { + for (let i = 0; i < 50 && replies.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + return replies[0]; + }, + }; +} + +let cleanups: Array<() => void> = []; +afterEach(() => { + cleanups.forEach((f) => f()); + cleanups = []; + registry.reset(); +}); + +function mount() { + const host = document.createElement("div"); + document.body.appendChild(host); + const el = document.createElement("div"); + host.appendChild(el); + const dispose = WorkerProvider(host); + cleanups.push(() => { + dispose(); + host.remove(); + }); + return el; +} + +describe("WorkerProvider", () => { + it("declines a kind it cannot serve, leaving the event to bubble", async () => { + // A provider that claimed unconditionally and only discovered there was no + // worker afterwards would swallow the event from any other answerer. + const el = mount(); + const asked = ask(el, "nobody"); + expect(asked.declined()).toBe(true); + expect(asked.replies).toHaveLength(0); + }); + + it("claims and serves a registered kind, transferring the streams", async () => { + registry.register("llm", fakeSpec()); + const el = mount(); + const asked = ask(el, "llm"); + + expect(asked.declined()).toBe(false); // claimed synchronously + const reply = await asked.settled(); + // The providers envelope, with the pair in `value` and the streams moved + // (not cloned) by respond()'s transfer list. + expect(reply.type).toBe("change"); + expect(reply.value.readable).toBeInstanceOf(ReadableStream); + expect(reply.value.writable).toBeInstanceOf(WritableStream); + }); + + it("passes the mounted element to the spec, so a worker can resolve host context", async () => { + let seen: any = null; + registry.register( + "llm", + fakeSpec({ + open: (ctx: unknown) => { + seen = ctx; + return null; + }, + }) + ); + const el = mount(); + // The provider serves synchronously; element reaches the spec via open(ctx). + await ask(el, "llm").settled(); + // open runs eagerly inside serveWorkerSpec, before any frame. + expect(seen?.element).toBeInstanceOf(HTMLElement); + }); + + it("refuses when the plugin does not resolve to a WorkerSpec", async () => { + // A claimed subscription must always settle; a bad plugin settles with null. + registry.register("llm", "not a spec"); + vi.spyOn(console, "error").mockImplementation(() => {}); + const el = mount(); + const reply = await ask(el, "llm").settled(); + expect(reply).toMatchObject({ type: "change", value: null }); + }); + + it("refuses a spec missing createWorker or handle", async () => { + registry.register("llm", { handle: () => {} }); // no createWorker + vi.spyOn(console, "error").mockImplementation(() => {}); + const el = mount(); + const reply = await ask(el, "llm").settled(); + expect(reply).toMatchObject({ type: "change", value: null }); + }); + + // NOTE: there is deliberately no "serves a worker registered AFTER the request" + // test. The synchronous `has(kind)` guard means an unregistered kind is + // DECLINED, not queued — so late registration is unreachable by construction, + // and `loadWhenReady`'s waiting branch only covers a plugin that is registered + // but not yet loaded. A test that registers first and calls itself "late" would + // assert a capability the provider does not have. +}); diff --git a/providers/src/WorkerProvider.ts b/providers/src/WorkerProvider.ts new file mode 100644 index 00000000..445150b9 --- /dev/null +++ b/providers/src/WorkerProvider.ts @@ -0,0 +1,175 @@ +/** + * The worker provider — the SERVING half of `patchwork:worker-channel`. + * + * Mounted in the host realm as a `patchwork:component`, it answers worker-channel + * subscriptions by running the requested worker HERE and transferring its + * `{readable, writable}` stream pair back to the consumer. Under isolation the + * consumer is inside the sandbox and only the streams cross; the worker, and any + * config or secrets it resolves, never leave the host. + * + * It is KIND-AGNOSTIC. Workers are resolved from the `patchwork:worker` plugin + * registry by the selector's `kind`, so this file imports nothing + * service-specific — no LLM, no config, no secrets. A package that owns a worker + * registers it declaratively: + * + * // in a Patchwork package's `plugins` array + * {type: "patchwork:worker", id: "llm", name: "LLM", + * async load() { return llmWorkerSpec }} + * + * and `load()` resolves to a `WorkerSpec` (see @grjte/patchwork-worker/serve.js): + * `{createWorker, open?, handle, abort?}`. The provider hands the spec to + * `serveWorkerSpec`, which owns the streams, the worker lifetime, and the id + * demux; the spec owns only the service's op vocabulary. + * + * Package placement: this lives in the host-only `providers` package and imports + * the transport's serve half from `@grjte/patchwork-worker/serve.js`. The + * consumer half (`connect.js`, the only file a sandboxed tool loads) never + * imports this module, so the plugin registry stays out of the sandbox. + * + * Three invariants make this reliable rather than racy: + * + * 1. DECLINE SYNCHRONOUSLY, CLAIM SYNCHRONOUSLY. `registry.has(kind)` is sync, + * so a kind we cannot serve is left to bubble (another provider may answer) + * by returning BEFORE `accept()`. `accept()` itself claims with + * `stopPropagation()` before running the producer, so a kind we can serve is + * claimed before any await — otherwise the event finishes bubbling while we + * wait and an ancestor double-answers. + * 2. ALWAYS SETTLE A CLAIMED REQUEST. Once claimed, the consumer is waiting on + * this subscription, so every path must reply — the stream pair on success, + * an explicit `null` on failure. Staying silent turns a bad config or a + * throwing worker into a hang. + * 3. LISTEN AT THE DOCUMENT, NOT THE MOUNT ELEMENT. `patchwork:subscribe` + * bubbles and is `composed`, so a document-level listener catches every + * request regardless of where this provider sits relative to the consumer. + * Listening on the mount element instead would make correctness depend on + * this component having mounted before any consumer dispatches — and + * `patchwork-view` mounting is async twice over (a deferred render, then an + * async registry load), so a host would have to gate its whole subtree on + * this provider being ready. That gate is a bad trade: `patchwork-view` + * emits no event when a component fails to load, so a failed load would + * leave the gated subtree blank forever. Listening at the document keeps + * this an optional capability that degrades to "no worker available" + * instead of taking the frame down with it. (The sibling providers in this + * package listen on their mount element; this one deliberately differs.) + */ + +import { getRegistry, type PluginDescription, type PluginRegistry } from "@inkandswitch/patchwork-plugins"; +import { accept, type SubscribeEvent } from "@inkandswitch/patchwork-providers"; +import { CHANNEL_SELECTOR } from "@grjte/patchwork-worker/connect.js"; +import { serveWorkerSpec, type WorkerSpec } from "@grjte/patchwork-worker/serve.js"; + +// The plugin type is a bare string in @grjte/patchwork-worker's entry +// (`WORKER_PLUGIN_TYPE`); re-stated here so this module's graph does not pull +// the barrel in for one constant. +const WORKER_PLUGIN_TYPE = "patchwork:worker"; + +/** + * How long to wait for a registered worker plugin to LOAD before refusing. + * + * `has(kind)` proves the plugin is registered, not that its module can be + * fetched — an unreachable package or a `load()` that never settles would + * otherwise leave a claimed port unanswered forever, which is exactly the hang + * invariant 2 exists to prevent. + */ +const LOAD_TIMEOUT_MS = 10000; + +/** The value a worker-channel subscription settles with: the pair, or a refusal. */ +type WorkerChannelValue = { readable: ReadableStream; writable: WritableStream } | null; + +/** The selector a consumer dispatches (see connect.js `CHANNEL_SELECTOR`). */ +type WorkerChannelSelector = { type: string; kind?: string; request?: unknown }; + +/** + * Mount the worker provider on `element`. Returns a cleanup function + * (the `patchwork:component` render contract). + * + * `element` is never read for `repo` or attributes; it is only the host-realm + * context handed to a spec's `open(ctx)`, so a worker can resolve things that + * need a mounted view (a settings doc, say). Hence `HTMLElement`, not + * `PatchworkViewElement`. + */ +export const WorkerProvider = (element: HTMLElement): (() => void) => { + const workers: PluginRegistry = getRegistry(WORKER_PLUGIN_TYPE); + + const onSubscribe = (event: Event) => { + const detail = (event as SubscribeEvent).detail; + const selector = detail?.selector as WorkerChannelSelector | undefined; + if (!selector || selector.type !== CHANNEL_SELECTOR) return; + if (!detail.port) return; + + const kind = selector.kind; + + // Decline what we cannot serve, SYNCHRONOUSLY and without claiming, so the + // event keeps bubbling and something else may answer. Must come BEFORE + // `accept()`, which claims via stopPropagation() the moment it is called. + if (!kind || !workers.has(kind)) return; + + // `accept()` claims the event and owns the port from here: its lifetime, + // its teardown, and the `{type:"change", value}` envelope. We only choose + // the value. + accept(event as SubscribeEvent, (respond) => { + void serve(kind, selector.request, respond); + }); + }; + + /** + * Run the worker for `kind` and hand its streams back. Always settles: the + * stream pair on success, `null` on any failure — a claimed subscription that + * stays silent hangs the consumer forever. + */ + async function serve( + kind: string, + request: unknown, + respond: (value: WorkerChannelValue, transfer?: Transferable[]) => void + ) { + try { + // `loadWhenReady` (not `load`) waits for the `registered` event, so a + // worker registered-but-not-yet-loaded still resolves instead of being + // reported missing. Bounded: a claimed subscription must always settle, + // and `accept()` does not bound the producer for us. + const plugin = await Promise.race([ + workers.loadWhenReady(kind), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`timed out loading worker plugin "${kind}"`)), + LOAD_TIMEOUT_MS + ) + ), + ]); + const spec = plugin?.module as WorkerSpec | undefined; + if (!spec || typeof spec.handle !== "function" || typeof spec.createWorker !== "function") { + throw new Error(`worker plugin "${kind}" did not resolve to a WorkerSpec`); + } + + // The element is what lets a worker resolve host-realm context it needs + // (e.g. a settings doc via the patchwork:tool-storage provider) without + // this provider knowing anything about that service. serveWorkerSpec owns + // the streams, the per-connection worker, and the id demux; the spec owns + // the service vocabulary. + const streams = serveWorkerSpec(spec, request, { element }); + + // The streams ride IN the value; naming them in the transfer list only + // upgrades the structured clone to a move. Both are required — a + // transferable named in the list but absent from the value is detached + // here and never arrives there. + respond({ readable: streams.readable, writable: streams.writable }, [ + streams.readable, + streams.writable, + ]); + } catch (err) { + // The reason no longer rides the wire (the envelope is `accept()`'s), so + // log it here — this is the only place it was ever surfaced anyway. + console.error(`[worker-provider] failed to serve "${kind}":`, err); + respond(null); + } + } + + // Document-level: see invariant 3. `element` is still used as the host-realm + // context handed to `open(ctx)`, so a worker can resolve things that need a + // mounted view (a settings doc, say) — the provider's POSITION matters for + // that, but not for whether it hears the request. + document.addEventListener("patchwork:subscribe", onSubscribe); + return () => { + document.removeEventListener("patchwork:subscribe", onSubscribe); + }; +}; diff --git a/providers/src/index.ts b/providers/src/index.ts index 45179f53..3108ef1e 100644 --- a/providers/src/index.ts +++ b/providers/src/index.ts @@ -46,4 +46,13 @@ export const plugins: Plugin[] = [ return ToolStorageProvider; }, }, + { + type: "patchwork:component", + id: "patchwork-worker-provider", + name: "Worker Provider", + async load() { + const { WorkerProvider } = await import("./WorkerProvider.js"); + return WorkerProvider; + }, + }, ]; diff --git a/providers/vite.config.ts b/providers/vite.config.ts index 810fc0f2..60dc1b59 100644 --- a/providers/vite.config.ts +++ b/providers/vite.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ plugins: [], build: { + // top-level await in the cross-origin virtual modules + target: "esnext", sourcemap: true, emptyOutDir: true, rollupOptions: { From 9c716d3db4560274cb7d4cd0c94ebbf9e1e6911c Mon Sep 17 00:00:00 2001 From: grjte Date: Mon, 21 Sep 2026 11:29:23 +0100 Subject: [PATCH 06/11] update: patchwork-worker-provider after review --- providers/src/WorkerProvider.test.ts | 2 +- providers/src/WorkerProvider.ts | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/providers/src/WorkerProvider.test.ts b/providers/src/WorkerProvider.test.ts index a1995141..f29c73d6 100644 --- a/providers/src/WorkerProvider.test.ts +++ b/providers/src/WorkerProvider.test.ts @@ -75,7 +75,7 @@ function ask(el: HTMLElement, kind: string) { document.addEventListener("patchwork:subscribe", sentinel); el.dispatchEvent( new CustomEvent("patchwork:subscribe", { - detail: { selector: { type: "patchwork:worker-channel", kind, request: {} }, port: channel.port2 }, + detail: { selector: { type: "patchwork:worker-channel", kind }, port: channel.port2 }, bubbles: true, composed: true, }) diff --git a/providers/src/WorkerProvider.ts b/providers/src/WorkerProvider.ts index 445150b9..555f8dda 100644 --- a/providers/src/WorkerProvider.ts +++ b/providers/src/WorkerProvider.ts @@ -55,14 +55,9 @@ import { getRegistry, type PluginDescription, type PluginRegistry } from "@inkandswitch/patchwork-plugins"; import { accept, type SubscribeEvent } from "@inkandswitch/patchwork-providers"; -import { CHANNEL_SELECTOR } from "@grjte/patchwork-worker/connect.js"; +import { CHANNEL_SELECTOR, WORKER_PLUGIN_TYPE } from "@grjte/patchwork-worker/connect.js"; import { serveWorkerSpec, type WorkerSpec } from "@grjte/patchwork-worker/serve.js"; -// The plugin type is a bare string in @grjte/patchwork-worker's entry -// (`WORKER_PLUGIN_TYPE`); re-stated here so this module's graph does not pull -// the barrel in for one constant. -const WORKER_PLUGIN_TYPE = "patchwork:worker"; - /** * How long to wait for a registered worker plugin to LOAD before refusing. * @@ -77,7 +72,7 @@ const LOAD_TIMEOUT_MS = 10000; type WorkerChannelValue = { readable: ReadableStream; writable: WritableStream } | null; /** The selector a consumer dispatches (see connect.js `CHANNEL_SELECTOR`). */ -type WorkerChannelSelector = { type: string; kind?: string; request?: unknown }; +type WorkerChannelSelector = { type: string; kind?: string }; /** * Mount the worker provider on `element`. Returns a cleanup function @@ -108,7 +103,7 @@ export const WorkerProvider = (element: HTMLElement): (() => void) => { // its teardown, and the `{type:"change", value}` envelope. We only choose // the value. accept(event as SubscribeEvent, (respond) => { - void serve(kind, selector.request, respond); + void serve(kind, respond); }); }; @@ -119,7 +114,6 @@ export const WorkerProvider = (element: HTMLElement): (() => void) => { */ async function serve( kind: string, - request: unknown, respond: (value: WorkerChannelValue, transfer?: Transferable[]) => void ) { try { @@ -146,7 +140,7 @@ export const WorkerProvider = (element: HTMLElement): (() => void) => { // this provider knowing anything about that service. serveWorkerSpec owns // the streams, the per-connection worker, and the id demux; the spec owns // the service vocabulary. - const streams = serveWorkerSpec(spec, request, { element }); + const streams = serveWorkerSpec(spec, { element }); // The streams ride IN the value; naming them in the transfer list only // upgrades the structured clone to a move. Both are required — a From 7b4e4afde1cfc419c392d99b848b8d8b737e8427 Mon Sep 17 00:00:00 2001 From: chee Date: Fri, 2 Oct 2026 00:45:27 +0100 Subject: [PATCH 07/11] Apply suggestion from @chee --- providers/src/WorkerProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/src/WorkerProvider.ts b/providers/src/WorkerProvider.ts index 555f8dda..7f54866f 100644 --- a/providers/src/WorkerProvider.ts +++ b/providers/src/WorkerProvider.ts @@ -55,8 +55,8 @@ import { getRegistry, type PluginDescription, type PluginRegistry } from "@inkandswitch/patchwork-plugins"; import { accept, type SubscribeEvent } from "@inkandswitch/patchwork-providers"; -import { CHANNEL_SELECTOR, WORKER_PLUGIN_TYPE } from "@grjte/patchwork-worker/connect.js"; -import { serveWorkerSpec, type WorkerSpec } from "@grjte/patchwork-worker/serve.js"; +import { CHANNEL_SELECTOR, WORKER_PLUGIN_TYPE } from "@grjte/patchwork-worker/connect"; +import { serveWorkerSpec, type WorkerSpec } from "@grjte/patchwork-worker/serve"; /** * How long to wait for a registered worker plugin to LOAD before refusing. From 18f7522a6aa01f96bcf69a6b123b01188f9aa3ce Mon Sep 17 00:00:00 2001 From: grjte Date: Fri, 11 Sep 2026 15:08:31 +0100 Subject: [PATCH 08/11] feat: update llm library to enable use with patchwork-worker --- libraries/llm/index.js | 170 +++++++++++++++++++---------------- libraries/llm/pnpm-lock.yaml | 24 ++--- 2 files changed, 105 insertions(+), 89 deletions(-) diff --git a/libraries/llm/index.js b/libraries/llm/index.js index 1fd091d9..bbbb1d31 100644 --- a/libraries/llm/index.js +++ b/libraries/llm/index.js @@ -21,92 +21,108 @@ */ export { - // config (account doc + patchwork:llm-config provider) - readConfig, - writeConfig, - callConfig, - normalizeConfig, - subscribeConfig, - resolveConfig, - ensureSettingsDoc, - ensureConfig, - // per-tool / per-doc whole-config overrides - scopedRaw, - hasScopeOverride, - readScopedConfig, - writeScopeOverride, - clearScopeOverride, - settingsDocHandle, - applyPrompts, - effectiveSystem, - DEFAULTS, - PARAM_KEYS, - PROVIDER_CAPS, - TOOL_STORAGE_ID, - CONFIG_SELECTOR, - // catalogues / labels - LOCAL_MODELS, - WEBLLM_MODELS, - fetchOpenRouterModels, - fetchOllamaModels, - describeConfig, -} from "./config.js" + // config (account doc + patchwork:llm-config provider) + readConfig, + writeConfig, + callConfig, + normalizeConfig, + subscribeConfig, + resolveConfig, + ensureSettingsDoc, + ensureConfig, + // per-tool / per-doc whole-config overrides + scopedRaw, + hasScopeOverride, + readScopedConfig, + writeScopeOverride, + clearScopeOverride, + settingsDocHandle, + applyPrompts, + effectiveSystem, + DEFAULTS, + PARAM_KEYS, + PROVIDER_CAPS, + TOOL_STORAGE_ID, + CONFIG_SELECTOR, + // catalogues / labels + LOCAL_MODELS, + WEBLLM_MODELS, + fetchOpenRouterModels, + fetchOllamaModels, + describeConfig, +} from "./config.js"; export { - generate, - generateWithTools, - stream, - predict, - scoreTokens, - preload, - abort, - resume, - onStatus, - registerLocalModel, - computeImportance, - computeAttentionWeights, - extractFeatures, - extractCutFeatures, - decodeTokens, - probeAttention, -} from "./client.js" + generate, + generateWithTools, + stream, + predict, + scoreTokens, + preload, + abort, + resume, + onStatus, + registerLocalModel, + computeImportance, + computeAttentionWeights, + extractFeatures, + extractCutFeatures, + decodeTokens, + probeAttention, +} from "./client.js"; -export {dom, popup} from "./picker.js" +export { dom, popup } from "./picker.js"; -export {builtinSupported, builtinAvailability} from "./builtin.js" +export { + builtinSupported, + builtinAvailability, + builtinGenerate, +} from "./builtin.js"; + +// Construct this library's compute worker. This has to live in the library: +// `new URL("./worker.js", import.meta.url)` resolves against this module's own +// URL, so it finds the sibling worker.js wherever the library is served from, +// and bundlers only emit the worker chunk when they see this exact literal. +// +// import { createWorker } from "@chee/patchwork-llm"; +// const worker = createWorker(); +// worker.onmessage = (e) => console.log(e.data.models); +// worker.postMessage({ type: "list-local-models" }); +export const createWorker = () => + new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); // LLM tools (user-defined tools the model can be given) export { - createLLMTool, - createToolFile, - LLMToolDatatype, - sanitizeToolName, - resolveTools, - toToolSchemas, - buildToolsSystem, - parseToolCalls, - loadHandler, - runTool, - runHandlerSandboxed, - // saved prompts (system + pre), same doc shape as tools - createPromptDoc, - resolvePromptDocs, - resolvePromptText, - resolveCfgPrompts, - LLMSystemPromptDatatype, - LLMPrePromptDatatype, - // folders + one-time migration - ensureFolderUrl, - addToFolder, - removeFromFolder, - migrateConfig, -} from "./tools.js" + createLLMTool, + createToolFile, + LLMToolDatatype, + sanitizeToolName, + resolveTools, + toToolSchemas, + buildToolsSystem, + parseToolCalls, + loadHandler, + runTool, + runHandlerSandboxed, + // saved prompts (system + pre), same doc shape as tools + createPromptDoc, + resolvePromptDocs, + resolvePromptText, + resolveCfgPrompts, + LLMSystemPromptDatatype, + LLMPrePromptDatatype, + // folders + one-time migration + ensureFolderUrl, + addToFolder, + removeFromFolder, + migrateConfig, +} from "./tools.js"; // Registers on import. export { - PatchworkLLMConfigProvider, - definePatchworkLLMConfigProvider, -} from "./provider.js" + PatchworkLLMConfigProvider, + definePatchworkLLMConfigProvider, +} from "./provider.js"; // Built-in prompt templates -export {PROMPT_TEMPLATES} from "./templates.js" +export { PROMPT_TEMPLATES } from "./templates.js"; diff --git a/libraries/llm/pnpm-lock.yaml b/libraries/llm/pnpm-lock.yaml index dc251f69..fa0a70c3 100644 --- a/libraries/llm/pnpm-lock.yaml +++ b/libraries/llm/pnpm-lock.yaml @@ -24,8 +24,8 @@ packages: '@automerge/automerge-repo@2.5.6': resolution: {integrity: sha512-ZXM6TOAwm192g3+zIxYvlB+Z3O00NP+psErOwvbSype8fFO+dhc8tB/jPwfZJqmn1ULUz5w7gssKEynwcYFRSA==} - '@automerge/automerge@3.4.0': - resolution: {integrity: sha512-THmghtTNGGt2xsI0pM3o1i3PM8oZKcYFgOj25FOzW7l6e94SQOivNtCwy6xc0I8hVJsQSSotoBNs+yk/9hM2dg==} + '@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==} @@ -79,8 +79,8 @@ packages: resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} hasBin: true - cbor-x@1.6.5: - resolution: {integrity: sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig==} + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -118,25 +118,25 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - xstate@5.32.5: - resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==} + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} snapshots: '@automerge/automerge-repo@2.5.6': dependencies: - '@automerge/automerge': 3.4.0 + '@automerge/automerge': 3.4.1 bs58check: 3.0.1 - cbor-x: 1.6.5 + cbor-x: 1.6.6 debug: 4.4.3 eventemitter3: 5.0.4 fast-sha256: 1.3.0 uuid: 9.0.1 - xstate: 5.32.5 + xstate: 5.32.6 transitivePeerDependencies: - supports-color - '@automerge/automerge@3.4.0': {} + '@automerge/automerge@3.4.1': {} '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': optional: true @@ -185,7 +185,7 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64': 2.2.2 optional: true - cbor-x@1.6.5: + cbor-x@1.6.6: optionalDependencies: cbor-extract: 2.2.2 @@ -211,4 +211,4 @@ snapshots: uuid@9.0.1: {} - xstate@5.32.5: {} + xstate@5.32.6: {} From dfb3c7b8cf53516ee77c434a9562fe8078a62b51 Mon Sep 17 00:00:00 2001 From: grjte Date: Wed, 16 Sep 2026 11:25:19 +0100 Subject: [PATCH 09/11] refactor: llm library for use with patchwork-worker without dup --- libraries/llm/.gitignore | 1 + libraries/llm/README.md | 26 ++++++++ libraries/llm/client.js | 72 +++++--------------- libraries/llm/index.js | 9 +++ libraries/llm/package.json | 6 +- libraries/llm/request.js | 128 ++++++++++++++++++++++++++++++++++++ libraries/llm/tsconfig.json | 1 + 7 files changed, 186 insertions(+), 57 deletions(-) create mode 100644 libraries/llm/request.js diff --git a/libraries/llm/.gitignore b/libraries/llm/.gitignore index a7c47537..f372293c 100644 --- a/libraries/llm/.gitignore +++ b/libraries/llm/.gitignore @@ -1 +1,2 @@ types/ +.pushwork diff --git a/libraries/llm/README.md b/libraries/llm/README.md index 0e7a68cd..481aa0fb 100644 --- a/libraries/llm/README.md +++ b/libraries/llm/README.md @@ -102,6 +102,32 @@ Pass a stable `sessionKey` (e.g. a doc URL) to `generate`/`stream`; after a reload, `resume(sessionKey, { onToken, onDone })` re-attaches to the still-running stream in the worker. +### Request preparation (`request.js`) + +`generate()` is two halves: preparing the request and posting it to the worker. +The first half is exported so a host-side worker spec (one that serves this +library over a stream to sandboxed tools) prepares requests exactly the way +`generate()` does: + +```js +import { prepareGenerate, buildGeneratePayload } from "@patchwork/llm" + +const { cfg, config, extraSystem, builtin, input } = await prepareGenerate(cfg0, { + messages, // chat messages or a string + system, // extra system prompt, already provider-resolved + tools, // tool descriptors, already filtered + continuation, // treat a string as a raw continuation + overrides, // passed to callConfig verbatim +}) +if (builtin) { /* run builtinGenerate(input, { system: effectiveSystem(cfg, extraSystem), … }) */ } +else worker.postMessage(buildGeneratePayload(config, input, { id, sessionKey })) +``` + +Resolving a provider-conditional `system` map, filtering tools by the user's +`toolToggles`, and choosing which `overrides` to forward are the caller's job: +`callConfig` honours provider / apiKey / model / url overrides, so a host serving +untrusted tools passes only sampling knobs. + ## Events reference | event | fields | local | openrouter | ollama | diff --git a/libraries/llm/client.js b/libraries/llm/client.js index f9b40765..63962508 100644 --- a/libraries/llm/client.js +++ b/libraries/llm/client.js @@ -13,7 +13,8 @@ import {readConfig, ensureConfig, callConfig, applyPrompts, effectiveSystem} from "./config.js" import {builtinGenerate} from "./builtin.js" -import {resolveTools, toToolSchemas, buildToolsSystem, parseToolCalls, runTool, resolveCfgPrompts, sanitizeToolName} from "./tools.js" +import {resolveTools, buildToolsSystem, parseToolCalls, runTool, resolveCfgPrompts, sanitizeToolName} from "./tools.js" +import {prepareGenerate, buildGeneratePayload, NATIVE_TOOL_PROVIDERS, TEMPLATE_TOOL_PROVIDERS} from "./request.js" /** * Per-call options. A superset of every option any exported function accepts; @@ -66,16 +67,7 @@ import {resolveTools, toToolSchemas, buildToolsSystem, parseToolCalls, runTool, /** @typedef {{post: (m:any)=>void}} Connection */ -/** CallConfig plus the extra mutable fields the client tacks on per-call. - * @typedef {import("./config.js").CallConfig & {tools?:any, toolSystem?:string, continuation?:boolean}} CallConfigExt */ - -/** @typedef {{type:string, id:string, sessionKey:string, provider:import("./config.js").ProviderId, config:import("./config.js").CallConfig, text?:string, messages?:any}} GeneratePayload */ - -// Providers with real function-calling APIs (the worker passes tool schemas and -// parses structured tool_calls). Everything else (local transformers, Chrome -// built-in) uses the XML prompt convention, parsed from the text. -const NATIVE_TOOL_PROVIDERS = new Set(["openrouter", "ollama", "webllm"]) -const TEMPLATE_TOOL_PROVIDERS = new Set(["local"]) +/** @typedef {import("./request.js").CallConfigExt} CallConfigExt */ /** @type {Connection|null} */ let connection = null @@ -197,34 +189,21 @@ function getConnection() { */ export async function generate(messages, opts = {}) { const cfg0 = opts.config ?? (await ensureConfig(opts.scope)) - // Resolve the selected system/pre prompt docs → their text. repo.find is - // cached, so this is cheap after first load. - const cfg = await resolveCfgPrompts(cfg0) - /** @type {CallConfigExt} */ - const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ (opts)) - - // Tools: native providers get JSON schemas on `config.tools`; the rest get the - // XML convention prepended to the system prompt (parsed from text). - const hasTools = Array.isArray(opts.tools) && opts.tools.length > 0 - const native = hasTools && NATIVE_TOOL_PROVIDERS.has(config.provider) - const templated = hasTools && TEMPLATE_TOOL_PROVIDERS.has(config.provider) - if (native || templated) config.tools = toToolSchemas(opts.tools) - if (templated) config.toolSystem = buildToolsSystem(opts.tools) - const extraSystem = - hasTools && !native && !templated - ? [buildToolsSystem(opts.tools), opts.system].filter(Boolean).join("\n\n") - : opts.system + // Prompt resolution, CallConfig, the tools decision, the builtin detour and + // the chat-vs-continuation input shape all live in request.js, shared with + // host-side worker specs. The whole opts is passed as overrides: a same-realm + // caller legitimately owns provider/model/apiKey overrides. + const {cfg, config, extraSystem, builtin, input} = await prepareGenerate(cfg0, { + messages, + system: opts.system, + tools: opts.tools, + continuation: opts.continuation, + overrides: opts, + }) // Built-in (Chrome Prompt API) runs on the main thread, not the worker. - if (config.provider === "builtin") { - const pre = cfg.resolved?.pre || "" - const text = - typeof messages === "string" - ? pre - ? pre + "\n\n" + messages - : messages - : messages - return builtinGenerate(text, { + if (builtin) { + return builtinGenerate(input, { temperature: config.temperature, topK: config.topK, system: effectiveSystem(/** @type {any} */ (cfg), extraSystem), @@ -234,19 +213,6 @@ export async function generate(messages, opts = {}) { }).then((t) => ({text: t, toolCalls: null, stats: null})) } - // A string input is CHAT by default — wrapped as a user turn, so instruct/chat - // models respond normally and the system prompt applies. It's a raw - // CONTINUATION only when opts.continuation is set: raw-fed for - // local/webllm/ollama, and CONTINUE_SYS-framed for chat-only OpenRouter (see - // the worker). Loom passes continuation:true; other callers get plain chat. - const asContinuation = !!opts.continuation && typeof messages === "string" - const prepared = asContinuation - ? messages - : typeof messages === "string" - ? [{role: "user", content: messages}] - : messages - // Prepend the configured system + pre-prompt (and any tool-supplied system). - const input = applyPrompts(prepared, /** @type {any} */ (cfg), extraSystem) const conn = getConnection() const id = nextId() const sessionKey = opts.sessionKey || id @@ -297,11 +263,7 @@ export async function generate(messages, opts = {}) { opts.signal.addEventListener("abort", onAbort) } // A string is a raw continuation prompt; an array is chat messages. - /** @type {GeneratePayload} */ - const payload = {type: "generate", id, sessionKey, provider: config.provider, config} - if (typeof input === "string") payload.text = input - else payload.messages = input - conn.post(payload) + conn.post(buildGeneratePayload(config, input, {id, sessionKey})) }) } diff --git a/libraries/llm/index.js b/libraries/llm/index.js index bbbb1d31..4cead657 100644 --- a/libraries/llm/index.js +++ b/libraries/llm/index.js @@ -91,6 +91,15 @@ export { export const createWorker = () => new Worker(new URL("./worker.js", import.meta.url), { type: "module" }); +// Transport-neutral request preparation, shared by generate() and by +// host-side worker specs that serve this library over a stream. +export { + prepareGenerate, + buildGeneratePayload, + NATIVE_TOOL_PROVIDERS, + TEMPLATE_TOOL_PROVIDERS, +} from "./request.js"; + // LLM tools (user-defined tools the model can be given) export { createLLMTool, diff --git a/libraries/llm/package.json b/libraries/llm/package.json index 9b2a9e08..84776ea7 100644 --- a/libraries/llm/package.json +++ b/libraries/llm/package.json @@ -1,6 +1,6 @@ { "name": "@chee/patchwork-llm", - "version": "0.2.1", + "version": "0.3.0", "description": "LLM toolkit for Patchwork tools: a model picker, a refresh-surviving SharedWorker that runs local (transformers.js) / OpenRouter / Ollama generation, and a streaming API that carries rich telemetry — next-token predictions, temperature, tokens/sec — alongside the text so you can build UIs that show how the model thinks.", "type": "module", "main": "index.js", @@ -17,13 +17,15 @@ }, "scripts": { "build": "pnpm build:types", - "build:types": "tsc" + "build:types": "tsc", + "push": "pnpm build && pushwork sync" }, "files": [ "types", "index.js", "config.js", "client.js", + "request.js", "worker.js", "picker.js", "tools.js", diff --git a/libraries/llm/request.js b/libraries/llm/request.js new file mode 100644 index 00000000..5343843b --- /dev/null +++ b/libraries/llm/request.js @@ -0,0 +1,128 @@ +/** + * Request preparation — the transport-neutral half of a `generate` call. + * + * Everything between "here is a resolved config and what the caller wants" and + * "here is the exact payload the worker understands" lives here, once: prompt + * resolution, the flat CallConfig, the native-vs-templated tool decision, the + * built-in (Chrome Prompt API) detour, and the chat-vs-continuation input shape. + * + * Two callers share it and must stay in step: + * - client.js `generate()` — same realm, posts straight to this library's worker. + * - a host-side worker spec (e.g. llm-host's worker-spec.js) — receives request + * frames over a stream and posts to the worker it runs. + * + * What is deliberately NOT here, because it is caller policy rather than + * preparation: resolving a provider-conditional `system` map, filtering tools by + * the user's `toolToggles`, and deciding which `overrides` reach `callConfig` + * (client.js passes the whole opts; a host must pass only sampling knobs, since + * callConfig also honours provider/apiKey/model/url). + */ + +import {callConfig, applyPrompts} from "./config.js" +import {toToolSchemas, buildToolsSystem, resolveCfgPrompts} from "./tools.js" + +/** + * CallConfig plus the extra mutable fields tacked on per call. + * @typedef {import("./config.js").CallConfig & {tools?:any, toolSystem?:string, continuation?:boolean}} CallConfigExt + */ + +/** + * The `generate` message the worker accepts. A string `text` is a raw + * continuation prompt; `messages` is chat. + * @typedef {{type:string, id:string, sessionKey:string, provider:import("./config.js").ProviderId, config:import("./config.js").CallConfig, text?:string, messages?:any}} GeneratePayload + */ + +// Providers with real function-calling APIs (the worker passes tool schemas and +// parses structured tool_calls). Everything else (local transformers, Chrome +// built-in) uses the XML prompt convention, parsed from the text. +export const NATIVE_TOOL_PROVIDERS = new Set(["openrouter", "ollama", "webllm"]) +export const TEMPLATE_TOOL_PROVIDERS = new Set(["local"]) + +/** + * @typedef {Object} PrepareOpts + * @property {any[]|string} messages chat messages, or a string + * @property {string} [system] extra system prompt, already provider-resolved + * @property {any[]} [tools] tool descriptors, already filtered + * @property {boolean} [continuation] treat a string input as a raw continuation + * @property {any} [overrides] passed to callConfig verbatim (see header) + * + * @typedef {Object} PreparedGenerate + * @property {any} cfg config with prompt docs resolved (`cfg.resolved`) + * @property {CallConfigExt} config the flat per-call config the worker receives + * @property {string|undefined} extraSystem + * @property {boolean} builtin provider is "builtin": run builtinGenerate(input, …) + * in-realm instead of posting to the worker + * @property {any} input builtin: the pre-joined text/messages; + * otherwise applyPrompts()'d text or messages + */ + +/** + * Prepare a generate request from an unresolved config and the caller's intent. + * + * @param {any} cfg0 an LLMConfig (e.g. from ensureConfig), prompts not yet resolved + * @param {PrepareOpts} opts + * @returns {Promise} + */ +export async function prepareGenerate(cfg0, {messages, system, tools, continuation, overrides = {}}) { + // Resolve the selected system/pre prompt docs → their text. repo.find is + // cached, so this is cheap after first load. + const cfg = await resolveCfgPrompts(cfg0) + /** @type {CallConfigExt} */ + const config = callConfig(/** @type {any} */ (cfg), /** @type {any} */ (overrides)) + + // Tools: native providers get JSON schemas on `config.tools`; the rest get the + // XML convention prepended to the system prompt (parsed from text). + const hasTools = Array.isArray(tools) && tools.length > 0 + const native = hasTools && NATIVE_TOOL_PROVIDERS.has(config.provider) + const templated = hasTools && TEMPLATE_TOOL_PROVIDERS.has(config.provider) + if (native || templated) config.tools = toToolSchemas(tools) + if (templated) config.toolSystem = buildToolsSystem(tools) + const extraSystem = + hasTools && !native && !templated + ? [buildToolsSystem(tools), system].filter(Boolean).join("\n\n") + : system + + // Built-in (Chrome Prompt API) runs in the caller's realm, not the worker. + // The caller runs builtinGenerate(input, {system: effectiveSystem(cfg, + // extraSystem), …}) with its own callbacks. + if (config.provider === "builtin") { + const pre = cfg.resolved?.pre || "" + const input = + typeof messages === "string" + ? pre + ? pre + "\n\n" + messages + : messages + : messages + return {cfg, config, extraSystem, builtin: true, input} + } + + // A string input is CHAT by default — wrapped as a user turn, so instruct/chat + // models respond normally and the system prompt applies. It's a raw + // CONTINUATION only when asked: raw-fed for local/webllm/ollama, and + // CONTINUE_SYS-framed for chat-only OpenRouter (see the worker). + const asContinuation = !!continuation && typeof messages === "string" + const prepared = asContinuation + ? messages + : typeof messages === "string" + ? [{role: "user", content: messages}] + : messages + // Prepend the configured system + pre-prompt (and any tool-supplied system). + const input = applyPrompts(prepared, /** @type {any} */ (cfg), extraSystem) + return {cfg, config, extraSystem, builtin: false, input} +} + +/** + * The `generate` message for the worker, from a prepared request. + * + * @param {CallConfigExt} config + * @param {any} input string (raw continuation) or chat messages + * @param {{id: string, sessionKey: string}} ids + * @returns {GeneratePayload} + */ +export function buildGeneratePayload(config, input, {id, sessionKey}) { + /** @type {GeneratePayload} */ + const payload = {type: "generate", id, sessionKey, provider: config.provider, config} + if (typeof input === "string") payload.text = input + else payload.messages = input + return payload +} diff --git a/libraries/llm/tsconfig.json b/libraries/llm/tsconfig.json index 5dc1f2d0..1f5e752a 100644 --- a/libraries/llm/tsconfig.json +++ b/libraries/llm/tsconfig.json @@ -15,6 +15,7 @@ "index.js", "config.js", "client.js", + "request.js", "worker.js", "picker.js", "tools.js", From d44392ed195c107460c943bc788d67fb26a49545 Mon Sep 17 00:00:00 2001 From: grjte Date: Wed, 16 Sep 2026 11:31:18 +0100 Subject: [PATCH 10/11] feat: add llm-host to manage llm worker provider/client + llm config (moved from chat) --- llm-host/.gitignore | 3 + llm-host/README.md | 361 ++++++ llm-host/client.js | 301 +++++ llm-host/package.json | 34 + llm-host/pnpm-lock.yaml | 2072 ++++++++++++++++++++++++++++++ llm-host/pnpm-workspace.yaml | 11 + llm-host/src/LlmConfigTray.tsx | 216 ++++ llm-host/src/client.js | 106 ++ llm-host/src/client.test.js | 112 ++ llm-host/src/index.ts | 81 ++ llm-host/src/worker-spec.js | 377 ++++++ llm-host/src/worker-spec.test.js | 143 +++ llm-host/tsconfig.json | 15 + llm-host/vite.config.ts | 26 + llm-host/vitest.config.ts | 14 + 15 files changed, 3872 insertions(+) create mode 100644 llm-host/.gitignore create mode 100644 llm-host/README.md create mode 100644 llm-host/client.js create mode 100644 llm-host/package.json create mode 100644 llm-host/pnpm-lock.yaml create mode 100644 llm-host/pnpm-workspace.yaml create mode 100644 llm-host/src/LlmConfigTray.tsx create mode 100644 llm-host/src/client.js create mode 100644 llm-host/src/client.test.js create mode 100644 llm-host/src/index.ts create mode 100644 llm-host/src/worker-spec.js create mode 100644 llm-host/src/worker-spec.test.js create mode 100644 llm-host/tsconfig.json create mode 100644 llm-host/vite.config.ts create mode 100644 llm-host/vitest.config.ts diff --git a/llm-host/.gitignore b/llm-host/.gitignore new file mode 100644 index 00000000..96f32a31 --- /dev/null +++ b/llm-host/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.pushwork diff --git a/llm-host/README.md b/llm-host/README.md new file mode 100644 index 00000000..1903ca3c --- /dev/null +++ b/llm-host/README.md @@ -0,0 +1,361 @@ +# @grjte/llm-host + +The **host-realm LLM package**. It is the Patchwork side of the LLM: everything +that must run in a real-origin host realm because it touches the settings doc and +the API key. It registers two plugins: + +- **`llm-config-tray`** (`patchwork:component`, tag `system-tray`) — the settings + doc + the model/config picker UI, so no other tool needs to read or write LLM + config. Renders a small icon in the frame's system tray; clicking it, or a + `patchwork:open-tool` event, opens the picker in a popover. +- **`llm`** (`patchwork:worker`) — the LLM worker itself. Its `load()` resolves a + **WorkerSpec** (via `makeLLMWorkerSpec`) that `@grjte/patchwork-worker` serves, so + a consumer reaches it by `connectWorker("llm", …)` — in or out of isolation. + +The design goal, and the reason this package exists as host-realm code: the LLM +worker (and the config + API key it needs) runs in the **host realm**, while a +consumer tool like chat holds only streams — the same code whether or not it runs +inside a sandboxed iframe. + +This README covers both the config tray (below) and the full LLM architecture (from +"Three layers" onward). The **generic** worker machinery it rides on — the +`WorkerSpec` contract, `connectWorker` / `serveWorkerSpec`, discovery and the +transferable-stream handoff, one-worker-per-connection — is documented in +[`../libraries/patchwork-worker/README.md`](../libraries/patchwork-worker/README.md). This package adds +the LLM-specific parts: the op vocabulary, the config/secret trust boundary, and the +config tray. + +--- + +## The config tray (`llm-config-tray`) + +### What it owns + +- **The settings doc.** LLM config lives in a dedicated Automerge document whose + body *is* the config (`provider`, per-provider `model`/`apiKey`/`url`, sampling + params, `toolToggles`, prompt-doc refs, and per-tool/per-doc overrides). The + account doc only holds a *pointer* to it (`accountDoc.toolStorage["llm"]`), + resolved via the `patchwork:tool-storage` provider. The only secret is + `openrouter.apiKey`, stored here and nowhere else. +- **The picker UI.** It reuses `@chee/patchwork-llm`'s `dom()` picker, driven + through the picker's injectable `source` `{read, write}` — backed by + `readScopedConfig` / `writeScopeOverride` / `writeConfig`. So this tool is the + sole writer of the settings doc; the picker never touches the doc singletons + directly. + +### How it's opened + +- **Sidebar icon:** the tray button toggles the popover. +- **`patchwork:open-tool` event:** any tool can dispatch + `new CustomEvent("patchwork:open-tool", { detail: { component: "llm-config-tray", + scope, toolPrompt, toolTools }, bubbles: true, composed: true })`. The tray has a + document-level listener that opens the picker for that `scope` and surfaces the + caller's `toolPrompt` / `toolTools` (tools tagged `defaultOff` render unchecked). + This is how chat's `/model` opens it — and, when chat runs inside the isolation + sandbox, the isolation *open-tool bridge* relays the event to the host so this + host-realm tool opens there. + +### Why host-only + +The settings doc (with the API key) is denylisted from the isolation sandbox, and +resolving it needs `window.repo` + a mounted ``. So config +management can only run in the host realm. This tool is mounted by the frame as host +chrome; consumer tools stay config-free and reach it via the event above. + +--- + +## The packages + +``` + @grjte/patchwork-worker the generic transport (connect / session / serve halves), a + plain library. Knows nothing about LLMs. See its README. The + host-realm provider that drives it (patchwork-worker-provider) + ships from the `providers` package. The LLM uses it unchanged: + it registers a WorkerSpec and connects by the id "llm". + + @chee/patchwork-llm the LLM library. Owns worker.js (transformers.js / OpenRouter / + Ollama), config resolution over the settings doc (config.js: + ensureConfig, callConfig, the apiKey), the picker UI, tool + parsing, request preparation (request.js: prepareGenerate + + buildGeneratePayload, shared by its own generate() and by + worker-spec.js below, so the two paths cannot diverge), and + `createWorker()` — the ONE worker-transport + concern that must live here, because + `new URL("./worker.js", import.meta.url)` has to resolve + against this library's own (cross-origin) URL, and bundlers + only emit the worker chunk when they see that exact literal. + NOTE: this is a Patchwork-coupled library, not a plain npm + one — it imports @inkandswitch/patchwork-{elements,providers}, + reads the account/settings docs, and ships a + element. It has no `plugins` + array, so importing it registers nothing; llm-host is what + makes it a Patchwork worker. + + @grjte/llm-host THIS package — the host-realm glue. Registers THREE plugins: + llm-config-tray (component), llm (patchwork:worker) and llm + (patchwork:worker-client). Owns BOTH halves of the LLM + protocol: src/worker-spec.js (serve half: host policy — which + overrides a consumer may set, system maps, toolToggles, + host-side parsing — over the library's request + preparation) and src/client.js (consume half: makeLLMClient, + pure, delivered to consumers THROUGH THE REGISTRY as a lazy + dist chunk). The library is imported lazily, so nothing + LLM-specific loads until first connect. + + chat (@patchwork/chat*) the CONSUMER. Imports only the transport + (@grjte/patchwork-worker, a library); has NO dependency on + this package. Calls connectWorkerClient("llm") and receives + makeLLMClient's {generate} at runtime; runs no worker; opens + config via event. Reads ZERO config. + + @patchwork/isolation the sandbox. When chat runs inside it, three relays cross the + boundary: worker-channel (streams), open-tool (config picker), + and the registry source-rewrite (so tool code + its automerge + deps load). None of it is LLM-specific. + + threepane the host frame. Mounts patchwork-worker-provider + the config + tray; sets shared-providers on the isolation element. +``` + +## Three layers, each kind-agnostic below the one above + +``` + layer package serve half consume half + ----- ------- ---------- ------------ + transport patchwork-worker serveWorkerSpec openSession / connectWorkerClient + protocol llm-host src/worker-spec.js src/client.js (makeLLMClient) + compute @chee/patchwork-llm worker.js createWorker +``` + +The transport knows nothing about the LLM's op vocabulary; the protocol layer knows +nothing about streams or ids. Both halves of the **LLM protocol** live in this +package and are registered as a PAIR of plugins with the same id, `"llm"`: +`patchwork:worker` resolves to the WorkerSpec (`src/worker-spec.js`, served by the +host provider) and `patchwork:worker-client` resolves to `makeLLMClient` +(`src/client.js`, a factory `(session) => {generate}`). The transport defines that +convention and does the lookup: a tool calls `connectWorkerClient("llm", …)`, which +finds the client plugin in the registry, opens a session for `"llm"`, and returns +`makeLLMClient(session)`. The tool never names this package at build time; the two +halves ship in one doc and cannot drift. `src/client.js` is PURE (imports nothing) +and must stay so — it is the chunk a sandboxed tool loads. A second worker kind +(e.g. transcription) would register its own pair and reuse the transport unchanged. + +## Config ownership — the LLM trust boundary + +This is the part the generic transport can't provide, and the reason llm-host exists +as a host-realm package. + +``` + CONFIG OWNERSHIP CONFIG RESOLUTION + WORKER CONSUMER + (llm-host, host) (llm-host worker-spec, host) (chat) + ------------------ --------------------------- -------- + settings doc ensureConfig(scope) -> CallConfig names a scope + picker UI (config tray) drives worker.js via WorkerSpec writes request frames + apiKey lives here apiKey injected here, never out reads event streams + reads ZERO config +``` + +The consumer never touches config. Everything config-derived is either resolved +host-side from the `scope` chat sends, or rides back on the generation stream (e.g. +the model label). So chat imports nothing from the LLM library, and no secret is +ever reachable from the sandbox. + +`worker-spec.js`'s `resolveForFrame` is where this is enforced: config is resolved +from the frame's `scope` alone; a consumer-supplied `frame.config` is ignored (it +would let a tool pick the provider AND supply its own key, defeating host-side +resolution). The apiKey enters the worker call host-side via `callConfig` and is +never placed in a request or event frame. + +## The LLM op vocabulary + +The transport treats frames as opaque (it reads only `id` and the reserved +`op:"abort"`). The LLM defines this vocabulary on top: + +``` + request (consumer -> worker): {id, op:"generate"|"predict"|"preload"| + "score-tokens"|... , scope, system, tools, + messages|text, sessionKey, ...sampling knobs} + events (worker -> consumer): {id, type:"token"|"stats"|"prediction"|"status"| + "model"|"result"|"error", ...} + result / error are terminal; abort = readable.cancel() +``` + +`worker-spec.js`'s `handle` switches on `op`: `generate` (incl. a `builtin` branch +for the Chrome Prompt API, which runs in-realm with no worker), `predict`, the +analytical ops (`score-tokens`, `compute-importance`, …), and `preload`. The consume +half (`client.js`) handles `token` / `status` / `model` progress plus the `result` / +`error` terminals. These two lists are maintained by hand and must stay in step — a +mismatch is silent (a frame type the serve half emits that the consume half doesn't +handle is simply ignored). + +## The two topologies + +The generic mechanics of each — who answers the subscription, how streams are +transferred within a realm vs. across the boundary — are in the worker README. What +follows is only the LLM-specific detail. + +### Topology A — isolation OFF (chat runs in the host realm) + +Everything is one realm. chat calls `connectWorkerClient("llm")`: the host registry +resolves the `patchwork:worker-client` plugin (this package's entry, then the +`client-*.js` chunk), and the returned client opens a session on first generate. The +`patchwork-worker-provider` (an ancestor in the frame) answers directly and hands chat +the streams by transfer. The worker runs right there, in the host origin (model +cache, WebGPU, `fetch`). + +``` + chat worker-provider llm-host worker-spec worker.js + ---- --------------- ------------------- --------- + connect ──────► answer subscribe ───────► serveWorkerSpec(spec) + spec.open: ensureConfig ──► (reads settings doc) + (settings warm) + ◄── {readable,writable} (transfer) ── + write {op:generate,scope,system,tools,messages} ─────► spec.handle: callConfig(+apiKey) + post to worker ─────────► token / stats / model / + read {type:model,label} ◄── result / error + read {type:token,...} ◄── (stream) + read {type:result,...} ◄── (terminal) +``` + +Opening the config picker: chat dispatches `patchwork:open-tool +{component:"llm-config-tray", scope, ...}`; the tray's document listener opens the +picker. No bridge involved. + +### Topology B — isolation ON (chat runs inside the sandboxed iframe) + +Same chat code, same `connectWorkerClient("llm")` call. The plugin registry is +mirrored into the iframe (every registry type, ungated — entries arrive with the boot +snapshot and as live `plugin-registered` pushes, and `loadWhenReady` waits for a late +one), so the lookup resolves in-sandbox: the iframe module loader fetches +`registry--@grjte--llm-host/dist/index.js` (descriptors only) and then the +`client-*.js` chunk — the ONLY llm-host code that enters the sandbox. The worker +can't run in the opaque-origin iframe (no shared model cache; the settings doc / API +key are denylisted), so the host answers and the streams are transferred **across +the boundary**. The three relays are generic (see ISOLATION.md); the LLM-specific +facts are: + +- **Why the host must answer.** The two reasons are both LLM-shaped: the model cache + / WebGPU device live in the host origin, and the settings doc + API key are + host-only. A worker kind with neither constraint could in principle run + in-boundary — the LLM cannot. +- **What crosses:** only the stream pair (plus the small setup messages). After the + handoff, tokens flow iframe⇄host-worker directly over the transferred port; the + bridge is involved only in *setup*. The API key never crosses — it is applied + host-side inside `worker-spec.js`. +- **Opt-in:** `patchwork:worker-channel` in `shared-providers` enables the worker + handoff, and `llm-config-tray` in `shared-tools` enables the open-tool relay for + the picker. Without the first, the session's subscription is never claimed, + `connectWorker` resolves null after its discovery backstop, and chat degrades. + +``` + chat (iframe) iframe providers-bridge host providers-bridge worker-provider / spec + ------------- ----------------------- --------------------- ---------------------- + connect ─────────► capture unclaimed sub + (subscribe) RPC {providers-bridge, ──────────────────────► gate selector.type ∈ + id, selector} shared-providers + re-dispatch subscribe on host + ◄ provider serves spec: + ensureConfig(scope)+worker + ◄ {type:"change", value:{readable,writable}} + (transfer, host port) + ◄ RPC {providers-bridge-change, + id, value} (TRANSFER across boundary) + ◄ value handed to chat's session + write {op:generate,...} ─────────────────────────────────────────► worker.js (host origin) + read token/model/result ◄──────────────────── (streams flow iframe⇄host directly now) +``` + +Opening the config picker (isolation on): chat dispatches the same +`patchwork:open-tool`; the iframe forwards it over RPC, the host open-tool bridge +gates it on `component ∈ ALLOWED_OPEN_TOOLS` (`["llm-config-tray"]`), and +re-dispatches on the host element so the tray opens **in the host realm**. The picker +— with the settings doc + API key — always runs in the host; the iframe only +*triggers* it. + +## Why @chee/patchwork-llm is NOT loaded in the iframe + +chat imports only the transport (`@grjte/patchwork-worker/client.js`, a library +subpath baked by `@chee/patchwork-bundles`). What it then loads from THIS package — +through the registry, not an import — is `dist/index.js` (three plugin descriptors, +no top-level imports) and the `client-*.js` chunk (imports nothing). Neither pulls +in the LLM library. `patchwork-llm-*.js` and `worker-spec-*.js` are reachable only +from the `patchwork:worker` plugin's `load()`, which only the host-realm provider +ever calls. So the transformers.js runtime, the config code, and the API key never +enter the sandbox graph. Two invariants keep it that way: `src/client.js` must never +import `@chee/patchwork-llm` or `./worker-spec.js` (vite would pull the library chunk +into the client chunk's graph), and `src/index.ts` must keep everything behind +`load()`. + +(The isolation registry marker is per package, so a sandboxed tool *could* fetch the +other chunks deliberately — as it could the old raw subpath. Nothing secret is in +the served files: the API key lives in the settings doc, which is denylisted.) + +(How tool code and its automerge deps load in the iframe at all — the `registry--` +marker rewrite of the baked `automerge:` dep — is generic isolation machinery; see +ISOLATION.md, "Package registry in iframe".) + +## Data flow + security summary + +- **API key never leaves the host.** Stored in the settings doc (host-only, + denylisted from the iframe), read only by `worker-spec.js` during `callConfig`, and + injected into the worker call host-side. Never in a request frame (chat sends only + `scope`) nor in any event frame. +- **Config is host-resolved from `scope`.** chat sends `{toolId, docId}`; the host + reads the real provider/model/prompts/toolToggles. chat reads no config; the model + *label* comes back as a `{type:"model"}` event frame. +- **Only streams cross the boundary** (plus the small setup messages). After handoff, + tokens flow iframe⇄host-worker directly over the transferred port. +- **Two opt-ins gate the bridges:** `shared-providers` ∩ `ALLOWED_PROVIDERS` + (includes `patchwork:worker-channel`) and `shared-tools` ∩ `ALLOWED_OPEN_TOOLS` + (open-tool). Neither is on by default. +- **isolation stays generic** — it relays worker-channel, open-tool, and the registry + source-rewrite; it imports nothing LLM-specific. + +## Is this the pattern for other workers? + +Yes, in this shape: **one package registers a `patchwork:worker` / `patchwork:worker-client` +pair under one id.** The serve half is a WorkerSpec; the consume half is a factory +`(session) => api`. Consumers call `connectWorkerClient(kind)` and never import the +package. The transport and the isolation bridges are untouched by a new kind. + +The **library + `-host` split is specific to the LLM**, not part of the pattern. It +exists because `@chee/patchwork-llm` predates the transport and is also used directly +(same realm) by other tools, so its request preparation is shared rather than moved. +A worker built transport-first keeps compute, spec and client in one package. + +## File map + +``` + @grjte/patchwork-worker ............. the generic transport library (see its README) + providers/src/WorkerProvider.ts ..... the host-realm provider that drives it + @chee/patchwork-llm worker.js ...... the compute worker; createWorker() constructs it + @chee/patchwork-llm config.js ...... settings doc + config resolution (ensureConfig, + callConfig, the apiKey), + picker.js (the picker UI) + @chee/patchwork-llm request.js ..... prepareGenerate + buildGeneratePayload: request + preparation shared by generate() and worker-spec + @grjte/patchwork-worker client.js ... connectWorkerClient(kind): registry lookup of the + paired worker-client plugin + openSession(kind) + llm-host/src/worker-spec.js ......... makeLLMWorkerSpec: host policy (sampling-only + overrides, system maps, toolToggles, host-side + parsing) over the library's request + preparation — the SERVE half of the protocol + llm-host/src/client.js .............. makeLLMClient(session) → {generate} — the CONSUME + half (pure; a lazy dist chunk resolved via the + patchwork:worker-client plugin) + llm-host/src/index.ts ............... registers llm-config-tray + the "llm" worker and + worker-client plugins + llm-host/src/LlmConfigTray.tsx ...... host config tray: picker + patchwork:open-tool listener + chat/src/components/ChatRoot.tsx .... connectWorkerClient("llm") → client.generate(); + /model dispatches patchwork:open-tool + isolation/src/bridges/providers-bridge.ts host + iframe halves of the provider relay, + incl. worker-channel and its transferred streams + isolation/src/bridges/open-tool-bridge.ts host + iframe halves of the open-tool relay + isolation/src/bridges/registry-bridge.ts marker mapping + served-source dep rewrite + threepane/src/PatchworkFrame.tsx .... mounts worker-provider + config tray + threepane/src/components/IsolatedDocumentArea.tsx sets shared-providers/shared-tools +``` + +## Build / sync + +Bundled with vite + `@chee/patchwork-bundles` (the `automerge:`→cross-origin rewrite +for its `@chee/patchwork-llm` dep). `pnpm build`, then `pushwork sync`. diff --git a/llm-host/client.js b/llm-host/client.js new file mode 100644 index 00000000..f4f4cb4e --- /dev/null +++ b/llm-host/client.js @@ -0,0 +1,301 @@ +/** + * The LLM consume-half — the mirror of worker-spec.js. + * + * worker-spec.js (the SERVE half) turns a consumer's request frames into worker + * traffic; this file (the CONSUME half) turns a tool's `generate(...)` call into + * those request frames and reads the event stream back. Together they are the LLM + * *protocol* layer, sitting above the generic transport (@grjte/patchwork-worker) + * and below any tool that wants to generate. + * + * ⚠ PURE by design — this file imports NOTHING. The transport is injected: a tool + * opens a session with @grjte/patchwork-worker's `openSession` and passes it to + * `makeGenerate`. That keeps this file a raw sandbox-safe subpath (like the + * transport's connect.js) with no cross-package import to bake, and it mirrors the + * transport's own shape — `createOpenSession(connectWorker)` injects its primitive + * the same way. The tool owns the CONNECTION (which worker kind, when to open); + * this file owns only the PROTOCOL. + * + * Frame vocabulary (must stay in step with worker-spec.js): + * request: {id, op:"generate", scope, system, tools, messages|text, sessionKey} + * events: {id, type:"token"|"stats"|"prediction"|"status"|"model"| + * "result"|"error", ...} result/error terminal. + */ + +/** + * @typedef {Object} GenOpts + * @property {any} [scope] {toolId, docId} — the host resolves config for this scope + * @property {string|Record} [system] string | {default, [provider]} + * @property {any[]} [tools] tool descriptors {name, description, defaultOff?, parameters?} + * @property {string} [sessionKey] + * @property {HTMLElement} [element] a node in the mounted , for discovery + * @property {AbortSignal} [signal] + * @property {(delta:string, full:string)=>void} [onToken] + * @property {(message:string)=>void} [onStatus] + * @property {(label:string)=>void} [onModel] + * + * @typedef {Object} GenResult + * @property {string} text + * @property {any[]|null} toolCalls + * @property {string} [toolMode] + */ + +/** + * Build a `generate` bound to an already-open transport session. The tool opens + * the session (`openSession("llm", {...})` from @grjte/patchwork-worker) and hands + * it in; this file only ever calls `session.request(...)`. + * + * @param {{request: (frame:any, opts:any)=>{promise:Promise}}} session + */ +export function makeGenerate(session) { + /** + * Generate a completion. Streams tokens via opts.onToken; resolves with the + * final text + any structured tool calls. Tool EXECUTION stays in the tool — + * the worker only receives tool descriptors and returns calls to run. + * @param {any[]|string} messages + * @param {GenOpts} [opts] + * @returns {Promise} + */ + return function generate(messages, opts = {}) { + const payload = typeof messages === "string" ? {text: messages} : {messages} + + const {promise} = session.request( + { + op: "generate", + sessionKey: opts.sessionKey, + scope: opts.scope, + system: opts.system, + tools: opts.tools, + ...payload, + }, + { + element: opts.element, + signal: opts.signal, + terminal: { + result: (/** @type {any} */ f) => ({ + text: f.text, + toolCalls: f.toolCalls || null, + toolMode: f.toolMode, + }), + error: (/** @type {any} */ f) => { + throw new Error(f.message) + }, + }, + // prediction / stats frames are ignored by the caller unless it asks. + onFrame: (/** @type {any} */ f) => { + if (f.type === "token") opts.onToken?.(f.delta, f.text) + else if (f.type === "status") opts.onStatus?.(f.message) + else if (f.type === "model") opts.onModel?.(f.label) + }, + } + ) + + return /** @type {Promise} */ (promise) + } +} + +/** + * @typedef {{name: string, args: Record}} ToolCall + */ + +/** + * Extract tool calls from a model's text output, for providers without native + * function calling (local / template). Handles LFM bracket syntax, `` + * XML, and fenced/bare JSON. Pure text — copied from @chee/patchwork-llm's + * tools.js (the repo's standalone-package rule forbids linking, so a copy is the + * sanctioned answer; it lives here, in the protocol layer, once for every LLM + * consumer instead of once per tool). + * + * @param {string} text + * @returns {ToolCall[]} + */ +export function parseToolCalls(text) { + if (!text) return [] + /** @type {ToolCall[]} */ + const calls = [] + const push = (/** @type {any} */ obj) => { + const name = obj?.name || obj?.tool + if (!name) return + let args = obj.arguments ?? obj.args ?? {} + if (typeof args === "string") { + try { + args = JSON.parse(args) + } catch { + args = {} + } + } + calls.push({name, args: args || {}}) + } + const thinkingEnd = text.lastIndexOf("") + const lfmRegionStart = thinkingEnd === -1 ? 0 : thinkingEnd + "".length + const lfmMatch = text + .slice(lfmRegionStart) + .search(/\[\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(/) + const lfmStart = lfmMatch === -1 ? -1 : lfmRegionStart + lfmMatch + if (lfmStart !== -1) { + let depth = 0 + let quote = "" + let escaped = false + let lfmEnd = -1 + for (let i = lfmStart; i < text.length; i++) { + const ch = text[i] + if (escaped) { + escaped = false + continue + } + if (quote) { + if (ch === "\\") escaped = true + else if (ch === quote) quote = "" + continue + } + if (ch === "'" || ch === '"') { + quote = ch + continue + } + if (ch === "[" || ch === "(" || ch === "{") depth++ + else if (ch === "]" || ch === ")" || ch === "}") { + depth-- + if (depth === 0) { + lfmEnd = i + break + } + } + } + if (lfmEnd !== -1) { + const split = (source = "") => { + const parts = [] + let start = 0 + let nested = 0 + let string = "" + let slash = false + for (let i = 0; i < source.length; i++) { + const ch = source[i] + if (slash) { + slash = false + continue + } + if (string) { + if (ch === "\\") slash = true + else if (ch === string) string = "" + continue + } + if (ch === "'" || ch === '"') string = ch + else if (ch === "[" || ch === "(" || ch === "{") nested++ + else if (ch === "]" || ch === ")" || ch === "}") nested-- + else if (ch === "," && nested === 0) { + parts.push(source.slice(start, i).trim()) + start = i + 1 + } + } + const last = source.slice(start).trim() + if (last) parts.push(last) + return parts + } + const literal = (source = "") => { + let json = "" + for (let i = 0; i < source.length; i++) { + const ch = source[i] + if (ch !== "'" && ch !== '"') { + json += ch + continue + } + let value = "" + for (i++; i < source.length; i++) { + const next = source[i] + if (next === ch) break + if (next !== "\\") { + value += next + continue + } + const escaped = source[++i] + value += + escaped === "n" + ? "\n" + : escaped === "r" + ? "\r" + : escaped === "t" + ? "\t" + : escaped + } + json += JSON.stringify(value) + } + try { + return JSON.parse( + json.replace(/\bTrue\b/g, "true").replace(/\bFalse\b/g, "false").replace(/\bNone\b/g, "null") + ) + } catch { + return source + } + } + for (const expression of split(text.slice(lfmStart + 1, lfmEnd))) { + const match = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*)\)$/.exec(expression) + if (!match) continue + const args = Object.fromEntries([]) + for (const arg of split(match[2])) { + const eq = arg.indexOf("=") + if (eq < 1) continue + const name = arg.slice(0, eq).trim() + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) continue + args[name] = literal(arg.slice(eq + 1).trim()) + } + calls.push({name: match[1], args}) + } + if (calls.length) return calls + } + } + let m + const xml = /\s*([\s\S]*?)\s*<\/tool_call>/g + let sawXml = false + while ((m = xml.exec(text))) { + sawXml = true + try { + push(JSON.parse(m[1].trim())) + } catch {} + } + if (sawXml) return calls + const fence = /```(?:json|tool[_-]call)?\s*([\s\S]*?)```/g + while ((m = fence.exec(text))) { + try { + push(JSON.parse(m[1].trim())) + } catch {} + } + if (calls.length) return calls + // Bare JSON objects — brace-depth-aware scan so nested objects (e.g. + // "arguments": { ... }) are captured whole instead of truncated at the + // first inner `}`. + let depth = 0, + start = -1, + inStr = false, + esc = false + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (esc) { + esc = false + continue + } + if (ch === "\\" && inStr) { + esc = true + continue + } + if (ch === '"') { + inStr = !inStr + continue + } + if (inStr) continue + if (ch === "{") { + if (depth === 0) start = i + depth++ + } else if (ch === "}") { + depth-- + if (depth === 0 && start >= 0) { + const block = text.slice(start, i + 1) + if (/"(?:name|tool)"/.test(block)) { + try { + push(JSON.parse(block)) + } catch {} + } + start = -1 + } + } + } + return calls +} diff --git a/llm-host/package.json b/llm-host/package.json new file mode 100644 index 00000000..ffc8b1e3 --- /dev/null +++ b/llm-host/package.json @@ -0,0 +1,34 @@ +{ + "name": "@grjte/llm-host", + "version": "0.2.0", + "description": "The host-realm LLM package: owns the settings doc + config picker (llm-config-tray) and registers the LLM worker as a patchwork:worker plugin. The library itself is @chee/patchwork-llm.", + "type": "module", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "push": "pnpm build && pushwork sync", + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "author": "grjte", + "license": "MIT", + "dependencies": { + "@chee/patchwork-llm": "automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey", + "solid-js": "^1.9.11" + }, + "devDependencies": { + "@chee/patchwork-bundles": "^0.2.0", + "@inkandswitch/patchwork-bootloader": "^0.0.3", + "@inkandswitch/patchwork-plugins": "^0.0.5", + "happy-dom": "^15.11.7", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vite-plugin-solid": "^2.10.0", + "vitest": "^3.2.4" + } +} diff --git a/llm-host/pnpm-lock.yaml b/llm-host/pnpm-lock.yaml new file mode 100644 index 00000000..cd54ddfe --- /dev/null +++ b/llm-host/pnpm-lock.yaml @@ -0,0 +1,2072 @@ +--- +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: + '@chee/patchwork-llm': + specifier: automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey + version: automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey@kHSoEYZn3QrxfLpLC7Lbm2j979WAsxGgNMH8wZd6jbasFTkx9(@automerge/automerge-repo@2.6.0-subduction.40) + solid-js: + specifier: ^1.9.11 + version: 1.9.15 + devDependencies: + '@chee/patchwork-bundles': + specifier: ^0.2.0 + version: 0.2.0(@automerge/automerge-repo-keyhive@0.0.0-alpha.71)(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0)(@keyhive/keyhive@0.0.0-alpha.40) + '@inkandswitch/patchwork-bootloader': + specifier: ^0.0.3 + version: 0.0.3(@automerge/automerge-repo-keyhive@0.0.0-alpha.71)(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0)(@keyhive/keyhive@0.0.0-alpha.40) + '@inkandswitch/patchwork-plugins': + specifier: ^0.0.5 + version: 0.0.5 + happy-dom: + specifier: ^15.11.7 + version: 15.11.7 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^5.4.19 + version: 5.4.21(@types/node@20.19.43) + vite-plugin-solid: + specifier: ^2.10.0 + version: 2.11.14(solid-js@1.9.15)(vite@5.4.21(@types/node@20.19.43)) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7) + +packages: + + '@automerge/automerge-repo-keyhive@0.0.0-alpha.71': + resolution: {integrity: sha512-bDP6QPNTG9eCYVnLW/PuODLFmGrOe4rwaOSAWLgW9VrHg/5aBlP0PmrzeJ58loYvGlqYeN6PWrV+GFDqCPUcug==} + + '@automerge/automerge-repo-network-broadcastchannel@2.5.0': + resolution: {integrity: sha512-57rUTYf0W6kL9DXwjRtyGLqeyz65tDIpiNQdc1nXHnN4VkNMvNo0xzb37nyFKWHEqPWA0DGCcBA8HRlbVHHyHw==} + + '@automerge/automerge-repo-network-messagechannel@2.5.0': + resolution: {integrity: sha512-G7m161tId4djPMzDPlP7nIEsDY7ddbKfKvpJP7kkVqv3OA/Ml8fzyKaU7C1++s/g0FSNCTiN6hOhFCbSkb6/Dw==} + + '@automerge/automerge-repo-network-websocket@2.5.0': + resolution: {integrity: sha512-zrwbkW8hk3fWkXdeaH+YDfGG63WaB9WLfWYyinZx2AQv4mr6D6UhIhviIjweMmRxMLen6NlIcdu3toHV33ZuKg==} + + '@automerge/automerge-repo-network-websocket@2.5.0-alpha.2': + resolution: {integrity: sha512-EtdsSA9PhuiWWbvT36DGylBeGCL7Aop8nSe4FrBYchBCWpqqZOBLdbxD0crwbCUwTBTJy76pXhSHjObQi0y4zQ==} + + '@automerge/automerge-repo-storage-indexeddb@2.5.0': + resolution: {integrity: sha512-7MJYJ5S6K7dHlbvs5/u/v9iexqOeprU/qQonup28r2IoVqwzjuN5ezaoVk6JRBMDI/ZxWfU4rNrqVrVlB49yXA==} + + '@automerge/automerge-repo@2.5.0': + resolution: {integrity: sha512-bdxuMuKmxw0ZjwQXecrIX1VrHXf445bYCftNJJ5vqgGWVvINB5ZKFYAbtgPIyu1Y0TXQKvc6eqESaDeL+g8MmA==} + + '@automerge/automerge-repo@2.5.0-alpha.2': + resolution: {integrity: sha512-Sjl1CaD4IpmboNIG9/EL9Run+AKrL8/1h3Om1NZp9syBnJN8OWjeG4ttpQce6k8HdPyl8sllAHYy8/Q4Ukh39w==} + + '@automerge/automerge-repo@2.6.0-subduction.40': + resolution: {integrity: sha512-9r3GS8FtZKoIJOtJqkQR0ukF7HSu+IyV5Iqf7MP3ELDz/TEaUbZV8ysyl5Ju4fVZjmagI/n3oH2h4ySk4FdEFQ==} + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.16.0': + resolution: {integrity: sha512-BOOn/4p8cF4LhjDUQdgM3qyZFya+M7ZuZywuC99x/kbhk4UK7dw/QcsifcHG5E1TAW4R/K17ZGFUC5nCVUSSWg==} + + '@automerge/automerge@3.2.1': + resolution: {integrity: sha512-xsEZlZe8lWyVCbD7Z7roH4X6ZKlRxMmmumb0M5CuxJWptUEfYsf+1z9lFEJh4aDBbWTLAgxsXd+TBi6jKTj0+g==} + + '@automerge/automerge@3.3.0-fragments.2': + resolution: {integrity: sha512-F5KL+YXD0GMFH/as0/kJ3+lotpuJsH1SSrhhKdYSGtR+S9ytBNTw+s3pAjF2Vr0+ZdTMHxsGjqj5SRkJiSNokQ==} + + '@automerge/vanillajs@2.5.0': + resolution: {integrity: sha512-18Z179fBkRYhfJOvzcz5NU+TSJOfOBez1ZQqjNnRlmHB0xwaC9P/MFuuc9ujWtJSiRAh6jzB3HxUZpCIjEfXFw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@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] + + '@chee/patchwork-bundles@0.2.0': + resolution: {integrity: sha512-Hc8YhdYM1s/6ij08wMFFBa0+LUyPyW7EBh8yrIsWtBzurWcsHnCtdF7ObZtWVqslkLjlgLqcvpDHYpWhfg6I0g==} + + '@chee/patchwork-llm@automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey@kHSoEYZn3QrxfLpLC7Lbm2j979WAsxGgNMH8wZd6jbasFTkx9': + resolution: {automergeUrl: automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey, type: custom:automerge} + version: 0.3.0 + peerDependencies: + '@automerge/automerge-repo': ^2.5.6 + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@inkandswitch/patchwork-bootloader@0.0.3': + resolution: {integrity: sha512-FPVsH+ncVw4gPWUvOweotcTdL421Dg+/KcYBOUVyqZ3PxpWoLwLPw1jevZVMuGrIURR2MNkLCQqVPd5tlZ2qsA==} + peerDependencies: + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.5.0 + '@automerge/automerge-repo-keyhive': 0.0.0-alpha.71 + '@automerge/vanillajs': 2.5.0 + '@keyhive/keyhive': 0.0.0-alpha.40 + + '@inkandswitch/patchwork-filesystem@0.0.3': + resolution: {integrity: sha512-x6yqX1Xyr05ug4YfRyfKJ7jSm/zjhMxU7g/e70nz4QbxfLmcG98LHB9qAkFI2zd+VvKLnTvSpPGu2UvJzXGciw==} + + '@inkandswitch/patchwork-filesystem@0.2.9': + resolution: {integrity: sha512-To2zBNQoAC4BT+PwpZrc0aKZhjLOTd0poJJs/wYEP1wX3r9xr97Og9CdLEvsqsVnEIUXEdZvizH0fbmY0VA14w==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + + '@inkandswitch/patchwork-plugins@0.0.5': + resolution: {integrity: sha512-pQv5M7asDdN8J2moTmLoSnqA8FC/vcOuNJnKveL3MzjEhH0GQdIl6vV3k5jxs83ZpB8stwceE85Zsve/QRnVSA==} + + '@inkandswitch/patchwork-providers@0.3.0': + resolution: {integrity: sha512-CUxWbONfOiz5SCzOpX/7zDTMePWgq7e4778eBJpbjC5Et4lnyL2/UrOZ6YJ4HiCG7r7MbfM86GUi63bqjTeutg==} + peerDependencies: + '@automerge/automerge-repo': '*' + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyhive/keyhive@0.0.0-alpha.40': + resolution: {integrity: sha512-FlvtX6ZK2q47OqqJZ/8G+SAevKJxjSdldhwJPlhUT9QTIv0LNmJO17pkEtLJ0VVrmPryP0tKQDIaQtmcE1f7Tg==} + + '@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.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@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.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@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.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@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.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@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.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@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.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@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.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@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.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@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.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@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.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@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.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@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==} + + '@types/serviceworker@0.0.153': + resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + + '@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==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.10: + resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.15: + resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.15 + peerDependenciesMeta: + solid-js: + optional: true + + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + + 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'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + 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'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + 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'} + + electron-to-chromium@1.5.433: + resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + 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] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + 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 + + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + 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.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + solid-js@1.9.15: + resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + 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==} + + tinyargs@0.1.4: + resolution: {integrity: sha512-5OpdhMRRE70j0zT7mrBpx12FJI+y0EGx8zMe8Vl2/aiwGgpW3X70OVHlsHmHeeG0ncnnWdq1o+k/S5d9ONgzGA==} + engines: {node: '>=14'} + + 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==} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + 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-plugin-solid@2.11.14: + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + 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'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + 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 + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.33.2: + resolution: {integrity: sha512-8tC7yXgeCvpT8gKEeEje6ikJJG1wpnoLiXe+HfECW8m10ubMd3QxKOwWA8KxPJVBcLwfdRoMKYxIBGYKmo37/A==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@automerge/automerge-repo-keyhive@0.0.0-alpha.71': + dependencies: + '@automerge/automerge-repo': 2.5.0-alpha.2 + '@automerge/automerge-repo-network-websocket': 2.5.0-alpha.2 + '@keyhive/keyhive': 0.0.0-alpha.40 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-broadcastchannel@2.5.0': + dependencies: + '@automerge/automerge-repo': 2.5.0 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo-network-messagechannel@2.5.0': + dependencies: + '@automerge/automerge-repo': 2.5.0 + debug: 4.4.3 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo-network-websocket@2.5.0': + dependencies: + '@automerge/automerge-repo': 2.5.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.5.0-alpha.2': + dependencies: + '@automerge/automerge-repo': 2.5.0-alpha.2 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-storage-indexeddb@2.5.0': + dependencies: + '@automerge/automerge-repo': 2.5.0 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo@2.5.0': + dependencies: + '@automerge/automerge': 3.2.1 + bs58check: 3.0.1 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + uuid: 9.0.1 + xstate: 5.33.2 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo@2.5.0-alpha.2': + dependencies: + '@automerge/automerge': 3.2.1 + bs58check: 3.0.1 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + uuid: 9.0.1 + xstate: 5.33.2 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo@2.6.0-subduction.40': + dependencies: + '@automerge/automerge': 3.3.0-fragments.2 + '@automerge/automerge-subduction': 0.16.0 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.33.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.16.0': {} + + '@automerge/automerge@3.2.1': {} + + '@automerge/automerge@3.3.0-fragments.2': {} + + '@automerge/vanillajs@2.5.0': + dependencies: + '@automerge/automerge-repo': 2.5.0 + '@automerge/automerge-repo-network-broadcastchannel': 2.5.0 + '@automerge/automerge-repo-network-messagechannel': 2.5.0 + '@automerge/automerge-repo-network-websocket': 2.5.0 + '@automerge/automerge-repo-storage-indexeddb': 2.5.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@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 + + '@chee/patchwork-bundles@0.2.0(@automerge/automerge-repo-keyhive@0.0.0-alpha.71)(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0)(@keyhive/keyhive@0.0.0-alpha.40)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.40 + '@inkandswitch/patchwork-bootloader': 0.0.3(@automerge/automerge-repo-keyhive@0.0.0-alpha.71)(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0)(@keyhive/keyhive@0.0.0-alpha.40) + '@inkandswitch/patchwork-filesystem': 0.2.9(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.1) + es-module-lexer: 2.3.2 + resolve.exports: 2.0.3 + unplugin: 2.3.11 + transitivePeerDependencies: + - '@automerge/automerge' + - '@automerge/automerge-repo-keyhive' + - '@automerge/vanillajs' + - '@keyhive/keyhive' + - bufferutil + - supports-color + - utf-8-validate + + '@chee/patchwork-llm@automerge:m6dt2f4hYdhyvmFDvXYbYDdNQey@kHSoEYZn3QrxfLpLC7Lbm2j979WAsxGgNMH8wZd6jbasFTkx9(@automerge/automerge-repo@2.6.0-subduction.40)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.40 + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40) + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@inkandswitch/patchwork-bootloader@0.0.3(@automerge/automerge-repo-keyhive@0.0.0-alpha.71)(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.1)(@automerge/vanillajs@2.5.0)(@keyhive/keyhive@0.0.0-alpha.40)': + dependencies: + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo-keyhive': 0.0.0-alpha.71 + '@automerge/vanillajs': 2.5.0 + '@keyhive/keyhive': 0.0.0-alpha.40 + '@types/debug': 4.1.13 + debug: 4.4.3 + resolve.exports: 2.0.3 + service-worker-types: '@types/serviceworker@0.0.153' + tinyargs: 0.1.4 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.0.3': + dependencies: + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.5.0 + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.2.9(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.1)': + dependencies: + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.6.0-subduction.40 + debug: 4.4.3 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.5': + dependencies: + '@automerge/automerge': 3.2.1 + '@automerge/automerge-repo': 2.5.0 + '@inkandswitch/patchwork-filesystem': 0.0.3 + '@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.3.0(@automerge/automerge-repo@2.6.0-subduction.40)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.40 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyhive/keyhive@0.0.0-alpha.40': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/hashes@1.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@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 + + '@types/serviceworker@0.0.153': {} + + '@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@5.4.21(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@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 + + acorn@8.18.0: {} + + assertion-error@2.0.1: {} + + babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.15 + + base-x@4.0.1: {} + + base-x@5.0.1: {} + + baseline-browser-mapping@2.11.25: {} + + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.433 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + + bs58@5.0.0: + dependencies: + base-x: 4.0.1 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@3.0.1: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 5.0.0 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cac@6.7.14: {} + + caniuse-lite@1.0.30001810: {} + + 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: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + electron-to-chromium@1.5.433: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + es-module-lexer@2.3.2: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + 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 + + gensync@1.0.0-beta.2: {} + + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + html-entities@2.3.3: {} + + is-what@4.1.16: {} + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-releases@2.0.56: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve.exports@2.0.3: {} + + 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.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 + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + siginfo@2.0.0: {} + + solid-js@1.9.15: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + solid-refresh@0.6.3(solid-js@1.9.15): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.8 + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + 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 + + tinyargs@0.1.4: {} + + 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: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uuid@14.0.2: {} + + uuid@9.0.1: {} + + 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: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@5.4.21(@types/node@20.19.43)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) + merge-anything: 5.1.7 + solid-js: 1.9.15 + solid-refresh: 0.6.3(solid-js@1.9.15) + vite: 5.4.21(@types/node@20.19.43) + vitefu: 1.1.3(vite@5.4.21(@types/node@20.19.43)) + transitivePeerDependencies: + - supports-color + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.4 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@5.4.21(@types/node@20.19.43)): + optionalDependencies: + vite: 5.4.21(@types/node@20.19.43) + + 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@5.4.21(@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: 5.4.21(@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: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + webidl-conversions@7.0.0: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xstate@5.33.2: {} + + yallist@3.1.1: {} diff --git a/llm-host/pnpm-workspace.yaml b/llm-host/pnpm-workspace.yaml new file mode 100644 index 00000000..b6a60e31 --- /dev/null +++ b/llm-host/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/llm-host/src/LlmConfigTray.tsx b/llm-host/src/LlmConfigTray.tsx new file mode 100644 index 00000000..de861545 --- /dev/null +++ b/llm-host/src/LlmConfigTray.tsx @@ -0,0 +1,216 @@ +import { createSignal, onCleanup, Show } from "solid-js"; +import { Portal, render } from "solid-js/web"; +// @ts-ignore — plain-JS library, ships no type declarations +import { + dom as pickerDom, + ensureConfig, + readScopedConfig, + readConfig, + writeScopeOverride, + writeConfig, +} from "@chee/patchwork-llm"; + +/** + * The LLM config tray tool — a `patchwork:component` (element) => cleanup. + * + * Runs in the HOST realm (it's a system-tray icon). It owns the picker UI over + * the LLM settings doc. Chat (and anything else) opens it either by clicking the + * tray icon or by dispatching a `patchwork:open-tool` + * {detail:{component:"llm-config-tray", scope, toolPrompt, toolTools}} event + * (relayed across the isolation boundary by the isolation open-tool bridge). + * + * The picker is driven through its injectable `source` {read, write} so this tool + * is the sole owner of settings-doc writes; per-tool/per-doc scope overrides are + * written via writeScopeOverride, the global default via writeConfig. + */ + +/** @typedef {{toolId?: string, docId?: string, toolName?: string, docName?: string}} Scope */ + +export function LlmConfigTray(element: HTMLElement) { + const style = document.createElement("style"); + style.textContent = ` + .llm-config-tray { position: relative; display: inline-flex; align-items: center; } + .llm-config-tray-button { + display: inline-flex; align-items: center; gap: var(--studio-space-2xs, 0.25rem); + height: 1.75rem; padding: 0 var(--studio-space-xs, 0.375rem); + border: 1px solid var(--studio-chrome-offset-20, var(--studio-fill-offset-20, #d4d4d4)); + border-radius: var(--studio-radius-sm, 4px); + background: var(--studio-chrome, var(--studio-fill, white)); + color: var(--studio-chrome-line, var(--studio-line, black)); + font: 500 0.75rem/1 var(--studio-family-sans, system-ui, sans-serif); cursor: pointer; + } + .llm-config-tray-button:hover { + background: var(--studio-chrome-offset-10, var(--studio-fill-offset-10, #f2f2f2)); + } + .llm-config-tray-popover { + position: fixed; z-index: 2147483000; + max-width: min(92vw, 520px); max-height: 80vh; overflow: auto; + background: var(--studio-fill, white); color: var(--studio-line, black); + border: 1px solid var(--studio-fill-offset-20, #d4d4d4); + border-radius: var(--studio-radius-md, 8px); + box-shadow: var(--studio-shadow-lg, 0 8px 32px rgba(0,0,0,0.24)); + padding: var(--studio-space-sm, 0.5rem); + } + `; + element.append(style); + + // Current scope the picker edits (set when opened via event; icon click uses + // the global default scope). + const [open, setOpen] = createSignal(false); + const [scope, setScope] = createSignal({}); + const [payload, setPayload] = createSignal({}); + const [pos, setPos] = createSignal<{ left: number; top: number } | null>( + null + ); + + let buttonEl!: HTMLButtonElement; + let popoverEl: HTMLDivElement | undefined; + let pickerHost: HTMLDivElement | undefined; + let currentPicker: any = null; + + // Build the picker's source from the current scope: scoped reads/writes when a + // toolId is present, else the global default config. + function sourceFor(sc: Scope) { + if (sc && sc.toolId) { + return { + read: () => { + try { + return readScopedConfig(sc); + } catch { + return readConfig(); + } + }, + write: (next: any) => writeScopeOverride(sc, next), + }; + } + return { + read: () => readConfig(), + write: (next: any) => writeConfig(next), + }; + } + + async function mountPicker() { + const sc = scope(); + const p = payload() || {}; + await ensureConfig(sc && sc.toolId ? sc : undefined, element); + if (!pickerHost) return; + pickerHost.replaceChildren(); + currentPicker = pickerDom({ + source: sourceFor(sc), + scope: sc && sc.toolId ? sc : undefined, + toolName: sc?.toolName || p.toolName, + toolPrompt: p.toolPrompt, + toolTools: p.toolTools, + }); + pickerHost.append(currentPicker); + } + + function positionPopover() { + const r = buttonEl.getBoundingClientRect(); + // Prefer opening above the tray (tray sits at the bottom), aligned right. + setPos({ left: Math.max(8, r.right - 520), top: Math.max(8, r.top - 8) }); + } + + function openPicker(sc: Scope, p: any) { + setScope(sc || {}); + setPayload(p || {}); + positionPopover(); + setOpen(true); + queueMicrotask(mountPicker); + } + + function closePicker() { + try { + currentPicker?.destroy?.(); + } catch {} + currentPicker = null; + setOpen(false); + } + + // Open via the `patchwork:open-tool` event (from chat / other tools, incl. + // relayed across isolation). Match our component id. + const onOpenTool = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail || detail.component !== "llm-config-tray") return; + openPicker(detail.scope || {}, detail); + }; + document.addEventListener("patchwork:open-tool", onOpenTool as EventListener); + + // Close on outside click. Use composedPath() (captured at event time, before + // the picker re-renders its own DOM on a selection) rather than + // popoverEl.contains(target): the picker rebuilds its option list when config + // changes, so the clicked node is often already detached by the time the click + // bubbles to document — contains() would then wrongly report "outside" and + // close the popover on every model click. + const onDocClick = (e: MouseEvent) => { + if (!open()) return; + const path = e.composedPath(); + if ( + (popoverEl && path.includes(popoverEl)) || + (buttonEl && path.includes(buttonEl)) + ) { + return; // click was inside the popover or on the tray button + } + closePicker(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && open()) closePicker(); + }; + document.addEventListener("click", onDocClick); + document.addEventListener("keydown", onKey); + + const dispose = render( + () => ( +
+ + + +
event.stopPropagation()} + > +
+
+ + +
+ ), + element + ); + + onCleanup(() => { + try { + currentPicker?.destroy?.(); + } catch {} + }); + + return () => { + dispose(); + style.remove(); + document.removeEventListener( + "patchwork:open-tool", + onOpenTool as EventListener + ); + document.removeEventListener("click", onDocClick); + document.removeEventListener("keydown", onKey); + }; +} diff --git a/llm-host/src/client.js b/llm-host/src/client.js new file mode 100644 index 00000000..a5fd83ce --- /dev/null +++ b/llm-host/src/client.js @@ -0,0 +1,106 @@ +/** + * The LLM consume-half — the mirror of worker-spec.js. + * + * worker-spec.js (the SERVE half) turns a consumer's request frames into worker + * traffic; this file (the CONSUME half) turns a tool's `generate(...)` call into + * those request frames and reads the event stream back. Together they are the LLM + * *protocol* layer, sitting above the generic transport (@grjte/patchwork-worker) + * and below any tool that wants to generate. + * + * How a tool gets it: NOT by import. index.ts registers `makeLLMClient` as the + * `patchwork:worker-client` plugin with id "llm", paired with the `patchwork:worker` + * plugin of the same id. A tool calls the transport's + * `connectWorkerClient("llm", …)`, which resolves this factory from the plugin + * registry, opens a session for "llm", and returns `makeLLMClient(session)`. So + * both halves of the protocol ship in this one package and cannot drift, and the + * tool has no build-time dependency on it. + * + * ⚠ Imports NOTHING — and must never import @chee/patchwork-llm or + * ./worker-spec.js. This chunk is what a sandboxed tool loads (via the plugin's + * `load()`); the library and the serve half stay host-side. + * + * Tool calls: the serve half parses `` text host-side for providers + * without native function calling, so `result.toolCalls` is populated (or null) + * for every provider and a consumer never parses model text itself. + * + * Frame vocabulary (must stay in step with worker-spec.js): + * request: {id, op:"generate", scope, system, tools, messages|text, sessionKey} + * events: {id, type:"token"|"stats"|"prediction"|"status"|"model"| + * "result"|"error", ...} result/error terminal. + */ + +/** + * @typedef {Object} GenOpts + * @property {any} [scope] {toolId, docId} — the host resolves config for this scope + * @property {string|Record} [system] string | {default, [provider]} + * @property {any[]} [tools] tool descriptors {name, description, defaultOff?, parameters?} + * @property {string} [sessionKey] + * @property {HTMLElement} [element] a node in the mounted , for discovery + * @property {AbortSignal} [signal] + * @property {(delta:string, full:string)=>void} [onToken] + * @property {(message:string)=>void} [onStatus] + * @property {(label:string)=>void} [onModel] + * + * @typedef {Object} GenResult + * @property {string} text + * @property {any[]|null} toolCalls + * @property {string} [toolMode] + * + * @typedef {{generate: (messages: any[]|string, opts?: GenOpts) => Promise}} LLMClient + */ + +/** + * The `patchwork:worker-client` factory for kind "llm": bind the LLM protocol to + * an already-open transport session and return the client API. + * + * @param {{request: (frame:any, opts:any)=>{promise:Promise}}} session + * @returns {LLMClient} + */ +export function makeLLMClient(session) { + /** + * Generate a completion. Streams tokens via opts.onToken; resolves with the + * final text + any structured tool calls. Tool EXECUTION stays in the tool — + * the worker only receives tool descriptors and returns calls to run. + * @param {any[]|string} messages + * @param {GenOpts} [opts] + * @returns {Promise} + */ + function generate(messages, opts = {}) { + const payload = typeof messages === "string" ? {text: messages} : {messages} + + const {promise} = session.request( + { + op: "generate", + sessionKey: opts.sessionKey, + scope: opts.scope, + system: opts.system, + tools: opts.tools, + ...payload, + }, + { + element: opts.element, + signal: opts.signal, + terminal: { + result: (/** @type {any} */ f) => ({ + text: f.text, + toolCalls: f.toolCalls || null, + toolMode: f.toolMode, + }), + error: (/** @type {any} */ f) => { + throw new Error(f.message) + }, + }, + // prediction / stats frames are ignored by the caller unless it asks. + onFrame: (/** @type {any} */ f) => { + if (f.type === "token") opts.onToken?.(f.delta, f.text) + else if (f.type === "status") opts.onStatus?.(f.message) + else if (f.type === "model") opts.onModel?.(f.label) + }, + } + ) + + return /** @type {Promise} */ (promise) + } + + return {generate} +} diff --git a/llm-host/src/client.test.js b/llm-host/src/client.test.js new file mode 100644 index 00000000..45fe38a6 --- /dev/null +++ b/llm-host/src/client.test.js @@ -0,0 +1,112 @@ +import {describe, it, expect, vi} from "vitest" +import {makeLLMClient} from "./client.js" + +/** + * A fake transport session: records the request frame + opts and lets the test + * drive the terminal/onFrame callbacks the way session.js would. + */ +function fakeSession() { + /** @type {any} */ + const seen = {frame: null, opts: null} + let settle = /** @type {any} */ (null) + const session = { + request(frame, opts) { + seen.frame = frame + seen.opts = opts + const promise = new Promise((resolve, reject) => { + settle = {resolve, reject} + }) + return {promise, abort: vi.fn()} + }, + } + /** Deliver a terminal frame through opts.terminal, like session.js does. */ + const terminal = (f) => { + try { + settle.resolve(seen.opts.terminal[f.type](f)) + } catch (e) { + settle.reject(e) + } + } + const frame = (f) => seen.opts.onFrame(f) + return {session, seen, terminal, frame} +} + +describe("makeLLMClient", () => { + it("returns the client API: {generate} only", () => { + const {session} = fakeSession() + const client = makeLLMClient(session) + expect(Object.keys(client)).toEqual(["generate"]) + expect(typeof client.generate).toBe("function") + }) + + it("sends a generate frame with messages, scope, system, tools and sessionKey", async () => { + const {session, seen, terminal} = fakeSession() + const client = makeLLMClient(session) + const el = {} + const signal = new AbortController().signal + const p = client.generate([{role: "user", content: "hi"}], { + sessionKey: "doc-1", + scope: {toolId: "chat"}, + system: {default: "be brief"}, + tools: [{name: "x"}], + element: /** @type {any} */ (el), + signal, + }) + expect(seen.frame).toEqual({ + op: "generate", + sessionKey: "doc-1", + scope: {toolId: "chat"}, + system: {default: "be brief"}, + tools: [{name: "x"}], + messages: [{role: "user", content: "hi"}], + }) + expect(seen.opts.element).toBe(el) + expect(seen.opts.signal).toBe(signal) + terminal({type: "result", text: "hello", toolCalls: null}) + await expect(p).resolves.toEqual({text: "hello", toolCalls: null, toolMode: undefined}) + }) + + it("sends a string prompt as text", async () => { + const {session, seen, terminal} = fakeSession() + const p = makeLLMClient(session).generate("continue this") + expect(seen.frame.text).toBe("continue this") + expect(seen.frame.messages).toBeUndefined() + terminal({type: "result", text: "…"}) + await p + }) + + it("passes structured toolCalls and toolMode through from the result frame", async () => { + const {session, terminal} = fakeSession() + const p = makeLLMClient(session).generate("x") + terminal({type: "result", text: "t", toolCalls: [{name: "x", args: {}}], toolMode: "template"}) + await expect(p).resolves.toEqual({ + text: "t", + toolCalls: [{name: "x", args: {}}], + toolMode: "template", + }) + }) + + it("rejects with the error frame's message", async () => { + const {session, terminal} = fakeSession() + const p = makeLLMClient(session).generate("x") + terminal({type: "error", message: "boom"}) + await expect(p).rejects.toThrow("boom") + }) + + it("routes token / status / model frames to the callbacks", async () => { + const {session, frame, terminal} = fakeSession() + const onToken = vi.fn() + const onStatus = vi.fn() + const onModel = vi.fn() + const p = makeLLMClient(session).generate("x", {onToken, onStatus, onModel}) + frame({type: "token", delta: "h", text: "h"}) + frame({type: "status", message: "loading"}) + frame({type: "model", label: "Local 1B"}) + frame({type: "stats", tokPerSec: 1}) // ignored + expect(onToken).toHaveBeenCalledWith("h", "h") + expect(onStatus).toHaveBeenCalledWith("loading") + expect(onModel).toHaveBeenCalledWith("Local 1B") + terminal({type: "result", text: "h"}) + await p + }) +}) diff --git a/llm-host/src/index.ts b/llm-host/src/index.ts new file mode 100644 index 00000000..700b4f0c --- /dev/null +++ b/llm-host/src/index.ts @@ -0,0 +1,81 @@ +import type { Plugin } from "@inkandswitch/patchwork-plugins"; + +/** + * @grjte/llm-host — the host-realm LLM package. + * + * `@chee/patchwork-llm` is a plain library (npm-bound, no Patchwork coupling). + * This package is the Patchwork side of it: it registers what the platform needs + * to know about, and owns everything that must run in the host realm because it + * touches the settings doc and the API key. + * + * Three plugins: + * + * `llm-config-tray` (patchwork:component) + * The system-tray icon that opens the model/config picker over the LLM + * settings doc. Consumer tools never read config — they open this tool via a + * `patchwork:open-tool` event (relayed across the isolation boundary) and + * otherwise just name a scope when they generate. + * + * `llm` (patchwork:worker) + * The LLM worker itself. The generic worker provider + * (`@grjte/patchwork-worker`) resolves it by this id when a tool calls + * `connectWorker("llm", …)`, in or out of isolation. Registering it + * declaratively is what keeps the provider kind-agnostic: it no longer + * imports the LLM, so nothing service-specific loads until someone actually + * connects. + * + * `llm` (patchwork:worker-client) + * The CONSUME half of the same protocol, paired with the worker by id. Its + * `load()` resolves to `makeLLMClient`, a factory `(session) => {generate}`. + * A tool calls `connectWorkerClient("llm", …)` from @grjte/patchwork-worker, + * which resolves this factory from the registry and binds it to a session — + * so the tool never imports this package. Both halves ship here and cannot + * drift. Under isolation the registry is mirrored into the iframe and the + * iframe loads this package's entry + the client chunk; nothing else of + * llm-host (and none of @chee/patchwork-llm) enters the sandbox. + * + * All three are behind `load()`, so importing this module registers descriptors + * only — the library (worker, config, picker, transformers.js glue) is imported + * lazily on first use rather than eagerly at frame mount. Descriptor fields must + * stay plain data: the isolation bridge structured-clones them. + */ +export const plugins: Plugin[] = [ + { + type: "patchwork:component", + id: "llm-config-tray", + name: "LLM Model & Config", + icon: "Bot", + tags: ["system-tray"], + async load() { + const { LlmConfigTray } = await import("./LlmConfigTray.js"); + return LlmConfigTray; + }, + }, + { + type: "patchwork:worker", + id: "llm", + name: "LLM", + async load() { + // Resolves to a WorkerSpec that @grjte/patchwork-worker serves. The library + // supplies the compute worker (createWorker) and the config/tool functions; + // the spec below adds the LLM op vocabulary and the settings-doc resolution + // that must run host-side (the key/settings doc never cross into a sandbox). + // @ts-ignore — plain-JS library, ships no bundled types + const lib = await import("@chee/patchwork-llm"); + // @ts-ignore — plain-JS sibling, JSDoc-typed + const { makeLLMWorkerSpec } = await import("./worker-spec.js"); + return makeLLMWorkerSpec(lib); + }, + }, + { + type: "patchwork:worker-client", + id: "llm", + name: "LLM client", + async load() { + // The consume half: pure, imports nothing, safe to load into a sandbox. + // @ts-ignore — plain-JS sibling, JSDoc-typed + const { makeLLMClient } = await import("./client.js"); + return makeLLMClient; + }, + }, +]; diff --git a/llm-host/src/worker-spec.js b/llm-host/src/worker-spec.js new file mode 100644 index 00000000..ca5d9d10 --- /dev/null +++ b/llm-host/src/worker-spec.js @@ -0,0 +1,377 @@ +/** + * The LLM WorkerSpec — the host-realm glue that turns @chee/patchwork-llm into a + * `patchwork:worker` that @grjte/patchwork-worker can serve. + * + * The generic transport (@grjte/patchwork-worker/serve.js) owns the stream pair, + * the per-connection worker lifetime, id demux, and abort plumbing. This file + * owns everything LLM-specific: resolving config/secrets from the settings doc, + * building tool schemas, the op vocabulary (generate / predict / the analytical + * ops / preload), and the model label. None of that can live in the sandbox — + * the settings doc is denylisted and the API key must never cross — which is why + * it lives HERE, in the host-realm package, rather than in the library or in the + * transport. + * + * `makeLLMWorkerSpec(lib)` takes the loaded @chee/patchwork-llm module (so this + * file has no static dependency on it — llm-host loads the library from its + * automerge doc at runtime) and returns the spec. + * + * Request preparation (prompt resolution, CallConfig, the native-vs-templated + * tools decision, the builtin detour, the input shape, the worker payload) is + * the library's `prepareGenerate` / `buildGeneratePayload` — the same code its + * own same-realm `generate()` runs — so the two paths cannot diverge. What stays + * here is host policy: which overrides a consumer may set, provider-conditional + * system maps, `toolToggles`, and parsing `` text into `toolCalls` so + * consumers (src/client.js, the consume half) never parse model output. + */ + +/** @param {...any} args */ +function clog(...args) { + try { + console.log("[llm-host]", ...args) + } catch {} +} + +/** + * Resolve a (possibly provider-conditional) system prompt. `system` may be a + * plain string (used as-is) or a map like `{default, local, openrouter, …}` — + * the entry matching the provider wins, falling back to `default`. + * @param {any} system + * @param {string} provider + */ +function resolveSystemForProvider(system, provider) { + if (system == null) return undefined + if (typeof system === "string") return system + if (typeof system === "object") return system[provider] ?? system.default ?? undefined + return undefined +} + +/** + * Filter tool descriptors by the config's `toolToggles`. A tool is included + * unless turned off (`toggles[name] === false`); a `defaultOff` tool is opt-in + * (included only if `toggles[name] === true`). Matching is by descriptor `name`. + * @param {any[]} tools + * @param {Record} toggles + */ +function filterToolsByToggles(tools, toggles) { + if (!Array.isArray(tools)) return tools + const t = toggles || {} + return tools.filter((tool) => { + if (tool && tool.defaultOff) return t[tool.name] === true + return t[tool.name] !== false + }) +} + +/** + * Build the LLM WorkerSpec. + * @param {any} lib the loaded @chee/patchwork-llm module + */ +export function makeLLMWorkerSpec(lib) { + const { + createWorker, + ensureConfig, + ensureSettingsDoc, + settingsDocHandle, + callConfig, + applyPrompts, + effectiveSystem, + describeConfig, + fetchOpenRouterModels, + builtinGenerate, + resolveCfgPrompts, + prepareGenerate, + buildGeneratePayload, + parseToolCalls, + } = lib + + // OpenRouter catalogue cache (host-side): turning a model id into a human name + // needs the catalogue. Fetch once and reuse; failure just yields the raw id. + /** @type {any[]|null} */ + let openrouterModelsCache = null + async function openrouterModels() { + if (openrouterModelsCache) return openrouterModelsCache + try { + openrouterModelsCache = await fetchOpenRouterModels() + } catch { + openrouterModelsCache = [] + } + return openrouterModelsCache + } + + /** + * Human-readable label for the resolved config. Consults the OpenRouter + * catalogue for that provider. Never includes secrets. + * @param {any} cfg + */ + async function modelLabel(cfg) { + try { + const openrouter = cfg.provider === "openrouter" ? (await openrouterModels()) || [] : [] + return describeConfig(cfg, {openrouterModels: openrouter}) + } catch { + return "the configured model" + } + } + + /** + * Resolve the unresolved config for a request frame — the DOM/repo-bound + * preamble. Config is resolved HERE from the frame's `scope`, never from a + * consumer-supplied `frame.config` (which would let a tool pick the provider + * AND supply its own key, defeating host-side resolution). + * + * @param {any} frame + * @param {Promise|null} settingsWarm the connection's in-flight + * settings-doc resolution (spec.open), awaited before reading config. + */ + async function resolveCfg0(frame, settingsWarm) { + if (settingsWarm) await settingsWarm + let cfg0 = await ensureConfig(frame.scope) + // Self-heal a cold config: `ensureConfig` resolves elementless and on its + // retryable paths silently yields DEFAULTS (local model for an OpenRouter + // account, or OpenRouter with an empty apiKey → 401). The warm normally + // settles this; this retry covers a caller whose warm timed out, and + // `ensureSettingsDoc` deliberately doesn't cache its null results. + if (!settingsDocHandle()) { + await ensureSettingsDoc() + cfg0 = await ensureConfig(frame.scope) + } + return cfg0 + } + + /** + * Only the sampling knobs a consumer legitimately owns are forwarded to + * callConfig. The frame is NOT passed wholesale: callConfig also honours + * provider / apiKey / model / url overrides, so handing it the raw frame would + * let a consumer redirect generation at its own endpoint with its own key. + * @param {any} frame + */ + function samplingOverrides(frame) { + return { + temperature: frame.temperature, + topP: frame.topP, + topk: frame.topk, + maxNewTokens: frame.maxNewTokens, + } + } + + /** + * Prepare a `generate` frame with the library's own request preparation, + * after applying host policy: the provider-conditional system map and the + * user's toolToggles. Returns what the library's generate() would have + * computed, plus `hasTools` (whether any tool survived the toggles). + * + * @param {any} frame + * @param {Promise|null} settingsWarm + */ + async function prepareFrame(frame, settingsWarm) { + const cfg0 = await resolveCfg0(frame, settingsWarm) + // The provider is fixed by the settings doc: sampling overrides never carry + // one and prompt resolution doesn't change it, so cfg0.provider is what + // callConfig will report. + const tools = filterToolsByToggles(frame.tools, cfg0.toolToggles || {}) + const prepared = await prepareGenerate(cfg0, { + messages: frame.text != null ? frame.text : frame.messages, + system: resolveSystemForProvider(frame.system, cfg0.provider), + tools, + // A continuation is a raw string prompt with no chat messages. + continuation: !!frame.continuation && typeof frame.messages === "undefined", + overrides: samplingOverrides(frame), + }) + return {...prepared, hasTools: Array.isArray(tools) && tools.length > 0} + } + + /** + * Config for the non-generate ops (predict, the analytical ops, preload): + * resolved prompts + the flat CallConfig with sampling overrides only. + * @param {any} frame + * @param {Promise|null} settingsWarm + */ + async function resolveForFrame(frame, settingsWarm) { + const cfg0 = await resolveCfg0(frame, settingsWarm) + const cfg = await resolveCfgPrompts(cfg0) + const config = callConfig(cfg, samplingOverrides(frame)) + return {cfg, config} + } + + /** + * The `toolCalls` a result frame should carry. Native function-calling + * providers return structured calls; for the rest (local / builtin) the model + * writes `` text, which is parsed HERE so every consumer sees the + * same shape and never parses model output itself. Parsing is gated on the + * request actually offering tools — prose that happens to contain a bare + * `{"name": …}` object is not a tool call. `null` when there are none. + * + * @param {string} text + * @param {any[]|null|undefined} toolCalls structured calls from the worker + * @param {boolean} hasTools + * @returns {any[]|null} + */ + function withToolCalls(text, toolCalls, hasTools) { + if (Array.isArray(toolCalls) && toolCalls.length) return toolCalls + if (!hasTools) return null + const parsed = parseToolCalls(text || "") + return parsed.length ? parsed : null + } + + return { + createWorker, + + /** + * Warm the settings doc before any frame is handled. Returned as opaque + * state; the transport bounds it with a timeout and passes it to `handle`, + * where `resolveForFrame` awaits it. `ctx.element` reaches the + * `patchwork:tool-storage` provider through the DOM; without it config + * silently falls back to DEFAULTS (the 401 case). + * @param {{element?: HTMLElement}} ctx + */ + open(ctx) { + return ctx.element ? Promise.resolve(ensureSettingsDoc(ctx.element)).catch(() => null) : null + }, + + /** + * Turn one consumer frame into worker traffic. Returns an abort token + * (`{sessionKey}`) so `op:"abort"` can cancel the matching worker request. + * + * `io` comes from @grjte/patchwork-worker's serveWorkerSpec: + * post(msg, transfer?) send to the worker + * emit(frame) enqueue onto this consumer's readable (id-tagged + * by the transport; we emit worker-shaped frames) + * on(fn) handle worker messages for this workerId; return + * true from fn when the request is complete + * workerId the transport-minted id to tag worker payloads + * state whatever open() resolved (the settings warm) + * + * @param {any} frame + * @param {any} io + */ + async handle(frame, io) { + const {post, emit, on, workerId, state: settingsWarm} = io + const op = frame.op + const sessionKey = frame.sessionKey || workerId + + if (op === "generate") { + const {cfg, config, extraSystem, builtin, input, hasTools} = await prepareFrame( + frame, + settingsWarm + ) + // Tell the consumer which model is answering (host-computed; no secrets). + void modelLabel(cfg).then((label) => emit({type: "model", label})) + + // builtin (Chrome Prompt API) runs in-realm, not in the worker. + if (builtin) { + try { + const full = await builtinGenerate(input, { + temperature: config.temperature, + topK: config.topK, + system: effectiveSystem(cfg, extraSystem), + onToken: (_d, f) => emit({type: "token", delta: "", text: f}), + onStatus: (m) => emit({type: "status", message: m}), + signal: undefined, + }) + emit({type: "result", text: full, toolCalls: withToolCalls(full, null, hasTools)}) + } catch (e) { + const err = /** @type {any} */ (e) + emit({type: "error", message: err?.message || String(e)}) + } + return {sessionKey} + } + + on((msg) => { + switch (msg.type) { + case "token": + case "prediction": + case "stats": + emit(msg) + return false + case "result": + emit({...msg, toolCalls: withToolCalls(msg.text, msg.toolCalls, hasTools)}) + return true + case "error": + clog("generate: worker error", msg.message) + emit(msg) + return true + } + return false + }) + post(buildGeneratePayload(config, input, {id: workerId, sessionKey})) + return {sessionKey} + } + + if (op === "predict") { + const {cfg, config} = await resolveForFrame(frame, settingsWarm) + config.continuation = !!frame.continuation + if (config.provider === "builtin") { + emit({type: "predictions", candidates: []}) + return {sessionKey} + } + const promptedText = applyPrompts(frame.text, cfg, frame.system) + on((msg) => { + if (msg.type === "predictions" || msg.type === "error") { + emit(msg) + return true + } + return false + }) + post({type: "predict", id: workerId, sessionKey, provider: config.provider, text: promptedText, config}) + return {sessionKey} + } + + // Analytical ops: local-only, single terminal result frame. + /** @type {Record} */ + const analytical = { + "score-tokens": ["score-progress", "token-scores"], + "compute-importance": ["importance-scores"], + "compute-attention-weights": ["attention-weights"], + "extract-features": ["features"], + "extract-cut-features": ["cut-features"], + "decode-tokens": ["decoded-tokens"], + "probe-attention": ["probe-attention-result"], + } + if (analytical[op]) { + const {config} = await resolveForFrame(frame, settingsWarm) + const [progressType, terminalType] = analytical[op] + const terminal = terminalType || progressType + on((msg) => { + if (msg.type === terminal) { + emit(msg) + return true + } + if (progressType && terminalType && msg.type === progressType) { + emit(msg) + return false + } + if (msg.type === "error") { + emit(msg) + return true + } + return false + }) + /** @type {any} */ + const payload = {type: op, id: workerId, sessionKey, provider: config.provider, config} + if (frame.text != null) payload.text = frame.text + if (frame.ids != null) payload.ids = frame.ids + post(payload) + return {sessionKey} + } + + if (op === "preload") { + const {config} = await resolveForFrame(frame, settingsWarm) + post({type: "preload", provider: config.provider, config}) + return {sessionKey} + } + + // Unknown op — nothing to do. + return {sessionKey} + }, + + /** + * Cancel one in-flight request. The worker keys `activeGenerations` by + * `sessionKey`, so that is what we post. + * @param {{sessionKey: string}} token + * @param {(m:any)=>void} post + */ + abort(token, post) { + try { + post({type: "abort", sessionKey: token.sessionKey}) + } catch {} + }, + } +} diff --git a/llm-host/src/worker-spec.test.js b/llm-host/src/worker-spec.test.js new file mode 100644 index 00000000..1d6367bb --- /dev/null +++ b/llm-host/src/worker-spec.test.js @@ -0,0 +1,143 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" +import {makeLLMWorkerSpec} from "./worker-spec.js" + +/** + * A fake @chee/patchwork-llm: just enough for the spec's host policy to be + * observable. `prepareGenerate` echoes what it was given so the test can assert + * on the overrides / system / tools the spec chose to forward. + */ +function fakeLib(cfg = {provider: "local", toolToggles: {}}) { + const lib = { + createWorker: () => ({}), + ensureConfig: vi.fn(async () => cfg), + ensureSettingsDoc: vi.fn(async () => ({})), + settingsDocHandle: () => ({}), + callConfig: (c, o) => ({provider: c.provider, ...o}), + applyPrompts: (input) => input, + effectiveSystem: () => "", + describeConfig: () => "Model", + fetchOpenRouterModels: async () => [], + builtinGenerate: vi.fn(async () => "built-in text"), + resolveCfgPrompts: async (c) => c, + prepareGenerate: vi.fn(async (cfg0, opts) => ({ + cfg: cfg0, + config: {provider: cfg0.provider, ...opts.overrides}, + extraSystem: opts.system, + builtin: cfg0.provider === "builtin", + input: opts.messages, + })), + buildGeneratePayload: (config, input, ids) => ({type: "generate", ...ids, provider: config.provider, config, messages: input}), + parseToolCalls: vi.fn((text) => (text.includes("") ? [{name: "x", args: {}}] : [])), + } + return lib +} + +function fakeIO() { + /** @type {any} */ + const io = { + post: vi.fn(), + emit: vi.fn(), + handler: null, + on: (fn) => { + io.handler = fn + }, + workerId: "w1", + state: null, + } + return io +} + +const flush = () => new Promise((r) => setTimeout(r, 0)) + +describe("makeLLMWorkerSpec · generate", () => { + let lib, spec, io + beforeEach(() => { + lib = fakeLib() + spec = makeLLMWorkerSpec(lib) + io = fakeIO() + }) + + it("parses text host-side when the request offered tools", async () => { + await spec.handle({op: "generate", messages: [{role: "user", content: "hi"}], tools: [{name: "x"}]}, io) + io.handler({type: "result", text: "{}", toolCalls: null, toolMode: "template"}) + const result = io.emit.mock.calls.map((c) => c[0]).find((f) => f.type === "result") + expect(result.toolCalls).toEqual([{name: "x", args: {}}]) + expect(result.toolMode).toBe("template") + expect(lib.parseToolCalls).toHaveBeenCalledOnce() + }) + + it("passes native toolCalls through untouched", async () => { + await spec.handle({op: "generate", messages: [], tools: [{name: "x"}]}, io) + const native = [{name: "x", args: {a: 1}}] + io.handler({type: "result", text: "", toolCalls: native}) + const result = io.emit.mock.calls.map((c) => c[0]).find((f) => f.type === "result") + expect(result.toolCalls).toBe(native) + expect(lib.parseToolCalls).not.toHaveBeenCalled() + }) + + it("does not parse prose when no tools were offered", async () => { + await spec.handle({op: "generate", messages: []}, io) + io.handler({type: "result", text: '{"name":"x"}', toolCalls: null}) + const result = io.emit.mock.calls.map((c) => c[0]).find((f) => f.type === "result") + expect(result.toolCalls).toBeNull() + expect(lib.parseToolCalls).not.toHaveBeenCalled() + }) + + it("forwards only sampling overrides, never provider / apiKey / model / url", async () => { + await spec.handle( + { + op: "generate", + messages: [], + temperature: 0.2, + topP: 0.9, + provider: "openrouter", + apiKey: "sk-evil", + model: "gpt", + url: "https://attacker", + config: {apiKey: "sk-evil"}, + }, + io + ) + const [, opts] = lib.prepareGenerate.mock.calls[0] + expect(opts.overrides).toEqual({temperature: 0.2, topP: 0.9, topk: undefined, maxNewTokens: undefined}) + expect(Object.keys(opts.overrides)).not.toContain("provider") + expect(Object.keys(opts.overrides)).not.toContain("apiKey") + }) + + it("applies the provider-conditional system map and toolToggles before preparing", async () => { + lib = fakeLib({provider: "local", toolToggles: {off: false, optin: true}}) + spec = makeLLMWorkerSpec(lib) + await spec.handle( + { + op: "generate", + messages: [], + system: {default: "d", local: "L"}, + tools: [{name: "on"}, {name: "off"}, {name: "optin", defaultOff: true}, {name: "hidden", defaultOff: true}], + }, + io + ) + const [, opts] = lib.prepareGenerate.mock.calls[0] + expect(opts.system).toBe("L") + expect(opts.tools.map((t) => t.name)).toEqual(["on", "optin"]) + }) + + it("posts the library-built payload tagged with the transport's workerId", async () => { + const token = await spec.handle({op: "generate", messages: [{role: "user", content: "hi"}], sessionKey: "s1"}, io) + expect(token).toEqual({sessionKey: "s1"}) + expect(io.post).toHaveBeenCalledWith( + expect.objectContaining({type: "generate", id: "w1", sessionKey: "s1", messages: [{role: "user", content: "hi"}]}) + ) + }) + + it("runs builtin in-realm and still fills toolCalls", async () => { + lib = fakeLib({provider: "builtin", toolToggles: {}}) + lib.builtinGenerate = vi.fn(async () => "{}") + spec = makeLLMWorkerSpec(lib) + await spec.handle({op: "generate", messages: [], tools: [{name: "x"}]}, io) + await flush() + expect(io.post).not.toHaveBeenCalled() + const result = io.emit.mock.calls.map((c) => c[0]).find((f) => f.type === "result") + expect(result.text).toBe("{}") + expect(result.toolCalls).toEqual([{name: "x", args: {}}]) + }) +}) diff --git a/llm-host/tsconfig.json b/llm-host/tsconfig.json new file mode 100644 index 00000000..4696f12f --- /dev/null +++ b/llm-host/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNEXT", + "module": "ESNEXT", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/llm-host/vite.config.ts b/llm-host/vite.config.ts new file mode 100644 index 00000000..886e68f2 --- /dev/null +++ b/llm-host/vite.config.ts @@ -0,0 +1,26 @@ +import {defineConfig} from "vite" +import solidPlugin from "vite-plugin-solid" +import patchworkBundles from "@chee/patchwork-bundles/vite" +import external from "@inkandswitch/patchwork-bootloader/externals" + +export default defineConfig({ + base: "./", + // Rewrite the automerge:-versioned @chee/patchwork-llm dep to a shared + // service-worker URL resolved at runtime (cross-origin), so the LLM lib loads + // as one canonical copy. This tool runs HOST-only, so loading patchwork-llm + // here is fine. (The tray injects its own