From 9fd0a27c36d36a38303236dafd26a19a4c690c49 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Tue, 18 Mar 2025 13:51:58 +0100 Subject: [PATCH 1/5] patch fetch cache waitUntil --- .../open-next/src/build/createServerBundle.ts | 1 + .../build/patch/patchFetchCacheWaitUntil.ts | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/open-next/src/build/patch/patchFetchCacheWaitUntil.ts diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index cb1057b17..9a000e8de 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -17,6 +17,7 @@ import { generateEdgeBundle } from "./edge/createEdgeBundle.js"; import * as buildHelper from "./helper.js"; import { installDependencies } from "./installDeps.js"; import { type CodePatcher, applyCodePatches } from "./patch/codePatcher.js"; +import { patchFetchCacheSetMissingWaitUntil } from "./patch/patchFetchCacheWaitUntil.js"; interface CodeCustomization { // These patches are meant to apply on user and next generated code diff --git a/packages/open-next/src/build/patch/patchFetchCacheWaitUntil.ts b/packages/open-next/src/build/patch/patchFetchCacheWaitUntil.ts new file mode 100644 index 000000000..0bb113bc7 --- /dev/null +++ b/packages/open-next/src/build/patch/patchFetchCacheWaitUntil.ts @@ -0,0 +1,42 @@ +import { getCrossPlatformPathRegex } from "utils/regex.js"; +import { createPatchCode } from "./astCodePatcher.js"; +import type { CodePatcher } from "./codePatcher"; + +export const rule = ` +rule: + kind: call_expression + pattern: $PROMISE + all: + - has: { pattern: $_.arrayBuffer().then, stopBy: end } + - has: { pattern: "Buffer.from", stopBy: end } + - any: + - inside: + kind: sequence_expression + inside: + kind: return_statement + - inside: + kind: expression_statement + precedes: + kind: return_statement + - has: { pattern: $_.FETCH, stopBy: end } + +fix: | + globalThis.__openNextAls?.getStore()?.pendingPromiseRunner.add($PROMISE) +`; + +export const patchFetchCacheSetMissingWaitUntil: CodePatcher = { + name: "patch-fetch-cache-set-missing-wait-until", + patches: [ + { + versions: ">=15.0.0", + field: { + pathFilter: getCrossPlatformPathRegex( + String.raw`(server/chunks/.*\.js|.*\.runtime\..*\.js|patch-fetch\.js)$`, + { escape: false }, + ), + contentFilter: /arrayBuffer\(\)\s*\.then/, + patchCode: createPatchCode(rule), + }, + }, + ], +}; From 576e19c8ab3acc6439ccd0ba2ffadf5c6c308563 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Tue, 18 Mar 2025 14:13:32 +0100 Subject: [PATCH 2/5] add unit test --- .../patch/patchFetchCacheWaitUntil.test.ts | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 packages/tests-unit/tests/build/patch/patchFetchCacheWaitUntil.test.ts diff --git a/packages/tests-unit/tests/build/patch/patchFetchCacheWaitUntil.test.ts b/packages/tests-unit/tests/build/patch/patchFetchCacheWaitUntil.test.ts new file mode 100644 index 000000000..4ae8f239e --- /dev/null +++ b/packages/tests-unit/tests/build/patch/patchFetchCacheWaitUntil.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, test } from "vitest"; + +import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import { rule } from "@opennextjs/aws/build/patch/patchFetchCacheWaitUntil.js"; + +describe("patchFetchCacheSetMissingWaitUntil", () => { + test("on minified code", () => { + const code = ` +{ + let [o4, a2] = (0, d2.cloneResponse)(e3); + return o4.arrayBuffer().then(async (e4) => { + var a3; + let i4 = Buffer.from(e4), s3 = { headers: Object.fromEntries(o4.headers.entries()), body: i4.toString("base64"), status: o4.status, url: o4.url }; + null == $ || null == (a3 = $.serverComponentsHmrCache) || a3.set(n2, s3), F && await H.set(n2, { kind: c2.CachedRouteKind.FETCH, data: s3, revalidate: t5 }, { fetchCache: true, revalidate: r4, fetchUrl: _, fetchIdx: q, tags: A2 }); + }).catch((e4) => console.warn("Failed to set fetch cache", u4, e4)).finally(X), a2; +}`; + + expect(patchCode(code, rule)).toMatchInlineSnapshot(` + "{ + let [o4, a2] = (0, d2.cloneResponse)(e3); + return globalThis.__openNextAls?.getStore()?.pendingPromiseRunner.add(o4.arrayBuffer().then(async (e4) => { + var a3; + let i4 = Buffer.from(e4), s3 = { headers: Object.fromEntries(o4.headers.entries()), body: i4.toString("base64"), status: o4.status, url: o4.url }; + null == $ || null == (a3 = $.serverComponentsHmrCache) || a3.set(n2, s3), F && await H.set(n2, { kind: c2.CachedRouteKind.FETCH, data: s3, revalidate: t5 }, { fetchCache: true, revalidate: r4, fetchUrl: _, fetchIdx: q, tags: A2 }); + }).catch((e4) => console.warn("Failed to set fetch cache", u4, e4)).finally(X)) + , a2; + }" + `); + }); + + describe("on non-minified code", () => { + test("15.1.0", () => { + // source: https://github.com/vercel/next.js/blob/fe45b74fdac83d3/packages/next/src/server/lib/patch-fetch.ts#L627-L732 + const code = `if ( + res.status === 200 && + incrementalCache && + cacheKey && + (isCacheableRevalidate || + useCacheOrRequestStore?.serverComponentsHmrCache) + ) { + const normalizedRevalidate = + finalRevalidate >= INFINITE_CACHE + ? CACHE_ONE_YEAR + : finalRevalidate + const externalRevalidate = + finalRevalidate >= INFINITE_CACHE ? false : finalRevalidate + + if (workUnitStore && workUnitStore.type === 'prerender') { + // We are prerendering at build time or revalidate time with dynamicIO so we need to + // buffer the response so we can guarantee it can be read in a microtask + const bodyBuffer = await res.arrayBuffer() + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: Buffer.from(bodyBuffer).toString('base64'), + status: res.status, + url: res.url, + } + + // We can skip checking the serverComponentsHmrCache because we aren't in + // dev mode. + + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + await handleUnlock() + + // We return a new Response to the caller. + return new Response(bodyBuffer, { + headers: res.headers, + status: res.status, + statusText: res.statusText, + }) + } else { + // 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(res) + + // We are dynamically rendering including dev mode. We want to return + // the response to the caller as soon as possible because it might stream + // over a very long time. + cloned1 + .arrayBuffer() + .then(async (arrayBuffer) => { + const bodyBuffer = Buffer.from(arrayBuffer) + + const fetchedData = { + headers: Object.fromEntries(cloned1.headers.entries()), + body: bodyBuffer.toString('base64'), + status: cloned1.status, + url: cloned1.url, + } + + useCacheOrRequestStore?.serverComponentsHmrCache?.set( + cacheKey, + fetchedData + ) + + if (isCacheableRevalidate) { + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + } + }) + .catch((error) => + console.warn(\`Failed to set fetch cache\`, input, error) + ) + .finally(handleUnlock) + + return cloned2 + } + } + `; + + expect(patchCode(code, rule)).toMatchInlineSnapshot(` + "if ( + res.status === 200 && + incrementalCache && + cacheKey && + (isCacheableRevalidate || + useCacheOrRequestStore?.serverComponentsHmrCache) + ) { + const normalizedRevalidate = + finalRevalidate >= INFINITE_CACHE + ? CACHE_ONE_YEAR + : finalRevalidate + const externalRevalidate = + finalRevalidate >= INFINITE_CACHE ? false : finalRevalidate + + if (workUnitStore && workUnitStore.type === 'prerender') { + // We are prerendering at build time or revalidate time with dynamicIO so we need to + // buffer the response so we can guarantee it can be read in a microtask + const bodyBuffer = await res.arrayBuffer() + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: Buffer.from(bodyBuffer).toString('base64'), + status: res.status, + url: res.url, + } + + // We can skip checking the serverComponentsHmrCache because we aren't in + // dev mode. + + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + await handleUnlock() + + // We return a new Response to the caller. + return new Response(bodyBuffer, { + headers: res.headers, + status: res.status, + statusText: res.statusText, + }) + } else { + // 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(res) + + // We are dynamically rendering including dev mode. We want to return + // the response to the caller as soon as possible because it might stream + // over a very long time. + globalThis.__openNextAls?.getStore()?.pendingPromiseRunner.add(cloned1 + .arrayBuffer() + .then(async (arrayBuffer) => { + const bodyBuffer = Buffer.from(arrayBuffer) + + const fetchedData = { + headers: Object.fromEntries(cloned1.headers.entries()), + body: bodyBuffer.toString('base64'), + status: cloned1.status, + url: cloned1.url, + } + + useCacheOrRequestStore?.serverComponentsHmrCache?.set( + cacheKey, + fetchedData + ) + + if (isCacheableRevalidate) { + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + } + }) + .catch((error) => + console.warn(\`Failed to set fetch cache\`, input, error) + ) + .finally(handleUnlock)) + + + return cloned2 + } + } + " + `); + }); + + test("Next.js 15.0.4", () => { + // source: https://github.com/vercel/next.js/blob/d6a6aa14069/packages/next/src/server/lib/patch-fetch.ts#L627-L725 + const code = `if ( + res.status === 200 && + incrementalCache && + cacheKey && + (isCacheableRevalidate || requestStore?.serverComponentsHmrCache) + ) { + const normalizedRevalidate = + finalRevalidate >= INFINITE_CACHE + ? CACHE_ONE_YEAR + : finalRevalidate + const externalRevalidate = + finalRevalidate >= INFINITE_CACHE ? false : finalRevalidate + + if (workUnitStore && workUnitStore.type === 'prerender') { + // We are prerendering at build time or revalidate time with dynamicIO so we need to + // buffer the response so we can guarantee it can be read in a microtask + const bodyBuffer = await res.arrayBuffer() + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: Buffer.from(bodyBuffer).toString('base64'), + status: res.status, + url: res.url, + } + + // We can skip checking the serverComponentsHmrCache because we aren't in + // dev mode. + + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + await handleUnlock() + + // We we return a new Response to the caller. + return new Response(bodyBuffer, { + headers: res.headers, + status: res.status, + statusText: res.statusText, + }) + } else { + // We are dynamically rendering including dev mode. We want to return + // the response to the caller as soon as possible because it might stream + // over a very long time. + res + .clone() + .arrayBuffer() + .then(async (arrayBuffer) => { + const bodyBuffer = Buffer.from(arrayBuffer) + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: bodyBuffer.toString('base64'), + status: res.status, + url: res.url, + } + + requestStore?.serverComponentsHmrCache?.set( + cacheKey, + fetchedData + ) + + if (isCacheableRevalidate) { + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + } + }) + .catch((error) => + console.warn(\`Failed to set fetch cache\`, input, error) + ) + .finally(handleUnlock) + + return res + } + }`; + + expect(patchCode(code, rule)).toMatchInlineSnapshot(` + "if ( + res.status === 200 && + incrementalCache && + cacheKey && + (isCacheableRevalidate || requestStore?.serverComponentsHmrCache) + ) { + const normalizedRevalidate = + finalRevalidate >= INFINITE_CACHE + ? CACHE_ONE_YEAR + : finalRevalidate + const externalRevalidate = + finalRevalidate >= INFINITE_CACHE ? false : finalRevalidate + + if (workUnitStore && workUnitStore.type === 'prerender') { + // We are prerendering at build time or revalidate time with dynamicIO so we need to + // buffer the response so we can guarantee it can be read in a microtask + const bodyBuffer = await res.arrayBuffer() + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: Buffer.from(bodyBuffer).toString('base64'), + status: res.status, + url: res.url, + } + + // We can skip checking the serverComponentsHmrCache because we aren't in + // dev mode. + + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + await handleUnlock() + + // We we return a new Response to the caller. + return new Response(bodyBuffer, { + headers: res.headers, + status: res.status, + statusText: res.statusText, + }) + } else { + // We are dynamically rendering including dev mode. We want to return + // the response to the caller as soon as possible because it might stream + // over a very long time. + globalThis.__openNextAls?.getStore()?.pendingPromiseRunner.add(res + .clone() + .arrayBuffer() + .then(async (arrayBuffer) => { + const bodyBuffer = Buffer.from(arrayBuffer) + + const fetchedData = { + headers: Object.fromEntries(res.headers.entries()), + body: bodyBuffer.toString('base64'), + status: res.status, + url: res.url, + } + + requestStore?.serverComponentsHmrCache?.set( + cacheKey, + fetchedData + ) + + if (isCacheableRevalidate) { + await incrementalCache.set( + cacheKey, + { + kind: CachedRouteKind.FETCH, + data: fetchedData, + revalidate: normalizedRevalidate, + }, + { + fetchCache: true, + revalidate: externalRevalidate, + fetchUrl, + fetchIdx, + tags, + } + ) + } + }) + .catch((error) => + console.warn(\`Failed to set fetch cache\`, input, error) + ) + .finally(handleUnlock)) + + + return res + } + }" + `); + }); + }); +}); From 788ea2572f626959b70a58896ebe6d4cb993081d Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Wed, 19 Mar 2025 18:40:40 +0100 Subject: [PATCH 3/5] changeset --- .changeset/fuzzy-eyes-pump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fuzzy-eyes-pump.md diff --git a/.changeset/fuzzy-eyes-pump.md b/.changeset/fuzzy-eyes-pump.md new file mode 100644 index 000000000..fa7b96efc --- /dev/null +++ b/.changeset/fuzzy-eyes-pump.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +fix dangling set on fetch cache From 3d0fb949adecf4f6eaaa35453fc3a5c42dc64fb4 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Thu, 20 Mar 2025 10:08:47 +0100 Subject: [PATCH 4/5] fix rebase --- packages/open-next/src/build/createServerBundle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/open-next/src/build/createServerBundle.ts b/packages/open-next/src/build/createServerBundle.ts index 9a000e8de..dc0d0a88f 100644 --- a/packages/open-next/src/build/createServerBundle.ts +++ b/packages/open-next/src/build/createServerBundle.ts @@ -180,6 +180,7 @@ async function generateBundle( const additionalCodePatches = codeCustomization?.additionalCodePatches ?? []; await applyCodePatches(options, tracedFiles, manifests, [ + patchFetchCacheSetMissingWaitUntil, ...additionalCodePatches, ]); From c6daa7967bce9ba37c079c31d4ee48926cbb36b1 Mon Sep 17 00:00:00 2001 From: Dorseuil Nicolas Date: Thu, 20 Mar 2025 10:12:26 +0100 Subject: [PATCH 5/5] fix changeset --- .changeset/fuzzy-eyes-pump.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fuzzy-eyes-pump.md b/.changeset/fuzzy-eyes-pump.md index fa7b96efc..28eb312c1 100644 --- a/.changeset/fuzzy-eyes-pump.md +++ b/.changeset/fuzzy-eyes-pump.md @@ -2,4 +2,4 @@ "@opennextjs/aws": patch --- -fix dangling set on fetch cache +fix dangling promise on set for the fetch cache