Skip to content

Stream reads over HTTP Range requests and from Blobs - #139

Merged
OBrezhniev merged 15 commits into
feature/direct_rw_optimizationfrom
feature/url-blob-streaming
Aug 27, 2026
Merged

Stream reads over HTTP Range requests and from Blobs#139
OBrezhniev merged 15 commits into
feature/direct_rw_optimizationfrom
feature/url-blob-streaming

Conversation

@OBrezhniev

Copy link
Copy Markdown
Member

Stream reads over HTTP Range requests and from Blobs

Stacked on #138 (feature/direct_rw_optimization) — this PR
shows only the two streaming commits.

Summary

readExisting with a URL previously buffered the entire response in memory
(the browser path fetched any string whole; Node had no URL support at all).
That defeated chunked zkey streaming for browser provers: the full zkey was
resident before the first section read.

Now a URL opens a range-backed read-only fd:

  • Open probe: one GET with Range: bytes=0-0. A 206 gives range
    support, the total size (from Content-Range), and a validator (strong
    ETag, else Last-Modified) in a single round trip. Anything else falls
    back to the historical buffer-it-all behavior, reusing the probe's own
    response body (no second fetch).
  • Large reads (≥ page size) become one Range request each, streaming the
    response body directly into the caller's buffer (typed array or BigBuffer)
    — no second full-size copy. With snarkjs' chunked section readers, the
    zkey's point sections are never resident as a whole.
  • Small header reads (binfile magic, section table scans) coalesce into
    page-aligned cached ranges (LRU) instead of issuing one HTTP request per
    4-byte field. Pages are capped at 64 KiB regardless of the caller's
    disk-tuned pageSize hints — snarkjs passes 8 MiB pages, which would
    otherwise turn a 4-byte header read into a whole-file range request.
  • Consistency: every range request carries If-Range with the validator
    captured at open. If the remote file changes mid-read, the server answers
    200 and the read throws instead of silently mixing chunks of two file
    versions.
  • Blob/File backend: readExisting(blob) streams via
    blob.slice(pos, pos+len).arrayBuffer() — a browser <input type="file">
    zkey reads from disk chunk-by-chunk with the same bounded footprint as the
    Node file backend (pages capped at 1 MiB).

Both backends share one implementation (src/rangefile.js), parameterized by
a positioned readRangeInto(dst, dstOffset, pos, len) primitive. Write
operations throw; the transport is strictly read-only.

Server requirements (CORS deployments)

  • Allow the Range request header; expose Content-Range, ETag,
    Accept-Ranges.
  • Serve without Content-Encoding (ranges address encoded bytes; zkey
    content is high-entropy anyway). S3/GCS/CloudFront work out of the box.

Validation

npm test: 31 passing, including 10 new tests against local HTTP servers:
range streaming with request accounting (no full-file GET ever issued),
single-request page-cache reuse, sequential unpositioned reads, the
no-range-support fallback (exactly one request), mid-session file-change
rejection via If-Range, out-of-bounds/write rejection, string reads through
the page cache, the disk-hint page-size cap, and Blob positioned reads.

Downstream, binfileutils gained URL round-trip tests (header scan + full and
partial section reads in both server modes) and snarkjs an e2e test proving
groth16 with the zkey served by a local range server — proof verifies, every
request is a Range request, and no single response carries the whole zkey.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wo6AVSAwvL9mHTpvnREZPR

OBrezhniev and others added 15 commits July 12, 2026 11:18
readExisting with a URL previously buffered the entire body in memory
(browser: any string; Node: unsupported). Now a Range probe (bytes=0-0)
routes to a paged read-only backend: large reads stream the 206 body
straight into the caller's buffer, small header reads coalesce into
cached pages. If-Range with the validator captured at open fails reads
if the remote file changes mid-session instead of mixing versions.
Servers without range support fall back to the old buffer-it-all path,
reusing the probe response body. Blob/File inputs stream via slice().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wo6AVSAwvL9mHTpvnREZPR
Callers (snarkjs) pass 8 MiB pageSize hints meant for the disk cache;
over HTTP that turned a 4-byte header read into a whole-file range
request for any file under the page size. Cap http pages at 64 KiB and
blob pages at 1 MiB; reads at/above the page size already bypass the
cache, so large section reads are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wo6AVSAwvL9mHTpvnREZPR
Double-closing an osfile threw "Closing the file twice" synchronously,
forcing every consumer's cleanup path (finally blocks in snarkjs) to
wrap close() in try/catch. Repeated calls now return the same promise
(including the same rejection when a final flush fails), and the http
range backend's close() is a no-op on repeat. Reads and writes after
close still fail fast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wo readString bugs

