Skip to content

Commit a7f2cec

Browse files
committed
add regression test and fix extra scenario
1 parent ea1d8ff commit a7f2cec

7 files changed

Lines changed: 290 additions & 2 deletions

File tree

.changeset/five-gifts-hope.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,11 @@
22
"@opennextjs/cloudflare": patch
33
---
44

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.
5+
Fix Turbopack external module resolution on workerd by dynamically discovering external imports at build time.
6+
7+
When packages are listed in `serverExternalPackages`, Turbopack externalizes them via `externalImport()` which uses dynamic `await import(id)`. On workerd, the bundler can't statically analyze `import(id)` with a variable, so these modules aren't included in the worker bundle.
8+
9+
This patch:
10+
- Discovers hashed Turbopack external module mappings from `.next/node_modules/` symlinks (e.g. `shiki-43d062b67f27bbdc``shiki`)
11+
- Scans traced chunk files for bare external imports (e.g. `externalImport("shiki")`) and subpath imports (e.g. `shiki/engine/javascript`)
12+
- Generates explicit `switch/case` entries so the bundler can statically resolve and include these modules
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createHighlighter } from "shiki";
2+
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3+
4+
export async function GET() {
5+
const highlighter = await createHighlighter({
6+
themes: ["vitesse-dark"],
7+
langs: ["javascript"],
8+
engine: createJavaScriptRegexEngine(),
9+
});
10+
11+
const html = highlighter.codeToHtml('console.log("hello")', {
12+
lang: "javascript",
13+
theme: "vitesse-dark",
14+
});
15+
16+
return new Response(JSON.stringify({ html }), {
17+
headers: { "content-type": "application/json" },
18+
});
19+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// Regression test for Turbopack external module resolution on workerd.
4+
// When shiki is in serverExternalPackages, Turbopack externalizes it via `externalImport()`,
5+
// which does `await import("shiki")` with a dynamic variable. On workerd, the bundler can't
6+
// statically analyze `import(id)`, so the module isn't included. The patch adds explicit
7+
// switch cases (e.g. `case "shiki": await import("shiki")`) so the bundler can trace them.
8+
// This also covers subpath imports like "shiki/engine/javascript".
9+
test("shiki syntax highlighting via API route", async ({ request }) => {
10+
const response = await request.get("/api/shiki");
11+
expect(response.status()).toEqual(200);
12+
13+
const json = await response.json();
14+
expect(json).toMatchObject({
15+
html: '<pre class="shiki vitesse-dark" style="background-color:#121212;color:#dbd7caee" tabindex="0"><code><span class="line"><span style="color:#BD976A">console</span><span style="color:#666666">.</span><span style="color:#80A665">log</span><span style="color:#666666">(</span><span style="color:#C98A7D77">"</span><span style="color:#C98A7D">hello</span><span style="color:#C98A7D77">"</span><span style="color:#666666">)</span></span></code></pre>',
16+
});
17+
});

examples/e2e/app-router/next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
44
poweredByHeader: false,
55
cleanDistDir: true,
66
transpilePackages: ["@example/shared"],
7+
serverExternalPackages: ["shiki"],
78
output: "standalone",
89
// outputFileTracingRoot: "../sst",
910
typescript: {

examples/e2e/app-router/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"@example/shared": "workspace:*",
2222
"next": "catalog:e2e",
2323
"react": "catalog:e2e",
24-
"react-dom": "catalog:e2e"
24+
"react-dom": "catalog:e2e",
25+
"shiki": "^3.22.0"
2526
},
2627
"devDependencies": {
2728
"@playwright/test": "catalog:",

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ function buildExternalImportRule(mappings: Map<string, string>, tracedFiles: str
8686
break;`);
8787
}
8888

89+
// Discover bare external imports from chunk files (e.g. externalImport("shiki")).
90+
// These need explicit switch cases so the bundler can statically resolve them.
91+
const bareImports = discoverBareExternalImports(tracedFiles);
92+
const alreadyCased = new Set(cases.map((c) => c.match(/case "([^"]+)"/)?.[1]).filter(Boolean));
93+
for (const [moduleName, realName] of bareImports) {
94+
if (!alreadyCased.has(moduleName)) {
95+
cases.push(` case "${moduleName}":
96+
$RAW = await import("${realName}");
97+
break;`);
98+
}
99+
}
100+
89101
return `
90102
rule:
91103
pattern: "$RAW = await import($ID)"
@@ -102,6 +114,40 @@ ${cases.join("\n")}
102114
`;
103115
}
104116

117+
/**
118+
* Scan traced chunk files for bare external module imports (e.g. `externalImport("shiki")`).
119+
*
120+
* In some Turbopack versions, externalized packages are referenced by their real names
121+
* (not hashed). On workerd, the default `await import(id)` with a variable `id` can't be
122+
* statically analyzed by the bundler. By adding explicit switch cases with string literals,
123+
* we make these imports statically discoverable so they get bundled into the worker.
124+
*/
125+
function discoverBareExternalImports(tracedFiles: string[]): Map<string, string> {
126+
const bareImports = new Map<string, string>();
127+
128+
// Turbopack compiles `externalImport(id)` calls as `.y("moduleName")` in chunks.
129+
// We scan all chunk files (not just [externals] ones) to find these patterns.
130+
const chunkFiles = tracedFiles.filter((f) => f.includes(".next/server/chunks/"));
131+
132+
for (const filePath of chunkFiles) {
133+
try {
134+
const content = fs.readFileSync(filePath, "utf-8");
135+
// Match patterns like .y("shiki") or .y("some-package/subpath")
136+
for (const match of content.matchAll(/\.y\("([^"]+)"\)/g)) {
137+
const moduleName = match[1];
138+
if (moduleName) {
139+
// Identity mapping — the module name is already the real name
140+
bareImports.set(moduleName, moduleName);
141+
}
142+
}
143+
} catch {
144+
// skip files we can't read
145+
}
146+
}
147+
148+
return bareImports;
149+
}
150+
105151
/**
106152
* Scan traced chunk files for external module subpath imports.
107153
* E.g. find "shiki-43d062b67f27bbdc/wasm" in chunk code and map it to "shiki/wasm".

0 commit comments

Comments
 (0)