From 0ac97ab1832d2286cec53fb5edb3dadd2ec89884 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 15:21:23 -0600 Subject: [PATCH 1/7] Fix fetch cache key collisions for Request and FormData bodies --- packages/vinext/src/shims/fetch-cache.ts | 87 +++++++++++++++++------- tests/fetch-cache.test.ts | 85 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 25 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index f074af9e..877bbfed 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -87,14 +87,42 @@ function hasAuthHeaders(input: string | URL | Request, init?: RequestInit): bool return AUTH_HEADERS.some((name) => name in headers); } +async function serializeFormData( + formData: FormData, + pushBodyChunk: (chunk: string) => void, + getTotalBodyBytes: () => number, +): Promise { + for (const key of new Set(formData.keys())) { + const values = formData.getAll(key); + const serializedValues = await Promise.all( + values.map(async (val) => { + if (typeof val === "string") { + return { kind: "string", value: val }; + } + if (val.size > MAX_CACHE_KEY_BODY_BYTES || getTotalBodyBytes() + val.size > MAX_CACHE_KEY_BODY_BYTES) { + throw new BodyTooLargeForCacheKeyError(); + } + return { + kind: "file", + // Note: File name/type/lastModified are not included — only content. + // Two Files with identical content but different names produce the same key. + value: await val.text(), + }; + }), + ); + pushBodyChunk(JSON.stringify([key, serializedValues])); + } +} + /** * Serialize request body into string chunks for cache key inclusion. - * Handles all body types: string, Uint8Array, ReadableStream, FormData, Blob. + * Handles all body types: string, Uint8Array, ReadableStream, FormData, Blob, + * and Request object bodies. * Returns the serialized body chunks and optionally stashes the original body * on init as `_ogBody` so it can still be used after stream consumption. */ -async function serializeBody(init?: RequestInit): Promise { - if (!init?.body) return []; +async function serializeBody(input: string | URL | Request, init?: RequestInit): Promise { + if (!init?.body && !(input instanceof Request && input.body)) return []; const bodyChunks: string[] = []; const encoder = new TextEncoder(); @@ -108,14 +136,15 @@ async function serializeBody(init?: RequestInit): Promise { } bodyChunks.push(chunk); }; + const getTotalBodyBytes = (): number => totalBodyBytes; - if (init.body instanceof Uint8Array) { + if (init?.body instanceof Uint8Array) { if (init.body.byteLength > MAX_CACHE_KEY_BODY_BYTES) { throw new BodyTooLargeForCacheKeyError(); } pushBodyChunk(decoder.decode(init.body)); (init as any)._ogBody = init.body; - } else if (typeof (init.body as any).getReader === "function") { + } else if (init?.body && typeof (init.body as any).getReader === "function") { // ReadableStream const readableBody = init.body as ReadableStream; const [bodyForHashing, bodyForFetch] = readableBody.tee(); @@ -149,30 +178,16 @@ async function serializeBody(init?: RequestInit): Promise { } console.error("[vinext] Problem reading body for cache key", err); } - } else if (init.body instanceof URLSearchParams) { + } else if (init?.body instanceof URLSearchParams) { // URLSearchParams — .toString() gives a stable serialization (init as any)._ogBody = init.body; pushBodyChunk(init.body.toString()); - } else if (typeof (init.body as any).keys === "function") { + } else if (init?.body && typeof (init.body as any).keys === "function") { // FormData const formData = init.body as FormData; (init as any)._ogBody = init.body; - for (const key of new Set(formData.keys())) { - const values = formData.getAll(key); - const serializedValues = await Promise.all( - values.map(async (val) => { - if (typeof val === "string") return val; - if (val.size > MAX_CACHE_KEY_BODY_BYTES || totalBodyBytes + val.size > MAX_CACHE_KEY_BODY_BYTES) { - throw new BodyTooLargeForCacheKeyError(); - } - // Note: File name/type/lastModified are not included — only content. - // Two Files with identical content but different names produce the same key. - return await val.text(); - }) - ); - pushBodyChunk(`${key}=${serializedValues.join(",")}`); - } - } else if (typeof (init.body as any).arrayBuffer === "function") { + await serializeFormData(formData, pushBodyChunk, getTotalBodyBytes); + } else if (init?.body && typeof (init.body as any).arrayBuffer === "function") { // Blob const blob = init.body as Blob; if (blob.size > MAX_CACHE_KEY_BODY_BYTES) { @@ -181,7 +196,7 @@ async function serializeBody(init?: RequestInit): Promise { pushBodyChunk(await blob.text()); const arrayBuffer = await blob.arrayBuffer(); (init as any)._ogBody = new Blob([arrayBuffer], { type: blob.type }); - } else if (typeof init.body === "string") { + } else if (typeof init?.body === "string") { // String length is always <= UTF-8 byte length, so this is a // cheap lower-bound check that avoids encoder.encode() for huge strings. if (init.body.length > MAX_CACHE_KEY_BODY_BYTES) { @@ -189,6 +204,28 @@ async function serializeBody(init?: RequestInit): Promise { } pushBodyChunk(init.body); (init as any)._ogBody = init.body; + } else if (input instanceof Request && input.body) { + const requestClone = input.clone(); + const contentType = requestClone.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase(); + + if (contentType === "multipart/form-data" || contentType === "application/x-www-form-urlencoded") { + try { + const formData = await input.clone().formData(); + await serializeFormData(formData, pushBodyChunk, getTotalBodyBytes); + return bodyChunks; + } catch (err) { + if (err instanceof BodyTooLargeForCacheKeyError) { + throw err; + } + console.error("[vinext] Problem reading Request form body for cache key", err); + } + } + + const arrayBuffer = await requestClone.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_CACHE_KEY_BODY_BYTES) { + throw new BodyTooLargeForCacheKeyError(); + } + pushBodyChunk(decoder.decode(new Uint8Array(arrayBuffer))); } return bodyChunks; @@ -218,7 +255,7 @@ async function buildFetchCacheKey(input: string | URL | Request, init?: RequestI if (init?.method) method = init.method; const headers = collectHeaders(input, init); - const bodyChunks = await serializeBody(init); + const bodyChunks = await serializeBody(input, init); const cacheString = JSON.stringify([ CACHE_KEY_PREFIX, diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index e48cbcb2..bdfa622e 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -380,6 +380,48 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("includes Request object bodies in the cache key", async () => { + const req1 = new Request("https://api.example.com/req-body", { + method: "POST", + body: "alpha", + headers: { "content-type": "text/plain" }, + }); + const res1 = await fetch(req1, { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const req2 = new Request("https://api.example.com/req-body", { + method: "POST", + body: "bravo", + headers: { "content-type": "text/plain" }, + }); + const res2 = await fetch(req2, { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); // Different Request body = different cache + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("same Request object bodies hit the same cache entry", async () => { + const req1 = new Request("https://api.example.com/req-body-same", { + method: "POST", + body: "same-body", + headers: { "content-type": "text/plain" }, + }); + const res1 = await fetch(req1, { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const req2 = new Request("https://api.example.com/req-body-same", { + method: "POST", + body: "same-body", + headers: { "content-type": "text/plain" }, + }); + const res2 = await fetch(req2, { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(1); // Same Request body = cached + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + // ── force-cache with next.revalidate ──────────────────────────────── it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => { @@ -807,6 +849,33 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("FormData values with commas do not collide in the cache key", async () => { + const formA = new FormData(); + formA.append("name", "a,b"); + formA.append("name", "c"); + + const formB = new FormData(); + formB.append("name", "a"); + formB.append("name", "b,c"); + + const res1 = await fetch("https://api.example.com/body-form-comma", { + method: "POST", + body: formA, + next: { revalidate: 60 }, + }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch("https://api.example.com/body-form-comma", { + method: "POST", + body: formB, + next: { revalidate: 60 }, + }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); // Different multi-value form data = different cache + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("ReadableStream bodies are included in cache key", async () => { const streamA = new ReadableStream({ start(controller) { @@ -1048,6 +1117,22 @@ describe("fetch cache shim", () => { const init = call[1] as RequestInit; expect(init.body).toBe('{"key":"value"}'); }); + + it("Request object body is still passed through after cache key generation", async () => { + const request = new Request("https://api.example.com/request-restore", { + method: "POST", + body: "request-body-content", + headers: { "content-type": "text/plain" }, + }); + + await fetch(request, { next: { revalidate: 60 } }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const call = fetchMock.mock.calls[0]; + const forwardedRequest = call[0] as Request; + expect(forwardedRequest).toBeInstanceOf(Request); + expect(await forwardedRequest.text()).toBe("request-body-content"); + }); }); describe("cache key oversized body safeguards", () => { From 13731560dc51a662e65292b142064ee7dc384e04 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 15:34:05 -0600 Subject: [PATCH 2/7] Add additional tests --- tests/fetch-cache.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index bdfa622e..a62cf922 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -422,6 +422,33 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("Request FormData values with commas do not collide in the cache key", async () => { + const formA = new FormData(); + formA.append("name", "a,b"); + formA.append("name", "c"); + + const req1 = new Request("https://api.example.com/req-form-body", { + method: "POST", + body: formA, + }); + const res1 = await fetch(req1, { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const formB = new FormData(); + formB.append("name", "a"); + formB.append("name", "b,c"); + + const req2 = new Request("https://api.example.com/req-form-body", { + method: "POST", + body: formB, + }); + const res2 = await fetch(req2, { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); // Different Request FormData body = different cache + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + // ── force-cache with next.revalidate ──────────────────────────────── it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => { From 7887a36f9e4e1009ba103df0b3ffa5970136db2d Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 16:13:55 -0600 Subject: [PATCH 3/7] Address codex feedback by reading incrementally with early exit on content-length check + regression test --- packages/vinext/src/shims/fetch-cache.ts | 65 +++++++++++++++++++++--- tests/fetch-cache.test.ts | 30 +++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 877bbfed..abc350d3 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -114,6 +114,48 @@ async function serializeFormData( } } +async function readRequestBodyChunksWithinLimit(request: Request): Promise<{ + chunks: Uint8Array[]; + contentType: string | undefined; +}> { + const contentLengthHeader = request.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = Number(contentLengthHeader); + if (Number.isFinite(contentLength) && contentLength > MAX_CACHE_KEY_BODY_BYTES) { + throw new BodyTooLargeForCacheKeyError(); + } + } + + const requestClone = request.clone(); + const contentType = requestClone.headers.get("content-type") ?? undefined; + const reader = requestClone.body?.getReader(); + if (!reader) { + return { chunks: [], contentType }; + } + + const chunks: Uint8Array[] = []; + let totalBodyBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBodyBytes += value.byteLength; + if (totalBodyBytes > MAX_CACHE_KEY_BODY_BYTES) { + throw new BodyTooLargeForCacheKeyError(); + } + + chunks.push(value); + } + } catch (err) { + void reader.cancel().catch(() => {}); + throw err; + } + + return { chunks, contentType }; +} + /** * Serialize request body into string chunks for cache key inclusion. * Handles all body types: string, Uint8Array, ReadableStream, FormData, Blob, @@ -205,12 +247,17 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): pushBodyChunk(init.body); (init as any)._ogBody = init.body; } else if (input instanceof Request && input.body) { - const requestClone = input.clone(); - const contentType = requestClone.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase(); + const { chunks, contentType } = await readRequestBodyChunksWithinLimit(input); + const normalizedContentType = contentType?.split(";")[0]?.trim().toLowerCase(); - if (contentType === "multipart/form-data" || contentType === "application/x-www-form-urlencoded") { + if (normalizedContentType === "multipart/form-data" || normalizedContentType === "application/x-www-form-urlencoded") { try { - const formData = await input.clone().formData(); + const boundedRequest = new Request(input.url, { + method: input.method, + headers: contentType ? { "content-type": contentType } : undefined, + body: new Blob(chunks.map((chunk) => chunk.slice().buffer)), + }); + const formData = await boundedRequest.formData(); await serializeFormData(formData, pushBodyChunk, getTotalBodyBytes); return bodyChunks; } catch (err) { @@ -221,11 +268,13 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): } } - const arrayBuffer = await requestClone.arrayBuffer(); - if (arrayBuffer.byteLength > MAX_CACHE_KEY_BODY_BYTES) { - throw new BodyTooLargeForCacheKeyError(); + for (const chunk of chunks) { + bodyChunks.push(decoder.decode(chunk, { stream: true })); + } + const finalChunk = decoder.decode(); + if (finalChunk) { + pushBodyChunk(finalChunk); } - pushBodyChunk(decoder.decode(new Uint8Array(arrayBuffer))); } return bodyChunks; diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index a62cf922..082bb8b8 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -1251,6 +1251,36 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("oversized Request body bypasses cache without cloning the body when content-length exceeds the limit", async () => { + const cloneSpy = vi.spyOn(Request.prototype, "clone"); + const request = new Request("https://api.example.com/large-request-stream", { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "content-length": String(1024 * 1024 + 1), + }, + body: new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + try { + const res = await fetch(request, { next: { revalidate: 60 } }); + const data = await res.json(); + + expect(data.count).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cloneSpy).not.toHaveBeenCalled(); + expect(request.bodyUsed).toBe(false); + } finally { + cloneSpy.mockRestore(); + } + }); + it("ReadableStream with many small chunks accumulating past limit bypasses cache", async () => { const chunkSize = 64 * 1024; // 64 KiB per chunk const numChunks = 17; // 17 * 64 KiB = 1088 KiB > 1 MiB From 44210a8cbb647254c56bb82d0b006cc140937ac4 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 16:26:10 -0600 Subject: [PATCH 4/7] Add regression test for already-consumed body --- tests/fetch-cache.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index 082bb8b8..ae78b0a7 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -449,6 +449,54 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("same multipart Request bodies hit the same cache entry even with different boundaries", async () => { + const makeMultipartRequest = (boundary: string) => new Request("https://api.example.com/req-form-boundary", { + method: "POST", + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + body: [ + `--${boundary}`, + 'Content-Disposition: form-data; name="name"', + "", + "same-value", + `--${boundary}--`, + "", + ].join("\r\n"), + }); + + const res1 = await fetch(makeMultipartRequest("boundary-a"), { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch(makeMultipartRequest("boundary-b"), { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("malformed multipart Request bodies bypass cache instead of hashing raw bytes", async () => { + const makeMalformedMultipartRequest = () => new Request("https://api.example.com/req-form-malformed", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=expected" }, + body: [ + "--actual", + 'Content-Disposition: form-data; name="name"', + "", + "value", + "--actual--", + "", + ].join("\r\n"), + }); + + const res1 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + // ── force-cache with next.revalidate ──────────────────────────────── it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => { From d659ed10447e2e5b421ea7214b9013904d670646 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 16:45:14 -0600 Subject: [PATCH 5/7] Harden fetch cache keying for Request form bodies + tests --- packages/vinext/src/shims/fetch-cache.ts | 77 ++++++++++++++++++++---- tests/fetch-cache.test.ts | 38 ++++++++++++ 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index abc350d3..b16b1481 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -37,7 +37,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; const HEADER_BLOCKLIST = ["traceparent", "tracestate"]; // Cache key version — bump when changing the key format to bust stale entries -const CACHE_KEY_PREFIX = "v2"; +const CACHE_KEY_PREFIX = "v3"; const MAX_CACHE_KEY_BODY_BYTES = 1024 * 1024; // 1 MiB class BodyTooLargeForCacheKeyError extends Error { @@ -46,6 +46,12 @@ class BodyTooLargeForCacheKeyError extends Error { } } +class SkipCacheKeyGenerationError extends Error { + constructor() { + super("Fetch body could not be serialized for cache key generation"); + } +} + /** * Collect all headers from the request, excluding the blocklist. * Merges headers from both the Request object and the init object, @@ -114,6 +120,33 @@ async function serializeFormData( } } +type ParsedFormContentType = "multipart/form-data" | "application/x-www-form-urlencoded"; + +function getParsedFormContentType(contentType: string | undefined): ParsedFormContentType | undefined { + const mediaType = contentType?.split(";")[0]?.trim().toLowerCase(); + if (mediaType === "multipart/form-data" || mediaType === "application/x-www-form-urlencoded") { + return mediaType; + } + return undefined; +} + +function stripMultipartBoundary(contentType: string): string { + const [type, ...params] = contentType.split(";"); + const keptParams = params + .map((param) => param.trim()) + .filter(Boolean) + .filter((param) => !/^boundary\s*=/i.test(param)); + const normalizedType = type.trim().toLowerCase(); + return keptParams.length > 0 + ? `${normalizedType}; ${keptParams.join("; ")}` + : normalizedType; +} + +interface SerializedBodyResult { + bodyChunks: string[]; + canonicalizedContentType?: string; +} + async function readRequestBodyChunksWithinLimit(request: Request): Promise<{ chunks: Uint8Array[]; contentType: string | undefined; @@ -163,13 +196,16 @@ async function readRequestBodyChunksWithinLimit(request: Request): Promise<{ * Returns the serialized body chunks and optionally stashes the original body * on init as `_ogBody` so it can still be used after stream consumption. */ -async function serializeBody(input: string | URL | Request, init?: RequestInit): Promise { - if (!init?.body && !(input instanceof Request && input.body)) return []; +async function serializeBody(input: string | URL | Request, init?: RequestInit): Promise { + if (!init?.body && !(input instanceof Request && input.body)) { + return { bodyChunks: [] }; + } const bodyChunks: string[] = []; const encoder = new TextEncoder(); const decoder = new TextDecoder(); let totalBodyBytes = 0; + let canonicalizedContentType: string | undefined; const pushBodyChunk = (chunk: string): void => { totalBodyBytes += encoder.encode(chunk).byteLength; @@ -218,7 +254,7 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): if (err instanceof BodyTooLargeForCacheKeyError) { throw err; } - console.error("[vinext] Problem reading body for cache key", err); + throw new SkipCacheKeyGenerationError(); } } else if (init?.body instanceof URLSearchParams) { // URLSearchParams — .toString() gives a stable serialization @@ -247,24 +283,36 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): pushBodyChunk(init.body); (init as any)._ogBody = init.body; } else if (input instanceof Request && input.body) { - const { chunks, contentType } = await readRequestBodyChunksWithinLimit(input); - const normalizedContentType = contentType?.split(";")[0]?.trim().toLowerCase(); + let chunks: Uint8Array[]; + let contentType: string | undefined; + try { + ({ chunks, contentType } = await readRequestBodyChunksWithinLimit(input)); + } catch (err) { + if (err instanceof BodyTooLargeForCacheKeyError) { + throw err; + } + throw new SkipCacheKeyGenerationError(); + } + const formContentType = getParsedFormContentType(contentType); - if (normalizedContentType === "multipart/form-data" || normalizedContentType === "application/x-www-form-urlencoded") { + if (formContentType) { try { const boundedRequest = new Request(input.url, { method: input.method, headers: contentType ? { "content-type": contentType } : undefined, - body: new Blob(chunks.map((chunk) => chunk.slice().buffer)), + body: new Blob(chunks.map((chunk) => chunk.slice())), }); const formData = await boundedRequest.formData(); await serializeFormData(formData, pushBodyChunk, getTotalBodyBytes); - return bodyChunks; + canonicalizedContentType = formContentType === "multipart/form-data" && contentType + ? stripMultipartBoundary(contentType) + : undefined; + return { bodyChunks, canonicalizedContentType }; } catch (err) { if (err instanceof BodyTooLargeForCacheKeyError) { throw err; } - console.error("[vinext] Problem reading Request form body for cache key", err); + throw new SkipCacheKeyGenerationError(); } } @@ -277,7 +325,7 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): } } - return bodyChunks; + return { bodyChunks, canonicalizedContentType }; } /** @@ -304,7 +352,10 @@ async function buildFetchCacheKey(input: string | URL | Request, init?: RequestI if (init?.method) method = init.method; const headers = collectHeaders(input, init); - const bodyChunks = await serializeBody(input, init); + const { bodyChunks, canonicalizedContentType } = await serializeBody(input, init); + if (canonicalizedContentType) { + headers["content-type"] = canonicalizedContentType; + } const cacheString = JSON.stringify([ CACHE_KEY_PREFIX, @@ -472,7 +523,7 @@ function createPatchedFetch(): typeof globalThis.fetch { try { cacheKey = await buildFetchCacheKey(input, init); } catch (err) { - if (err instanceof BodyTooLargeForCacheKeyError) { + if (err instanceof BodyTooLargeForCacheKeyError || err instanceof SkipCacheKeyGenerationError) { const cleanInit = stripNextFromInit(init); return originalFetch(input, cleanInit); } diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index ae78b0a7..367eb5a7 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -497,6 +497,23 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("urlencoded Request bodies with different charset headers get separate cache entries", async () => { + const makeRequest = (charset: string) => new Request("https://api.example.com/req-form-charset", { + method: "POST", + headers: { "content-type": `application/x-www-form-urlencoded; charset=${charset}` }, + body: "name=value", + }); + + const res1 = await fetch(makeRequest("utf-8"), { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch(makeRequest("shift_jis"), { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + // ── force-cache with next.revalidate ──────────────────────────────── it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => { @@ -1432,5 +1449,26 @@ describe("fetch cache shim", () => { expect(data2.count).toBe(1); // Same params = cached expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("explicit URLSearchParams charset headers remain part of the cache key", async () => { + const res1 = await fetch("https://api.example.com/body-usp-charset", { + method: "POST", + body: new URLSearchParams({ q: "same" }), + headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8" }, + next: { revalidate: 60 }, + }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch("https://api.example.com/body-usp-charset", { + method: "POST", + body: new URLSearchParams({ q: "same" }), + headers: { "content-type": "application/x-www-form-urlencoded; charset=shift_jis" }, + next: { revalidate: 60 }, + }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); }); From e2241aa8ae27c917b60706efc8e86b592a8c93b9 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 17:02:23 -0600 Subject: [PATCH 6/7] Preserve FormData insertion order + add file name/type into cache key payload + tests --- packages/vinext/src/shims/fetch-cache.ts | 33 +++++----- tests/fetch-cache.test.ts | 79 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index b16b1481..bff2cb49 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -98,25 +98,20 @@ async function serializeFormData( pushBodyChunk: (chunk: string) => void, getTotalBodyBytes: () => number, ): Promise { - for (const key of new Set(formData.keys())) { - const values = formData.getAll(key); - const serializedValues = await Promise.all( - values.map(async (val) => { - if (typeof val === "string") { - return { kind: "string", value: val }; - } - if (val.size > MAX_CACHE_KEY_BODY_BYTES || getTotalBodyBytes() + val.size > MAX_CACHE_KEY_BODY_BYTES) { - throw new BodyTooLargeForCacheKeyError(); - } - return { - kind: "file", - // Note: File name/type/lastModified are not included — only content. - // Two Files with identical content but different names produce the same key. - value: await val.text(), - }; - }), - ); - pushBodyChunk(JSON.stringify([key, serializedValues])); + for (const [key, val] of formData.entries()) { + if (typeof val === "string") { + pushBodyChunk(JSON.stringify([key, { kind: "string", value: val }])); + continue; + } + if (val.size > MAX_CACHE_KEY_BODY_BYTES || getTotalBodyBytes() + val.size > MAX_CACHE_KEY_BODY_BYTES) { + throw new BodyTooLargeForCacheKeyError(); + } + pushBodyChunk(JSON.stringify([key, { + kind: "file", + name: val.name, + type: val.type, + value: await val.text(), + }])); } } diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index 367eb5a7..dcd317cf 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -449,6 +449,31 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("same Request FormData bodies hit the same cache entry despite generated multipart boundaries", async () => { + const makeForm = () => { + const form = new FormData(); + form.append("name", "same-value"); + return form; + }; + + const req1 = new Request("https://api.example.com/req-form-same", { + method: "POST", + body: makeForm(), + }); + const res1 = await fetch(req1, { next: { revalidate: 60 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const req2 = new Request("https://api.example.com/req-form-same", { + method: "POST", + body: makeForm(), + }); + const res2 = await fetch(req2, { next: { revalidate: 60 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("same multipart Request bodies hit the same cache entry even with different boundaries", async () => { const makeMultipartRequest = (boundary: string) => new Request("https://api.example.com/req-form-boundary", { method: "POST", @@ -968,6 +993,60 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("FormData entry order is preserved in the cache key", async () => { + const formA = new FormData(); + formA.append("a", "1"); + formA.append("b", "2"); + formA.append("a", "3"); + + const formB = new FormData(); + formB.append("a", "1"); + formB.append("a", "3"); + formB.append("b", "2"); + + const res1 = await fetch("https://api.example.com/body-form-order", { + method: "POST", + body: formA, + next: { revalidate: 60 }, + }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch("https://api.example.com/body-form-order", { + method: "POST", + body: formB, + next: { revalidate: 60 }, + }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("FormData file metadata is included in the cache key", async () => { + const formA = new FormData(); + formA.append("file", new File(["same-bytes"], "a.txt", { type: "text/plain" })); + + const formB = new FormData(); + formB.append("file", new File(["same-bytes"], "b.bin", { type: "application/octet-stream" })); + + const res1 = await fetch("https://api.example.com/body-form-file-metadata", { + method: "POST", + body: formA, + next: { revalidate: 60 }, + }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + + const res2 = await fetch("https://api.example.com/body-form-file-metadata", { + method: "POST", + body: formB, + next: { revalidate: 60 }, + }); + const data2 = await res2.json(); + expect(data2.count).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("ReadableStream bodies are included in cache key", async () => { const streamA = new ReadableStream({ start(controller) { From 0951604ca1f009551f56474453df109266c3db7f Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Sat, 7 Mar 2026 18:53:04 -0600 Subject: [PATCH 7/7] Fix PR feedback + additional regression tests --- packages/vinext/src/shims/fetch-cache.ts | 4 +- tests/fetch-cache.test.ts | 67 ++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index bff2cb49..33af21eb 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -295,7 +295,7 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): const boundedRequest = new Request(input.url, { method: input.method, headers: contentType ? { "content-type": contentType } : undefined, - body: new Blob(chunks.map((chunk) => chunk.slice())), + body: new Blob(chunks as unknown as BlobPart[]), }); const formData = await boundedRequest.formData(); await serializeFormData(formData, pushBodyChunk, getTotalBodyBytes); @@ -312,7 +312,7 @@ async function serializeBody(input: string | URL | Request, init?: RequestInit): } for (const chunk of chunks) { - bodyChunks.push(decoder.decode(chunk, { stream: true })); + pushBodyChunk(decoder.decode(chunk, { stream: true })); } const finalChunk = decoder.decode(); if (finalChunk) { diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index dcd317cf..3de4f5f8 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -15,14 +15,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // We need to mock fetch at the module level BEFORE fetch-cache.ts captures // `originalFetch`. Use vi.stubGlobal to intercept at import time. let requestCount = 0; -const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { +const defaultFetchMockImplementation = async (input: string | URL | Request, _init?: RequestInit) => { requestCount++; const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; return new Response(JSON.stringify({ url, count: requestCount }), { status: 200, headers: { "content-type": "application/json" }, }); -}); +}; +const fetchMock = vi.fn(defaultFetchMockImplementation); // Stub globalThis.fetch BEFORE importing modules that capture it vi.stubGlobal("fetch", fetchMock); @@ -37,7 +38,8 @@ describe("fetch cache shim", () => { beforeEach(() => { // Reset state requestCount = 0; - fetchMock.mockClear(); + fetchMock.mockReset(); + fetchMock.mockImplementation(defaultFetchMockImplementation); // Reset the cache handler to a fresh instance for each test setCacheHandler(new MemoryCacheHandler()); // Install the patched fetch @@ -226,6 +228,46 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(2); // Original + background refetch }); + it("preserves Request bodies for stale background revalidation", async () => { + const seenBodies: string[] = []; + fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => { + requestCount++; + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const body = input instanceof Request ? await input.clone().text() : ""; + seenBodies.push(body); + return new Response(JSON.stringify({ url, count: requestCount, body }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + + const makeRequest = () => new Request("https://api.example.com/stale-request-body", { + method: "POST", + body: "request-body-content", + headers: { "content-type": "text/plain" }, + }); + + const res1 = await fetch(makeRequest(), { next: { revalidate: 1 } }); + const data1 = await res1.json(); + expect(data1.count).toBe(1); + expect(data1.body).toBe("request-body-content"); + + const handler = getCacheHandler() as InstanceType; + const store = (handler as any).store as Map; + for (const [, entry] of store) { + entry.revalidateAt = Date.now() - 1000; + } + + const res2 = await fetch(makeRequest(), { next: { revalidate: 1 } }); + const data2 = await res2.json(); + expect(data2.count).toBe(1); + expect(data2.body).toBe("request-body-content"); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(seenBodies).toEqual(["request-body-content", "request-body-content"]); + }); + // ── Independent cache entries per URL ─────────────────────────────── it("different URLs get independent cache entries", async () => { @@ -1304,6 +1346,25 @@ describe("fetch cache shim", () => { expect(forwardedRequest).toBeInstanceOf(Request); expect(await forwardedRequest.text()).toBe("request-body-content"); }); + + it("already-consumed Request bodies bypass cache key generation and defer to the underlying fetch", async () => { + fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => { + if (input instanceof Request && input.bodyUsed) { + throw new TypeError("body already used"); + } + return defaultFetchMockImplementation(input, _init); + }); + + const request = new Request("https://api.example.com/request-used", { + method: "POST", + body: "request-body-content", + headers: { "content-type": "text/plain" }, + }); + await request.text(); + + await expect(fetch(request, { next: { revalidate: 60 } })).rejects.toThrow("body already used"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); }); describe("cache key oversized body safeguards", () => {