diff --git a/tools/egg-bin/README.md b/tools/egg-bin/README.md index 497341cb93..bb8cd3d5ab 100644 --- a/tools/egg-bin/README.md +++ b/tools/egg-bin/README.md @@ -208,7 +208,9 @@ node worker.js - `--output` / `-o` output directory, default to `./dist-bundle` - `--manifest` path to `manifest.json`, default to `/.egg/manifest.json` -- `--framework` / `-f` framework name or absolute path +- `--framework` / `-f` framework package specifier, defaulting to + `package.json#egg.framework` or `egg`. Absolute framework paths are not + supported by the bundled runtime. - `--mode` build mode, `production` or `development`, default to `production` - `--no-tegg` accepted by the CLI, but not applied by the current bundler implementation yet diff --git a/tools/egg-bin/src/commands/bundle.ts b/tools/egg-bin/src/commands/bundle.ts index 873e899876..8696bfffd0 100644 --- a/tools/egg-bin/src/commands/bundle.ts +++ b/tools/egg-bin/src/commands/bundle.ts @@ -1,7 +1,7 @@ +import fs from 'node:fs/promises'; import path from 'node:path'; import { debuglog } from 'node:util'; -import { getFrameworkPath } from '@eggjs/utils'; import { Flags } from '@oclif/core'; import { BaseCommand } from '../baseCommand.ts'; @@ -35,6 +35,18 @@ function parsePackAliases(values: readonly string[], baseDir: string): Record { + if (framework) return framework; + + const pkgPath = path.join(baseDir, 'package.json'); + const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as { + egg?: { + framework?: unknown; + }; + }; + return typeof pkg.egg?.framework === 'string' && pkg.egg.framework ? pkg.egg.framework : 'egg'; +} + export default class Bundle extends BaseCommand { static override description = 'Bundle an egg app into a deployable artifact using @eggjs/egg-bundler'; @@ -57,7 +69,7 @@ export default class Bundle extends BaseCommand { }), framework: Flags.string({ char: 'f', - description: 'framework name or absolute path', + description: 'framework package specifier', }), mode: Flags.string({ description: 'build mode', @@ -110,7 +122,7 @@ export default class Bundle extends BaseCommand { baseDir, outputDir, manifestPath, - framework: getFrameworkPath({ framework: flags.framework, baseDir }), + framework: await getBundleFrameworkSpecifier(baseDir, flags.framework), mode: getBundleMode(flags.mode), tegg: !flags['no-tegg'], externals: { diff --git a/tools/egg-bin/test/commands/bundle.test.ts b/tools/egg-bin/test/commands/bundle.test.ts index 5b6f79a1ae..f08e6025ab 100644 --- a/tools/egg-bin/test/commands/bundle.test.ts +++ b/tools/egg-bin/test/commands/bundle.test.ts @@ -1,6 +1,5 @@ import path from 'node:path'; -import { getFrameworkPath } from '@eggjs/utils'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import Bundle from '../../src/commands/bundle.ts'; @@ -32,7 +31,7 @@ describe('test/commands/bundle.test.ts', () => { baseDir, outputDir: path.join(baseDir, 'dist-bundle'), manifestPath: undefined, - framework: getFrameworkPath({ baseDir }), + framework: 'aliyun-egg', mode: 'production', tegg: true, externals: { @@ -66,7 +65,7 @@ describe('test/commands/bundle.test.ts', () => { baseDir, outputDir: path.join(baseDir, 'bundle-output'), manifestPath: path.join(baseDir, '.egg/custom-manifest.json'), - framework: getFrameworkPath({ baseDir }), + framework: 'aliyun-egg', mode: 'development', tegg: false, externals: { @@ -91,7 +90,7 @@ describe('test/commands/bundle.test.ts', () => { baseDir, outputDir: path.join(baseDir, 'dist-bundle'), manifestPath: undefined, - framework: getFrameworkPath({ baseDir }), + framework: 'aliyun-egg', mode: 'production', tegg: true, externals: { @@ -108,4 +107,22 @@ describe('test/commands/bundle.test.ts', () => { }, }); }); + + it('should pass framework package specifier without resolving it to an absolute path', async () => { + await Bundle.run(['--base', baseDir, '--framework', '@my-org/framework']); + + expect(bundleMock).toHaveBeenCalledTimes(1); + expect(bundleMock).toHaveBeenCalledWith({ + baseDir, + outputDir: path.join(baseDir, 'dist-bundle'), + manifestPath: undefined, + framework: '@my-org/framework', + mode: 'production', + tegg: true, + externals: { + force: [], + inline: [], + }, + }); + }); }); diff --git a/tools/egg-bundler/README.md b/tools/egg-bundler/README.md index 1643fc300c..9aaefd6b67 100644 --- a/tools/egg-bundler/README.md +++ b/tools/egg-bundler/README.md @@ -57,7 +57,7 @@ not run. | `baseDir` | Application root directory. Required. | | `outputDir` | Output directory for the bundled artifact. Required. | | `manifestPath` | Path to `manifest.json`. Defaults to `/.egg/manifest.json`. | -| `framework` | Framework name or absolute path. Defaults to `egg`. | +| `framework` | Framework package specifier. Defaults to `egg`; absolute paths are unsupported. | | `mode` | Build mode, `production` or `development`. Defaults to `production`. | | `tegg` | Accepted by `BundlerConfig`, but not applied by the current implementation yet. | | `externals.force` | Package names to always keep external. | diff --git a/tools/egg-bundler/docs/output-structure.md b/tools/egg-bundler/docs/output-structure.md index 44e775fcf1..9670d81f1c 100644 --- a/tools/egg-bundler/docs/output-structure.md +++ b/tools/egg-bundler/docs/output-structure.md @@ -32,10 +32,13 @@ 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: outputDir, framework, mode: 'single' })`, so framework +specifier lookup is served by the already imported bundled framework module, +without adding framework path aliases. Runtime lookup keeps +the deploy output directory separate from the original app paths: the bundle map +is keyed by relKey, output-dir absolute paths, precomputed original app absolute +paths, and manifest `resolveCache` request aliases. Application code and plugins +may still use `fs` for resources such as config, views, or assets. ## `bundle-manifest.json` diff --git a/tools/egg-bundler/src/index.ts b/tools/egg-bundler/src/index.ts index 4155a92bca..7a7e198877 100644 --- a/tools/egg-bundler/src/index.ts +++ b/tools/egg-bundler/src/index.ts @@ -37,7 +37,7 @@ export interface BundlerConfig { readonly outputDir: string; /** Path to manifest.json. Defaults to `/.egg/manifest.json`. */ readonly manifestPath?: string; - /** Framework name or absolute path. Defaults to `'egg'`. */ + /** Framework package specifier. Defaults to `'egg'`; absolute framework paths are not supported by bundle runtime. */ readonly framework?: string; /** Build mode. Defaults to `'production'`. */ readonly mode?: 'production' | 'development'; diff --git a/tools/egg-bundler/src/lib/Bundler.ts b/tools/egg-bundler/src/lib/Bundler.ts index f288472501..bd435ea39c 100644 --- a/tools/egg-bundler/src/lib/Bundler.ts +++ b/tools/egg-bundler/src/lib/Bundler.ts @@ -7,6 +7,7 @@ import { load as yamlLoad } from 'js-yaml'; import type { BundlerConfig, BundleResult } from '../index.ts'; import { EntryGenerator } from './EntryGenerator.ts'; import { ExternalsResolver } from './ExternalsResolver.ts'; +import { assertFrameworkPackageSpecifier } from './frameworkSpecifier.ts'; import { ManifestLoader } from './ManifestLoader.ts'; import { PackRunner } from './PackRunner.ts'; @@ -204,6 +205,7 @@ export class Bundler { const absBaseDir = path.resolve(baseDir); const absOutputDir = path.resolve(absBaseDir, rawOutputDir); + assertFrameworkPackageSpecifier(framework); debug('bundle start: baseDir=%s outputDir=%s framework=%s mode=%s', absBaseDir, absOutputDir, framework, mode); const mergedPack = mergePackConfig( await wrapStep('module.yml bundle config load', () => loadModuleBundlePackConfig(absBaseDir)), diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index 149c970e4e..5c7f3fe3d9 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -6,6 +6,7 @@ import { debuglog } from 'node:util'; import type { StartupManifest } from '@eggjs/core'; +import { assertFrameworkPackageSpecifier } from './frameworkSpecifier.ts'; import type { ManifestLoader } from './ManifestLoader.ts'; const debug = debuglog('egg/bundler/entry-generator'); @@ -55,6 +56,7 @@ export class EntryGenerator { this.#loader = options.manifestLoader; this.#outputDir = options.outputDir ?? path.join(options.baseDir, '.egg-bundle', 'entries'); this.#framework = options.framework ?? 'egg'; + assertFrameworkPackageSpecifier(this.#framework); this.#externals = options.externals ?? new Set(); } @@ -176,6 +178,42 @@ export class EntryGenerator { .replaceAll(/\/+/g, '/'); } + #collectResolveCacheAliases(manifest: StartupManifest): Array<[string, string]> { + const aliases: Array<[string, string]> = []; + for (const [requestRel, targetRel] of Object.entries(manifest.resolveCache)) { + if (typeof targetRel !== 'string') continue; + for (const requestAbs of this.#absoluteAliasKeys(requestRel)) { + aliases.push([requestAbs, targetRel]); + } + } + return this.#uniqueAliasPairs(aliases).sort(([left], [right]) => left.localeCompare(right)); + } + + #normalizeKey(filepath: string): string { + return filepath.replaceAll(path.sep, '/'); + } + + #absoluteAliasKeys(relKey: string): string[] { + const keys = new Set(); + keys.add(this.#normalizeKey(this.#absFromRelKey(relKey))); + if (!path.isAbsolute(relKey)) { + keys.add(this.#normalizeKey(path.resolve(this.#baseDir, relKey))); + } + return [...keys]; + } + + #uniqueAliasPairs(pairs: Array<[string, string]>): Array<[string, string]> { + const seen = new Set(); + const unique: Array<[string, string]> = []; + for (const pair of pairs) { + const key = JSON.stringify(pair); + if (seen.has(key)) continue; + seen.add(key); + unique.push(pair); + } + return unique; + } + #renderWorkerEntry(entries: BundleEntry[], manifest: StartupManifest): string { const importLines: string[] = []; const mapLines: string[] = []; @@ -194,7 +232,13 @@ export class EntryGenerator { } const manifestJson = JSON.stringify(manifest, null, 2); - const frameworkSpec = JSON.stringify(this.#toFrameworkImportSpecifier()); + const appAbsoluteAliases = JSON.stringify( + this.#uniqueAliasPairs( + entries.flatMap((entry) => this.#absoluteAliasKeys(entry.relKey).map((abs) => [abs, entry.relKey])), + ), + ); + const appResolveCacheAliases = JSON.stringify(this.#collectResolveCacheAliases(manifest)); + const frameworkSpec = JSON.stringify(this.#framework); const externalBlock = externalSpecs.length > 0 @@ -202,7 +246,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(__outputDir, 'package.json')); const __EXTERNAL_SPECS: Array<[string, string]> = ${JSON.stringify(externalSpecs)}; for (const [key, spec] of __EXTERNAL_SPECS) { __BUNDLE_MAP_REL[key] = __rtReq(spec); @@ -216,38 +260,69 @@ import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; import { startEgg } from ${frameworkSpec}; +import * as __frameworkModule from ${frameworkSpec}; ${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 = path.dirname(path.resolve(process.argv[1] || '.')); +const __framework = ${frameworkSpec}; const MANIFEST_DATA = ${manifestJson} as const; +const __APP_ABSOLUTE_ALIASES: Array<[string, string]> = ${appAbsoluteAliases}; +const __APP_RESOLVE_CACHE_ALIASES: Array<[string, string]> = ${appResolveCacheAliases}; const __BUNDLE_MAP_REL: Record = { ${mapLines.join('\n')} }; ${externalBlock} const __BUNDLE_MAP: Record = {}; +const __normalizeBundleKey = (filepath: string) => filepath.split(path.sep).join('/'); +const __setBundleMap = (filepath: string, mod: unknown) => { + __BUNDLE_MAP[__normalizeBundleKey(filepath)] = mod; +}; +const __getBundleMap = (filepath: string) => __BUNDLE_MAP[__normalizeBundleKey(filepath)]; +const __setBundleAliases = (rel: string, mod: unknown) => { + __setBundleMap(rel, mod); + if (!path.isAbsolute(rel)) { + __setBundleMap(path.resolve(__outputDir, rel), mod); + } +}; +__setBundleMap(__framework, __frameworkModule); 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; + __setBundleAliases(rel, mod); +} +for (const [appAbs, targetRel] of __APP_ABSOLUTE_ALIASES) { + const mod = __getBundleMap(targetRel); + if (mod !== undefined) { + __setBundleMap(appAbs, mod); + } +} +for (const [requestRel, targetRel] of Object.entries(MANIFEST_DATA.resolveCache)) { + if (!targetRel) continue; + const mod = __getBundleMap(targetRel) ?? __getBundleMap(path.resolve(__outputDir, targetRel)); + if (mod !== undefined) { + __setBundleAliases(requestRel, mod); + } +} +for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) { + const mod = __getBundleMap(targetRel); + if (mod !== undefined) { + __setBundleMap(appAbsRequest, 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, __outputDir)); __bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { - const key = filepath.split(path.sep).join('/'); - return __BUNDLE_MAP[key]; + return __getBundleMap(filepath); }; -startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { +startEgg({ baseDir: __outputDir, 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 +336,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/src/lib/frameworkSpecifier.ts b/tools/egg-bundler/src/lib/frameworkSpecifier.ts new file mode 100644 index 0000000000..ec20a0cebd --- /dev/null +++ b/tools/egg-bundler/src/lib/frameworkSpecifier.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +const URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z\d+.-]*:/; +const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/; + +function isPathLikeFrameworkSpecifier(framework: string): boolean { + return ( + framework === '.' || + framework === '..' || + framework.startsWith('./') || + framework.startsWith('../') || + framework.startsWith('.\\') || + framework.startsWith('..\\') || + framework.startsWith('/') || + framework.startsWith('\\') || + framework.includes('\\') || + path.isAbsolute(framework) || + WINDOWS_ABSOLUTE_RE.test(framework) || + URL_SCHEME_RE.test(framework) + ); +} + +export function assertFrameworkPackageSpecifier(framework: string): void { + if (!framework || isPathLikeFrameworkSpecifier(framework)) { + throw new Error( + `[@eggjs/egg-bundler] framework must be a package specifier for bundled runtime, got path-like value: ${framework}`, + ); + } +} diff --git a/tools/egg-bundler/test/Bundler.test.ts b/tools/egg-bundler/test/Bundler.test.ts index 54599a4e4f..2b277df3c4 100644 --- a/tools/egg-bundler/test/Bundler.test.ts +++ b/tools/egg-bundler/test/Bundler.test.ts @@ -98,6 +98,33 @@ describe('Bundler', () => { }); }); + it.each([ + ['absolute path', () => path.join(tmpApp, 'node_modules/custom-egg')], + ['relative path', () => './custom-egg'], + ['parent relative path', () => '../custom-egg'], + ['file URL', () => 'file:///tmp/custom-egg'], + ['Windows absolute path', () => 'C:\\custom-egg'], + ['backslash path', () => 'custom\\egg'], + ])('rejects %s framework values before generating bundle entries', async (_label, frameworkFactory) => { + const framework = frameworkFactory(); + + await expect( + bundle({ + baseDir: tmpApp, + outputDir: tmpOutput, + framework, + pack: { + buildFunc: async () => { + await fs.writeFile(path.join(tmpOutput, 'worker.js'), '// worker\n'); + }, + }, + }), + ).rejects.toThrow('framework must be a package specifier for bundled runtime'); + + expect(mocks.manifestLoaderOptions).toHaveLength(0); + expect(mocks.entryGenerate).not.toHaveBeenCalled(); + }); + it('passes application supplied pack resolve aliases into the pack build config', async () => { let packResolve: unknown; const alias = { diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index d332d1e5e8..ae74cb106c 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import type { StartupManifest } from '@eggjs/core'; +import { execaNode } from 'execa'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { EntryGenerator } from '../src/lib/EntryGenerator.ts'; @@ -39,6 +40,27 @@ function extractImports(workerSource: string): { index: number; specifier: strin })); } +function escapeRegExp(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function toPosixPath(filepath: string): string { + return filepath.split(path.sep).join('/'); +} + +function normalizeAppBaseDir(source: string, baseDir: string): string { + return Array.from(new Set([baseDir, toPosixPath(baseDir)])).reduce( + (result, current) => result.replace(new RegExp(escapeRegExp(current), 'g'), ''), + source, + ); +} + +async function writePackage(dir: string, source: string): Promise { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ type: 'module', exports: './index.js' })); + await fs.writeFile(path.join(dir, 'index.js'), source); +} + describe('EntryGenerator', () => { let tmpDir: string; const createdDirs: string[] = []; @@ -174,12 +196,15 @@ describe('EntryGenerator', () => { expect(worker).toContain('import { startEgg } from "egg"'); 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('__setBundleMap(__framework, __frameworkModule)'); + expect(worker).not.toContain('__frameworkImport'); + expect(worker).toContain("startEgg({ baseDir: __outputDir, 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, output absolute, original app absolute, and resolveCache 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 +213,125 @@ 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('__APP_ABSOLUTE_ALIASES'); + expect(worker).toContain(JSON.stringify(toPosixPath(path.join(tmpDir, 'app/controller.ts')))); + expect(worker).toContain('__APP_RESOLVE_CACHE_ALIASES'); + expect(worker).toContain(JSON.stringify(toPosixPath(path.join(tmpDir, 'app/controller')))); + expect(worker).toContain('__setBundleMap(path.resolve(__outputDir, rel), mod)'); + expect(worker).toContain('for (const [requestRel, targetRel] of Object.entries(MANIFEST_DATA.resolveCache))'); + expect(worker).toContain('__setBundleAliases(requestRel, mod)'); + }); + + it('keeps original node_modules symlink absolute aliases alongside resolved package paths', async () => { + const realPackageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'egg-bundler-real-package-')); + createdDirs.push(realPackageDir); + await fs.writeFile(path.join(realPackageDir, 'package.json'), JSON.stringify({ name: 'fake-plugin' })); + await fs.writeFile(path.join(realPackageDir, 'app.ts'), 'export const plugin = true;\n'); + + const linkDir = path.join(tmpDir, 'node_modules/fake-plugin'); + await fs.mkdir(path.dirname(linkDir), { recursive: true }); + await fs.symlink(realPackageDir, linkDir, process.platform === 'win32' ? 'junction' : 'dir'); + + const manifest = makeManifest({ + fileDiscovery: { 'node_modules/fake-plugin': ['app.ts'] }, + resolveCache: { 'node_modules/fake-plugin/app.ts': 'node_modules/fake-plugin/app.ts' }, + }); + + const gen = new EntryGenerator({ baseDir: tmpDir, manifestLoader: createFakeLoader(manifest) }); + const result = await gen.generate(); + const worker = await fs.readFile(result.workerEntry, 'utf8'); + + expect(worker).toContain(JSON.stringify(toPosixPath(path.join(realPackageDir, 'app.ts')))); + expect(worker).toContain(JSON.stringify(toPosixPath(path.join(tmpDir, 'node_modules/fake-plugin/app.ts')))); + }); + + it('executes the generated worker with explicit framework resolved through the bundle loader', async () => { + const resultFile = path.join(tmpDir, 'runtime-result.json'); + await fs.writeFile(path.join(tmpDir, 'package.json'), JSON.stringify({ type: 'module' })); + await fs.mkdir(path.join(tmpDir, 'app'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'app/controller.ts'), + "export const controllerMarker = 'bundled-controller';\n", + ); + await writePackage( + path.join(tmpDir, 'node_modules/@eggjs/core'), + ` +export const ManifestStore = { + fromBundle(manifest, baseDir) { + return { manifest, baseDir }; + }, + setBundleStore(store) { + globalThis.__manifestStore = store; + }, +}; +`, + ); + await writePackage( + path.join(tmpDir, 'node_modules/@runtime/framework'), + ` +import fs from 'node:fs/promises'; + +export const frameworkMarker = 'bundled-framework'; + +export async function startEgg(options) { + const loader = globalThis.__EGG_BUNDLE_MODULE_LOADER__; + const resolvedFramework = loader?.(options.framework); + const resolvedController = loader?.('app/controller.ts'); + await fs.writeFile(process.env.EGG_RUNTIME_RESULT, JSON.stringify({ + options, + manifestBaseDir: globalThis.__manifestStore?.baseDir, + frameworkResolved: resolvedFramework?.frameworkMarker, + frameworkStartEggMatches: resolvedFramework?.startEgg === startEgg, + controllerResolved: resolvedController?.controllerMarker, + }, null, 2)); + return { + config: { cluster: { listen: { port: 0 } } }, + listen(_port, callback) { + callback(); + }, + }; +} +`, + ); + + const manifest = makeManifest({ + fileDiscovery: { app: ['controller.ts'] }, + }); + const outputDir = path.join(tmpDir, 'dist'); + const gen = new EntryGenerator({ + baseDir: tmpDir, + outputDir, + framework: '@runtime/framework', + manifestLoader: createFakeLoader(manifest), + }); + const result = await gen.generate(); + + await execaNode(result.workerEntry, [], { + cwd: tmpDir, + env: { + ...process.env, + EGG_RUNTIME_RESULT: resultFile, + }, + nodeOptions: ['--experimental-strip-types'], + timeout: 5_000, + }); + + const runtimeResult = JSON.parse(await fs.readFile(resultFile, 'utf8')) as { + options: { baseDir: string; framework: string; mode: string }; + manifestBaseDir: string; + frameworkResolved: string; + frameworkStartEggMatches: boolean; + controllerResolved: string; + }; + expect(runtimeResult.options).toEqual({ + baseDir: outputDir, + framework: '@runtime/framework', + mode: 'single', + }); + expect(runtimeResult.manifestBaseDir).toBe(outputDir); + expect(runtimeResult.frameworkResolved).toBe('bundled-framework'); + expect(runtimeResult.frameworkStartEggMatches).toBe(true); + expect(runtimeResult.controllerResolved).toBe('bundled-controller'); }); it('loads externalized package files via createRequire instead of static imports', async () => { @@ -242,7 +384,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: __outputDir, framework: __framework, mode: 'single' })"); expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain('ManifestStore.setBundleStore'); }); @@ -280,46 +422,30 @@ describe('EntryGenerator', () => { const worker = await fs.readFile(result.workerEntry, 'utf8'); expect(worker).toContain('import { startEgg } from "@my-org/framework"'); + expect(worker).toContain('import * as __frameworkModule from "@my-org/framework"'); + expect(worker).toContain('const __framework = "@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' })); - - 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); + it.each([ + ['absolute path', () => path.join(tmpDir, 'node_modules/custom-egg')], + ['relative path', () => './custom-egg'], + ['parent relative path', () => '../custom-egg'], + ['file URL', () => 'file:///tmp/custom-egg'], + ['Windows absolute path', () => 'C:\\custom-egg'], + ['backslash path', () => 'custom\\egg'], + ])('rejects %s framework values because bundled runtime resolves by specifier', (_label, frameworkFactory) => { + expect( + () => + new EntryGenerator({ + baseDir: tmpDir, + framework: frameworkFactory(), + manifestLoader: createFakeLoader(makeManifest()), + }), + ).toThrow('framework must be a package specifier for bundled runtime'); }); - it('produces byte-identical worker output across independent baseDir runs (T17 determinism baseline)', async () => { + it('keeps the module graph deterministic apart from original app absolute aliases', async () => { const manifest = makeManifest({ extensions: { tegg: { @@ -344,7 +470,7 @@ describe('EntryGenerator', () => { }).generate(); const secondWorker = await fs.readFile(second.workerEntry, 'utf8'); - expect(firstWorker).toBe(secondWorker); + expect(normalizeAppBaseDir(firstWorker, tmpDir)).toBe(normalizeAppBaseDir(secondWorker, tmpDir2)); }); it('matches the canonical file snapshot for a representative manifest', async () => { @@ -373,6 +499,7 @@ describe('EntryGenerator', () => { const result = await gen.generate(); const worker = await fs.readFile(result.workerEntry, 'utf8'); - await expect(worker).toMatchFileSnapshot('./__snapshots__/EntryGenerator.worker.canonical.snap'); + const stableWorker = normalizeAppBaseDir(worker, tmpDir); + await expect(stableWorker).toMatchFileSnapshot('./__snapshots__/EntryGenerator.worker.canonical.snap'); }); }); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 32d77bae95..d2092a39ee 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -4,6 +4,7 @@ import path from 'node:path'; import { ManifestStore } from '@eggjs/core'; import { startEgg } from "egg"; +import * as __frameworkModule from "egg"; import * as __m0 from "../../app/controller/home.ts"; import * as __m1 from "../../app/extend/context.ts"; @@ -13,7 +14,8 @@ 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 = path.dirname(path.resolve(process.argv[1] || '.')); +const __framework = "egg"; const MANIFEST_DATA = { "version": 1, @@ -50,6 +52,8 @@ const MANIFEST_DATA = { ] } } as const; +const __APP_ABSOLUTE_ALIASES: Array<[string, string]> = [["/app/controller/home.ts","app/controller/home.ts"],["/app/extend/context.ts","app/extend/context.ts"],["/app/service/user.ts","app/service/user.ts"],["/node_modules/@eggjs/fake-module/app/service/UserService.ts","node_modules/@eggjs/fake-module/app/service/UserService.ts"]]; +const __APP_RESOLVE_CACHE_ALIASES: Array<[string, string]> = [["/app/extend/context.ts","app/extend/context.ts"]]; const __BUNDLE_MAP_REL: Record = { ["app/controller/home.ts"]: __m0, @@ -59,23 +63,50 @@ const __BUNDLE_MAP_REL: Record = { }; const __BUNDLE_MAP: Record = {}; +const __normalizeBundleKey = (filepath: string) => filepath.split(path.sep).join('/'); +const __setBundleMap = (filepath: string, mod: unknown) => { + __BUNDLE_MAP[__normalizeBundleKey(filepath)] = mod; +}; +const __getBundleMap = (filepath: string) => __BUNDLE_MAP[__normalizeBundleKey(filepath)]; +const __setBundleAliases = (rel: string, mod: unknown) => { + __setBundleMap(rel, mod); + if (!path.isAbsolute(rel)) { + __setBundleMap(path.resolve(__outputDir, rel), mod); + } +}; +__setBundleMap(__framework, __frameworkModule); 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; + __setBundleAliases(rel, mod); +} +for (const [appAbs, targetRel] of __APP_ABSOLUTE_ALIASES) { + const mod = __getBundleMap(targetRel); + if (mod !== undefined) { + __setBundleMap(appAbs, mod); + } +} +for (const [requestRel, targetRel] of Object.entries(MANIFEST_DATA.resolveCache)) { + if (!targetRel) continue; + const mod = __getBundleMap(targetRel) ?? __getBundleMap(path.resolve(__outputDir, targetRel)); + if (mod !== undefined) { + __setBundleAliases(requestRel, mod); + } +} +for (const [appAbsRequest, targetRel] of __APP_RESOLVE_CACHE_ALIASES) { + const mod = __getBundleMap(targetRel); + if (mod !== undefined) { + __setBundleMap(appAbsRequest, 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, __outputDir)); __bundleGlobalThis.__EGG_BUNDLE_MODULE_LOADER__ = (filepath) => { - const key = filepath.split(path.sep).join('/'); - return __BUNDLE_MAP[key]; + return __getBundleMap(filepath); }; -startEgg({ baseDir: __baseDir, mode: 'single' }).then((app) => { +startEgg({ baseDir: __outputDir, 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 diff --git a/tools/egg-bundler/test/deterministic.test.ts b/tools/egg-bundler/test/deterministic.test.ts index 5325c4318f..d88d7013eb 100644 --- a/tools/egg-bundler/test/deterministic.test.ts +++ b/tools/egg-bundler/test/deterministic.test.ts @@ -15,6 +15,9 @@ const FIXTURE_SOURCE = path.join(__dirname, 'fixtures/apps/minimal-app'); // artifacts, modulo documented exceptions: // - bundle-manifest.json.generatedAt is always `new Date().toISOString()` // - bundle-manifest.json.baseDir reflects the caller's baseDir input +// - worker.entry.ts embeds concrete original app absolute aliases so runtime +// lookups can serve original absolute paths; comparisons normalize those +// aliases by baseDir // // Determinism sources exercised: // * EntryGenerator sorts fileDiscovery / resolveCache / tegg decoratedFiles @@ -33,6 +36,9 @@ const FIXTURE_SOURCE = path.join(__dirname, 'fixtures/apps/minimal-app'); // shared fixture output. // // Real @utoo/pack determinism is a separate concern, deferred to T16/T20. +// The mock build still copies the generated worker entry into worker.js so +// produced artifact checks cover the runtime alias strings emitted by +// EntryGenerator. const FIXTURE_MANIFEST = { version: 1, @@ -76,13 +82,22 @@ async function cloneFixture(destParent: string): Promise { return dest; } -// The mock build must be deterministic itself — two invocations must write -// identical content. Otherwise we'd be measuring pack variance, not bundler -// variance. Every byte is hard-coded. +interface MockPackConfig { + entry?: Array<{ name: string; import: string }>; +} + +// The mock build must be deterministic itself. It copies the generated worker +// entry into worker.js to exercise shipped-runtime content, while all support +// chunks stay hard-coded so we measure bundler variance rather than pack +// variance. function makeDeterministicMockBuild(outputDir: string): BuildFunc { - return async () => { + return async ({ config }) => { + const packConfig = config as MockPackConfig; + const workerEntry = packConfig.entry?.find((entry) => entry.name === 'worker')?.import; + if (!workerEntry) throw new Error('worker entry is missing from mock pack config'); + const workerSource = await fs.readFile(workerEntry, 'utf8'); const artifacts: Array<[string, string]> = [ - ['worker.js', '// deterministic worker chunk\nmodule.exports = { marker: "worker" };\n'], + ['worker.js', workerSource], ['worker.js.map', '{"version":3,"sources":[],"mappings":""}'], ['_turbopack__runtime.js', '// deterministic runtime shim\n'], ['_turbopack__runtime.js.map', '{}'], @@ -101,14 +116,40 @@ async function sha256(filepath: string): Promise { .digest('hex'); } +function sha256Content(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + function outputRel(outputDir: string, filepath: string): string { return path.relative(outputDir, filepath); } -async function hashByOutputRel(files: readonly string[], outputDir: string): Promise> { +function normalizeWorkerAppBaseDir(source: string, baseDir: string): string { + const posixBaseDir = baseDir.split(path.sep).join('/'); + return Array.from(new Set([baseDir, posixBaseDir])).reduce( + (result, current) => result.split(current).join(''), + source, + ); +} + +function hasEmbeddedBaseDir(source: string, baseDir: string): boolean { + const posixBaseDir = baseDir.split(path.sep).join('/'); + return source.includes(baseDir) || source.includes(posixBaseDir); +} + +async function hashByOutputRel( + files: readonly string[], + outputDir: string, + options: { normalizeAppBaseDir?: string } = {}, +): Promise> { const hashes: Record = {}; for (const f of files) { - hashes[outputRel(outputDir, f)] = await sha256(f); + const rel = outputRel(outputDir, f); + if (rel === 'worker.js' && options.normalizeAppBaseDir) { + hashes[rel] = sha256Content(normalizeWorkerAppBaseDir(await fs.readFile(f, 'utf8'), options.normalizeAppBaseDir)); + } else { + hashes[rel] = await sha256(f); + } } return hashes; } @@ -195,11 +236,12 @@ describe('bundle() is deterministic (T17)', () => { expect(bmA).toEqual(bmB); }); - it('different baseDir clones produce byte-identical worker.entry.ts (relative specifier guarantee)', async () => { + it('different baseDir clones produce the same worker runtime except for original app absolute aliases', async () => { // Two independent workspace clones, bundled to each own output. Because // EntryGenerator emits relative specifiers (`../../app/...`) keyed on - // manifest relKeys, the two entry files must be byte-identical even - // though the absolute baseDirs differ entirely. + // manifest relKeys, the module graph stays identical. The original app + // absolute aliases intentionally differ so bundled importModule() can still + // serve original absolute-path lookups. const { baseDir: baseDirA, outputDir: outA } = await makeWorkspace('diff-a'); const { baseDir: baseDirB, outputDir: outB } = await makeWorkspace('diff-b'); expect(baseDirA).not.toBe(baseDirB); @@ -217,17 +259,24 @@ describe('bundle() is deterministic (T17)', () => { const entryA = await fs.readFile(path.join(baseDirA, '.egg-bundle', 'entries', 'worker.entry.ts'), 'utf8'); const entryB = await fs.readFile(path.join(baseDirB, '.egg-bundle', 'entries', 'worker.entry.ts'), 'utf8'); - expect(entryA).toBe(entryB); - // Sanity: the entry must actually NOT contain either absolute baseDir, - // otherwise byte-equality would be accidental. - expect(entryA).not.toContain(baseDirA); - expect(entryA).not.toContain(baseDirB); + expect(normalizeWorkerAppBaseDir(entryA, baseDirA)).toBe(normalizeWorkerAppBaseDir(entryB, baseDirB)); + // Sanity: only the owning app absolute aliases are embedded. + expect(hasEmbeddedBaseDir(entryA, baseDirA)).toBe(true); + expect(hasEmbeddedBaseDir(entryA, baseDirB)).toBe(false); + expect(hasEmbeddedBaseDir(entryB, baseDirB)).toBe(true); + expect(hasEmbeddedBaseDir(entryB, baseDirA)).toBe(false); + const workerA = await fs.readFile(path.join(outA, 'worker.js'), 'utf8'); + const workerB = await fs.readFile(path.join(outB, 'worker.js'), 'utf8'); + expect(normalizeWorkerAppBaseDir(workerA, baseDirA)).toBe(normalizeWorkerAppBaseDir(workerB, baseDirB)); + expect(hasEmbeddedBaseDir(workerA, baseDirA)).toBe(true); + expect(hasEmbeddedBaseDir(workerB, baseDirB)).toBe(true); // Same for every produced artifact — everything in outA should be - // byte-identical to its outB counterpart, including nested files. + // identical to its outB counterpart after normalizing the documented + // original-app absolute aliases in the packed worker runtime. const drift: string[] = []; - const hashesA = await hashByOutputRel(resultA.files, outA); - const hashesB = await hashByOutputRel(resultB.files, outB); + const hashesA = await hashByOutputRel(resultA.files, outA, { normalizeAppBaseDir: baseDirA }); + const hashesB = await hashByOutputRel(resultB.files, outB, { normalizeAppBaseDir: baseDirB }); const namesInA = Object.keys(hashesA).sort(); const namesInB = Object.keys(hashesB).sort(); expect(namesInA).toEqual(namesInB); diff --git a/wiki/log.md b/wiki/log.md index 62d9f04732..9f8f659759 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,11 @@ # Wiki Log +## [2026-05-03] package | record egg bundler runtime path mapping + +- sources touched: `tools/egg-bundler/src/lib/EntryGenerator.ts`, `tools/egg-bundler/docs/output-structure.md` +- pages updated: `wiki/log.md`, `wiki/packages/egg-bundler.md` +- note: Documented that generated workers keep runtime outputDir separate from original app paths and key bundle module lookup by relKey, output absolute path, precomputed original app absolute path, and manifest resolveCache aliases. + ## [2026-05-03] package | refine egg bundler docs - sources touched: `tools/egg-bundler/src/lib/ManifestLoader.ts`, `tools/egg-bundler/src/lib/ExternalsResolver.ts`, `packages/core/src/lifecycle.ts`, `packages/egg/src/lib/start.ts` diff --git a/wiki/packages/egg-bundler.md b/wiki/packages/egg-bundler.md index c1c8bff37f..1ecb8ccd97 100644 --- a/wiki/packages/egg-bundler.md +++ b/wiki/packages/egg-bundler.md @@ -5,6 +5,7 @@ summary: Bundles Egg applications into deployable CommonJS artifacts and powers source_files: - tools/egg-bundler/src/index.ts - tools/egg-bundler/src/lib/Bundler.ts + - tools/egg-bundler/src/lib/EntryGenerator.ts - tools/egg-bin/src/commands/bundle.ts - tools/egg-bundler/docs/output-structure.md updated_at: 2026-05-03 @@ -43,7 +44,12 @@ CommonJS artifact from an Egg application. lifecycle, runs `loadMetadata()` hooks, and the manifest generation child process exits after writing the manifest, so registered `beforeClose` hooks do not run. -- The generated app runs in Egg single-process mode. +- The generated app runs in Egg single-process mode. Its worker entry treats the + deploy output directory as the runtime Egg `baseDir`, passes the framework + specifier explicitly to `startEgg`, maps that specifier to the already bundled + framework module, and precomputes original app absolute aliases so bundled + module lookup can serve relKeys, output-dir absolute paths, original app + absolute paths, and manifest `resolveCache` request aliases. - Explicit `externals.force` entries are external, and `ExternalsResolver` auto-detects root `peerDependencies`, root `optionalDependencies`, root dependency packages with native addons, root dependency packages whose optional