diff --git a/.changeset/faster-progressive-snapshots.md b/.changeset/faster-progressive-snapshots.md new file mode 100644 index 0000000..eb9f738 --- /dev/null +++ b/.changeset/faster-progressive-snapshots.md @@ -0,0 +1,12 @@ +--- +"schema-stream": minor +--- + +Reduce progressive parsing overhead by batching internal partial-string updates at source-chunk +boundaries when completion callbacks are unused, using safe fast property writes, and materializing +`iterate()` snapshots directly without a UTF-8 JSON round trip. External and exotic defaults retain +the previous JSON normalization behavior. Add the low-overhead `onValueComplete` event for +child-before-parent completion deltas with their completed values while preserving the legacy +progress callback. Add a polished Bun and Node benchmark, complex completion coverage, runnable +examples, server-to-browser transport guidance, and deterministic plus opt-in live SDK integration +tests, including a guarded Mastra Agent compatibility example and weekly live canary. diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml new file mode 100644 index 0000000..3d7eda9 --- /dev/null +++ b/.github/workflows/live-e2e.yml @@ -0,0 +1,58 @@ +name: Live model E2E + +on: + workflow_dispatch: + schedule: + - cron: "23 15 * * 3" + +concurrency: + group: live-model-e2e + cancel-in-progress: true + +permissions: + contents: read + +jobs: + openai: + if: github.repository == 'hack-dance/schema-stream' + name: ${{ matrix.provider }} live stream + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - provider: agents + agents-model: gpt-5.6-luna + mastra-model: unused + - provider: mastra + agents-model: unused + mastra-model: openai/gpt-5.6-luna + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run live structured-output matrix + run: bun run test:live + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + SCHEMA_STREAM_AGENTS_MODEL: ${{ matrix.agents-model }} + SCHEMA_STREAM_LIVE_E2E: "1" + SCHEMA_STREAM_LIVE_PROVIDER: ${{ matrix.provider }} + SCHEMA_STREAM_MASTRA_MODEL: ${{ matrix.mastra-model }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d0ef5a..ad80814 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,14 +4,16 @@ Thanks for helping improve Schema Stream. ## Setup -Install the repository toolchain with [mise](https://mise.jdx.dev/) and install dependencies with Bun: +Install the repository toolchain with [mise](https://mise.jdx.dev/) and install dependencies with +Bun: ```sh mise install bun install ``` -The repository develops with Bun 1.3.14, Node.js 24, and TypeScript 7. Consumers do not need TypeScript 7; the packed-package test verifies the published declarations with TypeScript 5.9. +The repository develops with Bun 1.3.14, Node.js 24, and TypeScript 7. Consumers do not need +TypeScript 7; the packed-package test verifies the published declarations with TypeScript 5.9. ## Development checks @@ -28,7 +30,44 @@ For behavior changes, also inspect coverage: bun run test:coverage ``` -`bun run test:packed` builds and installs the package tarball into isolated ESM and CommonJS consumers. It verifies Zod 3, Zod 4, Zod Mini, OpenAI Agents SDK, Vercel AI SDK, and TypeScript 5.9 compatibility. +`bun run test:packed` builds and installs the package tarball into isolated ESM and CommonJS +consumers. It verifies Zod 3, Zod 4, Zod Mini, OpenAI Agents SDK, Mastra, Vercel AI SDK, and +TypeScript 5.9 compatibility. + +Run the credential-free examples and SDK runtime integrations when changing stream adapters or +public examples: + +```sh +bun run examples +bun test tests/sdk-runtime.test.ts +``` + +The opt-in live provider matrix fails closed when explicitly enabled without its required model and +credential variables. See [`docs/integration-testing.md`](./docs/integration-testing.md) for secure +runtime injection, provider selection, and the exact verification contract. + +## Benchmarking + +Run the Bun and Node snapshot benchmark after changes to parser hot paths, snapshot materialization, +or emission policies: + +```sh +bun run benchmark +``` + +The default command builds the local package, validates every measured result, and prints compact +median comparisons. Use `bun run benchmark --verbose` for ranges and emission details, or +`bun run benchmark --json` to retain raw samples. Use `bun run benchmark --help` to narrow fixtures, +policies, runtimes, and payload sizes. + +Use `bun run benchmark --completion-scaling` when completion callback behavior changes. It compares +no callback, `onValueComplete`, and legacy `onKeyComplete` as nested record counts double without +adding the cumulative-history workload to the default benchmark. + +Keep runtime versions, fixture size, source chunk size, warmups, repetitions, and policy selection +identical when comparing revisions. Do not present isolated `JSON.stringify` or `JSON.parse` timings +as feature-equivalent SchemaStream competitors. Publish representative numbers only with their full +configuration and retain machine-readable evidence when making a performance claim. ## Changes @@ -41,4 +80,5 @@ bun run test:coverage bun run changeset ``` -See [`AGENTS.md`](./AGENTS.md) for the complete project conventions, testing matrix, and public-OSS privacy rules. +See [`AGENTS.md`](./AGENTS.md) for the complete project conventions, testing matrix, and public-OSS +privacy rules. diff --git a/README.md b/README.md index b349e62..82c1dd4 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ typed, schema-shaped snapshots, so a UI can render partial strings, nested objec before the response is complete. - SDK-neutral `iterate()` API for Web Streams and async iterables -- Schema-derived placeholders, progressive nested values, and completion paths +- Schema-derived placeholders, progressive nested values, and low-overhead completion events - Opt-in snapshot policies for per-value, byte-threshold, and final-only updates -- OpenAI Agents SDK and Vercel AI SDK text-stream compatibility +- OpenAI Agents and Vercel AI SDK text streams, plus a live-tested Mastra compatibility canary - Zod 4 Classic, Zod Mini, and Zod 3.25+ - ESM and CommonJS builds with no runtime dependency beyond the Zod peer @@ -38,8 +38,11 @@ the snapshots can progress like this: {"title":"Hello","details":{"score":42}} ``` -The default cadence follows the source chunks. Opt-in snapshot policies can instead emit at -completed-value boundaries, byte thresholds, or only once at the end. +The default cadence follows the source chunks with `snapshotPolicy: { mode: "chunk" }` and +`stringBufferSize: 0`. That disables the fixed-size byte buffer; the decoded value is still +accumulated. It is not necessarily one character per snapshot: an SDK or network chunk may contain +part of a Unicode code point or many complete code points. Opt-in snapshot policies can instead emit +at completed-value boundaries, byte thresholds, or only once at the end. ## Install @@ -64,8 +67,10 @@ const schema = z.object({ }) const parser = new SchemaStream(schema, { - onKeyComplete({ activePath, completedPaths }) { - console.log({ activePath, completedPaths }) + onValueComplete({ path, value }) { + if (path[0] === "details" && path[1] === "score") { + routeScore(value) + } } }) @@ -90,6 +95,92 @@ parser.iterate(source, { snapshotPolicy: { mode: "final" } }) ``` See [snapshot policies](./docs/snapshot-policies.md) for exact semantics and performance tradeoffs. +See [completion events](./docs/completion-events.md) for nested path ordering, filtering, and the +legacy progress callback. + +## Server-to-browser streaming + +Provider text deltas and raw response chunks are not standalone JSON documents. A raw Fetch response +can split a UTF-8 code point or JSON token anywhere. SSE and WebSocket add application framing: +`EventSource` assembles a complete SSE event, and WebSocket exposes a complete logical message after +protocol-frame reassembly. Either can carry a complete server-materialized snapshot. + +For most provider-backed applications, keep the API key, SDK, and SchemaStream on the server. Parse +the provider text once, choose an intentional snapshot cadence, then send each selected snapshot as +one versioned JSON message. The browser can use ordinary `JSON.parse` on every message without +understanding partial JSON or provider event formats. + +```typescript +for await (const snapshot of parser.iterate(providerTextStream, { + snapshotPolicy: { mode: "value" } +})) { + socket.send(JSON.stringify({ type: "snapshot", revision: revision++, value: snapshot })) +} +``` + +Browser-side parsing remains supported for local-first, offline, and client-only cases. Server-side +placement is usually preferable when it centralizes secrets, validation, cancellation, coalescing, +and backpressure. WebSocket is often the cleaner browser hop when start, cancel, policy changes, and +progress should share one connection; SSE remains a sound choice for one-way progress and automatic +reconnect. Do not blindly send a growing full snapshot for every token: repeated serialization and +network bytes can become the new bottleneck. + +From a repository checkout, run the interactive comparison at : + +```bash +bun run example:websocket +``` + +See the [transport guide](./docs/transports.md) and the repository's +[Bun WebSocket UI](./examples/websocket-ui/) for an interactive localhost visualization of the +message protocol and snapshot-policy controls. + +## Performance + +Run the repeatable Bun and Node benchmark from a repository checkout: + +```bash +bun run benchmark +``` + +The benchmark compares three feature-aligned paths for each snapshot policy: serialized byte +snapshots from `parse()`, those same snapshots materialized through UTF-8 decoding and `JSON.parse`, +and direct object snapshots from `iterate()`. The terminal summary reports the speedup of +`iterate()` over the serialized round-trip baseline and the cumulative serialization it avoids. + +It also reports normalized `JSON.stringify`, UTF-8 encoding, and `JSON.parse` costs as native +runtime references. Those isolated operations are not presented as equivalent alternatives to +progressive schema-shaped parsing. Results are local synthetic measurements; the runtime versions, +fixture size, chunk size, warmups, and sample count printed by the command define each result. + +Representative local results from an Apple M5 Max arm64 host on July 11, 2026 are shown below. +These are medians from the default 2 MiB fixtures, 64 KiB chunks, one warmup, and five measured +runs. The [machine-readable evidence](./docs/benchmarks/2026-07-11-apple-m5-max.json) retains the raw +samples, host, baseline revision, validation method, and benchmark configuration. + +| Runtime | Fixture / policy | Roundtrip | `iterate()` | Speedup | Serialization avoided | +| --- | --- | ---: | ---: | ---: | ---: | +| Bun 1.3.14 | long string / chunk | 11.94 ms | 1.53 ms | 7.80x | 33.00 MiB | +| Bun 1.3.14 | long string / final | 1.95 ms | 1.29 ms | 1.52x | 2.00 MiB | +| Bun 1.3.14 | object heavy / chunk | 310.36 ms | 298.24 ms | 1.04x | 35.00 MiB | +| Bun 1.3.14 | object heavy / final | 93.33 ms | 87.88 ms | 1.06x | 2.00 MiB | +| Node 24.18.0 | long string / chunk | 100.60 ms | 2.73 ms | 36.83x | 33.00 MiB | +| Node 24.18.0 | long string / final | 7.64 ms | 2.63 ms | 2.91x | 2.00 MiB | +| Node 24.18.0 | object heavy / chunk | 339.35 ms | 245.88 ms | 1.38x | 35.00 MiB | +| Node 24.18.0 | object heavy / final | 55.63 ms | 52.18 ms | 1.07x | 2.00 MiB | + +Use `bun run benchmark --help` for filters, verbose ranges, and machine-readable JSON. See the +[benchmark methodology](./docs/snapshot-policies.md#benchmark) for the fixtures and timing +boundaries. + +Completion callback scaling is measured separately so the normal suite stays quick: + +```bash +bun run benchmark --completion-scaling +``` + +That mode doubles nested record counts and compares parsing with no callback, delta-based +`onValueComplete`, and cumulative-history `onKeyComplete`. ## OpenAI Agents SDK @@ -108,7 +199,7 @@ const outputSchema = z.object({ const agent = new Agent({ name: "Analyst", - model: "gpt-5.5", + model: "gpt-5.6-luna", instructions: "Return a structured analysis.", outputType: outputSchema }) @@ -144,7 +235,7 @@ const outputSchema = z.object({ }) const result = streamText({ - model: "openai/gpt-5.5", + model: "openai/gpt-5.6-luna", output: Output.object({ schema: outputSchema }), prompt: input }) @@ -162,6 +253,56 @@ AI SDK's `partialOutputStream` is a good fit when its partial-object semantics a SchemaStream consumes the raw text stream when you need schema-derived stubs or finer-grained updates. `result.output` remains the authoritative validated result. +## Mastra + +Mastra's documented progressive structured-output surface is `objectStream`; its `textStream` is a +natural-language stream and is not generally a raw-JSON contract. Use `objectStream` directly when +its partial-object cadence is sufficient, and keep `object` as the authoritative validated result: + +```typescript +import { Agent } from "@mastra/core/agent" +import { z } from "zod" + +const outputSchema = z.object({ + summary: z.string(), + details: z.object({ score: z.number() }), + tags: z.array(z.string()) +}) + +const agent = new Agent({ + id: "analyst", + name: "Analyst", + model: "openai/gpt-5.6-luna", + instructions: "Return a structured analysis." +}) + +const result = await agent.stream(input, { + structuredOutput: { + schema: outputSchema, + errorStrategy: "strict" + } +}) + +for await (const partial of result.objectStream) { + renderProgress(partial) +} + +const finalOutput = await result.object +``` + +Some pinned Mastra/provider combinations currently expose direct structured JSON through +`textStream` because the schema is forwarded as the model's response format. That behavior is not +Mastra's public cross-provider contract. The repository's [executable Mastra compatibility +example](./examples/mastra.ts) guards the stream before passing it to SchemaStream, and the weekly +live test verifies `@mastra/core@1.50.1` with `openai/gpt-5.6-luna`. This optional lane provides +SchemaStream's incomplete-string updates, completion events, and snapshot policies, while failing +closed if an upstream version begins emitting prose. + +The repository runs credential-free runtime integrations against all three surfaces and provides +an opt-in live-provider matrix for the OpenAI Agents SDK, Mastra, and Vercel AI SDK with varied +prompts and schemas. See +[integration testing](./docs/integration-testing.md) for the commands and environment contract. + ## Zod compatibility The peer range is `zod@^3.25.0 || ^4.0.0`. @@ -181,7 +322,7 @@ Schema-derived stubs support objects, arrays, records, strings, numbers, boolean prefaults, optionals, nullables, readonly/catch wrappers, lazy schemas, and transform/pipe inputs. Ambiguous or non-JSON schema nodes begin as `null` and are replaced when streamed JSON arrives. -## Defaults and completion paths +## Defaults and completion events ```typescript const parser = new SchemaStream(schema, { @@ -193,14 +334,18 @@ const parser = new SchemaStream(schema, { number: null, boolean: null }, - onKeyComplete({ activePath, completedPaths }) { - updateLoadingState(activePath, completedPaths) + onValueComplete({ path, value }) { + routeCompletedValue(path, value) } }) ``` Zod defaults are used when present. `defaultData` overrides individual fields, including falsy -values. The final completion callback has an empty `activePath`. +values. `onValueComplete` emits the completed value with its path, reports leaves before their +containers, and uses an empty path for the completed root document. Event values are syntactically +complete but not schema-validated. The legacy `onKeyComplete` callback remains available when a +consumer needs character-level string progress and cumulative completion history; new completion +listeners should use `onValueComplete` to avoid repeatedly copying that history. ## Low-level transform @@ -225,6 +370,8 @@ mise install bun install bun run format bun run lint +bun run examples +bun run benchmark bun run check ``` @@ -237,8 +384,9 @@ checks the repository without writing, and `bun run check` includes linting befo tests, and packed-consumer verification. `test:packed` installs the generated tarball into clean consumers and verifies ESM, CommonJS, -Zod 4/Mini, Zod 3, OpenAI Agents SDK, and Vercel AI SDK compatibility with TypeScript 5.9 without -contacting a model. This protects the declaration surface from accidental TS7-only syntax. +Zod 4/Mini, Zod 3, OpenAI Agents SDK, Mastra, and Vercel AI SDK compatibility with TypeScript 5.9 +under Node and Bun without contacting a model. This protects the declaration surface from +accidental TS7-only syntax. ## License diff --git a/biome.jsonc b/biome.jsonc index c62933c..9a78430 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -127,6 +127,17 @@ } } } + }, + { + // Benchmark samples and runtime workers must run sequentially to avoid measurement contention. + "includes": ["tests/**/*.benchmark.mts"], + "linter": { + "rules": { + "performance": { + "noAwaitInLoops": "off" + } + } + } } ] } diff --git a/bun.lock b/bun.lock index 932cbb1..7047170 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@biomejs/biome": "2.5.2", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", + "@mastra/core": "1.50.1", "@openai/agents": "0.13.1", "@types/bun": "^1.3.14", "ai": "7.0.19", @@ -26,12 +27,28 @@ "esbuild": "^0.28.1", }, "packages": { + "@a2a-js/sdk": ["@a2a-js/sdk@0.3.14", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-F6Ew1AtPzCLhTn8h9yiqTe7DiDf6XVrSnq9V1YqSl9eWqPm6anMveTiKdCSb/76cW0YiJc24rNaUrVezFFHbqQ=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JHvgCU4SR6OdjOI+8rxIF3zhIboFAkQ1vEa+QOIdkK5VyME1ONx8SbgM2YtnSY1DOsCtUyhd+EG876pYpRjopg=="], "@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.7", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OSm5/5kdrHa11WIOo5LYgDKnxYWp5aB/wx5EXRHi0jpUGduMDeB6oht9U6p+UNNWIP3F/EqPpV8d7vdP/iRnqg=="], + "@ai-sdk/provider-utils-v5": ["@ai-sdk/provider-utils@3.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA=="], + + "@ai-sdk/provider-utils-v6": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + + "@ai-sdk/provider-utils-v7": ["@ai-sdk/provider-utils@5.0.0", "", { "dependencies": { "@ai-sdk/provider": "4.0.0", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A=="], + + "@ai-sdk/provider-v5": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@ai-sdk/provider-v6": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/provider-v7": ["@ai-sdk/provider@4.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg=="], + + "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], "@biomejs/biome": ["@biomejs/biome@2.5.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.2", "@biomejs/cli-darwin-x64": "2.5.2", "@biomejs/cli-linux-arm64": "2.5.2", "@biomejs/cli-linux-arm64-musl": "2.5.2", "@biomejs/cli-linux-x64": "2.5.2", "@biomejs/cli-linux-x64-musl": "2.5.2", "@biomejs/cli-win32-arm64": "2.5.2", "@biomejs/cli-win32-x64": "2.5.2" }, "bin": { "biome": "bin/biome" } }, "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA=="], @@ -174,6 +191,8 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.5", "", {}, "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], @@ -182,10 +201,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + + "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@mastra/core": ["@mastra/core@1.50.1", "", { "dependencies": { "@a2a-js/sdk": "~0.3.13", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.25", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.27", "@ai-sdk/provider-utils-v7": "npm:@ai-sdk/provider-utils@5.0.0", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.3", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.10", "@ai-sdk/provider-v7": "npm:@ai-sdk/provider@4.0.0", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.3.3", "@modelcontextprotocol/sdk": "^1.29.0", "@sindresorhus/slugify": "^2.2.1", "@standard-schema/spec": "^1.1.0", "ajv": "^8.20.0", "chat": "^4.29.0", "croner": "^10.0.1", "dotenv": "^17.3.1", "execa": "^9.6.1", "fastq": "^1.20.1", "gray-matter": "^4.0.3", "ignore": "^7.0.5", "jpeg-js": "^0.4.4", "json-schema": "^0.4.0", "lru-cache": "^11.2.7", "p-map": "^7.0.4", "p-retry": "^7.1.1", "picomatch": "^4.0.3", "posthog-node": "^5.37.0", "tokenx": "^1.3.0", "ws": "^8.21.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-8iFBnaR4LvWsvshFifuGMvo0bBKSxg6JXKtcr/BYhak13NjhLBumjwSoNdKt9SOci5uMp+e+/QUEeBgpFo65/w=="], + + "@mastra/schema-compat": ["@mastra/schema-compat@1.3.3", "", { "dependencies": { "json-schema-to-zod": "^2.7.0", "zod-from-json-schema": "^0.5.2", "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NqptophFxMB8Z3vXjnLAMbj2OMbspAA6Tqf3DQkxxNmlBH9RYhA4xqwK8/fum0lrgmYzVrsSkw7SwHrgVDcg6g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -202,6 +229,10 @@ "@openai/agents-realtime": ["@openai/agents-realtime@0.13.1", "", { "dependencies": { "@openai/agents-core": "0.13.1", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.21.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-1wAOGWl5qBM1WVVpWYKFiYMs3QJdb3ynmP+xToivTml5HkWgpitqMiX2VGlyAhn4khFFCDvX/7X0pZFhLkaViA=="], + "@posthog/core": ["@posthog/core@1.40.2", "", { "dependencies": { "@posthog/types": "^1.393.0" } }, "sha512-H12j7O9iHGvpK9t2ko8W4pvfbV1pBDxrsWC1LA6yp2RhzwvC4T3sWhu+AekDQJSRSrJEWlB0t/Ueq9QhPSq7FQ=="], + + "@posthog/types": ["@posthog/types@1.393.0", "", {}, "sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], @@ -252,18 +283,34 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.1", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw=="], + + "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], @@ -342,10 +389,12 @@ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], @@ -368,8 +417,14 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "chat": ["chat@4.33.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-qaQyr6Nm7gLEPkYpfjlZzCxKKjvDyRe3GoVA5++RrzW9po5fe3ddH4l9JkGaFsTvSiRGAzuz12WJj/BB5+A6Hw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -390,20 +445,28 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], @@ -426,7 +489,7 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], @@ -452,10 +515,16 @@ "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -478,6 +547,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -506,6 +577,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -516,6 +589,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], @@ -526,9 +601,11 @@ "human-id": ["human-id@4.2.0", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -538,16 +615,26 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -556,12 +643,16 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + + "js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-zod": ["json-schema-to-zod@2.8.1", "", { "bin": { "json-schema-to-zod": "dist/cjs/cli.js" } }, "sha512-fRr1mHgZ7hboLKBUdR428gd9dIHUFGivUqOeiDcSmyXkNZCtB1uGaZLvsjZ4GaN5pwBIs+TGIOf6s+Rp5/R/zA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -574,6 +665,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], @@ -586,18 +679,100 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -616,12 +791,16 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "nypm": ["nypm@0.6.8", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -644,12 +823,16 @@ "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], + "p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="], + + "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -678,10 +861,14 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + "posthog-node": ["posthog-node@5.41.0", "", { "dependencies": { "@posthog/core": "^1.40.2" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-jOkX6THOr5WD+FGUEaTxekas8c7NOC3TqJ2Byfe2KMimQdL/F/osz17uSbkNzR4V9WFZoe8YaGP3Xp0EUpPKGg=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -700,6 +887,14 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -714,6 +909,10 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -752,6 +951,10 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], @@ -768,10 +971,14 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tokenx": ["tokenx@1.3.0", "", {}, "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -790,14 +997,32 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -810,16 +1035,38 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod-from-json-schema": ["zod-from-json-schema@0.5.4", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-l+XRdV8YZAolpJSXDk3kSEam1W0knpSj7vvbA0laOzJyGV7vhmKFqgfl815VePvp2vhPOhbgo4pMtuYKXqkv9w=="], + + "zod-from-json-schema-v3": ["zod-from-json-schema@0.0.5", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-zYEoo86M1qpA1Pq6329oSyHLS785z/mTwfr9V1Xf/ZLhuuBGaMlDGu/pDVGVUe4H4oa1EFgWZT53DP0U3oT9CQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zod3": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@ai-sdk/provider-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@2.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-h88OPkavHTiN9tMn2l5awAznGB0lXzjcLhgR1/rvjB2zlLprsNxbM2tt6OJsHUxduLC3klq0/eqaSf6fX5XVww=="], + + "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/provider-utils-v7/@ai-sdk/provider": ["@ai-sdk/provider@4.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg=="], + + "@ai-sdk/ui-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "@ai-sdk/ui-utils-v5/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], + + "@changesets/parse/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -828,36 +1075,50 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@mastra/core/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "chat/@workflow/serde": ["@workflow/serde@4.1.0-beta.2", "", {}, "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "zod-from-json-schema-v3/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@changesets/parse/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "eslint/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "eslint/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "eslint/find-up/locate-path/p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], diff --git a/docs/benchmarks/2026-07-11-apple-m5-max.json b/docs/benchmarks/2026-07-11-apple-m5-max.json new file mode 100644 index 0000000..8d3615d --- /dev/null +++ b/docs/benchmarks/2026-07-11-apple-m5-max.json @@ -0,0 +1,165 @@ +{ + "capturedAt": "2026-07-11T21:00:48Z", + "source": { + "baselineHead": "5f11bfbe944f9e9136e74c42c5c28083b9e19c24", + "performanceChanges": "the repository revision containing this artifact" + }, + "host": { + "architecture": "arm64", + "chip": "Apple M5 Max", + "operatingSystem": "macOS 26.5.1" + }, + "configuration": { + "chunkSizeBytes": 65536, + "fixtures": ["long-string", "object-heavy"], + "payloadSizeMiB": 2, + "policies": ["chunk", "value", "bytes-256kb", "bytes-1mb", "final"], + "repeats": 5, + "runtimes": ["Bun 1.3.14", "Node v24.18.0"], + "warmups": 1 + }, + "method": { + "baseline": "parse plus UTF-8 decode plus JSON.parse for every emitted snapshot", + "candidate": "iterate with direct JSON-domain object snapshots", + "resultValidation": "full deep equality outside every timed window", + "sampleOrdering": "roundtrip and iterate alternate order within paired samples", + "speedup": "roundtrip median divided by iterate median" + }, + "representativeRows": [ + { + "avoidedSerializedBytes": 34604651, + "fixture": "long-string", + "iterateMedianMs": 1.5294169999999951, + "iterateSamplesMs": [ + 3.8770000000000095, 3.46712500000001, 1.4772079999999903, 1.3517500000000098, + 1.5294169999999951 + ], + "policy": "chunk", + "roundtripMedianMs": 11.936125000000004, + "roundtripSamplesMs": [ + 12.956417000000016, 13.79008300000001, 9.498624999999976, 11.936125000000004, + 8.594875000000002 + ], + "runtime": "Bun 1.3.14", + "speedup": 7.804362708143065 + }, + { + "avoidedSerializedBytes": 2097152, + "fixture": "long-string", + "iterateMedianMs": 1.2875419999999735, + "iterateSamplesMs": [ + 1.179666999999995, 1.287540999999976, 1.5760830000000396, 1.2875419999999735, + 1.324707999999987 + ], + "policy": "final", + "roundtripMedianMs": 1.9538749999999823, + "roundtripSamplesMs": [ + 1.6307089999999675, 2.6792919999999754, 1.622000000000014, 2.4417499999999563, + 1.9538749999999823 + ], + "runtime": "Bun 1.3.14", + "speedup": 1.517523311860912 + }, + { + "avoidedSerializedBytes": 36700118, + "fixture": "object-heavy", + "iterateMedianMs": 298.24375000000055, + "iterateSamplesMs": [ + 347.8932499999996, 356.20191699999987, 298.24375000000055, 252.8752089999998, + 276.20945800000027 + ], + "policy": "chunk", + "roundtripMedianMs": 310.35549999999967, + "roundtripSamplesMs": [ + 312.7470840000001, 354.9025419999998, 310.35549999999967, 307.2190840000003, + 274.57037500000024 + ], + "runtime": "Bun 1.3.14", + "speedup": 1.0406102391081091 + }, + { + "avoidedSerializedBytes": 2097188, + "fixture": "object-heavy", + "iterateMedianMs": 87.88358300000073, + "iterateSamplesMs": [ + 89.20529100000022, 93.46500000000015, 82.79874999999993, 86.65999999999985, + 87.88358300000073 + ], + "policy": "final", + "roundtripMedianMs": 93.32595899999978, + "roundtripSamplesMs": [ + 86.02829100000054, 102.47641699999986, 88.34962499999892, 93.32595899999978, 95.038958000001 + ], + "runtime": "Bun 1.3.14", + "speedup": 1.0619271064539895 + }, + { + "avoidedSerializedBytes": 34604651, + "fixture": "long-string", + "iterateMedianMs": 2.7317920000000413, + "iterateSamplesMs": [ + 2.887666999999965, 2.7317920000000413, 3.1579589999998916, 2.656041999999843, + 2.674541999999974 + ], + "policy": "chunk", + "roundtripMedianMs": 100.59987500000011, + "roundtripSamplesMs": [ + 102.08208400000012, 97.55825000000004, 103.35524999999984, 100.59987500000011, + 95.95591599999989 + ], + "runtime": "Node v24.18.0", + "speedup": 36.82559836180741 + }, + { + "avoidedSerializedBytes": 2097152, + "fixture": "long-string", + "iterateMedianMs": 2.6264170000004015, + "iterateSamplesMs": [ + 2.6264170000004015, 2.8311250000001564, 2.6974169999998594, 2.2538329999997586, + 2.614082999999937 + ], + "policy": "final", + "roundtripMedianMs": 7.6352499999998145, + "roundtripSamplesMs": [ + 7.418250000000171, 7.594625000000178, 8.957500000000437, 7.6352499999998145, + 7.674040999999761 + ], + "runtime": "Node v24.18.0", + "speedup": 2.90709738780957 + }, + { + "avoidedSerializedBytes": 36700118, + "fixture": "object-heavy", + "iterateMedianMs": 245.8846249999997, + "iterateSamplesMs": [ + 214.40049999999974, 226.01066600000013, 251.54075000000012, 245.8846249999997, + 247.1360420000001 + ], + "policy": "chunk", + "roundtripMedianMs": 339.34820799999943, + "roundtripSamplesMs": [ + 302.86620800000037, 325.14129200000025, 339.34820799999943, 347.52099999999973, + 357.08137499999975 + ], + "runtime": "Node v24.18.0", + "speedup": 1.380111538084172 + }, + { + "avoidedSerializedBytes": 2097188, + "fixture": "object-heavy", + "iterateMedianMs": 52.178500000001804, + "iterateSamplesMs": [ + 52.178500000001804, 53.85070800000176, 50.96966600000087, 48.434958999998344, + 54.09670900000128 + ], + "policy": "final", + "roundtripMedianMs": 55.63016599999901, + "roundtripSamplesMs": [ + 55.63016599999901, 57.23287500000151, 55.018583000000945, 53.80395899999712, + 60.50733300000138 + ], + "runtime": "Node v24.18.0", + "speedup": 1.0661511158810062 + } + ] +} diff --git a/docs/completion-events.md b/docs/completion-events.md new file mode 100644 index 0000000..3309327 --- /dev/null +++ b/docs/completion-events.md @@ -0,0 +1,158 @@ +# Completion events + +`onValueComplete` reports one path and value when each JSON value finishes parsing. It is the +low-overhead way to trigger work for completed fields, array entries, objects, or the whole +document. + +```ts +import { SchemaStream } from "schema-stream" +import { z } from "zod" + +const sectionSummarySchema = z.string() +const schema = z.object({ + sections: z.array( + z.object({ + heading: z.string(), + summary: sectionSummarySchema + }) + ) +}) + +const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + const completedSectionSummary = + path.length === 3 && + path[0] === "sections" && + typeof path[1] === "number" && + path[2] === "summary" + + if (completedSectionSummary) { + const summary = sectionSummarySchema.safeParse(value) + if (summary.success) { + routeCompletedSummary(path[1], summary.data) + } + } + } +}) +``` + +The callback receives the completed path and its syntactically complete JSON value. It does not +copy the full history of prior events, so its bookkeeping scales with path depth instead of +accumulated completion count. Container values are parser-owned and are not cloned for the event; +treat objects and arrays as read-only. + +## Ordering + +Values complete from the leaves outward. Given this input: + +```json +{"profile":{"name":"Ada"},"scores":[4,8]} +``` + +the callback receives: + +```text +["profile", "name"] +["profile"] +["scores", 0] +["scores", 1] +["scores"] +[] +``` + +The empty path is the completed root document. Every callback receives a fresh path array, so +mutating that path cannot affect parser state or a later event. + +## Filtering paths + +Use ordinary path checks when only one field matters: + +```ts +const parser = new SchemaStream(schema, { + onValueComplete({ path }) { + if (path.length === 2 && path[0] === "profile" && path[1] === "name") { + markNameComplete() + } + } +}) +``` + +## Conditional decisions + +Use the completed value to make decisions only after the field is syntactically settled: + +```ts +const planSchema = z.object({ + plan: z.object({ risk: z.enum(["low", "high"]) }) +}) +const riskSchema = planSchema.shape.plan.shape.risk + +const parser = new SchemaStream(planSchema, { + onValueComplete({ path, value }) { + if (path.length === 2 && path[0] === "plan" && path[1] === "risk") { + const risk = riskSchema.safeParse(value) + if (!risk.success || risk.data === "high") { + requireHumanReview() + } else { + continueAutomatically() + } + } + } +}) +``` + +This is different from reacting to a partial string snapshot. A value event fires once, after its +closing quote, number delimiter, literal, or container boundary has parsed. + +For several listeners, dispatch from one callback without adding parser work: + +```ts +const listeners = new Map void>([ + ['["profile","name"]', markNameComplete], + ['["profile"]', markProfileComplete] +]) + +const parser = new SchemaStream(schema, { + onValueComplete({ path }) { + listeners.get(JSON.stringify(path))?.() + } +}) +``` + +Array indexes are numbers. Object keys remain strings even when they contain dots, brackets, or +numeric text, so `["items", 0]` and `["items", "0"]` stay distinct. + +## Semantics + +- Primitives, empty containers, and non-empty containers each emit once when syntactically complete. +- Children emit before their containing object or array. +- The root value emits as `[]` only after the complete document parses successfully. +- Duplicate object keys emit once per occurrence even though the last value wins in the snapshot. +- Paths describe the JSON input, including keys that are not declared by the schema. +- `value` is the completed primitive or container at that path; the root event carries the complete + document. +- Objects and arrays are provided without cloning. Do not mutate them inside the callback: a change + can be visible in later ancestor or root completion values, though it does not change emitted + schema-shaped snapshots. +- Partial string characters do not emit completion events. +- Event cadence is the same for `parse()` and `iterate()` and does not depend on snapshot policy. +- Callbacks run synchronously with parsing. A thrown callback error fails the stream and cancels an + `iterate()` source through its normal error path. + +## Legacy progress callback + +`onKeyComplete` remains available for compatibility. Despite its historical name, it reports +partial string progress as well as completions and includes a fresh copy of every previously +completed path on each call. That shape is useful when a consumer needs both the active path and a +cumulative history, but it becomes expensive on large documents. + +Prefer `onValueComplete` for new completion listeners. Keep `onKeyComplete` only when the UI needs +its character-level progress or complete history contract. + +Compare callback scaling locally with: + +```sh +bun run benchmark --completion-scaling +``` + +Snapshot emission is configured separately. See [Snapshot policies](./snapshot-policies.md). diff --git a/docs/integration-testing.md b/docs/integration-testing.md new file mode 100644 index 0000000..6cc8702 --- /dev/null +++ b/docs/integration-testing.md @@ -0,0 +1,133 @@ +# Integration testing + +Schema Stream tests both parser correctness and the SDK stream shapes consumers use in production. +The default suite never needs credentials or network access. + +## Deterministic SDK runtime tests + +Run the SDK integration suite directly: + +```sh +bun test tests/sdk-runtime.test.ts +``` + +These tests execute Vercel AI SDK `streamText()` with `Output.object()`, the OpenAI Agents SDK +`Runner` with `toTextStream()`, and Mastra `Agent.stream()` with `structuredOutput`. Deterministic +model implementations supply provider-shaped stream events, so the tests exercise the real SDK +orchestration without contacting a provider. + +The fixtures cover two distinct schema families with nested arrays and objects, records, nullable +and optional fields, Unicode, escaped punctuation, and long progressive strings. Each run verifies +meaningful intermediate snapshots, independent object identity, prompt forwarding, final schema +validation, and equality with the SDK's authoritative output. + +## Runnable examples + +These development commands require a repository checkout and its dev dependencies; examples are not +part of the published package tarball. Run every credential-free example with: + +```sh +bun run examples +``` + +Run any example independently: + +```sh +bun run example:progressive +bun run example:sdk +bun run example:mastra +``` + +`example:progressive` streams complex JSON directly. `example:sdk` runs both supported SDKs through +their deterministic model interfaces. `example:mastra` is a guarded compatibility canary for a +Mastra one-model path whose mock emits JSON text. Every example verifies its final snapshots, and +the SDK examples also compare them with the producing framework's authoritative structured result. + +Mastra officially documents `objectStream` for progressive structured output and describes +`textStream` as natural-language text. The compatibility example therefore checks for raw JSON +before parsing. The opt-in live lane is a weekly canary for the pinned Mastra/OpenAI combination, +not a general promise that every Mastra provider exposes structured JSON text. + +The WebSocket UI is a long-running server example rather than part of `bun run examples`: + +```sh +bun run example:websocket +``` + +It keeps SchemaStream and provider credentials on Bun, then sends versioned, fully valid JSON +messages to a browser client. See [`examples/websocket-ui`](../examples/websocket-ui/) for its +fixed Fixture and model-backed OpenAI modes. + +## Live provider matrix + +Live tests are disabled unless `SCHEMA_STREAM_LIVE_E2E=1` and a provider is selected. Inject secrets +through a secure process environment such as a 1Password Developer Environment; do not commit a +credential file. + +For deliberately local development, the repository ignores `.env.local`. Bun normally loads it; +the following direct command makes the credential source explicit and reproducible: + +```sh +SCHEMA_STREAM_LIVE_E2E=1 \ +SCHEMA_STREAM_LIVE_PROVIDER=agents \ +SCHEMA_STREAM_AGENTS_MODEL=gpt-5.6-luna \ +bun --env-file=.env.local test tests/live-model.e2e.test.ts --timeout=300000 +``` + +Keep `.env.local` local and use runtime-injected secrets in CI. + +OpenAI Agents SDK requires `OPENAI_API_KEY` to already be present through the authorized runtime: + +```sh +SCHEMA_STREAM_LIVE_E2E=1 \ +SCHEMA_STREAM_LIVE_PROVIDER=agents \ +SCHEMA_STREAM_AGENTS_MODEL= \ +bun run test:live +``` + +Vercel AI Gateway requires `AI_GATEWAY_API_KEY` to already be present through the authorized +runtime: + +```sh +SCHEMA_STREAM_LIVE_E2E=1 \ +SCHEMA_STREAM_LIVE_PROVIDER=gateway \ +SCHEMA_STREAM_GATEWAY_MODEL= \ +bun run test:live +``` + +Mastra uses the same `OPENAI_API_KEY` with a provider-qualified model id: + +```sh +SCHEMA_STREAM_LIVE_E2E=1 \ +SCHEMA_STREAM_LIVE_PROVIDER=mastra \ +SCHEMA_STREAM_MASTRA_MODEL=openai/ \ +bun run test:live +``` + +Use `SCHEMA_STREAM_LIVE_PROVIDER=all` with all three model variables and both credential variables +to run every provider. When live E2E is explicitly enabled, an invalid provider or missing +selected-provider variable is a test failure rather than a skip. Without +`SCHEMA_STREAM_LIVE_E2E=1`, all live cases skip normally. The matrix uses three varied schemas: an +escape-heavy Unicode summary, a multilevel inventory, and a schedule with nullable branches and +repeated nested entries. + +Live assertions avoid model-specific wording and provider-specific chunk counts. They require at +least one snapshot, child-before-root completion events, schema-valid authoritative output, +case-specific semantic constraints, and agreement between the last Schema Stream snapshot and the +SDK result. Requests use bounded output and explicit timeouts. Agents tracing is disabled, and +failures print only a fixed case identifier, sanitized error class, and numeric status when +available; prompts, outputs, headers, and credentials are never logged. + +The `Live model E2E` GitHub Actions workflow runs the Agents and Mastra lanes weekly and on manual +dispatch. It is intentionally excluded from pull requests so forked code cannot access the +repository secret and routine contributions do not incur provider cost. + +## Packed consumers + +```sh +bun run test:packed +``` + +The packed gate installs the generated tarball into clean TypeScript 5.9 consumers. It executes ESM, +CommonJS, Zod 4, Zod Mini, and Zod 3 parsing under Node and Bun, checks completion events, and compiles +the OpenAI Agents SDK, Mastra, and Vercel AI SDK integration surfaces without contacting a model. diff --git a/docs/snapshot-policies.md b/docs/snapshot-policies.md index e733c74..c9a7555 100644 --- a/docs/snapshot-policies.md +++ b/docs/snapshot-policies.md @@ -1,8 +1,8 @@ # Snapshot policies -Snapshot policies reduce cumulative JSON serialization when a consumer does not need an update for -every source chunk. They are optional. Omitting `snapshotPolicy` retains the 4.0 behavior: one -snapshot for every input chunk. +Snapshot policies reduce cumulative snapshot work, including JSON serialization for `parse()`, when +a consumer does not need an update for every source chunk. They are optional. Omitting +`snapshotPolicy` retains the 4.0 behavior: one snapshot for every input chunk. The same options work with both `parse()` and `iterate()`: @@ -27,7 +27,10 @@ for await (const snapshot of parser.iterate(source, { ``` Emits after every input chunk. This is the default and is snapshot-for-snapshot compatible with -omitting the option. +omitting the option. The default `stringBufferSize` is `0`, so SchemaStream adds no fixed-size string +buffer, but source chunks remain the cadence boundary. A provider or network chunk can contain one +or many characters; character-by-character snapshots require the upstream source itself to emit one +Unicode code point per chunk. ### `value` @@ -39,7 +42,7 @@ omitting the option. Emits after an input chunk completes at least one primitive JSON value. Several values completed in one source chunk produce one snapshot. This is useful for long streamed strings because incomplete -characters do not repeatedly serialize the entire accumulated object. +characters do not repeatedly materialize or serialize the entire accumulated object. ### `bytes` @@ -69,21 +72,96 @@ validated output from the producing SDK. - `parse()` rejects invalid byte thresholds synchronously. `iterate()` rejects on first advancement, before locking the source. - Malformed and truncated JSON reject under every policy. -- `onKeyComplete` cadence is independent of snapshot cadence. +- `onValueComplete` and legacy `onKeyComplete` cadence are independent of snapshot cadence. See + [Completion events](./completion-events.md) for their different cost and ordering contracts. - `iterate()` preserves source backpressure at emission boundaries and cancels its source when the consumer returns early. -- Every yielded `iterate()` value is decoded from serialized output, so consumer mutation cannot - affect later snapshots. +- Every yielded `iterate()` value is an independent JSON-equivalent copy, so consumer mutation + cannot affect later snapshots. Parser-owned JSON-domain state is cloned directly; exotic custom + defaults retain the exact stringify/parse fallback behavior. - Timer-based policies are intentionally excluded because asynchronous controller lifetime, cancellation, and deterministic backpressure semantics require a separate design. ## Benchmark -Run the policy comparison with an optional payload size in megabytes: +Run the public cross-runtime benchmark: ```bash -bun run benchmark:snapshots 25 +bun run benchmark ``` -The benchmark combines one long string with 10,000 nested records and reports source throughput, -snapshot count, and total emitted bytes for `chunk`, `value`, 256 KB, 1 MB, and `final` policies. +The default run quietly builds the package, then benchmarks Bun and Node against the same ESM entry +point. Each runtime receives separate long-string and object-heavy fixtures targeting 2 MiB, split +into 64 KiB source chunks. Measurements run sequentially with one warmup and five recorded samples. +The round-trip and direct-iteration operations alternate order within paired samples to reduce JIT +and measurement-order bias. The compact terminal tables report medians; `--verbose` adds ranges and +detailed emission metrics, while `--json` includes every recorded sample. + +### What is compared + +The native JSON reference table isolates `JSON.stringify`, both `Buffer.from` and browser-compatible +`TextEncoder.encode` UTF-8 paths, and `TextDecoder` plus `JSON.parse`. +Its time column is normalized to milliseconds per operation even though each recorded sample repeats +the operation over at least 16 MiB. These rows expose component costs; they are not +feature-equivalent alternatives to SchemaStream. + +The streaming tables exercise `chunk`, `value`, 256 KiB, 1 MiB, and `final` policies through three +paths: + +- `parse` incrementally parses the source and emits serialized UTF-8 snapshots. +- `roundtrip` runs the same parser and policy, then decodes and applies `JSON.parse` to every + emitted snapshot. This is the feature-aligned baseline for the former serialized + object-materialization path. +- `iterate` incrementally parses the same source and emits independent object snapshots directly. + +`speedup` is the round-trip median divided by the `iterate` median. Serialized and avoided MiB are +cumulative across every snapshot, which makes amplification from frequent progressive updates +visible. Fixture construction, post-measurement correctness checks, and worker startup remain +outside timed windows. Every final result is compared deeply with the complete canonical fixture; +stream construction, parsing, snapshot materialization, and consumer iteration remain inside the +timed windows. + +This comparison isolates materialization strategy on the current parser. It does not claim to +reproduce every implementation detail of an older release, and it does not compare SchemaStream to +`JSON.stringify` as though they performed the same work. + +### Options and evidence + +Show the complete option reference or include timing ranges: + +```bash +bun run benchmark --help +bun run benchmark --verbose +``` + +Completion callbacks have a separate scaling mode so callback bookkeeping does not lengthen the +default materialization benchmark: + +```bash +bun run benchmark --completion-scaling +bun run benchmark --completion-scaling --verbose +``` + +It parses 250, 500, 1,000, and 2,000 nested records with a single final snapshot and compares no +callback, delta-based `onValueComplete`, and cumulative-history `onKeyComplete`. The compact table +shows median time and the growth factor as record counts double. Verbose output adds callback event +counts, timing ranges, input size, and records per second. This mode uses the current built module +because older modules may not expose `onValueComplete`. + +Use flags to narrow investigations or emit machine-readable evidence: + +```bash +bun run benchmark --size-mb 1 --warmups 2 --repeats 7 +bun run benchmark --runtimes node --fixtures object-heavy --policies chunk,final +bun run benchmark --size-mb 1 --json > /tmp/schema-stream-benchmark.json +bun run benchmark --module /tmp/baseline/dist/index.mjs \ + --iterate-materialization json-roundtrip --json > /tmp/schema-stream-before.json +``` + +An explicit `--module` is not built by the harness. Prepare that ESM entry point first. +`--iterate-materialization` labels the imported module's behavior for reports; it does not alter the +module. The default `direct-json-domain` label describes the current implementation. + +The benchmark is a local synthetic comparison, not a production latency claim. Run it on an idle +machine and compare multiple medians. Keep fixture size, chunk size, runtime versions, policies, +warmups, and repetitions identical when evaluating a change or publishing representative results. diff --git a/docs/transports.md b/docs/transports.md new file mode 100644 index 0000000..f75d3ee --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,351 @@ +# Streaming JSON over fetch, SSE, and WebSocket + +SchemaStream is a parser, not a transport. Fetch streams, Server-Sent Events (SSE), and WebSockets +move bytes or messages; none of them turns an arbitrary model-text chunk into a complete JSON +document. Keep transport framing separate from JSON parsing and application validation. + +Every snapshot yielded by `SchemaStream.iterate()` is an independent, JSON-equivalent value. It +can be serialized and sent as one valid JSON message even while the source document is incomplete. +The snapshot is not automatically schema-valid or trusted. Validate the settled result, or a +completed subtree, before using it for durable state or side effects. + +## What each layer guarantees + +| Layer | Boundary it guarantees | What it does not guarantee | +| --- | --- | --- | +| Fetch response body | Ordered byte chunks | A chunk may split UTF-8, a token, or any JSON value | +| SSE | Complete SSE events after the SSE stream is decoded | An event's `data` is not necessarily the target JSON document | +| WebSocket | Ordered application messages on one connection | A message is valid JSON only when the sender makes it so | +| SchemaStream snapshot | A complete, independent JSON-equivalent value | Schema validation, authorization, or business-rule validity | + +Provider SDKs often add another envelope around model text. Decode that envelope first and pass +only the ordered text deltas to SchemaStream. For example, do not concatenate an entire SSE event, +including `event:` and `data:` fields, into the JSON being parsed. + +## Raw chunks are not application messages + +The word *stream* hides three different browser contracts: + +1. A raw Fetch response body exposes arbitrary byte chunks. One server write can arrive in several + reads, and several writes can arrive in one read. A read is not a UTF-8, JSON-token, or JSON-value + boundary. +2. `EventSource` decodes an SSE response line by line, accumulates its `data:` fields, and dispatches + one `MessageEvent` only after the blank-line event delimiter. Network fragmentation is not visible + as several browser events. +3. WebSocket preserves logical message boundaries. A message can use several protocol frames or TCP + packets, but the browser dispatches one `message` event after the protocol reassembles it. + +This distinction corrects a common misconception: SSE can carry a complete server-materialized JSON +snapshot. Put one compact `JSON.stringify()` result in one SSE event, and `event.data` is the complete +JSON text even when the underlying response arrived in many network reads. The same snapshot can be +one WebSocket message: + +```typescript +const message = { + type: "snapshot", + revision: revision++, + value: snapshot +} +const payload = JSON.stringify(message) + +// SSE: the blank line terminates one application event. +controller.enqueue(encoder.encode(`id: ${message.revision}\ndata: ${payload}\n\n`)) + +// WebSocket: send() creates one logical application message. +socket.send(payload) +``` + +The corresponding browser handlers both parse complete JSON: + +```typescript +events.onmessage = event => render(JSON.parse(event.data)) +socket.onmessage = event => render(JSON.parse(String(event.data))) +``` + +Browser-side partial JSON parsing is necessary only when the server forwards raw model deltas or an +otherwise unframed response body *and* the UI must derive progressive structured snapshots from it. +A browser that renders raw text or waits for the final document does not need a partial JSON parser. +A raw Fetch stream can still carry complete snapshots, but it needs an explicit record protocol such +as NDJSON, JSON text sequences, or length-prefixed messages. The browser must buffer that framing +across arbitrary response chunks before calling `JSON.parse`. + +The provider hop and browser hop are separate decisions. A provider may force SSE or another streamed +protocol into the server. Once the server extracts the ordered model text, it can terminate that +protocol, run SchemaStream, and choose a different application transport for its own browser. + +## Why repeated accumulation is weak + +A common browser implementation retries `JSON.parse` after every chunk: + +```typescript +let text = "" + +for await (const chunk of source) { + text += chunk + + try { + render(JSON.parse(text)) + } catch { + // Most prefixes are not complete JSON documents. + } +} +``` + +Most attempts fail until the document closes. Each retry starts at the beginning, and repeated +string concatenation, scanning, exceptions, and object construction add work to the rendering +path. With fixed-size chunks, rescanning a response that grows from 1 to _n_ bytes can approach +quadratic cumulative work. + +SchemaStream keeps incremental parser state across chunks. That avoids reparsing the complete +prefix on every update, but snapshots still have a cost. Materializing, serializing, and sending a +growing full snapshot on every tiny source chunk can also approach quadratic cumulative bytes. + +## Recommended server boundary + +For most provider-backed applications, parse once on the server and send complete application +messages to the UI: + +```mermaid +flowchart LR + provider["Provider text stream"] --> parser["SchemaStream on server"] + parser --> cadence["Snapshot policy and coalescing"] + cadence --> encode["Serialize one versioned message"] + encode --> socket["SSE or WebSocket"] + socket --> browser["JSON.parse and render"] + provider --> final["Authoritative SDK result"] + final --> validate["Final schema validation"] + validate --> encode +``` + +A useful wire envelope separates progress from the final validated result: + +```typescript +type SnapshotMessage = { + type: "snapshot" + generationId: string + revision: number + final: boolean + value: T +} +``` + +The server should: + +1. Extract the provider's ordered JSON text stream. +2. Feed it to `iterate()` with a suitable [snapshot policy](./snapshot-policies.md). +3. Keep only the latest pending snapshot and flush at a bounded UI cadence. +4. Serialize each flushed envelope once, then reuse that string for every subscribed client. +5. Drop superseded progress revisions when a client is behind instead of building an unbounded + send queue. +6. Validate the authoritative final SDK result and always send a final revision. + +The browser receives a whole message and follows a simple contract: + +```typescript +let latestRevision = -1 + +socket.addEventListener("message", event => { + const message = JSON.parse(String(event.data)) as SnapshotMessage + + if (message.revision <= latestRevision) { + return + } + + latestRevision = message.revision + render(message.value) +}) +``` + +`JSON.parse` now succeeds for every accepted application message. The browser does not need to +understand provider event shapes, token boundaries, schema placeholders, or partial JSON syntax. +The repository's `examples/websocket-ui/` visualization demonstrates complete messages and policy +cadence, but intentionally sends every selected emission on localhost. It does not implement the +production coalescing, replay, or socket-backpressure controls above. + +## Choose the transport for the application + +| Browser transport | Browser framing work | Client-to-server controls | Reconnect and replay | Best fit | +| --- | --- | --- | --- | --- | +| Raw Fetch plus NDJSON or another record format | Buffer and decode records across arbitrary chunks | Request body and headers initially; a new request for later commands | Application-defined | One request-scoped generation, especially when custom request headers or a body matter | +| SSE through `EventSource` | Built-in SSE event parsing | A separate HTTP request | Automatic reconnect and `Last-Event-ID`; server-defined replay | One-way progress and notifications | +| WebSocket | Built-in logical message parsing | The same full-duplex connection | Application-defined | Interactive generation, cancellation, acknowledgements, subscriptions, or multiplexing | +| Raw provider stream plus browser-side SchemaStream | Incremental provider JSON parsing | Depends on the provider transport | Application-defined | Local-first or offline sources with no server secret | + +Large documents are a separate concern. Whichever transport is selected, prefer throttled patches or +completion events plus one authoritative final snapshot when repeated full objects dominate network +and browser parse cost. + +## Steelman: why WebSocket often wins here + +For an interactive provider-backed interface, the case for WebSocket is strong: + +- The API credential, provider SDK, `AbortController`, and SchemaStream already belong on the server. +- One serialized snapshot envelope maps directly to one WebSocket message. The browser only rejects + stale revisions, runs `JSON.parse`, and renders. +- Start, cancel, restart, snapshot-policy changes, acknowledgements, and subscriptions can use the + same connection as progress messages. +- The server parses the provider output once instead of making every browser bundle and run an + incremental parser or understand provider-specific event envelopes. +- In a Bun server that already handles HTTP, the WebSocket upgrade and message handler are small. The + repository example demonstrates the complete flow rather than only a transport fragment. + +For that shape of application, WebSocket is often simpler than an SSE connection plus separate +`POST` or `DELETE` endpoints for commands. The argument is about one bidirectional application +protocol, not about WebSocket being the only way to preserve JSON boundaries. + +The browser control path stays small: + +```typescript +const socket = new WebSocket("wss://app.example/runs") + +socket.addEventListener("message", handleVersionedMessage) +socket.addEventListener("open", () => { + socket.send(JSON.stringify({ type: "start", prompt, snapshotPolicy })) +}) + +function cancel() { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "cancel" })) + } +} +``` + +An SSE design can be equally correct, but usually splits that flow across a request that creates the +run, an `EventSource` that receives progress, and another request that cancels it. Whether one socket +or several HTTP endpoints are simpler depends on the rest of the application's protocol. + +## Adversarial review + +The strongest counterargument is that SSE already solves downstream framing. A server can run +SchemaStream and put each complete snapshot in one SSE event. `EventSource` also supplies automatic +reconnection and sends `Last-Event-ID`, which can make one-way progress and replay simpler than a +custom WebSocket resume protocol. Infrequent cancellation can use a separate HTTP request. + +WebSocket does not remove lifecycle work: + +- The provider request still needs an abort signal. A cancel message or closed socket must be wired to + it explicitly when no authorized consumer remains. +- The standard browser `WebSocket` API has no incoming backpressure mechanism. Servers must bound + outbound queues, coalesce superseded snapshots, and drop progress rather than final state when a + client falls behind. +- Reconnect, authentication, authorization, replay, heartbeat policy, stale-revision rejection, and + multi-instance connection routing remain application concerns. +- Browser WebSocket and native `EventSource` do not accept arbitrary application headers. Use an + intentional same-origin session or capability design, validate WebSocket `Origin`, and avoid + putting long-lived credentials in URLs. +- Sending a growing full snapshot at token cadence can approach quadratic cumulative bytes and + repeated browser `JSON.parse` work over SSE, WebSocket, or framed Fetch alike. + +Native `EventSource` also lacks an application demand signal, so high-rate SSE publishers need the +same bounded cadence and superseded-progress policy. Intermediaries can also buffer +`text/event-stream` responses or terminate idle ones, delaying an otherwise correct event. Production +SSE needs prompt flushing, appropriate content and cache headers, infrastructure-specific buffering +configuration, and often comment heartbeats. The +[HTML standard authoring notes](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream) +specifically warn that block buffering can delay event dispatch. + +Fetch `ReadableStream` exposes Web Streams cancellation and queue or pipe backpressure more directly. +That signal does not guarantee immediate end-to-end propagation through browser, network, or +provider buffers. These APIs are not evidence that streams are architecturally wrong; they make +lifecycle work explicit. WebSocket shifts that work into message queues, close handling, and the +application's reconnect protocol rather than removing it. + +Raw Fetch is also viable when ordinary request semantics matter. It can send a request body and +custom headers, then return NDJSON or another framed snapshot protocol. Its cost is a small browser +record decoder and application-defined reconnection, not necessarily a partial JSON parser. + +The weak claim to avoid is "SSE breaks JSON boundaries, therefore only WebSocket works." The equally +weak opposite is "WebSocket is needless complexity." Parse provider deltas once on the server and +emit complete, versioned application messages. Prefer WebSocket when the workflow is genuinely +bidirectional. Prefer SSE when progress is one-way and its reconnect model is valuable. + +## Control cumulative cost + +Start with `snapshotPolicy: { mode: "value" }` when incomplete long strings would otherwise cause +many near-identical updates. Use a byte policy when payload volume is a better cadence signal: + +```typescript +for await (const snapshot of parser.iterate(source, { + snapshotPolicy: { mode: "value" } +})) { + queueLatestSnapshot(snapshot) +} +``` + +Then add application-level controls based on the UI and network: + +- Coalesce updates to a bounded rendering rate, such as 10 to 20 updates per second. +- Send only the newest pending revision when transport backpressure or queue depth grows. +- Serialize once per revision, not once per connected client. +- Use path events or an application patch format for large objects that change in small regions. +- Send a complete final snapshot so reconnecting and late clients have a canonical state. +- Measure total bytes, serialization time, render time, and queue depth instead of only token + latency. + +Full snapshots are the simplest protocol and work well for modest outputs. If every revision is +nearly as large as the final document, patches can reduce network and parse cost. Patches add their +own requirements: ordering, base revisions, resynchronization, and a full-state fallback. + +## Completion events and conditional work + +`onValueComplete` is independent of snapshot cadence. It can mark a syntactically completed field +or subtree while snapshots are throttled: + +```typescript +let reviewMode = false +const actionSchema = z.enum(["continue", "require-review"]) + +const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + if (path.length === 2 && path[0] === "decision" && path[1] === "action") { + const action = actionSchema.safeParse(value) + reviewMode = !action.success || action.data === "require-review" + } + } +}) +``` + +Keep the callback synchronous and cheap. The event value is syntactically complete but not +schema-validated; validate the relevant subtree before queuing consequential work. A completed JSON +path is a syntax milestone, not permission to perform a payment, database write, tool call, or +other side effect. Paths can also describe input fields that are absent from the declared schema. + +Prefer `onValueComplete` for new listeners. Legacy `onKeyComplete` includes character progress and +a cumulative completion history, which costs more on large documents. See +[completion events](./completion-events.md) for ordering and callback semantics. + +## Browser parsing is supported + +Running SchemaStream in a browser is not inherently wrong. It is a reasonable choice when the +source already belongs in the browser, no provider secret is exposed, there is one consumer, and +the output is small enough for the target device. A Web Worker can keep heavier parsing and +materialization off the main rendering thread. + +Server placement is usually preferred for provider-backed applications because it keeps API keys +private, parses once for multiple viewers, and centralizes validation, throttling, cancellation, +size limits, logging, and protocol translation. It also prevents every client from paying the same +incremental parsing cost. + +Whichever boundary you choose: + +- Authenticate subscriptions and authorize each generation. +- Enforce source-size, nesting, time, and output-rate limits. +- Treat progressive and final model output as untrusted data; render text as text, not HTML. +- Respect backpressure and cancel provider work when no authorized consumer remains. +- Distinguish JSON-valid progress from the schema-validated final result in the wire protocol. + +The durable contract is simple: parse arbitrary provider chunks at one trusted boundary, control +snapshot cadence there, and give downstream consumers complete, versioned application messages. + +## Standards and runtime references + +- [Fetch body streams](https://fetch.spec.whatwg.org/#concept-body) and the + [Streams model](https://streams.spec.whatwg.org/#model) define raw response chunks and + queue or pipe backpressure. +- The [HTML SSE parsing algorithm](https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation) + defines event buffering, dispatch, reconnection, and `Last-Event-ID`. +- [RFC 6455 message fragmentation](https://www.rfc-editor.org/rfc/rfc6455.html#section-5.4) + and the [WebSockets browser API](https://websockets.spec.whatwg.org/#feedback-from-the-protocol) + distinguish protocol frames from browser-visible messages. +- [Bun's WebSocket backpressure documentation](https://bun.sh/docs/runtime/http/websockets#backpressure) + defines the send status and `drain` hook used for production queue control. diff --git a/examples/mastra.ts b/examples/mastra.ts new file mode 100644 index 0000000..c3f8be7 --- /dev/null +++ b/examples/mastra.ts @@ -0,0 +1,208 @@ +import { isDeepStrictEqual } from "node:util" +import { Agent } from "@mastra/core/agent" +import { simulateReadableStream } from "ai" +import { MockLanguageModelV4 } from "ai/test" +import { z } from "zod" + +import { SchemaStream, type SchemaStreamChunk, type SchemaStreamSource } from "../src" + +const schema = z.object({ + title: z.string(), + locale: z.enum(["en", "ja"]), + itinerary: z.array( + z.object({ + day: z.number(), + stops: z.array( + z.object({ + name: z.string(), + note: z.string().nullable(), + tags: z.array(z.string()) + }) + ) + }) + ), + metadata: z.record(z.string(), z.string()) +}) + +const expected: z.output = { + title: "週末 plan 🌊", + locale: "ja", + itinerary: [ + { + day: 1, + stops: [ + { + name: "Harbor museum", + note: 'Ask for the "tide clock" exhibit.', + tags: ["indoors", "family"] + }, + { name: "海辺 café", note: null, tags: ["lunch"] } + ] + }, + { + day: 2, + stops: [{ name: "Cliff walk", note: "Bring a light jacket.", tags: ["outdoors"] }] + } + ], + metadata: { network: "disabled", source: "deterministic fixture" } +} + +function splitJson(json: string, targetChunks = 14): string[] { + const codePoints = Array.from(json) + const chunkLength = Math.max(1, Math.ceil(codePoints.length / targetChunks)) + const chunks: string[] = [] + + for (let offset = 0; offset < codePoints.length; offset += chunkLength) { + chunks.push(codePoints.slice(offset, offset + chunkLength).join("")) + } + + return chunks +} + +/** Guards the pinned compatibility path because Mastra does not generally promise JSON text. */ +async function* requireJsonObjectText(source: SchemaStreamSource): AsyncIterable { + let sawJsonStart = false + for await (const chunk of source) { + if (!sawJsonStart) { + const candidate = chunk.trimStart() + if (candidate.length > 0) { + if (!candidate.startsWith("{")) { + throw new Error("Mastra textStream did not begin with a JSON object") + } + sawJsonStart = true + } + } + yield chunk + } + if (!sawJsonStart) { + throw new Error("Mastra textStream did not contain a JSON object") + } +} + +/** + * Mastra accepts AI SDK language models directly. This provider-shaped mock keeps the example + * credential-free while still exercising Mastra's real Agent streaming and validation path. + */ +function createModel(chunks: string[]): MockLanguageModelV4 { + return new MockLanguageModelV4({ + doStream: () => + Promise.resolve({ + stream: simulateReadableStream({ + chunkDelayInMs: null, + initialDelayInMs: null, + chunks: [ + { id: "text-1", type: "text-start" as const }, + ...chunks.map(delta => ({ delta, id: "text-1", type: "text-delta" as const })), + { id: "text-1", type: "text-end" as const }, + { + finishReason: { raw: undefined, unified: "stop" as const }, + logprobs: undefined, + type: "finish" as const, + usage: { + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: 1, + total: 1 + }, + outputTokens: { reasoning: undefined, text: 1, total: 1 } + } + } + ] + }) + }) + }) +} + +const serialized = JSON.stringify(expected) +const model = createModel(splitJson(serialized, 48)) +const agent = new Agent({ + id: "schema-stream-mastra-example", + name: "SchemaStream Mastra example", + instructions: "Return only the requested structured itinerary.", + model +}) +const result = await agent.stream("Build the deterministic itinerary fixture.", { + structuredOutput: { errorStrategy: "strict", schema } +}) + +/** + * This pinned compatibility example first requires Mastra's text stream to contain raw JSON, then + * uses SchemaStream for fine-grained snapshots. Mastra's documented `object` promise remains the + * authoritative result, and a nested tag completion selects a plan before day two arrives. + */ +const snapshots: SchemaStreamChunk[] = [] +type WeatherPlan = "rain-safe" | "weather-dependent" + +let weatherPlan: WeatherPlan | undefined +let secondDayStarted = false +let rootCompleted = false +let decisionPrecededSecondDay = false +let decisionPrecededRoot = false +const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + const isFirstStopTags = + path.length === 5 && + path[0] === "itinerary" && + path[1] === 0 && + path[2] === "stops" && + path[3] === 0 && + path[4] === "tags" + const isSecondDay = + path.length === 3 && path[0] === "itinerary" && path[1] === 1 && path[2] === "day" + + if (isFirstStopTags) { + if (!(Array.isArray(value) && value.every(tag => typeof tag === "string"))) { + throw new TypeError("The completed stop tags must be an array of strings") + } + weatherPlan = value.includes("indoors") ? "rain-safe" : "weather-dependent" + decisionPrecededSecondDay = !secondDayStarted + decisionPrecededRoot = !rootCompleted + } + if (isSecondDay) { + secondDayStarted = true + } + if (path.length === 0) { + rootCompleted = true + } + } +}) + +for await (const snapshot of parser.iterate(requireJsonObjectText(result.textStream))) { + snapshots.push(snapshot) +} +const finalOutput = await result.object + +if (snapshots.length <= 2) { + throw new Error("Mastra example did not produce progressive snapshots") +} +if ( + !snapshots.some( + snapshot => + typeof snapshot.title === "string" && + snapshot.title.length > 0 && + snapshot.title !== expected.title + ) +) { + throw new Error("Mastra example did not expose a meaningful intermediate string") +} +if (!(isDeepStrictEqual(snapshots.at(-1), expected) && isDeepStrictEqual(finalOutput, expected))) { + throw new Error("Mastra example did not reconstruct and validate the expected value") +} +if (model.doStreamCalls.length !== 1) { + throw new Error("Mastra example made an unexpected number of model calls") +} +if ( + weatherPlan !== "rain-safe" || + !decisionPrecededSecondDay || + !decisionPrecededRoot || + !secondDayStarted || + !rootCompleted +) { + throw new Error("Mastra example did not choose a weather plan before later JSON arrived") +} + +process.stdout.write( + `Mastra Agent: ${snapshots.length} snapshots, ${weatherPlan} plan selected before day 2 and ` + + "root completion, final structured output verified\n" +) diff --git a/examples/progressive-json.ts b/examples/progressive-json.ts new file mode 100644 index 0000000..9c0e701 --- /dev/null +++ b/examples/progressive-json.ts @@ -0,0 +1,100 @@ +import { isDeepStrictEqual } from "node:util" +import { z } from "zod" + +import { SchemaStream, type SchemaStreamChunk } from "../src" + +const schema = z.object({ + title: z.string(), + summary: z.string(), + details: z.object({ score: z.number(), note: z.string().nullable() }), + tags: z.array(z.string()), + metrics: z.record(z.string(), z.number()) +}) +const expected: z.output = { + title: "Progressive JSON", + summary: 'Unicode 🌊 日本語, quotes "like this", and newlines\narrive incrementally.', + details: { note: null, score: 0.98 }, + tags: ["streaming", "typed"], + metrics: { chunks: 9, records: 2 } +} + +function splitJson(json: string, targetChunks = 9): string[] { + const codePoints = Array.from(json) + const chunkLength = Math.max(1, Math.ceil(codePoints.length / targetChunks)) + const chunks: string[] = [] + let offset = 0 + + while (offset < codePoints.length) { + chunks.push(codePoints.slice(offset, offset + chunkLength).join("")) + offset += chunkLength + } + + return chunks +} + +const source = new ReadableStream({ + start(controller) { + for (const chunk of splitJson(JSON.stringify(expected))) { + controller.enqueue(chunk) + } + controller.close() + } +}) + +type PublicationDecision = "publish" | "review" + +/** + * Completion events expose syntactically finished values before later fields arrive. This score + * gate can therefore choose the publication route without inspecting a partial number or waiting + * for the complete document. + */ +let publicationDecision: PublicationDecision | undefined +let decisionPrecededMetrics = false +let decisionPrecededRoot = false +let metricsRecordsCompleted = false +let rootCompleted = false +const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + const isScore = path.length === 2 && path[0] === "details" && path[1] === "score" + const isMetricsRecords = path.length === 2 && path[0] === "metrics" && path[1] === "records" + + if (isScore) { + if (typeof value !== "number") { + throw new TypeError("The completed score must be a number") + } + publicationDecision = value >= 0.95 ? "publish" : "review" + decisionPrecededMetrics = !metricsRecordsCompleted + decisionPrecededRoot = !rootCompleted + } + if (isMetricsRecords) { + metricsRecordsCompleted = true + } + if (path.length === 0) { + rootCompleted = true + } + } +}) + +let finalSnapshot: SchemaStreamChunk | undefined +let snapshotCount = 0 +for await (const snapshot of parser.iterate(source)) { + finalSnapshot = snapshot + snapshotCount += 1 + process.stdout.write(`snapshot ${snapshotCount}: ${JSON.stringify(snapshot)}\n`) +} + +if (!isDeepStrictEqual(finalSnapshot, expected)) { + throw new Error("Progressive example did not reconstruct the expected value") +} +if ( + publicationDecision !== "publish" || + !decisionPrecededMetrics || + !decisionPrecededRoot || + !rootCompleted +) { + throw new Error("Progressive example did not make its score decision before later JSON arrived") +} + +process.stdout.write( + `decision: ${publicationDecision} (before metrics.records and root completion)\n` +) diff --git a/examples/sdk-mocks.ts b/examples/sdk-mocks.ts new file mode 100644 index 0000000..7ed8820 --- /dev/null +++ b/examples/sdk-mocks.ts @@ -0,0 +1,243 @@ +import { + Agent, + type Model, + type ModelResponse, + Runner, + type StreamEvent, + setTraceProcessors, + setTracingDisabled +} from "@openai/agents" +import { Output, simulateReadableStream, streamText } from "ai" +import { MockLanguageModelV4 } from "ai/test" +import { z } from "zod" + +import { SchemaStream, type SchemaStreamChunk, type SchemaStreamSource } from "../src" + +setTraceProcessors([]) +setTracingDisabled(true) + +const schema = z.object({ + headline: z.string(), + locale: z.string(), + sections: z.array( + z.object({ + title: z.string(), + facts: z.array(z.object({ label: z.string(), value: z.string() })) + }) + ) +}) +const expected: z.output = { + headline: "A deterministic 🌊 SDK stream", + locale: "日本語 + English", + sections: [ + { + title: "Runtime", + facts: [ + { label: "network", value: "disabled" }, + { label: "credentials", value: "not required" } + ] + } + ] +} + +function splitJson(serialized: string, targetChunks = 8): string[] { + const codePoints = Array.from(serialized) + const chunkLength = Math.max(1, Math.ceil(codePoints.length / targetChunks)) + const chunks: string[] = [] + let offset = 0 + + while (offset < codePoints.length) { + chunks.push(codePoints.slice(offset, offset + chunkLength).join("")) + offset += chunkLength + } + + return chunks +} + +type DeliveryRoute = "local-cache" | "remote-review" + +type DecisionRun = { + decision: DeliveryRoute + snapshots: SchemaStreamChunk[] +} + +/** + * Routes the result as soon as the deeply nested network fact is complete. The assertions prove + * the decision happens before the following credentials fact and before root completion, on both + * SDK stream adapters. + */ +async function collectWithDecision(source: SchemaStreamSource): Promise { + const snapshots: SchemaStreamChunk[] = [] + let decision: DeliveryRoute | undefined + let credentialsCompleted = false + let rootCompleted = false + let decisionPrecededCredentials = false + let decisionPrecededRoot = false + const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + const isNetworkFact = + path.length === 5 && + path[0] === "sections" && + path[1] === 0 && + path[2] === "facts" && + path[3] === 0 && + path[4] === "value" + const isCredentialsFact = + path.length === 5 && + path[0] === "sections" && + path[1] === 0 && + path[2] === "facts" && + path[3] === 1 && + path[4] === "value" + + if (isNetworkFact) { + if (typeof value !== "string") { + throw new TypeError("The completed network fact must be a string") + } + decision = value === "disabled" ? "local-cache" : "remote-review" + decisionPrecededCredentials = !credentialsCompleted + decisionPrecededRoot = !rootCompleted + } + if (isCredentialsFact) { + credentialsCompleted = true + } + if (path.length === 0) { + rootCompleted = true + } + } + }) + + for await (const snapshot of parser.iterate(source)) { + snapshots.push(snapshot) + } + + if ( + decision === undefined || + !decisionPrecededCredentials || + !decisionPrecededRoot || + !credentialsCompleted || + !rootCompleted + ) { + throw new Error("SDK stream did not route from the nested fact before later JSON arrived") + } + + return { decision, snapshots } +} + +function createAiSdkModel(deltas: string[]): MockLanguageModelV4 { + return new MockLanguageModelV4({ + doStream: () => + Promise.resolve({ + stream: simulateReadableStream({ + chunkDelayInMs: null, + initialDelayInMs: null, + chunks: [ + { id: "text-1", type: "text-start" as const }, + ...deltas.map(delta => ({ delta, id: "text-1", type: "text-delta" as const })), + { id: "text-1", type: "text-end" as const }, + { + finishReason: { raw: undefined, unified: "stop" as const }, + logprobs: undefined, + type: "finish" as const, + usage: { + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: 1, + total: 1 + }, + outputTokens: { reasoning: undefined, text: 1, total: 1 } + } + } + ] + }) + }) + }) +} + +function createAgentsModel({ + deltas, + serialized +}: { + deltas: string[] + serialized: string +}): Model { + return { + getResponse(): Promise { + return Promise.reject(new Error("Mock example unexpectedly used a non-stream call")) + }, + async *getStreamedResponse(): AsyncIterable { + await Promise.resolve() + yield { type: "response_started" } + for (const delta of deltas) { + yield { delta, type: "output_text_delta" } + } + yield { + response: { + id: "example-response", + output: [ + { + content: [{ text: serialized, type: "output_text" }], + id: "example-message", + role: "assistant", + status: "completed", + type: "message" + } + ], + usage: { + inputTokens: 1, + inputTokensDetails: {}, + outputTokens: 1, + outputTokensDetails: {}, + requests: 1, + totalTokens: 2 + } + }, + type: "response_done" + } + } + } +} + +const serializedExpected = JSON.stringify(expected) +const streamDeltas = splitJson(serializedExpected) +const aiResult = streamText({ + maxRetries: 0, + model: createAiSdkModel(streamDeltas), + output: Output.object({ schema }), + prompt: "Return the deterministic fixture." +}) +const aiRun = await collectWithDecision(aiResult.textStream) +const aiFinal = await aiResult.output + +const agent = new Agent({ + instructions: "Return the deterministic fixture.", + model: createAgentsModel({ deltas: streamDeltas, serialized: serializedExpected }), + name: "SchemaStream mock example", + outputType: schema +}) +const agentResult = await new Runner({ + traceIncludeSensitiveData: false, + tracingDisabled: true +}).run(agent, "Return the deterministic fixture.", { stream: true }) +const agentRun = await collectWithDecision(agentResult.toTextStream()) +await agentResult.completed + +const expectedJson = serializedExpected +const results = [ + { final: aiFinal, name: "Vercel AI SDK", ...aiRun }, + { final: agentResult.finalOutput, name: "OpenAI Agents SDK", ...agentRun } +] + +for (const result of results) { + if ( + JSON.stringify(result.final) !== expectedJson || + JSON.stringify(result.snapshots.at(-1)) !== expectedJson + ) { + throw new Error(`${result.name} mock example did not reconstruct the expected value`) + } + process.stdout.write( + `${result.name}: ${result.snapshots.length} snapshots, ${result.decision} route selected ` + + "before credentials and root completion, final output verified\n" + ) +} diff --git a/examples/websocket-ui/README.md b/examples/websocket-ui/README.md new file mode 100644 index 0000000..7a42a8e --- /dev/null +++ b/examples/websocket-ui/README.md @@ -0,0 +1,71 @@ +# Bun WebSocket UI example + +This example keeps incomplete model JSON on the server. A Bun WebSocket server runs an OpenAI +Agents SDK structured-output stream through `SchemaStream`, makes a conditional workflow decision +when `triage.requiresApproval` completes, and sends the browser only complete JSON messages. + +## Run it + +From the repository root: + +```sh +bun examples/websocket-ui/server.ts +``` + +Open . **Fixture** replays a fixed structured response through an Agents SDK +`Runner` and SchemaStream without credentials. It keeps the source bytes identical when comparing +snapshot policies. **OpenAI** enables the editable dashboard request and makes a fresh model call +when the server process has `OPENAI_API_KEY`; Bun loads a gitignored root `.env.local` automatically. +The renderer and output schema are intentionally fixed: OpenAI generates the values that materialize +inside the dashboard, not HTML, components, or page layout. + +Use the **Chunk**, **Value**, **Bytes**, and **Final** controls to compare snapshot cadence. Each +OpenAI run is not a controlled policy benchmark because the model can change its output and chunking. +**Chunk** is SchemaStream's default: it emits once per source chunk +with `stringBufferSize: 0`. A source chunk can contain part of a Unicode code point or many complete +code points. Literal character cadence requires re-chunking upstream and is usually too expensive +for production UI updates. + +The console reports input chunk and byte totals alongside emitted snapshot counts. Its lower raw +JSON pane shows one cumulative schema-shaped snapshot as valid JSON and updates that object in place +for each WebSocket snapshot message. The event log and JSON panes split the inspector height +evenly and collapse independently. Generated sections and list rows enter only when their values +first materialize, while the operating system's reduced-motion preference disables those +transitions. + +Optional settings: + +```sh +SCHEMA_STREAM_EXAMPLE_MODEL=gpt-5.6-luna +SCHEMA_STREAM_EXAMPLE_PORT=3401 +``` + +Do not put API keys in browser code. The server binds to `127.0.0.1`, tracing is disabled, and +the server requires an exact local Host and Origin plus a per-process WebSocket capability. This is +still a localhost visualization, not production authentication or authorization. + +## Data flow + +1. The Agents SDK yields incomplete JSON text through `result.toTextStream()`. +2. `SchemaStream` parses it on the server with the selected snapshot policy; omitted policy options + use the public chunk default. +3. `onValueComplete` reports the completed path and value. When `triage.requiresApproval` completes, + the server selects either `approval-gate` or `auto-stage` without waiting for a snapshot or the + full dashboard. +4. Each WebSocket message is one `JSON.stringify()`-encoded protocol envelope containing a complete + schema-shaped snapshot, decision, status, error, or final output. +5. The browser uses only DOM text APIs to render cumulative snapshots into the dashboard and event + log. It never parses partial model JSON or receives raw model chunks. + +The server also validates that the final SchemaStream snapshot is deeply equivalent to the Agents +SDK authoritative structured output before it sends the `complete` event. + +WebSocket is used here because start, cancel, snapshot-policy selection, conditional decisions, and +progress all share one connection. SSE could carry the same complete server-materialized snapshots, +but interactive commands would use separate HTTP requests. A raw Fetch response would also need an +explicit record decoder in the browser because response chunks are not message boundaries. See the +[transport guide](../../docs/transports.md) for the full steelman and adversarial comparison. + +For visual clarity, this localhost demo sends every emission selected by the policy. A production +server should coalesce pending snapshots, watch socket backpressure, drop superseded progress, and +always deliver one authoritative final revision. diff --git a/examples/websocket-ui/app.js b/examples/websocket-ui/app.js new file mode 100644 index 0000000..8371db3 --- /dev/null +++ b/examples/websocket-ui/app.js @@ -0,0 +1,755 @@ +const protocolVersion = 1 +const reconnectDelayMs = 900 +const eventLogRowLimit = 160 +const announcedEventKinds = new Set(["complete", "decision", "error"]) + +const elements = { + activity: document.querySelector("#activity"), + activityCount: document.querySelector("#activity-count"), + activitySection: document.querySelector("#activity-section"), + brief: document.querySelector("#brief"), + briefSection: document.querySelector("#brief-section"), + briefState: document.querySelector("#brief-state"), + byteThreshold: document.querySelector("#byte-threshold"), + bytesControl: document.querySelector("#bytes-control"), + checkList: document.querySelector("#check-list"), + connectionLabel: document.querySelector("#connection-label"), + connectionState: document.querySelector("#connection-state"), + decisionAction: document.querySelector("#decision-action"), + decisionBranch: document.querySelector("#decision-branch"), + decisionStrip: document.querySelector("#decision-strip"), + elapsedTime: document.querySelector("#elapsed-time"), + eventCounter: document.querySelector("#event-counter"), + eventLog: document.querySelector("#event-log"), + eventPanel: document.querySelector("#event-panel"), + inputBytes: document.querySelector("#input-bytes"), + inputChunks: document.querySelector("#input-chunks"), + jsonSize: document.querySelector("#json-size"), + jsonPanel: document.querySelector("#json-panel"), + metrics: document.querySelector("#metrics"), + metricsSection: document.querySelector("#metrics-section"), + metricsState: document.querySelector("#metrics-state"), + modeButtons: document.querySelectorAll(".mode-button"), + nextAction: document.querySelector("#next-action"), + nextActionSection: document.querySelector("#next-action-section"), + openAiAvailability: document.querySelector("#openai-availability"), + openAiMode: document.querySelector("#openai-mode"), + panelToggles: document.querySelectorAll(".panel-toggle"), + panelHeader: document.querySelector(".panel-header"), + policyButtons: document.querySelectorAll(".policy-button"), + previewTitle: document.querySelector("#preview-title"), + progressBar: document.querySelector("#progress-bar"), + prompt: document.querySelector("#prompt"), + promptLabel: document.querySelector("#prompt-label"), + readinessScore: document.querySelector("#readiness-score"), + readinessSection: document.querySelector("#readiness-section"), + readinessState: document.querySelector("#readiness-state"), + rawJson: document.querySelector("#raw-json"), + run: document.querySelector("#run"), + runLabel: document.querySelector("#run-label"), + snapshotCount: document.querySelector("#snapshot-count"), + scoreBar: document.querySelector("#score-bar"), + stageBadge: document.querySelector("#stage-badge"), + statusAnnouncer: document.querySelector("#status-announcer"), + stop: document.querySelector("#stop"), + terminal: document.querySelector("#terminal") +} + +const state = { + eventCount: 0, + mode: "fixture", + runId: null, + snapshotCount: 0, + socket: null +} +let isOpenAiAvailable = false +let isRunning = false +const fixtureScenario = elements.prompt.value +let openAiRequest = elements.prompt.dataset.openaiRequest ?? elements.prompt.value +let selectedPolicy = "chunk" +const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)") +const activeEntryAnimations = new Set() +const renderState = { + titleReady: false +} + +/** Applies one restrained spring-like entrance without introducing a frontend runtime. */ +function animateEntry(element, delay = 0) { + if (motionPreference.matches || typeof element.animate !== "function") { + return + } + const animation = element.animate( + [ + { opacity: 0, transform: "translateY(7px) scale(0.99)" }, + { opacity: 1, transform: "translateY(0) scale(1)" } + ], + { + delay, + duration: 220, + easing: "cubic-bezier(0.23, 1, 0.32, 1)", + fill: "backwards" + } + ) + activeEntryAnimations.add(animation) + const forgetAnimation = () => activeEntryAnimations.delete(animation) + animation.addEventListener("cancel", forgetAnimation, { once: true }) + animation.addEventListener("finish", forgetAnimation, { once: true }) +} + +/** Gives an existing schema placeholder a stable, non-disappearing materialization cue. */ +function animateMaterialization(element) { + if (motionPreference.matches || typeof element.animate !== "function") { + return + } + const animation = element.animate( + [{ transform: "translateY(3px)" }, { transform: "translateY(0)" }], + { duration: 240, easing: "cubic-bezier(0.23, 1, 0.32, 1)" } + ) + activeEntryAnimations.add(animation) + const forgetAnimation = () => activeEntryAnimations.delete(animation) + animation.addEventListener("cancel", forgetAnimation, { once: true }) + animation.addEventListener("finish", forgetAnimation, { once: true }) +} + +/** Cancels run-scoped entrances when a run resets or reduced motion is enabled. */ +function cancelEntryAnimations() { + for (const animation of activeEntryAnimations) { + animation.cancel() + } + activeEntryAnimations.clear() +} + +/** Responds immediately when the operating-system motion preference changes. */ +function handleMotionPreferenceChange(event) { + if (event.matches) { + cancelEntryAnimations() + } +} + +motionPreference.addEventListener("change", handleMotionPreferenceChange) + +/** Creates a text-only element so model output never enters an HTML parsing sink. */ +function createTextElement({ className, tagName, text }) { + const element = document.createElement(tagName) + if (className) { + element.className = className + } + element.textContent = text + return element +} + +/** Updates text children created by this module without reparsing or replacing their parent. */ +function updateTextChildren({ element, texts }) { + for (const [index, text] of texts.entries()) { + const child = element.children.item(index) + if (child) { + child.textContent = text + } + } +} + +/** Reconciles append-oriented model arrays so in-flight entrances survive later snapshots. */ +function reconcileList({ container, createItem, items, updateItem }) { + const previousLength = container.childElementCount + for (const [index, value] of items.entries()) { + let element = container.children.item(index) + const isNew = element === null + if (isNew) { + element = createItem() + container.append(element) + } + updateItem({ element, value }) + if (isNew) { + animateEntry(element, Math.max(0, index - previousLength) * 70) + } + } + while (container.childElementCount > items.length) { + container.lastElementChild?.remove() + } +} + +/** Creates one stable metric node whose fields can fill in across cumulative snapshots. */ +function createMetricItem() { + const item = document.createElement("div") + item.className = "metric" + item.append( + createTextElement({ className: "metric-label", tagName: "span", text: "Pending" }), + createTextElement({ className: "metric-value", tagName: "strong", text: "Pending" }), + createTextElement({ className: "metric-trend trend-steady", tagName: "span", text: "steady" }) + ) + return item +} + +/** Applies a progressive metric value while preserving the existing animated node. */ +function updateMetricItem({ element, value }) { + const trend = value.trend ?? "steady" + updateTextChildren({ + element, + texts: [display(value.label), display(value.value), display(trend, "steady")] + }) + const trendElement = element.children.item(2) + if (trendElement) { + trendElement.className = `metric-trend trend-${trend}` + } +} + +/** Creates one stable activity row for append-only timeline materialization. */ +function createActivityItem() { + const row = document.createElement("li") + row.append( + createTextElement({ className: "activity-time", tagName: "time", text: "Pending" }), + createTextElement({ className: "activity-marker", tagName: "span", text: "" }), + createTextElement({ className: "activity-label", tagName: "span", text: "Pending" }), + createTextElement({ className: "activity-state", tagName: "span", text: "queued" }) + ) + return row +} + +/** Applies a progressive activity value while preserving the existing animated row. */ +function updateActivityItem({ element, value }) { + element.dataset.state = value.state ?? "queued" + updateTextChildren({ + element, + texts: [display(value.time), "", display(value.label), display(value.state, "queued")] + }) +} + +/** Creates one stable readiness check for progressive status updates. */ +function createCheckItem() { + const item = document.createElement("li") + item.append( + createTextElement({ className: "check-mark", tagName: "span", text: "" }), + createTextElement({ className: "check-label", tagName: "span", text: "Pending" }), + createTextElement({ className: "check-status", tagName: "span", text: "watch" }) + ) + return item +} + +/** Applies a progressive readiness check while preserving the existing animated row. */ +function updateCheckItem({ element, value }) { + element.dataset.status = value.status ?? "watch" + updateTextChildren({ + element, + texts: ["", display(value.label), display(value.status, "watch")] + }) +} + +/** Converts nullable progressive values into stable display text. */ +function display(value, fallback = "Pending") { + return typeof value === "string" && value.trim() ? value : fallback +} + +/** Formats stream volume without hiding the exact byte count used by the bytes policy. */ +function formatBytes(bytes) { + return new Intl.NumberFormat("en-US").format(bytes) +} + +/** Updates frequent progress indicators through compositor-friendly transforms. */ +function setBarProgress({ element, percent }) { + const normalized = Math.max(0, Math.min(100, percent)) / 100 + element.style.transform = `scaleX(${normalized})` +} + +/** Marks a generated section as it moves from schema placeholder to materialized content. */ +function setSectionState({ element, label, ready, readyText }) { + const justUnlocked = ready && element.dataset.state !== "unlocked" + element.dataset.state = ready ? "unlocked" : "locked" + if (label) { + label.textContent = ready ? readyText : "Locked" + } + if (justUnlocked) { + animateMaterialization(element) + } +} + +/** Keeps source-chunk volume visible next to the selected snapshot cadence. */ +function updateInputStats({ inputBytes, inputChunks }) { + elements.inputBytes.textContent = formatBytes(inputBytes) + elements.inputChunks.textContent = String(inputChunks) +} + +/** Renders the complete JSON-equivalent snapshot received in the current WebSocket message. */ +function renderRawJson(value) { + const json = JSON.stringify(value, null, 2) + elements.rawJson.value = json + elements.jsonSize.textContent = `${formatBytes(new TextEncoder().encode(json).byteLength)} bytes` +} + +/** Appends a compact protocol event without ever exposing raw model chunks. */ +function appendEvent({ detail, kind, path }) { + state.eventCount += 1 + const row = document.createElement("div") + row.className = "event-row" + row.append( + createTextElement({ className: `event-kind event-kind-${kind}`, tagName: "span", text: kind }), + createTextElement({ + className: "event-path", + tagName: "span", + text: path || "server" + }), + createTextElement({ className: "event-detail", tagName: "span", text: detail }) + ) + elements.eventLog.append(row) + if (announcedEventKinds.has(kind)) { + animateEntry(row) + } + while (elements.eventLog.childElementCount > eventLogRowLimit) { + elements.eventLog.firstElementChild?.remove() + } + elements.eventLog.scrollTop = elements.eventLog.scrollHeight + elements.eventCounter.textContent = String(state.eventCount) + if (announcedEventKinds.has(kind)) { + elements.statusAnnouncer.textContent = `${kind}: ${detail}` + } +} + +/** Updates connection and generation controls as a single state transition. */ +function updateControls() { + const connected = state.socket?.readyState === WebSocket.OPEN + const isFixture = state.mode === "fixture" + const selectedModeAvailable = isFixture || isOpenAiAvailable + elements.run.disabled = !connected || isRunning || !selectedModeAvailable + elements.stop.disabled = !(connected && isRunning) + elements.prompt.disabled = isRunning || isFixture + elements.promptLabel.textContent = isFixture ? "Fixture scenario" : "Dashboard request" + elements.prompt.title = isFixture + ? "Fixed input for the deterministic fixture" + : "Describe the dashboard data to generate" + elements.runLabel.textContent = isFixture ? "Replay" : "Generate" + for (const button of elements.modeButtons) { + const { mode } = button.dataset + const unavailable = mode === "openai" && !isOpenAiAvailable + button.disabled = isRunning + button.classList.toggle("is-unavailable", unavailable) + button.classList.toggle("is-active", mode === state.mode) + button.setAttribute("aria-disabled", String(isRunning || unavailable)) + button.setAttribute("aria-pressed", String(mode === state.mode)) + } + for (const button of elements.policyButtons) { + const { policy } = button.dataset + button.disabled = isRunning + button.classList.toggle("is-active", policy === selectedPolicy) + button.setAttribute("aria-pressed", String(policy === selectedPolicy)) + } + elements.byteThreshold.disabled = isRunning || selectedPolicy !== "bytes" + elements.bytesControl.hidden = selectedPolicy !== "bytes" +} + +/** Preserves the editable OpenAI request while the deterministic fixture remains fixed. */ +function selectMode(mode) { + if (mode === state.mode) { + return + } + if (state.mode === "openai") { + openAiRequest = elements.prompt.value + } + state.mode = mode + elements.prompt.value = mode === "fixture" ? fixtureScenario : openAiRequest + updateControls() +} + +/** Renders each cumulative schema-shaped snapshot into the dashboard surface. */ +function renderSnapshot(snapshot) { + renderRawJson(snapshot) + const interfaceValue = snapshot.interface ?? {} + const readiness = snapshot.readiness ?? {} + const triage = snapshot.triage ?? {} + elements.previewTitle.textContent = display(interfaceValue.title, "Materializing snapshot") + elements.stageBadge.textContent = display( + interfaceValue.status, + display(triage.severity, "Parsing") + ) + elements.stageBadge.dataset.accent = interfaceValue.accent ?? "green" + elements.brief.textContent = display(snapshot.brief, "Receiving brief") + elements.nextAction.textContent = display(snapshot.nextAction) + const titleReady = typeof interfaceValue.title === "string" && interfaceValue.title.length > 0 + if (titleReady && !renderState.titleReady) { + renderState.titleReady = true + animateEntry(elements.previewTitle) + animateEntry(elements.stageBadge, 50) + } + const briefReady = typeof snapshot.brief === "string" && snapshot.brief.length > 0 + const nextActionReady = typeof snapshot.nextAction === "string" && snapshot.nextAction.length > 0 + setSectionState({ + element: elements.briefSection, + label: elements.briefState, + ready: briefReady, + readyText: "Ready" + }) + setSectionState({ + element: elements.nextActionSection, + ready: nextActionReady, + readyText: "Ready" + }) + + const metrics = Array.isArray(interfaceValue.metrics) ? interfaceValue.metrics : [] + reconcileList({ + container: elements.metrics, + createItem: createMetricItem, + items: metrics, + updateItem: updateMetricItem + }) + setSectionState({ + element: elements.metricsSection, + label: elements.metricsState, + ready: metrics.length > 0, + readyText: `${metrics.length} ready` + }) + + const activity = Array.isArray(interfaceValue.activity) ? interfaceValue.activity : [] + reconcileList({ + container: elements.activity, + createItem: createActivityItem, + items: activity, + updateItem: updateActivityItem + }) + elements.activityCount.textContent = `${activity.length} ${activity.length === 1 ? "event" : "events"}` + setSectionState({ + element: elements.activitySection, + ready: activity.length > 0, + readyText: "Ready" + }) + + const score = typeof readiness.score === "number" ? readiness.score : null + const checks = Array.isArray(readiness.checks) ? readiness.checks : [] + elements.readinessScore.textContent = score === null ? "--" : String(score) + setBarProgress({ element: elements.scoreBar, percent: score ?? 0 }) + reconcileList({ + container: elements.checkList, + createItem: createCheckItem, + items: checks, + updateItem: updateCheckItem + }) + setSectionState({ + element: elements.readinessSection, + label: elements.readinessState, + ready: score !== null || checks.length > 0, + readyText: score === null ? `${checks.length} checks` : `${score}%` + }) +} + +/** Applies the conditional server branch as soon as its source value completes. */ +function renderDecision(event) { + elements.decisionStrip.hidden = false + elements.decisionStrip.dataset.branch = event.branch + elements.decisionBranch.textContent = + event.branch === "approval-gate" ? "Approval gate" : "Auto-stage" + elements.decisionAction.textContent = event.action + animateEntry(elements.decisionStrip) + appendEvent({ detail: event.rationale, kind: "decision", path: event.path.join(".") }) +} + +/** Collapses one inspection half and lets its sibling consume the remaining sidebar height. */ +function toggleInspectionPanel(button) { + const panelName = button.dataset.panelToggle + const panel = panelName === "events" ? elements.eventPanel : elements.jsonPanel + const content = panelName === "events" ? elements.eventLog : elements.rawJson + const collapsed = panel.dataset.collapsed !== "true" + const label = panelName === "events" ? "event log" : "raw snapshot JSON" + + panel.dataset.collapsed = String(collapsed) + content.hidden = collapsed + elements.terminal.dataset[`${panelName}Collapsed`] = String(collapsed) + button.setAttribute("aria-expanded", String(!collapsed)) + button.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${label}`) + button.title = `${collapsed ? "Expand" : "Collapse"} ${label}` + if (!collapsed) { + animateEntry(content) + } +} + +/** Resets only run-scoped UI while preserving the socket session. */ +function resetRun() { + cancelEntryAnimations() + renderState.titleReady = false + state.eventCount = 0 + state.snapshotCount = 0 + elements.eventLog.replaceChildren() + elements.statusAnnouncer.textContent = "" + elements.eventCounter.textContent = "0" + renderRawJson({}) + elements.snapshotCount.textContent = "0 snapshots" + updateInputStats({ inputBytes: 0, inputChunks: 0 }) + elements.elapsedTime.textContent = "0 ms" + setBarProgress({ element: elements.progressBar, percent: 2 }) + elements.decisionStrip.hidden = true + elements.metrics.replaceChildren() + elements.activity.replaceChildren() + elements.checkList.replaceChildren() + elements.activityCount.textContent = "0 events" + elements.previewTitle.textContent = "Materializing snapshot" + elements.brief.textContent = "Waiting for the first complete value." + elements.nextAction.textContent = "Pending" + elements.stageBadge.textContent = "Starting" + elements.readinessScore.textContent = "--" + setBarProgress({ element: elements.scoreBar, percent: 0 }) + setSectionState({ + element: elements.briefSection, + label: elements.briefState, + ready: false, + readyText: "Ready" + }) + setSectionState({ + element: elements.metricsSection, + label: elements.metricsState, + ready: false, + readyText: "Ready" + }) + setSectionState({ + element: elements.activitySection, + ready: false, + readyText: "Ready" + }) + setSectionState({ + element: elements.nextActionSection, + ready: false, + readyText: "Ready" + }) + setSectionState({ + element: elements.readinessSection, + label: elements.readinessState, + ready: false, + readyText: "Ready" + }) +} + +/** Returns whether a run-scoped message belongs to an older generation. */ +function isStaleRunEvent(event) { + return state.runId !== null && event.runId !== state.runId +} + +/** Applies server capability metadata after the socket opens. */ +function handleHelloEvent(event) { + isOpenAiAvailable = event.openAiAvailable === true + if (!isOpenAiAvailable && state.mode === "openai") { + selectMode("fixture") + } + elements.openAiMode.title = isOpenAiAvailable + ? `Generate with ${event.model}` + : "OPENAI_API_KEY unavailable" + elements.openAiAvailability.textContent = isOpenAiAvailable + ? `OpenAI model ${event.model} is available.` + : "OpenAI is unavailable because the server has no OPENAI_API_KEY." + appendEvent({ + detail: isOpenAiAvailable + ? `Fixture ready · OpenAI ${event.model} available` + : "Fixture ready · OpenAI unavailable", + kind: "ready" + }) + updateControls() +} + +/** Reconciles the optimistic run state with the server-assigned run identifier. */ +function handleStatusEvent(event) { + if (event.phase === "generating") { + state.runId = event.runId + isRunning = true + } else if (!isStaleRunEvent(event)) { + state.runId = null + isRunning = false + } + elements.stageBadge.textContent = isRunning ? "Generating" : elements.stageBadge.textContent + updateControls() +} + +/** Renders one accepted cumulative snapshot and its completion summary. */ +function handleSnapshotEvent(event) { + if (isStaleRunEvent(event)) { + return + } + state.snapshotCount = event.sequence + renderSnapshot(event.snapshot) + updateInputStats(event) + elements.snapshotCount.textContent = `${event.sequence} snapshots` + setBarProgress({ + element: elements.progressBar, + percent: Math.min(94, 8 + event.completedValues * 5) + }) + const completed = event.completedPaths.map(path => path.join(".")).filter(Boolean) + appendEvent({ + detail: `${event.policy.mode} · ${completed.length} value${completed.length === 1 ? "" : "s"} · ${event.inputChunks} chunks in`, + kind: "snapshot", + path: completed.at(-1) ?? "root" + }) +} + +/** Applies the authoritative final object for the active run. */ +function handleCompleteEvent(event) { + if (isStaleRunEvent(event)) { + return + } + isRunning = false + renderSnapshot(event.output) + updateInputStats(event) + setBarProgress({ element: elements.progressBar, percent: 100 }) + elements.elapsedTime.textContent = `${event.durationMs} ms` + elements.stageBadge.textContent = "Complete" + appendEvent({ detail: "Authoritative output matched final snapshot", kind: "complete" }) + updateControls() +} + +/** Keeps busy responses attached to the active run and rejects stale run failures. */ +function handleErrorEvent(event) { + if (typeof event.runId === "number" && isStaleRunEvent(event)) { + return + } + if (event.code === "busy" && typeof event.runId === "number") { + state.runId = event.runId + isRunning = true + elements.stageBadge.textContent = "Generating" + } else { + state.runId = null + isRunning = false + elements.stageBadge.textContent = event.code === "cancelled" ? "Cancelled" : "Error" + } + appendEvent({ detail: event.message, kind: "error" }) + updateControls() +} + +const serverEventHandlers = { + complete: handleCompleteEvent, + decision(event) { + if (!isStaleRunEvent(event)) { + renderDecision(event) + } + }, + error: handleErrorEvent, + hello: handleHelloEvent, + snapshot: handleSnapshotEvent, + status: handleStatusEvent +} + +/** Dispatches a versioned server message to its narrow UI update. */ +function handleServerEvent(event) { + const compatible = + event && + typeof event === "object" && + event.version === protocolVersion && + typeof event.type === "string" && + Object.hasOwn(serverEventHandlers, event.type) + if (!compatible) { + appendEvent({ detail: "Ignored an incompatible protocol message", kind: "error" }) + return + } + serverEventHandlers[event.type](event) +} + +/** Fetches the process-scoped capability required for an exact-origin WebSocket upgrade. */ +async function readSessionToken() { + const response = await fetch("/session", { + cache: "no-store", + headers: { Accept: "application/json" } + }) + if (!response.ok) { + throw new Error("Session capability request failed") + } + const payload = await response.json() + if (!payload || typeof payload !== "object" || typeof payload.token !== "string") { + throw new Error("Session capability response was invalid") + } + return payload.token +} + +/** Sets the visible disconnected state shared by failed handshakes and closed sockets. */ +function markReconnecting() { + isRunning = false + isOpenAiAvailable = false + state.runId = null + elements.openAiAvailability.textContent = "Checking OpenAI availability." + elements.openAiMode.title = "Checking OpenAI availability" + elements.connectionState.dataset.state = "disconnected" + elements.connectionLabel.textContent = "Reconnecting" + updateControls() +} + +/** Starts a handled connection attempt so rejected session requests never become unhandled. */ +function startConnection() { + connect().catch(() => { + markReconnecting() + setTimeout(startConnection, reconnectDelayMs) + }) +} + +/** Opens the capability-bound socket after obtaining the process-scoped browser token. */ +async function connect() { + const token = await readSessionToken() + const protocol = location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket( + `${protocol}//${location.host}/ws?token=${encodeURIComponent(token)}` + ) + state.socket = socket + elements.connectionState.dataset.state = "connecting" + elements.connectionLabel.textContent = "Connecting" + updateControls() + + socket.addEventListener("open", () => { + elements.connectionState.dataset.state = "connected" + elements.connectionLabel.textContent = "Connected" + updateControls() + }) + socket.addEventListener("message", message => { + try { + handleServerEvent(JSON.parse(message.data)) + } catch { + appendEvent({ detail: "Rejected a malformed server message", kind: "error" }) + } + }) + socket.addEventListener("close", () => { + markReconnecting() + setTimeout(startConnection, reconnectDelayMs) + }) +} + +for (const button of elements.modeButtons) { + button.addEventListener("click", () => { + const { mode } = button.dataset + const availableMode = mode === "fixture" || (mode === "openai" && isOpenAiAvailable) + if (availableMode) { + selectMode(mode) + } + }) +} + +elements.prompt.addEventListener("input", () => { + if (state.mode === "openai") { + openAiRequest = elements.prompt.value + } +}) + +for (const button of elements.policyButtons) { + button.addEventListener("click", () => { + const { policy } = button.dataset + if (policy) { + selectedPolicy = policy + updateControls() + } + }) +} + +for (const button of elements.panelToggles) { + button.addEventListener("click", () => toggleInspectionPanel(button)) +} + +elements.run.addEventListener("click", () => { + const prompt = elements.prompt.value.trim() + if (!(prompt && !isRunning && state.socket?.readyState === WebSocket.OPEN)) { + return + } + isRunning = true + state.runId = null + resetRun() + updateControls() + const snapshotPolicy = + selectedPolicy === "bytes" + ? { bytes: Number(elements.byteThreshold.value), mode: "bytes" } + : { mode: selectedPolicy } + state.socket.send(JSON.stringify({ mode: state.mode, prompt, snapshotPolicy, type: "start" })) +}) + +elements.stop.addEventListener("click", () => { + if (state.socket?.readyState === WebSocket.OPEN) { + state.socket.send(JSON.stringify({ type: "cancel" })) + } +}) + +startConnection() diff --git a/examples/websocket-ui/index.html b/examples/websocket-ui/index.html new file mode 100644 index 0000000..59997f2 --- /dev/null +++ b/examples/websocket-ui/index.html @@ -0,0 +1,251 @@ + + + + + + + SchemaStream Snapshot Console + + + +
+
+
+ +
+

SchemaStream

+

Snapshot console

+
+
+
+ + Connecting +
+
+ +
+ +
+ Data source + + + + Checking OpenAI availability. + +
+ + +
+ +
+
+ Snapshots + + + + +
+ +
+ Input stream totals + 0 string buffer + 0 chunks in + 0 bytes +
+
+ + + +
+
+
+
+

Materialized snapshot

+

Waiting for a run

+
+ Idle +
+ + + +
+
+

Brief

+ Locked +
+

No structured values received.

+
+ +
+
+

Metrics

+ Locked +
+
+
+ +
+
+

Activity

+ 0 events +
+
    +
    + +
    +
    +

    Release readiness

    + Locked +
    +
    +
    +
    + -- + / 100 +
    + +
    +
      +
      +
      + +
      + Next action + Pending +
      +
      + + +
      +
      + + + + diff --git a/examples/websocket-ui/server.ts b/examples/websocket-ui/server.ts new file mode 100644 index 0000000..0c73cf7 --- /dev/null +++ b/examples/websocket-ui/server.ts @@ -0,0 +1,686 @@ +import { isDeepStrictEqual } from "node:util" +import { + Agent, + type Model, + type ModelResponse, + Runner, + type StreamEvent, + setTraceProcessors, + setTracingDisabled +} from "@openai/agents" +import { z } from "zod" + +import { + SchemaStream, + type SchemaStreamChunk, + type SchemaStreamSource, + type SchemaStreamValuePath, + type SnapshotPolicy +} from "../../src" + +setTraceProcessors([]) +setTracingDisabled(true) + +const protocolVersion = 1 as const +const defaultPort = 3400 +const defaultModel = "gpt-5.6-luna" +const maxPromptLength = 800 +const decisionPath = ["triage", "requiresApproval"] as const +const fixtureBrief = "Customer-facing release with three open checks and an approval gate." + +const dashboardSchema = z.object({ + brief: z.string(), + triage: z.object({ + severity: z.enum(["low", "medium", "high"]), + requiresApproval: z.boolean(), + rationale: z.string() + }), + interface: z.object({ + title: z.string(), + status: z.string(), + accent: z.enum(["green", "amber", "coral"]), + metrics: z.array( + z.object({ + label: z.string(), + value: z.string(), + trend: z.enum(["up", "steady", "down"]) + }) + ), + activity: z.array( + z.object({ + time: z.string(), + label: z.string(), + state: z.enum(["done", "active", "queued"]) + }) + ) + }), + readiness: z.object({ + score: z.number().min(0).max(100), + checks: z.array( + z.object({ + label: z.string(), + status: z.enum(["pass", "watch", "blocker"]) + }) + ) + }), + nextAction: z.string() +}) + +type Dashboard = z.output +type DashboardSnapshot = SchemaStreamChunk +type ExecutionMode = "fixture" | "openai" +type WorkflowBranch = "approval-gate" | "auto-stage" + +type SocketData = { + controller?: AbortController + running: boolean + runId: number + task?: Promise +} + +type ServerEvent = + | { + openAiAvailable: boolean + model: string + type: "hello" + version: typeof protocolVersion + } + | { + mode: ExecutionMode + phase: "generating" | "idle" + runId: number + type: "status" + version: typeof protocolVersion + } + | { + completedPaths: SchemaStreamValuePath[] + completedValues: number + inputBytes: number + inputChunks: number + policy: SnapshotPolicy + runId: number + sequence: number + snapshot: DashboardSnapshot + type: "snapshot" + version: typeof protocolVersion + } + | { + action: string + branch: WorkflowBranch + path: typeof decisionPath + rationale: string + runId: number + type: "decision" + version: typeof protocolVersion + } + | { + durationMs: number + inputBytes: number + inputChunks: number + output: Dashboard + policy: SnapshotPolicy + runId: number + snapshots: number + type: "complete" + version: typeof protocolVersion + } + | { + code: "bad-message" | "busy" | "cancelled" | "generation-failed" | "live-unavailable" + message: string + runId?: number + type: "error" + version: typeof protocolVersion + } + +type AgentExecution = { + readFinalOutput: () => Promise + source: SchemaStreamSource +} + +const openAiModel = process.env.SCHEMA_STREAM_EXAMPLE_MODEL?.trim() || defaultModel +const openAiAvailable = Boolean(process.env.OPENAI_API_KEY?.trim()) +const port = readPort(process.env.SCHEMA_STREAM_EXAMPLE_PORT) +const allowedHost = `127.0.0.1:${port}` +const allowedOrigin = `http://${allowedHost}` +const websocketToken = crypto.randomUUID() +const runner = new Runner({ + traceIncludeSensitiveData: false, + tracingDisabled: true +}) + +const assets = new Map([ + ["/", { contentType: "text/html; charset=utf-8", file: "index.html" }], + ["/app.js", { contentType: "text/javascript; charset=utf-8", file: "app.js" }], + ["/styles.css", { contentType: "text/css; charset=utf-8", file: "styles.css" }] +]) + +/** Resolves the optional example port while rejecting ambiguous or privileged values. */ +function readPort(rawPort: string | undefined): number { + if (!rawPort) { + return defaultPort + } + + const parsed = Number(rawPort) + if (!Number.isInteger(parsed) || parsed < 1024 || parsed > 65_535) { + throw new Error("SCHEMA_STREAM_EXAMPLE_PORT must be an integer from 1024 through 65535") + } + return parsed +} + +/** Narrows decoded client frames before any property is trusted. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** Compares a completion delta with the field that controls the server workflow branch. */ +function isDecisionPath(path: SchemaStreamValuePath): boolean { + return path.length === 2 && path[0] === decisionPath[0] && path[1] === decisionPath[1] +} + +/** Parses a client-selected cadence into SchemaStream's public snapshot policy contract. */ +function readSnapshotPolicy(value: unknown): SnapshotPolicy | null { + if (value === undefined) { + return { mode: "chunk" } + } + if (!isRecord(value) || typeof value.mode !== "string") { + return null + } + if (value.mode === "chunk" || value.mode === "value" || value.mode === "final") { + return { mode: value.mode } + } + if ( + value.mode === "bytes" && + typeof value.bytes === "number" && + Number.isInteger(value.bytes) && + value.bytes > 0 && + value.bytes <= 1_000_000 + ) { + return { bytes: value.bytes, mode: "bytes" } + } + return null +} + +/** Sends exactly one complete, versioned JSON document per WebSocket message. */ +function sendEvent(socket: Bun.ServerWebSocket, event: ServerEvent): void { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(event)) + } +} + +/** Rejects DNS-rebinding hosts before serving assets, session tokens, or WebSocket upgrades. */ +function hasAllowedHost(request: Request, requestUrl: URL): boolean { + return ( + request.headers.get("host") === allowedHost && + requestUrl.protocol === "http:" && + requestUrl.host === allowedHost + ) +} + +/** Requires an exact browser origin for every WebSocket upgrade. */ +function hasAllowedOrigin(request: Request): boolean { + const origin = request.headers.get("origin") + if (!origin) { + return false + } + + try { + return new URL(origin).origin === allowedOrigin + } catch { + return false + } +} + +/** Produces stable code-point chunks so the fixture exercises real incremental parsing. */ +function splitJson(serialized: string, chunkSize = 13): string[] { + const codePoints = Array.from(serialized) + const chunks: string[] = [] + for (let offset = 0; offset < codePoints.length; offset += chunkSize) { + chunks.push(codePoints.slice(offset, offset + chunkSize).join("")) + } + return chunks +} + +/** Measures provider chunks before yielding them unchanged to SchemaStream. */ +async function* measureSource({ + onChunk, + source +}: { + onChunk: (byteLength: number) => void + source: SchemaStreamSource +}): AsyncIterable { + const encoder = new TextEncoder() + for await (const chunk of source) { + onChunk(encoder.encode(chunk).byteLength) + yield chunk + } +} + +/** Delays fixture chunks recursively so the browser can paint each progressive state. */ +async function* paceChunks(chunks: string[], index = 0): AsyncIterable { + if (index >= chunks.length) { + return + } + + await Bun.sleep(38) + yield chunks[index] ?? "" + yield* paceChunks(chunks, index + 1) +} + +/** Builds the fixed fixture used to compare snapshot policies without model variance. */ +function createFixtureDashboard(): Dashboard { + return { + brief: fixtureBrief, + triage: { + severity: "high", + requiresApproval: true, + rationale: "The launch changes a customer-facing workflow and needs an explicit review." + }, + interface: { + title: "Launch control", + status: "Review required", + accent: "amber", + metrics: [ + { label: "Readiness", trend: "up", value: "82%" }, + { label: "Open checks", trend: "down", value: "3" }, + { label: "Owners online", trend: "steady", value: "6" } + ], + activity: [ + { label: "Schema verified", state: "done", time: "09:41" }, + { label: "Approval requested", state: "active", time: "09:42" }, + { label: "Production stage", state: "queued", time: "09:45" } + ] + }, + readiness: { + score: 82, + checks: [ + { label: "Schema contract", status: "pass" }, + { label: "Release owner", status: "watch" }, + { label: "Rollback rehearsal", status: "blocker" } + ] + }, + nextAction: "Route the generated launch plan to the release owner for approval." + } +} + +/** + * Implements the Agents SDK model contract without credentials. The SDK Runner still owns the + * stream lifecycle, so Fixture and OpenAI exercise the same `toTextStream()` integration. + */ +function createFixtureModel(output: Dashboard): Model { + const serialized = JSON.stringify(output) + const chunks = splitJson(serialized) + + return { + getResponse(): Promise { + return Promise.reject(new Error("Fixture unexpectedly used a non-stream call")) + }, + async *getStreamedResponse(): AsyncIterable { + yield { type: "response_started" } + for await (const delta of paceChunks(chunks)) { + yield { delta, type: "output_text_delta" } + } + yield { + response: { + id: "websocket-example-response", + output: [ + { + content: [{ text: serialized, type: "output_text" }], + id: "websocket-example-message", + role: "assistant", + status: "completed", + type: "message" + } + ], + usage: { + inputTokens: 1, + inputTokensDetails: {}, + outputTokens: chunks.length, + outputTokensDetails: {}, + requests: 1, + totalTokens: chunks.length + 1 + } + }, + type: "response_done" + } + } + } +} + +/** Starts either model behind one Agents SDK structured-output stream. */ +async function createAgentExecution({ + mode, + prompt, + signal +}: { + mode: ExecutionMode + prompt: string + signal: AbortSignal +}): Promise { + const model: Model | string = + mode === "fixture" ? createFixtureModel(createFixtureDashboard()) : openAiModel + const agent = new Agent({ + instructions: + "Populate a compact operational dashboard. Put triage first. Set requiresApproval true when the request carries meaningful operational or customer risk. Return only the requested structured output.", + model, + modelSettings: { maxTokens: 1400, store: false }, + name: "SchemaStream WebSocket dashboard", + outputType: dashboardSchema + }) + const result = await runner.run(agent, prompt, { signal, stream: true }) + + return { + source: result.toTextStream(), + async readFinalOutput(): Promise { + await result.completed + return dashboardSchema.parse(result.finalOutput) + } + } +} + +/** Converts the completed boolean into an application decision before generation has finished. */ +function createWorkflowDecision(requiresApproval: boolean): { + action: string + branch: WorkflowBranch + rationale: string +} { + return requiresApproval + ? { + action: "Hold production staging and notify the release owner.", + branch: "approval-gate", + rationale: "The completed triage field requires an explicit approval." + } + : { + action: "Stage the generated dashboard automatically.", + branch: "auto-stage", + rationale: "The completed triage field permits the low-risk path." + } +} + +/** + * Parses model deltas on the server, emits only materialized snapshots, and branches immediately + * when `triage.requiresApproval` becomes syntactically complete. + */ +async function streamDashboard({ + controller, + mode, + policy, + prompt, + runId, + socket +}: { + controller: AbortController + mode: ExecutionMode + policy: SnapshotPolicy + prompt: string + runId: number + socket: Bun.ServerWebSocket +}): Promise { + const startedAt = performance.now() + const pendingPaths: SchemaStreamValuePath[] = [] + let completedValues = 0 + let decisionSent = false + let finalSnapshot: DashboardSnapshot | undefined + let inputBytes = 0 + let inputChunks = 0 + let sequence = 0 + + try { + sendEvent(socket, { + mode, + phase: "generating", + runId, + type: "status", + version: protocolVersion + }) + const execution = await createAgentExecution({ mode, prompt, signal: controller.signal }) + const parser = new SchemaStream(dashboardSchema, { + onValueComplete({ path, value }) { + pendingPaths.push(path) + completedValues += 1 + if (!decisionSent && isDecisionPath(path) && typeof value === "boolean") { + decisionSent = true + sendEvent(socket, { + ...createWorkflowDecision(value), + path: decisionPath, + runId, + type: "decision", + version: protocolVersion + }) + } + } + }) + const source = measureSource({ + onChunk(byteLength) { + inputBytes += byteLength + inputChunks += 1 + }, + source: execution.source + }) + + for await (const snapshot of parser.iterate(source, { snapshotPolicy: policy })) { + sequence += 1 + finalSnapshot = snapshot + const completedPaths = pendingPaths.splice(0) + sendEvent(socket, { + completedPaths, + completedValues, + inputBytes, + inputChunks, + policy, + runId, + sequence, + snapshot, + type: "snapshot", + version: protocolVersion + }) + } + + const output = await execution.readFinalOutput() + if (!isDeepStrictEqual(finalSnapshot, output)) { + throw new Error("The final progressive snapshot did not match the authoritative output") + } + sendEvent(socket, { + durationMs: Math.round(performance.now() - startedAt), + inputBytes, + inputChunks, + output, + policy, + runId, + snapshots: sequence, + type: "complete", + version: protocolVersion + }) + } catch (error) { + const cancelled = controller.signal.aborted + sendEvent(socket, { + code: cancelled ? "cancelled" : "generation-failed", + message: cancelled + ? "Generation cancelled." + : "Generation failed without exposing provider or credential details.", + runId, + type: "error", + version: protocolVersion + }) + if (!cancelled) { + const errorName = error instanceof Error ? error.name : "UnknownError" + const fixtureDetail = mode === "fixture" && error instanceof Error ? `: ${error.message}` : "" + process.stderr.write(`WebSocket example generation failed (${errorName}${fixtureDetail})\n`) + } + } finally { + if (socket.data.runId === runId) { + socket.data.controller = undefined + socket.data.running = false + } + } +} + +/** Validates control frames and starts one cancellable generation per connection. */ +function handleClientMessage({ + message, + socket +}: { + message: string | Buffer + socket: Bun.ServerWebSocket +}): void { + let decoded: unknown + try { + decoded = JSON.parse(typeof message === "string" ? message : message.toString("utf8")) + } catch { + sendEvent(socket, { + code: "bad-message", + message: "Client messages must be complete JSON documents.", + type: "error", + version: protocolVersion + }) + return + } + + if (!isRecord(decoded) || typeof decoded.type !== "string") { + sendEvent(socket, { + code: "bad-message", + message: "Unknown client message.", + type: "error", + version: protocolVersion + }) + return + } + + if (decoded.type === "cancel") { + socket.data.controller?.abort() + return + } + if (decoded.type !== "start" || (decoded.mode !== "fixture" && decoded.mode !== "openai")) { + sendEvent(socket, { + code: "bad-message", + message: "Expected a start message with a supported execution mode.", + type: "error", + version: protocolVersion + }) + return + } + if (socket.data.running) { + sendEvent(socket, { + code: "busy", + message: "This connection already has an active generation.", + runId: socket.data.runId, + type: "error", + version: protocolVersion + }) + return + } + if (decoded.mode === "openai" && !openAiAvailable) { + sendEvent(socket, { + code: "live-unavailable", + message: "OpenAI mode requires OPENAI_API_KEY on the server.", + type: "error", + version: protocolVersion + }) + return + } + + const prompt = typeof decoded.prompt === "string" ? decoded.prompt.trim() : "" + if (!prompt || prompt.length > maxPromptLength) { + sendEvent(socket, { + code: "bad-message", + message: `Prompt length must be between 1 and ${maxPromptLength} characters.`, + type: "error", + version: protocolVersion + }) + return + } + + const policy = readSnapshotPolicy(decoded.snapshotPolicy) + if (!policy) { + sendEvent(socket, { + code: "bad-message", + message: "Snapshot policy must be chunk, value, final, or bytes with a valid threshold.", + type: "error", + version: protocolVersion + }) + return + } + + const controller = new AbortController() + const runId = socket.data.runId + 1 + socket.data.controller = controller + socket.data.running = true + socket.data.runId = runId + socket.data.task = streamDashboard({ + controller, + mode: decoded.mode, + policy, + prompt, + runId, + socket + }) +} + +const server = Bun.serve({ + fetch(request, bunServer) { + const url = new URL(request.url) + if (!hasAllowedHost(request, url)) { + return new Response("Forbidden", { status: 403 }) + } + if (url.pathname === "/session") { + if (request.method !== "GET") { + return new Response("Method not allowed", { status: 405 }) + } + return Response.json( + { token: websocketToken }, + { + headers: { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff" + } + } + ) + } + if (url.pathname === "/ws") { + if (!hasAllowedOrigin(request) || url.searchParams.get("token") !== websocketToken) { + return new Response("Forbidden", { status: 403 }) + } + const upgraded = bunServer.upgrade(request, { + data: { running: false, runId: 0 } + }) + return upgraded ? undefined : new Response("WebSocket upgrade required", { status: 426 }) + } + + const asset = assets.get(url.pathname) + if (!asset || request.method !== "GET") { + return new Response("Not found", { status: 404 }) + } + return new Response(Bun.file(`${import.meta.dir}/${asset.file}`), { + headers: { + "Cache-Control": "no-store", + "Content-Security-Policy": `default-src 'self'; connect-src 'self' ws://${allowedHost}; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`, + "Content-Type": asset.contentType, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff" + } + }) + }, + hostname: "127.0.0.1", + port, + websocket: { + close(socket) { + socket.data.controller?.abort() + }, + message(socket, message) { + handleClientMessage({ message, socket }) + }, + open(socket) { + sendEvent(socket, { + model: openAiModel, + openAiAvailable, + type: "hello", + version: protocolVersion + }) + } + } +}) + +process.stdout.write(`SchemaStream WebSocket UI: ${server.url}\n`) +process.stdout.write( + `OpenAI mode: ${openAiAvailable ? `available (${openAiModel})` : "disabled"}\n` +) diff --git a/examples/websocket-ui/styles.css b/examples/websocket-ui/styles.css new file mode 100644 index 0000000..a310cb8 --- /dev/null +++ b/examples/websocket-ui/styles.css @@ -0,0 +1,1133 @@ +:root { + color: #e8ece9; + background: #0c100e; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + font-synthesis: none; + letter-spacing: 0; + --amber: #f2b84b; + --border: #29312d; + --coral: #ff7669; + --green: #61d095; + --muted: #91a098; + --panel: #151a17; + --terminal: #090c0a; + --topbar-height: 72px; + --commandbar-height: 78px; + --policybar-height: 50px; + --chrome-height: 202px; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + linear-gradient(rgba(255, 255, 255, 0.018) 1px, transparent 1px), #0c100e; + background-size: 100% 32px; +} + +button, +input { + color: inherit; + font: inherit; + letter-spacing: 0; +} + +button { + cursor: pointer; + transition: + color 140ms ease, + background-color 140ms ease, + border-color 140ms ease, + transform 140ms ease; +} + +button:not(:disabled):active { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.shell { + width: min(1440px, 100%); + min-height: 100vh; + margin: 0 auto; + border-right: 1px solid var(--border); + border-left: 1px solid var(--border); + background: rgba(12, 16, 14, 0.96); +} + +.topbar, +.command-bar { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.topbar { + min-height: var(--topbar-height); + justify-content: space-between; + padding: 12px 22px; +} + +.brand-lockup { + display: flex; + gap: 11px; + align-items: center; +} + +.brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid #435048; + border-radius: 7px; + color: #0c100e; + background: var(--green); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 13px; + font-weight: 800; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +.brand-lockup h1 { + font-size: 16px; + line-height: 1.2; +} + +.brand-lockup p { + margin-top: 2px; + color: var(--muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 11px; + text-transform: uppercase; +} + +.connection { + display: flex; + gap: 8px; + align-items: center; + color: var(--muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 11px; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 3px rgba(242, 184, 75, 0.12); +} + +.connection[data-state="connected"] .status-dot { + background: var(--green); + box-shadow: 0 0 0 3px rgba(97, 208, 149, 0.12); +} + +.connection[data-state="disconnected"] .status-dot { + background: var(--coral); +} + +.command-bar { + gap: 10px; + min-height: var(--commandbar-height); + padding: 12px 22px; + background: #111512; +} + +.prompt-field { + display: grid; + flex: 1; + min-width: 0; + gap: 5px; +} + +.prompt-field span { + color: var(--muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 10px; + text-transform: uppercase; +} + +.prompt-field input { + width: 100%; + height: 38px; + padding: 0 11px; + border: 1px solid #364039; + border-radius: 5px; + outline: none; + background: #090c0a; + font-size: 13px; +} + +.prompt-field input:focus { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(97, 208, 149, 0.12); +} + +.prompt-field input:disabled { + cursor: not-allowed; + color: #8d9991; + background: #0d110e; +} + +.mode-picker { + display: flex; + align-self: end; + height: 38px; + margin: 0; + padding: 3px; + border: 1px solid #364039; + border-radius: 5px; + background: #090c0a; +} + +.mode-button { + min-width: 60px; + border: 0; + border-radius: 3px; + color: var(--muted); + background: transparent; + font-size: 12px; +} + +.mode-button.is-active { + color: #f1f4f2; + background: #28302b; +} + +.mode-button.is-unavailable { + cursor: not-allowed; + opacity: 0.42; +} + +.policy-bar { + display: flex; + min-height: var(--policybar-height); + gap: 14px; + align-items: center; + padding: 7px 22px; + border-bottom: 1px solid var(--border); + background: #0d110f; +} + +.policy-picker { + display: flex; + height: 34px; + align-items: center; + margin: 0; + padding: 3px; + border: 1px solid #313a35; + border-radius: 5px; +} + +.policy-picker legend { + float: left; + margin: 0 8px 0 3px; + padding: 0; + color: var(--muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.policy-button { + height: 26px; + min-width: 54px; + padding: 0 8px; + border: 0; + border-radius: 3px; + color: #78857d; + background: transparent; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.policy-button.is-active { + color: #07110c; + background: var(--green); +} + +.bytes-control { + display: flex; + height: 34px; + gap: 7px; + align-items: center; + color: var(--muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.bytes-control[hidden] { + display: none; +} + +.bytes-control input { + width: 68px; + height: 28px; + padding: 0 7px; + border: 1px solid #39433d; + border-radius: 4px; + outline: none; + background: #080b09; + font-size: 10px; +} + +.wire-stats { + display: flex; + min-inline-size: 0; + gap: 15px; + align-items: center; + padding: 0; + border: 0; + margin-left: auto; + color: #68756d; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.wire-stats strong { + color: #c8d2cb; + font-weight: 600; +} + +.run-button, +.stop-button { + display: flex; + align-self: end; + height: 38px; + align-items: center; + justify-content: center; + border-radius: 5px; +} + +.run-button { + gap: 8px; + min-width: 92px; + border: 1px solid #71dda4; + color: #08120d; + background: var(--green); + font-size: 12px; + font-weight: 750; +} + +.play-icon { + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 8px solid currentColor; +} + +.stop-button { + width: 38px; + border: 1px solid #4a544e; + background: #161c18; +} + +.stop-button > span:first-child { + width: 9px; + height: 9px; + border-radius: 1px; + background: var(--coral); +} + +.progress-track { + height: 2px; + overflow: hidden; + background: #1c231f; +} + +.progress-track span { + display: block; + width: 100%; + height: 100%; + transform: scaleX(0); + transform-origin: left; + background: var(--green); + transition: transform 180ms ease; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(360px, 0.85fr); + min-height: calc(100dvh - var(--chrome-height)); +} + +.preview, +.terminal { + min-width: 0; +} + +.preview { + padding: 28px; + background: #f2f4f1; + color: #172019; +} + +.panel-header { + display: flex; + min-height: 54px; + align-items: start; + justify-content: space-between; + gap: 18px; + padding-bottom: 20px; + border-bottom: 1px solid #cdd4ce; +} + +.eyebrow { + margin-bottom: 5px; + color: #667069; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 10px; + text-transform: uppercase; +} + +.panel-header h2 { + max-width: 620px; + font-size: 30px; + line-height: 1.06; +} + +.stage-badge { + flex: 0 0 auto; + max-width: 180px; + padding: 5px 8px; + overflow: hidden; + border: 1px solid #b8c3bb; + border-radius: 4px; + color: #405047; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-badge[data-accent="amber"] { + border-color: #b58428; + color: #765510; + background: #fff4dc; +} + +.stage-badge[data-accent="coral"] { + border-color: #d26559; + color: #8b2d25; + background: #ffebe8; +} + +.decision-strip { + display: flex; + gap: 11px; + align-items: center; + margin: 18px 0 0; + padding: 11px 13px; + border-left: 3px solid var(--amber); + background: #fff6e4; +} + +.decision-strip[hidden] { + display: none; +} + +.decision-strip[data-branch="auto-stage"] { + border-color: #2a9a63; + background: #e7f7ee; +} + +.decision-icon { + display: grid; + width: 24px; + height: 24px; + flex: 0 0 auto; + place-items: center; + border-radius: 50%; + color: #fff; + background: #a87316; + font-weight: 800; +} + +.decision-strip strong { + display: block; + font-size: 12px; +} + +.decision-strip p { + margin-top: 2px; + color: #647067; + font-size: 11px; +} + +.stream-section { + position: relative; + transition: opacity 160ms ease; +} + +.stream-section[data-state="locked"] { + opacity: 0.42; +} + +.stream-section[data-state="unlocked"] { + opacity: 1; +} + +.stream-section[data-state="unlocked"] .section-heading > span { + color: #26744d; +} + +.brief-section, +.metrics-section { + margin-top: 22px; +} + +.brief { + max-width: 800px; + min-height: 42px; + margin: 12px 0 0; + color: #526057; + font-size: 13px; + line-height: 1.55; +} + +.metrics { + display: grid; + min-height: 102px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-bottom: 1px solid #cdd4ce; +} + +.metric { + display: grid; + min-width: 0; + align-content: center; + padding: 16px; + border-right: 1px solid #cdd4ce; +} + +.metric:last-child { + border-right: 0; +} + +.metric-label, +.metric-trend { + color: #68736b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.metric-value { + margin: 4px 0; + overflow-wrap: anywhere; + font-size: 23px; +} + +.trend-up { + color: #26744d; +} + +.trend-down { + color: #b4483e; +} + +.activity-section { + margin-top: 24px; +} + +.section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: 9px; + border-bottom: 1px solid #cdd4ce; +} + +.section-heading h3 { + font-size: 13px; +} + +.section-heading span { + color: #68736b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; +} + +.activity-list { + min-height: 138px; + margin: 0; + padding: 0; + list-style: none; +} + +.activity-list li { + display: grid; + grid-template-columns: 46px 12px minmax(0, 1fr) 62px; + gap: 10px; + align-items: center; + min-height: 46px; + border-bottom: 1px solid #dde2de; + font-size: 11px; +} + +.activity-time, +.activity-state { + color: #788179; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; +} + +.activity-state { + text-align: right; + text-transform: uppercase; +} + +.activity-marker { + width: 7px; + height: 7px; + border-radius: 50%; + background: #aab2ac; +} + +li[data-state="done"] .activity-marker { + background: #2a9a63; +} + +li[data-state="active"] .activity-marker { + background: var(--amber); + box-shadow: 0 0 0 4px rgba(242, 184, 75, 0.18); +} + +.readiness-section { + margin-top: 24px; +} + +.readiness-body { + display: grid; + min-height: 114px; + grid-template-columns: minmax(150px, 0.5fr) minmax(0, 1.5fr); + border-bottom: 1px solid #cdd4ce; +} + +.readiness-score { + display: grid; + align-content: center; + padding: 18px; + border-right: 1px solid #cdd4ce; +} + +.readiness-score strong { + font-size: 29px; +} + +.readiness-score > div > span { + color: #68736b; + font-size: 10px; +} + +.score-track { + height: 4px; + margin-top: 10px; + overflow: hidden; + background: #d5dbd6; +} + +.score-track span { + display: block; + width: 100%; + height: 100%; + transform: scaleX(0); + transform-origin: left; + background: #2a9a63; + transition: transform 180ms ease; +} + +.check-list { + display: grid; + align-content: center; + margin: 0; + padding: 8px 16px; + list-style: none; +} + +.check-list li { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) 54px; + gap: 9px; + align-items: center; + min-height: 30px; + font-size: 10px; +} + +.check-mark { + width: 7px; + height: 7px; + border-radius: 50%; + background: #aab2ac; +} + +.check-list li[data-status="pass"] .check-mark { + background: #2a9a63; +} + +.check-list li[data-status="watch"] .check-mark { + background: var(--amber); +} + +.check-list li[data-status="blocker"] .check-mark { + background: var(--coral); +} + +.check-status { + color: #68736b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 8px; + text-align: right; + text-transform: uppercase; +} + +.next-action { + display: grid; + gap: 4px; + margin-top: 20px; + padding: 12px 14px; + border: 1px solid #cdd4ce; + border-radius: 5px; + background: #e8ece8; +} + +.next-action span { + color: #68736b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + text-transform: uppercase; +} + +.next-action strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.terminal { + display: grid; + position: sticky; + top: var(--chrome-height); + height: calc(100dvh - var(--chrome-height)); + min-height: 0; + align-self: start; + grid-template-rows: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border-left: 1px solid var(--border); + background: var(--terminal); +} + +.terminal[data-events-collapsed="true"] { + grid-template-rows: 48px minmax(0, 1fr); +} + +.terminal[data-json-collapsed="true"] { + grid-template-rows: minmax(0, 1fr) 38px; +} + +.terminal[data-events-collapsed="true"][data-json-collapsed="true"] { + height: 86px; + min-height: 86px; + grid-template-rows: 48px 38px; +} + +.terminal-panel { + display: grid; + min-height: 0; + grid-template-rows: 48px minmax(0, 1fr); + overflow: hidden; +} + +.json-panel { + grid-template-rows: 38px minmax(0, 1fr); + border-top: 1px solid var(--border); +} + +.terminal-panel[data-collapsed="true"] { + grid-template-rows: auto; +} + +.terminal-header, +.json-header { + display: flex; + align-items: center; + border-color: var(--border); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.terminal-header { + gap: 12px; + padding: 0 16px; + border-bottom: 1px solid var(--border); +} + +.json-header { + gap: 10px; + justify-content: space-between; + padding: 0 16px; + border-bottom: 1px solid var(--border); +} + +.json-header h3 { + flex: 1; + color: #bec8c1; + font-size: 10px; + font-weight: 500; +} + +.json-header span { + color: #68756c; + font-size: 9px; +} + +.terminal-header h2 { + flex: 1; + color: #bec8c1; + font-size: 11px; + font-weight: 500; +} + +.window-controls { + display: flex; + gap: 5px; +} + +.window-controls span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4d5952; +} + +.window-controls span:first-child { + background: var(--coral); +} + +.window-controls span:nth-child(2) { + background: var(--amber); +} + +.window-controls span:last-child { + background: var(--green); +} + +.event-counter { + min-width: 24px; + padding: 2px 5px; + border: 1px solid #38423c; + border-radius: 3px; + color: var(--muted); + font-size: 9px; + text-align: center; +} + +.terminal-stats { + display: flex; + gap: 10px; + align-items: center; + color: #68756c; + font-size: 9px; + white-space: nowrap; +} + +.panel-toggle { + display: grid; + width: 24px; + height: 24px; + flex: 0 0 auto; + place-items: center; + padding: 0; + border: 1px solid #38423c; + border-radius: 4px; + color: #9aa69e; + background: #111713; +} + +.panel-toggle span { + width: 7px; + height: 7px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + transform: translateY(-2px) rotate(45deg); + transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.terminal-panel[data-collapsed="true"] .panel-toggle span { + transform: translateY(2px) rotate(225deg); +} + +.terminal-body { + min-height: 0; + overflow: auto; + scrollbar-color: #39443d transparent; + scrollbar-width: thin; +} + +.raw-json { + display: block; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + margin: 0; + padding: 14px 16px; + overflow: auto; + resize: none; + border: 0; + color: #9bd8b6; + background: #070a08; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 10px; + line-height: 1.55; + overflow-wrap: anywhere; + scrollbar-color: #39443d transparent; + scrollbar-width: thin; + white-space: pre-wrap; +} + +.raw-json:focus-visible { + outline: 2px solid var(--green); + outline-offset: -2px; +} + +.event-row { + display: grid; + grid-template-columns: 68px minmax(82px, 0.7fr) minmax(0, 1.4fr); + min-height: 42px; + align-items: start; + border-bottom: 1px solid #171d19; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 9px; + line-height: 1.45; +} + +.event-row > span { + min-width: 0; + padding: 13px 9px; + overflow-wrap: anywhere; +} + +.event-kind { + color: #8d9991; + text-transform: uppercase; +} + +.event-kind-decision { + color: var(--amber); +} + +.event-kind-complete, +.event-kind-ready { + color: var(--green); +} + +.event-kind-error { + color: var(--coral); +} + +.event-path { + color: #a6b3aa; +} + +.event-detail { + color: #68756c; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 900px) { + .command-bar { + flex-wrap: wrap; + } + + .prompt-field { + flex-basis: 100%; + } + + .mode-picker, + .run-button, + .stop-button { + align-self: auto; + } + + .policy-bar { + flex-wrap: wrap; + } + + .workspace { + grid-template-columns: 1fr; + } + + .terminal { + position: static; + top: auto; + height: 100dvh; + min-height: 0; + border-top: 1px solid var(--border); + border-left: 0; + } + + .topbar, + .command-bar, + .policy-bar, + .progress-track { + position: static; + } + + .terminal-body { + min-height: 0; + } + + .raw-json { + min-height: 0; + } +} + +@media (max-width: 560px) { + .topbar, + .command-bar, + .policy-bar { + padding-right: 14px; + padding-left: 14px; + } + + .policy-picker { + width: 100%; + } + + .policy-button { + min-width: 0; + flex: 1; + } + + .wire-stats { + width: 100%; + justify-content: space-between; + margin-left: 0; + } + + .preview { + padding: 22px 16px; + } + + .panel-header { + align-items: start; + } + + .panel-header h2 { + font-size: 24px; + } + + .metrics { + grid-template-columns: 1fr; + } + + .metric { + min-height: 78px; + border-right: 0; + border-bottom: 1px solid #cdd4ce; + } + + .metric:last-child { + border-bottom: 0; + } + + .activity-list li { + grid-template-columns: 42px 10px minmax(0, 1fr); + } + + .activity-state { + display: none; + } + + .readiness-body { + grid-template-columns: 1fr; + } + + .readiness-score { + border-right: 0; + border-bottom: 1px solid #cdd4ce; + } + + .event-row { + grid-template-columns: 62px minmax(0, 1fr); + } + + .event-detail { + grid-column: 1 / -1; + padding-top: 0 !important; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} + +@media (min-width: 901px) { + .topbar, + .command-bar, + .policy-bar, + .progress-track { + position: sticky; + z-index: 10; + } + + .topbar { + top: 0; + background: #0c100e; + } + + .command-bar { + top: var(--topbar-height); + } + + .policy-bar { + top: calc(var(--topbar-height) + var(--commandbar-height)); + } + + .progress-track { + top: calc( + var(--topbar-height) + + var(--commandbar-height) + + var(--policybar-height) + ); + z-index: 11; + } +} diff --git a/package.json b/package.json index 2ccacb1..0e7abb5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,11 @@ }, "files": [ "dist/**", + "docs/benchmarks/2026-07-11-apple-m5-max.json", + "docs/completion-events.md", + "docs/integration-testing.md", "docs/snapshot-policies.md", + "docs/transports.md", "CHANGELOG.md", "LICENSE", "README.md" @@ -50,12 +54,18 @@ }, "packageManager": "bun@1.3.14", "scripts": { - "benchmark:snapshots": "bun tests/snapshot-policy.benchmark.ts", + "benchmark": "bun tests/snapshot-policy.benchmark.mts", + "benchmark:snapshots": "bun tests/snapshot-policy.benchmark.mts", "build": "tsup && tsc --project tsconfig.build.json", "check": "bun run lint && bun run type-check && bun run test && bun run test:packed", "changeset": "changeset", "clean": "rm -rf coverage dist node_modules", "dev": "tsup --watch", + "example:mastra": "bun examples/mastra.ts", + "example:progressive": "bun examples/progressive-json.ts", + "example:sdk": "bun examples/sdk-mocks.ts", + "example:websocket": "bun examples/websocket-ui/server.ts", + "examples": "bun run example:progressive && bun run example:sdk && bun run example:mastra", "format": "ultracite fix", "format:check": "ultracite check", "lint": "ultracite check", @@ -64,6 +74,7 @@ "release": "bun run build && changeset publish", "test": "bun test --timeout=25000", "test:coverage": "bun test --coverage --timeout=25000", + "test:live": "bun test tests/live-model.e2e.test.ts --timeout=300000", "test:packed": "bash tests/packed-consumer/verify.sh", "type-check": "tsc --noEmit", "version": "changeset version" @@ -72,6 +83,7 @@ "@biomejs/biome": "2.5.2", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", + "@mastra/core": "1.50.1", "@openai/agents": "0.13.1", "@types/bun": "^1.3.14", "ai": "7.0.19", diff --git a/src/utils/buffered-string.ts b/src/utils/buffered-string.ts index 348202e..67088dd 100644 --- a/src/utils/buffered-string.ts +++ b/src/utils/buffered-string.ts @@ -13,19 +13,34 @@ export interface StringBuilder { appendChar: (char: number) => void appendString: (value: string) => void byteLength: number + flushPending: () => void reset: () => void toString: () => string } +/** + * Accumulates an unbounded decoded string while optionally coalescing incremental updates. + * Deferred mode records every mutation but publishes at most once per tokenizer input chunk. + */ export class NonBufferedString implements StringBuilder { private readonly decoder = new TextDecoder("utf-8") private readonly encoder = new TextEncoder() private string = "" + private updateRevision = 0 + private flushedRevision = 0 + private readonly deferIncrementalUpdates: boolean private readonly onIncrementalString?: (str: string) => void public byteLength = 0 - constructor({ onIncrementalString }: { onIncrementalString?: (str: string) => void }) { + constructor({ + deferIncrementalUpdates = false, + onIncrementalString + }: { + deferIncrementalUpdates?: boolean + onIncrementalString?: (str: string) => void + }) { + this.deferIncrementalUpdates = deferIncrementalUpdates this.onIncrementalString = onIncrementalString ?? undefined } @@ -48,21 +63,39 @@ export class NonBufferedString implements StringBuilder { } private update(): void { - if (this.onIncrementalString) { - this.onIncrementalString(this.string) + if (!this.onIncrementalString) { + return + } + if (this.deferIncrementalUpdates) { + this.updateRevision += 1 + return } + this.onIncrementalString(this.string) + } + + /** Publishes a deferred incremental value once when the accumulated string has changed. */ + public flushPending(): void { + if (this.flushedRevision === this.updateRevision || !this.onIncrementalString) { + return + } + this.flushedRevision = this.updateRevision + this.onIncrementalString(this.string) } public reset(): void { this.string = "" + this.updateRevision = 0 + this.flushedRevision = 0 this.byteLength = 0 } public toString(): string { + this.flushedRevision = this.updateRevision return this.string } } +/** Accumulates string bytes in fixed-size blocks before decoding and publishing them. */ export class BufferedString implements StringBuilder { private readonly decoder = new TextDecoder("utf-8") private readonly encoder = new TextEncoder() @@ -128,6 +161,11 @@ export class BufferedString implements StringBuilder { } } + /** Leaves buffered publication to the configured byte threshold and final string read. */ + public flushPending(): void { + // Intentionally empty: BufferedString publishes through its byte-threshold flush. + } + public reset(): void { this.string = "" this.bufferOffset = 0 diff --git a/src/utils/json-clone.ts b/src/utils/json-clone.ts new file mode 100644 index 0000000..539658d --- /dev/null +++ b/src/utils/json-clone.ts @@ -0,0 +1,212 @@ +type JsonContainer = Record | unknown[] + +type CloneFrame = + | { + index: number + input: unknown[] + output: unknown[] + type: "array" + } + | { + index: number + input: Record + keys: string[] + output: Record + type: "object" + } + +const omittedValue = Symbol("omitted JSON value") +const requiresJsonRoundTrip = Symbol("requires JSON round trip") +const maximumRecursiveDepth = 128 + +function cloneScalar(value: unknown): unknown | typeof omittedValue { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return value + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return null + } + return Object.is(value, -0) ? 0 : value + } + if (typeof value === "bigint") { + throw requiresJsonRoundTrip + } + if (typeof value === "function" && "toJSON" in value) { + throw requiresJsonRoundTrip + } + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") { + return omittedValue + } + return value +} + +function hasCustomJsonBehavior(value: object): boolean { + return "toJSON" in value +} + +/** + * Creates one traversal frame for JSON-domain arrays and plain objects. + * Custom `toJSON` behavior and exotic prototypes deliberately request the native JSON fallback. + */ +function createCloneFrame(input: JsonContainer): { frame: CloneFrame; output: JsonContainer } { + if (hasCustomJsonBehavior(input)) { + throw requiresJsonRoundTrip + } + if (Array.isArray(input)) { + const output = new Array(input.length) + return { frame: { index: 0, input, output, type: "array" }, output } + } + + const prototype = Object.getPrototypeOf(input) + if (prototype !== Object.prototype && prototype !== null) { + throw requiresJsonRoundTrip + } + const output: Record = {} + return { + frame: { index: 0, input, keys: Object.keys(input), output, type: "object" }, + output + } +} + +/** Writes clone keys without invoking inherited setters such as `Object.prototype.__proto__`. */ +function setCloneValue( + target: Record | unknown[], + key: string | number, + value: unknown +): void { + if (key in target) { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true + }) + return + } + const writableTarget = target as Record + writableTarget[key] = value +} + +/** + * Clones a deeply nested JSON container with explicit frames so adversarial depth cannot exhaust + * the JavaScript call stack. The ancestor set preserves native `JSON.stringify` cycle failures. + */ +function cloneJsonContainerIterative( + root: JsonContainer, + inheritedAncestors: ReadonlySet +): JsonContainer { + const rootClone = createCloneFrame(root) + const ancestors = new Set(inheritedAncestors) + if (ancestors.has(root)) { + throw requiresJsonRoundTrip + } + ancestors.add(root) + const frames: CloneFrame[] = [rootClone.frame] + + while (frames.length > 0) { + const frame = frames.at(-1) as CloneFrame + const isComplete = + frame.type === "array" ? frame.index >= frame.input.length : frame.index >= frame.keys.length + if (isComplete) { + ancestors.delete(frame.input) + frames.pop() + continue + } + + const key = frame.type === "array" ? frame.index : frame.keys[frame.index] + const inputValue = + frame.type === "array" ? frame.input[frame.index] : frame.input[frame.keys[frame.index]] + frame.index += 1 + const value = cloneScalar(inputValue) + if (value === omittedValue) { + if (frame.type === "array") { + setCloneValue(frame.output, key, null) + } + continue + } + if (typeof value !== "object" || value === null) { + setCloneValue(frame.output, key, value) + continue + } + if (ancestors.has(value)) { + throw requiresJsonRoundTrip + } + + const childClone = createCloneFrame(value as JsonContainer) + setCloneValue(frame.output, key, childClone.output) + ancestors.add(value) + frames.push(childClone.frame) + } + + return rootClone.output +} + +/** + * Clones shallow JSON-domain values recursively for the common JIT-friendly path, then switches + * to explicit frames at the depth limit. JSON omissions and number normalization match a native + * stringify/parse round trip. + */ +function cloneJsonValue( + inputValue: unknown, + ancestors: Set, + depth: number +): unknown | typeof omittedValue { + const value = cloneScalar(inputValue) + if (value === omittedValue || typeof value !== "object" || value === null) { + return value + } + if (ancestors.has(value)) { + throw requiresJsonRoundTrip + } + if (depth >= maximumRecursiveDepth) { + return cloneJsonContainerIterative(value as JsonContainer, ancestors) + } + + const clone = createCloneFrame(value as JsonContainer) + ancestors.add(value) + if (clone.frame.type === "array") { + for (let index = 0; index < clone.frame.input.length; index += 1) { + const child = cloneJsonValue(clone.frame.input[index], ancestors, depth + 1) + setCloneValue(clone.frame.output, index, child === omittedValue ? null : child) + } + } else { + for (const key of clone.frame.keys) { + const child = cloneJsonValue(clone.frame.input[key], ancestors, depth + 1) + if (child !== omittedValue) { + setCloneValue(clone.frame.output, key, child) + } + } + } + ancestors.delete(value) + return clone.output +} + +/** Clones parser-owned JSON data without allocating serialized text or UTF-8 bytes. */ +function cloneJsonDomain(root: Record): Record { + return cloneJsonValue(root, new Set(), 0) as Record +} + +/** Preserves native JSON behavior for custom serializers, exotic values, and cyclic errors. */ +function cloneWithJsonRoundTrip(value: Record): Record { + return JSON.parse(JSON.stringify(value)) as Record +} + +/** + * Creates an independent JSON-equivalent object without allocating UTF-8 snapshots. + * Custom or exotic graphs retain the exact legacy stringify/parse behavior. + */ +export function cloneJsonSnapshot({ + value +}: { + value: Record +}): Record { + try { + return cloneJsonDomain(value) + } catch (error) { + if (error === requiresJsonRoundTrip) { + return cloneWithJsonRoundTrip(value) + } + throw error + } +} diff --git a/src/utils/json-parser.ts b/src/utils/json-parser.ts index 8d5c74b..b25cf84 100644 --- a/src/utils/json-parser.ts +++ b/src/utils/json-parser.ts @@ -10,6 +10,14 @@ import TokenParser, { import TokenType from "./token-type" import Tokenizer, { type TokenizerOptions } from "./tokenizer" +/** + * Identifies primitive value tokens without allocating a lookup collection. + * This range check depends on `TRUE` through `NUMBER` remaining contiguous in `TokenType`. + */ +function isValueToken(token: TokenType): boolean { + return token >= TokenType.TRUE && token <= TokenType.NUMBER +} + export interface JSONParserOptions extends TokenizerOptions, TokenParserOptions {} export default class JSONParser { @@ -57,18 +65,7 @@ export default class JSONParser { tokenizer: ParsedTokenInfo }) => void) { this.tokenizer.onToken = parsedToken => { - const valueTokenTypes = [ - TokenType.STRING, - TokenType.NUMBER, - TokenType.TRUE, - TokenType.FALSE, - TokenType.NULL - ] - - if ( - this.tokenParser.state === TokenParserState.VALUE && - valueTokenTypes.includes(parsedToken.token) - ) { + if (this.tokenParser.state === TokenParserState.VALUE && isValueToken(parsedToken.token)) { cb({ parser: { state: this.tokenParser.state, @@ -80,7 +77,9 @@ export default class JSONParser { }) } - this.tokenParser.write(parsedToken) + if (!parsedToken.partial) { + this.tokenParser.write(parsedToken) + } } } diff --git a/src/utils/streaming-json-parser.ts b/src/utils/streaming-json-parser.ts index 4fa752d..38f9454 100644 --- a/src/utils/streaming-json-parser.ts +++ b/src/utils/streaming-json-parser.ts @@ -1,3 +1,4 @@ +import { cloneJsonSnapshot } from "./json-clone" import JSONParser from "./json-parser" import type { ParsedElementInfo, @@ -25,7 +26,7 @@ export type TypeDefaults = { /** Object keys and array indexes locating a value in the streamed document. */ export type SchemaPath = (string | number | undefined)[] -/** Completion state reported after a streamed value changes. */ +/** Legacy progress state reported after a streamed value changes. */ export type OnKeyCompleteCallbackParams = { /** The value currently receiving streamed content, or an empty path after parsing finishes. */ activePath: SchemaPath @@ -33,9 +34,30 @@ export type OnKeyCompleteCallbackParams = { completedPaths: SchemaPath[] } -/** Receives immutable path snapshots as streamed values progress and complete. */ +/** Receives independent path snapshots as streamed values progress and complete. */ export type OnKeyCompleteCallback = (data: OnKeyCompleteCallbackParams) => void +/** Object keys and array indexes locating a syntactically complete JSON value. */ +export type SchemaStreamValuePath = readonly (string | number)[] + +/** A single completed-value event without cumulative completion history. */ +export type OnValueCompleteCallbackParams = { + /** + * Path of the value that just completed. Children complete before their containers, and an empty + * path identifies the completed root document. + */ + path: SchemaStreamValuePath + /** + * The syntactically complete JSON value at `path`. The parser does not clone container values for + * this callback; consumers should treat objects and arrays as read-only because mutations can be + * visible in later ancestor and root completion events. + */ + value: unknown +} + +/** Receives the path and value when each primitive or container finishes parsing. */ +export type OnValueCompleteCallback = (event: OnValueCompleteCallbackParams) => void + /** Configures schema-derived placeholders and completion reporting. */ export type SchemaStreamOptions = { /** Field-level placeholders that take precedence over schema and primitive defaults. */ @@ -44,6 +66,11 @@ export type SchemaStreamOptions = { typeDefaults?: TypeDefaults /** Called as values progress and once more with an empty active path after completion. */ onKeyComplete?: OnKeyCompleteCallback + /** + * Called once for each completed JSON value, including containers and the root document. Unlike + * `onKeyComplete`, this callback emits a path delta and does not copy cumulative history. + */ + onValueComplete?: OnValueCompleteCallback } /** Configures JSON tokenization and snapshot cadence for `parse()` and `iterate()`. */ @@ -56,7 +83,7 @@ export type SchemaStreamParseOptions = { snapshotPolicy?: SnapshotPolicy } -/** Controls when cumulative JSON snapshots are serialized and emitted. The default is `chunk`. */ +/** Controls when cumulative JSON snapshots are emitted. The default is `chunk`. */ export type SnapshotPolicy = | { /** Emits after every input chunk. */ @@ -89,6 +116,11 @@ type OpenSource = { finish: (cancel: boolean) => Promise } +type ParserSession = { + finish: () => boolean + write: (chunk: Uint8Array) => boolean +} + type JsonContainer = Record function hasOwn(value: object, key: PropertyKey): boolean { @@ -99,7 +131,25 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } +/** + * Writes schema-shaped values without invoking inherited setters or the legacy `__proto__` + * mutator. Compatible own properties retain the assignment fast path used by progressive updates. + */ function setOwnValue(target: JsonContainer, key: string | number, value: unknown): void { + const descriptor = Object.getOwnPropertyDescriptor(target, key) + const hasCompatibleOwnDataProperty = + descriptor !== undefined && + "value" in descriptor && + descriptor.configurable === true && + descriptor.enumerable === true && + descriptor.writable === true + const canCreateWithAssignment = descriptor === undefined && !(key in target) + + if (hasCompatibleOwnDataProperty || canCreateWithAssignment) { + target[key] = value + return + } + Object.defineProperty(target, key, { configurable: true, enumerable: true, @@ -229,6 +279,7 @@ export class SchemaStream { private readonly completedPaths: SchemaPath[] = [] private readonly completedPathKeys = new Set() private readonly onKeyComplete?: OnKeyCompleteCallback + private readonly onValueComplete?: OnValueCompleteCallback private readonly typeDefaults?: TypeDefaults /** @@ -244,6 +295,7 @@ export class SchemaStream { options.defaultData as Record | undefined ) this.onKeyComplete = options.onKeyComplete + this.onValueComplete = options.onValueComplete } private getTypeDefault(type: keyof TypeDefaults): unknown { @@ -330,7 +382,16 @@ export class SchemaStream { } private getPathFromStack(stack: StackElement[], key: string | number | undefined): SchemaPath { - return [...stack.map(element => element.key), key].slice(1) + if (stack.length === 0) { + return [] + } + + const path: SchemaPath = new Array(stack.length) + for (let index = 1; index < stack.length; index += 1) { + path[index - 1] = stack[index].key + } + path[stack.length - 1] = key + return path } private emitCompletion(): void { @@ -352,15 +413,21 @@ export class SchemaStream { } } - private handleEmptyContainer({ key, stack, value }: ParsedElementInfo): boolean { + private handleEmptyContainer({ + parsedValue: { key, stack, value }, + valuePath + }: { + parsedValue: ParsedElementInfo + valuePath?: SchemaPath + }): boolean { const emptyArray = Array.isArray(value) && value.length === 0 const emptyObject = isObject(value) && Object.keys(value).length === 0 if (!(emptyArray || emptyObject)) { return false } - const valuePath = this.getPathFromStack(stack, key) - const existingValue = getPathValue(this.schemaInstance, valuePath) + const resolvedPath = valuePath ?? this.getPathFromStack(stack, key) + const existingValue = getPathValue(this.schemaInstance, resolvedPath) const alreadyPresent = emptyArray ? Array.isArray(existingValue) && existingValue.length === 0 : isObject(existingValue) && Object.keys(existingValue).length === 0 @@ -368,16 +435,47 @@ export class SchemaStream { return false } - if (valuePath.length > 0) { - this.activePath = valuePath - this.recordCompletedPath(valuePath) - setPathValue(this.schemaInstance, valuePath, emptyArray ? [] : {}) - this.emitCompletion() + if (resolvedPath.length > 0) { + if (this.onKeyComplete) { + this.activePath = resolvedPath + this.recordCompletedPath(resolvedPath) + } + setPathValue(this.schemaInstance, resolvedPath, emptyArray ? [] : {}) + if (this.onKeyComplete) { + this.emitCompletion() + } } return true } + /** + * Handles TokenParser's canonical completed-value boundary. Completion deltas are dispatched only + * here so partial string tokens cannot emit them and primitives cannot be reported twice. + * + * @param parsedValue - Completed primitive or container with its parser stack location. + * @returns Whether an empty container changed the schema-shaped parser state. + */ + private handleCompletedValue(parsedValue: ParsedElementInfo): boolean { + const callback = this.onValueComplete + const valuePath = callback + ? this.getPathFromStack(parsedValue.stack, parsedValue.key) + : undefined + const changedParserState = this.handleEmptyContainer({ parsedValue, valuePath }) + + if (callback && valuePath && valuePath.length > 0) { + const path: (string | number)[] = [] + for (const segment of valuePath) { + if (segment !== undefined) { + path.push(segment) + } + } + callback({ path, value: parsedValue.value }) + } + + return changedParserState + } + private handleToken({ parser: { key, stack }, tokenizer: { value, partial } @@ -391,15 +489,18 @@ export class SchemaStream { tokenizer: ParsedTokenInfo }): boolean { const valuePath = this.getPathFromStack(stack, key) - this.activePath = valuePath - - if (!partial && valuePath.length > 0) { - this.recordCompletedPath(valuePath) + if (this.onKeyComplete) { + this.activePath = valuePath + if (!partial && valuePath.length > 0) { + this.recordCompletedPath(valuePath) + } } setPathValue(this.schemaInstance, valuePath, value) - this.emitCompletion() + if (this.onKeyComplete) { + this.emitCompletion() + } return !partial && valuePath.length > 0 } @@ -421,11 +522,15 @@ export class SchemaStream { ) as SchemaStreamChunk } - private createTransform( - options: SchemaStreamParseOptions, - onSnapshot?: (snapshot: Uint8Array) => void - ): TransformStream { - const textEncoder = new TextEncoder() + /** + * Creates tokenizer state and emission accounting for one parse operation. Legacy progress events + * retain character-level string cadence, while completed-value events use TokenParser's single + * `onValue` boundary and therefore keep chunk-batched tokenization. + * + * @param options - Tokenizer behavior and snapshot cadence for this operation. + * @returns Stateful write and finish operations shared by `parse()` and `iterate()`. + */ + private createParserSession(options: SchemaStreamParseOptions): ParserSession { const snapshotPolicy = options.snapshotPolicy ?? { mode: "chunk" } if ( snapshotPolicy.mode === "bytes" && @@ -436,13 +541,16 @@ export class SchemaStream { } const parser = new JSONParser({ stringBufferSize: options.stringBufferSize ?? 0, - handleUnescapedNewLines: options.handleUnescapedNewLines ?? true + handleUnescapedNewLines: options.handleUnescapedNewLines ?? true, + partialStringTokenMode: this.onKeyComplete ? "character" : "chunk" }) let bytesSinceEmission = 0 let completedValuesSinceEmission = 0 let parserRevision = 0 let emittedRevision = -1 let hasParsedValue = false + let rootCompletionPending = false + let rootCompletionValue: unknown parser.onToken = parsedToken => { const completedValue = this.handleToken(parsedToken) @@ -453,15 +561,16 @@ export class SchemaStream { } parser.onValue = parsedValue => { hasParsedValue = true - if (this.handleEmptyContainer(parsedValue)) { + if (this.onValueComplete && parsedValue.stack.length === 0) { + rootCompletionPending = true + rootCompletionValue = parsedValue.value + } + if (this.handleCompletedValue(parsedValue)) { parserRevision += 1 } } - const emitSnapshot = (controller: TransformStreamDefaultController): void => { - const snapshot = textEncoder.encode(JSON.stringify(this.schemaInstance)) - controller.enqueue(snapshot) - onSnapshot?.(snapshot) + const recordEmission = (): void => { bytesSinceEmission = 0 completedValuesSinceEmission = 0 emittedRevision = parserRevision @@ -480,33 +589,44 @@ export class SchemaStream { return false } - return new TransformStream({ - transform: (chunk, controller): void => { - try { - parser.write(chunk) - bytesSinceEmission += chunk.byteLength - if (shouldEmit()) { - emitSnapshot(controller) - } - } catch (error) { - controller.error(error) - } - }, - flush: controller => { + return { + finish: (): boolean => { if (!parser.isEnded) { parser.end() } - if ( - snapshotPolicy.mode !== "chunk" && - hasParsedValue && - emittedRevision !== parserRevision - ) { - emitSnapshot(controller) + const emitFinalSnapshot = + snapshotPolicy.mode !== "chunk" && hasParsedValue && emittedRevision !== parserRevision + if (emitFinalSnapshot) { + recordEmission() + } + if (rootCompletionPending) { + rootCompletionPending = false + this.onValueComplete?.({ path: [], value: rootCompletionValue }) + rootCompletionValue = undefined } this.activePath = [] this.emitCompletion() + return emitFinalSnapshot + }, + write: (chunk): boolean => { + parser.write(chunk) + bytesSinceEmission += chunk.byteLength + if (!shouldEmit()) { + return false + } + recordEmission() + return true } - }) + } + } + + /** + * Materializes an independent object snapshot without serialized UTF-8. Parser-owned JSON data + * uses the direct clone; custom serializers and exotic values dynamically retain the native + * stringify/parse contract. + */ + private createObjectSnapshot(): SchemaStreamChunk { + return cloneJsonSnapshot({ value: this.schemaInstance }) as SchemaStreamChunk } /** @@ -518,11 +638,36 @@ export class SchemaStream { * @throws {TypeError} When a byte snapshot threshold is not a positive finite integer. */ public parse(options: SchemaStreamParseOptions = {}): TransformStream { - return this.createTransform(options) + const session = this.createParserSession(options) + const textEncoder = new TextEncoder() + const createSnapshot = (): Uint8Array => { + const json = JSON.stringify(this.schemaInstance) + if (json === undefined) { + return new Uint8Array() + } + return textEncoder.encode(json) + } + + return new TransformStream({ + flush: controller => { + if (session.finish()) { + controller.enqueue(createSnapshot()) + } + }, + transform: (chunk, controller): void => { + try { + if (session.write(chunk)) { + controller.enqueue(createSnapshot()) + } + } catch (error) { + controller.error(error) + } + } + }) } /** - * Consumes streamed JSON text or bytes and yields immutable schema-shaped snapshots. + * Consumes streamed JSON text or bytes and yields independent schema-shaped snapshots. * The completed value is still unvalidated; use the producing SDK's settled output or * validate the final snapshot with the schema. * @@ -537,22 +682,10 @@ export class SchemaStream { source: SchemaStreamSource, options: SchemaStreamParseOptions = {} ): AsyncGenerator, void, void> { - const decoder = new TextDecoder() - const outputQueue: SchemaStreamChunk[] = [] - const transform = this.createTransform(options, snapshot => { - outputQueue.push(JSON.parse(decoder.decode(snapshot)) as SchemaStreamChunk) - }) + const session = this.createParserSession(options) const sourceHandle = openSource(source) - const reader = transform.readable.getReader() - const writer = transform.writable.getWriter() const encoder = new TextEncoder() let sourceDone = false - let parserDone = false - const outputPump = (async () => { - while (!(await reader.read()).done) { - // Reading relieves TransformStream backpressure; createTransform owns snapshot decoding. - } - })() try { while (true) { @@ -564,29 +697,16 @@ export class SchemaStream { const input: SchemaStreamInputChunk = next.value const bytes = typeof input === "string" ? encoder.encode(input) : input - await writer.write(bytes) - while (outputQueue.length > 0) { - yield outputQueue.shift() as SchemaStreamChunk + if (session.write(bytes)) { + yield this.createObjectSnapshot() } } - await writer.close() - await outputPump - while (outputQueue.length > 0) { - yield outputQueue.shift() as SchemaStreamChunk + if (session.finish()) { + yield this.createObjectSnapshot() } - parserDone = true } finally { - const cleanup: Promise[] = [sourceHandle.finish(!sourceDone)] - - if (!parserDone) { - cleanup.push(writer.abort(), reader.cancel()) - } - - cleanup.push(outputPump.catch(() => undefined)) - await Promise.allSettled(cleanup) - writer.releaseLock() - reader.releaseLock() + await Promise.allSettled([sourceHandle.finish(!sourceDone)]) } } } diff --git a/src/utils/token-parser.ts b/src/utils/token-parser.ts index ba5518f..795d58b 100644 --- a/src/utils/token-parser.ts +++ b/src/utils/token-parser.ts @@ -42,6 +42,24 @@ export interface ParsedElementInfo { value: JsonPrimitive | JsonStruct } +/** + * Writes parsed object keys without invoking inherited setters or the legacy `__proto__` mutator. + * Ordinary owned keys retain the assignment fast path used by object-heavy streams. + */ +function setOwnJsonValue(target: JsonObject, key: string, value: JsonPrimitive | JsonStruct): void { + if (Object.hasOwn(target, key) || !(key in target)) { + target[key] = value + return + } + + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true + }) +} + export const enum TokenParserState { VALUE = 0, KEY = 1, @@ -217,7 +235,7 @@ export default class TokenParser { token === TokenType.NULL ) { if (this.mode === TokenParserMode.OBJECT) { - ;(this.value as JsonObject)[this.key as string] = value + setOwnJsonValue(this.value as JsonObject, this.key as string, value) this.state = TokenParserState.COMMA } else if (this.mode === TokenParserMode.ARRAY) { ;(this.value as JsonArray).push(value) @@ -232,7 +250,7 @@ export default class TokenParser { this.push() if (this.mode === TokenParserMode.OBJECT) { const object: JsonObject = {} - ;(this.value as JsonObject)[this.key as string] = object + setOwnJsonValue(this.value as JsonObject, this.key as string, object) this.value = object } else if (this.mode === TokenParserMode.ARRAY) { const val = {} @@ -251,7 +269,7 @@ export default class TokenParser { this.push() if (this.mode === TokenParserMode.OBJECT) { const array: JsonArray = [] - ;(this.value as JsonObject)[this.key as string] = array + setOwnJsonValue(this.value as JsonObject, this.key as string, array) this.value = array } else if (this.mode === TokenParserMode.ARRAY) { const val: JsonArray = [] diff --git a/src/utils/tokenizer.ts b/src/utils/tokenizer.ts index 226201b..8b096a3 100644 --- a/src/utils/tokenizer.ts +++ b/src/utils/tokenizer.ts @@ -80,9 +80,15 @@ function tokenizerStateToString(tokenizerState: TokenizerStates): string { ][tokenizerState] } +/** Internal tokenizer controls shared by the streaming parser and its compatibility layer. */ export interface TokenizerOptions { handleUnescapedNewLines?: boolean numberBufferSize?: number + /** + * Controls partial string callback frequency without changing source chunks or snapshot policy. + * Character mode preserves legacy progress cadence; chunk mode coalesces hot-path updates. + */ + partialStringTokenMode?: "character" | "chunk" separator?: string stringBufferSize?: number } @@ -90,6 +96,7 @@ export interface TokenizerOptions { const defaultOpts: TokenizerOptions = { stringBufferSize: 0, numberBufferSize: 0, + partialStringTokenMode: "character", separator: undefined, handleUnescapedNewLines: false } @@ -101,6 +108,10 @@ export class TokenizerError extends Error { } } +/** + * Tokenizes JSON across arbitrary byte boundaries, including split UTF-8 and escape sequences. + * Chunk string mode batches contiguous ASCII bytes and flushes one partial value after each write. + */ export default class Tokenizer { private state = TokenizerStates.START @@ -110,6 +121,7 @@ export default class Tokenizer { private separatorIndex = 0 private readonly bufferedString: StringBuilder private readonly bufferedNumber: StringBuilder + private readonly batchStringsByChunk: boolean private unicode?: string // unicode escapes private highSurrogate?: number @@ -143,8 +155,11 @@ export default class Tokenizer { options.stringBufferSize && options.stringBufferSize > 0 ? new BufferedString(options.stringBufferSize) : new NonBufferedString({ + deferIncrementalUpdates: options.partialStringTokenMode === "chunk", onIncrementalString }) + this.batchStringsByChunk = + options.partialStringTokenMode === "chunk" && !(options.stringBufferSize ?? 0) this.bufferedNumber = options.numberBufferSize && options.numberBufferSize > 0 @@ -349,7 +364,25 @@ export default class Tokenizer { } if (n >= charset.SPACE) { - this.bufferedString.appendChar(n) + if (this.batchStringsByChunk) { + let end = i + 1 + while (end < buffer.length) { + const next = buffer[end] + if ( + next < charset.SPACE || + next >= 128 || + next === charset.QUOTATION_MARK || + next === charset.REVERSE_SOLIDUS + ) { + break + } + end += 1 + } + this.bufferedString.appendBuf(buffer, i, end) + i = end - 1 + } else { + this.bufferedString.appendChar(n) + } continue } @@ -654,6 +687,7 @@ export default class Tokenizer { )}" at position "${i}" in state ${tokenizerStateToString(this.state)}` ) } + this.bufferedString.flushPending() } catch (error: unknown) { this.error(error instanceof Error ? error : new Error(String(error))) } diff --git a/tests/completion-events.test.ts b/tests/completion-events.test.ts new file mode 100644 index 0000000..41fac1e --- /dev/null +++ b/tests/completion-events.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, test } from "bun:test" +import { z } from "zod" +import { + SchemaStream, + type SchemaStreamParseOptions, + type SchemaStreamValuePath, + type ZodObjectSchema +} from "@/index" + +type CompletionPath = (string | number)[] +type StreamingApi = "iterate" | "parse" + +function createByteSource(chunks: readonly Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk) + } + controller.close() + } + }) +} + +async function collectCompletionEvents({ + api, + chunks, + parseOptions, + schema +}: { + api: StreamingApi + chunks: readonly Uint8Array[] + parseOptions?: SchemaStreamParseOptions + schema: TSchema +}): Promise<{ + eventPaths: CompletionPath[] + eventReferences: SchemaStreamValuePath[] + finalValue: unknown +}> { + const eventPaths: CompletionPath[] = [] + const eventReferences: SchemaStreamValuePath[] = [] + const parser = new SchemaStream(schema, { + onValueComplete({ path }) { + eventPaths.push([...path]) + eventReferences.push(path) + } + }) + const source = createByteSource(chunks) + + if (api === "iterate") { + let finalValue: unknown + for await (const snapshot of parser.iterate(source, parseOptions)) { + finalValue = snapshot + } + return { eventPaths, eventReferences, finalValue } + } + + const decoder = new TextDecoder() + let finalSnapshot: Uint8Array | undefined + for await (const snapshot of source.pipeThrough(parser.parse(parseOptions))) { + finalSnapshot = snapshot + } + if (finalSnapshot === undefined) { + throw new Error("expected parse() to emit a final snapshot") + } + + return { + eventPaths, + eventReferences, + finalValue: JSON.parse(decoder.decode(finalSnapshot)) as unknown + } +} + +async function drain({ + parser, + source +}: { + parser: SchemaStream + source: AsyncIterable +}): Promise { + for await (const _snapshot of parser.iterate(source, { + snapshotPolicy: { mode: "final" } + })) { + // Drain the parser so callback and source lifecycle behavior can be asserted. + } +} + +const complexSchema = z.object({ + profile: z.object({ + name: z.string(), + preferences: z.object({ + alerts: z.array(z.boolean()), + emptyObject: z.object({}), + emptyArray: z.array(z.unknown()) + }) + }), + projects: z.array( + z.object({ + id: z.string(), + metrics: z.object({ + score: z.number(), + tags: z.array(z.string()) + }) + }) + ), + "a.b": z.object({ "0": z.string() }), + ["__proto__"]: z.object({ safe: z.string() }) +}) + +const complexValue = { + profile: { + name: `Ada ${String.fromCodePoint(127_757)}`, + preferences: { + alerts: [true, false], + emptyObject: {}, + emptyArray: [] + } + }, + projects: [ + { id: "alpha", metrics: { score: 98, tags: ["fast", "typed"] } }, + { id: "beta", metrics: { score: 87, tags: [] } } + ], + "a.b": { "0": "numeric object key" }, + ["__proto__"]: { safe: "yes" } +} + +const expectedComplexPaths: CompletionPath[] = [ + ["profile", "name"], + ["profile", "preferences", "alerts", 0], + ["profile", "preferences", "alerts", 1], + ["profile", "preferences", "alerts"], + ["profile", "preferences", "emptyObject"], + ["profile", "preferences", "emptyArray"], + ["profile", "preferences"], + ["profile"], + ["projects", 0, "id"], + ["projects", 0, "metrics", "score"], + ["projects", 0, "metrics", "tags", 0], + ["projects", 0, "metrics", "tags", 1], + ["projects", 0, "metrics", "tags"], + ["projects", 0, "metrics"], + ["projects", 0], + ["projects", 1, "id"], + ["projects", 1, "metrics", "score"], + ["projects", 1, "metrics", "tags"], + ["projects", 1, "metrics"], + ["projects", 1], + ["projects"], + ["a.b", "0"], + ["a.b"], + ["__proto__", "safe"], + ["__proto__"], + [] +] + +describe("completed-value events", () => { + test("emits child-before-parent deltas across APIs, policies, and split UTF-8 bytes", async () => { + const json = JSON.stringify(complexValue) + const chunks = Array.from(new TextEncoder().encode(json), byte => Uint8Array.of(byte)) + const policies: Array<{ + name: string + options?: SchemaStreamParseOptions + }> = [ + { name: "implicit chunk" }, + { name: "chunk", options: { snapshotPolicy: { mode: "chunk" } } }, + { name: "value", options: { snapshotPolicy: { mode: "value" } } }, + { name: "bytes", options: { snapshotPolicy: { bytes: 7, mode: "bytes" } } }, + { name: "final", options: { snapshotPolicy: { mode: "final" } } } + ] + const apis: StreamingApi[] = ["parse", "iterate"] + + for (const api of apis) { + for (const policy of policies) { + const { eventPaths, eventReferences, finalValue } = await collectCompletionEvents({ + api, + chunks, + parseOptions: policy.options, + schema: complexSchema + }) + + expect(eventPaths).toEqual(expectedComplexPaths) + expect(new Set(eventReferences).size).toBe(eventReferences.length) + expect(finalValue).toEqual(complexValue) + expect(Object.hasOwn(finalValue as object, "__proto__")).toBe(true) + } + } + }) + + test("keeps completion deltas independent from legacy character progress", async () => { + const valueEvents: CompletionPath[] = [] + const legacyEvents: CompletionPath[] = [] + const parser = new SchemaStream(z.object({ text: z.string() }), { + onKeyComplete({ activePath }) { + legacyEvents.push(activePath.filter(segment => segment !== undefined)) + }, + onValueComplete({ path }) { + valueEvents.push([...path]) + } + }) + + for await (const _snapshot of parser.iterate( + (async function* () { + yield '{"text":"a' + yield "b" + yield 'c"}' + })(), + { snapshotPolicy: { mode: "final" } } + )) { + // Consume the final snapshot so both callback streams reach document completion. + } + + expect(valueEvents).toEqual([["text"], []]) + expect(legacyEvents.filter(path => path[0] === "text")).toHaveLength(4) + expect(legacyEvents.at(-1)).toEqual([]) + }) + + test("emits fresh mutable-at-runtime paths without exposing parser state", async () => { + const observedPaths: CompletionPath[] = [] + const references: SchemaStreamValuePath[] = [] + const parser = new SchemaStream( + z.object({ outer: z.object({ first: z.number(), second: z.number() }) }), + { + onValueComplete({ path }) { + observedPaths.push([...path]) + references.push(path) + const consumerOwnedPath = path as CompletionPath + consumerOwnedPath[0] = "consumer mutation" + consumerOwnedPath.push("extra") + } + } + ) + let finalValue: unknown + + for await (const snapshot of parser.iterate( + (async function* () { + yield '{"outer":{"first":1,"second":2}}' + })(), + { snapshotPolicy: { mode: "final" } } + )) { + finalValue = snapshot + } + + expect(observedPaths).toEqual([["outer", "first"], ["outer", "second"], ["outer"], []]) + expect(new Set(references).size).toBe(references.length) + expect(finalValue).toEqual({ outer: { first: 1, second: 2 } }) + }) + + test("reports duplicate object-key occurrences without conflating array indexes", async () => { + const schema = z.object({ same: z.number(), list: z.array(z.string()) }) + const json = '{"same":1,"same":2,"list":["zero"]}' + const { eventPaths, finalValue } = await collectCompletionEvents({ + api: "iterate", + chunks: [new TextEncoder().encode(json)], + parseOptions: { snapshotPolicy: { mode: "final" } }, + schema + }) + + expect(eventPaths).toEqual([["same"], ["same"], ["list", 0], ["list"], []]) + expect(finalValue).toEqual({ same: 2, list: ["zero"] }) + }) + + test("provides completed values for conditional decisions before the root completes", async () => { + const decisions: string[] = [] + const rootValues: unknown[] = [] + const parser = new SchemaStream( + z.object({ route: z.enum(["fast", "careful"]), score: z.number() }), + { + onValueComplete({ path, value }) { + if (path.length === 1 && path[0] === "route" && value === "careful") { + decisions.push("enable-review") + } + if (path.length === 1 && path[0] === "score" && value === 98) { + decisions.push("publish-score") + } + if (path.length === 0) { + rootValues.push(value) + } + } + } + ) + + for await (const _snapshot of parser.iterate( + (async function* () { + yield '{"route":"careful",' + yield '"score":98}' + })() + )) { + // Drain snapshots so completion decisions run at their parser boundaries. + } + + expect(decisions).toEqual(["enable-review", "publish-score"]) + expect(rootValues).toEqual([{ route: "careful", score: 98 }]) + }) + + test("does not emit root completion for a truncated document", async () => { + const events: CompletionPath[] = [] + const parser = new SchemaStream( + z.object({ done: z.number(), open: z.object({ text: z.string() }) }), + { + onValueComplete({ path }) { + events.push([...path]) + } + } + ) + + await expect( + drain({ + parser, + source: (async function* () { + yield '{"done":1,"open":{"text":"partial' + })() + }) + ).rejects.toThrow("Tokenizer ended in the middle of a token") + expect(events).toEqual([["done"]]) + expect(events).not.toContainEqual([]) + }) + + test("does not emit root completion before trailing input is rejected", async () => { + for (const api of ["iterate", "parse"] as const) { + const events: CompletionPath[] = [] + const parser = new SchemaStream(z.object({ done: z.number() }), { + onValueComplete({ path }) { + events.push([...path]) + } + }) + const encoder = new TextEncoder() + const source = createByteSource([encoder.encode('{"done":1}'), encoder.encode(" trailing")]) + + if (api === "iterate") { + await expect( + (async () => { + for await (const _snapshot of parser.iterate(source)) { + // Consume until the parser rejects the trailing input. + } + })() + ).rejects.toThrow('Unexpected "t"') + } else { + await expect( + (async () => { + for await (const _snapshot of source.pipeThrough(parser.parse())) { + // Consume until the parser rejects the trailing input. + } + })() + ).rejects.toThrow('Unexpected "t"') + } + + expect(events).toEqual([["done"]]) + } + }) + + test("propagates callback failures and cancels the source", async () => { + const callbackError = new Error("completion callback failed") + let sourceClosed = false + const parser = new SchemaStream(z.object({ value: z.number() }), { + onValueComplete() { + throw callbackError + } + }) + const source = (async function* () { + try { + yield '{"value":1}' + } finally { + sourceClosed = true + } + })() + + await expect(drain({ parser, source })).rejects.toBe(callbackError) + expect(sourceClosed).toBe(true) + }) + + test("handles deeply nested completion paths without recursive event processing", async () => { + const depth = 512 + const json = `{"nested":${"[".repeat(depth)}0${"]".repeat(depth)}}` + const eventLengths: number[] = [] + const parser = new SchemaStream(z.object({ nested: z.unknown() }), { + onValueComplete({ path }) { + eventLengths.push(path.length) + } + }) + let finalValue: unknown + + for await (const snapshot of parser.iterate( + (async function* () { + yield json + })(), + { snapshotPolicy: { mode: "final" } } + )) { + finalValue = snapshot + } + + expect(eventLengths).toEqual( + Array.from({ length: depth + 2 }, (_value, index) => depth + 1 - index) + ) + let nestedValue = (finalValue as { nested: unknown }).nested + for (let level = 0; level < depth; level += 1) { + if (!Array.isArray(nestedValue)) { + throw new Error(`expected an array at nesting level ${level}`) + } + const [nextNestedValue] = nestedValue + nestedValue = nextNestedValue + } + expect(nestedValue).toBe(0) + }) +}) diff --git a/tests/iterate-materialization.test.ts b/tests/iterate-materialization.test.ts new file mode 100644 index 0000000..3e9753c --- /dev/null +++ b/tests/iterate-materialization.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "bun:test" +import * as z from "zod" + +import { + SchemaStream, + type SchemaStreamChunk, + type SchemaStreamOptions, + type SchemaStreamParseOptions, + type ZodObjectSchema +} from "@/index" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +async function collectIterate({ + chunks, + options, + parseOptions, + schema +}: { + chunks: string[] + options?: SchemaStreamOptions + parseOptions?: SchemaStreamParseOptions + schema: TSchema +}): Promise[]> { + const snapshots: SchemaStreamChunk[] = [] + const source = (async function* () { + for (const chunk of chunks) { + yield chunk + } + })() + + for await (const snapshot of new SchemaStream(schema, options).iterate(source, parseOptions)) { + snapshots.push(snapshot) + } + return snapshots +} + +async function collectParse({ + chunks, + options, + parseOptions, + schema +}: { + chunks: string[] + options?: SchemaStreamOptions + parseOptions?: SchemaStreamParseOptions + schema: TSchema +}): Promise[]> { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + } + controller.close() + } + }) + const snapshots: SchemaStreamChunk[] = [] + const output = source.pipeThrough(new SchemaStream(schema, options).parse(parseOptions)) + + for await (const snapshot of output) { + snapshots.push(JSON.parse(decoder.decode(snapshot)) as SchemaStreamChunk) + } + return snapshots +} + +function createExoticDefaults(): Record { + const getter = {} + Object.defineProperty(getter, "answer", { + enumerable: true, + get() { + return 42 + } + }) + const holes = new Array(3) + holes[1] = -0 + const callable = Object.assign(() => undefined, { + toJSON() { + return "callable" + } + }) + + return { + boxed: Reflect.construct(Number, [7]), + callable, + custom: { + toJSON() { + return { normalized: true } + } + }, + date: new Date("2024-01-02T03:04:05.000Z"), + getter, + holes, + map: new Map([["ignored", true]]), + typed: new Uint8Array([1, 2]) + } +} + +describe("iterate snapshot materialization", () => { + test("matches serialized snapshots across every policy", async () => { + const schema = z.object({ + items: z.array(z.object({ name: z.string() })), + pending: z.string(), + text: z.string(), + value: z.number() + }) + const chunks = [ + '{"text":"hel', + 'lo","value":-0,"items":[{"name":"a"}],"__proto__":{"safe":true}}' + ] + const policies: SchemaStreamParseOptions["snapshotPolicy"][] = [ + undefined, + { mode: "chunk" }, + { mode: "value" }, + { bytes: 8, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + const input = { + chunks, + options: { typeDefaults: { string: undefined } }, + parseOptions: { snapshotPolicy }, + schema + } + const parsed = await collectParse(input) + const iterated = await collectIterate(input) + + expect(iterated).toEqual(parsed) + expect(iterated.at(-1)?.value).toBe(0) + expect(Object.hasOwn(iterated.at(-1) ?? {}, "pending")).toBe(false) + expect(Object.hasOwn(iterated.at(-1) ?? {}, "__proto__")).toBe(true) + } + }) + + test("retains JSON normalization for external and exotic defaults", async () => { + const schema = z.object({ + boxed: z.unknown(), + callable: z.unknown(), + custom: z.unknown(), + date: z.unknown(), + getter: z.unknown(), + holes: z.unknown(), + map: z.unknown(), + streamed: z.string(), + typed: z.unknown() + }) + const chunks = ['{"streamed":"a', 'b"}'] + const parsed = await collectParse({ + chunks, + options: { defaultData: createExoticDefaults() }, + schema + }) + const iterated = await collectIterate({ + chunks, + options: { defaultData: createExoticDefaults() }, + schema + }) + + expect(iterated).toEqual(parsed) + expect(iterated.at(-1)).toEqual({ + boxed: 7, + callable: "callable", + custom: { normalized: true }, + date: "2024-01-02T03:04:05.000Z", + getter: { answer: 42 }, + holes: [null, 0, null], + map: {}, + streamed: "ab", + typed: { 0: 1, 1: 2 } + }) + }) + + test("honors inherited toJSON behavior", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, "toJSON") + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value() { + return { inherited: true } + } + }) + const schema = z.object({ value: z.string() }) + + try { + const input = { chunks: ['{"value":"parsed"}'], schema } + const parsed = await collectParse(input) + const iterated = await collectIterate(input) + + expect(iterated).toEqual(parsed) + expect(iterated as unknown).toEqual([{ inherited: true }]) + } finally { + if (originalDescriptor) { + Object.defineProperty(Object.prototype, "toJSON", originalDescriptor) + } else { + delete (Object.prototype as { toJSON?: unknown }).toJSON + } + } + }) + + test("preserves JSON errors for BigInt and circular defaults", async () => { + const schema = z.object({ streamed: z.string(), value: z.unknown() }) + const cycle: Record = {} + cycle.self = cycle + + for (const value of [1n, cycle]) { + const input = { + chunks: ['{"streamed":"ready"}'], + options: { defaultData: { value } }, + schema + } + + await expect(collectParse(input)).rejects.toBeInstanceOf(TypeError) + await expect(collectIterate(input)).rejects.toBeInstanceOf(TypeError) + } + }) + + test("honors custom BigInt JSON behavior", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(BigInt.prototype, "toJSON") + Object.defineProperty(BigInt.prototype, "toJSON", { + configurable: true, + value() { + return String(this) + } + }) + const schema = z.object({ streamed: z.string(), value: z.unknown() }) + + try { + const input = { + chunks: ['{"streamed":"ready"}'], + options: { defaultData: { value: 1n } }, + schema + } + const parsed = await collectParse(input) + const iterated = await collectIterate(input) + + expect(iterated).toEqual(parsed) + expect(iterated.at(-1)?.value).toBe("1") + } finally { + if (originalDescriptor) { + Object.defineProperty(BigInt.prototype, "toJSON", originalDescriptor) + } else { + delete (BigInt.prototype as { toJSON?: unknown }).toJSON + } + } + }) + + test("clones deeply nested parser-owned graphs with bounded recursion", async () => { + const depth = 2048 + const schema = z.object({ value: z.unknown() }) + const json = `{"value":${"[".repeat(depth)}0${"]".repeat(depth)}}` + const snapshots = await collectIterate({ + chunks: [json], + parseOptions: { snapshotPolicy: { mode: "final" } }, + schema + }) + let value = snapshots.at(-1)?.value + + for (let index = 0; index < depth; index += 1) { + expect(Array.isArray(value)).toBe(true) + const [nestedValue] = value as unknown[] + value = nestedValue + } + expect(value).toBe(0) + }) +}) diff --git a/tests/live-model.e2e.test.ts b/tests/live-model.e2e.test.ts new file mode 100644 index 0000000..10425b9 --- /dev/null +++ b/tests/live-model.e2e.test.ts @@ -0,0 +1,453 @@ +import { describe, test } from "bun:test" +import { isDeepStrictEqual } from "node:util" +import { Agent as MastraAgent } from "@mastra/core/agent" +import { Agent, Runner, setTraceProcessors, setTracingDisabled } from "@openai/agents" +import { Output, streamText } from "ai" +import * as z from "zod" + +import { + SchemaStream, + type SchemaStreamChunk, + type SchemaStreamSource, + type SchemaStreamValuePath +} from "@/index" + +const liveRequestTimeoutMs = 90_000 +const liveSuiteTimeoutMs = 300_000 +const liveMaxOutputTokens = 2048 +const unsafeErrorTokenPattern = /[^A-Za-z0-9_.-]/g + +setTraceProcessors([]) +setTracingDisabled(true) + +interface LiveCase { + id: string + prompt: string + schema: TSchema + validate: (value: z.output) => boolean +} + +interface LiveResult { + authoritative: unknown + completedPaths: SchemaStreamValuePath[] + snapshots: SchemaStreamChunk[] +} + +const summarySchema = z.object({ + headline: z.string(), + abstract: z.string(), + sentiment: z.enum(["positive", "neutral", "negative"]), + note: z.string().nullable(), + tags: z.array(z.string()) +}) +const summaryCase: LiveCase = { + id: "unicode-summary", + prompt: + "Create a concise structured summary about safe ocean research. Include the exact phrase 日本語, a quoted phrase, a line break in the abstract, at least three tags, and use null for note.", + schema: summarySchema, + validate: value => + value.abstract.includes("日本語") && value.note === null && value.tags.length >= 3 +} + +const inventorySchema = z.object({ + collection: z.string(), + groups: z.array( + z.object({ + name: z.string(), + items: z.array( + z.object({ + active: z.boolean(), + count: z.number(), + label: z.string() + }) + ) + }) + ), + attributes: z.array(z.object({ key: z.string(), value: z.string() })), + totals: z.object({ active: z.number(), items: z.number() }) +}) +const inventoryCase: LiveCase = { + id: "nested-inventory", + prompt: + "Build a structured inventory named summer. Include two groups with two items each, three key-value attributes, and numeric totals consistent with the item arrays.", + schema: inventorySchema, + validate: value => + value.groups.length === 2 && + value.groups.every(group => group.items.length === 2) && + value.attributes.length === 3 && + value.totals.items === 4 +} + +const scheduleSchema = z.object({ + timezone: z.string(), + owner: z.object({ email: z.string().nullable(), name: z.string() }), + windows: z.array( + z.object({ + end: z.string(), + label: z.string(), + start: z.string() + }) + ), + warnings: z.array(z.string()) +}) +const scheduleCase: LiveCase = { + id: "schedule-branches", + prompt: + "Return a UTC schedule owned by Casey with null email, exactly three non-overlapping ISO-8601 windows, and two short warning strings. Keep every field present.", + schema: scheduleSchema, + validate: value => + value.timezone === "UTC" && + value.owner.email === null && + value.windows.length === 3 && + value.warnings.length === 2 +} + +const liveEnabled = process.env.SCHEMA_STREAM_LIVE_E2E === "1" +const selectedProvider = process.env.SCHEMA_STREAM_LIVE_PROVIDER +const selectsAgents = selectedProvider === "agents" || selectedProvider === "all" +const selectsGateway = selectedProvider === "gateway" || selectedProvider === "all" +const selectsMastra = selectedProvider === "mastra" || selectedProvider === "all" + +function readRequiredEnvironmentVariable(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`Live E2E configuration is missing ${name}`) + } + return value +} + +/** Returns one configuration failure before any selected provider can make a live request. */ +function getLiveConfigurationError(): string | undefined { + if (!liveEnabled) { + return + } + if (!(selectsAgents || selectsGateway || selectsMastra)) { + return "SCHEMA_STREAM_LIVE_PROVIDER must be agents, gateway, mastra, or all when live E2E is enabled" + } + const requiredVariables: string[] = [] + if (selectsAgents) { + requiredVariables.push("OPENAI_API_KEY", "SCHEMA_STREAM_AGENTS_MODEL") + } + if (selectsGateway) { + requiredVariables.push("AI_GATEWAY_API_KEY", "SCHEMA_STREAM_GATEWAY_MODEL") + } + if (selectsMastra) { + requiredVariables.push("OPENAI_API_KEY", "SCHEMA_STREAM_MASTRA_MODEL") + } + const missingVariable = requiredVariables.find(name => !process.env[name]?.trim()) + return missingVariable ? `Live E2E configuration is missing ${missingVariable}` : undefined +} + +const liveConfigurationError = getLiveConfigurationError() +const liveConfigurationValid = liveEnabled && liveConfigurationError === undefined + +async function collect( + source: AsyncIterable> +): Promise[]> { + const snapshots: SchemaStreamChunk[] = [] + for await (const snapshot of source) { + snapshots.push(snapshot) + } + return snapshots +} + +/** Records real completion ordering without assuming any provider-specific text chunk cadence. */ +function createObservedParser( + schema: TSchema +): { + completedPaths: SchemaStreamValuePath[] + parser: SchemaStream +} { + const completedPaths: SchemaStreamValuePath[] = [] + const parser = new SchemaStream(schema, { + onValueComplete({ path }) { + completedPaths.push(path) + } + }) + return { completedPaths, parser } +} + +/** Fails the pinned Mastra compatibility lane if textStream stops carrying raw JSON text. */ +async function* requireJsonObjectText(source: SchemaStreamSource): AsyncIterable { + let sawJsonStart = false + for await (const chunk of source) { + if (!sawJsonStart) { + const candidate = chunk.trimStart() + if (candidate.length > 0) { + if (!candidate.startsWith("{")) { + throw new Error("Mastra textStream did not begin with a JSON object") + } + sawJsonStart = true + } + } + yield chunk + } + if (!sawJsonStart) { + throw new Error("Mastra textStream did not contain a JSON object") + } +} + +async function withAbortTimeout({ + task +}: { + task: (signal: AbortSignal) => Promise +}): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), liveRequestTimeoutMs) + + try { + return await task(controller.signal) + } finally { + clearTimeout(timer) + } +} + +function sanitizeError(error: unknown): string { + const rawName = error instanceof Error ? error.name : "UnknownError" + const name = rawName.replace(unsafeErrorTokenPattern, "").slice(0, 48) || "Error" + if (typeof error !== "object" || error === null) { + return name + } + + const record = error as Record + const { status: responseStatus, statusCode } = record + let status: number | undefined + if (typeof responseStatus === "number") { + status = responseStatus + } else if (typeof statusCode === "number") { + status = statusCode + } + return status === undefined ? name : `${name}:status-${status}` +} + +function failLive({ + caseId, + detail, + provider +}: { + caseId: string + detail: string + provider: "agents" | "gateway" | "mastra" +}): never { + throw new Error(`[live:${provider}:${caseId}] ${detail}`) +} + +function verifyLiveResult({ + authoritative, + completedPaths, + fixture, + provider, + snapshots +}: { + authoritative: unknown + completedPaths: SchemaStreamValuePath[] + fixture: LiveCase + provider: "agents" | "gateway" | "mastra" + snapshots: SchemaStreamChunk[] +}): void { + const parsed = fixture.schema.safeParse(authoritative) + if (!parsed.success) { + failLive({ + caseId: fixture.id, + detail: "authoritative output failed schema validation", + provider + }) + } + if (!fixture.validate(parsed.data)) { + failLive({ + caseId: fixture.id, + detail: "authoritative output failed semantic checks", + provider + }) + } + if (snapshots.length === 0) { + failLive({ caseId: fixture.id, detail: "stream produced no snapshots", provider }) + } + if (!isDeepStrictEqual(snapshots.at(-1), parsed.data)) { + failLive({ + caseId: fixture.id, + detail: "final snapshot differed from authoritative output", + provider + }) + } + const rootCompletionIndex = completedPaths.findIndex(path => path.length === 0) + const hasNestedCompletion = completedPaths.some(path => path.length > 0) + if (!(hasNestedCompletion && rootCompletionIndex === completedPaths.length - 1)) { + failLive({ caseId: fixture.id, detail: "completion event ordering was invalid", provider }) + } +} + +async function verifyAgentsLive({ + fixture, + model +}: { + fixture: LiveCase + model: string +}): Promise { + let liveResult: LiveResult + try { + liveResult = await withAbortTimeout({ + task: async signal => { + const agent = new Agent({ + instructions: "Return only structured output matching the supplied schema.", + model, + modelSettings: { maxTokens: liveMaxOutputTokens, store: false }, + name: `SchemaStream live ${fixture.id}`, + outputType: fixture.schema + }) + const result = await new Runner({ + traceIncludeSensitiveData: false, + tracingDisabled: true + }).run(agent, fixture.prompt, { signal, stream: true }) + const { completedPaths, parser } = createObservedParser(fixture.schema) + const streamed = await collect(parser.iterate(result.toTextStream())) + await result.completed + return { authoritative: result.finalOutput, completedPaths, snapshots: streamed } + } + }) + } catch (error) { + failLive({ + caseId: fixture.id, + detail: `request failed (${sanitizeError(error)})`, + provider: "agents" + }) + } + verifyLiveResult({ + authoritative: liveResult.authoritative, + completedPaths: liveResult.completedPaths, + fixture, + provider: "agents", + snapshots: liveResult.snapshots + }) +} + +async function verifyGatewayLive({ + fixture, + model +}: { + fixture: LiveCase + model: string +}): Promise { + let liveResult: LiveResult + try { + liveResult = await withAbortTimeout({ + task: async signal => { + const result = streamText({ + abortSignal: signal, + maxOutputTokens: liveMaxOutputTokens, + maxRetries: 0, + model, + output: Output.object({ schema: fixture.schema }), + prompt: fixture.prompt, + timeout: { chunkMs: 30_000, totalMs: liveRequestTimeoutMs } + }) + const { completedPaths, parser } = createObservedParser(fixture.schema) + const streamed = await collect(parser.iterate(result.textStream)) + return { authoritative: await result.output, completedPaths, snapshots: streamed } + } + }) + } catch (error) { + failLive({ + caseId: fixture.id, + detail: `request failed (${sanitizeError(error)})`, + provider: "gateway" + }) + } + verifyLiveResult({ + authoritative: liveResult.authoritative, + completedPaths: liveResult.completedPaths, + fixture, + provider: "gateway", + snapshots: liveResult.snapshots + }) +} + +async function verifyMastraLive({ + fixture, + model +}: { + fixture: LiveCase + model: string +}): Promise { + let liveResult: LiveResult + try { + liveResult = await withAbortTimeout({ + task: async signal => { + const agent = new MastraAgent({ + id: `schema-stream-live-${fixture.id}`, + name: `SchemaStream live ${fixture.id}`, + instructions: "Return only structured output matching the supplied schema.", + model + }) + const result = await agent.stream(fixture.prompt, { + abortSignal: signal, + modelSettings: { maxOutputTokens: liveMaxOutputTokens, maxRetries: 0 }, + structuredOutput: { errorStrategy: "strict", schema: fixture.schema } + }) + const { completedPaths, parser } = createObservedParser(fixture.schema) + const streamed = await collect( + parser.iterate(requireJsonObjectText(result.textStream)) + ) + return { authoritative: await result.object, completedPaths, snapshots: streamed } + } + }) + } catch (error) { + failLive({ + caseId: fixture.id, + detail: `request failed (${sanitizeError(error)})`, + provider: "mastra" + }) + } + verifyLiveResult({ + authoritative: liveResult.authoritative, + completedPaths: liveResult.completedPaths, + fixture, + provider: "mastra", + snapshots: liveResult.snapshots + }) +} + +describe("opt-in live model integration", () => { + test.skipIf(!liveEnabled)( + "uses a recognized provider selection", + () => { + if (liveConfigurationError) { + throw new Error(liveConfigurationError) + } + }, + liveSuiteTimeoutMs + ) + + test.skipIf(!(liveConfigurationValid && selectsAgents))( + "streams varied schemas through OpenAI Agents", + async () => { + const model = readRequiredEnvironmentVariable("SCHEMA_STREAM_AGENTS_MODEL") + await verifyAgentsLive({ fixture: summaryCase, model }) + await verifyAgentsLive({ fixture: inventoryCase, model }) + await verifyAgentsLive({ fixture: scheduleCase, model }) + }, + liveSuiteTimeoutMs + ) + + test.skipIf(!(liveConfigurationValid && selectsGateway))( + "streams varied schemas through Vercel AI Gateway", + async () => { + const model = readRequiredEnvironmentVariable("SCHEMA_STREAM_GATEWAY_MODEL") + await verifyGatewayLive({ fixture: summaryCase, model }) + await verifyGatewayLive({ fixture: inventoryCase, model }) + await verifyGatewayLive({ fixture: scheduleCase, model }) + }, + liveSuiteTimeoutMs + ) + + test.skipIf(!(liveConfigurationValid && selectsMastra))( + "checks pinned Mastra JSON text compatibility across varied schemas", + async () => { + const model = readRequiredEnvironmentVariable("SCHEMA_STREAM_MASTRA_MODEL") + await verifyMastraLive({ fixture: summaryCase, model }) + await verifyMastraLive({ fixture: inventoryCase, model }) + await verifyMastraLive({ fixture: scheduleCase, model }) + }, + liveSuiteTimeoutMs + ) +}) diff --git a/tests/packed-consumer/consumer.cjs b/tests/packed-consumer/consumer.cjs index f0a96ac..b0e419b 100644 --- a/tests/packed-consumer/consumer.cjs +++ b/tests/packed-consumer/consumer.cjs @@ -1,8 +1,64 @@ "use strict" const schemaStream = require("schema-stream") +const { z } = require("zod") if (typeof schemaStream.SchemaStream !== "function") { throw new Error("schema-stream packed CommonJS export mismatch") } -console.log("packed CommonJS export passed") +async function main() { + const schema = z.object({ title: z.string(), nested: z.object({ count: z.number() }) }) + const events = [] + let completedCount + const parser = new schemaStream.SchemaStream(schema, { + onValueComplete({ path, value }) { + events.push(path) + if (path.join(".") === "nested.count") { + completedCount = value + } + } + }) + const snapshots = [] + + for await (const snapshot of parser.iterate( + (async function* () { + yield '{"title":"common' + yield 'js","nested":{"count":4}}' + })(), + { snapshotPolicy: { mode: "final" } } + )) { + snapshots.push(snapshot) + } + + const finalSnapshot = snapshots.at(-1) + if (finalSnapshot?.title !== "commonjs" || finalSnapshot?.nested?.count !== 4) { + throw new Error("schema-stream packed CommonJS runtime mismatch") + } + if (!events.some(path => path.join(".") === "nested.count") || completedCount !== 4) { + throw new Error("schema-stream packed CommonJS completion event mismatch") + } + + const byteSource = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"title":"bytes","nested":{"count":5}}')) + controller.close() + } + }) + const byteReader = byteSource + .pipeThrough(new schemaStream.SchemaStream(schema).parse({ snapshotPolicy: { mode: "final" } })) + .getReader() + const { value: byteSnapshot } = await byteReader.read() + if ( + byteSnapshot?.byteOffset !== 0 || + byteSnapshot.buffer.byteLength !== byteSnapshot.byteLength + ) { + throw new Error("schema-stream packed byte snapshot exposed a pooled backing store") + } + + console.log("packed CommonJS runtime passed") +} + +main().catch(error => { + console.error(error) + process.exitCode = 1 +}) diff --git a/tests/packed-consumer/consumer.ts b/tests/packed-consumer/consumer.ts index 2255011..7ac4f5d 100644 --- a/tests/packed-consumer/consumer.ts +++ b/tests/packed-consumer/consumer.ts @@ -1,3 +1,4 @@ +import { Agent as MastraAgent } from "@mastra/core/agent" import { Agent, run } from "@openai/agents" import { Output, streamText } from "ai" import { SchemaStream, type SchemaStreamChunk, type SnapshotPolicy } from "schema-stream" @@ -30,13 +31,29 @@ if (stub.title !== null || stub.nested?.count !== null || stub.items?.length !== } const emissions: SchemaStreamChunk[] = [] -for await (const partial of new SchemaStream(schema).iterate(chunkedJson())) { +const completedPaths: (readonly (string | number)[])[] = [] +let completedLabel: unknown +const parser = new SchemaStream(schema, { + onValueComplete({ path, value }) { + completedPaths.push(path) + if (path.join(".") === "items.0.label") { + completedLabel = value + } + } +}) +for await (const partial of parser.iterate(chunkedJson())) { emissions.push(partial) } if (!emissions.some(partial => partial.title === "hel") || emissions.at(-1)?.nested?.count !== 2) { throw new Error("schema-stream packed iterate mismatch") } +if ( + !completedPaths.some(path => path.join(".") === "items.0.label") || + completedLabel !== "first" +) { + throw new Error("schema-stream packed completion event mismatch") +} const finalPolicy = { mode: "final" } satisfies SnapshotPolicy const finalEmissions: SchemaStreamChunk[] = [] @@ -57,12 +74,24 @@ const miniStub = new SchemaStream(miniSchema).getSchemaStub(miniSchema) if (miniStub.title !== null || miniStub.nested?.count !== null) { throw new Error("schema-stream packed Zod Mini stub mismatch") } +const miniEmissions: SchemaStreamChunk[] = [] +for await (const partial of new SchemaStream(miniSchema).iterate( + (async function* () { + yield '{"title":"mini","nested":{"count":3}}' + })(), + { snapshotPolicy: { mode: "final" } } +)) { + miniEmissions.push(partial) +} +if (miniEmissions.at(-1)?.title !== "mini" || miniEmissions.at(-1)?.nested?.count !== 3) { + throw new Error("schema-stream packed Zod Mini iterate mismatch") +} /** Compile-only fixture; it is never called and cannot contact a model. */ async function openAiAgentsCompatibility(): Promise { const agent = new Agent({ name: "Packed SchemaStream fixture", - model: "gpt-5.5", + model: "gpt-5.6-luna", instructions: "Return structured data.", outputType: schema }) @@ -81,7 +110,7 @@ async function openAiAgentsCompatibility(): Promise { /** Compile-only fixture; it is never called and cannot contact a model. */ async function vercelAiSdkCompatibility(): Promise { const result = streamText({ - model: "openai/gpt-5.5", + model: "openai/gpt-5.6-luna", output: Output.object({ schema }), prompt: "Extract data." }) @@ -95,7 +124,29 @@ async function vercelAiSdkCompatibility(): Promise { void finalOutput } +/** Compile-only fixture; it is never called and cannot contact a model. */ +async function mastraCompatibility(): Promise { + const agent = new MastraAgent({ + id: "packed-schema-stream-fixture", + name: "Packed SchemaStream fixture", + model: "openai/gpt-5.6-luna", + instructions: "Return structured data." + }) + const result = await agent.stream("Extract data.", { + structuredOutput: { schema } + }) + + for await (const partial of new SchemaStream(schema).iterate(result.textStream)) { + const typedPartial: SchemaStreamChunk = partial + void typedPartial.nested?.count + } + + const finalOutput: z.output = await result.object + void finalOutput +} + void openAiAgentsCompatibility void vercelAiSdkCompatibility +void mastraCompatibility -console.log("packed ESM, Zod 4/Mini, and SDK compatibility passed") +console.log("packed ESM, Zod 4/Mini runtime, completion events, and SDK compatibility passed") diff --git a/tests/packed-consumer/verify.sh b/tests/packed-consumer/verify.sh index 3eef71a..5d6ba14 100755 --- a/tests/packed-consumer/verify.sh +++ b/tests/packed-consumer/verify.sh @@ -20,6 +20,22 @@ if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/snapshot-policies.md"* ]]; then echo "schema-stream tarball is missing snapshot policy documentation" >&2 exit 1 fi +if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/completion-events.md"* ]]; then + echo "schema-stream tarball is missing completion event documentation" >&2 + exit 1 +fi +if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/integration-testing.md"* ]]; then + echo "schema-stream tarball is missing integration testing documentation" >&2 + exit 1 +fi +if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/transports.md"* ]]; then + echo "schema-stream tarball is missing transport documentation" >&2 + exit 1 +fi +if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/benchmarks/2026-07-11-apple-m5-max.json"* ]]; then + echo "schema-stream tarball is missing README benchmark evidence" >&2 + exit 1 +fi cp "$FIXTURE/consumer.ts" "$FIXTURE/consumer.cjs" "$FIXTURE/tsconfig.json" "$TEMP_DIR/zod4-consumer/" cd "$TEMP_DIR/zod4-consumer" @@ -27,11 +43,13 @@ npm init -y >/dev/null npm pkg set type=module >/dev/null npm install --ignore-scripts --no-audit --no-fund \ "$TARBALL" \ - @openai/agents@0.13.1 ai@7.0.19 openai@6.46.0 \ + @mastra/core@1.50.1 @openai/agents@0.13.1 ai@7.0.19 openai@6.46.0 \ zod@4.4.3 typescript@5.9.3 @types/node@24 >/dev/null ./node_modules/.bin/tsc -p tsconfig.json node dist/consumer.js node consumer.cjs +bun dist/consumer.js +bun consumer.cjs cp "$FIXTURE/consumer-zod3.ts" "$FIXTURE/tsconfig-zod3.json" "$TEMP_DIR/zod3-consumer/" cd "$TEMP_DIR/zod3-consumer" @@ -41,5 +59,6 @@ npm install --ignore-scripts --no-audit --no-fund \ "$TARBALL" zod@3.25.76 typescript@5.9.3 @types/node@24 >/dev/null ./node_modules/.bin/tsc -p tsconfig-zod3.json node dist/consumer-zod3.js +bun dist/consumer-zod3.js -echo "packed schema-stream consumer matrix passed" +echo "packed schema-stream Node and Bun consumer matrix passed" diff --git a/tests/parser-regressions.test.ts b/tests/parser-regressions.test.ts index e5326c2..b2ea48b 100644 --- a/tests/parser-regressions.test.ts +++ b/tests/parser-regressions.test.ts @@ -141,7 +141,7 @@ describe("stream parser regressions", () => { const schema = z.object({ safe: z.string() }) const parser = new SchemaStream(schema) const source = (async function* () { - yield '{"__proto__":{"polluted":"yes"},"safe":"ok"}' + yield '{"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted":"yes"}},"safe":"ok"}' })() try { @@ -153,8 +153,56 @@ describe("stream parser regressions", () => { expect(({} as { polluted?: unknown }).polluted).toBeUndefined() expect(emissions.at(-1)?.safe).toBe("ok") expect(Object.hasOwn(emissions.at(-1) ?? {}, "__proto__")).toBe(true) + const finalValue = emissions.at(-1) as unknown as Record | undefined + const constructorValue = Object.entries(finalValue ?? {}).find( + ([key]) => key === "constructor" + )?.[1] + expect(constructorValue).toEqual({ prototype: { polluted: "yes" } }) + expect(Object.hasOwn(emissions.at(-1) ?? {}, "constructor")).toBe(true) } finally { delete (Object.prototype as { polluted?: unknown }).polluted } }) + + test("does not invoke inherited setters for parsed keys", async () => { + const inheritedKey = "schemaStreamInheritedSetterProbe" + let setterCalls = 0 + Object.defineProperty(Object.prototype, inheritedKey, { + configurable: true, + set() { + setterCalls += 1 + } + }) + const schema = z.object({ [inheritedKey]: z.string() }) + + try { + const emissions = [] + for await (const value of new SchemaStream(schema).iterate( + (async function* () { + yield `{"${inheritedKey}":"safe"}` + })() + )) { + emissions.push(value) + } + + const finalValue = emissions.at(-1) as Record | undefined + expect(setterCalls).toBe(0) + expect(finalValue?.[inheritedKey]).toBe("safe") + expect(Object.hasOwn(finalValue ?? {}, inheritedKey)).toBe(true) + } finally { + delete (Object.prototype as Record)[inheritedKey] + } + }) + + test("preserves per-character completion callbacks when one is registered", async () => { + const schema = z.object({ text: z.string() }) + const { completions, emissions } = await collectEmissions({ + chunks: ['{"text":"abc"}'], + schema + }) + + expect(emissions).toEqual([{ text: "abc" }]) + expect(completions.filter(({ activePath }) => activePath[0] === "text")).toHaveLength(4) + expect(completions.at(-1)?.activePath).toEqual([]) + }) }) diff --git a/tests/sdk-compatibility.ts b/tests/sdk-compatibility.ts index 49a5d85..7db3f58 100644 --- a/tests/sdk-compatibility.ts +++ b/tests/sdk-compatibility.ts @@ -1,3 +1,4 @@ +import { Agent as MastraAgent } from "@mastra/core/agent" import { Agent, run } from "@openai/agents" import { Output, streamText } from "ai" import * as z from "zod" @@ -17,8 +18,9 @@ const schema = z.object({ export async function openAiAgentsTypeCompatibility(): Promise { const agent = new Agent({ name: "SchemaStream type fixture", - model: "gpt-5.5", - instructions: "Return the requested structured data as JSON." + model: "gpt-5.6-luna", + instructions: "Return the requested structured data as JSON.", + outputType: schema }) const result = await run(agent, "Summarize this input.", { stream: true }) const parser = new SchemaStream(schema) @@ -29,14 +31,14 @@ export async function openAiAgentsTypeCompatibility(): Promise { } await result.completed - const finalOutput: string | undefined = result.finalOutput + const finalOutput: z.output | undefined = result.finalOutput void finalOutput } /** Compile-only fixture. It is never called and does not contact a model. */ export async function aiSdkTypeCompatibility(): Promise { const result = streamText({ - model: "openai/gpt-5.5", + model: "openai/gpt-5.6-luna", output: Output.object({ schema }), prompt: "Summarize this input." }) @@ -50,3 +52,25 @@ export async function aiSdkTypeCompatibility(): Promise { const finalOutput: z.output = await result.output void finalOutput } + +/** Compile-only fixture. It is never called and does not contact a model. */ +export async function mastraTypeCompatibility(): Promise { + const agent = new MastraAgent({ + id: "schema-stream-type-fixture", + name: "SchemaStream type fixture", + model: "openai/gpt-5.6-luna", + instructions: "Return the requested structured data as JSON." + }) + const result = await agent.stream("Summarize this input.", { + structuredOutput: { schema } + }) + const parser = new SchemaStream(schema) + + for await (const partial of parser.iterate(result.textStream)) { + const typedPartial: SchemaStreamChunk = partial + void typedPartial.details?.score + } + + const finalOutput: z.output = await result.object + void finalOutput +} diff --git a/tests/sdk-runtime.test.ts b/tests/sdk-runtime.test.ts new file mode 100644 index 0000000..a039e1d --- /dev/null +++ b/tests/sdk-runtime.test.ts @@ -0,0 +1,363 @@ +import { describe, test } from "bun:test" +import { isDeepStrictEqual } from "node:util" +import { Agent as MastraAgent } from "@mastra/core/agent" +import { + Agent, + type Model, + type ModelRequest, + type ModelResponse, + Runner, + type StreamEvent, + setTraceProcessors, + setTracingDisabled +} from "@openai/agents" +import { Output, simulateReadableStream, streamText } from "ai" +import { MockLanguageModelV4 } from "ai/test" +import * as z from "zod" + +import { SchemaStream, type SchemaStreamChunk } from "@/index" + +setTraceProcessors([]) +setTracingDisabled(true) + +interface RuntimeCase { + hasMeaningfulProgress: (snapshots: SchemaStreamChunk[]) => boolean + id: string + prompt: string + schema: TSchema + value: SchemaStreamChunk +} + +const reportSchema = z.object({ + title: z.string(), + summary: z.string(), + locale: z.enum(["en", "ja"]), + confidence: z.number(), + source: z.string().optional(), + note: z.string().nullable(), + sections: z.array( + z.object({ + heading: z.string(), + facts: z.array(z.object({ label: z.string(), value: z.string() })) + }) + ), + metrics: z.record(z.string(), z.number()) +}) +const reportValue: z.output = { + title: "海辺 report", + summary: 'Line one\n"quoted" \\ slash 🌊 with 日本語 and a long progressive ending.', + locale: "ja", + confidence: 0.97, + source: "deterministic fixture", + note: null, + sections: [ + { + heading: "Signals", + facts: [ + { label: "temperature", value: "18°C" }, + { label: "condition", value: "clear" } + ] + }, + { + heading: "Escapes", + facts: [{ label: "literal", value: 'tab\tnewline\nquote"' }] + } + ], + metrics: { accuracy: 99.5, citations: 3 } +} +const reportCase: RuntimeCase = { + id: "unicode-report", + prompt: "Extract the multilingual report with every requested field.", + schema: reportSchema, + value: reportValue, + hasMeaningfulProgress: snapshots => + snapshots.some( + snapshot => + typeof snapshot.summary === "string" && + snapshot.summary.startsWith("Line one") && + snapshot.summary !== reportValue.summary + ) +} + +const workflowSchema = z.object({ + runId: z.string(), + owner: z.object({ name: z.string(), alias: z.string().nullable() }), + flags: z.record(z.string(), z.boolean()), + batches: z.array( + z.object({ + name: z.string(), + note: z.string().optional(), + entries: z.array( + z.object({ + label: z.string(), + score: z.number(), + tags: z.array(z.string()) + }) + ) + }) + ) +}) +const workflowValue: z.output = { + runId: "run-0042", + owner: { name: "Ada Lovelace", alias: null }, + flags: { dryRun: false, verified: true }, + batches: [ + { + name: "primary", + note: "Keep braces {like this} inside text.", + entries: [ + { label: "first progressive record", score: 11, tags: ["alpha", "βeta"] }, + { label: "second record", score: 23, tags: ["ready"] } + ] + }, + { + name: "fallback", + note: "Use only after primary.", + entries: [{ label: "third record", score: 5, tags: [] }] + } + ] +} +const workflowCase: RuntimeCase = { + id: "nested-workflow", + prompt: "Build a nested workflow manifest from the supplied operational facts.", + schema: workflowSchema, + value: workflowValue, + hasMeaningfulProgress: snapshots => + snapshots.some(snapshot => { + const label = snapshot.batches?.[0]?.entries?.[0]?.label + return ( + typeof label === "string" && + label.startsWith("first") && + label !== "first progressive record" + ) + }) +} + +function splitJson(json: string, targetChunks = 12): string[] { + const codePoints = Array.from(json) + const chunkLength = Math.max(1, Math.ceil(codePoints.length / targetChunks)) + const chunks: string[] = [] + let offset = 0 + + while (offset < codePoints.length) { + chunks.push(codePoints.slice(offset, offset + chunkLength).join("")) + offset += chunkLength + } + + return chunks +} + +async function collect( + stream: AsyncIterable> +): Promise[]> { + const snapshots: SchemaStreamChunk[] = [] + for await (const snapshot of stream) { + snapshots.push(snapshot) + } + return snapshots +} + +function failRuntime({ detail, id }: { detail: string; id: string }): never { + throw new Error(`[sdk-runtime:${id}] ${detail}`) +} + +function verifyProgress({ + fixture, + snapshots, + authoritative +}: { + fixture: RuntimeCase + snapshots: SchemaStreamChunk[] + authoritative: unknown +}): void { + if (snapshots.length <= 2) { + failRuntime({ detail: "expected more than two snapshots", id: fixture.id }) + } + if (!fixture.hasMeaningfulProgress(snapshots)) { + failRuntime({ detail: "expected a meaningful intermediate value", id: fixture.id }) + } + if (snapshots[0] === snapshots.at(-1)) { + failRuntime({ detail: "snapshots reused the same object reference", id: fixture.id }) + } + if (!isDeepStrictEqual(snapshots.at(-1), fixture.value)) { + failRuntime({ detail: "final snapshot differed from the fixture", id: fixture.id }) + } + if (!isDeepStrictEqual(authoritative, fixture.value)) { + failRuntime({ detail: "authoritative output differed from the fixture", id: fixture.id }) + } +} + +function createAiSdkModel(chunks: string[]): MockLanguageModelV4 { + return new MockLanguageModelV4({ + doStream: () => + Promise.resolve({ + stream: simulateReadableStream({ + chunkDelayInMs: null, + initialDelayInMs: null, + chunks: [ + { id: "text-1", type: "text-start" as const }, + ...chunks.map(delta => ({ delta, id: "text-1", type: "text-delta" as const })), + { id: "text-1", type: "text-end" as const }, + { + finishReason: { raw: undefined, unified: "stop" as const }, + logprobs: undefined, + type: "finish" as const, + usage: { + inputTokens: { + cacheRead: undefined, + cacheWrite: undefined, + noCache: 1, + total: 1 + }, + outputTokens: { reasoning: undefined, text: 1, total: 1 } + } + } + ] + }) + }) + }) +} + +async function verifyAiSdk( + fixture: RuntimeCase +): Promise { + const json = JSON.stringify(fixture.value) + const model = createAiSdkModel(splitJson(json)) + const result = streamText({ + maxRetries: 0, + model, + output: Output.object({ schema: fixture.schema }), + prompt: fixture.prompt + }) + const snapshots = await collect(new SchemaStream(fixture.schema).iterate(result.textStream)) + const authoritative = await result.output + + if (model.doStreamCalls.length !== 1) { + failRuntime({ detail: "AI SDK made an unexpected number of model calls", id: fixture.id }) + } + if (!JSON.stringify(model.doStreamCalls[0]?.prompt).includes(fixture.prompt)) { + failRuntime({ detail: "AI SDK did not forward the fixture prompt", id: fixture.id }) + } + verifyProgress({ authoritative, fixture, snapshots }) +} + +async function verifyMastra( + fixture: RuntimeCase +): Promise { + const json = JSON.stringify(fixture.value) + const model = createAiSdkModel(splitJson(json)) + const agent = new MastraAgent({ + id: `schema-stream-${fixture.id}`, + name: `SchemaStream ${fixture.id} fixture`, + instructions: "Return only the requested structured output.", + model + }) + const result = await agent.stream(fixture.prompt, { + structuredOutput: { errorStrategy: "strict", schema: fixture.schema } + }) + const snapshots = await collect(new SchemaStream(fixture.schema).iterate(result.textStream)) + const authoritative = await result.object + + if (model.doStreamCalls.length !== 1) { + failRuntime({ detail: "Mastra made an unexpected number of model calls", id: fixture.id }) + } + if (!JSON.stringify(model.doStreamCalls[0]?.prompt).includes(fixture.prompt)) { + failRuntime({ detail: "Mastra did not forward the fixture prompt", id: fixture.id }) + } + if (model.doStreamCalls[0]?.responseFormat?.type !== "json") { + failRuntime({ detail: "Mastra did not forward the structured-output schema", id: fixture.id }) + } + verifyProgress({ authoritative, fixture, snapshots }) +} + +function createAgentsModel({ + chunks, + json, + requests +}: { + chunks: string[] + json: string + requests: ModelRequest[] +}): Model { + return { + getResponse(): Promise { + return Promise.reject(new Error("Deterministic fixture unexpectedly used a non-stream call")) + }, + async *getStreamedResponse(request: ModelRequest): AsyncIterable { + requests.push(request) + yield { type: "response_started" } + for (const delta of chunks) { + yield { delta, type: "output_text_delta" } + } + yield { + response: { + id: "fixture-response", + output: [ + { + content: [{ text: json, type: "output_text" }], + id: "fixture-message", + role: "assistant", + status: "completed", + type: "message" + } + ], + usage: { + inputTokens: 1, + inputTokensDetails: {}, + outputTokens: 1, + outputTokensDetails: {}, + requests: 1, + totalTokens: 2 + } + }, + type: "response_done" + } + } + } +} + +async function verifyAgentsSdk( + fixture: RuntimeCase +): Promise { + const json = JSON.stringify(fixture.value) + const requests: ModelRequest[] = [] + const agent = new Agent({ + instructions: "Return only the requested structured output.", + model: createAgentsModel({ chunks: splitJson(json), json, requests }), + name: `SchemaStream ${fixture.id} fixture`, + outputType: fixture.schema + }) + const runner = new Runner({ + traceIncludeSensitiveData: false, + tracingDisabled: true + }) + const result = await runner.run(agent, fixture.prompt, { stream: true }) + const snapshots = await collect(new SchemaStream(fixture.schema).iterate(result.toTextStream())) + await result.completed + + if (requests.length !== 1) { + failRuntime({ detail: "Agents SDK made an unexpected number of model calls", id: fixture.id }) + } + const expectedInput = [{ content: fixture.prompt, role: "user", type: "message" }] + if (!isDeepStrictEqual(requests[0]?.input, expectedInput)) { + failRuntime({ detail: "Agents SDK did not forward the normalized prompt", id: fixture.id }) + } + verifyProgress({ authoritative: result.finalOutput, fixture, snapshots }) +} + +describe("SDK runtime integration", () => { + test("consumes deterministic Vercel AI SDK structured-output streams", async () => { + await verifyAiSdk(reportCase) + await verifyAiSdk(workflowCase) + }) + + test("consumes deterministic OpenAI Agents SDK structured-output streams", async () => { + await verifyAgentsSdk(reportCase) + await verifyAgentsSdk(workflowCase) + }) + + test("consumes deterministic Mastra Agent structured-output streams", async () => { + await verifyMastra(reportCase) + await verifyMastra(workflowCase) + }) +}) diff --git a/tests/snapshot-policy.benchmark.mts b/tests/snapshot-policy.benchmark.mts new file mode 100644 index 0000000..8884ecd --- /dev/null +++ b/tests/snapshot-policy.benchmark.mts @@ -0,0 +1,1556 @@ +import { Buffer } from "node:buffer" +import { spawn } from "node:child_process" +import { resolve as resolvePath } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { isDeepStrictEqual } from "node:util" +import { array, boolean, number, object, string } from "zod" +import type { SnapshotPolicy, ZodObjectSchema } from "@/index" + +type RuntimeName = "bun" | "node" +type FixtureName = "long-string" | "object-heavy" +type PolicyName = "chunk" | "value" | "bytes-256kb" | "bytes-1mb" | "final" +type CompletionCallbackName = "none" | "onValueComplete" | "onKeyComplete" +type IterateMaterialization = "direct-json-domain" | "json-roundtrip" +type Materialization = IterateMaterialization | "serialized-utf8" +type MicroPhase = + | "JSON.stringify" + | "Buffer.from (UTF-8)" + | "TextEncoder.encode" + | "TextDecoder + JSON.parse" +type StreamPhase = "parse" | "roundtrip" | "iterate" + +interface BenchmarkOptions { + chunkSizeBytes: number + completionScaling: boolean + fixtureNames: FixtureName[] + iterateMaterialization: IterateMaterialization + json: boolean + modulePath: string + payloadSizeMb: number + policyNames: PolicyName[] + repeats: number + runtimeNames: RuntimeName[] + verbose: boolean + warmups: number + worker: boolean + workerRuntime?: RuntimeName +} + +interface BenchmarkFixture { + assertFinal: (value: unknown) => void + chunks: Uint8Array[] + encodedJson: Uint8Array + expected: Record + json: string + name: FixtureName + schema: ZodObjectSchema +} + +interface BenchmarkStats { + maximumMs: number + medianMs: number + minimumMs: number + samplesMs: number[] +} + +interface MeasurementSpec { + assertValue: (value: TValue) => void + operation: () => Promise | TValue + signature: (value: TValue) => string +} + +interface MeasurementResult { + signature: string + stats: BenchmarkStats +} + +interface MicroBenchmarkRow extends BenchmarkStats { + fixture: FixtureName + inputBytes: number + medianMsPerOperation: number + operationsPerSample: number + phase: MicroPhase + processedMbPerSecond: number + runtime: RuntimeName + runtimeVersion: string +} + +interface StreamBenchmarkRow extends BenchmarkStats { + actualEmittedBytes: number + emittedSnapshots: number + equivalentSerializationAmplification: number + equivalentSerializedBytes: number + fixture: FixtureName + inputBytes: number + materialization: Materialization + phase: StreamPhase + policy: PolicyName + runtime: RuntimeName + runtimeVersion: string + serializationAvoidedBytes: number + sourceMbPerSecond: number + speedupVsRoundTrip: number +} + +interface CompletionScalingRow extends BenchmarkStats { + callback: CompletionCallbackName + callbackEvents: number + inputBytes: number + recordCount: number + recordsPerSecond: number + runtime: RuntimeName + runtimeVersion: string +} + +interface WorkerOutput { + completionScaling: CompletionScalingRow[] + micro: MicroBenchmarkRow[] + runtime: RuntimeName + runtimeVersion: string + streaming: StreamBenchmarkRow[] +} + +interface BenchmarkOutput { + completionScaling: CompletionScalingRow[] + configuration: { + chunkSizeBytes: number + completionScaling: boolean + fixtures: FixtureName[] + iterateMaterialization: IterateMaterialization + modulePath: string + payloadSizeMb: number + policies: PolicyName[] + repeats: number + runtimes: RuntimeName[] + warmups: number + } + micro: MicroBenchmarkRow[] + streaming: StreamBenchmarkRow[] +} + +interface ParseResult { + emittedBytes: number + emittedSnapshots: number + finalSnapshot: Uint8Array +} + +interface IterateResult { + emittedSnapshots: number + finalSnapshot: unknown +} + +interface RoundTripResult extends IterateResult { + emittedBytes: number +} + +interface CompletionScalingResult { + callbackEvents: number + checksum: number + finalRecordCount: number +} + +interface CompletionCallbackState { + callbackEvents: number + checksum: number +} + +interface PolicyDefinition { + name: PolicyName + snapshotPolicy: SnapshotPolicy +} + +const bytesPerMegabyte = 1024 * 1024 +const defaultChunkSizeBytes = 64 * 1024 +const completionScalingRecordCounts = [250, 500, 1000, 2000] as const +const defaultModulePath = fileURLToPath(new URL("../dist/index.mjs", import.meta.url)) +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)) +const minimumMicroPhaseBytes = 16 * bytesPerMegabyte +const encoder = new TextEncoder() +const decoder = new TextDecoder() +const policyDefinitions: Record = { + chunk: { name: "chunk", snapshotPolicy: { mode: "chunk" } }, + value: { name: "value", snapshotPolicy: { mode: "value" } }, + "bytes-256kb": { + name: "bytes-256kb", + snapshotPolicy: { bytes: 256 * 1024, mode: "bytes" } + }, + "bytes-1mb": { + name: "bytes-1mb", + snapshotPolicy: { bytes: bytesPerMegabyte, mode: "bytes" } + }, + final: { name: "final", snapshotPolicy: { mode: "final" } } +} +const fixtureNames = ["long-string", "object-heavy"] as const +const policyNames = ["chunk", "value", "bytes-256kb", "bytes-1mb", "final"] as const +const runtimeNames = ["bun", "node"] as const + +const helpText = `schema-stream snapshot benchmark + +Usage: + bun run benchmark [size-mib] [options] + +Options: + --size-mb Target MiB per fixture (default: 2) + --chunk-kb Source chunk size in KiB (default: 64) + --fixtures long-string,object-heavy + --policies chunk,value,bytes-256kb,bytes-1mb,final + --runtimes bun,node + --warmups Warmup samples (default: 1) + --repeats Measured samples (default: 5) + --completion-scaling Compare completion callbacks across 250-2000 records + --verbose Include ranges and detailed emission metrics + --json Emit full machine-readable evidence + --module Benchmark another built ESM entry point + --iterate-materialization Label imported iterate as direct-json-domain or json-roundtrip + -h, --help Show this help + +The default module is built before measurement. Native JSON operations are reference +costs, not feature-equivalent alternatives. The roundtrip streaming row is the +like-for-like baseline for object snapshots materialized through serialized UTF-8.` + +function parseNumberFlag({ name, value }: { name: string; value: string | undefined }): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new TypeError(`${name} must be a finite number`) + } + return parsed +} + +function parseListFlag({ + allowed, + name, + value +}: { + allowed: readonly TName[] + name: string + value: string | undefined +}): TName[] { + const values = value?.split(",").filter(Boolean) ?? [] + if (values.length === 0 || values.some(item => !allowed.includes(item as TName))) { + throw new TypeError(`${name} must contain only: ${allowed.join(", ")}`) + } + return values as TName[] +} + +function validateOptions(options: BenchmarkOptions): void { + if (options.payloadSizeMb <= 0) { + throw new TypeError("payload size must be greater than zero") + } + if (!(Number.isInteger(options.chunkSizeBytes) && options.chunkSizeBytes > 0)) { + throw new TypeError("chunk size must resolve to a positive integer number of bytes") + } + if (!(Number.isInteger(options.warmups) && options.warmups >= 0)) { + throw new TypeError("warmups must be a non-negative integer") + } + if (!(Number.isInteger(options.repeats) && options.repeats > 0)) { + throw new TypeError("repeats must be a positive integer") + } + if (options.worker && options.workerRuntime === undefined) { + throw new TypeError("worker mode requires --runtime") + } + if ( + options.completionScaling && + !options.worker && + resolvePath(options.modulePath) !== resolvePath(defaultModulePath) + ) { + throw new TypeError("--completion-scaling only supports the current built module") + } +} + +function applyOption({ + flag, + options, + value +}: { + flag: string + options: BenchmarkOptions + value: string | undefined +}): void { + const handlers: Record void> = { + "--chunk-kb": () => { + options.chunkSizeBytes = Math.round(parseNumberFlag({ name: flag, value }) * 1024) + }, + "--fixtures": () => { + options.fixtureNames = parseListFlag({ allowed: fixtureNames, name: flag, value }) + }, + "--iterate-materialization": () => { + options.iterateMaterialization = parseListFlag({ + allowed: ["direct-json-domain", "json-roundtrip"] as const, + name: flag, + value + })[0] + }, + "--module": () => { + if (!value) { + throw new TypeError(`${flag} requires a path`) + } + options.modulePath = resolvePath(value) + }, + "--policies": () => { + options.policyNames = parseListFlag({ allowed: policyNames, name: flag, value }) + }, + "--repeats": () => { + options.repeats = parseNumberFlag({ name: flag, value }) + }, + "--runtime": () => { + options.workerRuntime = parseListFlag({ allowed: runtimeNames, name: flag, value })[0] + }, + "--runtimes": () => { + options.runtimeNames = parseListFlag({ allowed: runtimeNames, name: flag, value }) + }, + "--size-mb": () => { + options.payloadSizeMb = parseNumberFlag({ name: flag, value }) + }, + "--warmups": () => { + options.warmups = parseNumberFlag({ name: flag, value }) + } + } + const handler = handlers[flag] + if (handler === undefined) { + throw new TypeError(`Unknown benchmark option: ${flag}`) + } + handler() +} + +function parseArguments(args: string[]): BenchmarkOptions { + const options: BenchmarkOptions = { + chunkSizeBytes: defaultChunkSizeBytes, + completionScaling: false, + fixtureNames: [...fixtureNames], + iterateMaterialization: "direct-json-domain", + json: false, + modulePath: defaultModulePath, + payloadSizeMb: 2, + policyNames: [...policyNames], + repeats: 5, + runtimeNames: [...runtimeNames], + verbose: false, + warmups: 1, + worker: false + } + let positionalSizeSeen = false + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === "--json") { + options.json = true + continue + } + if (argument === "--completion-scaling") { + options.completionScaling = true + continue + } + if (argument === "--worker") { + options.worker = true + continue + } + if (argument === "--verbose") { + options.verbose = true + continue + } + if (!argument.startsWith("--")) { + if (positionalSizeSeen) { + throw new TypeError(`Unexpected positional argument: ${argument}`) + } + options.payloadSizeMb = parseNumberFlag({ name: "payload size", value: argument }) + positionalSizeSeen = true + continue + } + + const [flag, inlineValue] = argument.split("=", 2) + const value = inlineValue ?? args[index + 1] + if (inlineValue === undefined) { + index += 1 + } + + applyOption({ flag, options, value }) + } + + validateOptions(options) + return options +} + +/** + * Builds the local package without mixing successful build logs into benchmark tables or JSON. + * An explicit `--module` is assumed to have been prepared by the caller. + */ +async function buildDefaultModule(options: BenchmarkOptions): Promise { + if (resolvePath(options.modulePath) !== resolvePath(defaultModulePath)) { + return + } + + await new Promise((resolve, reject) => { + const child = spawn(process.env.BUN_BINARY ?? "bun", ["run", "build"], { + cwd: repositoryRoot, + stdio: ["ignore", "pipe", "pipe"] + }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", chunk => { + stdout += chunk + }) + child.stderr.on("data", chunk => { + stderr += chunk + }) + child.on("error", reject) + child.on("close", code => { + if (code === 0) { + resolve() + return + } + reject(new Error(`benchmark build exited with code ${code}:\n${stdout}${stderr}`)) + }) + }) +} + +function serializeJson(value: Record): string { + const json = JSON.stringify(value) + if (json === undefined) { + throw new TypeError("benchmark fixture must serialize to JSON") + } + return json +} + +function splitBytes(bytes: Uint8Array, chunkSizeBytes: number): Uint8Array[] { + const chunks: Uint8Array[] = [] + for (let offset = 0; offset < bytes.byteLength; offset += chunkSizeBytes) { + chunks.push(bytes.subarray(offset, offset + chunkSizeBytes)) + } + return chunks +} + +function assertRecord( + value: unknown, + fixture: FixtureName +): asserts value is Record { + if (!(typeof value === "object" && value !== null && !Array.isArray(value))) { + throw new Error(`${fixture} emitted a non-object final snapshot`) + } +} + +function createLongStringFixture({ + chunkSizeBytes, + targetBytes +}: { + chunkSizeBytes: number + targetBytes: number +}): BenchmarkFixture { + const metadata = { active: true, id: 42, source: "benchmark" } + const fixedJsonBytes = encoder.encode(serializeJson({ content: "", metadata })).byteLength + const contentLength = Math.max(1, targetBytes - fixedJsonBytes) + const expected = { content: "x".repeat(contentLength), metadata } + const json = serializeJson(expected) + const encodedJson = encoder.encode(json) + + return { + assertFinal(value) { + assertRecord(value, "long-string") + if (!isDeepStrictEqual(value, expected)) { + throw new Error("long-string emitted an incorrect final snapshot") + } + }, + chunks: splitBytes(encodedJson, chunkSizeBytes), + encodedJson, + expected, + json, + name: "long-string", + schema: object({ + content: string(), + metadata: object({ active: boolean(), id: number(), source: string() }) + }) + } +} + +function createObjectRecord(id: number): Record { + return { + active: id % 2 === 0, + id, + label: `record-${id.toString().padStart(8, "0")}`, + metadata: { region: `region-${id % 8}`, tier: id % 4 }, + score: (id % 1000) / 10, + tags: [`group-${id % 16}`, `bucket-${id % 32}`, "streaming"] + } +} + +function createObjectHeavyFixture({ + chunkSizeBytes, + targetBytes +}: { + chunkSizeBytes: number + targetBytes: number +}): BenchmarkFixture { + const sampleBytes = encoder.encode(serializeJson({ records: [createObjectRecord(1)] })).byteLength + let recordCount = Math.max(1, Math.round(targetBytes / sampleBytes)) + let expected: { records: Record[] } = { records: [] } + let json = "" + + for (let attempt = 0; attempt < 3; attempt += 1) { + expected = { + records: Array.from({ length: recordCount }, (_, id) => createObjectRecord(id)) + } + json = serializeJson(expected) + const actualBytes = encoder.encode(json).byteLength + const adjustedCount = Math.max(1, Math.round((recordCount * targetBytes) / actualBytes)) + if (adjustedCount === recordCount) { + break + } + recordCount = adjustedCount + } + + const encodedJson = encoder.encode(json) + return { + assertFinal(value) { + assertRecord(value, "object-heavy") + if (!isDeepStrictEqual(value, expected)) { + throw new Error("object-heavy emitted an incorrect final snapshot") + } + }, + chunks: splitBytes(encodedJson, chunkSizeBytes), + encodedJson, + expected, + json, + name: "object-heavy", + schema: object({ + records: array( + object({ + active: boolean(), + id: number(), + label: string(), + metadata: object({ region: string(), tier: number() }), + score: number(), + tags: array(string()) + }) + ) + }) + } +} + +function createFixtures(options: BenchmarkOptions): BenchmarkFixture[] { + const targetBytes = Math.round(options.payloadSizeMb * bytesPerMegabyte) + const available: Record BenchmarkFixture> = { + "long-string": () => + createLongStringFixture({ chunkSizeBytes: options.chunkSizeBytes, targetBytes }), + "object-heavy": () => + createObjectHeavyFixture({ chunkSizeBytes: options.chunkSizeBytes, targetBytes }) + } + return options.fixtureNames.map(name => available[name]()) +} + +function median(samples: number[]): number { + const sorted = [...samples].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +function summarizeSamples(samplesMs: number[]): BenchmarkStats { + return { + maximumMs: Math.max(...samplesMs), + medianMs: median(samplesMs), + minimumMs: Math.min(...samplesMs), + samplesMs + } +} + +async function measure({ + assertValue, + operation, + repeats, + signature, + warmups +}: { + assertValue: (value: TValue) => void + operation: () => Promise | TValue + repeats: number + signature: (value: TValue) => string + warmups: number +}): Promise { + for (let warmup = 0; warmup < warmups; warmup += 1) { + const value = await operation() + assertValue(value) + } + + const samplesMs: number[] = [] + let expectedSignature: string | undefined + for (let repeat = 0; repeat < repeats; repeat += 1) { + const startedAt = performance.now() + const value = await operation() + const durationMs = performance.now() - startedAt + assertValue(value) + const actualSignature = signature(value) + if (expectedSignature !== undefined && actualSignature !== expectedSignature) { + throw new Error(`benchmark result changed between repeats: ${actualSignature}`) + } + expectedSignature = actualSignature + samplesMs.push(durationMs) + } + + if (expectedSignature === undefined) { + throw new Error("benchmark did not collect a measured sample") + } + return { + signature: expectedSignature, + stats: summarizeSamples(samplesMs) + } +} + +/** Alternates two feature-aligned operations within every sample to reduce order and JIT bias. */ +async function measurePair({ + left, + repeats, + right, + startWithLeft, + warmups +}: { + left: MeasurementSpec + repeats: number + right: MeasurementSpec + startWithLeft: boolean + warmups: number +}): Promise<{ left: MeasurementResult; right: MeasurementResult }> { + const leftSamplesMs: number[] = [] + const rightSamplesMs: number[] = [] + let leftSignature: string | undefined + let rightSignature: string | undefined + + const runLeft = async (record: boolean): Promise => { + const startedAt = performance.now() + const value = await left.operation() + const durationMs = performance.now() - startedAt + left.assertValue(value) + if (!record) { + return + } + const signature = left.signature(value) + if (leftSignature !== undefined && signature !== leftSignature) { + throw new Error(`paired left benchmark result changed between repeats: ${signature}`) + } + leftSignature = signature + leftSamplesMs.push(durationMs) + } + const runRight = async (record: boolean): Promise => { + const startedAt = performance.now() + const value = await right.operation() + const durationMs = performance.now() - startedAt + right.assertValue(value) + if (!record) { + return + } + const signature = right.signature(value) + if (rightSignature !== undefined && signature !== rightSignature) { + throw new Error(`paired right benchmark result changed between repeats: ${signature}`) + } + rightSignature = signature + rightSamplesMs.push(durationMs) + } + const runCycle = async (leftFirst: boolean, record: boolean): Promise => { + if (leftFirst) { + await runLeft(record) + await runRight(record) + return + } + await runRight(record) + await runLeft(record) + } + + for (let warmup = 0; warmup < warmups; warmup += 1) { + await runCycle(startWithLeft === (warmup % 2 === 0), false) + } + for (let repeat = 0; repeat < repeats; repeat += 1) { + await runCycle(startWithLeft === (repeat % 2 === 0), true) + } + + if (leftSignature === undefined || rightSignature === undefined) { + throw new Error("paired benchmark did not collect a measured sample") + } + return { + left: { signature: leftSignature, stats: summarizeSamples(leftSamplesMs) }, + right: { signature: rightSignature, stats: summarizeSamples(rightSamplesMs) } + } +} + +function createSource(chunks: Uint8Array[]): ReadableStream { + let index = 0 + return new ReadableStream({ + pull(controller) { + const chunk = chunks[index] + index += 1 + if (chunk === undefined) { + controller.close() + return + } + controller.enqueue(chunk) + } + }) +} + +function getRuntimeVersion(runtime: RuntimeName): string { + return runtime === "bun" ? `Bun ${Bun.version}` : `Node ${process.version}` +} + +/** + * Measures native JSON and UTF-8 operations independently. Each timed sample processes at least + * 16 MiB so sub-millisecond operations remain measurable; terminal output normalizes back to one + * fixture operation. + */ +async function runMicroBenchmarks({ + fixture, + options, + runtime, + runtimeVersion +}: { + fixture: BenchmarkFixture + options: BenchmarkOptions + runtime: RuntimeName + runtimeVersion: string +}): Promise { + const operationsPerSample = Math.max( + 1, + Math.ceil(minimumMicroPhaseBytes / fixture.encodedJson.byteLength) + ) + const processedBytes = fixture.encodedJson.byteLength * operationsPerSample + const rows: MicroBenchmarkRow[] = [] + + const stringify = await measure({ + assertValue(value: string) { + if (value !== fixture.json) { + throw new Error(`${fixture.name} JSON.stringify produced incorrect JSON`) + } + }, + operation() { + let value = "" + for (let operation = 0; operation < operationsPerSample; operation += 1) { + value = serializeJson(fixture.expected) + } + return value + }, + repeats: options.repeats, + signature: value => String(value.length), + warmups: options.warmups + }) + rows.push({ + ...stringify.stats, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + medianMsPerOperation: stringify.stats.medianMs / operationsPerSample, + operationsPerSample, + phase: "JSON.stringify", + processedMbPerSecond: processedBytes / bytesPerMegabyte / (stringify.stats.medianMs / 1000), + runtime, + runtimeVersion + }) + + const encode = await measure({ + assertValue(value: Uint8Array) { + if (value.byteLength !== fixture.encodedJson.byteLength) { + throw new Error(`${fixture.name} TextEncoder produced an incorrect byte count`) + } + }, + operation() { + let value = new Uint8Array() + for (let operation = 0; operation < operationsPerSample; operation += 1) { + value = encoder.encode(fixture.json) + } + return value + }, + repeats: options.repeats, + signature: value => String(value.byteLength), + warmups: options.warmups + }) + rows.push({ + ...encode.stats, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + medianMsPerOperation: encode.stats.medianMs / operationsPerSample, + operationsPerSample, + phase: "TextEncoder.encode", + processedMbPerSecond: processedBytes / bytesPerMegabyte / (encode.stats.medianMs / 1000), + runtime, + runtimeVersion + }) + + const bufferFrom = await measure({ + assertValue(value: Uint8Array) { + if (value.byteLength !== fixture.encodedJson.byteLength) { + throw new Error(`${fixture.name} Buffer.from produced an incorrect byte count`) + } + }, + operation() { + let value = new Uint8Array() + for (let operation = 0; operation < operationsPerSample; operation += 1) { + value = Buffer.from(fixture.json) + } + return value + }, + repeats: options.repeats, + signature: value => String(value.byteLength), + warmups: options.warmups + }) + rows.push({ + ...bufferFrom.stats, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + medianMsPerOperation: bufferFrom.stats.medianMs / operationsPerSample, + operationsPerSample, + phase: "Buffer.from (UTF-8)", + processedMbPerSecond: processedBytes / bytesPerMegabyte / (bufferFrom.stats.medianMs / 1000), + runtime, + runtimeVersion + }) + + const decodeAndParse = await measure({ + assertValue: fixture.assertFinal, + operation() { + let value: unknown + for (let operation = 0; operation < operationsPerSample; operation += 1) { + value = JSON.parse(decoder.decode(fixture.encodedJson)) as unknown + } + return value + }, + repeats: options.repeats, + signature(value) { + fixture.assertFinal(value) + return fixture.name === "long-string" + ? String((value as { content: string }).content.length) + : String((value as { records: unknown[] }).records.length) + }, + warmups: options.warmups + }) + rows.push({ + ...decodeAndParse.stats, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + medianMsPerOperation: decodeAndParse.stats.medianMs / operationsPerSample, + operationsPerSample, + phase: "TextDecoder + JSON.parse", + processedMbPerSecond: + processedBytes / bytesPerMegabyte / (decodeAndParse.stats.medianMs / 1000), + runtime, + runtimeVersion + }) + + return rows +} + +/** + * Compares byte snapshots, serialized object materialization, and direct object snapshots while + * holding the parser, fixture, chunking, and snapshot cadence constant. The round-trip row is the + * feature-aligned baseline used for direct-iteration speedups. + */ +async function runStreamingBenchmarks({ + SchemaStream, + fixture, + options, + runtime, + runtimeVersion +}: { + SchemaStream: typeof import("@/index").SchemaStream + fixture: BenchmarkFixture + options: BenchmarkOptions + runtime: RuntimeName + runtimeVersion: string +}): Promise { + const rows: StreamBenchmarkRow[] = [] + + for (const [policyIndex, policyName] of options.policyNames.entries()) { + const policy = policyDefinitions[policyName] + const parse = await measure({ + assertValue(result: ParseResult) { + fixture.assertFinal(JSON.parse(decoder.decode(result.finalSnapshot)) as unknown) + }, + async operation(): Promise { + const parser = new SchemaStream(fixture.schema) + const output = createSource(fixture.chunks).pipeThrough( + parser.parse({ snapshotPolicy: policy.snapshotPolicy }) + ) + let emittedBytes = 0 + let parseSnapshots = 0 + let finalSnapshot: Uint8Array | undefined + for await (const snapshot of output) { + emittedBytes += snapshot.byteLength + parseSnapshots += 1 + finalSnapshot = snapshot + } + if (finalSnapshot === undefined) { + throw new Error(`${fixture.name}/${policy.name}/parse did not emit a final snapshot`) + } + return { emittedBytes, emittedSnapshots: parseSnapshots, finalSnapshot } + }, + repeats: options.repeats, + signature: result => `${result.emittedSnapshots}:${result.emittedBytes}`, + warmups: options.warmups + }) + const [emittedSnapshotsText, emittedBytesText] = parse.signature.split(":") + const emittedSnapshots = Number(emittedSnapshotsText) + const emittedBytes = Number(emittedBytesText) + + const { left: roundTrip, right: iterate } = await measurePair({ + left: { + assertValue(result) { + fixture.assertFinal(result.finalSnapshot) + if ( + result.emittedSnapshots !== emittedSnapshots || + result.emittedBytes !== emittedBytes + ) { + throw new Error( + `${fixture.name}/${policy.name}/roundtrip did not match parse emissions` + ) + } + }, + async operation(): Promise { + const parser = new SchemaStream(fixture.schema) + const output = createSource(fixture.chunks).pipeThrough( + parser.parse({ snapshotPolicy: policy.snapshotPolicy }) + ) + let roundTripBytes = 0 + let roundTripSnapshots = 0 + let finalSnapshot: unknown + for await (const snapshot of output) { + roundTripBytes += snapshot.byteLength + roundTripSnapshots += 1 + finalSnapshot = JSON.parse(decoder.decode(snapshot)) as unknown + } + if (finalSnapshot === undefined) { + throw new Error( + `${fixture.name}/${policy.name}/roundtrip did not emit a final snapshot` + ) + } + return { + emittedBytes: roundTripBytes, + emittedSnapshots: roundTripSnapshots, + finalSnapshot + } + }, + signature: result => `${result.emittedSnapshots}:${result.emittedBytes}` + }, + repeats: options.repeats, + right: { + assertValue(result) { + fixture.assertFinal(result.finalSnapshot) + if (result.emittedSnapshots !== emittedSnapshots) { + throw new Error( + `${fixture.name}/${policy.name}/iterate emitted ${result.emittedSnapshots} snapshots; ` + + `parse emitted ${emittedSnapshots}` + ) + } + }, + async operation(): Promise { + const parser = new SchemaStream(fixture.schema) + let iterateSnapshots = 0 + let finalSnapshot: unknown + for await (const snapshot of parser.iterate(createSource(fixture.chunks), { + snapshotPolicy: policy.snapshotPolicy + })) { + iterateSnapshots += 1 + finalSnapshot = snapshot + } + if (finalSnapshot === undefined) { + throw new Error(`${fixture.name}/${policy.name}/iterate did not emit a final snapshot`) + } + return { emittedSnapshots: iterateSnapshots, finalSnapshot } + }, + signature: result => String(result.emittedSnapshots) + }, + startWithLeft: policyIndex % 2 === 0, + warmups: options.warmups + }) + + rows.push({ + ...parse.stats, + actualEmittedBytes: emittedBytes, + emittedSnapshots, + equivalentSerializedBytes: emittedBytes, + equivalentSerializationAmplification: emittedBytes / fixture.encodedJson.byteLength, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + materialization: "serialized-utf8", + phase: "parse", + policy: policy.name, + runtime, + runtimeVersion, + serializationAvoidedBytes: 0, + sourceMbPerSecond: + fixture.encodedJson.byteLength / bytesPerMegabyte / (parse.stats.medianMs / 1000), + speedupVsRoundTrip: roundTrip.stats.medianMs / parse.stats.medianMs + }) + rows.push({ + ...roundTrip.stats, + actualEmittedBytes: emittedBytes, + emittedSnapshots, + equivalentSerializedBytes: emittedBytes, + equivalentSerializationAmplification: emittedBytes / fixture.encodedJson.byteLength, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + materialization: "json-roundtrip", + phase: "roundtrip", + policy: policy.name, + runtime, + runtimeVersion, + serializationAvoidedBytes: 0, + sourceMbPerSecond: + fixture.encodedJson.byteLength / bytesPerMegabyte / (roundTrip.stats.medianMs / 1000), + speedupVsRoundTrip: 1 + }) + + rows.push({ + ...iterate.stats, + actualEmittedBytes: 0, + emittedSnapshots, + equivalentSerializedBytes: emittedBytes, + equivalentSerializationAmplification: emittedBytes / fixture.encodedJson.byteLength, + fixture: fixture.name, + inputBytes: fixture.encodedJson.byteLength, + materialization: options.iterateMaterialization, + phase: "iterate", + policy: policy.name, + runtime, + runtimeVersion, + serializationAvoidedBytes: + options.iterateMaterialization === "direct-json-domain" ? emittedBytes : 0, + sourceMbPerSecond: + fixture.encodedJson.byteLength / bytesPerMegabyte / (iterate.stats.medianMs / 1000), + speedupVsRoundTrip: roundTrip.stats.medianMs / iterate.stats.medianMs + }) + } + + return rows +} + +function createCompletionBenchmarkParser({ + SchemaStream, + callback, + schema, + state +}: { + SchemaStream: typeof import("@/index").SchemaStream + callback: CompletionCallbackName + schema: ZodObjectSchema + state: CompletionCallbackState +}) { + if (callback === "onValueComplete") { + return new SchemaStream(schema, { + onValueComplete({ path }) { + state.callbackEvents += 1 + state.checksum += path.length + } + }) + } + if (callback === "onKeyComplete") { + return new SchemaStream(schema, { + onKeyComplete({ activePath, completedPaths }) { + state.callbackEvents += 1 + state.checksum += activePath.length + completedPaths.length + } + }) + } + return new SchemaStream(schema) +} + +/** + * Isolates completion-callback bookkeeping with one final snapshot per document. Doubling record + * counts makes cumulative-history copying visible without adding that cost to the default suite. + */ +async function runCompletionScalingBenchmarks({ + SchemaStream, + options, + runtime, + runtimeVersion +}: { + SchemaStream: typeof import("@/index").SchemaStream + options: BenchmarkOptions + runtime: RuntimeName + runtimeVersion: string +}): Promise { + const schema = object({ records: array(object({ id: number(), score: number() })) }) + const callbacks: CompletionCallbackName[] = ["none", "onValueComplete", "onKeyComplete"] + const rows: CompletionScalingRow[] = [] + + for (const recordCount of completionScalingRecordCounts) { + const value = { + records: Array.from({ length: recordCount }, (_, id) => ({ id, score: id % 100 })) + } + const encodedJson = encoder.encode(serializeJson(value)) + + for (const callback of callbacks) { + const measured = await measure({ + assertValue(result: CompletionScalingResult) { + if (result.finalRecordCount !== recordCount) { + throw new Error(`${callback}/${recordCount} emitted an incorrect final record count`) + } + let expectedEvents = 0 + if (callback === "onValueComplete") { + expectedEvents = recordCount * 3 + 2 + } else if (callback === "onKeyComplete") { + expectedEvents = recordCount * 2 + 1 + } + if (result.callbackEvents !== expectedEvents) { + throw new Error( + `${callback}/${recordCount} emitted ${result.callbackEvents} callbacks; ` + + `expected ${expectedEvents}` + ) + } + }, + async operation(): Promise { + const state: CompletionCallbackState = { callbackEvents: 0, checksum: 0 } + const parser = createCompletionBenchmarkParser({ + SchemaStream, + callback, + schema, + state + }) + let finalSnapshot: unknown + for await (const snapshot of parser.iterate(createSource([encodedJson]), { + snapshotPolicy: { mode: "final" } + })) { + finalSnapshot = snapshot + } + if (!(typeof finalSnapshot === "object" && finalSnapshot !== null)) { + throw new Error(`${callback}/${recordCount} did not emit a final object snapshot`) + } + const { records } = finalSnapshot as { records?: unknown } + return { + callbackEvents: state.callbackEvents, + checksum: state.checksum, + finalRecordCount: Array.isArray(records) ? records.length : -1 + } + }, + repeats: options.repeats, + signature: result => + `${result.finalRecordCount}:${result.callbackEvents}:${result.checksum}`, + warmups: options.warmups + }) + const [, callbackEventsText] = measured.signature.split(":") + rows.push({ + ...measured.stats, + callback, + callbackEvents: Number(callbackEventsText), + inputBytes: encodedJson.byteLength, + recordCount, + recordsPerSecond: recordCount / (measured.stats.medianMs / 1000), + runtime, + runtimeVersion + }) + } + } + + return rows +} + +async function runWorker(options: BenchmarkOptions): Promise { + const runtime = options.workerRuntime + if (runtime === undefined) { + throw new TypeError("worker runtime is required") + } + const actualRuntime: RuntimeName = typeof Bun === "undefined" ? "node" : "bun" + if (runtime !== actualRuntime) { + throw new Error(`expected ${runtime} worker, running under ${actualRuntime}`) + } + + const distModuleUrl = pathToFileURL(options.modulePath).href + const { SchemaStream } = (await import(distModuleUrl)) as typeof import("@/index") + const runtimeVersion = getRuntimeVersion(runtime) + if (options.completionScaling) { + return { + completionScaling: await runCompletionScalingBenchmarks({ + SchemaStream, + options, + runtime, + runtimeVersion + }), + micro: [], + runtime, + runtimeVersion, + streaming: [] + } + } + const fixtures = createFixtures(options) + const micro: MicroBenchmarkRow[] = [] + const streaming: StreamBenchmarkRow[] = [] + + for (const fixture of fixtures) { + micro.push(...(await runMicroBenchmarks({ fixture, options, runtime, runtimeVersion }))) + streaming.push( + ...(await runStreamingBenchmarks({ + SchemaStream, + fixture, + options, + runtime, + runtimeVersion + })) + ) + } + + return { completionScaling: [], micro, runtime, runtimeVersion, streaming } +} + +/** + * Runs each runtime in a fresh process so Bun and Node execute the same built module in isolation. + */ +async function spawnWorker({ + options, + runtime +}: { + options: BenchmarkOptions + runtime: RuntimeName +}): Promise { + const executable = + runtime === "bun" ? (process.env.BUN_BINARY ?? "bun") : (process.env.NODE_BINARY ?? "node") + const benchmarkPath = fileURLToPath(import.meta.url) + const args = [ + benchmarkPath, + "--worker", + "--runtime", + runtime, + "--size-mb", + String(options.payloadSizeMb), + "--chunk-kb", + String(options.chunkSizeBytes / 1024), + "--warmups", + String(options.warmups), + "--repeats", + String(options.repeats), + "--fixtures", + options.fixtureNames.join(","), + "--iterate-materialization", + options.iterateMaterialization, + "--module", + options.modulePath, + "--policies", + options.policyNames.join(","), + "--json" + ] + if (options.completionScaling) { + args.push("--completion-scaling") + } + + return await new Promise((resolve, reject) => { + const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", chunk => { + stdout += chunk + }) + child.stderr.on("data", chunk => { + stderr += chunk + }) + child.on("error", reject) + child.on("close", code => { + if (code !== 0) { + reject(new Error(`${runtime} benchmark exited with code ${code}:\n${stderr}`)) + return + } + try { + resolve(JSON.parse(stdout) as WorkerOutput) + } catch (error) { + reject( + new Error(`${runtime} benchmark returned invalid JSON:\n${stdout}\n${stderr}`, { + cause: error + }) + ) + } + }) + }) +} + +function formatMebibytes(bytes: number): string { + return (bytes / bytesPerMegabyte).toFixed(2) +} + +function formatMilliseconds(milliseconds: number): string { + const magnitude = Math.abs(milliseconds) + if (magnitude < 0.01) { + return milliseconds.toFixed(4) + } + if (magnitude < 1) { + return milliseconds.toFixed(3) + } + return milliseconds.toFixed(2) +} + +function getStreamRow({ + phase, + policy, + rows +}: { + phase: StreamPhase + policy: PolicyName + rows: StreamBenchmarkRow[] +}): StreamBenchmarkRow { + const row = rows.find(candidate => candidate.phase === phase && candidate.policy === policy) + if (row === undefined) { + throw new Error(`missing ${policy}/${phase} benchmark result`) + } + return row +} + +function printNativeReference(output: BenchmarkOutput): void { + console.log("\nNative JSON reference (isolated costs; not feature-equivalent parsers)") + for (const runtime of output.configuration.runtimes) { + const rows = output.micro.filter(row => row.runtime === runtime) + const runtimeVersion = rows[0]?.runtimeVersion ?? runtime + console.log(`\n${runtimeVersion}`) + console.table( + rows.map(row => ({ + fixture: row.fixture, + operation: row.phase, + "median ms/op": formatMilliseconds(row.medianMsPerOperation), + "MiB/s": row.processedMbPerSecond.toFixed(2) + })) + ) + } +} + +function printStreamingSummary(output: BenchmarkOutput): void { + console.log("\nStreaming object snapshots") + console.log( + "roundtrip = parse + UTF-8 decode + JSON.parse per snapshot; speedup = roundtrip / iterate" + ) + + for (const runtime of output.configuration.runtimes) { + for (const fixture of output.configuration.fixtures) { + const rows = output.streaming.filter( + row => row.runtime === runtime && row.fixture === fixture + ) + if (rows.length === 0) { + continue + } + + const runtimeVersion = rows[0]?.runtimeVersion ?? runtime + console.log( + `\n${runtimeVersion} / ${fixture} / ${formatMebibytes(rows[0]?.inputBytes ?? 0)} MiB ` + + `(iterate: ${output.configuration.iterateMaterialization})` + ) + console.table( + output.configuration.policies.map(policy => { + const parse = getStreamRow({ phase: "parse", policy, rows }) + const roundTrip = getStreamRow({ phase: "roundtrip", policy, rows }) + const iterate = getStreamRow({ phase: "iterate", policy, rows }) + return { + policy, + snapshots: iterate.emittedSnapshots, + "parse ms": formatMilliseconds(parse.medianMs), + "roundtrip ms": formatMilliseconds(roundTrip.medianMs), + "iterate ms": formatMilliseconds(iterate.medianMs), + speedup: `${iterate.speedupVsRoundTrip.toFixed(2)}x`, + "serialized MiB": formatMebibytes(roundTrip.actualEmittedBytes), + "avoided MiB": formatMebibytes(iterate.serializationAvoidedBytes) + } + }) + ) + } + } +} + +function printVerboseEvidence(output: BenchmarkOutput): void { + console.log("\nVerbose configuration") + console.log({ + chunkKiB: output.configuration.chunkSizeBytes / 1024, + fixtures: output.configuration.fixtures.join(", "), + iterateMaterialization: output.configuration.iterateMaterialization, + modulePath: output.configuration.modulePath, + payloadMiBPerFixture: output.configuration.payloadSizeMb, + policies: output.configuration.policies.join(", "), + repeats: output.configuration.repeats, + runtimes: output.configuration.runtimes.join(", "), + warmups: output.configuration.warmups + }) + + console.log("\nNative JSON sample ranges") + console.table( + output.micro.map(row => ({ + runtime: row.runtimeVersion, + fixture: row.fixture, + operation: row.phase, + "ops/sample": row.operationsPerSample, + "min ms/sample": formatMilliseconds(row.minimumMs), + "median ms/sample": formatMilliseconds(row.medianMs), + "max ms/sample": formatMilliseconds(row.maximumMs), + "median ms/op": formatMilliseconds(row.medianMsPerOperation) + })) + ) + + console.log("\nStreaming timing ranges") + console.table( + output.streaming.map(row => ({ + runtime: row.runtimeVersion, + fixture: row.fixture, + policy: row.policy, + API: row.phase, + materialization: row.materialization, + "min ms": formatMilliseconds(row.minimumMs), + "median ms": formatMilliseconds(row.medianMs), + "max ms": formatMilliseconds(row.maximumMs), + "MiB/s": row.sourceMbPerSecond.toFixed(2), + "vs roundtrip": `${row.speedupVsRoundTrip.toFixed(2)}x` + })) + ) + + console.log("\nStreaming emission details") + console.table( + output.streaming + .filter(row => row.phase === "iterate") + .map(row => ({ + runtime: row.runtimeVersion, + fixture: row.fixture, + policy: row.policy, + snapshots: row.emittedSnapshots, + "serialized MiB": formatMebibytes(row.equivalentSerializedBytes), + amplification: `${row.equivalentSerializationAmplification.toFixed(2)}x`, + "avoided MiB": formatMebibytes(row.serializationAvoidedBytes) + })) + ) +} + +function getCompletionRow({ + callback, + recordCount, + rows +}: { + callback: CompletionCallbackName + recordCount: number + rows: CompletionScalingRow[] +}): CompletionScalingRow { + const row = rows.find( + candidate => candidate.callback === callback && candidate.recordCount === recordCount + ) + if (row === undefined) { + throw new Error(`missing ${callback}/${recordCount} completion benchmark result`) + } + return row +} + +function printCompletionScaling(output: BenchmarkOutput, verbose: boolean): void { + console.log("schema-stream completion callback scaling") + console.log( + `one final snapshot | ${output.configuration.warmups} warmup(s) | ` + + `median of ${output.configuration.repeats}` + ) + console.log("growth compares each row with the preceding doubled record count") + + for (const runtime of output.configuration.runtimes) { + const rows = output.completionScaling.filter(row => row.runtime === runtime) + const runtimeVersion = rows[0]?.runtimeVersion ?? runtime + let previousValueComplete: CompletionScalingRow | undefined + let previousKeyComplete: CompletionScalingRow | undefined + console.log(`\n${runtimeVersion}`) + console.table( + completionScalingRecordCounts.map(recordCount => { + const none = getCompletionRow({ callback: "none", recordCount, rows }) + const valueComplete = getCompletionRow({ + callback: "onValueComplete", + recordCount, + rows + }) + const keyComplete = getCompletionRow({ + callback: "onKeyComplete", + recordCount, + rows + }) + const valueGrowth = previousValueComplete + ? `${(valueComplete.medianMs / previousValueComplete.medianMs).toFixed(2)}x` + : "-" + const keyGrowth = previousKeyComplete + ? `${(keyComplete.medianMs / previousKeyComplete.medianMs).toFixed(2)}x` + : "-" + previousValueComplete = valueComplete + previousKeyComplete = keyComplete + return { + records: recordCount, + "none ms": formatMilliseconds(none.medianMs), + "complete ms": formatMilliseconds(valueComplete.medianMs), + "complete delta": valueGrowth, + "legacy ms": formatMilliseconds(keyComplete.medianMs), + "legacy delta": keyGrowth, + "legacy/none": `${(keyComplete.medianMs / none.medianMs).toFixed(2)}x` + } + }) + ) + } + + if (!verbose) { + return + } + + console.log("\nCompletion callback evidence") + console.table( + output.completionScaling.map(row => ({ + runtime: row.runtimeVersion, + records: row.recordCount, + callback: row.callback, + events: row.callbackEvents, + "input KiB": (row.inputBytes / 1024).toFixed(2), + "min ms": formatMilliseconds(row.minimumMs), + "median ms": formatMilliseconds(row.medianMs), + "max ms": formatMilliseconds(row.maximumMs), + "records/s": row.recordsPerSecond.toFixed(2) + })) + ) +} + +function printResults(output: BenchmarkOutput, verbose: boolean): void { + if (output.configuration.completionScaling) { + printCompletionScaling(output, verbose) + return + } + + console.log("schema-stream benchmark") + console.log( + `${output.configuration.payloadSizeMb} MiB/fixture target | ` + + `${output.configuration.chunkSizeBytes / 1024} KiB chunks | ` + + `${output.configuration.warmups} warmup(s) | median of ${output.configuration.repeats}` + ) + printNativeReference(output) + printStreamingSummary(output) + if (verbose) { + printVerboseEvidence(output) + } +} + +async function main(): Promise { + const args = process.argv.slice(2) + if (args.includes("--help") || args.includes("-h")) { + console.log(helpText) + return + } + + const options = parseArguments(args) + if (options.worker) { + console.log(JSON.stringify(await runWorker(options))) + return + } + + await buildDefaultModule(options) + + const workers: WorkerOutput[] = [] + for (const runtime of options.runtimeNames) { + workers.push(await spawnWorker({ options, runtime })) + } + const output: BenchmarkOutput = { + configuration: { + chunkSizeBytes: options.chunkSizeBytes, + completionScaling: options.completionScaling, + fixtures: options.fixtureNames, + iterateMaterialization: options.iterateMaterialization, + modulePath: options.modulePath, + payloadSizeMb: options.payloadSizeMb, + policies: options.policyNames, + repeats: options.repeats, + runtimes: options.runtimeNames, + warmups: options.warmups + }, + completionScaling: workers.flatMap(worker => worker.completionScaling), + micro: workers.flatMap(worker => worker.micro), + streaming: workers.flatMap(worker => worker.streaming) + } + + if (options.json) { + console.log(JSON.stringify(output, null, 2)) + return + } + printResults(output, options.verbose) +} + +await main() diff --git a/tests/snapshot-policy.benchmark.ts b/tests/snapshot-policy.benchmark.ts deleted file mode 100644 index b7efd6a..0000000 --- a/tests/snapshot-policy.benchmark.ts +++ /dev/null @@ -1,116 +0,0 @@ -import * as z from "zod" -import { SchemaStream, type SnapshotPolicy } from "@/index" - -interface BenchmarkResult { - durationSeconds: string - emittedMb: string - emittedSnapshots: number - mode: string - throughputMbPerSecond: string -} - -const encoder = new TextEncoder() -const decoder = new TextDecoder() -const networkChunkSize = 64 * 1024 -const [bunExecutable, , payloadSizeArgument, selectedMode] = Bun.argv -const payloadSizeMb = Number(payloadSizeArgument ?? 10) - -if (!Number.isFinite(payloadSizeMb) || payloadSizeMb <= 0) { - throw new TypeError("Payload size must be a positive number of megabytes") -} - -const schema = z.object({ - content: z.string(), - records: z.array(z.object({ active: z.boolean(), id: z.number() })) -}) -const expected = { - content: "x".repeat(payloadSizeMb * 1024 * 1024), - records: Array.from({ length: 10_000 }, (_, id) => ({ active: id % 2 === 0, id })) -} -const encodedJson = encoder.encode(JSON.stringify(expected)) -const policies: Array<{ mode: string; snapshotPolicy: SnapshotPolicy }> = [ - { mode: "chunk", snapshotPolicy: { mode: "chunk" } }, - { mode: "value", snapshotPolicy: { mode: "value" } }, - { mode: "bytes-256kb", snapshotPolicy: { bytes: 256 * 1024, mode: "bytes" } }, - { mode: "bytes-1mb", snapshotPolicy: { bytes: 1024 * 1024, mode: "bytes" } }, - { mode: "final", snapshotPolicy: { mode: "final" } } -] - -async function runPolicy({ - mode, - snapshotPolicy -}: { - mode: string - snapshotPolicy: SnapshotPolicy -}): Promise { - const parser = new SchemaStream(schema) - const transform = parser.parse({ snapshotPolicy }) - const writer = transform.writable.getWriter() - const reader = transform.readable.getReader() - const startedAt = performance.now() - let emittedBytes = 0 - let emittedSnapshots = 0 - let finalSnapshot: Uint8Array | undefined - const readPromise = (async () => { - while (true) { - const output = await reader.read() - if (output.done) { - return - } - emittedBytes += output.value.byteLength - emittedSnapshots += 1 - finalSnapshot = output.value - } - })() - - for (let offset = 0; offset < encodedJson.length; offset += networkChunkSize) { - await writer.write(encodedJson.slice(offset, offset + networkChunkSize)) - } - await writer.close() - await readPromise - - if (!finalSnapshot) { - throw new Error(`${mode} did not emit a final snapshot`) - } - const actual = JSON.parse(decoder.decode(finalSnapshot)) - if (actual.content !== expected.content || actual.records.length !== expected.records.length) { - throw new Error(`${mode} emitted an incorrect final snapshot`) - } - - const durationSeconds = (performance.now() - startedAt) / 1000 - return { - durationSeconds: durationSeconds.toFixed(3), - emittedMb: (emittedBytes / 1024 / 1024).toFixed(2), - emittedSnapshots, - mode, - throughputMbPerSecond: (encodedJson.byteLength / 1024 / 1024 / durationSeconds).toFixed(2) - } -} - -if (selectedMode) { - const policy = policies.find(({ mode }) => mode === selectedMode) - if (!policy) { - throw new TypeError(`Unknown snapshot policy benchmark: ${selectedMode}`) - } - console.log(JSON.stringify(await runPolicy(policy))) -} else { - const results: BenchmarkResult[] = [] - for (const policy of policies) { - const process = Bun.spawn( - [bunExecutable, import.meta.path, String(payloadSizeMb), policy.mode], - { stderr: "inherit", stdout: "pipe" } - ) - const output = await new Response(process.stdout).text() - const exitCode = await process.exited - if (exitCode !== 0) { - throw new Error(`${policy.mode} benchmark exited with code ${exitCode}`) - } - results.push(JSON.parse(output) as BenchmarkResult) - } - - console.log({ - networkChunkKb: networkChunkSize / 1024, - payloadMb: (encodedJson.byteLength / 1024 / 1024).toFixed(2) - }) - console.table(results) -} diff --git a/tests/websocket-example.test.ts b/tests/websocket-example.test.ts new file mode 100644 index 0000000..b923056 --- /dev/null +++ b/tests/websocket-example.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test" +import { join } from "node:path" + +const repositoryRoot = join(import.meta.dir, "..") +const port = 38_000 + (process.pid % 10_000) +const origin = `http://127.0.0.1:${port}` +const BunWebSocketClient = WebSocket as unknown as { + new (url: string | URL, options?: Bun.WebSocketOptions): WebSocket +} + +interface SessionResponse { + token: string +} + +interface CompleteMessage { + output: { + brief: string + } + snapshots: number + type: "complete" +} + +/** Starts the example without forwarding repository credentials to the child process. */ +function startExampleServer(): Bun.Subprocess { + return Bun.spawn([process.execPath, "examples/websocket-ui/server.ts"], { + cwd: repositoryRoot, + env: { + HOME: process.env.HOME ?? "", + PATH: process.env.PATH ?? "", + SCHEMA_STREAM_EXAMPLE_PORT: String(port), + TMPDIR: process.env.TMPDIR ?? "/tmp" + }, + stderr: "pipe", + stdout: "pipe" + }) +} + +/** Waits for the bound loopback listener without assuming process startup timing. */ +async function waitForServer(): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const response = await fetch(origin) + if (response.ok) { + return + } + } catch { + // The process has not bound its socket yet. + } + await Bun.sleep(25) + } + throw new Error("WebSocket example did not start") +} + +/** Runs one capability-bound final-policy generation and records its decision branch. */ +async function runFinalGeneration(token: string): Promise<{ + complete: CompleteMessage + decision: boolean +}> { + return await new Promise((resolve, reject) => { + const socket = new BunWebSocketClient( + `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`, + { headers: { Origin: origin } } + ) + const timeout = setTimeout(() => { + socket.close() + reject(new Error("WebSocket example generation timed out")) + }, 10_000) + let decision = false + let completedMessage: CompleteMessage | undefined + + socket.addEventListener("error", () => { + clearTimeout(timeout) + reject(new Error("WebSocket example connection failed")) + }) + socket.addEventListener("message", event => { + const message = JSON.parse(String(event.data)) as Record + if (message.type === "hello") { + socket.send( + JSON.stringify({ + mode: "fixture", + prompt: "Build the deterministic test dashboard.", + snapshotPolicy: { mode: "final" }, + type: "start" + }) + ) + } else if (message.type === "decision") { + decision = true + } else if (message.type === "error") { + clearTimeout(timeout) + socket.close() + reject(new Error(`WebSocket example returned ${String(message.code)}`)) + } else if (message.type === "complete") { + completedMessage = message as unknown as CompleteMessage + setTimeout(() => { + clearTimeout(timeout) + socket.close() + resolve({ complete: completedMessage as CompleteMessage, decision }) + }, 25) + } else if (message.type === "status" && message.phase === "idle" && completedMessage) { + clearTimeout(timeout) + socket.close() + reject(new Error("WebSocket example sent a redundant status after completion")) + } + }) + }) +} + +test("WebSocket example enforces its local capability boundary and completes a run", async () => { + const server = startExampleServer() + try { + await waitForServer() + + const page = await fetch(origin) + const html = await page.text() + expect(html).toContain("Fixture") + expect(html).toContain("OpenAI") + expect(html).toContain("Fixture scenario") + expect(html).not.toContain(">Demo<") + expect(html).not.toContain(">Live<") + + const badHost = await fetch(origin, { headers: { Host: "attacker.test" } }) + expect(badHost.status).toBe(403) + + const sessionResponse = await fetch(`${origin}/session`) + expect(sessionResponse.status).toBe(200) + const session = (await sessionResponse.json()) as SessionResponse + expect(session.token).toHaveLength(36) + + const wrongToken = await fetch( + `${origin}/ws?token=${encodeURIComponent(`${session.token}x`)}`, + { + headers: { Origin: origin } + } + ) + expect(wrongToken.status).toBe(403) + + const missingOrigin = await fetch(`${origin}/ws?token=${encodeURIComponent(session.token)}`) + expect(missingOrigin.status).toBe(403) + const badOrigin = await fetch(`${origin}/ws?token=${encodeURIComponent(session.token)}`, { + headers: { Origin: "http://attacker.test" } + }) + expect(badOrigin.status).toBe(403) + const validPreflight = await fetch(`${origin}/ws?token=${encodeURIComponent(session.token)}`, { + headers: { Origin: origin } + }) + expect(validPreflight.status).toBe(426) + + const result = await runFinalGeneration(session.token) + expect(result.decision).toBe(true) + expect(result.complete.snapshots).toBe(1) + expect(result.complete.output.brief).toBe( + "Customer-facing release with three open checks and an approval gate." + ) + } finally { + server.kill() + await server.exited + } +}, 20_000) diff --git a/tests/workflow-security.test.ts b/tests/workflow-security.test.ts index 2bea35c..5dc4114 100644 --- a/tests/workflow-security.test.ts +++ b/tests/workflow-security.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { join } from "node:path" const workflowDirectory = join(import.meta.dir, "..", ".github", "workflows") -const workflowNames = ["ci.yml", "publish.yml", "release-pr.yml"] as const +const workflowNames = ["ci.yml", "live-e2e.yml", "publish.yml", "release-pr.yml"] as const const actionReferencePattern = /^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm const immutableActionReferencePattern = /^[^@\s]+@[0-9a-f]{40}$/ const pullRequestTriggerPattern = /^\s*pull_request:\s*$/m @@ -11,6 +11,7 @@ const persistedCredentialsPattern = /persist-credentials: false/g const publishEnvironmentPattern = /^\s+environment: PUBLISH$/m const publishPermissionsPattern = /^\s+permissions:\n\s+contents: read\n\s+id-token: write$/m const releasePermissionsPattern = /^permissions:\n\s+contents: write\n\s+pull-requests: write$/m +const openAiSecretPattern = /OPENAI_API_KEY: \$\{\{ secrets\.OPENAI_API_KEY \}\}/ function readWorkflow(name: (typeof workflowNames)[number]): Promise { return Bun.file(join(workflowDirectory, name)).text() @@ -39,6 +40,19 @@ describe("GitHub Actions security", () => { expect(workflow.match(persistedCredentialsPattern)).toHaveLength(2) }) + test("keeps live model credentials off pull requests", async () => { + const workflow = await readWorkflow("live-e2e.yml") + + expect(workflow).toContain("workflow_dispatch:") + expect(workflow).toContain("schedule:") + expect(workflow).not.toMatch(pullRequestTriggerPattern) + expect(workflow).not.toContain("pull_request_target") + expect(workflow).toContain("if: github.repository == 'hack-dance/schema-stream'") + expect(workflow).toMatch(readOnlyPermissionsPattern) + expect(workflow).toContain("persist-credentials: false") + expect(workflow).toMatch(openAiSecretPattern) + }) + test("limits trusted publishing to canonical release paths", async () => { const workflow = await readWorkflow("publish.yml")