From a9826f0e1169010bc4c6438794dc93b176875917 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 16:40:08 +0800 Subject: [PATCH 01/30] feat(core): add module plugin core mechanism Introduce the declarative framework-extension (module plugin) core so a plain tegg module can provide lifecycle hooks and framework inner objects via decorators, instead of the host hand-registering every hook in its boot code. Ported from eggjs/tegg#325 (standalone-next) and adapted to the next-branch architecture. - core-decorator/types: @InnerObjectProto (framework inner object, a SingletonProto with EGG_INNER_OBJECT_PROTO_IMPL_TYPE) and @EggLifecycleProto with the five typed variants (LoadUnit/LoadUnitInstance/EggPrototype/EggObject/EggContext); PrototypeUtil metadata accessors. - loader: divert inner object classes into ModuleDescriptor.innerObjectClazzList; getDecoratedFiles now covers the new list so bundle/manifest mode still re-imports those files. - metadata: EggInnerObjectPrototypeImpl (resolves injects at create time); InjectObjectPrototypeFinder extracted from EggPrototypeBuilder (behavior unchanged); ProtoGraphUtils extracted from GlobalGraph with a proto-name index replacing the O(n*m*n) scan; ProtoDescriptorHelper.createByInstanceClazz supports define/instance module separation. - runtime: EggInnerObjectImpl runs only decorator-declared self lifecycle methods (hook-callback names never double as self lifecycle); host-agnostic InnerObjectLoadUnit(+Instance/Builder) instantiates inner objects on a dedicated topologically-sorted proto graph (cycle detection, hard error on missing non-optional deps) and auto registers/deregisters lifecycle protos by declared type via the scope-aware lifecycle utils. The InnerObjectLoadUnit is designed to be instantiated before the business GlobalGraph build so registered hooks (including future declarative graph build hooks) land inside their consumption windows; host wiring for standalone/app follows in separate PRs. Co-Authored-By: Claude Fable 5 --- .../src/decorator/EggLifecycleProto.ts | 28 +++ .../src/decorator/InnerObjectProto.ts | 16 ++ .../core-decorator/src/decorator/index.ts | 2 + .../core-decorator/src/util/PrototypeUtil.ts | 56 +++++ .../test/__snapshots__/index.test.ts.snap | 8 + .../test/inner-object-decorators.test.ts | 130 ++++++++++ tegg/core/loader/src/LoaderFactory.ts | 6 +- .../loader/test/LoaderInnerObject.test.ts | 45 ++++ .../ControllerHook.ts | 13 + .../module-with-inner-object/FetchRouter.ts | 8 + .../module-with-inner-object/HelloService.ts | 8 + .../module-with-inner-object/package.json | 7 + .../src/impl/EggInnerObjectPrototypeImpl.ts | 147 ++++++++++++ .../metadata/src/impl/EggPrototypeBuilder.ts | 119 +-------- .../src/impl/InjectObjectPrototypeFinder.ts | 138 +++++++++++ tegg/core/metadata/src/impl/index.ts | 2 + .../metadata/src/model/ModuleDescriptor.ts | 9 + .../src/model/ProtoDescriptorHelper.ts | 20 +- .../metadata/src/model/graph/GlobalGraph.ts | 85 +------ .../src/model/graph/ProtoGraphUtils.ts | 131 ++++++++++ tegg/core/metadata/src/model/graph/index.ts | 1 + .../test/ModuleDescriptorDumper.test.ts | 4 + .../test/__snapshots__/index.test.ts.snap | 3 + .../runtime/src/impl/EggInnerObjectImpl.ts | 221 +++++++++++++++++ .../runtime/src/impl/InnerObjectLoadUnit.ts | 117 +++++++++ .../src/impl/InnerObjectLoadUnitBuilder.ts | 114 +++++++++ .../src/impl/InnerObjectLoadUnitInstance.ts | 71 ++++++ .../src/impl/ProvidedInnerObjectProto.ts | 126 ++++++++++ tegg/core/runtime/src/impl/index.ts | 5 + .../runtime/test/InnerObjectLoadUnit.test.ts | 227 ++++++++++++++++++ .../test/__snapshots__/index.test.ts.snap | 9 + .../src/core-decorator/EggLifecycleProto.ts | 7 + .../src/core-decorator/InnerObjectProto.ts | 3 + .../types/src/core-decorator/Prototype.ts | 2 + tegg/core/types/src/core-decorator/index.ts | 2 + .../core-decorator/model/EggLifecycleInfo.ts | 3 + .../types/src/core-decorator/model/index.ts | 1 + .../src/metadata/model/ProtoDescriptor.ts | 2 + .../test/__snapshots__/index.test.ts.snap | 1 + 39 files changed, 1706 insertions(+), 191 deletions(-) create mode 100644 tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts create mode 100644 tegg/core/core-decorator/src/decorator/InnerObjectProto.ts create mode 100644 tegg/core/core-decorator/test/inner-object-decorators.test.ts create mode 100644 tegg/core/loader/test/LoaderInnerObject.test.ts create mode 100644 tegg/core/loader/test/fixtures/modules/module-with-inner-object/ControllerHook.ts create mode 100644 tegg/core/loader/test/fixtures/modules/module-with-inner-object/FetchRouter.ts create mode 100644 tegg/core/loader/test/fixtures/modules/module-with-inner-object/HelloService.ts create mode 100644 tegg/core/loader/test/fixtures/modules/module-with-inner-object/package.json create mode 100644 tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts create mode 100644 tegg/core/metadata/src/impl/InjectObjectPrototypeFinder.ts create mode 100644 tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts create mode 100644 tegg/core/runtime/src/impl/EggInnerObjectImpl.ts create mode 100644 tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts create mode 100644 tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts create mode 100644 tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts create mode 100644 tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts create mode 100644 tegg/core/runtime/test/InnerObjectLoadUnit.test.ts create mode 100644 tegg/core/types/src/core-decorator/EggLifecycleProto.ts create mode 100644 tegg/core/types/src/core-decorator/InnerObjectProto.ts create mode 100644 tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts 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/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..84b8872bc0 --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts @@ -0,0 +1,117 @@ +import { IdenticalUtil } from '@eggjs/lifecycle'; +import { ClassProtoDescriptor, EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; +import { MapUtil } from '@eggjs/tegg-common-util'; +import type { EggPrototype, EggPrototypeName, LoadUnit, ProtoDescriptor, QualifierInfo } from '@eggjs/tegg-types'; +import { ObjectInitType } from '@eggjs/tegg-types'; + +import { ProvidedInnerObjectProto } from './ProvidedInnerObjectProto.ts'; + +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[]; +} + +export interface InnerObjectLoadUnitOptions { + /** Host-provided, already-constructed objects (logger, router, ...). */ + innerObjects: Record; + /** + * Proto descriptors of `@InnerObjectProto` / `@XxxLifecycleProto` classes + * collected from modules, in instantiation (topological) order. + */ + 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 #innerObjects: Record; + 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.#innerObjects = options.innerObjects; + this.#protos = options.protos ?? []; + } + + async init(): Promise { + for (const [name, objs] of Object.entries(this.#innerObjects)) { + for (const { obj, qualifiers } of objs) { + const proto = new ProvidedInnerObjectProto( + IdenticalUtil.createProtoId(this.id, name), + name, + (() => obj) as any, + ObjectInitType.SINGLETON, + this.id, + qualifiers || [], + ); + EggPrototypeFactory.instance.registerPrototype(proto, this); + } + } + + const protoDescriptors = this.#protos.filter((t) => ClassProtoDescriptor.isClassProtoDescriptor(t)); + for (const protoDescriptor of protoDescriptors) { + 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..160954d764 --- /dev/null +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -0,0 +1,114 @@ +import { + 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 { + 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'; + +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}`); + } + } + } + + #buildProtoGraph(providedNames: Set): 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) { + // Host-provided inner objects are registered on the load unit + // directly (not part of this graph); their resolution happens at + // prototype-build time. Anything else missing is a hard error — + // deferring it to runtime hides broken module plugins. + if (injectObject.optional || providedNames.has(injectObject.objName)) { + continue; + } + throw new EggPrototypeNotFound(injectObject.objName, protoNode.val.proto.defineModuleName); + } + this.#protoGraph.addEdge(protoNode, injectProto, new ProtoDependencyMeta({ injectObj: injectObject.objName })); + } + } + 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 { + const providedNames = new Set(Object.keys(options.innerObjects)); + const protos = this.#buildProtoGraph(providedNames); + LoadUnitFactory.registerLoadUnitCreator(INNER_OBJECT_LOAD_UNIT_TYPE, () => { + return new InnerObjectLoadUnit({ + innerObjects: options.innerObjects, + 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..6547e67514 --- /dev/null +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -0,0 +1,126 @@ +import { MetadataUtil, QualifierUtil } from '@eggjs/core-decorator'; +import { IdenticalUtil } from '@eggjs/lifecycle'; +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; + 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 selfQualifier = this.qualifiers.find((t) => t.attribute === qualifier.attribute); + return selfQualifier?.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); + return new ProvidedInnerObjectProto( + id, + name, + clazz, + ctx.prototypeInfo.initType, + loadUnit.id, + QualifierUtil.getProtoQualifiers(clazz), + ); + } +} + +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..3b8db601a7 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,8 @@ exports[`should export stable 1`] = ` "registerObjectLifecycle": [Function], }, "ModuleLoadUnitInstance": [Function], + "ProvidedInnerObject": [Function], + "ProvidedInnerObjectProto": [Function], "eggContextLifecycleUtilFromBag": [Function], "eggObjectLifecycleUtilFromBag": [Function], "loadUnitInstanceLifecycleUtilFromBag": [Function], 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", From bc70853ee8ce9a735a14f1f94d36ae1dc66145be Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 19:09:18 +0800 Subject: [PATCH 02/30] feat(standalone): two-phase StandaloneApp with module plugin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename Runner to StandaloneApp (no Runner alias kept — 4.x breaking change; main()/preLoad() entries are unchanged) and restructure boot into explicit phases so module plugins work end to end: 1. scan modules + create the business GlobalGraph (nodes only) 2. create AND instantiate the InnerObjectLoadUnit — host-provided inner objects plus every scanned @InnerObjectProto/@XxxLifecycleProto class (topologically ordered); lifecycle protos auto-register 3. build()/sort() the business graph and create module load units 4. instantiate module load units Deferring build/sort until after the inner load unit is instantiated restores the tegg#325 two-phase ordering: hooks registered by lifecycle protos (including graph build hooks registered in @LifecyclePostInject) land before their consumption windows. Destroy now runs in reverse creation order so lifecycle protos stay registered until every object they may hook is torn down. Also: - new frameworkDeps option: framework module plugins scanned ahead of the app's own modules (service worker runtime packages plug in here) - bundle-mode support: EggModuleLoader accepts a tegg manifest + loaderFS (reuses precomputed decorated files instead of globbing) and StandaloneApp.loadMetadata() provides the scan-only manifest generation entry for bundlers - replace StandaloneLoadUnit / StandaloneInnerObjectProto / StandaloneInnerObject with the host-agnostic core implementations (InnerObjectLoadUnit / ProvidedInnerObjectProto) - port the tegg#325 module plugin test suite (five lifecycle proto types, inner object DI, PUBLIC/PRIVATE access semantics) Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/package.json | 1 + .../standalone/src/EggModuleLoader.ts | 85 +++++++- .../src/{Runner.ts => StandaloneApp.ts} | 205 +++++++++++------- .../standalone/src/StandaloneInnerObject.ts | 36 --- .../src/StandaloneInnerObjectProto.ts | 86 -------- .../standalone/src/StandaloneLoadUnit.ts | 84 ------- tegg/standalone/standalone/src/index.ts | 4 +- tegg/standalone/standalone/src/main.ts | 20 +- .../standalone/test/ModulePlugin.test.ts | 55 +++++ .../egg-context-lifecycle-proto/Foo.ts | 11 + .../FooEggContextHook.ts | 10 + .../egg-context-lifecycle-proto/package.json | 7 + .../egg-object-lifecycle-proto/Foo.ts | 12 + .../FooEggObjectHook.ts | 12 + .../egg-object-lifecycle-proto/package.json | 7 + .../egg-prototype-lifecycle-proto/Foo.ts | 12 + .../FooEggPrototypeHook.ts | 14 ++ .../package.json | 7 + .../test/fixtures/inner-object-proto/foo.ts | 15 ++ .../fixtures/inner-object-proto/innerBar.ts | 16 ++ .../fixtures/inner-object-proto/package.json | 7 + .../invalid-inner-object-inject/foo.ts | 15 ++ .../invalid-inner-object-inject/innerBar.ts | 4 + .../invalid-inner-object-inject/package.json | 7 + .../load-unit-instance-lifecycle-proto/Foo.ts | 12 + .../FooLoadUnitInstanceHook.ts | 14 ++ .../package.json | 7 + .../fixtures/load-unit-lifecycle-proto/Foo.ts | 8 + .../FooLoadUnitHook.ts | 28 +++ .../load-unit-lifecycle-proto/Runner.ts | 13 ++ .../load-unit-lifecycle-proto/package.json | 7 + tegg/standalone/standalone/test/index.test.ts | 16 +- 32 files changed, 527 insertions(+), 310 deletions(-) rename tegg/standalone/standalone/src/{Runner.ts => StandaloneApp.ts} (61%) delete mode 100644 tegg/standalone/standalone/src/StandaloneInnerObject.ts delete mode 100644 tegg/standalone/standalone/src/StandaloneInnerObjectProto.ts delete mode 100644 tegg/standalone/standalone/src/StandaloneLoadUnit.ts create mode 100644 tegg/standalone/standalone/test/ModulePlugin.test.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/Foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/FooEggContextHook.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-context-lifecycle-proto/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/Foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/FooEggObjectHook.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-object-lifecycle-proto/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/Foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/FooEggPrototypeHook.ts create mode 100644 tegg/standalone/standalone/test/fixtures/egg-prototype-lifecycle-proto/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/inner-object-proto/foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/inner-object-proto/innerBar.ts create mode 100644 tegg/standalone/standalone/test/fixtures/inner-object-proto/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/innerBar.ts create mode 100644 tegg/standalone/standalone/test/fixtures/invalid-inner-object-inject/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/Foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/FooLoadUnitInstanceHook.ts create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-instance-lifecycle-proto/package.json create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/Runner.ts create mode 100644 tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/package.json diff --git a/tegg/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index 8a279a5984..295efd33ef 100644 --- a/tegg/standalone/standalone/package.json +++ b/tegg/standalone/standalone/package.json @@ -46,6 +46,7 @@ "@eggjs/aop-runtime": "workspace:*", "@eggjs/dal-plugin": "workspace:*", "@eggjs/lifecycle": "workspace:*", + "@eggjs/loader-fs": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg": "workspace:*", "@eggjs/tegg-common-util": "workspace:*", 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/StandaloneApp.ts similarity index 61% rename from tegg/standalone/standalone/src/Runner.ts rename to tegg/standalone/standalone/src/StandaloneApp.ts index f1ca5e734b..3794424d98 100644 --- a/tegg/standalone/standalone/src/Runner.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -13,6 +13,7 @@ import { TableModelManager, TransactionPrototypeHook, } from '@eggjs/dal-plugin'; +import type { LoaderFS } from '@eggjs/loader-fs'; import { type EggPrototype, EggPrototypeFactory, @@ -23,27 +24,23 @@ import { LoadUnitLifecycleUtil, LoadUnitMultiInstanceProtoHook, } from '@eggjs/metadata'; -import { - type EggProtoImplClass, - type ModuleConfigHolder, - ModuleConfigs, - ConfigSourceQualifierAttribute, - type Logger, -} from '@eggjs/tegg'; +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, EggObjectLifecycleUtil, + type InnerObject, + InnerObjectLoadUnitBuilder, type LoadUnitInstance, LoadUnitInstanceFactory, - ModuleLoadUnitInstance, } from '@eggjs/tegg-runtime'; import { TeggScope } from '@eggjs/tegg-types'; import type { TeggScopeBag } from '@eggjs/tegg-types'; @@ -54,13 +51,12 @@ 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 { +export interface StandaloneAppOptions { /** * @deprecated * use inner object handlers instead @@ -70,16 +66,31 @@ export interface RunnerOptions { name?: string; innerObjectHandlers?: Record; dependencies?: (string | ModuleDependency)[]; + /** + * 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; + /** + * 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 Runner { +export class StandaloneApp { readonly cwd: string; readonly moduleReferences: readonly ModuleReference[]; readonly moduleConfigs: Record; readonly env?: string; readonly name?: string; - readonly options?: RunnerOptions; + readonly options?: StandaloneAppOptions; private loadUnitLoader: EggModuleLoader; private runnerProto: EggPrototype; private configSourceEggPrototypeHook: ConfigSourceLoadUnitHook; @@ -97,11 +108,11 @@ export class Runner { 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. + // 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(cwd: string, options?: RunnerOptions) { + constructor(cwd: string, options?: StandaloneAppOptions) { this.cwd = cwd; this.env = options?.env; this.name = options?.name; @@ -109,23 +120,27 @@ export class Runner { this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); try { - this.moduleReferences = Runner.getModuleReferences(this.cwd, options?.dependencies); + this.moduleReferences = StandaloneApp.getModuleReferences( + this.cwd, + options?.dependencies, + options?.frameworkDeps, + ); 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. + // never-returned app 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. */ + /** 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); } - private initInnerObjectsAndConfigs(options?: RunnerOptions): void { + private initInnerObjectsAndConfigs(options?: StandaloneAppOptions): void { this.innerObjects = { moduleConfigs: [ { @@ -153,8 +168,8 @@ export class Runner { ]; // 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. + // 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 = ['module.default', `module.${this.env}`]; for (const reference of this.moduleReferences) { const absoluteRef = { @@ -193,32 +208,13 @@ export class Runner { } } - 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); + static getModuleReferences( + cwd: string, + dependencies?: StandaloneAppOptions['dependencies'], + frameworkDeps?: StandaloneAppOptions['frameworkDeps'], + ): readonly ModuleReference[] { + // framework deps first so their modules are scanned ahead of app modules + const moduleDirs = (frameworkDeps || []).concat(dependencies || []).concat(cwd); return moduleDirs.reduce( (list, baseDir) => { const module = typeof baseDir === 'string' ? { baseDir } : baseDir; @@ -228,8 +224,12 @@ export class Runner { ); } - static async preLoad(cwd: string, dependencies?: RunnerOptions['dependencies']): Promise { - const moduleReferences = Runner.getModuleReferences(cwd, dependencies); + static async preLoad( + cwd: string, + dependencies?: StandaloneAppOptions['dependencies'], + frameworkDeps?: StandaloneAppOptions['frameworkDeps'], + ): Promise { + const moduleReferences = StandaloneApp.getModuleReferences(cwd, dependencies, frameworkDeps); await EggModuleLoader.preLoad(moduleReferences, { baseDir: cwd, logger: console, @@ -237,19 +237,45 @@ export class Runner { }); } - private async initLoaderInstance() { + /** + * 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: console, + baseDir: cwd, + dump: false, + loaderFS: options?.loaderFS, + }); + await loader.init(); + return EggModuleLoader.buildTeggManifestData(moduleReferences, loader.moduleDescriptors); + }); + } + + private async initLoaderInstance(): Promise { 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, + manifest: this.options?.manifest, + loaderFS: this.options?.loaderFS, }); await this.loadUnitLoader.init(); + // The graph exists (nodes only) and build() has not run yet, so build hooks + // registered here — or declaratively by lifecycle protos instantiated in + // the InnerObjectLoadUnit below — all land before their consumption point. GlobalGraph.instance!.registerBuildHook(crossCutGraphHook); GlobalGraph.instance!.registerBuildHook(pointCutGraphHook); const configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); LoadUnitLifecycleUtil.registerLifecycle(configSourceEggPrototypeHook); - // TODO refactor with egg module + // TODO(PR4): revamp the manual registrations below to module plugins + // (@XxxLifecycleProto) inside their own packages. // aop runtime this.crosscutAdviceFactory = new CrosscutAdviceFactory(); this.loadUnitAopHook = new LoadUnitAopHook(this.crosscutAdviceFactory); @@ -274,28 +300,58 @@ export class Runner { LoadUnitLifecycleUtil.registerLifecycle(this.dalModuleLoadUnitHook); } + /** + * 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. + */ + private 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. */ + private 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); + } + } + + private 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(): 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; + await this.instantiateInnerObjectLoadUnit(); + await this.instantiateModuleLoadUnits(); + this.initRunner(); }); } @@ -328,19 +384,22 @@ export class Runner { 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. + // (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) { + 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); } } 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..8f6ba65fdd 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -1,8 +1,8 @@ -import { Runner, type RunnerOptions } from './Runner.ts'; +import { StandaloneApp, type StandaloneAppOptions } from './StandaloneApp.ts'; -export async function preLoad(cwd: string, dependencies?: RunnerOptions['dependencies']): Promise { +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,25 +11,25 @@ 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 main(cwd: string, options?: StandaloneAppOptions): Promise { + const app = new StandaloneApp(cwd, options); try { - await runner.init(); + await app.init(); } 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(); } finally { - runner.destroy().catch((e) => { + app.destroy().catch((e) => { e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; console.warn(e); }); 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/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index ffb3a96dc7..b3eea5b36f 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'; @@ -69,7 +69,7 @@ describe('standalone/standalone/test/index.test.ts', () => { describe('runner with custom context', () => { it('should work', async () => { - const runner = new Runner(path.join(__dirname, './fixtures/custom-context')); + const runner = new StandaloneApp(path.join(__dirname, './fixtures/custom-context')); await runner.init(); const ctx = new StandaloneContext(); ctx.set('foo', 'foo'); @@ -210,7 +210,7 @@ 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(fixturePath); await assert.rejects( runner.init(), /EggPrototypeNotFound: Object doesNotExist not found in LOAD_UNIT:invalidInject/, @@ -232,15 +232,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')); + runner = new StandaloneApp(path.join(__dirname, './fixtures/simple')); await runner.init(); - const loadunits = await runner.load(); + const loadunits = runner.loadUnits; for (const loadunit of loadunits) { for (const proto of loadunit.iterateEggPrototype()) { if (proto.id.match(/:hello$/)) { @@ -255,9 +255,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')); + runner = new StandaloneApp(path.join(__dirname, './fixtures/multi-callback-instance-module')); await runner.init(); - const loadunits = await runner.load(); + const loadunits = runner.loadUnits; for (const loadunit of loadunits) { for (const proto of loadunit.iterateEggPrototype()) { if (proto.id.match(/:dynamicLogger$/)) { From ff97944ad69b0283cffd60b242caa2226cec4c13 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 19:18:04 +0800 Subject: [PATCH 03/30] feat(tegg-plugin): instantiate InnerObjectLoadUnit in app mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the module plugin mechanism into the egg host, the part tegg#325 left out (inner object / lifecycle protos were silently ignored in app mode). ModuleHandler.init() now runs in phases: 1. EggModuleLoader.initGraph(): scan modules, create the business GlobalGraph (nodes only) and flush buffered build hooks 2. create AND instantiate the InnerObjectLoadUnit from every scanned module's innerObjectClazzList — lifecycle protos auto-register with DI wired, before any business load unit exists 3. EggModuleLoader.load(): APP load unit + build()/sort() + module load units — declarative graph build hooks registered in step 2 land before build() consumes them 4. instantiate business load units (inner unit skips egg compatible mounting) destroy() runs in reverse creation order so lifecycle protos stay registered until every object they may hook is torn down. The same module plugin package now behaves identically under the egg host and the standalone host. Covered by a fixture app whose module provides @InnerObjectProto/@LoadUnitLifecycleProto/@EggObjectLifecycleProto classes; MultiApp isolation regression stays green. Co-Authored-By: Claude Fable 5 --- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 18 ++++++- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 47 +++++++++++++++-- tegg/plugin/tegg/test/ModulePlugin.test.ts | 51 +++++++++++++++++++ .../config/config.default.ts | 7 +++ .../apps/module-plugin-app/config/module.json | 5 ++ .../apps/module-plugin-app/config/plugin.ts | 15 ++++++ .../modules/plugin-module/HelloService.ts | 20 ++++++++ .../modules/plugin-module/InnerRegistry.ts | 22 ++++++++ .../modules/plugin-module/PluginHooks.ts | 30 +++++++++++ .../modules/plugin-module/package.json | 7 +++ .../apps/module-plugin-app/package.json | 4 ++ 11 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 tegg/plugin/tegg/test/ModulePlugin.test.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/config.default.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/module.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/config/plugin.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/InnerRegistry.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/PluginHooks.ts create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/package.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 85f67b4958..9af281ecf8 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -11,6 +11,7 @@ export class EggModuleLoader { app: Application; globalGraph: GlobalGraph; private pendingBuildHooks: GlobalGraphBuildHook[] = []; + #moduleDescriptors: readonly ModuleDescriptor[] = []; /** * True when the app graph was built from a tegg manifest (bundle mode). In * that case the module source files do not exist on disk, so module load @@ -52,6 +53,7 @@ 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) { @@ -133,11 +135,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..55ae37d98c 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -1,6 +1,11 @@ import { EggLoadUnitType, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import type { GlobalGraphBuildHook } from '@eggjs/metadata'; -import { type LoadUnitInstance, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { + INNER_OBJECT_LOAD_UNIT_TYPE, + InnerObjectLoadUnitBuilder, + type LoadUnitInstance, + LoadUnitInstanceFactory, +} from '@eggjs/tegg-runtime'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; @@ -25,6 +30,27 @@ 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) { + builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { + name: moduleDescriptor.name, + path: moduleDescriptor.unitPath, + }); + } + const innerObjectLoadUnit = await builder.createLoadUnit({ + innerObjects: {}, + }); + this.loadUnits.push(innerObjectLoadUnit); + return await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); + } + async init(): Promise { try { this.app.eggPrototypeCreatorFactory.registerPrototypeCreator( @@ -32,18 +58,26 @@ 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; for (const loadUnit of this.loadUnits) { + if (loadUnit.type === INNER_OBJECT_LOAD_UNIT_TYPE) { + continue; + } const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); if (instance.loadUnit.type !== EggLoadUnitType.APP) { CompatibleUtil.appCompatible(this.app, instance); } instances.push(instance); } - CompatibleUtil.contextModuleCompatible(this.app.context, instances); + CompatibleUtil.contextModuleCompatible( + this.app.context, + instances.filter((instance) => instance.loadUnit.type !== INNER_OBJECT_LOAD_UNIT_TYPE), + ); this.loadUnitInstances = instances; this.ready(true); } catch (e) { @@ -53,13 +87,16 @@ 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); } } 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/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" +} From b386da48270d592fbdfd0ac0f47ec9c874d57338 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 20:09:32 +0800 Subject: [PATCH 04/30] feat(tegg): convert built-in framework hooks to module plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the module plugin bootstrap (the #handleCompatibility TODO from tegg#325): every hook the standalone host used to hand-register is now a declarative module plugin class, shared verbatim by both hosts. - aop: LoadUnitAopHook / EggPrototypeCrossCutHook / EggObjectAopHook are @XxxLifecycleProto classes injecting an @InnerObjectProto CrosscutAdviceFactory (optional constructor kept for manual paths); the crossCut/pointCut graph build hooks are registered by the new AopGraphHookRegistrar from @LifecyclePostInject — instantiated after graph creation and before build(), the only valid window. Exported as AOP_INNER_OBJECT_CLAZZ_LIST. - dal: the three hooks are @XxxLifecycleProto classes injecting the host-provided moduleConfigs / runtimeConfig / logger inner objects instead of constructor args. Exported as DAL_INNER_OBJECT_CLAZZ_LIST. - config source: both ConfigSourceLoadUnitHook copies decorated with @LoadUnitLifecycleProto. - LoadUnitMultiInstanceProtoHook registration dropped on both hosts — its preCreate is empty and the static set has no consumers. Host wiring: - ModuleHandler.registerInnerObjectClazzList() buffers plugin-provided classes (aop/dal boots now register one list in configDidLoad instead of constructing and registering hooks); the egg host provides moduleConfigs/runtimeConfig/logger as PRIVATE inner objects so they stay visible to inner objects only and never pollute cross-unit resolution (InnerObject/ProvidedInnerObjectProto gain accessLevel). - StandaloneApp feeds the built-in lists, always provides a logger inner object, and drops all manual register/delete bookkeeping — lifecycle protos deregister with the InnerObjectLoadUnit. - InnerObjectLoadUnitBuilder dedupes by class: hosts hard-feed built-in lists while the owning package may also be scanned as an eggModule (e.g. @eggjs/dal-plugin as a module dependency) — first add wins. - test-util LoaderUtil mirrors the production loader and diverts inner object classes out of module load units. Co-Authored-By: Claude Fable 5 --- .../src/CrosscutAdviceFactory.ts | 2 + tegg/core/aop-runtime/package.json | 1 + .../aop-runtime/src/AopGraphHookRegistrar.ts | 27 +++++ .../src/AopInnerObjectClazzList.ts | 26 +++++ tegg/core/aop-runtime/src/EggObjectAopHook.ts | 3 +- .../src/EggPrototypeCrossCutHook.ts | 10 +- tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 10 +- tegg/core/aop-runtime/src/index.ts | 2 + .../test/__snapshots__/index.test.ts.snap | 12 ++ .../runtime/src/impl/InnerObjectLoadUnit.ts | 19 +++- .../src/impl/InnerObjectLoadUnitBuilder.ts | 10 ++ .../src/impl/ProvidedInnerObjectProto.ts | 3 +- .../tegg/test/__snapshots__/dal.test.ts.snap | 9 ++ .../test/__snapshots__/exports.test.ts.snap | 8 ++ .../test/__snapshots__/helper.test.ts.snap | 12 ++ tegg/core/test-util/src/LoaderUtil.ts | 5 + tegg/plugin/aop/src/app.ts | 45 ++------ tegg/plugin/dal/src/app.ts | 31 ++--- tegg/plugin/dal/src/index.ts | 1 + .../dal/src/lib/DalInnerObjectClazzList.ts | 21 ++++ .../dal/src/lib/DalModuleLoadUnitHook.ts | 22 ++-- .../dal/src/lib/DalTableEggPrototypeHook.ts | 7 +- .../dal/src/lib/TransactionPrototypeHook.ts | 17 +-- tegg/plugin/tegg/src/app.ts | 24 ++-- .../tegg/src/lib/ConfigSourceLoadUnitHook.ts | 2 + tegg/plugin/tegg/src/lib/ModuleHandler.ts | 42 ++++++- .../src/ConfigSourceLoadUnitHook.ts | 2 + .../standalone/src/StandaloneApp.ts | 106 +++--------------- 28 files changed, 286 insertions(+), 193 deletions(-) create mode 100644 tegg/core/aop-runtime/src/AopGraphHookRegistrar.ts create mode 100644 tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts create mode 100644 tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts 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..a3ec81cdbc 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:*", 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/AopInnerObjectClazzList.ts b/tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts new file mode 100644 index 0000000000..912794f954 --- /dev/null +++ b/tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts @@ -0,0 +1,26 @@ +import { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +import { AopGraphHookRegistrar } from './AopGraphHookRegistrar.js'; +import { EggObjectAopHook } from './EggObjectAopHook.js'; +import { EggPrototypeCrossCutHook } from './EggPrototypeCrossCutHook.js'; +import { LoadUnitAopHook } from './LoadUnitAopHook.js'; + +/** + * The AOP module plugin: feed this list into the InnerObjectLoadUnit builder + * (standalone: StandaloneApp built-ins; egg: aop plugin boot via + * moduleHandler.registerInnerObjectClazzList) instead of hand-registering each + * hook on the host. + */ +export const AOP_INNER_OBJECT_CLAZZ_LIST: readonly EggProtoImplClass[] = [ + CrosscutAdviceFactory, + LoadUnitAopHook, + EggPrototypeCrossCutHook, + EggObjectAopHook, + AopGraphHookRegistrar, +]; + +export const AOP_INNER_OBJECT_MODULE_REFERENCE = { + name: 'teggAop', + path: 'tegg:aop-runtime', +}; 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..1fbbab98b4 100644 --- a/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts +++ b/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts @@ -1,11 +1,17 @@ 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; + // Optional manual-construction path (tests / legacy hosts); DI overrides it. + constructor(crosscutAdviceFactory?: CrosscutAdviceFactory) { + if (crosscutAdviceFactory) { + this.crosscutAdviceFactory = crosscutAdviceFactory; + } } async preCreate(ctx: EggPrototypeLifecycleContext): Promise { diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 6d8ff70f06..78f51caaeb 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,11 +9,16 @@ import type { LoadUnitLifecycleContext, } from '@eggjs/tegg-types'; +@LoadUnitLifecycleProto() export class LoadUnitAopHook implements LifecycleHook { + @Inject() private readonly crosscutAdviceFactory: CrosscutAdviceFactory; - constructor(crosscutAdviceFactory: CrosscutAdviceFactory) { - this.crosscutAdviceFactory = crosscutAdviceFactory; + // Optional manual-construction path (tests / legacy hosts); DI overrides it. + constructor(crosscutAdviceFactory?: CrosscutAdviceFactory) { + if (crosscutAdviceFactory) { + this.crosscutAdviceFactory = crosscutAdviceFactory; + } } async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { diff --git a/tegg/core/aop-runtime/src/index.ts b/tegg/core/aop-runtime/src/index.ts index a115cb43f9..f92708936b 100644 --- a/tegg/core/aop-runtime/src/index.ts +++ b/tegg/core/aop-runtime/src/index.ts @@ -4,3 +4,5 @@ export * from './EggObjectAopHook.js'; export * from './EggPrototypeCrossCutHook.js'; export * from './LoadUnitAopHook.js'; export * from './PointCutGraphHook.js'; +export * from './AopGraphHookRegistrar.js'; +export * from './AopInnerObjectClazzList.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..3c1a41c3e3 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,18 @@ exports[`should export stable 1`] = ` { + "AOP_INNER_OBJECT_CLAZZ_LIST": [ + [Function], + [Function], + [Function], + [Function], + [Function], + ], + "AOP_INNER_OBJECT_MODULE_REFERENCE": { + "name": "teggAop", + "path": "tegg:aop-runtime", + }, + "AopGraphHookRegistrar": [Function], "AspectExecutor": [Function], "EggObjectAopHook": [Function], "EggPrototypeCrossCutHook": [Function], diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts index 84b8872bc0..35df54e316 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts @@ -1,7 +1,14 @@ import { IdenticalUtil } from '@eggjs/lifecycle'; import { ClassProtoDescriptor, EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; import { MapUtil } from '@eggjs/tegg-common-util'; -import type { EggPrototype, EggPrototypeName, LoadUnit, ProtoDescriptor, QualifierInfo } from '@eggjs/tegg-types'; +import type { + AccessLevel, + EggPrototype, + EggPrototypeName, + LoadUnit, + ProtoDescriptor, + QualifierInfo, +} from '@eggjs/tegg-types'; import { ObjectInitType } from '@eggjs/tegg-types'; import { ProvidedInnerObjectProto } from './ProvidedInnerObjectProto.ts'; @@ -13,6 +20,13 @@ 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 { @@ -54,7 +68,7 @@ export class InnerObjectLoadUnit implements LoadUnit { async init(): Promise { for (const [name, objs] of Object.entries(this.#innerObjects)) { - for (const { obj, qualifiers } of objs) { + for (const { obj, qualifiers, accessLevel } of objs) { const proto = new ProvidedInnerObjectProto( IdenticalUtil.createProtoId(this.id, name), name, @@ -62,6 +76,7 @@ export class InnerObjectLoadUnit implements LoadUnit { ObjectInitType.SINGLETON, this.id, qualifiers || [], + accessLevel, ); EggPrototypeFactory.instance.registerPrototype(proto, this); } diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index 160954d764..bb6c987753 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -42,9 +42,19 @@ export interface CreateInnerObjectLoadUnitOptions { */ export class InnerObjectLoadUnitBuilder { readonly #protoGraph: Graph = new Graph(); + readonly #seenClazzSet: Set = new Set(); addInnerObjectClazzList(clazzList: readonly EggProtoImplClass[], moduleReference: InnerObjectModuleReference): void { for (const clazz of clazzList) { + // The same class may arrive twice — hosts hard-feed built-in framework + // lists unconditionally, and the owning package may also be scanned as an + // eggModule (e.g. @eggjs/dal-plugin declared as a module dependency). + // First registration wins; a DIFFERENT class with a colliding proto id + // still fails below. + if (this.#seenClazzSet.has(clazz)) { + continue; + } + this.#seenClazzSet.add(clazz); const descriptor = ProtoDescriptorHelper.createByInstanceClazz(clazz, { moduleName: INNER_OBJECT_LOAD_UNIT_NAME, unitPath: INNER_OBJECT_LOAD_UNIT_PATH, diff --git a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts index 6547e67514..e5f9dbfcb4 100644 --- a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -42,12 +42,13 @@ export class ProvidedInnerObjectProto implements EggPrototype { initType: ObjectInitTypeLike, loadUnitId: Id, qualifiers: QualifierInfo[], + accessLevel?: AccessLevel, ) { this.id = id; this.clazz = clazz; this.name = name; this.initType = initType; - this.accessLevel = AccessLevel.PUBLIC; + this.accessLevel = accessLevel ?? AccessLevel.PUBLIC; this.injectObjects = []; this.loadUnitId = loadUnitId; this.qualifiers = qualifiers; 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..163ac74892 100644 --- a/tegg/core/test-util/src/LoaderUtil.ts +++ b/tegg/core/test-util/src/LoaderUtil.ts @@ -76,6 +76,11 @@ export class LoaderUtil { const clazzList = await loader.load(); const eggProtoClass: EggProtoImplClass[] = []; for (const clazz of clazzList) { + // Inner object protos are diverted out of module load units by the + // production loader (LoaderFactory.loadApp); mirror that here. + if (PrototypeUtil.isEggInnerObject(clazz)) { + continue; + } if (PrototypeUtil.isEggPrototype(clazz)) { eggProtoClass.push(clazz); } diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index b111456b0a..676a5ee3e1 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -1,13 +1,6 @@ import assert from 'node:assert'; -import { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; -import { - crossCutGraphHook, - EggObjectAopHook, - EggPrototypeCrossCutHook, - LoadUnitAopHook, - pointCutGraphHook, -} from '@eggjs/aop-runtime'; +import { AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE } from '@eggjs/aop-runtime'; import { GlobalGraph } from '@eggjs/metadata'; import type { Application, ILifecycleBoot } from 'egg'; @@ -15,48 +8,34 @@ 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. + this.app.moduleHandler.registerInnerObjectClazzList(AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE); } 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'); + // AopContextHook snapshots moduleHandler.loadUnitInstances, so it must stay + // registered AFTER init — it is a per-request ctx hook, late 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/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index 56a53f32ce..137e0b04f4 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,43 +1,28 @@ 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 { DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE } from './lib/DalInnerObjectClazzList.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. + this.app.moduleHandler.registerInnerObjectClazzList(DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE); } 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/index.ts b/tegg/plugin/dal/src/index.ts index 7caa89bffd..2072b921ef 100644 --- a/tegg/plugin/dal/src/index.ts +++ b/tegg/plugin/dal/src/index.ts @@ -1,5 +1,6 @@ import './types.ts'; +export * from './lib/DalInnerObjectClazzList.ts'; export * from './lib/DalModuleLoadUnitHook.ts'; export * from './lib/DalTableEggPrototypeHook.ts'; export * from './lib/DataSource.ts'; diff --git a/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts b/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts new file mode 100644 index 0000000000..b294c83e4c --- /dev/null +++ b/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts @@ -0,0 +1,21 @@ +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +import { DalModuleLoadUnitHook } from './DalModuleLoadUnitHook.ts'; +import { DalTableEggPrototypeHook } from './DalTableEggPrototypeHook.ts'; +import { TransactionPrototypeHook } from './TransactionPrototypeHook.ts'; + +/** + * The DAL module plugin: feed this list into the InnerObjectLoadUnit builder + * instead of hand-registering each hook on the host. The hooks inject the + * host-provided `moduleConfigs` / `runtimeConfig` / `logger` inner objects. + */ +export const DAL_INNER_OBJECT_CLAZZ_LIST: readonly EggProtoImplClass[] = [ + DalModuleLoadUnitHook, + DalTableEggPrototypeHook, + TransactionPrototypeHook, +]; + +export const DAL_INNER_OBJECT_MODULE_REFERENCE = { + name: 'teggDal', + path: 'tegg:dal-plugin', +}; diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index a00eea2097..9212c9e003 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -1,23 +1,29 @@ +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'; +@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; 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 { @@ -55,16 +59,10 @@ export default class TeggAppBoot implements ILifecycleBoot { // Load tegg objects within this app's factory scope so every factory/graph/ // lifecycle-util mutation during boot reads/writes the per-app slots. await TeggScope.run(this.app._teggScopeBag, async () => { - this.loadUnitMultiInstanceProtoHook = new LoadUnitMultiInstanceProtoHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.loadUnitMultiInstanceProtoHook); - // wait all file loaded, so app/ctx has all properties this.eggQualifierProtoHook = new EggQualifierProtoHook(this.app); this.app.loadUnitLifecycleUtil.registerLifecycle(this.eggQualifierProtoHook); - this.configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); - this.app.loadUnitLifecycleUtil.registerLifecycle(this.configSourceEggPrototypeHook); - // start load tegg objects await this.app.moduleHandler.init(); this.compatibleHook = new EggContextCompatibleHook(this.app.moduleHandler); @@ -89,14 +87,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/ConfigSourceLoadUnitHook.ts b/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts index 8b0de193dc..8eb3289b8d 100644 --- a/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts +++ b/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts @@ -1,4 +1,5 @@ import { + LoadUnitLifecycleProto, PrototypeUtil, QualifierUtil, ConfigSourceQualifier, @@ -12,6 +13,7 @@ import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; * Hook for inject moduleConfig. * Add default qualifier value is current module 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/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index 55ae37d98c..b0681fdb8b 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -1,11 +1,14 @@ import { EggLoadUnitType, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import type { GlobalGraphBuildHook } from '@eggjs/metadata'; +import { ModuleConfigs } from '@eggjs/tegg-common-util'; import { INNER_OBJECT_LOAD_UNIT_TYPE, InnerObjectLoadUnitBuilder, + type InnerObjectModuleReference, type LoadUnitInstance, LoadUnitInstanceFactory, } from '@eggjs/tegg-runtime'; +import { AccessLevel, type EggProtoImplClass } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; @@ -30,6 +33,24 @@ export class ModuleHandler extends Base { this.loadUnitLoader.registerBuildHook(hook); } + readonly #innerObjectClazzRegistrations: Array<{ + clazzList: readonly EggProtoImplClass[]; + moduleReference: InnerObjectModuleReference; + }> = []; + + /** + * Buffer framework module plugin classes (`@InnerObjectProto` / + * `@XxxLifecycleProto`) provided by other egg plugins. They are instantiated + * in the InnerObjectLoadUnit during init(), before any business load unit is + * created. Call from configDidLoad or the synchronous part of didLoad. + */ + registerInnerObjectClazzList( + clazzList: readonly EggProtoImplClass[], + moduleReference: InnerObjectModuleReference, + ): void { + this.#innerObjectClazzRegistrations.push({ clazzList, moduleReference }); + } + /** * Create AND instantiate the InnerObjectLoadUnit before the business graph * is built, so `@XxxLifecycleProto` hooks provided by module plugins @@ -38,6 +59,9 @@ export class ModuleHandler extends Base { */ private async instantiateInnerObjectLoadUnit(): Promise { const builder = new InnerObjectLoadUnitBuilder(); + for (const { clazzList, moduleReference } of this.#innerObjectClazzRegistrations) { + builder.addInnerObjectClazzList(clazzList, moduleReference); + } for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, @@ -45,7 +69,23 @@ export class ModuleHandler extends Base { }); } const innerObjectLoadUnit = await builder.createLoadUnit({ - innerObjects: {}, + // Base host objects for framework hooks. 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.loadUnits.push(innerObjectLoadUnit); return await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); diff --git a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts b/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts index afcdef0234..4e378ec40d 100644 --- a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts +++ b/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts @@ -1,6 +1,7 @@ import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; import { type LifecycleHook, + LoadUnitLifecycleProto, PrototypeUtil, QualifierUtil, ConfigSourceQualifier, @@ -11,6 +12,7 @@ import { * Hook for inject moduleConfig. * Add default qualifier value is current module name. */ +@LoadUnitLifecycleProto() export class ConfigSourceLoadUnitHook implements LifecycleHook { async preCreate(ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { const classList = await ctx.loader.load(); diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 3794424d98..4eaa9a301b 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -1,29 +1,13 @@ +import { AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE } from '@eggjs/aop-runtime'; import { - crossCutGraphHook, - EggObjectAopHook, - EggPrototypeCrossCutHook, - LoadUnitAopHook, - pointCutGraphHook, -} from '@eggjs/aop-runtime'; -import { - DalTableEggPrototypeHook, - DalModuleLoadUnitHook, + DAL_INNER_OBJECT_CLAZZ_LIST, + DAL_INNER_OBJECT_MODULE_REFERENCE, MysqlDataSourceManager, SqlMapManager, TableModelManager, - TransactionPrototypeHook, } from '@eggjs/dal-plugin'; import type { LoaderFS } from '@eggjs/loader-fs'; -import { - type EggPrototype, - EggPrototypeFactory, - EggPrototypeLifecycleUtil, - GlobalGraph, - type LoadUnit, - LoadUnitFactory, - LoadUnitLifecycleUtil, - LoadUnitMultiInstanceProtoHook, -} from '@eggjs/metadata'; +import { type EggPrototype, EggPrototypeFactory, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import { type ModuleConfigHolder, ModuleConfigs, ConfigSourceQualifierAttribute, type Logger } from '@eggjs/tegg'; import { ModuleConfigUtil, @@ -36,7 +20,6 @@ import { ContextHandler, EggContainerFactory, type EggContext, - EggObjectLifecycleUtil, type InnerObject, InnerObjectLoadUnitBuilder, type LoadUnitInstance, @@ -44,7 +27,6 @@ import { } 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'; @@ -93,16 +75,6 @@ export class StandaloneApp { readonly options?: StandaloneAppOptions; 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[] = []; @@ -206,6 +178,8 @@ export class StandaloneApp { } else if (options?.innerObjectHandlers) { Object.assign(this.innerObjects, options.innerObjectHandlers); } + // Framework hooks (e.g. DAL) inject `logger`; make sure it always resolves. + this.innerObjects.logger ??= [{ obj: console }]; } static getModuleReferences( @@ -266,38 +240,6 @@ export class StandaloneApp { loaderFS: this.options?.loaderFS, }); await this.loadUnitLoader.init(); - // The graph exists (nodes only) and build() has not run yet, so build hooks - // registered here — or declaratively by lifecycle protos instantiated in - // the InnerObjectLoadUnit below — all land before their consumption point. - GlobalGraph.instance!.registerBuildHook(crossCutGraphHook); - GlobalGraph.instance!.registerBuildHook(pointCutGraphHook); - const configSourceEggPrototypeHook = new ConfigSourceLoadUnitHook(); - LoadUnitLifecycleUtil.registerLifecycle(configSourceEggPrototypeHook); - - // TODO(PR4): revamp the manual registrations below to module plugins - // (@XxxLifecycleProto) inside their own packages. - // 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); } /** @@ -308,6 +250,13 @@ export class StandaloneApp { private async instantiateInnerObjectLoadUnit(): Promise { StandaloneContextHandler.register(); const builder = new InnerObjectLoadUnitBuilder(); + // Built-in framework module plugins (declarative hooks in their own packages). + builder.addInnerObjectClazzList([ConfigSourceLoadUnitHook], { + name: 'standalone', + path: 'tegg:standalone', + }); + builder.addInnerObjectClazzList(AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE); + builder.addInnerObjectClazzList(DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE); for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, @@ -403,33 +352,8 @@ export class StandaloneApp { 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); - } + // Framework hooks (ConfigSource/AOP/DAL) live in the InnerObjectLoadUnit + // and deregister themselves when it is destroyed above. MysqlDataSourceManager.instance.clear(); SqlMapManager.instance.clear(); TableModelManager.instance.clear(); From e0ad514a05bd07540ae6ae3a3d3148f4a0b77fb5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 20:10:42 +0800 Subject: [PATCH 05/30] docs(wiki): record tegg module plugin architecture Co-Authored-By: Claude Fable 5 --- wiki/concepts/tegg-module-plugin.md | 79 +++++++++++++++++++++++++++++ wiki/index.md | 1 + wiki/log.md | 6 +++ 3 files changed, 86 insertions(+) create mode 100644 wiki/concepts/tegg-module-plugin.md 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. From f4267dfe89f95ae2e5fc45af625de75a847bcfd5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 18:10:04 +0800 Subject: [PATCH 06/30] refactor(tegg): address module-plugin review - dedupe hook, untangle inner unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the module plugin PR: - ConfigSourceLoadUnitHook existed twice (standalone + egg plugin copy) — exactly the duplication the module plugin mechanism is meant to end. Move the single host-agnostic class to @eggjs/metadata (hook/); both hosts now feed the same import into their inner-object clazz lists. - ModuleHandler tracked the inner-object load unit inside the business loadUnits list, forcing INNER_OBJECT_LOAD_UNIT_TYPE filters in the init loop and contextModuleCompatible. Track it in its own field: business loops need no filtering, destroy still tears it down last (its lifecycle protos must outlive every hooked object). Co-Authored-By: Claude Fable 5 --- .../src/hook}/ConfigSourceLoadUnitHook.ts | 8 ++--- tegg/core/metadata/src/hook/index.ts | 1 + tegg/core/metadata/src/index.ts | 1 + .../test/__snapshots__/index.test.ts.snap | 1 + tegg/plugin/tegg/src/app.ts | 2 +- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 21 ++++++------ .../src/ConfigSourceLoadUnitHook.ts | 32 ------------------- .../standalone/src/StandaloneApp.ts | 9 ++++-- 8 files changed, 27 insertions(+), 48 deletions(-) rename tegg/{plugin/tegg/src/lib => core/metadata/src/hook}/ConfigSourceLoadUnitHook.ts (84%) create mode 100644 tegg/core/metadata/src/hook/index.ts delete mode 100644 tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts diff --git a/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts b/tegg/core/metadata/src/hook/ConfigSourceLoadUnitHook.ts similarity index 84% rename from tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts rename to tegg/core/metadata/src/hook/ConfigSourceLoadUnitHook.ts index 8eb3289b8d..b3104cc706 100644 --- a/tegg/plugin/tegg/src/lib/ConfigSourceLoadUnitHook.ts +++ b/tegg/core/metadata/src/hook/ConfigSourceLoadUnitHook.ts @@ -6,12 +6,12 @@ import { 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 { diff --git a/tegg/core/metadata/src/hook/index.ts b/tegg/core/metadata/src/hook/index.ts new file mode 100644 index 0000000000..3ea006e9e2 --- /dev/null +++ b/tegg/core/metadata/src/hook/index.ts @@ -0,0 +1 @@ +export * from './ConfigSourceLoadUnitHook.ts'; diff --git a/tegg/core/metadata/src/index.ts b/tegg/core/metadata/src/index.ts index 592e38bcc2..0df2d578ab 100644 --- a/tegg/core/metadata/src/index.ts +++ b/tegg/core/metadata/src/index.ts @@ -5,3 +5,4 @@ export * from './model/index.ts'; export * from './errors.ts'; export * from './util/index.ts'; export * from './impl/index.ts'; +export * from './hook/index.ts'; diff --git a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap index 90d0af85cf..958f8a9e9f 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], + "ConfigSourceLoadUnitHook": [Function], "EggInnerObjectPrototypeImpl": [Function], "EggLoadUnitType": { "APP": "APP", diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index a8225b3b7e..630592a5a5 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -2,12 +2,12 @@ import './lib/AppLoadUnit.ts'; import './lib/AppLoadUnitInstance.ts'; import './lib/EggCompatibleObject.ts'; +import { ConfigSourceLoadUnitHook } from '@eggjs/metadata'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; import { CompatibleUtil } from './lib/CompatibleUtil.ts'; -import { ConfigSourceLoadUnitHook } from './lib/ConfigSourceLoadUnitHook.ts'; import { EggContextCompatibleHook } from './lib/EggContextCompatibleHook.ts'; import { EggContextHandler } from './lib/EggContextHandler.ts'; import { EggModuleLoader } from './lib/EggModuleLoader.ts'; diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index b0681fdb8b..c48a435ab8 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -2,7 +2,6 @@ import { EggLoadUnitType, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata import type { GlobalGraphBuildHook } from '@eggjs/metadata'; import { ModuleConfigs } from '@eggjs/tegg-common-util'; import { - INNER_OBJECT_LOAD_UNIT_TYPE, InnerObjectLoadUnitBuilder, type InnerObjectModuleReference, type LoadUnitInstance, @@ -18,6 +17,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; @@ -87,7 +90,7 @@ export class ModuleHandler extends Base { logger: [{ obj: this.app.logger, accessLevel: AccessLevel.PRIVATE }], }, }); - this.loadUnits.push(innerObjectLoadUnit); + this.#innerObjectLoadUnit = innerObjectLoadUnit; return await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); } @@ -104,20 +107,16 @@ export class ModuleHandler extends Base { const instances: LoadUnitInstance[] = [innerObjectInstance]; this.app.module = {} as any; + const businessInstances: LoadUnitInstance[] = []; for (const loadUnit of this.loadUnits) { - if (loadUnit.type === INNER_OBJECT_LOAD_UNIT_TYPE) { - continue; - } 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.filter((instance) => instance.loadUnit.type !== INNER_OBJECT_LOAD_UNIT_TYPE), - ); + CompatibleUtil.contextModuleCompatible(this.app.context, businessInstances); this.loadUnitInstances = instances; this.ready(true); } catch (e) { @@ -140,5 +139,9 @@ export class ModuleHandler extends Base { await LoadUnitFactory.destroyLoadUnit(loadUnit); } } + if (this.#innerObjectLoadUnit) { + await LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit); + this.#innerObjectLoadUnit = undefined; + } } } diff --git a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts b/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts deleted file mode 100644 index 4e378ec40d..0000000000 --- a/tegg/standalone/standalone/src/ConfigSourceLoadUnitHook.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; -import { - type LifecycleHook, - LoadUnitLifecycleProto, - PrototypeUtil, - QualifierUtil, - ConfigSourceQualifier, - ConfigSourceQualifierAttribute, -} from '@eggjs/tegg'; - -/** - * Hook for inject moduleConfig. - * Add default qualifier value is current module name. - */ -@LoadUnitLifecycleProto() -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/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 4eaa9a301b..c9892c557b 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -7,7 +7,13 @@ import { TableModelManager, } from '@eggjs/dal-plugin'; import type { LoaderFS } from '@eggjs/loader-fs'; -import { type EggPrototype, EggPrototypeFactory, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; +import { + ConfigSourceLoadUnitHook, + type EggPrototype, + EggPrototypeFactory, + type LoadUnit, + LoadUnitFactory, +} from '@eggjs/metadata'; import { type ModuleConfigHolder, ModuleConfigs, ConfigSourceQualifierAttribute, type Logger } from '@eggjs/tegg'; import { ModuleConfigUtil, @@ -29,7 +35,6 @@ import { TeggScope } from '@eggjs/tegg-types'; import type { TeggScopeBag } from '@eggjs/tegg-types'; 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'; From 9eab20433b9baa786ed2efae2e8f22ec47894024 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 20:57:26 +0800 Subject: [PATCH 07/30] refactor(tegg): built-in framework hooks load through the module scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: hand-fed inner-object class lists defeated the module plugin's purpose — the packages already declare eggModule metadata, so consume them AS modules: - StandaloneApp: aop-runtime/dal-plugin join the regular module scan as built-in framework modules (their @InnerObjectProto/@XxxLifecycleProto classes are diverted into the InnerObjectLoadUnit by loadApp); the AOP_/DAL_INNER_OBJECT_CLAZZ_LIST exports are gone. Module references now dedupe by path (an app may also depend on a built-in module directly). - egg host: moduleHandler.registerInnerObjectModule(path) — the aop/dal plugins register their package as a scanned module instead of a class list; manifest collection includes registered framework modules. - aop-runtime re-exports CrosscutAdviceFactory (decorated in aop-decorator) so the scan picks it up as a module member, and no longer needs the manual list file. - LoaderUtil.filePattern: '!**/test' does not exclude descendants — add '!**/test/**' (and coverage) so scanning workspace packages that ship test dirs stays safe; built-in references also exclude test fixture modules from the reference scan. - Drop the test-only optional constructor params from LoadUnitAopHook / EggPrototypeCrossCutHook; the aop-runtime tests wire the dependency via Reflect.set on the DI property instead. registerInnerObjectClazzList stays for host-boot wiring (e.g. the shared ConfigSourceLoadUnitHook) and edge hosts. Co-Authored-By: Claude Fable 5 --- .../src/AopInnerObjectClazzList.ts | 26 ---------- .../src/EggPrototypeCrossCutHook.ts | 7 --- tegg/core/aop-runtime/src/InnerObjects.ts | 4 ++ tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 7 --- tegg/core/aop-runtime/src/index.ts | 2 +- .../test/__snapshots__/index.test.ts.snap | 12 +---- .../core/aop-runtime/test/aop-runtime.test.ts | 18 ++++--- tegg/core/loader/src/LoaderUtil.ts | 6 ++- tegg/plugin/aop/src/app.ts | 9 +++- tegg/plugin/dal/src/app.ts | 7 ++- tegg/plugin/dal/src/index.ts | 1 - .../dal/src/lib/DalInnerObjectClazzList.ts | 21 -------- tegg/plugin/tegg/src/app.ts | 5 +- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 40 ++++++++++++++-- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 17 ++++++- .../standalone/src/StandaloneApp.ts | 48 ++++++++++++++----- tegg/standalone/standalone/test/index.test.ts | 6 ++- 17 files changed, 130 insertions(+), 106 deletions(-) delete mode 100644 tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts create mode 100644 tegg/core/aop-runtime/src/InnerObjects.ts delete mode 100644 tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts diff --git a/tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts b/tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts deleted file mode 100644 index 912794f954..0000000000 --- a/tegg/core/aop-runtime/src/AopInnerObjectClazzList.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; -import type { EggProtoImplClass } from '@eggjs/tegg-types'; - -import { AopGraphHookRegistrar } from './AopGraphHookRegistrar.js'; -import { EggObjectAopHook } from './EggObjectAopHook.js'; -import { EggPrototypeCrossCutHook } from './EggPrototypeCrossCutHook.js'; -import { LoadUnitAopHook } from './LoadUnitAopHook.js'; - -/** - * The AOP module plugin: feed this list into the InnerObjectLoadUnit builder - * (standalone: StandaloneApp built-ins; egg: aop plugin boot via - * moduleHandler.registerInnerObjectClazzList) instead of hand-registering each - * hook on the host. - */ -export const AOP_INNER_OBJECT_CLAZZ_LIST: readonly EggProtoImplClass[] = [ - CrosscutAdviceFactory, - LoadUnitAopHook, - EggPrototypeCrossCutHook, - EggObjectAopHook, - AopGraphHookRegistrar, -]; - -export const AOP_INNER_OBJECT_MODULE_REFERENCE = { - name: 'teggAop', - path: 'tegg:aop-runtime', -}; diff --git a/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts b/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts index 1fbbab98b4..8f09695073 100644 --- a/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts +++ b/tegg/core/aop-runtime/src/EggPrototypeCrossCutHook.ts @@ -7,13 +7,6 @@ export class EggPrototypeCrossCutHook implements LifecycleHook { if (CrosscutInfoUtil.isCrosscutAdvice(ctx.clazz)) { this.crosscutAdviceFactory.registerCrossAdviceClazz(ctx.clazz); diff --git a/tegg/core/aop-runtime/src/InnerObjects.ts b/tegg/core/aop-runtime/src/InnerObjects.ts new file mode 100644 index 0000000000..6d98c275a2 --- /dev/null +++ b/tegg/core/aop-runtime/src/InnerObjects.ts @@ -0,0 +1,4 @@ +// Re-exported so the module scan picks CrosscutAdviceFactory up as a member +// of this module — it is decorated @InnerObjectProto in @eggjs/aop-decorator, +// but the scan only collects classes exported from this package's files. +export { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 78f51caaeb..5bd0caaeab 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -14,13 +14,6 @@ export class LoadUnitAopHook implements LifecycleHook { 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 f92708936b..bbfd459637 100644 --- a/tegg/core/aop-runtime/src/index.ts +++ b/tegg/core/aop-runtime/src/index.ts @@ -1,8 +1,8 @@ export * from './AspectExecutor.js'; +export * from './InnerObjects.js'; export * from './CrossCutGraphHook.js'; export * from './EggObjectAopHook.js'; export * from './EggPrototypeCrossCutHook.js'; export * from './LoadUnitAopHook.js'; export * from './PointCutGraphHook.js'; export * from './AopGraphHookRegistrar.js'; -export * from './AopInnerObjectClazzList.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 3c1a41c3e3..571d24fa99 100644 --- a/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap @@ -2,19 +2,9 @@ exports[`should export stable 1`] = ` { - "AOP_INNER_OBJECT_CLAZZ_LIST": [ - [Function], - [Function], - [Function], - [Function], - [Function], - ], - "AOP_INNER_OBJECT_MODULE_REFERENCE": { - "name": "teggAop", - "path": "tegg:aop-runtime", - }, "AopGraphHookRegistrar": [Function], "AspectExecutor": [Function], + "CrosscutAdviceFactory": [Function], "EggObjectAopHook": [Function], "EggPrototypeCrossCutHook": [Function], "LoadUnitAopHook": [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..726f07528c 100644 --- a/tegg/core/aop-runtime/test/aop-runtime.test.ts +++ b/tegg/core/aop-runtime/test/aop-runtime.test.ts @@ -37,8 +37,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); @@ -166,8 +168,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); @@ -193,8 +197,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); 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/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index 676a5ee3e1..83ecdd1afb 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -1,6 +1,7 @@ import assert from 'node:assert'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE } from '@eggjs/aop-runtime'; import { GlobalGraph } from '@eggjs/metadata'; import type { Application, ILifecycleBoot } from 'egg'; @@ -21,7 +22,11 @@ export default class AopAppHook implements ILifecycleBoot { // before ours) so they are instantiated inside the InnerObjectLoadUnit — // after the business GlobalGraph is created and before build() runs. // Registration/deregistration is automatic. - this.app.moduleHandler.registerInnerObjectClazzList(AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE); + // Module-plugin path: @eggjs/aop-runtime declares eggModule metadata, the + // regular module scan collects its hooks - no hand-fed class list. + this.app.moduleHandler.registerInnerObjectModule( + path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-runtime/package.json'))), + ); } async didLoad(): Promise { diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index 137e0b04f4..d7ebc4cf77 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,7 +1,8 @@ +import path from 'node:path'; + import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; -import { DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE } from './lib/DalInnerObjectClazzList.ts'; import { MysqlDataSourceManager } from './lib/MysqlDataSourceManager.ts'; import { SqlMapManager } from './lib/SqlMapManager.ts'; import { TableModelManager } from './lib/TableModelManager.ts'; @@ -19,7 +20,9 @@ export default class DalAppBootHook implements ILifecycleBoot { // 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. - this.app.moduleHandler.registerInnerObjectClazzList(DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE); + // Module-plugin path: this plugin package itself declares eggModule + // metadata (teggDal); the regular module scan collects its hooks. + this.app.moduleHandler.registerInnerObjectModule(path.join(import.meta.dirname, '..')); } async beforeClose(): Promise { diff --git a/tegg/plugin/dal/src/index.ts b/tegg/plugin/dal/src/index.ts index 2072b921ef..7caa89bffd 100644 --- a/tegg/plugin/dal/src/index.ts +++ b/tegg/plugin/dal/src/index.ts @@ -1,6 +1,5 @@ import './types.ts'; -export * from './lib/DalInnerObjectClazzList.ts'; export * from './lib/DalModuleLoadUnitHook.ts'; export * from './lib/DalTableEggPrototypeHook.ts'; export * from './lib/DataSource.ts'; diff --git a/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts b/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts deleted file mode 100644 index b294c83e4c..0000000000 --- a/tegg/plugin/dal/src/lib/DalInnerObjectClazzList.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EggProtoImplClass } from '@eggjs/tegg-types'; - -import { DalModuleLoadUnitHook } from './DalModuleLoadUnitHook.ts'; -import { DalTableEggPrototypeHook } from './DalTableEggPrototypeHook.ts'; -import { TransactionPrototypeHook } from './TransactionPrototypeHook.ts'; - -/** - * The DAL module plugin: feed this list into the InnerObjectLoadUnit builder - * instead of hand-registering each hook on the host. The hooks inject the - * host-provided `moduleConfigs` / `runtimeConfig` / `logger` inner objects. - */ -export const DAL_INNER_OBJECT_CLAZZ_LIST: readonly EggProtoImplClass[] = [ - DalModuleLoadUnitHook, - DalTableEggPrototypeHook, - TransactionPrototypeHook, -]; - -export const DAL_INNER_OBJECT_MODULE_REFERENCE = { - name: 'teggDal', - path: 'tegg:dal-plugin', -}; diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 630592a5a5..19ce0a9de0 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -72,8 +72,9 @@ 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); + const moduleReferences = this.app.moduleHandler.allModuleReferences; + const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); + EggModuleLoader.collectTeggManifest(this.app, moduleReferences, moduleDescriptors); } async beforeClose(): Promise { diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 9af281ecf8..d667200c80 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,5 +1,6 @@ import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; +import { ModuleConfigUtil } from '@eggjs/tegg-common-util'; import { LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; import type { TeggManifestExtension } from '@eggjs/tegg-loader'; import type { ModuleReference } from '@eggjs/tegg-types'; @@ -19,11 +20,38 @@ export class EggModuleLoader { * globbing the file system. */ private loadedFromManifest = false; + /** Framework module plugins registered by other egg plugins (scan path). */ + readonly #extraModuleReferences: ModuleReference[] = []; constructor(app: Application) { this.app = app; } + /** + * Register a framework package as a scanned module: its + * `@InnerObjectProto` / `@XxxLifecycleProto` classes are collected by the + * regular module scan (loadApp diverts them into innerObjectClazzList) — + * the module-plugin path, no hand-fed class lists. + */ + registerModule(modulePath: string): void { + this.#extraModuleReferences.push({ + name: ModuleConfigUtil.readModuleNameSync(modulePath), + path: modulePath, + }); + } + + /** App references plus registered framework modules, deduped by path (first wins). */ + get allModuleReferences(): readonly ModuleReference[] { + const seen = new Set(); + const res: ModuleReference[] = []; + for (const ref of [...this.#extraModuleReferences, ...this.app.moduleReferences]) { + if (seen.has(ref.path)) continue; + seen.add(ref.path); + res.push(ref); + } + return res; + } + registerBuildHook(hook: GlobalGraphBuildHook): void { this.pendingBuildHooks.push(hook); } @@ -52,12 +80,12 @@ export class EggModuleLoader { // Reuse egg-core's loader fs so discovery goes through the shared VFS: // 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); + const moduleDescriptors = await LoaderFactory.loadApp(this.allModuleReferences, 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.allModuleReferences, moduleDescriptors); } for (const moduleDescriptor of moduleDescriptors) { @@ -98,8 +126,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); } diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index c48a435ab8..e47c90cc48 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -7,7 +7,7 @@ import { type LoadUnitInstance, LoadUnitInstanceFactory, } from '@eggjs/tegg-runtime'; -import { AccessLevel, type EggProtoImplClass } from '@eggjs/tegg-types'; +import { AccessLevel, type EggProtoImplClass, type ModuleReference } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; @@ -36,6 +36,21 @@ export class ModuleHandler extends Base { this.loadUnitLoader.registerBuildHook(hook); } + /** + * Register a framework package (declaring `eggModule` metadata) as a + * scanned module plugin — the preferred path: hooks are collected by the + * module scan like any module. Call from configDidLoad / the synchronous + * part of didLoad, before the graph is built. + */ + registerInnerObjectModule(modulePath: string): void { + this.loadUnitLoader.registerModule(modulePath); + } + + /** App references plus registered framework modules (deduped). */ + get allModuleReferences(): readonly ModuleReference[] { + return this.loadUnitLoader.allModuleReferences; + } + readonly #innerObjectClazzRegistrations: Array<{ clazzList: readonly EggProtoImplClass[]; moduleReference: InnerObjectModuleReference; diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index c9892c557b..e9909998f2 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -1,11 +1,7 @@ -import { AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE } from '@eggjs/aop-runtime'; -import { - DAL_INNER_OBJECT_CLAZZ_LIST, - DAL_INNER_OBJECT_MODULE_REFERENCE, - MysqlDataSourceManager, - SqlMapManager, - TableModelManager, -} from '@eggjs/dal-plugin'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MysqlDataSourceManager, SqlMapManager, TableModelManager } from '@eggjs/dal-plugin'; import type { LoaderFS } from '@eggjs/loader-fs'; import { ConfigSourceLoadUnitHook, @@ -187,20 +183,50 @@ export class StandaloneApp { this.innerObjects.logger ??= [{ obj: console }]; } + /** + * Built-in framework module plugins, consumed through the SAME module scan + * as any business module (their `@InnerObjectProto` / `@XxxLifecycleProto` + * classes are diverted into the InnerObjectLoadUnit by loadApp) — no + * hand-fed class lists. The packages declare `eggModule` metadata; the + * default file pattern already excludes `test/`. + */ + static builtinFrameworkModules(): ModuleDependency[] { + // The packages ARE the modules; never pick their test fixture modules up + // (workspace/dev layouts ship test/ next to src/). + const scan = { extraFilePattern: ['!test/**'] }; + return [ + { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-runtime/package.json'))), ...scan }, + { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/dal-plugin/package.json'))), ...scan }, + ]; + } + static getModuleReferences( cwd: string, dependencies?: StandaloneAppOptions['dependencies'], frameworkDeps?: StandaloneAppOptions['frameworkDeps'], ): readonly ModuleReference[] { // framework deps first so their modules are scanned ahead of app modules - const moduleDirs = (frameworkDeps || []).concat(dependencies || []).concat(cwd); - return moduleDirs.reduce( + const moduleDirs = (StandaloneApp.builtinFrameworkModules() 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); first reference wins. + const seenPaths = new Set(); + return references.filter((reference) => { + if (seenPaths.has(reference.path)) { + return false; + } + seenPaths.add(reference.path); + return true; + }); } static async preLoad( @@ -260,8 +286,6 @@ export class StandaloneApp { name: 'standalone', path: 'tegg:standalone', }); - builder.addInnerObjectClazzList(AOP_INNER_OBJECT_CLAZZ_LIST, AOP_INNER_OBJECT_MODULE_REFERENCE); - builder.addInnerObjectClazzList(DAL_INNER_OBJECT_CLAZZ_LIST, DAL_INNER_OBJECT_MODULE_REFERENCE); for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index b3eea5b36f..303d05265f 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.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 two built-in framework modules (teggAopRuntime/teggDal) + assert.equal((ModuleDescriptorDumper.dump as any).called, 3); }); it('should not dump', async () => { @@ -357,7 +358,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 two built-in framework modules (teggAopRuntime/teggDal) + assert.equal((ModuleDescriptorDumper.dump as any).called, 3); }); }); }); From c14a699f7dd8db55aa53ab0b0043ef3cf1ae4c22 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:15:39 +0800 Subject: [PATCH 08/30] refactor(tegg): decentralize module plugins - enabling the plugin IS the contract Second review round: - Drop registerInnerObjectModule (added one commit ago): a central register is exactly what module plugins should not need. The egg host resolves module plugins from what already exists: tegg-config's ModuleScanner picks eggModule-declaring packages up from app/framework dependencies, and EggModuleLoader.resolveModuleReferences() fills the remaining gap by auto-including every ENABLED plugin whose package declares eggModule (ModuleConfigUtil.hasEggModule). Enabling the plugin is the whole contract. - plugin/aop is now itself the AOP module (eggModule: teggAop) and re-exports the aop-runtime/aop-decorator hook classes for the scan; plugin/dal already declared eggModule - both app.ts registrations are gone. aop-runtime keeps its own eggModule for the standalone built-in. - InnerObjectLoadUnitBuilder: remove the #seenClazzSet dual-arrival dedupe - reference-level path dedupe covers the legitimate case now that hand-fed lists are gone; a genuine double registration fails loud. Host-provided instances now resolve through the SAME selectProto rules as graph protos (name + qualifiers + access level) instead of a bare name whitelist; they still skip the topological sort - an already-constructed instance has no construction order and no outgoing edges. - test-util buildGlobalGraph reuses LoaderFactory.loadApp for classification instead of re-implementing the inner-object diversion. Co-Authored-By: Claude Fable 5 --- tegg/core/common-util/src/ModuleConfig.ts | 11 +++ .../src/impl/InnerObjectLoadUnitBuilder.ts | 84 ++++++++++++++----- tegg/core/test-util/src/LoaderUtil.ts | 57 +++---------- tegg/plugin/aop/package.json | 3 + tegg/plugin/aop/src/InnerObjects.ts | 6 ++ tegg/plugin/aop/src/app.ts | 7 -- tegg/plugin/dal/src/app.ts | 5 -- tegg/plugin/tegg/src/app.ts | 2 +- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 51 +++++------ tegg/plugin/tegg/src/lib/ModuleHandler.ts | 17 +--- 10 files changed, 123 insertions(+), 120 deletions(-) create mode 100644 tegg/plugin/aop/src/InnerObjects.ts diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index 25b8ea2aca..de35709eb9 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -220,6 +220,17 @@ export class ModuleConfigUtil { return ModuleConfigUtil.getModuleName(pkg); } + /** Whether the package at moduleDir declares eggModule metadata. */ + public static hasEggModule(moduleDir: string, baseDir?: string): boolean { + moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8')); + return !!pkg.eggModule?.name; + } catch { + return false; + } + } + public static readModuleNameSync(moduleDir: string, baseDir?: string): string { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index bb6c987753..411676439b 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -8,6 +8,7 @@ import { } 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, @@ -42,19 +43,9 @@ export interface CreateInnerObjectLoadUnitOptions { */ export class InnerObjectLoadUnitBuilder { readonly #protoGraph: Graph = new Graph(); - readonly #seenClazzSet: Set = new Set(); addInnerObjectClazzList(clazzList: readonly EggProtoImplClass[], moduleReference: InnerObjectModuleReference): void { for (const clazz of clazzList) { - // The same class may arrive twice — hosts hard-feed built-in framework - // lists unconditionally, and the owning package may also be scanned as an - // eggModule (e.g. @eggjs/dal-plugin declared as a module dependency). - // First registration wins; a DIFFERENT class with a colliding proto id - // still fails below. - if (this.#seenClazzSet.has(clazz)) { - continue; - } - this.#seenClazzSet.add(clazz); const descriptor = ProtoDescriptorHelper.createByInstanceClazz(clazz, { moduleName: INNER_OBJECT_LOAD_UNIT_NAME, unitPath: INNER_OBJECT_LOAD_UNIT_PATH, @@ -68,7 +59,41 @@ export class InnerObjectLoadUnitBuilder { } } - #buildProtoGraph(providedNames: Set): ProtoDescriptor[] { + /** + * Host-provided instances resolve through the SAME matching rules as graph + * protos (name + qualifiers + access level via selectProto); they just + * never join the topological sort — an already-constructed instance has no + * construction order and no outgoing dependencies. Model each provided + * entry as a minimal descriptor for matching only. + */ + static #providedDescriptors(innerObjects: Record): ProtoDescriptor[] { + const descriptors: ProtoDescriptor[] = []; + for (const [name, objects] of Object.entries(innerObjects)) { + for (const innerObject of objects) { + descriptors.push({ + name, + accessLevel: innerObject.accessLevel ?? AccessLevel.PUBLIC, + initType: ObjectInitType.SINGLETON, + protoImplType: 'PROVIDED_INNER_OBJECT', + 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, + equal: () => false, + }); + } + } + return descriptors; + } + + #buildProtoGraph(providedDescriptors: ProtoDescriptor[]): ProtoDescriptor[] { const index = ProtoGraphUtils.buildProtoNameIndex(this.#protoGraph); for (const protoNode of this.#protoGraph.nodes.values()) { for (const injectObject of protoNode.val.proto.injectObjects) { @@ -78,17 +103,30 @@ export class InnerObjectLoadUnitBuilder { injectObject, index, ); - if (!injectProto) { - // Host-provided inner objects are registered on the load unit - // directly (not part of this graph); their resolution happens at - // prototype-build time. Anything else missing is a hard error — - // deferring it to runtime hides broken module plugins. - if (injectObject.optional || providedNames.has(injectObject.objName)) { - continue; - } - throw new EggPrototypeNotFound(injectObject.objName, protoNode.val.proto.defineModuleName); + if (injectProto) { + this.#protoGraph.addEdge( + protoNode, + injectProto, + new ProtoDependencyMeta({ injectObj: injectObject.objName }), + ); + continue; + } + // Not a hook proto: match host-provided instances with the same + // selectProto rules. Resolution to the instance happens at + // prototype-build time; no edge is needed (no construction order). + const provided = providedDescriptors.find((descriptor) => + ProtoDescriptorHelper.selectProto(descriptor, { + name: injectObject.objName, + qualifiers: injectObject.qualifiers ?? [], + moduleName: protoNode.val.proto.instanceModuleName, + }), + ); + if (provided || injectObject.optional) { + continue; } - this.#protoGraph.addEdge(protoNode, injectProto, new ProtoDependencyMeta({ injectObj: injectObject.objName })); + // Anything else 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(); @@ -100,8 +138,8 @@ export class InnerObjectLoadUnitBuilder { } async createLoadUnit(options: CreateInnerObjectLoadUnitOptions): Promise { - const providedNames = new Set(Object.keys(options.innerObjects)); - const protos = this.#buildProtoGraph(providedNames); + const providedDescriptors = InnerObjectLoadUnitBuilder.#providedDescriptors(options.innerObjects); + const protos = this.#buildProtoGraph(providedDescriptors); LoadUnitFactory.registerLoadUnitCreator(INNER_OBJECT_LOAD_UNIT_TYPE, () => { return new InnerObjectLoadUnit({ innerObjects: options.innerObjects, diff --git a/tegg/core/test-util/src/LoaderUtil.ts b/tegg/core/test-util/src/LoaderUtil.ts index 163ac74892..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,50 +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) { - // Inner object protos are diverted out of module load units by the - // production loader (LoaderFactory.loadApp); mirror that here. - if (PrototypeUtil.isEggInnerObject(clazz)) { - continue; - } - 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/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 83ecdd1afb..b2ec768200 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -1,6 +1,4 @@ import assert from 'node:assert'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { GlobalGraph } from '@eggjs/metadata'; import type { Application, ILifecycleBoot } from 'egg'; @@ -22,11 +20,6 @@ export default class AopAppHook implements ILifecycleBoot { // before ours) so they are instantiated inside the InnerObjectLoadUnit — // after the business GlobalGraph is created and before build() runs. // Registration/deregistration is automatic. - // Module-plugin path: @eggjs/aop-runtime declares eggModule metadata, the - // regular module scan collects its hooks - no hand-fed class list. - this.app.moduleHandler.registerInnerObjectModule( - path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-runtime/package.json'))), - ); } async didLoad(): Promise { diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index d7ebc4cf77..76bae5a497 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; @@ -20,9 +18,6 @@ export default class DalAppBootHook implements ILifecycleBoot { // 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. - // Module-plugin path: this plugin package itself declares eggModule - // metadata (teggDal); the regular module scan collects its hooks. - this.app.moduleHandler.registerInnerObjectModule(path.join(import.meta.dirname, '..')); } async beforeClose(): Promise { diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 19ce0a9de0..36180a0595 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -72,7 +72,7 @@ export default class TeggAppBoot implements ILifecycleBoot { async loadMetadata(): Promise { if (!this.app.moduleReferences) return; - const moduleReferences = this.app.moduleHandler.allModuleReferences; + const moduleReferences = EggModuleLoader.resolveModuleReferences(this.app); const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); EggModuleLoader.collectTeggManifest(this.app, moduleReferences, moduleDescriptors); } diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index d667200c80..b93378d779 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -20,36 +20,38 @@ export class EggModuleLoader { * globbing the file system. */ private loadedFromManifest = false; - /** Framework module plugins registered by other egg plugins (scan path). */ - readonly #extraModuleReferences: ModuleReference[] = []; constructor(app: Application) { this.app = app; } /** - * Register a framework package as a scanned module: its - * `@InnerObjectProto` / `@XxxLifecycleProto` classes are collected by the - * regular module scan (loadApp diverts them into innerObjectClazzList) — - * the module-plugin path, no hand-fed class lists. + * Enabled egg plugins that declare `eggModule` metadata ARE module plugins: + * their `@InnerObjectProto` / `@XxxLifecycleProto` classes are collected by + * the regular module scan (loadApp diverts them into the + * InnerObjectLoadUnit) — no registration API, enabling the plugin is the + * whole contract. Most are already picked up from the app/framework + * dependencies by tegg-config's ModuleScanner; this only fills the gap for + * enabled plugins that are not on that dependency path, deduped by path. */ - registerModule(modulePath: string): void { - this.#extraModuleReferences.push({ - name: ModuleConfigUtil.readModuleNameSync(modulePath), - path: modulePath, - }); - } - - /** App references plus registered framework modules, deduped by path (first wins). */ - get allModuleReferences(): readonly ModuleReference[] { - const seen = new Set(); - const res: ModuleReference[] = []; - for (const ref of [...this.#extraModuleReferences, ...this.app.moduleReferences]) { - if (seen.has(ref.path)) continue; - seen.add(ref.path); - res.push(ref); + static resolveModuleReferences(app: Application): readonly ModuleReference[] { + const references: ModuleReference[] = [...app.moduleReferences]; + const seenPaths = new Set(references.map((t) => t.path)); + for (const plugin of Object.values(app.plugins)) { + if (!plugin.enable || !plugin.path || seenPaths.has(plugin.path)) continue; + let name: string; + try { + name = ModuleConfigUtil.readModuleNameSync(plugin.path); + } catch { + continue; + } + // readModuleNameSync falls back to the package name for non-eggModule + // packages; only packages DECLARING eggModule join the scan. + if (!ModuleConfigUtil.hasEggModule(plugin.path)) continue; + seenPaths.add(plugin.path); + references.push({ name, path: plugin.path }); } - return res; + return references; } registerBuildHook(hook: GlobalGraphBuildHook): void { @@ -80,12 +82,13 @@ export class EggModuleLoader { // Reuse egg-core's loader fs so discovery goes through the shared VFS: // RealLoaderFS in normal mode (zero behavior change), ManifestLoaderFS in bundle mode. const loaderFS = this.app.loader.loaderFS; - const moduleDescriptors = await LoaderFactory.loadApp(this.allModuleReferences, loadAppManifest, loaderFS); + const moduleReferences = EggModuleLoader.resolveModuleReferences(this.app); + const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences, loadAppManifest, loaderFS); this.#moduleDescriptors = moduleDescriptors; // Collect manifest data when not loaded from manifest if (!loadAppManifest) { - EggModuleLoader.collectTeggManifest(this.app, this.allModuleReferences, moduleDescriptors); + EggModuleLoader.collectTeggManifest(this.app, moduleReferences, moduleDescriptors); } for (const moduleDescriptor of moduleDescriptors) { diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index e47c90cc48..c48a435ab8 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -7,7 +7,7 @@ import { type LoadUnitInstance, LoadUnitInstanceFactory, } from '@eggjs/tegg-runtime'; -import { AccessLevel, type EggProtoImplClass, type ModuleReference } from '@eggjs/tegg-types'; +import { AccessLevel, type EggProtoImplClass } from '@eggjs/tegg-types'; import type { Application } from 'egg'; import { Base } from 'sdk-base'; @@ -36,21 +36,6 @@ export class ModuleHandler extends Base { this.loadUnitLoader.registerBuildHook(hook); } - /** - * Register a framework package (declaring `eggModule` metadata) as a - * scanned module plugin — the preferred path: hooks are collected by the - * module scan like any module. Call from configDidLoad / the synchronous - * part of didLoad, before the graph is built. - */ - registerInnerObjectModule(modulePath: string): void { - this.loadUnitLoader.registerModule(modulePath); - } - - /** App references plus registered framework modules (deduped). */ - get allModuleReferences(): readonly ModuleReference[] { - return this.loadUnitLoader.allModuleReferences; - } - readonly #innerObjectClazzRegistrations: Array<{ clazzList: readonly EggProtoImplClass[]; moduleReference: InnerObjectModuleReference; From 37f88dac288ab1e2e6873a548e803a3e4335c3f9 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:38:15 +0800 Subject: [PATCH 09/30] refactor(tegg): the aop PLUGIN is the aop module; provided objects join the graph Third review round: - Only plugins are modules: aop-runtime drops its eggModule declaration (pure library again); @eggjs/aop-plugin (eggModule: teggAop) is THE aop module for BOTH hosts - its egg imports are all type-only, so the standalone built-in reference now points at the plugin package, same as the egg host resolves it from the enabled-plugin list. One module name, one carrier. resolveModuleReferences locates the real package root from plugin.path (which points inside the package, through symlinks). - Host-provided inner objects are now REAL graph nodes: same descriptors, same vertices, same edge resolution and topological sort as hook protos (ProtoNode ids include qualifiers, so multi-entry names like per-module moduleConfig coexist). Only the instantiation list excludes them - the instances already exist. The selectProto fallback branch is gone; one resolution path. - aop-runtime tests no longer feed the package root as a module (hooks are registered manually there); fixture .egg manifest caches are stale-prone local artifacts, not repo files. Co-Authored-By: Claude Fable 5 --- tegg/core/aop-runtime/package.json | 3 -- .../core/aop-runtime/test/aop-runtime.test.ts | 3 -- .../src/impl/InnerObjectLoadUnitBuilder.ts | 49 ++++++++++--------- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 37 +++++++++----- tegg/standalone/standalone/package.json | 2 +- .../standalone/src/StandaloneApp.ts | 4 +- 6 files changed, 55 insertions(+), 43 deletions(-) diff --git a/tegg/core/aop-runtime/package.json b/tegg/core/aop-runtime/package.json index a3ec81cdbc..96f1f8cf8b 100644 --- a/tegg/core/aop-runtime/package.json +++ b/tegg/core/aop-runtime/package.json @@ -60,8 +60,5 @@ }, "engines": { "node": ">=22.18.0" - }, - "eggModule": { - "name": "teggAopRuntime" } } diff --git a/tegg/core/aop-runtime/test/aop-runtime.test.ts b/tegg/core/aop-runtime/test/aop-runtime.test.ts index 726f07528c..28f321c5df 100644 --- a/tegg/core/aop-runtime/test/aop-runtime.test.ts +++ b/tegg/core/aop-runtime/test/aop-runtime.test.ts @@ -47,7 +47,6 @@ describe('test/aop-runtime.test.ts', () => { 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'), @@ -180,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/); @@ -207,7 +205,6 @@ describe('test/aop-runtime.test.ts', () => { 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/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index 411676439b..fae68747b7 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -59,12 +59,14 @@ export class InnerObjectLoadUnitBuilder { } } + /** protoImplType marking host-provided instances in the builder graph. */ + static readonly PROVIDED_PROTO_IMPL_TYPE = 'PROVIDED_INNER_OBJECT'; + /** - * Host-provided instances resolve through the SAME matching rules as graph - * protos (name + qualifiers + access level via selectProto); they just - * never join the topological sort — an already-constructed instance has no - * construction order and no outgoing dependencies. Model each provided - * entry as a minimal descriptor for matching only. + * Host-provided instances are graph citizens like every hook proto: same + * descriptor shape, same vertices, same edge resolution and topological + * sort (they trivially sort first — no outgoing edges). Only the + * instantiation step skips them, because the instance already exists. */ static #providedDescriptors(innerObjects: Record): ProtoDescriptor[] { const descriptors: ProtoDescriptor[] = []; @@ -74,7 +76,7 @@ export class InnerObjectLoadUnitBuilder { name, accessLevel: innerObject.accessLevel ?? AccessLevel.PUBLIC, initType: ObjectInitType.SINGLETON, - protoImplType: 'PROVIDED_INNER_OBJECT', + protoImplType: InnerObjectLoadUnitBuilder.PROVIDED_PROTO_IMPL_TYPE, qualifiers: ProtoDescriptorHelper.addDefaultQualifier( innerObject.qualifiers ?? [], ObjectInitType.SINGLETON, @@ -93,7 +95,7 @@ export class InnerObjectLoadUnitBuilder { return descriptors; } - #buildProtoGraph(providedDescriptors: ProtoDescriptor[]): ProtoDescriptor[] { + #buildProtoGraph(): ProtoDescriptor[] { const index = ProtoGraphUtils.buildProtoNameIndex(this.#protoGraph); for (const protoNode of this.#protoGraph.nodes.values()) { for (const injectObject of protoNode.val.proto.injectObjects) { @@ -111,21 +113,11 @@ export class InnerObjectLoadUnitBuilder { ); continue; } - // Not a hook proto: match host-provided instances with the same - // selectProto rules. Resolution to the instance happens at - // prototype-build time; no edge is needed (no construction order). - const provided = providedDescriptors.find((descriptor) => - ProtoDescriptorHelper.selectProto(descriptor, { - name: injectObject.objName, - qualifiers: injectObject.qualifiers ?? [], - moduleName: protoNode.val.proto.instanceModuleName, - }), - ); - if (provided || injectObject.optional) { + if (injectObject.optional) { continue; } - // Anything else missing is a hard error — deferring it to runtime - // hides broken module plugins. + // Missing is a hard error — deferring it to runtime hides broken + // module plugins. throw new EggPrototypeNotFound(injectObject.objName, protoNode.val.proto.defineModuleName); } } @@ -134,12 +126,23 @@ export class InnerObjectLoadUnitBuilder { throw new Error('inner object proto has recursive deps: ' + loopPath); } - return this.#protoGraph.sort().map((node) => node.val.proto); + // Provided instances participated in resolution and ordering above; the + // instantiation list excludes them because their objects already exist + // (the load unit registers them from options.innerObjects). + return this.#protoGraph + .sort() + .map((node) => node.val.proto) + .filter((proto) => proto.protoImplType !== InnerObjectLoadUnitBuilder.PROVIDED_PROTO_IMPL_TYPE); } async createLoadUnit(options: CreateInnerObjectLoadUnitOptions): Promise { - const providedDescriptors = InnerObjectLoadUnitBuilder.#providedDescriptors(options.innerObjects); - const protos = this.#buildProtoGraph(providedDescriptors); + 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({ innerObjects: options.innerObjects, diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index b93378d779..77300f39ae 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 { ModuleConfigUtil } from '@eggjs/tegg-common-util'; @@ -38,22 +41,32 @@ export class EggModuleLoader { const references: ModuleReference[] = [...app.moduleReferences]; const seenPaths = new Set(references.map((t) => t.path)); for (const plugin of Object.values(app.plugins)) { - if (!plugin.enable || !plugin.path || seenPaths.has(plugin.path)) continue; - let name: string; - try { - name = ModuleConfigUtil.readModuleNameSync(plugin.path); - } catch { - continue; - } - // readModuleNameSync falls back to the package name for non-eggModule - // packages; only packages DECLARING eggModule join the scan. - if (!ModuleConfigUtil.hasEggModule(plugin.path)) continue; - seenPaths.add(plugin.path); - references.push({ name, path: plugin.path }); + if (!plugin.enable || !plugin.path) continue; + // plugin.path points INSIDE the package (e.g. /src) and may go + // through a symlink; module references use the real package root. + const packageRoot = EggModuleLoader.#findPackageRoot(plugin.path); + if (!packageRoot || seenPaths.has(packageRoot)) continue; + if (!ModuleConfigUtil.hasEggModule(packageRoot)) continue; + const name = ModuleConfigUtil.readModuleNameSync(packageRoot); + seenPaths.add(packageRoot); + references.push({ name, path: packageRoot }); } return references; } + 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; + } + registerBuildHook(hook: GlobalGraphBuildHook): void { this.pendingBuildHooks.push(hook); } diff --git a/tegg/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index 295efd33ef..23750a5475 100644 --- a/tegg/standalone/standalone/package.json +++ b/tegg/standalone/standalone/package.json @@ -43,7 +43,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@eggjs/aop-runtime": "workspace:*", + "@eggjs/aop-plugin": "workspace:*", "@eggjs/dal-plugin": "workspace:*", "@eggjs/lifecycle": "workspace:*", "@eggjs/loader-fs": "workspace:*", diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index e9909998f2..f300f4b1bb 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -195,7 +195,9 @@ export class StandaloneApp { // (workspace/dev layouts ship test/ next to src/). const scan = { extraFilePattern: ['!test/**'] }; return [ - { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-runtime/package.json'))), ...scan }, + // The aop PLUGIN package is the aop module (eggModule: teggAop) for + // both hosts; its egg imports are type-only so the scan is host-safe. + { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-plugin/package.json'))), ...scan }, { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/dal-plugin/package.json'))), ...scan }, ]; } From 6e593e5730a76b51e92b9e51d092502ca378b554 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:45:57 +0800 Subject: [PATCH 10/30] refactor(runtime): provided inner objects are ordinary protos end to end Restore the pre-refactor design (StandaloneInnerObjectProto) inside the new graph: a provided instance's descriptor is a regular ClassProtoDescriptor whose clazz is the factory `() => obj`, with protoImplType PROVIDED_INNER_OBJECT dispatched through the standard creator registry (the same polymorphism point as COMPATIBLE/INNER_OBJECT proto types). "Constructing" one returns the instance. One uniform channel: the builder feeds every descriptor - hooks and provided alike - through the same vertices, edge resolution, topological sort, createProtoByDescriptor loop and instantiation loop. The sort-output filter and the load unit's separate #innerObjects registration channel are both gone; InnerObjectLoadUnitOptions.innerObjects is removed. Co-Authored-By: Claude Fable 5 --- .../runtime/src/impl/InnerObjectLoadUnit.ts | 33 +++------- .../src/impl/InnerObjectLoadUnitBuilder.ts | 62 +++++++++---------- .../src/impl/ProvidedInnerObjectProto.ts | 19 +++++- .../test/__snapshots__/index.test.ts.snap | 1 + 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts index 35df54e316..5da51b7cd1 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts @@ -1,4 +1,3 @@ -import { IdenticalUtil } from '@eggjs/lifecycle'; import { ClassProtoDescriptor, EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; import { MapUtil } from '@eggjs/tegg-common-util'; import type { @@ -9,9 +8,6 @@ import type { ProtoDescriptor, QualifierInfo, } from '@eggjs/tegg-types'; -import { ObjectInitType } from '@eggjs/tegg-types'; - -import { ProvidedInnerObjectProto } from './ProvidedInnerObjectProto.ts'; export const INNER_OBJECT_LOAD_UNIT_TYPE = 'INNER_OBJECT_LOAD_UNIT'; export const INNER_OBJECT_LOAD_UNIT_NAME = 'InnerObjectLoadUnit'; @@ -30,11 +26,11 @@ export interface InnerObject { } export interface InnerObjectLoadUnitOptions { - /** Host-provided, already-constructed objects (logger, router, ...). */ - innerObjects: Record; /** - * Proto descriptors of `@InnerObjectProto` / `@XxxLifecycleProto` classes - * collected from modules, in instantiation (topological) order. + * 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; @@ -54,7 +50,6 @@ export class InnerObjectLoadUnit implements LoadUnit { readonly unitPath: string; readonly type: string = INNER_OBJECT_LOAD_UNIT_TYPE; - readonly #innerObjects: Record; readonly #protos: ProtoDescriptor[]; readonly #protoMap: Map = new Map(); @@ -62,28 +57,14 @@ export class InnerObjectLoadUnit implements LoadUnit { this.name = options.name ?? INNER_OBJECT_LOAD_UNIT_NAME; this.unitPath = options.unitPath ?? INNER_OBJECT_LOAD_UNIT_PATH; this.id = this.name; - this.#innerObjects = options.innerObjects; this.#protos = options.protos ?? []; } async init(): Promise { - for (const [name, objs] of Object.entries(this.#innerObjects)) { - for (const { obj, qualifiers, accessLevel } of objs) { - const proto = new ProvidedInnerObjectProto( - IdenticalUtil.createProtoId(this.id, name), - name, - (() => obj) as any, - ObjectInitType.SINGLETON, - this.id, - qualifiers || [], - accessLevel, - ); - EggPrototypeFactory.instance.registerPrototype(proto, this); + for (const protoDescriptor of this.#protos) { + if (!ClassProtoDescriptor.isClassProtoDescriptor(protoDescriptor)) { + continue; } - } - - const protoDescriptors = this.#protos.filter((t) => ClassProtoDescriptor.isClassProtoDescriptor(t)); - for (const protoDescriptor of protoDescriptors) { const proto = await EggPrototypeCreatorFactory.createProtoByDescriptor(protoDescriptor, this); EggPrototypeFactory.instance.registerPrototype(proto, this); } diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index fae68747b7..6e1251fa6f 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -1,4 +1,5 @@ import { + ClassProtoDescriptor, EggPrototypeNotFound, LoadUnitFactory, ProtoDependencyMeta, @@ -19,6 +20,7 @@ import { } 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; @@ -59,37 +61,38 @@ export class InnerObjectLoadUnitBuilder { } } - /** protoImplType marking host-provided instances in the builder graph. */ - static readonly PROVIDED_PROTO_IMPL_TYPE = 'PROVIDED_INNER_OBJECT'; - /** - * Host-provided instances are graph citizens like every hook proto: same - * descriptor shape, same vertices, same edge resolution and topological - * sort (they trivially sort first — no outgoing edges). Only the - * instantiation step skips them, because the instance already exists. + * 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({ - name, - accessLevel: innerObject.accessLevel ?? AccessLevel.PUBLIC, - initType: ObjectInitType.SINGLETON, - protoImplType: InnerObjectLoadUnitBuilder.PROVIDED_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, - equal: () => false, - }); + 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; @@ -126,13 +129,7 @@ export class InnerObjectLoadUnitBuilder { throw new Error('inner object proto has recursive deps: ' + loopPath); } - // Provided instances participated in resolution and ordering above; the - // instantiation list excludes them because their objects already exist - // (the load unit registers them from options.innerObjects). - return this.#protoGraph - .sort() - .map((node) => node.val.proto) - .filter((proto) => proto.protoImplType !== InnerObjectLoadUnitBuilder.PROVIDED_PROTO_IMPL_TYPE); + return this.#protoGraph.sort().map((node) => node.val.proto); } async createLoadUnit(options: CreateInnerObjectLoadUnitOptions): Promise { @@ -145,7 +142,6 @@ export class InnerObjectLoadUnitBuilder { const protos = this.#buildProtoGraph(); LoadUnitFactory.registerLoadUnitCreator(INNER_OBJECT_LOAD_UNIT_TYPE, () => { return new InnerObjectLoadUnit({ - innerObjects: options.innerObjects, protos, name: options.name, unitPath: options.unitPath, diff --git a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts index e5f9dbfcb4..612ebbb45e 100644 --- a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -1,5 +1,6 @@ -import { MetadataUtil, QualifierUtil } from '@eggjs/core-decorator'; +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, @@ -90,11 +91,25 @@ export class ProvidedInnerObjectProto implements EggPrototype { clazz, ctx.prototypeInfo.initType, loadUnit.id, - QualifierUtil.getProtoQualifiers(clazz), + 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; diff --git a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap index 3b8db601a7..7f2b5f6e5b 100644 --- a/tegg/core/runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/runtime/test/__snapshots__/index.test.ts.snap @@ -66,6 +66,7 @@ exports[`should export stable 1`] = ` "registerObjectLifecycle": [Function], }, "ModuleLoadUnitInstance": [Function], + "PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE": "PROVIDED_INNER_OBJECT", "ProvidedInnerObject": [Function], "ProvidedInnerObjectProto": [Function], "eggContextLifecycleUtilFromBag": [Function], From 9a6948b5324e00679ac7a279feae70c75c0f2995 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:50:58 +0800 Subject: [PATCH 11/30] chore(aop-runtime): drop leftover InnerObjects re-export Vestige of the intermediate round where aop-runtime itself was the scanned module; plugin/aop (the actual aop module) re-exports CrosscutAdviceFactory from aop-decorator directly. Co-Authored-By: Claude Fable 5 --- tegg/core/aop-runtime/src/InnerObjects.ts | 4 ---- tegg/core/aop-runtime/src/index.ts | 1 - tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap | 1 - 3 files changed, 6 deletions(-) delete mode 100644 tegg/core/aop-runtime/src/InnerObjects.ts diff --git a/tegg/core/aop-runtime/src/InnerObjects.ts b/tegg/core/aop-runtime/src/InnerObjects.ts deleted file mode 100644 index 6d98c275a2..0000000000 --- a/tegg/core/aop-runtime/src/InnerObjects.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Re-exported so the module scan picks CrosscutAdviceFactory up as a member -// of this module — it is decorated @InnerObjectProto in @eggjs/aop-decorator, -// but the scan only collects classes exported from this package's files. -export { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; diff --git a/tegg/core/aop-runtime/src/index.ts b/tegg/core/aop-runtime/src/index.ts index bbfd459637..7a7a56253f 100644 --- a/tegg/core/aop-runtime/src/index.ts +++ b/tegg/core/aop-runtime/src/index.ts @@ -1,5 +1,4 @@ export * from './AspectExecutor.js'; -export * from './InnerObjects.js'; export * from './CrossCutGraphHook.js'; export * from './EggObjectAopHook.js'; export * from './EggPrototypeCrossCutHook.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 571d24fa99..15bc14cefd 100644 --- a/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/aop-runtime/test/__snapshots__/index.test.ts.snap @@ -4,7 +4,6 @@ exports[`should export stable 1`] = ` { "AopGraphHookRegistrar": [Function], "AspectExecutor": [Function], - "CrosscutAdviceFactory": [Function], "EggObjectAopHook": [Function], "EggPrototypeCrossCutHook": [Function], "LoadUnitAopHook": [Function], From 03da2e2db8a1dc1c4c06f6dc0cb849334501bc8b Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:33:06 +0800 Subject: [PATCH 12/30] refactor(tegg): discovery stays deps/module.json-declared; drop enabled-plugin fallback Fourth review round, closing the discovery question: The enabled-plugin auto-include (resolveModuleReferences + hasEggModule) was solving a fixture problem with a mechanism. The pre-existing contract already covers plugins-as-modules on BOTH declaration styles: - explicit config/module.json: an npm-form reference to the plugin package (the dal fixture has carried `{"package": "../../../../"}` all along - that is HOW its plugin module loaded before this PR); - scan mode: eggModule-declaring packages in app/framework dependencies (the standalone dal fixture declares `@eggjs/dal-plugin` in deps). What broke was never the mechanism: the aop/dal/controller egg fixtures enable plugins whose hooks now ride the module scan, without declaring the plugin package as a module. Before the refactor that omission was invisible (hooks were hand-registered). Fix the fixtures to follow the dal precedent (`{"package": "@eggjs/aop-plugin"}` in module.json) and revert the discovery machinery: EggModuleLoader is back to app.moduleReferences, hasEggModule is gone. Co-Authored-By: Claude Fable 5 --- tegg/core/common-util/src/ModuleConfig.ts | 11 ----- .../fixtures/apps/aop-app/config/module.json | 3 ++ .../apps/controller-app/config/module.json | 3 ++ .../apps/http-inject-app/config/module.json | 6 ++- .../apps/module-app/config/module.json | 3 ++ .../fixtures/apps/dal-app/config/module.json | 3 ++ tegg/plugin/tegg/src/app.ts | 5 +- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 48 +------------------ 8 files changed, 21 insertions(+), 61 deletions(-) diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index de35709eb9..25b8ea2aca 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -220,17 +220,6 @@ export class ModuleConfigUtil { return ModuleConfigUtil.getModuleName(pkg); } - /** Whether the package at moduleDir declares eggModule metadata. */ - public static hasEggModule(moduleDir: string, baseDir?: string): boolean { - moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); - try { - const pkg = JSON.parse(fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8')); - return !!pkg.eggModule?.name; - } catch { - return false; - } - } - public static readModuleNameSync(moduleDir: string, baseDir?: string): string { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); diff --git a/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json b/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json index 840fd31370..b23ca2868f 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json @@ -4,5 +4,8 @@ }, { "path": "../modules/aop-cross-module" + }, + { + "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json index 51691ddaec..2f7b31999a 100644 --- a/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json @@ -7,5 +7,8 @@ }, { "path": "../modules/multi-module-service" + }, + { + "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json index fe51488c70..1617d02df2 100644 --- a/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json @@ -1 +1,5 @@ -[] +[ + { + "package": "@eggjs/aop-plugin" + } +] diff --git a/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json index 1ed8820538..12e4d81cb7 100644 --- a/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json @@ -4,5 +4,8 @@ }, { "path": "../modules/foo-module" + }, + { + "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json b/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json index 9d6c4fd2ef..354c414399 100644 --- a/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json +++ b/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json @@ -4,5 +4,8 @@ }, { "package": "../../../../" + }, + { + "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 36180a0595..c8c1b08103 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -72,9 +72,8 @@ export default class TeggAppBoot implements ILifecycleBoot { async loadMetadata(): Promise { if (!this.app.moduleReferences) return; - const moduleReferences = EggModuleLoader.resolveModuleReferences(this.app); - const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences); - EggModuleLoader.collectTeggManifest(this.app, moduleReferences, moduleDescriptors); + const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences); + EggModuleLoader.collectTeggManifest(this.app, this.app.moduleReferences, moduleDescriptors); } async beforeClose(): Promise { diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 77300f39ae..dd667a3cb8 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,9 +1,5 @@ -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 { ModuleConfigUtil } from '@eggjs/tegg-common-util'; import { LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; import type { TeggManifestExtension } from '@eggjs/tegg-loader'; import type { ModuleReference } from '@eggjs/tegg-types'; @@ -28,45 +24,6 @@ export class EggModuleLoader { this.app = app; } - /** - * Enabled egg plugins that declare `eggModule` metadata ARE module plugins: - * their `@InnerObjectProto` / `@XxxLifecycleProto` classes are collected by - * the regular module scan (loadApp diverts them into the - * InnerObjectLoadUnit) — no registration API, enabling the plugin is the - * whole contract. Most are already picked up from the app/framework - * dependencies by tegg-config's ModuleScanner; this only fills the gap for - * enabled plugins that are not on that dependency path, deduped by path. - */ - static resolveModuleReferences(app: Application): readonly ModuleReference[] { - const references: ModuleReference[] = [...app.moduleReferences]; - const seenPaths = new Set(references.map((t) => t.path)); - for (const plugin of Object.values(app.plugins)) { - if (!plugin.enable || !plugin.path) continue; - // plugin.path points INSIDE the package (e.g. /src) and may go - // through a symlink; module references use the real package root. - const packageRoot = EggModuleLoader.#findPackageRoot(plugin.path); - if (!packageRoot || seenPaths.has(packageRoot)) continue; - if (!ModuleConfigUtil.hasEggModule(packageRoot)) continue; - const name = ModuleConfigUtil.readModuleNameSync(packageRoot); - seenPaths.add(packageRoot); - references.push({ name, path: packageRoot }); - } - return references; - } - - 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; - } - registerBuildHook(hook: GlobalGraphBuildHook): void { this.pendingBuildHooks.push(hook); } @@ -95,13 +52,12 @@ export class EggModuleLoader { // Reuse egg-core's loader fs so discovery goes through the shared VFS: // RealLoaderFS in normal mode (zero behavior change), ManifestLoaderFS in bundle mode. const loaderFS = this.app.loader.loaderFS; - const moduleReferences = EggModuleLoader.resolveModuleReferences(this.app); - const moduleDescriptors = await LoaderFactory.loadApp(moduleReferences, loadAppManifest, 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, moduleReferences, moduleDescriptors); + EggModuleLoader.collectTeggManifest(this.app, this.app.moduleReferences, moduleDescriptors); } for (const moduleDescriptor of moduleDescriptors) { From a40a7d0ad3d9736b355ad498187edf32502c25b7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:37:51 +0800 Subject: [PATCH 13/30] refactor(runtime): name the provided-instance factory for what it is ProvidedInnerObjectProto stored the `() => obj` factory in a field named `clazz` typed EggProtoImplClass (inherited from the old StandaloneInnerObjectProto trick), making constructEggObject read like an instantiation. Rename to `objFactory: () => object` and call it directly - the only casts left sit at the EggPrototypeLifecycleContext boundary, whose `clazz` slot is the carrier, with comments saying so. Co-Authored-By: Claude Fable 5 --- .../runtime/src/impl/ProvidedInnerObjectProto.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts index 612ebbb45e..b61d0bd70a 100644 --- a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -26,7 +26,8 @@ import { EggObjectFactory } from '../factory/EggObjectFactory.ts'; */ export class ProvidedInnerObjectProto implements EggPrototype { [key: symbol]: PropertyDescriptor; - private readonly clazz: EggProtoImplClass; + /** NOT a class: the factory `() => obj` returning the provided instance. */ + private readonly objFactory: () => object; private readonly qualifiers: QualifierInfo[]; readonly id: string; @@ -39,14 +40,14 @@ export class ProvidedInnerObjectProto implements EggPrototype { constructor( id: string, name: EggPrototypeName, - clazz: EggProtoImplClass, + objFactory: () => object, initType: ObjectInitTypeLike, loadUnitId: Id, qualifiers: QualifierInfo[], accessLevel?: AccessLevel, ) { this.id = id; - this.clazz = clazz; + this.objFactory = objFactory; this.name = name; this.initType = initType; this.accessLevel = accessLevel ?? AccessLevel.PUBLIC; @@ -70,11 +71,12 @@ export class ProvidedInnerObjectProto implements EggPrototype { } constructEggObject(): object { - return Reflect.apply(this.clazz, null, []); + // no `new`: calling the factory returns the host-provided instance + return this.objFactory(); } getMetaData(metadataKey: MetaDataKey): T | undefined { - return MetadataUtil.getMetaData(metadataKey, this.clazz); + return MetadataUtil.getMetaData(metadataKey, this.objFactory as unknown as EggProtoImplClass); } getQualifier(attribute: string): QualifierValue | undefined { @@ -82,13 +84,15 @@ export class ProvidedInnerObjectProto implements EggPrototype { } 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, + clazz as unknown as () => object, ctx.prototypeInfo.initType, loadUnit.id, ctx.prototypeInfo.qualifiers ?? [], From 7bcfd38bd9c79de58d11d1430b08fb2565d0425d Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:57:37 +0800 Subject: [PATCH 14/30] docs(aop): explain why AopContextHook stays imperatively registered Co-Authored-By: Claude Fable 5 --- tegg/plugin/aop/src/app.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index b2ec768200..84a680d53f 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -27,8 +27,15 @@ export default class AopAppHook implements ILifecycleBoot { // 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'); - // AopContextHook snapshots moduleHandler.loadUnitInstances, so it must stay - // registered AFTER init — it is a per-request ctx hook, late is harmless. + // 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); } From 02a72b408e31ef8e8dfc1edd900f6960930f1c16 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:25:49 +0800 Subject: [PATCH 15/30] docs(tegg): explain why inner objects use provided instances, not egg compat Co-Authored-By: Claude Fable 5 --- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index c48a435ab8..b649b994b2 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -72,9 +72,14 @@ export class ModuleHandler extends Base { }); } const innerObjectLoadUnit = await builder.createLoadUnit({ - // Base host objects for framework hooks. 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. + // 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: [ From 9d1f8b017a3ad2831ac89cbf45d82ec5b06a66e7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:30:41 +0800 Subject: [PATCH 16/30] docs(tegg): state why ConfigSourceLoadUnitHook is a host built-in Core tegg semantics with no declaring app: scan discovery would leak host internals into user module.json. One shared class, one line per host. Co-Authored-By: Claude Fable 5 --- tegg/plugin/tegg/src/app.ts | 9 ++++++--- tegg/standalone/standalone/src/StandaloneApp.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index c8c1b08103..2e8cce2e35 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -45,9 +45,12 @@ export default class TeggAppBoot implements ILifecycleBoot { this.eggContextHandler.register(); }); this.app.moduleHandler = new ModuleHandler(this.app); - // ConfigSourceLoadUnitHook is a module plugin class (@LoadUnitLifecycleProto): - // it is instantiated in the InnerObjectLoadUnit during init() and - // registered/deregistered automatically. + // Host built-in, NOT scan-discovered: this hook is core tegg semantics + // (every module's `moduleConfig` injection depends on it, unconditionally) + // with no declaring app — requiring a module.json entry for it would leak + // host internals into user configuration. The class itself is the single + // shared implementation from @eggjs/metadata; each host's composition + // root wires it with one line (standalone does the same). this.app.moduleHandler.registerInnerObjectClazzList([ConfigSourceLoadUnitHook], { name: 'tegg', path: 'tegg:tegg-plugin', diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index f300f4b1bb..f612f292c2 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -283,7 +283,9 @@ export class StandaloneApp { private async instantiateInnerObjectLoadUnit(): Promise { StandaloneContextHandler.register(); const builder = new InnerObjectLoadUnitBuilder(); - // Built-in framework module plugins (declarative hooks in their own packages). + // Host built-in, NOT scan-discovered: core tegg semantics (moduleConfig + // injection) with no declaring app — same single shared class as the egg + // host, wired by each composition root with one line. builder.addInnerObjectClazzList([ConfigSourceLoadUnitHook], { name: 'standalone', path: 'tegg:standalone', From c09177e6d79e0d3379b59202553b1d8a6146ad5e Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:41:07 +0800 Subject: [PATCH 17/30] refactor(tegg): ConfigSourceLoadUnitHook lives in @eggjs/tegg-config, the config module Review suggestion: the config-domain hook belongs to the config package, and that package should simply BE a module. This kills the last clazz-list registration and with it the whole registerInnerObjectClazzList API. - @eggjs/tegg-config declares eggModule (teggConfig) and hosts the hook in src/lib (host-safe: its egg imports are type-only). Both hosts consume it as a scanned module: standalone via the third built-in framework module reference; the egg host via the framework-dependency channel. - ModuleScanner now takes the RUNTIME framework dir (app.options.framework, what egg/mm actually resolved) over re-deriving from appPkg.egg.framework - the gap that made framework-deps discovery invisible in test harnesses. With it, packages/egg's own dependencies (aop/dal/config plugins) reach every fixture as OPTIONAL modules, promoted by the existing enabled loop; the fixture module.json declarations added earlier are retired. - ModuleHandler.registerInnerObjectClazzList and its buffer are gone - every hook now arrives through the module scan; ReadModule asserts the production shape (business refs exact, framework refs optional). Co-Authored-By: Claude Fable 5 --- tegg/core/metadata/src/hook/index.ts | 1 - tegg/core/metadata/src/index.ts | 1 - .../test/__snapshots__/index.test.ts.snap | 1 - .../fixtures/apps/aop-app/config/module.json | 3 -- tegg/plugin/config/package.json | 4 ++ tegg/plugin/config/src/app.ts | 8 +++- .../src/lib}/ConfigSourceLoadUnitHook.ts | 0 tegg/plugin/config/src/lib/ModuleScanner.ts | 38 ++++++++++++------- tegg/plugin/config/test/ReadModule.test.ts | 24 +++++++----- .../apps/controller-app/config/module.json | 3 -- .../apps/http-inject-app/config/module.json | 6 +-- .../apps/module-app/config/module.json | 3 -- .../fixtures/apps/dal-app/config/module.json | 3 -- tegg/plugin/tegg/src/app.ts | 11 ------ tegg/plugin/tegg/src/lib/ModuleHandler.ts | 30 +-------------- tegg/standalone/standalone/package.json | 1 + .../standalone/src/StandaloneApp.ts | 20 ++-------- tegg/standalone/standalone/test/index.test.ts | 8 ++-- 18 files changed, 63 insertions(+), 102 deletions(-) delete mode 100644 tegg/core/metadata/src/hook/index.ts rename tegg/{core/metadata/src/hook => plugin/config/src/lib}/ConfigSourceLoadUnitHook.ts (100%) diff --git a/tegg/core/metadata/src/hook/index.ts b/tegg/core/metadata/src/hook/index.ts deleted file mode 100644 index 3ea006e9e2..0000000000 --- a/tegg/core/metadata/src/hook/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ConfigSourceLoadUnitHook.ts'; diff --git a/tegg/core/metadata/src/index.ts b/tegg/core/metadata/src/index.ts index 0df2d578ab..592e38bcc2 100644 --- a/tegg/core/metadata/src/index.ts +++ b/tegg/core/metadata/src/index.ts @@ -5,4 +5,3 @@ export * from './model/index.ts'; export * from './errors.ts'; export * from './util/index.ts'; export * from './impl/index.ts'; -export * from './hook/index.ts'; diff --git a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap index 958f8a9e9f..90d0af85cf 100644 --- a/tegg/core/metadata/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/metadata/test/__snapshots__/index.test.ts.snap @@ -7,7 +7,6 @@ exports[`should export stable 1`] = ` "ClassProtoDescriptor": [Function], "ClassUtil": [Function], "ClazzMap": [Function], - "ConfigSourceLoadUnitHook": [Function], "EggInnerObjectPrototypeImpl": [Function], "EggLoadUnitType": { "APP": "APP", diff --git a/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json b/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json index b23ca2868f..840fd31370 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/config/module.json @@ -4,8 +4,5 @@ }, { "path": "../modules/aop-cross-module" - }, - { - "package": "@eggjs/aop-plugin" } ] 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/config/src/app.ts b/tegg/plugin/config/src/app.ts index 4346a65f91..b52c2bd55c 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -77,7 +77,13 @@ export default class App implements ILifecycleBoot { readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; } } - const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions); + const moduleScanner = new ModuleScanner(this.app.baseDir, { + ...readModuleOptions, + // egg already resolved the real framework (mm option / egg-scripts); + // framework dependencies declaring eggModule join the scan as + // OPTIONAL modules, promoted when their plugin is enabled. + frameworkDir: (this.app.options as { framework?: string } | undefined)?.framework, + }); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { diff --git a/tegg/core/metadata/src/hook/ConfigSourceLoadUnitHook.ts b/tegg/plugin/config/src/lib/ConfigSourceLoadUnitHook.ts similarity index 100% rename from tegg/core/metadata/src/hook/ConfigSourceLoadUnitHook.ts rename to tegg/plugin/config/src/lib/ConfigSourceLoadUnitHook.ts diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 2b5a02ee54..17141fd013 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -7,11 +7,20 @@ import { importResolve } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); +export interface ModuleScannerOptions extends ReadModuleReferenceOptions { + /** + * The RUNTIME framework directory (egg resolves it from options.framework / + * mm). Preferred over re-deriving from `appPkg.egg.framework`, which is + * absent in test harnesses and custom launches. + */ + frameworkDir?: string; +} + export class ModuleScanner { private readonly baseDir: string; - private readonly readModuleOptions: ReadModuleReferenceOptions; + private readonly readModuleOptions: ModuleScannerOptions; - constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions) { + constructor(baseDir: string, readModuleOptions: ModuleScannerOptions) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; } @@ -22,18 +31,21 @@ export class ModuleScanner { */ loadModuleReferences(): readonly ModuleReference[] { const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); - const appPkg: { egg?: { framework?: string } } = JSON.parse( - readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), - ); - const framework = appPkg.egg?.framework; - if (!framework) { - return ModuleConfigUtil.deduplicateModules(moduleReferences); + let frameworkDir = this.readModuleOptions?.frameworkDir; + if (!frameworkDir) { + const appPkg: { egg?: { framework?: string } } = JSON.parse( + readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), + ); + const framework = appPkg.egg?.framework; + if (!framework) { + return ModuleConfigUtil.deduplicateModules(moduleReferences); + } + const frameworkPkg = importResolve(`${framework}/package.json`, { + paths: [this.baseDir], + }); + frameworkDir = path.dirname(frameworkPkg); } - const frameworkPkg = importResolve(`${framework}/package.json`, { - paths: [this.baseDir], - }); - const frameworkDir = path.dirname(frameworkPkg); - debug('loadModuleReferences from framework:%o, frameworkDir:%o', framework, frameworkDir); + debug('loadModuleReferences frameworkDir:%o', frameworkDir); const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); // Merge all module references and deduplicate diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index 2381eb28e8..aa5e8c8a9c 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -18,24 +18,30 @@ describe('plugin/config/test/ReadModule.test.ts', () => { }); it('should work', () => { - expect(app.moduleConfigs).toEqual({ - moduleA: { - config: {}, + expect(app.moduleConfigs.moduleA).toEqual({ + config: {}, + name: 'moduleA', + reference: { + optional: undefined, name: 'moduleA', - reference: { - optional: undefined, - name: 'moduleA', - path: getFixtures('apps/app-with-modules/app/module-a'), - }, + path: getFixtures('apps/app-with-modules/app/module-a'), }, }); - expect(app.moduleReferences).toEqual([ + const appRefs = app.moduleReferences.filter((t) => !t.optional); + expect(appRefs).toEqual([ { optional: undefined, name: 'moduleA', path: getFixtures('apps/app-with-modules/app/module-a'), }, ]); + // The runtime framework's eggModule-declaring dependencies join as + // OPTIONAL modules (promoted only when their plugin is enabled) — the + // same shape a production app with `egg.framework` always saw. + for (const ref of app.moduleReferences) { + if (ref.name === 'moduleA') continue; + expect(ref.optional).toBe(true); + } }); it('should type defines work', () => { diff --git a/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json index 2f7b31999a..51691ddaec 100644 --- a/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/controller-app/config/module.json @@ -7,8 +7,5 @@ }, { "path": "../modules/multi-module-service" - }, - { - "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json index 1617d02df2..fe51488c70 100644 --- a/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/http-inject-app/config/module.json @@ -1,5 +1 @@ -[ - { - "package": "@eggjs/aop-plugin" - } -] +[] diff --git a/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json b/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json index 12e4d81cb7..1ed8820538 100644 --- a/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json +++ b/tegg/plugin/controller/test/fixtures/apps/module-app/config/module.json @@ -4,8 +4,5 @@ }, { "path": "../modules/foo-module" - }, - { - "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json b/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json index 354c414399..9d6c4fd2ef 100644 --- a/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json +++ b/tegg/plugin/dal/test/fixtures/apps/dal-app/config/module.json @@ -4,8 +4,5 @@ }, { "package": "../../../../" - }, - { - "package": "@eggjs/aop-plugin" } ] diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 2e8cce2e35..7d02ac6384 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -2,7 +2,6 @@ import './lib/AppLoadUnit.ts'; import './lib/AppLoadUnitInstance.ts'; import './lib/EggCompatibleObject.ts'; -import { ConfigSourceLoadUnitHook } from '@eggjs/metadata'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; @@ -45,16 +44,6 @@ export default class TeggAppBoot implements ILifecycleBoot { this.eggContextHandler.register(); }); this.app.moduleHandler = new ModuleHandler(this.app); - // Host built-in, NOT scan-discovered: this hook is core tegg semantics - // (every module's `moduleConfig` injection depends on it, unconditionally) - // with no declaring app — requiring a module.json entry for it would leak - // host internals into user configuration. The class itself is the single - // shared implementation from @eggjs/metadata; each host's composition - // root wires it with one line (standalone does the same). - this.app.moduleHandler.registerInnerObjectClazzList([ConfigSourceLoadUnitHook], { - name: 'tegg', - path: 'tegg:tegg-plugin', - }); } async didLoad(): Promise { diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index b649b994b2..bbd0ad7840 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -1,13 +1,8 @@ import { EggLoadUnitType, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import type { GlobalGraphBuildHook } from '@eggjs/metadata'; import { ModuleConfigs } from '@eggjs/tegg-common-util'; -import { - InnerObjectLoadUnitBuilder, - type InnerObjectModuleReference, - type LoadUnitInstance, - LoadUnitInstanceFactory, -} from '@eggjs/tegg-runtime'; -import { AccessLevel, type EggProtoImplClass } from '@eggjs/tegg-types'; +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'; @@ -36,24 +31,6 @@ export class ModuleHandler extends Base { this.loadUnitLoader.registerBuildHook(hook); } - readonly #innerObjectClazzRegistrations: Array<{ - clazzList: readonly EggProtoImplClass[]; - moduleReference: InnerObjectModuleReference; - }> = []; - - /** - * Buffer framework module plugin classes (`@InnerObjectProto` / - * `@XxxLifecycleProto`) provided by other egg plugins. They are instantiated - * in the InnerObjectLoadUnit during init(), before any business load unit is - * created. Call from configDidLoad or the synchronous part of didLoad. - */ - registerInnerObjectClazzList( - clazzList: readonly EggProtoImplClass[], - moduleReference: InnerObjectModuleReference, - ): void { - this.#innerObjectClazzRegistrations.push({ clazzList, moduleReference }); - } - /** * Create AND instantiate the InnerObjectLoadUnit before the business graph * is built, so `@XxxLifecycleProto` hooks provided by module plugins @@ -62,9 +39,6 @@ export class ModuleHandler extends Base { */ private async instantiateInnerObjectLoadUnit(): Promise { const builder = new InnerObjectLoadUnitBuilder(); - for (const { clazzList, moduleReference } of this.#innerObjectClazzRegistrations) { - builder.addInnerObjectClazzList(clazzList, moduleReference); - } for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, diff --git a/tegg/standalone/standalone/package.json b/tegg/standalone/standalone/package.json index 23750a5475..06aeac7a95 100644 --- a/tegg/standalone/standalone/package.json +++ b/tegg/standalone/standalone/package.json @@ -50,6 +50,7 @@ "@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/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index f612f292c2..1f2b2d6334 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -3,13 +3,7 @@ import { fileURLToPath } from 'node:url'; import { MysqlDataSourceManager, SqlMapManager, TableModelManager } from '@eggjs/dal-plugin'; import type { LoaderFS } from '@eggjs/loader-fs'; -import { - ConfigSourceLoadUnitHook, - type EggPrototype, - EggPrototypeFactory, - type LoadUnit, - LoadUnitFactory, -} from '@eggjs/metadata'; +import { type EggPrototype, EggPrototypeFactory, type LoadUnit, LoadUnitFactory } from '@eggjs/metadata'; import { type ModuleConfigHolder, ModuleConfigs, ConfigSourceQualifierAttribute, type Logger } from '@eggjs/tegg'; import { ModuleConfigUtil, @@ -195,10 +189,11 @@ export class StandaloneApp { // (workspace/dev layouts ship test/ next to src/). const scan = { extraFilePattern: ['!test/**'] }; return [ - // The aop PLUGIN package is the aop module (eggModule: teggAop) for - // both hosts; its egg imports are type-only so the scan is host-safe. + // The PLUGIN packages are the modules (teggAop/teggDal/teggConfig) for + // both hosts; their egg imports are type-only so the scan is host-safe. { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-plugin/package.json'))), ...scan }, { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/dal-plugin/package.json'))), ...scan }, + { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/tegg-config/package.json'))), ...scan }, ]; } @@ -283,13 +278,6 @@ export class StandaloneApp { private async instantiateInnerObjectLoadUnit(): Promise { StandaloneContextHandler.register(); const builder = new InnerObjectLoadUnitBuilder(); - // Host built-in, NOT scan-discovered: core tegg semantics (moduleConfig - // injection) with no declaring app — same single shared class as the egg - // host, wired by each composition root with one line. - builder.addInnerObjectClazzList([ConfigSourceLoadUnitHook], { - name: 'standalone', - path: 'tegg:standalone', - }); for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 303d05265f..7070b06721 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -28,8 +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); - // app module + the two built-in framework modules (teggAopRuntime/teggDal) - assert.equal((ModuleDescriptorDumper.dump as any).called, 3); + // app module + the three built-in framework modules (teggAop/teggDal/teggConfig) + assert.equal((ModuleDescriptorDumper.dump as any).called, 4); }); it('should not dump', async () => { @@ -358,8 +358,8 @@ describe('standalone/standalone/test/index.test.ts', () => { await preLoad(fixturePath); await main(fixturePath); assert.deepEqual(Foo.staticCalled, ['preLoad', 'construct', 'postConstruct', 'preInject', 'postInject', 'init']); - // app module + the two built-in framework modules (teggAopRuntime/teggDal) - assert.equal((ModuleDescriptorDumper.dump as any).called, 3); + // app module + the three built-in framework modules (teggAop/teggDal/teggConfig) + assert.equal((ModuleDescriptorDumper.dump as any).called, 4); }); }); }); From 1ef5ff9c48dc0e9ec27b6fc4393d2c13edee8fd1 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:46:59 +0800 Subject: [PATCH 18/30] fix(tegg): gate optional modules' hooks; fix the enabled-promotion match Follow-up from asking whether standalone can reuse ModuleScanner (it can't: the scanner's increment over readModuleReference is egg-only semantics - single frameworkDir + optional marking gated by plugin enablement, and standalone has no plugin system; the shared primitives already live in common-util): - Graph sort only gates optional modules' BUSINESS protos; their inner hooks were instantiated regardless, so a disabled plugin's hooks silently loaded. instantiateInnerObjectLoadUnit now skips unpromoted optional descriptors - symmetric gating. - That gate exposed a long-dormant bug: buildAppGraph's enabled-promotion matched plugin.path (inside the package, through symlinks) against reference paths (real package roots) and never fired. Resolve the real package root before matching. - StandaloneApp switches its hand-rolled path dedupe to the shared ModuleConfigUtil.deduplicateModules (same first-wins semantics, plus duplicate-name conflict detection). Co-Authored-By: Claude Fable 5 --- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 26 +++++++++++++++++-- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 8 ++++++ .../standalone/src/StandaloneApp.ts | 12 +++------ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index dd667a3cb8..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'; @@ -28,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); @@ -35,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; } diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index bbd0ad7840..ab9e50065b 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -40,6 +40,14 @@ export class ModuleHandler extends Base { 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, diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 1f2b2d6334..f468c7826f 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -215,15 +215,9 @@ export class StandaloneApp { [] as readonly ModuleReference[], ); // The same module may be reachable from multiple scan roots (a built-in - // framework module the app also depends on); first reference wins. - const seenPaths = new Set(); - return references.filter((reference) => { - if (seenPaths.has(reference.path)) { - return false; - } - seenPaths.add(reference.path); - return true; - }); + // framework module the app also depends on) — shared dedupe (first path + // wins, conflicting duplicate names throw). + return ModuleConfigUtil.deduplicateModules(references); } static async preLoad( From 82cdd81b7bbaa2766459f2f9a31b8c1b9a6e8f35 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:56:25 +0800 Subject: [PATCH 19/30] revert(tegg-config): framework discovery reads package.json only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review decision: drop the runtime-frameworkDir override on ModuleScanner — `egg.framework` in the app's package.json is the single source for framework-dependency module discovery, avoiding behavior divergence for launcher-provided framework paths (mm/egg-scripts/FaaS launchers), where module scanning, moduleConfigs surface and manifests would silently grow. Test fixtures that rely on framework-dependency modules (teggAop/teggDal/ teggConfig) now declare `"egg": {"framework": "egg"}` in their package.json - the exact production convention. Co-Authored-By: Claude Fable 5 --- .../test/fixtures/apps/aop-app/package.json | 5 ++- tegg/plugin/config/src/app.ts | 8 +--- tegg/plugin/config/src/lib/ModuleScanner.ts | 38 +++++++------------ tegg/plugin/config/test/ReadModule.test.ts | 24 +++++------- .../fixtures/apps/controller-app/package.json | 5 ++- .../test/fixtures/apps/dal-app/package.json | 5 ++- .../apps/inject-module-config/package.json | 5 ++- 7 files changed, 39 insertions(+), 51 deletions(-) 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/src/app.ts b/tegg/plugin/config/src/app.ts index b52c2bd55c..4346a65f91 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -77,13 +77,7 @@ export default class App implements ILifecycleBoot { readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; } } - const moduleScanner = new ModuleScanner(this.app.baseDir, { - ...readModuleOptions, - // egg already resolved the real framework (mm option / egg-scripts); - // framework dependencies declaring eggModule join the scan as - // OPTIONAL modules, promoted when their plugin is enabled. - frameworkDir: (this.app.options as { framework?: string } | undefined)?.framework, - }); + const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 17141fd013..2b5a02ee54 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -7,20 +7,11 @@ import { importResolve } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); -export interface ModuleScannerOptions extends ReadModuleReferenceOptions { - /** - * The RUNTIME framework directory (egg resolves it from options.framework / - * mm). Preferred over re-deriving from `appPkg.egg.framework`, which is - * absent in test harnesses and custom launches. - */ - frameworkDir?: string; -} - export class ModuleScanner { private readonly baseDir: string; - private readonly readModuleOptions: ModuleScannerOptions; + private readonly readModuleOptions: ReadModuleReferenceOptions; - constructor(baseDir: string, readModuleOptions: ModuleScannerOptions) { + constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; } @@ -31,21 +22,18 @@ export class ModuleScanner { */ loadModuleReferences(): readonly ModuleReference[] { const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); - let frameworkDir = this.readModuleOptions?.frameworkDir; - if (!frameworkDir) { - const appPkg: { egg?: { framework?: string } } = JSON.parse( - readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), - ); - const framework = appPkg.egg?.framework; - if (!framework) { - return ModuleConfigUtil.deduplicateModules(moduleReferences); - } - const frameworkPkg = importResolve(`${framework}/package.json`, { - paths: [this.baseDir], - }); - frameworkDir = path.dirname(frameworkPkg); + const appPkg: { egg?: { framework?: string } } = JSON.parse( + readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), + ); + const framework = appPkg.egg?.framework; + if (!framework) { + return ModuleConfigUtil.deduplicateModules(moduleReferences); } - debug('loadModuleReferences frameworkDir:%o', frameworkDir); + const frameworkPkg = importResolve(`${framework}/package.json`, { + paths: [this.baseDir], + }); + const frameworkDir = path.dirname(frameworkPkg); + debug('loadModuleReferences from framework:%o, frameworkDir:%o', framework, frameworkDir); const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); // Merge all module references and deduplicate diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index aa5e8c8a9c..2381eb28e8 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -18,30 +18,24 @@ describe('plugin/config/test/ReadModule.test.ts', () => { }); it('should work', () => { - expect(app.moduleConfigs.moduleA).toEqual({ - config: {}, - name: 'moduleA', - reference: { - optional: undefined, + expect(app.moduleConfigs).toEqual({ + moduleA: { + config: {}, name: 'moduleA', - path: getFixtures('apps/app-with-modules/app/module-a'), + reference: { + optional: undefined, + name: 'moduleA', + path: getFixtures('apps/app-with-modules/app/module-a'), + }, }, }); - const appRefs = app.moduleReferences.filter((t) => !t.optional); - expect(appRefs).toEqual([ + expect(app.moduleReferences).toEqual([ { optional: undefined, name: 'moduleA', path: getFixtures('apps/app-with-modules/app/module-a'), }, ]); - // The runtime framework's eggModule-declaring dependencies join as - // OPTIONAL modules (promoted only when their plugin is enabled) — the - // same shape a production app with `egg.framework` always saw. - for (const ref of app.moduleReferences) { - if (ref.name === 'moduleA') continue; - expect(ref.optional).toBe(true); - } }); it('should type defines work', () => { 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/test/fixtures/apps/dal-app/package.json b/tegg/plugin/dal/test/fixtures/apps/dal-app/package.json index 089e700fc8..5ec33feadd 100644 --- a/tegg/plugin/dal/test/fixtures/apps/dal-app/package.json +++ b/tegg/plugin/dal/test/fixtures/apps/dal-app/package.json @@ -1,4 +1,7 @@ { "name": "dal-app", - "type": "module" + "type": "module", + "egg": { + "framework": "egg" + } } 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" + } } From 1d6ce34a7857a62227cc38374a810f5aa2b487e2 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:26:59 +0800 Subject: [PATCH 20/30] refactor(standalone): defer module scan out of the StandaloneApp constructor moduleReferences becomes a lazily computed getter (same public read surface, no fs scan at construction) and initInnerObjectsAndConfigs moves to the top of init(): moduleConfigs starts as an empty placeholder map that the ModuleConfigs inner object already holds by reference, and the per-module qualified moduleConfig inner objects are only consumed by instantiateInnerObjectLoadUnit during init(). Scan failures now surface at init() where callers already tear down via destroy(), so the constructor no longer needs its scope-unregister try/catch. Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index f468c7826f..13ab178d2a 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -63,8 +63,9 @@ export interface StandaloneAppOptions { export class StandaloneApp { readonly cwd: string; - readonly moduleReferences: readonly ModuleReference[]; + /** Filled during init(); the ModuleConfigs inner object holds this same map. */ readonly moduleConfigs: Record; + #moduleReferences?: readonly ModuleReference[]; readonly env?: string; readonly name?: string; readonly options?: StandaloneAppOptions; @@ -84,22 +85,24 @@ export class StandaloneApp { this.env = options?.env; this.name = options?.name; this.options = options; + this.moduleConfigs = {}; this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); - try { - this.moduleReferences = StandaloneApp.getModuleReferences( - this.cwd, - options?.dependencies, - options?.frameworkDeps, - ); - this.moduleConfigs = {}; - this.runInScope(() => this.initInnerObjectsAndConfigs(options)); - } catch (e) { - // Construction failed after the scope was registered; release it so the - // never-returned app does not leak into liveScopeBags. - TeggScope.unregisterScope(this.scopeBag); - throw e; - } + } + + /** + * Lazily computed on first access so constructing an app does no fs scan; + * init() is the usual first consumer. Scan errors (e.g. duplicate module + * names) therefore surface at init() where callers already tear down via + * destroy() — never from the constructor. + */ + get moduleReferences(): readonly ModuleReference[] { + this.#moduleReferences ??= StandaloneApp.getModuleReferences( + this.cwd, + this.options?.dependencies, + this.options?.frameworkDeps, + ); + return this.#moduleReferences; } /** Run `fn` within THIS app's per-app scope so factories/managers resolve here. */ @@ -312,6 +315,7 @@ export class StandaloneApp { async init(): Promise { await this.runInScope(async () => { + this.initInnerObjectsAndConfigs(this.options); await this.initLoaderInstance(); await this.instantiateInnerObjectLoadUnit(); await this.instantiateModuleLoadUnits(); From 42b5933f3268d1e5124a2ed07bab1561a0f053c1 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:29:27 +0800 Subject: [PATCH 21/30] refactor(standalone): drop deprecated StandaloneAppOptions.innerObjects No remaining callers; innerObjectHandlers is the only channel for host-provided inner objects. Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/src/StandaloneApp.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 13ab178d2a..0a228e8be6 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -34,11 +34,6 @@ export interface ModuleDependency extends ReadModuleReferenceOptions { } export interface StandaloneAppOptions { - /** - * @deprecated - * use inner object handlers instead - */ - innerObjects?: Record; env?: string; name?: string; innerObjectHandlers?: Record; @@ -165,15 +160,7 @@ export class StandaloneApp { ], }); } - if (options?.innerObjects) { - for (const [name, obj] of Object.entries(options.innerObjects)) { - this.innerObjects[name] = [ - { - obj, - }, - ]; - } - } else if (options?.innerObjectHandlers) { + if (options?.innerObjectHandlers) { Object.assign(this.innerObjects, options.innerObjectHandlers); } // Framework hooks (e.g. DAL) inject `logger`; make sure it always resolves. From 9903c5677743b9cfd411a7aec1d711c5c04a4ff7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:32:25 +0800 Subject: [PATCH 22/30] refactor(standalone): keep innerObjects assembly in the constructor, config loading in init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit innerObjects is a construction-time contract (hosts add provided objects between new and init), so its placeholder structure — moduleConfigs wrapper, empty moduleConfig list, runtimeConfig, handler merge, logger default — is built in the constructor again, with no fs I/O. The module scan and per-module config loading stay in init() via loadModuleConfigs, which fills the placeholder maps the inner objects already hold by reference. Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 0a228e8be6..ed5e52b334 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -83,6 +83,17 @@ export class StandaloneApp { this.moduleConfigs = {}; this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); + try { + // innerObjects is a construction-time contract: hosts may add provided + // objects between new and init(). No fs I/O happens here — module + // configs are loaded into the placeholder maps during init(). + this.runInScope(() => this.initInnerObjects(options)); + } catch (e) { + // Construction failed after the scope was registered; release it so the + // never-returned app does not leak into liveScopeBags. + TeggScope.unregisterScope(this.scopeBag); + throw e; + } } /** @@ -105,7 +116,7 @@ export class StandaloneApp { return TeggScope.run(this.scopeBag, fn); } - private initInnerObjectsAndConfigs(options?: StandaloneAppOptions): void { + private initInnerObjects(options?: StandaloneAppOptions): void { this.innerObjects = { moduleConfigs: [ { @@ -132,6 +143,20 @@ export class StandaloneApp { }, ]; + if (options?.innerObjectHandlers) { + Object.assign(this.innerObjects, options.innerObjectHandlers); + } + // Framework hooks (e.g. DAL) inject `logger`; make sure it always resolves. + this.innerObjects.logger ??= [{ obj: console }]; + } + + /** + * Fill the placeholder maps created in the constructor: load every module's + * config and expose it as a qualified `moduleConfig` inner object. Runs at + * init() so the module scan (the `moduleReferences` getter) stays off the + * construction path. + */ + private loadModuleConfigs(): void { // 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. @@ -160,11 +185,6 @@ export class StandaloneApp { ], }); } - if (options?.innerObjectHandlers) { - Object.assign(this.innerObjects, options.innerObjectHandlers); - } - // Framework hooks (e.g. DAL) inject `logger`; make sure it always resolves. - this.innerObjects.logger ??= [{ obj: console }]; } /** @@ -302,7 +322,7 @@ export class StandaloneApp { async init(): Promise { await this.runInScope(async () => { - this.initInnerObjectsAndConfigs(this.options); + this.loadModuleConfigs(); await this.initLoaderInstance(); await this.instantiateInnerObjectLoadUnit(); await this.instantiateModuleLoadUnits(); From a0ce9556cd862950d7c5e3c6d49e396d9370363a Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:36:30 +0800 Subject: [PATCH 23/30] refactor(standalone): make runtimeConfig a placeholder filled at init Same pattern as moduleConfigs: the constructor registers an empty runtimeConfig object as the inner object, and loadConfigs (init phase) assigns its values. Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index ed5e52b334..71c1930916 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -61,6 +61,8 @@ export class StandaloneApp { /** Filled during init(); the ModuleConfigs inner object holds this same map. */ readonly moduleConfigs: Record; #moduleReferences?: readonly ModuleReference[]; + /** Filled during init(); the runtimeConfig inner object holds this same object. */ + readonly #runtimeConfig: Partial = {}; readonly env?: string; readonly name?: string; readonly options?: StandaloneAppOptions; @@ -131,15 +133,10 @@ export class StandaloneApp { ], }; - const runtimeConfig: Partial = { - baseDir: this.cwd, - name: this.name, - env: this.env, - }; - // Inject runtimeConfig + // Inject runtimeConfig (placeholder; values are assigned during init()) this.innerObjects.runtimeConfig = [ { - obj: runtimeConfig, + obj: this.#runtimeConfig, }, ]; @@ -151,12 +148,18 @@ export class StandaloneApp { } /** - * Fill the placeholder maps created in the constructor: load every module's - * config and expose it as a qualified `moduleConfig` inner object. Runs at - * init() so the module scan (the `moduleReferences` getter) stays off the - * construction path. + * Fill the placeholders created in the constructor: assign runtimeConfig + * values, load every module's config and expose it as a qualified + * `moduleConfig` inner object. Runs at init() so the module scan (the + * `moduleReferences` getter) stays off the construction path. */ - private loadModuleConfigs(): void { + private loadConfigs(): void { + Object.assign(this.#runtimeConfig, { + baseDir: this.cwd, + name: this.name, + env: this.env, + }); + // 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. @@ -322,7 +325,7 @@ export class StandaloneApp { async init(): Promise { await this.runInScope(async () => { - this.loadModuleConfigs(); + this.loadConfigs(); await this.initLoaderInstance(); await this.instantiateInnerObjectLoadUnit(); await this.instantiateModuleLoadUnits(); From 689497b8ead20391789937a69015b292c4007459 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:41:32 +0800 Subject: [PATCH 24/30] feat(standalone): accept a logger via StandaloneAppOptions options.logger backs the logger inner object and the loadMetadata loader; an explicit innerObjectHandlers logger entry still wins, console remains the last resort. Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/src/StandaloneApp.ts | 13 ++++++++++--- tegg/standalone/standalone/test/index.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 71c1930916..14dd5b9c69 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -36,6 +36,11 @@ export interface ModuleDependency extends ReadModuleReferenceOptions { export interface StandaloneAppOptions { env?: string; name?: string; + /** + * Logger used by the framework (loader, hooks) and injectable as the + * `logger` inner object. Defaults to console. + */ + logger?: Logger; innerObjectHandlers?: Record; dependencies?: (string | ModuleDependency)[]; /** @@ -143,8 +148,10 @@ export class StandaloneApp { if (options?.innerObjectHandlers) { Object.assign(this.innerObjects, options.innerObjectHandlers); } - // Framework hooks (e.g. DAL) inject `logger`; make sure it always resolves. - this.innerObjects.logger ??= [{ obj: console }]; + // Framework hooks (e.g. DAL) inject `logger`; make sure it always + // resolves. An innerObjectHandlers entry wins, then options.logger, + // console as the last resort. + this.innerObjects.logger ??= [{ obj: options?.logger ?? console }]; } /** @@ -256,7 +263,7 @@ export class StandaloneApp { const moduleReferences = StandaloneApp.getModuleReferences(cwd, options?.dependencies, options?.frameworkDeps); return await TeggScope.run(TeggScope.createBag(), async () => { const loader = new EggModuleLoader(moduleReferences, { - logger: console, + logger: options?.logger ?? console, baseDir: cwd, dump: false, loaderFS: options?.loaderFS, diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 7070b06721..77715e5e55 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -68,6 +68,20 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('custom logger option', () => { + it('should expose options.logger as the logger inner object', async () => { + const customLogger = { ...console }; + const app = new StandaloneApp(path.join(__dirname, './fixtures/inner-object'), { + logger: customLogger, + }); + try { + assert.equal(app.innerObjects.logger[0].obj, customLogger); + } finally { + await app.destroy(); + } + }); + }); + describe('runner with custom context', () => { it('should work', async () => { const runner = new StandaloneApp(path.join(__dirname, './fixtures/custom-context')); From c1332de0ce4e2a27fde0e36b396c813bbc05ddef Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:47:40 +0800 Subject: [PATCH 25/30] refactor(standalone): make StandaloneApp.init idempotent Same contract as ServiceWorkerApp.init: a repeated call returns instead of re-pushing moduleConfig inner objects and re-creating load units. Matters more now that config loading runs inside init(). Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/src/StandaloneApp.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 14dd5b9c69..1891760792 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -66,6 +66,7 @@ export class StandaloneApp { /** Filled during init(); the ModuleConfigs inner object holds this same map. */ readonly moduleConfigs: Record; #moduleReferences?: readonly ModuleReference[]; + #initialized = false; /** Filled during init(); the runtimeConfig inner object holds this same object. */ readonly #runtimeConfig: Partial = {}; readonly env?: string; @@ -331,6 +332,11 @@ export class StandaloneApp { } async init(): Promise { + // Idempotent (same contract as ServiceWorkerApp.init): a second call must + // not re-push moduleConfig inner objects or re-create load units. + if (this.#initialized) { + return; + } await this.runInScope(async () => { this.loadConfigs(); await this.initLoaderInstance(); @@ -338,6 +344,7 @@ export class StandaloneApp { await this.instantiateModuleLoadUnits(); this.initRunner(); }); + this.#initialized = true; } async run(aCtx?: EggContext): Promise { From e7fd82c48983ff5ffcfdd0c9dd415a9d9cebfd42 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:51:48 +0800 Subject: [PATCH 26/30] fix(standalone): keep host moduleConfig handler override semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadConfigs fills the constructor's own placeholder list instead of whatever sits under innerObjects.moduleConfig, so a host that overrides the key via innerObjectHandlers fully replaces the built-in entries — the pre-refactor replace-wins behavior. Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/src/StandaloneApp.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 1891760792..a1718741cc 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -69,6 +69,12 @@ export class StandaloneApp { #initialized = false; /** Filled during init(); the runtimeConfig inner object holds this same object. */ readonly #runtimeConfig: Partial = {}; + /** + * Filled during init() with one qualified entry per module. A host that + * overrides `moduleConfig` via innerObjectHandlers replaces the registered + * list entirely — the fills below stay invisible, as before. + */ + readonly #moduleConfigList: InnerObject[] = []; readonly env?: string; readonly name?: string; readonly options?: StandaloneAppOptions; @@ -131,7 +137,7 @@ export class StandaloneApp { obj: new ModuleConfigs(this.moduleConfigs), }, ], - moduleConfig: [], + moduleConfig: this.#moduleConfigList, mysqlDataSourceManager: [ { obj: MysqlDataSourceManager.instance, @@ -186,7 +192,7 @@ export class StandaloneApp { }; } for (const moduleConfig of Object.values(this.moduleConfigs)) { - this.innerObjects.moduleConfig.push({ + this.#moduleConfigList.push({ obj: moduleConfig.config, qualifiers: [ { From fd75bb478a179b5a99ac2ed069b72ba0e17115c9 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:05:19 +0800 Subject: [PATCH 27/30] refactor(standalone): build framework base inner objects at the point of use Align with the egg host's ModuleHandler: moduleConfigs/moduleConfig/ runtimeConfig/mysqlDataSourceManager/logger are assembled when the InnerObjectLoadUnit is created, not pre-baked in the constructor with placeholder back-fill. The constructor's innerObjects field now carries only the host contract (innerObjectHandlers plus additions between new and init), merged over the base objects so host entries win on name clash. Removes the runtimeConfig/moduleConfig placeholder fields and the constructor's scope-dependent work (no more try/catch or runInScope). Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 121 +++++++----------- .../test/fixtures/logger-option/foo.ts | 13 ++ .../test/fixtures/logger-option/package.json | 7 + tegg/standalone/standalone/test/index.test.ts | 22 +++- 4 files changed, 82 insertions(+), 81 deletions(-) create mode 100644 tegg/standalone/standalone/test/fixtures/logger-option/foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/logger-option/package.json diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index a1718741cc..a1e7d8bf57 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -67,14 +67,6 @@ export class StandaloneApp { readonly moduleConfigs: Record; #moduleReferences?: readonly ModuleReference[]; #initialized = false; - /** Filled during init(); the runtimeConfig inner object holds this same object. */ - readonly #runtimeConfig: Partial = {}; - /** - * Filled during init() with one qualified entry per module. A host that - * overrides `moduleConfig` via innerObjectHandlers replaces the registered - * list entirely — the fills below stay invisible, as before. - */ - readonly #moduleConfigList: InnerObject[] = []; readonly env?: string; readonly name?: string; readonly options?: StandaloneAppOptions; @@ -83,6 +75,13 @@ export class StandaloneApp { loadUnits: LoadUnit[] = []; loadUnitInstances: LoadUnitInstance[] = []; + /** + * Host contract: provided objects merged OVER the framework base objects + * when the InnerObjectLoadUnit is created — hosts may add entries between + * new and init(). The base objects themselves (moduleConfigs/moduleConfig/ + * runtimeConfig/logger/...) are built at that point of use, same shape as + * the egg host's ModuleHandler. + */ innerObjects: Record; // This app's own per-app TeggScope bag — all factories/managers/graph/config @@ -95,19 +94,9 @@ export class StandaloneApp { this.name = options?.name; this.options = options; this.moduleConfigs = {}; + this.innerObjects = { ...options?.innerObjectHandlers }; this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); - try { - // innerObjects is a construction-time contract: hosts may add provided - // objects between new and init(). No fs I/O happens here — module - // configs are loaded into the placeholder maps during init(). - this.runInScope(() => this.initInnerObjects(options)); - } catch (e) { - // Construction failed after the scope was registered; release it so the - // never-returned app does not leak into liveScopeBags. - TeggScope.unregisterScope(this.scopeBag); - throw e; - } } /** @@ -130,50 +119,20 @@ export class StandaloneApp { return TeggScope.run(this.scopeBag, fn); } - private initInnerObjects(options?: StandaloneAppOptions): void { - this.innerObjects = { - moduleConfigs: [ - { - obj: new ModuleConfigs(this.moduleConfigs), - }, - ], - moduleConfig: this.#moduleConfigList, - mysqlDataSourceManager: [ - { - obj: MysqlDataSourceManager.instance, - }, - ], - }; - - // Inject runtimeConfig (placeholder; values are assigned during init()) - this.innerObjects.runtimeConfig = [ - { - obj: this.#runtimeConfig, - }, - ]; - - if (options?.innerObjectHandlers) { - Object.assign(this.innerObjects, options.innerObjectHandlers); - } - // Framework hooks (e.g. DAL) inject `logger`; make sure it always - // resolves. An innerObjectHandlers entry wins, then options.logger, - // console as the last resort. - this.innerObjects.logger ??= [{ obj: options?.logger ?? console }]; + /** + * The framework logger: a host `logger` entry wins, then options.logger, + * console as the last resort. + */ + get #logger(): Logger { + return (this.innerObjects.logger?.[0]?.obj as Logger) ?? this.options?.logger ?? console; } /** - * Fill the placeholders created in the constructor: assign runtimeConfig - * values, load every module's config and expose it as a qualified - * `moduleConfig` inner object. Runs at init() so the module scan (the - * `moduleReferences` getter) stays off the construction path. + * Load every module's config into `this.moduleConfigs`. Runs at init() so + * the module scan (the `moduleReferences` getter) stays off the + * construction path. */ - private loadConfigs(): void { - Object.assign(this.#runtimeConfig, { - baseDir: this.cwd, - name: this.name, - env: this.env, - }); - + private loadModuleConfigs(): void { // 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. @@ -191,17 +150,6 @@ export class StandaloneApp { config: ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path), }; } - for (const moduleConfig of Object.values(this.moduleConfigs)) { - this.#moduleConfigList.push({ - obj: moduleConfig.config, - qualifiers: [ - { - attribute: ConfigSourceQualifierAttribute, - value: moduleConfig.name, - }, - ], - }); - } } /** @@ -282,7 +230,7 @@ export class StandaloneApp { private async initLoaderInstance(): Promise { this.loadUnitLoader = new EggModuleLoader(this.moduleReferences, { - logger: ((this.innerObjects.logger && this.innerObjects.logger[0])?.obj as Logger) || console, + logger: this.#logger, baseDir: this.cwd, dump: this.options?.dump, manifest: this.options?.manifest, @@ -305,8 +253,33 @@ export class StandaloneApp { path: moduleDescriptor.unitPath, }); } + // Framework base objects, built at the point of use from this app's + // surface — the same shape as the egg host's ModuleHandler. Host entries + // (constructor innerObjectHandlers or additions between new and init) + // win on name clash via the spread. + const runtimeConfig: Partial = { + baseDir: this.cwd, + name: this.name, + env: this.env, + }; + const moduleConfigList: InnerObject[] = Object.values(this.moduleConfigs).map((moduleConfig) => ({ + obj: moduleConfig.config, + qualifiers: [ + { + attribute: ConfigSourceQualifierAttribute, + value: moduleConfig.name, + }, + ], + })); const innerObjectLoadUnit = await builder.createLoadUnit({ - innerObjects: this.innerObjects, + innerObjects: { + moduleConfigs: [{ obj: new ModuleConfigs(this.moduleConfigs) }], + moduleConfig: moduleConfigList, + runtimeConfig: [{ obj: runtimeConfig }], + mysqlDataSourceManager: [{ obj: MysqlDataSourceManager.instance }], + logger: [{ obj: this.#logger }], + ...this.innerObjects, + }, }); this.loadUnits.push(innerObjectLoadUnit); const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); @@ -339,12 +312,12 @@ export class StandaloneApp { async init(): Promise { // Idempotent (same contract as ServiceWorkerApp.init): a second call must - // not re-push moduleConfig inner objects or re-create load units. + // not reload configs or re-create load units. if (this.#initialized) { return; } await this.runInScope(async () => { - this.loadConfigs(); + this.loadModuleConfigs(); await this.initLoaderInstance(); await this.instantiateInnerObjectLoadUnit(); await this.instantiateModuleLoadUnits(); 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 77715e5e55..0b8c763b79 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -69,16 +69,24 @@ describe('standalone/standalone/test/index.test.ts', () => { }); describe('custom logger option', () => { - it('should expose options.logger as the logger inner object', async () => { + it('should inject options.logger as the logger inner object', async () => { const customLogger = { ...console }; - const app = new StandaloneApp(path.join(__dirname, './fixtures/inner-object'), { + const injected = await main(path.join(__dirname, './fixtures/logger-option'), { logger: customLogger, }); - try { - assert.equal(app.innerObjects.logger[0].obj, customLogger); - } finally { - await app.destroy(); - } + 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); }); }); From 0e759a176655b391616d8999c5e2ff750fab005a Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:22:46 +0800 Subject: [PATCH 28/30] refactor(standalone): align StandaloneApp API with tegg#325 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructor takes StandaloneAppInit (frameworkDeps/dump/innerObjects/ logger) — capability wiring only, with the runtimeConfig/moduleConfig(s) placeholders pre-created and registered as inner objects. App binding (name/env/baseDir, plus our dependencies/manifest/loaderFS extras) moves to init(opts): the module scan, config loading and placeholder fill all happen there, so runtimeConfig values come from init options, not the constructor. main(cwd, options) keeps its flat signature and delegates to the new appMain(options, init, ctx) entry, same as the upstream PR. Ref: https://github.com/eggjs/tegg/pull/325 Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 249 ++++++++++-------- tegg/standalone/standalone/src/main.ts | 40 ++- tegg/standalone/standalone/test/index.test.ts | 20 +- 3 files changed, 183 insertions(+), 126 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index a1e7d8bf57..e5f363702f 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -33,16 +33,11 @@ export interface ModuleDependency extends ReadModuleReferenceOptions { baseDir: string; } -export interface StandaloneAppOptions { - env?: string; - name?: string; - /** - * Logger used by the framework (loader, hooks) and injectable as the - * `logger` inner object. Defaults to console. - */ - logger?: Logger; - innerObjectHandlers?: Record; - dependencies?: (string | ModuleDependency)[]; +/** + * 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). @@ -52,6 +47,26 @@ export interface StandaloneAppOptions { */ 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. @@ -61,95 +76,129 @@ export interface StandaloneAppOptions { 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 cwd: string; - /** Filled during init(); the ModuleConfigs inner object holds this same map. */ - readonly moduleConfigs: Record; - #moduleReferences?: readonly ModuleReference[]; + 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; - readonly env?: string; - readonly name?: string; - readonly options?: StandaloneAppOptions; - private loadUnitLoader: EggModuleLoader; - private runnerProto: EggPrototype; + #loadUnitLoader: EggModuleLoader; + #runnerProto: EggPrototype; loadUnits: LoadUnit[] = []; loadUnitInstances: LoadUnitInstance[] = []; - /** - * Host contract: provided objects merged OVER the framework base objects - * when the InnerObjectLoadUnit is created — hosts may add entries between - * new and init(). The base objects themselves (moduleConfigs/moduleConfig/ - * runtimeConfig/logger/...) are built at that point of use, same shape as - * the egg host's ModuleHandler. - */ - innerObjects: Record; // 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(cwd: string, options?: StandaloneAppOptions) { - this.cwd = cwd; - this.env = options?.env; - this.name = options?.name; - this.options = options; - this.moduleConfigs = {}; - this.innerObjects = { ...options?.innerObjectHandlers }; + constructor(init?: StandaloneAppInit) { + this.#frameworkDeps = init?.frameworkDeps ?? []; + this.#dump = init?.dump !== false; this.scopeBag = TeggScope.createBag(); TeggScope.registerScope(this.scopeBag); + try { + // In this app's scope: MysqlDataSourceManager.instance resolves per-app. + this.#innerObjects = this.runInScope(() => this.#createInnerObjects(init)); + this.#logger = this.#innerObjects.logger[0].obj as Logger; + } catch (e) { + // Construction failed after the scope was registered; release it so the + // never-returned app does not leak into liveScopeBags. + TeggScope.unregisterScope(this.scopeBag); + throw e; + } } - /** - * Lazily computed on first access so constructing an app does no fs scan; - * init() is the usual first consumer. Scan errors (e.g. duplicate module - * names) therefore surface at init() where callers already tear down via - * destroy() — never from the constructor. - */ + /** Scanned during init(); empty before that. */ get moduleReferences(): readonly ModuleReference[] { - this.#moduleReferences ??= StandaloneApp.getModuleReferences( - this.cwd, - this.options?.dependencies, - this.options?.frameworkDeps, - ); 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); } - /** - * The framework logger: a host `logger` entry wins, then options.logger, - * console as the last resort. - */ - get #logger(): Logger { - return (this.innerObjects.logger?.[0]?.obj as Logger) ?? this.options?.logger ?? console; + #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 }], + mysqlDataSourceManager: [{ obj: MysqlDataSourceManager.instance }], + }, + 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 }], + }, + ); } - /** - * Load every module's config into `this.moduleConfigs`. Runs at init() so - * the module scan (the `moduleReferences` getter) stays off the - * construction path. - */ - private loadModuleConfigs(): void { + /** 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 = ['module.default', `module.${this.env}`]; - for (const reference of this.moduleReferences) { + 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.cwd), + path: ModuleConfigUtil.resolveModuleDir(reference.path, this.#runtimeConfig.baseDir), name: reference.name, }; const moduleName = ModuleConfigUtil.readModuleNameSync(absoluteRef.path); - this.moduleConfigs[moduleName] = { + 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, + }, + ], + }); + } } /** @@ -174,8 +223,8 @@ export class StandaloneApp { static getModuleReferences( cwd: string, - dependencies?: StandaloneAppOptions['dependencies'], - frameworkDeps?: StandaloneAppOptions['frameworkDeps'], + dependencies?: (string | ModuleDependency)[], + frameworkDeps?: (string | ModuleDependency)[], ): readonly ModuleReference[] { // framework deps first so their modules are scanned ahead of app modules const moduleDirs = (StandaloneApp.builtinFrameworkModules() as (string | ModuleDependency)[]) @@ -197,8 +246,8 @@ export class StandaloneApp { static async preLoad( cwd: string, - dependencies?: StandaloneAppOptions['dependencies'], - frameworkDeps?: StandaloneAppOptions['frameworkDeps'], + dependencies?: (string | ModuleDependency)[], + frameworkDeps?: (string | ModuleDependency)[], ): Promise { const moduleReferences = StandaloneApp.getModuleReferences(cwd, dependencies, frameworkDeps); await EggModuleLoader.preLoad(moduleReferences, { @@ -228,15 +277,15 @@ export class StandaloneApp { }); } - private async initLoaderInstance(): Promise { - this.loadUnitLoader = new EggModuleLoader(this.moduleReferences, { + async #initLoaderInstance(opts: InitStandaloneAppOptions): Promise { + this.#loadUnitLoader = new EggModuleLoader(this.#moduleReferences, { logger: this.#logger, - baseDir: this.cwd, - dump: this.options?.dump, - manifest: this.options?.manifest, - loaderFS: this.options?.loaderFS, + baseDir: opts.baseDir, + dump: this.#dump, + manifest: opts.manifest, + loaderFS: opts.loaderFS, }); - await this.loadUnitLoader.init(); + await this.#loadUnitLoader.init(); } /** @@ -244,42 +293,17 @@ export class StandaloneApp { * graph is built, so `@XxxLifecycleProto` hooks (including graph build hooks * they register in `@LifecyclePostInject`) are live for every later phase. */ - private async instantiateInnerObjectLoadUnit(): Promise { + async #instantiateInnerObjectLoadUnit(): Promise { StandaloneContextHandler.register(); const builder = new InnerObjectLoadUnitBuilder(); - for (const moduleDescriptor of this.loadUnitLoader.moduleDescriptors) { + for (const moduleDescriptor of this.#loadUnitLoader.moduleDescriptors) { builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { name: moduleDescriptor.name, path: moduleDescriptor.unitPath, }); } - // Framework base objects, built at the point of use from this app's - // surface — the same shape as the egg host's ModuleHandler. Host entries - // (constructor innerObjectHandlers or additions between new and init) - // win on name clash via the spread. - const runtimeConfig: Partial = { - baseDir: this.cwd, - name: this.name, - env: this.env, - }; - const moduleConfigList: InnerObject[] = Object.values(this.moduleConfigs).map((moduleConfig) => ({ - obj: moduleConfig.config, - qualifiers: [ - { - attribute: ConfigSourceQualifierAttribute, - value: moduleConfig.name, - }, - ], - })); const innerObjectLoadUnit = await builder.createLoadUnit({ - innerObjects: { - moduleConfigs: [{ obj: new ModuleConfigs(this.moduleConfigs) }], - moduleConfig: moduleConfigList, - runtimeConfig: [{ obj: runtimeConfig }], - mysqlDataSourceManager: [{ obj: MysqlDataSourceManager.instance }], - logger: [{ obj: this.#logger }], - ...this.innerObjects, - }, + innerObjects: this.#innerObjects, }); this.loadUnits.push(innerObjectLoadUnit); const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); @@ -287,8 +311,8 @@ export class StandaloneApp { } /** Phase 3/4: build + sort the business graph, then create and instantiate module load units. */ - private async instantiateModuleLoadUnits(): Promise { - const loadUnits = await this.loadUnitLoader.load(); + async #instantiateModuleLoadUnits(): Promise { + const loadUnits = await this.#loadUnitLoader.load(); this.loadUnits.push(...loadUnits); for (const loadUnit of loadUnits) { const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); @@ -296,7 +320,7 @@ export class StandaloneApp { } } - private initRunner(): void { + #initRunner(): void { const runnerClass = StandaloneUtil.getMainRunner(); if (!runnerClass) { throw new Error('not found runner class. Do you add @Runner decorator?'); @@ -307,21 +331,24 @@ export class StandaloneApp { if (!proto) { throw new Error(`can not get proto for clazz ${runnerClass.name}`); } - this.runnerProto = proto as EggPrototype; + this.#runnerProto = proto as EggPrototype; } - async init(): Promise { + 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.loadModuleConfigs(); - await this.initLoaderInstance(); - await this.instantiateInnerObjectLoadUnit(); - await this.instantiateModuleLoadUnits(); - this.initRunner(); + 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; } @@ -334,7 +361,7 @@ export class StandaloneApp { if (ctx.init) { await ctx.init(lifecycle); } - const eggObject = await EggContainerFactory.getOrCreateEggObject(this.runnerProto); + const eggObject = await EggContainerFactory.getOrCreateEggObject(this.#runnerProto); const runner = eggObject.obj as MainRunner; try { return await runner.main(); diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 8f6ba65fdd..69c10c257e 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -1,4 +1,11 @@ -import { StandaloneApp, type StandaloneAppOptions } from './StandaloneApp.ts'; +import type { EggContext } from '@eggjs/tegg-runtime'; + +import { + StandaloneApp, + type InitStandaloneAppOptions, + type StandaloneAppInit, + type StandaloneAppOptions, +} from './StandaloneApp.ts'; export async function preLoad(cwd: string, dependencies?: StandaloneAppOptions['dependencies']): Promise { try { @@ -11,10 +18,14 @@ export async function preLoad(cwd: string, dependencies?: StandaloneAppOptions[' } } -export async function main(cwd: string, options?: StandaloneAppOptions): Promise { - const app = new StandaloneApp(cwd, options); +export async function appMain( + options: InitStandaloneAppOptions, + init?: StandaloneAppInit, + ctx?: EggContext, +): Promise { + const app = new StandaloneApp(init); try { - await app.init(); + await app.init(options); } catch (e) { if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap tegg failed: ${e.message}`; @@ -27,7 +38,7 @@ export async function main(cwd: string, options?: StandaloneAppOptions throw e; } try { - return await app.run(); + return await app.run(ctx); } finally { app.destroy().catch((e) => { e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; @@ -35,3 +46,22 @@ export async function main(cwd: string, options?: StandaloneAppOptions }); } } + +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/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 0b8c763b79..63c4b3581a 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -92,8 +92,8 @@ describe('standalone/standalone/test/index.test.ts', () => { describe('runner with custom context', () => { it('should work', async () => { - const runner = new StandaloneApp(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); @@ -161,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: '', }); }); @@ -233,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 StandaloneApp(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(); @@ -261,8 +261,8 @@ describe('standalone/standalone/test/index.test.ts', () => { }); it('should work', async () => { - runner = new StandaloneApp(path.join(__dirname, './fixtures/simple')); - await runner.init(); + 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()) { @@ -278,8 +278,8 @@ describe('standalone/standalone/test/index.test.ts', () => { }); it('should work with multi', async () => { - runner = new StandaloneApp(path.join(__dirname, './fixtures/multi-callback-instance-module')); - await runner.init(); + 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()) { From 5bcb6ff0d5af3f281319d12fca93a0636df22b39 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:33:08 +0800 Subject: [PATCH 29/30] refactor(standalone): discover builtin framework plugins via own package.json Drop the hand-maintained builtinFrameworkModules list. The standalone package root joins the scan as the built-in framework root, so its own package.json dependencies that declare eggModule (aop/dal/config plugin packages) are discovered through the same node_modules convention as app dependencies. Adding a framework capability is now just adding the dependency. Co-Authored-By: Claude Fable 5 --- .../standalone/src/StandaloneApp.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index e5f363702f..818e548591 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -201,33 +201,24 @@ export class StandaloneApp { } } - /** - * Built-in framework module plugins, consumed through the SAME module scan - * as any business module (their `@InnerObjectProto` / `@XxxLifecycleProto` - * classes are diverted into the InnerObjectLoadUnit by loadApp) — no - * hand-fed class lists. The packages declare `eggModule` metadata; the - * default file pattern already excludes `test/`. - */ - static builtinFrameworkModules(): ModuleDependency[] { - // The packages ARE the modules; never pick their test fixture modules up - // (workspace/dev layouts ship test/ next to src/). - const scan = { extraFilePattern: ['!test/**'] }; - return [ - // The PLUGIN packages are the modules (teggAop/teggDal/teggConfig) for - // both hosts; their egg imports are type-only so the scan is host-safe. - { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/aop-plugin/package.json'))), ...scan }, - { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/dal-plugin/package.json'))), ...scan }, - { baseDir: path.dirname(fileURLToPath(import.meta.resolve('@eggjs/tegg-config/package.json'))), ...scan }, - ]; - } - 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 = (StandaloneApp.builtinFrameworkModules() as (string | ModuleDependency)[]) + const moduleDirs = ([standaloneRoot] as (string | ModuleDependency)[]) .concat(frameworkDeps || []) .concat(dependencies || []) .concat(cwd); From 0a330b735dca13ab6cbd318f2a30a792ea199cf0 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:40:51 +0800 Subject: [PATCH 30/30] refactor(standalone): move dal manager cleanup into the dal module itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DalModuleLoadUnitHook gains an EggObjectLifecycle destroy that clears MysqlDataSourceManager/SqlMapManager/TableModelManager when the InnerObjectLoadUnit instance goes down (after every business load unit) — the standalone counterpart of the dal plugin's egg-side beforeClose. StandaloneApp drops its dal-plugin import entirely: the unconsumed mysqlDataSourceManager inner object entry (no injector anywhere) and the host-side clear() calls are gone, and with no scope-resolved work left in the constructor the runInScope/try-catch wrapper goes too. Co-Authored-By: Claude Fable 5 --- .../dal/src/lib/DalModuleLoadUnitHook.ts | 15 ++++++++++++++ .../standalone/src/StandaloneApp.ts | 20 ++++--------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index 9212c9e003..e83c334387 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -6,6 +6,8 @@ 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 { @@ -51,4 +53,17 @@ export class DalModuleLoadUnitHook implements LifecycleHook { + MysqlDataSourceManager.instance.clear(); + SqlMapManager.instance.clear(); + TableModelManager.instance.clear(); + } } diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 818e548591..55ac731450 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { MysqlDataSourceManager, SqlMapManager, TableModelManager } from '@eggjs/dal-plugin'; 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'; @@ -113,18 +112,10 @@ export class StandaloneApp { 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); - try { - // In this app's scope: MysqlDataSourceManager.instance resolves per-app. - this.#innerObjects = this.runInScope(() => this.#createInnerObjects(init)); - this.#logger = this.#innerObjects.logger[0].obj as Logger; - } catch (e) { - // Construction failed after the scope was registered; release it so the - // never-returned app does not leak into liveScopeBags. - TeggScope.unregisterScope(this.scopeBag); - throw e; - } } /** Scanned during init(); empty before that. */ @@ -148,7 +139,6 @@ export class StandaloneApp { // 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 }], - mysqlDataSourceManager: [{ obj: MysqlDataSourceManager.instance }], }, init?.innerObjects, { @@ -393,10 +383,8 @@ export class StandaloneApp { } } // Framework hooks (ConfigSource/AOP/DAL) live in the InnerObjectLoadUnit - // and deregister themselves when it is destroyed above. - MysqlDataSourceManager.instance.clear(); - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); + // and deregister themselves — and clean up their own managers — when it + // is destroyed above (dal: DalModuleLoadUnitHook#destroy). // clear configNames ModuleConfigUtil.setConfigNames(undefined); }