diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md index 44e775fcf1..5a7e419db0 100644 --- a/tools/egg-bundler/docs/output-structure.md +++ b/tools/egg-bundler/docs/output-structure.md @@ -16,7 +16,7 @@ the chunks. ├── _turbopack__runtime.js.map ├── tsconfig.json # written by PackRunner; SWC reads decorator options from here ├── package.json # written by PackRunner; `{ "type": "commonjs" }` so node parses *.js as CJS -└── bundle-manifest.json # written by Bundler; reference / debug metadata +└── bundle-manifest.json # written by Bundler; runtime metadata plus debug metadata ``` Chunk filenames prefixed with `_turbopack__` or `_root-of-the-server___` come @@ -32,14 +32,20 @@ node worker.js The worker entry installs `ManifestStore.setBundleStore(...)` and `globalThis.__EGG_BUNDLE_MODULE_LOADER__` before calling -`startEgg({ baseDir, mode: 'single' })`, so framework module resolution for -bundled files is served from the inlined bundle map, avoiding `fs.readdir` for -bundled framework file discovery. Application code and plugins may still use -`fs` for resources such as config, views, or assets. +`startEgg({ baseDir, framework, mode: 'single' })`. The worker reads +`bundle-manifest.json` at startup to recover the original app `baseDir` and +configured `framework`, while still using its own output directory to locate the +bundle artifact. Framework module resolution for bundled files is served from +the inlined bundle map, avoiding `fs.readdir` for bundled framework file +discovery. Application code and plugins may still use `fs` for resources such +as config, views, or assets. ## `bundle-manifest.json` -A reference file produced by `Bundler` (not consumed at runtime). Shape: +A runtime metadata file produced by `Bundler`. The worker reads `baseDir` and +`framework` from this file during startup, so deployment must keep it next to +`worker.js`; a missing, unreadable, or malformed manifest is a startup error. +The remaining fields are reference / debug metadata. Shape: ```json { diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index 0f65222ae0..704d604fbd 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -112,7 +112,6 @@ export class Bundler { const entryGen = new EntryGenerator({ baseDir: absBaseDir, manifestLoader, - framework, externals: new Set(Object.keys(externalsMap)), }); const entries = await wrapStep('entry generation', () => entryGen.generate()); diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index 149c970e4e..b463ff9568 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -14,7 +14,6 @@ export interface EntryGeneratorOptions { baseDir: string; manifestLoader: ManifestLoader; outputDir?: string; - framework?: string; externals?: ReadonlySet; } @@ -47,14 +46,12 @@ export class EntryGenerator { readonly #baseDir: string; readonly #loader: ManifestLoader; readonly #outputDir: string; - readonly #framework: string; readonly #externals: ReadonlySet; constructor(options: EntryGeneratorOptions) { this.#baseDir = options.baseDir; this.#loader = options.manifestLoader; this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries'); - this.#framework = options.framework ?? 'egg'; this.#externals = options.externals ?? new Set(); } @@ -194,7 +191,6 @@ export class EntryGenerator { } const manifestJson = JSON.stringify(manifest, null, 2); - const frameworkSpec = JSON.stringify(this.#toFrameworkImportSpecifier()); const externalBlock = externalSpecs.length > 0 @@ -202,7 +198,7 @@ export class EntryGenerator { // External-package files: loaded at runtime via require(), not bundled. // Uses createRequire + dynamic specifiers so @utoo/pack cannot trace them. import { createRequire as __createRequire } from 'node:module'; -const __rtReq = __createRequire(path.join(__baseDir, 'package.json')); +const __rtReq = __createRequire(path.join(__appBaseDir, 'package.json')); const __EXTERNAL_SPECS: Array<[string, string]> = ${JSON.stringify(externalSpecs)}; for (const [key, spec] of __EXTERNAL_SPECS) { __BUNDLE_MAP_REL[key] = __rtReq(spec); @@ -212,17 +208,39 @@ for (const [key, spec] of __EXTERNAL_SPECS) { return `// ⚠️ auto-generated by @eggjs/egg-bundler — do not edit /* eslint-disable */ +import fs from 'node:fs'; import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; -import { startEgg } from ${frameworkSpec}; +import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"; ${importLines.join('\n')} // Derive the runtime output directory from the entry file being executed. // Cannot use __dirname because turbopack replaces it with the compile-time // path of the INPUT file, not the OUTPUT directory. -const __baseDir = path.dirname(path.resolve(process.argv[1] || '.')); +const __outputDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd(); +type __BundleManifest = { baseDir: string; framework: string }; +function __readBundleManifest(): __BundleManifest { + const manifestPath = path.join(__outputDir, 'bundle-manifest.json'); + let data: Record | null; + try { + data = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record | null; + } catch (error) { + throw new Error(\`[egg-bundler] failed to read \${manifestPath}: \${String(error)}\`); + } + if (!data || typeof data !== 'object' || typeof data.baseDir !== 'string' || typeof data.framework !== 'string') { + throw new Error(\`[egg-bundler] invalid bundle-manifest.json: \${manifestPath}\`); + } + return { + baseDir: data.baseDir, + framework: data.framework, + }; +} + +const __bundleManifest = __readBundleManifest(); +const __appBaseDir = path.resolve(__outputDir, __bundleManifest.baseDir); +const __framework = __bundleManifest.framework; const MANIFEST_DATA = ${manifestJson} as const; @@ -231,23 +249,44 @@ ${mapLines.join('\n')} }; ${externalBlock} const __BUNDLE_MAP: Record = {}; +function __toMapKey(filepath: string): string { + return filepath.split(path.win32.sep).join(path.posix.sep); +} + +function __setBundleMapAlias(key: string | undefined, mod: unknown): void { + if (!key) return; + __BUNDLE_MAP[__toMapKey(key)] = mod; +} + +if (__framework === 'egg') { + __setBundleMapAlias(__framework, { Agent: __EggAgent, Application: __EggApplication }); +} + for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { - const abs = path.resolve(__baseDir, rel).split(path.sep).join('/'); - __BUNDLE_MAP[abs] = mod; - // Also key by posix join so callers that already hand us posix paths hit. - __BUNDLE_MAP[rel] = mod; + __setBundleMapAlias(rel, mod); + __setBundleMapAlias(path.resolve(__appBaseDir, rel), mod); + __setBundleMapAlias(path.resolve(__outputDir, rel), mod); +} + +for (const [requestRel, resolvedRel] of Object.entries(MANIFEST_DATA.resolveCache ?? {})) { + if (!resolvedRel) continue; + const mod = __BUNDLE_MAP[__toMapKey(resolvedRel)]; + if (mod === undefined) continue; + __setBundleMapAlias(requestRel, mod); + __setBundleMapAlias(path.resolve(__appBaseDir, requestRel), mod); + __setBundleMapAlias(path.resolve(__outputDir, requestRel), mod); } const __bundleGlobalThis = globalThis as typeof globalThis & { __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; }; -ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); +ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __appBaseDir)); __bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { - const key = filepath.split(path.sep).join('/'); + const key = __toMapKey(filepath); return __BUNDLE_MAP[key]; }; -startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { +startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' }).then((app) => { const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; app.listen(port, () => { // eslint-disable-next-line no-console @@ -261,46 +300,6 @@ startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { `; } - #toFrameworkImportSpecifier(): string { - if (!path.isAbsolute(this.#framework)) return this.#framework; - const packageName = this.#packageNameFromDir(this.#framework); - if (packageName && this.#canUseFrameworkPackageName(packageName, this.#framework)) { - return packageName; - } - return this.#toImportSpecifier(this.#framework); - } - - #packageNameFromDir(dir: string): string | undefined { - try { - const req = createRequire(path.join(dir, 'package.json')); - const pkg = req(path.join(dir, 'package.json')) as { name?: unknown }; - return typeof pkg.name === 'string' && pkg.name ? pkg.name : undefined; - } catch { - return undefined; - } - } - - #canUseFrameworkPackageName(packageName: string, dir: string): boolean { - if (this.#isInsideDir(path.join(this.#baseDir, 'node_modules'), dir)) return true; - - try { - const req = createRequire(path.join(this.#baseDir, 'package.json')); - const resolvedPackageJson = req.resolve(`${packageName}/package.json`); - return this.#samePath(path.dirname(resolvedPackageJson), dir); - } catch { - return false; - } - } - - #isInsideDir(parent: string, dir: string): boolean { - const rel = path.relative(path.resolve(parent), path.resolve(dir)); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } - - #samePath(left: string, right: string): boolean { - return path.resolve(left) === path.resolve(right); - } - #toImportSpecifier(absPath: string): string { // Prefer a relative specifier from the entry output dir to keep the // bundled paths portable across machines (absolute paths would leak diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index d332d1e5e8..5a4d64f27a 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -171,15 +171,22 @@ describe('EntryGenerator', () => { const worker = await fs.readFile(result.workerEntry, 'utf8'); expect(worker).toContain("import { ManifestStore } from '@eggjs/core'"); - expect(worker).toContain('import { startEgg } from "egg"'); + expect(worker).toContain('import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"'); + expect(worker).toContain( + 'const __outputDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd()', + ); + expect(worker).toContain('const __appBaseDir = path.resolve(__outputDir, __bundleManifest.baseDir)'); + expect(worker).toContain('const __framework = __bundleManifest.framework'); expect(worker).toContain('ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA'); expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); - expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })"); + expect(worker).toContain('__setBundleMapAlias(__framework, { Agent: __EggAgent, Application: __EggApplication })'); + expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })"); }); - it('builds a BUNDLE_MAP keyed by both the relKey form and the resolved absolute form', async () => { + it('builds a BUNDLE_MAP keyed by relKey, app absolute, output absolute, and resolveCache request aliases', async () => { const manifest = makeManifest({ fileDiscovery: { app: ['controller.ts'] }, + resolveCache: { 'app/controller': 'app/controller.ts' }, }); const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); @@ -188,8 +195,13 @@ describe('EntryGenerator', () => { expect(worker).toContain('__BUNDLE_MAP_REL'); expect(worker).toContain('["app/controller.ts"]: __m0'); - expect(worker).toContain('__BUNDLE_MAP[abs] = mod'); - expect(worker).toContain('__BUNDLE_MAP[rel] = mod'); + expect(worker).toContain('__setBundleMapAlias(rel, mod)'); + expect(worker).toContain('__setBundleMapAlias(path.resolve(__appBaseDir, rel), mod)'); + expect(worker).toContain('__setBundleMapAlias(path.resolve(__outputDir, rel), mod)'); + expect(worker).toContain( + 'for (const [requestRel, resolvedRel] of Object.entries(MANIFEST_DATA.resolveCache ?? {}))', + ); + expect(worker).toContain('__setBundleMapAlias(path.resolve(__appBaseDir, requestRel), mod)'); }); it('loads externalized package files via createRequire instead of static imports', async () => { @@ -242,7 +254,7 @@ describe('EntryGenerator', () => { const worker = await fs.readFile(result.workerEntry, 'utf8'); expect(extractImports(worker).length).toBe(0); - expect(worker).toContain("startEgg({ baseDir: __baseDir, mode: 'single' })"); + expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })"); expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain('ManifestStore.setBundleStore'); }); @@ -270,53 +282,17 @@ describe('EntryGenerator', () => { expect(path.dirname(result.workerEntry)).toBe(customOut); }); - it('honors a custom framework specifier', async () => { - const gen = new EntryGenerator({ - baseDir: tmpDir, - framework: '@my-org/framework', - manifestLoader: createFakeLoader(makeManifest()), - }); - const result = await gen.generate(); - const worker = await fs.readFile(result.workerEntry, 'utf8'); - - expect(worker).toContain('import { startEgg } from "@my-org/framework"'); - expect(worker).not.toContain('import { startEgg } from "egg"'); - }); - - it('uses the package name for an absolute framework directory with package metadata', async () => { - const frameworkDir = path.join(tmpDir, 'node_modules/custom-egg'); - await fs.mkdir(frameworkDir, { recursive: true }); - await fs.writeFile(path.join(frameworkDir, 'package.json'), JSON.stringify({ name: 'custom-egg' })); - - const gen = new EntryGenerator({ - baseDir: tmpDir, - framework: frameworkDir, - manifestLoader: createFakeLoader(makeManifest()), - }); - const result = await gen.generate(); - const worker = await fs.readFile(result.workerEntry, 'utf8'); - - expect(worker).toContain('import { startEgg } from "custom-egg"'); - expect(worker).not.toContain(frameworkDir); - }); - - it('keeps an absolute framework checkout relative when the app cannot resolve its package name', async () => { - const frameworkDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-framework-')); - createdDirs.push(frameworkDir); - await fs.writeFile(path.join(frameworkDir, 'package.json'), JSON.stringify({ name: 'custom-egg' })); - + it('uses framework from bundle-manifest.json at runtime', async () => { const gen = new EntryGenerator({ baseDir: tmpDir, - framework: frameworkDir, manifestLoader: createFakeLoader(makeManifest()), }); const result = await gen.generate(); const worker = await fs.readFile(result.workerEntry, 'utf8'); - const relFramework = path.relative(result.entryDir, frameworkDir).replaceAll(path.sep, '/'); - expect(worker).toContain(`import { startEgg } from "${relFramework}"`); - expect(worker).not.toContain('import { startEgg } from "custom-egg"'); - expect(worker).not.toContain(frameworkDir); + expect(worker).toContain('import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"'); + expect(worker).toContain('const __framework = __bundleManifest.framework'); + expect(worker).toContain("startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' })"); }); it('produces byte-identical worker output across independent baseDir runs (T17 determinism baseline)', async () => { diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 32d77bae95..65b9abc543 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -1,9 +1,10 @@ // ⚠️ auto-generated by @eggjs/egg-bundler — do not edit /* eslint-disable */ +import fs from 'node:fs'; import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; -import { startEgg } from "egg"; +import { Agent as __EggAgent, Application as __EggApplication, startEgg } from "egg"; import * as __m0 from "../../app/controller/home.ts"; import * as __m1 from "../../app/extend/context.ts"; @@ -13,7 +14,28 @@ import * as __m3 from "../../node_modules/@eggjs/fake-module/app/service/UserSer // Derive the runtime output directory from the entry file being executed. // Cannot use __dirname because turbopack replaces it with the compile-time // path of the INPUT file, not the OUTPUT directory. -const __baseDir = path.dirname(path.resolve(process.argv[1] || '.')); +const __outputDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : process.cwd(); +type __BundleManifest = { baseDir: string; framework: string }; +function __readBundleManifest(): __BundleManifest { + const manifestPath = path.join(__outputDir, 'bundle-manifest.json'); + let data: Record | null; + try { + data = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record | null; + } catch (error) { + throw new Error(`[egg-bundler] failed to read ${manifestPath}: ${String(error)}`); + } + if (!data || typeof data !== 'object' || typeof data.baseDir !== 'string' || typeof data.framework !== 'string') { + throw new Error(`[egg-bundler] invalid bundle-manifest.json: ${manifestPath}`); + } + return { + baseDir: data.baseDir, + framework: data.framework, + }; +} + +const __bundleManifest = __readBundleManifest(); +const __appBaseDir = path.resolve(__outputDir, __bundleManifest.baseDir); +const __framework = __bundleManifest.framework; const MANIFEST_DATA = { "version": 1, @@ -59,23 +81,44 @@ const __BUNDLE_MAP_REL: Record = { }; const __BUNDLE_MAP: Record = {}; +function __toMapKey(filepath: string): string { + return filepath.split(path.win32.sep).join(path.posix.sep); +} + +function __setBundleMapAlias(key: string | undefined, mod: unknown): void { + if (!key) return; + __BUNDLE_MAP[__toMapKey(key)] = mod; +} + +if (__framework === 'egg') { + __setBundleMapAlias(__framework, { Agent: __EggAgent, Application: __EggApplication }); +} + for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { - const abs = path.resolve(__baseDir, rel).split(path.sep).join('/'); - __BUNDLE_MAP[abs] = mod; - // Also key by posix join so callers that already hand us posix paths hit. - __BUNDLE_MAP[rel] = mod; + __setBundleMapAlias(rel, mod); + __setBundleMapAlias(path.resolve(__appBaseDir, rel), mod); + __setBundleMapAlias(path.resolve(__outputDir, rel), mod); +} + +for (const [requestRel, resolvedRel] of Object.entries(MANIFEST_DATA.resolveCache ?? {})) { + if (!resolvedRel) continue; + const mod = __BUNDLE_MAP[__toMapKey(resolvedRel)]; + if (mod === undefined) continue; + __setBundleMapAlias(requestRel, mod); + __setBundleMapAlias(path.resolve(__appBaseDir, requestRel), mod); + __setBundleMapAlias(path.resolve(__outputDir, requestRel), mod); } const __bundleGlobalThis = globalThis as typeof globalThis & { __EGG_BUNDLE_MODULE_LOADER__?: (filepath: string) => unknown; }; -ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __baseDir)); +ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA as any, __appBaseDir)); __bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { - const key = filepath.split(path.sep).join('/'); + const key = __toMapKey(filepath); return __BUNDLE_MAP[key]; }; -startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { +startEgg({ baseDir: __appBaseDir, framework: __framework, mode: 'single' }).then((app) => { const port = process.env.PORT || app.config.cluster?.listen?.port || 7001; app.listen(port, () => { // eslint-disable-next-line no-console