From d6eb18e0ebabd273746a99199aa55dc334639b3c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 13 Aug 2026 09:00:06 +0100 Subject: [PATCH] fix(cache): preserve binary fetch response bodies --- packages/vinext/src/shims/fetch-cache.ts | 8 +++--- tests/fetch-cache.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 72e98b4d0c..22d8c93c93 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -19,6 +19,8 @@ * await runWithFetchCache(async () => { ... render ... }); */ +import { Buffer } from "node:buffer"; + import { getDataCacheHandler, type CachedFetchValue, type CacheHandler } from "./cache-handler.js"; import { encodeCacheTags } from "../utils/encode-cache-tag.js"; import { getOrCreateAls } from "./internal/als-registry.js"; @@ -43,7 +45,7 @@ import { const HEADER_BLOCKLIST = ["traceparent", "tracestate"]; // Cache key version — bump when changing the key format to bust stale entries -const CACHE_KEY_PREFIX = "v4"; +const CACHE_KEY_PREFIX = "v5"; const MAX_CACHE_KEY_BODY_BYTES = 1024 * 1024; // 1 MiB // "Cache indefinitely" duration — mirrors upstream's CACHE_ONE_YEAR_SECONDS. @@ -656,7 +658,7 @@ async function buildFetchCacheValue( if (response.status !== 200) return null; const responseForCache = options?.cloneForReturn === false ? response : response.clone(); - const body = await responseForCache.text(); + const body = Buffer.from(await responseForCache.arrayBuffer()).toString("base64"); const headers: Record = {}; responseForCache.headers.forEach((v, k) => { if (k.toLowerCase() === "set-cookie") return; @@ -995,7 +997,7 @@ function buildCachedFetchResponse( data: CachedFetchValue["data"], input: string | URL | Request, ): Response { - const response = new Response(data.body, { + const response = new Response(Buffer.from(data.body, "base64"), { status: data.status ?? 200, headers: data.headers, }); diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index 9c62bd11a1..40eb9da822 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -122,6 +122,38 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + // Next.js stores CachedFetchData.body as base64: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/patch-fetch.ts + it("preserves binary response bodies when replaying the fetch cache", async () => { + const url = "https://api.example.com/compressed"; + const body = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0xff, 0x80, 0x00, 0x7f]); + + fetchMock.mockImplementationOnce(async () => { + const response = new Response(body, { + status: 200, + headers: { + "content-encoding": "gzip", + "content-type": "application/octet-stream", + }, + }); + Object.defineProperty(response, "url", { + value: url, + configurable: true, + enumerable: true, + writable: false, + }); + return response; + }); + + const cold = await fetch(url, { cache: "force-cache" }); + expect(new Uint8Array(await cold.arrayBuffer())).toEqual(body); + + const cached = await fetch(url, { cache: "force-cache" }); + expect(new Uint8Array(await cached.arrayBuffer())).toEqual(body); + expect(cached.headers.get("content-encoding")).toBe("gzip"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("preserves Response.url on cached fetch responses", async () => { const url = "https://api.example.com/force-url";