From ef8d3af7f56431a0605f3d3061950983cb956d13 Mon Sep 17 00:00:00 2001 From: shubham-mathur Date: Sat, 10 Oct 2026 17:22:47 +0530 Subject: [PATCH 1/4] fix: inline named webpack chunks (`webpackChunkName`) in the server runtime The webpack runtime patch only inlined `chunks/.js` and switched on the numeric chunk id, so a chunk named with `/* webpackChunkName */` (written as `chunks/.js` and reached through the `__webpack_require__.u` id-to-filename map) hit `default: throw new Error("Unknown chunk N")` at runtime. List every `.js` file under `chunks/` (nested names included) and switch on the computed path `"./chunks/" + __webpack_require__.u(chunkId)` instead of the id. Chunk names are escaped so they cannot collide with the `$NAME` placeholders of the fix template. The single chunk rule uses the same switch so a single named chunk also loads. Adds a pages-router e2e page using `next/dynamic` with a named chunk. Fixes #1326 --- .changeset/webpack-named-chunks.md | 7 + .../e2e/pages-router/e2e/named-chunk.test.ts | 14 + .../src/components/named-chunk.tsx | 3 + .../src/pages/named-chunk/index.tsx | 19 + .../build/patches/ast/webpack-runtime.spec.ts | 339 +++++++++++++++--- .../cli/build/patches/ast/webpack-runtime.ts | 149 +++++--- 6 files changed, 429 insertions(+), 102 deletions(-) create mode 100644 .changeset/webpack-named-chunks.md create mode 100644 examples/e2e/pages-router/e2e/named-chunk.test.ts create mode 100644 examples/e2e/pages-router/src/components/named-chunk.tsx create mode 100644 examples/e2e/pages-router/src/pages/named-chunk/index.tsx diff --git a/.changeset/webpack-named-chunks.md b/.changeset/webpack-named-chunks.md new file mode 100644 index 000000000..0787c9853 --- /dev/null +++ b/.changeset/webpack-named-chunks.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix: inline named webpack chunks (`webpackChunkName`) in the server runtime + +Apps built with webpack returned a 500 with `Error: Unknown chunk N` when a dependency or a `next/dynamic` import used `/* webpackChunkName: "..." */`. Next.js writes such a chunk as `.next/server/chunks/.js` rather than `.js`, but the webpack runtime patch only inlined the numeric chunk files, so the named chunk was never bundled into the Worker. The patch now inlines every `.js` file in the chunks folder (including nested names like `dir/name`) and resolves them through the same `__webpack_require__.u` id-to-filename mapping that webpack uses. Turbopack builds are not affected. diff --git a/examples/e2e/pages-router/e2e/named-chunk.test.ts b/examples/e2e/pages-router/e2e/named-chunk.test.ts new file mode 100644 index 000000000..48ad7fd58 --- /dev/null +++ b/examples/e2e/pages-router/e2e/named-chunk.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 +test("Server side render a component from a named webpack chunk", async ({ page }) => { + const res = await page.goto("/named-chunk/"); + expect(res?.status()).toBe(200); + await expect(page.getByTestId("named-chunk")).toHaveText("Rendered from a named webpack chunk"); +}); + +test("Server HTML includes the component from a named webpack chunk", async ({ request }) => { + const res = await request.get("/named-chunk/"); + expect(res.status()).toBe(200); + expect(await res.text()).toContain("Rendered from a named webpack chunk"); +}); diff --git a/examples/e2e/pages-router/src/components/named-chunk.tsx b/examples/e2e/pages-router/src/components/named-chunk.tsx new file mode 100644 index 000000000..6ab5182a2 --- /dev/null +++ b/examples/e2e/pages-router/src/components/named-chunk.tsx @@ -0,0 +1,3 @@ +export default function NamedChunk() { + return

Rendered from a named webpack chunk

; +} diff --git a/examples/e2e/pages-router/src/pages/named-chunk/index.tsx b/examples/e2e/pages-router/src/pages/named-chunk/index.tsx new file mode 100644 index 000000000..0a1ca6075 --- /dev/null +++ b/examples/e2e/pages-router/src/pages/named-chunk/index.tsx @@ -0,0 +1,19 @@ +import dynamic from "next/dynamic"; + +// `webpackChunkName` makes webpack emit the server chunk as `chunks/named-chunk.js` +// instead of `chunks/.js`. +// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 +const NamedChunk = dynamic(() => import(/* webpackChunkName: "named-chunk" */ "@/components/named-chunk")); + +export async function getServerSideProps() { + return { props: {} }; +} + +export default function Page() { + return ( + <> +

Named chunk

