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 3dbf1c63c9..96f1f8cf8b 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/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..5bd0caaeab 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,13 +9,11 @@ import type { LoadUnitLifecycleContext, } from '@eggjs/tegg-types'; +@LoadUnitLifecycleProto() export class LoadUnitAopHook implements LifecycleHook { + @Inject() private readonly crosscutAdviceFactory: CrosscutAdviceFactory; - constructor(crosscutAdviceFactory: CrosscutAdviceFactory) { - this.crosscutAdviceFactory = crosscutAdviceFactory; - } - async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { for (const proto of loadUnit.iterateEggPrototype()) { const protoWithClazz = proto as EggPrototypeWithClazz; diff --git a/tegg/core/aop-runtime/src/index.ts b/tegg/core/aop-runtime/src/index.ts index a115cb43f9..7a7a56253f 100644 --- a/tegg/core/aop-runtime/src/index.ts +++ b/tegg/core/aop-runtime/src/index.ts @@ -4,3 +4,4 @@ export * from './EggObjectAopHook.js'; export * from './EggPrototypeCrossCutHook.js'; export * from './LoadUnitAopHook.js'; export * from './PointCutGraphHook.js'; +export * from './AopGraphHookRegistrar.js'; diff --git a/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap b/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap index cb162c87e9..15bc14cefd 100644 --- a/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap @@ -2,6 +2,7 @@ exports[`should export stable 1`] = ` { + "AopGraphHookRegistrar": [Function], "AspectExecutor": [Function], "EggObjectAopHook": [Function], "EggPrototypeCrossCutHook": [Function], diff --git a/tegg/core/aop-runtime/test/aop-runtime.test.ts b/tegg/core/aop-runtime/test/aop-runtime.test.ts index 5b4efef2c2..28f321c5df 100644 --- a/tegg/core/aop-runtime/test/aop-runtime.test.ts +++ b/tegg/core/aop-runtime/test/aop-runtime.test.ts @@ -37,15 +37,16 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = new LoadUnitAopHook(); + Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', 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 +167,10 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = new LoadUnitAopHook(); + Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', crosscutAdviceFactory); + eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); + Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.registerLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.registerLifecycle(eggObjectAopHook); @@ -176,7 +179,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 +195,16 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(crosscutAdviceFactory); - eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(crosscutAdviceFactory); + loadUnitAopHook = new LoadUnitAopHook(); + Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', 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/core-decorator/src/decorator/EggLifecycleProto.ts b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts new file mode 100644 index 0000000000..044b2af14d --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert'; + +import type { CommonEggLifecycleProtoParams, EggLifecycleProtoParams, EggProtoImplClass } from '@eggjs/tegg-types'; + +import { PrototypeUtil } from '../util/PrototypeUtil.ts'; +import { InnerObjectProto } from './InnerObjectProto.ts'; + +export function EggLifecycleProto(params: CommonEggLifecycleProtoParams) { + 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 }); + }; +} + +const createLifecycleProto = (type: CommonEggLifecycleProtoParams['type']) => { + return (params?: EggLifecycleProtoParams) => EggLifecycleProto({ type, ...params }); +}; + +export const LoadUnitLifecycleProto = createLifecycleProto('LoadUnit'); +export const LoadUnitInstanceLifecycleProto = createLifecycleProto('LoadUnitInstance'); +export const EggObjectLifecycleProto = createLifecycleProto('EggObject'); +export const EggPrototypeLifecycleProto = createLifecycleProto('EggPrototype'); +export const EggContextLifecycleProto = 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..afc4592044 --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts @@ -0,0 +1,16 @@ +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 { SingletonProto } from './SingletonProto.ts'; + +export function InnerObjectProto(params?: InnerObjectProtoParams) { + 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..923338bb4f 100644 --- a/tegg/core/core-decorator/src/decorator/index.ts +++ b/tegg/core/core-decorator/src/decorator/index.ts @@ -1,8 +1,10 @@ export * from './ConfigSource.ts'; export * from './ContextProto.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..79a5e581ed 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,11 @@ exports[`should export stable 1`] = ` "ConfigSourceQualifierAttribute": Symbol(Qualifier.ConfigSource), "ContextProto": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "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 +35,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/inner-object-decorators.test.ts b/tegg/core/core-decorator/test/inner-object-decorators.test.ts new file mode 100644 index 0000000000..dae8a0acdf --- /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: 'CustomLifecycle', + name: 'customName', + accessLevel: AccessLevel.PUBLIC, +}) +class ControllerOtherLifecycle {} + +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 open lifecycle type', () => { + const expectObjectProperty: EggPrototypeInfo = { + name: 'customName', + initType: ObjectInitType.SINGLETON, + accessLevel: AccessLevel.PUBLIC, + protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, + className: 'ControllerOtherLifecycle', + }; + assert.deepEqual(PrototypeUtil.getProperty(ControllerOtherLifecycle), expectObjectProperty); + assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(ControllerOtherLifecycle), { + type: 'CustomLifecycle', + }); + }); + + 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..c3d6ca0fe0 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -95,12 +95,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/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index b622489d39..4423a6960f 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -76,9 +76,13 @@ export class LoaderUtil { '!**/node_modules', // node load type definitions '!**/*.d.ts', - // not load test/coverage files + // not load test/coverage files (both the directory entry and its + // contents: a bare '!**/test' does not exclude descendants, which + // matters when scanning workspace packages that ship their test dirs) '!**/test', + '!**/test/**', '!**/coverage', + '!**/coverage/**', // extra file pattern ...(this.config.extraFilePattern || []), ]; 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/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/impl/EggInnerObjectPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts new file mode 100644 index 0000000000..86dc8277b3 --- /dev/null +++ b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert'; + +import { type InjectType as InjectTypeType, MetadataUtil, PrototypeUtil, QualifierUtil } from '@eggjs/core-decorator'; +import { IdenticalUtil } from '@eggjs/lifecycle'; +import type { + AccessLevel, + EggProtoImplClass, + EggPrototype, + EggPrototypeLifecycleContext, + EggPrototypeName, + Id, + InjectConstructorProto, + InjectObjectProto, + MetaDataKey, + ObjectInitTypeLike, + QualifierAttribute, + QualifierInfo, + QualifierValue, +} from '@eggjs/tegg-types'; +import { EGG_INNER_OBJECT_PROTO_IMPL_TYPE, InjectType } from '@eggjs/tegg-types'; + +import { EggPrototypeCreatorFactory } from '../factory/EggPrototypeCreatorFactory.ts'; +import { InjectObjectPrototypeFinder } from './InjectObjectPrototypeFinder.ts'; + +export class EggInnerObjectPrototypeImpl implements EggPrototype { + [key: symbol]: PropertyDescriptor; + + private readonly clazz: EggProtoImplClass; + private readonly qualifiers: QualifierInfo[]; + readonly filepath: string; + + readonly id: string; + readonly name: EggPrototypeName; + readonly initType: ObjectInitTypeLike; + readonly accessLevel: AccessLevel; + readonly injectObjects: Array; + readonly injectType: InjectTypeType; + readonly loadUnitId: Id; + readonly className?: string; + readonly multiInstanceConstructorIndex?: number; + readonly multiInstanceConstructorAttributes?: QualifierAttribute[]; + + constructor( + id: string, + name: EggPrototypeName, + clazz: EggProtoImplClass, + filepath: string, + initType: ObjectInitTypeLike, + accessLevel: AccessLevel, + injectObjectProtos: Array, + loadUnitId: Id, + qualifiers: QualifierInfo[], + className?: string, + injectType?: InjectTypeType, + multiInstanceConstructorIndex?: number, + multiInstanceConstructorAttributes?: QualifierAttribute[], + ) { + this.id = id; + this.clazz = clazz; + this.name = name; + this.filepath = filepath; + this.initType = initType; + this.accessLevel = accessLevel; + this.injectObjects = injectObjectProtos; + this.loadUnitId = loadUnitId; + this.qualifiers = qualifiers; + this.className = className; + this.injectType = injectType || InjectType.PROPERTY; + this.multiInstanceConstructorIndex = multiInstanceConstructorIndex; + this.multiInstanceConstructorAttributes = multiInstanceConstructorAttributes; + } + + 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; + } + + getQualifier(attribute: string): QualifierValue | undefined { + return this.qualifiers.find((t) => t.attribute === attribute)?.value; + } + + constructEggObject(...args: any): object { + return Reflect.construct(this.clazz, args); + } + + getMetaData(metadataKey: MetaDataKey): T | undefined { + return MetadataUtil.getMetaData(metadataKey, this.clazz); + } + + static create(ctx: EggPrototypeLifecycleContext): EggPrototype { + const { clazz, loadUnit } = ctx; + const filepath = PrototypeUtil.getFilePath(clazz); + assert(filepath, 'not find filepath'); + const name = ctx.prototypeInfo.name; + const className = ctx.prototypeInfo.className; + const initType = ctx.prototypeInfo.initType; + const accessLevel = ctx.prototypeInfo.accessLevel; + const injectType = PrototypeUtil.getInjectType(clazz); + const injectObjects = PrototypeUtil.getInjectObjects(clazz) || []; + const qualifiers = QualifierUtil.mergeQualifiers( + QualifierUtil.getProtoQualifiers(clazz), + ctx.prototypeInfo.qualifiers ?? [], + ); + const properQualifiers = ctx.prototypeInfo.properQualifiers ?? {}; + const multiInstanceConstructorIndex = PrototypeUtil.getMultiInstanceConstructorIndex(clazz); + const multiInstanceConstructorAttributes = PrototypeUtil.getMultiInstanceConstructorAttributes(clazz); + const injectObjectProtos = InjectObjectPrototypeFinder.findInjectObjectPrototypes({ + clazz, + loadUnit, + properQualifiers, + initType, + injectType, + injectObjects, + }); + const id = IdenticalUtil.createProtoId(loadUnit.id, name); + + return new EggInnerObjectPrototypeImpl( + id, + name, + clazz, + filepath, + initType, + accessLevel, + injectObjectProtos, + loadUnit.id, + qualifiers, + className, + injectType, + multiInstanceConstructorIndex, + multiInstanceConstructorAttributes, + ); + } +} + +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..fb5bbc3bbe 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, @@ -10,21 +10,15 @@ import type { EggPrototypeName, InjectConstructor, 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 class EggPrototypeBuilder { private clazz: EggProtoImplClass; @@ -65,104 +59,15 @@ export class EggPrototypeBuilder { 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( id, 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/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..cca6fff06c 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[]; } @@ -36,6 +37,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); @@ -79,6 +85,9 @@ 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); } 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 917c16c6ba..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,6 +60,8 @@ export class GlobalGraph { moduleProtoDescriptorMap: Map; strict: boolean; private buildHooks: GlobalGraphBuildHook[]; + /** 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 @@ -113,6 +106,7 @@ export class GlobalGraph { throw new Error(`duplicate proto: ${protoNode.val}`); } } + this.#protoNameIndex = null; } build(): void { @@ -180,75 +174,12 @@ export class GlobalGraph { return edge?.val.proto; } - #findDependencyProtoWithDefaultQualifiers( - proto: ProtoDescriptor, - injectObject: InjectObjectDescriptor, - qualifiers: QualifierInfo[], - ): GraphNode[] { - // TODO perf O(n(proto count)*m(inject count)*n) - const result: GraphNode[] = []; - for (const node of this.protoGraph.nodes.values()) { - 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/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/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts index b94749c88f..dd62efe5dc 100644 --- a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -17,6 +17,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: '/tmp/empty', clazzList: [], multiInstanceClazzList: [], + innerObjectClazzList: [], protos: [], }; const files = ModuleDescriptorDumper.getDecoratedFiles(desc); @@ -35,6 +36,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: loadUnitPath, clazzList: [AppRepo], multiInstanceClazzList: [], + innerObjectClazzList: [], protos: [], }; @@ -56,6 +58,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: loadUnitPath, clazzList: [AppRepo], multiInstanceClazzList: [AppRepo], + innerObjectClazzList: [], protos: [], }; @@ -73,6 +76,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { unitPath: '/tmp/fake-module', clazzList: [], multiInstanceClazzList: [AppRepo], + innerObjectClazzList: [], protos: [], }; 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/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..6e1251fa6f --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -0,0 +1,161 @@ +import { + ClassProtoDescriptor, + EggPrototypeNotFound, + LoadUnitFactory, + ProtoDependencyMeta, + ProtoDescriptorHelper, + ProtoGraphUtils, + ProtoNode, +} from '@eggjs/metadata'; +import { Graph, GraphNode } from '@eggjs/tegg-common-util'; +import type { EggProtoImplClass, LoadUnit, ProtoDescriptor } from '@eggjs/tegg-types'; +import { AccessLevel, 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; +} + +/** + * 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(); + + 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, + }); + 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( + innerObject.qualifiers ?? [], + 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..b61d0bd70a --- /dev/null +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -0,0 +1,146 @@ +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, + 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; + + constructor( + id: string, + name: EggPrototypeName, + objFactory: () => object, + initType: ObjectInitTypeLike, + loadUnitId: Id, + qualifiers: QualifierInfo[], + accessLevel?: AccessLevel, + ) { + 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; + } + + 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: string): 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 name = ctx.prototypeInfo.name; + const id = IdenticalUtil.createProtoId(loadUnit.id, name); + return new ProvidedInnerObjectProto( + id, + name, + clazz as unknown as () => object, + ctx.prototypeInfo.initType, + loadUnit.id, + ctx.prototypeInfo.qualifiers ?? [], + ctx.prototypeInfo.accessLevel, + ); + } +} + +/** + * 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..f9a29a4250 --- /dev/null +++ b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; + +import { + 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); + } + }); +}); 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__/dal.test.ts.snap b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap index 1eb8a44f62..1c1a84dd23 100644 --- a/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap @@ -58,6 +58,15 @@ exports[`should dal exports stable 1`] = ` "DAL_COLUMN_INFO_MAP": Symbol(EggPrototype#dalColumnInfoMap), "DAL_COLUMN_TYPE_MAP": Symbol(EggPrototype#dalColumnTypeMap), "DAL_INDEX_LIST": Symbol(EggPrototype#dalIndexList), + "DAL_INNER_OBJECT_CLAZZ_LIST": [ + [Function], + [Function], + [Function], + ], + "DAL_INNER_OBJECT_MODULE_REFERENCE": { + "name": "teggDal", + "path": "tegg:dal-plugin", + }, "DAL_IS_DAO": Symbol(EggPrototype#dalIsDao), "DAL_IS_TABLE": Symbol(EggPrototype#dalIsTable), "DAL_TABLE_PARAMS": Symbol(EggPrototype#dalTableParams), diff --git a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap index 17641c1a71..e8b4b7f6ae 100644 --- a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap @@ -64,8 +64,13 @@ exports[`should export stable 1`] = ` "Cookies": [Function], "CookiesParamMeta": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "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 +133,7 @@ exports[`should export stable 1`] = ` "CONSTRUCTOR": "CONSTRUCTOR", "PROPERTY": "PROPERTY", }, + "InnerObjectProto": [Function], "LifecycleDestroy": [Function], "LifecycleInit": [Function], "LifecyclePostConstruct": [Function], @@ -136,6 +142,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..d527e95c79 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": { @@ -135,7 +144,10 @@ exports[`should helper exports stable 1`] = ` "ProtoDescriptorType": { "CLASS": "CLASS", }, + "ProtoGraphUtils": [Function], "ProtoNode": [Function], + "ProvidedInnerObject": [Function], + "ProvidedInnerObjectProto": [Function], "ProxyUtil": [Function], "StackUtil": [Function], "StreamUtil": [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/src/core-decorator/EggLifecycleProto.ts b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts new file mode 100644 index 0000000000..e744d1195c --- /dev/null +++ b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts @@ -0,0 +1,7 @@ +import type { InnerObjectProtoParams } from './InnerObjectProto.ts'; + +export interface CommonEggLifecycleProtoParams extends InnerObjectProtoParams { + type: 'LoadUnit' | 'LoadUnitInstance' | 'EggObject' | 'EggPrototype' | 'EggContext' | string; +} + +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/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..4829600bf4 --- /dev/null +++ b/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts @@ -0,0 +1,3 @@ +export interface EggLifecycleInfo { + type: string; +} 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/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..37c7b77504 100644 --- a/tegg/core/types/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/types/test/__snapshots__/index.test.ts.snap @@ -121,6 +121,7 @@ exports[`should export stable 1`] = ` "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", "DataSourceInjectName": "dataSource", "DataSourceQualifierAttribute": Symbol(Qualifier.DataSource), + "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 900a4a180b..d7a7de5252 100644 --- a/tegg/plugin/aop/package.json +++ b/tegg/plugin/aop/package.json @@ -70,6 +70,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..0eba6238d8 --- /dev/null +++ b/tegg/plugin/aop/src/InnerObjects.ts @@ -0,0 +1,6 @@ +// 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 { AopGraphHookRegistrar, EggObjectAopHook, EggPrototypeCrossCutHook, LoadUnitAopHook } from '@eggjs/aop-runtime'; +export { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index b111456b0a..84a680d53f 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -1,13 +1,5 @@ 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'; @@ -15,48 +7,40 @@ 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); + // The AOP hooks are module plugin classes (@XxxLifecycleProto / + // @InnerObjectProto, incl. the graph build hook registrar): buffer them on + // the moduleHandler (created in the tegg plugin's configDidLoad, which runs + // before ours) so they are instantiated inside the InnerObjectLoadUnit — + // after the business GlobalGraph is created and before build() runs. + // Registration/deregistration is automatic. } 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'); + // Deliberately NOT a module plugin (@EggContextLifecycleProto), for two + // reasons: (1) timing — it snapshots moduleHandler.loadUnitInstances, + // which is only populated AFTER the business load units exist, i.e. + // outside the inner-object instantiation window; (2) host boundary — it + // is egg-controller compatibility wiring depending on the egg + // moduleHandler, while this plugin package is the aop module for BOTH + // hosts (the standalone scan must not instantiate it). Host boot wiring + // stays imperative; being a per-request ctx hook, late registration is + // harmless. 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/test/fixtures/apps/aop-app/package.json b/tegg/plugin/aop/test/fixtures/apps/aop-app/package.json index a0496f0b11..b592def620 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/package.json +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/package.json @@ -1,4 +1,7 @@ { "name": "egg-app", - "type": "module" + "type": "module", + "egg": { + "framework": "egg" + } } diff --git a/tegg/plugin/config/package.json b/tegg/plugin/config/package.json index b9842c8067..0f4351cb82 100644 --- a/tegg/plugin/config/package.json +++ b/tegg/plugin/config/package.json @@ -51,6 +51,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@eggjs/core-decorator": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@eggjs/tegg-types": "workspace:*", @@ -68,6 +69,9 @@ "engines": { "node": ">=22.18.0" }, + "eggModule": { + "name": "teggConfig" + }, "eggPlugin": { "name": "teggConfig" } 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/controller/test/fixtures/apps/controller-app/package.json b/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json index 44ed5faf32..6afd4fd704 100644 --- a/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json +++ b/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json @@ -1,4 +1,7 @@ { "name": "controller-app", - "type": "module" + "type": "module", + "egg": { + "framework": "egg" + } } diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index 56a53f32ce..76bae5a497 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,43 +1,26 @@ 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); + configDidLoad(): void { + // The DAL hooks are module plugin classes (@XxxLifecycleProto): buffer them + // on the moduleHandler (created in the tegg plugin's configDidLoad, which + // runs before ours) so they are instantiated inside the InnerObjectLoadUnit + // — with moduleConfigs/runtimeConfig/logger injected — before any business + // load unit is created. Registration/deregistration is automatic. } 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(); diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index a00eea2097..e83c334387 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -1,23 +1,31 @@ +import { Inject, InjectOptional, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; import { DatabaseForker, type DataSourceOptions } from '@eggjs/dal-runtime'; import 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; + 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; @@ -45,4 +53,17 @@ export class DalModuleLoadUnitHook implements LifecycleHook { + MysqlDataSourceManager.instance.clear(); + SqlMapManager.instance.clear(); + TableModelManager.instance.clear(); + } } diff --git a/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts b/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts index bda96bfa1e..bf3f699a83 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,13 +8,11 @@ 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; - } - async preCreate(ctx: EggPrototypeLifecycleContext): Promise { if (!DaoInfoUtil.getIsDao(ctx.clazz)) { return; diff --git a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts index fce764bef5..360b0e6386 100644 --- a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts +++ b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts @@ -1,23 +1,24 @@ 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; - private readonly logger: Logger; + @Inject() + private readonly moduleConfigs: ModuleConfigs; - constructor(moduleConfigs: Record, logger: Logger) { - this.moduleConfigs = moduleConfigs; - this.logger = logger; - } + @Inject() + private readonly logger: Logger; public async preCreate(ctx: EggPrototypeLifecycleContext): Promise { const builder = new TransactionMetaBuilder(ctx.clazz); @@ -28,7 +29,7 @@ export class TransactionPrototypeHook implements LifecycleHook { - 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); @@ -75,7 +65,7 @@ export default class TeggAppBoot implements ILifecycleBoot { async loadMetadata(): Promise { if (!this.app.moduleReferences) return; 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 +79,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..9a553c5119 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + 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'; @@ -11,6 +14,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 +31,19 @@ export class EggModuleLoader { this.pendingBuildHooks.push(hook); } + static #findPackageRoot(dir: string): string | undefined { + let current = dir; + for (let i = 0; i < 5; i++) { + if (fs.existsSync(path.join(current, 'package.json'))) { + return fs.realpathSync(current); + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } + return undefined; + } + private async loadApp(): Promise { const loader = new EggAppLoader(this.app); const loadUnit = await LoadUnitFactory.createLoadUnit(this.app.baseDir, EggLoadUnitType.APP, loader); @@ -34,9 +51,15 @@ export class EggModuleLoader { } private async buildAppGraph(): Promise { + // Promote enabled plugins' module references to non-optional. plugin.path + // points INSIDE the package (e.g. /src) and may go through a + // symlink, while references carry the real package root — resolve before + // matching, or the promotion never fires. 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 (!plugin.enable || !plugin.path) continue; + const packageRoot = EggModuleLoader.#findPackageRoot(plugin.path); + if (!packageRoot) continue; + const modulePlugin = this.app.moduleReferences.find((t) => t.path === packageRoot); if (modulePlugin) { modulePlugin.optional = false; } @@ -52,10 +75,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) { @@ -96,8 +120,12 @@ export class EggModuleLoader { /** * 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); } @@ -133,11 +161,25 @@ export class EggModuleLoader { } } - 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/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index ee1b51732a..ab9e50065b 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,10 @@ 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; loadUnitInstances: LoadUnitInstance[] = []; private readonly loadUnitLoader: EggModuleLoader; @@ -25,6 +31,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,18 +88,22 @@ export class ModuleHandler extends Base { EggCompatibleProtoImpl.create, ); + await this.loadUnitLoader.initGraph(); + const innerObjectInstance = await this.instantiateInnerObjectLoadUnit(); await this.loadUnitLoader.load(); - const instances: LoadUnitInstance[] = []; + const instances: LoadUnitInstance[] = [innerObjectInstance]; 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); + businessInstances.push(instance); } - CompatibleUtil.contextModuleCompatible(this.app.context, instances); + CompatibleUtil.contextModuleCompatible(this.app.context, businessInstances); this.loadUnitInstances = instances; this.ready(true); } catch (e) { @@ -53,15 +113,22 @@ export class ModuleHandler extends Base { } async destroy(): 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) { + for (const instance of [...this.loadUnitInstances].reverse()) { await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); } } if (this.loadUnits) { - for (const loadUnit of this.loadUnits) { + for (const loadUnit of [...this.loadUnits].reverse()) { await LoadUnitFactory.destroyLoadUnit(loadUnit); } } + if (this.#innerObjectLoadUnit) { + await LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit); + this.#innerObjectLoadUnit = undefined; + } } } 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/fixtures/apps/inject-module-config/package.json b/tegg/plugin/tegg/test/fixtures/apps/inject-module-config/package.json index 2e715e183c..64c27536c1 100644 --- a/tegg/plugin/tegg/test/fixtures/apps/inject-module-config/package.json +++ b/tegg/plugin/tegg/test/fixtures/apps/inject-module-config/package.json @@ -1,4 +1,7 @@ { "name": "inject-module-config", - "type": "module" + "type": "module", + "egg": { + "framework": "egg" + } } 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/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index 8a279a5984..06aeac7a95 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..3f4cb4ef8e 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 { 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,45 @@ 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 { + 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), + })), + }; + } + + #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 +114,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 +123,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..55ac731450 --- /dev/null +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -0,0 +1,391 @@ +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. The framework placeholders + * (moduleConfigs/moduleConfig/runtimeConfig) always win on name clash; + * a `logger` entry here wins over the `logger` option. + */ + innerObjects?: Record; + /** + * 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 { + return Object.assign( + { + // 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 }], + }, + init?.innerObjects, + { + // Framework placeholders pre-created at construction and filled during + // init() — the inner objects hold these same references. + moduleConfigs: [{ obj: new ModuleConfigs(this.#moduleConfigs) }], + moduleConfig: [] as InnerObject[], + runtimeConfig: [{ obj: this.#runtimeConfig }], + }, + ); + } + + /** 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 + // Always set configNames for this app invocation, since destroy() clears it + // asynchronously and may not have completed before the next app is created. + 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 absoluteRef = { + path: ModuleConfigUtil.resolveModuleDir(reference.path, this.#runtimeConfig.baseDir), + 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, + }, + ], + }); + } + } + + 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); + // The module scan happens here — baseDir only arrives at init(). + this.#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) { + 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 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). + // clear configNames + 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..69c10c257e 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -1,8 +1,15 @@ -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']): Promise { try { - await Runner.preLoad(cwd, dependencies); + await StandaloneApp.preLoad(cwd, dependencies); } catch (e) { if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap standalone preLoad failed: ${e.message}`; @@ -11,27 +18,50 @@ 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) => { + app.destroy().catch((e) => { e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; console.warn(e); }); } } + +export async function main(cwd: string, options?: StandaloneAppOptions): Promise { + 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, + 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/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..63c4b3581a 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -9,7 +9,7 @@ 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 } from '../src/index.ts'; import { crosscutAdviceParams, pointcutAdviceParams } from './fixtures/aop-module/Hello.ts'; import { Foo } from './fixtures/dal-module/src/Foo.ts'; @@ -28,7 +28,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 () => { @@ -67,10 +68,32 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + 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 optionLogger = { ...console }; + const handlerLogger = { ...console }; + const injected = await main(path.join(__dirname, './fixtures/logger-option'), { + logger: optionLogger, + innerObjectHandlers: { + logger: [{ obj: handlerLogger }], + }, + }); + assert.equal(injected, handlerLogger); + }); + }); + 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 +161,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: '', }); }); @@ -210,9 +233,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 +255,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 +278,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$/)) { @@ -357,7 +380,8 @@ describe('standalone/standalone/test/index.test.ts', () => { await preLoad(fixturePath); await main(fixturePath); assert.deepEqual(Foo.staticCalled, ['preLoad', 'construct', 'postConstruct', 'preInject', 'postInject', 'init']); - 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); }); }); }); diff --git a/wiki/concepts/tegg-module-plugin.md b/wiki/concepts/tegg-module-plugin.md new file mode 100644 index 0000000000..7aa4b6c1d9 --- /dev/null +++ b/wiki/concepts/tegg-module-plugin.md @@ -0,0 +1,79 @@ +--- +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 +updated_at: 2026-07-04 +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 + +- Scanned modules feed automatically (`innerObjectClazzList`). +- Built-in framework hooks (AOP `AOP_INNER_OBJECT_CLAZZ_LIST`, DAL + `DAL_INNER_OBJECT_CLAZZ_LIST`, ConfigSource) are hard-fed: + standalone in `StandaloneApp`, egg via + `moduleHandler.registerInnerObjectClazzList()` from the aop/dal plugin + boots (configDidLoad; moduleHandler exists because those plugins depend + on `tegg`). +- The builder dedupes by class: a package may be BOTH hard-fed and scanned + as an eggModule (e.g. `@eggjs/dal-plugin` as a module dependency). +- 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 needing egg-only resources (`EggQualifierProtoHook` + captures `app`; `EggContextCompatibleHook`/`AopContextHook` snapshot + init products) intentionally stay host-registered. 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..c48831ee38 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -114,3 +114,9 @@ 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.