Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -130,4 +136,81 @@ 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<number> {
// 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;

// 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 =
/\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 ${urlExpr};
},
get dirname () {
return ${chunkFilenameExpr}.replace(/[\\\\/][^\\\\/]*$/, "");
},
get filename () {
return ${chunkFilenameExpr};
}
};`;
}

let totalPatches = 0;
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);
let patched = content.replace(THROWING_IIFE, urlExpr);

// Pass 2
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 ?? 0) + metaMatches;
debug('patched %d import.meta in %s', (urlMatches?.length ?? 0) + metaMatches, relativeName);
}
return totalPatches;
}
}
79 changes: 77 additions & 2 deletions tools/egg-bundler/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 $1 #?.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 $1 #?.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 () => {
Expand Down