Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions packages/vinext/src/shims/fetch-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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();
Expand All @@ -114,12 +126,13 @@ async function serializeBody(init?: RequestInit): Promise<string[]> {
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<Uint8Array | string>;
const [bodyForHashing, bodyForFetch] = readableBody.tee();
(init as any)._ogBody = bodyForFetch;
cacheInit._ogBody = bodyForFetch;
cacheInit._restoreBodyFromOg = true;
const reader = bodyForHashing.getReader();

try {
Expand Down Expand Up @@ -151,12 +164,12 @@ async function serializeBody(init?: RequestInit): Promise<string[]> {
}
} 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(
Expand All @@ -180,15 +193,15 @@ async function serializeBody(init?: RequestInit): Promise<string[]> {
}
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.
if (init.body.length > MAX_CACHE_KEY_BODY_BYTES) {
throw new BodyTooLargeForCacheKeyError();
}
pushBodyChunk(init.body);
(init as any)._ogBody = init.body;
cacheInit._ogBody = init.body;
}

return bodyChunks;
Expand Down Expand Up @@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore captured body for non-stream requests

Gating restoration on _restoreBodyFromOg drops the _ogBody snapshot for string/Blob/Uint8Array/FormData bodies, so mutating a shared RequestInit after calling fetch() can now alter an in-flight request. In patchedFetch, we await cache-key generation before stripNextFromInit, so a caller doing const p = fetch(..., init); delete init.body; await p; now sends no body, whereas the previous behavior preserved the original body via _ogBody. This is a behavioral regression versus native fetch snapshot semantics and can silently change request payloads when init objects are reused.

Useful? React with 👍 / 👎.

rest.body = _ogBody;
}
return Object.keys(rest).length > 0 ? rest : undefined;
Expand Down
41 changes: 41 additions & 0 deletions tests/fetch-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
} = {
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", () => {
Expand Down