diff --git a/packages/vinext/src/server/cloudflare-fetch-adapter.ts b/packages/vinext/src/server/cloudflare-fetch-adapter.ts new file mode 100644 index 000000000..5de4fabe2 --- /dev/null +++ b/packages/vinext/src/server/cloudflare-fetch-adapter.ts @@ -0,0 +1,219 @@ +import type { Readable as NodeReadable } from "node:stream"; + +type SupportedContentEncoding = "br" | "deflate" | "gzip"; + +type WorkersRequestInit = RequestInit & { + encodeResponseBody?: "automatic" | "manual"; +}; + +type WorkersResponse = Response & { + cf?: unknown; +}; + +const INSTALL_KEY = Symbol.for("vinext.cloudflareFetchAdapter.installed"); +const ORIGINAL_FETCH_KEY = Symbol.for("vinext.cloudflareFetchAdapter.originalFetch"); +const NODE_DEFAULT_ACCEPT_ENCODING = "gzip, deflate"; + +function isCloudflareWorkersRuntime(): boolean { + return globalThis.navigator?.userAgent === "Cloudflare-Workers"; +} + +function getEffectiveRequestHeaders( + input: string | URL | Request, + init: RequestInit | undefined, +): Headers | null { + if (init?.headers !== undefined) return new Headers(init.headers); + return input instanceof Request ? input.headers : null; +} + +function requestsEncodedPassthrough(init: RequestInit | undefined): boolean { + return (init as WorkersRequestInit | undefined)?.encodeResponseBody === "manual"; +} + +function parseContentEncodings(headers: Headers): SupportedContentEncoding[] | null { + const contentEncoding = headers.get("content-encoding"); + if (!contentEncoding) return []; + + const encodings: SupportedContentEncoding[] = []; + for (const value of contentEncoding.split(",")) { + const encoding = value.trim().toLowerCase(); + if (!encoding || encoding === "identity") continue; + if (encoding === "x-gzip") { + encodings.push("gzip"); + } else if (encoding === "br" || encoding === "deflate" || encoding === "gzip") { + encodings.push(encoding); + } else { + return null; + } + } + return encodings; +} + +async function createDecodedBodyReader( + body: ReadableStream, + encodings: SupportedContentEncoding[], +): Promise> { + const [{ Readable }, zlib] = await Promise.all([import("node:stream"), import("node:zlib")]); + let decoded: NodeReadable = Readable.fromWeb(body as Parameters[0]); + + // Content codings are listed in the order in which they were applied, so + // decoding runs in reverse. Node's zlib streams accept concatenated gzip + // members, matching the HTTP decoder used by Next.js's native fetch. + for (const encoding of [...encodings].reverse()) { + const decoder = + encoding === "br" + ? zlib.createBrotliDecompress() + : encoding === "deflate" + ? zlib.createInflate() + : zlib.createGunzip(); + decoded = decoded.pipe(decoder); + } + + return (Readable.toWeb(decoded) as unknown as ReadableStream).getReader(); +} + +function decodeBodyLazily( + body: ReadableStream, + encodings: SupportedContentEncoding[], +): ReadableStream { + let readerPromise: Promise> | undefined; + + return new ReadableStream( + { + async pull(controller) { + const reader = await (readerPromise ??= createDecodedBodyReader(body, encodings)); + const { done, value } = await reader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + async cancel(reason) { + if (readerPromise) { + await (await readerPromise).cancel(reason); + } else { + await body.cancel(reason); + } + }, + }, + // Do not pull from the origin until the caller consumes the body. Native + // fetch (and Next.js) resolves as soon as response headers are available. + { highWaterMark: 0 }, + ); +} + +function copyResponseProperty( + target: Response, + source: Response, + property: "redirected" | "url", +): void { + Object.defineProperty(target, property, { + value: source[property], + configurable: true, + enumerable: true, + writable: false, + }); +} + +function preserveFetchResponseMetadata(target: Response, source: Response): Response { + const nativeClone = target.clone.bind(target); + + copyResponseProperty(target, source, "redirected"); + copyResponseProperty(target, source, "url"); + Object.defineProperty(target, "type", { + // Server-side HTTP fetches are `basic` in Node/Undici. Workerd reports + // `default`, so normalize this observable field to the Next.js oracle. + value: "basic", + configurable: true, + enumerable: true, + writable: false, + }); + + const cf = (source as WorkersResponse).cf; + if (cf !== undefined) { + Object.defineProperty(target, "cf", { + value: structuredClone(cf), + configurable: true, + enumerable: true, + writable: false, + }); + } + + // Response.clone() operates on internal slots, which do not include the URL + // list from the original fetch response after reconstruction. Decorate each + // clone so metadata parity survives the same operation Next.js performs. + // Keep the Response constructor's own Headers object: Next's cloneResponse + // helper does the same, giving every response and clone distinct header + // storage instead of exposing a separate copied object. + Object.defineProperty(target, "clone", { + value: () => preserveFetchResponseMetadata(nativeClone(), source), + configurable: true, + enumerable: false, + writable: false, + }); + + return target; +} + +function decodeResponse(response: Response): Response { + if (!response.body) return response; + + const encodings = parseContentEncodings(response.headers); + if (!encodings || encodings.length === 0) return response; + + return preserveFetchResponseMetadata( + new Response(decodeBodyLazily(response.body, encodings), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + response, + ); +} + +/** + * Reproduce the complete content decoding performed by Node's native fetch. + * + * Workerd's automatic decoder currently handles only a single `gzip` or `br` + * token. Asking it for the raw body lets vinext decode the complete advertised + * chain while preserving the response headers and metadata that Next.js sees. + * Explicit Workers compressed-passthrough requests remain untouched. + */ +export function createCloudflareFetchAdapter( + originalFetch: typeof globalThis.fetch, +): typeof globalThis.fetch { + return async function cloudflareFetch( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + if (requestsEncodedPassthrough(init)) return originalFetch(input, init); + + const headers = new Headers(getEffectiveRequestHeaders(input, init) ?? undefined); + // Node's native fetch (the transport used by Next.js) sends this default. + // Workerd stops adding Accept-Encoding when manual body mode is selected, + // so provide the same value explicitly before decoding the raw response. + if (!headers.has("accept-encoding")) { + headers.set("accept-encoding", NODE_DEFAULT_ACCEPT_ENCODING); + } + const response = await originalFetch(input, { + ...init, + headers, + encodeResponseBody: "manual", + } as WorkersRequestInit); + return decodeResponse(response); + } as typeof globalThis.fetch; +} + +/** Install once, before generated server entries evaluate user modules. */ +export function installCloudflareFetchAdapter(): void { + if (!isCloudflareWorkersRuntime()) return; + + const globals = globalThis as unknown as Record; + if (globals[INSTALL_KEY]) return; + + const originalFetch = (globals[ORIGINAL_FETCH_KEY] ??= + globalThis.fetch) as typeof globalThis.fetch; + globalThis.fetch = createCloudflareFetchAdapter(originalFetch); + globals[INSTALL_KEY] = true; +} diff --git a/packages/vinext/src/server/server-globals.ts b/packages/vinext/src/server/server-globals.ts index 0c1898cdd..061bb70a7 100644 --- a/packages/vinext/src/server/server-globals.ts +++ b/packages/vinext/src/server/server-globals.ts @@ -8,6 +8,7 @@ * body would run after static user imports have already evaluated. */ import { AsyncLocalStorage } from "node:async_hooks"; +import { installCloudflareFetchAdapter } from "./cloudflare-fetch-adapter.js"; type BrowserGlobalName = "window" | "document"; @@ -39,6 +40,7 @@ function clearBrowserGlobal(name: BrowserGlobalName): void { export function installServerGlobals(): void { clearBrowserGlobal("window"); clearBrowserGlobal("document"); + installCloudflareFetchAdapter(); // Next.js's edge sandbox exposes AsyncLocalStorage as a global. Cloudflare // Workers exposes it via node:async_hooks under nodejs_compat, so mirror the diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 22d8c93c9..28c5686d4 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -45,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 = "v5"; +const CACHE_KEY_PREFIX = "v6"; const MAX_CACHE_KEY_BODY_BYTES = 1024 * 1024; // 1 MiB // "Cache indefinitely" duration — mirrors upstream's CACHE_ONE_YEAR_SECONDS. @@ -1335,7 +1335,16 @@ function createPatchedFetch(): typeof globalThis.fetch { ? originalFetch(input, fetchInit) : dedupeFetch(input, fetchInit)); - const cacheValue = await buildFetchCacheValue(response, tags, revalidateSeconds); + let cacheValue: CachedFetchValue | null = null; + try { + cacheValue = await buildFetchCacheValue(response, tags, revalidateSeconds); + } catch (error) { + // A response body can fail after fetch has resolved (for example, a + // truncated compressed stream). Cache serialization must not turn that + // late body error into a rejected fetch before callers can inspect the + // response status and headers. + console.error("[vinext] fetch cache serialization error:", error); + } if (cacheValue) { handler .set(cacheKey, cacheValue, { diff --git a/tests/cloudflare-fetch-adapter.test.ts b/tests/cloudflare-fetch-adapter.test.ts new file mode 100644 index 000000000..208a15a14 --- /dev/null +++ b/tests/cloudflare-fetch-adapter.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { createCloudflareFetchAdapter } from "../packages/vinext/src/server/cloudflare-fetch-adapter.js"; + +type WorkersRequestInit = RequestInit & { + encodeResponseBody?: "automatic" | "manual"; +}; + +function responseWithMetadata( + body: BodyInit, + contentEncoding: string, + url = "https://api.example.com/final", +): Response { + const response = new Response(body, { + headers: { + "content-encoding": contentEncoding, + "content-length": String(body instanceof Uint8Array ? body.byteLength : 0), + "content-type": "application/json", + }, + }); + Object.defineProperties(response, { + url: { value: url, configurable: true, enumerable: true }, + redirected: { value: true, configurable: true, enumerable: true }, + type: { value: "default", configurable: true, enumerable: true }, + cf: { value: { colo: "LHR" }, configurable: true, enumerable: true }, + }); + return response; +} + +describe("Cloudflare fetch adapter", () => { + it("decodes the complete content-encoding chain and preserves fetch metadata", async () => { + const payload = { timestamp: 1_787_056_318 }; + const compressed = brotliCompressSync(gzipSync(JSON.stringify(payload))); + const source = responseWithMetadata(compressed, "gzip, br"); + const originalFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => source, + ); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + + const response = await runtimeFetch("https://api.example.com/redirect"); + + expect(originalFetch).toHaveBeenCalledWith( + "https://api.example.com/redirect", + expect.objectContaining({ + encodeResponseBody: "manual", + headers: expect.any(Headers), + }), + ); + expect(new Headers(originalFetch.mock.calls[0]?.[1]?.headers).get("accept-encoding")).toBe( + "gzip, deflate", + ); + expect(response.headers).not.toBe(source.headers); + expect(response.headers.get("content-encoding")).toBe("gzip, br"); + expect(response.headers.get("content-length")).toBe(String(compressed.byteLength)); + response.headers.set("x-test", "response"); + expect(response.headers.get("x-test")).toBe("response"); + expect(response.url).toBe(source.url); + expect(response.redirected).toBe(true); + expect(response.type).toBe("basic"); + expect(Reflect.get(response, "cf")).toEqual({ colo: "LHR" }); + const cloned = response.clone(); + expect(cloned.headers).not.toBe(response.headers); + cloned.headers.set("x-test", "clone"); + expect(cloned.headers.get("x-test")).toBe("clone"); + expect(response.headers.get("x-test")).toBe("response"); + expect(cloned.url).toBe(source.url); + expect(cloned.redirected).toBe(true); + expect(cloned.type).toBe("basic"); + expect(Reflect.get(cloned, "cf")).toEqual({ colo: "LHR" }); + expect(Reflect.get(cloned, "cf")).not.toBe(Reflect.get(response, "cf")); + await expect(Promise.all([response.json(), cloned.json()])).resolves.toEqual([ + payload, + payload, + ]); + }); + + it.each([ + { + name: "repeated gzip codings", + encoding: "gzip, gzip", + body: (value: string) => gzipSync(gzipSync(value)), + }, + { + name: "concatenated gzip members", + encoding: "gzip", + body: (value: string) => + Buffer.concat([gzipSync(value.slice(0, 5)), gzipSync(value.slice(5))]), + }, + ])("decodes $name like Node fetch", async ({ encoding, body }) => { + const json = JSON.stringify({ ok: true }); + const originalFetch = vi.fn(async () => responseWithMetadata(body(json), encoding)); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + + await expect((await runtimeFetch("https://api.example.com/data")).json()).resolves.toEqual({ + ok: true, + }); + }); + + it("resolves at headers without pulling the encoded body", async () => { + const compressed = gzipSync(JSON.stringify({ delayed: true })); + let pulled = false; + let release: (() => void) | undefined; + const body = new ReadableStream( + { + pull(controller) { + pulled = true; + return new Promise((resolve) => { + release = () => { + controller.enqueue(compressed); + controller.close(); + resolve(); + }; + }); + }, + }, + { highWaterMark: 0 }, + ); + const originalFetch = vi.fn(async () => responseWithMetadata(body, "gzip")); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + + const response = await runtimeFetch("https://api.example.com/slow"); + expect(pulled).toBe(false); + + const data = response.json(); + await vi.waitFor(() => expect(pulled).toBe(true)); + release?.(); + await expect(data).resolves.toEqual({ delayed: true }); + }); + + it("cancels an unconsumed origin body without starting decompression", async () => { + const cancel = vi.fn(); + const body = new ReadableStream( + { + pull() { + return new Promise(() => {}); + }, + cancel, + }, + { highWaterMark: 0 }, + ); + const originalFetch = vi.fn(async () => responseWithMetadata(body, "gzip")); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + + const response = await runtimeFetch("https://api.example.com/stalled"); + await response.body?.cancel("caller stopped"); + + expect(cancel).toHaveBeenCalledWith("caller stopped"); + }); + + it("decodes caller-supplied Accept-Encoding like Node fetch", async () => { + const payload = { callerSelected: true }; + const compressed = gzipSync(JSON.stringify(payload)); + const source = responseWithMetadata(compressed, "gzip"); + const originalFetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => source, + ); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + const init = { headers: { "accept-encoding": "gzip" } }; + + const response = await runtimeFetch("https://api.example.com/archive.json", init); + + expect(new Headers(originalFetch.mock.calls[0]?.[1]?.headers).get("accept-encoding")).toBe( + "gzip", + ); + expect(originalFetch.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ encodeResponseBody: "manual" }), + ); + await expect(response.json()).resolves.toEqual(payload); + }); + + it("honors the Workers manual response-body mode", async () => { + const compressed = gzipSync(JSON.stringify({ manual: true })); + const source = responseWithMetadata(compressed, "gzip"); + const originalFetch = vi.fn(async () => source); + const runtimeFetch = createCloudflareFetchAdapter(originalFetch); + const init: WorkersRequestInit = { encodeResponseBody: "manual" }; + + const response = await runtimeFetch("https://api.example.com/manual", init); + + expect(originalFetch).toHaveBeenCalledWith("https://api.example.com/manual", init); + expect(response).toBe(source); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array(compressed)); + }); +}); diff --git a/tests/cloudflare-fetch-cache-integration.test.ts b/tests/cloudflare-fetch-cache-integration.test.ts new file mode 100644 index 000000000..2dfe31253 --- /dev/null +++ b/tests/cloudflare-fetch-cache-integration.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { createCloudflareFetchAdapter } from "../packages/vinext/src/server/cloudflare-fetch-adapter.js"; + +const payload = { timestamp: 1_787_056_318 }; +const encodedBody = brotliCompressSync(gzipSync(JSON.stringify(payload))); +const networkFetch = vi.fn( + async () => + new Response(encodedBody, { + headers: { + "content-encoding": "gzip, br", + "content-length": String(encodedBody.byteLength), + "content-type": "application/json", + }, + }), +); + +vi.stubGlobal("fetch", createCloudflareFetchAdapter(networkFetch)); + +const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js"); +const { MemoryCacheHandler, setCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + +describe("Cloudflare fetch adapter with fetch cache", () => { + let cleanup: (() => void) | undefined; + + afterEach(() => { + cleanup?.(); + cleanup = undefined; + }); + + it("caches and replays the fully decoded body with Next-compatible headers", async () => { + networkFetch.mockClear(); + setCacheHandler(new MemoryCacheHandler()); + cleanup = withFetchCache(); + + const cold = await fetch("https://api.example.com/stacked", { cache: "force-cache" }); + expect(cold.headers.get("content-encoding")).toBe("gzip, br"); + await expect(cold.json()).resolves.toEqual(payload); + + const cached = await fetch("https://api.example.com/stacked", { cache: "force-cache" }); + expect(cached.headers.get("content-encoding")).toBe("gzip, br"); + await expect(cached.json()).resolves.toEqual(payload); + expect(networkFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/fetch-cache.test.ts b/tests/fetch-cache.test.ts index 40eb9da82..2f7e92e70 100644 --- a/tests/fetch-cache.test.ts +++ b/tests/fetch-cache.test.ts @@ -154,6 +154,32 @@ describe("fetch cache shim", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("returns the response when cache serialization hits a late body error", async () => { + const cacheError = new Error("truncated compressed body"); + const body = new ReadableStream({ + pull(controller) { + controller.error(cacheError); + }, + }); + fetchMock.mockResolvedValueOnce( + new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const response = await fetch("https://api.example.com/truncated", { cache: "force-cache" }); + + expect(response.status).toBe(200); + await expect(response.text()).rejects.toThrow("truncated compressed body"); + expect(consoleError).toHaveBeenCalledWith( + "[vinext] fetch cache serialization error:", + cacheError, + ); + consoleError.mockRestore(); + }); + it("preserves Response.url on cached fetch responses", async () => { const url = "https://api.example.com/force-url";