diff --git a/packages/next/src/server/lib/clone-response.ts b/packages/next/src/server/lib/clone-response.ts new file mode 100644 index 0000000000000..6cf81255da088 --- /dev/null +++ b/packages/next/src/server/lib/clone-response.ts @@ -0,0 +1,43 @@ +/** + * Clones a response by teeing the body so we can return two independent + * ReadableStreams from it. This avoids the bug in the undici library around + * response cloning. + * + * After cloning, the original response's body will be consumed and closed. + * + * @see https://github.com/vercel/next.js/pull/73274 + * + * @param original - The original response to clone. + * @returns A tuple containing two independent clones of the original response. + */ +export function cloneResponse(original: Response): [Response, Response] { + // If the response has no body, then we can just return the original response + // twice because it's immutable. + if (!original.body) { + return [original, original] + } + + const [body1, body2] = original.body.tee() + + const cloned1 = new Response(body1, { + status: original.status, + statusText: original.statusText, + headers: original.headers, + }) + + Object.defineProperty(cloned1, 'url', { + value: original.url, + }) + + const cloned2 = new Response(body2, { + status: original.status, + statusText: original.statusText, + headers: original.headers, + }) + + Object.defineProperty(cloned2, 'url', { + value: original.url, + }) + + return [cloned1, cloned2] +} diff --git a/packages/next/src/server/lib/dedupe-fetch.ts b/packages/next/src/server/lib/dedupe-fetch.ts new file mode 100644 index 0000000000000..d2202f09aef61 --- /dev/null +++ b/packages/next/src/server/lib/dedupe-fetch.ts @@ -0,0 +1,125 @@ +/** + * Based on https://github.com/facebook/react/blob/d4e78c42a94be027b4dc7ed2659a5fddfbf9bd4e/packages/react/src/ReactFetch.js + */ +import * as React from 'react' +import { cloneResponse } from './clone-response' + +const simpleCacheKey = '["GET",[],null,"follow",null,null,null,null]' // generateCacheKey(new Request('https://blank')); + +function generateCacheKey(request: Request): string { + // We pick the fields that goes into the key used to dedupe requests. + // We don't include the `cache` field, because we end up using whatever + // caching resulted from the first request. + // Notably we currently don't consider non-standard (or future) options. + // This might not be safe. TODO: warn for non-standard extensions differing. + // IF YOU CHANGE THIS UPDATE THE simpleCacheKey ABOVE. + return JSON.stringify([ + request.method, + Array.from(request.headers.entries()), + request.mode, + request.redirect, + request.credentials, + request.referrer, + request.referrerPolicy, + request.integrity, + ]) +} + +type CacheEntry = [ + key: string, + promise: Promise, + response: Response | null +] + +export function createDedupeFetch(originalFetch: typeof fetch) { + const getCacheEntries = React.cache( + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- url is the cache key + (url: string): CacheEntry[] => [] + ) + + return function dedupeFetch( + resource: URL | RequestInfo, + options?: RequestInit + ): Promise { + if (options && options.signal) { + // If we're passed a signal, then we assume that + // someone else controls the lifetime of this object and opts out of + // caching. It's effectively the opt-out mechanism. + // Ideally we should be able to check this on the Request but + // it always gets initialized with its own signal so we don't + // know if it's supposed to override - unless we also override the + // Request constructor. + return originalFetch(resource, options) + } + // Normalize the Request + let url: string + let cacheKey: string + if (typeof resource === 'string' && !options) { + // Fast path. + cacheKey = simpleCacheKey + url = resource + } else { + // Normalize the request. + // if resource is not a string or a URL (its an instance of Request) + // then do not instantiate a new Request but instead + // reuse the request as to not disturb the body in the event it's a ReadableStream. + const request = + typeof resource === 'string' || resource instanceof URL + ? new Request(resource, options) + : resource + if ( + (request.method !== 'GET' && request.method !== 'HEAD') || + request.keepalive + ) { + // We currently don't dedupe requests that might have side-effects. Those + // have to be explicitly cached. We assume that the request doesn't have a + // body if it's GET or HEAD. + // keepalive gets treated the same as if you passed a custom cache signal. + return originalFetch(resource, options) + } + cacheKey = generateCacheKey(request) + url = request.url + } + + const cacheEntries = getCacheEntries(url) + for (let i = 0, j = cacheEntries.length; i < j; i += 1) { + const [key, promise] = cacheEntries[i] + if (key === cacheKey) { + return promise.then(() => { + const response = cacheEntries[i][2] + if (!response) throw new Error('No cached response') + + // We're cloning the response using this utility because there exists + // a bug in the undici library around response cloning. See the + // following pull request for more details: + // https://github.com/vercel/next.js/pull/73274 + const [cloned1, cloned2] = cloneResponse(response) + cacheEntries[i][2] = cloned2 + return cloned1 + }) + } + } + + // We pass the original arguments here in case normalizing the Request + // doesn't include all the options in this environment. We also pass a + // signal down to the original fetch as to bypass the underlying React fetch + // cache. + const controller = new AbortController() + const promise = originalFetch(resource, { + ...options, + signal: controller.signal, + }) + const entry: CacheEntry = [cacheKey, promise, null] + cacheEntries.push(entry) + + return promise.then((response) => { + // We're cloning the response using this utility because there exists + // a bug in the undici library around response cloning. See the + // following pull request for more details: + // https://github.com/vercel/next.js/pull/73274 + const [cloned1, cloned2] = cloneResponse(response) + entry[2] = cloned2 + return cloned1 + }) + } +} diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 63a4dccd74920..25722cb7dd12a 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -15,6 +15,8 @@ import { import * as Log from '../../build/output/log' import { trackDynamicFetch } from '../app-render/dynamic-rendering' import type { FetchMetric } from '../base-http' +import { createDedupeFetch } from './dedupe-fetch' +import { cloneResponse } from './clone-response' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' @@ -623,15 +625,26 @@ function createPatchedFetcher( if (entry.isStale) { staticGenerationStore.pendingRevalidates ??= {} if (!staticGenerationStore.pendingRevalidates[cacheKey]) { + const pendingRevalidate = doOriginalFetch(true) + .then(async (response) => ({ + body: await response.arrayBuffer(), + headers: response.headers, + status: response.status, + statusText: response.statusText, + })) + .finally(() => { + staticGenerationStore.pendingRevalidates ??= {} + delete staticGenerationStore.pendingRevalidates[ + cacheKey || '' + ] + }) + + // Attach the empty catch here so we don't get a "unhandled + // promise rejection" warning. + pendingRevalidate.catch(console.error) + staticGenerationStore.pendingRevalidates[cacheKey] = - doOriginalFetch(true) - .catch(console.error) - .finally(() => { - staticGenerationStore.pendingRevalidates ??= {} - delete staticGenerationStore.pendingRevalidates[ - cacheKey || '' - ] - }) + pendingRevalidate } } const resData = entry.value.data @@ -730,16 +743,40 @@ function createPatchedFetcher( // origin hit if it's a cache-able entry if (cacheKey && isForegroundRevalidate) { staticGenerationStore.pendingRevalidates ??= {} - const pendingRevalidate = + let pendingRevalidate = staticGenerationStore.pendingRevalidates[cacheKey] if (pendingRevalidate) { - const res: Response = await pendingRevalidate - return res.clone() + const revalidatedResult: { + body: ArrayBuffer + headers: Headers + status: number + statusText: string + } = await pendingRevalidate + return new Response(revalidatedResult.body, { + headers: revalidatedResult.headers, + status: revalidatedResult.status, + statusText: revalidatedResult.statusText, + }) } + const pendingResponse = doOriginalFetch(true, cacheReasonOverride) - const nextRevalidate = pendingResponse - .then((res) => res.clone()) + // We're cloning the response using this utility because there + // exists a bug in the undici library around response cloning. + // See the following pull request for more details: + // https://github.com/vercel/next.js/pull/73274 + .then(cloneResponse) + + pendingRevalidate = pendingResponse + .then(async (responses) => { + const response = responses[0] + return { + body: await response.arrayBuffer(), + headers: response.headers, + status: response.status, + statusText: response.statusText, + } + }) .finally(() => { if (cacheKey) { // If the pending revalidate is not present in the store, then @@ -754,11 +791,11 @@ function createPatchedFetcher( // Attach the empty catch here so we don't get a "unhandled promise // rejection" warning - nextRevalidate.catch(() => {}) + pendingRevalidate.catch(() => {}) - staticGenerationStore.pendingRevalidates[cacheKey] = nextRevalidate + staticGenerationStore.pendingRevalidates[cacheKey] = pendingRevalidate - return pendingResponse + return pendingResponse.then((responses) => responses[1]) } else { return doOriginalFetch(false, cacheReasonOverride).finally( handleUnlock @@ -784,7 +821,7 @@ export function patchFetch(options: PatchableModule) { // Grab the original fetch function. We'll attach this so we can use it in // the patched fetch function. - const original = globalThis.fetch + const original = createDedupeFetch(globalThis.fetch) // Set the global fetch to the patched fetch. globalThis.fetch = createPatchedFetcher(original, options)