diff --git a/.changeset/webpack-named-chunks.md b/.changeset/webpack-named-chunks.md new file mode 100644 index 000000000..7afbccfeb --- /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 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/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" }); +}); 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..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,113 +1,149 @@ -import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; -import { describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { buildMultipleChunksRule, singleChunkRule } from "./webpack-runtime.js"; +import { applyRule, parseCode, patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; -describe("webpack runtime", () => { - describe("multiple chunks", () => { - 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, 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}\`); - } - } - } - ; - " - `); - }); - - 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, 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}\`); - } - } - } - - " - ` - ); - }); - }); +import { buildChunkRequireRule, listChunks } from "./webpack-runtime.js"; - describe("single chunk", () => { +describe("webpack runtime", () => { + 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(710 == chunkId) { +/******/ if(658 != chunkId) { /******/ installChunk(require("./chunks/" + __webpack_require__.u(chunkId))); /******/ } else installedChunks[chunkId] = 1; /******/ } /******/ }; `; - 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, 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", () => { + const code = `t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)}`; + + 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}\`); + } + })("./chunks/" + t.u(o)) + ):e[o]=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)}" + `); + }); + + 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(patchCode(code, buildChunkRequireRule(["$CHUNK_FILE.js"]))).toContain( + `case "./chunks/\\x24CHUNK_FILE.js": return require("./chunks/\\x24CHUNK_FILE.js");` + ); + }); + + test("run the patched runtime", () => { const code = ` - 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 {} - } - } - - " - `); +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, buildChunkRequireRule(["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 ./chunks/404.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(applyRule(buildChunkRequireRule(["1.js"]), parseCode(patched)).edits).toHaveLength(0); + }); + }); + + describe("listChunks", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "webpack-runtime-test-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + 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), ""); + } + + expect(listChunks(tmpDir)).toEqual(["103.js", "9.js", "dir/nested-chunk.js", "named-chunk.js"]); + }); + + 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 92832165c..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,103 +1,109 @@ /** - * 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))); * - * This patch unrolls the dynamic require for all the existing chunks: + * `__webpack_require__.u` returns `.js`, or `.js` for a named chunk (`webpackChunkName`). + * The require is replaced with a switch over the existing chunk files: * - * 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}`); - * } - * - * For a single chunk: - * require("./chunks/CHUNK_ID.js"); + * 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}`); + * } + * })("./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 rule inlining the dynamic chunk require. + * + * @param chunks Chunk files, relative to the chunks folder (i.e. `123.js`, `named-chunk.js`). + * @returns The ast-grep rule. + */ +export function buildChunkRequireRule(chunks: string[]): string { + const cases = chunks.map((chunk) => { + // Escape `$` so that ast-grep does not treat it as a metavariable + const path = JSON.stringify(`./chunks/${chunk}`).replaceAll("$", "\\x24"); + return ` case ${path}: return require(${path});`; + }); -// Inline the code when there are multiple chunks -export function buildMultipleChunksRule(chunks: number[]) { return ` rule: - pattern: ($CHUNK_ID, $_PROMISES) => { $$$ } + pattern: require("./chunks/" + $CHUNK_FILE) inside: {pattern: $_.$_.require = $$$_, stopBy: end} - all: - - has: {pattern: $INSTALL(require("./chunks/" + $$$)), 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}\`); - } + ((path) => { + switch (path) { +${cases.join("\n")} + default: throw new Error(\`Unknown chunk \${path}\`); } - }`; + })("./chunks/" + $CHUNK_FILE)`; } -// 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 = ` -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: "$INSTALLED_CHUNK[$CHUNK_ID] = 1", stopBy: end} -fix: | - ($CHUNK_ID, _) => { - if (!$INSTALLED_CHUNK[$CHUNK_ID]) { - try { - $INSTALL(require("./chunks/$SELF_ID.js")); - } catch {} - } - } -`; +/** + * 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 []; + } + + 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) { - // 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)); - patchFile(join(dotNextServerDir, "webpack-runtime.js"), chunks); - patchFile(join(dotNextServerDir, "webpack-api-runtime.js"), chunks); + // Turbopack builds have no webpack runtime + if (runtimes.length === 0) { + return; + } + + 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[]) { + const code = patchCode(readFileSync(filename, "utf-8"), buildChunkRequireRule(chunks)); + + // 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); }