fix: fetch cache key generation — handle all body types, include all headers minus blocklist#111
Conversation
…headers minus blocklist - Make buildFetchCacheKey async to support stream/blob body serialization - Handle all BodyInit types: string, Uint8Array, ReadableStream, FormData, Blob, URLSearchParams - Switch header strategy from allowlist (3 auth headers) to blocklist (traceparent/tracestate), matching Next.js - Include RequestInit fields (mode, redirect, credentials, etc.) in cache key - SHA-256 hash the cache key for compact, deterministic storage - Fix spent stream bug: restore _ogBody in stripNextFromInit so originalFetch gets usable body - Add 19 new tests covering body types, header inclusion, body restoration, and URLSearchParams
commit: |
|
|
/bigbonk review this PR |
There was a problem hiding this comment.
Review Summary
Solid, well-tested bug fix PR. All three bugs are real and the fixes are correct. The approach of switching from an allowlist to a blocklist for header inclusion matches Next.js behavior and is the right call. 52 tests pass (19 new).
What's Good
- Comprehensive test coverage: 19 new tests covering all body types (string, Uint8Array, Blob, FormData, ReadableStream, URLSearchParams), header edge cases (Accept, custom headers, trace header exclusion, order independence), body restoration, and cache key determinism.
- SHA-256 hashing: Moving from plaintext keys (
fetch:GET:url|body|auth:...) to SHA-256 hashed keys is good practice — prevents key injection and keeps key length bounded. CACHE_KEY_PREFIX = "v1": Smart approach for future cache-busting on key format changes.- Bug 3 fix is subtle and important: The
_ogBodystashing inserializeBody+ restoration instripNextFromInitis a clean solution for the consumed-stream problem. Without this, every cached ReadableStream request would send a spent stream to the network. collectHeadersnormalizes through theHeadersAPI (line 57-59), which sorts by header name per spec. This makes the cache key deterministic regardless of property insertion order in plain objects.
Issues
Stream error handling leaves request in broken state (non-blocking)
serializeBody at fetch-cache.ts:104-132: if readableBody.pipeTo() fails, the catch block logs but doesn't set _ogBody. This means:
bodyChunksmay contain partial data → cache key generated from incomplete body_ogBodyis never set →stripNextFromInitwon't find it → the original (now consumed) stream is passed tooriginalFetch→ network request fails with a "body already consumed" error
The catch block should either re-throw (so the fetch falls through to the original unpatched path) or set _ogBody to the partial data so the request at least has something to send. As-is, a stream read failure during cache key generation silently breaks the actual fetch call.
} catch (err) {
console.error("[vinext] Problem reading body for cache key", err);
// Still reconstruct what we have so originalFetch doesn't get a spent stream
if (chunks.length > 0) {
const length = chunks.reduce((total, arr) => total + arr.length, 0);
const partial = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
partial.set(chunk, offset);
offset += chunk.length;
}
(init as any)._ogBody = partial;
}
}
FormData File serialization doesn't include metadata (informational)
fetch-cache.ts:146-149: File objects in FormData are serialized via val.text(), which reads content but ignores name, lastModified, and type. Two Files with identical content but different names produce the same cache key. This is an edge case (caching FormData with File uploads is unusual) but worth a code comment for future reference.
Fixture rebuild (entry.js)
The tests/fixtures/pages-basic/dist/server/entry.js change (+111/-29) is a rebuilt production bundle reflecting the fetch-cache changes. This is expected since this module is bundled into it. Just confirming this file is intentionally tracked in the repo.
Verdict
Approving — the fixes are correct, well-tested, and the approach matches Next.js semantics. The stream error handling suggestion is non-blocking and can be addressed in a follow-up.
|
Review posted. Here's a summary of my findings: Approved PR #111 with the following observations: All 52 tests pass (19 new), covering body type handling, header inclusion/exclusion, body restoration, and URLSearchParams. The three bug fixes are correct:
One non-blocking suggestion: If |
Replace simple auth-header extraction with a unified header collector, add a header blocklist and cache version prefix, and introduce async cache key building. serializeBody now handles various body types (Uint8Array, streams, URLSearchParams, FormData, Blob, string), preserves the original body on init._ogBody, and body content is included in the cache key. The cache key is generated by normalizing URL, method, headers and init options and hashing them with SHA-256. buildFetchCacheKey is now async and awaited where used, and stripNextFromInit restores the preserved original body. Also add hasAuthHeaders and move auth header list into a constant.
8549643 to
84a5ca7
Compare
|
/bigbonk revew this pr again |
There was a problem hiding this comment.
Review Summary
All three bug fixes are correct and well-implemented. 52/52 tests pass. The previous review's non-blocking suggestion (stream error handling) has already been addressed in the current code. Approving.
Analysis
Bug 1: Binary/FormData bodies — Correct
serializeBody() at fetch-cache.ts:89-178 correctly handles all BodyInit types with appropriate serialization for each. The type detection ordering matters and is correct:
Uint8Array(direct decode)ReadableStream(duck-typed viagetReader)URLSearchParams(checked before FormData since both have.keys(), but USP has.toString())FormData(duck-typed via.keys())Blob(duck-typed via.arrayBuffer())string(fallback)
Bug 2: Header blocklist — Correct
collectHeaders() at fetch-cache.ts:47-69 merges Request + init headers with correct precedence (init wins), normalizes through the Headers API which sorts by name per spec, then strips only traceparent/tracestate. This matches Next.js behavior.
Bug 3: Body restoration — Correct
stripNextFromInit() at fetch-cache.ts:481-491 destructures _ogBody from init and restores it as body before passing to originalFetch. The stream error catch block at lines 130-142 now also sets _ogBody with partial data, addressing the previous review's suggestion.
Cache key hashing — Correct
buildFetchCacheKey() at fetch-cache.ts:188-228 includes CACHE_KEY_PREFIX, URL, method, all headers, relevant RequestInit properties (mode, redirect, credentials, referrer, referrerPolicy, integrity, cache), and serialized body chunks in a JSON array, then SHA-256 hashes it. The prefix enables future cache-busting on format changes.
Fixture rebuild
The entry.js change is a production bundle reflecting the source changes — expected and correct.
One observation (informational, not blocking)
The collectHeaders approach of building a plain object merged and then JSON-serializing it in buildFetchCacheKey relies on the fact that Headers.forEach iterates in sorted order per spec. If headers were added via the plain object path (init.headers is a Record<string, string>), they go through new Headers() which normalizes and sorts them. This is correct but worth noting: the determinism guarantee comes from the Headers constructor, not from JSON.stringify property ordering.
Co-authored-by: threepointone <threepointone@users.noreply.github.com>
|
PR #111 reviewed and approved. All 52 tests pass (19 new). The three bug fixes are correct:
The SHA-256 hashing with |
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com> Co-authored-by: southpolesteve <southpolesteve@users.noreply.github.com>
Summary
Fixes two bugs in the fetch cache key generation and adds a third bug fix discovered during review.
Bug 1: Binary/FormData bodies ignored in cache key
buildFetchCacheKeyonly handledstringbodies. Requests withUint8Array,ReadableStream,FormData,Blob, orURLSearchParamsbodies all produced the same cache key regardless of body content — causing incorrect cache hits.Fix:
serializeBody()now handles allBodyInittypes.buildFetchCacheKeyis now async to support stream/blob consumption.Bug 2: Header inclusion strategy inverted vs Next.js
We used an allowlist of 3 auth headers (
authorization,cookie,x-api-key). Next.js uses a blocklist (traceparent,tracestate) and includes everything else. This meant headers likeAccept,Accept-Language, and custom headers were ignored — different requests could incorrectly share cache entries.Fix: Switched to blocklist approach matching Next.js. All headers are now included in the cache key except W3C trace context headers.
Bug 3: Consumed stream body sent to network (found during review)
After
serializeBodyconsumed aReadableStreambody for cache key generation, the spent stream was still passed tooriginalFetch(). The reconstructed body (_ogBody) was stashed but never restored.Fix:
stripNextFromInitnow restores_ogBodyas the body before passing tooriginalFetch.Additional improvements
mode,redirect,credentials,referrer,referrerPolicy,integrity,cachefromRequestInitCACHE_KEY_PREFIX = "v1"for future cache-bustingURLSearchParamshandled separately fromFormData(different APIs)Tests
19 new tests (52 total):
originalFetchgets usable body after key generation)Not breaking
All changes are internal. Public API unchanged. Only user-visible effect: one-time cold cache after upgrade (new key format).