Skip to content

Commit ea1d8ff

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

2 files changed

Lines changed: 136 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: 131 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
14
import { patchCode } from "@opennextjs/aws/build/patch/astCodePatcher.js";
25
import type { CodePatcher } from "@opennextjs/aws/build/patch/codePatcher.js";
36
import { getCrossPlatformPathRegex } from "@opennextjs/aws/utils/regex.js";
@@ -10,6 +13,130 @@ fix:
1013
requireChunk(chunkPath)
1114
`;
1215

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

26155
return `${patched}\n${inlineChunksFn(tracedFiles)}`;
@@ -63,27 +192,3 @@ ${chunks
63192
}
64193
`;
65194
}
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)