Skip to content

Add streaming bundle reader and incremental JSON parser - #184

Merged
ChALkeR merged 2 commits into
mainfrom
claude/focused-noether-0q8ffx
Oct 2, 2026
Merged

ChALkeR merged 2 commits into
mainfrom
claude/focused-noether-0q8ffx

Conversation

@exo-nikita

@exo-nikita exo-nikita commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR adds @exodus/stasis/bundle-reader, a streaming reader for stasis bundles. It decompresses and parses the bundle chunk by chunk, so neither the decompressed bytes nor the JSON text are ever held whole. It is opt-in: the stasis commands keep the one-shot Bundle.parse path. The stasis-core support it builds on, Bundle.fromJSON(…, { contents: false }) and Bundle.fileKeyAt, landed in #188.

Key Changes

  • JsonStreamParser (stasis/src/json-stream.js): a pure-JavaScript incremental JSON parser over UTF-8 bytes. For any chunking, it gives exactly the result of JSON.parse(Buffer.concat(chunks).toString('utf8')). That covers the accepted inputs, key order, how a repeated key resolves, __proto__, BOMs and invalid UTF-8. It holds only the value being built and one token in flight. An onString(value, path) hook lets a caller take a string out of the tree as soon as it completes.
  • readBundle(source, { onFile, signal }) (stasis/src/bundle-reader.js):
    • source is a path or file URL, any ArrayBuffer or view, or an (async) iterable of compressed chunks. Bytes after the end of the brotli stream are ignored, as brotliDecompressSync ignores them.
    • Without onFile, it builds the same Bundle as Bundle.parse.
    • With onFile, each file's contents go to await onFile(file, contents, { signal }) one at a time, in stream order, keyed by Bundle.fileKeyAt. A placeholder is left in their place, and Bundle.fromJSON(tree, { contents: false }) validates the bundle and returns it contents-free. A file delivered twice, or from outside the bundle's file list, is rejected.
    • Files stream wherever the bundle puts them. Newer bundles write sources and modules after the metadata, older ones before it, and both are supported.
    • An abort rejects at once, even while onFile is running.
  • Package exports: stasis/package.json exports ./bundle-reader.
  • Docs: a "Streaming reader" section in doc/file-formats.md.

Tests

  • tests/json-stream.test.js: differential tests against JSON.parse under many chunkings, plus edge cases, UTF-8 handling and seeded fuzzing. A mismatch is re-checked in a fresh process, which works around a V8 key-caching bug in Node 24+.
  • tests/bundle-reader.test.js, covering:
    • every source kind, parity with Bundle.parse (v1 and v0), and onFile order and backpressure;
    • the same bundle with its files after the metadata and before it;
    • abort, including during an in-flight onFile, and trailing bytes after the brotli stream;
    • rejection of non-canonical paths, repeated payloads, and bundles whose streamed files differ from their file list.
  • Local runs: node --run lint is clean and node --run test passes all 85 suites.

Notable Implementation Details

  • Brotli decompression uses 1 MiB output chunks and a 4 MiB buffer, so zlib's thread pool decompresses ahead while the parser runs. With zlib's default 16 KiB chunks, the read takes about twice as long.
  • onFile calls run one after another, which keeps stream order and applies backpressure to the decompressor.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u

claude added 2 commits October 2, 2026 10:41
readBundle(source, { onFile, signal }) decompresses and parses a
stasis.code.br chunk by chunk, so neither the decompressed bytes nor the
JSON text are ever held whole. It is opt-in: the stasis commands keep
the one-shot Bundle.parse path.

- Without onFile, it builds the same Bundle as Bundle.parse, through an
  incremental JSON parser (json-stream.js) that matches JSON.parse
  exactly.
- With onFile, each file's contents go to
  `await onFile(file, contents, { signal })`, one at a time in stream
  order, keyed by Bundle.fileKeyAt. A symbol placeholder is left in
  their place, and Bundle.fromJSON(tree, { contents: false }) validates
  the bundle and returns it contents-free. A file delivered twice, or
  from outside the bundle's file list, is rejected.
- Sources: a path or file URL, any ArrayBuffer or view, or an (async)
  iterable of chunks. Bytes after the brotli stream are ignored, as
  brotliDecompressSync ignores them. An abort rejects at once, even
  while onFile runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
…e orders

Comments now keep only what the code doesn't say: why the reader
rejects on abort without waiting for onFile, the zlib chunk size, why
bytes after the brotli stream are ignored, and the parser's
exact-JSON.parse contract. The API itself is described in
doc/file-formats.md.

Newer bundles write `sources` and `modules` after the metadata, older
ones before it. A new test reads the same bundle in both orders, with
and without onFile, and the doc says files stream wherever the bundle
puts them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FinLHpaKeRRjstqzMnwj6u
@exo-nikita
exo-nikita force-pushed the claude/focused-noether-0q8ffx branch from 54ea6ce to bbc5d64 Compare October 2, 2026 10:44
@ChALkeR
ChALkeR merged commit 5805952 into main Oct 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants