schema-stream

schema-stream

Progressively parse streamed JSON into typed, schema-shaped snapshots.

Documentation · Examples · Approach · Playground

Parse JSON while it is still arriving. schema-stream turns a Web Stream or async iterable into typed, schema-shaped snapshots, so a UI can render partial strings, nested objects, and arrays before the response is complete.

  • SDK-neutral iterate() API for Web Streams and async iterables
  • 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 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

Snapshots are progressive parser output, not validation results. schema-stream uses the schema for types and initial placeholders, but it does not validate partial or final values. Validate the settled value with your schema or use the producing SDK's validated result.

Schema Stream progressing from raw JSON chunks to completion events, snapshot policies, and a materialized dashboard

One stream, many snapshots

Every value yielded by iterate() is a complete, independent snapshot. If this JSON arrives in small chunks:

{"title":"Hello","details":{"score":42}}

the snapshots can progress like this:

{"title":"Hel","details":{"score":null}}
{"title":"Hello","details":{"score":null}}
{"title":"Hello","details":{"score":42}}

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

npm install schema-stream zod
bun add schema-stream zod

Progressive JSON

import { SchemaStream, type SchemaStreamChunk } from "schema-stream"
import { z } from "zod"

const schema = z.object({
  summary: z.string(),
  details: z.object({ score: z.number() }),
  tags: z.array(z.string())
})

const parser = new SchemaStream(schema, {
  onValueComplete({ path, value }) {
    if (path[0] === "details" && path[1] === "score") {
      routeScore(value)
    }
  }
})

for await (const partial of parser.iterate(response.body!)) {
  const update: SchemaStreamChunk<typeof schema> = partial
  renderProgress(update)
}

iterate() accepts ReadableStream<string | Uint8Array> or AsyncIterable<string | Uint8Array>. It preserves backpressure, handles UTF-8 code points split across byte chunks, cancels the source when iteration ends early, and yields an independent snapshot for every input chunk.

Snapshot cadence is opt-in and shared with parse(). Omitting the option retains one snapshot per input chunk:

parser.iterate(source, { snapshotPolicy: { mode: "value" } })
parser.iterate(source, { snapshotPolicy: { mode: "bytes", bytes: 256 * 1024 } })
parser.iterate(source, { snapshotPolicy: { mode: "final" } })

See snapshot policies for exact semantics and performance tradeoffs. See completion events 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.

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 http://127.0.0.1:3400:

bun run example:websocket

See the transport guide and the repository's Bun WebSocket UI for an interactive localhost visualization of the message protocol and snapshot-policy controls.

OpenAI Agents SDK

Pass the Agents SDK text stream directly to iterate():

import { Agent, run } from "@openai/agents"
import { SchemaStream } from "schema-stream"
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({
  name: "Analyst",
  model: "gpt-5.6-luna",
  instructions: "Return a structured analysis.",
  outputType: outputSchema
})

const result = await run(agent, input, { stream: true })
const parser = new SchemaStream(outputSchema)

for await (const partial of parser.iterate(result.toTextStream())) {
  renderProgress(partial)
}

await result.completed
const finalOutput = result.finalOutput

The progressive chunks are for immediate UX. After completed resolves, finalOutput is the Agents SDK's authoritative schema-validated result.

Vercel AI SDK

Pass streamText().textStream to SchemaStream to receive schema-shaped defaults and updates inside incomplete strings and nested values:

import { Output, streamText } from "ai"
import { SchemaStream } from "schema-stream"
import { z } from "zod"

const outputSchema = z.object({
  summary: z.string(),
  details: z.object({ score: z.number() }),
  tags: z.array(z.string())
})

const result = streamText({
  model: "openai/gpt-5.6-luna",
  output: Output.object({ schema: outputSchema }),
  prompt: input
})

const parser = new SchemaStream(outputSchema)

for await (const partial of parser.iterate(result.textStream)) {
  renderProgress(partial)
}

const finalOutput = await result.output

AI SDK's partialOutputStream is a good fit when its partial-object semantics are sufficient. 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:

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 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 for the commands and environment contract.

Zod compatibility

The peer range is zod@^3.25.0 || ^4.0.0.

// Zod 4 Classic
import { z } from "zod"

// Zod Mini
import * as z from "zod/mini"

// Zod 3.25+
import { z } from "zod/v3"

Schema-derived stubs support objects, arrays, records, strings, numbers, booleans, enums, defaults, 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 events

const parser = new SchemaStream(schema, {
  defaultData: {
    summary: "Waiting for the model..."
  },
  typeDefaults: {
    string: "",
    number: null,
    boolean: null
  },
  onValueComplete({ path, value }) {
    routeCompletedValue(path, value)
  }
})

Zod defaults are used when present. defaultData overrides individual fields, including falsy 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

parse() remains available for pipelines that need serialized JSON snapshots:

const transform = new SchemaStream(schema).parse()
const snapshots = response.body!.pipeThrough(transform)

for await (const bytes of snapshots) {
  const partial = JSON.parse(new TextDecoder().decode(bytes))
  renderProgress(partial)
}

parse() accepts the same snapshotPolicy option as iterate().

Development

mise install
bun install
bun run format
bun run lint
bun run examples
bun run docs:dev
bun run docs:check
bun run docs:build
bun run check

mise.toml pins Bun 1.3.14 and the current Node 24 release. Maintainers type-check and emit declarations with TypeScript 7.0.2. TypeScript is a development-only dependency, so installing schema-stream does not install or require TypeScript 7.

Ultracite and Biome own formatting and linting. bun run format applies safe fixes, bun run lint checks the repository without writing, and bun run check includes linting before type checks, tests, packed-consumer verification, generated-doc drift checks, and the production docs build.

The checked-in Markdown in README.md, public guides under docs/, and CHANGELOG.md is the documentation source of truth for both GitHub and schema.stream. Public API reference Markdown is generated from exported TSDoc, and site/content/docs is an ignored build-stage copy. Run bun run docs:generate after changing exports or public TSDoc; never edit the staged site content directly. Contributor guidance and engineering evidence remain repository-only material.

test:packed installs the generated tarball into clean consumers and verifies ESM, CommonJS, 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

MIT

On this page