diff --git a/tegg/core/aop-decorator/src/CrosscutAdviceFactory.ts b/tegg/core/aop-decorator/src/CrosscutAdviceFactory.ts index bbb03a5d4f..713c566000 100644 --- a/tegg/core/aop-decorator/src/CrosscutAdviceFactory.ts +++ b/tegg/core/aop-decorator/src/CrosscutAdviceFactory.ts @@ -1,9 +1,11 @@ import assert from 'node:assert'; +import { InnerObjectProto } from '@eggjs/core-decorator'; import type { EggProtoImplClass, IAdvice, AdviceInfo } from '@eggjs/tegg-types'; import { CrosscutInfoUtil } from './util/index.ts'; +@InnerObjectProto() export class CrosscutAdviceFactory { private readonly crosscutAdviceClazzList: Array> = []; diff --git a/tegg/core/aop-runtime/package.json b/tegg/core/aop-runtime/package.json index bd80879382..1b37646d83 100644 --- a/tegg/core/aop-runtime/package.json +++ b/tegg/core/aop-runtime/package.json @@ -44,6 +44,7 @@ "dependencies": { "@eggjs/aop-decorator": "workspace:*", "@eggjs/core-decorator": "workspace:*", + "@eggjs/lifecycle": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", @@ -59,8 +60,5 @@ }, "engines": { "node": ">=22.18.0" - }, - "eggModule": { - "name": "teggAopRuntime" } } diff --git a/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts b/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts new file mode 100644 index 0000000000..aa02bab107 --- /dev/null +++ b/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts @@ -0,0 +1,27 @@ +import { InnerObjectProto } from '@eggjs/core-decorator'; +import { ObjectInitType } from '@eggjs/tegg-types'; +import type { EggPrototype } from '@eggjs/tegg-types'; + +export interface ProtoToCreate { + name: string; + proto: EggPrototype; +} + +@InnerObjectProto() +export class AopContextAdviceRegistry { + readonly #requestProtoList: ProtoToCreate[] = []; + + addAdvice(name: string, proto: EggPrototype): void { + if (proto.initType !== ObjectInitType.CONTEXT) { + return; + } + if (this.#requestProtoList.some((t) => t.name === name && t.proto === proto)) { + return; + } + this.#requestProtoList.push({ name, proto }); + } + + getRequestProtos(): readonly ProtoToCreate[] { + return this.#requestProtoList; + } +} diff --git a/tegg/core/aop-runtime/src/AopGraphHookRegistrar.ts b/tegg/core/aop-runtime/src/AopGraphHookRegistrar.ts new file mode 100644 index 0000000000..85751f818b --- /dev/null +++ b/tegg/core/aop-runtime/src/AopGraphHookRegistrar.ts @@ -0,0 +1,27 @@ +import { InnerObjectProto } from '@eggjs/core-decorator'; +import { LifecyclePostInject } from '@eggjs/lifecycle'; +import { GlobalGraph } from '@eggjs/metadata'; + +import { crossCutGraphHook } from './CrossCutGraphHook.js'; +import { pointCutGraphHook } from './PointCutGraphHook.js'; + +/** + * Registers the AOP graph build hooks declaratively. Instantiated with the + * InnerObjectLoadUnit, which both hosts run AFTER the business GlobalGraph is + * created and BEFORE build() consumes the hooks — the only valid window. + */ +@InnerObjectProto() +export class AopGraphHookRegistrar { + @LifecyclePostInject() + protected registerGraphHooks(): void { + const globalGraph = GlobalGraph.instance; + if (!globalGraph) { + throw new Error( + '[aop-runtime] GlobalGraph must be created before AopGraphHookRegistrar is instantiated, ' + + 'cross-loadUnit crosscut/pointcut weaving would silently never happen', + ); + } + globalGraph.registerBuildHook(crossCutGraphHook); + globalGraph.registerBuildHook(pointCutGraphHook); + } +} diff --git a/tegg/core/aop-runtime/src/EggObjectAopHook.ts b/tegg/core/aop-runtime/src/EggObjectAopHook.ts index cb37422d9d..57359790ac 100644 --- a/tegg/core/aop-runtime/src/EggObjectAopHook.ts +++ b/tegg/core/aop-runtime/src/EggObjectAopHook.ts @@ -1,13 +1,14 @@ import assert from 'node:assert'; import { Aspect } from '@eggjs/aop-decorator'; -import { PrototypeUtil } from '@eggjs/core-decorator'; +import { EggObjectLifecycleProto, PrototypeUtil } from '@eggjs/core-decorator'; import { EggContainerFactory } from '@eggjs/tegg-runtime'; import { ASPECT_LIST, InjectType } from '@eggjs/tegg-types'; import type { EggObject, EggObjectLifeCycleContext, LifecycleHook } from '@eggjs/tegg-types'; import { AspectExecutor } from './AspectExecutor.js'; +@EggObjectLifecycleProto() export class EggObjectAopHook implements LifecycleHook { private hijackMethods(obj: any, aspectList: Array) { for (const aspect of aspectList) { diff --git a/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts b/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts index f7b0360ee0..8f09695073 100644 --- a/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts +++ b/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts @@ -1,13 +1,12 @@ import { CrosscutAdviceFactory, CrosscutInfoUtil } from '@eggjs/aop-decorator'; +import { EggPrototypeLifecycleProto, Inject } from '@eggjs/core-decorator'; import type { EggPrototype, EggPrototypeLifecycleContext, LifecycleHook } from '@eggjs/tegg-types'; +@EggPrototypeLifecycleProto() export class EggPrototypeCrossCutHook implements LifecycleHook { + @Inject() private readonly crosscutAdviceFactory: CrosscutAdviceFactory; - constructor(crosscutAdviceFactory: CrosscutAdviceFactory) { - this.crosscutAdviceFactory = crosscutAdviceFactory; - } - async preCreate(ctx: EggPrototypeLifecycleContext): Promise { if (CrosscutInfoUtil.isCrosscutAdvice(ctx.clazz)) { this.crosscutAdviceFactory.registerCrossAdviceClazz(ctx.clazz); diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 6d8ff70f06..4cb93463d4 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -1,4 +1,5 @@ import { AspectInfoUtil, AspectMetaBuilder, CrosscutAdviceFactory } from '@eggjs/aop-decorator'; +import { Inject, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; import { EggPrototypeFactory, TeggError } from '@eggjs/metadata'; import type { EggPrototype, @@ -8,12 +9,15 @@ import type { LoadUnitLifecycleContext, } from '@eggjs/tegg-types'; +import { AopContextAdviceRegistry } from './AopContextAdviceRegistry.js'; + +@LoadUnitLifecycleProto() export class LoadUnitAopHook implements LifecycleHook { + @Inject() private readonly crosscutAdviceFactory: CrosscutAdviceFactory; - constructor(crosscutAdviceFactory: CrosscutAdviceFactory) { - this.crosscutAdviceFactory = crosscutAdviceFactory; - } + @Inject() + private readonly aopContextAdviceRegistry: AopContextAdviceRegistry; async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { for (const proto of loadUnit.iterateEggPrototype()) { @@ -39,6 +43,7 @@ export class LoadUnitAopHook implements LifecycleHook { afterEach(() => { mock.reset(); @@ -37,15 +45,15 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); + eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); + Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.registerLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.registerLifecycle(eggObjectAopHook); modules = await CoreTestHelper.prepareModules( [ - path.join(__dirname, '..'), path.join(__dirname, 'fixtures/modules/hello_succeed'), path.join(__dirname, 'fixtures/modules/hello_point_cut'), path.join(__dirname, 'fixtures/modules/state_point_cut'), @@ -166,8 +174,9 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); + eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); + Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.registerLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.registerLifecycle(eggObjectAopHook); @@ -176,7 +185,6 @@ describe('test/aop-runtime.test.ts', () => { it('should throw', async () => { await assert.rejects(async () => { await CoreTestHelper.prepareModules([ - path.join(__dirname, '..'), path.join(__dirname, 'fixtures/modules/should_throw'), ]); }, /Aop Advice\(PointcutAdvice\) not found in loadUnits/); @@ -193,15 +201,15 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); + eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); + Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.registerLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.registerLifecycle(eggObjectAopHook); modules = await CoreTestHelper.prepareModules( [ - path.join(__dirname, '..'), path.join(__dirname, 'fixtures/modules/constructor_inject_aop'), path.join(__dirname, 'fixtures/modules/hello_point_cut'), path.join(__dirname, 'fixtures/modules/hello_cross_cut'), diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index 25b8ea2aca..74d86b857a 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -36,6 +36,12 @@ const DEFAULT_READ_MODULE_REF_OPTS = { const CONFIG_NAMES_SLOT = Symbol('tegg:common-util:moduleConfigNames'); +export interface ResolvedModuleConfig { + name: string; + path: string; + config: ModuleConfig; +} + export class ModuleConfigUtil { // Per-app/per-Runner: each standalone Runner (and app) has distinct config // names (env-based); a process-global static races across them (the standalone @@ -80,15 +86,21 @@ export class ModuleConfigUtil { const pkgJson = path.posix.join(moduleReferenceConfig.package, 'package.json'); const file = importResolve(pkgJson, options); const modulePath = path.dirname(file); + const pkg = ModuleConfigUtil.readPackageJsonSync(modulePath); moduleReference = { path: modulePath, - name: ModuleConfigUtil.readModuleNameSync(modulePath), + name: ModuleConfigUtil.getModuleName(pkg), + package: ModuleConfigUtil.getPackageName(pkg), + ...(moduleReferenceConfig.optional === undefined ? {} : { optional: moduleReferenceConfig.optional }), }; } else if (ModuleReferenceConfigHelp.isInlineModuleReference(moduleReferenceConfig)) { const modulePath = path.join(configDir, moduleReferenceConfig.path); + const pkg = ModuleConfigUtil.readPackageJsonSync(modulePath); moduleReference = { path: modulePath, - name: ModuleConfigUtil.readModuleNameSync(modulePath), + name: ModuleConfigUtil.getModuleName(pkg), + package: ModuleConfigUtil.getPackageName(pkg), + ...(moduleReferenceConfig.optional === undefined ? {} : { optional: moduleReferenceConfig.optional }), }; } else { throw new Error('unknown type of module reference config: ' + JSON.stringify(moduleReferenceConfig)); @@ -138,15 +150,18 @@ export class ModuleConfigUtil { } moduleDirSet.add(moduleDir); + let pkg: any; let name: string; try { - name = this.readModuleNameSync(moduleDir); + pkg = this.readPackageJsonSync(moduleDir); + name = this.getModuleName(pkg); } catch { continue; } ref.push({ path: moduleDir, name, + package: this.getPackageName(pkg), }); } const moduleReferences = this.readModuleFromNodeModules(baseDir); @@ -157,10 +172,7 @@ export class ModuleConfigUtil { throw new Error('duplicate import of module reference: ' + moduleBasePath); } }); - ref.push({ - path: moduleReference.path, - name: moduleReference.name, - }); + ref.push(moduleReference); } return ref; } @@ -188,10 +200,11 @@ export class ModuleConfigUtil { const absolutePkgPath = path.dirname(packageJsonPath); const realPkgPath = fs.realpathSync(absolutePkgPath); try { - const name = this.readModuleNameSync(realPkgPath); + const pkg = this.readPackageJsonSync(realPkgPath); ref.push({ path: realPkgPath, - name, + name: this.getModuleName(pkg), + package: this.getPackageName(pkg), }); } catch { continue; @@ -213,6 +226,15 @@ export class ModuleConfigUtil { return pkg.eggModule.name; } + private static getPackageName(pkg: any): string | undefined { + return pkg.name; + } + + private static readPackageJsonSync(moduleDir: string): any { + const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); + return JSON.parse(pkgContent); + } + public static async readModuleName(baseDir: string, moduleDir: string): Promise { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); const pkgContent = await fsPromise.readFile(path.join(moduleDir, 'package.json'), 'utf8'); @@ -222,8 +244,7 @@ export class ModuleConfigUtil { public static readModuleNameSync(moduleDir: string, baseDir?: string): string { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); - const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); - const pkg = JSON.parse(pkgContent); + const pkg = ModuleConfigUtil.readPackageJsonSync(moduleDir); return ModuleConfigUtil.getModuleName(pkg); } @@ -306,6 +327,32 @@ export class ModuleConfigUtil { return target; } + public static resolveModuleConfigTolerant( + reference: ModuleReference, + baseDir?: string, + env?: string, + ): ResolvedModuleConfig { + if (!path.isAbsolute(reference.path)) { + assert(baseDir, 'baseDir is required for relative module reference path'); + } + const modulePath = path.isAbsolute(reference.path) ? reference.path : path.resolve(baseDir!, reference.path); + + if (!fs.existsSync(modulePath) && reference.name) { + return { + name: reference.name, + path: modulePath, + config: {}, + }; + } + + const name = ModuleConfigUtil.readModuleNameSync(modulePath); + return { + name, + path: modulePath, + config: ModuleConfigUtil.loadModuleConfigSync(modulePath, undefined, env), + }; + } + static #loadOneSync(moduleDir: string, configName: string): ModuleConfig | undefined { const yamlConfigPath = path.join(moduleDir, `${configName}.yml`); let config = ModuleConfigUtil.#loadYamlSync(yamlConfigPath); diff --git a/tegg/core/common-util/test/ModuleConfig.test.ts b/tegg/core/common-util/test/ModuleConfig.test.ts index 3679c2ff4d..4f47f50d51 100644 --- a/tegg/core/common-util/test/ModuleConfig.test.ts +++ b/tegg/core/common-util/test/ModuleConfig.test.ts @@ -54,21 +54,57 @@ describe('test/ModuleConfig.test.ts', () => { // }); }); + describe('resolve module config tolerant', () => { + it('should read existing module config', () => { + const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-module-json'); + const modulePath = path.join(fixturesPath, 'app/module-a'); + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant({ + path: modulePath, + name: 'moduleA', + }); + + assert.deepStrictEqual(resolved, { + name: 'moduleA', + path: modulePath, + config: {}, + }); + }); + + it('should use reference name and empty config when module dir does not exist', () => { + const baseDir = path.join(__dirname, './fixtures/apps/app-with-module-json'); + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant( + { + path: 'external/module-a', + name: 'externalModule', + }, + baseDir, + ); + + assert.deepStrictEqual(resolved, { + name: 'externalModule', + path: path.resolve(baseDir, 'external/module-a'), + config: {}, + }); + }); + }); + describe('load module reference', () => { describe('module.json not exits', () => { it('should work', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-no-module-json'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); assert.deepStrictEqual(ref, [ - { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }, - { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB' }, + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b' }, { path: path.join(fixturesPath, 'app/module-b/test/fixtures/module-e'), name: 'moduleE', + package: 'module-e', }, { path: path.join(fixturesPath, 'node_modules/module-c'), name: 'moduleC', + package: 'module-c', }, ]); }); @@ -88,7 +124,9 @@ describe('test/ModuleConfig.test.ts', () => { it('should work', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-symlink'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); - assert.deepStrictEqual(ref, [{ path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }]); + assert.deepStrictEqual(ref, [ + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + ]); }); }); }); @@ -98,8 +136,8 @@ describe('test/ModuleConfig.test.ts', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-module-json'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); assert.deepStrictEqual(ref, [ - { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }, - { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB' }, + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b', optional: true }, ]); }); }); @@ -114,6 +152,8 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.join(fixturesPath, 'node_modules/module-a'), name: 'moduleA', + package: 'module-a', + optional: true, }, ]); }); @@ -127,7 +167,9 @@ describe('test/ModuleConfig.test.ts', () => { extraFilePattern: ['!**/dist'], }; const ref = ModuleConfigUtil.readModuleReference(fixturesPath, readModuleOptions); - assert.deepStrictEqual(ref, [{ path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }]); + assert.deepStrictEqual(ref, [ + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + ]); }); }); }); @@ -146,10 +188,12 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/d/node_modules/e'), name: 'e', + package: 'e', }, { path: path.resolve(__dirname, './fixtures/monorepo/packages/d/node_modules/f'), name: 'f', + package: 'f', }, ]); }); @@ -161,6 +205,7 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/a/node_modules/c'), name: 'c', + package: 'c', }, ]); }); @@ -172,6 +217,7 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/a'), name: 'a', + package: 'b', }, ]); }); diff --git a/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json b/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json index 1d83090be1..fe99c54f79 100644 --- a/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json +++ b/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json @@ -1 +1 @@ -[{ "path": "../app/module-a" }, { "path": "../app/module-b" }] +[{ "path": "../app/module-a" }, { "path": "../app/module-b", "optional": true }] diff --git a/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json b/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json index c1552b1b26..2efcce69b3 100644 --- a/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json +++ b/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json @@ -1 +1 @@ -[{ "package": "module-a" }] +[{ "package": "module-a", "optional": true }] diff --git a/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts b/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts new file mode 100644 index 0000000000..9e3a7e0a36 --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts @@ -0,0 +1,16 @@ +import { DefineModuleQualifierAttribute } from '@eggjs/tegg-types'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +import { QualifierUtil } from '../util/index.ts'; + +export function DefineModuleQualifier(moduleName: string) { + return function (target: any, propertyKey?: PropertyKey, parameterIndex?: number): void { + QualifierUtil.addInjectQualifier( + target as EggProtoImplClass, + propertyKey, + parameterIndex, + DefineModuleQualifierAttribute, + moduleName, + ); + }; +} diff --git a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts new file mode 100644 index 0000000000..725be55f5f --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert'; + +import type { + CommonEggLifecycleProtoParams, + EggLifecycleProtoParams, + EggLifecycleType, + EggProtoImplClass, +} from '@eggjs/tegg-types'; + +import { PrototypeUtil } from '../util/PrototypeUtil.ts'; +import { InnerObjectProto } from './InnerObjectProto.ts'; +import type { PrototypeDecorator } from './Prototype.ts'; + +export function EggLifecycleProto(params: CommonEggLifecycleProtoParams): PrototypeDecorator { + return function (clazz: EggProtoImplClass) { + const { type, ...protoParams } = params || {}; + assert(type, 'EggLifecycle decorator should have type property'); + + InnerObjectProto(protoParams)(clazz); + + PrototypeUtil.setIsEggLifecyclePrototype(clazz); + PrototypeUtil.setEggLifecyclePrototypeMetadata(clazz, { type }); + }; +} + +type EggLifecycleProtoDecoratorFactory = (params?: EggLifecycleProtoParams) => PrototypeDecorator; + +const createLifecycleProto = (type: EggLifecycleType): EggLifecycleProtoDecoratorFactory => { + return (params?: EggLifecycleProtoParams) => EggLifecycleProto({ type, ...params }); +}; + +export const LoadUnitLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('LoadUnit'); +export const LoadUnitInstanceLifecycleProto: EggLifecycleProtoDecoratorFactory = + createLifecycleProto('LoadUnitInstance'); +export const EggObjectLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggObject'); +export const EggPrototypeLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggPrototype'); +export const EggContextLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggContext'); diff --git a/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts b/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts new file mode 100644 index 0000000000..b0c91ffea0 --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts @@ -0,0 +1,17 @@ +import { EGG_INNER_OBJECT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; +import type { EggProtoImplClass, InnerObjectProtoParams } from '@eggjs/tegg-types'; + +import { PrototypeUtil } from '../util/PrototypeUtil.ts'; +import type { PrototypeDecorator } from './Prototype.ts'; +import { SingletonProto } from './SingletonProto.ts'; + +export function InnerObjectProto(params?: InnerObjectProtoParams): PrototypeDecorator { + return function (clazz: EggProtoImplClass) { + const protoParams = { + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + ...params, + }; + SingletonProto(protoParams)(clazz); + PrototypeUtil.setIsEggInnerObject(clazz); + }; +} diff --git a/tegg/core/core-decorator/src/decorator/index.ts b/tegg/core/core-decorator/src/decorator/index.ts index accc6f9c9f..a6925c75cb 100644 --- a/tegg/core/core-decorator/src/decorator/index.ts +++ b/tegg/core/core-decorator/src/decorator/index.ts @@ -1,8 +1,11 @@ export * from './ConfigSource.ts'; export * from './ContextProto.ts'; +export * from './DefineModuleQualifier.ts'; +export * from './EggLifecycleProto.ts'; export * from './EggQualifier.ts'; export * from './InitTypeQualifier.ts'; export * from './Inject.ts'; +export * from './InnerObjectProto.ts'; export * from './ModuleQualifier.ts'; export * from './MultiInstanceInfo.ts'; export * from './MultiInstanceProto.ts'; diff --git a/tegg/core/core-decorator/src/util/PrototypeUtil.ts b/tegg/core/core-decorator/src/util/PrototypeUtil.ts index 6734520609..b82a594812 100644 --- a/tegg/core/core-decorator/src/util/PrototypeUtil.ts +++ b/tegg/core/core-decorator/src/util/PrototypeUtil.ts @@ -1,4 +1,5 @@ import { + type EggLifecycleInfo, type EggMultiInstanceCallbackPrototypeInfo, type EggMultiInstancePrototypeInfo, type EggProtoImplClass, @@ -37,6 +38,9 @@ export class PrototypeUtil { static readonly MULTI_INSTANCE_CONSTRUCTOR_ATTRIBUTES: symbol = Symbol.for( 'EggPrototype#multiInstanceConstructorAttributes', ); + static readonly IS_EGG_INNER_OBJECT: symbol = Symbol.for('EggPrototype#isEggInnerObject'); + static readonly IS_EGG_LIFECYCLE_PROTOTYPE: symbol = Symbol.for('EggPrototype#isEggLifecyclePrototype'); + static readonly EGG_LIFECYCLE_PROTOTYPE_METADATA: symbol = Symbol.for('EggPrototype#eggLifecyclePrototype#metadata'); /** * Mark class is egg object prototype @@ -70,6 +74,58 @@ export class PrototypeUtil { return MetadataUtil.getOwnBooleanMetaData(PrototypeUtil.IS_EGG_OBJECT_MULTI_INSTANCE_PROTOTYPE, clazz); } + /** + * Mark class is egg inner object prototype + * @param {Function} clazz - + */ + static setIsEggInnerObject(clazz: EggProtoImplClass): void { + MetadataUtil.defineMetaData(PrototypeUtil.IS_EGG_INNER_OBJECT, true, clazz); + } + + /** + * If class is egg inner object prototype, return true + * @param {Function} clazz - + */ + static isEggInnerObject(clazz: EggProtoImplClass): boolean { + return MetadataUtil.getOwnBooleanMetaData(PrototypeUtil.IS_EGG_INNER_OBJECT, clazz); + } + + /** + * Mark class is egg lifecycle prototype + * @param {Function} clazz - + */ + static setIsEggLifecyclePrototype(clazz: EggProtoImplClass): void { + MetadataUtil.defineMetaData(PrototypeUtil.IS_EGG_LIFECYCLE_PROTOTYPE, true, clazz); + } + + /** + * If class is egg lifecycle prototype, return true + * @param {Function} clazz - + */ + static isEggLifecyclePrototype(clazz: EggProtoImplClass): boolean { + return MetadataUtil.getOwnBooleanMetaData(PrototypeUtil.IS_EGG_LIFECYCLE_PROTOTYPE, clazz); + } + + /** + * Set egg lifecycle prototype metadata, like the lifecycle type + * @param {Function} clazz - + * @param {EggLifecycleInfo} metadata - + */ + static setEggLifecyclePrototypeMetadata(clazz: EggProtoImplClass, metadata: EggLifecycleInfo): void { + MetadataUtil.defineMetaData(PrototypeUtil.EGG_LIFECYCLE_PROTOTYPE_METADATA, metadata, clazz); + } + + /** + * Get egg lifecycle prototype metadata + * @param {Function} clazz - + */ + static getEggLifecyclePrototypeMetadata(clazz: EggProtoImplClass): EggLifecycleInfo | undefined { + if (!PrototypeUtil.isEggLifecyclePrototype(clazz)) { + return undefined; + } + return MetadataUtil.getOwnMetaData(PrototypeUtil.EGG_LIFECYCLE_PROTOTYPE_METADATA, clazz); + } + /** * Get the type of the egg multi-instance prototype. * @param {Function} clazz - diff --git a/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap b/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap index c7f70a07e7..35afc58349 100644 --- a/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap @@ -11,6 +11,13 @@ exports[`should export stable 1`] = ` "ConfigSourceQualifierAttribute": Symbol(Qualifier.ConfigSource), "ContextProto": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "DefineModuleQualifier": [Function], + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), + "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", + "EggContextLifecycleProto": [Function], + "EggLifecycleProto": [Function], + "EggObjectLifecycleProto": [Function], + "EggPrototypeLifecycleProto": [Function], "EggQualifier": [Function], "EggQualifierAttribute": Symbol(Qualifier.Egg), "EggType": { @@ -30,6 +37,9 @@ exports[`should export stable 1`] = ` "CONSTRUCTOR": "CONSTRUCTOR", "PROPERTY": "PROPERTY", }, + "InnerObjectProto": [Function], + "LoadUnitInstanceLifecycleProto": [Function], + "LoadUnitLifecycleProto": [Function], "LoadUnitNameQualifierAttribute": Symbol(Qualifier.LoadUnitName), "MetadataUtil": [Function], "ModuleQualifier": [Function], diff --git a/tegg/core/core-decorator/test/decorators.test.ts b/tegg/core/core-decorator/test/decorators.test.ts index d9da64cd0a..cc6113971e 100644 --- a/tegg/core/core-decorator/test/decorators.test.ts +++ b/tegg/core/core-decorator/test/decorators.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { AccessLevel, + DefineModuleQualifierAttribute, ObjectInitType, LoadUnitNameQualifierAttribute, InitTypeQualifierAttribute, @@ -134,6 +135,10 @@ describe('test/decorators.test.ts', () => { QualifierUtil.getProperQualifier(QualifierCacheService, property, InitTypeQualifierAttribute) === ObjectInitType.SINGLETON, ); + assert( + QualifierUtil.getProperQualifier(QualifierCacheService, property, DefineModuleQualifierAttribute) === + 'define-module', + ); }); it('should set default initType in inject', () => { diff --git a/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts b/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts index 91d3219201..293cbf2979 100644 --- a/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts +++ b/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts @@ -1,6 +1,13 @@ import { ObjectInitType } from '@eggjs/tegg-types'; -import { ContextProto, InitTypeQualifier, Inject, ModuleQualifier, SingletonProto } from '../../../src/index.ts'; +import { + ContextProto, + DefineModuleQualifier, + InitTypeQualifier, + Inject, + ModuleQualifier, + SingletonProto, +} from '../../../src/index.ts'; import { type ICache } from './ICache.ts'; @ContextProto() @@ -15,6 +22,7 @@ export default class CacheService { name: 'fooCache', }) @InitTypeQualifier(ObjectInitType.SINGLETON) + @DefineModuleQualifier('define-module') @ModuleQualifier('foo') cache: ICache; diff --git a/tegg/core/core-decorator/test/inner-object-decorators.test.ts b/tegg/core/core-decorator/test/inner-object-decorators.test.ts new file mode 100644 index 0000000000..4eeab2158d --- /dev/null +++ b/tegg/core/core-decorator/test/inner-object-decorators.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; + +import type { EggPrototypeInfo } from '@eggjs/tegg-types'; +import { AccessLevel, EGG_INNER_OBJECT_PROTO_IMPL_TYPE, ObjectInitType } from '@eggjs/tegg-types'; +import { describe, it } from 'vitest'; + +import type { EggProtoImplClass } from '../src/index.ts'; +import { + EggContextLifecycleProto, + EggLifecycleProto, + EggObjectLifecycleProto, + EggPrototypeLifecycleProto, + InnerObjectProto, + LoadUnitInstanceLifecycleProto, + LoadUnitLifecycleProto, + PrototypeUtil, +} from '../src/index.ts'; + +@InnerObjectProto() +class Router {} + +@InnerObjectProto({ + accessLevel: AccessLevel.PUBLIC, + name: 'customRouter', +}) +class OtherRouter {} + +@LoadUnitLifecycleProto() +class ControllerLoadUnitLifecycle {} + +@LoadUnitInstanceLifecycleProto() +class ControllerLoadUnitInstanceLifecycle {} + +@EggObjectLifecycleProto() +class ControllerObjectLifecycle {} + +@EggPrototypeLifecycleProto() +class ControllerPrototypeLifecycle {} + +@EggContextLifecycleProto() +class ControllerContextLifecycle {} + +@EggLifecycleProto({ + type: 'EggObject', + name: 'customName', + accessLevel: AccessLevel.PUBLIC, +}) +class CustomNamedLifecycle {} + +describe('core/core-decorator/test/inner-object-decorators.test.ts', () => { + describe('InnerObjectProto', () => { + it('should work', () => { + assert(PrototypeUtil.isEggPrototype(Router)); + assert(PrototypeUtil.isEggInnerObject(Router)); + assert(!PrototypeUtil.isEggLifecyclePrototype(Router)); + const expectObjectProperty: EggPrototypeInfo = { + name: 'router', + initType: ObjectInitType.SINGLETON, + accessLevel: AccessLevel.PRIVATE, + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + className: 'Router', + }; + assert.deepEqual(PrototypeUtil.getProperty(Router), expectObjectProperty); + }); + + it('should params work', () => { + const expectObjectProperty: EggPrototypeInfo = { + name: 'customRouter', + initType: ObjectInitType.SINGLETON, + accessLevel: AccessLevel.PUBLIC, + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + className: 'OtherRouter', + }; + assert.deepEqual(PrototypeUtil.getProperty(OtherRouter), expectObjectProperty); + }); + + it('should not mark plain prototypes', () => { + assert(!PrototypeUtil.isEggInnerObject(class Foo {})); + }); + }); + + describe('EggLifecycleProto', () => { + const assertLifecycleProtoMetadata = (clazz: EggProtoImplClass, type: string) => { + assert(PrototypeUtil.isEggPrototype(clazz)); + assert(PrototypeUtil.isEggInnerObject(clazz)); + assert(PrototypeUtil.isEggLifecyclePrototype(clazz)); + const expectObjectProperty: EggPrototypeInfo = { + name: clazz.name.replace(/^./, (c) => c.toLowerCase()), + initType: ObjectInitType.SINGLETON, + accessLevel: AccessLevel.PRIVATE, + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + className: clazz.name, + }; + assert.deepEqual(PrototypeUtil.getProperty(clazz), expectObjectProperty); + assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(clazz), { type }); + }; + + it('should work for the five lifecycle protos', () => { + assertLifecycleProtoMetadata(ControllerLoadUnitLifecycle, 'LoadUnit'); + assertLifecycleProtoMetadata(ControllerLoadUnitInstanceLifecycle, 'LoadUnitInstance'); + assertLifecycleProtoMetadata(ControllerObjectLifecycle, 'EggObject'); + assertLifecycleProtoMetadata(ControllerPrototypeLifecycle, 'EggPrototype'); + assertLifecycleProtoMetadata(ControllerContextLifecycle, 'EggContext'); + }); + + it('should params work with explicit supported lifecycle type', () => { + const expectObjectProperty: EggPrototypeInfo = { + name: 'customName', + initType: ObjectInitType.SINGLETON, + accessLevel: AccessLevel.PUBLIC, + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + className: 'CustomNamedLifecycle', + }; + assert.deepEqual(PrototypeUtil.getProperty(CustomNamedLifecycle), expectObjectProperty); + assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(CustomNamedLifecycle), { + type: 'EggObject', + }); + }); + + it('should throw without type', () => { + assert.throws(() => { + EggLifecycleProto({} as any)(class Foo {}); + }, /EggLifecycle decorator should have type property/); + }); + + it('should return undefined metadata for non lifecycle proto', () => { + assert.equal(PrototypeUtil.getEggLifecyclePrototypeMetadata(Router), undefined); + }); + }); +}); diff --git a/tegg/core/loader/src/LoaderFactory.ts b/tegg/core/loader/src/LoaderFactory.ts index 2ac1e231eb..5c5be597c0 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -1,6 +1,6 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; import type { LoaderFS } from '@eggjs/loader-fs'; -import type { ModuleDescriptor } from '@eggjs/metadata'; +import { ModuleDescriptorDumper, type ModuleDescriptor } from '@eggjs/metadata'; import { EggLoadUnitType, type EggLoadUnitTypeLike, @@ -13,6 +13,7 @@ export type LoaderCreator = (unitPath: string, loaderFS?: LoaderFS) => Loader; export interface ManifestModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; @@ -34,6 +35,27 @@ export interface TeggManifestExtension { export const TEGG_MANIFEST_KEY = 'tegg'; +export function buildTeggManifestData( + moduleReferences: readonly ModuleReference[], + moduleDescriptors: readonly ModuleDescriptor[], +): TeggManifestExtension { + return { + moduleReferences: moduleReferences.map((ref) => ({ + name: ref.name, + package: ref.package, + 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), + })), + }; +} + export interface LoadAppManifest { moduleDescriptors: ManifestModuleDescriptor[]; } @@ -95,12 +117,16 @@ export class LoaderFactory { clazzList: [], protos: [], multiInstanceClazzList, + innerObjectClazzList: [], optional: moduleReference.optional, }; result.push(res); const clazzList = await loader.load(); for (const clazz of clazzList) { - if (PrototypeUtil.isEggPrototype(clazz)) { + // Inner object protos are also egg prototypes, so this branch must come first. + if (PrototypeUtil.isEggInnerObject(clazz)) { + res.innerObjectClazzList.push(clazz); + } else if (PrototypeUtil.isEggPrototype(clazz)) { res.clazzList.push(clazz); } else if (PrototypeUtil.isEggMultiInstancePrototype(clazz)) { res.multiInstanceClazzList.push(clazz); diff --git a/tegg/core/loader/test/LoaderInnerObject.test.ts b/tegg/core/loader/test/LoaderInnerObject.test.ts new file mode 100644 index 0000000000..e4d84c7224 --- /dev/null +++ b/tegg/core/loader/test/LoaderInnerObject.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { ModuleDescriptorDumper } from '@eggjs/metadata'; +import { EggLoadUnitType } from '@eggjs/tegg-types'; +import type { ModuleReference } from '@eggjs/tegg-types'; +import { describe, it } from 'vitest'; + +import { LoaderFactory } from '../src/index.ts'; + +describe('core/loader/test/LoaderInnerObject.test.ts', () => { + const modulePath = path.join(__dirname, './fixtures/modules/module-with-inner-object'); + const moduleRef: ModuleReference = { + name: 'inner-object-module', + path: modulePath, + loaderType: EggLoadUnitType.MODULE, + }; + + it('should divert inner object clazz to innerObjectClazzList', async () => { + const [descriptor] = await LoaderFactory.loadApp([moduleRef]); + + const innerNames = descriptor.innerObjectClazzList.map((t) => t.name).sort(); + assert.deepEqual(innerNames, ['ControllerHook', 'FetchRouter']); + + // Inner object classes must NOT stay in clazzList. + const clazzNames = descriptor.clazzList.map((t) => t.name); + assert.deepEqual(clazzNames, ['HelloService']); + assert.deepEqual(descriptor.multiInstanceClazzList, []); + }); + + it('should record inner object files in decoratedFiles for manifest', async () => { + const [descriptor] = await LoaderFactory.loadApp([moduleRef]); + const files = ModuleDescriptorDumper.getDecoratedFiles(descriptor).sort(); + assert.deepEqual(files, ['ControllerHook.ts', 'FetchRouter.ts', 'HelloService.ts']); + }); + + it('should dump innerObjectClazzList in module descriptor json', async () => { + const [descriptor] = await LoaderFactory.loadApp([moduleRef]); + const json = JSON.parse(ModuleDescriptorDumper.stringifyDescriptor(descriptor)); + assert.deepEqual(json.innerObjectClazzList.map((t: { name: string }) => t.name).sort(), [ + 'ControllerHook', + 'FetchRouter', + ]); + }); +}); diff --git a/tegg/core/loader/test/__snapshots__/index.test.ts.snap b/tegg/core/loader/test/__snapshots__/index.test.ts.snap index 55d1af2d9a..b1442790fd 100644 --- a/tegg/core/loader/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/loader/test/__snapshots__/index.test.ts.snap @@ -6,5 +6,6 @@ exports[`should export stable 1`] = ` "LoaderUtil": [Function], "ModuleLoader": [Function], "TEGG_MANIFEST_KEY": "tegg", + "buildTeggManifestData": [Function], } `; diff --git a/tegg/core/loader/test/fixtures/modules/module-with-inner-object/ControllerHook.ts b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/ControllerHook.ts new file mode 100644 index 0000000000..15a809d13d --- /dev/null +++ b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/ControllerHook.ts @@ -0,0 +1,13 @@ +import { Inject, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; + +import type { FetchRouter } from './FetchRouter.ts'; + +@LoadUnitLifecycleProto() +export class ControllerHook { + @Inject() + fetchRouter: FetchRouter; + + async postCreate(): Promise { + return; + } +} diff --git a/tegg/core/loader/test/fixtures/modules/module-with-inner-object/FetchRouter.ts b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/FetchRouter.ts new file mode 100644 index 0000000000..04626006c0 --- /dev/null +++ b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/FetchRouter.ts @@ -0,0 +1,8 @@ +import { InnerObjectProto } from '@eggjs/core-decorator'; + +@InnerObjectProto() +export class FetchRouter { + routes(): string[] { + return []; + } +} diff --git a/tegg/core/loader/test/fixtures/modules/module-with-inner-object/HelloService.ts b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/HelloService.ts new file mode 100644 index 0000000000..f3317526df --- /dev/null +++ b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/HelloService.ts @@ -0,0 +1,8 @@ +import { SingletonProto } from '@eggjs/core-decorator'; + +@SingletonProto() +export class HelloService { + hello(): string { + return 'hello'; + } +} diff --git a/tegg/core/loader/test/fixtures/modules/module-with-inner-object/package.json b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/package.json new file mode 100644 index 0000000000..6264f1ec79 --- /dev/null +++ b/tegg/core/loader/test/fixtures/modules/module-with-inner-object/package.json @@ -0,0 +1,7 @@ +{ + "name": "module-with-inner-object", + "type": "module", + "eggModule": { + "name": "inner-object-module" + } +} diff --git a/tegg/core/metadata/src/errors.ts b/tegg/core/metadata/src/errors.ts index ebc593e9b2..c7b0f4e660 100644 --- a/tegg/core/metadata/src/errors.ts +++ b/tegg/core/metadata/src/errors.ts @@ -2,6 +2,10 @@ import { FrameworkBaseError } from '@eggjs/errors'; import { ErrorCodes } from '@eggjs/tegg-types'; import type { EggPrototypeName, QualifierInfo } from '@eggjs/tegg-types'; +function formatQualifiers(qualifiers: readonly QualifierInfo[]): string { + return `[${qualifiers.map((qualifier) => `${String(qualifier.attribute)}=${String(qualifier.value)}`).join(',')}]`; +} + export class TeggError extends FrameworkBaseError { get module() { return 'TEGG'; @@ -19,7 +23,7 @@ export class EggPrototypeNotFound extends TeggError { export class MultiPrototypeFound extends TeggError { constructor(name: EggPrototypeName, qualifier: QualifierInfo[], result?: string) { - const msg = `multi proto found for name:${String(name)} and qualifiers ${JSON.stringify(qualifier)}${ + const msg = `multi proto found for name:${String(name)} and qualifiers ${formatQualifiers(qualifier)}${ result ? `, result is ${result}` : '' }`; super(msg, ErrorCodes.MULTI_PROTO_FOUND); diff --git a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts index 400b8e2c76..dfbc21824e 100644 --- a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts +++ b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts @@ -1,7 +1,7 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; import { FrameworkErrorFormatter } from '@eggjs/errors'; import { MapUtil } from '@eggjs/tegg-common-util'; -import { AccessLevel, TeggScope } from '@eggjs/tegg-types'; +import { AccessLevel, DefineModuleQualifierAttribute, TeggScope } from '@eggjs/tegg-types'; import type { EggProtoImplClass, EggPrototypeName, @@ -98,7 +98,9 @@ export class EggPrototypeFactory { if (protos.length === 1) { return protos[0]; } - throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(name, qualifiers)); + throw FrameworkErrorFormatter.formatError( + new MultiPrototypeFound(name, qualifiers, JSON.stringify(protos.map(EggPrototypeFactory.formatPrototype))), + ); } private doGetPrototype(name: EggPrototypeName, qualifiers: QualifierInfo[], loadUnit?: LoadUnit): EggPrototype[] { @@ -114,4 +116,13 @@ export class EggPrototypeFactory { const protos = this.publicProtoMap.get(name); return protos?.filter((proto) => proto.verifyQualifiers(qualifiers)) || []; } + + private static formatPrototype(proto: EggPrototype): string { + return ( + `${String(proto.name)}@${proto.loadUnitId}` + + ` define:${String(proto.defineModuleName ?? proto.getQualifier(DefineModuleQualifierAttribute))}` + + `@${String(proto.defineUnitPath ?? proto.loadUnitId)}` + + ` qualifiers:[${String(DefineModuleQualifierAttribute)}=${String(proto.getQualifier(DefineModuleQualifierAttribute))}]` + ); + } } diff --git a/tegg/core/metadata/src/factory/LoadUnitFactory.ts b/tegg/core/metadata/src/factory/LoadUnitFactory.ts index 7996899113..413eef6ebf 100644 --- a/tegg/core/metadata/src/factory/LoadUnitFactory.ts +++ b/tegg/core/metadata/src/factory/LoadUnitFactory.ts @@ -45,13 +45,19 @@ export class LoadUnitFactory { return await creator(ctx); } - static async createLoadUnit(unitPath: string, type: EggLoadUnitTypeLike, loader: Loader): Promise { + static async createLoadUnit( + unitPath: string, + type: EggLoadUnitTypeLike, + loader: Loader, + unitName?: string, + ): Promise { const loadUnitMap = LoadUnitFactory.loadUnitMap; if (loadUnitMap.has(unitPath)) { return loadUnitMap.get(unitPath)!.loadUnit; } const ctx: LoadUnitLifecycleContext = { unitPath, + unitName, loader, }; const loadUnit = await LoadUnitFactory.getLoanUnit(ctx, type); @@ -65,9 +71,15 @@ export class LoadUnitFactory { return loadUnit; } - static async createPreloadLoadUnit(unitPath: string, type: EggLoadUnitTypeLike, loader: Loader): Promise { + static async createPreloadLoadUnit( + unitPath: string, + type: EggLoadUnitTypeLike, + loader: Loader, + unitName?: string, + ): Promise { const ctx: LoadUnitLifecycleContext = { unitPath, + unitName, loader, }; return await LoadUnitFactory.getLoanUnit(ctx, type); diff --git a/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts new file mode 100644 index 0000000000..7ab3ac5131 --- /dev/null +++ b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts @@ -0,0 +1,17 @@ +import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; +import { EGG_INNER_OBJECT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; + +import { EggPrototypeCreatorFactory } from '../factory/EggPrototypeCreatorFactory.ts'; +import { EggPrototypeBuilder } from './EggPrototypeBuilder.ts'; +import { EggPrototypeImpl } from './EggPrototypeImpl.ts'; + +export class EggInnerObjectPrototypeImpl extends EggPrototypeImpl { + static create(ctx: EggPrototypeLifecycleContext): EggPrototype { + return EggPrototypeBuilder.createWithProtoImpl(ctx, EggInnerObjectPrototypeImpl); + } +} + +EggPrototypeCreatorFactory.registerPrototypeCreator( + EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + EggInnerObjectPrototypeImpl.create, +); diff --git a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts index bac614e15c..a23075a218 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; -import { InjectType, PrototypeUtil, type QualifierAttribute, QualifierUtil } from '@eggjs/core-decorator'; +import { type InjectType, PrototypeUtil, type QualifierAttribute, QualifierUtil } from '@eggjs/core-decorator'; import { IdenticalUtil } from '@eggjs/lifecycle'; import type { AccessLevel, @@ -9,22 +9,36 @@ import type { EggPrototypeLifecycleContext, EggPrototypeName, InjectConstructor, + InjectConstructorProto, InjectObject, InjectObjectProto, LoadUnit, ObjectInitTypeLike, QualifierInfo, } from '@eggjs/tegg-types'; -import { - DEFAULT_PROTO_IMPL_TYPE, - InitTypeQualifierAttribute, - type InjectConstructorProto, - ObjectInitType, -} from '@eggjs/tegg-types'; +import { DEFAULT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; -import { EggPrototypeNotFound, MultiPrototypeFound } from '../errors.ts'; -import { EggPrototypeFactory, EggPrototypeCreatorFactory } from '../factory/index.ts'; +import { EggPrototypeCreatorFactory } from '../factory/index.ts'; import { EggPrototypeImpl } from './EggPrototypeImpl.ts'; +import { InjectObjectPrototypeFinder } from './InjectObjectPrototypeFinder.ts'; + +export type EggPrototypeImplClass = new ( + id: string, + name: EggPrototypeName, + clazz: EggProtoImplClass, + filepath: string, + initType: ObjectInitTypeLike, + accessLevel: AccessLevel, + injectObjectProtos: Array, + loadUnitId: string, + qualifiers: QualifierInfo[], + className?: string, + injectType?: InjectType, + multiInstanceConstructorIndex?: number, + multiInstanceConstructorAttributes?: QualifierAttribute[], + defineModuleName?: string, + defineUnitPath?: string, +) => EggPrototype; export class EggPrototypeBuilder { private clazz: EggProtoImplClass; @@ -40,131 +54,56 @@ export class EggPrototypeBuilder { private className?: string; private multiInstanceConstructorIndex?: number; private multiInstanceConstructorAttributes?: QualifierAttribute[]; + private defineModuleName?: string; + private defineUnitPath?: string; + private protoImplClass: EggPrototypeImplClass = EggPrototypeImpl; static create(ctx: EggPrototypeLifecycleContext): EggPrototype { + return EggPrototypeBuilder.createWithProtoImpl(ctx, EggPrototypeImpl); + } + + static createWithProtoImpl(ctx: EggPrototypeLifecycleContext, protoImplClass: EggPrototypeImplClass): EggPrototype { const { clazz, loadUnit } = ctx; + const prototypeInfo = ctx.prototypeInfo as typeof ctx.prototypeInfo & { + defineModuleName?: string; + defineUnitPath?: string; + }; const filepath = PrototypeUtil.getFilePath(clazz); assert(filepath, 'not find filepath'); const builder = new EggPrototypeBuilder(); + builder.protoImplClass = protoImplClass; builder.clazz = clazz; - builder.name = ctx.prototypeInfo.name; - builder.className = ctx.prototypeInfo.className; - builder.initType = ctx.prototypeInfo.initType; - builder.accessLevel = ctx.prototypeInfo.accessLevel; + builder.name = prototypeInfo.name; + builder.className = prototypeInfo.className; + builder.initType = prototypeInfo.initType; + builder.accessLevel = prototypeInfo.accessLevel; builder.filepath = filepath!; builder.injectType = PrototypeUtil.getInjectType(clazz); builder.injectObjects = PrototypeUtil.getInjectObjects(clazz) || []; builder.loadUnit = loadUnit; builder.qualifiers = QualifierUtil.mergeQualifiers( QualifierUtil.getProtoQualifiers(clazz), - ctx.prototypeInfo.qualifiers ?? [], + prototypeInfo.qualifiers ?? [], ); - builder.properQualifiers = ctx.prototypeInfo.properQualifiers ?? {}; + builder.properQualifiers = prototypeInfo.properQualifiers ?? {}; builder.multiInstanceConstructorIndex = PrototypeUtil.getMultiInstanceConstructorIndex(clazz); builder.multiInstanceConstructorAttributes = PrototypeUtil.getMultiInstanceConstructorAttributes(clazz); + builder.defineModuleName = prototypeInfo.defineModuleName; + builder.defineUnitPath = prototypeInfo.defineUnitPath; return builder.build(); } - private tryFindDefaultPrototype(injectObject: InjectObject | InjectConstructor): EggPrototype { - const propertyQualifiers = QualifierUtil.getProperQualifiers(this.clazz, injectObject.refName); - const multiInstancePropertyQualifiers = this.properQualifiers[injectObject.refName as string] ?? []; - return EggPrototypeFactory.instance.getPrototype( - injectObject.objName, - this.loadUnit, - QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers), - ); - } - - private tryFindContextPrototype(injectObject: InjectObject | InjectConstructor): EggPrototype { - const propertyQualifiers = QualifierUtil.getProperQualifiers(this.clazz, injectObject.refName); - const multiInstancePropertyQualifiers = this.properQualifiers[injectObject.refName as string] ?? []; - return EggPrototypeFactory.instance.getPrototype( - injectObject.objName, - this.loadUnit, - QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers, [ - { - attribute: InitTypeQualifierAttribute, - value: ObjectInitType.CONTEXT, - }, - ]), - ); - } - - private tryFindSelfInitTypePrototype(injectObject: InjectObject | InjectConstructor): EggPrototype { - const propertyQualifiers = QualifierUtil.getProperQualifiers(this.clazz, injectObject.refName); - const multiInstancePropertyQualifiers = this.properQualifiers[injectObject.refName as string] ?? []; - return EggPrototypeFactory.instance.getPrototype( - injectObject.objName, - this.loadUnit, - QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers, [ - { - attribute: InitTypeQualifierAttribute, - value: this.initType, - }, - ]), - ); - } - - private findInjectObjectPrototype(injectObject: InjectObject | InjectConstructor): EggPrototype { - const propertyQualifiers = QualifierUtil.getProperQualifiers(this.clazz, injectObject.refName); - try { - return this.tryFindDefaultPrototype(injectObject); - } catch (e) { - if ( - !( - e instanceof MultiPrototypeFound && - !propertyQualifiers.find((t) => t.attribute === InitTypeQualifierAttribute) - ) - ) { - throw e; - } - } - try { - return this.tryFindContextPrototype(injectObject); - } catch (e) { - if (!(e instanceof EggPrototypeNotFound)) { - throw e; - } - } - return this.tryFindSelfInitTypePrototype(injectObject); - } - public build(): EggPrototype { - const injectObjectProtos: Array = []; - for (const injectObject of this.injectObjects) { - const propertyQualifiers = QualifierUtil.getProperQualifiers(this.clazz, injectObject.refName); - try { - const proto = this.findInjectObjectPrototype(injectObject); - let injectObjectProto: InjectObjectProto | InjectConstructorProto; - if (this.injectType === InjectType.PROPERTY) { - injectObjectProto = { - refName: injectObject.refName, - objName: injectObject.objName, - qualifiers: propertyQualifiers, - proto, - }; - } else { - injectObjectProto = { - refIndex: (injectObject as InjectConstructor).refIndex, - refName: injectObject.refName, - objName: injectObject.objName, - qualifiers: propertyQualifiers, - proto, - }; - } - if (injectObject.optional) { - injectObject.optional = true; - } - injectObjectProtos.push(injectObjectProto); - } catch (e) { - if (e instanceof EggPrototypeNotFound && injectObject.optional) { - continue; - } - throw e; - } - } + const injectObjectProtos = InjectObjectPrototypeFinder.findInjectObjectPrototypes({ + clazz: this.clazz, + loadUnit: this.loadUnit, + properQualifiers: this.properQualifiers, + initType: this.initType, + injectType: this.injectType, + injectObjects: this.injectObjects, + }); const id = IdenticalUtil.createProtoId(this.loadUnit.id, this.name); - return new EggPrototypeImpl( + return new this.protoImplClass( id, this.name, this.clazz, @@ -178,6 +117,8 @@ export class EggPrototypeBuilder { this.injectType, this.multiInstanceConstructorIndex, this.multiInstanceConstructorAttributes, + this.defineModuleName, + this.defineUnitPath, ); } } diff --git a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts index 8e329cc417..1845a9a1a1 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts @@ -25,6 +25,8 @@ export class EggPrototypeImpl implements EggPrototype { readonly injectObjects: Array; readonly injectType: InjectType; readonly loadUnitId: Id; + readonly defineModuleName?: string; + readonly defineUnitPath?: string; readonly className?: string; readonly multiInstanceConstructorIndex?: number; readonly multiInstanceConstructorAttributes?: QualifierAttribute[]; @@ -44,6 +46,8 @@ export class EggPrototypeImpl implements EggPrototype { injectType?: InjectType, multiInstanceConstructorIndex?: number, multiInstanceConstructorAttributes?: QualifierAttribute[], + defineModuleName?: string, + defineUnitPath?: string, ) { this.id = id; this.clazz = clazz; @@ -58,6 +62,8 @@ export class EggPrototypeImpl implements EggPrototype { this.injectType = injectType || InjectType.PROPERTY; this.multiInstanceConstructorIndex = multiInstanceConstructorIndex; this.multiInstanceConstructorAttributes = multiInstanceConstructorAttributes; + this.defineModuleName = defineModuleName; + this.defineUnitPath = defineUnitPath; } verifyQualifiers(qualifiers: QualifierInfo[]): boolean { @@ -74,7 +80,7 @@ export class EggPrototypeImpl implements EggPrototype { return selfQualifiers?.value === qualifier.value; } - getQualifier(attribute: string): QualifierValue | undefined { + getQualifier(attribute: QualifierAttribute): QualifierValue | undefined { return this.qualifiers.find((t) => t.attribute === attribute)?.value; } diff --git a/tegg/core/metadata/src/impl/InjectObjectPrototypeFinder.ts b/tegg/core/metadata/src/impl/InjectObjectPrototypeFinder.ts new file mode 100644 index 0000000000..24ec1e9169 --- /dev/null +++ b/tegg/core/metadata/src/impl/InjectObjectPrototypeFinder.ts @@ -0,0 +1,138 @@ +import { QualifierUtil } from '@eggjs/core-decorator'; +import type { + EggProtoImplClass, + EggPrototype, + InjectConstructor, + InjectConstructorProto, + InjectObject, + InjectObjectProto, + LoadUnit, + ObjectInitTypeLike, + QualifierInfo, +} from '@eggjs/tegg-types'; +import { InitTypeQualifierAttribute, InjectType, ObjectInitType } from '@eggjs/tegg-types'; + +import { EggPrototypeNotFound, MultiPrototypeFound } from '../errors.ts'; +import { EggPrototypeFactory } from '../factory/EggPrototypeFactory.ts'; + +export interface EggProtoInfo { + clazz: EggProtoImplClass; + loadUnit: LoadUnit; + properQualifiers: Record; + initType: ObjectInitTypeLike; + injectType?: InjectType; + injectObjects: Array; +} + +export class InjectObjectPrototypeFinder { + private static tryFindDefaultPrototype( + proto: EggProtoInfo, + injectObject: InjectObject | InjectConstructor, + ): EggPrototype { + const propertyQualifiers = QualifierUtil.getProperQualifiers(proto.clazz, injectObject.refName); + const multiInstancePropertyQualifiers = proto.properQualifiers[injectObject.refName as string] ?? []; + return EggPrototypeFactory.instance.getPrototype( + injectObject.objName, + proto.loadUnit, + QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers), + ); + } + + private static tryFindContextPrototype( + proto: EggProtoInfo, + injectObject: InjectObject | InjectConstructor, + ): EggPrototype { + const propertyQualifiers = QualifierUtil.getProperQualifiers(proto.clazz, injectObject.refName); + const multiInstancePropertyQualifiers = proto.properQualifiers[injectObject.refName as string] ?? []; + return EggPrototypeFactory.instance.getPrototype( + injectObject.objName, + proto.loadUnit, + QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers, [ + { + attribute: InitTypeQualifierAttribute, + value: ObjectInitType.CONTEXT, + }, + ]), + ); + } + + private static tryFindSelfInitTypePrototype( + proto: EggProtoInfo, + injectObject: InjectObject | InjectConstructor, + ): EggPrototype { + const propertyQualifiers = QualifierUtil.getProperQualifiers(proto.clazz, injectObject.refName); + const multiInstancePropertyQualifiers = proto.properQualifiers[injectObject.refName as string] ?? []; + return EggPrototypeFactory.instance.getPrototype( + injectObject.objName, + proto.loadUnit, + QualifierUtil.mergeQualifiers(propertyQualifiers, multiInstancePropertyQualifiers, [ + { + attribute: InitTypeQualifierAttribute, + value: proto.initType, + }, + ]), + ); + } + + private static findInjectObjectPrototype( + proto: EggProtoInfo, + injectObject: InjectObject | InjectConstructor, + ): EggPrototype { + const propertyQualifiers = QualifierUtil.getProperQualifiers(proto.clazz, injectObject.refName); + try { + return InjectObjectPrototypeFinder.tryFindDefaultPrototype(proto, injectObject); + } catch (e) { + if ( + !( + e instanceof MultiPrototypeFound && + !propertyQualifiers.find((t) => t.attribute === InitTypeQualifierAttribute) + ) + ) { + throw e; + } + } + try { + return InjectObjectPrototypeFinder.tryFindContextPrototype(proto, injectObject); + } catch (e) { + if (!(e instanceof EggPrototypeNotFound)) { + throw e; + } + } + return InjectObjectPrototypeFinder.tryFindSelfInitTypePrototype(proto, injectObject); + } + + static findInjectObjectPrototypes(targetProto: EggProtoInfo): Array { + const injectObjectProtos: Array = []; + for (const injectObject of targetProto.injectObjects) { + const propertyQualifiers = QualifierUtil.getProperQualifiers(targetProto.clazz, injectObject.refName); + try { + const proto = InjectObjectPrototypeFinder.findInjectObjectPrototype(targetProto, injectObject); + let injectObjectProto: InjectObjectProto | InjectConstructorProto; + if (targetProto.injectType === InjectType.PROPERTY) { + injectObjectProto = { + refName: injectObject.refName, + objName: injectObject.objName, + qualifiers: propertyQualifiers, + proto, + }; + } else { + injectObjectProto = { + refIndex: (injectObject as InjectConstructor).refIndex, + refName: injectObject.refName, + objName: injectObject.objName, + qualifiers: propertyQualifiers, + proto, + }; + } + injectObjectProtos.push(injectObjectProto); + } catch (e) { + if (e instanceof EggPrototypeNotFound && injectObject.optional) { + continue; + } + throw e; + } + } + + return injectObjectProtos; + } +} diff --git a/tegg/core/metadata/src/impl/ModuleLoadUnit.ts b/tegg/core/metadata/src/impl/ModuleLoadUnit.ts index bc9c43a4d1..6ff5283a47 100644 --- a/tegg/core/metadata/src/impl/ModuleLoadUnit.ts +++ b/tegg/core/metadata/src/impl/ModuleLoadUnit.ts @@ -319,6 +319,9 @@ export class ModuleLoadUnit implements LoadUnit { } static createModule(ctx: LoadUnitLifecycleContext): ModuleLoadUnit { + if (ctx.unitName) { + return new ModuleLoadUnit(ctx.unitName, ctx.unitPath); + } const pkgPath = path.join(ctx.unitPath, 'package.json'); const pkg: { eggModule?: { name: string } } = JSON.parse(readFileSync(pkgPath, 'utf-8')); assert(pkg.eggModule, `module config not found in package ${pkgPath}`); diff --git a/tegg/core/metadata/src/impl/index.ts b/tegg/core/metadata/src/impl/index.ts index d12e2c3bee..f966dce5ad 100644 --- a/tegg/core/metadata/src/impl/index.ts +++ b/tegg/core/metadata/src/impl/index.ts @@ -1,4 +1,6 @@ +export * from './EggInnerObjectPrototypeImpl.ts'; export * from './EggPrototypeBuilder.ts'; export * from './EggPrototypeImpl.ts'; +export * from './InjectObjectPrototypeFinder.ts'; export * from './LoadUnitMultiInstanceProtoHook.ts'; export * from './ModuleLoadUnit.ts'; diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index 1a504a03c4..1b9629fba7 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -12,6 +12,7 @@ export interface ModuleDescriptor { optional?: boolean; clazzList: EggProtoImplClass[]; multiInstanceClazzList: EggProtoImplClass[]; + innerObjectClazzList: EggProtoImplClass[]; protos: ProtoDescriptor[]; } @@ -23,8 +24,10 @@ export class ModuleDescriptorDumper { static stringifyDescriptor(moduleDescriptor: ModuleDescriptor): string { return ( '{' + - `"name": "${moduleDescriptor.name}",` + - `"unitPath": "${moduleDescriptor.unitPath}",` + + // JSON.stringify the string fields — unitPath/filePath contain + // backslashes on Windows, raw interpolation produces invalid JSON. + `"name": ${JSON.stringify(moduleDescriptor.name)},` + + `"unitPath": ${JSON.stringify(moduleDescriptor.unitPath)},` + (typeof moduleDescriptor.optional !== 'undefined' ? `"optional": ${moduleDescriptor.optional},` : '') + `"clazzList": [${moduleDescriptor.clazzList .map((t) => { @@ -36,6 +39,11 @@ export class ModuleDescriptorDumper { return ModuleDescriptorDumper.stringifyClazz(t, moduleDescriptor); }) .join(',')}],` + + `"innerObjectClazzList": [${moduleDescriptor.innerObjectClazzList + .map((t) => { + return ModuleDescriptorDumper.stringifyClazz(t, moduleDescriptor); + }) + .join(',')}],` + `"protos": [${moduleDescriptor.protos .map((t) => { return JSON.stringify(t); @@ -46,14 +54,11 @@ export class ModuleDescriptorDumper { } static stringifyClazz(clazz: EggProtoImplClass, moduleDescriptor: ModuleDescriptor): string { - return ( - '{' + - `"name": "${clazz.name}",` + - (PrototypeUtil.getFilePath(clazz) - ? `"filePath": "${path.relative(moduleDescriptor.unitPath, PrototypeUtil.getFilePath(clazz)!)}"` - : '') + - '}' - ); + const filePath = PrototypeUtil.getFilePath(clazz); + return JSON.stringify({ + name: clazz.name, + ...(filePath ? { filePath: path.relative(moduleDescriptor.unitPath, filePath) } : {}), + }); } static dumpPath(desc: ModuleDescriptor, options?: ModuleDumpOptions): string { @@ -79,12 +84,23 @@ export class ModuleDescriptorDumper { }; for (const clazz of desc.clazzList) addClazz(clazz); for (const clazz of desc.multiInstanceClazzList) addClazz(clazz); + // Inner object / lifecycle proto classes are diverted out of clazzList, but + // their files must still be recorded so bundle mode re-imports them. + for (const clazz of desc.innerObjectClazzList) 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 }); - await fs.writeFile(dumpPath, ModuleDescriptorDumper.stringifyDescriptor(desc)); + const dumpDir = path.dirname(dumpPath); + await fs.mkdir(dumpDir, { recursive: true }); + const tmpDir = await fs.mkdtemp(path.join(dumpDir, '.tmp-')); + const tmpPath = path.join(tmpDir, path.basename(dumpPath)); + try { + await fs.writeFile(tmpPath, ModuleDescriptorDumper.stringifyDescriptor(desc)); + await fs.rename(tmpPath, dumpPath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } } } diff --git a/tegg/core/metadata/src/model/ProtoDescriptorHelper.ts b/tegg/core/metadata/src/model/ProtoDescriptorHelper.ts index e44b77acf4..6b2b44789b 100644 --- a/tegg/core/metadata/src/model/ProtoDescriptorHelper.ts +++ b/tegg/core/metadata/src/model/ProtoDescriptorHelper.ts @@ -17,6 +17,17 @@ import { import { type ProtoSelectorContext } from './graph/index.ts'; import { ClassProtoDescriptor } from './ProtoDescriptor/index.ts'; +/** + * Context to create a ProtoDescriptor from a plain prototype class. The + * optional define* fields support protos that are defined in one module but + * instantiated in another load unit (e.g. inner objects collected into the + * InnerObjectLoadUnit). + */ +export interface CreateProtoDescriptorContext extends MultiInstancePrototypeGetObjectsContext { + defineModuleName?: string; + defineUnitPath?: string; +} + export class ProtoDescriptorHelper { static addDefaultQualifier( qualifiers: QualifierInfo[], @@ -147,10 +158,7 @@ export class ProtoDescriptorHelper { return res; } - static createByInstanceClazz( - clazz: EggProtoImplClass, - ctx: MultiInstancePrototypeGetObjectsContext, - ): ProtoDescriptor { + static createByInstanceClazz(clazz: EggProtoImplClass, ctx: CreateProtoDescriptorContext): ProtoDescriptor { assert(PrototypeUtil.isEggPrototype(clazz), `clazz ${clazz.name} is not EggPrototype`); assert(!PrototypeUtil.isEggMultiInstancePrototype(clazz), `clazz ${clazz.name} is not Prototype`); @@ -178,8 +186,8 @@ export class ProtoDescriptorHelper { injectObjects, instanceDefineUnitPath: ctx.unitPath, instanceModuleName: ctx.moduleName, - defineUnitPath: ctx.unitPath, - defineModuleName: ctx.moduleName, + defineUnitPath: ctx.defineUnitPath || ctx.unitPath, + defineModuleName: ctx.defineModuleName || ctx.moduleName, clazz, properQualifiers: {}, }); diff --git a/tegg/core/metadata/src/model/graph/GlobalGraph.ts b/tegg/core/metadata/src/model/graph/GlobalGraph.ts index 43fbb2d3e0..d540afce9e 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -1,23 +1,14 @@ import { debuglog } from 'node:util'; -import { QualifierUtil } from '@eggjs/core-decorator'; import { FrameworkErrorFormatter } from '@eggjs/errors'; import { Graph, GraphNode, type ModuleReference } from '@eggjs/tegg-common-util'; -import { - InitTypeQualifierAttribute, - type InjectObjectDescriptor, - LoadUnitNameQualifierAttribute, - ObjectInitType, - type ProtoDescriptor, - type QualifierInfo, - TeggScope, - type TeggScopeBag, -} from '@eggjs/tegg-types'; +import { type InjectObjectDescriptor, type ProtoDescriptor, TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; -import { EggPrototypeNotFound, MultiPrototypeFound } from '../../errors.ts'; +import { EggPrototypeNotFound } from '../../errors.ts'; import type { ModuleDescriptor } from '../ModuleDescriptor.ts'; import { ModuleDependencyMeta, GlobalModuleNode } from './GlobalModuleNode.ts'; import { GlobalModuleNodeBuilder } from './GlobalModuleNodeBuilder.ts'; +import { ProtoGraphUtils, type ProtoNameIndex } from './ProtoGraphUtils.ts'; import { ProtoDependencyMeta, ProtoNode } from './ProtoNode.ts'; const debug = debuglog('tegg/core/metadata/model/graph/GlobalGraph'); @@ -69,7 +60,8 @@ export class GlobalGraph { moduleProtoDescriptorMap: Map; strict: boolean; private buildHooks: GlobalGraphBuildHook[]; - private protoNameNodeMap: Map[]>; + /** Lazily built proto-name index for dependency resolution; invalidated on vertex changes. */ + #protoNameIndex: ProtoNameIndex | null = null; /** * The per-app graph instance used in ModuleLoadUnit, backed by TeggScope: the @@ -99,7 +91,6 @@ export class GlobalGraph { this.strict = options?.strict ?? false; this.moduleProtoDescriptorMap = new Map(); this.buildHooks = []; - this.protoNameNodeMap = new Map(); } registerBuildHook(hook: GlobalGraphBuildHook): void { @@ -114,13 +105,8 @@ export class GlobalGraph { if (!this.protoGraph.addVertex(protoNode)) { throw new Error(`duplicate proto: ${protoNode.val}`); } - let nodes = this.protoNameNodeMap.get(protoNode.val.proto.name); - if (!nodes) { - nodes = []; - this.protoNameNodeMap.set(protoNode.val.proto.name, nodes); - } - nodes.push(protoNode); } + this.#protoNameIndex = null; } build(): void { @@ -188,74 +174,12 @@ export class GlobalGraph { return edge?.val.proto; } - #findDependencyProtoWithDefaultQualifiers( - proto: ProtoDescriptor, - injectObject: InjectObjectDescriptor, - qualifiers: QualifierInfo[], - ): GraphNode[] { - const result: GraphNode[] = []; - for (const node of this.protoNameNodeMap.get(injectObject.objName) ?? []) { - if ( - node.val.selectProto({ - name: injectObject.objName, - qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, qualifiers), - moduleName: proto.instanceModuleName, - }) - ) { - result.push(node); - } - } - return result; - } - findDependencyProtoNode( proto: ProtoDescriptor, injectObject: InjectObjectDescriptor, ): GraphNode | undefined { - // 1. find proto with request - // 2. try to add Context qualifier to find - // 3. try to add self init type qualifier to find - const protos = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, []); - if (protos.length === 0) { - return; - // throw FrameworkErrorFormater.formatError(new EggPrototypeNotFound(injectObject.objName, proto.instanceModuleName)); - } - if (protos.length === 1) { - return protos[0]; - } - - const protoWithContext = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, [ - { - attribute: InitTypeQualifierAttribute, - value: ObjectInitType.CONTEXT, - }, - ]); - if (protoWithContext.length === 1) { - return protoWithContext[0]; - } - - const protoWithSelfInitType = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, [ - { - attribute: InitTypeQualifierAttribute, - value: proto.initType, - }, - ]); - if (protoWithSelfInitType.length === 1) { - return protoWithSelfInitType[0]; - } - const loadUnitQualifier = injectObject.qualifiers.find((t) => t.attribute === LoadUnitNameQualifierAttribute); - if (!loadUnitQualifier) { - return this.findDependencyProtoNode(proto, { - ...injectObject, - qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, [ - { - attribute: LoadUnitNameQualifierAttribute, - value: proto.instanceModuleName, - }, - ]), - }); - } - throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(injectObject.objName, injectObject.qualifiers)); + this.#protoNameIndex ??= ProtoGraphUtils.buildProtoNameIndex(this.protoGraph); + return ProtoGraphUtils.findDependencyProtoNode(this.protoGraph, proto, injectObject, this.#protoNameIndex); } findModuleNode(moduleName: string): GraphNode | undefined { diff --git a/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts b/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts new file mode 100644 index 0000000000..b12f9ea5b5 --- /dev/null +++ b/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts @@ -0,0 +1,131 @@ +import { QualifierUtil } from '@eggjs/core-decorator'; +import { FrameworkErrorFormatter } from '@eggjs/errors'; +import type { Graph, GraphNode } from '@eggjs/tegg-common-util'; +import { + type EggPrototypeName, + InitTypeQualifierAttribute, + type InjectObjectDescriptor, + LoadUnitNameQualifierAttribute, + ObjectInitType, + type ProtoDescriptor, + type QualifierInfo, +} from '@eggjs/tegg-types'; + +import { MultiPrototypeFound } from '../../errors.ts'; +import { type ProtoDependencyMeta, type ProtoNode } from './ProtoNode.ts'; +import { type ProtoSelectorContext } from './ProtoSelector.ts'; + +export type ProtoGraph = Graph; +export type ProtoGraphNode = GraphNode; +/** + * Proto nodes grouped by proto name. `selectProto` requires an exact name + * match, so the index is a safe pre-filter that avoids scanning every node + * for every inject object. + */ +export type ProtoNameIndex = Map; + +export class ProtoGraphUtils { + static buildProtoNameIndex(graph: ProtoGraph): ProtoNameIndex { + const index: ProtoNameIndex = new Map(); + for (const node of graph.nodes.values()) { + const name = node.val.proto.name; + let nodes = index.get(name); + if (!nodes) { + nodes = []; + index.set(name, nodes); + } + nodes.push(node); + } + return index; + } + + static findDependencyProtoNode( + graph: ProtoGraph, + proto: ProtoDescriptor, + injectObject: InjectObjectDescriptor, + index?: ProtoNameIndex, + ): ProtoGraphNode | undefined { + // 1. find proto with request + // 2. try to add Context qualifier to find + // 3. try to add self init type qualifier to find + const protos = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers(graph, proto, injectObject, [], index); + if (protos.length === 0) { + return; + } + if (protos.length === 1) { + return protos[0]; + } + + const protoWithContext = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers( + graph, + proto, + injectObject, + [ + { + attribute: InitTypeQualifierAttribute, + value: ObjectInitType.CONTEXT, + }, + ], + index, + ); + if (protoWithContext.length === 1) { + return protoWithContext[0]; + } + + const protoWithSelfInitType = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers( + graph, + proto, + injectObject, + [ + { + attribute: InitTypeQualifierAttribute, + value: proto.initType, + }, + ], + index, + ); + if (protoWithSelfInitType.length === 1) { + return protoWithSelfInitType[0]; + } + const loadUnitQualifier = injectObject.qualifiers.find((t) => t.attribute === LoadUnitNameQualifierAttribute); + if (!loadUnitQualifier) { + return ProtoGraphUtils.findDependencyProtoNode( + graph, + proto, + { + ...injectObject, + qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, [ + { + attribute: LoadUnitNameQualifierAttribute, + value: proto.instanceModuleName, + }, + ]), + }, + index, + ); + } + throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(injectObject.objName, injectObject.qualifiers)); + } + + static #findDependencyProtoWithDefaultQualifiers( + graph: ProtoGraph, + proto: ProtoDescriptor, + injectObject: InjectObjectDescriptor, + qualifiers: QualifierInfo[], + index?: ProtoNameIndex, + ): ProtoGraphNode[] { + const candidates: Iterable = index ? (index.get(injectObject.objName) ?? []) : graph.nodes.values(); + const result: ProtoGraphNode[] = []; + const ctx: ProtoSelectorContext = { + name: injectObject.objName, + qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, qualifiers), + moduleName: proto.instanceModuleName, + }; + for (const node of candidates) { + if (node.val.selectProto(ctx)) { + result.push(node); + } + } + return result; + } +} diff --git a/tegg/core/metadata/src/model/graph/ProtoNode.ts b/tegg/core/metadata/src/model/graph/ProtoNode.ts index 03c851fd9a..b49e5c0b97 100644 --- a/tegg/core/metadata/src/model/graph/ProtoNode.ts +++ b/tegg/core/metadata/src/model/graph/ProtoNode.ts @@ -29,8 +29,15 @@ export class ProtoNode implements GraphNodeObj { this.proto = proto; } - toString() { - return `${String(this.proto.name)}@${this.proto.instanceDefineUnitPath}`; + toString(): string { + const qualifiers = this.proto.qualifiers + .map((qualifier) => `${String(qualifier.attribute)}=${String(qualifier.value)}`) + .join(','); + return ( + `${String(this.proto.name)}@${this.proto.instanceDefineUnitPath}` + + ` define:${this.proto.defineModuleName}@${this.proto.defineUnitPath}` + + ` qualifiers:[${qualifiers}]` + ); } selectProto(ctx: ProtoSelectorContext): boolean { diff --git a/tegg/core/metadata/src/model/graph/index.ts b/tegg/core/metadata/src/model/graph/index.ts index 45a37d6a1f..5e8fb08863 100644 --- a/tegg/core/metadata/src/model/graph/index.ts +++ b/tegg/core/metadata/src/model/graph/index.ts @@ -1,5 +1,6 @@ export * from './GlobalGraph.ts'; export * from './GlobalModuleNode.ts'; export * from './GlobalModuleNodeBuilder.ts'; +export * from './ProtoGraphUtils.ts'; export * from './ProtoNode.ts'; export * from './ProtoSelector.ts'; diff --git a/tegg/core/metadata/test/LoadUnit.test.ts b/tegg/core/metadata/test/LoadUnit.test.ts index c517bee72d..fdf4f0862b 100644 --- a/tegg/core/metadata/test/LoadUnit.test.ts +++ b/tegg/core/metadata/test/LoadUnit.test.ts @@ -91,6 +91,24 @@ describe('test/LoadUnit/LoadUnit.test.ts', () => { await LoadUnitFactory.destroyLoadUnit(loadUnit); }); + it('should create from provided unit name without reading package.json', async () => { + const modulePath = path.join(__dirname, './fixtures/modules/no-package-module'); + const loader = new TestLoader(modulePath); + await buildGlobalGraph([], []); + + const loadUnit = await LoadUnitFactory.createLoadUnit( + modulePath, + EggLoadUnitType.MODULE, + loader, + 'manifestModule', + ); + + assert(loadUnit.id === 'LOAD_UNIT:manifestModule'); + assert(loadUnit.name === 'manifestModule'); + assert(loadUnit.unitPath === modulePath); + await LoadUnitFactory.destroyLoadUnit(loadUnit); + }); + it('recursive deps should should throw error', async () => { const repoModulePath = path.join(__dirname, './fixtures/modules/recursive-load-unit'); const loader = new TestLoader(repoModulePath); diff --git a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts index b94749c88f..a2b77f7491 100644 --- a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { PrototypeUtil } from '@eggjs/core-decorator'; @@ -8,6 +10,23 @@ import { ModuleDescriptorDumper } from '../src/index.js'; import type { ModuleDescriptor } from '../src/index.js'; describe('test/ModuleDescriptorDumper.test.ts', () => { + describe('stringifyDescriptor()', () => { + it('should emit valid JSON for clazz without filePath', () => { + class MissingFilePath {} + const desc: ModuleDescriptor = { + name: 'no-file-path', + unitPath: '/tmp/no-file-path', + clazzList: [MissingFilePath as any], + multiInstanceClazzList: [], + innerObjectClazzList: [], + protos: [], + }; + + const json = JSON.parse(ModuleDescriptorDumper.stringifyDescriptor(desc)); + assert.deepEqual(json.clazzList, [{ name: 'MissingFilePath' }]); + }); + }); + describe('getDecoratedFiles()', () => { const loadUnitPath = path.join(__dirname, 'fixtures/modules/load-unit'); @@ -17,6 +36,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: '/tmp/empty', clazzList: [], multiInstanceClazzList: [], + innerObjectClazzList: [], protos: [], }; const files = ModuleDescriptorDumper.getDecoratedFiles(desc); @@ -35,6 +55,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: loadUnitPath, clazzList: [AppRepo], multiInstanceClazzList: [], + innerObjectClazzList: [], protos: [], }; @@ -56,6 +77,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: loadUnitPath, clazzList: [AppRepo], multiInstanceClazzList: [AppRepo], + innerObjectClazzList: [], protos: [], }; @@ -73,6 +95,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: '/tmp/fake-module', clazzList: [], multiInstanceClazzList: [AppRepo], + innerObjectClazzList: [], protos: [], }; @@ -81,4 +104,30 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { assert.equal(files.length, 0); }); }); + + describe('dump()', () => { + it('should write descriptor to deterministic path and cleanup temp dir', async () => { + const dumpDir = await fs.mkdtemp(path.join(tmpdir(), 'module-desc-dump-')); + try { + const desc: ModuleDescriptor = { + name: 'dumped', + unitPath: '/tmp/dumped', + clazzList: [], + multiInstanceClazzList: [], + innerObjectClazzList: [], + protos: [], + }; + + await ModuleDescriptorDumper.dump(desc, { dumpDir }); + + const dumpPath = ModuleDescriptorDumper.dumpPath(desc, { dumpDir }); + const json = JSON.parse(await fs.readFile(dumpPath, 'utf8')); + assert.equal(json.name, 'dumped'); + const dumpEntries = await fs.readdir(path.join(dumpDir, '.egg')); + assert.deepEqual(dumpEntries, ['dumped_module_desc.json']); + } finally { + await fs.rm(dumpDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap index e5d5c04888..90d0af85cf 100644 --- a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap @@ -7,6 +7,7 @@ exports[`should export stable 1`] = ` "ClassProtoDescriptor": [Function], "ClassUtil": [Function], "ClazzMap": [Function], + "EggInnerObjectPrototypeImpl": [Function], "EggLoadUnitType": { "APP": "APP", "MODULE": "MODULE", @@ -39,6 +40,7 @@ exports[`should export stable 1`] = ` "GlobalModuleNode": [Function], "GlobalModuleNodeBuilder": [Function], "IncompatibleProtoInject": [Function], + "InjectObjectPrototypeFinder": [Function], "LoadUnitFactory": [Function], "LoadUnitLifecycleUtil": { "clearObjectLifecycle": [Function], @@ -65,6 +67,7 @@ exports[`should export stable 1`] = ` "ProtoDescriptorType": { "CLASS": "CLASS", }, + "ProtoGraphUtils": [Function], "ProtoNode": [Function], "TeggError": [Function], "eggPrototypeLifecycleUtilFromBag": [Function], diff --git a/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep b/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep @@ -0,0 +1 @@ + diff --git a/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts b/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts new file mode 100644 index 0000000000..9a55fd98f5 --- /dev/null +++ b/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts @@ -0,0 +1,221 @@ +import { IdenticalUtil } from '@eggjs/lifecycle'; +import { EggInnerObjectPrototypeImpl, LoadUnitFactory } from '@eggjs/metadata'; +import type { + EggObject, + EggObjectLifecycle, + EggObjectLifeCycleContext, + EggObjectName, + EggPrototype, + LifecycleHookName, + ObjectInfo, + QualifierInfo, +} from '@eggjs/tegg-types'; +import { EggObjectStatus, InjectType, ObjectInitType } from '@eggjs/tegg-types'; + +import { EggContainerFactory } from '../factory/EggContainerFactory.ts'; +import { EggObjectFactory } from '../factory/EggObjectFactory.ts'; +import { ContextHandler } from '../model/ContextHandler.ts'; +import { EggObjectLifecycleUtil } from '../model/EggObject.ts'; +import { EggObjectUtil } from './EggObjectUtil.ts'; + +/** + * EggObject implementation for inner object / lifecycle protos. + * + * A lifecycle proto implements hook-callback methods (e.g. `postCreate`, + * `preDestroy`) whose names collide with the object's own lifecycle interface + * methods. To avoid invoking hook callbacks as self lifecycle by accident, + * this implementation only runs self lifecycle methods that were explicitly + * declared via decorators (`@LifecyclePostInject`, `@LifecycleInit`, ...) — + * unlike EggObjectImpl, interface method names are never used as fallback. + */ +export class EggInnerObjectImpl implements EggObject { + private _obj: object; + private status: EggObjectStatus = EggObjectStatus.PENDING; + + readonly proto: EggPrototype; + readonly name: EggObjectName; + readonly id: string; + + constructor(name: EggObjectName, proto: EggPrototype) { + this.name = name; + this.proto = proto; + const ctx = ContextHandler.getContext(); + this.id = IdenticalUtil.createObjectId(this.proto.id, ctx?.id); + } + + async initWithInjectProperty(ctx: EggObjectLifeCycleContext): Promise { + // 1. create obj + // 2. call obj lifecycle preCreate + // 3. inject deps + // 4. call obj lifecycle postCreate + // 5. success create + try { + this._obj = this.proto.constructEggObject(); + + // global hook + await EggObjectLifecycleUtil.objectPreCreate(ctx, this); + // self hook + await this.callObjectLifecycle('postConstruct', ctx); + + await this.callObjectLifecycle('preInject', ctx); + await Promise.all( + this.proto.injectObjects.map(async (injectObject) => { + const proto = injectObject.proto; + const loadUnit = LoadUnitFactory.getLoadUnitById(proto.loadUnitId); + if (!loadUnit) { + throw new Error(`can not find load unit: ${proto.loadUnitId}`); + } + if ( + this.proto.initType !== ObjectInitType.CONTEXT && + injectObject.proto.initType === ObjectInitType.CONTEXT + ) { + this.injectProperty( + injectObject.refName, + EggObjectUtil.contextEggObjectGetProperty(proto, injectObject.objName), + ); + } else { + const injectObj = await EggContainerFactory.getOrCreateEggObject(proto, injectObject.objName); + this.injectProperty(injectObject.refName, EggObjectUtil.eggObjectGetProperty(injectObj)); + } + }), + ); + + // global hook + await EggObjectLifecycleUtil.objectPostCreate(ctx, this); + + // self hook + await this.callObjectLifecycle('postInject', ctx); + + await this.callObjectLifecycle('init', ctx); + + this.status = EggObjectStatus.READY; + } catch (e) { + this.status = EggObjectStatus.ERROR; + throw e; + } + } + + async initWithInjectConstructor(ctx: EggObjectLifeCycleContext): Promise { + // 1. create inject deps + // 2. create obj + // 3. call obj lifecycle preCreate + // 4. call obj lifecycle postCreate + // 5. success create + try { + const constructArgs: any[] = await Promise.all( + this.proto.injectObjects!.map(async (injectObject) => { + const proto = injectObject.proto; + const loadUnit = LoadUnitFactory.getLoadUnitById(proto.loadUnitId); + if (!loadUnit) { + throw new Error(`can not find load unit: ${proto.loadUnitId}`); + } + if ( + this.proto.initType !== ObjectInitType.CONTEXT && + injectObject.proto.initType === ObjectInitType.CONTEXT + ) { + return EggObjectUtil.contextEggObjectProxy(proto, injectObject.objName); + } + const injectObj = await EggContainerFactory.getOrCreateEggObject(proto, injectObject.objName); + return EggObjectUtil.eggObjectProxy(injectObj); + }), + ); + if (typeof this.proto.multiInstanceConstructorIndex !== 'undefined') { + const qualifiers = + this.proto.multiInstanceConstructorAttributes + ?.map((t) => { + return { + attribute: t, + value: this.proto.getQualifier(t), + } as QualifierInfo; + }) + ?.filter((t) => typeof t.value !== 'undefined') ?? []; + const objInfo: ObjectInfo = { + name: this.proto.name, + qualifiers, + }; + constructArgs.splice(this.proto.multiInstanceConstructorIndex, 0, objInfo); + } + + this._obj = this.proto.constructEggObject(...constructArgs); + + // global hook + await EggObjectLifecycleUtil.objectPreCreate(ctx, this); + // self hook + await this.callObjectLifecycle('postConstruct', ctx); + + await this.callObjectLifecycle('preInject', ctx); + + // global hook + await EggObjectLifecycleUtil.objectPostCreate(ctx, this); + + // self hook + await this.callObjectLifecycle('postInject', ctx); + + await this.callObjectLifecycle('init', ctx); + + this.status = EggObjectStatus.READY; + } catch (e) { + this.status = EggObjectStatus.ERROR; + throw e; + } + } + + async init(ctx: EggObjectLifeCycleContext): Promise { + if (this.proto.injectType === InjectType.CONSTRUCTOR) { + await this.initWithInjectConstructor(ctx); + } else { + await this.initWithInjectProperty(ctx); + } + } + + async destroy(ctx: EggObjectLifeCycleContext): Promise { + if (this.status === EggObjectStatus.READY) { + this.status = EggObjectStatus.DESTROYING; + // global hook + await EggObjectLifecycleUtil.objectPreDestroy(ctx, this); + + // self hook + await this.callObjectLifecycle('preDestroy', ctx); + + await this.callObjectLifecycle('destroy', ctx); + + this.status = EggObjectStatus.DESTROYED; + } + } + + injectProperty(name: EggObjectName, descriptor: PropertyDescriptor): void { + Reflect.defineProperty(this._obj, name, descriptor); + } + + get obj(): object { + return this._obj; + } + + get isReady(): boolean { + return this.status === EggObjectStatus.READY; + } + + /** + * Only run self lifecycle methods declared through decorators — never fall + * back to the lifecycle interface method name (it may be a hook callback). + */ + private async callObjectLifecycle(hookName: LifecycleHookName, ctx: EggObjectLifeCycleContext): Promise { + const objLifecycleHook = this._obj as EggObjectLifecycle; + const lifecycleMethod = EggObjectLifecycleUtil.getLifecycleHook(hookName, this.proto); + if (lifecycleMethod) { + await objLifecycleHook[lifecycleMethod]?.(ctx, this); + } + } + + static async createObject( + name: EggObjectName, + proto: EggPrototype, + lifecycleContext: EggObjectLifeCycleContext, + ): Promise { + const obj = new EggInnerObjectImpl(name, proto); + await obj.init(lifecycleContext); + return obj; + } +} + +EggObjectFactory.registerEggObjectCreateMethod(EggInnerObjectPrototypeImpl, EggInnerObjectImpl.createObject); diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts new file mode 100644 index 0000000000..5da51b7cd1 --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts @@ -0,0 +1,113 @@ +import { ClassProtoDescriptor, EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; +import { MapUtil } from '@eggjs/tegg-common-util'; +import type { + AccessLevel, + EggPrototype, + EggPrototypeName, + LoadUnit, + ProtoDescriptor, + QualifierInfo, +} from '@eggjs/tegg-types'; + +export const INNER_OBJECT_LOAD_UNIT_TYPE = 'INNER_OBJECT_LOAD_UNIT'; +export const INNER_OBJECT_LOAD_UNIT_NAME = 'InnerObjectLoadUnit'; +export const INNER_OBJECT_LOAD_UNIT_PATH = 'InnerObjectLoadUnitPath'; + +export interface InnerObject { + obj: object; + qualifiers?: QualifierInfo[]; + /** + * Defaults to PUBLIC (standalone convention: business modules may inject + * host-provided objects). Hosts with their own resolution surface for these + * names (e.g. the egg host) should pass PRIVATE so the provided protos stay + * visible to inner objects only and never pollute cross-unit resolution. + */ + accessLevel?: AccessLevel; +} + +export interface InnerObjectLoadUnitOptions { + /** + * Proto descriptors in instantiation (topological) order: + * `@InnerObjectProto` / `@XxxLifecycleProto` classes collected from + * modules AND host-provided instances (factory-clazz descriptors with + * PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE) — one uniform channel. + */ + protos?: ProtoDescriptor[]; + name?: string; + unitPath?: string; +} + +/** + * A host-agnostic load unit holding framework inner objects. It is created and + * instantiated BEFORE the business global graph is built and before any + * business load unit is created, so the lifecycle protos it carries can hook + * into every later phase (graph build, load unit / prototype / object / context + * lifecycles). + */ +export class InnerObjectLoadUnit implements LoadUnit { + readonly id: string; + readonly name: string; + readonly unitPath: string; + readonly type: string = INNER_OBJECT_LOAD_UNIT_TYPE; + + readonly #protos: ProtoDescriptor[]; + readonly #protoMap: Map = new Map(); + + constructor(options: InnerObjectLoadUnitOptions) { + this.name = options.name ?? INNER_OBJECT_LOAD_UNIT_NAME; + this.unitPath = options.unitPath ?? INNER_OBJECT_LOAD_UNIT_PATH; + this.id = this.name; + this.#protos = options.protos ?? []; + } + + async init(): Promise { + for (const protoDescriptor of this.#protos) { + if (!ClassProtoDescriptor.isClassProtoDescriptor(protoDescriptor)) { + continue; + } + const proto = await EggPrototypeCreatorFactory.createProtoByDescriptor(protoDescriptor, this); + EggPrototypeFactory.instance.registerPrototype(proto, this); + } + } + + containPrototype(proto: EggPrototype): boolean { + return !!this.#protoMap.get(proto.name)?.find((t) => t === proto); + } + + getEggPrototype(name: string, qualifiers: QualifierInfo[]): EggPrototype[] { + const protos = this.#protoMap.get(name); + return protos?.filter((proto) => proto.verifyQualifiers(qualifiers)) || []; + } + + registerEggPrototype(proto: EggPrototype): void { + const protoList = MapUtil.getOrStore(this.#protoMap, proto.name, []); + protoList.push(proto); + } + + deletePrototype(proto: EggPrototype): void { + const protos = this.#protoMap.get(proto.name); + if (protos) { + const index = protos.indexOf(proto); + if (index !== -1) { + protos.splice(index, 1); + } + } + } + + async destroy(): Promise { + for (const namedProtos of this.#protoMap.values()) { + for (const proto of Array.from(namedProtos)) { + EggPrototypeFactory.instance.deletePrototype(proto, this); + } + } + this.#protoMap.clear(); + } + + iterateEggPrototype(): IterableIterator { + const protos: EggPrototype[] = []; + for (const namedProtos of this.#protoMap.values()) { + protos.push(...namedProtos); + } + return protos.values(); + } +} diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts new file mode 100644 index 0000000000..d75d433f9b --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -0,0 +1,183 @@ +import { + ClassProtoDescriptor, + EggPrototypeNotFound, + LoadUnitFactory, + ProtoDependencyMeta, + ProtoDescriptorHelper, + ProtoGraphUtils, + ProtoNode, +} from '@eggjs/metadata'; +import { Graph, GraphNode } from '@eggjs/tegg-common-util'; +import type { EggProtoImplClass, LoadUnit, ProtoDescriptor, QualifierInfo } from '@eggjs/tegg-types'; +import { AccessLevel, DefineModuleQualifierAttribute, ObjectInitType } from '@eggjs/tegg-types'; + +import { + INNER_OBJECT_LOAD_UNIT_NAME, + INNER_OBJECT_LOAD_UNIT_PATH, + INNER_OBJECT_LOAD_UNIT_TYPE, + type InnerObject, + InnerObjectLoadUnit, +} from './InnerObjectLoadUnit.ts'; +// Import for the side effect of registering the load unit instance class. +import './InnerObjectLoadUnitInstance.ts'; +import { PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE } from './ProvidedInnerObjectProto.ts'; + +export interface InnerObjectModuleReference { + name: string; + path: string; +} + +export interface CreateInnerObjectLoadUnitOptions { + /** Host-provided, already-constructed objects (logger, router, ...). */ + innerObjects: Record; + name?: string; + unitPath?: string; +} + +const PROVIDED_DEFINE_MODULE_NAME = 'app'; + +/** + * Collects `@InnerObjectProto` / `@XxxLifecycleProto` classes from scanned + * modules, resolves their mutual dependencies on a dedicated proto graph + * (topological order + cycle detection), and creates the InnerObjectLoadUnit + * that instantiates them before any business load unit exists. + * + * Must be driven inside the host's TeggScope: the load unit creator it + * registers captures per-app state and lands in the per-app creator overlay. + */ +export class InnerObjectLoadUnitBuilder { + readonly #protoGraph: Graph = new Graph(); + + static #addDefaultDefineModuleQualifier(qualifiers: QualifierInfo[], moduleName: string): QualifierInfo[] { + if (qualifiers.find((t) => t.attribute === DefineModuleQualifierAttribute)) { + return qualifiers; + } + return [ + ...qualifiers, + { + attribute: DefineModuleQualifierAttribute, + value: moduleName, + }, + ]; + } + + addInnerObjectClazzList(clazzList: readonly EggProtoImplClass[], moduleReference: InnerObjectModuleReference): void { + for (const clazz of clazzList) { + const descriptor = ProtoDescriptorHelper.createByInstanceClazz(clazz, { + moduleName: INNER_OBJECT_LOAD_UNIT_NAME, + unitPath: INNER_OBJECT_LOAD_UNIT_PATH, + defineModuleName: moduleReference.name, + defineUnitPath: moduleReference.path, + }); + descriptor.qualifiers = InnerObjectLoadUnitBuilder.#addDefaultDefineModuleQualifier( + descriptor.qualifiers, + moduleReference.name, + ); + const protoGraphNode = new GraphNode(new ProtoNode(descriptor)); + if (!this.#protoGraph.addVertex(protoGraphNode)) { + throw new Error(`duplicate inner object proto: ${protoGraphNode.val}`); + } + } + } + + /** + * Host-provided instances are ordinary protos, exactly as before the + * module-plugin refactor (StandaloneInnerObjectProto): the descriptor + * carries a factory clazz (`() => obj`), so they flow through the same + * graph, the same creator dispatch (PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE) + * and the same instantiation loop — "constructing" one returns the + * instance. No filtering anywhere. + */ + static #providedDescriptors(innerObjects: Record): ProtoDescriptor[] { + const descriptors: ProtoDescriptor[] = []; + for (const [name, objects] of Object.entries(innerObjects)) { + for (const innerObject of objects) { + descriptors.push( + new ClassProtoDescriptor({ + name, + clazz: (() => innerObject.obj) as unknown as EggProtoImplClass, + accessLevel: innerObject.accessLevel ?? AccessLevel.PUBLIC, + initType: ObjectInitType.SINGLETON, + protoImplType: PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE, + qualifiers: ProtoDescriptorHelper.addDefaultQualifier( + InnerObjectLoadUnitBuilder.#addDefaultDefineModuleQualifier( + innerObject.qualifiers ?? [], + PROVIDED_DEFINE_MODULE_NAME, + ), + ObjectInitType.SINGLETON, + INNER_OBJECT_LOAD_UNIT_NAME, + ), + injectObjects: [], + properQualifiers: {}, + defineModuleName: INNER_OBJECT_LOAD_UNIT_NAME, + defineUnitPath: INNER_OBJECT_LOAD_UNIT_PATH, + instanceModuleName: INNER_OBJECT_LOAD_UNIT_NAME, + instanceDefineUnitPath: INNER_OBJECT_LOAD_UNIT_PATH, + }), + ); + } + } + return descriptors; + } + + #buildProtoGraph(): ProtoDescriptor[] { + const index = ProtoGraphUtils.buildProtoNameIndex(this.#protoGraph); + for (const protoNode of this.#protoGraph.nodes.values()) { + for (const injectObject of protoNode.val.proto.injectObjects) { + const injectProto = ProtoGraphUtils.findDependencyProtoNode( + this.#protoGraph, + protoNode.val.proto, + injectObject, + index, + ); + if (injectProto) { + this.#protoGraph.addEdge( + protoNode, + injectProto, + new ProtoDependencyMeta({ injectObj: injectObject.objName }), + ); + continue; + } + if (injectObject.optional) { + continue; + } + // Missing is a hard error — deferring it to runtime hides broken + // module plugins. + throw new EggPrototypeNotFound(injectObject.objName, protoNode.val.proto.defineModuleName); + } + } + const loopPath = this.#protoGraph.loopPath(); + if (loopPath) { + throw new Error('inner object proto has recursive deps: ' + loopPath); + } + + return this.#protoGraph.sort().map((node) => node.val.proto); + } + + async createLoadUnit(options: CreateInnerObjectLoadUnitOptions): Promise { + for (const descriptor of InnerObjectLoadUnitBuilder.#providedDescriptors(options.innerObjects)) { + const node = new GraphNode(new ProtoNode(descriptor)); + if (!this.#protoGraph.addVertex(node)) { + throw new Error(`duplicate provided inner object: ${node.val}`); + } + } + const protos = this.#buildProtoGraph(); + LoadUnitFactory.registerLoadUnitCreator(INNER_OBJECT_LOAD_UNIT_TYPE, () => { + return new InnerObjectLoadUnit({ + protos, + name: options.name, + unitPath: options.unitPath, + }); + }); + + return await LoadUnitFactory.createLoadUnit( + options.unitPath ?? INNER_OBJECT_LOAD_UNIT_PATH, + INNER_OBJECT_LOAD_UNIT_TYPE, + { + async load(): Promise { + return []; + }, + }, + ); + } +} diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts new file mode 100644 index 0000000000..e130e8ab34 --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts @@ -0,0 +1,71 @@ +import { PrototypeUtil } from '@eggjs/core-decorator'; +import type { LifecycleUtil } from '@eggjs/lifecycle'; +import { EggPrototypeLifecycleUtil, LoadUnitLifecycleUtil } from '@eggjs/metadata'; +import type { EggLifecycleInfo, LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; + +import { LoadUnitInstanceFactory } from '../factory/LoadUnitInstanceFactory.ts'; +import { EggContextLifecycleUtil } from '../model/EggContext.ts'; +import { EggObjectLifecycleUtil } from '../model/EggObject.ts'; +import { LoadUnitInstanceLifecycleUtil } from '../model/LoadUnitInstance.ts'; +import { INNER_OBJECT_LOAD_UNIT_TYPE } from './InnerObjectLoadUnit.ts'; +import { ModuleLoadUnitInstance } from './ModuleLoadUnitInstance.ts'; + +/** + * Instantiates all inner objects of an InnerObjectLoadUnit and auto-registers + * every `@XxxLifecycleProto` object into the lifecycle util matching its + * declared type. Deletion is symmetric on destroy. + * + * The lifecycle utils below are scope-aware, so running init/destroy inside + * the host's TeggScope keeps registrations per-app. + */ +export class InnerObjectLoadUnitInstance extends ModuleLoadUnitInstance { + static readonly LifecycleUtils: Record> = { + LoadUnit: LoadUnitLifecycleUtil, + LoadUnitInstance: LoadUnitInstanceLifecycleUtil, + EggPrototype: EggPrototypeLifecycleUtil, + EggObject: EggObjectLifecycleUtil, + EggContext: EggContextLifecycleUtil, + }; + + readonly #lifecycleObjects: [string, object][] = []; + + async init(ctx: LoadUnitInstanceLifecycleContext): Promise { + await super.init(ctx); + + for (const [name, proto] of this.iterateProtoToCreate()) { + const isLifecycleProto = proto.getMetaData(PrototypeUtil.IS_EGG_LIFECYCLE_PROTOTYPE); + const lifecycleInfo = proto.getMetaData(PrototypeUtil.EGG_LIFECYCLE_PROTOTYPE_METADATA); + if (isLifecycleProto && lifecycleInfo?.type) { + const lifecycleUtil = InnerObjectLoadUnitInstance.LifecycleUtils[lifecycleInfo.type]; + if (!lifecycleUtil) { + throw new Error( + `register lifecycle for ${String(proto.name)} failed, unknown lifecycle type ${lifecycleInfo.type}`, + ); + } + const lifecycle = this.getEggObject(name, proto).obj; + lifecycleUtil.registerLifecycle(lifecycle); + this.#lifecycleObjects.push([lifecycleInfo.type, lifecycle]); + } + } + } + + async destroy(): Promise { + let toBeDeleted = this.#lifecycleObjects.shift(); + while (toBeDeleted) { + const [type, lifecycle] = toBeDeleted; + InnerObjectLoadUnitInstance.LifecycleUtils[type]?.deleteLifecycle(lifecycle); + toBeDeleted = this.#lifecycleObjects.shift(); + } + + await super.destroy(); + } + + static createInnerObjectLoadUnitInstance(ctx: LoadUnitInstanceLifecycleContext): LoadUnitInstance { + return new InnerObjectLoadUnitInstance(ctx.loadUnit); + } +} + +LoadUnitInstanceFactory.registerLoadUnitInstanceClass( + INNER_OBJECT_LOAD_UNIT_TYPE, + InnerObjectLoadUnitInstance.createInnerObjectLoadUnitInstance, +); diff --git a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts new file mode 100644 index 0000000000..39058f812a --- /dev/null +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -0,0 +1,159 @@ +import { MetadataUtil } from '@eggjs/core-decorator'; +import { IdenticalUtil } from '@eggjs/lifecycle'; +import { EggPrototypeCreatorFactory } from '@eggjs/metadata'; +import type { EggPrototypeLifecycleContext } from '@eggjs/metadata'; +import type { + EggObject, + EggObjectName, + EggProtoImplClass, + EggPrototype, + EggPrototypeName, + Id, + InjectObjectProto, + MetaDataKey, + ObjectInitTypeLike, + QualifierAttribute, + QualifierInfo, + QualifierValue, +} from '@eggjs/tegg-types'; +import { AccessLevel } from '@eggjs/tegg-types'; + +import { EggObjectFactory } from '../factory/EggObjectFactory.ts'; + +/** + * EggPrototype for a host-provided, already-constructed inner object + * (e.g. logger / router instances handed in by the host). It has no inject + * objects and always resolves to the provided instance. + */ +export class ProvidedInnerObjectProto implements EggPrototype { + [key: symbol]: PropertyDescriptor; + /** NOT a class: the factory `() => obj` returning the provided instance. */ + private readonly objFactory: () => object; + private readonly qualifiers: QualifierInfo[]; + + readonly id: string; + readonly name: EggPrototypeName; + readonly initType: ObjectInitTypeLike; + readonly accessLevel: AccessLevel; + readonly injectObjects: InjectObjectProto[]; + readonly loadUnitId: Id; + readonly defineModuleName?: string; + readonly defineUnitPath?: string; + + constructor( + id: string, + name: EggPrototypeName, + objFactory: () => object, + initType: ObjectInitTypeLike, + loadUnitId: Id, + qualifiers: QualifierInfo[], + accessLevel?: AccessLevel, + defineModuleName?: string, + defineUnitPath?: string, + ) { + this.id = id; + this.objFactory = objFactory; + this.name = name; + this.initType = initType; + this.accessLevel = accessLevel ?? AccessLevel.PUBLIC; + this.injectObjects = []; + this.loadUnitId = loadUnitId; + this.qualifiers = qualifiers; + this.defineModuleName = defineModuleName; + this.defineUnitPath = defineUnitPath; + } + + verifyQualifiers(qualifiers: QualifierInfo[]): boolean { + for (const qualifier of qualifiers) { + if (!this.verifyQualifier(qualifier)) { + return false; + } + } + return true; + } + + verifyQualifier(qualifier: QualifierInfo): boolean { + const selfQualifier = this.qualifiers.find((t) => t.attribute === qualifier.attribute); + return selfQualifier?.value === qualifier.value; + } + + constructEggObject(): object { + // no `new`: calling the factory returns the host-provided instance + return this.objFactory(); + } + + getMetaData(metadataKey: MetaDataKey): T | undefined { + return MetadataUtil.getMetaData(metadataKey, this.objFactory as unknown as EggProtoImplClass); + } + + getQualifier(attribute: QualifierAttribute): QualifierValue | undefined { + return this.qualifiers.find((t) => t.attribute === attribute)?.value; + } + + static create(ctx: EggPrototypeLifecycleContext): EggPrototype { + // The descriptor rides the standard EggPrototypeLifecycleContext, whose + // `clazz` slot carries the provided-instance factory (see the builder). + const { clazz, loadUnit } = ctx; + const prototypeInfo = ctx.prototypeInfo as typeof ctx.prototypeInfo & { + defineModuleName?: string; + defineUnitPath?: string; + }; + const name = prototypeInfo.name; + const id = IdenticalUtil.createProtoId(loadUnit.id, name); + return new ProvidedInnerObjectProto( + id, + name, + clazz as unknown as () => object, + prototypeInfo.initType, + loadUnit.id, + prototypeInfo.qualifiers ?? [], + prototypeInfo.accessLevel, + prototypeInfo.defineModuleName, + prototypeInfo.defineUnitPath, + ); + } +} + +/** + * protoImplType for host-provided, already-constructed instances. The + * descriptor carries a factory `clazz` (`() => obj`) so provided objects are + * ordinary protos end to end: same graph vertices, same creator dispatch, + * same instantiation loop — "constructing" one returns the instance. + */ +export const PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE = 'PROVIDED_INNER_OBJECT'; + +EggPrototypeCreatorFactory.registerPrototypeCreator( + PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE, + ProvidedInnerObjectProto.create, +); + +export class ProvidedInnerObject implements EggObject { + readonly isReady: boolean = true; + #obj: object; + readonly proto: ProvidedInnerObjectProto; + readonly name: EggObjectName; + readonly id: string; + + constructor(name: EggObjectName, proto: ProvidedInnerObjectProto) { + this.proto = proto; + this.name = name; + this.id = IdenticalUtil.createObjectId(this.proto.id); + } + + get obj(): object { + if (!this.#obj) { + this.#obj = this.proto.constructEggObject(); + } + return this.#obj; + } + + injectProperty(): void { + return; + } + + static async createObject(name: EggObjectName, proto: EggPrototype): Promise { + return new ProvidedInnerObject(name, proto as ProvidedInnerObjectProto); + } +} + +EggObjectFactory.registerEggObjectCreateMethod(ProvidedInnerObjectProto, ProvidedInnerObject.createObject); diff --git a/tegg/core/runtime/src/impl/index.ts b/tegg/core/runtime/src/impl/index.ts index 8c961313da..727d3527ca 100644 --- a/tegg/core/runtime/src/impl/index.ts +++ b/tegg/core/runtime/src/impl/index.ts @@ -1,6 +1,11 @@ export * from './ContextInitiator.ts'; export * from './ContextObjectGraph.ts'; export * from './EggAlwaysNewObjectContainer.ts'; +export * from './EggInnerObjectImpl.ts'; export * from './EggObjectImpl.ts'; +export * from './InnerObjectLoadUnit.ts'; +export * from './InnerObjectLoadUnitBuilder.ts'; +export * from './InnerObjectLoadUnitInstance.ts'; +export * from './ProvidedInnerObjectProto.ts'; export * from './EggObjectUtil.ts'; export * from './ModuleLoadUnitInstance.ts'; diff --git a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts new file mode 100644 index 0000000000..cdf9ea011c --- /dev/null +++ b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; + +import { + DefineModuleQualifier, + DefineModuleQualifierAttribute, + EggObjectLifecycleProto, + Inject, + InjectOptional, + InnerObjectProto, + LoadUnitLifecycleProto, +} from '@eggjs/core-decorator'; +import { LifecycleInit, LifecyclePostInject } from '@eggjs/lifecycle'; +import { EggPrototypeFactory, EggPrototypeNotFound, LoadUnitFactory } from '@eggjs/metadata'; +import type { + EggObject, + EggObjectLifeCycleContext, + LifecycleHook, + LoadUnit, + LoadUnitInstance, + LoadUnitLifecycleContext, +} from '@eggjs/tegg-types'; +import { AccessLevel } from '@eggjs/tegg-types'; +import { afterEach, beforeEach, describe, it } from 'vitest'; + +import { InnerObjectLoadUnitBuilder, LoadUnitInstanceFactory } from '../src/index.ts'; +import { ContextHandler } from '../src/model/ContextHandler.ts'; +import { EggTestContext } from './fixtures/EggTestContext.ts'; +import TestUtil from './util.ts'; + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +class FooInner { + hello(): string { + return 'foo'; + } +} + +@InnerObjectProto() +class BarInner { + @Inject() + fooInner: FooInner; + + @Inject() + logger: Console; +} + +@LoadUnitLifecycleProto() +class UnitHook implements LifecycleHook { + static postInjectCalled = 0; + static createdUnits: string[] = []; + + @Inject() + barInner: BarInner; + + @LifecyclePostInject() + protected onPostInject(): void { + UnitHook.postInjectCalled++; + } + + async postCreate(_ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + UnitHook.createdUnits.push(String(loadUnit.name)); + } +} + +@EggObjectLifecycleProto() +class ObjectHook implements LifecycleHook { + static interfaceInitCalled = 0; + static decoratedInitCalled = 0; + static hookedObjects: string[] = []; + + // Same name as the self-lifecycle interface method. A lifecycle proto only + // runs decorator-declared self lifecycle, so this must NOT be invoked while + // the hook object itself is created. + async init(): Promise { + ObjectHook.interfaceInitCalled++; + } + + @LifecycleInit() + protected async realInit(): Promise { + ObjectHook.decoratedInitCalled++; + } + + async postCreate(_ctx: EggObjectLifeCycleContext, obj: EggObject): Promise { + ObjectHook.hookedObjects.push(String(obj.proto.name)); + } +} + +describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { + let ctx: EggTestContext; + + beforeEach(() => { + ctx = new EggTestContext(); + mock.method(ContextHandler, 'getContext', () => { + return ctx; + }); + }); + + afterEach(async () => { + await ctx.destroy({}); + mock.reset(); + }); + + it('should instantiate inner objects, wire DI and auto register lifecycle protos', async () => { + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([FooInner, BarInner, UnitHook, ObjectHook], { + name: 'test-inner-module', + path: __dirname, + }); + const innerLoadUnit = await builder.createLoadUnit({ + innerObjects: { + logger: [{ obj: console }], + }, + }); + let innerInstance: LoadUnitInstance | undefined; + let businessInstance: LoadUnitInstance | undefined; + try { + innerInstance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerLoadUnit); + + // inner object DI: class-proto inject + host-provided object inject + const barProto = EggPrototypeFactory.instance.getPrototype('barInner', innerLoadUnit); + const barObj = (innerInstance as any).getEggObject('barInner', barProto).obj as BarInner; + assert.equal(barObj.fooInner.hello(), 'foo'); + assert.equal(barObj.logger, console); + + // decorator-declared self lifecycle ran; interface-name method did not + assert.equal(UnitHook.postInjectCalled, 1); + assert.equal(ObjectHook.decoratedInitCalled, 1); + assert.equal(ObjectHook.interfaceInitCalled, 0); + + // lifecycle protos are live: a business load unit created afterwards is hooked + businessInstance = await TestUtil.createLoadUnitInstance('module-for-load-unit-instance'); + assert(UnitHook.createdUnits.includes(String(businessInstance.loadUnit.name))); + assert(ObjectHook.hookedObjects.length > 0); + + await TestUtil.destroyLoadUnitInstance(businessInstance); + businessInstance = undefined; + + // destroy is symmetric: hooks are deregistered with the inner instance + await LoadUnitInstanceFactory.destroyLoadUnitInstance(innerInstance); + innerInstance = undefined; + const createdCount = UnitHook.createdUnits.length; + businessInstance = await TestUtil.createLoadUnitInstance('module-for-load-unit-instance'); + assert.equal(UnitHook.createdUnits.length, createdCount); + } finally { + if (businessInstance) { + await TestUtil.destroyLoadUnitInstance(businessInstance); + } + if (innerInstance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(innerInstance); + } + await LoadUnitFactory.destroyLoadUnit(innerLoadUnit); + } + }); + + it('should throw on recursive inner object deps', async () => { + @InnerObjectProto() + class CycleA { + @Inject() + cycleB: object; + } + + @InnerObjectProto() + class CycleB { + @Inject() + cycleA: object; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([CycleA, CycleB], { + name: 'cycle-module', + path: __dirname, + }); + await assert.rejects(async () => { + await builder.createLoadUnit({ innerObjects: {} }); + }, /recursive deps/); + }); + + it('should throw on missing non-optional dependency', async () => { + @InnerObjectProto() + class BadInner { + @Inject() + notExists: object; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([BadInner], { + name: 'bad-module', + path: __dirname, + }); + await assert.rejects(async () => { + await builder.createLoadUnit({ innerObjects: {} }); + }, EggPrototypeNotFound); + }); + + it('should allow missing optional dependency and host-provided names', async () => { + @InnerObjectProto() + class TolerantInner { + @InjectOptional() + notExists?: object; + + @Inject() + logger: Console; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([TolerantInner], { + name: 'tolerant-module', + path: __dirname, + }); + const loadUnit = await builder.createLoadUnit({ + innerObjects: { + logger: [{ obj: console }], + }, + }); + let instance: LoadUnitInstance | undefined; + try { + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const proto = EggPrototypeFactory.instance.getPrototype('tolerantInner', loadUnit); + const obj = (instance as any).getEggObject('tolerantInner', proto).obj as TolerantInner; + assert.equal(obj.logger, console); + assert.equal(obj.notExists, undefined); + } finally { + if (instance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); + + it('should resolve same-name inner objects by define module qualifier', async () => { + @InnerObjectProto({ name: 'sharedInner', accessLevel: AccessLevel.PUBLIC }) + class ModuleAInner { + value = 'module-a'; + } + + @InnerObjectProto({ name: 'sharedInner', accessLevel: AccessLevel.PUBLIC }) + class ModuleBInner { + value = 'module-b'; + } + + @InnerObjectProto() + class QualifiedInnerConsumer { + @Inject() + @DefineModuleQualifier('module-b') + sharedInner: { value: string }; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([ModuleAInner], { + name: 'module-a', + path: '/module-a', + }); + builder.addInnerObjectClazzList([ModuleBInner, QualifiedInnerConsumer], { + name: 'module-b', + path: '/module-b', + }); + const loadUnit = await builder.createLoadUnit({ innerObjects: {} }); + let instance: LoadUnitInstance | undefined; + try { + assert.throws( + () => { + EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit); + }, + (err) => { + assert(err instanceof Error); + assert.match(err.message, /multi proto found/); + assert.match(err.message, /define:module-a@\/module-a/); + assert.match(err.message, /define:module-b@\/module-b/); + assert.match(err.message, /Symbol\(Qualifier\.DefineModule\)=module-a/); + assert.match(err.message, /Symbol\(Qualifier\.DefineModule\)=module-b/); + return true; + }, + ); + const moduleAProto = EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit, [ + { + attribute: DefineModuleQualifierAttribute, + value: 'module-a', + }, + ]); + assert(moduleAProto.verifyQualifier({ attribute: DefineModuleQualifierAttribute, value: 'module-a' })); + + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const consumerProto = EggPrototypeFactory.instance.getPrototype('qualifiedInnerConsumer', loadUnit); + const consumer = (instance as any).getEggObject('qualifiedInnerConsumer', consumerProto) + .obj as QualifiedInnerConsumer; + assert.equal(consumer.sharedInner.value, 'module-b'); + } finally { + if (instance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); + + it('should use app as default define module qualifier for provided inner objects', async () => { + const providedShared = { value: 'provided' }; + + @InnerObjectProto({ name: 'sharedHostObject', accessLevel: AccessLevel.PUBLIC }) + class ModuleSharedHostObject { + value = 'module'; + } + + @InnerObjectProto() + class ProvidedInnerConsumer { + @Inject() + @DefineModuleQualifier('app') + sharedHostObject: { value: string }; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([ModuleSharedHostObject, ProvidedInnerConsumer], { + name: 'host-module', + path: '/host-module', + }); + const loadUnit = await builder.createLoadUnit({ + innerObjects: { + sharedHostObject: [{ obj: providedShared }], + }, + }); + let instance: LoadUnitInstance | undefined; + try { + const providedProto = EggPrototypeFactory.instance.getPrototype('sharedHostObject', loadUnit, [ + { + attribute: DefineModuleQualifierAttribute, + value: 'app', + }, + ]); + assert.equal(providedProto.getQualifier(DefineModuleQualifierAttribute), 'app'); + + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const consumerProto = EggPrototypeFactory.instance.getPrototype('providedInnerConsumer', loadUnit); + const consumer = (instance as any).getEggObject('providedInnerConsumer', consumerProto) + .obj as ProvidedInnerConsumer; + assert.equal(consumer.sharedHostObject, providedShared); + } finally { + if (instance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); +}); diff --git a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap index 7de1eddc4d..7f2b5f6e5b 100644 --- a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap @@ -21,6 +21,7 @@ exports[`should export stable 1`] = ` "registerLifecycle": [Function], "registerObjectLifecycle": [Function], }, + "EggInnerObjectImpl": [Function], "EggObjectFactory": [Function], "EggObjectImpl": [Function], "EggObjectLifecycleUtil": { @@ -44,6 +45,12 @@ exports[`should export stable 1`] = ` "READY": "READY", }, "EggObjectUtil": [Function], + "INNER_OBJECT_LOAD_UNIT_NAME": "InnerObjectLoadUnit", + "INNER_OBJECT_LOAD_UNIT_PATH": "InnerObjectLoadUnitPath", + "INNER_OBJECT_LOAD_UNIT_TYPE": "INNER_OBJECT_LOAD_UNIT", + "InnerObjectLoadUnit": [Function], + "InnerObjectLoadUnitBuilder": [Function], + "InnerObjectLoadUnitInstance": [Function], "LoadUnitInstanceFactory": [Function], "LoadUnitInstanceLifecycleUtil": { "clearObjectLifecycle": [Function], @@ -59,6 +66,9 @@ exports[`should export stable 1`] = ` "registerObjectLifecycle": [Function], }, "ModuleLoadUnitInstance": [Function], + "PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE": "PROVIDED_INNER_OBJECT", + "ProvidedInnerObject": [Function], + "ProvidedInnerObjectProto": [Function], "eggContextLifecycleUtilFromBag": [Function], "eggObjectLifecycleUtilFromBag": [Function], "loadUnitInstanceLifecycleUtilFromBag": [Function], diff --git a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap index 17641c1a71..70249544b8 100644 --- a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap @@ -64,8 +64,15 @@ exports[`should export stable 1`] = ` "Cookies": [Function], "CookiesParamMeta": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "DefineModuleQualifier": [Function], + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), + "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", "EVENT_CONTEXT_INJECT": Symbol(EggPrototype#event#handler#context#inject), "EVENT_NAME": Symbol(EggPrototype#eventName), + "EggContextLifecycleProto": [Function], + "EggLifecycleProto": [Function], + "EggObjectLifecycleProto": [Function], + "EggPrototypeLifecycleProto": [Function], "EggQualifier": [Function], "EggQualifierAttribute": Symbol(Qualifier.Egg), "EggType": { @@ -128,6 +135,7 @@ exports[`should export stable 1`] = ` "CONSTRUCTOR": "CONSTRUCTOR", "PROPERTY": "PROPERTY", }, + "InnerObjectProto": [Function], "LifecycleDestroy": [Function], "LifecycleInit": [Function], "LifecyclePostConstruct": [Function], @@ -136,6 +144,8 @@ exports[`should export stable 1`] = ` "LifecyclePreInject": [Function], "LifecyclePreLoad": [Function], "LifecycleUtil": [Function], + "LoadUnitInstanceLifecycleProto": [Function], + "LoadUnitLifecycleProto": [Function], "LoadUnitNameQualifierAttribute": Symbol(Qualifier.LoadUnitName), "MCPController": [Function], "MCPControllerMeta": [Function], diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index 8cce458575..21c4c73fc5 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -26,6 +26,8 @@ exports[`should helper exports stable 1`] = ` "registerLifecycle": [Function], "registerObjectLifecycle": [Function], }, + "EggInnerObjectImpl": [Function], + "EggInnerObjectPrototypeImpl": [Function], "EggLoadUnitType": { "APP": "APP", "MODULE": "MODULE", @@ -84,7 +86,14 @@ exports[`should helper exports stable 1`] = ` "Graph": [Function], "GraphNode": [Function], "GraphPath": [Function], + "INNER_OBJECT_LOAD_UNIT_NAME": "InnerObjectLoadUnit", + "INNER_OBJECT_LOAD_UNIT_PATH": "InnerObjectLoadUnitPath", + "INNER_OBJECT_LOAD_UNIT_TYPE": "INNER_OBJECT_LOAD_UNIT", "IncompatibleProtoInject": [Function], + "InjectObjectPrototypeFinder": [Function], + "InnerObjectLoadUnit": [Function], + "InnerObjectLoadUnitBuilder": [Function], + "InnerObjectLoadUnitInstance": [Function], "LoadUnitFactory": [Function], "LoadUnitInstanceFactory": [Function], "LoadUnitInstanceLifecycleUtil": { @@ -130,18 +139,23 @@ exports[`should helper exports stable 1`] = ` "MultiPrototypeFound": [Function], "NameUtil": [Function], "ObjectUtils": [Function], + "PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE": "PROVIDED_INNER_OBJECT", "ProtoDependencyMeta": [Function], "ProtoDescriptorHelper": [Function], "ProtoDescriptorType": { "CLASS": "CLASS", }, + "ProtoGraphUtils": [Function], "ProtoNode": [Function], + "ProvidedInnerObject": [Function], + "ProvidedInnerObjectProto": [Function], "ProxyUtil": [Function], "StackUtil": [Function], "StreamUtil": [Function], "TEGG_MANIFEST_KEY": "tegg", "TeggError": [Function], "TimerUtil": [Function], + "buildTeggManifestData": [Function], "eggContextLifecycleUtilFromBag": [Function], "eggObjectLifecycleUtilFromBag": [Function], "eggPrototypeLifecycleUtilFromBag": [Function], diff --git a/tegg/core/test-util/src/LoaderUtil.ts b/tegg/core/test-util/src/LoaderUtil.ts index 1c2afa2250..16188bbf6f 100644 --- a/tegg/core/test-util/src/LoaderUtil.ts +++ b/tegg/core/test-util/src/LoaderUtil.ts @@ -1,6 +1,5 @@ import { type EggProtoImplClass, PrototypeUtil } from '@eggjs/core-decorator'; import { - EggLoadUnitType, GlobalGraph, type GlobalGraphBuildHook, GlobalModuleNodeBuilder, @@ -46,45 +45,20 @@ export class LoaderUtil { } static async buildGlobalGraph(modulePaths: string[], hooks?: GlobalGraphBuildHook[]): Promise { - GlobalGraph.instance = new GlobalGraph(); + // Reuse the production classification (LoaderFactory.loadApp): inner + // object protos are diverted out of module clazzLists there, exactly as + // in a real boot — no test-local re-implementation. + const moduleReferences = modulePaths.map((modulePath) => ({ + path: modulePath, + name: ModuleConfigUtil.readModuleNameSync(modulePath), + })); + const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); + const globalGraph = await GlobalGraph.create(moduleDescriptors); for (const hook of hooks ?? []) { - GlobalGraph.instance.registerBuildHook(hook); + globalGraph.registerBuildHook(hook); } - const multiInstanceEggProtoClass: { - clazz: any; - unitPath: string; - moduleName: string; - }[] = []; - for (let i = 0; i < modulePaths.length; i++) { - const modulePath = modulePaths[i]; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); - const clazzList = await loader.load(); - const moduleName = ModuleConfigUtil.readModuleNameSync(modulePath); - for (const clazz of clazzList) { - if (PrototypeUtil.isEggMultiInstancePrototype(clazz)) { - multiInstanceEggProtoClass.push({ - clazz, - unitPath: modulePath, - moduleName, - }); - } - } - } - for (let i = 0; i < modulePaths.length; i++) { - const modulePath = modulePaths[i]; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); - const clazzList = await loader.load(); - const eggProtoClass: EggProtoImplClass[] = []; - for (const clazz of clazzList) { - if (PrototypeUtil.isEggPrototype(clazz)) { - eggProtoClass.push(clazz); - } - } - GlobalGraph.instance.addModuleNode( - LoaderUtil.buildModuleNode(modulePath, eggProtoClass, multiInstanceEggProtoClass), - ); - } - GlobalGraph.instance.build(); - GlobalGraph.instance.sort(); + GlobalGraph.instance = globalGraph; + globalGraph.build(); + globalGraph.sort(); } } diff --git a/tegg/core/types/package.json b/tegg/core/types/package.json index 2b7500edb2..9547718b71 100644 --- a/tegg/core/types/package.json +++ b/tegg/core/types/package.json @@ -60,6 +60,7 @@ "./controller-decorator/model/types": "./src/controller-decorator/model/types.ts", "./core-decorator": "./src/core-decorator/index.ts", "./core-decorator/ContextProto": "./src/core-decorator/ContextProto.ts", + "./core-decorator/EggLifecycleProto": "./src/core-decorator/EggLifecycleProto.ts", "./core-decorator/enum": "./src/core-decorator/enum/index.ts", "./core-decorator/enum/AccessLevel": "./src/core-decorator/enum/AccessLevel.ts", "./core-decorator/enum/EggType": "./src/core-decorator/enum/EggType.ts", @@ -68,8 +69,10 @@ "./core-decorator/enum/ObjectInitType": "./src/core-decorator/enum/ObjectInitType.ts", "./core-decorator/enum/Qualifier": "./src/core-decorator/enum/Qualifier.ts", "./core-decorator/Inject": "./src/core-decorator/Inject.ts", + "./core-decorator/InnerObjectProto": "./src/core-decorator/InnerObjectProto.ts", "./core-decorator/Metadata": "./src/core-decorator/Metadata.ts", "./core-decorator/model": "./src/core-decorator/model/index.ts", + "./core-decorator/model/EggLifecycleInfo": "./src/core-decorator/model/EggLifecycleInfo.ts", "./core-decorator/model/EggMultiInstancePrototypeInfo": "./src/core-decorator/model/EggMultiInstancePrototypeInfo.ts", "./core-decorator/model/EggPrototypeInfo": "./src/core-decorator/model/EggPrototypeInfo.ts", "./core-decorator/model/InjectConstructorInfo": "./src/core-decorator/model/InjectConstructorInfo.ts", @@ -165,6 +168,7 @@ "./controller-decorator/model/types": "./dist/controller-decorator/model/types.js", "./core-decorator": "./dist/core-decorator/index.js", "./core-decorator/ContextProto": "./dist/core-decorator/ContextProto.js", + "./core-decorator/EggLifecycleProto": "./dist/core-decorator/EggLifecycleProto.js", "./core-decorator/enum": "./dist/core-decorator/enum/index.js", "./core-decorator/enum/AccessLevel": "./dist/core-decorator/enum/AccessLevel.js", "./core-decorator/enum/EggType": "./dist/core-decorator/enum/EggType.js", @@ -173,8 +177,10 @@ "./core-decorator/enum/ObjectInitType": "./dist/core-decorator/enum/ObjectInitType.js", "./core-decorator/enum/Qualifier": "./dist/core-decorator/enum/Qualifier.js", "./core-decorator/Inject": "./dist/core-decorator/Inject.js", + "./core-decorator/InnerObjectProto": "./dist/core-decorator/InnerObjectProto.js", "./core-decorator/Metadata": "./dist/core-decorator/Metadata.js", "./core-decorator/model": "./dist/core-decorator/model/index.js", + "./core-decorator/model/EggLifecycleInfo": "./dist/core-decorator/model/EggLifecycleInfo.js", "./core-decorator/model/EggMultiInstancePrototypeInfo": "./dist/core-decorator/model/EggMultiInstancePrototypeInfo.js", "./core-decorator/model/EggPrototypeInfo": "./dist/core-decorator/model/EggPrototypeInfo.js", "./core-decorator/model/InjectConstructorInfo": "./dist/core-decorator/model/InjectConstructorInfo.js", diff --git a/tegg/core/types/src/common/ModuleConfig.ts b/tegg/core/types/src/common/ModuleConfig.ts index b30c8561ff..472bac514e 100644 --- a/tegg/core/types/src/common/ModuleConfig.ts +++ b/tegg/core/types/src/common/ModuleConfig.ts @@ -1,5 +1,6 @@ export interface ModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; diff --git a/tegg/core/types/src/core-decorator/EggLifecycleProto.ts b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts new file mode 100644 index 0000000000..c68c38919d --- /dev/null +++ b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts @@ -0,0 +1,8 @@ +import type { InnerObjectProtoParams } from './InnerObjectProto.ts'; +import type { EggLifecycleType } from './model/EggLifecycleInfo.ts'; + +export interface CommonEggLifecycleProtoParams extends InnerObjectProtoParams { + type: EggLifecycleType; +} + +export type EggLifecycleProtoParams = Omit; diff --git a/tegg/core/types/src/core-decorator/InnerObjectProto.ts b/tegg/core/types/src/core-decorator/InnerObjectProto.ts new file mode 100644 index 0000000000..f85e5d45ee --- /dev/null +++ b/tegg/core/types/src/core-decorator/InnerObjectProto.ts @@ -0,0 +1,3 @@ +import type { SingletonProtoParams } from './SingletonProto.ts'; + +export type InnerObjectProtoParams = SingletonProtoParams; diff --git a/tegg/core/types/src/core-decorator/Prototype.ts b/tegg/core/types/src/core-decorator/Prototype.ts index b5c77fc530..6a1f446c74 100644 --- a/tegg/core/types/src/core-decorator/Prototype.ts +++ b/tegg/core/types/src/core-decorator/Prototype.ts @@ -8,3 +8,5 @@ export interface PrototypeParams { } export const DEFAULT_PROTO_IMPL_TYPE = 'DEFAULT'; + +export const EGG_INNER_OBJECT_PROTO_IMPL_TYPE = 'EGG_INNER_OBJECT_PROTOTYPE'; diff --git a/tegg/core/types/src/core-decorator/enum/Qualifier.ts b/tegg/core/types/src/core-decorator/enum/Qualifier.ts index 0a6a5fc0d7..6e8cd8bfec 100644 --- a/tegg/core/types/src/core-decorator/enum/Qualifier.ts +++ b/tegg/core/types/src/core-decorator/enum/Qualifier.ts @@ -1,5 +1,7 @@ export const ConfigSourceQualifierAttribute: symbol = Symbol.for('Qualifier.ConfigSource'); +export const DefineModuleQualifierAttribute: symbol = Symbol.for('Qualifier.DefineModule'); + export const EggQualifierAttribute: symbol = Symbol.for('Qualifier.Egg'); export const InitTypeQualifierAttribute: symbol = Symbol.for('Qualifier.InitType'); diff --git a/tegg/core/types/src/core-decorator/index.ts b/tegg/core/types/src/core-decorator/index.ts index 884edfa4fc..5a5c6ee953 100644 --- a/tegg/core/types/src/core-decorator/index.ts +++ b/tegg/core/types/src/core-decorator/index.ts @@ -1,7 +1,9 @@ export * from './enum/index.ts'; export * from './model/index.ts'; export * from './ContextProto.ts'; +export * from './EggLifecycleProto.ts'; export * from './Inject.ts'; +export * from './InnerObjectProto.ts'; export * from './Metadata.ts'; export * from './MultiInstanceProto.ts'; export * from './Prototype.ts'; diff --git a/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts b/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts new file mode 100644 index 0000000000..342266bfd6 --- /dev/null +++ b/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts @@ -0,0 +1,5 @@ +export type EggLifecycleType = 'LoadUnit' | 'LoadUnitInstance' | 'EggObject' | 'EggPrototype' | 'EggContext'; + +export interface EggLifecycleInfo { + type: EggLifecycleType; +} diff --git a/tegg/core/types/src/core-decorator/model/index.ts b/tegg/core/types/src/core-decorator/model/index.ts index b9c0ef0795..202a9af54f 100644 --- a/tegg/core/types/src/core-decorator/model/index.ts +++ b/tegg/core/types/src/core-decorator/model/index.ts @@ -1,3 +1,4 @@ +export * from './EggLifecycleInfo.ts'; export * from './EggMultiInstancePrototypeInfo.ts'; export * from './EggPrototypeInfo.ts'; export * from './InjectConstructorInfo.ts'; diff --git a/tegg/core/types/src/metadata/model/EggPrototype.ts b/tegg/core/types/src/metadata/model/EggPrototype.ts index a207508fd1..992fb302d7 100644 --- a/tegg/core/types/src/metadata/model/EggPrototype.ts +++ b/tegg/core/types/src/metadata/model/EggPrototype.ts @@ -125,6 +125,8 @@ export interface EggPrototype extends LifecycleObject; readonly injectType?: InjectType; readonly className?: string; diff --git a/tegg/core/types/src/metadata/model/LoadUnit.ts b/tegg/core/types/src/metadata/model/LoadUnit.ts index c3864b7503..ebc11b0179 100644 --- a/tegg/core/types/src/metadata/model/LoadUnit.ts +++ b/tegg/core/types/src/metadata/model/LoadUnit.ts @@ -14,6 +14,7 @@ export type EggLoadUnitTypeLike = EggLoadUnitType | string; export interface LoadUnitLifecycleContext extends LifecycleContext { unitPath: string; + unitName?: string; loader: Loader; } diff --git a/tegg/core/types/src/metadata/model/ProtoDescriptor.ts b/tegg/core/types/src/metadata/model/ProtoDescriptor.ts index f32eeef1f7..3094143983 100644 --- a/tegg/core/types/src/metadata/model/ProtoDescriptor.ts +++ b/tegg/core/types/src/metadata/model/ProtoDescriptor.ts @@ -12,6 +12,8 @@ export interface InjectObjectDescriptor { refName: PropertyKey; objName: PropertyKey; qualifiers: QualifierInfo[]; + // Spread from InjectObject/InjectConstructor when the descriptor is created. + optional?: boolean; } export interface ProtoDescriptor extends EggPrototypeInfo { diff --git a/tegg/core/types/test/__snapshots__/index.test.ts.snap b/tegg/core/types/test/__snapshots__/index.test.ts.snap index 084833f765..230c00c924 100644 --- a/tegg/core/types/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/types/test/__snapshots__/index.test.ts.snap @@ -121,6 +121,8 @@ exports[`should export stable 1`] = ` "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", "DataSourceInjectName": "dataSource", "DataSourceQualifierAttribute": Symbol(Qualifier.DataSource), + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), + "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", "EggLoadUnitType": { "APP": "APP", "MODULE": "MODULE", diff --git a/tegg/plugin/aop/package.json b/tegg/plugin/aop/package.json index e2d2e69b21..360c301e98 100644 --- a/tegg/plugin/aop/package.json +++ b/tegg/plugin/aop/package.json @@ -28,6 +28,7 @@ "exports": { ".": "./src/index.ts", "./app": "./src/app.ts", + "./InnerObjects": "./src/InnerObjects.ts", "./lib/AopContextHook": "./src/lib/AopContextHook.ts", "./types": "./src/types.ts", "./package.json": "./package.json" @@ -37,6 +38,7 @@ "exports": { ".": "./dist/index.js", "./app": "./dist/app.js", + "./InnerObjects": "./dist/InnerObjects.js", "./lib/AopContextHook": "./dist/lib/AopContextHook.js", "./types": "./dist/types.js", "./package.json": "./package.json" @@ -70,6 +72,9 @@ "engines": { "node": ">=22.18.0" }, + "eggModule": { + "name": "teggAop" + }, "eggPlugin": { "name": "teggAop", "dependencies": [ diff --git a/tegg/plugin/aop/src/InnerObjects.ts b/tegg/plugin/aop/src/InnerObjects.ts new file mode 100644 index 0000000000..8c61ddfefd --- /dev/null +++ b/tegg/plugin/aop/src/InnerObjects.ts @@ -0,0 +1,14 @@ +// This plugin package IS the AOP module: the module scan collects these +// re-exported hook classes (decorated in @eggjs/aop-runtime / +// @eggjs/aop-decorator) into the InnerObjectLoadUnit. Enabling the plugin is +// the whole contract - no registration API. +export { + AopContextAdviceRegistry, + AopGraphHookRegistrar, + EggObjectAopHook, + EggPrototypeCrossCutHook, + LoadUnitAopHook, +} from '@eggjs/aop-runtime'; +export { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; + +export { AopContextHook } from './lib/AopContextHook.ts'; diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index b111456b0a..4de5491abe 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -1,62 +1,19 @@ import assert from 'node:assert'; -import { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; -import { - crossCutGraphHook, - EggObjectAopHook, - EggPrototypeCrossCutHook, - LoadUnitAopHook, - pointCutGraphHook, -} from '@eggjs/aop-runtime'; import { GlobalGraph } from '@eggjs/metadata'; import type { Application, ILifecycleBoot } from 'egg'; -import { AopContextHook } from './lib/AopContextHook.ts'; - export default class AopAppHook implements ILifecycleBoot { private readonly app: Application; - private readonly crosscutAdviceFactory: CrosscutAdviceFactory; - private readonly loadUnitAopHook: LoadUnitAopHook; - private readonly eggPrototypeCrossCutHook: EggPrototypeCrossCutHook; - private readonly eggObjectAopHook: EggObjectAopHook; - private aopContextHook: AopContextHook; constructor(app: Application) { this.app = app; - this.crosscutAdviceFactory = new CrosscutAdviceFactory(); - this.loadUnitAopHook = new LoadUnitAopHook(this.crosscutAdviceFactory); - this.eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(this.crosscutAdviceFactory); - this.eggObjectAopHook = new EggObjectAopHook(); - } - - configDidLoad(): void { - // app.*LifecycleUtil getters are pinned to this app's scope bag, so hook - // registration does not need a TeggScope.run wrapper. - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); } async didLoad(): Promise { - // Register the GlobalGraph build hooks BEFORE moduleHandler.ready(). ready() - // triggers EggModuleLoader.load() -> globalGraph.build(), which is what runs - // the registered build hooks. Registering on GlobalGraph.instance *after* - // ready() is too late — the build has already run — so cross-loadUnit - // crosscut/pointcut advice weaving silently never happens. - this.app.moduleHandler.registerGlobalGraphBuildHook(crossCutGraphHook); - this.app.moduleHandler.registerGlobalGraphBuildHook(pointCutGraphHook); await this.app.moduleHandler.ready(); - // Build hooks are registered above (before ready()), so the graph already - // ran them during build. Resolve the per-app graph for the sanity assert. + // The graph already ran the declaratively registered build hooks during + // build. Resolve the per-app graph for the sanity assert. assert(GlobalGraph.instanceFor(this.app._teggScopeBag), 'GlobalGraph.instance is not set'); - this.aopContextHook = new AopContextHook(this.app.moduleHandler); - this.app.eggContextLifecycleUtil.registerLifecycle(this.aopContextHook); - } - - async beforeClose(): Promise { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); - this.app.eggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); - this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); } } diff --git a/tegg/plugin/aop/src/lib/AopContextHook.ts b/tegg/plugin/aop/src/lib/AopContextHook.ts index e7a8b3bcbe..0d1df2b99d 100644 --- a/tegg/plugin/aop/src/lib/AopContextHook.ts +++ b/tegg/plugin/aop/src/lib/AopContextHook.ts @@ -1,56 +1,19 @@ -import { AspectInfoUtil } from '@eggjs/aop-decorator'; -import { PrototypeUtil, ObjectInitType, type EggProtoImplClass } from '@eggjs/core-decorator'; +import { AopContextAdviceRegistry } from '@eggjs/aop-runtime'; +import { EggContextLifecycleProto, Inject } from '@eggjs/core-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; -import { type EggPrototype, TeggError } from '@eggjs/metadata'; import { ROOT_PROTO } from '@eggjs/module-common'; import type { EggContext, EggContextLifecycleContext } from '@eggjs/tegg-runtime'; -import type { Application } from 'egg'; - -export interface EggPrototypeWithClazz extends EggPrototype { - clazz?: EggProtoImplClass; -} - -export interface ProtoToCreate { - name: string; - proto: EggPrototype; -} +@EggContextLifecycleProto() export class AopContextHook implements LifecycleHook { - private readonly moduleHandler: Application['moduleHandler']; - private requestProtoList: Array = []; - - constructor(moduleHandler: Application['moduleHandler']) { - this.moduleHandler = moduleHandler; - for (const loadUnitInstance of this.moduleHandler.loadUnitInstances) { - const iterator = loadUnitInstance.loadUnit.iterateEggPrototype(); - for (const proto of iterator) { - const protoWithClazz = proto as EggPrototypeWithClazz; - const clazz = protoWithClazz.clazz; - if (!clazz) continue; - const aspects = AspectInfoUtil.getAspectList(clazz); - for (const aspect of aspects) { - for (const advice of aspect.adviceList) { - const adviceProto = PrototypeUtil.getClazzProto(advice.clazz) as EggPrototype | undefined; - if (!adviceProto) { - throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found'); - } - if (adviceProto.initType === ObjectInitType.CONTEXT) { - this.requestProtoList.push({ - name: advice.name, - proto: adviceProto, - }); - } - } - } - } - } - } + @Inject() + private readonly aopContextAdviceRegistry: AopContextAdviceRegistry; async preCreate(_: unknown, ctx: EggContext): Promise { // compatible with egg controller // add context aspect to ctx if (!ctx.get(ROOT_PROTO)) { - for (const proto of this.requestProtoList) { + for (const proto of this.aopContextAdviceRegistry.getRequestProtos()) { ctx.addProtoToCreate(proto.name, proto.proto); } } diff --git a/tegg/plugin/aop/test/aop.test.ts b/tegg/plugin/aop/test/aop.test.ts index d5d817c479..96538b47ae 100644 --- a/tegg/plugin/aop/test/aop.test.ts +++ b/tegg/plugin/aop/test/aop.test.ts @@ -14,12 +14,15 @@ describe('plugin/aop/test/aop.test.ts', () => { return mm.restore(); }); + // App boot exceeds the 10s vitest default on slow Windows runners; the tegg + // projects do not inherit the root config's 20s hookTimeout, so state it + // explicitly with the same value. beforeAll(async () => { app = mm.app({ baseDir: path.join(import.meta.dirname, 'fixtures/apps/aop-app'), }); await app.ready(); - }); + }, 20_000); it('module aop should work', async () => { app.mockCsrf(); diff --git a/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts b/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts index 8f510729e0..37426b5a70 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts @@ -1,4 +1,4 @@ -import { AccessLevel, ContextProto, Inject, SingletonProto } from '@eggjs/tegg'; +import { AccessLevel, ContextProto, Inject, ObjectInitType, SingletonProto } from '@eggjs/tegg'; import { Advice, type AdviceContext, Crosscut, type IAdvice, Pointcut, PointcutType } from '@eggjs/tegg/aop'; import type { EggLogger } from 'egg'; @@ -49,7 +49,7 @@ export class CrosscutAdvice implements IAdvice { } } -@Advice() +@Advice({ initType: ObjectInitType.CONTEXT }) export class ContextPointcutAdvice implements IAdvice { async around(ctx: AdviceContext, next: () => Promise): Promise { ctx.args[0] = `withContextPointAroundParam(${ctx.args[0]})`; diff --git a/tegg/plugin/config/package.json b/tegg/plugin/config/package.json index 1fe338dc72..9552a50310 100644 --- a/tegg/plugin/config/package.json +++ b/tegg/plugin/config/package.json @@ -31,6 +31,7 @@ "./agent": "./src/agent.ts", "./app": "./src/app.ts", "./config/config.default": "./src/config/config.default.ts", + "./lib/ConfigSourceLoadUnitHook": "./src/lib/ConfigSourceLoadUnitHook.ts", "./lib/ModuleScanner": "./src/lib/ModuleScanner.ts", "./types": "./src/types.ts", "./package.json": "./package.json" @@ -42,6 +43,7 @@ "./agent": "./dist/agent.js", "./app": "./dist/app.js", "./config/config.default": "./dist/config/config.default.js", + "./lib/ConfigSourceLoadUnitHook": "./dist/lib/ConfigSourceLoadUnitHook.js", "./lib/ModuleScanner": "./dist/lib/ModuleScanner.js", "./types": "./dist/types.js", "./package.json": "./package.json" @@ -51,6 +53,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@eggjs/core-decorator": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-types": "workspace:*", @@ -68,6 +71,9 @@ "engines": { "node": ">=22.18.0" }, + "eggModule": { + "name": "teggConfig" + }, "eggPlugin": { "name": "teggConfig" } diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 4346a65f91..e65e34f5ef 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -70,14 +70,23 @@ export default class App implements ILifecycleBoot { // 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; + let appReadModuleOptions = readModuleOptions; if (outDir) { const extraFilePattern = readModuleOptions.extraFilePattern || []; const excludePattern = `!**/${outDir}`; if (!extraFilePattern.includes(excludePattern)) { - readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; + appReadModuleOptions = { + ...readModuleOptions, + extraFilePattern: [...extraFilePattern, excludePattern], + }; } } - const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions); + const moduleScanner = new ModuleScanner( + this.app.baseDir, + readModuleOptions, + this.app.coreLogger, + appReadModuleOptions, + ); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { @@ -92,23 +101,18 @@ export default class App implements ILifecycleBoot { #loadModuleConfigs(): void { this.app.moduleConfigs = {}; for (const reference of this.app.moduleReferences) { - // Module reference paths from the manifest / ModuleScanner are absolute or - // relative to baseDir. `ModuleConfigUtil.resolveModuleDir` resolves a - // relative path against `baseDir/config` (the `config/module.json` - // convention), which is wrong here, so resolve against baseDir directly. In - // bundle mode baseDir is the output dir where the bundler copied each - // module's package.json. - const absoluteRef: ModuleReference = { - path: path.isAbsolute(reference.path) ? reference.path : path.resolve(this.app.baseDir, reference.path), - name: reference.name, + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.app.baseDir); + const resolvedRef: ModuleReference = { + path: resolved.path, + name: resolved.name, + package: reference.package, optional: reference.optional, + loaderType: reference.loaderType, }; - - const moduleName = ModuleConfigUtil.readModuleNameSync(absoluteRef.path); - this.app.moduleConfigs[moduleName] = { - name: moduleName, - reference: absoluteRef, - config: ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path), + this.app.moduleConfigs[resolved.name] = { + name: resolved.name, + reference: resolvedRef, + config: resolved.config, }; } diff --git a/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts b/tegg/plugin/config/src/lib/ConfigSourceLoadUnitHook.ts similarity index 80% rename from tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts rename to tegg/plugin/config/src/lib/ConfigSourceLoadUnitHook.ts index 8b0de193dc..b3104cc706 100644 --- a/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts +++ b/tegg/plugin/config/src/lib/ConfigSourceLoadUnitHook.ts @@ -1,17 +1,19 @@ import { + LoadUnitLifecycleProto, PrototypeUtil, QualifierUtil, ConfigSourceQualifier, ConfigSourceQualifierAttribute, } from '@eggjs/core-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; -import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; +import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; /** - * Copy from standalone/src/ConfigSourceLoadUnitHook - * Hook for inject moduleConfig. - * Add default qualifier value is current module name. + * Host-agnostic module plugin hook shared by the egg plugin and the + * standalone app: gives every `moduleConfig` injection a default + * ConfigSourceQualifier of the owning module's name. */ +@LoadUnitLifecycleProto() export class ConfigSourceLoadUnitHook implements LifecycleHook { async preCreate(ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { const classList = await ctx.loader.load(); diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 2b5a02ee54..0ee4757d55 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -1,40 +1,152 @@ -import { readFileSync } from 'node:fs'; +import fs from 'node:fs'; import path from 'node:path'; import { debuglog } from 'node:util'; import { ModuleConfigUtil, type ModuleReference, type ReadModuleReferenceOptions } from '@eggjs/tegg-common-util'; -import { importResolve } from '@eggjs/utils'; +import { getFrameworkPath } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); +interface WarnLogger { + warn(message: string): void; +} + export class ModuleScanner { private readonly baseDir: string; private readonly readModuleOptions: ReadModuleReferenceOptions; + private readonly appReadModuleOptions: ReadModuleReferenceOptions; + private readonly logger?: WarnLogger; - constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions) { + constructor( + baseDir: string, + readModuleOptions: ReadModuleReferenceOptions, + logger?: WarnLogger, + appReadModuleOptions: ReadModuleReferenceOptions = readModuleOptions, + ) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; + this.appReadModuleOptions = appReadModuleOptions; + this.logger = logger; + } + + /** + * Directory of THE framework the app runs on — its own package.json + * dependencies declare the module plugins it ships. Resolved with the + * canonical convention (`getFrameworkPath`): explicit `pkg.egg.framework` + * first, default `egg` otherwise — so apps that declare nothing (e.g. + * cnpmcore) still get the egg-shipped module plugins, and chair apps + * (which declare their framework) automatically scan chair. + */ + private resolveFrameworkDir(baseDir: string): string | undefined { + try { + return getFrameworkPath({ baseDir }); + } catch (err) { + // No package.json or no resolvable framework next to the app (e.g. + // bare unit fixtures without node_modules) — app modules only. + debug( + 'resolve framework dir failed, baseDir: %s, err: %s', + baseDir, + err instanceof Error ? err.message : String(err), + ); + return undefined; + } + } + + private resolveParentFrameworkDir(frameworkDir: string): string | undefined { + let pkg: { egg?: { framework?: unknown } }; + try { + pkg = JSON.parse(fs.readFileSync(path.join(frameworkDir, 'package.json'), 'utf8')); + } catch (err) { + debug( + 'read framework package failed, frameworkDir: %s, err: %s', + frameworkDir, + err instanceof Error ? err.message : String(err), + ); + return undefined; + } + const framework = pkg.egg?.framework; + if (typeof framework !== 'string' || !framework) { + return undefined; + } + return this.resolveFrameworkDir(frameworkDir); + } + + private resolveFrameworkDirs(): readonly string[] { + const frameworkDirs: string[] = []; + const seen = new Set(); + let frameworkDir = this.resolveFrameworkDir(this.baseDir); + while (frameworkDir && !seen.has(frameworkDir)) { + seen.add(frameworkDir); + frameworkDirs.push(frameworkDir); + frameworkDir = this.resolveParentFrameworkDir(frameworkDir); + } + return frameworkDirs; + } + + private readModuleReferences(baseDir: string, cwd?: string): readonly ModuleReference[] { + const readModuleOptions = cwd ? this.readModuleOptions : this.appReadModuleOptions; + return ModuleConfigUtil.readModuleReference(baseDir, { + ...readModuleOptions, + ...(cwd ? { cwd } : {}), + }); + } + + // Same physical module reached via two different path strings — e.g. an app + // inline module.json path (not realpath'd) vs the same module resolved from + // node_modules with fs.realpathSync — must NOT warn. Compare realpaths, not + // raw strings, so pnpm/symlinked layouts don't trigger a spurious duplicate. + private static isSamePath(a: string, b: string): boolean { + if (a === b) return true; + try { + return fs.realpathSync(a) === fs.realpathSync(b); + } catch { + return false; + } + } + + private warnDuplicateModuleName(kept: ModuleReference, skipped: ModuleReference): void { + if (ModuleScanner.isSamePath(kept.path, skipped.path)) { + return; + } + const message = + `[egg/tegg/plugin/config] Duplicate module name "${skipped.name}" found while scanning module references, ` + + `keep ${kept.path}, skip ${skipped.path}`; + if (this.logger) { + this.logger.warn(message); + } else { + debug(message); + } + } + + private deduplicateLayeredModuleReferences(moduleReferences: readonly ModuleReference[]): readonly ModuleReference[] { + const result: ModuleReference[] = []; + const nameMap = new Map(); + + for (const moduleReference of moduleReferences) { + const existing = nameMap.get(moduleReference.name); + if (existing) { + this.warnDuplicateModuleName(existing, moduleReference); + continue; + } + nameMap.set(moduleReference.name, moduleReference); + result.push(moduleReference); + } + + return result; } /** * - load module references from config or scan from baseDir - * - load framework module as optional module reference + * - load the framework's module plugins as OPTIONAL references + * (plugin promotion flips the enabled ones to non-optional) */ loadModuleReferences(): readonly ModuleReference[] { - const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); - const appPkg: { egg?: { framework?: string } } = JSON.parse( - readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), + const moduleReferences = this.readModuleReferences(this.baseDir); + const frameworkDirs = this.resolveFrameworkDirs(); + debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); + const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => + this.readModuleReferences(frameworkDir, frameworkDir), ); - const framework = appPkg.egg?.framework; - if (!framework) { - return ModuleConfigUtil.deduplicateModules(moduleReferences); - } - const frameworkPkg = importResolve(`${framework}/package.json`, { - paths: [this.baseDir], - }); - const frameworkDir = path.dirname(frameworkPkg); - debug('loadModuleReferences from framework:%o, frameworkDir:%o', framework, frameworkDir); - const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); // Merge all module references and deduplicate const allModuleReferences = [ @@ -42,6 +154,6 @@ export class ModuleScanner { ...optionalModuleReferences.map((ref) => ({ ...ref, optional: true })), ]; - return ModuleConfigUtil.deduplicateModules(allModuleReferences); + return this.deduplicateLayeredModuleReferences(allModuleReferences); } } diff --git a/tegg/plugin/config/test/ManifestModuleReference.test.ts b/tegg/plugin/config/test/ManifestModuleReference.test.ts index 4cbfe84583..b4867bf52a 100644 --- a/tegg/plugin/config/test/ManifestModuleReference.test.ts +++ b/tegg/plugin/config/test/ManifestModuleReference.test.ts @@ -13,7 +13,9 @@ const moduleDir = getFixtures('apps/app-with-modules/app/module-a'); // Build a minimal fake Application that feeds module references straight from a // tegg manifest extension, mirroring how a bundled worker entry primes the loader // without running the globby module scan. -function createFakeApp(moduleReferences: { name?: string; path: string; optional?: boolean }[]): Application { +function createFakeApp( + moduleReferences: { name?: string; package?: string; path: string; optional?: boolean }[], +): Application { return { baseDir, config: { tegg: { readModuleOptions: {} } }, @@ -51,7 +53,9 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { reference: { optional: undefined, name: 'moduleA', + package: undefined, path: moduleDir, + loaderType: undefined, }, }, }); @@ -67,4 +71,12 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { expect(app.moduleConfigs.moduleA.reference.path).toBe(moduleDir); }); + + it('stores the resolved module name on manifest references without name', () => { + const app = createFakeApp([{ path: 'app/module-a' }]); + + new App(app).configWillLoad(); + + expect(app.moduleConfigs.moduleA.reference.name).toBe('moduleA'); + }); }); diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts new file mode 100644 index 0000000000..692a227f81 --- /dev/null +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -0,0 +1,136 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { ModuleScanner } from '../src/lib/ModuleScanner.js'; +import { getFixtures } from './utils.js'; + +describe('plugin/config/test/ModuleScanner.test.ts', () => { + it('should scan module plugins from every framework layer and keep nearest duplicate names', () => { + const baseDir = getFixtures('framework-chain/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + optional: true, + }, + { + name: 'sharedModule', + package: 'shared-module-chair', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair'), + optional: true, + }, + { + name: 'baseModule', + package: 'base-module', + path: path.join(baseDir, 'node_modules/base-framework/node_modules/base-module'), + optional: true, + }, + ]); + + const refsWithLogger = new ModuleScanner( + baseDir, + {}, + { warn: (message) => warnings.push(message) }, + ).loadModuleReferences(); + expect(refsWithLogger).toEqual(refs); + expect(warnings).toEqual([ + expect.stringContaining('Duplicate module name "sharedModule" found while scanning module references'), + ]); + expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair')); + expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/base-framework/node_modules/shared-module-base')); + }); + + it('should keep the first app reference when one scan root has duplicate module names', () => { + const baseDir = getFixtures('app-duplicate-name-first-wins/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toHaveLength(1); + expect(refs[0].name).toBe('sharedModule'); + expect(['near-module', 'far-module']).toContain(refs[0].package); + expect([path.join(baseDir, 'node_modules/near-module'), path.join(baseDir, 'node_modules/far-module')]).toContain( + refs[0].path, + ); + expect(warnings).toEqual([ + expect.stringContaining('Duplicate module name "sharedModule" found while scanning module references'), + ]); + expect(warnings[0]).toContain(`keep ${refs[0].path}`); + expect(warnings[0]).toContain( + `skip ${ + refs[0].package === 'near-module' + ? path.join(baseDir, 'node_modules/far-module') + : path.join(baseDir, 'node_modules/near-module') + }`, + ); + }); + + it('should keep the app reference when a framework scans the same module path', () => { + const baseDir = getFixtures('framework-same-path/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + }, + ]); + expect(warnings).toEqual([]); + }); + + it('should resolve framework module.json package references from the framework directory', () => { + const baseDir = getFixtures('framework-module-json/app'); + const refs = new ModuleScanner(baseDir, { cwd: baseDir }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'frameworkConfigModule', + package: 'framework-config-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/framework-config-module'), + optional: true, + }, + ]); + }); + + it('should apply auto outDir exclusion only to the app scan', () => { + const baseDir = getFixtures('framework-dist-scan/app'); + const refs = new ModuleScanner(baseDir, {}, undefined, { + extraFilePattern: ['!**/dist'], + }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'frameworkDistModule', + package: 'framework-dist-module', + path: path.join(baseDir, 'node_modules/chair-framework/dist/modules/framework-dist-module'), + optional: true, + }, + ]); + }); + + it('should stop scanning when framework chain has a cycle', () => { + const baseDir = getFixtures('framework-cycle/app'); + const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + optional: true, + }, + { + name: 'baseModule', + package: 'base-module', + path: path.join(baseDir, 'node_modules/base-framework/node_modules/base-module'), + optional: true, + }, + ]); + }); +}); diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index 2381eb28e8..bf3c4a9657 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -18,24 +18,30 @@ describe('plugin/config/test/ReadModule.test.ts', () => { }); it('should work', () => { - expect(app.moduleConfigs).toEqual({ - moduleA: { - config: {}, - name: 'moduleA', - reference: { - optional: undefined, - name: 'moduleA', - path: getFixtures('apps/app-with-modules/app/module-a'), - }, - }, - }); - expect(app.moduleReferences).toEqual([ - { + // The app's own module, exactly as scanned. + expect(app.moduleConfigs.moduleA).toEqual({ + config: {}, + name: 'moduleA', + reference: { optional: undefined, name: 'moduleA', + package: 'module-a', path: getFixtures('apps/app-with-modules/app/module-a'), + loaderType: undefined, }, - ]); + }); + expect(app.moduleReferences).toContainEqual({ + optional: undefined, + name: 'moduleA', + package: 'module-a', + path: getFixtures('apps/app-with-modules/app/module-a'), + }); + // Framework module plugins are discovered through the default framework + // (`egg`) scan even when the app declares no pkg.egg.framework — the + // cnpmcore shape. They join as OPTIONAL references until plugin + // promotion. + const teggConfigRef = app.moduleReferences.find((ref) => ref.name === 'teggConfig'); + expect(teggConfigRef?.optional).toBe(true); }); it('should type defines work', () => { diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json new file mode 100644 index 0000000000..70120f994f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "far-module", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json new file mode 100644 index 0000000000..216610b5c1 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "near-module", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json new file mode 100644 index 0000000000..7f1c11947d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "app-duplicate-name-first-wins", + "type": "module", + "dependencies": { + "far-module": "*", + "near-module": "*" + }, + "egg": { + "framework": "missing-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json new file mode 100644 index 0000000000..3cae39e962 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "base-module", + "type": "module", + "eggModule": { + "name": "baseModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json new file mode 100644 index 0000000000..811f8ccbff --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared-module-base", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json new file mode 100644 index 0000000000..360d64c0dd --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json @@ -0,0 +1,11 @@ +{ + "name": "base-framework", + "type": "module", + "dependencies": { + "base-module": "*", + "shared-module-base": "*" + }, + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json new file mode 100644 index 0000000000..5d6b676bb7 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared-module-chair", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..ae8e9dc342 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json @@ -0,0 +1,11 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*", + "shared-module-chair": "*" + }, + "egg": { + "framework": "base-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/package.json new file mode 100644 index 0000000000..750960de6f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-chain-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json new file mode 100644 index 0000000000..3cae39e962 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "base-module", + "type": "module", + "eggModule": { + "name": "baseModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json new file mode 100644 index 0000000000..cfbb57804e --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json @@ -0,0 +1,10 @@ +{ + "name": "base-framework", + "type": "module", + "dependencies": { + "base-module": "*" + }, + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..50aed7984b --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json @@ -0,0 +1,10 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*" + }, + "egg": { + "framework": "base-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json new file mode 100644 index 0000000000..703b8787e6 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-cycle-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json new file mode 100644 index 0000000000..027dc4a7a3 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-dist-module", + "type": "module", + "eggModule": { + "name": "appDistModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json new file mode 100644 index 0000000000..94b25d8d55 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-dist-module", + "type": "module", + "eggModule": { + "name": "frameworkDistModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..3e840b548d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json new file mode 100644 index 0000000000..107dcb7ff3 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-dist-scan-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json new file mode 100644 index 0000000000..6a32c04891 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json @@ -0,0 +1,5 @@ +[ + { + "package": "framework-config-module" + } +] diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json new file mode 100644 index 0000000000..e56b63a263 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-config-module", + "type": "module", + "eggModule": { + "name": "frameworkConfigModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..3e840b548d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json new file mode 100644 index 0000000000..561b429610 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-config-module", + "type": "module", + "eggModule": { + "name": "appWrongModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json new file mode 100644 index 0000000000..7d52ceb0a7 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-module-json-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json new file mode 100644 index 0000000000..0a74110cad --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../node_modules/chair-framework/node_modules/chair-module" + } +] diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..b106add274 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json new file mode 100644 index 0000000000..f2c32d2045 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-same-path-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/dal/package.json b/tegg/plugin/dal/package.json index 22d4b4a7fa..4a814ae5af 100644 --- a/tegg/plugin/dal/package.json +++ b/tegg/plugin/dal/package.json @@ -28,8 +28,6 @@ "types": "./dist/index.d.ts", "exports": { ".": "./src/index.ts", - "./app": "./src/app.ts", - "./app/extend/application": "./src/app/extend/application.ts", "./lib/DalModuleLoadUnitHook": "./src/lib/DalModuleLoadUnitHook.ts", "./lib/DalTableEggPrototypeHook": "./src/lib/DalTableEggPrototypeHook.ts", "./lib/DataSource": "./src/lib/DataSource.ts", @@ -45,8 +43,6 @@ "access": "public", "exports": { ".": "./dist/index.js", - "./app": "./dist/app.js", - "./app/extend/application": "./dist/app/extend/application.js", "./lib/DalModuleLoadUnitHook": "./dist/lib/DalModuleLoadUnitHook.js", "./lib/DalTableEggPrototypeHook": "./dist/lib/DalTableEggPrototypeHook.js", "./lib/DataSource": "./dist/lib/DataSource.js", @@ -87,8 +83,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "@eggjs/tegg-plugin": "workspace:*", - "egg": "workspace:*" + "@eggjs/tegg-plugin": "workspace:*" }, "engines": { "node": ">=22.18.0" diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts deleted file mode 100644 index 56a53f32ce..0000000000 --- a/tegg/plugin/dal/src/app.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { TeggScope } from '@eggjs/tegg-types'; -import type { Application, ILifecycleBoot } from 'egg'; - -import { DalModuleLoadUnitHook } from './lib/DalModuleLoadUnitHook.ts'; -import { DalTableEggPrototypeHook } from './lib/DalTableEggPrototypeHook.ts'; -import { MysqlDataSourceManager } from './lib/MysqlDataSourceManager.ts'; -import { SqlMapManager } from './lib/SqlMapManager.ts'; -import { TableModelManager } from './lib/TableModelManager.ts'; -import { TransactionPrototypeHook } from './lib/TransactionPrototypeHook.ts'; - -export default class DalAppBootHook implements ILifecycleBoot { - private readonly app: Application; - private dalTableEggPrototypeHook: DalTableEggPrototypeHook; - private dalModuleLoadUnitHook: DalModuleLoadUnitHook; - private transactionPrototypeHook: TransactionPrototypeHook; - - constructor(app: Application) { - this.app = app; - } - - configWillLoad(): void { - this.dalModuleLoadUnitHook = new DalModuleLoadUnitHook(this.app.config.env, this.app.moduleConfigs); - this.dalTableEggPrototypeHook = new DalTableEggPrototypeHook(this.app.logger); - this.transactionPrototypeHook = new TransactionPrototypeHook(this.app.moduleConfigs, this.app.logger); - // app.*LifecycleUtil getters are pinned to this app's scope bag — no run wrap needed. - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); - this.app.eggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); - } - - async beforeClose(): Promise { - if (this.dalTableEggPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); - } - if (this.dalModuleLoadUnitHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); - } - if (this.transactionPrototypeHook) { - this.app.eggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); - } - // The per-app DAL managers are resolved/cleared within this app's scope. - await TeggScope.run(this.app._teggScopeBag, async () => { - MysqlDataSourceManager.instance.clear(); - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); - }); - } -} diff --git a/tegg/plugin/dal/src/app/extend/application.ts b/tegg/plugin/dal/src/app/extend/application.ts deleted file mode 100644 index 3c3b4d90c2..0000000000 --- a/tegg/plugin/dal/src/app/extend/application.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TeggScope } from '@eggjs/tegg-types'; -import type { Application } from 'egg'; - -import { MysqlDataSourceManager } from '../../lib/MysqlDataSourceManager.ts'; - -export default { - // Pin to THIS app's scope so `app.mysqlDataSourceManager` returns the app's - // per-app manager even when accessed outside a request/boot scope. - get mysqlDataSourceManager(): MysqlDataSourceManager { - const app = this as unknown as Application; - return TeggScope.run(app._teggScopeBag, () => MysqlDataSourceManager.instance); - }, -}; diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index a00eea2097..6ad8165ca5 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -1,23 +1,40 @@ +import { Inject, InjectOptional, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; import { DatabaseForker, type DataSourceOptions } from '@eggjs/dal-runtime'; -import type { LifecycleHook } from '@eggjs/lifecycle'; +import { LifecycleDestroy, type LifecycleHook } from '@eggjs/lifecycle'; import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; -import type { Logger, ModuleConfigHolder } from '@eggjs/tegg-types'; +import type { ModuleConfigs, RuntimeConfig } from '@eggjs/tegg-common-util'; +import type { Logger } from '@eggjs/tegg-types'; import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; +import { SqlMapManager } from './SqlMapManager.ts'; +import { TableModelManager } from './TableModelManager.ts'; +@LoadUnitLifecycleProto() export class DalModuleLoadUnitHook implements LifecycleHook { - private readonly moduleConfigs: Record; - private readonly env: string; + @Inject() + private readonly moduleConfigs: ModuleConfigs; + + @Inject() + private readonly runtimeConfig: Partial; + + @InjectOptional() private readonly logger?: Logger; - constructor(env: string, moduleConfigs: Record, logger?: Logger) { - this.env = env; - this.moduleConfigs = moduleConfigs; - this.logger = logger; + @Inject() + private readonly mysqlDataSourceManager: MysqlDataSourceManager; + + @Inject() + private readonly sqlMapManager: SqlMapManager; + + @Inject() + private readonly tableModelManager: TableModelManager; + + private get env(): string { + return this.runtimeConfig.env ?? ''; } async preCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { - const moduleConfigHolder = this.moduleConfigs[loadUnit.name]; + const moduleConfigHolder = this.moduleConfigs.inner[loadUnit.name]; if (!moduleConfigHolder) return; const dataSourceConfig: Record | undefined = (moduleConfigHolder.config as any) .dataSource; @@ -35,7 +52,7 @@ export class DalModuleLoadUnitHook implements LifecycleHook { + this.mysqlDataSourceManager.clear(); + this.sqlMapManager.clear(); + this.tableModelManager.clear(); + } } diff --git a/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts b/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts index bda96bfa1e..d2bf4924a5 100644 --- a/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts +++ b/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts @@ -1,3 +1,4 @@ +import { EggPrototypeLifecycleProto, Inject } from '@eggjs/core-decorator'; import { DaoInfoUtil, TableModel } from '@eggjs/dal-decorator'; import { SqlMapLoader } from '@eggjs/dal-runtime'; import type { LifecycleHook } from '@eggjs/lifecycle'; @@ -7,12 +8,16 @@ import type { Logger } from '@eggjs/tegg-types'; import { SqlMapManager } from './SqlMapManager.ts'; import { TableModelManager } from './TableModelManager.ts'; +@EggPrototypeLifecycleProto() export class DalTableEggPrototypeHook implements LifecycleHook { + @Inject() private readonly logger: Logger; - constructor(logger: Logger) { - this.logger = logger; - } + @Inject() + private readonly sqlMapManager: SqlMapManager; + + @Inject() + private readonly tableModelManager: TableModelManager; async preCreate(ctx: EggPrototypeLifecycleContext): Promise { if (!DaoInfoUtil.getIsDao(ctx.clazz)) { @@ -20,9 +25,9 @@ export class DalTableEggPrototypeHook implements LifecycleHook = TableModel.build(tableClazz); - TableModelManager.instance.set(ctx.loadUnit.name, tableModel); + this.tableModelManager.set(ctx.loadUnit.name, tableModel); const loader = new SqlMapLoader(tableModel, ctx.clazz, this.logger); const sqlMap = loader.load(); - SqlMapManager.instance.set(ctx.loadUnit.name, sqlMap); + this.sqlMapManager.set(ctx.loadUnit.name, sqlMap); } } diff --git a/tegg/plugin/dal/src/lib/DataSource.ts b/tegg/plugin/dal/src/lib/DataSource.ts index acc1959289..dbdfdbbfd3 100644 --- a/tegg/plugin/dal/src/lib/DataSource.ts +++ b/tegg/plugin/dal/src/lib/DataSource.ts @@ -61,6 +61,9 @@ export class DataSourceDelegate extends DataSource { objInfo: ObjectInfo; constructor( + @Inject() mysqlDataSourceManager: MysqlDataSourceManager, + @Inject() sqlMapManager: SqlMapManager, + @Inject() tableModelManager: TableModelManager, @Inject({ name: 'transactionalAOP' }) transactionalAOP: TransactionalAOP, @MultiInstanceInfo([DataSourceQualifierAttribute, LoadUnitNameQualifierAttribute]) objInfo: ObjectInfo, @@ -70,11 +73,11 @@ export class DataSourceDelegate extends DataSource { )?.value; assert(dataSourceQualifierValue); const [moduleName, dataSource, clazzName] = (dataSourceQualifierValue as string).split('.'); - const tableModel = TableModelManager.instance.get(moduleName, clazzName); + const tableModel = tableModelManager.get(moduleName, clazzName); assert(tableModel, `not found table ${dataSourceQualifierValue}`); - const mysqlDataSource = MysqlDataSourceManager.instance.get(moduleName, dataSource); + const mysqlDataSource = mysqlDataSourceManager.get(moduleName, dataSource); assert(mysqlDataSource, `not found dataSource ${dataSource} in module ${moduleName}`); - const sqlMap = SqlMapManager.instance.get(moduleName, clazzName); + const sqlMap = sqlMapManager.get(moduleName, clazzName); assert(sqlMap, `not found SqlMap ${clazzName} in module ${moduleName}`); super(tableModel as TableModel, mysqlDataSource, sqlMap); diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts index a73f925fdc..8a5a39f3df 100644 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts @@ -1,22 +1,10 @@ import crypto from 'node:crypto'; +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import { type DataSourceOptions, MysqlDataSource } from '@eggjs/dal-runtime'; -import { TeggScope } from '@eggjs/tegg-types'; - -const MYSQL_DATA_SOURCE_MANAGER_SLOT = Symbol('tegg:dal:mysqlDataSourceManager'); +@InnerObjectProto({ name: 'mysqlDataSourceManager', accessLevel: AccessLevel.PUBLIC }) export class MysqlDataSourceManager { - // Per-app: holds live MysqlDataSource connections keyed by config hash. Made - // per-app so two apps with identical DB config no longer share one connection - // object (and each app's teardown only disposes its own). - static get instance(): MysqlDataSourceManager { - return TeggScope.resolve( - MYSQL_DATA_SOURCE_MANAGER_SLOT, - () => new MysqlDataSourceManager(), - 'MysqlDataSourceManager.instance', - ); - } - private readonly dataSourceIndices: Map< string /* moduleName */, Map diff --git a/tegg/plugin/dal/src/lib/SqlMapManager.ts b/tegg/plugin/dal/src/lib/SqlMapManager.ts index 14d9825624..3f6842dbae 100644 --- a/tegg/plugin/dal/src/lib/SqlMapManager.ts +++ b/tegg/plugin/dal/src/lib/SqlMapManager.ts @@ -1,14 +1,8 @@ +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import type { TableSqlMap } from '@eggjs/dal-runtime'; -import { TeggScope } from '@eggjs/tegg-types'; - -const SQL_MAP_MANAGER_SLOT = Symbol('tegg:dal:sqlMapManager'); +@InnerObjectProto({ name: 'sqlMapManager', accessLevel: AccessLevel.PUBLIC }) export class SqlMapManager { - // Per-app: keyed by module name (collides across apps); resolved from scope. - static get instance(): SqlMapManager { - return TeggScope.resolve(SQL_MAP_MANAGER_SLOT, () => new SqlMapManager(), 'SqlMapManager.instance'); - } - private sqlMaps: Map>; constructor() { diff --git a/tegg/plugin/dal/src/lib/TableModelManager.ts b/tegg/plugin/dal/src/lib/TableModelManager.ts index af516b252f..73c3c4f3ec 100644 --- a/tegg/plugin/dal/src/lib/TableModelManager.ts +++ b/tegg/plugin/dal/src/lib/TableModelManager.ts @@ -1,15 +1,8 @@ +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import type { TableModel } from '@eggjs/dal-decorator'; -import { TeggScope } from '@eggjs/tegg-types'; - -const TABLE_MODEL_MANAGER_SLOT = Symbol('tegg:dal:tableModelManager'); +@InnerObjectProto({ name: 'tableModelManager', accessLevel: AccessLevel.PUBLIC }) export class TableModelManager { - // Per-app: keyed by module name, which collides across apps; resolved from the - // active TeggScope bag so two apps never share table-model registrations. - static get instance(): TableModelManager { - return TeggScope.resolve(TABLE_MODEL_MANAGER_SLOT, () => new TableModelManager(), 'TableModelManager.instance'); - } - private tableModels: Map>; constructor() { diff --git a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts index fce764bef5..dbc83363c2 100644 --- a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts +++ b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts @@ -1,23 +1,27 @@ import assert from 'node:assert'; import { Pointcut } from '@eggjs/aop-decorator'; +import { EggPrototypeLifecycleProto, Inject } from '@eggjs/core-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; import type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/metadata'; -import type { ModuleConfigHolder, Logger } from '@eggjs/tegg-types'; +import type { ModuleConfigs } from '@eggjs/tegg-common-util'; +import type { Logger } from '@eggjs/tegg-types'; import { PropagationType } from '@eggjs/tegg-types'; import { TransactionMetaBuilder } from '@eggjs/transaction-decorator'; import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; import { TransactionalAOP, type TransactionalParams } from './TransactionalAOP.ts'; +@EggPrototypeLifecycleProto() export class TransactionPrototypeHook implements LifecycleHook { - private readonly moduleConfigs: Record; + @Inject() + private readonly moduleConfigs: ModuleConfigs; + + @Inject() private readonly logger: Logger; - constructor(moduleConfigs: Record, logger: Logger) { - this.moduleConfigs = moduleConfigs; - this.logger = logger; - } + @Inject() + private readonly mysqlDataSourceManager: MysqlDataSourceManager; public async preCreate(ctx: EggPrototypeLifecycleContext): Promise { const builder = new TransactionMetaBuilder(ctx.clazz); @@ -28,7 +32,7 @@ export class TransactionPrototypeHook implements LifecycleHook { - const mysqlDataSource = MysqlDataSourceManager.instance.get(moduleName, datasourceName); + const mysqlDataSource = this.mysqlDataSourceManager.get(moduleName, datasourceName); if (!mysqlDataSource) { throw new Error(`method ${clazzName} not found datasource ${datasourceName}`); } diff --git a/tegg/plugin/dal/test/transaction.test.ts b/tegg/plugin/dal/test/transaction.test.ts index 5523006737..6fe00f0e68 100644 --- a/tegg/plugin/dal/test/transaction.test.ts +++ b/tegg/plugin/dal/test/transaction.test.ts @@ -1,3 +1,4 @@ +import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; import { mm, type MockApplication } from '@eggjs/mock'; import { describe, afterEach, beforeAll, afterAll, it, expect } from 'vitest'; @@ -20,7 +21,8 @@ describe('plugin/dal/test/transaction.test.ts', () => { }); afterEach(async () => { - const dataSource = app.mysqlDataSourceManager.get('dal', 'foo')!; + const mysqlDataSourceManager = await app.getEggObjectFromName('mysqlDataSourceManager'); + const dataSource = mysqlDataSourceManager.get('dal', 'foo')!; await dataSource.query('delete from egg_foo;'); }); diff --git a/tegg/plugin/tegg/package.json b/tegg/plugin/tegg/package.json index e5695cb48d..8cfc1fe6eb 100644 --- a/tegg/plugin/tegg/package.json +++ b/tegg/plugin/tegg/package.json @@ -35,7 +35,6 @@ "./lib/AppLoadUnit": "./src/lib/AppLoadUnit.ts", "./lib/AppLoadUnitInstance": "./src/lib/AppLoadUnitInstance.ts", "./lib/CompatibleUtil": "./src/lib/CompatibleUtil.ts", - "./lib/ConfigSourceLoadUnitHook": "./src/lib/ConfigSourceLoadUnitHook.ts", "./lib/ctx_lifecycle_middleware": "./src/lib/ctx_lifecycle_middleware.ts", "./lib/EggAppLoader": "./src/lib/EggAppLoader.ts", "./lib/EggCompatibleObject": "./src/lib/EggCompatibleObject.ts", @@ -64,7 +63,6 @@ "./lib/AppLoadUnit": "./dist/lib/AppLoadUnit.js", "./lib/AppLoadUnitInstance": "./dist/lib/AppLoadUnitInstance.js", "./lib/CompatibleUtil": "./dist/lib/CompatibleUtil.js", - "./lib/ConfigSourceLoadUnitHook": "./dist/lib/ConfigSourceLoadUnitHook.js", "./lib/ctx_lifecycle_middleware": "./dist/lib/ctx_lifecycle_middleware.js", "./lib/EggAppLoader": "./dist/lib/EggAppLoader.js", "./lib/EggCompatibleObject": "./dist/lib/EggCompatibleObject.js", diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 63afdd4563..271bb21de0 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -2,13 +2,11 @@ import './lib/AppLoadUnit.ts'; import './lib/AppLoadUnitInstance.ts'; import './lib/EggCompatibleObject.ts'; -import { LoadUnitMultiInstanceProtoHook } from '@eggjs/metadata'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { TeggScope } from '@eggjs/tegg-types'; 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'; @@ -21,8 +19,6 @@ export default class TeggAppBoot implements ILifecycleBoot { private compatibleHook?: EggContextCompatibleHook; private eggContextHandler: EggContextHandler; private eggQualifierProtoHook: EggQualifierProtoHook; - private loadUnitMultiInstanceProtoHook: LoadUnitMultiInstanceProtoHook; - private configSourceEggPrototypeHook: ConfigSourceLoadUnitHook; constructor(app: Application) { this.app = app; @@ -55,16 +51,10 @@ export default class TeggAppBoot implements ILifecycleBoot { // Load tegg objects within this app's factory scope so every factory/graph/ // lifecycle-util mutation during boot reads/writes the per-app slots. await TeggScope.run(this.app._teggScopeBag, async () => { - this.loadUnitMultiInstanceProtoHook = new LoadUnitMultiInstanceProtoHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitMultiInstanceProtoHook); - // wait all file loaded, so app/ctx has all properties this.eggQualifierProtoHook = new EggQualifierProtoHook(this.app); this.app.loadUnitLifecycleUtil.registerLifecycle(this.eggQualifierProtoHook); - this.configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.configSourceEggPrototypeHook); - // start load tegg objects await this.app.moduleHandler.init(); this.compatibleHook = new EggContextCompatibleHook(this.app.moduleHandler); @@ -74,8 +64,9 @@ export default class TeggAppBoot implements ILifecycleBoot { async loadMetadata(): Promise { if (!this.app.moduleReferences) return; + EggModuleLoader.reconcileModulePluginReferences(this.app); const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences); - EggModuleLoader.collectTeggManifest(this.app, moduleDescriptors); + EggModuleLoader.collectTeggManifest(this.app, this.app.moduleReferences, moduleDescriptors); } async beforeClose(): Promise { @@ -89,14 +80,6 @@ export default class TeggAppBoot implements ILifecycleBoot { if (this.eggQualifierProtoHook) { this.app.loadUnitLifecycleUtil.deleteLifecycle(this.eggQualifierProtoHook); } - if (this.configSourceEggPrototypeHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); - } - if (this.loadUnitMultiInstanceProtoHook) { - this.app.loadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); - } - // per-app multi-instance proto set: cleared within this app's scope - LoadUnitMultiInstanceProtoHook.clear(); }); } finally { // The whole per-app scope (bag) is dropped with the app; release the scope diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 85f67b4958..b62d800c8b 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,6 +1,6 @@ import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; -import { LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import { buildTeggManifestData, LoaderFactory, ModuleLoader, 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'; @@ -11,6 +11,7 @@ export class EggModuleLoader { app: Application; globalGraph: GlobalGraph; private pendingBuildHooks: GlobalGraphBuildHook[] = []; + #moduleDescriptors: readonly ModuleDescriptor[] = []; /** * True when the app graph was built from a tegg manifest (bundle mode). In * that case the module source files do not exist on disk, so module load @@ -27,6 +28,28 @@ export class EggModuleLoader { this.pendingBuildHooks.push(hook); } + static #isModulePluginReference(moduleReference: ModuleReference, plugin: { package?: string; path?: string }) { + if (moduleReference.package && plugin.package) { + return moduleReference.package === plugin.package; + } + return !!plugin.path && moduleReference.path === plugin.path; + } + + static reconcileModulePluginReferences(app: Application): void { + const enabledPlugins = Object.values(app.plugins).filter((plugin) => plugin.enable); + const allPlugins = Object.values(app.loader.allPlugins ?? {}); + + for (const moduleReference of app.moduleReferences) { + if (enabledPlugins.some((plugin) => EggModuleLoader.#isModulePluginReference(moduleReference, plugin))) { + moduleReference.optional = false; + continue; + } + if (allPlugins.some((plugin) => EggModuleLoader.#isModulePluginReference(moduleReference, plugin))) { + moduleReference.optional = true; + } + } + } + private async loadApp(): Promise { const loader = new EggAppLoader(this.app); const loadUnit = await LoadUnitFactory.createLoadUnit(this.app.baseDir, EggLoadUnitType.APP, loader); @@ -34,13 +57,9 @@ export class EggModuleLoader { } 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); - if (modulePlugin) { - modulePlugin.optional = false; - } - } + // Normalize module plugin references against the Egg plugin enable state. + // Ordinary app modules keep their original optional semantics. + EggModuleLoader.reconcileModulePluginReferences(this.app); // Pass manifest data to LoaderFactory if available const manifest = this.app.loader.manifest; @@ -52,10 +71,11 @@ export class EggModuleLoader { // RealLoaderFS in normal mode (zero behavior change), ManifestLoaderFS in bundle mode. const loaderFS = this.app.loader.loaderFS; const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences, loadAppManifest, loaderFS); + this.#moduleDescriptors = moduleDescriptors; // Collect manifest data when not loaded from manifest if (!loadAppManifest) { - EggModuleLoader.collectTeggManifest(this.app, moduleDescriptors); + EggModuleLoader.collectTeggManifest(this.app, this.app.moduleReferences, moduleDescriptors); } for (const moduleDescriptor of moduleDescriptors) { @@ -77,27 +97,18 @@ export class EggModuleLoader { 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), - })), - }; + return buildTeggManifestData(moduleReferences, moduleDescriptors); } /** * Collect tegg manifest data and store in manifest extensions. */ - static collectTeggManifest(app: Application, moduleDescriptors: readonly ModuleDescriptor[]): void { - const data = EggModuleLoader.buildTeggManifestData(app.moduleReferences, moduleDescriptors); + static collectTeggManifest( + app: Application, + moduleReferences: readonly ModuleReference[], + moduleDescriptors: readonly ModuleDescriptor[], + ): void { + const data = EggModuleLoader.buildTeggManifestData(moduleReferences, moduleDescriptors); app.loader.manifest.setExtension(TEGG_MANIFEST_KEY, data); } @@ -128,16 +139,35 @@ export class EggModuleLoader { const loader = precomputedFiles ? new ModuleLoader(modulePath, { precomputedFiles, loaderFS }) : LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, loaderFS); - const loadUnit = await LoadUnitFactory.createLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); + const loadUnit = await LoadUnitFactory.createLoadUnit( + modulePath, + EggLoadUnitType.MODULE, + loader, + precomputedFiles ? moduleConfig.name : undefined, + ); this.app.moduleHandler.loadUnits.push(loadUnit); } } - async load(): Promise { + get moduleDescriptors(): readonly ModuleDescriptor[] { + return this.#moduleDescriptors; + } + + /** + * Phase 1: scan modules and create the business GlobalGraph (nodes only), + * then flush buffered build hooks onto it. Kept separate from load() so the + * InnerObjectLoadUnit can be instantiated in between — its lifecycle protos + * (including graph build hooks they register) must be live before build(). + */ + async initGraph(): Promise { GlobalGraph.instance = this.globalGraph = await this.buildAppGraph(); for (const hook of this.pendingBuildHooks) { this.globalGraph.registerBuildHook(hook); } + } + + /** Phase 2: create the APP load unit, build()/sort() the graph and create module load units. */ + async load(): Promise { await this.loadApp(); await this.loadModule(); } diff --git a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts index c8fc646506..deb7fe072a 100644 --- a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts +++ b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts @@ -53,15 +53,15 @@ export class ModuleConfigLoader { const result: EggProtoImplClass[] = []; const moduleConfigMap: Record = {}; for (const reference of this.app.moduleReferences) { - const moduleName = ModuleConfigUtil.readModuleNameSync(reference.path); - const defaultConfig = ModuleConfigUtil.loadModuleConfigSync(reference.path, undefined, this.app.config.env); + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.app.baseDir, this.app.config.env); // @eggjs/tegg-config moduleConfigs[module].config overwrite - const config = extend(true, {}, defaultConfig, this.app.moduleConfigs[moduleName]?.config); - moduleConfigMap[moduleName] = { - name: moduleName, + const config = extend(true, {}, resolved.config, this.app.moduleConfigs[resolved.name]?.config); + moduleConfigMap[resolved.name] = { + name: resolved.name, reference: { - name: moduleName, - path: reference.path, + name: resolved.name, + package: reference.package, + path: resolved.path, }, config, }; @@ -87,7 +87,7 @@ export class ModuleConfigLoader { QualifierUtil.addProtoQualifier(func, LoadUnitNameQualifierAttribute, 'app'); QualifierUtil.addProtoQualifier(func, InitTypeQualifierAttribute, ObjectInitType.SINGLETON); QualifierUtil.addProtoQualifier(func, EggQualifierAttribute, EggType.APP); - QualifierUtil.addProtoQualifier(func, ConfigSourceQualifierAttribute, moduleName); + QualifierUtil.addProtoQualifier(func, ConfigSourceQualifierAttribute, resolved.name); result.push(func); } const moduleConfigs = this.loadModuleConfigs(moduleConfigMap); diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index ee1b51732a..48098a9c12 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -1,6 +1,8 @@ import { EggLoadUnitType, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import type { GlobalGraphBuildHook } from '@eggjs/metadata'; -import { type LoadUnitInstance, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { ModuleConfigs } from '@eggjs/tegg-common-util'; +import { InnerObjectLoadUnitBuilder, type LoadUnitInstance, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { AccessLevel } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; @@ -10,6 +12,11 @@ import { EggModuleLoader } from './EggModuleLoader.ts'; export class ModuleHandler extends Base { loadUnits: LoadUnit[] = []; + // The inner-object load unit is tracked separately from business load + // units: init iterates business units without filtering, destroy tears it + // down last (its lifecycle protos must outlive every hooked object). + #innerObjectLoadUnit?: LoadUnit; + #innerObjectLoadUnitInstance?: LoadUnitInstance; loadUnitInstances: LoadUnitInstance[] = []; private readonly loadUnitLoader: EggModuleLoader; @@ -25,6 +32,56 @@ export class ModuleHandler extends Base { this.loadUnitLoader.registerBuildHook(hook); } + /** + * Create AND instantiate the InnerObjectLoadUnit before the business graph + * is built, so `@XxxLifecycleProto` hooks provided by module plugins + * (including graph build hooks they register in `@LifecyclePostInject`) are + * live for the business load-unit phases below. + */ + private async instantiateInnerObjectLoadUnit(): Promise { + const builder = new InnerObjectLoadUnitBuilder(); + for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { + // Optional modules that were NOT promoted (framework-dependency modules + // whose plugin is disabled, or unused optional modules) must not have + // their hooks instantiated — graph sort already gates their business + // protos, this gates their inner objects symmetrically. Enabled + // plugins' references were promoted to non-optional in buildAppGraph. + if (moduleDescriptor.optional === true) { + continue; + } + builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { + name: moduleDescriptor.name, + path: moduleDescriptor.unitPath, + }); + } + const innerObjectLoadUnit = await builder.createLoadUnit({ + // Base host objects for framework hooks — the SAME instances mounted on + // `app`, fed through the host-agnostic provided-objects contract. They + // cannot resolve via the egg compatible mechanism (EggAppLoader's + // COMPATIBLE protos): that load unit is only created in load(), AFTER + // this unit — which must instantiate first so its lifecycle hooks see + // every later load unit, egg-app included. PRIVATE: the egg host has + // its own resolution surface for these names (egg compatible objects), + // the provided protos must stay visible to inner objects only. + innerObjects: { + moduleConfigs: [{ obj: new ModuleConfigs(this.app.moduleConfigs), accessLevel: AccessLevel.PRIVATE }], + runtimeConfig: [ + { + obj: { + baseDir: this.app.baseDir, + env: this.app.config.env, + name: this.app.name, + }, + accessLevel: AccessLevel.PRIVATE, + }, + ], + logger: [{ obj: this.app.logger, accessLevel: AccessLevel.PRIVATE }], + }, + }); + this.#innerObjectLoadUnit = innerObjectLoadUnit; + return await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); + } + async init(): Promise { try { this.app.eggPrototypeCreatorFactory.registerPrototypeCreator( @@ -32,19 +89,23 @@ export class ModuleHandler extends Base { EggCompatibleProtoImpl.create, ); + await this.loadUnitLoader.initGraph(); + const innerObjectInstance = await this.instantiateInnerObjectLoadUnit(); + this.#innerObjectLoadUnitInstance = innerObjectInstance; + this.loadUnitInstances.push(innerObjectInstance); await this.loadUnitLoader.load(); - const instances: LoadUnitInstance[] = []; this.app.module = {} as any; + const businessInstances: LoadUnitInstance[] = []; for (const loadUnit of this.loadUnits) { const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); if (instance.loadUnit.type !== EggLoadUnitType.APP) { CompatibleUtil.appCompatible(this.app, instance); } - instances.push(instance); + this.loadUnitInstances.push(instance); + businessInstances.push(instance); } - CompatibleUtil.contextModuleCompatible(this.app.context, instances); - this.loadUnitInstances = instances; + CompatibleUtil.contextModuleCompatible(this.app.context, businessInstances); this.ready(true); } catch (e) { this.ready(e as Error); @@ -53,15 +114,40 @@ export class ModuleHandler extends Base { } async destroy(): Promise { + const errors: unknown[] = []; + const safe = async (destroy: () => Promise) => { + try { + await destroy(); + } catch (e) { + errors.push(e); + } + }; + + // Reverse creation order: business load units go down first; the inner + // instance and load unit go down after business load-unit metadata so its + // lifecycle protos still observe LoadUnitFactory.destroyLoadUnit(). + const innerObjectLoadUnitInstance = this.#innerObjectLoadUnitInstance ?? this.loadUnitInstances[0]; if (this.loadUnitInstances) { - for (const instance of this.loadUnitInstances) { - await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + const businessInstances = this.loadUnitInstances.filter((instance) => instance !== innerObjectLoadUnitInstance); + for (const instance of [...businessInstances].reverse()) { + await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(instance)); } } if (this.loadUnits) { - for (const loadUnit of this.loadUnits) { - await LoadUnitFactory.destroyLoadUnit(loadUnit); + for (const loadUnit of [...this.loadUnits].reverse()) { + await safe(() => LoadUnitFactory.destroyLoadUnit(loadUnit)); } } + if (innerObjectLoadUnitInstance) { + await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(innerObjectLoadUnitInstance)); + this.#innerObjectLoadUnitInstance = undefined; + } + if (this.#innerObjectLoadUnit) { + await safe(() => LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit!)); + this.#innerObjectLoadUnit = undefined; + } + if (errors.length) { + throw new AggregateError(errors, 'destroy tegg module handler failed'); + } } } diff --git a/tegg/plugin/tegg/test/BundledAppBoot.test.ts b/tegg/plugin/tegg/test/BundledAppBoot.test.ts index 08d8001d26..e5fead707c 100644 --- a/tegg/plugin/tegg/test/BundledAppBoot.test.ts +++ b/tegg/plugin/tegg/test/BundledAppBoot.test.ts @@ -65,7 +65,9 @@ describe('plugin/tegg/test/BundledAppBoot.test.ts', () => { app = mm.app({ baseDir, mode: 'single', loaderFS } as Parameters[0]); await app.ready(); bootFallbackGlobTargets = [...fallbackGlobTargets]; - }); + // TWO app boots plus manifest generation: 3x the root config's 20s + // single-boot hookTimeout (which tegg projects do not inherit). + }, 60_000); afterEach(async () => { return mm.restore(); diff --git a/tegg/plugin/tegg/test/ManifestCollection.test.ts b/tegg/plugin/tegg/test/ManifestCollection.test.ts index 765276d3ba..fa38cf2d0f 100644 --- a/tegg/plugin/tegg/test/ManifestCollection.test.ts +++ b/tegg/plugin/tegg/test/ManifestCollection.test.ts @@ -49,6 +49,10 @@ describe('plugin/tegg/test/ManifestCollection.test.ts', () => { .map((r) => r.name) .sort((a: string, b: string) => a.localeCompare(b)); assert.deepStrictEqual(manifestRefNames, appRefNames); + + const appPackages = app.moduleReferences.map((r: any) => r.package).sort(); + const manifestPackages = teggExt.moduleReferences.map((r) => r.package).sort(); + assert.deepStrictEqual(manifestPackages, appPackages); }); it('should have moduleDescriptors with decoratedFiles', () => { diff --git a/tegg/plugin/tegg/test/ModulePlugin.test.ts b/tegg/plugin/tegg/test/ModulePlugin.test.ts new file mode 100644 index 0000000000..e4ac459f10 --- /dev/null +++ b/tegg/plugin/tegg/test/ModulePlugin.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; + +import { mm, type MockApplication } from '@eggjs/mock'; +import { afterAll, afterEach, beforeAll, describe, it } from 'vitest'; + +import { HelloService } from './fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts'; +import { getAppBaseDir } from './utils.ts'; + +describe('plugin/tegg/test/ModulePlugin.test.ts', () => { + let app: MockApplication; + + beforeAll(async () => { + app = mm.app({ + baseDir: getAppBaseDir('module-plugin-app'), + }); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(() => { + return mm.restore(); + }); + + it('should run module plugin lifecycle protos in app mode', async () => { + const helloService = await app.getEggObject(HelloService); + const result = helloService.hello(); + + // EggObjectLifecycleProto hooked the business object creation + assert.equal(result.message, 'from HelloObjectHook'); + + // LoadUnitLifecycleProto (with inner object DI through InnerCounter) + // observed the business load unit creations — registered before any + // business load unit was created. + assert(result.createdLoadUnits.length > 0); + assert( + result.createdLoadUnits.some((t) => t.endsWith(':pluginModule')), + `should contain pluginModule, got ${JSON.stringify(result.createdLoadUnits)}`, + ); + // counter proves the private inner object was injected and shared + assert.equal(result.createdLoadUnits[0].split(':')[0], '1'); + }); + + it('should inject PUBLIC inner object into business singleton', async () => { + const helloService = await app.getEggObject(HelloService); + assert(helloService.innerRegistry); + assert.equal(typeof helloService.innerRegistry.record, 'function'); + }); +}); diff --git a/tegg/plugin/tegg/test/MultiApp.test.ts b/tegg/plugin/tegg/test/MultiApp.test.ts index 76fd32117c..e0018fb58e 100644 --- a/tegg/plugin/tegg/test/MultiApp.test.ts +++ b/tegg/plugin/tegg/test/MultiApp.test.ts @@ -5,7 +5,10 @@ import { describe, it } from 'vitest'; import { BackgroundCounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts'; import { CounterProducer } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts'; -import { CounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; +import { + CounterInnerState, + CounterService, +} from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; import { getAppBaseDir } from './utils.ts'; async function waitFor(predicate: () => boolean, timeout = 2000): Promise { @@ -59,6 +62,26 @@ describe('plugin/tegg/test/MultiApp.test.ts', () => { } }); + it('should isolate public inner object state between two concurrent apps', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + const inner1 = await app1.getEggObject(CounterInnerState); + const inner2 = await app2.getEggObject(CounterInnerState); + assert.notStrictEqual(inner1, inner2, 'each app must have its own CounterInnerState inner object'); + + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + counter1.incrementInnerState(); + counter1.incrementInnerState(); + assert.equal(counter1.getInnerStateCount(), 2); + assert.equal(counter2.getInnerStateCount(), 0, 'app2 inner object must not be affected by app1 mutations'); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + it('should isolate EventBus emit/handler dispatch between two concurrent apps', async () => { const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/config.default.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/config.default.ts new file mode 100644 index 0000000000..f5079f5950 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/config.default.ts @@ -0,0 +1,7 @@ +import type { EggAppConfig } from 'egg'; + +export default function (): Partial { + return { + keys: 'test key', + }; +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/module.json b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/module.json new file mode 100644 index 0000000000..c46d3e5354 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/plugin-module" + } +] diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/plugin.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/plugin.ts new file mode 100644 index 0000000000..09c3e380f2 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/plugin.ts @@ -0,0 +1,15 @@ +export default { + tracer: { + package: '@eggjs/tracer', + enable: true, + }, + teggConfig: { + package: '@eggjs/tegg-config', + enable: true, + }, + tegg: { + package: '@eggjs/tegg-plugin', + enable: true, + }, + watcher: false, +}; diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts new file mode 100644 index 0000000000..f1d5d8182b --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts @@ -0,0 +1,20 @@ +import { AccessLevel, Inject, SingletonProto } from '@eggjs/tegg'; + +import { InnerRegistry } from './InnerRegistry.ts'; + +@SingletonProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class HelloService { + @Inject() + innerRegistry: InnerRegistry; + + message: string; + + hello(): { message: string; createdLoadUnits: string[] } { + return { + message: this.message, + createdLoadUnits: [...this.innerRegistry.createdLoadUnits], + }; + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/InnerRegistry.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/InnerRegistry.ts new file mode 100644 index 0000000000..149aba7222 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/InnerRegistry.ts @@ -0,0 +1,22 @@ +import { AccessLevel, Inject, InnerObjectProto } from '@eggjs/tegg'; + +@InnerObjectProto() +export class InnerCounter { + count = 0; + + next(): number { + return ++this.count; + } +} + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +export class InnerRegistry { + @Inject() + innerCounter: InnerCounter; + + readonly createdLoadUnits: string[] = []; + + record(loadUnitName: string): void { + this.createdLoadUnits.push(`${this.innerCounter.next()}:${loadUnitName}`); + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/PluginHooks.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/PluginHooks.ts new file mode 100644 index 0000000000..ef34a42a8e --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/PluginHooks.ts @@ -0,0 +1,30 @@ +import { EggObjectLifecycleProto, Inject, LoadUnitLifecycleProto } from '@eggjs/tegg'; +import type { + EggObject, + EggObjectLifeCycleContext, + LifecycleHook, + LoadUnit, + LoadUnitLifecycleContext, +} from '@eggjs/tegg-types'; + +import { InnerRegistry } from './InnerRegistry.ts'; + +@LoadUnitLifecycleProto() +export class ModuleLoadUnitHook implements LifecycleHook { + @Inject() + innerRegistry: InnerRegistry; + + async postCreate(_ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + this.innerRegistry.record(String(loadUnit.name)); + } +} + +@EggObjectLifecycleProto() +export class HelloObjectHook implements LifecycleHook { + async postCreate(_ctx: EggObjectLifeCycleContext, eggObject: EggObject): Promise { + if (eggObject.name !== 'helloService') { + return; + } + (eggObject.obj as any).message = 'from HelloObjectHook'; + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/package.json b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/package.json new file mode 100644 index 0000000000..d67109c9c2 --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "plugin-module", + "type": "module", + "eggModule": { + "name": "pluginModule" + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json new file mode 100644 index 0000000000..696ab1d9bf --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-plugin-app", + "type": "module" +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts index 118f05bc5a..f388993628 100644 --- a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts @@ -1,4 +1,17 @@ -import { AccessLevel, SingletonProto } from '@eggjs/tegg'; +import { AccessLevel, Inject, InnerObjectProto, SingletonProto } from '@eggjs/tegg'; + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +export class CounterInnerState { + private count = 0; + + increment(): void { + this.count++; + } + + getCount(): number { + return this.count; + } +} /** * Per-app singleton state. The SAME class is loaded by two apps; each app must @@ -11,6 +24,9 @@ import { AccessLevel, SingletonProto } from '@eggjs/tegg'; */ @SingletonProto({ accessLevel: AccessLevel.PUBLIC }) export class CounterService { + @Inject() + innerState: CounterInnerState; + private count = 0; private eventCount = 0; private readonly store = new Map(); @@ -23,6 +39,14 @@ export class CounterService { return this.count; } + incrementInnerState(): void { + this.innerState.increment(); + } + + getInnerStateCount(): number { + return this.innerState.getCount(); + } + onEvent(delta: number): void { this.eventCount += delta; } diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index f66aaed7cb..263075b50c 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -1,16 +1,253 @@ import assert from 'node:assert/strict'; +import { mock } from 'node:test'; // import { scheduler } from 'node:timers/promises'; +import { EggLoadUnitType, GlobalGraph, LoadUnitFactory } from '@eggjs/metadata'; import { mm } from '@eggjs/mock'; +import { LoaderFactory } from '@eggjs/tegg-loader'; import { describe, it, afterEach } from 'vitest'; +import TeggAppBoot from '../../src/app.ts'; +import { EggModuleLoader } from '../../src/lib/EggModuleLoader.ts'; import { getAppBaseDir } from '../utils.ts'; describe('test/lib/EggModuleLoader.test.ts', () => { afterEach(() => { + mock.reset(); return mm.restore(); }); + it('should promote enabled plugin module references by package with path fallback', async () => { + const moduleReferences = [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config-package-root', + optional: true, + }, + { + name: 'legacyPlugin', + path: '/legacy/plugin/path', + optional: true, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: false, + }, + { + name: 'samePathDifferentPackage', + package: 'module-package', + path: '/virtual/same-path', + optional: true, + }, + { + name: 'regularAppModule', + package: 'regular-app-module', + path: '/virtual/regular-app-module', + optional: false, + }, + { + name: 'disabledPathPlugin', + path: '/disabled/path-plugin', + optional: false, + }, + ]; + const app = { + baseDir: '/virtual/app', + loader: { + allPlugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/some/plugin/path/inside-package', + }, + legacyPlugin: { + enable: true, + path: '/legacy/plugin/path', + }, + disabledPlugin: { + enable: false, + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + }, + samePathDifferentPackage: { + enable: true, + package: 'plugin-package', + path: '/virtual/same-path', + }, + disabledPathPlugin: { + enable: false, + path: '/disabled/path-plugin', + }, + }, + loaderFS: undefined, + manifest: { + getExtension: () => undefined, + setExtension() {}, + }, + }, + logger: { + warn() {}, + }, + moduleReferences, + plugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/some/plugin/path/inside-package', + }, + legacyPlugin: { + enable: true, + path: '/legacy/plugin/path', + }, + samePathDifferentPackage: { + enable: true, + package: 'plugin-package', + path: '/virtual/same-path', + }, + }, + } as any; + + mock.method(LoaderFactory, 'loadApp', async () => []); + mock.method(GlobalGraph, 'create', async () => { + return { + registerBuildHook() {}, + } as any; + }); + + await new EggModuleLoader(app).initGraph(); + + assert.equal(moduleReferences[0].optional, false); + assert.equal(moduleReferences[1].optional, false); + assert.equal(moduleReferences[2].optional, true); + assert.equal(moduleReferences[3].optional, true); + assert.equal(moduleReferences[4].optional, false); + assert.equal(moduleReferences[5].optional, true); + }); + + it('should reconcile module plugin references before collecting metadata manifest', async () => { + const moduleReferences = [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + optional: true, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: false, + }, + ]; + const extensions = new Map(); + const app = { + moduleReferences, + loader: { + allPlugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + }, + disabledPlugin: { + enable: false, + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + }, + }, + manifest: { + setExtension(key: string, value: unknown) { + extensions.set(key, value); + }, + }, + }, + plugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + }, + }, + } as any; + + mock.method(LoaderFactory, 'loadApp', async () => []); + + await new TeggAppBoot(app).loadMetadata(); + + assert.equal(moduleReferences[0].optional, false); + assert.equal(moduleReferences[1].optional, true); + assert.deepEqual(extensions.get('tegg'), { + moduleReferences: [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + optional: false, + loaderType: undefined, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: true, + loaderType: undefined, + }, + ], + moduleDescriptors: [], + }); + }); + + it('should pass manifest module name when creating bundled module load units', async () => { + const app = { + loader: { + loaderFS: undefined, + manifest: { + getExtension: () => ({ + moduleDescriptors: [ + { + name: 'bundledModule', + unitPath: '/virtual/bundled-module', + decoratedFiles: ['src/index.ts'], + }, + ], + }), + }, + }, + moduleHandler: { + loadUnits: [], + }, + } as any; + const moduleLoader = new EggModuleLoader(app); + moduleLoader.globalGraph = { + build() {}, + sort() {}, + moduleConfigList: [ + { + name: 'bundledModule', + path: '/virtual/bundled-module', + }, + ], + } as any; + (moduleLoader as any).loadedFromManifest = true; + + const calls: unknown[][] = []; + mock.method(LoadUnitFactory, 'createLoadUnit', async (...args: unknown[]) => { + calls.push(args); + return { name: 'bundledModule', unitPath: '/virtual/bundled-module' }; + }); + + await (moduleLoader as any).loadModule(); + + assert.equal(calls.length, 1); + assert.equal(calls[0][0], '/virtual/bundled-module'); + assert.equal(calls[0][1], EggLoadUnitType.MODULE); + assert.equal(calls[0][3], 'bundledModule'); + assert.equal(app.moduleHandler.loadUnits.length, 1); + }); + describe('has recursive dependency module', () => { it('should throw error', async () => { const app = mm.app({ diff --git a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts new file mode 100644 index 0000000000..e05c71ca12 --- /dev/null +++ b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; + +import { EggLoadUnitType, LoadUnitFactory, type LoadUnit } from '@eggjs/metadata'; +import { LoadUnitInstanceFactory, type LoadUnitInstance } from '@eggjs/tegg-runtime'; +import { afterEach, describe, it } from 'vitest'; + +import { ModuleHandler } from '../../src/lib/ModuleHandler.ts'; + +function createLoadUnit(name: string): LoadUnit { + return { + name, + type: EggLoadUnitType.APP, + } as unknown as LoadUnit; +} + +function createInstance(name: string): LoadUnitInstance { + return { + loadUnit: createLoadUnit(name), + } as unknown as LoadUnitInstance; +} + +function createHandler(): ModuleHandler { + const app = { + baseDir: '/tmp/app', + config: { env: 'test' }, + context: {}, + eggPrototypeCreatorFactory: { + registerPrototypeCreator() {}, + }, + logger: console, + moduleConfigs: {}, + name: 'test-app', + } as any; + const handler = new ModuleHandler(app); + app.moduleHandler = handler; + (handler as any).loadUnitLoader = { + async initGraph() {}, + async load() {}, + moduleDescriptors: [], + }; + return handler; +} + +describe('plugin/tegg/test/lib/ModuleHandler.test.ts', () => { + afterEach(() => { + mock.reset(); + }); + + it('should track initialized load unit instances before init finishes', async () => { + const handler = createHandler(); + const innerInstance = createInstance('inner'); + const firstLoadUnit = createLoadUnit('first'); + const secondLoadUnit = createLoadUnit('second'); + handler.loadUnits.push(firstLoadUnit, secondLoadUnit); + (handler as any).instantiateInnerObjectLoadUnit = async () => innerInstance; + + mock.method(LoadUnitInstanceFactory, 'createLoadUnitInstance', async (loadUnit: LoadUnit) => { + if (loadUnit === secondLoadUnit) { + throw new Error('create failed'); + } + return { loadUnit } as LoadUnitInstance; + }); + + await assert.rejects(() => handler.init(), /create failed/); + assert.deepEqual( + handler.loadUnitInstances.map((instance) => String(instance.loadUnit.name)), + ['inner', 'first'], + ); + }); + + it('should continue destroying remaining load units and aggregate errors', async () => { + const handler = createHandler(); + const firstLoadUnit = createLoadUnit('first'); + const secondLoadUnit = createLoadUnit('second'); + handler.loadUnitInstances.push(createInstance('inner'), createInstance('business')); + handler.loadUnits.push(firstLoadUnit, secondLoadUnit); + + const destroyed: string[] = []; + const destroyedInstances: string[] = []; + const destroyedLoadUnits: string[] = []; + mock.method(LoadUnitInstanceFactory, 'destroyLoadUnitInstance', async (instance: LoadUnitInstance) => { + destroyed.push(`instance:${String(instance.loadUnit.name)}`); + destroyedInstances.push(String(instance.loadUnit.name)); + if (instance.loadUnit.name === 'business') { + throw new Error('destroy instance failed'); + } + }); + mock.method(LoadUnitFactory, 'destroyLoadUnit', async (loadUnit: LoadUnit) => { + destroyed.push(`loadUnit:${String(loadUnit.name)}`); + destroyedLoadUnits.push(String(loadUnit.name)); + if (loadUnit === firstLoadUnit) { + throw new Error('destroy load unit failed'); + } + }); + + await assert.rejects( + () => handler.destroy(), + (e: unknown) => { + assert(e instanceof AggregateError); + assert.equal(e.message, 'destroy tegg module handler failed'); + assert.equal(e.errors.length, 2); + return true; + }, + ); + assert.deepEqual(destroyedInstances, ['business', 'inner']); + assert.deepEqual(destroyedLoadUnits, ['second', 'first']); + assert.deepEqual(destroyed, ['instance:business', 'loadUnit:second', 'loadUnit:first', 'instance:inner']); + }); +}); diff --git a/tegg/standalone/standalone/README.md b/tegg/standalone/standalone/README.md index d5546b0cfc..772df8ee89 100644 --- a/tegg/standalone/standalone/README.md +++ b/tegg/standalone/standalone/README.md @@ -43,11 +43,11 @@ export class Foo implements MainRunner { - cwd 为当前应用工作目录 - options: - - innerObjects: 当前运行环境中内置的对象 + - innerObjectHandlers: 当前运行环境中内置的对象 ``` await main(cwd, { - innerObjects: { + innerObjectHandlers: { hello: { hello: () => { return 'hello, inner'; diff --git a/tegg/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index 1f5161099f..3b831d4fc7 100644 --- a/tegg/standalone/standalone/package.json +++ b/tegg/standalone/standalone/package.json @@ -43,12 +43,14 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@eggjs/aop-runtime": "workspace:*", + "@eggjs/aop-plugin": "workspace:*", "@eggjs/dal-plugin": "workspace:*", "@eggjs/lifecycle": "workspace:*", + "@eggjs/loader-fs": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", + "@eggjs/tegg-config": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-runtime": "workspace:*", "@eggjs/tegg-types": "workspace:*" diff --git a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts b/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts deleted file mode 100644 index afcdef0234..0000000000 --- a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; -import { - type LifecycleHook, - PrototypeUtil, - QualifierUtil, - ConfigSourceQualifier, - ConfigSourceQualifierAttribute, -} from '@eggjs/tegg'; - -/** - * Hook for inject moduleConfig. - * Add default qualifier value is current module name. - */ -export class ConfigSourceLoadUnitHook implements LifecycleHook { - async preCreate(ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { - const classList = await ctx.loader.load(); - for (const clazz of classList) { - const injectObjects = PrototypeUtil.getInjectObjects(clazz); - const moduleConfigObject = injectObjects.find((t) => t.objName === 'moduleConfig'); - const configSourceQualifier = QualifierUtil.getProperQualifier( - clazz, - 'moduleConfig', - ConfigSourceQualifierAttribute, - ); - if (moduleConfigObject && !configSourceQualifier) { - ConfigSourceQualifier(loadUnit.name)(clazz.prototype, moduleConfigObject.refName); - } - } - } -} diff --git a/tegg/standalone/standalone/src/EggModuleLoader.ts b/tegg/standalone/standalone/src/EggModuleLoader.ts index 87dbdf3dfb..bae43c220d 100644 --- a/tegg/standalone/standalone/src/EggModuleLoader.ts +++ b/tegg/standalone/standalone/src/EggModuleLoader.ts @@ -1,34 +1,60 @@ -import { EggLoadUnitType, GlobalGraph, type LoadUnit, LoadUnitFactory, ModuleDescriptorDumper } from '@eggjs/metadata'; +import type { LoaderFS } from '@eggjs/loader-fs'; +import { + EggLoadUnitType, + GlobalGraph, + type LoadUnit, + LoadUnitFactory, + type ModuleDescriptor, + ModuleDescriptorDumper, +} from '@eggjs/metadata'; import type { Logger } from '@eggjs/tegg'; import type { ModuleReference } from '@eggjs/tegg-common-util'; -import { LoaderFactory } from '@eggjs/tegg-loader'; +import { buildTeggManifestData, LoaderFactory, ModuleLoader, type TeggManifestExtension } from '@eggjs/tegg-loader'; import { TeggScope } from '@eggjs/tegg-types'; export interface EggModuleLoaderOptions { logger: Logger; baseDir: string; dump?: boolean; + /** + * Tegg manifest data (bundle mode). When provided the module scan reuses the + * precomputed decorated files instead of globbing the file system. + */ + manifest?: TeggManifestExtension; + /** Virtual fs used together with manifest in bundle mode. */ + loaderFS?: LoaderFS; } export class EggModuleLoader { private moduleReferences: readonly ModuleReference[]; private globalGraph: GlobalGraph; private options: EggModuleLoaderOptions; + #moduleDescriptors: readonly ModuleDescriptor[] = []; constructor(moduleReferences: readonly ModuleReference[], options: EggModuleLoaderOptions) { this.moduleReferences = moduleReferences; this.options = options; } + get moduleDescriptors(): readonly ModuleDescriptor[] { + return this.#moduleDescriptors; + } + async init(): Promise { - GlobalGraph.instance = this.globalGraph = await EggModuleLoader.generateAppGraph( + const { globalGraph, moduleDescriptors } = await EggModuleLoader.generateAppGraph( this.moduleReferences, this.options, ); + GlobalGraph.instance = this.globalGraph = globalGraph; + this.#moduleDescriptors = moduleDescriptors; } - private static async generateAppGraph(moduleReferences: readonly ModuleReference[], options: EggModuleLoaderOptions) { - const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); + private static async generateAppGraph( + moduleReferences: readonly ModuleReference[], + options: EggModuleLoaderOptions, + ): Promise<{ globalGraph: GlobalGraph; moduleDescriptors: readonly ModuleDescriptor[] }> { + const manifest = options.manifest?.moduleDescriptors?.length ? options.manifest : undefined; + const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences, manifest, options.loaderFS); if (options.dump !== false) { for (const moduleDescriptor of moduleDescriptors) { ModuleDescriptorDumper.dump(moduleDescriptor, { @@ -40,7 +66,32 @@ export class EggModuleLoader { } } const globalGraph = await GlobalGraph.create(moduleDescriptors); - return globalGraph; + return { globalGraph, moduleDescriptors }; + } + + /** + * Build tegg manifest data from module references and descriptors, the + * standalone counterpart of the egg plugin's manifest collection. A bundler + * persists this so bundle-mode boot can skip globbing. + */ + static buildTeggManifestData( + moduleReferences: readonly ModuleReference[], + moduleDescriptors: readonly ModuleDescriptor[], + ): TeggManifestExtension { + return buildTeggManifestData(moduleReferences, moduleDescriptors); + } + + #createModuleLoader(modulePath: string) { + // Bundle mode: module source files are not on disk, reuse the manifest's + // precomputed decorated files so the loader skips globbing. + const manifestDesc = this.options.manifest?.moduleDescriptors?.find((desc) => desc.unitPath === modulePath); + if (manifestDesc) { + return new ModuleLoader(modulePath, { + precomputedFiles: manifestDesc.decoratedFiles, + loaderFS: this.options.loaderFS, + }); + } + return LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, this.options.loaderFS); } async load(): Promise { @@ -50,7 +101,7 @@ export class EggModuleLoader { const moduleConfigList = GlobalGraph.instance!.moduleConfigList; for (const moduleConfig of moduleConfigList) { const modulePath = moduleConfig.path; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); + const loader = this.#createModuleLoader(modulePath); const loadUnit = await LoadUnitFactory.createLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); loadUnits.push(loadUnit); } @@ -59,15 +110,16 @@ export class EggModuleLoader { static async preLoad(moduleReferences: readonly ModuleReference[], options: EggModuleLoaderOptions): Promise { // Isolate preload in its own temporary scope so its graph/proto registrations - // do not leak into the process-default bag or any concurrent Runner. + // do not leak into the process-default bag or any concurrent app. await TeggScope.run(TeggScope.createBag(), async () => { const loadUnits: LoadUnit[] = []; - const globalGraph = (GlobalGraph.instance = await EggModuleLoader.generateAppGraph(moduleReferences, options)); + const { globalGraph } = await EggModuleLoader.generateAppGraph(moduleReferences, options); + GlobalGraph.instance = globalGraph; globalGraph.sort(); const moduleConfigList = globalGraph.moduleConfigList; for (const moduleConfig of moduleConfigList) { const modulePath = moduleConfig.path; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); + const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, options.loaderFS); const loadUnit = await LoadUnitFactory.createPreloadLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); loadUnits.push(loadUnit); } diff --git a/tegg/standalone/standalone/src/Runner.ts b/tegg/standalone/standalone/src/Runner.ts deleted file mode 100644 index f1ca5e734b..0000000000 --- a/tegg/standalone/standalone/src/Runner.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { - crossCutGraphHook, - EggObjectAopHook, - EggPrototypeCrossCutHook, - LoadUnitAopHook, - pointCutGraphHook, -} from '@eggjs/aop-runtime'; -import { - DalTableEggPrototypeHook, - DalModuleLoadUnitHook, - MysqlDataSourceManager, - SqlMapManager, - TableModelManager, - TransactionPrototypeHook, -} from '@eggjs/dal-plugin'; -import { - type EggPrototype, - EggPrototypeFactory, - EggPrototypeLifecycleUtil, - GlobalGraph, - type LoadUnit, - LoadUnitFactory, - LoadUnitLifecycleUtil, - LoadUnitMultiInstanceProtoHook, -} from '@eggjs/metadata'; -import { - type EggProtoImplClass, - type ModuleConfigHolder, - ModuleConfigs, - ConfigSourceQualifierAttribute, - type Logger, -} from '@eggjs/tegg'; -import { - ModuleConfigUtil, - type ModuleReference, - type ReadModuleReferenceOptions, - type RuntimeConfig, -} from '@eggjs/tegg-common-util'; -import { - ContextHandler, - EggContainerFactory, - type EggContext, - EggObjectLifecycleUtil, - type LoadUnitInstance, - LoadUnitInstanceFactory, - ModuleLoadUnitInstance, -} from '@eggjs/tegg-runtime'; -import { TeggScope } from '@eggjs/tegg-types'; -import type { TeggScopeBag } from '@eggjs/tegg-types'; -import { CrosscutAdviceFactory } from '@eggjs/tegg/aop'; -import { StandaloneUtil, type MainRunner } from '@eggjs/tegg/standalone'; - -import { ConfigSourceLoadUnitHook } from './ConfigSourceLoadUnitHook.ts'; -import { EggModuleLoader } from './EggModuleLoader.ts'; -import { StandaloneContext } from './StandaloneContext.ts'; -import { StandaloneContextHandler } from './StandaloneContextHandler.ts'; -import { type InnerObject, StandaloneLoadUnit, StandaloneLoadUnitType } from './StandaloneLoadUnit.ts'; - -export interface ModuleDependency extends ReadModuleReferenceOptions { - baseDir: string; -} - -export interface RunnerOptions { - /** - * @deprecated - * use inner object handlers instead - */ - innerObjects?: Record; - env?: string; - name?: string; - innerObjectHandlers?: Record; - dependencies?: (string | ModuleDependency)[]; - dump?: boolean; -} - -export class Runner { - readonly cwd: string; - readonly moduleReferences: readonly ModuleReference[]; - readonly moduleConfigs: Record; - readonly env?: string; - readonly name?: string; - readonly options?: RunnerOptions; - private loadUnitLoader: EggModuleLoader; - private runnerProto: EggPrototype; - private configSourceEggPrototypeHook: ConfigSourceLoadUnitHook; - private loadUnitMultiInstanceProtoHook: LoadUnitMultiInstanceProtoHook; - private dalTableEggPrototypeHook: DalTableEggPrototypeHook; - private dalModuleLoadUnitHook: DalModuleLoadUnitHook; - private transactionPrototypeHook: TransactionPrototypeHook; - - private crosscutAdviceFactory: CrosscutAdviceFactory; - private loadUnitAopHook: LoadUnitAopHook; - private eggPrototypeCrossCutHook: EggPrototypeCrossCutHook; - private eggObjectAopHook: EggObjectAopHook; - - loadUnits: LoadUnit[] = []; - loadUnitInstances: LoadUnitInstance[] = []; - innerObjects: Record; - - // This Runner's own per-app TeggScope bag — all factories/managers/graph/config - // names resolve here, so multiple Runners in one process stay isolated. - readonly scopeBag: TeggScopeBag; - - constructor(cwd: string, options?: RunnerOptions) { - this.cwd = cwd; - this.env = options?.env; - this.name = options?.name; - this.options = options; - this.scopeBag = TeggScope.createBag(); - TeggScope.registerScope(this.scopeBag); - try { - this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); - this.moduleConfigs = {}; - this.runInScope(() => this.initInnerObjectsAndConfigs(options)); - } catch (e) { - // Construction failed after the scope was registered; release it so the - // never-returned Runner does not leak into liveScopeBags. - TeggScope.unregisterScope(this.scopeBag); - throw e; - } - } - - /** Run `fn` within THIS Runner's per-app scope so factories/managers resolve here. */ - private runInScope(fn: () => R): R { - return TeggScope.run(this.scopeBag, fn); - } - - private initInnerObjectsAndConfigs(options?: RunnerOptions): void { - this.innerObjects = { - moduleConfigs: [ - { - obj: new ModuleConfigs(this.moduleConfigs), - }, - ], - moduleConfig: [], - mysqlDataSourceManager: [ - { - obj: MysqlDataSourceManager.instance, - }, - ], - }; - - const runtimeConfig: Partial = { - baseDir: this.cwd, - name: this.name, - env: this.env, - }; - // Inject runtimeConfig - this.innerObjects.runtimeConfig = [ - { - obj: runtimeConfig, - }, - ]; - - // load module.yml and module.env.yml by default - // Always set configNames for this runner invocation, since destroy() clears it - // asynchronously and may not have completed before the next Runner is created. - ModuleConfigUtil.configNames = ['module.default', `module.${this.env}`]; - for (const reference of this.moduleReferences) { - const absoluteRef = { - path: ModuleConfigUtil.resolveModuleDir(reference.path, this.cwd), - name: reference.name, - }; - - const moduleName = ModuleConfigUtil.readModuleNameSync(absoluteRef.path); - this.moduleConfigs[moduleName] = { - name: moduleName, - reference: absoluteRef, - config: ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path), - }; - } - for (const moduleConfig of Object.values(this.moduleConfigs)) { - this.innerObjects.moduleConfig.push({ - obj: moduleConfig.config, - qualifiers: [ - { - attribute: ConfigSourceQualifierAttribute, - value: moduleConfig.name, - }, - ], - }); - } - if (options?.innerObjects) { - for (const [name, obj] of Object.entries(options.innerObjects)) { - this.innerObjects[name] = [ - { - obj, - }, - ]; - } - } else if (options?.innerObjectHandlers) { - Object.assign(this.innerObjects, options.innerObjectHandlers); - } - } - - async load(): Promise { - return this.runInScope(async () => { - StandaloneContextHandler.register(); - LoadUnitFactory.registerLoadUnitCreator(StandaloneLoadUnitType, () => { - return new StandaloneLoadUnit(this.innerObjects); - }); - LoadUnitInstanceFactory.registerLoadUnitInstanceClass( - StandaloneLoadUnitType, - ModuleLoadUnitInstance.createModuleLoadUnitInstance, - ); - const standaloneLoadUnit = await LoadUnitFactory.createLoadUnit( - 'MockStandaloneLoadUnitPath', - StandaloneLoadUnitType, - { - async load(): Promise { - return []; - }, - }, - ); - const loadUnits = await this.loadUnitLoader.load(); - return [standaloneLoadUnit, ...loadUnits]; - }); - } - - static getModuleReferences(cwd: string, dependencies?: RunnerOptions['dependencies']): readonly ModuleReference[] { - const moduleDirs = (dependencies || []).concat(cwd); - return moduleDirs.reduce( - (list, baseDir) => { - const module = typeof baseDir === 'string' ? { baseDir } : baseDir; - return list.concat(...ModuleConfigUtil.readModuleReference(module.baseDir, module)); - }, - [] as readonly ModuleReference[], - ); - } - - static async preLoad(cwd: string, dependencies?: RunnerOptions['dependencies']): Promise { - const moduleReferences = Runner.getModuleReferences(cwd, dependencies); - await EggModuleLoader.preLoad(moduleReferences, { - baseDir: cwd, - logger: console, - dump: false, - }); - } - - private async initLoaderInstance() { - this.loadUnitLoader = new EggModuleLoader(this.moduleReferences, { - logger: ((this.innerObjects.logger && this.innerObjects.logger[0])?.obj as Logger) || console, - baseDir: this.cwd, - dump: this.options?.dump, - }); - await this.loadUnitLoader.init(); - GlobalGraph.instance!.registerBuildHook(crossCutGraphHook); - GlobalGraph.instance!.registerBuildHook(pointCutGraphHook); - const configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); - LoadUnitLifecycleUtil.registerLifecycle(configSourceEggPrototypeHook); - - // TODO refactor with egg module - // aop runtime - this.crosscutAdviceFactory = new CrosscutAdviceFactory(); - this.loadUnitAopHook = new LoadUnitAopHook(this.crosscutAdviceFactory); - this.eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(this.crosscutAdviceFactory); - this.eggObjectAopHook = new EggObjectAopHook(); - - EggPrototypeLifecycleUtil.registerLifecycle(this.eggPrototypeCrossCutHook); - LoadUnitLifecycleUtil.registerLifecycle(this.loadUnitAopHook); - EggObjectLifecycleUtil.registerLifecycle(this.eggObjectAopHook); - - this.loadUnitMultiInstanceProtoHook = new LoadUnitMultiInstanceProtoHook(); - LoadUnitLifecycleUtil.registerLifecycle(this.loadUnitMultiInstanceProtoHook); - - const loggerInnerObject = this.innerObjects.logger && this.innerObjects.logger[0]; - const logger = (loggerInnerObject?.obj || console) as Logger; - - this.dalModuleLoadUnitHook = new DalModuleLoadUnitHook(this.env ?? '', this.moduleConfigs, logger); - this.dalTableEggPrototypeHook = new DalTableEggPrototypeHook(logger); - this.transactionPrototypeHook = new TransactionPrototypeHook(this.moduleConfigs, logger); - EggPrototypeLifecycleUtil.registerLifecycle(this.dalTableEggPrototypeHook); - EggPrototypeLifecycleUtil.registerLifecycle(this.transactionPrototypeHook); - LoadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); - } - - async init(): Promise { - await this.runInScope(async () => { - await this.initLoaderInstance(); - - this.loadUnits = await this.load(); - const instances: LoadUnitInstance[] = []; - for (const loadUnit of this.loadUnits) { - const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); - instances.push(instance); - } - this.loadUnitInstances = instances; - const runnerClass = StandaloneUtil.getMainRunner(); - if (!runnerClass) { - throw new Error('not found runner class. Do you add @Runner decorator?'); - } - // Prefer the per-app scoped lookup so parallel Runners don't fall back to - // process-global class metadata when a per-scope prototype is registered. - const proto = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(runnerClass); - if (!proto) { - throw new Error(`can not get proto for clazz ${runnerClass.name}`); - } - this.runnerProto = proto as EggPrototype; - }); - } - - async run(aCtx?: EggContext): Promise { - return this.runInScope(async () => { - const lifecycle = {}; - const ctx = aCtx || new StandaloneContext(); - return await ContextHandler.run(ctx, async () => { - if (ctx.init) { - await ctx.init(lifecycle); - } - const eggObject = await EggContainerFactory.getOrCreateEggObject(this.runnerProto); - const runner = eggObject.obj as MainRunner; - try { - return await runner.main(); - } finally { - if (ctx.destroy) { - ctx.destroy(lifecycle).catch((e) => { - e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; - console.warn(e); - }); - } - } - }); - }); - } - - async destroy(): Promise { - try { - await this.runInScope(() => this.doDestroy()); - } finally { - // Always release the scope, even if doDestroy rejects, so liveScopeBags - // (and thus isMultiApp / the sole-app fallback) never leaks a dead Runner. - TeggScope.unregisterScope(this.scopeBag); - } - } - - private async doDestroy(): Promise { - if (this.loadUnitInstances) { - for (const instance of this.loadUnitInstances) { - await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); - } - } - if (this.loadUnits) { - for (const loadUnit of this.loadUnits) { - await LoadUnitFactory.destroyLoadUnit(loadUnit); - } - } - if (this.configSourceEggPrototypeHook) { - LoadUnitLifecycleUtil.deleteLifecycle(this.configSourceEggPrototypeHook); - } - - if (this.eggPrototypeCrossCutHook) { - EggPrototypeLifecycleUtil.deleteLifecycle(this.eggPrototypeCrossCutHook); - } - if (this.loadUnitAopHook) { - LoadUnitLifecycleUtil.deleteLifecycle(this.loadUnitAopHook); - } - if (this.eggObjectAopHook) { - EggObjectLifecycleUtil.deleteLifecycle(this.eggObjectAopHook); - } - - if (this.loadUnitMultiInstanceProtoHook) { - LoadUnitLifecycleUtil.deleteLifecycle(this.loadUnitMultiInstanceProtoHook); - } - - if (this.dalTableEggPrototypeHook) { - EggPrototypeLifecycleUtil.deleteLifecycle(this.dalTableEggPrototypeHook); - } - if (this.dalModuleLoadUnitHook) { - LoadUnitLifecycleUtil.deleteLifecycle(this.dalModuleLoadUnitHook); - } - if (this.transactionPrototypeHook) { - EggPrototypeLifecycleUtil.deleteLifecycle(this.transactionPrototypeHook); - } - MysqlDataSourceManager.instance.clear(); - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); - // clear configNames - ModuleConfigUtil.setConfigNames(undefined); - } -} diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts new file mode 100644 index 0000000000..0a3f749260 --- /dev/null +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -0,0 +1,419 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { LoaderFS } from '@eggjs/loader-fs'; +import { type EggPrototype, EggPrototypeFactory, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; +import { type ModuleConfigHolder, ModuleConfigs, ConfigSourceQualifierAttribute, type Logger } from '@eggjs/tegg'; +import { + ModuleConfigUtil, + type ModuleReference, + type ReadModuleReferenceOptions, + type RuntimeConfig, +} from '@eggjs/tegg-common-util'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { + ContextHandler, + EggContainerFactory, + type EggContext, + type InnerObject, + InnerObjectLoadUnitBuilder, + type LoadUnitInstance, + LoadUnitInstanceFactory, +} from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; +import type { TeggScopeBag } from '@eggjs/tegg-types'; +import { StandaloneUtil, type MainRunner } from '@eggjs/tegg/standalone'; + +import { EggModuleLoader } from './EggModuleLoader.ts'; +import { StandaloneContext } from './StandaloneContext.ts'; +import { StandaloneContextHandler } from './StandaloneContextHandler.ts'; + +export interface ModuleDependency extends ReadModuleReferenceOptions { + baseDir: string; +} + +/** + * Construction-time wiring: capabilities and provided objects. No app binding + * happens here — WHICH app to load (baseDir/name/env) arrives at init(). + */ +export interface StandaloneAppInit { + /** + * Framework-level module plugins loaded BEFORE the app's own modules + * (e.g. a service-worker runtime package providing controller support). + * They join the same scan; their `@InnerObjectProto` / `@XxxLifecycleProto` + * classes are instantiated in the InnerObjectLoadUnit ahead of every + * business load unit. + */ + frameworkDeps?: (string | ModuleDependency)[]; + dump?: boolean; + /** + * Host-provided inner objects. Entries here win over the framework default + * placeholders, except `moduleConfig` entries are extended with module config + * objects loaded during init(). + */ + innerObjects?: Record; + /** User-facing option name for diagnostics. Defaults to `innerObjects`. */ + innerObjectsName?: string; + /** + * Logger used by the framework (loader, hooks) and injectable as the + * `logger` inner object. Defaults to console. + */ + logger?: Logger; +} + +/** init()-time binding: which app to load and its runtime identity. */ +export interface InitStandaloneAppOptions { + name?: string; + env?: string; + baseDir: string; + /** Extra module dirs (e.g. npm packages) joining the scan after framework deps. */ + dependencies?: (string | ModuleDependency)[]; + /** + * Tegg manifest data (bundle mode). When provided the module scan reuses the + * precomputed decorated files instead of globbing the file system. + */ + manifest?: TeggManifestExtension; + /** Virtual fs used together with manifest in bundle mode. */ + loaderFS?: LoaderFS; +} + +/** Flat convenience options for the `main(cwd, options)` entry (cwd = baseDir). */ +export interface StandaloneAppOptions { + env?: string; + name?: string; + logger?: Logger; + innerObjectHandlers?: Record; + dependencies?: (string | ModuleDependency)[]; + frameworkDeps?: (string | ModuleDependency)[]; + dump?: boolean; + manifest?: TeggManifestExtension; + loaderFS?: LoaderFS; +} + +export class StandaloneApp { + readonly #frameworkDeps: (string | ModuleDependency)[]; + readonly #dump: boolean; + readonly #logger: Logger; + readonly #innerObjects: Record; + readonly #moduleConfigs: Record = {}; + // In the constructor there is no runtime config yet — pre-create the object + // so the runtimeConfig inner object can hold it; init() fills the values. + readonly #runtimeConfig = {} as RuntimeConfig; + #moduleReferences: readonly ModuleReference[] = []; + #initialized = false; + #loadUnitLoader: EggModuleLoader; + #runnerProto: EggPrototype; + + loadUnits: LoadUnit[] = []; + loadUnitInstances: LoadUnitInstance[] = []; + + // This app's own per-app TeggScope bag — all factories/managers/graph/config + // names resolve here, so multiple StandaloneApps in one process stay isolated. + readonly scopeBag: TeggScopeBag; + + constructor(init?: StandaloneAppInit) { + this.#frameworkDeps = init?.frameworkDeps ?? []; + this.#dump = init?.dump !== false; + this.#innerObjects = this.#createInnerObjects(init); + this.#logger = this.#innerObjects.logger[0].obj as Logger; + this.scopeBag = TeggScope.createBag(); + TeggScope.registerScope(this.scopeBag); + } + + /** Scanned during init(); empty before that. */ + get moduleReferences(): readonly ModuleReference[] { + return this.#moduleReferences; + } + + /** Loaded during init(); empty before that. */ + get moduleConfigs(): Record { + return this.#moduleConfigs; + } + + /** Run `fn` within THIS app's per-app scope so factories/managers resolve here. */ + private runInScope(fn: () => R): R { + return TeggScope.run(this.scopeBag, fn); + } + + #createInnerObjects(init?: StandaloneAppInit): Record { + const frameworkInnerObjects = { + // Framework hooks (e.g. DAL) inject `logger`; an init.innerObjects + // entry wins over init.logger, console is the last resort. + logger: [{ obj: init?.logger ?? console }], + // Framework placeholders pre-created at construction and filled during + // init() — the inner objects hold these same references unless the caller + // intentionally overrides them below. + moduleConfigs: [{ obj: new ModuleConfigs(this.#moduleConfigs) }], + moduleConfig: [] as InnerObject[], + runtimeConfig: [{ obj: this.#runtimeConfig }], + }; + const innerObjectsName = init?.innerObjectsName ?? 'innerObjects'; + const logger = init?.logger ?? console; + if (init?.innerObjects?.logger) { + logger.warn(`[tegg/standalone] ${innerObjectsName}.logger overrides the framework provided inner object`); + } + for (const name of ['moduleConfigs', 'runtimeConfig']) { + if (init?.innerObjects?.[name]) { + logger.warn(`[tegg/standalone] ${innerObjectsName}.${name} overrides the framework provided inner object`); + } + } + if (init?.innerObjects?.moduleConfig) { + logger.warn(`[tegg/standalone] ${innerObjectsName}.moduleConfig extends the framework provided inner objects`); + } + return Object.assign(frameworkInnerObjects, init?.innerObjects); + } + + /** Fill the runtimeConfig placeholder and bind module config names. */ + #initRuntime(opts: InitStandaloneAppOptions): void { + this.#runtimeConfig.name = opts.name ?? ''; + this.#runtimeConfig.env = opts.env ?? ''; + this.#runtimeConfig.baseDir = opts.baseDir; + + // Load module.yml and module.env.yml by default. The override is scoped to + // this app's TeggScope bag. + ModuleConfigUtil.configNames = opts.env ? ['module.default', `module.${opts.env}`] : ['module.default']; + } + + /** Load every module's config and expose it as a qualified `moduleConfig` inner object. */ + #loadModuleConfigs(): void { + for (const reference of this.#moduleReferences) { + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.#runtimeConfig.baseDir); + const resolvedRef = { + path: resolved.path, + name: reference.name, + package: reference.package, + optional: reference.optional, + loaderType: reference.loaderType, + }; + this.#moduleConfigs[resolved.name] = { + name: resolved.name, + reference: resolvedRef, + config: resolved.config, + }; + } + for (const moduleConfig of Object.values(this.#moduleConfigs)) { + this.#innerObjects.moduleConfig.push({ + obj: moduleConfig.config, + qualifiers: [ + { + attribute: ConfigSourceQualifierAttribute, + value: moduleConfig.name, + }, + ], + }); + } + } + + static getModuleReferences( + cwd: string, + dependencies?: (string | ModuleDependency)[], + frameworkDeps?: (string | ModuleDependency)[], + ): readonly ModuleReference[] { + // The standalone package itself is the built-in framework scan root: its + // own package.json dependencies that declare `eggModule` (the aop/dal/ + // config plugin packages) are discovered through the SAME node_modules + // convention as app dependencies — no hand-maintained package list. + // `!test/**` keeps this package's own test fixture modules out of the + // scan in workspace layouts (src/ in dev, dist/ when published — the + // package root is one level up either way). + const standaloneRoot: ModuleDependency = { + baseDir: path.join(path.dirname(fileURLToPath(import.meta.url)), '..'), + extraFilePattern: ['!test/**'], + }; + // framework deps first so their modules are scanned ahead of app modules + const moduleDirs = ([standaloneRoot] as (string | ModuleDependency)[]) + .concat(frameworkDeps || []) + .concat(dependencies || []) + .concat(cwd); + const references = moduleDirs.reduce( + (list, baseDir) => { + const module = typeof baseDir === 'string' ? { baseDir } : baseDir; + return list.concat(...ModuleConfigUtil.readModuleReference(module.baseDir, module)); + }, + [] as readonly ModuleReference[], + ); + // The same module may be reachable from multiple scan roots (a built-in + // framework module the app also depends on) — shared dedupe (first path + // wins, conflicting duplicate names throw). + return ModuleConfigUtil.deduplicateModules(references); + } + + static async preLoad( + cwd: string, + dependencies?: (string | ModuleDependency)[], + frameworkDeps?: (string | ModuleDependency)[], + ): Promise { + const moduleReferences = StandaloneApp.getModuleReferences(cwd, dependencies, frameworkDeps); + await EggModuleLoader.preLoad(moduleReferences, { + baseDir: cwd, + logger: console, + dump: false, + }); + } + + /** + * Scan-only metadata generation entry (the standalone counterpart of the egg + * plugin's loadMetadata): produce the tegg manifest extension a bundler can + * persist, without instantiating anything. Runs in a temporary scope so no + * state leaks into the process-default bag. + */ + static async loadMetadata(cwd: string, options?: StandaloneAppOptions): Promise { + const moduleReferences = StandaloneApp.getModuleReferences(cwd, options?.dependencies, options?.frameworkDeps); + return await TeggScope.run(TeggScope.createBag(), async () => { + const loader = new EggModuleLoader(moduleReferences, { + logger: options?.logger ?? console, + baseDir: cwd, + dump: false, + loaderFS: options?.loaderFS, + }); + await loader.init(); + return EggModuleLoader.buildTeggManifestData(moduleReferences, loader.moduleDescriptors); + }); + } + + async #initLoaderInstance(opts: InitStandaloneAppOptions): Promise { + this.#loadUnitLoader = new EggModuleLoader(this.#moduleReferences, { + logger: this.#logger, + baseDir: opts.baseDir, + dump: this.#dump, + manifest: opts.manifest, + loaderFS: opts.loaderFS, + }); + await this.#loadUnitLoader.init(); + } + + /** + * Phase 2: create AND instantiate the InnerObjectLoadUnit before the business + * graph is built, so `@XxxLifecycleProto` hooks (including graph build hooks + * they register in `@LifecyclePostInject`) are live for every later phase. + */ + async #instantiateInnerObjectLoadUnit(): Promise { + StandaloneContextHandler.register(); + const builder = new InnerObjectLoadUnitBuilder(); + for (const moduleDescriptor of this.#loadUnitLoader.moduleDescriptors) { + builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { + name: moduleDescriptor.name, + path: moduleDescriptor.unitPath, + }); + } + const innerObjectLoadUnit = await builder.createLoadUnit({ + innerObjects: this.#innerObjects, + }); + this.loadUnits.push(innerObjectLoadUnit); + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); + this.loadUnitInstances.push(instance); + } + + /** Phase 3/4: build + sort the business graph, then create and instantiate module load units. */ + async #instantiateModuleLoadUnits(): Promise { + const loadUnits = await this.#loadUnitLoader.load(); + this.loadUnits.push(...loadUnits); + for (const loadUnit of loadUnits) { + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + this.loadUnitInstances.push(instance); + } + } + + #initRunner(): void { + const runnerClass = StandaloneUtil.getMainRunner(); + if (!runnerClass) { + throw new Error('not found runner class. Do you add @Runner decorator?'); + } + // Prefer the per-app scoped lookup so parallel apps don't fall back to + // process-global class metadata when a per-scope prototype is registered. + const proto = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(runnerClass); + if (!proto) { + throw new Error(`can not get proto for clazz ${runnerClass.name}`); + } + this.#runnerProto = proto as EggPrototype; + } + + async init(opts: InitStandaloneAppOptions): Promise { + // Idempotent (same contract as ServiceWorkerApp.init): a second call must + // not reload configs or re-create load units. + if (this.#initialized) { + return; + } + await this.runInScope(async () => { + this.#initRuntime(opts); + // In manifest-consume mode the module reference graph was already + // captured at build time; reuse it instead of re-scanning the filesystem. + this.#moduleReferences = opts.manifest?.moduleReferences?.length + ? opts.manifest.moduleReferences + : StandaloneApp.getModuleReferences(opts.baseDir, opts.dependencies, this.#frameworkDeps); + this.#loadModuleConfigs(); + await this.#initLoaderInstance(opts); + await this.#instantiateInnerObjectLoadUnit(); + await this.#instantiateModuleLoadUnits(); + this.#initRunner(); + }); + this.#initialized = true; + } + + async run(aCtx?: EggContext): Promise { + return this.runInScope(async () => { + const lifecycle = {}; + const ctx = aCtx || new StandaloneContext(); + return await ContextHandler.run(ctx, async () => { + if (ctx.init) { + await ctx.init(lifecycle); + } + const eggObject = await EggContainerFactory.getOrCreateEggObject(this.#runnerProto); + const runner = eggObject.obj as MainRunner; + try { + return await runner.main(); + } finally { + if (ctx.destroy) { + // Fire-and-forget on purpose: do NOT await here. A host may return a + // response whose body is drained by the CALLER after run() returns + // (e.g. the service-worker pipes a streaming Response body and keeps + // context protos alive via a BackgroundTaskHelper drain task). + // ctx.destroy() drains those tasks, so awaiting it here would block + // run() from returning the Response the caller must consume first — + // a deadlock. App-level teardown determinism is handled by the + // awaited app.destroy() in appMain (main.ts). + void ctx.destroy(lifecycle).catch((e: unknown) => { + if (e instanceof Error) { + e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; + console.warn(e); + return; + } + console.warn('[tegg/standalone] destroy tegg context failed:', e); + }); + } + } + }); + }); + } + + async destroy(): Promise { + try { + await this.runInScope(() => this.doDestroy()); + } finally { + // Always release the scope, even if doDestroy rejects, so liveScopeBags + // (and thus isMultiApp / the sole-app fallback) never leaks a dead app. + TeggScope.unregisterScope(this.scopeBag); + } + } + + private async doDestroy(): Promise { + // Reverse creation order: business load units go down first, the + // InnerObjectLoadUnit last — its lifecycle protos stay registered until + // every object they may hook has been destroyed. + if (this.loadUnitInstances) { + for (const instance of [...this.loadUnitInstances].reverse()) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + } + if (this.loadUnits) { + for (const loadUnit of [...this.loadUnits].reverse()) { + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + } + // Framework hooks (ConfigSource/AOP/DAL) live in the InnerObjectLoadUnit + // and deregister themselves — and clean up their own managers — when it + // is destroyed above (dal: DalModuleLoadUnitHook#destroy). + // Release this app's scoped config name override before unregistering the scope. + ModuleConfigUtil.setConfigNames(undefined); + } +} diff --git a/tegg/standalone/standalone/src/StandaloneInnerObject.ts b/tegg/standalone/standalone/src/StandaloneInnerObject.ts deleted file mode 100644 index 7a29dc4cda..0000000000 --- a/tegg/standalone/standalone/src/StandaloneInnerObject.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { EggPrototype } from '@eggjs/metadata'; -import { IdenticalUtil, type EggObjectName } from '@eggjs/tegg'; -import { type EggObject, EggObjectFactory } from '@eggjs/tegg-runtime'; - -import { StandaloneInnerObjectProto } from './StandaloneInnerObjectProto.ts'; - -export class StandaloneInnerObject implements EggObject { - readonly isReady: boolean = true; - #obj: object; - readonly proto: StandaloneInnerObjectProto; - readonly name: EggObjectName; - readonly id: string; - - constructor(name: EggObjectName, proto: StandaloneInnerObjectProto) { - this.proto = proto; - this.name = name; - this.id = IdenticalUtil.createObjectId(this.proto.id); - } - - get obj(): object { - if (!this.#obj) { - this.#obj = this.proto.constructEggObject(); - } - return this.#obj; - } - - injectProperty(): void { - return; - } - - static async createObject(name: EggObjectName, proto: EggPrototype): Promise { - return new StandaloneInnerObject(name, proto as StandaloneInnerObjectProto); - } -} - -EggObjectFactory.registerEggObjectCreateMethod(StandaloneInnerObjectProto, StandaloneInnerObject.createObject); diff --git a/tegg/standalone/standalone/src/StandaloneInnerObjectProto.ts b/tegg/standalone/standalone/src/StandaloneInnerObjectProto.ts deleted file mode 100644 index 7c45e57e78..0000000000 --- a/tegg/standalone/standalone/src/StandaloneInnerObjectProto.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { EggPrototype, InjectObjectProto, EggPrototypeLifecycleContext } from '@eggjs/metadata'; -import { - AccessLevel, - type EggProtoImplClass, - type EggPrototypeName, - type MetaDataKey, - MetadataUtil, - type ObjectInitTypeLike, - type QualifierInfo, - QualifierUtil, - type Id, - IdenticalUtil, - type QualifierValue, -} from '@eggjs/tegg'; - -export class StandaloneInnerObjectProto implements EggPrototype { - [key: symbol]: PropertyDescriptor; - private readonly clazz: EggProtoImplClass; - private readonly qualifiers: QualifierInfo[]; - - readonly id: string; - readonly name: EggPrototypeName; - readonly initType: ObjectInitTypeLike; - readonly accessLevel: AccessLevel; - readonly injectObjects: InjectObjectProto[]; - readonly loadUnitId: Id; - - constructor( - id: string, - name: EggPrototypeName, - clazz: EggProtoImplClass, - initType: ObjectInitTypeLike, - loadUnitId: Id, - qualifiers: QualifierInfo[], - ) { - this.id = id; - this.clazz = clazz; - this.name = name; - this.initType = initType; - this.accessLevel = AccessLevel.PUBLIC; - this.injectObjects = []; - this.loadUnitId = loadUnitId; - this.qualifiers = qualifiers; - } - - verifyQualifiers(qualifiers: QualifierInfo[]): boolean { - for (const qualifier of qualifiers) { - if (!this.verifyQualifier(qualifier)) { - return false; - } - } - return true; - } - - verifyQualifier(qualifier: QualifierInfo): boolean { - const selfQualifiers = this.qualifiers.find((t) => t.attribute === qualifier.attribute); - return selfQualifiers?.value === qualifier.value; - } - - constructEggObject(): object { - return Reflect.apply(this.clazz, null, []); - } - - getMetaData(metadataKey: MetaDataKey): T | undefined { - return MetadataUtil.getMetaData(metadataKey, this.clazz); - } - - getQualifier(attribute: string): QualifierValue | undefined { - return this.qualifiers.find((t) => t.attribute === attribute)?.value; - } - - static create(ctx: EggPrototypeLifecycleContext): EggPrototype { - const { clazz, loadUnit } = ctx; - const name = ctx.prototypeInfo.name; - const id = IdenticalUtil.createProtoId(loadUnit.id, name); - const proto = new StandaloneInnerObjectProto( - id, - name, - clazz, - ctx.prototypeInfo.initType, - loadUnit.id, - QualifierUtil.getProtoQualifiers(clazz), - ); - return proto; - } -} diff --git a/tegg/standalone/standalone/src/StandaloneLoadUnit.ts b/tegg/standalone/standalone/src/StandaloneLoadUnit.ts deleted file mode 100644 index 2659f98f13..0000000000 --- a/tegg/standalone/standalone/src/StandaloneLoadUnit.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { IdenticalUtil } from '@eggjs/lifecycle'; -import { type EggPrototype, EggPrototypeFactory, type LoadUnit } from '@eggjs/metadata'; -import { type EggPrototypeName, ObjectInitType, type QualifierInfo } from '@eggjs/tegg'; -import { MapUtil } from '@eggjs/tegg-common-util'; - -import { StandaloneInnerObjectProto } from './StandaloneInnerObjectProto.ts'; - -export const StandaloneLoadUnitType = 'StandaloneLoadUnitType'; - -export interface InnerObject { - obj: object; - qualifiers?: QualifierInfo[]; -} - -export class StandaloneLoadUnit implements LoadUnit { - readonly id: string = 'StandaloneLoadUnit'; - readonly name: string = 'StandaloneLoadUnit'; - readonly unitPath: string = 'MockStandaloneLoadUnitPath'; - readonly type: string = StandaloneLoadUnitType; - - private innerObject: Record; - private protoMap: Map = new Map(); - - constructor(innerObject: Record) { - this.innerObject = innerObject; - } - - async init(): Promise { - for (const [name, objs] of Object.entries(this.innerObject)) { - for (const { obj, qualifiers } of objs) { - const proto = new StandaloneInnerObjectProto( - IdenticalUtil.createProtoId(this.id, name), - name, - (() => obj) as any, - ObjectInitType.SINGLETON, - this.id, - qualifiers || [], - ); - EggPrototypeFactory.instance.registerPrototype(proto, this); - } - } - } - - containPrototype(proto: EggPrototype): boolean { - return !!this.protoMap.get(proto.name)?.find((t) => t === proto); - } - - getEggPrototype(name: string, qualifiers: QualifierInfo[]): EggPrototype[] { - const protos = this.protoMap.get(name); - return protos?.filter((proto) => proto.verifyQualifiers(qualifiers)) || []; - } - - registerEggPrototype(proto: EggPrototype): void { - const protoList = MapUtil.getOrStore(this.protoMap, proto.name, []); - protoList.push(proto); - } - - deletePrototype(proto: EggPrototype): void { - const protos = this.protoMap.get(proto.name); - if (protos) { - const index = protos.indexOf(proto); - if (index !== -1) { - protos.splice(index, 1); - } - } - } - - async destroy(): Promise { - for (const namedProtoMap of this.protoMap.values()) { - for (const proto of namedProtoMap.values()) { - EggPrototypeFactory.instance.deletePrototype(proto, this); - } - } - this.protoMap.clear(); - } - - iterateEggPrototype(): IterableIterator { - const protos: EggPrototype[] = Array.from(this.protoMap.values()).reduce((p, c) => { - p = p.concat(c); - return p; - }, []); - return protos.values(); - } -} diff --git a/tegg/standalone/standalone/src/index.ts b/tegg/standalone/standalone/src/index.ts index 125db6c480..a0b2b19679 100644 --- a/tegg/standalone/standalone/src/index.ts +++ b/tegg/standalone/standalone/src/index.ts @@ -1,6 +1,4 @@ export * from './EggModuleLoader.ts'; -export * from './Runner.ts'; export * from './main.ts'; -export * from './StandaloneInnerObjectProto.ts'; +export * from './StandaloneApp.ts'; export * from './StandaloneContext.ts'; -export * from './StandaloneInnerObject.ts'; diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index b4a29d8028..b045ea50ca 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -1,8 +1,19 @@ -import { Runner, type RunnerOptions } from './Runner.ts'; +import type { EggContext } from '@eggjs/tegg-runtime'; -export async function preLoad(cwd: string, dependencies?: RunnerOptions['dependencies']): Promise { +import { + StandaloneApp, + type InitStandaloneAppOptions, + type StandaloneAppInit, + type StandaloneAppOptions, +} from './StandaloneApp.ts'; + +export async function preLoad( + cwd: string, + dependencies?: StandaloneAppOptions['dependencies'], + frameworkDeps?: StandaloneAppOptions['frameworkDeps'], +): Promise { try { - await Runner.preLoad(cwd, dependencies); + await StandaloneApp.preLoad(cwd, dependencies, frameworkDeps); } catch (e) { if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap standalone preLoad failed: ${e.message}`; @@ -11,27 +22,58 @@ export async function preLoad(cwd: string, dependencies?: RunnerOptions['depende } } -export async function main(cwd: string, options?: RunnerOptions): Promise { - const runner = new Runner(cwd, options); +export async function appMain( + options: InitStandaloneAppOptions, + init?: StandaloneAppInit, + ctx?: EggContext, +): Promise { + const app = new StandaloneApp(init); try { - await runner.init(); + await app.init(options); } catch (e) { if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap tegg failed: ${e.message}`; } // Boot failed and run()'s finally below is never reached, so tear down here - // to release this Runner's TeggScope so it does not leak into liveScopeBags. - await runner.destroy().catch(() => { + // to release this app's TeggScope so it does not leak into liveScopeBags. + await app.destroy().catch(() => { /* swallow: surface the original boot error */ }); throw e; } try { - return await runner.run(); + return await app.run(ctx); } finally { - runner.destroy().catch((e) => { - e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; - console.warn(e); + await app.destroy().catch((e: unknown) => { + if (e instanceof Error) { + e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; + console.warn(e); + return; + } + console.warn('[tegg/standalone] destroy tegg failed:', e); }); } } + +export async function main(cwd: string, options?: StandaloneAppOptions): Promise { + if (options && 'innerObjects' in options) { + throw new Error('[tegg/standalone] options.innerObjects has been removed, use options.innerObjectHandlers instead'); + } + return await appMain( + { + baseDir: cwd, + name: options?.name, + env: options?.env, + dependencies: options?.dependencies, + manifest: options?.manifest, + loaderFS: options?.loaderFS, + }, + { + frameworkDeps: options?.frameworkDeps, + dump: options?.dump, + innerObjects: options?.innerObjectHandlers, + innerObjectsName: 'innerObjectHandlers', + logger: options?.logger, + }, + ); +} diff --git a/tegg/standalone/standalone/test/ModulePlugin.test.ts b/tegg/standalone/standalone/test/ModulePlugin.test.ts new file mode 100644 index 0000000000..fef9bf68c2 --- /dev/null +++ b/tegg/standalone/standalone/test/ModulePlugin.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { EggPrototypeNotFound } from '@eggjs/metadata'; +import { describe, it } from 'vitest'; + +import { main } from '../src/index.ts'; + +describe('standalone/standalone/test/ModulePlugin.test.ts', () => { + const getFixture = (name: string) => path.join(__dirname, 'fixtures', name); + + describe('EggLifecycleProto', () => { + it('should LoadUnitLifecycleProto work', async () => { + // The hook injects an inner object and registers a dynamic proto on the + // business load unit at preCreate — proves lifecycle protos are live + // (with DI wired) before business load units are created. + const msg = await main(getFixture('load-unit-lifecycle-proto')); + assert.equal(msg, 'dynamic bar name|foo fake name'); + }); + + it('should LoadUnitInstanceLifecycleProto work', async () => { + const count = await main(getFixture('load-unit-instance-lifecycle-proto')); + assert.equal(count, 66); + }); + + it('should EggObjectLifecycleProto work', async () => { + const msg = await main(getFixture('egg-object-lifecycle-proto')); + assert.equal(msg, 'foo message from FooEggObjectHook'); + }); + + it('should EggPrototypeLifecycleProto work', async () => { + const msg = await main(getFixture('egg-prototype-lifecycle-proto')); + assert.equal(msg, 'class name is Foo'); + }); + + it('should EggContextLifecycleProto work', async () => { + const msg = await main(getFixture('egg-context-lifecycle-proto')); + assert.equal(msg, 'Y'); + }); + }); + + describe('InnerObjectProto', () => { + it('should inject innerObject work when accessLevel is public', async () => { + const message = await main(getFixture('inner-object-proto')); + assert.equal(message, 'with inner bar and inner foo'); + }); + + it('should throw error if business proto injects a private innerObject', async () => { + await assert.rejects( + main(getFixture('invalid-inner-object-inject')), + (e: unknown) => e instanceof EggPrototypeNotFound && /innerBar not found/.test((e as Error).message), + ); + }); + }); +}); diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts new file mode 100644 index 0000000000..328b357d2c --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts @@ -0,0 +1,28 @@ +import type { MysqlDataSourceManager, SqlMapManager, TableModelManager } from '@eggjs/dal-plugin'; +import { Inject, SingletonProto } from '@eggjs/tegg'; +import { Runner, type MainRunner } from '@eggjs/tegg/standalone'; + +/** + * Pins the PUBLIC dal manager injection surface: business modules inject the + * dal managers by name. + */ +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + @Inject() + mysqlDataSourceManager: MysqlDataSourceManager; + + @Inject() + sqlMapManager: SqlMapManager; + + @Inject() + tableModelManager: TableModelManager; + + async main(): Promise { + return ( + typeof this.mysqlDataSourceManager.createDataSource === 'function' && + typeof this.sqlMapManager.get === 'function' && + typeof this.tableModelManager.get === 'function' + ); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json new file mode 100644 index 0000000000..7aeb0c5280 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json @@ -0,0 +1,7 @@ +{ + "name": "dalmanagerinject", + "type": "module", + "eggModule": { + "name": "dalmanagerinject" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/Foo.ts b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/Foo.ts new file mode 100644 index 0000000000..44aea1f29d --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/Foo.ts @@ -0,0 +1,11 @@ +import { SingletonProto } from '@eggjs/tegg'; +import { ContextHandler } from '@eggjs/tegg/helper'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + async main(): Promise { + return ContextHandler.getContext()?.get('initialized'); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/FooEggContextHook.ts b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/FooEggContextHook.ts new file mode 100644 index 0000000000..6147b92374 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/FooEggContextHook.ts @@ -0,0 +1,10 @@ +import { EggContextLifecycleProto } from '@eggjs/tegg'; +import type { EggContext } from '@eggjs/tegg-runtime'; +import type { EggContextLifecycleContext, LifecycleHook } from '@eggjs/tegg-types'; + +@EggContextLifecycleProto() +export class FooEggContextHook implements LifecycleHook { + async postCreate(_: EggContextLifecycleContext, ctx: EggContext): Promise { + ctx.set('initialized', 'Y'); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/package.json b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/package.json new file mode 100644 index 0000000000..86bbb87cd3 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "egg-context-lifecycle-proto", + "type": "module", + "eggModule": { + "name": "eggContextLifecycleApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/Foo.ts b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/Foo.ts new file mode 100644 index 0000000000..8f29af4f10 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/Foo.ts @@ -0,0 +1,12 @@ +import { SingletonProto } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + message: string; + + async main(): Promise { + return this.message; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/FooEggObjectHook.ts b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/FooEggObjectHook.ts new file mode 100644 index 0000000000..78d1b0d2b5 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/FooEggObjectHook.ts @@ -0,0 +1,12 @@ +import { EggObjectLifecycleProto } from '@eggjs/tegg'; +import type { EggObject, EggObjectLifeCycleContext, LifecycleHook } from '@eggjs/tegg-types'; + +@EggObjectLifecycleProto() +export class FooEggObjectHook implements LifecycleHook { + async postCreate(_: EggObjectLifeCycleContext, eggObject: EggObject): Promise { + if (eggObject.name !== 'foo') { + return; + } + (eggObject.obj as any).message = 'foo message from FooEggObjectHook'; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/package.json b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/package.json new file mode 100644 index 0000000000..cf4ffc1514 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "egg-object-lifecycle-proto", + "type": "module", + "eggModule": { + "name": "eggObjectLifecycleApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/Foo.ts b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/Foo.ts new file mode 100644 index 0000000000..88464afbc0 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/Foo.ts @@ -0,0 +1,12 @@ +import { SingletonProto } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +@Runner() +@SingletonProto({ name: 'FooRunner' }) +export class Foo implements MainRunner { + static message: string; + + async main(): Promise { + return Foo.message; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/FooEggPrototypeHook.ts b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/FooEggPrototypeHook.ts new file mode 100644 index 0000000000..60c45b1737 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/FooEggPrototypeHook.ts @@ -0,0 +1,14 @@ +import { EggPrototypeLifecycleProto } from '@eggjs/tegg'; +import type { EggPrototype, EggPrototypeLifecycleContext, LifecycleHook } from '@eggjs/tegg-types'; + +import { Foo } from './Foo.ts'; + +@EggPrototypeLifecycleProto() +export class FooEggPrototypeHook implements LifecycleHook { + async postCreate(_: EggPrototypeLifecycleContext, proto: EggPrototype): Promise { + if (proto.name !== 'FooRunner') { + return; + } + Foo.message = 'class name is ' + proto.className; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/package.json b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/package.json new file mode 100644 index 0000000000..3b0bdcf5cb --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "egg-prototype-lifecycle-proto", + "type": "module", + "eggModule": { + "name": "eggPrototypeLifecycleApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/inner-object-proto/foo.ts b/tegg/standalone/standalone/test/fixtures/inner-object-proto/foo.ts new file mode 100644 index 0000000000..2c4248f9c4 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/inner-object-proto/foo.ts @@ -0,0 +1,15 @@ +import { Inject, SingletonProto } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +import { InnerBar } from './innerBar.ts'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + @Inject() + innerBar: InnerBar; + + async main(): Promise { + return this.innerBar.message(); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/inner-object-proto/innerBar.ts b/tegg/standalone/standalone/test/fixtures/inner-object-proto/innerBar.ts new file mode 100644 index 0000000000..0d03bd6e4c --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/inner-object-proto/innerBar.ts @@ -0,0 +1,16 @@ +import { AccessLevel, Inject, InnerObjectProto } from '@eggjs/tegg'; + +@InnerObjectProto() +export class InnerFoo { + message = 'inner foo'; +} + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +export class InnerBar { + @Inject() + innerFoo: InnerFoo; + + message() { + return 'with inner bar and ' + this.innerFoo.message; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/inner-object-proto/package.json b/tegg/standalone/standalone/test/fixtures/inner-object-proto/package.json new file mode 100644 index 0000000000..7ba622ad7a --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/inner-object-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "inner-object-proto", + "type": "module", + "eggModule": { + "name": "innerObjectProtoApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/foo.ts b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/foo.ts new file mode 100644 index 0000000000..22b19310d5 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/foo.ts @@ -0,0 +1,15 @@ +import { Inject, SingletonProto } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +import { InnerBar } from './innerBar.ts'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + @Inject() + innerBar: InnerBar; + + async main(): Promise { + return !!this.innerBar; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/innerBar.ts b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/innerBar.ts new file mode 100644 index 0000000000..b58c3eed1a --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/innerBar.ts @@ -0,0 +1,4 @@ +import { InnerObjectProto } from '@eggjs/tegg'; + +@InnerObjectProto() +export class InnerBar {} diff --git a/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/package.json b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/package.json new file mode 100644 index 0000000000..a3f03a3aae --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/package.json @@ -0,0 +1,7 @@ +{ + "name": "invalid-inner-object-inject", + "type": "module", + "eggModule": { + "name": "invalidInnerObjectInject" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/Foo.ts b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/Foo.ts new file mode 100644 index 0000000000..883f2a864f --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/Foo.ts @@ -0,0 +1,12 @@ +import { SingletonProto } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + count: number; + + async main(): Promise { + return this.count; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/FooLoadUnitInstanceHook.ts b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/FooLoadUnitInstanceHook.ts new file mode 100644 index 0000000000..9150180e1d --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/FooLoadUnitInstanceHook.ts @@ -0,0 +1,14 @@ +import { LoadUnitInstanceLifecycleProto } from '@eggjs/tegg'; +import type { LifecycleHook, LoadUnitInstance, LoadUnitInstanceLifecycleContext } from '@eggjs/tegg-types'; + +@LoadUnitInstanceLifecycleProto() +export class FooLoadUnitInstanceHook implements LifecycleHook { + async postCreate(_: LoadUnitInstanceLifecycleContext, loadUnitInstance: LoadUnitInstance): Promise { + if (loadUnitInstance.name !== 'loadUnitInstanceLifecycleApp') { + return; + } + const proto = loadUnitInstance.loadUnit.getEggPrototype('foo', []); + const foo = loadUnitInstance.getEggObject('foo', proto[0]); + (foo.obj as any).count = 66; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/package.json b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/package.json new file mode 100644 index 0000000000..dfb30fab51 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "load-unit-instance-lifecycle-proto", + "type": "module", + "eggModule": { + "name": "loadUnitInstanceLifecycleApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Foo.ts b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Foo.ts new file mode 100644 index 0000000000..a2bc6e4b05 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Foo.ts @@ -0,0 +1,8 @@ +import { InnerObjectProto } from '@eggjs/tegg'; + +@InnerObjectProto() +export class Foo { + getName() { + return 'foo fake name'; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts new file mode 100644 index 0000000000..36514ece75 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts @@ -0,0 +1,28 @@ +import { EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; +import { Inject, LoadUnitLifecycleProto, SingletonProto } from '@eggjs/tegg'; +import type { LifecycleHook, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; + +import { Foo } from './Foo.ts'; + +@LoadUnitLifecycleProto() +export class FooLoadUnitHook implements LifecycleHook { + @Inject() + foo: Foo; + + async preCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.name !== 'loadUnitLifecycleApp') { + return; + } + const fooName = this.foo.getName(); + class DynamicBar { + getName() { + return 'dynamic bar name|' + fooName; + } + } + SingletonProto()(DynamicBar); + const protos = await EggPrototypeCreatorFactory.createProto(DynamicBar, loadUnit); + for (const proto of protos) { + EggPrototypeFactory.instance.registerPrototype(proto, loadUnit); + } + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Runner.ts b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Runner.ts new file mode 100644 index 0000000000..1c35388e47 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Runner.ts @@ -0,0 +1,13 @@ +import { ContextProto, Inject } from '@eggjs/tegg'; +import { type MainRunner, Runner } from '@eggjs/tegg/standalone'; + +@ContextProto() +@Runner() +export class FooRunner implements MainRunner { + @Inject() + dynamicBar: any; + + async main(): Promise { + return this.dynamicBar.getName(); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/package.json b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/package.json new file mode 100644 index 0000000000..f2f9a921a0 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/package.json @@ -0,0 +1,7 @@ +{ + "name": "load-unit-lifecycle-proto", + "type": "module", + "eggModule": { + "name": "loadUnitLifecycleApp" + } +} diff --git a/tegg/standalone/standalone/test/fixtures/logger-option/foo.ts b/tegg/standalone/standalone/test/fixtures/logger-option/foo.ts new file mode 100644 index 0000000000..5ab3a95e6e --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/logger-option/foo.ts @@ -0,0 +1,13 @@ +import { Inject, SingletonProto, type Logger } from '@eggjs/tegg'; +import { Runner, type MainRunner } from '@eggjs/tegg/standalone'; + +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + @Inject() + logger: Logger; + + async main(): Promise { + return this.logger; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/logger-option/package.json b/tegg/standalone/standalone/test/fixtures/logger-option/package.json new file mode 100644 index 0000000000..e2bda11eb7 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/logger-option/package.json @@ -0,0 +1,7 @@ +{ + "name": "loggeroption", + "type": "module", + "eggModule": { + "name": "loggeroption" + } +} diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index ffb3a96dc7..0537b23fde 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -4,18 +4,109 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; +import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; +import { TeggScope } from '@eggjs/tegg-types'; import { type ModuleConfig, ModuleConfigs, ModuleDescriptorDumper } from '@eggjs/tegg/helper'; import { importResolve } from '@eggjs/utils'; import { mm } from 'mm'; import { describe, it, afterEach, beforeEach } from 'vitest'; -import { main, StandaloneContext, Runner, preLoad } from '../src/index.ts'; +import { main, StandaloneContext, StandaloneApp, preLoad, appMain } from '../src/index.ts'; import { crosscutAdviceParams, pointcutAdviceParams } from './fixtures/aop-module/Hello.ts'; import { Foo } from './fixtures/dal-module/src/Foo.ts'; const __dirname = import.meta.dirname; describe('standalone/standalone/test/index.test.ts', () => { + describe('preLoad', () => { + afterEach(() => { + mm.restore(); + }); + + it('should pass frameworkDeps to StandaloneApp.preLoad', async () => { + const calls: unknown[][] = []; + mm(StandaloneApp, 'preLoad', async (...args: unknown[]) => { + calls.push(args); + }); + + await preLoad('/tmp/app', ['dep'], ['framework']); + + assert.deepEqual(calls, [['/tmp/app', ['dep'], ['framework']]]); + }); + }); + + describe('appMain', () => { + afterEach(() => { + mm.restore(); + }); + + it('should await app destroy on success', async () => { + const events: string[] = []; + mm(StandaloneApp.prototype, 'init', async () => { + events.push('init'); + }); + mm(StandaloneApp.prototype, 'run', async () => { + events.push('run'); + return 'done'; + }); + mm(StandaloneApp.prototype, 'destroy', async () => { + await sleep(10); + events.push('destroy'); + }); + + const result = await appMain({ baseDir: '/tmp/app' }); + + assert.equal(result, 'done'); + assert.deepEqual(events, ['init', 'run', 'destroy']); + }); + + it('should warn when destroy rejects with non-Error value', async () => { + const warnings: unknown[][] = []; + mm(StandaloneApp.prototype, 'init', async () => {}); + mm(StandaloneApp.prototype, 'run', async () => 'done'); + mm(StandaloneApp.prototype, 'destroy', async () => { + throw 'boom'; + }); + mm(console, 'warn', (...args: unknown[]) => { + warnings.push(args); + }); + + const result = await appMain({ baseDir: '/tmp/app' }); + + assert.equal(result, 'done'); + assert.deepEqual(warnings, [['[tegg/standalone] destroy tegg failed:', 'boom']]); + }); + }); + + describe('manifest consume', () => { + afterEach(() => { + mm.restore(); + }); + + it('should reuse manifest moduleReferences without scanning modules', async () => { + const fixture = path.join(__dirname, './fixtures/simple'); + const moduleReferences = StandaloneApp.getModuleReferences(fixture); + mm(StandaloneApp, 'getModuleReferences', () => { + throw new Error('should not scan module references when manifest provides them'); + }); + + const app = new StandaloneApp(); + await app.init({ + baseDir: fixture, + manifest: { + moduleReferences: [...moduleReferences], + moduleDescriptors: [], + }, + }); + try { + assert.deepEqual(app.moduleReferences, moduleReferences); + } finally { + await app.destroy(); + } + }); + }); + describe('simple runner', () => { const fixture = path.join(__dirname, './fixtures/simple'); @@ -28,7 +119,8 @@ describe('standalone/standalone/test/index.test.ts', () => { const msg: string = await main(fixture); assert.equal(msg, 'hello!hello from ctx'); await sleep(500); - assert.equal((ModuleDescriptorDumper.dump as any).called, 1); + // app module + the three built-in framework modules (teggAop/teggDal/teggConfig) + assert.equal((ModuleDescriptorDumper.dump as any).called, 4); }); it('should not dump', async () => { @@ -65,12 +157,75 @@ describe('standalone/standalone/test/index.test.ts', () => { }); assert.equal(msg, 'hello, inner'); }); + + it('should reject removed innerObjects option', async () => { + await assert.rejects( + main(path.join(__dirname, './fixtures/inner-object'), { + innerObjects: { + hello: [{ obj: {} }], + }, + } as any), + /options\.innerObjects has been removed, use options\.innerObjectHandlers instead/, + ); + }); + }); + + describe('custom logger option', () => { + it('should inject options.logger as the logger inner object', async () => { + const customLogger = { ...console }; + const injected = await main(path.join(__dirname, './fixtures/logger-option'), { + logger: customLogger, + }); + assert.equal(injected, customLogger); + }); + + it('should let an innerObjectHandlers logger entry win over options.logger', async () => { + const warnings: unknown[][] = []; + const optionLogger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const handlerLogger = { ...console }; + const injected = await main(path.join(__dirname, './fixtures/logger-option'), { + logger: optionLogger, + innerObjectHandlers: { + logger: [{ obj: handlerLogger }], + }, + }); + assert.equal(injected, handlerLogger); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjectHandlers.logger overrides the framework provided inner object'], + ]); + }); + + it('should warn when innerObjects logger overrides the framework logger', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const handlerLogger = { ...console }; + const app = new StandaloneApp({ + logger, + innerObjects: { + logger: [{ obj: handlerLogger }], + }, + }); + await app.destroy(); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjects.logger overrides the framework provided inner object'], + ]); + }); }); describe('runner with custom context', () => { it('should work', async () => { - const runner = new Runner(path.join(__dirname, './fixtures/custom-context')); - await runner.init(); + const runner = new StandaloneApp(); + await runner.init({ baseDir: path.join(__dirname, './fixtures/custom-context') }); const ctx = new StandaloneContext(); ctx.set('foo', 'foo'); const msg = await runner.run(ctx); @@ -138,8 +293,8 @@ describe('standalone/standalone/test/index.test.ts', () => { const msg = await main(path.join(__dirname, './fixtures/runtime-config')); assert.deepEqual(msg, { baseDir: path.join(__dirname, './fixtures/runtime-config'), - env: undefined, - name: undefined, + env: '', + name: '', }); }); @@ -154,6 +309,55 @@ describe('standalone/standalone/test/index.test.ts', () => { env: 'unittest', }); }); + + it('should let innerObjectHandlers runtimeConfig override framework placeholder with warning', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const runtimeConfig = { + baseDir: 'custom-base-dir', + env: 'custom-env', + name: 'custom-name', + }; + + const injected = await main(path.join(__dirname, './fixtures/runtime-config'), { + logger, + innerObjectHandlers: { + runtimeConfig: [{ obj: runtimeConfig }], + }, + }); + + assert.equal(injected, runtimeConfig); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjectHandlers.runtimeConfig overrides the framework provided inner object'], + ]); + }); + + it('should use low-level innerObjects name in framework object warnings', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const app = new StandaloneApp({ + logger, + innerObjects: { + runtimeConfig: [{ obj: {} }], + moduleConfig: [{ obj: {} }], + }, + }); + await app.destroy(); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjects.runtimeConfig overrides the framework provided inner object'], + ['[tegg/standalone] innerObjects.moduleConfig extends the framework provided inner objects'], + ]); + }); }); describe('multi instance prototype runner', () => { @@ -210,9 +414,9 @@ describe('standalone/standalone/test/index.test.ts', () => { it('should throw error if no proto found', async () => { const fixturePath = path.join(__dirname, './fixtures/invalid-inject'); - const runner = new Runner(fixturePath); + const runner = new StandaloneApp(); await assert.rejects( - runner.init(), + runner.init({ baseDir: fixturePath }), /EggPrototypeNotFound: Object doesNotExist not found in LOAD_UNIT:invalidInject/, ); await runner.destroy(); @@ -232,15 +436,15 @@ describe('standalone/standalone/test/index.test.ts', () => { }); describe('load', () => { - let runner: Runner; + let runner: StandaloneApp; afterEach(async () => { if (runner) await runner.destroy(); }); it('should work', async () => { - runner = new Runner(path.join(__dirname, './fixtures/simple')); - await runner.init(); - const loadunits = await runner.load(); + runner = new StandaloneApp(); + await runner.init({ baseDir: path.join(__dirname, './fixtures/simple') }); + const loadunits = runner.loadUnits; for (const loadunit of loadunits) { for (const proto of loadunit.iterateEggPrototype()) { if (proto.id.match(/:hello$/)) { @@ -255,9 +459,9 @@ describe('standalone/standalone/test/index.test.ts', () => { }); it('should work with multi', async () => { - runner = new Runner(path.join(__dirname, './fixtures/multi-callback-instance-module')); - await runner.init(); - const loadunits = await runner.load(); + runner = new StandaloneApp(); + await runner.init({ baseDir: path.join(__dirname, './fixtures/multi-callback-instance-module') }); + const loadunits = runner.loadUnits; for (const loadunit of loadunits) { for (const proto of loadunit.iterateEggPrototype()) { if (proto.id.match(/:dynamicLogger$/)) { @@ -268,6 +472,36 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('inject mysqlDataSourceManager inner object', () => { + it('should stay injectable for business modules', async () => { + const ok = await main(path.join(__dirname, './fixtures/dal-manager-inject')); + assert.equal(ok, true); + }); + }); + + describe('dal manager cleanup', () => { + it('should clear dal managers when the app is destroyed', async () => { + const app = new StandaloneApp(); + let manager: MysqlDataSourceManager | undefined; + try { + await app.init({ baseDir: path.join(__dirname, './fixtures/dal-module'), env: 'unittest' }); + // The inner object instance survives destroy as a plain object + // reference, so we can observe the cleanup. + manager = await TeggScope.run(app.scopeBag, async () => { + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName('mysqlDataSourceManager'); + return eggObject.obj as MysqlDataSourceManager; + }); + assert(manager.get('dal', 'foo'), 'datasource created during init'); + } finally { + await app.destroy(); + } + // Cleared by DalModuleLoadUnitHook#destroyManagers (@LifecycleDestroy) + // when the InnerObjectLoadUnit instance goes down. + assert(manager); + assert.equal(manager.get('dal', 'foo'), undefined); + }); + }); + describe('dal runner', () => { it('should work', async () => { const foo: Foo = await main(path.join(__dirname, './fixtures/dal-module'), { @@ -356,8 +590,18 @@ describe('standalone/standalone/test/index.test.ts', () => { it('should work', async () => { await preLoad(fixturePath); await main(fixturePath); - assert.deepEqual(Foo.staticCalled, ['preLoad', 'construct', 'postConstruct', 'preInject', 'postInject', 'init']); - assert.equal((ModuleDescriptorDumper.dump as any).called, 1); + assert.deepEqual(Foo.staticCalled, [ + 'preLoad', + 'construct', + 'postConstruct', + 'preInject', + 'postInject', + 'init', + 'preDestroy', + 'destroy', + ]); + // app module + the three built-in framework modules (teggAop/teggDal/teggConfig) + assert.equal((ModuleDescriptorDumper.dump as any).called, 4); }); }); }); diff --git a/tegg/standalone/standalone/tsdown.config.ts b/tegg/standalone/standalone/tsdown.config.ts index 6dfb63bdb8..9ab856ad7b 100644 --- a/tegg/standalone/standalone/tsdown.config.ts +++ b/tegg/standalone/standalone/tsdown.config.ts @@ -4,4 +4,12 @@ export default defineConfig({ entry: { index: 'src/index.ts', }, + unused: { + level: 'error', + // Built-in framework module plugins: consumed through the package.json + // driven module scan (readModuleFromNodeModules reads this package's + // dependencies), NOT through code imports — being a dependency IS the + // declaration that makes them join the scan. + ignore: ['@eggjs/aop-plugin', '@eggjs/dal-plugin', '@eggjs/tegg-config'], + }, }); diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts index eb12f6a80c..5e0e056768 100644 --- a/tools/egg-bundler/src/lib/ManifestLoader.ts +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -30,6 +30,7 @@ interface TeggModuleDescriptor { interface TeggModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; diff --git a/wiki/concepts/tegg-module-plugin.md b/wiki/concepts/tegg-module-plugin.md new file mode 100644 index 0000000000..90e0043f23 --- /dev/null +++ b/wiki/concepts/tegg-module-plugin.md @@ -0,0 +1,93 @@ +--- +title: Tegg Module Plugin (declarative framework hooks) +type: concept +summary: How @InnerObjectProto/@EggLifecycleProto classes are collected into the InnerObjectLoadUnit and instantiated before the business graph builds, in both the standalone and egg hosts. +source_files: + - tegg/core/core-decorator/src/decorator/InnerObjectProto.ts + - tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts + - tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts + - tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts + - tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts + - tegg/core/runtime/src/impl/EggInnerObjectImpl.ts + - tegg/standalone/standalone/src/StandaloneApp.ts + - tegg/plugin/tegg/src/lib/ModuleHandler.ts + - tegg/plugin/tegg/src/lib/EggModuleLoader.ts + - tegg/plugin/aop/src/app.ts + - tegg/plugin/aop/src/lib/AopContextHook.ts + - tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts + - tegg/core/aop-runtime/src/LoadUnitAopHook.ts + - tegg/plugin/config/src/app.ts + - tegg/plugin/dal/src/index.ts + - tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +updated_at: 2026-07-09 +status: active +--- + +# Tegg Module Plugin + +Ported from eggjs/tegg#325 (standalone-next) and completed for both hosts. +A plain eggModule package can provide framework extensions declaratively — +no host boot code: + +- `@InnerObjectProto` — framework inner object (SingletonProto with + `EGG_INNER_OBJECT_PROTO_IMPL_TYPE`); diverted by the loader into + `ModuleDescriptor.innerObjectClazzList`, never into business load units. +- `@EggLifecycleProto` five variants (`LoadUnit` / `LoadUnitInstance` / + `EggPrototype` / `EggObject` / `EggContext`) — a DI-capable hook object, + auto-registered into the matching scope-aware LifecycleUtil by + `InnerObjectLoadUnitInstance` and deregistered symmetrically on destroy. + +## Two-phase ordering (the load-bearing constraint) + +`GlobalGraph.create()` only adds nodes; `build()` adds inject edges and runs +`registerBuildHook` hooks once at its end (late registration is silently +lost). Both hosts therefore boot in this order: + +1. scan modules → `GlobalGraph.create` (nodes only) +2. create AND instantiate the `InnerObjectLoadUnit` (own topologically + sorted proto graph; cycle detection; hard error on missing non-optional + deps unless host-provided) — hooks register here, including graph build + hooks from `@LifecyclePostInject` (see `AopGraphHookRegistrar`) +3. `build()` / `sort()` → business load units (EggPrototype/LoadUnit hooks + observe them) → business instances +4. destroy in reverse creation order (inner unit last) + +Hosts: `StandaloneApp.init()` (standalone) and `ModuleHandler.init()` via +`EggModuleLoader.initGraph()`/`load()` split (egg). + +## Feeding rules + +- Module scanning is the single feed path: the loader diverts + `@InnerObjectProto` / lifecycle proto classes into + `ModuleDescriptor.innerObjectClazzList`. +- Egg (`ModuleHandler.instantiateInnerObjectLoadUnit`) and standalone + (`StandaloneApp.#instantiateInnerObjectLoadUnit`) both iterate loaded + module descriptors and call + `InnerObjectLoadUnitBuilder.addInnerObjectClazzList()`. +- Built-in AOP / DAL / ConfigSource hooks are ordinary module plugin classes + discovered through module references. There are no + `AOP_INNER_OBJECT_CLAZZ_LIST` / `DAL_INNER_OBJECT_CLAZZ_LIST` hard-fed + lists and no `moduleHandler.registerInnerObjectClazzList()` API. +- The builder does not silently dedupe classes: duplicate inner-object proto + ids are hard errors. Package/path dedupe belongs to module reference + discovery before descriptors are loaded. +- Host-provided instances (`innerObjects` / `innerObjectHandlers`) become + `ProvidedInnerObjectProto`s. Standalone keeps them PUBLIC (business + modules inject `moduleConfigs` etc.); the egg host passes PRIVATE for its + base objects (`moduleConfigs`/`runtimeConfig`/`logger`) so they never + pollute cross-unit resolution. + +## Semantics worth remembering + +- `EggInnerObjectImpl` runs ONLY decorator-declared self lifecycle methods — + hook-callback names (`postCreate`, `preDestroy`, `init`, ...) never double + as self lifecycle. +- Cross-module ordering between lifecycle protos must be expressed as + `@Inject` edges; implicit registration order is not guaranteed. +- `frameworkDeps` (StandaloneApp option) scans framework module packages + ahead of app modules; app mode has no frameworkDeps — plugins enter via + the egg plugin shell + eggModule scanning. +- Inference: hooks that truly capture egg-only resources + (`EggQualifierProtoHook`, `EggContextCompatibleHook`) stay host-registered. + `AopContextHook` is now an `@EggContextLifecycleProto` inner object and + reads request-scope advice from `AopContextAdviceRegistry`. diff --git a/wiki/index.md b/wiki/index.md index e2996b1c3a..ac5f0533cf 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -7,6 +7,7 @@ Read this file before exploring raw sources. ## Concepts - [Repository Map](./concepts/repository-map.md) - High-level map of the main repository areas and where to look first. +- [Tegg Module Plugin](./concepts/tegg-module-plugin.md) - Declarative framework hooks (@InnerObjectProto/@EggLifecycleProto), the InnerObjectLoadUnit two-phase boot ordering, and host feeding rules. - [Vitest isolate:false state leaks](./concepts/vitest-isolate-false-state-leaks.md) - Why pool:threads + isolate:false exposes cross-file/cross-project state leaks, the concrete leaks (import.ts snapshot loader, mock mockContext, teardown close/load race), and how to triage them. ## Workflows diff --git a/wiki/log.md b/wiki/log.md index 97cc34bb55..e0df9a006e 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -114,3 +114,21 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - sources touched: `packages/utils/src/import.ts`, `packages/utils/README.md`, `packages/utils/test/module-importer.test.ts`, `packages/utils/test/fixtures/module-importer-require-esm/run.mjs`, `packages/typings/src/index.ts` - pages updated: `wiki/log.md`, `wiki/packages/utils.md` - note: Documented the `__EGG_BUNDLE_MODULE_LOADER__` → snapshot loader (`setSnapshotModuleLoader`) → `__EGG_MODULE_IMPORTER__` → native priority as a formal contract (JSDoc on `BundleModuleLoader`/`ModuleImporter` + README). Added regression coverage for the V8 snapshot-restore path where `__EGG_MODULE_IMPORTER__ = require` loads ESM with no dynamic-import callback (inline sync-require test + spawned `node:vm` fixture). No load-semantics change — types/declarations already existed. + +## [2026-07-04] architecture | tegg module plugin mechanism (both hosts) + +- sources touched: `tegg/core/{types,core-decorator,loader,metadata,runtime}`, `tegg/core/aop-runtime`, `tegg/plugin/{tegg,aop,dal}`, `tegg/standalone/standalone` +- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Ported tegg#325's declarative module plugin core to next and completed it: @InnerObjectProto/@EggLifecycleProto five variants, host-agnostic InnerObjectLoadUnit instantiated before the business graph builds (restores the two-phase ordering so declarative graph build hooks land in-window), egg-host wiring (#325 left app mode out), and conversion of the built-in AOP/DAL/ConfigSource hooks to module plugins on both hosts. Runner renamed to StandaloneApp (no alias). Also fixed plugin/controller's middlewareGraphHook silent no-op (registered on a not-yet-created graph) on branch fix/controller-middleware-graph-hook. + +## [2026-07-09] docs | correct tegg module plugin feeding rules + +- sources touched: `tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/plugin/{aop,config,dal}/src/app.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Corrected stale feeding-rule notes: inner object/lifecycle classes now arrive only through `ModuleDescriptor.innerObjectClazzList`; built-in AOP/DAL/ConfigSource hooks are discovered as normal module plugin classes rather than hard-fed lists, and duplicate inner-object proto ids are errors instead of class-level dedupe. + +## [2026-07-09] docs | align tegg module plugin notes with review fixes + +- sources touched: `tegg/plugin/aop/src/lib/AopContextHook.ts`, `tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts`, `tegg/core/aop-runtime/src/LoadUnitAopHook.ts`, `tegg/plugin/dal/src/index.ts`, `tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Replaced stale DAL source paths and updated the AOP note after `AopContextHook` moved to lifecycle-proto/inner-object registration backed by `AopContextAdviceRegistry`.