diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index f074af9e..edbf5639 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -94,7 +94,19 @@ function hasAuthHeaders(input: string | URL | Request, init?: RequestInit): bool * on init as `_ogBody` so it can still be used after stream consumption. */ async function serializeBody(init?: RequestInit): Promise { - if (!init?.body) return []; + if (!init) return []; + + const cacheInit = init as RequestInit & { + _ogBody?: BodyInit; + _restoreBodyFromOg?: boolean; + }; + + // Clear stale restoration metadata on every request. This prevents replaying + // a previous request body when callers reuse the same RequestInit object. + delete cacheInit._ogBody; + delete cacheInit._restoreBodyFromOg; + + if (!init.body) return []; const bodyChunks: string[] = []; const encoder = new TextEncoder(); @@ -114,12 +126,13 @@ async function serializeBody(init?: RequestInit): Promise { throw new BodyTooLargeForCacheKeyError(); } pushBodyChunk(decoder.decode(init.body)); - (init as any)._ogBody = init.body; + cacheInit._ogBody = init.body; } else if (typeof (init.body as any).getReader === "function") { // ReadableStream const readableBody = init.body as ReadableStream; const [bodyForHashing, bodyForFetch] = readableBody.tee(); - (init as any)._ogBody = bodyForFetch; + cacheInit._ogBody = bodyForFetch; + cacheInit._restoreBodyFromOg = true; const reader = bodyForHashing.getReader(); try { @@ -151,12 +164,12 @@ async function serializeBody(init?: RequestInit): Promise { } } else if (init.body instanceof URLSearchParams) { // URLSearchParams — .toString() gives a stable serialization - (init as any)._ogBody = init.body; + cacheInit._ogBody = init.body; pushBodyChunk(init.body.toString()); } else if (typeof (init.body as any).keys === "function") { // FormData const formData = init.body as FormData; - (init as any)._ogBody = init.body; + cacheInit._ogBody = init.body; for (const key of new Set(formData.keys())) { const values = formData.getAll(key); const serializedValues = await Promise.all( @@ -180,7 +193,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 }); + cacheInit._ogBody = new Blob([arrayBuffer], { type: blob.type }); } 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. @@ -188,7 +201,7 @@ async function serializeBody(init?: RequestInit): Promise { throw new BodyTooLargeForCacheKeyError(); } pushBodyChunk(init.body); - (init as any)._ogBody = init.body; + cacheInit._ogBody = init.body; } return bodyChunks; @@ -505,11 +518,15 @@ function createPatchedFetch(): typeof globalThis.fetch { */ function stripNextFromInit(init?: RequestInit): RequestInit | undefined { if (!init) return init; - const castInit = init as RequestInit & { next?: unknown; _ogBody?: BodyInit }; - const { next: _next, _ogBody, ...rest } = castInit; + const castInit = init as RequestInit & { + next?: unknown; + _ogBody?: BodyInit; + _restoreBodyFromOg?: boolean; + }; + const { next: _next, _ogBody, _restoreBodyFromOg, ...rest } = castInit; // Restore the original body if it was stashed by serializeBody (e.g. after // consuming a ReadableStream for cache key generation). - if (_ogBody !== undefined) { + if (_restoreBodyFromOg && _ogBody !== undefined) { rest.body = _ogBody; } return Object.keys(rest).length > 0 ? rest : undefined; diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index e48cbcb2..4d61c546 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -1048,6 +1048,47 @@ describe("fetch cache shim", () => { const init = call[1] as RequestInit; expect(init.body).toBe('{"key":"value"}'); }); + + it("does not replay a stale body when reusing init without body", async () => { + const sharedInit: RequestInit & { next?: { revalidate?: number } } = { + method: "POST", + body: '{"secret":"value"}', + next: { revalidate: 60 }, + }; + + await fetch("https://api.example.com/reuse-body-first", sharedInit); + + delete sharedInit.body; + sharedInit.method = "GET"; + + await fetch("https://api.example.com/reuse-body-second", sharedInit); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondInit = fetchMock.mock.calls[1][1] as RequestInit; + expect(secondInit.method).toBe("GET"); + expect(secondInit.body).toBeUndefined(); + }); + + it("does not replay a stale body when reusing init with unsupported body type", async () => { + const sharedInit: RequestInit & { + next?: { revalidate?: number }; + body?: BodyInit | Record; + } = { + method: "POST", + body: new TextEncoder().encode("first-body"), + next: { revalidate: 60 }, + }; + + await fetch("https://api.example.com/reuse-unsupported-first", sharedInit); + + sharedInit.body = { unexpected: "shape" }; + + await fetch("https://api.example.com/reuse-unsupported-second", sharedInit as RequestInit); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondInit = fetchMock.mock.calls[1][1] as RequestInit; + expect(secondInit.body).toEqual({ unexpected: "shape" }); + }); }); describe("cache key oversized body safeguards", () => {