From 9911d53636e986be8dd5b104dffed3cd284586bb Mon Sep 17 00:00:00 2001 From: killagu Date: Tue, 14 Apr 2026 11:37:47 +0800 Subject: [PATCH 1/3] feat(bundler): post-process turbopack output to fix import.meta.url --- tools/egg-bundler/src/lib/Bundler.ts | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 028253dde5..50532e4af3 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -90,6 +90,12 @@ export class Bundler { const packResult = await wrapStep('pack build', () => packRunner.run()); debug('pack produced %d files', packResult.files.length); + // turbopack wraps `import.meta` in a throwing getter for bundled ESM + // chunks. Patch the output so `createRequire(import.meta.url)` and + // other `import.meta.url` usages work at runtime. + const patchCount = await wrapStep('patch import.meta.url', () => this.#patchImportMetaUrl(absOutputDir)); + debug('patched %d import.meta.url occurrences', patchCount); + // Merge project name into output package.json so the framework's // getAppname() finds it (it reads baseDir/package.json). const outputPkgPath = path.join(absOutputDir, 'package.json'); @@ -130,4 +136,60 @@ export class Bundler { manifestPath: manifestPathAbs, }; } + + /** + * Turbopack replaces `import.meta` in bundled ESM chunks with an object + * that only defines a throwing `url` getter and omits `dirname`/`filename`. + * + * We post-process the output .js files in two passes: + * 1. Replace the throwing `url` IIFE with a working `file://` URL. + * 2. Inject `dirname` and `filename` getters so code like + * `path.join(import.meta.dirname, '../lib/asset.html')` works. + */ + async #patchImportMetaUrl(outputDir: string): Promise { + // Pass 1 — fix the throwing url getter. + const THROWING_IIFE = + /\(\(\)\s*=>\s*\{\s*throw\s+new\s+Error\(\s*['"]could not convert import\.meta\.url to filepath['"]\s*\)\s*;?\s*\}\)\s*\(\)/g; + // Avoid require() in the replacement — turbopack modules may declare + // `const require = createRequire(import.meta.url)` which would be in + // the TDZ when our getter runs. Use globals only. + const URL_EXPR = 'new URL("file:///" + encodeURI(process.argv[1])).href'; + + // Pass 2 — add dirname/filename after patching url. + // Match the url-only meta object that results from pass 1. + const META_URL_ONLY = + /var __TURBOPACK__import\$2e\$meta__ = \{\s*get url \(\) \{\s*return new URL\("file:\/\/\/" \+ encodeURI\(process\.argv\[1\]\)\)\.href;\s*\}\s*\};/g; + const META_FULL = `var __TURBOPACK__import$2e$meta__ = { + get url () { + return new URL("file:///" + encodeURI(process.argv[1])).href; + }, + get dirname () { + return process.argv[1].replace(/[\\\\/][^\\\\/]*$/, ""); + }, + get filename () { + return process.argv[1]; + } +};`; + + let totalPatches = 0; + const entries = await fs.readdir(outputDir); + for (const name of entries) { + if (!name.endsWith('.js')) continue; + const filepath = path.join(outputDir, name); + const content = await fs.readFile(filepath, 'utf8'); + + // Pass 1 + const urlMatches = content.match(THROWING_IIFE); + if (!urlMatches) continue; + let patched = content.replace(THROWING_IIFE, URL_EXPR); + + // Pass 2 + patched = patched.replace(META_URL_ONLY, META_FULL); + + await fs.writeFile(filepath, patched); + totalPatches += urlMatches.length; + debug('patched %d import.meta in %s', urlMatches.length, name); + } + return totalPatches; + } } From a120a179e10d95e421132182edaa1ac879b731a7 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 27 Apr 2026 10:44:21 +0800 Subject: [PATCH 2/3] fix(bundler): patch nested import.meta chunks --- tools/egg-bundler/src/lib/Bundler.ts | 63 +++++++++++------ tools/egg-bundler/test/integration.test.ts | 79 +++++++++++++++++++++- 2 files changed, 119 insertions(+), 23 deletions(-) diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 50532e4af3..3ecb361baf 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -147,48 +147,69 @@ export class Bundler { * `path.join(import.meta.dirname, '../lib/asset.html')` works. */ async #patchImportMetaUrl(outputDir: string): Promise { - // Pass 1 — fix the throwing url getter. + // Pass 1 - fix the throwing url getter. const THROWING_IIFE = /\(\(\)\s*=>\s*\{\s*throw\s+new\s+Error\(\s*['"]could not convert import\.meta\.url to filepath['"]\s*\)\s*;?\s*\}\)\s*\(\)/g; - // Avoid require() in the replacement — turbopack modules may declare - // `const require = createRequire(import.meta.url)` which would be in - // the TDZ when our getter runs. Use globals only. - const URL_EXPR = 'new URL("file:///" + encodeURI(process.argv[1])).href'; - // Pass 2 — add dirname/filename after patching url. - // Match the url-only meta object that results from pass 1. + // Pass 2 - add dirname/filename for a url-only import.meta object. Match + // the Turbopack import.meta binding structurally so formatting changes, + // let/const declarations, or an already-patched url getter still work. const META_URL_ONLY = - /var __TURBOPACK__import\$2e\$meta__ = \{\s*get url \(\) \{\s*return new URL\("file:\/\/\/" \+ encodeURI\(process\.argv\[1\]\)\)\.href;\s*\}\s*\};/g; - const META_FULL = `var __TURBOPACK__import$2e$meta__ = { + /\b(var|let|const)\s+([A-Za-z_$][\w$]*import\$2e\$meta__[A-Za-z0-9_$]*)\s*=\s*\{\s*get\s+url\s*\(\)\s*\{[\s\S]*?\}\s*\};?/g; + + function buildRuntimeExpressions(relativeName: string): { chunkFilenameExpr: string; urlExpr: string } { + const chunkFilenameExpr = `process.argv[1].replace(/[^\\\\/]*$/, ${JSON.stringify(relativeName)})`; + const urlExpr = `(() => { const u = new URL("file:///"); u.pathname = ${chunkFilenameExpr}.replace(/\\\\/g, "/"); return u.href; })()`; + return { chunkFilenameExpr, urlExpr }; + } + + function buildMetaFull( + declarationKind: string, + metaName: string, + chunkFilenameExpr: string, + urlExpr: string, + ): string { + return `${declarationKind} ${metaName} = { get url () { - return new URL("file:///" + encodeURI(process.argv[1])).href; + return ${urlExpr}; }, get dirname () { - return process.argv[1].replace(/[\\\\/][^\\\\/]*$/, ""); + return ${chunkFilenameExpr}.replace(/[\\\\/][^\\\\/]*$/, ""); }, get filename () { - return process.argv[1]; + return ${chunkFilenameExpr}; } };`; + } let totalPatches = 0; - const entries = await fs.readdir(outputDir); - for (const name of entries) { - if (!name.endsWith('.js')) continue; - const filepath = path.join(outputDir, name); + const entries = await fs.readdir(outputDir, { + recursive: true, + withFileTypes: true, + }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + const filepath = path.join(entry.parentPath ?? outputDir, entry.name); + const relativeName = path.relative(outputDir, filepath).split(path.sep).join('/'); const content = await fs.readFile(filepath, 'utf8'); + const { chunkFilenameExpr, urlExpr } = buildRuntimeExpressions(relativeName); // Pass 1 const urlMatches = content.match(THROWING_IIFE); - if (!urlMatches) continue; - let patched = content.replace(THROWING_IIFE, URL_EXPR); + let patched = content.replace(THROWING_IIFE, urlExpr); // Pass 2 - patched = patched.replace(META_URL_ONLY, META_FULL); + let metaMatches = 0; + patched = patched.replace(META_URL_ONLY, (_match, declarationKind: string, metaName: string) => { + metaMatches++; + return buildMetaFull(declarationKind, metaName, chunkFilenameExpr, urlExpr); + }); + + if (!urlMatches && metaMatches === 0) continue; await fs.writeFile(filepath, patched); - totalPatches += urlMatches.length; - debug('patched %d import.meta in %s', urlMatches.length, name); + totalPatches += (urlMatches?.length ?? 0) + metaMatches; + debug('patched %d import.meta in %s', (urlMatches?.length ?? 0) + metaMatches, relativeName); } return totalPatches; } diff --git a/tools/egg-bundler/test/integration.test.ts b/tools/egg-bundler/test/integration.test.ts index a7307d080b..3894ca979d 100644 --- a/tools/egg-bundler/test/integration.test.ts +++ b/tools/egg-bundler/test/integration.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -158,10 +159,10 @@ describe('bundle() integration — minimal-app (Phase 1: mocked @utoo/pack)', () expect(bm.entries).toEqual([{ name: 'worker', source: expect.stringContaining('worker.entry.ts') }]); expect(Array.isArray(bm.externals)).toBe(true); // externals should be sorted and should contain at least egg (workspace dep) - expect([...bm.externals]).toEqual([...bm.externals].sort()); + expect([...bm.externals]).toEqual([...bm.externals].sort((a, b) => a.localeCompare(b))); expect(bm.externals).toContain('egg'); // chunks should be sorted and contain worker.js - expect([...bm.chunks]).toEqual([...bm.chunks].sort()); + expect([...bm.chunks]).toEqual([...bm.chunks].sort((a, b) => a.localeCompare(b))); expect(bm.chunks).toContain('worker.js'); expect(bm.chunks).toContain('tsconfig.json'); expect(bm.chunks).toContain('package.json'); @@ -189,6 +190,80 @@ describe('bundle() integration — minimal-app (Phase 1: mocked @utoo/pack)', () expect(bm.externals).toContain('synthetic-force-ext'); }); + it('patches nested Turbopack import.meta chunks with chunk-local url, dirname, and filename', async () => { + const throwingMeta = `var __TURBOPACK__import$2e$meta__ = { + get url () { + return (() => { throw new Error("could not convert import.meta.url to filepath"); })(); + } +}; +globalThis.__patchedMeta = { + url: __TURBOPACK__import$2e$meta__.url, + dirname: __TURBOPACK__import$2e$meta__.dirname, + filename: __TURBOPACK__import$2e$meta__.filename +}; +`; + const urlOnlyMeta = `let __TURBOPACK__import$2e$meta__ = { get url () { return "file:///already-patched.js"; } }; +globalThis.__patchedMeta = { + url: __TURBOPACK__import$2e$meta__.url, + dirname: __TURBOPACK__import$2e$meta__.dirname, + filename: __TURBOPACK__import$2e$meta__.filename +}; +`; + + const buildFunc: BuildFunc = async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// mock worker entry\n'); + await fs.mkdir(path.join(tmpOutput, 'chunks'), { recursive: true }); + await fs.writeFile(path.join(tmpOutput, 'chunks/chunk #?.js'), throwingMeta); + await fs.writeFile(path.join(tmpOutput, 'chunks/url-only.js'), urlOnlyMeta); + }; + + await bundle({ + baseDir: FIXTURE_BASE, + outputDir: tmpOutput, + pack: { buildFunc }, + }); + + async function runPatchedChunk(filepath: string): Promise<{ url: string; dirname: string; filename: string }> { + interface Sandbox { + URL: typeof URL; + process: { argv: string[] }; + globalThis: Sandbox; + __patchedMeta?: { url: string; dirname: string; filename: string }; + } + const sandbox = { + URL, + process: { argv: ['node', path.join(tmpOutput, 'worker.js')] }, + } as unknown as Sandbox; + sandbox.globalThis = sandbox; + runInNewContext(await fs.readFile(filepath, 'utf8'), sandbox); + return sandbox.__patchedMeta!; + } + + function expectedFileUrl(filename: string): string { + const u = new URL('file:///'); + u.pathname = filename.replace(/\\/g, '/'); + return u.href; + } + + const nestedFilename = path.join(tmpOutput, 'chunks/chunk #?.js'); + const nestedMeta = await runPatchedChunk(nestedFilename); + expect(nestedMeta).toEqual({ + url: expectedFileUrl(nestedFilename), + dirname: path.dirname(nestedFilename), + filename: nestedFilename, + }); + + const urlOnlyFilename = path.join(tmpOutput, 'chunks/url-only.js'); + const urlOnlyPatched = await fs.readFile(urlOnlyFilename, 'utf8'); + expect(urlOnlyPatched).not.toContain('already-patched.js'); + const urlOnlyMetaResult = await runPatchedChunk(urlOnlyFilename); + expect(urlOnlyMetaResult).toEqual({ + url: expectedFileUrl(urlOnlyFilename), + dirname: path.dirname(urlOnlyFilename), + filename: urlOnlyFilename, + }); + }); + it('wraps a buildFunc failure under the "pack build" step with an identifiable prefix and preserves cause', async () => { const original = new Error('synthetic pack failure'); const buildFunc: BuildFunc = async () => { From 2b524f40c24150b5fd548346bdbf78d488105215 Mon Sep 17 00:00:00 2001 From: killa Date: Mon, 27 Apr 2026 11:27:18 +0800 Subject: [PATCH 3/3] fix(bundler): preserve dollar signs in chunk paths --- tools/egg-bundler/src/lib/Bundler.ts | 2 +- tools/egg-bundler/test/integration.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 3ecb361baf..9099df4a72 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -158,7 +158,7 @@ export class Bundler { /\b(var|let|const)\s+([A-Za-z_$][\w$]*import\$2e\$meta__[A-Za-z0-9_$]*)\s*=\s*\{\s*get\s+url\s*\(\)\s*\{[\s\S]*?\}\s*\};?/g; function buildRuntimeExpressions(relativeName: string): { chunkFilenameExpr: string; urlExpr: string } { - const chunkFilenameExpr = `process.argv[1].replace(/[^\\\\/]*$/, ${JSON.stringify(relativeName)})`; + const chunkFilenameExpr = `process.argv[1].replace(/[^\\\\/]*$/, () => ${JSON.stringify(relativeName)})`; const urlExpr = `(() => { const u = new URL("file:///"); u.pathname = ${chunkFilenameExpr}.replace(/\\\\/g, "/"); return u.href; })()`; return { chunkFilenameExpr, urlExpr }; } diff --git a/tools/egg-bundler/test/integration.test.ts b/tools/egg-bundler/test/integration.test.ts index 3894ca979d..c5ceee80c0 100644 --- a/tools/egg-bundler/test/integration.test.ts +++ b/tools/egg-bundler/test/integration.test.ts @@ -213,7 +213,7 @@ globalThis.__patchedMeta = { const buildFunc: BuildFunc = async () => { await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// mock worker entry\n'); await fs.mkdir(path.join(tmpOutput, 'chunks'), { recursive: true }); - await fs.writeFile(path.join(tmpOutput, 'chunks/chunk #?.js'), throwingMeta); + await fs.writeFile(path.join(tmpOutput, 'chunks/chunk $1 #?.js'), throwingMeta); await fs.writeFile(path.join(tmpOutput, 'chunks/url-only.js'), urlOnlyMeta); }; @@ -245,7 +245,7 @@ globalThis.__patchedMeta = { return u.href; } - const nestedFilename = path.join(tmpOutput, 'chunks/chunk #?.js'); + const nestedFilename = path.join(tmpOutput, 'chunks/chunk $1 #?.js'); const nestedMeta = await runPatchedChunk(nestedFilename); expect(nestedMeta).toEqual({ url: expectedFileUrl(nestedFilename),