diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index f3e7e597e2..771376ada4 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -1763,12 +1763,11 @@ export class EggLoader { * Generate startup manifest from collected data. * Should be called after all loading phases complete. */ - generateManifest(extensions?: Record): StartupManifest { + generateManifest(): StartupManifest { return this.manifest.generateManifest({ serverEnv: this.serverEnv, serverScope: this.serverScope, typescriptEnabled: isSupportTypeScript(), - extensions, }); } } diff --git a/packages/core/src/loader/manifest.ts b/packages/core/src/loader/manifest.ts index 8a6d4040b4..0dd07d5adc 100644 --- a/packages/core/src/loader/manifest.ts +++ b/packages/core/src/loader/manifest.ts @@ -39,6 +39,7 @@ export class ManifestStore { // Collectors for manifest generation (populated during loading) readonly #resolveCacheCollector: Record = {}; readonly #fileDiscoveryCollector: Record = {}; + readonly #extensionCollector: Record = {}; private constructor(data: StartupManifest, baseDir: string) { this.data = data; @@ -194,6 +195,13 @@ export class ManifestStore { return this.data.extensions?.[name]; } + /** + * Register plugin extension data for manifest generation. + */ + setExtension(name: string, data: unknown): void { + this.#extensionCollector[name] = data; + } + // --- Generation APIs --- /** @@ -210,7 +218,7 @@ export class ManifestStore { serverScope: options.serverScope, typescriptEnabled: options.typescriptEnabled, }, - extensions: options.extensions ?? {}, + extensions: this.#extensionCollector, resolveCache: this.#resolveCacheCollector, fileDiscovery: this.#fileDiscoveryCollector, }; @@ -309,5 +317,4 @@ export interface ManifestGenerateOptions { serverEnv: string; serverScope: string; typescriptEnabled: boolean; - extensions?: Record; } diff --git a/packages/core/test/loader/manifest.test.ts b/packages/core/test/loader/manifest.test.ts index dbb4168bc6..f3b4c357a8 100644 --- a/packages/core/test/loader/manifest.test.ts +++ b/packages/core/test/loader/manifest.test.ts @@ -64,19 +64,19 @@ describe('ManifestStore', () => { } }); - it('should preserve provided extensions', () => { + it('should preserve extensions set via setExtension', () => { const baseDir = setupBaseDir(); try { - const extensions = { tegg: { moduleReferences: [{ name: 'mod', path: '/tmp/mod' }] } }; + const teggData = { moduleReferences: [{ name: 'mod', path: '/tmp/mod' }] }; const collector = ManifestStore.createCollector(baseDir); + collector.setExtension('tegg', teggData); const manifest = collector.generateManifest({ serverEnv: 'prod', serverScope: '', typescriptEnabled: true, - extensions, }); - assert.deepStrictEqual(manifest.extensions, extensions); + assert.deepStrictEqual(manifest.extensions, { tegg: teggData }); } finally { fs.rmSync(baseDir, { recursive: true, force: true }); } @@ -474,4 +474,80 @@ describe('ManifestStore', () => { } }); }); + + describe('setExtension()', () => { + it('should store and retrieve extension data', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + const data = { modules: ['a', 'b'] }; + collector.setExtension('tegg', data); + // Not accessible via getExtension until generateManifest + // (getExtension reads from data, not collector) + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.deepStrictEqual(manifest.extensions.tegg, data); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should support multiple extension keys', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.setExtension('tegg', { a: 1 }); + collector.setExtension('custom', { b: 2 }); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.deepStrictEqual(manifest.extensions.tegg, { a: 1 }); + assert.deepStrictEqual(manifest.extensions.custom, { b: 2 }); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should overwrite previous value for same key', () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.setExtension('tegg', { old: true }); + collector.setExtension('tegg', { new: true }); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + assert.deepStrictEqual(manifest.extensions.tegg, { new: true }); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('should survive write → load roundtrip', async () => { + const baseDir = setupBaseDir(); + try { + const collector = ManifestStore.createCollector(baseDir); + collector.setExtension('tegg', { roundtrip: true }); + const manifest = collector.generateManifest({ + serverEnv: 'prod', + serverScope: '', + typescriptEnabled: true, + }); + await ManifestStore.write(baseDir, manifest); + + const store = ManifestStore.load(baseDir, 'prod', '')!; + assert.ok(store); + assert.deepStrictEqual(store.getExtension('tegg'), { roundtrip: true }); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/packages/core/test/loader/manifest_roundtrip.test.ts b/packages/core/test/loader/manifest_roundtrip.test.ts index 8e4534f180..732dd2856d 100644 --- a/packages/core/test/loader/manifest_roundtrip.test.ts +++ b/packages/core/test/loader/manifest_roundtrip.test.ts @@ -29,12 +29,12 @@ describe('ManifestStore roundtrip: generate → write → load', () => { collector.resolveModule(path.join(baseDir, 'missing'), () => undefined); collector.globFiles(path.join(baseDir, 'app/controller'), () => ['home.ts', 'user.ts']); - const extensions = { tegg: { moduleReferences: [{ name: 'foo', path: '/tmp/foo' }] } }; + const teggData = { moduleReferences: [{ name: 'foo', path: '/tmp/foo' }] }; + collector.setExtension('tegg', teggData); const original = collector.generateManifest({ serverEnv: 'prod', serverScope: '', typescriptEnabled: true, - extensions, }); await ManifestStore.write(baseDir, original); @@ -46,7 +46,7 @@ describe('ManifestStore roundtrip: generate → write → load', () => { assert.equal(original.resolveCache['some/path'], 'resolved/path'); assert.equal(original.resolveCache['missing'], null); assert.deepStrictEqual(original.fileDiscovery['app/controller'], ['home.ts', 'user.ts']); - assert.deepStrictEqual(store.data.extensions, extensions); + assert.deepStrictEqual(store.data.extensions, { tegg: teggData }); assert.equal(store.data.version, original.version); assert.equal(store.data.generatedAt, original.generatedAt); diff --git a/packages/egg/src/lib/egg.ts b/packages/egg/src/lib/egg.ts index 35de909f23..907db0473b 100644 --- a/packages/egg/src/lib/egg.ts +++ b/packages/egg/src/lib/egg.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { Cookies as ContextCookies } from '@eggjs/cookies'; -import { EggCore, Router } from '@eggjs/core'; +import { EggCore, Router, ManifestStore } from '@eggjs/core'; import type { EggCoreOptions, Next, MiddlewareFunc as EggCoreMiddlewareFunc, ILifecycleBoot } from '@eggjs/core'; import { utils as eggUtils } from '@eggjs/core'; import { extend } from '@eggjs/extend2'; @@ -190,6 +190,7 @@ export class EggApplicationCore extends EggCore { const dumpStartTime = Date.now(); this.dumpConfig(); this.dumpTiming(); + this.dumpManifest(); this.coreLogger.info('[egg] dump config after ready, %sms', Date.now() - dumpStartTime); }), ); @@ -214,7 +215,7 @@ export class EggApplicationCore extends EggCore { // single process mode will close agent before app close if (this.type === 'application' && this.options.mode === 'single') { - await this.agent!.close(); + await this.agent?.close(); } for (const logger of this.loggers.values()) { @@ -533,6 +534,29 @@ export class EggApplicationCore extends EggCore { } } + /** + * Generate and save startup manifest for faster subsequent startups. + * Only generates when no valid manifest was loaded (avoids overwriting during manifest-accelerated starts). + */ + dumpManifest(): void { + try { + // Skip in local env (manifest is not loaded there unless EGG_MANIFEST=true) + if (this.loader.serverEnv === 'local' && process.env.EGG_MANIFEST !== 'true') { + return; + } + // Skip if we loaded from a valid manifest (generatedAt is truthy) + if (this.loader.manifest.data.generatedAt) { + return; + } + const manifest = this.loader.generateManifest(); + ManifestStore.write(this.baseDir, manifest).catch((err: Error) => { + this.coreLogger.warn('[egg] dumpManifest write error: %s', err.message); + }); + } catch (err: any) { + this.coreLogger.warn('[egg] dumpManifest error: %s', err.message); + } + } + protected override customEggPaths(): string[] { return [path.dirname(import.meta.dirname), ...super.customEggPaths()]; } diff --git a/packages/egg/src/lib/start.ts b/packages/egg/src/lib/start.ts index e2b5b445f8..b04b3c88ea 100644 --- a/packages/egg/src/lib/start.ts +++ b/packages/egg/src/lib/start.ts @@ -17,6 +17,8 @@ export interface StartEggOptions { mode?: 'single'; env?: string; plugins?: EggPlugin; + /** Skip lifecycle hooks, only trigger loadMetadata for manifest generation */ + metadataOnly?: boolean; } export interface SingleModeApplication extends Application { @@ -53,18 +55,27 @@ export async function startEgg(options: StartEggOptions = {}): Promise Loader; +export interface ManifestModuleReference { + name: string; + path: string; + optional?: boolean; + loaderType?: string; +} + +export interface ManifestModuleDescriptor { + name: string; + unitPath: string; + optional?: boolean; + /** Files containing decorated classes, relative to unitPath */ + decoratedFiles: string[]; +} + +/** Shape of the 'tegg' manifest extension stored via ManifestStore.setExtension() */ +export interface TeggManifestExtension { + moduleReferences: ManifestModuleReference[]; + moduleDescriptors: ManifestModuleDescriptor[]; +} + +export const TEGG_MANIFEST_KEY = 'tegg'; + +export interface LoadAppManifest { + moduleDescriptors: ManifestModuleDescriptor[]; +} + export class LoaderFactory { private static loaderCreatorMap: Map = new Map(); @@ -25,14 +52,38 @@ export class LoaderFactory { this.loaderCreatorMap.set(type, creator); } - static async loadApp(moduleReferences: readonly ModuleReference[]): Promise { + static async loadApp( + moduleReferences: readonly ModuleReference[], + manifest?: LoadAppManifest, + ): Promise { const result: ModuleDescriptor[] = []; const multiInstanceClazzList: EggProtoImplClass[] = []; + + const manifestMap = new Map(); + if (manifest?.moduleDescriptors) { + for (const desc of manifest.moduleDescriptors) { + manifestMap.set(desc.unitPath, desc); + } + } + + // Lazy-load ModuleLoader to avoid circular dependency + // (ModuleLoader.ts calls LoaderFactory.registerLoader at module scope) + let ModuleLoaderClass: (typeof import('./impl/ModuleLoader.ts'))['ModuleLoader'] | undefined; + if (manifestMap.size > 0) { + ModuleLoaderClass = (await import('./impl/ModuleLoader.ts')).ModuleLoader; + } + for (const moduleReference of moduleReferences) { - const loader = LoaderFactory.createLoader( - moduleReference.path, - moduleReference.loaderType || EggLoadUnitType.MODULE, - ); + const manifestDesc = manifestMap.get(moduleReference.path); + const loaderType = moduleReference.loaderType || EggLoadUnitType.MODULE; + + let loader: Loader; + if (manifestDesc && ModuleLoaderClass && loaderType === EggLoadUnitType.MODULE) { + loader = new ModuleLoaderClass(moduleReference.path, manifestDesc.decoratedFiles); + } else { + loader = LoaderFactory.createLoader(moduleReference.path, loaderType); + } + const res: ModuleDescriptor = { name: moduleReference.name, unitPath: moduleReference.path, diff --git a/tegg/core/loader/src/impl/ModuleLoader.ts b/tegg/core/loader/src/impl/ModuleLoader.ts index aaf654968c..4d20d5b542 100644 --- a/tegg/core/loader/src/impl/ModuleLoader.ts +++ b/tegg/core/loader/src/impl/ModuleLoader.ts @@ -12,9 +12,12 @@ const debug = debuglog('egg/tegg/loader/impl/ModuleLoader'); export class ModuleLoader implements Loader { private readonly moduleDir: string; private protoClazzList: EggProtoImplClass[]; + /** Pre-computed file list from manifest (only decorated files) */ + private readonly precomputedFiles?: string[]; - constructor(moduleDir: string) { + constructor(moduleDir: string, precomputedFiles?: string[]) { this.moduleDir = moduleDir; + this.precomputedFiles = precomputedFiles; } async load(): Promise { @@ -23,10 +26,16 @@ export class ModuleLoader implements Loader { return this.protoClazzList; } const protoClassList: EggProtoImplClass[] = []; - const filePattern = LoaderUtil.filePattern(); - const files = await globby(filePattern, { cwd: this.moduleDir }); - debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir); + let files: string[]; + if (this.precomputedFiles) { + files = this.precomputedFiles; + debug('load from manifest, files: %o, moduleDir: %o', files, this.moduleDir); + } else { + const filePattern = LoaderUtil.filePattern(); + files = await globby(filePattern, { cwd: this.moduleDir }); + debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir); + } for (const file of files) { const realPath = path.join(this.moduleDir, file); const fileClazzList = await LoaderUtil.loadFile(realPath); diff --git a/tegg/core/loader/test/LoaderFactoryManifest.test.ts b/tegg/core/loader/test/LoaderFactoryManifest.test.ts new file mode 100644 index 0000000000..ecb2f4ed38 --- /dev/null +++ b/tegg/core/loader/test/LoaderFactoryManifest.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { ModuleDescriptorDumper } from '@eggjs/metadata'; +import { describe, it } from 'vitest'; + +import { LoaderFactory } from '../src/index.ts'; +import type { LoadAppManifest, ManifestModuleDescriptor } from '../src/index.ts'; + +describe('core/loader/test/LoaderFactoryManifest.test.ts', () => { + const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); + const moduleRef = { name: 'module-for-loader', path: repoModulePath }; + + it('should load normally without manifest', async () => { + const descriptors = await LoaderFactory.loadApp([moduleRef]); + assert.equal(descriptors.length, 1); + assert.equal(descriptors[0].name, 'module-for-loader'); + assert(descriptors[0].clazzList.length > 0); + }); + + it('should use precomputed files when manifest matches unitPath', async () => { + // First: normal load to get decorated files + const normalDescs = await LoaderFactory.loadApp([moduleRef]); + const decoratedFiles = ModuleDescriptorDumper.getDecoratedFiles(normalDescs[0]); + + // Build manifest from the decorated files + const manifest: LoadAppManifest = { + moduleDescriptors: [ + { + name: 'module-for-loader', + unitPath: repoModulePath, + decoratedFiles, + }, + ], + }; + + // Load with manifest + const manifestDescs = await LoaderFactory.loadApp([moduleRef], manifest); + assert.equal(manifestDescs.length, 1); + + // Same classes loaded + const normalNames = normalDescs[0].clazzList.map((c) => c.name).sort(); + const manifestNames = manifestDescs[0].clazzList.map((c) => c.name).sort(); + assert.deepStrictEqual(manifestNames, normalNames); + }); + + it('should fall back to globby for unmatched unitPath', async () => { + const manifest: LoadAppManifest = { + moduleDescriptors: [ + { + name: 'other-module', + unitPath: '/nonexistent/path', + decoratedFiles: ['foo.ts'], + }, + ], + }; + + // Should still work — falls back to normal globby + const descriptors = await LoaderFactory.loadApp([moduleRef], manifest); + assert.equal(descriptors.length, 1); + assert(descriptors[0].clazzList.length > 0); + }); + + it('should roundtrip: loadApp → getDecoratedFiles → loadApp(manifest)', async () => { + // Step 1: normal load + const firstDescs = await LoaderFactory.loadApp([moduleRef]); + + // Step 2: build manifest + const manifestDescs: ManifestModuleDescriptor[] = firstDescs.map((d) => ({ + name: d.name, + unitPath: d.unitPath, + optional: d.optional, + decoratedFiles: ModuleDescriptorDumper.getDecoratedFiles(d), + })); + const manifest: LoadAppManifest = { moduleDescriptors: manifestDescs }; + + // Step 3: reload with manifest + const secondDescs = await LoaderFactory.loadApp([moduleRef], manifest); + + // Verify identical results + assert.equal(firstDescs.length, secondDescs.length); + for (let i = 0; i < firstDescs.length; i++) { + assert.equal(firstDescs[i].name, secondDescs[i].name); + assert.equal(firstDescs[i].unitPath, secondDescs[i].unitPath); + const firstNames = firstDescs[i].clazzList.map((c) => c.name).sort(); + const secondNames = secondDescs[i].clazzList.map((c) => c.name).sort(); + assert.deepStrictEqual(secondNames, firstNames); + } + }); +}); diff --git a/tegg/core/loader/test/ModuleLoaderManifest.test.ts b/tegg/core/loader/test/ModuleLoaderManifest.test.ts new file mode 100644 index 0000000000..b797d96144 --- /dev/null +++ b/tegg/core/loader/test/ModuleLoaderManifest.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { EggLoadUnitType } from '@eggjs/metadata'; +import { describe, it } from 'vitest'; + +import { ModuleLoader } from '../src/impl/ModuleLoader.ts'; +import { LoaderFactory } from '../src/index.ts'; + +describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { + const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); + + it('should load only precomputed files when provided', async () => { + const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']); + const prototypes = await loader.load(); + // AppRepo.ts has 2 decorated classes: AppRepo and AppRepo2 + assert.equal(prototypes.length, 2); + assert(prototypes.find((t) => t.name === 'AppRepo')); + assert(prototypes.find((t) => t.name === 'AppRepo2')); + }); + + it('should produce same result as globby-based loading', async () => { + // Load via globby (normal path) + const normalLoader = LoaderFactory.createLoader(repoModulePath, EggLoadUnitType.MODULE); + const normalProtos = await normalLoader.load(); + + // Load via precomputed files (manifest path) + // Get the file list from normal loading to ensure consistency + const fileNames = ['AppRepo.ts', 'SprintRepo.ts', 'UserRepo.ts']; + const manifestLoader = new ModuleLoader(repoModulePath, fileNames); + const manifestProtos = await manifestLoader.load(); + + // Same number and same class names + assert.equal(manifestProtos.length, normalProtos.length); + const normalNames = normalProtos.map((p) => p.name).sort(); + const manifestNames = manifestProtos.map((p) => p.name).sort(); + assert.deepStrictEqual(manifestNames, normalNames); + }); + + it('should return empty list for empty precomputedFiles', async () => { + const loader = new ModuleLoader(repoModulePath, []); + const prototypes = await loader.load(); + assert.equal(prototypes.length, 0); + }); + + it('should cache result on subsequent calls', async () => { + const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']); + const first = await loader.load(); + const second = await loader.load(); + assert.strictEqual(first, second); + }); +}); diff --git a/tegg/core/loader/test/__snapshots__/index.test.ts.snap b/tegg/core/loader/test/__snapshots__/index.test.ts.snap index 12e0953735..55d1af2d9a 100644 --- a/tegg/core/loader/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/loader/test/__snapshots__/index.test.ts.snap @@ -5,5 +5,6 @@ exports[`should export stable 1`] = ` "LoaderFactory": [Function], "LoaderUtil": [Function], "ModuleLoader": [Function], + "TEGG_MANIFEST_KEY": "tegg", } `; diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index 346125f98f..1a504a03c4 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -61,6 +61,27 @@ export class ModuleDescriptorDumper { return path.join(dumpDir, '.egg', `${desc.name}_module_desc.json`); } + /** + * Extract decorated file paths (relative to unitPath) from a ModuleDescriptor. + * Used for manifest generation to record which files contain egg prototypes. + */ + static getDecoratedFiles(desc: ModuleDescriptor): string[] { + const fileSet = new Set(); + const addClazz = (clazz: EggProtoImplClass): void => { + const filePath = PrototypeUtil.getFilePath(clazz); + if (filePath) { + const rel = path.relative(desc.unitPath, filePath).replaceAll(path.sep, '/'); + // Only include files within the module (multiInstanceClazzList is shared) + if (!rel.startsWith('..')) { + fileSet.add(rel); + } + } + }; + for (const clazz of desc.clazzList) addClazz(clazz); + for (const clazz of desc.multiInstanceClazzList) addClazz(clazz); + return Array.from(fileSet); + } + static async dump(desc: ModuleDescriptor, options?: ModuleDumpOptions): Promise { const dumpPath = ModuleDescriptorDumper.dumpPath(desc, options); await fs.mkdir(path.dirname(dumpPath), { recursive: true }); diff --git a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts new file mode 100644 index 0000000000..b94749c88f --- /dev/null +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { PrototypeUtil } from '@eggjs/core-decorator'; +import { describe, it } from 'vitest'; + +import { ModuleDescriptorDumper } from '../src/index.js'; +import type { ModuleDescriptor } from '../src/index.js'; + +describe('test/ModuleDescriptorDumper.test.ts', () => { + describe('getDecoratedFiles()', () => { + const loadUnitPath = path.join(__dirname, 'fixtures/modules/load-unit'); + + it('should return empty array for empty descriptor', () => { + const desc: ModuleDescriptor = { + name: 'empty', + unitPath: '/tmp/empty', + clazzList: [], + multiInstanceClazzList: [], + protos: [], + }; + const files = ModuleDescriptorDumper.getDecoratedFiles(desc); + assert.deepStrictEqual(files, []); + }); + + it('should return relative file paths from clazzList', async () => { + // Import to trigger decorator registration + const mod = await import('./fixtures/modules/load-unit/AppRepo.js'); + const AppRepo = mod.default; + const filePath = PrototypeUtil.getFilePath(AppRepo); + assert.ok(filePath, 'AppRepo should have a file path'); + + const desc: ModuleDescriptor = { + name: 'load-unit', + unitPath: loadUnitPath, + clazzList: [AppRepo], + multiInstanceClazzList: [], + protos: [], + }; + + const files = ModuleDescriptorDumper.getDecoratedFiles(desc); + assert(files.length > 0); + for (const file of files) { + assert(!path.isAbsolute(file), `expected relative path, got: ${file}`); + assert(!file.startsWith('..'), `expected path within module, got: ${file}`); + } + assert(files.includes('AppRepo.ts')); + }); + + it('should deduplicate file paths', async () => { + const mod = await import('./fixtures/modules/load-unit/AppRepo.js'); + const AppRepo = mod.default; + + const desc: ModuleDescriptor = { + name: 'load-unit', + unitPath: loadUnitPath, + clazzList: [AppRepo], + multiInstanceClazzList: [AppRepo], + protos: [], + }; + + const files = ModuleDescriptorDumper.getDecoratedFiles(desc); + const uniqueFiles = [...new Set(files)]; + assert.equal(files.length, uniqueFiles.length); + }); + + it('should filter out files outside unitPath', async () => { + const mod = await import('./fixtures/modules/load-unit/AppRepo.js'); + const AppRepo = mod.default; + + const desc: ModuleDescriptor = { + name: 'fake', + unitPath: '/tmp/fake-module', + clazzList: [], + multiInstanceClazzList: [AppRepo], + protos: [], + }; + + // File is outside /tmp/fake-module so relative path starts with .. + const files = ModuleDescriptorDumper.getDecoratedFiles(desc); + assert.equal(files.length, 0); + }); + }); +}); diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index ed33b83e6b..57b72fda2e 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -94,6 +94,7 @@ exports[`should helper exports stable 1`] = ` "ProxyUtil": [Function], "StackUtil": [Function], "StreamUtil": [Function], + "TEGG_MANIFEST_KEY": "tegg", "TeggError": [Function], "TimerUtil": [Function], } diff --git a/tegg/plugin/config/package.json b/tegg/plugin/config/package.json index fa9528deff..1f830dd00b 100644 --- a/tegg/plugin/config/package.json +++ b/tegg/plugin/config/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@eggjs/tegg-common-util": "workspace:*", + "@eggjs/tegg-loader": "workspace:*", "@eggjs/utils": "workspace:*" }, "devDependencies": { diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index ebd455f670..9f2ecdca50 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -4,6 +4,8 @@ import { debuglog } from 'node:util'; import { ModuleConfigUtil } from '@eggjs/tegg-common-util'; import type { ModuleReference } from '@eggjs/tegg-common-util'; +import { TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; import type { Application, ILifecycleBoot } from 'egg'; import { ModuleScanner } from './lib/ModuleScanner.ts'; @@ -20,30 +22,50 @@ export default class App implements ILifecycleBoot { } configWillLoad(): void { + this.#scanModuleReferences(); + this.#loadModuleConfigs(); + } + + async loadMetadata(): Promise { + this.#scanModuleReferences(); + this.#loadModuleConfigs(); + } + + #scanModuleReferences(): void { const { readModuleOptions } = this.app.config.tegg; - // Auto-exclude outDir (e.g. dist/) from module scanning to avoid - // duplicate modules when both source and compiled output exist - const outDir = this.app.loader.outDir; - if (outDir) { - const extraFilePattern = readModuleOptions.extraFilePattern || []; - const excludePattern = `!**/${outDir}`; - if (!extraFilePattern.includes(excludePattern)) { - readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; + + // Try to use manifest for module references (skip expensive globby scan) + const manifest = this.app.loader.manifest; + const manifestTegg = manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; + + let moduleReferences: readonly ModuleReference[]; + if (manifestTegg?.moduleReferences?.length) { + moduleReferences = manifestTegg.moduleReferences; + debug('load moduleReferences from manifest: %o', moduleReferences); + } else { + // Auto-exclude outDir (e.g. dist/) from module scanning to avoid + // duplicate modules when both source and compiled output exist + const outDir = this.app.loader.outDir; + if (outDir) { + const extraFilePattern = readModuleOptions.extraFilePattern || []; + const excludePattern = `!**/${outDir}`; + if (!extraFilePattern.includes(excludePattern)) { + readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; + } + } + const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions); + moduleReferences = moduleScanner.loadModuleReferences(); + + if (outDir) { + moduleReferences = this.#rewriteModulePaths(moduleReferences, outDir); } - } - const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions); - let moduleReferences = moduleScanner.loadModuleReferences(); - - // When outDir is configured and compiled output exists, rewrite module paths - // from source (e.g. app/port/) to compiled output (e.g. dist/app/port/) - // so that LoaderUtil can find .js files in production mode - if (outDir) { - moduleReferences = this.#rewriteModulePaths(moduleReferences, outDir); } this.app.moduleReferences = moduleReferences; debug('load moduleReferences: %o', this.app.moduleReferences); + } + #loadModuleConfigs(): void { this.app.moduleConfigs = {}; for (const reference of this.app.moduleReferences) { const absoluteRef: ModuleReference = { diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 3795960a8a..1e5a76f1f3 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -3,12 +3,14 @@ import './lib/AppLoadUnit.ts'; import './lib/AppLoadUnitInstance.ts'; import './lib/EggCompatibleObject.ts'; import { LoadUnitMultiInstanceProtoHook } from '@eggjs/metadata'; +import { LoaderFactory } from '@eggjs/tegg-loader'; import type { Application, ILifecycleBoot } from 'egg'; import { CompatibleUtil } from './lib/CompatibleUtil.ts'; import { ConfigSourceLoadUnitHook } from './lib/ConfigSourceLoadUnitHook.ts'; import { EggContextCompatibleHook } from './lib/EggContextCompatibleHook.ts'; import { EggContextHandler } from './lib/EggContextHandler.ts'; +import { EggModuleLoader } from './lib/EggModuleLoader.ts'; import { EggQualifierProtoHook } from './lib/EggQualifierProtoHook.ts'; import { ModuleHandler } from './lib/ModuleHandler.ts'; import { hijackRunInBackground } from './lib/run_in_background.ts'; @@ -54,6 +56,12 @@ export default class TeggAppBoot implements ILifecycleBoot { this.app.eggContextLifecycleUtil.registerLifecycle(this.compatibleHook); } + async loadMetadata(): Promise { + if (!this.app.moduleReferences) return; + const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences); + EggModuleLoader.collectTeggManifest(this.app, moduleDescriptors); + } + async beforeClose(): Promise { CompatibleUtil.clean(); await this.app.moduleHandler.destroy(); diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 0c200264e7..fc97c39ede 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,6 +1,8 @@ import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; -import type { GlobalGraphBuildHook } from '@eggjs/metadata'; -import { LoaderFactory } from '@eggjs/tegg-loader'; +import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; +import { LoaderFactory, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import type { ModuleReference } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { EggAppLoader } from './EggAppLoader.ts'; @@ -18,13 +20,13 @@ export class EggModuleLoader { this.pendingBuildHooks.push(hook); } - private async loadApp() { + private async loadApp(): Promise { const loader = new EggAppLoader(this.app); const loadUnit = await LoadUnitFactory.createLoadUnit(this.app.baseDir, EggLoadUnitType.APP, loader); this.app.moduleHandler.loadUnits.push(loadUnit); } - private async buildAppGraph() { + private async buildAppGraph(): Promise { for (const plugin of Object.values(this.app.plugins)) { if (!plugin.enable) continue; const modulePlugin = this.app.moduleReferences.find((t) => t.path === plugin.path); @@ -32,11 +34,23 @@ export class EggModuleLoader { modulePlugin.optional = false; } } - const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences); + + // 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 moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences, loadAppManifest); + + // Collect manifest data when not loaded from manifest + if (!loadAppManifest) { + EggModuleLoader.collectTeggManifest(this.app, moduleDescriptors); + } + for (const moduleDescriptor of moduleDescriptors) { ModuleDescriptorDumper.dump(moduleDescriptor, { dumpDir: this.app.baseDir, - }).catch((e) => { + }).catch((e: Error) => { e.message = 'dump module descriptor failed: ' + e.message; this.app.logger.warn(e); }); @@ -45,7 +59,38 @@ export class EggModuleLoader { return graph; } - private async loadModule() { + /** + * Build tegg manifest data from module references and descriptors. + */ + static buildTeggManifestData( + moduleReferences: readonly ModuleReference[], + moduleDescriptors: readonly ModuleDescriptor[], + ): TeggManifestExtension { + return { + moduleReferences: moduleReferences.map((ref) => ({ + name: ref.name, + path: ref.path, + optional: ref.optional, + loaderType: ref.loaderType, + })), + moduleDescriptors: moduleDescriptors.map((desc) => ({ + name: desc.name, + unitPath: desc.unitPath, + optional: desc.optional, + decoratedFiles: ModuleDescriptorDumper.getDecoratedFiles(desc), + })), + }; + } + + /** + * Collect tegg manifest data and store in manifest extensions. + */ + static collectTeggManifest(app: Application, moduleDescriptors: readonly ModuleDescriptor[]): void { + const data = EggModuleLoader.buildTeggManifestData(app.moduleReferences, moduleDescriptors); + app.loader.manifest.setExtension(TEGG_MANIFEST_KEY, data); + } + + private async loadModule(): Promise { this.globalGraph.build(); this.globalGraph.sort(); const moduleConfigList = this.globalGraph.moduleConfigList; diff --git a/tegg/plugin/tegg/test/ManifestCollection.test.ts b/tegg/plugin/tegg/test/ManifestCollection.test.ts new file mode 100644 index 0000000000..765276d3ba --- /dev/null +++ b/tegg/plugin/tegg/test/ManifestCollection.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { describe, it, afterEach, afterAll, beforeAll } from 'vitest'; + +import { getAppBaseDir } from './utils.ts'; + +describe('plugin/tegg/test/ManifestCollection.test.ts', () => { + let app: MockApplication; + + afterEach(async () => { + return mm.restore(); + }); + + describe('manifest collection on app startup', () => { + beforeAll(async () => { + app = mm.app({ + baseDir: getAppBaseDir('egg-app'), + }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should collect tegg manifest extension after ready', () => { + const teggExt = app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; + assert.ok(teggExt, 'tegg manifest extension should be set'); + }); + + it('should have moduleReferences matching app.moduleReferences', () => { + const teggExt = app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension; + assert.ok(teggExt.moduleReferences); + assert.ok(teggExt.moduleReferences.length > 0); + + for (const ref of teggExt.moduleReferences) { + assert.ok(ref.name, 'moduleReference should have name'); + assert.ok(ref.path, 'moduleReference should have path'); + } + + // All module reference names should match + const appRefNames = app.moduleReferences + .map((r: any) => r.name) + .sort((a: string, b: string) => a.localeCompare(b)); + const manifestRefNames = teggExt.moduleReferences + .map((r) => r.name) + .sort((a: string, b: string) => a.localeCompare(b)); + assert.deepStrictEqual(manifestRefNames, appRefNames); + }); + + it('should have moduleDescriptors with decoratedFiles', () => { + const teggExt = app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension; + assert.ok(teggExt.moduleDescriptors); + assert.ok(teggExt.moduleDescriptors.length > 0); + + for (const desc of teggExt.moduleDescriptors) { + assert.ok(desc.name, 'descriptor should have name'); + assert.ok(desc.unitPath, 'descriptor should have unitPath'); + assert.ok(Array.isArray(desc.decoratedFiles), 'descriptor should have decoratedFiles array'); + } + }); + + it('should have non-empty decoratedFiles for modules with prototypes', () => { + const teggExt = app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension; + // At least one module should have decorated files + const hasFiles = teggExt.moduleDescriptors.some((d) => d.decoratedFiles.length > 0); + assert.ok(hasFiles, 'at least one module should have decorated files'); + }); + }); +});