Stream reads over HTTP Range requests and from Blobs - #139
Merged
OBrezhniev merged 15 commits intoAug 27, 2026
Conversation
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
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
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
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01833VaUEJmrFZ7bVprrWwpp
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stream reads over HTTP Range requests and from Blobs
Stacked on #138 (
feature/direct_rw_optimization) — this PRshows only the two streaming commits.
Summary
readExistingwith 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:
GETwithRange: bytes=0-0. A206gives rangesupport, the total size (from
Content-Range), and a validator (strongETag, elseLast-Modified) in a single round trip. Anything else fallsback to the historical buffer-it-all behavior, reusing the probe's own
response body (no second fetch).
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.
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.
If-Rangewith the validatorcaptured at open. If the remote file changes mid-read, the server answers
200and the read throws instead of silently mixing chunks of two fileversions.
readExisting(blob)streams viablob.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 bya positioned
readRangeInto(dst, dstOffset, pos, len)primitive. Writeoperations throw; the transport is strictly read-only.
Server requirements (CORS deployments)
Rangerequest header; exposeContent-Range,ETag,Accept-Ranges.Content-Encoding(ranges address encoded bytes; zkeycontent 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