+ + + ); +} diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts index 4f87dbf7f..522a615e4 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts @@ -1,7 +1,10 @@ -import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; -import { describe, expect, test } from "vitest"; +import { join } from "node:path"; -import { buildMultipleChunksRule, singleChunkRule } from "./webpack-runtime.js"; +import { applyRule, parseCode, patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import mockFs from "mock-fs"; +import { afterEach, describe, expect, test } from "vitest"; + +import { buildMultipleChunksRule, buildSingleChunkRule, listChunks } from "./webpack-runtime.js"; describe("webpack runtime", () => { describe("multiple chunks", () => { @@ -21,22 +24,25 @@ describe("webpack runtime", () => { }; `; - expect(patchCode(code, buildMultipleChunksRule([1, 2, 3]))).toMatchInlineSnapshot(` - "/******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, _) => { - if (!installedChunks[chunkId]) { - switch (chunkId) { - case 1: installChunk(require("./chunks/1.js")); break; - case 2: installChunk(require("./chunks/2.js")); break; - case 3: installChunk(require("./chunks/3.js")); break; - case 658: installedChunks[chunkId] = 1; break; - default: throw new Error(\`Unknown chunk \${chunkId}\`); - } - } - } - ; - " - `); + expect(patchCode(code, buildMultipleChunksRule(["1.js", "2.js", "3.js"]))).toMatchInlineSnapshot(` + "/******/ // require() chunk loading for javascript + /******/ __webpack_require__.f.require = (chunkId, _) => { + if (!installedChunks[chunkId]) { + if (658 == chunkId) { + installedChunks[chunkId] = 1; + } else { + switch ("./chunks/" + __webpack_require__.u(chunkId)) { + case "./chunks/1.js": installChunk(require("./chunks/1.js")); break; + case "./chunks/2.js": installChunk(require("./chunks/2.js")); break; + case "./chunks/3.js": installChunk(require("./chunks/3.js")); break; + default: throw new Error(\`Unknown chunk \${chunkId}\`); + } + } + } + } + ; + " + `); }); test("patch minified runtime", () => { @@ -44,23 +50,176 @@ describe("webpack runtime", () => { t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)} `; - expect(patchCode(code, buildMultipleChunksRule([1, 2, 3]))).toMatchInlineSnapshot( - ` - "t.f.require=(o, _) => { - if (!e[o]) { - switch (o) { - case 1: r(require("./chunks/1.js")); break; - case 2: r(require("./chunks/2.js")); break; - case 3: r(require("./chunks/3.js")); break; - case 658: e[o] = 1; break; - default: throw new Error(\`Unknown chunk \${o}\`); - } - } - } - - " - ` + expect(patchCode(code, buildMultipleChunksRule(["1.js", "2.js", "3.js"]))).toMatchInlineSnapshot(` + "t.f.require=(o, _) => { + if (!e[o]) { + if (658 == o) { + e[o] = 1; + } else { + switch ("./chunks/" + t.u(o)) { + case "./chunks/1.js": r(require("./chunks/1.js")); break; + case "./chunks/2.js": r(require("./chunks/2.js")); break; + case "./chunks/3.js": r(require("./chunks/3.js")); break; + default: throw new Error(\`Unknown chunk \${o}\`); + } + } + } + } + + " + `); + }); + + test("patch runtime with named chunks", () => { + const code = ` +/******/ /* webpack/runtime/get javascript chunk filename */ +/******/ (() => { +/******/ // This function allow to reference async chunks and sibling chunks for the entrypoint +/******/ __webpack_require__.u = (chunkId) => { +/******/ // return url for filenames based on template +/******/ return "" + ({"522":"named-chunk","789":"dir/nested-chunk"}[chunkId] || chunkId) + ".js"; +/******/ }; +/******/ })(); +/******/ +/******/ // require() chunk loading for javascript +/******/ __webpack_require__.f.require = (chunkId, promises) => { +/******/ // "1" is the signal for "already loaded" +/******/ if(!installedChunks[chunkId]) { +/******/ if(311 != chunkId) { +/******/ installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); +/******/ } else installedChunks[chunkId] = 1; +/******/ } +/******/ }; +`; + + const patched = patchCode( + code, + buildMultipleChunksRule(["1.js", "2.js", "dir/nested-chunk.js", "named-chunk.js"]) ); + + expect(patched).toContain( + `case "./chunks/named-chunk.js": installChunk(require("./chunks/named-chunk.js")); break;` + ); + expect(patched).toContain( + `case "./chunks/dir/nested-chunk.js": installChunk(require("./chunks/dir/nested-chunk.js")); break;` + ); + expect(patched).not.toContain(`require("./chunks/" +`); + expect(patched).toMatchInlineSnapshot(` + "/******/ /* webpack/runtime/get javascript chunk filename */ + /******/ (() => { + /******/ // This function allow to reference async chunks and sibling chunks for the entrypoint + /******/ __webpack_require__.u = (chunkId) => { + /******/ // return url for filenames based on template + /******/ return "" + ({"522":"named-chunk","789":"dir/nested-chunk"}[chunkId] || chunkId) + ".js"; + /******/ }; + /******/ })(); + /******/ + /******/ // require() chunk loading for javascript + /******/ __webpack_require__.f.require = (chunkId, _) => { + if (!installedChunks[chunkId]) { + if (311 == chunkId) { + installedChunks[chunkId] = 1; + } else { + switch ("./chunks/" + __webpack_require__.u(chunkId)) { + case "./chunks/1.js": installChunk(require("./chunks/1.js")); break; + case "./chunks/2.js": installChunk(require("./chunks/2.js")); break; + case "./chunks/dir/nested-chunk.js": installChunk(require("./chunks/dir/nested-chunk.js")); break; + case "./chunks/named-chunk.js": installChunk(require("./chunks/named-chunk.js")); break; + default: throw new Error(\`Unknown chunk \${chunkId}\`); + } + } + } + } + ; + " + `); + }); + + test("patch minified runtime with named chunks", () => { + const code = ` +e.u=a=>""+({522:"named-chunk",789:"other-chunk"}[a]||a)+".js",e.o=(a,b)=>Object.prototype.hasOwnProperty.call(a,b),e.f.require=(c,d)=>{a[c]||(311!=c?b(require("./chunks/"+e.u(c))):a[c]=1)},module.exports=e +`; + + const patched = patchCode( + code, + buildMultipleChunksRule(["1.js", "2.js", "named-chunk.js", "other-chunk.js"]) + ); + + expect(patched).toContain( + `case "./chunks/named-chunk.js": b(require("./chunks/named-chunk.js")); break;` + ); + expect(patched).toContain( + `case "./chunks/other-chunk.js": b(require("./chunks/other-chunk.js")); break;` + ); + expect(patched).not.toContain(`require("./chunks/"+`); + expect(patched).toMatchInlineSnapshot(` + "e.u=a=>""+({522:"named-chunk",789:"other-chunk"}[a]||a)+".js",e.o=(a,b)=>Object.prototype.hasOwnProperty.call(a,b),e.f.require=(c, _) => { + if (!a[c]) { + if (311 == c) { + a[c] = 1; + } else { + switch ("./chunks/" + e.u(c)) { + case "./chunks/1.js": b(require("./chunks/1.js")); break; + case "./chunks/2.js": b(require("./chunks/2.js")); break; + case "./chunks/named-chunk.js": b(require("./chunks/named-chunk.js")); break; + case "./chunks/other-chunk.js": b(require("./chunks/other-chunk.js")); break; + default: throw new Error(\`Unknown chunk \${c}\`); + } + } + } + } + ,module.exports=e + " + `); + }); + + test("escapes `$` in chunk names so that placeholders are not substituted", () => { + const code = ` +e.u=a=>""+({522:"$INSTALL"}[a]||a)+".js",e.f.require=(c,d)=>{a[c]||(311!=c?b(require("./chunks/"+e.u(c))):a[c]=1)} +`; + + const patched = patchCode(code, buildMultipleChunksRule(["1.js", "$INSTALL.js"])); + + expect(patched).toContain( + `case "./chunks/\\x24INSTALL.js": b(require("./chunks/\\x24INSTALL.js")); break;` + ); + expect(patched).not.toContain(`./chunks/b.js`); + // The escaped path is the same string at runtime + expect(new Function(`return "./chunks/\\x24INSTALL.js"`)()).toBe("./chunks/$INSTALL.js"); + }); + + test("runs the patched runtime", () => { + const code = ` +const installed = []; +const require = (path) => path; +const installChunk = (path) => installed.push(path); +const installedChunks = { 311: 0 }; +const __webpack_require__ = { f: {} }; +__webpack_require__.u = (chunkId) => "" + ({ 522: "named-chunk" }[chunkId] || chunkId) + ".js"; +__webpack_require__.f.require = (chunkId, promises) => { + if (!installedChunks[chunkId]) { + if (311 != chunkId) { + installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); + } else installedChunks[chunkId] = 1; + } +}; +`; + const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); + const run = new Function( + `${patched} + __webpack_require__.f.require(1); + __webpack_require__.f.require(522); + __webpack_require__.f.require(311); + let error; + try { __webpack_require__.f.require(404); } catch (e) { error = e.message; } + return { installed, installedChunks, error };` + ); + + expect(run()).toEqual({ + installed: ["./chunks/1.js", "./chunks/named-chunk.js"], + installedChunks: { 311: 1 }, + error: "Unknown chunk 404", + }); }); }); @@ -78,18 +237,23 @@ describe("webpack runtime", () => { /******/ }; `; - expect(patchCode(code, singleChunkRule)).toMatchInlineSnapshot(` - "/******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, _) => { - if (!installedChunks[chunkId]) { - try { - installChunk(require("./chunks/710.js")); - } catch {} - } - } - ; - " - `); + expect(patchCode(code, buildSingleChunkRule(["710.js"]))).toMatchInlineSnapshot(` + "/******/ // require() chunk loading for javascript + /******/ __webpack_require__.f.require = (chunkId, _) => { + if (!installedChunks[chunkId]) { + if (710 == chunkId) { + switch ("./chunks/" + __webpack_require__.u(chunkId)) { + case "./chunks/710.js": installChunk(require("./chunks/710.js")); break; + default: break; + } + } else { + installedChunks[chunkId] = 1; + } + } + } + ; + " + `); }); test("patch minified runtime", () => { @@ -97,17 +261,76 @@ describe("webpack runtime", () => { o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)} `; - expect(patchCode(code, singleChunkRule)).toMatchInlineSnapshot(` - "o.f.require=(t, _) => { - if (!e[t]) { - try { - r(require("./chunks/710.js")); - } catch {} - } - } - - " - `); + expect(patchCode(code, buildSingleChunkRule(["710.js"]))).toMatchInlineSnapshot(` + "o.f.require=(t, _) => { + if (!e[t]) { + if (710 == t) { + switch ("./chunks/" + o.u(t)) { + case "./chunks/710.js": r(require("./chunks/710.js")); break; + default: break; + } + } else { + e[t] = 1; + } + } + } + + " + `); + }); + + test("patch minified runtime with a named chunk", () => { + const code = ` + o.u=t=>""+(710===t?"named-chunk":t)+".js",o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)} + `; + + const patched = patchCode(code, buildSingleChunkRule(["named-chunk.js"])); + + expect(patched).toContain( + `case "./chunks/named-chunk.js": r(require("./chunks/named-chunk.js")); break;` + ); + expect(patched).not.toContain(`require("./chunks/"+`); + }); + }); + + test("rules do not match an already patched runtime", () => { + const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; + const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); + + expect(applyRule(buildMultipleChunksRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); + expect(applyRule(buildSingleChunkRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); + }); + + test("rules only match their own runtime shape", () => { + const multiple = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; + const single = `o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)}`; + + expect(applyRule(buildSingleChunkRule(["1.js"]), parseCode(multiple)).edits).toHaveLength(0); + expect(applyRule(buildMultipleChunksRule(["1.js"]), parseCode(single)).edits).toHaveLength(0); + }); + + describe("listChunks", () => { + afterEach(() => mockFs.restore()); + + test("lists numeric, named and nested chunks", () => { + const chunksDir = join("/", ".next", "server", "chunks"); + mockFs({ + [chunksDir]: { + "103.js": "", + "named-chunk.js": "", + "named-chunk.js.map": "", + "9.js": "", + dir: { "nested-chunk.js": "" }, + }, + }); + + expect(listChunks(chunksDir)).toEqual(["103.js", "9.js", "dir/nested-chunk.js", "named-chunk.js"]); + }); + + test("returns an empty list when there is no chunks folder", () => { + mockFs({}); + + expect(listChunks(join("/", "missing"))).toEqual([]); }); }); }); diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts index 92832165c..6c07a330e 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts @@ -5,19 +5,27 @@ * * installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); * - * This patch unrolls the dynamic require for all the existing chunks: + * `__webpack_require__.u` maps a chunk id to its file name. Next.js uses `chunkFilename: "[name].js"` for the + * server, so the file is `.js` by default but `.js` for a named chunk (i.e. `webpackChunkName`): + * + * __webpack_require__.u = (chunkId) => "" + ({ 522: "named-chunk" }[chunkId] || chunkId) + ".js"; + * + * This patch unrolls the dynamic require for all the existing chunk files by switching on the computed path: * * For multiple chunks: - * switch (chunkId) { - * case ID1: installChunk(require("./chunks/ID1"); break; - * case ID2: installChunk(require("./chunks/ID2"); break; - * // ... - * case SELF_ID: installedChunks[chunkId] = 1; break; - * default: throw new Error(`Unknown chunk ${chunkId}`); + * if (SELF_ID == chunkId) { + * installedChunks[chunkId] = 1; + * } else { + * switch ("./chunks/" + __webpack_require__.u(chunkId)) { + * case "./chunks/ID1.js": installChunk(require("./chunks/ID1.js")); break; + * case "./chunks/NAME.js": installChunk(require("./chunks/NAME.js")); break; + * // ... + * default: throw new Error(`Unknown chunk ${chunkId}`); + * } * } * - * For a single chunk: - * require("./chunks/CHUNK_ID.js"); + * For a single chunk, the same switch is used for `CHUNK_ID == chunkId`. + * The chunk does not always exist so the default case is a no-op. */ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -25,23 +33,44 @@ import { join } from "node:path"; import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +/** + * Builds the switch that requires the chunk files. + * + * Note: `$CHUNK_FILE` is the dynamic file name expression (`__webpack_require__.u(chunkId)`). + * + * @param chunks Chunk file names, relative to the chunks folder (i.e. `123.js`, `named-chunk.js`). + * @param defaultCase Statement for the default case. + */ +function buildChunksSwitch(chunks: string[], defaultCase: string): string { + const cases = chunks.map((chunk) => { + // `$NAME` placeholders are substituted in the fix string, escape `$` to keep chunk names intact + const path = JSON.stringify(`./chunks/${chunk}`).replaceAll("$", "\\x24"); + return ` case ${path}: $INSTALL(require(${path})); break;`; + }); + + return ` switch ("./chunks/" + $CHUNK_FILE) { +${cases.join("\n")} + default: ${defaultCase} + }`; +} + // Inline the code when there are multiple chunks -export function buildMultipleChunksRule(chunks: number[]) { +export function buildMultipleChunksRule(chunks: string[]) { return ` rule: pattern: ($CHUNK_ID, $_PROMISES) => { $$$ } inside: {pattern: $_.$_.require = $$$_, stopBy: end} all: - - has: {pattern: $INSTALL(require("./chunks/" + $$$)), stopBy: end} + - has: {pattern: $INSTALL(require("./chunks/" + $CHUNK_FILE)), stopBy: end} - has: {pattern: $SELF_ID != $CHUNK_ID, stopBy: end} - has: {pattern: "$INSTALLED_CHUNK[$CHUNK_ID] = 1", stopBy: end} fix: | ($CHUNK_ID, _) => { if (!$INSTALLED_CHUNK[$CHUNK_ID]) { - switch ($CHUNK_ID) { -${chunks.map((chunk) => ` case ${chunk}: $INSTALL(require("./chunks/${chunk}.js")); break;`).join("\n")} - case $SELF_ID: $INSTALLED_CHUNK[$CHUNK_ID] = 1; break; - default: throw new Error(\`Unknown chunk \${$CHUNK_ID}\`); + if ($SELF_ID == $CHUNK_ID) { + $INSTALLED_CHUNK[$CHUNK_ID] = 1; + } else { +${buildChunksSwitch(chunks, "throw new Error(`Unknown chunk ${$CHUNK_ID}`);")} } } }`; @@ -49,55 +78,87 @@ ${chunks.map((chunk) => ` case ${chunk}: $INSTALL(require("./chunks/${ch // Inline the code when there is a single chunk. // For example when there is a single Pages API route. -// Note: The chunk does not always exist which explain the need for the try...catch. -export const singleChunkRule = ` +// Note: The chunk does not always exist which explain the no-op default case. +export function buildSingleChunkRule(chunks: string[]) { + return ` rule: pattern: ($CHUNK_ID, $_PROMISES) => { $$$ } inside: {pattern: $_.$_.require = $$$_, stopBy: end} all: - - has: {pattern: $INSTALL(require("./chunks/" + $$$)), stopBy: end} - - has: {pattern: $SELF_ID == $CHUNK_ID, stopBy: end} + - has: {pattern: $INSTALL(require("./chunks/" + $CHUNK_FILE)), stopBy: end} + - has: {pattern: $ONLY_ID == $CHUNK_ID, stopBy: end} - has: {pattern: "$INSTALLED_CHUNK[$CHUNK_ID] = 1", stopBy: end} fix: | ($CHUNK_ID, _) => { if (!$INSTALLED_CHUNK[$CHUNK_ID]) { - try { - $INSTALL(require("./chunks/$SELF_ID.js")); - } catch {} + if ($ONLY_ID == $CHUNK_ID) { +${buildChunksSwitch(chunks, "break;")} + } else { + $INSTALLED_CHUNK[$CHUNK_ID] = 1; + } } - } -`; + }`; +} + +/** + * Lists the chunk files. + * + * Named chunks can be nested (i.e. `webpackChunkName: "dir/name"` is emitted as `chunks/dir/name.js`). + * + * @param chunksDir Path to the chunks folder. + * @returns The `.js` files, relative to the chunks folder and using `/` as the separator. + */ +export function listChunks(chunksDir: string): string[] { + if (!existsSync(chunksDir)) { + return []; + } + + const chunks: string[] = []; + const walk = (relativeDir: string) => { + for (const entry of readdirSync(join(chunksDir, relativeDir), { withFileTypes: true })) { + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(relativePath); + } else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".js")) { + chunks.push(relativePath); + } + } + }; + walk(""); + + return chunks.sort(); +} /** * Fixes the webpack-runtime.js and webpack-api-runtime.js files by inlining * the webpack dynamic requires. */ export async function patchWebpackRuntime(dotNextServerDir: string) { - // Look for all the chunks. - const chunksDir = join(dotNextServerDir, "chunks"); - const chunks = existsSync(chunksDir) - ? readdirSync(chunksDir) - .filter((chunk) => /^\d+\.js$/.test(chunk)) - .map((chunk) => { - return Number(chunk.replace(/\.js$/, "")); - }) - : []; + const runtimes = ["webpack-runtime.js", "webpack-api-runtime.js"] + .map((runtime) => join(dotNextServerDir, runtime)) + .filter((runtime) => existsSync(runtime)); + + // Turbopack builds have no webpack runtime, skip listing the chunks + if (runtimes.length === 0) { + return; + } - patchFile(join(dotNextServerDir, "webpack-runtime.js"), chunks); - patchFile(join(dotNextServerDir, "webpack-api-runtime.js"), chunks); + const chunks = listChunks(join(dotNextServerDir, "chunks")); + + for (const runtime of runtimes) { + patchFile(runtime, chunks); + } } /** - * Inline chunks when the file exists. + * Inline the chunks in a webpack runtime file. * * @param filename Path to the webpack runtime. - * @param chunks List of chunks in the chunks folder. + * @param chunks List of chunk files in the chunks folder. */ -function patchFile(filename: string, chunks: number[]) { - if (existsSync(filename)) { - let code = readFileSync(filename, "utf-8"); - code = patchCode(code, buildMultipleChunksRule(chunks)); - code = patchCode(code, singleChunkRule); - writeFileSync(filename, code); - } +function patchFile(filename: string, chunks: string[]) { + let code = readFileSync(filename, "utf-8"); + code = patchCode(code, buildMultipleChunksRule(chunks)); + code = patchCode(code, buildSingleChunkRule(chunks)); + writeFileSync(filename, code); } From 385968820b8bf62a2b3e7029ed858e528b92c13e Mon Sep 17 00:00:00 2001 From: shubham-mathur Date: Sat, 10 Oct 2026 19:06:27 +0530 Subject: [PATCH 2/4] fix: report when the webpack runtime chunk requires could not be inlined A non-matching rule used to leave the dynamic require in place silently and the worker would only fail when loading a chunk at runtime. --- .../build/patches/ast/webpack-runtime.spec.ts | 23 ++++++++++++++++++- .../cli/build/patches/ast/webpack-runtime.ts | 19 +++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts index 522a615e4..f3004e937 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts @@ -4,7 +4,12 @@ import { applyRule, parseCode, patchCode } from "@opennextjs/aws/build/patch/ast import mockFs from "mock-fs"; import { afterEach, describe, expect, test } from "vitest"; -import { buildMultipleChunksRule, buildSingleChunkRule, listChunks } from "./webpack-runtime.js"; +import { + buildMultipleChunksRule, + buildSingleChunkRule, + hasDynamicChunkRequire, + listChunks, +} from "./webpack-runtime.js"; describe("webpack runtime", () => { describe("multiple chunks", () => { @@ -309,6 +314,22 @@ __webpack_require__.f.require = (chunkId, promises) => { expect(applyRule(buildMultipleChunksRule(["1.js"]), parseCode(single)).edits).toHaveLength(0); }); + describe("hasDynamicChunkRequire", () => { + test("detects the dynamic require of the webpack runtime", () => { + expect( + hasDynamicChunkRequire(`installChunk(require("./chunks/" + __webpack_require__.u(chunkId)));`) + ).toBe(true); + expect(hasDynamicChunkRequire(`r(require("./chunks/"+t.u(o)))`)).toBe(true); + }); + + test("does not detect a patched runtime", () => { + const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; + const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); + + expect(hasDynamicChunkRequire(patched)).toBe(false); + }); + }); + describe("listChunks", () => { afterEach(() => mockFs.restore()); diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts index 6c07a330e..8be5ec0db 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts @@ -32,6 +32,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import logger from "@opennextjs/aws/logger.js"; /** * Builds the switch that requires the chunk files. @@ -150,6 +151,16 @@ export async function patchWebpackRuntime(dotNextServerDir: string) { } } +/** + * Whether the code still contains the dynamic chunk require of the webpack runtime. + * + * @param code The webpack runtime code. + * @returns `true` when `require("./chunks/" + ...)` is present. + */ +export function hasDynamicChunkRequire(code: string): boolean { + return /require\("\.\/chunks\/"\s*\+/.test(code); +} + /** * Inline the chunks in a webpack runtime file. * @@ -160,5 +171,13 @@ function patchFile(filename: string, chunks: string[]) { let code = readFileSync(filename, "utf-8"); code = patchCode(code, buildMultipleChunksRule(chunks)); code = patchCode(code, buildSingleChunkRule(chunks)); + + // A silent non-match would only surface at runtime when the worker fails to load a chunk + if (hasDynamicChunkRequire(code)) { + logger.error( + `Failed to inline the dynamic chunk requires in ${filename}: the webpack runtime has an unexpected shape and loading chunks will fail at runtime.` + ); + } + writeFileSync(filename, code); } From 6cc5da7e648fd99dc5cad5364101a3d3dd2d338f Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 16 Oct 2026 10:39:05 +0200 Subject: [PATCH 3/4] fixup! move the e2e test from examples/e2e/pages-router to playground16 Apps in examples/e2e come from `@opennextjs/aws` so it's better not to add tests only for cloudflare if they can be added to a cloudflare specific app --- .../e2e/pages-router/e2e/named-chunk.test.ts | 14 -------------- .../src/components/named-chunk.tsx | 3 --- .../src/pages/named-chunk/index.tsx | 19 ------------------- .../app/api/named-chunk/message.ts | 1 + .../playground16/app/api/named-chunk/route.ts | 8 ++++++++ examples/playground16/e2e/named-chunk.spec.ts | 9 +++++++++ 6 files changed, 18 insertions(+), 36 deletions(-) delete mode 100644 examples/e2e/pages-router/e2e/named-chunk.test.ts delete mode 100644 examples/e2e/pages-router/src/components/named-chunk.tsx delete mode 100644 examples/e2e/pages-router/src/pages/named-chunk/index.tsx create mode 100644 examples/playground16/app/api/named-chunk/message.ts create mode 100644 examples/playground16/app/api/named-chunk/route.ts create mode 100644 examples/playground16/e2e/named-chunk.spec.ts diff --git a/examples/e2e/pages-router/e2e/named-chunk.test.ts b/examples/e2e/pages-router/e2e/named-chunk.test.ts deleted file mode 100644 index 48ad7fd58..000000000 --- a/examples/e2e/pages-router/e2e/named-chunk.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 -test("Server side render a component from a named webpack chunk", async ({ page }) => { - const res = await page.goto("/named-chunk/"); - expect(res?.status()).toBe(200); - await expect(page.getByTestId("named-chunk")).toHaveText("Rendered from a named webpack chunk"); -}); - -test("Server HTML includes the component from a named webpack chunk", async ({ request }) => { - const res = await request.get("/named-chunk/"); - expect(res.status()).toBe(200); - expect(await res.text()).toContain("Rendered from a named webpack chunk"); -}); diff --git a/examples/e2e/pages-router/src/components/named-chunk.tsx b/examples/e2e/pages-router/src/components/named-chunk.tsx deleted file mode 100644 index 6ab5182a2..000000000 --- a/examples/e2e/pages-router/src/components/named-chunk.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function NamedChunk() { - return

Rendered from a named webpack chunk

; -} diff --git a/examples/e2e/pages-router/src/pages/named-chunk/index.tsx b/examples/e2e/pages-router/src/pages/named-chunk/index.tsx deleted file mode 100644 index 0a1ca6075..000000000 --- a/examples/e2e/pages-router/src/pages/named-chunk/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import dynamic from "next/dynamic"; - -// `webpackChunkName` makes webpack emit the server chunk as `chunks/named-chunk.js` -// instead of `chunks/.js`. -// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 -const NamedChunk = dynamic(() => import(/* webpackChunkName: "named-chunk" */ "@/components/named-chunk")); - -export async function getServerSideProps() { - return { props: {} }; -} - -export default function Page() { - return ( - <> -

Named chunk

- - - ); -} diff --git a/examples/playground16/app/api/named-chunk/message.ts b/examples/playground16/app/api/named-chunk/message.ts new file mode 100644 index 000000000..50df1abb8 --- /dev/null +++ b/examples/playground16/app/api/named-chunk/message.ts @@ -0,0 +1 @@ +export const message = "Loaded from a named webpack chunk"; diff --git a/examples/playground16/app/api/named-chunk/route.ts b/examples/playground16/app/api/named-chunk/route.ts new file mode 100644 index 000000000..9704c18aa --- /dev/null +++ b/examples/playground16/app/api/named-chunk/route.ts @@ -0,0 +1,8 @@ +export const dynamic = "force-dynamic"; + +// `webpackChunkName` makes webpack emit the server chunk as `chunks/named-chunk.js` instead of `chunks/.js`. +// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 +export async function GET() { + const { message } = await import(/* webpackChunkName: "named-chunk" */ "./message"); + return Response.json({ message }); +} diff --git a/examples/playground16/e2e/named-chunk.spec.ts b/examples/playground16/e2e/named-chunk.spec.ts new file mode 100644 index 000000000..9bd9f4864 --- /dev/null +++ b/examples/playground16/e2e/named-chunk.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "@playwright/test"; + +// Regression test for named webpack chunks (`webpackChunkName`). +// See https://github.com/opennextjs/opennextjs-cloudflare/issues/1326 +test("load a named webpack chunk", async ({ request }) => { + const response = await request.get("/api/named-chunk"); + expect(response.status()).toEqual(200); + expect(await response.json()).toEqual({ message: "Loaded from a named webpack chunk" }); +}); From a8b267d5e4bd31bb6134af53798e68477e5ce1fd Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 16 Oct 2026 10:46:57 +0200 Subject: [PATCH 4/4] fixup! simplify --- .changeset/webpack-named-chunks.md | 2 +- .../build/patches/ast/webpack-runtime.spec.ts | 348 ++++-------------- .../cli/build/patches/ast/webpack-runtime.ts | 142 ++----- 3 files changed, 105 insertions(+), 387 deletions(-) diff --git a/.changeset/webpack-named-chunks.md b/.changeset/webpack-named-chunks.md index 0787c9853..7afbccfeb 100644 --- a/.changeset/webpack-named-chunks.md +++ b/.changeset/webpack-named-chunks.md @@ -4,4 +4,4 @@ fix: inline named webpack chunks (`webpackChunkName`) in the server runtime -Apps built with webpack returned a 500 with `Error: Unknown chunk N` when a dependency or a `next/dynamic` import used `/* webpackChunkName: "..." */`. Next.js writes such a chunk as `.next/server/chunks/.js` rather than `.js`, but the webpack runtime patch only inlined the numeric chunk files, so the named chunk was never bundled into the Worker. The patch now inlines every `.js` file in the chunks folder (including nested names like `dir/name`) and resolves them through the same `__webpack_require__.u` id-to-filename mapping that webpack uses. Turbopack builds are not affected. +Apps built with webpack returned a 500 with `Error: Unknown chunk N` when the server code loaded a chunk named with `/* webpackChunkName: "..." */`, i.e. from a `next/dynamic` or `import()` in the app or in a dependency. Named chunks are now bundled into the Worker and load correctly. diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts index f3004e937..90e404c3b 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.spec.ts @@ -1,199 +1,86 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyRule, parseCode, patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; -import mockFs from "mock-fs"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { - buildMultipleChunksRule, - buildSingleChunkRule, - hasDynamicChunkRequire, - listChunks, -} from "./webpack-runtime.js"; +import { buildChunkRequireRule, listChunks } from "./webpack-runtime.js"; describe("webpack runtime", () => { - describe("multiple chunks", () => { + describe("buildChunkRequireRule", () => { test("patch runtime", () => { const code = ` - /******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, promises) => { - /******/ // "1" is the signal for "already loaded" - /******/ if (!installedChunks[chunkId]) { - /******/ if (658 != chunkId) { - /******/ installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); - /******/ - } else installedChunks[chunkId] = 1; - /******/ - } - /******/ - }; - `; - - expect(patchCode(code, buildMultipleChunksRule(["1.js", "2.js", "3.js"]))).toMatchInlineSnapshot(` - "/******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, _) => { - if (!installedChunks[chunkId]) { - if (658 == chunkId) { - installedChunks[chunkId] = 1; - } else { - switch ("./chunks/" + __webpack_require__.u(chunkId)) { - case "./chunks/1.js": installChunk(require("./chunks/1.js")); break; - case "./chunks/2.js": installChunk(require("./chunks/2.js")); break; - case "./chunks/3.js": installChunk(require("./chunks/3.js")); break; - default: throw new Error(\`Unknown chunk \${chunkId}\`); - } - } - } - } - ; - " - `); - }); - - test("patch minified runtime", () => { - const code = ` - t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)} - `; - - expect(patchCode(code, buildMultipleChunksRule(["1.js", "2.js", "3.js"]))).toMatchInlineSnapshot(` - "t.f.require=(o, _) => { - if (!e[o]) { - if (658 == o) { - e[o] = 1; - } else { - switch ("./chunks/" + t.u(o)) { - case "./chunks/1.js": r(require("./chunks/1.js")); break; - case "./chunks/2.js": r(require("./chunks/2.js")); break; - case "./chunks/3.js": r(require("./chunks/3.js")); break; - default: throw new Error(\`Unknown chunk \${o}\`); - } - } - } - } - - " - `); - }); - - test("patch runtime with named chunks", () => { - const code = ` -/******/ /* webpack/runtime/get javascript chunk filename */ -/******/ (() => { -/******/ // This function allow to reference async chunks and sibling chunks for the entrypoint -/******/ __webpack_require__.u = (chunkId) => { -/******/ // return url for filenames based on template -/******/ return "" + ({"522":"named-chunk","789":"dir/nested-chunk"}[chunkId] || chunkId) + ".js"; -/******/ }; -/******/ })(); -/******/ /******/ // require() chunk loading for javascript /******/ __webpack_require__.f.require = (chunkId, promises) => { /******/ // "1" is the signal for "already loaded" /******/ if(!installedChunks[chunkId]) { -/******/ if(311 != chunkId) { +/******/ if(658 != chunkId) { /******/ installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); /******/ } else installedChunks[chunkId] = 1; /******/ } /******/ }; `; - const patched = patchCode( - code, - buildMultipleChunksRule(["1.js", "2.js", "dir/nested-chunk.js", "named-chunk.js"]) - ); - - expect(patched).toContain( - `case "./chunks/named-chunk.js": installChunk(require("./chunks/named-chunk.js")); break;` - ); - expect(patched).toContain( - `case "./chunks/dir/nested-chunk.js": installChunk(require("./chunks/dir/nested-chunk.js")); break;` - ); - expect(patched).not.toContain(`require("./chunks/" +`); - expect(patched).toMatchInlineSnapshot(` - "/******/ /* webpack/runtime/get javascript chunk filename */ - /******/ (() => { - /******/ // This function allow to reference async chunks and sibling chunks for the entrypoint - /******/ __webpack_require__.u = (chunkId) => { - /******/ // return url for filenames based on template - /******/ return "" + ({"522":"named-chunk","789":"dir/nested-chunk"}[chunkId] || chunkId) + ".js"; - /******/ }; - /******/ })(); - /******/ - /******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, _) => { - if (!installedChunks[chunkId]) { - if (311 == chunkId) { - installedChunks[chunkId] = 1; - } else { - switch ("./chunks/" + __webpack_require__.u(chunkId)) { - case "./chunks/1.js": installChunk(require("./chunks/1.js")); break; - case "./chunks/2.js": installChunk(require("./chunks/2.js")); break; - case "./chunks/dir/nested-chunk.js": installChunk(require("./chunks/dir/nested-chunk.js")); break; - case "./chunks/named-chunk.js": installChunk(require("./chunks/named-chunk.js")); break; - default: throw new Error(\`Unknown chunk \${chunkId}\`); - } - } + expect(patchCode(code, buildChunkRequireRule(["1.js", "named-chunk.js"]))).toMatchInlineSnapshot(` + "/******/ // require() chunk loading for javascript + /******/ __webpack_require__.f.require = (chunkId, promises) => { + /******/ // "1" is the signal for "already loaded" + /******/ if(!installedChunks[chunkId]) { + /******/ if(658 != chunkId) { + /******/ installChunk(((path) => { + switch (path) { + case "./chunks/1.js": return require("./chunks/1.js"); + case "./chunks/named-chunk.js": return require("./chunks/named-chunk.js"); + default: throw new Error(\`Unknown chunk \${path}\`); } - } - ; + })("./chunks/" + __webpack_require__.u(chunkId)) + ); + /******/ } else installedChunks[chunkId] = 1; + /******/ } + /******/ }; " `); }); - test("patch minified runtime with named chunks", () => { - const code = ` -e.u=a=>""+({522:"named-chunk",789:"other-chunk"}[a]||a)+".js",e.o=(a,b)=>Object.prototype.hasOwnProperty.call(a,b),e.f.require=(c,d)=>{a[c]||(311!=c?b(require("./chunks/"+e.u(c))):a[c]=1)},module.exports=e -`; - - const patched = patchCode( - code, - buildMultipleChunksRule(["1.js", "2.js", "named-chunk.js", "other-chunk.js"]) - ); + test("patch minified runtime", () => { + const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; - expect(patched).toContain( - `case "./chunks/named-chunk.js": b(require("./chunks/named-chunk.js")); break;` - ); - expect(patched).toContain( - `case "./chunks/other-chunk.js": b(require("./chunks/other-chunk.js")); break;` - ); - expect(patched).not.toContain(`require("./chunks/"+`); - expect(patched).toMatchInlineSnapshot(` - "e.u=a=>""+({522:"named-chunk",789:"other-chunk"}[a]||a)+".js",e.o=(a,b)=>Object.prototype.hasOwnProperty.call(a,b),e.f.require=(c, _) => { - if (!a[c]) { - if (311 == c) { - a[c] = 1; - } else { - switch ("./chunks/" + e.u(c)) { - case "./chunks/1.js": b(require("./chunks/1.js")); break; - case "./chunks/2.js": b(require("./chunks/2.js")); break; - case "./chunks/named-chunk.js": b(require("./chunks/named-chunk.js")); break; - case "./chunks/other-chunk.js": b(require("./chunks/other-chunk.js")); break; - default: throw new Error(\`Unknown chunk \${c}\`); - } - } + expect(patchCode(code, buildChunkRequireRule(["1.js", "named-chunk.js"]))).toMatchInlineSnapshot(` + "t.f.require=(o,n)=>{e[o]||(658!=o?r(((path) => { + switch (path) { + case "./chunks/1.js": return require("./chunks/1.js"); + case "./chunks/named-chunk.js": return require("./chunks/named-chunk.js"); + default: throw new Error(\`Unknown chunk \${path}\`); } - } - ,module.exports=e - " + })("./chunks/" + t.u(o)) + ):e[o]=1)}" `); }); - test("escapes `$` in chunk names so that placeholders are not substituted", () => { - const code = ` -e.u=a=>""+({522:"$INSTALL"}[a]||a)+".js",e.f.require=(c,d)=>{a[c]||(311!=c?b(require("./chunks/"+e.u(c))):a[c]=1)} -`; + test("patch minified runtime with a single chunk", () => { + const code = `o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)}`; + + expect(patchCode(code, buildChunkRequireRule(["named-chunk.js"]))).toMatchInlineSnapshot(` + "o.f.require=(t,a)=>{e[t]||(710==t?r(((path) => { + switch (path) { + case "./chunks/named-chunk.js": return require("./chunks/named-chunk.js"); + default: throw new Error(\`Unknown chunk \${path}\`); + } + })("./chunks/" + o.u(t)) + ):e[t]=1)}" + `); + }); - const patched = patchCode(code, buildMultipleChunksRule(["1.js", "$INSTALL.js"])); + test("escape `$` in chunk names", () => { + const code = `e.f.require=(c,d)=>{a[c]||(311!=c?b(require("./chunks/"+e.u(c))):a[c]=1)}`; - expect(patched).toContain( - `case "./chunks/\\x24INSTALL.js": b(require("./chunks/\\x24INSTALL.js")); break;` + expect(patchCode(code, buildChunkRequireRule(["$CHUNK_FILE.js"]))).toContain( + `case "./chunks/\\x24CHUNK_FILE.js": return require("./chunks/\\x24CHUNK_FILE.js");` ); - expect(patched).not.toContain(`./chunks/b.js`); - // The escaped path is the same string at runtime - expect(new Function(`return "./chunks/\\x24INSTALL.js"`)()).toBe("./chunks/$INSTALL.js"); }); - test("runs the patched runtime", () => { + test("run the patched runtime", () => { const code = ` const installed = []; const require = (path) => path; @@ -209,7 +96,7 @@ __webpack_require__.f.require = (chunkId, promises) => { } }; `; - const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); + const patched = patchCode(code, buildChunkRequireRule(["1.js", "named-chunk.js"])); const run = new Function( `${patched} __webpack_require__.f.require(1); @@ -223,135 +110,40 @@ __webpack_require__.f.require = (chunkId, promises) => { expect(run()).toEqual({ installed: ["./chunks/1.js", "./chunks/named-chunk.js"], installedChunks: { 311: 1 }, - error: "Unknown chunk 404", + error: "Unknown chunk ./chunks/404.js", }); }); - }); - - describe("single chunk", () => { - test("patch runtime", () => { - const code = ` -/******/ // require() chunk loading for javascript -/******/ __webpack_require__.f.require = (chunkId, promises) => { -/******/ // "1" is the signal for "already loaded" -/******/ if(!installedChunks[chunkId]) { -/******/ if(710 == chunkId) { -/******/ installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); -/******/ } else installedChunks[chunkId] = 1; -/******/ } -/******/ }; -`; - - expect(patchCode(code, buildSingleChunkRule(["710.js"]))).toMatchInlineSnapshot(` - "/******/ // require() chunk loading for javascript - /******/ __webpack_require__.f.require = (chunkId, _) => { - if (!installedChunks[chunkId]) { - if (710 == chunkId) { - switch ("./chunks/" + __webpack_require__.u(chunkId)) { - case "./chunks/710.js": installChunk(require("./chunks/710.js")); break; - default: break; - } - } else { - installedChunks[chunkId] = 1; - } - } - } - ; - " - `); - }); - - test("patch minified runtime", () => { - const code = ` - o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)} - `; - - expect(patchCode(code, buildSingleChunkRule(["710.js"]))).toMatchInlineSnapshot(` - "o.f.require=(t, _) => { - if (!e[t]) { - if (710 == t) { - switch ("./chunks/" + o.u(t)) { - case "./chunks/710.js": r(require("./chunks/710.js")); break; - default: break; - } - } else { - e[t] = 1; - } - } - } - " - `); - }); - - test("patch minified runtime with a named chunk", () => { - const code = ` - o.u=t=>""+(710===t?"named-chunk":t)+".js",o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)} - `; - - const patched = patchCode(code, buildSingleChunkRule(["named-chunk.js"])); + test("do not match an already patched runtime", () => { + const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; + const patched = patchCode(code, buildChunkRequireRule(["1.js"])); - expect(patched).toContain( - `case "./chunks/named-chunk.js": r(require("./chunks/named-chunk.js")); break;` - ); - expect(patched).not.toContain(`require("./chunks/"+`); + expect(applyRule(buildChunkRequireRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); }); }); - test("rules do not match an already patched runtime", () => { - const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; - const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); - - expect(applyRule(buildMultipleChunksRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); - expect(applyRule(buildSingleChunkRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); - }); - - test("rules only match their own runtime shape", () => { - const multiple = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; - const single = `o.f.require=(t,a)=>{e[t]||(710==t?r(require("./chunks/"+o.u(t))):e[t]=1)}`; - - expect(applyRule(buildSingleChunkRule(["1.js"]), parseCode(multiple)).edits).toHaveLength(0); - expect(applyRule(buildMultipleChunksRule(["1.js"]), parseCode(single)).edits).toHaveLength(0); - }); + describe("listChunks", () => { + let tmpDir: string; - describe("hasDynamicChunkRequire", () => { - test("detects the dynamic require of the webpack runtime", () => { - expect( - hasDynamicChunkRequire(`installChunk(require("./chunks/" + __webpack_require__.u(chunkId)));`) - ).toBe(true); - expect(hasDynamicChunkRequire(`r(require("./chunks/"+t.u(o)))`)).toBe(true); + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "webpack-runtime-test-")); }); - test("does not detect a patched runtime", () => { - const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; - const patched = patchCode(code, buildMultipleChunksRule(["1.js", "named-chunk.js"])); - - expect(hasDynamicChunkRequire(patched)).toBe(false); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); }); - }); - describe("listChunks", () => { - afterEach(() => mockFs.restore()); + test("list numeric, named and nested chunks", () => { + mkdirSync(join(tmpDir, "dir")); + for (const file of ["103.js", "9.js", "named-chunk.js", "named-chunk.js.map", "dir/nested-chunk.js"]) { + writeFileSync(join(tmpDir, file), ""); + } - test("lists numeric, named and nested chunks", () => { - const chunksDir = join("/", ".next", "server", "chunks"); - mockFs({ - [chunksDir]: { - "103.js": "", - "named-chunk.js": "", - "named-chunk.js.map": "", - "9.js": "", - dir: { "nested-chunk.js": "" }, - }, - }); - - expect(listChunks(chunksDir)).toEqual(["103.js", "9.js", "dir/nested-chunk.js", "named-chunk.js"]); + expect(listChunks(tmpDir)).toEqual(["103.js", "9.js", "dir/nested-chunk.js", "named-chunk.js"]); }); - test("returns an empty list when there is no chunks folder", () => { - mockFs({}); - - expect(listChunks(join("/", "missing"))).toEqual([]); + test("return an empty list when there is no chunks folder", () => { + expect(listChunks(join(tmpDir, "missing"))).toEqual([]); }); }); }); diff --git a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts index 8be5ec0db..f4dfc1568 100644 --- a/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts +++ b/packages/cloudflare/src/cli/build/patches/ast/webpack-runtime.ts @@ -1,104 +1,52 @@ /** - * Inline dynamic requires in the webpack runtime. + * Inline the dynamic chunk requires of the webpack runtime. * - * The webpack runtime has dynamic requires that would not be bundled by ESBuild: + * The webpack runtime loads chunks with a dynamic require that ESBuild can not bundle: * * installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); * - * `__webpack_require__.u` maps a chunk id to its file name. Next.js uses `chunkFilename: "[name].js"` for the - * server, so the file is `.js` by default but `.js` for a named chunk (i.e. `webpackChunkName`): + * `__webpack_require__.u` returns `.js`, or `.js` for a named chunk (`webpackChunkName`). + * The require is replaced with a switch over the existing chunk files: * - * __webpack_require__.u = (chunkId) => "" + ({ 522: "named-chunk" }[chunkId] || chunkId) + ".js"; - * - * This patch unrolls the dynamic require for all the existing chunk files by switching on the computed path: - * - * For multiple chunks: - * if (SELF_ID == chunkId) { - * installedChunks[chunkId] = 1; - * } else { - * switch ("./chunks/" + __webpack_require__.u(chunkId)) { - * case "./chunks/ID1.js": installChunk(require("./chunks/ID1.js")); break; - * case "./chunks/NAME.js": installChunk(require("./chunks/NAME.js")); break; - * // ... - * default: throw new Error(`Unknown chunk ${chunkId}`); + * installChunk(((path) => { + * switch (path) { + * case "./chunks/123.js": return require("./chunks/123.js"); + * case "./chunks/named.js": return require("./chunks/named.js"); + * default: throw new Error(`Unknown chunk ${path}`); * } - * } - * - * For a single chunk, the same switch is used for `CHUNK_ID == chunkId`. - * The chunk does not always exist so the default case is a no-op. + * })("./chunks/" + __webpack_require__.u(chunkId))); */ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; import logger from "@opennextjs/aws/logger.js"; /** - * Builds the switch that requires the chunk files. + * Builds the rule inlining the dynamic chunk require. * - * Note: `$CHUNK_FILE` is the dynamic file name expression (`__webpack_require__.u(chunkId)`). - * - * @param chunks Chunk file names, relative to the chunks folder (i.e. `123.js`, `named-chunk.js`). - * @param defaultCase Statement for the default case. + * @param chunks Chunk files, relative to the chunks folder (i.e. `123.js`, `named-chunk.js`). + * @returns The ast-grep rule. */ -function buildChunksSwitch(chunks: string[], defaultCase: string): string { +export function buildChunkRequireRule(chunks: string[]): string { const cases = chunks.map((chunk) => { - // `$NAME` placeholders are substituted in the fix string, escape `$` to keep chunk names intact + // Escape `$` so that ast-grep does not treat it as a metavariable const path = JSON.stringify(`./chunks/${chunk}`).replaceAll("$", "\\x24"); - return ` case ${path}: $INSTALL(require(${path})); break;`; + return ` case ${path}: return require(${path});`; }); - return ` switch ("./chunks/" + $CHUNK_FILE) { -${cases.join("\n")} - default: ${defaultCase} - }`; -} - -// Inline the code when there are multiple chunks -export function buildMultipleChunksRule(chunks: string[]) { - return ` -rule: - pattern: ($CHUNK_ID, $_PROMISES) => { $$$ } - inside: {pattern: $_.$_.require = $$$_, stopBy: end} - all: - - has: {pattern: $INSTALL(require("./chunks/" + $CHUNK_FILE)), stopBy: end} - - has: {pattern: $SELF_ID != $CHUNK_ID, stopBy: end} - - has: {pattern: "$INSTALLED_CHUNK[$CHUNK_ID] = 1", stopBy: end} -fix: | - ($CHUNK_ID, _) => { - if (!$INSTALLED_CHUNK[$CHUNK_ID]) { - if ($SELF_ID == $CHUNK_ID) { - $INSTALLED_CHUNK[$CHUNK_ID] = 1; - } else { -${buildChunksSwitch(chunks, "throw new Error(`Unknown chunk ${$CHUNK_ID}`);")} - } - } - }`; -} - -// Inline the code when there is a single chunk. -// For example when there is a single Pages API route. -// Note: The chunk does not always exist which explain the no-op default case. -export function buildSingleChunkRule(chunks: string[]) { return ` rule: - pattern: ($CHUNK_ID, $_PROMISES) => { $$$ } + pattern: require("./chunks/" + $CHUNK_FILE) inside: {pattern: $_.$_.require = $$$_, stopBy: end} - all: - - has: {pattern: $INSTALL(require("./chunks/" + $CHUNK_FILE)), stopBy: end} - - has: {pattern: $ONLY_ID == $CHUNK_ID, stopBy: end} - - has: {pattern: "$INSTALLED_CHUNK[$CHUNK_ID] = 1", stopBy: end} fix: | - ($CHUNK_ID, _) => { - if (!$INSTALLED_CHUNK[$CHUNK_ID]) { - if ($ONLY_ID == $CHUNK_ID) { -${buildChunksSwitch(chunks, "break;")} - } else { - $INSTALLED_CHUNK[$CHUNK_ID] = 1; - } + ((path) => { + switch (path) { +${cases.join("\n")} + default: throw new Error(\`Unknown chunk \${path}\`); } - }`; + })("./chunks/" + $CHUNK_FILE)`; } /** @@ -114,32 +62,24 @@ export function listChunks(chunksDir: string): string[] { return []; } - const chunks: string[] = []; - const walk = (relativeDir: string) => { - for (const entry of readdirSync(join(chunksDir, relativeDir), { withFileTypes: true })) { - const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; - if (entry.isDirectory()) { - walk(relativePath); - } else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.endsWith(".js")) { - chunks.push(relativePath); - } - } - }; - walk(""); - - return chunks.sort(); + return readdirSync(chunksDir, { encoding: "utf-8", recursive: true }) + .filter((file) => file.endsWith(".js")) + .map((file) => file.replaceAll(sep, "/")) + .sort(); } /** * Fixes the webpack-runtime.js and webpack-api-runtime.js files by inlining * the webpack dynamic requires. + * + * @param dotNextServerDir Path to the `.next/server` folder. */ export async function patchWebpackRuntime(dotNextServerDir: string) { const runtimes = ["webpack-runtime.js", "webpack-api-runtime.js"] .map((runtime) => join(dotNextServerDir, runtime)) .filter((runtime) => existsSync(runtime)); - // Turbopack builds have no webpack runtime, skip listing the chunks + // Turbopack builds have no webpack runtime if (runtimes.length === 0) { return; } @@ -151,16 +91,6 @@ export async function patchWebpackRuntime(dotNextServerDir: string) { } } -/** - * Whether the code still contains the dynamic chunk require of the webpack runtime. - * - * @param code The webpack runtime code. - * @returns `true` when `require("./chunks/" + ...)` is present. - */ -export function hasDynamicChunkRequire(code: string): boolean { - return /require\("\.\/chunks\/"\s*\+/.test(code); -} - /** * Inline the chunks in a webpack runtime file. * @@ -168,15 +98,11 @@ export function hasDynamicChunkRequire(code: string): boolean { * @param chunks List of chunk files in the chunks folder. */ function patchFile(filename: string, chunks: string[]) { - let code = readFileSync(filename, "utf-8"); - code = patchCode(code, buildMultipleChunksRule(chunks)); - code = patchCode(code, buildSingleChunkRule(chunks)); + const code = patchCode(readFileSync(filename, "utf-8"), buildChunkRequireRule(chunks)); - // A silent non-match would only surface at runtime when the worker fails to load a chunk - if (hasDynamicChunkRequire(code)) { - logger.error( - `Failed to inline the dynamic chunk requires in ${filename}: the webpack runtime has an unexpected shape and loading chunks will fail at runtime.` - ); + // The worker would fail to load chunks at runtime + if (/require\("\.\/chunks\/"\s*\+/.test(code)) { + logger.error(`Failed to inline the dynamic chunk requires in ${filename}`); } writeFileSync(filename, code);