New test/api_surface.test.js (34 tests) covers the dispatcher variants
(createOverride/createNoOverride/readExisting/readWriteExisting/
readWriteExistingOrCreate for mem, bigMem, file, blob and invalid types),
the ULE32/UBE32/ULE64/readString helpers on every backend, error paths
(out-of-bounds reads, writes to read-only backends, read-after-close,
short/overlong/misaligned HTTP range responses, failing probes, 416 and
unknown-total fallbacks, cache eviction, failed page-load retry).

Two genuine readString bugs found by the new tests, both fixed:
- memfile: reading a string past the written data on a writable file
  built a negative-length typed-array view and threw a RangeError; it
  now returns "" (read-only files still reject out of bounds).
- bigmemfile: an unterminated string spun forever re-reading an empty
  window at the end of the data; it now ends the string at EOF,
  matching the rangefile backend.

c8-ignored with reasons: osfile's logHistory debug instrumentation and
httpfile's non-streaming fetch fallbacks (Node's undici always streams).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
A 206 answer to the probe can come from an intermediary -- notably a
browser's HTTP cache, which satisfies range requests out of a cached
full 200 -- while the origin itself ignores Range and answers 200.
The range reader treated any mid-session 200 as fatal ('file changed'),
which broke snarkjs's in-browser URL streaming against st-style static
servers. A 200 whose strong validator still matches now hands its body
to the reader, which buffers it once and serves all further reads from
memory; a changed validator remains a hard error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
…ent-close)

Port of the feature/esm-tooling migration (fde21ad) onto the current branch:
Rollup -> Vite (build/node/main.cjs + browser esm/iife), Mocha/Chai -> Vitest
(node-esm / node-cjs / browser Playwright projects), flat eslint.config.js.
Everything since the original fork point is preserved:

- src/fastfile.browser.js adopts the explicit browser/node split but routes
  strings through the HTTP Range-streaming backend and Blobs through the blob
  backend (the original migration fetched URLs whole, regressing the
  streaming work); constants.browser.js and the browser field are gone.
- Our post-fork suites (api_surface, httpfile, blobfile, osfile additions)
  converted: chai/chai-as-promised -> vitest expect, rejectedWith ->
  rejects.toThrow, this.timeout dropped.
- The browser URL test now mocks a 200 no-Range server (the backend probes
  with Range: bytes=0-0 and reuses the full body on fallback).
- ffjavascript devDep pinned to 4ac1cba (Vite+Vitest migration landed there);
  vitest family at ^4.1.11 (GHSA-p63j-vcc4-9vmv); postcss/brace-expansion
  audit overrides added (npm audit clean).

72 tests pass (64 node incl. CJS-build project, 8 browser in Chromium);
lint clean.

(cherry picked from commit fde21ad)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
.vitest-attachments/ and test/__screenshots__/ are debris from a failing
browser-test run that git add -A swept into the migration commit; with no
"files" field they would ship into every consumer's node_modules on a git
install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
The Chromium suite verified URL fetching but not Blob reads; the blob
backend (page-cached small reads + direct large slices) is the primary
browser use case, so exercise both read shapes against a 64 KiB Blob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
README now covers the page cache, the five backends (file/mem/bigMem/
http/blob) with examples for each, the API surface, browser resolution via
the exports map, and idempotent close. Copyright updated to 2018-2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
@OBrezhniev
OBrezhniev merged commit 306d759 into feature/direct_rw_optimization Aug 27, 2026
7 checks passed
@OBrezhniev
OBrezhniev deleted the feature/url-blob-streaming branch August 27, 2026 13:50
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.

1 participant