diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f4fd66..bab8b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,8 +105,8 @@ jobs: working-directory: conformance run: pnpm turbo run _test - ts-core-verify: - name: ts/packages/core Verify + ts-verify: + name: ts/ Verify runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -133,6 +133,10 @@ jobs: working-directory: ts run: pnpm turbo run _typecheck + - name: Build every package (cloudflare-hub's wrangler deploy --dry-run validates the Worker bundle without credentials) + working-directory: ts + run: pnpm turbo run _build + - name: Confirm generate.ts's output matches the committed generated schema working-directory: ts/packages/core run: | @@ -192,7 +196,7 @@ jobs: required-checks: name: Required Checks - needs: [cddl-validate, conformance-verify, ts-core-verify, rust-verify] + needs: [cddl-validate, conformance-verify, ts-verify, rust-verify] if: always() runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index f1b8ead..3936d78 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ !ts/packages/core/dist/ !ts/packages/core/dist/** rust/target/ +.wrangler/ diff --git a/README.md b/README.md index 450f0b1..4253c43 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ ts/ `ts/packages/core` exists: a ports/adapters implementation (Transport, Storage, Identity/crypto, and Clock as first-class ports) consuming Zod schemas generated from `spec/protocol.cddl` by [cddl.js](https://github.com/ExaDev/cddl.js), with real domain logic for handshake negotiation and capability-token verification (including the delegation-chain narrowing rules `tokens.cddl` documents); its `conformance-check` round-trips every vector in `conformance/`'s golden suite through the generated schemas. +`ts/packages/cloudflare-hub` exists: a reference Cloudflare Worker deployment of a public, always-on hub node, depending on core as an ordinary workspace consumer — Worker-shaped adapters for core's ports (WebSocket-message Transport, Web Crypto Identity with signature interop proven against core's Node adapter in both directions) rather than any reinvented protocol logic, serving the relay role with gossip-based pairing. CI validates the bundle with `wrangler deploy --dry-run`; a real deploy is an authenticated one-off, not part of CI. + `rust/` exists too, mirroring the same architecture: `wire-mesh-wire` carries the CDDL model as hand-written types over a minicbor codec with CDE (canonical) encoding by construction and strict decoding (unknown keys, indefinite lengths, and non-canonical shapes all rejected), and `wire-mesh-core` carries the ports (Transport, KeyValueStorage, Identity, Clock), the same domain logic (handshake negotiation, capability-token verification with narrowing/expiry-clamping and the signed-revocation obligations, including the ancestor-chain sweep), and Tokio TCP / in-memory / Ed25519+ES256 adapters. Its `conformance-check` binary proves every golden vector round-trips byte-exactly in both directions. There is no CDDL-to-Rust generator (cddl.js emits TypeScript/Zod only), so the wire types are hand-written against `spec/*.cddl` — the frozen vectors are the cross-language pin, exactly why they exist. - **[Cascade](https://github.com/Mearman/cascade)** refactors its own hand-written protocol code onto `rust/` as an ordinary Cargo dependency, rather than maintaining a parallel implementation. diff --git a/ts/packages/cloudflare-hub/README.md b/ts/packages/cloudflare-hub/README.md new file mode 100644 index 0000000..20ddcae --- /dev/null +++ b/ts/packages/cloudflare-hub/README.md @@ -0,0 +1,33 @@ +# @exadev/wire-mesh-cloudflare-hub + +A reference deployment of a wire-mesh hub node on Cloudflare Workers: a public, always-on node other peers dial into, serving the relay role the repo README names for this package (transport.cddl's `relay-offer`/`relay-connect`/`relay-data`/`relay-inbound` — an opaque byte pipe when two peers can't connect directly, with device discovery over gossip). It depends on `@exadev/wire-mesh-core` as an ordinary workspace consumer and reinvents nothing the core owns: all protocol logic is core's, reached through its ports. + +## Why the hub lives in a Durable Object + +A plain Worker's request context cannot host the hub: each connection is driven by a long-lived pull loop (`for await` over the `receive()` iteration, parked on a pure-JS waiter), and workerd's hang detection cancels any request whose promise chain parks that way — empirically, the original plain-Worker entry relayed zero frames across every run while looking alive (the socket-level listeners still fired; `wrangler deploy --dry-run` passed because bundling executes nothing). A Durable Object is the documented home for exactly this shape: its lifetime is tied to the accepted WebSockets rather than to a single fetch. + +So the entrypoint (`src/worker.ts`) defines `RelayHubDurableObject` directly (wrangler resolves the binding against the entrypoint's own exports) — it owns the hub state, accepts the server side of the runtime's `WebSocketPair` on each upgrade, and drives the hub per connection, while the default export stays a thin router forwarding upgrades to the single named DO instance. The DO is SQLite-backed (hibernation-capable) per current wrangler guidance, though it keeps connections alive for its own lifetime rather than hibernating — see the deferrals below. + +## How it maps onto core's ports + +The ports architecture is what makes a Worker possible at all — Workers have no `net.Server`, no full `node:crypto`, no filesystem, so core's Node adapters can't run there and don't try to. This package supplies Worker-shaped implementations for the same contracts: + +- **Connection** (`src/adapters/websocket-transport.ts`): WebSocket messages instead of TCP streams. Each binary WebSocket message is self-delimiting, so one message carries exactly one CBOR frame with no length prefix. Undecodable bytes and non-binary messages reject that connection; a decodable but schema-invalid frame drops without disconnecting — mirroring core's TCP adapter's split between connection-level and frame-level failure. +- **Identity** (`src/adapters/web-crypto-identity.ts`): Web Crypto (`crypto.subtle`) ECDSA P-256, deriving `device-id` as SHA-256 of the raw public-key bytes — never certificate DER. The test suite proves signature interop with core's Node identity adapter in both directions. + +The hub domain logic itself (`src/hub.ts`) is deliberately thin and transport-agnostic (it runs unchanged over core's TCP adapter in tests): pairing `relay-connect` initiators with gossiped targets and forwarding `relay-data` both ways, tearing down both sides of a pairing when either endpoint re-pairs or disconnects, and moving a device's mapping when a newer gossip arrives on a fresher connection. + +## What is real versus deferred + +Real and tested: the WebSocket connection adapter (hostile-input behaviour included), the Web Crypto identity adapter with cross-adapter signature interop, the relay pairing logic — and the full entry path verified against the real workerd runtime, not just bundling: `pnpm dev` plus `node scripts/live-check.mjs` drives two genuine WebSocket clients through gossip → relay-connect → relay-inbound → bidirectional relay-data and asserts every hop, exiting non-zero and naming the failing step if the runtime ever regresses to the hang-cancellation behaviour. Unit tests additionally drive the hub over the real adapter, not only fakes. + +Deferred deliberately: + +- **Hibernation** — the DO keeps its connections alive for its own lifetime, which is correct but not the idle-cost optimum; the hibernation API (`state.acceptWebSocket` + `webSocketMessage` handlers) is the follow-up. +- **Raw TCP ingress** via `cloudflare:sockets` — the WebSocket ingress is the sound first pass; raw TCP is a follow-up adapter behind the same Connection contract. +- **The announcer role** (the second role the repo README names: `discovery.cddl`'s `mailboxes` — holding peers' handle-records as `core/data` entries) needs a Storage port adapter over KV or Durable Object storage. +- **A real deployment** — `wrangler deploy --dry-run --outdir=dist` (the `_build` task, and what CI runs) validates the bundle without Cloudflare credentials; an actual deploy needs `wrangler deploy` with an authenticated account and is not part of CI. + +## Type environment + +`src/` is pure Worker code and typechecks against `@cloudflare/workers-types` alone. The tests run in Node under vitest but import that src, so the test tsconfig loads both type packages — under which the `Buffer` global's overloads resolve as `any` for eslint, which is why the test helpers construct bytes from hex with a plain loop instead of `Buffer.from(hex, "hex")`. diff --git a/ts/packages/cloudflare-hub/eslint.config.ts b/ts/packages/cloudflare-hub/eslint.config.ts new file mode 100644 index 0000000..4bb3eee --- /dev/null +++ b/ts/packages/cloudflare-hub/eslint.config.ts @@ -0,0 +1,36 @@ +import { exadevConfig } from "@exadev/eslint-config"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import globals from "globals"; + +export default exadevConfig( + {}, + { + // scripts/ holds plain-JS operational scripts (the live-runtime check) with no TS project to type them against. + ignores: [ + "dist", + "coverage", + "node_modules", + ".turbo", + ".wrangler", + "scripts", + ], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, + }, + globals: { ...globals.node }, + }, + }, + { + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], + }, + }, + eslintPluginPrettierRecommended, +); diff --git a/ts/packages/cloudflare-hub/package.json b/ts/packages/cloudflare-hub/package.json new file mode 100644 index 0000000..d715b1a --- /dev/null +++ b/ts/packages/cloudflare-hub/package.json @@ -0,0 +1,37 @@ +{ + "name": "@exadev/wire-mesh-cloudflare-hub", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.33.0", + "scripts": { + "build": "turbo run _build", + "_build": "wrangler deploy --dry-run --outdir=dist", + "dev": "wrangler dev", + "test": "turbo run _test", + "_test": "vitest run", + "typecheck": "turbo run _typecheck", + "_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "lint": "turbo run _lint", + "_lint": "eslint . --fix --cache --max-warnings 0" + }, + "dependencies": { + "@exadev/wire-mesh-core": "workspace:*", + "cbor2": "2.3.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260905.1", + "@exadev/eslint-config": "2.10.6", + "@types/node": "26.4.1", + "eslint": "10.10.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-prettier": "5.5.6", + "globals": "17.12.0", + "jiti": "2.7.0", + "prettier": "3.9.6", + "turbo": "2.10.12", + "typescript": "6.0.3", + "vitest": "5.0.0", + "wrangler": "4.42.0" + } +} diff --git a/ts/packages/cloudflare-hub/scripts/live-check.mjs b/ts/packages/cloudflare-hub/scripts/live-check.mjs new file mode 100644 index 0000000..9555a0c --- /dev/null +++ b/ts/packages/cloudflare-hub/scripts/live-check.mjs @@ -0,0 +1,143 @@ +// Live-runtime verification: drives the hub end to end against a real `wrangler dev` workerd process -- two genuine WebSocket clients exchanging gossip, relay-connect, and relay-data through the Durable Object. This is the check `wrangler deploy --dry-run` (the CI gate) cannot make: bundling executes nothing, and the original plain-Worker entry passed dry-run while never relaying a frame on the real runtime. +// +// Usage: start the dev server in one terminal (`pnpm dev`, serving on :8787), then `node scripts/live-check.mjs`. Exits non-zero naming the failing step. + +import { encode, decode, cdeEncodeOptions, cdeDecodeOptions } from "cbor2"; + +const HUB_URL = "ws://localhost:8787/"; +const SHA256_BYTE_LENGTH = 32; +const CONNECT_TIMEOUT_MS = 5000; +const FRAME_TIMEOUT_MS = 3000; +const GOSSIP_SETTLE_MS = 300; + +const deviceA = new Uint8Array(SHA256_BYTE_LENGTH).fill(0x11); +const deviceB = new Uint8Array(SHA256_BYTE_LENGTH).fill(0x22); +const relayPayload = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]); + +function fail(step, detail) { + console.error(`FAIL [${step}]: ${detail}`); + process.exit(1); +} + +function gossipFor(device) { + return { + type: "gossip", + peers: [ + { + device, + addresses: ["203.0.113.5:4433"], + "snapshot-seconds": 1861833600, + }, + ], + }; +} + +function connect(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(HUB_URL); + ws.binaryType = "arraybuffer"; + const timer = setTimeout( + () => reject(new Error(`${name}: connect/open timed out`)), + CONNECT_TIMEOUT_MS, + ); + ws.addEventListener("open", () => { + clearTimeout(timer); + resolve(ws); + }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error(`${name}: connection error`)); + }); + }); +} + +function sendFrame(ws, frame) { + ws.send(new Uint8Array(encode(frame, cdeEncodeOptions))); +} + +function bytesEqual(a, b) { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +/** Polls a per-client frame queue for the next frame of the expected type (other types stay queued). */ +function waitFor(queue, expectedType) { + return new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => { + const index = queue.findIndex((frame) => frame.type === expectedType); + if (index !== -1) { + resolve(queue.splice(index, 1)[0]); + return; + } + if (Date.now() - started > FRAME_TIMEOUT_MS) { + reject( + new Error( + `timed out waiting for ${expectedType}; queue holds ${JSON.stringify(queue.map((f) => f.type))}`, + ), + ); + return; + } + setTimeout(poll, 20); + }; + poll(); + }); +} + +function collectFrames(ws, queue) { + ws.addEventListener("message", (event) => { + queue.push(decode(new Uint8Array(event.data), cdeDecodeOptions)); + }); +} + +const a = await connect("client A").catch((error) => + fail("connect A", error.message), +); +const b = await connect("client B").catch((error) => + fail("connect B", error.message), +); +const queueA = []; +const queueB = []; +collectFrames(a, queueA); +collectFrames(b, queueB); + +sendFrame(a, gossipFor(deviceA)); +sendFrame(b, gossipFor(deviceB)); +await new Promise((resolve) => setTimeout(resolve, GOSSIP_SETTLE_MS)); + +sendFrame(a, { type: "relay-connect", "target-device": deviceB }); +const inbound = await waitFor(queueB, "relay-inbound").catch((error) => + fail("relay-inbound", error.message), +); +if ( + inbound.type !== "relay-inbound" || + !bytesEqual(new Uint8Array(inbound["source-device"]), deviceA) +) { + fail("relay-inbound", `unexpected frame: ${JSON.stringify(inbound)}`); +} + +sendFrame(a, { type: "relay-data", payload: relayPayload }); +sendFrame(b, { type: "relay-data", payload: relayPayload }); +const toB = await waitFor(queueB, "relay-data").catch((error) => + fail("a->b relay-data", error.message), +); +const toA = await waitFor(queueA, "relay-data").catch((error) => + fail("b->a relay-data", error.message), +); +for (const [label, frame] of [ + ["a->b", toB], + ["b->a", toA], +]) { + if ( + frame.type !== "relay-data" || + !bytesEqual(new Uint8Array(frame.payload), relayPayload) + ) { + fail(`${label} relay-data`, `unexpected frame: ${JSON.stringify(frame)}`); + } +} + +a.close(); +b.close(); +console.log( + "PASS: two real WebSocket clients relayed gossip -> relay-connect -> relay-inbound -> bidirectional relay-data through the Durable Object hub", +); +process.exit(0); diff --git a/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts b/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts new file mode 100644 index 0000000..594818f --- /dev/null +++ b/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts @@ -0,0 +1,99 @@ +// A Worker-runtime IdentityPort implementation using the standard Web Crypto API (globalThis.crypto.subtle), which exists identically in Cloudflare Workers and in Node >= 19 -- mirroring core's node-identity adapter's algorithm coverage (ES256 P-256 and Ed25519, the two algorithms identity-key.alg carries in the spec's own conformance vectors) so the two adapters are drop-in substitutes for each other behind the same port. + +import type { + DeviceId, + IdentityKey, +} from "@exadev/wire-mesh-core/generated/protocol"; +import type { IdentityPort } from "@exadev/wire-mesh-core/ports/identity"; + +const ES256 = -7; +const EDDSA = -8; + +function algParams(alg: number): EcdsaParams | { name: "Ed25519" } { + if (alg === ES256) { + return { name: "ECDSA", hash: "SHA-256" }; + } + if (alg === EDDSA) { + return { name: "Ed25519" }; + } + throw new Error(`unsupported identity-key alg ${String(alg)}`); +} + +/** Copies into a fresh, non-shared, whole-buffer Uint8Array -- Web Crypto's BufferSource parameters reject a view over a SharedArrayBuffer or a sub-range view, neither of which a caller-supplied Uint8Array is guaranteed not to be. */ +function toBufferSource(bytes: Uint8Array): Uint8Array { + return Uint8Array.from(bytes); +} + +async function importPublicKey(key: IdentityKey): Promise { + if (key.alg === ES256) { + return crypto.subtle.importKey( + "raw", + toBufferSource(key["public-key"]), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + } + if (key.alg === EDDSA) { + return crypto.subtle.importKey( + "raw", + toBufferSource(key["public-key"]), + { name: "Ed25519" }, + false, + ["verify"], + ); + } + throw new Error(`unsupported identity-key alg ${String(key.alg)}`); +} + +export async function deriveDeviceId(publicKey: Uint8Array): Promise { + return new Uint8Array( + await crypto.subtle.digest("SHA-256", toBufferSource(publicKey)), + ); +} + +export async function verifyWithPublicKey( + key: IdentityKey, + message: Uint8Array, + signature: Uint8Array, +): Promise { + const cryptoKey = await importPublicKey(key); + return crypto.subtle.verify( + algParams(key.alg), + cryptoKey, + toBufferSource(signature), + toBufferSource(message), + ); +} + +/** Generates a fresh ES256 (ECDSA P-256) identity for this node and builds an IdentityPort from it -- P-256 because it is the one curve Web Crypto's non-extractable key generation supports uniformly across the Worker runtime and Node, and ES256 is the algorithm the spec's own conformance vectors use for every token issuer. */ +export async function createWebCryptoIdentity(): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await crypto.subtle.exportKey("raw", keyPair.publicKey), + ); + const identityKey: IdentityKey = { + alg: ES256, + "public-key": publicKeyBytes, + }; + const deviceId = await deriveDeviceId(publicKeyBytes); + + return { + deviceId, + identityKey, + async sign(message) { + const signature = await crypto.subtle.sign( + algParams(ES256), + keyPair.privateKey, + toBufferSource(message), + ); + return new Uint8Array(signature); + }, + verify: verifyWithPublicKey, + deriveDeviceId, + }; +} diff --git a/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts b/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts new file mode 100644 index 0000000..42c0040 --- /dev/null +++ b/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts @@ -0,0 +1,139 @@ +// A Worker-runtime Connection implementation over WebSocket messages, driven directly by the Durable Object entry (worker.ts's RelayHubDurableObject): the DO accepts the server side of the runtime's WebSocketPair and hands it here. Unlike the TCP adapter -- a stream, needing a length prefix to delimit frames -- each WebSocket binary message is already self-delimiting, so one message carries exactly one CBOR-encoded frame and no prefix is needed. Undecodable bytes are a connection-level failure (the receive iteration rejects and the socket closes), matching the TCP adapter's treatment of hostile wire input; a decodable frame that fails schema validation is dropped rather than disconnecting -- an unrecognised frame from a newer peer is what version negotiation exists to tolerate. + +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; +import { + frameSchema, + type Frame, +} from "@exadev/wire-mesh-core/generated/protocol"; +import type { Connection } from "@exadev/wire-mesh-core/ports/transport"; + +// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error. +const CLOSE_NORMAL = 1000; +const CLOSE_PROTOCOL_ERROR = 1002; + +export function messageFromFrame(frame: Frame): Uint8Array { + // A fresh whole-buffer copy rather than cbor2's own return value: the WebSocket send signatures require a view over a plain ArrayBuffer, and the copy also matches the fresh-buffer discipline the identity adapters apply to anything crossing a runtime boundary. + return new Uint8Array(encode(frame, cdeEncodeOptions)); +} + +/** A frame that fails schema validation, caught separately from a decode failure so it can be dropped without disconnecting the peer. */ +class SchemaInvalidFrameError extends Error { + constructor(message: string) { + super(message); + this.name = "SchemaInvalidFrameError"; + } +} + +/** Decodes one message, distinguishing a decode failure (connection-level) from a schema failure (drop this frame, keep the connection) -- mirroring the TCP adapter's split between the two. */ +function decodeMessage(data: Readonly): Frame { + let decoded: unknown; + try { + decoded = decode(new Uint8Array(data), cdeDecodeOptions); + } catch (error) { + const connectionError = + error instanceof Error + ? error + : new Error(`frame body failed to decode: ${String(error)}`); + throw connectionError; + } + const result = frameSchema.safeParse(decoded); + if (!result.success) { + throw new SchemaInvalidFrameError(result.error.message); + } + return result.data; +} + +export function wrapWebSocket(ws: Readonly): Connection { + const pending: Frame[] = []; + const waiters: { + resolve: (result: IteratorResult) => void; + reject: (error: unknown) => void; + }[] = []; + let ended = false; + let failure: Error | null = null; + + function endAll(): void { + ended = true; + for (const waiter of waiters.splice(0)) { + waiter.resolve({ value: undefined, done: true }); + } + } + + function failAll(error: Error): void { + failure = error; + ended = true; + for (const waiter of waiters.splice(0)) { + waiter.reject(error); + } + } + + ws.addEventListener("message", (event: MessageEvent) => { + if (!(event.data instanceof ArrayBuffer)) { + // Only binary messages carry frames; a text message from a confused or hostile client is a protocol violation on this connection, same class as undecodable bytes + failAll(new Error("expected a binary WebSocket message")); + ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; + } + let frame: Frame; + try { + frame = decodeMessage(event.data); + } catch (error) { + if (error instanceof SchemaInvalidFrameError) { + return; + } + failAll( + error instanceof Error + ? error + : new Error(`frame body failed to decode: ${String(error)}`), + ); + ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; + } + const waiter = waiters.shift(); + if (waiter) { + waiter.resolve({ value: frame, done: false }); + } else { + pending.push(frame); + } + }); + ws.addEventListener("close", endAll); + ws.addEventListener("error", endAll); + + const receiveStream: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + const next = pending.shift(); + if (next !== undefined) { + return { value: next, done: false }; + } + if (failure !== null) { + throw failure; + } + if (ended) { + return { value: undefined, done: true }; + } + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + }, + }; + }, + }; + + return { + // Non-async by promise-returning: the port demands Promise, and the WebSocket API is synchronous underneath -- an async function with no await in its body would only add a microtask hop (same reasoning as core's memory-storage adapter). + async send(frame): Promise { + if (ended) { + return Promise.reject(new Error("connection is closed")); + } + ws.send(messageFromFrame(frame)); + return Promise.resolve(); + }, + receive: () => receiveStream, + async close(): Promise { + ws.close(CLOSE_NORMAL); + return Promise.resolve(); + }, + }; +} diff --git a/ts/packages/cloudflare-hub/src/hub.ts b/ts/packages/cloudflare-hub/src/hub.ts new file mode 100644 index 0000000..a049c89 --- /dev/null +++ b/ts/packages/cloudflare-hub/src/hub.ts @@ -0,0 +1,152 @@ +// The hub's relay role, expressed purely against core's Transport port and the generated frame schemas -- no Worker-specific or WebSocket-specific type appears here, so the same logic runs under the TCP adapter in tests or any future transport. A connection's device-id is learned from its own gossiped peer-advert (the only spec frame that carries a device-id over a plain connection; TLS-cert identity extraction is deliberately out of scope for the WebSocket-ingress first pass, noted in the README). Registry semantics: last gossip wins for a device-id, and a mapping is only removed on disconnect if it still points at the connection that registered it, so a re-announcement by a newer connection isn't clobbered by an older one leaving. + +import type { + DeviceId, + Frame, +} from "@exadev/wire-mesh-core/generated/protocol"; +import type { Connection } from "@exadev/wire-mesh-core/ports/transport"; + +interface Registration { + connection: Readonly; + /** The original bytes, kept so relay-inbound can carry the initiator's device-id without re-parsing; the map itself is keyed by the hex form because a Map keyed directly on Uint8Array compares by reference, and two equal device-ids from two different parsed frames are always distinct objects. */ + device: DeviceId; +} + +/** Pairs an initiating connection with the target connection it asked to reach. `initiatorDevice` is the initiator's gossiped device-id, carried in relay-inbound so the target knows who is dialing it. */ +interface RelayPairing { + initiator: Readonly; + initiatorDevice: DeviceId; + target: Readonly; +} + +export interface RelayHub { + /** Drives one accepted connection until it closes: registers gossip-advertised devices, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. */ + handleConnection: (connection: Readonly) => Promise; + /** Drops all registry and pairing state -- used by tests and by transport teardown. */ + stop: () => void; +} + +const HEX_RADIX = 16; +const HEX_DIGITS_PER_BYTE = 2; + +function deviceKey(device: Uint8Array): string { + let key = ""; + for (const byte of device) { + key += byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0"); + } + return key; +} + +export function createRelayHub(): RelayHub { + const devices = new Map(); + const pairingsByInitiator = new Map, RelayPairing>(); + const pairingsByTarget = new Map, RelayPairing>(); + + function forgetConnection(connection: Readonly): void { + for (const [key, registration] of devices) { + if (registration.connection === connection) { + devices.delete(key); + } + } + forgetPairingsOf(connection); + } + + /** Removes every pairing the connection belongs to -- in either role, and both directions of each. A connection can be the initiator of one pairing and the target of a different one at the same time, so covering both roles is what makes a teardown total. */ + function forgetPairingsOf(connection: Readonly): void { + const asInitiator = pairingsByInitiator.get(connection); + if (asInitiator) { + pairingsByInitiator.delete(connection); + pairingsByTarget.delete(asInitiator.target); + } + const asTarget = pairingsByTarget.get(connection); + if (asTarget) { + pairingsByTarget.delete(connection); + pairingsByInitiator.delete(asTarget.initiator); + } + } + + function deviceOf(connection: Readonly): DeviceId | null { + for (const registration of devices.values()) { + if (registration.connection === connection) { + return registration.device; + } + } + return null; + } + + async function handleFrame( + connection: Readonly, + frame: Frame, + ): Promise { + if (frame.type === "gossip") { + for (const advert of frame.peers) { + devices.set(deviceKey(advert.device), { + connection, + device: advert.device, + }); + } + return; + } + + if (frame.type === "relay-connect") { + const registration = devices.get(deviceKey(frame["target-device"])); + if (!registration || registration.connection === connection) { + // No such device on this hub (or it dialled itself): the spec's transport.cddl defines no error frame for this, so a first pass silently ignores the request -- a protocol change would be needed to answer it, noted in the README. + return; + } + const initiatorDevice = deviceOf(connection); + if (initiatorDevice === null) { + // The initiator never gossiped its own advert, so relay-inbound would carry no source-device; ignore until it identifies itself. + return; + } + // A new relay-connect re-pairs totally: every pairing the initiator belongs to (either role) and every pairing the target belongs to (either role) is torn down in both directions first, so a stale partner's mapping cannot survive on a half-dead pipe to mis-attribute its relay-data onto the new one. + forgetPairingsOf(connection); + forgetPairingsOf(registration.connection); + await registration.connection.send({ + type: "relay-inbound", + "source-device": initiatorDevice, + }); + const pairing: RelayPairing = { + initiator: connection, + initiatorDevice, + target: registration.connection, + }; + pairingsByInitiator.set(connection, pairing); + pairingsByTarget.set(registration.connection, pairing); + return; + } + + if (frame.type === "relay-data") { + const pairing = + pairingsByInitiator.get(connection) ?? pairingsByTarget.get(connection); + if (!pairing) { + return; + } + const peer = + pairing.initiator === connection ? pairing.target : pairing.initiator; + await peer.send(frame); + return; + } + + // Everything else (handshake, ping, candidates, manage-*, streaming, data-domain, coordinator, revocation-announce) is not the relay role's business: the hub is a transport-level node and forwards nothing it isn't named in. Frames are consumed and dropped. + } + + return { + async handleConnection(connection) { + try { + for await (const frame of connection.receive()) { + await handleFrame(connection, frame); + } + } catch { + // A rejecting receive iteration is the connection-level failure signal (the adapter already closed the socket for undecodable bytes or a non-binary message), and a failed peer.send inside handleFrame means that peer's connection died mid-forward -- both are disconnects, not errors to surface, and the entry point voids its caller anyway. Cleanup below runs identically to a clean end. + } finally { + forgetConnection(connection); + } + }, + stop() { + devices.clear(); + pairingsByInitiator.clear(); + pairingsByTarget.clear(); + }, + }; +} diff --git a/ts/packages/cloudflare-hub/src/worker.ts b/ts/packages/cloudflare-hub/src/worker.ts new file mode 100644 index 0000000..edaaf7b --- /dev/null +++ b/ts/packages/cloudflare-hub/src/worker.ts @@ -0,0 +1,46 @@ +// The Worker entry. The Durable Object class must be exported from this entrypoint file for wrangler to bind it (wrangler.toml names this export), so it is defined here directly rather than re-exported from a sibling module. A plain Worker's request context cannot host the hub's long-lived per-connection loops -- workerd's hang detection cancels any request whose promise chain parks on a pure-JS waiter (the pull-based receive() iteration), which is exactly what the relay loop does; a Durable Object is the documented home for that shape, its lifetime tied to the accepted WebSockets rather than to a single fetch. The DO keeps connections alive for its own lifetime; the hibernation API (state.acceptWebSocket + webSocketMessage handlers) is the idle-cost follow-up noted in README.md, not a correctness requirement. + +import { DurableObject } from "cloudflare:workers"; +import { createRelayHub, type RelayHub } from "./hub.js"; +import { wrapWebSocket } from "./adapters/websocket-transport.js"; + +export function healthResponse(): Response { + return Response.json({ + ok: true, + node: "wire-mesh-cloudflare-hub", + roles: ["relay"], + }); +} + +export class RelayHubDurableObject extends DurableObject { + private readonly hub: RelayHub = createRelayHub(); + + fetch(request: Request): Response { + if (request.headers.get("Upgrade") !== "websocket") { + // The Worker router already answers non-WebSocket requests itself; this covers only a malformed upgrade routed here anyway. + return healthResponse(); + } + const pair = new WebSocketPair(); + pair[1].accept(); + const connection = wrapWebSocket(pair[1]); + // Drives the relay loop for this connection until it closes; handleConnection treats a receive rejection as disconnect internally, so there is no rejection to surface here. + void this.hub.handleConnection(connection); + return new Response(null, { status: 101, webSocket: pair[0] }); + } +} + +interface Env { + HUB: DurableObjectNamespace; +} + +const HUB_INSTANCE_NAME = "relay-hub"; + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.headers.get("Upgrade") === "websocket") { + const stub = env.HUB.get(env.HUB.idFromName(HUB_INSTANCE_NAME)); + return stub.fetch(request); + } + return healthResponse(); + }, +} satisfies ExportedHandler; diff --git a/ts/packages/cloudflare-hub/test/fake-web-socket.ts b/ts/packages/cloudflare-hub/test/fake-web-socket.ts new file mode 100644 index 0000000..addea9b --- /dev/null +++ b/ts/packages/cloudflare-hub/test/fake-web-socket.ts @@ -0,0 +1,50 @@ +// A minimal stand-in for the platform's WebSocket, firing events and recording sends -- it stands in for the runtime (the side of the port this adapter does NOT own), exactly what a unit test of an adapter should fake. + +export class FakeWebSocket { + binaryType = "arraybuffer"; + sent: ArrayBuffer[] = []; + closed = false; + private readonly listeners = new Map< + string, + ((event: { data?: unknown }) => void)[] + >(); + + addEventListener(type: string, listener: () => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + private dispatch(type: string, event?: { data?: unknown }): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event ?? {}); + } + } + + send(data: Readonly): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + this.dispatch("close"); + } + + // Test-side drivers + emitMessage(data: Readonly): void { + this.dispatch("message", { data }); + } + + /** A text (non-binary) message -- the protocol violation the adapter must treat as a connection-level failure. */ + emitText(text: string): void { + this.dispatch("message", { data: text }); + } + + emitError(): void { + this.dispatch("error"); + } + + accept(): void { + // Present on the Workers server-side socket; a no-op in the fake. + } +} diff --git a/ts/packages/cloudflare-hub/test/hex.ts b/ts/packages/cloudflare-hub/test/hex.ts new file mode 100644 index 0000000..f07c8dd --- /dev/null +++ b/ts/packages/cloudflare-hub/test/hex.ts @@ -0,0 +1,22 @@ +// Test-side byte construction from hex strings. Deliberately not Buffer.from(hex, "hex"): this package's tsconfig loads both node and @cloudflare/workers-types for its mixed Node-test/Worker-src environment, under which the Buffer global's overloads resolve as any for eslint's type info -- a plain loop needs none of it. +const HEX_PAIR_LENGTH = 2; +const HEX_RADIX = 16; +const SHA256_BYTE_LENGTH = 32; + +export function bytesFromHex(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / HEX_PAIR_LENGTH); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt( + hex.slice(i * HEX_PAIR_LENGTH, (i + 1) * HEX_PAIR_LENGTH), + HEX_RADIX, + ); + } + return out; +} + +/** A full synthetic device-id from a one-byte hex fill, the conformance suite's repeated-byte convention. */ +export function deviceIdFromFillHex(fillHex: string): Uint8Array { + const out = new Uint8Array(SHA256_BYTE_LENGTH); + out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; +} diff --git a/ts/packages/cloudflare-hub/test/hub.test.ts b/ts/packages/cloudflare-hub/test/hub.test.ts new file mode 100644 index 0000000..755f575 --- /dev/null +++ b/ts/packages/cloudflare-hub/test/hub.test.ts @@ -0,0 +1,588 @@ +import { describe, expect, it } from "vitest"; +import { decode } from "cbor2"; +import type { + DeviceId, + Frame, +} from "@exadev/wire-mesh-core/generated/protocol"; +import type { Connection } from "@exadev/wire-mesh-core/ports/transport"; +import { createRelayHub } from "../src/hub.js"; +import { + messageFromFrame, + wrapWebSocket, +} from "../src/adapters/websocket-transport.js"; +import { bytesFromHex, deviceIdFromFillHex } from "./hex.js"; +import { FakeWebSocket } from "./fake-web-socket.js"; + +const deviceA = deviceIdFromFillHex("11"); +const deviceB = deviceIdFromFillHex("22"); +const relayPayload = bytesFromHex("deadbeef"); +const orphanPayload = bytesFromHex("aa"); +const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable, the hostile-input case + +/** One macrotask turn, letting the hub drain frames already queued on its connections. */ +async function tick(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** An in-memory Connection driving the hub through the port contract: queued inbound frames the test pushes, and a record of everything the hub sends back. */ +class FakeConnection { + inbound: Frame[] = []; + sent: Frame[] = []; + private closed = false; + private readonly wakeWaiters: (() => void)[] = []; + + get connection(): Readonly { + return { + send: async (frame: Frame): Promise => { + this.sent.push(frame); + return Promise.resolve(); + }, + receive: () => this.stream(), + close: async (): Promise => { + this.closed = true; + this.wake(); + return Promise.resolve(); + }, + }; + } + + push(frame: Frame): void { + this.inbound.push(frame); + this.wake(); + } + + /** Ends the inbound stream (simulating disconnect) while leaving sent readable. */ + async end(): Promise { + this.closed = true; + this.wake(); + return Promise.resolve(); + } + + private wake(): void { + for (const wake of this.wakeWaiters.splice(0)) { + wake(); + } + } + + private stream(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async (): Promise> => this.nextFrame(), + }), + }; + } + + private async nextFrame(): Promise> { + return this.drain(); + } + + private async drain(): Promise> { + for (;;) { + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; + } + if (this.closed) { + return { value: undefined, done: true }; + } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + } + } +} + +function gossipFor(device: DeviceId): Frame { + return { + type: "gossip", + peers: [ + { + device, + addresses: ["203.0.113.5:4433"], + "snapshot-seconds": 1861833600, + }, + ], + }; +} + +/** A Connection whose receive() stream delivers pushed frames until rejectNow(), then rejects -- the mid-stream hostile-input failure the real adapter produces for undecodable bytes. */ +class RejectingAfterFramesConnection { + inbound: Frame[] = []; + sent: Frame[] = []; + private rejection: Error | null = null; + private readonly wakeWaiters: (() => void)[] = []; + + get connection(): Readonly { + return { + send: async (frame: Frame): Promise => { + this.sent.push(frame); + return Promise.resolve(); + }, + receive: () => this.stream(), + close: async (): Promise => Promise.resolve(), + }; + } + + push(frame: Frame): void { + this.inbound.push(frame); + for (const wake of this.wakeWaiters.splice(0)) wake(); + } + + rejectNow(): void { + this.rejection = new Error("simulated undecodable bytes"); + for (const wake of this.wakeWaiters.splice(0)) wake(); + } + + private stream(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async (): Promise> => this.nextFrame(), + }), + }; + } + + private async nextFrame(): Promise> { + return this.step(); + } + + private async step(): Promise> { + for (;;) { + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; + } + if (this.rejection !== null) { + throw this.rejection; + } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + } + } +} + +describe("createRelayHub", () => { + it("pairs a relay-connect initiator with the target and notifies the target with the initiator's gossiped device-id", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const handling = [ + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + ]; + + a.push(gossipFor(deviceA)); + b.push(gossipFor(deviceB)); + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + await Promise.all([a.end(), b.end()]); + await Promise.all(handling); + }); + + it("forwards relay-data in both directions within a pairing", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const handling = [ + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + ]; + + // Ticks between the setup frames and the data frames: all frames land in the fakes' queues synchronously, and without them one connection can drain its whole queue (including relay-data) before the other's relay-connect has created the pairing -- an ordering a real transport, with per-frame network latency, never produces. + a.push(gossipFor(deviceA)); + b.push(gossipFor(deviceB)); + await tick(); + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + a.push({ type: "relay-data", payload: relayPayload }); + b.push({ type: "relay-data", payload: relayPayload }); + await tick(); + + // b received relay-inbound (from the connect) then a's relay-data; a received b's relay-data + expect(b.sent[0]).toEqual({ + type: "relay-inbound", + "source-device": deviceA, + }); + expect(b.sent[1]).toEqual({ type: "relay-data", payload: relayPayload }); + expect(a.sent).toEqual([{ type: "relay-data", payload: relayPayload }]); + await Promise.all([a.end(), b.end()]); + await Promise.all(handling); + }); + + it("ignores a relay-connect for a device not registered on this hub", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const handling = [ + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + ]; + + b.push(gossipFor(deviceB)); + const unknown = deviceIdFromFillHex("33"); + a.push({ type: "relay-connect", "target-device": unknown }); + await tick(); + + expect(b.sent).toEqual([]); + expect(a.sent).toEqual([]); + await Promise.all([a.end(), b.end()]); + await Promise.all(handling); + }); + + it("ignores relay-data from a connection with no pairing, and drops unrelated frames", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const handling = hub.handleConnection(a.connection); + + a.push(gossipFor(deviceA)); + a.push({ type: "relay-data", payload: orphanPayload }); + a.push({ type: "ping" }); + a.push({ type: "handshake", version: 1, domains: ["core/data"] }); + await tick(); + + expect(a.sent).toEqual([]); + await a.end(); + await handling; + }); + + it("forgets a device when its connection ends, so later relay-connects to it are ignored", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const bHandling = hub.handleConnection(b.connection); + b.push(gossipFor(deviceB)); + + const aHandling = hub.handleConnection(a.connection); + a.push(gossipFor(deviceA)); + await tick(); + + // b goes away + await b.end(); + await bHandling; + + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + + expect(a.sent).toEqual([]); + expect(b.sent).toEqual([]); + await a.end(); + await aHandling; + }); + + it("a newer gossip for the same device moves the mapping to the newer connection", async () => { + const hub = createRelayHub(); + const old = new FakeConnection(); + const fresh = new FakeConnection(); + const dialer = new FakeConnection(); + const handling = [ + hub.handleConnection(old.connection), + hub.handleConnection(fresh.connection), + hub.handleConnection(dialer.connection), + ]; + + old.push(gossipFor(deviceB)); + fresh.push(gossipFor(deviceB)); + dialer.push(gossipFor(deviceA)); + dialer.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + + expect(fresh.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + expect(old.sent).toEqual([]); + await Promise.all([old.end(), fresh.end(), dialer.end()]); + await Promise.all(handling); + }); + + it("a rejecting receive iteration is treated as disconnect: state is cleaned up and later relay-connects to the device are ignored", async () => { + const hub = createRelayHub(); + const b = new FakeConnection(); + const a = new RejectingAfterFramesConnection(); + const bHandling = hub.handleConnection(b.connection); + b.push(gossipFor(deviceB)); + + const aHandling = hub.handleConnection(a.connection); + a.push(gossipFor(deviceA)); + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + + // b's connection paired and was notified before a's stream rejected mid-flight + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + a.rejectNow(); + await aHandling; + + // a's registration is gone even though its stream ended by rejection, not clean closure: a third party dialing a is now ignored + const c = new FakeConnection(); + const cHandling = hub.handleConnection(c.connection); + c.push(gossipFor(deviceB)); + await tick(); + c.push({ type: "relay-connect", "target-device": deviceA }); + await tick(); + expect(a.sent).toEqual([]); + await c.end(); + await cHandling; + await b.end(); + await bHandling; + }); + + it("a second relay-connect from the same initiator tears the old pairing down in both directions", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const c = new FakeConnection(); + const handling = [ + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + hub.handleConnection(c.connection), + ]; + + const deviceC = deviceIdFromFillHex("44"); + a.push(gossipFor(deviceA)); + b.push(gossipFor(deviceB)); + c.push(gossipFor(deviceC)); + await tick(); + + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + a.push({ type: "relay-connect", "target-device": deviceC }); + await tick(); + + // c was notified of the new pairing; the stale partner b was not told anything, but its side of the old pairing is gone + expect(c.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + // b's relay-data must NOT reach a anymore -- the pipe a holds is now with c + b.push({ type: "relay-data", payload: relayPayload }); + await tick(); + expect(a.sent).toEqual([]); + + // while c's relay-data does reach a, and a's reaches c + c.push({ type: "relay-data", payload: relayPayload }); + a.push({ type: "relay-data", payload: relayPayload }); + await tick(); + expect(a.sent).toEqual([{ type: "relay-data", payload: relayPayload }]); + expect(c.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + { type: "relay-data", payload: relayPayload }, + ]); + + await Promise.all([a.end(), b.end(), c.end()]); + await Promise.all(handling); + }); + + it("an initiator that was already a target sheds its old pipe when it re-connects out", async () => { + const hub = createRelayHub(); + const x = new FakeConnection(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const handling = [ + hub.handleConnection(x.connection), + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + ]; + + const deviceX = deviceIdFromFillHex("55"); + x.push(gossipFor(deviceX)); + a.push(gossipFor(deviceA)); + b.push(gossipFor(deviceB)); + await tick(); + + // x dials a: a becomes the target of x -> a + x.push({ type: "relay-connect", "target-device": deviceA }); + await tick(); + expect(a.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceX }, + ]); + + // a now initiates its own pipe to b: the x -> a pairing must be torn down too (a belongs to it, as target), or x keeps sending into what a believes is its pipe with b + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + x.push({ type: "relay-data", payload: relayPayload }); + await tick(); + // a.sent is unchanged from the earlier relay-inbound: x's data on the torn-down pipe arrived nowhere + expect(a.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceX }, + ]); + + // while the live a <-> b pipe still forwards both ways + a.push({ type: "relay-data", payload: relayPayload }); + b.push({ type: "relay-data", payload: relayPayload }); + await tick(); + expect(a.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceX }, + { type: "relay-data", payload: relayPayload }, + ]); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + { type: "relay-data", payload: relayPayload }, + ]); + + await Promise.all([x.end(), a.end(), b.end()]); + await Promise.all(handling); + }); + + it("a target that was already an initiator sheds its old pipe when dialed", async () => { + const hub = createRelayHub(); + const a = new FakeConnection(); + const b = new FakeConnection(); + const y = new FakeConnection(); + const handling = [ + hub.handleConnection(a.connection), + hub.handleConnection(b.connection), + hub.handleConnection(y.connection), + ]; + + const deviceY = deviceIdFromFillHex("66"); + a.push(gossipFor(deviceA)); + b.push(gossipFor(deviceB)); + y.push(gossipFor(deviceY)); + await tick(); + + // b dials y: b becomes the initiator of b -> y + b.push({ type: "relay-connect", "target-device": deviceY }); + await tick(); + expect(y.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceB }, + ]); + + // a now dials b: the b -> y pairing must be torn down too (b belongs to it, as initiator), or y keeps sending into what b believes is its pipe with a + a.push({ type: "relay-connect", "target-device": deviceB }); + await tick(); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + y.push({ type: "relay-data", payload: relayPayload }); + await tick(); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + // while the live a <-> b pipe still forwards both ways + a.push({ type: "relay-data", payload: relayPayload }); + b.push({ type: "relay-data", payload: relayPayload }); + await tick(); + expect(a.sent).toEqual([{ type: "relay-data", payload: relayPayload }]); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + { type: "relay-data", payload: relayPayload }, + ]); + + await Promise.all([a.end(), b.end(), y.end()]); + await Promise.all(handling); + }); +}); + +describe("createRelayHub over the real wrapWebSocket adapter", () => { + function arrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + } + + function decodeSent(ws: FakeWebSocket): unknown[] { + return ws.sent.map((buffer) => decode(new Uint8Array(buffer))); + } + + it("gossip, relay-connect and relay-data flow end to end through real WebSocket message encoding", async () => { + const hub = createRelayHub(); + const wsA = new FakeWebSocket(); + const wsB = new FakeWebSocket(); + const a = wrapWebSocket(wsA as unknown as WebSocket); + const b = wrapWebSocket(wsB as unknown as WebSocket); + const handling = [hub.handleConnection(a), hub.handleConnection(b)]; + + wsA.emitMessage(arrayBuffer(messageFromFrame(gossipFor(deviceA)))); + wsB.emitMessage(arrayBuffer(messageFromFrame(gossipFor(deviceB)))); + await tick(); + wsA.emitMessage( + arrayBuffer( + messageFromFrame({ type: "relay-connect", "target-device": deviceB }), + ), + ); + await tick(); + + expect(decodeSent(wsB)).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + wsA.emitMessage( + arrayBuffer( + messageFromFrame({ type: "relay-data", payload: relayPayload }), + ), + ); + wsB.emitMessage( + arrayBuffer( + messageFromFrame({ type: "relay-data", payload: relayPayload }), + ), + ); + await tick(); + + expect(decodeSent(wsB)).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + { type: "relay-data", payload: relayPayload }, + ]); + expect(decodeSent(wsA)).toEqual([ + { type: "relay-data", payload: relayPayload }, + ]); + + await Promise.all([a.close(), b.close()]); + await Promise.all(handling); + }); + + it("undecodable bytes from one client close only that connection: the hub and the other peer keep working", async () => { + const hub = createRelayHub(); + const wsA = new FakeWebSocket(); + const b = new FakeConnection(); + const a = wrapWebSocket(wsA as unknown as WebSocket); + const aHandling = hub.handleConnection(a); + const bHandling = hub.handleConnection(b.connection); + + wsA.emitMessage(arrayBuffer(messageFromFrame(gossipFor(deviceA)))); + b.push(gossipFor(deviceB)); + await tick(); + + // hostile bytes on a's socket: its receive iteration rejects, the hub treats it as disconnect, nothing throws + wsA.emitMessage(arrayBuffer(Uint8Array.from([CBOR_BREAK_BYTE]))); + await tick(); + await aHandling; + + expect(wsA.closed).toBe(true); + + // b can still be dialed by a fresh peer + const wsC = new FakeWebSocket(); + const c = wrapWebSocket(wsC as unknown as WebSocket); + const cHandling = hub.handleConnection(c); + wsC.emitMessage(arrayBuffer(messageFromFrame(gossipFor(deviceA)))); + await tick(); + wsC.emitMessage( + arrayBuffer( + messageFromFrame({ type: "relay-connect", "target-device": deviceB }), + ), + ); + await tick(); + expect(b.sent).toEqual([ + { type: "relay-inbound", "source-device": deviceA }, + ]); + + await c.close(); + await cHandling; + await b.end(); + await bHandling; + }); +}); diff --git a/ts/packages/cloudflare-hub/test/web-crypto-identity.test.ts b/ts/packages/cloudflare-hub/test/web-crypto-identity.test.ts new file mode 100644 index 0000000..968b7ef --- /dev/null +++ b/ts/packages/cloudflare-hub/test/web-crypto-identity.test.ts @@ -0,0 +1,118 @@ +import { createHash, webcrypto } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + createWebCryptoIdentity, + deriveDeviceId, + verifyWithPublicKey, +} from "../src/adapters/web-crypto-identity.js"; +import { + createNodeIdentity, + verifyWithPublicKey as verifyWithNodeIdentity, +} from "@exadev/wire-mesh-core/adapters/node-identity"; +import { bytesFromHex } from "./hex.js"; + +const ES256 = -7; +const ES512 = -36; // a real COSE algorithm this adapter deliberately does not implement +const SHA256_BYTE_LENGTH = 32; +const UNCOMPRESSED_P256_POINT_BYTE_LENGTH = 65; // 0x04 || X || Y + +function someMessage(): Uint8Array { + return bytesFromHex("0102030405"); +} + +/** Byte-for-byte equality without Buffer -- the two type packages loaded for this mixed Node/Workers environment make Buffer's own overloads resolve as any under eslint's type info, and a plain comparison needs none of it. */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +describe("createWebCryptoIdentity", () => { + it("derives device-id as sha256 of the raw public-key bytes, not any container encoding", async () => { + const identity = await createWebCryptoIdentity(); + const rawKeyBytes = identity.identityKey["public-key"]; + + expect(rawKeyBytes.byteLength).toBe(UNCOMPRESSED_P256_POINT_BYTE_LENGTH); + const expected = createHash("sha256").update(rawKeyBytes).digest(); + expect(bytesEqual(identity.deviceId, expected)).toBe(true); + expect(identity.deviceId.byteLength).toBe(SHA256_BYTE_LENGTH); + }); + + it("signs with the private key and verifies its own signature through the port", async () => { + const identity = await createWebCryptoIdentity(); + const message = someMessage(); + const signature = await identity.sign(message); + + expect( + await identity.verify(identity.identityKey, message, signature), + ).toBe(true); + // A genuinely different message (same bytes in a fresh buffer would still + // legitimately verify -- the signature covers content, not identity). + const tampered = bytesFromHex("0102030406"); + expect( + await identity.verify(identity.identityKey, tampered, signature), + ).toBe(false); + }); + + it("round-trips independently with core's node-identity adapter -- a signature made by one adapter verifies under the other", async () => { + // The hub's Worker adapter signs; core's Node adapter verifies. + const workerIdentity = await createWebCryptoIdentity(); + const message = someMessage(); + const signature = await workerIdentity.sign(message); + expect( + await verifyWithNodeIdentity( + workerIdentity.identityKey, + message, + signature, + ), + ).toBe(true); + + // And the mirror direction: a Node-side keypair signs, the Worker adapter verifies. + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + const nodeIdentity = await createNodeIdentity( + keyPair.privateKey, + publicKeyBytes, + ES256, + ); + const nodeSignature = await nodeIdentity.sign(message); + expect( + await workerIdentity.verify( + nodeIdentity.identityKey, + message, + nodeSignature, + ), + ).toBe(true); + }); + + it("deriveDeviceId is a pure function of the key bytes, matching an independent sha256 computation", async () => { + const { publicKey } = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + const raw = new Uint8Array( + await webcrypto.subtle.exportKey("raw", publicKey), + ); + const derived = await deriveDeviceId(raw); + const expected = createHash("sha256").update(raw).digest(); + expect(bytesEqual(derived, expected)).toBe(true); + }); + + it("throws loudly on an identity-key algorithm it does not implement, rather than mis-verifying", async () => { + const identity = await createWebCryptoIdentity(); + const message = someMessage(); + const signature = await identity.sign(message); + const es512Key = { + ...identity.identityKey, + alg: ES512, + }; + await expect( + verifyWithPublicKey(es512Key, message, signature), + ).rejects.toThrow("unsupported identity-key alg -36"); + }); +}); diff --git a/ts/packages/cloudflare-hub/test/websocket-transport.test.ts b/ts/packages/cloudflare-hub/test/websocket-transport.test.ts new file mode 100644 index 0000000..e58a490 --- /dev/null +++ b/ts/packages/cloudflare-hub/test/websocket-transport.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { decode, encode } from "cbor2"; +import type { Frame } from "@exadev/wire-mesh-core/generated/protocol"; +import { + messageFromFrame, + wrapWebSocket, +} from "../src/adapters/websocket-transport.js"; +import { FakeWebSocket } from "./fake-web-socket.js"; +import { bytesFromHex } from "./hex.js"; + +const ping: Frame = { type: "ping" }; +const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; // a one-entry CBOR map head -- the ping frame, no length prefix +const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable as a complete value +const samplePayload = bytesFromHex("010203"); + +function arrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; +} + +function frameFromBuffer(buffer: Readonly): unknown { + return decode(new Uint8Array(buffer)); +} + +async function collect(iterable: Readonly>): Promise { + const out: T[] = []; + for await (const item of iterable) out.push(item); + return out; +} + +describe("wrapWebSocket", () => { + it("delivers a valid CBOR frame message and encodes sends as single CBOR messages", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + + const received = connection.receive()[Symbol.asyncIterator](); + const nextFrame = received.next(); + ws.emitMessage(arrayBuffer(messageFromFrame(ping))); + + expect((await nextFrame).value).toEqual(ping); + + await connection.send(ping); + expect(ws.sent.length).toBe(1); + const decoded = new Uint8Array(ws.sent[0] ?? new ArrayBuffer(0)); + expect(decoded[0]).toBe(CBOR_MAP_ONE_ENTRY_FIRST_BYTE); + }); + + it("rejects the receive iteration on bytes that do not decode as CBOR, and closes the socket", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + + const nextFrame = connection.receive()[Symbol.asyncIterator]().next(); + ws.emitMessage(arrayBuffer(Uint8Array.from([CBOR_BREAK_BYTE]))); + + await expect(nextFrame).rejects.toThrow(); + expect(ws.closed).toBe(true); + }); + + it("treats a text (non-binary) message as a connection-level failure: rejects and closes", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + + const nextFrame = connection.receive()[Symbol.asyncIterator]().next(); + ws.emitText("hello"); + + await expect(nextFrame).rejects.toThrow( + "expected a binary WebSocket message", + ); + expect(ws.closed).toBe(true); + }); + + it("rejects send() after the socket has closed", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + ws.close(); + + await expect(connection.send(ping)).rejects.toThrow("connection is closed"); + }); + + it("drops a decodable but schema-invalid frame without ending the connection", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + + const received = connection.receive()[Symbol.asyncIterator](); + const first = received.next(); + // A well-formed CBOR map with the wrong literal: not any known frame. It is dropped, so the next valid frame still arrives and nothing closes. + ws.emitMessage( + arrayBuffer(new Uint8Array(encode({ type: "not-a-real-frame-kind" }))), + ); + ws.emitMessage(arrayBuffer(messageFromFrame(ping))); + + expect((await first).value).toEqual(ping); + expect(ws.closed).toBe(false); + + const after = collect(connection.receive()); + ws.close(); + expect(await after).toEqual([]); + }); + + it("ends the iteration cleanly when the socket closes", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + const frames = collect(connection.receive()); + ws.close(); + expect(await frames).toEqual([]); + }); + + it("ends the iteration when the socket errors after delivering pending frames", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + const frames = collect(connection.receive()); + ws.emitMessage(arrayBuffer(messageFromFrame(ping))); + ws.emitError(); + expect(await frames).toEqual([ping]); + }); + + it("re-encodes a received frame byte-identically (the round-trip the hub relies on for forwarding)", async () => { + const ws = new FakeWebSocket(); + const connection = wrapWebSocket(ws as unknown as WebSocket); + + const received = connection.receive()[Symbol.asyncIterator](); + const nextFrame = received.next(); + const bytes = messageFromFrame({ + type: "relay-data", + payload: samplePayload, + }); + ws.emitMessage(arrayBuffer(bytes)); + + const result = await nextFrame; + if (result.done === true) { + throw new Error("expected a relay-data frame, got stream end"); + } + expect(result.value).toEqual({ + type: "relay-data", + payload: samplePayload, + }); + await connection.send(result.value); + const forwarded = ws.sent[0]; + expect(new Uint8Array(forwarded ?? new ArrayBuffer(0))).toEqual(bytes); + expect(frameFromBuffer(forwarded ?? new ArrayBuffer(0))).toEqual( + frameFromBuffer(arrayBuffer(bytes)), + ); + }); +}); diff --git a/ts/packages/cloudflare-hub/tsconfig.json b/ts/packages/cloudflare-hub/tsconfig.json new file mode 100644 index 0000000..c8f8408 --- /dev/null +++ b/ts/packages/cloudflare-hub/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/ts/packages/cloudflare-hub/tsconfig.node.json b/ts/packages/cloudflare-hub/tsconfig.node.json new file mode 100644 index 0000000..6f77ba1 --- /dev/null +++ b/ts/packages/cloudflare-hub/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["eslint.config.ts", "test/**/*.ts"] +} diff --git a/ts/packages/cloudflare-hub/turbo.json b/ts/packages/cloudflare-hub/turbo.json new file mode 100644 index 0000000..bfc7ba6 --- /dev/null +++ b/ts/packages/cloudflare-hub/turbo.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + + "tasks": { + "_build": { + // wrangler's dry-run bundle IS this package's build: it resolves the entry, bundles the workspace's core package and every npm dependency, and type-checks along the way -- a genuine "does it build" gate with no Cloudflare credentials needed. + "inputs": ["src/**", "wrangler.toml", "tsconfig.json"], + "outputs": ["dist/**"] + }, + "_test": { + "dependsOn": ["^_build"] + }, + "_typecheck": { + "dependsOn": ["^_build"], + "inputs": ["**/*.ts", "tsconfig.json", "tsconfig.node.json"] + }, + "_lint": { + "dependsOn": ["^_build"], + "inputs": ["$TURBO_DEFAULT$", "eslint.config.ts"], + "outputs": [".eslintcache"] + } + } +} diff --git a/ts/packages/cloudflare-hub/wrangler.toml b/ts/packages/cloudflare-hub/wrangler.toml new file mode 100644 index 0000000..6e35ab9 --- /dev/null +++ b/ts/packages/cloudflare-hub/wrangler.toml @@ -0,0 +1,15 @@ +# A reference deployment of a wire-mesh hub node on Cloudflare Workers -- see README.md for what is implemented versus deferred. `wrangler deploy --dry-run --outdir=dist` (the package's _build task) validates the bundle without any Cloudflare credentials, which is what CI runs; a real deploy needs `wrangler deploy` with an authenticated account and is deliberately not part of this package's CI. The hub state lives in one named Durable Object instance -- see src/worker.ts for why a plain Worker cannot host it. +name = "wire-mesh-hub" +main = "src/worker.ts" +compatibility_date = "2026-09-01" + +[durable_objects] +bindings = [{ name = "HUB", class_name = "RelayHubDurableObject" }] + +# Hibernation-capable (SQLite-backed) class, per current wrangler guidance for DOs that hold WebSockets. +[[migrations]] +tag = "v1" +new_sqlite_classes = ["RelayHubDurableObject"] + +[dev] +port = 8787 diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index a07dab3..6f8e576 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -12,6 +12,55 @@ importers: specifier: 2.10.12 version: 2.10.12 + packages/cloudflare-hub: + dependencies: + '@exadev/wire-mesh-core': + specifier: workspace:* + version: link:../core + cbor2: + specifier: 2.3.0 + version: 2.3.0 + devDependencies: + '@cloudflare/workers-types': + specifier: 5.20260905.1 + version: 5.20260905.1 + '@exadev/eslint-config': + specifier: 2.10.6 + version: 2.10.6(eslint@10.10.0(jiti@2.7.0))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(typescript@6.0.3) + '@types/node': + specifier: 26.4.1 + version: 26.4.1 + eslint: + specifier: 10.10.0 + version: 10.10.0(jiti@2.7.0) + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@10.10.0(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: 5.5.6 + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)))(eslint@10.10.0(jiti@2.7.0))(prettier@3.9.6) + globals: + specifier: 17.12.0 + version: 17.12.0 + jiti: + specifier: 2.7.0 + version: 2.7.0 + prettier: + specifier: 3.9.6 + version: 3.9.6 + turbo: + specifier: 2.10.12 + version: 2.10.12 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.0 + version: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + wrangler: + specifier: 4.42.0 + version: 4.42.0(@cloudflare/workers-types@5.20260905.1) + packages/core: dependencies: cbor2: @@ -87,14 +136,217 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@cloudflare/kv-asset-handler@0.4.0': + resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} + engines: {node: '>=18.0.0'} + + '@cloudflare/unenv-preset@2.7.6': + resolution: {integrity: sha512-ykG2nd3trk6jbknRCH69xL3RpGLLbKCrbTbWSOvKEq7s4jH06yLrQlRr/q9IU+dK9p1JY1EXqhFK7VG5KqhzmQ==} + peerDependencies: + unenv: 2.0.0-rc.21 + workerd: ^1.20250927.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20251001.0': + resolution: {integrity: sha512-y1ST/cCscaRewWRnsHZdWbgiLJbki5UMGd0hMo/FLqjlztwPeDgQ5CGm5jMiCDdw/IBCpWxEukftPYR34rWNog==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20251001.0': + resolution: {integrity: sha512-+z4QHHZ/Yix82zLFYS+ZS2UV09IENFPwDCEKUWfnrM9Km2jOOW3Ua4hJNob1EgQUYs8fFZo7k5O/tpwxMsSbbQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20251001.0': + resolution: {integrity: sha512-hGS+O2V9Mm2XjJUaB9ZHMA5asDUaDjKko42e+accbew0PQR7zrAl1afdII6hMqCLV4tk4GAjvhv281pN4g48rg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20251001.0': + resolution: {integrity: sha512-QYaMK+pRgt28N7CX1JlJ+ToegJF9LxzqdT7MjWqPgVj9D2WTyIhBVYl3wYjJRcgOlnn+DRt42+li4T64CPEeuA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20251001.0': + resolution: {integrity: sha512-ospnDR/FlyRvrv9DSHuxDAXmzEBLDUiAHQrQHda1iUH9HqxnNQ8giz9VlPfq7NIRc7bQ1ZdIYPGLJOY4Q366Ng==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260905.1': + resolution: {integrity: sha512-ebDpVTgrnee245IRBTazyfjfmZas+HJehZyVQkKmUozsBN8RzvXXMI0XjjcT2rQjw+0USewfEGGLYiHO+TL0Sg==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@cto.af/wtf8@0.0.5': resolution: {integrity: sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==} engines: {node: '>=20'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.25.4': + resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -175,6 +427,123 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -185,6 +554,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keyv/bigmap@1.3.1': resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} engines: {node: '>= 18'} @@ -204,6 +576,15 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -310,6 +691,13 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@turbo/darwin-64@2.10.12': resolution: {integrity: sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA==} cpu: [x64] @@ -571,6 +959,15 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -606,6 +1003,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -667,10 +1067,21 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -717,9 +1128,17 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -793,10 +1212,17 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -851,6 +1277,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + globals@17.12.0: resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} @@ -891,6 +1320,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -919,6 +1351,10 @@ packages: keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1019,6 +1455,16 @@ packages: engines: {node: '>= 16'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + miniflare@4.20251001.0: + resolution: {integrity: sha512-OHd31D2LT8JH+85nVXClV0Z18jxirCohzKNAcZs/fgt4mIkUDtidX3VqR3ovAM0jWooNxrFhB9NSs3iDbiJF7Q==} + engines: {node: '>=18.0.0'} + hasBin: true + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1049,6 +1495,9 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1078,6 +1527,12 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1149,6 +1604,10 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1160,6 +1619,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + skin-tone@2.0.0: resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} engines: {node: '>=8'} @@ -1174,6 +1636,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1182,6 +1648,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1261,6 +1731,9 @@ packages: unrun: optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + turbo@2.10.12: resolution: {integrity: sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw==} hasBin: true @@ -1286,12 +1759,22 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.14.0: + resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.21: + resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -1405,10 +1888,37 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20251001.0: + resolution: {integrity: sha512-oT/K4YWNhmwpVmGeaHNmF7mLRfgjszlVr7lJtpS4jx5khmxmMzWZEEQRrJEpgzeHP6DOq9qWLPNT0bjMK7TchQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.42.0: + resolution: {integrity: sha512-OZXiUSfGD66OVkncDbjZtqrsH6bWPRQMYc6RmMbkzYm/lEvJ8lvARKcqDgEyq8zDAgJAivlMQLyPtKQoVjQ/4g==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20251001.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1425,6 +1935,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-ast@0.9.3: resolution: {integrity: sha512-Kt0PXlPCXdKF1fWFTl7N3DaxsVk6DHF8VAXHJMkwPtJnWYgbx20pYgGSweuC/86mIM7k1NUITQ4JffgOLUWTCw==} @@ -1434,6 +1950,9 @@ packages: yuku-parser@0.9.3: resolution: {integrity: sha512-96wPoHnwaXfkZv7UIOUkDb+s7ZH8lv8Qtg+MxdvHUV1LFa5jV9iKOaams1942YQt+krFQrWiD6lSg2ermv5kHA==} + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -1476,11 +1995,122 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@cloudflare/kv-asset-handler@0.4.0': + dependencies: + mime: 3.0.0 + + '@cloudflare/unenv-preset@2.7.6(unenv@2.0.0-rc.21)(workerd@1.20251001.0)': + dependencies: + unenv: 2.0.0-rc.21 + optionalDependencies: + workerd: 1.20251001.0 + + '@cloudflare/workerd-darwin-64@1.20251001.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20251001.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20251001.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20251001.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20251001.0': + optional: true + + '@cloudflare/workers-types@5.20260905.1': {} + '@colors/colors@1.5.0': optional: true + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@cto.af/wtf8@0.0.5': {} + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.4': + optional: true + + '@esbuild/android-arm64@0.25.4': + optional: true + + '@esbuild/android-arm@0.25.4': + optional: true + + '@esbuild/android-x64@0.25.4': + optional: true + + '@esbuild/darwin-arm64@0.25.4': + optional: true + + '@esbuild/darwin-x64@0.25.4': + optional: true + + '@esbuild/freebsd-arm64@0.25.4': + optional: true + + '@esbuild/freebsd-x64@0.25.4': + optional: true + + '@esbuild/linux-arm64@0.25.4': + optional: true + + '@esbuild/linux-arm@0.25.4': + optional: true + + '@esbuild/linux-ia32@0.25.4': + optional: true + + '@esbuild/linux-loong64@0.25.4': + optional: true + + '@esbuild/linux-mips64el@0.25.4': + optional: true + + '@esbuild/linux-ppc64@0.25.4': + optional: true + + '@esbuild/linux-riscv64@0.25.4': + optional: true + + '@esbuild/linux-s390x@0.25.4': + optional: true + + '@esbuild/linux-x64@0.25.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.4': + optional: true + + '@esbuild/netbsd-x64@0.25.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.4': + optional: true + + '@esbuild/openbsd-x64@0.25.4': + optional: true + + '@esbuild/sunos-x64@0.25.4': + optional: true + + '@esbuild/win32-arm64@0.25.4': + optional: true + + '@esbuild/win32-ia32@0.25.4': + optional: true + + '@esbuild/win32-x64@0.25.4': + optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0))': dependencies: eslint: 10.10.0(jiti@2.7.0) @@ -1542,6 +2172,81 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -1551,6 +2256,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: hashery: 1.5.1 @@ -1567,6 +2277,18 @@ snapshots: '@pkgr/core@0.3.6': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -1620,6 +2342,10 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@turbo/darwin-64@2.10.12': optional: true @@ -1835,6 +2561,10 @@ snapshots: dependencies: acorn: 8.18.0 + acorn-walk@8.3.2: {} + + acorn@8.14.0: {} + acorn@8.18.0: {} ajv@6.15.0: @@ -1862,6 +2592,8 @@ snapshots: balanced-match@4.0.4: {} + blake3-wasm@2.1.5: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1928,8 +2660,20 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + commander@10.0.1: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1956,8 +2700,38 @@ snapshots: environment@1.1.0: {} + error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.2: {} + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -2045,8 +2819,12 @@ snapshots: esutils@2.0.3: {} + exit-hook@2.2.1: {} + expect-type@1.4.0: {} + exsolve@1.1.1: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -2091,6 +2869,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + globals@17.12.0: {} has-flag@4.0.0: {} @@ -2115,6 +2895,8 @@ snapshots: imurmurhash@0.1.4: {} + is-arrayish@0.3.4: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2135,6 +2917,8 @@ snapshots: dependencies: '@keyv/serialize': 1.1.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2212,6 +2996,26 @@ snapshots: marked@9.1.6: {} + mime@3.0.0: {} + + miniflare@4.20251001.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + sharp: 0.33.5 + stoppable: 1.1.0 + undici: 7.14.0 + workerd: 1.20251001.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -2239,6 +3043,8 @@ snapshots: obug@2.1.4: {} + ohash@2.0.12: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2268,6 +3074,10 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2335,6 +3145,32 @@ snapshots: semver@7.8.5: {} + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2343,6 +3179,10 @@ snapshots: siginfo@2.0.0: {} + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + skin-tone@2.0.0: dependencies: unicode-emoji-modifier-base: 1.0.0 @@ -2353,6 +3193,8 @@ snapshots: std-env@4.2.0: {} + stoppable@1.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2363,6 +3205,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2426,6 +3270,9 @@ snapshots: - oxc-resolver - vue-tsc + tslib@2.8.1: + optional: true + turbo@2.10.12: optionalDependencies: '@turbo/darwin-64': 2.10.12 @@ -2454,6 +3301,8 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -2461,6 +3310,16 @@ snapshots: undici-types@8.3.0: {} + undici@7.14.0: {} + + unenv@2.0.0-rc.21: + dependencies: + defu: 6.1.7 + exsolve: 1.1.1 + ohash: 2.0.12 + pathe: 2.0.3 + ufo: 1.6.4 + unicode-emoji-modifier-base@1.0.0: {} uri-js@4.4.1: @@ -2515,12 +3374,39 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20251001.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20251001.0 + '@cloudflare/workerd-darwin-arm64': 1.20251001.0 + '@cloudflare/workerd-linux-64': 1.20251001.0 + '@cloudflare/workerd-linux-arm64': 1.20251001.0 + '@cloudflare/workerd-windows-64': 1.20251001.0 + + wrangler@4.42.0(@cloudflare/workers-types@5.20260905.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.0 + '@cloudflare/unenv-preset': 2.7.6(unenv@2.0.0-rc.21)(workerd@1.20251001.0) + blake3-wasm: 2.1.5 + esbuild: 0.25.4 + miniflare: 4.20251001.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.21 + workerd: 1.20251001.0 + optionalDependencies: + '@cloudflare/workers-types': 5.20260905.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.18.0: {} + y18n@5.0.8: {} yargs-parser@20.2.9: {} @@ -2537,6 +3423,19 @@ snapshots: yocto-queue@0.1.0: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + yuku-ast@0.9.3: dependencies: '@yuku-toolchain/types': 0.9.3 @@ -2576,4 +3475,6 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.9.3 '@yuku-parser/binding-win32-x64': 0.9.3 + zod@3.22.3: {} + zod@4.5.4: {}