Skip to content

Commit f2932bd

Browse files
committed
fix: dynamically resolve Turbopack external module mappings for workerd
1 parent 63e29f2 commit f2932bd

2 files changed

Lines changed: 135 additions & 26 deletions

File tree

.changeset/five-gifts-hope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
Dynamically discover Turbopack external module mappings from `.next/node_modules/` symlinks instead of using a static import rule. This fixes runtime failures on workerd for packages listed in `serverExternalPackages` that Turbopack externalizes with hashed identifiers.

packages/cloudflare/src/cli/build/patches/plugins/turbopack.ts

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
22
import type { CodePatcher } from "@opennextjs/aws/build/patch/codePatcher.js";
33
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
4+
import fs from "node:fs";
5+
import path from "node:path";
46

57
const inlineChunksRule = `
68
rule:
@@ -10,6 +12,130 @@ fix:
1012
requireChunk(chunkPath)
1113
`;
1214

15+
/**
16+
* Discover Turbopack external module mappings by reading symlinks in .next/node_modules/.
17+
*
18+
* Turbopack externalizes packages listed in serverExternalPackages and creates hashed
19+
* identifiers (e.g. "shiki-43d062b67f27bbdc") with symlinks in .next/node_modules/ pointing
20+
* to the real packages (e.g. ../../node_modules/shiki). At runtime, externalImport() does
21+
* `await import("shiki-43d062b67f27bbdc/wasm")` which fails on workerd because those hashed
22+
* names are not real modules. This function discovers the mappings so we can intercept them.
23+
*/
24+
function discoverExternalModuleMappings(filePath: string): Map<string, string> {
25+
// filePath is like: .../.next/server/chunks/ssr/[turbopack]_runtime.js
26+
// We need: .../.next/node_modules/
27+
const dotNextDir = filePath.replace(/\/server\/chunks\/.*$/, "");
28+
const nodeModulesDir = path.join(dotNextDir, "node_modules");
29+
30+
const mappings = new Map<string, string>();
31+
32+
if (!fs.existsSync(nodeModulesDir)) {
33+
return mappings;
34+
}
35+
36+
for (const entry of fs.readdirSync(nodeModulesDir)) {
37+
const entryPath = path.join(nodeModulesDir, entry);
38+
try {
39+
const stat = fs.lstatSync(entryPath);
40+
if (stat.isSymbolicLink()) {
41+
const target = fs.readlinkSync(entryPath);
42+
// target is like "../../node_modules/shiki" — extract package name
43+
const match = target.match(/node_modules\/(.+)$/);
44+
if (match?.[1]) {
45+
mappings.set(entry, match[1]);
46+
}
47+
}
48+
} catch {
49+
// skip entries we can't read
50+
}
51+
}
52+
53+
return mappings;
54+
}
55+
56+
/**
57+
* Build a dynamic inlineExternalImportRule that includes cases for all discovered
58+
* Turbopack external module hashes, mapping them back to their real package names.
59+
*
60+
* We use a switch for exact matches (including bare + subpath cases) and a fallback
61+
* for the default case. Since switch/case can only match exact strings, we enumerate
62+
* known subpaths from the traced files to cover cases like "shiki-hash/wasm".
63+
*/
64+
function buildExternalImportRule(mappings: Map<string, string>, tracedFiles: string[]): string {
65+
const cases: string[] = [];
66+
67+
// Always include the @vercel/og rewrite
68+
cases.push(` case "next/dist/compiled/@vercel/og/index.node.js":
69+
$RAW = await import("next/dist/compiled/@vercel/og/index.edge.js");
70+
break;`);
71+
72+
// Add case for each discovered external module mapping (bare import)
73+
for (const [hashedName, realName] of mappings) {
74+
cases.push(` case "${hashedName}":
75+
$RAW = await import("${realName}");
76+
break;`);
77+
}
78+
79+
// Discover subpath imports from the traced chunk files.
80+
// Chunks reference external modules like "shiki-hash/wasm" — scan for these patterns.
81+
const subpathCases = discoverExternalSubpaths(mappings, tracedFiles);
82+
for (const [hashedSubpath, realSubpath] of subpathCases) {
83+
cases.push(` case "${hashedSubpath}":
84+
$RAW = await import("${realSubpath}");
85+
break;`);
86+
}
87+
88+
return `
89+
rule:
90+
pattern: "$RAW = await import($ID)"
91+
inside:
92+
regex: "externalImport"
93+
kind: function_declaration
94+
stopBy: end
95+
fix: |-
96+
switch ($ID) {
97+
${cases.join("\n")}
98+
default:
99+
$RAW = await import($ID);
100+
}
101+
`;
102+
}
103+
104+
/**
105+
* Scan traced chunk files for external module subpath imports.
106+
* E.g. find "shiki-43d062b67f27bbdc/wasm" in chunk code and map it to "shiki/wasm".
107+
*
108+
* Only scans files with "[externals]" in the name since those are the chunks that
109+
* contain externalImport calls.
110+
*/
111+
function discoverExternalSubpaths(mappings: Map<string, string>, tracedFiles: string[]): Map<string, string> {
112+
const subpaths = new Map<string, string>();
113+
114+
const externalChunks = tracedFiles.filter((f) => f.includes("[externals]"));
115+
116+
for (const [hashedName, realName] of mappings) {
117+
const pattern = new RegExp(`"(${hashedName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/[^"]*)"`, "g");
118+
119+
for (const filePath of externalChunks) {
120+
try {
121+
const content = fs.readFileSync(filePath, "utf-8");
122+
for (const match of content.matchAll(pattern)) {
123+
const fullHashedPath = match[1];
124+
if (fullHashedPath) {
125+
const subpath = fullHashedPath.slice(hashedName.length);
126+
const realSubpath = realName + subpath;
127+
subpaths.set(fullHashedPath, realSubpath);
128+
}
129+
}
130+
} catch {
131+
// skip files we can't read
132+
}
133+
}
134+
}
135+
136+
return subpaths;
137+
}
138+
13139
export const patchTurbopackRuntime: CodePatcher = {
14140
name: "inline-turbopack-chunks",
15141
patches: [
@@ -19,8 +145,10 @@ export const patchTurbopackRuntime: CodePatcher = {
19145
escape: false,
20146
}),
21147
contentFilter: /loadRuntimeChunkPath/,
22-
patchCode: async ({ code, tracedFiles }) => {
23-
let patched = patchCode(code, inlineExternalImportRule);
148+
patchCode: async ({ code, tracedFiles, filePath }) => {
149+
const mappings = discoverExternalModuleMappings(filePath);
150+
const externalImportRule = buildExternalImportRule(mappings, tracedFiles);
151+
let patched = patchCode(code, externalImportRule);
24152
patched = patchCode(patched, inlineChunksRule);
25153

26154
return `${patched}\n${inlineChunksFn(tracedFiles)}`;
@@ -63,27 +191,3 @@ ${chunks
63191
}
64192
`;
65193
}
66-
67-
// Turbopack imports `og` via `externalImport`.
68-
// We patch it to:
69-
// - add the explicit path so that the file is inlined by wrangler
70-
// - use the edge version of the module instead of the node version.
71-
//
72-
// Modules that are not inlined (no added to the switch), would generate an error similar to:
73-
// Failed to load external module path/to/module: Error: No such module "path/to/module"
74-
const inlineExternalImportRule = `
75-
rule:
76-
pattern: "$RAW = await import($ID)"
77-
inside:
78-
regex: "externalImport"
79-
kind: function_declaration
80-
stopBy: end
81-
fix: |-
82-
switch ($ID) {
83-
case "next/dist/compiled/@vercel/og/index.node.js":
84-
$RAW = await import("next/dist/compiled/@vercel/og/index.edge.js");
85-
break;
86-
default:
87-
$RAW = await import($ID);
88-
}
89-
`;

0 commit comments

Comments
 (0)