diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index f4add29cf3..3d2cd4c37e 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -27,6 +27,7 @@ import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_ import { ManifestStore, type StartupManifest } from './manifest.ts'; const debug = debuglog('egg/core/loader/egg_loader'); +const LOADER_MANIFEST_EXTENSION = 'eggLoader'; const CONVENTIONAL_MANIFEST_LOADS = [ { type: 'resolve', path: ['agent'] }, { type: 'resolve', path: ['app'] }, @@ -66,6 +67,20 @@ export interface EggDirInfo { type: EggDirInfoType; } +interface LoaderManifestPluginInfo { + path?: string; + package?: string; + dependencies?: string[]; + optionalDependencies?: string[]; + env?: string[]; + version?: string; +} + +interface LoaderManifestExtension { + eggPaths?: string[]; + plugins?: Record; +} + export class EggLoader { #requiredCount = 0; readonly options: EggLoaderOptions; @@ -379,6 +394,20 @@ export class EggLoader { eggPaths.unshift(realpath); } } + + const bundleStore = ManifestStore.getBundleStore(); + const extension = + bundleStore?.baseDir === this.options.baseDir + ? (bundleStore.getExtension(LOADER_MANIFEST_EXTENSION) as LoaderManifestExtension | undefined) + : undefined; + if (extension?.eggPaths?.length) { + return Array.from( + new Set([ + ...extension.eggPaths.map((eggPath) => this.#toManifestAbsolute(eggPath)), + ...eggPaths.filter((eggPath) => !this.#isBundleOutputRootPath(eggPath)), + ]), + ); + } return eggPaths; } @@ -450,6 +479,7 @@ export class EggLoader { this.#extendPlugins(this.allPlugins, this.eggPlugins); this.#extendPlugins(this.allPlugins, this.appPlugins); this.#extendPlugins(this.allPlugins, this.customPlugins); + this.#applyManifestPluginInfo(this.allPlugins); const enabledPluginNames: string[] = []; // enabled plugins that configured explicitly const plugins: Record = {}; @@ -498,6 +528,7 @@ export class EggLoader { * @since 1.0.0 */ this.plugins = enablePlugins; + this.#collectLoaderManifestExtension(); this.timing.end('Load Plugin'); } @@ -924,6 +955,78 @@ export class EggLoader { } } } + + #applyManifestPluginInfo(allPlugins: Record): void { + // getEggPaths reads ManifestStore.getBundleStore in the constructor before this.manifest is assigned. + const extension = this.manifest.getExtension(LOADER_MANIFEST_EXTENSION) as LoaderManifestExtension | undefined; + const plugins = extension?.plugins; + if (!plugins) return; + + for (const [name, manifestPlugin] of Object.entries(plugins)) { + const plugin = allPlugins[name]; + if (!plugin) continue; + + if (manifestPlugin.path && (!plugin.path || this.#isBundleOutputRootPath(plugin.path))) { + plugin.path = this.#toManifestAbsolute(manifestPlugin.path); + } + if (manifestPlugin.package && !plugin.package) { + plugin.package = manifestPlugin.package; + } + for (const key of ['dependencies', 'optionalDependencies', 'env'] as const) { + const values = manifestPlugin[key]; + if (Array.isArray(values) && !plugin[key]?.length) { + plugin[key] = [...values]; + } + } + if (manifestPlugin.version && !plugin.version) { + plugin.version = manifestPlugin.version; + } + } + } + + #collectLoaderManifestExtension(): void { + if (this.manifest === ManifestStore.getBundleStore()) return; + + const plugins: Record = {}; + for (const [name, plugin] of Object.entries(this.allPlugins)) { + plugins[name] = { + path: plugin.path ? this.#toManifestRelative(plugin.path) : undefined, + package: plugin.package, + dependencies: plugin.dependencies ? [...plugin.dependencies] : undefined, + optionalDependencies: plugin.optionalDependencies ? [...plugin.optionalDependencies] : undefined, + env: plugin.env ? [...plugin.env] : undefined, + version: plugin.version, + }; + } + this.manifest.setExtension(LOADER_MANIFEST_EXTENSION, { + eggPaths: this.eggPaths.map((eggPath) => this.#toManifestRelative(eggPath)), + plugins, + } satisfies LoaderManifestExtension); + } + + #toManifestAbsolute(filepath: string): string { + return path.isAbsolute(filepath) ? filepath : path.join(this.options.baseDir, filepath); + } + + #toManifestRelative(filepath: string): string { + return path.isAbsolute(filepath) + ? path.relative(this.options.baseDir, filepath).replaceAll(path.sep, '/') + : filepath; + } + + #toRealpath(filepath: string): string { + try { + return fs.realpathSync(filepath); + } catch { + return filepath; + } + } + + #isBundleOutputRootPath(filepath: string): boolean { + const resolvedBaseDir = path.resolve(this.options.baseDir); + const resolvedFilepath = path.resolve(this.options.baseDir, filepath); + return this.#toRealpath(resolvedFilepath) === this.#toRealpath(resolvedBaseDir); + } /** end Plugin loader */ /** start Config loader */ diff --git a/packages/core/test/loader/mixin/load_plugin.test.ts b/packages/core/test/loader/mixin/load_plugin.test.ts index 49740f20ff..55d399bcf0 100644 --- a/packages/core/test/loader/mixin/load_plugin.test.ts +++ b/packages/core/test/loader/mixin/load_plugin.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { mm } from 'mm'; import { describe, it, afterEach } from 'vitest'; -import { EggCore, EggLoader } from '../../../src/index.js'; +import { EggCore, EggLoader, ManifestStore, type StartupManifest } from '../../../src/index.js'; import { createApp, getFilepath, type Application } from '../../helper.js'; // windows path is case-insensitive, the equal assert will fail @@ -14,6 +14,7 @@ describe.skipIf(process.platform === 'win32')('test/loader/mixin/load_plugin.tes afterEach(async () => { mm.restore(); + ManifestStore.setBundleStore(undefined); if (app) { await app.close(); } @@ -41,6 +42,57 @@ describe.skipIf(process.platform === 'win32')('test/loader/mixin/load_plugin.tes assert(loader.plugins.a.enable); }); + it('should apply bundled loader manifest eggPaths and plugin paths', async () => { + const baseDir = getFilepath('plugin'); + const manifest: StartupManifest = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + invalidation: { + lockfileFingerprint: 'bundle-test', + configFingerprint: 'bundle-test', + serverEnv: 'unittest', + serverScope: '', + typescriptEnabled: true, + }, + extensions: { + eggLoader: { + eggPaths: ['node_modules/egg'], + plugins: { + virtual: { + path: 'node_modules/virtual-plugin', + }, + }, + }, + }, + resolveCache: {}, + fileDiscovery: {}, + }; + const bundleStore = ManifestStore.fromBundle(manifest, baseDir); + ManifestStore.setBundleStore(bundleStore); + + app = createApp('plugin'); + const loader = app.loader; + assert.deepEqual(loader.eggPaths, [path.join(baseDir, 'node_modules/egg'), getFilepath('egg-esm')]); + mm(loader, 'loadEggPlugins', async () => ({ + virtual: { + enable: true, + name: 'virtual', + path: baseDir, + dependencies: [], + optionalDependencies: [], + env: [], + from: path.join(baseDir, 'config/plugin.js'), + }, + })); + mm(loader, 'loadAppPlugins', async () => ({})); + mm(loader, 'loadCustomPlugins', () => ({})); + + await loader.loadPlugin(); + + assert.equal(loader.allPlugins.virtual.path, path.join(baseDir, 'node_modules/virtual-plugin')); + assert.deepEqual(bundleStore.getExtension('eggLoader'), manifest.extensions.eggLoader); + }); + it('should loadConfig all plugins', async () => { const baseDir = getFilepath('plugin'); app = createApp('plugin'); diff --git a/tegg/core/loader/test/LoaderFactoryManifest.test.ts b/tegg/core/loader/test/LoaderFactoryManifest.test.ts index ecb2f4ed38..1a1a84f4d8 100644 --- a/tegg/core/loader/test/LoaderFactoryManifest.test.ts +++ b/tegg/core/loader/test/LoaderFactoryManifest.test.ts @@ -87,4 +87,35 @@ describe('core/loader/test/LoaderFactoryManifest.test.ts', () => { assert.deepStrictEqual(secondNames, firstNames); } }); + + it('should use restored bundled manifest paths before matching module descriptors', async () => { + const baseDir = path.dirname(repoModulePath); + const bundledModulePath = path.relative(baseDir, repoModulePath); + const manifestRef = { name: 'module-for-loader', path: bundledModulePath }; + const manifest: LoadAppManifest = { + moduleDescriptors: [ + { + name: 'module-for-loader', + unitPath: bundledModulePath, + decoratedFiles: [], + }, + ], + }; + const restoredRef = { ...manifestRef, path: path.join(baseDir, manifestRef.path) }; + const restoredManifest: LoadAppManifest = { + moduleDescriptors: manifest.moduleDescriptors.map((desc) => ({ + ...desc, + unitPath: path.join(baseDir, desc.unitPath), + })), + }; + + assert.notEqual(manifestRef.path, repoModulePath); + assert.notEqual(manifest.moduleDescriptors[0].unitPath, repoModulePath); + assert.equal(restoredRef.path, repoModulePath); + assert.equal(restoredManifest.moduleDescriptors[0].unitPath, repoModulePath); + + const manifestDescs = await LoaderFactory.loadApp([restoredRef], restoredManifest); + assert.equal(manifestDescs[0].unitPath, repoModulePath); + assert.deepStrictEqual(manifestDescs[0].clazzList, []); + }); }); diff --git a/tegg/plugin/aop/test/aop.test.ts b/tegg/plugin/aop/test/aop.test.ts index be331d6bb0..a412550e70 100644 --- a/tegg/plugin/aop/test/aop.test.ts +++ b/tegg/plugin/aop/test/aop.test.ts @@ -19,7 +19,7 @@ describe('plugin/aop/test/aop.test.ts', () => { baseDir: path.join(import.meta.dirname, 'fixtures/apps/aop-app'), }); await app.ready(); - }); + }, 30000); it('module aop should work', async () => { app.mockCsrf(); diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 9f2ecdca50..cbb98935ca 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -12,6 +12,23 @@ import { ModuleScanner } from './lib/ModuleScanner.ts'; const debug = debuglog('egg/tegg/plugin/config/app'); +function restoreManifestModulePath(modulePath: string, baseDir: string): string { + return path.isAbsolute(modulePath) ? modulePath : path.join(baseDir, modulePath); +} + +function restoreTeggManifestExtension(manifest: TeggManifestExtension, baseDir: string): TeggManifestExtension { + return { + moduleReferences: (manifest.moduleReferences ?? []).map((ref) => ({ + ...ref, + path: restoreManifestModulePath(ref.path, baseDir), + })), + moduleDescriptors: (manifest.moduleDescriptors ?? []).map((desc) => ({ + ...desc, + unitPath: restoreManifestModulePath(desc.unitPath, baseDir), + })), + }; +} + export default class App implements ILifecycleBoot { private readonly app: Application; @@ -40,7 +57,7 @@ export default class App implements ILifecycleBoot { let moduleReferences: readonly ModuleReference[]; if (manifestTegg?.moduleReferences?.length) { - moduleReferences = manifestTegg.moduleReferences; + moduleReferences = restoreTeggManifestExtension(manifestTegg, this.app.baseDir).moduleReferences; debug('load moduleReferences from manifest: %o', moduleReferences); } else { // Auto-exclude outDir (e.g. dist/) from module scanning to avoid @@ -68,8 +85,11 @@ export default class App implements ILifecycleBoot { #loadModuleConfigs(): void { this.app.moduleConfigs = {}; for (const reference of this.app.moduleReferences) { + const modulePath = path.isAbsolute(reference.path) + ? reference.path + : ModuleConfigUtil.resolveModuleDir(reference.path, this.app.baseDir); const absoluteRef: ModuleReference = { - path: ModuleConfigUtil.resolveModuleDir(reference.path, this.app.baseDir), + path: modulePath, name: reference.name, optional: reference.optional, }; diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index 2381eb28e8..65a99e781f 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -1,6 +1,10 @@ +import path from 'node:path'; + import { mm, type MockApplication } from '@eggjs/mock'; -import { describe, it, afterAll, beforeAll, expect } from 'vitest'; +import { describe, it, afterAll, beforeAll, expect, vi } from 'vitest'; +import AppBootHook from '../src/app.ts'; +import { ModuleScanner } from '../src/lib/ModuleScanner.ts'; import { getFixtures } from './utils.ts'; describe('plugin/config/test/ReadModule.test.ts', () => { @@ -42,4 +46,94 @@ describe('plugin/config/test/ReadModule.test.ts', () => { expect(app.moduleConfigs).toBeDefined(); expect(app.moduleReferences).toBeDefined(); }); + + it('should restore manifest module references against runtime baseDir', async () => { + const baseDir = getFixtures('apps/app-with-modules'); + const fakeApp = { + baseDir, + config: { + tegg: { + readModuleOptions: {}, + }, + }, + loader: { + getTypeFiles() { + return ['module']; + }, + manifest: { + getExtension(key: string) { + if (key !== 'tegg') return undefined; + return { + moduleReferences: [ + { + optional: undefined, + name: 'moduleA', + path: 'app/module-a', + }, + ], + moduleDescriptors: [ + { + name: 'moduleA', + unitPath: 'app/module-a', + decoratedFiles: [], + }, + ], + }; + }, + }, + }, + }; + + await new AppBootHook(fakeApp as any).loadMetadata(); + + const modulePath = path.join(baseDir, 'app/module-a'); + expect((fakeApp as any).moduleReferences).toEqual([ + { + optional: undefined, + name: 'moduleA', + path: modulePath, + }, + ]); + expect((fakeApp as any).moduleConfigs.moduleA.reference.path).toBe(modulePath); + }); + + it('should resolve relative module reference paths from config directory', async () => { + const baseDir = getFixtures('apps/app-with-relative-module'); + const fakeApp = { + baseDir, + config: { + tegg: { + readModuleOptions: {}, + }, + }, + loader: { + getTypeFiles() { + return ['module']; + }, + manifest: { + getExtension() { + return undefined; + }, + }, + }, + }; + const loadModuleReferences = vi.spyOn(ModuleScanner.prototype, 'loadModuleReferences').mockReturnValue([ + { + optional: undefined, + name: 'relativeModule', + path: 'relative-module', + }, + ]); + + try { + await new AppBootHook(fakeApp as any).loadMetadata(); + } finally { + loadModuleReferences.mockRestore(); + } + + const modulePath = path.join(baseDir, 'config/relative-module'); + expect((fakeApp as any).moduleConfigs.relativeModule.reference.path).toBe(modulePath); + expect((fakeApp as any).moduleConfigs.relativeModule.name).toBe('relativeModule'); + expect((fakeApp as any).moduleConfigs.relativeModule.config).toEqual({}); + }); }); diff --git a/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/config/relative-module/package.json b/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/config/relative-module/package.json new file mode 100644 index 0000000000..6b7e314d6d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/config/relative-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "relative-module", + "type": "module", + "eggModule": { + "name": "relativeModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/package.json b/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/package.json new file mode 100644 index 0000000000..8b152afe10 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/apps/app-with-relative-module/package.json @@ -0,0 +1,4 @@ +{ + "name": "app-with-relative-module", + "type": "module" +} diff --git a/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts b/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts index c08352af73..fa9358b26d 100644 --- a/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts +++ b/tegg/plugin/controller/src/lib/ControllerLoadUnitHandler.ts @@ -18,7 +18,7 @@ export class ControllerLoadUnitHandler extends Base { } async _init(): Promise { - const controllerDir = path.join(this.app.config.baseDir, 'app/controller'); + const controllerDir = path.join(this.app.config.baseDir ?? this.app.baseDir, 'app/controller'); const loader = this.app.loaderFactory.createLoader(controllerDir, CONTROLLER_LOAD_UNIT as EggLoadUnitType); this.controllerLoadUnit = await this.app.loadUnitFactory.createLoadUnit( controllerDir, diff --git a/tegg/plugin/controller/test/lib/ControllerLoadUnitHandler.test.ts b/tegg/plugin/controller/test/lib/ControllerLoadUnitHandler.test.ts new file mode 100644 index 0000000000..389a22c365 --- /dev/null +++ b/tegg/plugin/controller/test/lib/ControllerLoadUnitHandler.test.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { CONTROLLER_LOAD_UNIT } from '../../src/lib/ControllerLoadUnit.js'; +import { ControllerLoadUnitHandler } from '../../src/lib/ControllerLoadUnitHandler.js'; + +describe('plugin/controller/test/lib/ControllerLoadUnitHandler.test.ts', () => { + it('should fall back to app.baseDir when config.baseDir is not restored yet', async () => { + const baseDir = '/runtime/app'; + const controllerDir = path.join(baseDir, 'app/controller'); + const loader = {}; + const loadUnit = {}; + const loadUnitInstance = {}; + const calls: string[] = []; + const app = { + baseDir, + config: {}, + loaderFactory: { + createLoader(unitPath: string, type: string) { + calls.push(`loader:${unitPath}:${type}`); + return loader; + }, + }, + loadUnitFactory: { + async createLoadUnit(unitPath: string, type: string, createdLoader: unknown) { + calls.push(`loadUnit:${unitPath}:${type}:${createdLoader === loader}`); + return loadUnit; + }, + }, + loadUnitInstanceFactory: { + async createLoadUnitInstance(createdLoadUnit: unknown) { + calls.push(`instance:${createdLoadUnit === loadUnit}`); + return loadUnitInstance; + }, + }, + }; + + const handler = new ControllerLoadUnitHandler(app as any); + await handler._init(); + + expect(calls).toEqual([ + `loader:${controllerDir}:${CONTROLLER_LOAD_UNIT}`, + `loadUnit:${controllerDir}:${CONTROLLER_LOAD_UNIT}:true`, + 'instance:true', + ]); + expect(handler.controllerLoadUnit).toBe(loadUnit); + expect(handler.controllerLoadUnitInstance).toBe(loadUnitInstance); + }); +}); diff --git a/tegg/plugin/orm/test/index.test.ts b/tegg/plugin/orm/test/index.test.ts index 50b645fc08..1eca2037e9 100644 --- a/tegg/plugin/orm/test/index.test.ts +++ b/tegg/plugin/orm/test/index.test.ts @@ -31,7 +31,7 @@ describe('plugin/orm/test/orm.test.ts', () => { }); await app.ready(); appService = await app.getEggObject(AppService); - }); + }, 30000); afterAll(() => { return app.close(); diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index fc97c39ede..48b5cef69f 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; import { LoaderFactory, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; @@ -7,6 +9,23 @@ import type { Application } from 'egg'; import { EggAppLoader } from './EggAppLoader.ts'; +function restoreManifestModulePath(modulePath: string, baseDir: string): string { + return path.isAbsolute(modulePath) ? modulePath : path.join(baseDir, modulePath); +} + +function restoreTeggManifestExtension(manifest: TeggManifestExtension, baseDir: string): TeggManifestExtension { + return { + moduleReferences: (manifest.moduleReferences ?? []).map((ref) => ({ + ...ref, + path: restoreManifestModulePath(ref.path, baseDir), + })), + moduleDescriptors: (manifest.moduleDescriptors ?? []).map((desc) => ({ + ...desc, + unitPath: restoreManifestModulePath(desc.unitPath, baseDir), + })), + }; +} + export class EggModuleLoader { app: Application; globalGraph: GlobalGraph; @@ -38,7 +57,10 @@ export class EggModuleLoader { // Pass manifest data to LoaderFactory if available const manifest = this.app.loader.manifest; const manifestTegg = manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; - const loadAppManifest = manifestTegg?.moduleDescriptors?.length ? manifestTegg : undefined; + const restoredManifestTegg = manifestTegg + ? restoreTeggManifestExtension(manifestTegg, this.app.baseDir) + : undefined; + const loadAppManifest = restoredManifestTegg?.moduleDescriptors?.length ? restoredManifestTegg : undefined; const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences, loadAppManifest); diff --git a/tegg/plugin/tegg/test/ModuleConfig.test.ts b/tegg/plugin/tegg/test/ModuleConfig.test.ts index 270ec9d4b3..dec24e30de 100644 --- a/tegg/plugin/tegg/test/ModuleConfig.test.ts +++ b/tegg/plugin/tegg/test/ModuleConfig.test.ts @@ -21,7 +21,7 @@ describe('plugin/tegg/test/ModuleConfig.test.ts', () => { baseDir: getAppBaseDir('inject-module-config'), }); await app.ready(); - }); + }, 30000); it('should work', async () => { await app diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index f66aaed7cb..210e186813 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -1,13 +1,19 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; // import { scheduler } from 'node:timers/promises'; +import { ModuleDescriptorDumper } from '@eggjs/metadata'; import { mm } from '@eggjs/mock'; -import { describe, it, afterEach } from 'vitest'; +import { LoaderFactory, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { describe, it, afterEach, vi } from 'vitest'; +import { EggModuleLoader } from '../../src/lib/EggModuleLoader.js'; import { getAppBaseDir } from '../utils.ts'; describe('test/lib/EggModuleLoader.test.ts', () => { afterEach(() => { + vi.restoreAllMocks(); return mm.restore(); }); @@ -33,4 +39,55 @@ describe('test/lib/EggModuleLoader.test.ts', () => { await app.close(); }); }); + + describe('bundled manifest metadata', () => { + it('should restore manifest descriptor paths before loading modules', async () => { + const repoModulePath = path.join(__dirname, '../../../../core/loader/test/fixtures/modules/module-for-loader'); + const baseDir = path.dirname(repoModulePath); + const bundledModulePath = path.relative(baseDir, repoModulePath); + const teggManifest: TeggManifestExtension = { + moduleReferences: [{ name: 'module-for-loader', path: bundledModulePath }], + moduleDescriptors: [ + { + name: 'module-for-loader', + unitPath: bundledModulePath, + decoratedFiles: [], + }, + ], + }; + const loadApp = vi.spyOn(LoaderFactory, 'loadApp').mockResolvedValue([ + { + name: 'module-for-loader', + unitPath: repoModulePath, + clazzList: [], + multiInstanceClazzList: [], + protos: [], + }, + ]); + vi.spyOn(ModuleDescriptorDumper, 'dump').mockResolvedValue(); + const app = { + baseDir, + moduleReferences: [{ name: 'module-for-loader', path: repoModulePath }], + plugins: {}, + loader: { + manifest: { + getExtension(key: string) { + return key === TEGG_MANIFEST_KEY ? teggManifest : undefined; + }, + }, + }, + logger: { + warn() {}, + }, + }; + + const loader = new EggModuleLoader(app as any); + await (loader as any).buildAppGraph(); + + assert.notEqual(teggManifest.moduleDescriptors[0].unitPath, repoModulePath); + assert.equal(loadApp.mock.calls.length, 1); + assert.equal(loadApp.mock.calls[0][0][0].path, repoModulePath); + assert.equal(loadApp.mock.calls[0][1]?.moduleDescriptors[0].unitPath, repoModulePath); + }); + }); }); diff --git a/tools/egg-bundler/src/lib/EntryGenerator.ts b/tools/egg-bundler/src/lib/EntryGenerator.ts index b533f100ef..3258558e91 100644 --- a/tools/egg-bundler/src/lib/EntryGenerator.ts +++ b/tools/egg-bundler/src/lib/EntryGenerator.ts @@ -256,6 +256,7 @@ 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'; @@ -291,7 +292,56 @@ const __setBundleAliases = (rel: string, mod: unknown) => { __setBundleMap(path.resolve(__outputDir, rel), mod); } }; +const __packageName = (specifier: string) => { + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +}; +const __packageRoot = (specifier: string) => path.join(__outputDir, 'node_modules', ...__packageName(specifier).split('/')); +const __toOutputPath = (filepath: string) => (path.isAbsolute(filepath) ? filepath : path.resolve(__outputDir, filepath)); +const __loaderEggPaths = ( + ((MANIFEST_DATA.extensions as { eggLoader?: { eggPaths?: unknown[] } }).eggLoader?.eggPaths ?? []).filter( + (filepath): filepath is string => typeof filepath === 'string', + ) +).map(__toOutputPath); +const __fallbackFrameworkPaths = [ + __packageRoot(__framework), + __framework === 'egg' ? undefined : __packageRoot('egg'), +].filter((filepath): filepath is string => typeof filepath === 'string'); +const __frameworkPaths = Array.from( + new Set(__loaderEggPaths.length > 0 ? __loaderEggPaths : __fallbackFrameworkPaths), +); +const __realpathOrSelf = (filepath: string) => { + try { + return fs.realpathSync(filepath); + } catch { + return filepath; + } +}; +const __outputDirRealpath = __realpathOrSelf(path.resolve(__outputDir)); +const __isOutputRootPath = (filepath: unknown) => + typeof filepath === 'string' && __realpathOrSelf(path.resolve(__outputDir, filepath)) === __outputDirRealpath; +const __patchFrameworkPaths = (Clazz: unknown) => { + const proto = (Clazz as { prototype?: Record } | undefined)?.prototype; + if (!proto) return; + const original = proto.customEggPaths; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'customEggPaths'); + if (descriptor && !descriptor.configurable && !descriptor.writable) return; + Object.defineProperty(proto, 'customEggPaths', { + configurable: descriptor?.configurable ?? true, + enumerable: descriptor?.enumerable ?? false, + writable: descriptor?.writable ?? true, + value: function (this: unknown) { + const originalPaths = typeof original === 'function' ? original.call(this) : []; + const preservedPaths = Array.isArray(originalPaths) + ? originalPaths.filter((p: unknown) => !__isOutputRootPath(p)) + : []; + return Array.from(new Set([...__frameworkPaths, ...preservedPaths])); + }, + }); +}; __setBundleMap(__framework, __frameworkModule); +__patchFrameworkPaths(__frameworkModule.Application); +__patchFrameworkPaths(__frameworkModule.Agent); for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { __setBundleAliases(rel, mod); } diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts index eb12f6a80c..39e5ad81d9 100644 --- a/tools/egg-bundler/src/lib/ManifestLoader.ts +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -12,6 +12,7 @@ const debug = debuglog('egg/bundler/manifest-loader'); const SUPPORTED_MANIFEST_VERSION = 1; const FRAMEWORK_DEFAULT = 'egg'; const PACKAGE_ENTRY_ACTIVE_CONDITIONS = new Set(['import', 'node', 'default']); +const LOADER_MANIFEST_EXTENSION = 'eggLoader'; export interface ManifestLoaderOptions { baseDir: string; @@ -45,6 +46,17 @@ interface ModuleMapEntry { normalizedDir: string; } +interface LoaderManifestPluginInfo { + path?: string; + [key: string]: unknown; +} + +interface LoaderManifestExtension { + eggPaths?: string[]; + plugins?: Record; + [key: string]: unknown; +} + export class ManifestLoader { readonly #baseDir: string; readonly #manifestPath: string; @@ -429,6 +441,28 @@ export class ManifestLoader { } result.tegg = normalizedTegg; } + const eggLoader = extensions?.[LOADER_MANIFEST_EXTENSION] as LoaderManifestExtension | undefined; + if (eggLoader) { + result[LOADER_MANIFEST_EXTENSION] = { + ...eggLoader, + eggPaths: eggLoader.eggPaths + ? await Promise.all(eggLoader.eggPaths.map((eggPath) => this.#normalizeRelKey(eggPath, moduleMap))) + : undefined, + plugins: eggLoader.plugins + ? Object.fromEntries( + await Promise.all( + Object.entries(eggLoader.plugins).map(async ([name, plugin]) => [ + name, + { + ...plugin, + path: plugin.path ? await this.#normalizeRelKey(plugin.path, moduleMap) : undefined, + }, + ]), + ), + ) + : undefined, + }; + } return result; } diff --git a/tools/egg-bundler/test/EntryGenerator.test.ts b/tools/egg-bundler/test/EntryGenerator.test.ts index 6d3560c1d2..22a2577bcf 100644 --- a/tools/egg-bundler/test/EntryGenerator.test.ts +++ b/tools/egg-bundler/test/EntryGenerator.test.ts @@ -229,6 +229,7 @@ describe('EntryGenerator', () => { expect(worker).toContain('ManifestStore.setBundleStore(ManifestStore.fromBundle(MANIFEST_DATA'); expect(worker).toContain('__EGG_BUNDLE_MODULE_LOADER__'); expect(worker).toContain('__setBundleMap(__framework, __frameworkModule)'); + expect(worker).toContain('__patchFrameworkPaths(__frameworkModule.Application)'); expect(worker).not.toContain('__frameworkImport'); expect(worker).toContain("startEgg({ baseDir: __outputDir, framework: __framework, mode: 'single' })"); }); @@ -366,6 +367,104 @@ export async function startEgg(options) { expect(runtimeResult.controllerResolved).toBe('bundled-controller'); }); + it('virtualizes framework eggPaths under node_modules before startEgg runs', async () => { + const resultFile = path.join(tmpDir, 'framework-paths.json'); + await fs.writeFile(path.join(tmpDir, 'package.json'), JSON.stringify({ type: 'module' })); + await writePackage( + path.join(tmpDir, 'node_modules/@eggjs/core'), + ` +export const ManifestStore = { + fromBundle(manifest, baseDir) { + return { manifest, baseDir }; + }, + setBundleStore() {}, +}; +`, + ); + const frameworkDir = path.join(tmpDir, 'node_modules/@runtime/framework'); + await fs.mkdir(frameworkDir, { recursive: true }); + await fs.writeFile( + path.join(frameworkDir, 'package.json'), + JSON.stringify({ type: 'module', exports: { './subpath': './subpath.js' } }), + ); + await fs.writeFile( + path.join(frameworkDir, 'subpath.js'), + ` +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const outputDir = () => path.dirname(path.resolve(process.argv[1])); + +export class Application { + customEggPaths() { + return [outputDir(), path.join(outputDir(), 'preserved-framework')]; + } +} + +export class Agent extends Application {} + +export async function startEgg(options) { + await fs.writeFile(process.env.EGG_RUNTIME_RESULT, JSON.stringify({ + appPaths: new Application().customEggPaths(), + agentPaths: new Agent().customEggPaths(), + appDescriptor: Object.getOwnPropertyDescriptor(Application.prototype, 'customEggPaths'), + agentDescriptor: Object.getOwnPropertyDescriptor(Agent.prototype, 'customEggPaths'), + }, null, 2)); + return { + config: { cluster: { listen: { port: 0 } } }, + listen(_port, callback) { + callback(); + }, + }; +} +`, + ); + + const outputDir = path.join(tmpDir, 'dist'); + const gen = new EntryGenerator({ + baseDir: tmpDir, + outputDir, + framework: '@runtime/framework/subpath', + manifestLoader: createFakeLoader( + makeManifest({ + extensions: { + eggLoader: { + eggPaths: ['node_modules/@runtime/root-framework', 'node_modules/@runtime/framework', 'node_modules/egg'], + }, + }, + }), + ), + }); + 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 { + appPaths: string[]; + agentPaths: string[]; + appDescriptor: PropertyDescriptor; + agentDescriptor: PropertyDescriptor; + }; + const expected = [ + path.join(outputDir, 'node_modules/@runtime/root-framework'), + path.join(outputDir, 'node_modules/@runtime/framework'), + path.join(outputDir, 'node_modules/egg'), + path.join(outputDir, 'preserved-framework'), + ]; + expect(runtimeResult.appPaths).toEqual(expected); + expect(runtimeResult.agentPaths).toEqual(expected); + expect(runtimeResult.appDescriptor).toMatchObject({ configurable: true, enumerable: false, writable: true }); + expect(runtimeResult.agentDescriptor).toMatchObject({ configurable: true, enumerable: false, writable: true }); + }); + it('loads externalized package files via createRequire instead of static imports', async () => { const manifest = makeManifest({ fileDiscovery: { diff --git a/tools/egg-bundler/test/ManifestLoader.test.ts b/tools/egg-bundler/test/ManifestLoader.test.ts index 1a506f165c..b8f6ff606a 100644 --- a/tools/egg-bundler/test/ManifestLoader.test.ts +++ b/tools/egg-bundler/test/ManifestLoader.test.ts @@ -250,6 +250,18 @@ describe('ManifestLoader', () => { }, ], }, + eggLoader: { + eggPaths: [directRoot], + plugins: { + direct: { + path: directRoot, + dependencies: [], + }, + transitive: { + path: transitiveRoot, + }, + }, + }, }, }), ); @@ -265,13 +277,27 @@ describe('ManifestLoader', () => { 'node_modules/direct/node_modules/transitive/entry': 'node_modules/direct/node_modules/transitive/lib/svc.ts', 'node_modules/optional-native/entry': 'node_modules/optional-native/index.ts', }); - expect(loaded.extensions.tegg).toEqual({ - moduleDescriptors: [ - { - unitPath: 'node_modules/direct/node_modules/transitive', - decoratedFiles: ['lib/svc.ts'], + expect(loaded.extensions).toEqual({ + tegg: { + moduleDescriptors: [ + { + unitPath: 'node_modules/direct/node_modules/transitive', + decoratedFiles: ['lib/svc.ts'], + }, + ], + }, + eggLoader: { + eggPaths: ['node_modules/direct'], + plugins: { + direct: { + path: 'node_modules/direct', + dependencies: [], + }, + transitive: { + path: 'node_modules/direct/node_modules/transitive', + }, }, - ], + }, }); expect(loader.getAllDiscoveredFiles()).toEqual([transitiveFile]); expect(loader.getTeggDecoratedFiles()).toEqual([transitiveFile]); diff --git a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap index 4b2f8e79e3..42f360ab12 100644 --- a/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap +++ b/tools/egg-bundler/test/__snapshots__/EntryGenerator.worker.canonical.snap @@ -1,5 +1,6 @@ // ⚠️ 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'; @@ -75,7 +76,56 @@ const __setBundleAliases = (rel: string, mod: unknown) => { __setBundleMap(path.resolve(__outputDir, rel), mod); } }; +const __packageName = (specifier: string) => { + const parts = specifier.split('/'); + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; +}; +const __packageRoot = (specifier: string) => path.join(__outputDir, 'node_modules', ...__packageName(specifier).split('/')); +const __toOutputPath = (filepath: string) => (path.isAbsolute(filepath) ? filepath : path.resolve(__outputDir, filepath)); +const __loaderEggPaths = ( + ((MANIFEST_DATA.extensions as { eggLoader?: { eggPaths?: unknown[] } }).eggLoader?.eggPaths ?? []).filter( + (filepath): filepath is string => typeof filepath === 'string', + ) +).map(__toOutputPath); +const __fallbackFrameworkPaths = [ + __packageRoot(__framework), + __framework === 'egg' ? undefined : __packageRoot('egg'), +].filter((filepath): filepath is string => typeof filepath === 'string'); +const __frameworkPaths = Array.from( + new Set(__loaderEggPaths.length > 0 ? __loaderEggPaths : __fallbackFrameworkPaths), +); +const __realpathOrSelf = (filepath: string) => { + try { + return fs.realpathSync(filepath); + } catch { + return filepath; + } +}; +const __outputDirRealpath = __realpathOrSelf(path.resolve(__outputDir)); +const __isOutputRootPath = (filepath: unknown) => + typeof filepath === 'string' && __realpathOrSelf(path.resolve(__outputDir, filepath)) === __outputDirRealpath; +const __patchFrameworkPaths = (Clazz: unknown) => { + const proto = (Clazz as { prototype?: Record } | undefined)?.prototype; + if (!proto) return; + const original = proto.customEggPaths; + const descriptor = Object.getOwnPropertyDescriptor(proto, 'customEggPaths'); + if (descriptor && !descriptor.configurable && !descriptor.writable) return; + Object.defineProperty(proto, 'customEggPaths', { + configurable: descriptor?.configurable ?? true, + enumerable: descriptor?.enumerable ?? false, + writable: descriptor?.writable ?? true, + value: function (this: unknown) { + const originalPaths = typeof original === 'function' ? original.call(this) : []; + const preservedPaths = Array.isArray(originalPaths) + ? originalPaths.filter((p: unknown) => !__isOutputRootPath(p)) + : []; + return Array.from(new Set([...__frameworkPaths, ...preservedPaths])); + }, + }); +}; __setBundleMap(__framework, __frameworkModule); +__patchFrameworkPaths(__frameworkModule.Application); +__patchFrameworkPaths(__frameworkModule.Agent); for (const [rel, mod] of Object.entries(__BUNDLE_MAP_REL)) { __setBundleAliases(rel, mod); }