diff --git a/.changeset/bright-caches-stream.md b/.changeset/bright-caches-stream.md new file mode 100644 index 000000000..915378597 --- /dev/null +++ b/.changeset/bright-caches-stream.md @@ -0,0 +1,11 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix: support Cache Components rendering on Workers + +Next.js renders Cache Components as a pipeline of event loop tasks and, between two of them, drains the immediates React queued so each stage flushes before the next unblocks more content. Its Node.js implementation builds that boundary out of `_idleStart` timer alignment and `process.nextTick`, neither of which behaves the same on workerd, so the render lands a stage late: runtime prefetches drop everything that arrives after their final task aborts the render, and document renders report cached data as uncached and fail. Replace the staged runner with a workerd implementation that waits for the request's outstanding immediates to settle before entering the next stage, and let Next.js resume partially prerendered routes instead of returning their cached shell as a complete response. The wait is scoped to the request rather than to the render, because Next.js awaits the RSC payload before it starts staging, so React resumes much of the work from promises created outside the staged run. + +Also keep module loading `CacheSignal` instances and subscriptions request scoped. A shared promise registry forwards current and future imports through request-owned notifications, so overlapping renders wait without sharing timer handles. + +Preserve the custom promise API on the wrapped `setImmediate`. Workerd does not provide this hook on the global callback API, but Next.js requires it when it installs `node:timers/promises.setImmediate`. diff --git a/examples/e2e/experimental/e2e/concurrent-rsc.test.ts b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts new file mode 100644 index 000000000..82b2697bc --- /dev/null +++ b/examples/e2e/experimental/e2e/concurrent-rsc.test.ts @@ -0,0 +1,210 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +/** + * Cache Components state Next keeps per process is shared by every request in a Worker isolate. When + * it holds request-bound I/O handles, an overlapping request clears a handle it does not own and + * workerd rejects it mid render, poisoning the isolate. Overlapping RSC prefetches are what a browser + * issues while hovering links, so they force that overlap cheaply. + */ + +const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; +const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" }; +// What the router sends on click: a dynamic RSC refetch, not a prefetch. +const NAVIGATION = { rsc: "1", "next-url": "/" }; + +/** + * Every response is checked against content it must contain, because a poisoned isolate answers 200 + * with a body that is empty or cut short. Documents carry the prerendered `shell`; `resolvedHtml` and + * `resolvedRsc` are flushed last, so only a fully rendered response can contain them. Prefetch depth + * varies by route, but every valid prefetch contains a root model instead of only a close marker. + * + * The tracked-import routes await a dynamic import inside the render, which routes through + * `trackPendingChunkLoad` and the module loading `CacheSignal` — the state this suite guards. + */ +const ROUTES = [ + { + path: "/ppr", + shell: "static component that does not change", + resolvedHtml: "This component should be SSR", + resolvedRsc: "This component should be SSR", + }, + { + path: "/ppr/first", + shell: "Static shell", + resolvedHtml: "Dynamic slug: first", + resolvedRsc: '"data-testid":"dynamic-slug"', + }, + { + path: "/ppr/second", + shell: "Static shell", + resolvedHtml: "Dynamic slug: second", + resolvedRsc: '"data-testid":"dynamic-slug"', + }, + { + path: "/use-cache/ssr", + shell: "Cache", + resolvedHtml: 'data-testid="fully-cached"', + resolvedRsc: '"data-testid":"fully-cached"', + }, + { + path: "/use-cache/isr", + shell: "Cache", + resolvedHtml: 'data-testid="fully-cached"', + resolvedRsc: '"data-testid":"fully-cached"', + }, + { + path: "/tracked-import/first", + shell: "Tracked import shell", + resolvedHtml: "Imported module for first", + resolvedRsc: "Imported module for first", + }, + { + path: "/tracked-import/second", + shell: "Tracked import shell", + resolvedHtml: "Imported module for second", + resolvedRsc: "Imported module for second", + }, + { + path: "/large-shell/concurrent", + shell: "Large shell", + resolvedHtml: "Large dynamic: ", + resolvedRsc: '"data-testid":"large-dynamic"', + }, +] as const; + +type Route = (typeof ROUTES)[number]; + +type Fetched = { + route: Route; + kind: keyof typeof VARIANTS; + status: number; + contentType: string; + body: Buffer; +}; + +async function fetchPath( + request: APIRequestContext, + route: Route, + kind: keyof typeof VARIANTS +): Promise { + let response = await request.get(route.path, { + headers: VARIANTS[kind], + maxRedirects: 0, + }); + + // Next 16.3 redirects synthetic RSC requests for a fully static route to the same route with the + // cache-busting `_rsc` key that a browser normally supplies. Follow only that exact redirect shape, + // while keeping every other redirect visible as a failure below. + if (kind !== "document" && (response.status() === 307 || response.status() === 308)) { + const location = response.headers().location; + const redirected = location ? new URL(location, "http://opennext.invalid") : undefined; + if ( + !location?.startsWith("/") || + location.startsWith("//") || + redirected?.pathname !== route.path || + !redirected.searchParams.has("_rsc") + ) { + throw new Error(`${kind} ${route.path} returned an unexpected redirect to ${location ?? "nowhere"}`); + } + + await response.dispose(); + response = await request.get(`${redirected.pathname}${redirected.search}`, { + headers: VARIANTS[kind], + maxRedirects: 0, + }); + } + + return { + route, + kind, + status: response.status(), + contentType: response.headers()["content-type"] ?? "", + body: await response.body(), + }; +} + +const VARIANTS = { + document: {} as Record, + route: ROUTE_PREFETCH, + segment: SEGMENT_PREFETCH, + navigation: NAVIGATION, +}; + +function assertComplete(result: Fetched) { + const where = `${result.kind} ${result.route.path}`; + const isPrefetch = result.kind === "route" || result.kind === "segment"; + + // A poisoned isolate answers 200 with an empty or truncated body, so status alone proves nothing. + expect(result.status, `${where} should complete`).toEqual(200); + + const body = result.body.toString("utf8"); + if (isPrefetch) { + // Prefetch depth varies by route: some include the static shell and some only router metadata. A + // usable response always has a root model; the reported failure had only a one-byte close marker. + expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + expect(body, `${where} should contain a root model`).toContain("0:{"); + expect(body, `${where} should not contain a Flight error record`).not.toMatch(/^\w+:E\{/m); + + if (result.kind === "segment") { + expect(body, `${where} should contain its router tree`).toContain('"tree"'); + expect(body, `${where} should identify its build`).toContain('"buildId"'); + } + return; + } + + expect(body, `${where} should contain its prerendered shell`).toContain(result.route.shell); + + if (result.contentType.includes("text/html")) { + // Truncated streams lose the closing tag that Next.js flushes last. + expect(body, `${where} should not be truncated`).toContain(""); + expect(body.replaceAll("", ""), `${where} should have resolved its dynamic hole`).toContain( + result.route.resolvedHtml + ); + return; + } + + expect(result.contentType, `${where} should be an RSC payload`).toContain("text/x-component"); + + // A dynamic RSC response has to carry the resolved model, which a truncated Flight stream loses. + expect(body, `${where} should have resolved its dynamic hole`).toContain(result.route.resolvedRsc); +} + +test.describe("concurrent Cache Components requests", () => { + test("overlapping RSC prefetches all complete without poisoning the isolate", async ({ request }) => { + for (let round = 0; round < 3; round++) { + const results = await Promise.all( + ROUTES.flatMap((route) => [ + fetchPath(request, route, "document"), + fetchPath(request, route, "route"), + fetchPath(request, route, "segment"), + ]) + ); + + for (const result of results) { + assertComplete(result); + } + } + + // The failure outlives the requests that caused it, so check the isolate still serves traffic. + for (const route of ROUTES) { + assertComplete(await fetchPath(request, route, "document")); + } + }); + + test("a navigation refetch overlapping its partial prefetch completes", async ({ request }) => { + // Hover starts a partial prefetch; clicking before it settles fires the dynamic refetch while + // the prefetch request is finishing. The dynamic response must still stream to completion. + for (const route of ROUTES) { + const prefetch = fetchPath(request, route, "segment"); + const refetch = fetchPath(request, route, "navigation"); + + assertComplete(await refetch); + assertComplete(await prefetch); + } + + // A hang shows up on later traffic too, so prove the isolate is still healthy. + for (const route of ROUTES) { + assertComplete(await fetchPath(request, route, "navigation")); + } + }); +}); diff --git a/examples/e2e/experimental/e2e/hostile-cache-components.test.ts b/examples/e2e/experimental/e2e/hostile-cache-components.test.ts new file mode 100644 index 000000000..5ed34451c --- /dev/null +++ b/examples/e2e/experimental/e2e/hostile-cache-components.test.ts @@ -0,0 +1,110 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; +const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" }; +const RUNTIME_PREFETCH = { rsc: "1", "next-router-prefetch": "2" }; +const NAVIGATION = { rsc: "1", "next-url": "/" }; + +const PAYLOAD_CASES = [ + { kind: "document", headers: {} }, + { kind: "document", headers: {} }, + { kind: "route", headers: ROUTE_PREFETCH }, + { kind: "segment", headers: SEGMENT_PREFETCH }, + { kind: "runtime", headers: RUNTIME_PREFETCH }, + { kind: "navigation", headers: NAVIGATION }, +] as const; + +async function getBody(request: APIRequestContext, path: string, headers: Record = {}) { + const response = await request.get(path, { headers }); + return { + status: response.status(), + contentType: response.headers()["content-type"] ?? "", + body: await response.body(), + }; +} + +function expectCompleteShell(body: Buffer, where: string) { + expect(body.byteLength, `${where} should include the wide shell`).toBeGreaterThan(100 * 1024); + const text = body.toString("utf8"); + for (const index of [0, 47, 95]) { + expect(text, `${where} lost block ${index}`).toMatch( + new RegExp(`(?:"data-hostile-block":${index}|data-hostile-block="${index}")`) + ); + } + expect(text, `${where} returned a Flight error`).not.toMatch(/^\w+:E\{/m); +} + +function expectCompleteSegment(body: Buffer, where: string) { + const text = body.toString("utf8"); + expect(body.byteLength, `${where} carried only a close marker`).toBeGreaterThan(1); + expect(text, `${where} lost its root model`).toContain("0:{"); + expect(text, `${where} lost its router tree`).toContain('"tree"'); + expect(text, `${where} lost its build id`).toContain('"buildId"'); + expect(text, `${where} returned a Flight error`).not.toMatch(/^\w+:E\{/m); +} + +test.describe("hostile Cache Components graph", () => { + test("cold and warm documents, prefetches, and navigation payloads complete", async ({ request }) => { + const path = `/hostile-shell/cold-${Date.now()}`; + + for (const [index, { kind, headers }] of PAYLOAD_CASES.entries()) { + const session = `hostile-${Date.now()}-${index}`; + const result = await getBody(request, path, { ...headers, "x-session": session }); + const where = `${kind} ${path}`; + + expect(result.status, where).toEqual(200); + if (kind === "segment") { + expectCompleteSegment(result.body, where); + } else { + expectCompleteShell(result.body, where); + } + if (kind === "document") { + const html = result.body.toString("utf8"); + expect(result.contentType).toContain("text/html"); + expect(html).toContain(""); + expect(html).toContain("Hostile dynamic: "); + expect(html).toContain(path.split("/").at(-1)); + expect(html).toContain(session); + } else { + expect(result.contentType).toContain("text/x-component"); + } + } + }); + + test("same-route and cold-route runtime prefetches remain complete under overlap", async ({ request }) => { + const repeated = Array.from({ length: 24 }, (_, index) => + getBody(request, "/hostile-shell/repeated", { + ...RUNTIME_PREFETCH, + "x-session": `repeated-${index}`, + }) + ); + const cold = Array.from({ length: 24 }, (_, index) => + getBody(request, `/hostile-shell/cold-${Date.now()}-${index}`, RUNTIME_PREFETCH) + ); + + const results = await Promise.all([...repeated, ...cold]); + for (const [index, result] of results.entries()) { + expect(result.status, `overlapping runtime prefetch ${index}`).toEqual(200); + expect(result.contentType).toContain("text/x-component"); + expectCompleteShell(result.body, `overlapping runtime prefetch ${index}`); + } + }); + + test("client navigation resolves the request hole without a document reload", async ({ page }) => { + const session = `hostile-navigation-${Date.now()}`; + await page.setExtraHTTPHeaders({ "x-session": session }); + await page.goto("/hostile-shell/navigation-first"); + + await expect(page.getByTestId("hostile-dynamic")).toContainText( + `Hostile dynamic: navigation-first:${session}:` + ); + await expect(page.locator('[data-hostile-block="95"]:visible')).toBeVisible({ timeout: 15_000 }); + await page.getByRole("link", { name: "Hostile shell second item" }).click(); + await page.waitForURL("/hostile-shell/navigation-second"); + await expect(page.getByTestId("hostile-dynamic")).toContainText( + `Hostile dynamic: navigation-second:${session}:` + ); + await expect(page.locator('[data-hostile-block="95"]:visible')).toBeVisible({ timeout: 15_000 }); + expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toEqual(1); + }); +}); diff --git a/examples/e2e/experimental/e2e/ppr.test.ts b/examples/e2e/experimental/e2e/ppr.test.ts index 369c02a9d..2f1bf32bf 100644 --- a/examples/e2e/experimental/e2e/ppr.test.ts +++ b/examples/e2e/experimental/e2e/ppr.test.ts @@ -12,6 +12,9 @@ test.describe("PPR", () => { }); test("PPR rsc prefetch request should be cached", async ({ request }) => { + await request.get("/ppr", { + headers: { rsc: "1", "next-router-prefetch": "1" }, + }); const resp = await request.get("/ppr", { headers: { rsc: "1", "next-router-prefetch": "1" }, }); @@ -21,4 +24,56 @@ test.describe("PPR", () => { expect(headers["x-nextjs-cache"]).toEqual("HIT"); expect(headers["cache-control"]).toEqual("s-maxage=31536000"); }); + + test("dynamic PPR fallback should resume with route params", async ({ page }) => { + const response = await page.goto("/ppr/first"); + + expect(response?.status()).toEqual(200); + await expect(page.getByTestId("static-shell")).toBeVisible(); + await expect(page.getByTestId("dynamic-slug")).toHaveText("Dynamic slug: first"); + }); + + test("dynamic PPR responses stream the shell and resumed content on cold and warm requests", async ({ + request, + }) => { + for (const path of ["/ppr/first", "/ppr/first", "/ppr/second"]) { + const response = await request.get(path); + const body = await response.text(); + + expect(response.status()).toEqual(200); + expect(body).toContain("Static shell"); + expect(body.replaceAll("", "")).toContain(`Dynamic slug: ${path.split("/").at(-1)}`); + expect(body).toContain("self.__next_f.push"); + expect(body).toMatch(/\$(?:RC|RS|RX)\b/); + } + }); + + test("dynamic PPR supports route and segment prefetch requests", async ({ request }) => { + const variants: Record[] = [ + { rsc: "1", "next-router-prefetch": "1" }, + { + rsc: "1", + "next-router-prefetch": "1", + "next-router-segment-prefetch": "/_tree", + }, + ]; + + for (const headers of variants) { + const response = await request.get("/ppr/first", { headers }); + + expect(response.status()).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + expect((await response.body()).byteLength).toBeGreaterThan(0); + } + }); + + test("client navigation can transition between dynamic PPR params", async ({ page }) => { + await page.goto("/ppr/first"); + await expect(page.getByTestId("dynamic-slug")).toHaveText("Dynamic slug: first"); + + await page.getByRole("link", { name: "Second item" }).click(); + await page.waitForURL("/ppr/second"); + await expect(page.getByText("Dynamic slug: second", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toEqual(1); + }); }); diff --git a/examples/e2e/experimental/e2e/staged-render.test.ts b/examples/e2e/experimental/e2e/staged-render.test.ts new file mode 100644 index 000000000..0a9bd1cb1 --- /dev/null +++ b/examples/e2e/experimental/e2e/staged-render.test.ts @@ -0,0 +1,163 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +/** + * Next.js renders Cache Components as a pipeline of event loop tasks and expects React to flush each + * stage before the next one unblocks more content. Its Node.js implementation gets that boundary from + * `process.nextTick`; workerd implements `process.nextTick` with `queueMicrotask`, so the adapter has + * to supply the boundary itself. When it slips, the render lands a stage late: a runtime prefetch + * drops everything that arrives after its final task aborts the render, and a document render reports + * cached data as uncached and fails with a 500. + * + * `/deep-shell/[slug]` is built for this: its shell awaits many times before rendering, so the flush + * that a broken boundary loses is the shell itself. + */ + +const RUNTIME_PREFETCH = { rsc: "1", "next-router-prefetch": "2" }; +const ROUTE_PREFETCH = { rsc: "1", "next-router-prefetch": "1" }; +const SEGMENT_PREFETCH = { ...ROUTE_PREFETCH, "next-router-segment-prefetch": "/_tree" }; + +/** Runtime prefetches start with `~` when partial and `#` when complete; anything else is not one. */ +function expectRuntimePrefetch(body: Buffer, where: string) { + expect(body.byteLength, `${where} carried only the partial marker`).toBeGreaterThan(1); + expect(String.fromCharCode(body[0]!), `${where} should start with a partial marker`).toMatch(/^[~#]$/); +} + +async function runtimePrefetch(request: APIRequestContext, path: string, session: string) { + const response = await request.get(path, { + headers: { ...RUNTIME_PREFETCH, "x-session": session }, + }); + expect(response.status(), `runtime prefetch ${path} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + return response; +} + +test.describe("staged Cache Components rendering", () => { + test("a runtime prefetch carries the shell it rendered", async ({ request }) => { + const response = await runtimePrefetch(request, "/runtime-prefetch/one", "xyz"); + const body = await response.body(); + + expectRuntimePrefetch(body, "/runtime-prefetch/one"); + const text = body.toString("utf8"); + expect(text).toContain("Runtime shell"); + // Session content resolves in a later stage than the shell, so it proves the pipeline advanced. + expect(text).toContain("Runtime session: "); + expect(text).toContain("xyz"); + }); + + test("a deep shell reaches the client instead of being cut off mid render", async ({ request }) => { + const response = await runtimePrefetch(request, "/deep-shell/one", "abc"); + const body = await response.body(); + + expectRuntimePrefetch(body, "/deep-shell/one"); + const text = body.toString("utf8"); + // The leaf is the last thing the shell renders: a render that lands a stage late loses it. + expect(text).toContain("deep-leaf"); + expect(text).toContain("level 0"); + expect(text).toContain("Deep session: "); + expect(text).toContain("abc"); + }); + + test("a deep shell renders its document and prefetches", async ({ request }) => { + const document = await request.get("/deep-shell/two", { headers: { "x-session": "doc" } }); + const html = await document.text(); + + expect(document.status()).toEqual(200); + expect(html).toContain(""); + expect(html.replaceAll("", "")).toContain("Deep leaf level 0"); + // The dynamic hole resolves through a streamed Flight row, so its text arrives JSON escaped. + expect(html).toContain("deep-dynamic"); + expect(html).toContain("Deep dynamic: "); + + for (const headers of [ROUTE_PREFETCH, SEGMENT_PREFETCH]) { + const response = await request.get("/deep-shell/two", { headers }); + + expect(response.status(), `prefetch ${JSON.stringify(headers)} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + expect((await response.body()).byteLength).toBeGreaterThan(1); + } + }); + + test("overlapping runtime prefetches each keep their own shell", async ({ request }) => { + const sessions = ["a", "b", "c", "d", "e", "f"]; + const responses = await Promise.all( + sessions.map((session) => runtimePrefetch(request, "/deep-shell/one", session)) + ); + + for (const [index, response] of responses.entries()) { + const body = await response.body(); + const where = `overlapping prefetch ${index}`; + + expectRuntimePrefetch(body, where); + const text = body.toString("utf8"); + expect(text, `${where} lost its deep shell`).toContain("deep-leaf"); + expect(text, `${where} lost its session content`).toContain(`Deep session: `); + expect(text, `${where} served another request's session`).toContain(sessions[index]!); + } + }); + + test("large runtime prefetches are complete across repeated and overlapping renders", async ({ + request, + }) => { + const sessions = Array.from({ length: 30 }, (_, index) => `large-${index}`); + const responses = []; + + for (const session of sessions.slice(0, 10)) { + responses.push(await runtimePrefetch(request, "/large-shell/one", session)); + } + responses.push( + ...(await Promise.all( + sessions.slice(10).map((session) => runtimePrefetch(request, "/large-shell/one", session)) + )) + ); + + for (const [index, response] of responses.entries()) { + const body = await response.body(); + const where = `large prefetch ${index}`; + + expectRuntimePrefetch(body, where); + expect(body.byteLength, `${where} was truncated`).toBeGreaterThan(60 * 1024); + const text = body.toString("utf8"); + expect(text, `${where} lost its final cached block`).toContain('"data-large-block":63'); + } + }); + + test("a cold large shell keeps request-time random values out of prerendering", async ({ request }) => { + const path = `/large-shell/cold-${Date.now()}`; + const session = `document-session-${Date.now()}`; + const document = await request.get(path, { headers: { "x-session": session } }); + const html = await document.text(); + + expect(document.status()).toEqual(200); + expect(html).toContain(""); + expect(html).toContain('data-large-block="63"'); + expect(html).toContain("Large dynamic: "); + expect(html).toContain(session); + + for (const headers of [ROUTE_PREFETCH, SEGMENT_PREFETCH]) { + const response = await request.get(path, { headers }); + const body = (await response.body()).toString("utf8"); + + expect(response.status(), `prefetch ${JSON.stringify(headers)} should complete`).toEqual(200); + expect(response.headers()["content-type"]).toContain("text/x-component"); + expect(body).toContain("0:{"); + expect(body).not.toMatch(/^\w+:E\{/m); + } + }); + + test("client navigation to a large dynamic shell does not reload the document", async ({ page }) => { + const session = `navigation-session-${Date.now()}`; + await page.setExtraHTTPHeaders({ "x-session": session }); + await page.goto("/large-shell/navigation-first"); + + await expect( + page.getByTestId("large-dynamic").filter({ hasText: `Large dynamic: navigation-first:${session}:` }) + ).toBeVisible(); + await page.getByRole("link", { name: "Large shell second item" }).click(); + await page.waitForURL("/large-shell/navigation-second"); + await expect( + page.getByTestId("large-dynamic").filter({ hasText: `Large dynamic: navigation-second:${session}:` }) + ).toBeVisible(); + await expect(page.locator('[data-large-block="63"]:visible')).toBeAttached(); + expect(await page.evaluate(() => performance.getEntriesByType("navigation").length)).toEqual(1); + }); +}); diff --git a/examples/e2e/experimental/open-next.config.ts b/examples/e2e/experimental/open-next.config.ts index ba0aacef0..c060f622e 100644 --- a/examples/e2e/experimental/open-next.config.ts +++ b/examples/e2e/experimental/open-next.config.ts @@ -1,10 +1,18 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; +import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache"; import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; import shardedTagCache from "@opennextjs/cloudflare/overrides/tag-cache/do-sharded-tag-cache"; import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue"; export default defineCloudflareConfig({ - incrementalCache: r2IncrementalCache, + incrementalCache: + process.env.OPEN_NEXT_REGIONAL_CACHE === "true" + ? withRegionalCache(r2IncrementalCache, { + mode: "long-lived", + shouldLazilyUpdateOnCacheHit: true, + }) + : r2IncrementalCache, + enableCacheInterception: process.env.OPEN_NEXT_CACHE_INTERCEPTION !== "false", // With such a configuration, we could have up to 12 * (8 + 2) = 120 Durable Objects instances tagCache: shardedTagCache({ baseShardSize: 12, diff --git a/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx new file mode 100644 index 000000000..fe036d794 --- /dev/null +++ b/examples/e2e/experimental/src/app/deep-shell/[slug]/page.tsx @@ -0,0 +1,63 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +async function cachedLabel(level: number) { + "use cache"; + return `level ${level}`; +} + +/** Each level awaits several times before rendering, pushing React's first flush away from the task boundary. */ +async function Level({ depth }: { depth: number }) { + for (let i = 0; i < 6; i++) await Promise.resolve(); + const label = await cachedLabel(depth); + for (let i = 0; i < 6; i++) await Promise.resolve(); + + if (depth === 0) return

Deep leaf {label}

; + return ( +
+ {label} + +
+ ); +} + +async function DeepSession() { + for (let i = 0; i < 8; i++) await Promise.resolve(); + const requestHeaders = await headers(); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + return

Deep session: {requestHeaders.get("x-session") ?? "none"}

; +} + +async function DeepDynamic({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + await setTimeout(50); + + return

Deep dynamic: {slug}

; +} + +export default async function DeepShellPage({ params }: PageProps) { + for (let i = 0; i < 4; i++) await Promise.resolve(); + + return ( +
+

Deep shell

+ + Loading deep session...

}> + +
+ Loading deep dynamic...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts new file mode 100644 index 000000000..efb1cfc4d --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunk-loader.ts @@ -0,0 +1,19 @@ +const chunkLoaders = [ + () => import("./chunks/chunk-00"), + () => import("./chunks/chunk-01"), + () => import("./chunks/chunk-02"), + () => import("./chunks/chunk-03"), + () => import("./chunks/chunk-04"), + () => import("./chunks/chunk-05"), + () => import("./chunks/chunk-06"), + () => import("./chunks/chunk-07"), + () => import("./chunks/chunk-08"), + () => import("./chunks/chunk-09"), + () => import("./chunks/chunk-10"), + () => import("./chunks/chunk-11"), +] as const; + +/** Keep imports genuinely lazy so a cold isolate must exercise Next's module-loading signal. */ +export async function loadHostileChunk(index: number) { + return chunkLoaders[index % chunkLoaders.length]!(); +} diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts new file mode 100644 index 000000000..fe5fe7550 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-00.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-00"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts new file mode 100644 index 000000000..569ed821d --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-01.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-01"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts new file mode 100644 index 000000000..7627ba587 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-02.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-02"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts new file mode 100644 index 000000000..51990dfdb --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-03.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-03"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts new file mode 100644 index 000000000..67a05fe53 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-04.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-04"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts new file mode 100644 index 000000000..1ce5f0b6a --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-05.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-05"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts new file mode 100644 index 000000000..bdc45d0f0 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-06.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-06"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts new file mode 100644 index 000000000..174e29b79 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-07.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-07"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts new file mode 100644 index 000000000..d7582c226 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-08.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-08"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts new file mode 100644 index 000000000..bb8879f00 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-09.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-09"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts new file mode 100644 index 000000000..26e4fd172 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-10.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-10"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts new file mode 100644 index 000000000..ae30d8524 --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/chunks/chunk-11.ts @@ -0,0 +1 @@ +export const chunkToken = "hostile-chunk-11"; diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx new file mode 100644 index 000000000..1877c564c --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/page.tsx @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; + +import { headers } from "next/headers"; +import Link from "next/link"; +import { Suspense } from "react"; + +import { loadHostileChunk } from "./chunk-loader"; +import { hostileYield } from "./timer-harness"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +const LAST_BLOCK = 95; +const BLOCK_PAYLOAD = "hostile-cache-components-payload-".repeat(36); + +async function getHostileBlock(index: number) { + "use cache"; + + const { chunkToken } = await loadHostileChunk(index); + await hostileYield(index); + return `${index}:${chunkToken}:${BLOCK_PAYLOAD}`; +} + +async function HostileBlock({ index }: { index: number }) { + for (let hop = 0; hop < index % 7; hop++) { + await Promise.resolve(); + } + + const value = await getHostileBlock(index); + return

{value}

; +} + +async function RequestHole({ params }: PageProps) { + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]); + await hostileYield(slug.length); + + return ( +

+ Hostile dynamic: {slug}:{requestHeaders.get("x-session") ?? "none"}:{randomUUID()} +

+ ); +} + +/** + * A deliberately hostile Cache Components graph: a wide cached shell, cold split-chunk imports, + * every immediate API an application can capture, varied microtask depth, and a request-only hole. + * Truncation at any staged boundary loses a numbered block or the final sentinel. + */ +export default function HostileShellPage({ params }: PageProps) { + return ( +
+

Hostile shell

+ Hostile shell second item + {Array.from({ length: LAST_BLOCK + 1 }, (_, index) => ( + Loading block {index}

}> + +
+ ))} + Loading hostile dynamic...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts b/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts new file mode 100644 index 000000000..a805fac9f --- /dev/null +++ b/examples/e2e/experimental/src/app/hostile-shell/[slug]/timer-harness.ts @@ -0,0 +1,63 @@ +import { setImmediate as timersSetImmediate } from "node:timers"; +import { setImmediate as timersPromisesSetImmediate } from "node:timers/promises"; + +type ImmediateHandle = ReturnType; + +// Capture every scheduling surface when this application module initializes. Real applications and +// their dependencies commonly keep these references, so replacing only the current global function +// is not sufficient to control the work they schedule later. +const capturedGlobalSetImmediate = globalThis.setImmediate; +const capturedGlobalClearImmediate = globalThis.clearImmediate; +const capturedTimersSetImmediate = timersSetImmediate; +const capturedTimersPromisesSetImmediate = timersPromisesSetImmediate; +const capturedNextTick = process.nextTick.bind(process); + +function callbackImmediate(schedule: typeof setImmediate): Promise { + return new Promise((resolve) => schedule(resolve)); +} + +function nextTick(): Promise { + return new Promise((resolve) => capturedNextTick(resolve)); +} + +function nestedImmediate(): Promise { + return new Promise((resolve) => { + capturedGlobalSetImmediate(() => capturedTimersSetImmediate(resolve)); + }); +} + +function cancelledImmediate(): void { + const handle = capturedGlobalSetImmediate(() => { + throw new Error("A cancelled hostile-shell immediate ran"); + }) as ImmediateHandle; + capturedGlobalClearImmediate(handle); +} + +/** Exercise the scheduling shapes that can place React's flush after a staged-render boundary. */ +export async function hostileYield(index: number): Promise { + await Promise.resolve(); + + switch (index % 6) { + case 0: + await callbackImmediate(capturedGlobalSetImmediate); + break; + case 1: + await callbackImmediate(capturedTimersSetImmediate); + break; + case 2: + await capturedTimersPromisesSetImmediate(); + break; + case 3: + await nestedImmediate(); + break; + case 4: + cancelledImmediate(); + await callbackImmediate(capturedGlobalSetImmediate); + break; + default: + await nextTick(); + await callbackImmediate(capturedTimersSetImmediate); + } + + await new Promise((resolve) => queueMicrotask(resolve)); +} diff --git a/examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx b/examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx new file mode 100644 index 000000000..683a5eac0 --- /dev/null +++ b/examples/e2e/experimental/src/app/large-shell/[slug]/page.tsx @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; + +import { headers } from "next/headers"; +import Link from "next/link"; +import { Suspense } from "react"; + +type PageProps = { params: Promise<{ slug: string }> }; + +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + unstable_disableBuildValidation: true, +}; + +const BLOCK = "cache-components-large-shell-".repeat(48); + +async function cachedBlock(index: number) { + "use cache"; + await Promise.resolve(); + return `${index}:${BLOCK}`; +} + +async function LargeBlock({ index }: { index: number }) { + for (let i = 0; i < index % 5; i++) await Promise.resolve(); + const value = await cachedBlock(index); + return

{value}

; +} + +async function RequestContent({ params }: PageProps) { + const [{ slug }, requestHeaders] = await Promise.all([params, headers()]); + return ( +

+ Large dynamic: {slug}:{requestHeaders.get("x-session") ?? "none"}:{randomUUID()} +

+ ); +} + +export default function LargeShellPage({ params }: PageProps) { + return ( +
+

Large shell

+ Large shell second item + {Array.from({ length: 64 }, (_, index) => ( + + ))} + Loading large dynamic content...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx b/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx new file mode 100644 index 000000000..55d7d3352 --- /dev/null +++ b/examples/e2e/experimental/src/app/ppr/[slug]/page.tsx @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +async function DynamicSlug({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + await setTimeout(100); + + return

Dynamic slug: {slug}

; +} + +export default function DynamicPPRPage({ params }: PageProps) { + return ( +
+

Static shell

+ + Loading dynamic slug...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx b/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx new file mode 100644 index 000000000..08693fd00 --- /dev/null +++ b/examples/e2e/experimental/src/app/runtime-prefetch/[slug]/page.tsx @@ -0,0 +1,59 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +/** + * A runtime prefetch renders the shell and the session content in a five task pipeline and aborts + * right after the last task, so anything React has not flushed by then is dropped and the client + * receives only the one byte partial marker. It is the strictest user of Next's staged scheduler. + */ +export const unstable_instant = { + prefetch: "runtime", + samples: [{ params: { slug: "sample" }, headers: [["x-session", "sample"]] }], + // Build time validation renders the page in a worker, which is not what this fixture exercises. + unstable_disableBuildValidation: true, +}; + +async function getShellLabel() { + "use cache"; + return "Runtime shell"; +} + +async function RuntimeShell() { + const label = await getShellLabel(); + + return

{label}

; +} + +async function RuntimeSession() { + const requestHeaders = await headers(); + + return

Runtime session: {requestHeaders.get("x-session") ?? "none"}

; +} + +async function RuntimeDynamic({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + await setTimeout(50); + + return

Runtime dynamic: {slug}

; +} + +export default async function RuntimePrefetchPage({ params }: PageProps) { + await Promise.resolve(); + + return ( +
+ + Loading runtime session...

}> + +
+ Loading runtime dynamic...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx b/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx new file mode 100644 index 000000000..ccb623f4e --- /dev/null +++ b/examples/e2e/experimental/src/app/tracked-import/[slug]/page.tsx @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { setTimeout } from "node:timers/promises"; +import { Suspense } from "react"; + +type PageProps = { + params: Promise<{ slug: string }>; +}; + +/** + * A dynamic import inside a Cache Components render makes Next.js track module loading, which is the + * state that used to be shared by every request in a Worker isolate. + */ +async function TrackedImport({ params }: PageProps) { + const [{ slug }] = await Promise.all([params, headers()]); + const { describeSlug } = await import("@/lib/late-module"); + await setTimeout(50); + + return

{describeSlug(slug)}

; +} + +export default function TrackedImportPage({ params }: PageProps) { + return ( +
+

Tracked import shell

+ Loading tracked import...

}> + +
+
+ ); +} diff --git a/examples/e2e/experimental/src/lib/late-module.ts b/examples/e2e/experimental/src/lib/late-module.ts new file mode 100644 index 000000000..3286ee66d --- /dev/null +++ b/examples/e2e/experimental/src/lib/late-module.ts @@ -0,0 +1,3 @@ +export function describeSlug(slug: string): string { + return `Imported module for ${slug}`; +} diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index d625f8bd8..302c1621e 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -44,7 +44,9 @@ export type CloudflareOverrides = { /** * Enable cache interception - * Should be `false` when PPR is used + * Cache Components routes bypass interception so Next.js can resume their postponed work. + * Should still be `false` with `experimental.ppr` alone: the interceptor only holds the cached + * shell, so it would serve a partial page as if it were complete. * @default false */ enableCacheInterception?: boolean; diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts index 9e80ec3bc..1e807ee73 100644 --- a/packages/cloudflare/src/cli/build/build.ts +++ b/packages/cloudflare/src/cli/build/build.ts @@ -19,6 +19,7 @@ import { compileInit } from "./open-next/compile-init.js"; import { compileSkewProtection } from "./open-next/compile-skew-protection.js"; import { compileDurableObjects } from "./open-next/compileDurableObjects.js"; import { createServerBundle } from "./open-next/createServerBundle.js"; +import { patchMiddlewareCacheComponents } from "./patches/plugins/cache-components.js"; import { useNodeMiddleware } from "./utils/middleware.js"; import { getVersion } from "./utils/version.js"; @@ -100,6 +101,7 @@ export async function build( // Compile middleware await createMiddleware(options, { forceOnlyBuildOnce: true }); + patchMiddlewareCacheComponents(options); createStaticAssets(options, { useBasePath: true }); diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts index 9e1cad3e5..a16bbdf15 100644 --- a/packages/cloudflare/src/cli/build/bundle-server.ts +++ b/packages/cloudflare/src/cli/build/bundle-server.ts @@ -13,6 +13,11 @@ import type { ProjectOptions } from "../project-options.js"; import { normalizePath } from "../utils/normalize-path.js"; import { patchVercelOgLibrary } from "./patches/ast/patch-vercel-og-library.js"; import { patchWebpackRuntime } from "./patches/ast/webpack-runtime.js"; +import { + cacheComponentsSchedulerModule, + patchCacheComponents, + usesCacheComponents, +} from "./patches/plugins/cache-components.js"; import { inlineDynamicRequires } from "./patches/plugins/dynamic-requires.js"; import { inlineFindDir } from "./patches/plugins/find-dir.js"; import { patchInstrumentation } from "./patches/plugins/instrumentation.js"; @@ -69,11 +74,21 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project const packagePath = getPackagePath(buildOpts); const openNextServer = path.join(outputPath, packagePath, `index.mjs`); const openNextServerBundle = path.join(outputPath, packagePath, `handler.mjs`); + const initializeCacheComponentsScheduler = + usesCacheComponents(nextConfig) && !buildHelper.compareSemver(buildOpts.nextVersion, "<", "16.2.11"); const updater = new ContentUpdater(buildOpts); const result = await build({ - entryPoints: [openNextServer], + ...(initializeCacheComponentsScheduler + ? { + stdin: { + contents: `import "${cacheComponentsSchedulerModule}"; export { handler } from ${JSON.stringify(openNextServer)};`, + resolveDir: buildOpts.appPath, + sourcefile: "cache-components-server-entry.mjs", + }, + } + : { entryPoints: [openNextServer] }), bundle: true, outfile: openNextServerBundle, format: "esm", @@ -102,6 +117,7 @@ export async function bundleServer(buildOpts: BuildOptions, projectOpts: Project fixRequire(updater), handleOptionalDependencies(optionalDependencies), patchInstrumentation(updater, buildOpts), + patchCacheComponents(updater, buildOpts, nextConfig), patchPagesRouterContext(buildOpts), inlineFindDir(updater, buildOpts), inlineLoadManifest(updater, buildOpts), diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts new file mode 100644 index 000000000..aa5faebea --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.spec.ts @@ -0,0 +1,536 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import type { ContentUpdater } from "@opennextjs/aws/plugins/content-updater.js"; +import type { NextConfig } from "@opennextjs/aws/types/next-types.js"; +import mockFs from "mock-fs"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { computePatchDiff } from "../../utils/test-patch.js"; +import { + bypassPprCacheInterceptionRule, + cacheComponentsSchedulerFileFilter, + cacheComponentsSchedulerModule, + moduleLoadingSignalFileFilter, + patchCacheComponents, + patchCacheComponentsScheduler, + patchMiddlewareCacheComponents, + patchModuleLoadingSignal, + runInSequentialTasksRule, + usesCacheComponents, +} from "./cache-components.js"; + +const incompatibleSchedulerPattern = /["']_idleStart["']\s*in/; + +/** + * The module loading patch is only meaningful against the real Next.js implementation: what it has to + * preserve is how `CacheSignal.subscribeToReads` replays in-flight reads to a late subscriber. Resolve + * both from the example app, which pins the Next.js version this adapter is built against. + */ +const nextRequire = createRequire( + new URL("../../../../../../../examples/e2e/experimental/package.json", import.meta.url) +); +const next15Require = createRequire( + new URL("../../../../../../../examples/playground15/package.json", import.meta.url) +); +const next15Version = (next15Require("next/package.json") as { version: string }).version; +const next15RuntimePath = next15Require.resolve("next/dist/compiled/next-server/app-page.runtime.prod.js"); +const moduleTrackerPath = nextRequire.resolve( + "next/dist/server/app-render/module-loading/track-module-loading.instance.js" +); +const trackerRequire = createRequire(moduleTrackerPath); +const { CacheSignal } = trackerRequire("../cache-signal") as { + CacheSignal: new () => { + hasPendingReads(): boolean; + cacheReady(): Promise; + }; +}; + +type ModuleTracker = { + trackPendingImport(exportsOrPromise: unknown): void; + trackPendingModules(cacheSignal: unknown): void; +}; + +/** Runs the patched copy of Next's real module tracker so its behaviour, not its text, is asserted. */ +function loadPatchedModuleTracker( + contents = readFileSync(moduleTrackerPath, "utf8"), + modulePath = moduleTrackerPath +): ModuleTracker { + const patched = patchModuleLoadingSignal(contents, modulePath); + const module = { exports: {} as ModuleTracker }; + + new Function("require", "module", "exports", patched)(trackerRequire, module, module.exports); + + return module.exports; +} + +/** Long enough for the signal's `nextTick` -> `setImmediate` -> `setTimeout` chain to settle. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); + +const cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); + +/** + * `runWithCloudflareRequestContext` exposes the current request's store through this symbol. Keep the + * getter installed across awaits so tests can model two requests sharing one module instance. + */ +async function withRequestScopes( + run: (enterRequest: (name: string) => void) => T | Promise +): Promise { + let current: Record | undefined; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, cloudflareContextSymbol); + Object.defineProperty(globalThis, cloudflareContextSymbol, { + configurable: true, + get: () => current, + }); + + const scopes = new Map>(); + try { + return await run((name) => { + if (!scopes.has(name)) { + scopes.set(name, { env: {}, ctx: {}, cf: {} }); + } + current = scopes.get(name); + }); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, cloudflareContextSymbol, descriptor); + } else { + delete (globalThis as Record)[cloudflareContextSymbol]; + } + } +} + +function buildOptsFor(nextVersion: string): BuildOptions { + return { nextVersion, outputDir: "/output" } as BuildOptions; +} + +type PluginHarness = { + onEnd: (result: { errors: unknown[] }) => void; + resolve: (specifier: string) => { path: string } | undefined; +}; + +/** Captures the esbuild callbacks the plugin registers so they can be exercised directly. */ +function setupPlugin(plugin: ReturnType): PluginHarness { + const resolvers: Array<[RegExp, (args: { path: string }) => { path: string }]> = []; + let onEnd: PluginHarness["onEnd"] = () => {}; + + plugin.setup({ + onEnd: (callback: PluginHarness["onEnd"]) => (onEnd = callback), + onResolve: (options: { filter: RegExp }, callback: (args: { path: string }) => { path: string }) => + resolvers.push([options.filter, callback]), + } as never); + + return { + onEnd: (result) => onEnd(result), + resolve: (specifier) => resolvers.find(([filter]) => filter.test(specifier))?.[1]({ path: specifier }), + }; +} + +function readSchedulerFixture(name: string): string { + return readFileSync(new URL(`./fixtures/cache-components/${name}`, import.meta.url), "utf8"); +} + +describe("Cache Components", () => { + afterEach(() => mockFs.restore()); + + test("uses a workerd-compatible sequential task scheduler", () => { + const code = `let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js"); +function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[]; +if("_idleStart"in s)s._idleStart=0; +s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}})); +for(let e=0;er()))} +s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})}`; + + expect(computePatchDiff("app-render-render-utils.js", code, runInSequentialTasksRule)) + .toMatchInlineSnapshot(` + "Index: app-render-render-utils.js + =================================================================== + --- app-render-render-utils.js + +++ app-render-render-utils.js + @@ -1,6 +1,4 @@ + let oX=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js"); + -function oJ(e,...t){return new Promise((r,n)=>{let a,i=createAtomicTimerGroup(),s=[]; + -if("_idleStart"in s)s._idleStart=0; + -s.push(i(()=>{try{(0,oX.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e()}catch(e){n(e)}})); + -for(let e=0;er()))} + -s.push(i(()=>{try{(0,oX.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})} + \\ No newline at end of file + +function oJ(first, ...rest) { + + return require("__opennext_cache_components_scheduler").runInSequentialTasks(first, ...rest); + +} + \\ No newline at end of file + " + `); + }); + + test.each([ + "next-16.2.12-app-page-turbo.runtime.prod.txt", + "next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt", + ])("patches the minified Turbo scheduler from %s", (fixture) => { + const code = readSchedulerFixture(fixture); + const patched = patchCacheComponentsScheduler(code, fixture); + + expect(patched).not.toBe(code); + expect(patched).not.toMatch(incompatibleSchedulerPattern); + expect(patched).toContain(`require("${cacheComponentsSchedulerModule}").runInSequentialTasks`); + }); + + test.each([ + "/next/dist/compiled/next-server/app-page.runtime.prod.js", + "/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js", + "/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js", + "/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js", + "/app/.next/server/chunks/ssr/[root-of-the-server]__abc._.js", + "/app/.next/server/chunks/214.js", + ])("targets the Cache Components runtime %s", (runtimePath) => { + expect(cacheComponentsSchedulerFileFilter.test(runtimePath)).toBe(true); + }); + + test.each([ + "/next/dist/server/app-render/module-loading/track-module-loading.instance.js", + "/next/dist/esm/server/app-render/module-loading/track-module-loading.instance.js", + ])("targets the module loading signal in %s", (modulePath) => { + expect(moduleLoadingSignalFileFilter.test(modulePath)).toBe(true); + }); + + test("does not target the re-exporting module loading facade", () => { + expect( + moduleLoadingSignalFileFilter.test( + "/next/dist/server/app-render/module-loading/track-module-loading.external.js" + ) + ).toBe(false); + }); + + test("keeps a userspace cached import visible to a request that never executed it", async () => { + const { trackPendingImport, trackPendingModules } = loadPatchedModuleTracker(); + + await withRequestScopes(async (enterRequest) => { + // The pattern Next.js documents on `trackDynamicImport`: only the first caller runs the + // instrumented `import()`, every later caller gets the already created promise back. + let cached: Promise | undefined; + let settle: () => void = () => {}; + function loadOnce() { + if (!cached) { + cached = new Promise((resolve) => (settle = resolve)); + trackPendingImport(cached); + } + return cached; + } + + enterRequest("A"); + const renderA = new CacheSignal(); + trackPendingModules(renderA); + loadOnce(); + + // A second request starts while the import is in flight and reuses the cached promise, so + // nothing tracks the import on its behalf — it has to learn about it from the shared signal. + enterRequest("B"); + const renderB = new CacheSignal(); + trackPendingModules(renderB); + loadOnce(); + + expect(renderB.hasPendingReads()).toBe(true); + + let ready = false; + void renderB.cacheReady().then(() => (ready = true)); + await tick(); + expect(ready, "cacheReady must not resolve while the import is pending").toBe(false); + + settle(); + await tick(); + expect(ready).toBe(true); + }); + }); + + test("forwards an import that starts after another request subscribes", async () => { + const { trackPendingImport, trackPendingModules } = loadPatchedModuleTracker(); + + await withRequestScopes(async (enterRequest) => { + let settle: () => void = () => {}; + const cachedImport = new Promise((resolve) => (settle = resolve)); + + enterRequest("B"); + const renderB = new CacheSignal(); + trackPendingModules(renderB); + + // A starts the only instrumented import after B subscribed. B later reuses the user-land + // cached promise, so the module tracker must forward A's future read to B's request signal. + enterRequest("A"); + trackPendingImport(cachedImport); + enterRequest("B"); + await Promise.resolve(); + + expect(renderB.hasPendingReads()).toBe(true); + + let ready = false; + void renderB.cacheReady().then(() => (ready = true)); + await tick(); + expect(ready, "cacheReady must wait for a future import from another request").toBe(false); + + settle(); + await tick(); + expect(ready).toBe(true); + }); + }); + + test("does not clear a timer handle owned by another request", async () => { + const { trackPendingImport } = loadPatchedModuleTracker(); + + // Model workerd's ownership check on immediate handles. The first resolved import leaves a cleanup + // handle pending; starting an import in another request must not touch it. + const realSetImmediate = globalThis.setImmediate; + const realClearImmediate = globalThis.clearImmediate; + const scheduled = new Set(); + const owners = new Map(); + let currentRequest = ""; + let cleanupAttempts = 0; + + try { + globalThis.setImmediate = ((callback: (...args: unknown[]) => void) => { + const handle = realSetImmediate(callback); + scheduled.add(handle); + owners.set(handle, currentRequest); + return handle; + }) as typeof setImmediate; + globalThis.clearImmediate = ((handle: NodeJS.Immediate) => { + if (owners.get(handle) !== currentRequest) { + cleanupAttempts++; + throw new Error("Cannot perform I/O on behalf of a different request."); + } + scheduled.delete(handle); + owners.delete(handle); + return realClearImmediate(handle); + }) as typeof clearImmediate; + + await withRequestScopes(async (enterRequest) => { + currentRequest = "A"; + enterRequest(currentRequest); + trackPendingImport(Promise.resolve()); + await Promise.resolve(); + await Promise.resolve(); + + currentRequest = "B"; + enterRequest(currentRequest); + expect(() => trackPendingImport(Promise.resolve())).not.toThrow(); + }); + } finally { + for (const handle of scheduled) { + realClearImmediate(handle); + } + globalThis.setImmediate = realSetImmediate; + globalThis.clearImmediate = realClearImmediate; + } + + expect(cleanupAttempts, "one request must not clear another request's timer").toBe(0); + }); + + test("fails when the module loading signal getter cannot be patched", () => { + const code = `let _moduleLoadingSignal; +function getModuleLoadingSignal() { + return (_moduleLoadingSignal ??= new _cachesignal.CacheSignal()); +} +function trackPendingChunkLoad(promise) { + const moduleLoadingSignal = getModuleLoadingSignal(); + moduleLoadingSignal.trackRead(promise); +} +function trackPendingModules(cacheSignal) { + const moduleLoadingSignal = getModuleLoadingSignal(); + const unsubscribe = moduleLoadingSignal.subscribeToReads(cacheSignal); + cacheSignal.cacheReady().then(unsubscribe); +}`; + + expect(() => patchModuleLoadingSignal(code, "changed-module-loading.js")).toThrow( + "Failed to patch the module loading signal in changed-module-loading.js" + ); + }); + + test("does not depend on the module loading signal's backing identifier", () => { + const renamedSource = readFileSync(moduleTrackerPath, "utf8").replaceAll( + "_moduleLoadingSignal", + "renamedModuleLoadingSignal" + ); + const tracker = loadPatchedModuleTracker(renamedSource, "renamed-module-loading.js"); + + expect(() => tracker.trackPendingImport(Promise.resolve())).not.toThrow(); + }); + + test("removes the separate atomic timer group emitted by webpack", () => { + const unrelatedIdleStartCheck = `function inspectTimer(timer){return "_idleStart" in timer?timer._idleStart:null}`; + const code = `function createGroup(){let didRun=false;return function schedule(callback){ + if(didRun)throw new Error("Cannot schedule more timers into a group that already executed"); + const timer=setTimeout(callback,0); + if("_idleStart" in timer)timer._idleStart=0; + return timer; +}} +function run(first,...rest){return new Promise((resolve)=>{ + const schedule=createAtomicTimerGroup(); + schedule(()=>DANGEROUSLY_runPendingImmediatesAfterCurrentTask()); + schedule(()=>resolve(first())); +})} +${unrelatedIdleStartCheck}`; + + const patched = patchCacheComponentsScheduler(code, "webpack-server-chunk.js"); + + expect(patched).not.toContain("Cannot schedule more timers into a group that already executed"); + expect(patched).toContain(unrelatedIdleStartCheck); + expect(patched).toContain("OpenNext replaced this incompatible Cache Components timer group"); + expect(patched).toContain(`require("${cacheComponentsSchedulerModule}").runInSequentialTasks`); + }); + + test("fails when an incompatible scheduler is present but cannot be patched", () => { + const code = `function changedScheduler(){ + if (didRun) throw new Error("Cannot schedule more timers into a group that already executed"); + const timer = setTimeout(() => {}, 0); + if ("_idleStart" in timer) timer._idleStart = 0; +}`; + + expect(() => patchCacheComponentsScheduler(code, "changed-runtime.js")).toThrow( + "Failed to patch the Cache Components scheduler in changed-runtime.js" + ); + }); + + test("leaves partially prerendered routes for Next.js to resume", () => { + const code = `export async function cacheInterceptor(event) { + let localizedPath = event.rawPath; + const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) || + Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath)); + if (isISR) { + const cachedData = await globalThis.incrementalCache.get(localizedPath); + if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value); + } + return event; +}`; + + expect(computePatchDiff("cacheInterceptor.js", code, bypassPprCacheInterceptionRule)) + .toMatchInlineSnapshot(` + "Index: cacheInterceptor.js + =================================================================== + --- cacheInterceptor.js + +++ cacheInterceptor.js + @@ -1,10 +1,14 @@ + export async function cacheInterceptor(event) { + let localizedPath = event.rawPath; + const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath) || + Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((dr) => new RegExp(dr.routeRegex).test(localizedPath)); + - if (isISR) { + - const cachedData = await globalThis.incrementalCache.get(localizedPath); + - if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value); + - } + + if (isISR && !( + + PrerenderManifest?.routes?.[localizedPath]?.renderingMode === "PARTIALLY_STATIC" || + + PrerenderManifest?.routes?.[localizedPath]?.experimentalPPR === true || + + Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((route) => + + new RegExp(route.routeRegex).test(localizedPath) && + + (route.renderingMode === "PARTIALLY_STATIC" || route.experimentalPPR === true) + + ) + +)) { const cachedData = await globalThis.incrementalCache.get(localizedPath);if (cachedData?.value) return generateResult(event, localizedPath, cachedData.value); } + return event; + } + \\ No newline at end of file + " + `); + }); + + const middlewareBundle = `export async function cacheInterceptor(event) { + let localizedPath = event.rawPath; + const isISR = Object.keys(PrerenderManifest?.routes ?? {}).includes(localizedPath); + if (isISR) { + return generateResult(event); + } + return event; +}`; + + function mockMiddlewareBuild(nextConfig: object, middleware = middlewareBundle) { + mockFs({ + "/app/.next/required-server-files.json": JSON.stringify({ config: nextConfig }), + "/output/middleware/handler.mjs": middleware, + }); + + return { + appBuildOutputPath: "/app", + outputDir: "/output", + config: { dangerous: { enableCacheInterception: true } }, + } as BuildOptions; + } + + test("patches cache interception in the generated external middleware", () => { + patchMiddlewareCacheComponents(mockMiddlewareBuild({ cacheComponents: true })); + + expect(readFileSync("/output/middleware/handler.mjs", "utf8")).toContain( + 'route.renderingMode === "PARTIALLY_STATIC"' + ); + }); + + // Cache interception alone must not make the middleware bundle's shape build-critical. + test("leaves the middleware alone when the app does not use Cache Components", () => { + const buildOpts = mockMiddlewareBuild({}, "export function unrelated() {}"); + + expect(() => patchMiddlewareCacheComponents(buildOpts)).not.toThrow(); + expect(readFileSync("/output/middleware/handler.mjs", "utf8")).toBe("export function unrelated() {}"); + }); + + // The flag moved across Next canaries; missing a spelling would silently skip the patches. + test.each([ + [{ cacheComponents: true }, true], + [{ experimental: { cacheComponents: true } }, true], + [{ experimental: { dynamicIO: true } }, true], + [{ experimental: { ppr: true } }, false], + [{}, false], + ] as const)("detects Cache Components in %j", (nextConfig, expected) => { + expect(usesCacheComponents(nextConfig as NextConfig)).toBe(expected); + }); + + test("registers no patches when the app does not use Cache Components", () => { + const updateContent = vi.fn(); + const updater = { updateContent } as unknown as ContentUpdater; + + patchCacheComponents(updater, buildOptsFor("16.2.11"), {} as NextConfig); + expect(updateContent).not.toHaveBeenCalled(); + + patchCacheComponents(updater, buildOptsFor("16.2.11"), { cacheComponents: true } as NextConfig); + expect(updateContent).toHaveBeenCalledWith("cache-components-scheduler", expect.anything()); + expect(updateContent).toHaveBeenCalledWith("cache-components-module-loading-signal", expect.anything()); + }); + + test("does not require Next 16 patches for Next 15 Cache Components", () => { + expect(next15Version).toBe("15.5.21"); + expect(readFileSync(next15RuntimePath, "utf8")).not.toMatch( + /Cannot schedule more timers into a group that already executed/ + ); + + const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; + const plugin = patchCacheComponents(updater, buildOptsFor(next15Version), { + experimental: { dynamicIO: true }, + } as NextConfig); + + expect(updater.updateContent).not.toHaveBeenCalled(); + expect(() => setupPlugin(plugin).onEnd({ errors: [] })).not.toThrow(); + }); + + // A filter that stops matching skips the callback silently, which would ship an unpatched build. + test("fails the build when a patch matched nothing", () => { + const updater = { updateContent: vi.fn() } as unknown as ContentUpdater; + const plugin = patchCacheComponents(updater, buildOptsFor("16.2.11"), { + cacheComponents: true, + } as NextConfig); + const { onEnd } = setupPlugin(plugin); + + expect(() => onEnd({ errors: [] })).toThrow(/scheduler and module loading signal patches/); + // A build that already failed keeps its own error. + expect(() => onEnd({ errors: [{ text: "something else broke" }] })).not.toThrow(); + }); + // The generated `require` must resolve to the adapter's own scheduler, not to a missing package. + test("resolves the scheduler module to the copied template", () => { + const plugin = patchCacheComponents( + { updateContent: vi.fn() } as unknown as ContentUpdater, + buildOptsFor("16.2.11"), + { cacheComponents: true } as NextConfig + ); + + expect(setupPlugin(plugin).resolve(cacheComponentsSchedulerModule)?.path).toBe( + join("/output", "cloudflare-templates/cache-components-scheduler.js") + ); + }); +}); diff --git a/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts new file mode 100644 index 000000000..f0d7bfe80 --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/cache-components.ts @@ -0,0 +1,374 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { loadConfig } from "@opennextjs/aws/adapters/config/util.js"; +import { type BuildOptions, compareSemver } from "@opennextjs/aws/build/helper.js"; +import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import type { ContentUpdater, Plugin } from "@opennextjs/aws/plugins/content-updater.js"; +import type { NextConfig } from "@opennextjs/aws/types/next-types.js"; +import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js"; + +type CacheComponentsNextConfig = NextConfig & { + cacheComponents?: boolean; + experimental?: { + cacheComponents?: boolean; + dynamicIO?: boolean; + }; +}; + +/** + * The flag moved from `experimental.dynamicIO` to `experimental.cacheComponents` to a top level + * option over the Next 15/16 canaries, so accept every spelling. + */ +export function usesCacheComponents(nextConfig: CacheComponentsNextConfig): boolean { + return Boolean( + nextConfig.cacheComponents ?? + nextConfig.experimental?.cacheComponents ?? + nextConfig.experimental?.dynamicIO + ); +} + +/** + * Bare specifier the patched scheduler requires. `patchCacheComponents` resolves it to the copied + * `cache-components-scheduler` template, so the generated code carries no build machine paths. + */ +export const cacheComponentsSchedulerModule = "__opennext_cache_components_scheduler"; + +const atomicTimerGroupErrorPattern = /Cannot schedule more timers into a group that already executed/; + +const moduleLoadingSignalPattern = /moduleLoadingSignal/; + +export const cacheComponentsSchedulerFileFilter = getCrossPlatformPathRegex( + String.raw`/(?:next/dist/compiled/next-server/app-page(?:-turbo)?(?:-experimental)?\.runtime\.prod|\.next/server/chunks/.+)\.js$`, + { escape: false } +); + +export const moduleLoadingSignalFileFilter = getCrossPlatformPathRegex( + String.raw`/next/dist/(?:esm/)?server/app-render/module-loading/track-module-loading\.instance\.js$`, + { escape: false } +); + +/** + * Next stages Cache Components renders across event loop tasks, using `_idleStart` timer alignment + * and `process.nextTick` to bound them. Neither behaves the same on workerd, so swap the staged + * runner for a workerd implementation and stub the now unreachable timer group. + * + * The matched code ships in every Next 16.2+ bundle, so only register the patches - and their + * fail-loudly errors - for apps that actually enable Cache Components. + */ +export function patchCacheComponents( + updater: ContentUpdater, + buildOpts: BuildOptions, + nextConfig: NextConfig +): Plugin { + if (!usesCacheComponents(nextConfig) || compareSemver(buildOpts.nextVersion, "<", "16.2.11")) { + return { + name: "patch-cache-components", + setup() {}, + }; + } + + const schedulerPath = path.join(buildOpts.outputDir, "cloudflare-templates/cache-components-scheduler.js"); + + // `ContentUpdater` silently skips a callback whose filter stops matching, so a renamed error + // message or moved file would otherwise ship an unpatched build. + const applied = { scheduler: false, "module loading signal": false }; + + updater.updateContent("cache-components-scheduler", [ + { + filter: cacheComponentsSchedulerFileFilter, + contentFilter: atomicTimerGroupErrorPattern, + callback: async ({ contents, path: runtimePath }) => { + applied.scheduler = true; + return patchCacheComponentsScheduler(contents, runtimePath); + }, + }, + ]); + + updater.updateContent("cache-components-module-loading-signal", [ + { + filter: moduleLoadingSignalFileFilter, + contentFilter: moduleLoadingSignalPattern, + callback: async ({ contents, path: modulePath }) => { + applied["module loading signal"] = true; + return patchModuleLoadingSignal(contents, modulePath); + }, + }, + ]); + + return { + name: "patch-cache-components", + setup(build) { + build.onResolve({ filter: new RegExp(`^${cacheComponentsSchedulerModule}$`) }, () => ({ + path: schedulerPath, + })); + + build.onEnd((result) => { + // Another plugin already failed the build, so do not bury its error under ours. + if (result.errors.length > 0) { + return; + } + + const missing = Object.entries(applied) + .filter(([, wasApplied]) => !wasApplied) + .map(([name]) => name); + + if (missing.length > 0) { + throw new Error( + `Cache Components is enabled but the Next.js ${missing.join(" and ")} patch${ + missing.length > 1 ? "es" : "" + } matched nothing. Next.js likely moved or reshaped the code these patches target, and the app would render incorrectly on Workers. Please report this against @opennextjs/cloudflare with your Next.js version.` + ); + } + }); + }, + }; +} + +export function patchModuleLoadingSignal(contents: string, modulePath: string): string { + const trackedPromiseCount = contents.match(/\bmoduleLoadingSignal\.trackRead\s*\(/g)?.length ?? 0; + const trackedPromises = patchCode(contents, trackModuleLoadingPromiseRule); + if ( + trackedPromiseCount === 0 || + trackedPromises === contents || + (trackedPromises.match(/\.__openNextTrackModuleLoad\s*\(/g)?.length ?? 0) !== trackedPromiseCount + ) { + throw new Error(`Failed to patch module promise tracking in ${modulePath}`); + } + + const forwardedPromises = patchCode(trackedPromises, forwardModuleLoadingPromisesRule); + if (forwardedPromises === trackedPromises) { + throw new Error(`Failed to patch module promise forwarding in ${modulePath}`); + } + + const patchedContents = patchCode(forwardedPromises, requestScopedModuleLoadingSignalRule); + if (patchedContents === forwardedPromises) { + throw new Error(`Failed to patch the module loading signal in ${modulePath}`); + } + + return patchedContents; +} + +export function patchCacheComponentsScheduler(contents: string, runtimePath: string): string { + const patchedScheduler = patchCode(contents, runInSequentialTasksRule); + if (patchedScheduler === contents) { + throw new Error(`Failed to patch the Cache Components scheduler in ${runtimePath}`); + } + + const patchedContents = patchCode(patchedScheduler, disableAtomicTimerGroupRule); + if (atomicTimerGroupErrorPattern.test(patchedContents)) { + throw new Error(`Failed to patch the Cache Components scheduler in ${runtimePath}`); + } + + return patchedContents; +} + +/** + * Cache interception is compiled into the external middleware before the server bundle plugins run, + * so patch its output where Cloudflare takes ownership of the AWS build. Only apps combining Cache + * Components with cache interception hit the unresumable shell. + */ +export function patchMiddlewareCacheComponents(buildOpts: BuildOptions): void { + if (buildOpts.config.dangerous?.enableCacheInterception !== true) { + return; + } + + if (!usesCacheComponents(loadConfig(path.join(buildOpts.appBuildOutputPath, ".next")))) { + return; + } + + const middlewarePath = path.join(buildOpts.outputDir, "middleware", "handler.mjs"); + if (!existsSync(middlewarePath)) { + throw new Error("Cannot patch cache interception because the middleware bundle is missing"); + } + + const contents = readFileSync(middlewarePath, "utf8"); + if (!contents.includes("async function cacheInterceptor(")) { + throw new Error("Cannot find cache interception in the generated middleware bundle"); + } + + const patchedContents = patchCode(contents, bypassPprCacheInterceptionRule); + if (patchedContents === contents) { + throw new Error("Failed to patch cache interception for Cache Components routes"); + } + + writeFileSync(middlewarePath, patchedContents); +} + +export const runInSequentialTasksRule = ` +rule: + pattern: + selector: function_declaration + context: "function $FUNCTION($$$ARGS) { $$$BODY }" + all: + - has: + regex: DANGEROUSLY_runPendingImmediatesAfterCurrentTask + stopBy: end + - any: + - has: + regex: '["'']_idleStart["'']\\s*in' + stopBy: end + - has: + regex: createAtomicTimerGroup + stopBy: end +fix: |- + function $FUNCTION(first, ...rest) { + return require("${cacheComponentsSchedulerModule}").runInSequentialTasks(first, ...rest); + } +`; + +/** + * Next subscribes every render's `CacheSignal` to one module-scoped signal, and both store timer + * cleanup closures, so a later request can clear a handle owned by an older one. workerd rejects + * that cross-request I/O and the render truncates. Keep the signals and subscriptions request + * scoped, with a shared registry forwarding imports through request-owned notifications. + */ +export const requestScopedModuleLoadingSignalRule = ` +rule: + pattern: + selector: function_declaration + context: "function $FUNCTION() { if (!$SIGNAL) { $SIGNAL = new $CTOR(); } return $SIGNAL; }" +fix: |- + function $FUNCTION() { + if (!$SIGNAL) { + $SIGNAL = { + pendingModuleLoads: new Set(), + moduleLoadSubscribers: new Set(), + requestSignals: new WeakMap(), + }; + } + + const requestScope = globalThis[Symbol.for("__cloudflare-context__")] ?? globalThis; + let requestModuleLoadingSignal = $SIGNAL.requestSignals.get(requestScope); + if (!requestModuleLoadingSignal) { + requestModuleLoadingSignal = new $CTOR(); + const trackedModuleLoads = new Set(); + + requestModuleLoadingSignal.__openNextModuleLoadingRegistry = $SIGNAL; + requestModuleLoadingSignal.__openNextTrackModuleLoad = function (promise) { + if (trackedModuleLoads.has(promise)) return; + + trackedModuleLoads.add(promise); + promise.then( + () => trackedModuleLoads.delete(promise), + () => trackedModuleLoads.delete(promise) + ); + requestModuleLoadingSignal.trackRead(promise); + }; + $SIGNAL.requestSignals.set(requestScope, requestModuleLoadingSignal); + } + + for (const pendingModuleLoad of $SIGNAL.pendingModuleLoads) { + requestModuleLoadingSignal.__openNextTrackModuleLoad(pendingModuleLoad); + } + return requestModuleLoadingSignal; + } +`; + +/** Record and announce each import before attaching it to the current request's signal. */ +export const trackModuleLoadingPromiseRule = ` +rule: + pattern: + selector: expression_statement + context: "$MODULE_LOADING_SIGNAL.trackRead($PROMISE);" +fix: |- + $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.add($PROMISE); + $PROMISE.then( + () => $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.delete($PROMISE), + () => $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.pendingModuleLoads.delete($PROMISE) + ); + for (const notifyModuleLoad of $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry.moduleLoadSubscribers) { + notifyModuleLoad($PROMISE); + } + $MODULE_LOADING_SIGNAL.__openNextTrackModuleLoad($PROMISE) +`; + +/** Forward future imports through promises created and observed by the subscribing request. */ +export const forwardModuleLoadingPromisesRule = ` +rule: + pattern: + selector: lexical_declaration + context: "const $UNSUBSCRIBE = $MODULE_LOADING_SIGNAL.subscribeToReads($CACHE_SIGNAL);" +fix: |- + const openNextModuleLoadingRegistry = $MODULE_LOADING_SIGNAL.__openNextModuleLoadingRegistry; + const openNextQueuedModuleLoads = []; + let openNextSubscriptionActive = true; + let openNextResolveNotification; + + function openNextWaitForModuleLoads() { + const notification = new Promise((resolve) => { + openNextResolveNotification = resolve; + }); + void notification.then(() => { + openNextResolveNotification = undefined; + if (!openNextSubscriptionActive) return; + + for (const promise of openNextQueuedModuleLoads.splice(0)) { + $MODULE_LOADING_SIGNAL.__openNextTrackModuleLoad(promise); + } + openNextWaitForModuleLoads(); + }); + } + + openNextWaitForModuleLoads(); + const openNextNotifyModuleLoad = (promise) => { + if (!openNextSubscriptionActive) return; + openNextQueuedModuleLoads.push(promise); + openNextResolveNotification(); + }; + openNextModuleLoadingRegistry.moduleLoadSubscribers.add(openNextNotifyModuleLoad); + + const openNextUnsubscribe = $MODULE_LOADING_SIGNAL.subscribeToReads($CACHE_SIGNAL); + const $UNSUBSCRIBE = () => { + openNextSubscriptionActive = false; + openNextModuleLoadingRegistry.moduleLoadSubscribers.delete(openNextNotifyModuleLoad); + openNextResolveNotification(); + openNextUnsubscribe(); + }; +`; + +/** + * Webpack emits the timer group and the sequential-task runner as separate modules. The runner is + * replaced above, so stub the unreachable timer group rather than ship its `_idleStart` mutation. + */ +export const disableAtomicTimerGroupRule = ` +rule: + pattern: + selector: function_declaration + context: "function $FUNCTION($$$ARGS) { $$$BODY }" + all: + - has: + regex: '["'']_idleStart["'']\\s*in' + stopBy: end + - has: + regex: Cannot schedule more timers into a group that already executed + stopBy: end + - has: + regex: '\\bsetTimeout\\s*\\(' + stopBy: end +fix: |- + function $FUNCTION() { + throw new Error("OpenNext replaced this incompatible Cache Components timer group"); + } +`; + +/** + * The interceptor only has the cached PPR shell, not the postponed state Next needs to resume a + * Cache Components render, so these routes must reach Next's handler. Other ISR routes are unchanged. + */ +export const bypassPprCacheInterceptionRule = ` +rule: + pattern: if (isISR) { $$$BODY } + inside: + pattern: async function cacheInterceptor($$$ARGS) { $$$FUNCTION_BODY } + stopBy: end +fix: |- + if (isISR && !( + PrerenderManifest?.routes?.[localizedPath]?.renderingMode === "PARTIALLY_STATIC" || + PrerenderManifest?.routes?.[localizedPath]?.experimentalPPR === true || + Object.values(PrerenderManifest?.dynamicRoutes ?? {}).some((route) => + new RegExp(route.routeRegex).test(localizedPath) && + (route.renderingMode === "PARTIALLY_STATIC" || route.experimentalPPR === true) + ) + )) { $$$BODY } +`; diff --git a/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt new file mode 100644 index 000000000..7cdd93c49 --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.2.12-app-page-turbo.runtime.prod.txt @@ -0,0 +1 @@ +let sB=require("next/dist/server/node-environment-extensions/fast-set-immediate.external.js"),sq=!0;function sz(){console.warn("Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/")}function sX(){}function sV(e,...t){return new Promise((r,n)=>{let a,i=function(e=0){{let n=!0,a=null,i=!1,o=!1;function t(e){return i=!0,sq&&(0,sB.unpatchedSetImmediate)(()=>{o=!0}),e()}function r(e){return sq&&o&&(sq=!1,sz()),e()}return function(o){if(i)throw Object.defineProperty(new eB.z("Cannot schedule more timers into a group that already executed"),"__NEXT_ERROR_CODE",{value:"E935",enumerable:!1,configurable:!0});let s=setTimeout(n?t:r,e,o);if(n=!1,!sq)return s;try{"_idleStart"in s&&"number"==typeof s._idleStart?null===a?a=s._idleStart:s._idleStart=a:(sq=!1,sz())}catch(e){console.error(Object.defineProperty(new eB.z("An unexpected error occurred while adjusting `_idleStart` on an atomic timer",{cause:e}),"__NEXT_ERROR_CODE",{value:"E933",enumerable:!1,configurable:!0})),sq=!1,sz()}return s}}}(),o=[];o.push(i(()=>{try{(0,sB.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e(),(0,so.Q)(a)&&a.then(sX,sX)}catch(e){for(let e=1;e{try{(0,sB.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),r()}catch(e){for(;++a{try{(0,sB.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})} diff --git a/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt new file mode 100644 index 000000000..345605ceb --- /dev/null +++ b/packages/cloudflare/src/cli/build/patches/plugins/fixtures/cache-components/next-16.3.0-canary.105-app-page-turbo-experimental.runtime.prod.txt @@ -0,0 +1 @@ +let iy=!0;function iv(){console.warn("Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/")}function ib(){}function iS(e,...t){return new Promise((r,n)=>{let a,i=function(e=0){{let n=!0,a=null,i=!1,s=!1;function t(e){return i=!0,iy&&(0,nZ.unpatchedSetImmediate)(()=>{s=!0}),e()}function r(e){return iy&&s&&(iy=!1,iv()),e()}return function(s){if(i)throw Object.defineProperty(new ey.z("Cannot schedule more timers into a group that already executed"),"__NEXT_ERROR_CODE",{value:"E935",enumerable:!1,configurable:!0});let o=setTimeout(n?t:r,e,s);if(n=!1,!iy)return o;try{"_idleStart"in o&&"number"==typeof o._idleStart?null===a?a=o._idleStart:o._idleStart=a:(iy=!1,iv())}catch(e){console.error(Object.defineProperty(new ey.z("An unexpected error occurred while adjusting `_idleStart` on an atomic timer",{cause:e}),"__NEXT_ERROR_CODE",{value:"E933",enumerable:!1,configurable:!0})),iy=!1,iv()}return o}}}(),s=[];s.push(i(()=>{try{(0,nZ.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),a=e(),(0,aN.Q)(a)&&a.then(ib,ib)}catch(e){for(let e=1;e{try{(0,nZ.DANGEROUSLY_runPendingImmediatesAfterCurrentTask)(),r()}catch(e){for(;++a{try{(0,nZ.expectNoPendingImmediates)(),r(a)}catch(e){n(e)}}))})} diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts new file mode 100644 index 000000000..504919d24 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.spec.ts @@ -0,0 +1,498 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { clearImmediate as nativeClearImmediate, setImmediate as nativeSetImmediate } from "node:timers"; +import { promisify } from "node:util"; + +import { afterAll, describe, expect, it, vi } from "vitest"; + +import { runInSequentialTasks } from "./cache-components-scheduler.js"; + +/** Mirrors `init.ts`: an ALS store on a global symbol, wrapping the whole request. */ +const requestContextStorage = new AsyncLocalStorage(); +Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { + get: () => requestContextStorage.getStore(), + configurable: true, +}); +const withRequestContext = (run: () => T): T => requestContextStorage.run({}, run); + +afterAll(() => { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; +}); + +/** + * Stands in for React Flight: a stage opens a gate, the component resumes `hops` microtasks later, + * then schedules its flush. Next's contract is that both land before the next stage. + */ +function createGatedRender(stages: number, hops: number, log: string[]) { + const gates = Array.from({ length: stages }, () => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + return { opened, open }; + }); + + return { + start() { + for (const [stage, gate] of gates.entries()) { + void gate.opened.then(async () => { + for (let hop = 0; hop < hops; hop++) await null; + setImmediate(() => { + log.push(`work${stage}`); + setImmediate(() => log.push(`flush${stage}`)); + }); + }); + } + }, + advance: (stage: number) => gates[stage]!.open(), + }; +} + +function runGatedRender(stages: number, hops: number, log: string[]) { + const render = createGatedRender(stages, hops, log); + return runInSequentialTasks( + () => { + render.start(); + return "rendered"; + }, + ...Array.from({ length: stages }, (_, stage) => () => { + log.push(`stage${stage}`); + render.advance(stage); + }) + ); +} + +describe("runInSequentialTasks", () => { + it("resolves with what the first task returned", async () => { + const order: string[] = []; + const result = await runInSequentialTasks( + () => { + order.push("first"); + return 42; + }, + () => order.push("second"), + () => order.push("third") + ); + + expect(result).toEqual(42); + expect(order).toEqual(["first", "second", "third"]); + }); + + it("adopts a promise returned by the first task", async () => { + await expect( + runInSequentialTasks( + async () => "async result", + () => {} + ) + ).resolves.toEqual("async result"); + }); + + // The regression: on workerd the render's flush used to slip behind the stage that unblocked it, + // so a runtime prefetch aborted before the content it had already rendered was collected. + it.each([0, 1, 3, 12])( + "flushes each stage's work before the next stage, %i microtask hops in", + async (hops) => { + const log: string[] = []; + + await runGatedRender(4, hops, log); + + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + "stage3", + "work3", + "flush3", + ]); + } + ); + + it("keeps overlapping renders from gating each other", async () => { + const slowLog: string[] = []; + const fastLog: string[] = []; + + // The slow render keeps scheduling immediates long after the fast one is done, which must not + // hold the fast render's stages back. + await Promise.all([ + withRequestContext(() => runGatedRender(4, 12, slowLog)), + withRequestContext(() => runGatedRender(4, 0, fastLog)), + ]); + + for (const log of [slowLog, fastLog]) { + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + "stage3", + "work3", + "flush3", + ]); + } + }); + + it("runs staged renders from one request without interleaving their stages", async () => { + const log: string[] = []; + + await withRequestContext(() => + Promise.all([ + runInSequentialTasks( + () => log.push("first:a"), + () => log.push("second:a"), + () => log.push("third:a") + ), + runInSequentialTasks( + () => log.push("first:b"), + () => log.push("second:b"), + () => log.push("third:b") + ), + ]) + ); + + expect(log).toEqual(["first:a", "second:a", "third:a", "first:b", "second:b", "third:b"]); + }); + + it("preserves the async context of a staged render while it waits for an earlier render", async () => { + const workStorage = new AsyncLocalStorage(); + const log: string[] = []; + + await withRequestContext(() => + Promise.all([ + workStorage.run("a", () => + runInSequentialTasks( + () => log.push(`first:${workStorage.getStore()}`), + () => log.push(`second:${workStorage.getStore()}`) + ) + ), + workStorage.run("b", () => + runInSequentialTasks( + () => log.push(`first:${workStorage.getStore()}`), + () => log.push(`second:${workStorage.getStore()}`) + ) + ), + ]) + ); + + expect(log).toEqual(["first:a", "second:a", "first:b", "second:b"]); + }); + + it("starts the next staged render after an earlier render throws", async () => { + const failure = new Error("stage failed"); + const log: string[] = []; + + await withRequestContext(async () => { + const failed = runInSequentialTasks( + () => log.push("failed:first"), + () => { + throw failure; + } + ); + const recovered = runInSequentialTasks( + () => { + log.push("recovered:first"); + return "recovered"; + }, + () => log.push("recovered:second") + ); + + await expect(failed).rejects.toBe(failure); + await expect(recovered).resolves.toEqual("recovered"); + }); + + expect(log).toEqual(["failed:first", "recovered:first", "recovered:second"]); + }); + + it("releases the queue after the stages finish without awaiting the first result", async () => { + const log: string[] = []; + + await withRequestContext(async () => { + void runInSequentialTasks( + () => new Promise(() => {}), + () => log.push("pending:stage") + ); + + await runInSequentialTasks( + () => log.push("next:first"), + () => log.push("next:second") + ); + }); + + expect(log).toEqual(["pending:stage", "next:first", "next:second"]); + }); + + // Next awaits the RSC payload before staging, so React resumes from promises created outside the + // run. Counting by run alone reads zero and every stage advances over a flush still in flight. + it("waits for work rooted outside the staged run", async () => { + const log: string[] = []; + const gates = Array.from({ length: 3 }, () => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + return { opened, open }; + }); + + await withRequestContext(() => { + // Registered before the render, the way work rooted in the RSC payload is. + for (const [stage, gate] of gates.entries()) { + void gate.opened.then(async () => { + for (let hop = 0; hop < 4; hop++) await null; + setImmediate(() => { + log.push(`work${stage}`); + setImmediate(() => log.push(`flush${stage}`)); + }); + }); + } + + return runInSequentialTasks( + () => "rendered", + ...gates.map((gate, stage) => () => { + log.push(`stage${stage}`); + gate.open(); + }) + ); + }); + + expect(log).toEqual([ + "stage0", + "work0", + "flush0", + "stage1", + "work1", + "flush1", + "stage2", + "work2", + "flush2", + ]); + }); + + // React chooses and stores its scheduler when the runtime module loads. A wrapper installed on the + // first render cannot observe work sent through that earlier reference. + it("waits for work scheduled through an immediate reference captured during initialization", async () => { + const capturedSetImmediate = globalThis.setImmediate; + const log: string[] = []; + + await withRequestContext(() => + runInSequentialTasks( + () => { + void Promise.resolve().then(() => { + capturedSetImmediate(() => log.push("work")); + }); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["work", "stage"]); + }); + + it("preserves and waits for the promise-based immediate captured by Next", async () => { + type PromisifiedImmediate = (value?: T, options?: { signal?: AbortSignal }) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const log: string[] = []; + + expect(capturedSetImmediatePromise).toBeTypeOf("function"); + await withRequestContext(() => + runInSequentialTasks( + () => { + void capturedSetImmediatePromise!("preserved").then((value) => log.push(value)); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["preserved", "stage"]); + }); + + it("releases a rejected promise-based immediate", async () => { + type PromisifiedImmediate = (value?: T, options?: { signal?: AbortSignal }) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const abort = new AbortController(); + const log: string[] = []; + + await withRequestContext(() => + runInSequentialTasks( + () => { + const pending = capturedSetImmediatePromise!(undefined, { signal: abort.signal }); + void pending.catch(() => log.push("aborted")); + abort.abort(); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["aborted", "stage"]); + }); + + it("supplies the promise hook missing from workerd's global immediate", async () => { + const workerdSetImmediate = ((callback: (...args: unknown[]) => void, ...args: unknown[]) => + nativeSetImmediate(callback, ...args)) as typeof setImmediate; + globalThis.setImmediate = workerdSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + vi.resetModules(); + + try { + const { runInSequentialTasks: freshRunInSequentialTasks } = await import( + "./cache-components-scheduler.js" + ); + type PromisifiedImmediate = (value?: T) => Promise; + const capturedSetImmediatePromise = ( + globalThis.setImmediate as typeof setImmediate & { + [promisify.custom]?: PromisifiedImmediate; + } + )[promisify.custom]; + const log: string[] = []; + + expect(workerdSetImmediate[promisify.custom]).toBeUndefined(); + expect(capturedSetImmediatePromise).toBeTypeOf("function"); + await withRequestContext(() => + freshRunInSequentialTasks( + () => { + void capturedSetImmediatePromise!().then(() => log.push("work")); + }, + () => log.push("stage") + ) + ); + + expect(log).toEqual(["work", "stage"]); + } finally { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + } + }); + + // Scoped to the request, not process wide like Next's capture, so requests stay independent. + it("keeps requests from gating each other", async () => { + const log: string[] = []; + + const noisy = withRequestContext(() => { + let remaining = 300; + const spin = () => { + if (remaining-- > 0) setImmediate(spin); + }; + return runInSequentialTasks( + () => setImmediate(spin), + () => log.push("noisy:stage0") + ); + }); + + const quiet = withRequestContext(() => { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + void opened.then(() => setImmediate(() => log.push("quiet:flush"))); + return runInSequentialTasks( + () => open(), + () => log.push("quiet:stage0") + ); + }); + + await Promise.all([noisy, quiet]); + + expect(log.indexOf("quiet:flush")).toBeLessThan(log.indexOf("quiet:stage0")); + }); + + it("rejects and skips the remaining tasks when a task throws", async () => { + const order: string[] = []; + const failure = new Error("stage failed"); + + await expect( + runInSequentialTasks( + () => order.push("first"), + () => { + throw failure; + }, + () => order.push("never") + ) + ).rejects.toBe(failure); + + expect(order).toEqual(["first"]); + }); + + it("does not wait on an immediate that was cleared", async () => { + const log: string[] = []; + + await runInSequentialTasks( + () => { + const immediate = setImmediate(() => log.push("cleared")); + clearImmediate(immediate); + }, + () => log.push("stage1") + ); + + expect(log).toEqual(["stage1"]); + }); + + // A clear that threw did not cancel anything, so releasing the count there would advance the stage + // over an immediate that is still going to run. + it("keeps waiting on an immediate whose clear failed", async () => { + const failure = new Error("clear failed"); + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = (() => { + throw failure; + }) as unknown as typeof clearImmediate; + vi.resetModules(); + + try { + const { runInSequentialTasks: freshRunInSequentialTasks } = await import( + "./cache-components-scheduler.js" + ); + const log: string[] = []; + + await freshRunInSequentialTasks( + // Scheduling from a continuation puts the immediate behind the settle hop, so only the + // pending count can hold `stage1` back. + () => + void Promise.resolve().then(() => { + const immediate = setImmediate(() => log.push("still live")); + try { + clearImmediate(immediate); + } catch (error) { + log.push(error === failure ? "clear failed" : "unexpected error"); + } + }), + () => log.push("stage1") + ); + + expect(log).toEqual(["clear failed", "still live", "stage1"]); + } finally { + globalThis.setImmediate = nativeSetImmediate; + globalThis.clearImmediate = nativeClearImmediate; + } + }); + + // Resolving here would hand back a render whose last stage never flushed - the truncated response + // this scheduler exists to prevent, only silent. + it("rejects instead of advancing a stage that never settles", async () => { + let rescheduling = true; + + const settled = runInSequentialTasks( + () => { + const reschedule = () => { + if (rescheduling) setImmediate(reschedule); + }; + setImmediate(reschedule); + }, + () => { + throw new Error("unreachable: the stage must not be entered"); + } + ); + + await expect(settled).rejects.toThrow(/did not settle: 1 immediate\(s\) still pending/); + rescheduling = false; + }); +}); diff --git a/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts new file mode 100644 index 000000000..7df0735bc --- /dev/null +++ b/packages/cloudflare/src/cli/templates/cache-components-scheduler.ts @@ -0,0 +1,255 @@ +/** + * Cache Components staged rendering for workerd. + * + * Next drains the immediates React queued between two stages so each stage flushes before the next + * unblocks more content. It builds that boundary from `process.nextTick`, which workerd implements + * as `queueMicrotask`, so the drain ends before React has scheduled its flush and the render slips a + * stage. workerd runs timers and immediates from one ordered macrotask queue and drains microtasks + * between two of them, so an immediate is an exact "everything queued before me has run" signal: + * count the outstanding ones and hop until none remain. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { setImmediate as timersPromisesSetImmediate } from "node:timers/promises"; +import { promisify } from "node:util"; + +type StagedRun = { pending: number; scope: RequestScope }; + +/** Immediates the request caused that no staged run owns. Shared by the request's renders. */ +type RequestScope = { unattributed: number; stagedTail: Promise }; + +type ScheduleMacrotask = (callback: () => void) => unknown; +type PromisifiedSetImmediate = ( + value?: T, + options?: { ref?: boolean; signal?: AbortSignal } +) => Promise; + +/** Keeps one render from waiting on another's immediates. */ +const runStorage = new AsyncLocalStorage(); + +const REQUEST_CONTEXT = Symbol.for("__cloudflare-context__"); + +function createRequestScope(): RequestScope { + return { unattributed: 0, stagedTail: Promise.resolve() }; +} + +/** + * Next awaits the RSC payload before it stages, so React resumes from promises created outside the + * run where `runStorage` cannot see it - which is why Next's own capture is process wide. The + * request is the next widest owner that still keeps one request from gating another. + */ +const scopes = new WeakMap(); +const isolateScope = createRequestScope(); + +function currentScope(): RequestScope { + const context = (globalThis as Record)[REQUEST_CONTEXT]; + if (typeof context !== "object" || context === null) { + return isolateScope; + } + + let scope = scopes.get(context); + if (!scope) { + scope = createRequestScope(); + scopes.set(context, scope); + } + return scope; +} + +/** Fail rather than advance a stage over work it still owns, which is what truncates responses. */ +const MAX_SETTLE_HOPS = 1000; + +const COUNTED = Symbol.for("__opennext.cache-components.countedSetImmediate"); + +let scheduleMacrotask: ScheduleMacrotask | undefined; +// workerd's global callback API does not expose Node's custom-promisify hook. Capture its native +// promise API before Next replaces the `node:timers/promises` export, then expose that as the hook. +const scheduleMacrotaskPromisified = timersPromisesSetImmediate as PromisifiedSetImmediate; + +/** Next patches `setImmediate` when its server environment loads, so wrap whatever is installed. */ +function install(): ScheduleMacrotask { + const current = globalThis.setImmediate as typeof setImmediate & { [COUNTED]?: true }; + if (scheduleMacrotask && current[COUNTED]) { + return scheduleMacrotask; + } + + const previousSetImmediate = globalThis.setImmediate; + const previousClearImmediate = globalThis.clearImmediate; + const releaseByImmediate = new WeakMap void>(); + const previousPromisifiedSetImmediate = + (previousSetImmediate as typeof setImmediate & { [promisify.custom]?: PromisifiedSetImmediate })[ + promisify.custom + ] ?? scheduleMacrotaskPromisified; + + const countCurrentWork = () => { + const run = runStorage.getStore(); + // A render must also wait for work it did not root itself, so charge the rest to the request. + const owner = run ?? currentScope(); + if (owner === isolateScope) return; + + let released = false; + if (run) run.pending++; + else (owner as RequestScope).unattributed++; + + return () => { + if (released) return; + released = true; + if (run) run.pending--; + else (owner as RequestScope).unattributed--; + }; + }; + + const countedSetImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { + const release = countCurrentWork(); + try { + const immediate = previousSetImmediate(() => { + release?.(); + callback(...args); + }); + if (release && typeof immediate === "object" && immediate !== null) { + releaseByImmediate.set(immediate, release); + } + return immediate; + } catch (error) { + // Nothing was scheduled, so nothing will settle the count. + release?.(); + throw error; + } + }; + Object.defineProperty(countedSetImmediate, COUNTED, { value: true }); + + if (previousPromisifiedSetImmediate) { + const countedSetImmediatePromise: PromisifiedSetImmediate = ( + value?: T, + options?: { ref?: boolean; signal?: AbortSignal } + ) => { + const release = countCurrentWork(); + try { + const pending = previousPromisifiedSetImmediate(value, options); + if (!release) return pending; + return pending.then( + (result) => { + release(); + return result; + }, + (error: unknown) => { + release(); + throw error; + } + ); + } catch (error) { + release?.(); + throw error; + } + }; + Object.defineProperty(countedSetImmediate, promisify.custom, { + value: countedSetImmediatePromise, + }); + } + + const countedClearImmediate = (immediate: unknown) => { + // A clear that threw left the immediate live, so it stays counted. + previousClearImmediate(immediate as Parameters[0]); + if (typeof immediate === "object" && immediate !== null) { + releaseByImmediate.get(immediate)?.(); + } + }; + + globalThis.setImmediate = countedSetImmediate as unknown as typeof setImmediate; + globalThis.clearImmediate = countedClearImmediate as unknown as typeof clearImmediate; + + // Hops must not count themselves, so they go through the unwrapped function. + scheduleMacrotask = previousSetImmediate as unknown as ScheduleMacrotask; + return scheduleMacrotask; +} + +function ignore(): void {} + +// React captures `setImmediate` while its runtime loads. Install before the Next server is evaluated +// so those captured references are counted too; installing only on the first staged render is late. +install(); + +/** Drop-in for Next's `runInSequentialTasks`: each callback gets its own settled task. */ +export function runInSequentialTasks(first: () => T, ...rest: Array<() => void>): Promise { + const hop = install(); + const scope = currentScope(); + const run: StagedRun = { pending: 0, scope }; + const previousRun = scope.stagedTail; + let releaseRun!: () => void; + scope.stagedTail = new Promise((resolve) => (releaseRun = resolve)); + + return new Promise((resolve, reject) => { + let result: T; + let stage = 0; + let hops = 0; + let finished = false; + + const fail = (error: unknown) => { + if (finished) return; + finished = true; + releaseRun(); + reject(error); + }; + + const complete = () => { + if (finished) return; + finished = true; + releaseRun(); + resolve(result); + }; + + const schedule = (callback: () => void) => { + try { + hop(callback); + } catch (error) { + fail(error); + } + }; + + const settleThen = (next: () => void) => { + schedule(() => { + const pending = run.pending + run.scope.unattributed; + if (pending === 0) { + next(); + return; + } + if (hops++ < MAX_SETTLE_HOPS) { + settleThen(next); + return; + } + fail( + new Error( + `Cache Components render did not settle: ${pending} immediate(s) still pending after ${MAX_SETTLE_HOPS} tasks.` + ) + ); + }); + }; + + const enterStage = () => { + try { + runStorage.run(run, () => { + if (stage === 0) { + result = first(); + // A later task may reject this; the caller sees it through the returned promise. + const thenable = result as PromiseLike | null | undefined; + if (thenable && typeof thenable.then === "function") { + thenable.then(ignore, ignore); + } + } else { + rest[stage - 1]!(); + } + }); + } catch (error) { + fail(error); + return; + } + + stage++; + hops = 0; + settleThen(stage > rest.length ? complete : enterStage); + }; + + // Next schedules one timer group at a time. Queue groups from this request so their stages do + // not alternate, but do not make one request wait for another request's render. + void previousRun.then(() => schedule(enterStage)); + }); +}