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..e8503244 --- /dev/null +++ b/libraries/patchwork-worker/README.md @@ -0,0 +1,266 @@ +# @grjte/patchwork-worker + +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 — the same code in any realm +import { connectWorkerClient } from "@grjte/patchwork-worker/client"; +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", // serve half: how to run the worker + id: "search-index", + name: "Search Index", + async load() { + return makeWorkerSpec(); // -> 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, ...} + }, + }, +]; +``` + +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**, +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 `"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. + +Concrete service integrations, including the LLM architecture, are outside this library and are documented by their owning packages. + +## Why this exists + +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 + +``` + worker owner (host realm) consumer (any realm) + ------------------------- -------------------- + {type:"patchwork:worker", const {readable, writable, disconnect} + id:"kind", load: () => spec} = await connectWorker("kind", {element}) + spec = {createWorker, open?, handle, abort?} + → serveWorkerSpec(spec, {element}) + -> {readable, writable} + + writable <--- request frames {id, op, ...} ---- (consumer writes) + readable ---- event frames {id, type, ...} ---> (consumer reads) +``` + +- **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, loads its `WorkerSpec`, and hands that to `serveWorkerSpec`, which it + 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"`, + 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 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 +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 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 + +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 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): + 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. 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) + + 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 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**, 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}) 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 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` **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 +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), + `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`. +- `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. +- `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 + +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 new file mode 100644 index 00000000..67aaf9e6 --- /dev/null +++ b/libraries/patchwork-worker/client.js @@ -0,0 +1,45 @@ +// Resolve a worker's typed client from the `patchwork:worker-client` registry +// and bind it to a session, so consumers never import the service package. + +import {getRegistry} from "@inkandswitch/patchwork-plugins" +import {openSession, WORKER_CLIENT_PLUGIN_TYPE} from "./connect.js" + +// `loadWhenReady` waits forever for a late registration; this bounds it. +const LOAD_TIMEOUT_MS = 10000 + +/** + * @typedef {import("./session.js").Session} WorkerSession + * @typedef {(session: WorkerSession) => any} WorkerClientFactory + * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin + */ + +/** + * @param {string} kind + * @param {{sessionOpts?: import("./session.js").SessionOpts, timeoutMs?: number}} [opts] + * @returns {Promise} + */ +export async function connectWorkerClient(kind, opts = {}) { + /** @type {ReturnType | undefined} */ + let timer + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out loading worker-client plugin "${kind}"`)), + opts.timeoutMs ?? LOAD_TIMEOUT_MS + ) + }) + /** @type {any} */ + let plugin + try { + plugin = await Promise.race([ + getRegistry(WORKER_CLIENT_PLUGIN_TYPE).loadWhenReady(kind), + timeout, + ]) + } finally { + clearTimeout(timer) + } + const factory = 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/connect.js b/libraries/patchwork-worker/connect.js new file mode 100644 index 00000000..11750e3f --- /dev/null +++ b/libraries/patchwork-worker/connect.js @@ -0,0 +1,80 @@ +// Consumer half: discover a provider for a worker `kind` and receive a +// transferable `{readable, writable}` pair. Registry-free, so sandboxes load +// only what they need. No local fallback: without a provider, it rejects. + +import {subscribe} from "@inkandswitch/patchwork-providers" +import {createOpenSession} from "./session.js" + +// Strings, not Symbols, so they still match if this module loads twice. +export const CHANNEL_SELECTOR = "patchwork:worker-channel" +export const WORKER_PLUGIN_TYPE = "patchwork:worker" +export const WORKER_CLIENT_PLUGIN_TYPE = "patchwork:worker-client" + +// Backstop: an unanswered subscribe never settles. +const DISCOVERY_TIMEOUT_MS = 8000 + +/** @typedef {{readable: ReadableStream, writable: WritableStream}} WorkerStreams */ +/** @typedef {WorkerStreams & {disconnect: () => void}} WorkerConnection */ +/** @typedef {import("./serve.js").WorkerSpec} WorkerSpec */ +/** @typedef {{type: "patchwork:worker", id: string, name?: string, load: () => Promise}} WorkerPlugin */ + +/** + * @param {string} kind + * @param {{element: HTMLElement}} opts a node inside a mounted + * @returns {Promise} + */ +export async function connectWorker(kind, opts) { + const element = opts?.element + if (!element) { + throw new Error(`no worker available for kind "${kind}": no element to discover a provider from`) + } + const streams = await discover(element, kind) + if (!streams) throw new Error(`no worker available for kind "${kind}"`) + return { + ...streams, + // Only valid while the caller holds no reader/writer lock. + disconnect() { + streams.readable.cancel().catch(() => {}) + streams.writable.abort().catch(() => {}) + }, + } +} + +/** + * Resolves the first answer, or null on refusal or timeout. + * @param {HTMLElement} element + * @param {string} kind + * @returns {Promise} + */ +function discover(element, kind) { + return new Promise((resolve) => { + let settled = false + /** @type {(() => void) | undefined} */ + let unsubscribe + const stop = () => { + try { + unsubscribe?.() + } catch {} + } + const finish = (/** @type {WorkerStreams | null} */ value) => { + if (settled) return + settled = true + clearTimeout(timer) + stop() + resolve(value) + } + const timer = setTimeout(() => finish(null), DISCOVERY_TIMEOUT_MS) + + // Cast: `subscribe` is typed for JSON values; a stream pair isn't one. + unsubscribe = /** @type {any} */ (subscribe)( + element, + {type: CHANNEL_SELECTOR, kind}, + (/** @type {any} */ value) => + finish(value?.readable && value?.writable ? {readable: value.readable, writable: value.writable} : null) + ) + // The provider may have answered synchronously, before we had `unsubscribe`. + if (settled) stop() + }) +} + +export const openSession = createOpenSession(connectWorker) diff --git a/libraries/patchwork-worker/index.js b/libraries/patchwork-worker/index.js new file mode 100644 index 00000000..8c197d9d --- /dev/null +++ b/libraries/patchwork-worker/index.js @@ -0,0 +1,32 @@ +/** + * @grjte/patchwork-worker — run a worker in the host realm and hand any consumer + * a transferable stream pair, the same inside or outside an isolation boundary. + * + * connect.js consumer transport: `connectWorker`, `openSession`, constants + * session.js request/response multiplexing over a connection + * serve.js host-only serving half, driven by `patchwork-worker-provider` + * client.js `connectWorkerClient` — typed client from the plugin registry + * + * Nothing here may hold host-only state or secrets: consumers load these files + * into their own (possibly sandboxed) realm. + */ + +export { + connectWorker, + openSession, + CHANNEL_SELECTOR, + WORKER_PLUGIN_TYPE, + WORKER_CLIENT_PLUGIN_TYPE, +} from "./connect.js" + +export {connectWorkerClient} from "./client.js" + +/** + * @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 + */ diff --git a/libraries/patchwork-worker/package.json b/libraries/patchwork-worker/package.json new file mode 100644 index 00000000..2ecc47f5 --- /dev/null +++ b/libraries/patchwork-worker/package.json @@ -0,0 +1,57 @@ +{ + "name": "@grjte/patchwork-worker", + "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", + "types": "./types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./index.js" + }, + "./connect": { + "types": "./types/connect.d.ts", + "default": "./connect.js" + }, + "./session": { + "types": "./types/session.d.ts", + "default": "./session.js" + }, + "./serve": { + "types": "./types/serve.d.ts", + "default": "./serve.js" + }, + "./client": { + "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..22dc9432 --- /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.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.2(@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.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==} + 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.2': + resolution: {integrity: sha512-jTR6aOPX4o5uj2OA45hcJ0A/K5fhQJPtEY+B2z9xaBWCgktZ/OLKWV2IQeQ2E98r++gu7BV/2f8tP+KwTLWJJA==} + 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.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/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.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 + + 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 + + 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.33.2: + resolution: {integrity: sha512-8tC7yXgeCvpT8gKEeEje6ikJJG1wpnoLiXe+HfECW8m10ubMd3QxKOwWA8KxPJVBcLwfdRoMKYxIBGYKmo37/A==} + +snapshots: + + '@automerge/automerge-repo@2.6.0-alpha.3': + dependencies: + '@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.33.2 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge@3.5.0': {} + + '@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.5.0)': + dependencies: + '@automerge/automerge': 3.5.0 + '@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.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.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.5.0) + '@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.2(@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.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/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.19: {} + + 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.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 + + 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.4 + 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.33.2: {} 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..c076ac7d --- /dev/null +++ b/libraries/patchwork-worker/serve.js @@ -0,0 +1,201 @@ +// Serving half, the mirror of `openSession`. A service supplies a WorkerSpec; +// this owns the stream pair, one dedicated Worker per connection, id demux, the +// reserved `op:"abort"`, and teardown. Worker messages without an `id` are +// emitted straight onto the readable. + +// `spec.open` may never settle (e.g. an unanswered providers `request()`); +// after this, frames are served with `state: null`. +const OPEN_TIMEOUT_MS = 5000 + +/** + * @typedef {(msg: any, transfer?: Transferable[]) => void} Post + * @typedef {(frame: any) => void} Emit + * + * @typedef {Object} IO + * @property {Post} post send to the worker + * @property {Emit} emit enqueue a frame (tagged with the caller id) onto the readable + * @property {(fn: (msg: any) => boolean | void) => void} on handle worker messages + * tagged `workerId`; return truthy when done. Without `on`, a request is + * fire-and-forget and can't be aborted. + * @property {string} workerId + * @property {any} state what `spec.open` resolved, or null + * @property {{element?: HTMLElement}} ctx + * + * @typedef {Object} WorkerSpec + * @property {() => Worker | Promise} createWorker + * @property {(ctx: {element?: HTMLElement}) => any} [open] + * @property {(frame: any, io: IO) => any} handle returns an abort token + * @property {(token: any, post: Post) => void} [abort] + */ + +let idSeq = 0 + +/** + * @param {WorkerSpec} spec + * @param {{element?: HTMLElement}} [ctx] + * @returns {{readable: ReadableStream, writable: WritableStream}} + */ +export function serveWorkerSpec(spec, ctx = {}) { + /** @type {Promise | null} */ + let workerReady = null + /** @type {Worker | null} */ + let worker = null + /** @type {ReadableStreamDefaultController | null} */ + let controller = null + let closed = false + /** worker id -> reply handler @type {Map boolean | void>} */ + const handlers = new Map() + /** + * caller id -> request. Entered synchronously on arrival so an abort that + * races in before `handle` returns is still honoured. + * @type {Map} + */ + const requests = new Map() + + const emit = (/** @type {any} */ frame) => { + try { + controller?.enqueue(frame) + } catch {} + } + + const openState = spec.open + ? Promise.race([ + Promise.resolve().then(() => spec.open?.(ctx)), + new Promise((resolve) => setTimeout(resolve, OPEN_TIMEOUT_MS, null)), + ]).catch(() => null) + : Promise.resolve(null) + + /** @type {Post} */ + const post = (msg, transfer) => { + if (closed) return + getWorker() + .then((w) => w.postMessage(msg, transfer || [])) + .catch(() => {}) + } + + const abortToken = (/** @type {any} */ token) => { + try { + spec.abort?.(token, post) + } catch {} + } + + // A worker that can't be constructed, or dies, ends the connection. + function getWorker() { + if (workerReady) return workerReady + workerReady = Promise.resolve() + .then(() => spec.createWorker()) + .then((w) => { + worker = w + w.onmessage = (ev) => dispatch(ev.data) + w.onerror = teardown + w.onmessageerror = teardown + return w + }) + workerReady.catch(teardown) + return workerReady + } + + function dispatch(/** @type {any} */ msg) { + if (!msg || closed) return + if (msg.id == null) return emit(msg) + if (handlers.get(msg.id)?.(msg)) handlers.delete(msg.id) + } + + async function handleFrame(/** @type {any} */ frame) { + if (!frame || typeof frame !== "object") return + const {id} = frame + + if (frame.op === "abort") { + const req = requests.get(id) + if (!req) return + if (!req.ready) { + req.aborted = true + return + } + requests.delete(id) + abortToken(req.token) + return + } + + const req = {token: undefined, ready: false, aborted: false} + requests.set(id, req) + const state = await openState + if (closed) return + + const workerId = `wk-${++idSeq}-${performance.now() | 0}` + let tracked = false + let done = false + + /** @type {IO} */ + const io = { + post, + emit: (f) => emit({...f, id}), + on(fn) { + tracked = true + handlers.set(workerId, (msg) => { + const finished = fn(msg) + if (finished) { + done = true + requests.delete(id) + } + return finished + }) + }, + workerId, + state, + ctx, + } + + try { + const token = await spec.handle(frame, io) + if (closed) return + if (req.aborted) { + requests.delete(id) + handlers.delete(workerId) + abortToken(token) + } else if (tracked && !done) { + req.token = token + req.ready = true + } else { + requests.delete(id) + } + } catch (e) { + emit({id, type: "error", message: /** @type {any} */ (e)?.message || String(e)}) + handlers.delete(workerId) + requests.delete(id) + } + } + + function teardown() { + if (closed) return + closed = true + handlers.clear() + requests.clear() + // Close the readable too, in case teardown came from the writable or the worker. + try { + controller?.close() + } catch {} + controller = null + 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/session.js b/libraries/patchwork-worker/session.js new file mode 100644 index 00000000..a09f9092 --- /dev/null +++ b/libraries/patchwork-worker/session.js @@ -0,0 +1,189 @@ +// Request/response multiplexing over a worker connection. The connection opens +// lazily, is shared by every request, and is dropped when it fails or ends so +// the next request reconnects. Frames with an `id` go to that request; frames +// without one are broadcast to every in-flight request. + +/** + * @typedef {Object} RequestOpts + * @property {Record any>} terminal frame.type -> settle; + * the return value resolves the request, a throw rejects it + * @property {(frame: any) => void} [onFrame] non-terminal and broadcast frames + * @property {AbortSignal} [signal] + * @property {HTMLElement} [element] discovery element, if not set on the session + * + * @typedef {Object} SessionOpts + * @property {HTMLElement} [element] + * @property {string} [idPrefix] defaults to the kind + * @property {(...a: any[]) => void} [onLog] + * + * @typedef {{readable: ReadableStream, writable: WritableStream, disconnect: () => void}} Connection + * @typedef {{writer: WritableStreamDefaultWriter, reader: ReadableStreamDefaultReader}} OpenConnection + * + * @typedef {Object} Session + * @property {(frame: any, opts: RequestOpts) => {promise: Promise, abort: () => void}} request + * @property {() => void} close drop the connection and reject in-flight requests + */ + +/** + * Injected rather than imported to avoid a cycle with connect.js. + * @param {(kind: string, opts: {element: HTMLElement}) => Promise} connectWorker + */ +export function createOpenSession(connectWorker) { + /** + * @param {string} kind + * @param {SessionOpts} [sessionOpts] + * @returns {Session} + */ + return function openSession(kind, sessionOpts = {}) { + const idPrefix = sessionOpts.idPrefix || kind + const log = sessionOpts.onLog || (() => {}) + + /** @type {Promise | null} */ + let connection = null + /** @type {Map void, onClosed: (cause: any) => void}>} */ + const handlers = new Map() + let idSeq = 0 + + /** + * Uncache `conn` (if still current) and fail everything in flight on it. + * @param {Promise | null} conn + * @param {any} cause + */ + function reset(conn, cause) { + if (conn !== connection) return + connection = null + const inFlight = [...handlers.values()] + handlers.clear() + for (const h of inFlight) h.onClosed(cause) + } + + /** @param {HTMLElement | undefined} element */ + function ensureConnection(element) { + if (connection) return connection + const conn = connectWorker(kind, { + element: /** @type {HTMLElement} */ (element ?? sessionOpts.element), + }).then(({readable, writable}) => ({writer: writable.getWriter(), reader: readable.getReader()})) + connection = conn + conn.then(({reader}) => pump(conn, reader), (e) => reset(conn, e)) + return conn + } + + /** + * @param {Promise} conn + * @param {ReadableStreamDefaultReader} reader + */ + async function pump(conn, reader) { + /** @type {any} */ + let cause = null + try { + while (true) { + const {value, done} = await reader.read() + if (done || conn !== connection) break + if (!value) continue + if (value.id != null) handlers.get(value.id)?.onFrame(value) + else for (const h of [...handlers.values()]) h.onFrame(value) + } + } catch (e) { + cause = e + log("connection readable errored", e) + } finally { + reset(conn, cause) + } + } + + /** + * @param {any} frame + * @param {HTMLElement | undefined} element + */ + async function send(frame, element) { + const {writer} = await ensureConnection(element) + await writer.write(frame) + } + + /** + * @param {any} frame + * @param {RequestOpts} opts + */ + function request(frame, opts) { + const id = `${idPrefix}-${++idSeq}-${performance.now() | 0}` + const terminal = opts.terminal || {} + let settled = false + /** @type {(v: any) => void} */ + let resolve = () => {} + /** @type {(e: any) => void} */ + let reject = () => {} + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + /** Settle once, via `fn`; a throw from `fn` rejects. */ + const settle = (/** @type {() => any} */ fn) => { + if (settled) return + settled = true + handlers.delete(id) + opts.signal?.removeEventListener("abort", abort) + try { + resolve(fn()) + } catch (e) { + reject(e) + } + } + + function abort() { + // Don't open a connection just to abort a request that was never sent. + if (!settled && connection) send({op: "abort", id}, opts.element).catch(() => {}) + settle(() => { + throw new DOMException("Aborted", "AbortError") + }) + } + + handlers.set(id, { + onFrame(f) { + const done = terminal[f.type] + if (done) return settle(() => done(f)) + try { + opts.onFrame?.(f) + } catch (e) { + log("onFrame threw", e) + } + }, + onClosed(cause) { + settle(() => { + throw cause instanceof Error + ? cause + : new Error(`worker connection for "${kind}" closed before the request completed`) + }) + }, + }) + + if (opts.signal?.aborted) { + abort() + return {promise, abort} + } + opts.signal?.addEventListener("abort", abort) + + send({...frame, id}, opts.element).catch((e) => + settle(() => { + throw e + }) + ) + return {promise, abort} + } + + function close() { + const conn = connection + if (!conn) return + reset(conn, new Error(`worker connection for "${kind}" was closed`)) + // Release through our locks; ending the writer terminates the host worker. + conn + .then(({reader, writer}) => { + reader.cancel().catch(() => {}) + writer.abort().catch(() => {}) + }) + .catch(() => {}) + } + + return {request, close} + } +} diff --git a/libraries/patchwork-worker/tests/client.test.js b/libraries/patchwork-worker/tests/client.test.js new file mode 100644 index 00000000..7c3e4f23 --- /dev/null +++ b/libraries/patchwork-worker/tests/client.test.js @@ -0,0 +1,75 @@ +import {describe, it, expect, afterEach, vi} from "vitest" + +// 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} = 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. + expect(typeof seen.request).toBe("function") + expect(typeof seen.close).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/ + ) + }) + +}) diff --git a/libraries/patchwork-worker/tests/connect.test.js b/libraries/patchwork-worker/tests/connect.test.js new file mode 100644 index 00000000..8d702cf1 --- /dev/null +++ b/libraries/patchwork-worker/tests/connect.test.js @@ -0,0 +1,371 @@ +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, 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. +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()) + .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) +} + +/** 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 + 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} +} + +/** 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 +} + +let cleanups = [] +afterEach(() => { + cleanups.forEach((f) => f()) + cleanups = [] + vi.useRealTimers() +}) + +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 +} + +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 the transferred streams are live", async () => { + const kind = "echo-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + + 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"}) + 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 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"}) + 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 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: mountElement()})).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 — 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", () => { + it("resolves on the terminal frame and streams the rest", async () => { + const kind = "echo-" + Math.random() + cleanups.push(serveKinds({[kind]: echoWorker})) + + const session = openSession(kind, {element: mountElement()}) + 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 () => { + // 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 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/) + 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, 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() + const workers = [] + cleanups.push( + serveKinds({ + [kind]: () => { + const w = manualWorker() + workers.push(w) + return w + }, + }) + ) + const session = openSession(kind, {element: mountElement()}) + + 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/) + + 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") + }) + + 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("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}) + }, + } + cleanups.push(serveKinds({[kind]: () => serveWorkerSpec(spec, {})})) + const session = openSession(kind, {element: mountElement()}) + + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + await tick() + expect(workers).toHaveLength(1) + session.close() + await expect(first.promise).rejects.toThrow(/was closed/) + await tick() + expect(workers[0].terminated).toBe(true) + + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + second.promise.catch(() => {}) + await tick() + expect(workers).toHaveLength(2) + session.close() + }) + + it("a closed connection's shutdown does not break the one that replaced it", async () => { + // close() then an immediate request: the old connection's pump ends after the + // new one has opened, and must not uncache it or fail its requests. + const kind = "replace-" + Math.random() + const workers = [] + cleanups.push( + serveKinds({ + [kind]: () => { + const w = manualWorker() + workers.push(w) + return w + }, + }) + ) + const session = openSession(kind, {element: mountElement()}) + + const first = session.request({op: "generate"}, {terminal: TERMINAL}) + first.promise.catch(() => {}) + await tick() + session.close() + const second = session.request({op: "generate"}, {terminal: TERMINAL}) + await tick() + expect(workers).toHaveLength(2) + workers[1].push({id: workers[1].written[0].id, type: "result", text: "ok"}) + await expect(second.promise).resolves.toBe("ok") + + // ...and the session still reuses that connection. + const third = session.request({op: "generate"}, {terminal: TERMINAL}) + await tick() + expect(workers).toHaveLength(2) + workers[1].push({id: workers[1].written[1].id, type: "result", text: "again"}) + await expect(third.promise).resolves.toBe("again") + }) + + 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 () => { + 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: mountElement()}) + const {promise} = session.request( + {op: "generate"}, + {terminal: TERMINAL, signal: AbortSignal.abort()} + ) + await expect(promise).rejects.toThrow(/Aborted/) + expect(dispatches).toBe(0) + }) +}) + +describe("package shape", () => { + 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/tests/serve.test.js b/libraries/patchwork-worker/tests/serve.test.js new file mode 100644 index 00000000..37f62159 --- /dev/null +++ b/libraries/patchwork-worker/tests/serve.test.js @@ -0,0 +1,346 @@ +import {describe, it, expect, vi, afterEach} 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, + /** @type {((ev:any)=>void)|null} */ + onerror: null, + /** @type {((ev:any)=>void)|null} */ + onmessageerror: 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}) + }, + /** simulate the worker crashing */ + crash() { + w.onerror?.({message: "boom"}) + }, + } + 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 = [] + let ended = false + ;(async () => { + try { + for (;;) { + const {value, done} = await reader.read() + if (done) break + 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)) + }, + } +} + +/** 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 conn = drive(serveWorkerSpec(echoSpec(w), {})) + await conn.write({id: "req-1", op: "generate", text: "hi"}) + await conn.settle() + expect(w.posted).toHaveLength(1) + 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).toEqual([{id: "req-1", type: "result", text: "hi"}]) + }) + + 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) => { + seenCtx = io.ctx + io.post({type: "x", id: io.workerId}) + }, + } + 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("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 conn = drive(serveWorkerSpec(echoSpec(w), {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + w.crash() + await conn.settle() + expect(w.terminated).toBe(true) + expect(conn.ended()).toBe(true) + }) + + 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() + expect(conn.ended()).toBe(true) + }) + + it("routes op:abort to spec.abort with the stored token, once", async () => { + const w = fakeWorker() + const aborted = [] + const spec = { + ...echoSpec(w), + 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.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"}]) + 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", 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 = { + ...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 on open + await conn.write({id: "a", op: "abort"}) // before the token exists + await conn.settle() + expect(aborted).toEqual([]) + + releaseOpen(null) + await conn.settle() + expect(aborted).toEqual([{sessionKey: "a"}]) + await conn.write({id: "a", op: "abort"}) + await conn.settle() + expect(aborted).toEqual([{sessionKey: "a"}]) + }) + + it("forgets a request whose final reply arrives before handle returns", async () => { + const w = fakeWorker() + const aborted = [] + let release + const spec = { + createWorker: () => w, + async handle(frame, io) { + io.on(() => true) + io.post({id: io.workerId}) + await new Promise((r) => (release = r)) + return {sessionKey: frame.id} + }, + abort: (token) => void aborted.push(token), + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "generate"}) + await conn.settle() + w.reply({id: w.posted[0].msg.id}) // done, while handle is still pending + release() + await conn.settle() + await conn.write({id: "a", op: "abort"}) + await conn.settle() + expect(aborted).toEqual([]) + }) + + it("does nothing for a request whose handle returns after teardown", async () => { + const w = fakeWorker() + const aborted = [] + let release + const spec = { + createWorker: () => w, + async handle(frame, io) { + io.on(() => false) + await new Promise((r) => (release = r)) + return {sessionKey: frame.id} + }, + abort: (token) => void aborted.push(token), + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "generate"}) + await conn.write({id: "a", op: "abort"}) // deferred: no token yet + await conn.close() + release() + await conn.settle() + expect(aborted).toEqual([]) + }) + + it("treats an open that throws synchronously as state: null", async () => { + const spec = { + createWorker: () => fakeWorker(), + open: () => { + throw new Error("no settings") + }, + handle: (frame, io) => void io.emit({type: "ran", state: io.state}), + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await conn.settle() + expect(conn.frames).toEqual([{id: "a", type: "ran", state: null}]) + }) + + it("does not track a fire-and-forget request (no io.on), so op:abort ignores it", async () => { + const w = fakeWorker() + 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: "preload"}) + await conn.settle() + await conn.write({id: "a", op: "abort"}) + await conn.settle() + expect(aborted).toEqual([]) + }) + + it("creates no worker for a request that was still pending at teardown", async () => { + const createWorker = vi.fn(() => fakeWorker()) + let releaseOpen + const spec = { + 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.settle() + w.reply({type: "status", message: "downloading model"}) // no id + await conn.settle() + expect(conn.frames).toContainEqual({type: "status", message: "downloading model"}) + }) + + it("emits {type:error} tagged with the caller id when handle throws", async () => { + const spec = { + createWorker: () => fakeWorker(), + 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 () => { + vi.useFakeTimers() + const spec = { + createWorker: () => fakeWorker(), + open: () => new Promise(() => {}), // never resolves + handle: (frame, io) => void io.emit({type: "ran", state: io.state}), + } + const conn = drive(serveWorkerSpec(spec, {})) + await conn.write({id: "a", op: "go"}) + await vi.advanceTimersByTimeAsync(5001) + vi.useRealTimers() + await conn.settle() + expect(conn.frames).toContainEqual({id: "a", type: "ran", state: null}) + }) +}) diff --git a/libraries/patchwork-worker/tsconfig.json b/libraries/patchwork-worker/tsconfig.json new file mode 100644 index 00000000..e7de32b1 --- /dev/null +++ b/libraries/patchwork-worker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNEXT", + "module": "ESNEXT", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": true, + "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..c9f30e8b --- /dev/null +++ b/libraries/patchwork-worker/types/client.d.ts @@ -0,0 +1,22 @@ +/** + * @typedef {import("./session.js").Session} WorkerSession + * @typedef {(session: WorkerSession) => any} WorkerClientFactory + * @typedef {{type: "patchwork:worker-client", id: string, name?: string, load: () => Promise}} WorkerClientPlugin + */ +/** + * @param {string} kind + * @param {{sessionOpts?: import("./session.js").SessionOpts, timeoutMs?: number}} [opts] + * @returns {Promise} + */ +export function connectWorkerClient(kind: string, opts?: { + sessionOpts?: import("./session.js").SessionOpts; + timeoutMs?: number; +}): Promise; +export type WorkerSession = import("./session.js").Session; +export type WorkerClientFactory = (session: WorkerSession) => any; +export type WorkerClientPlugin = { + type: "patchwork:worker-client"; + id: string; + name?: string; + load: () => Promise; +}; diff --git a/libraries/patchwork-worker/types/connect.d.ts b/libraries/patchwork-worker/types/connect.d.ts new file mode 100644 index 00000000..ac139aad --- /dev/null +++ b/libraries/patchwork-worker/types/connect.d.ts @@ -0,0 +1,30 @@ +/** @typedef {{readable: ReadableStream, writable: WritableStream}} WorkerStreams */ +/** @typedef {WorkerStreams & {disconnect: () => void}} WorkerConnection */ +/** @typedef {import("./serve.js").WorkerSpec} WorkerSpec */ +/** @typedef {{type: "patchwork:worker", id: string, name?: string, load: () => Promise}} WorkerPlugin */ +/** + * @param {string} kind + * @param {{element: HTMLElement}} opts a node inside a mounted + * @returns {Promise} + */ +export function connectWorker(kind: string, opts: { + element: HTMLElement; +}): Promise; +export const CHANNEL_SELECTOR: "patchwork:worker-channel"; +export const WORKER_PLUGIN_TYPE: "patchwork:worker"; +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; +}; +export type WorkerConnection = WorkerStreams & { + disconnect: () => void; +}; +export type WorkerSpec = import("./serve.js").WorkerSpec; +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..78aebabc --- /dev/null +++ b/libraries/patchwork-worker/types/index.d.ts @@ -0,0 +1,9 @@ +export { connectWorkerClient } from "./client.js"; +export type WorkerSpec = import("./connect.js").WorkerSpec; +export type WorkerPlugin = import("./connect.js").WorkerPlugin; +export type WorkerStreams = import("./connect.js").WorkerStreams; +export type WorkerConnection = import("./connect.js").WorkerConnection; +export type WorkerClientPlugin = import("./client.js").WorkerClientPlugin; +export type WorkerClientFactory = import("./client.js").WorkerClientFactory; +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 new file mode 100644 index 00000000..bc9b8a89 --- /dev/null +++ b/libraries/patchwork-worker/types/serve.d.ts @@ -0,0 +1,48 @@ +/** + * @param {WorkerSpec} spec + * @param {{element?: HTMLElement}} [ctx] + * @returns {{readable: ReadableStream, writable: WritableStream}} + */ +export function serveWorkerSpec(spec: WorkerSpec, ctx?: { + element?: HTMLElement; +}): { + readable: ReadableStream; + writable: WritableStream; +}; +export type Post = (msg: any, transfer?: Transferable[]) => void; +export type Emit = (frame: any) => void; +export type IO = { + /** + * send to the worker + */ + post: Post; + /** + * enqueue a frame (tagged with the caller id) onto the readable + */ + emit: Emit; + /** + * handle worker messages + * tagged `workerId`; return truthy when done. Without `on`, a request is + * fire-and-forget and can't be aborted. + */ + on: (fn: (msg: any) => boolean | void) => void; + workerId: string; + /** + * what `spec.open` resolved, or null + */ + state: any; + ctx: { + element?: HTMLElement; + }; +}; +export type WorkerSpec = { + createWorker: () => Worker | Promise; + open?: ((ctx: { + element?: HTMLElement; + }) => any) | undefined; + /** + * returns an abort token + */ + 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..d824b07a --- /dev/null +++ b/libraries/patchwork-worker/types/session.d.ts @@ -0,0 +1,70 @@ +/** + * @typedef {Object} RequestOpts + * @property {Record any>} terminal frame.type -> settle; + * the return value resolves the request, a throw rejects it + * @property {(frame: any) => void} [onFrame] non-terminal and broadcast frames + * @property {AbortSignal} [signal] + * @property {HTMLElement} [element] discovery element, if not set on the session + * + * @typedef {Object} SessionOpts + * @property {HTMLElement} [element] + * @property {string} [idPrefix] defaults to the kind + * @property {(...a: any[]) => void} [onLog] + * + * @typedef {{readable: ReadableStream, writable: WritableStream, disconnect: () => void}} Connection + * @typedef {{writer: WritableStreamDefaultWriter, reader: ReadableStreamDefaultReader}} OpenConnection + * + * @typedef {Object} Session + * @property {(frame: any, opts: RequestOpts) => {promise: Promise, abort: () => void}} request + * @property {() => void} close drop the connection and reject in-flight requests + */ +/** + * Injected rather than imported to avoid a cycle with connect.js. + * @param {(kind: string, opts: {element: HTMLElement}) => Promise} connectWorker + */ +export function createOpenSession(connectWorker: (kind: string, opts: { + element: HTMLElement; +}) => Promise): (kind: string, sessionOpts?: SessionOpts) => Session; +export type RequestOpts = { + /** + * frame.type -> settle; + * the return value resolves the request, a throw rejects it + */ + terminal: Record any>; + /** + * non-terminal and broadcast frames + */ + onFrame?: ((frame: any) => void) | undefined; + signal?: AbortSignal | undefined; + /** + * discovery element, if not set on the session + */ + element?: HTMLElement | undefined; +}; +export type SessionOpts = { + element?: HTMLElement | undefined; + /** + * defaults to the kind + */ + idPrefix?: string | undefined; + onLog?: ((...a: any[]) => void) | undefined; +}; +export type Connection = { + readable: ReadableStream; + writable: WritableStream; + disconnect: () => void; +}; +export type OpenConnection = { + writer: WritableStreamDefaultWriter; + reader: ReadableStreamDefaultReader; +}; +export type Session = { + request: (frame: any, opts: RequestOpts) => { + promise: Promise; + abort: () => void; + }; + /** + * drop the connection and reject in-flight requests + */ + close: () => void; +}; 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, + }, +});