From fe0d3d43bdd92a7c51d38492848aa40be0f3dae5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 16:40:08 +0800 Subject: [PATCH 01/74] 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 | 92 +------ .../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(+), 198 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 43fbb2d3e0..d540afce9e 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -1,23 +1,14 @@ import { debuglog } from 'node:util'; -import { QualifierUtil } from '@eggjs/core-decorator'; import { FrameworkErrorFormatter } from '@eggjs/errors'; import { Graph, GraphNode, type ModuleReference } from '@eggjs/tegg-common-util'; -import { - InitTypeQualifierAttribute, - type InjectObjectDescriptor, - LoadUnitNameQualifierAttribute, - ObjectInitType, - type ProtoDescriptor, - type QualifierInfo, - TeggScope, - type TeggScopeBag, -} from '@eggjs/tegg-types'; +import { type InjectObjectDescriptor, type ProtoDescriptor, TeggScope, type TeggScopeBag } from '@eggjs/tegg-types'; -import { EggPrototypeNotFound, MultiPrototypeFound } from '../../errors.ts'; +import { EggPrototypeNotFound } from '../../errors.ts'; import type { ModuleDescriptor } from '../ModuleDescriptor.ts'; import { ModuleDependencyMeta, GlobalModuleNode } from './GlobalModuleNode.ts'; import { GlobalModuleNodeBuilder } from './GlobalModuleNodeBuilder.ts'; +import { ProtoGraphUtils, type ProtoNameIndex } from './ProtoGraphUtils.ts'; import { ProtoDependencyMeta, ProtoNode } from './ProtoNode.ts'; const debug = debuglog('tegg/core/metadata/model/graph/GlobalGraph'); @@ -69,7 +60,8 @@ export class GlobalGraph { moduleProtoDescriptorMap: Map; strict: boolean; private buildHooks: GlobalGraphBuildHook[]; - private protoNameNodeMap: Map[]>; + /** Lazily built proto-name index for dependency resolution; invalidated on vertex changes. */ + #protoNameIndex: ProtoNameIndex | null = null; /** * The per-app graph instance used in ModuleLoadUnit, backed by TeggScope: the @@ -99,7 +91,6 @@ export class GlobalGraph { this.strict = options?.strict ?? false; this.moduleProtoDescriptorMap = new Map(); this.buildHooks = []; - this.protoNameNodeMap = new Map(); } registerBuildHook(hook: GlobalGraphBuildHook): void { @@ -114,13 +105,8 @@ export class GlobalGraph { if (!this.protoGraph.addVertex(protoNode)) { throw new Error(`duplicate proto: ${protoNode.val}`); } - let nodes = this.protoNameNodeMap.get(protoNode.val.proto.name); - if (!nodes) { - nodes = []; - this.protoNameNodeMap.set(protoNode.val.proto.name, nodes); - } - nodes.push(protoNode); } + this.#protoNameIndex = null; } build(): void { @@ -188,74 +174,12 @@ export class GlobalGraph { return edge?.val.proto; } - #findDependencyProtoWithDefaultQualifiers( - proto: ProtoDescriptor, - injectObject: InjectObjectDescriptor, - qualifiers: QualifierInfo[], - ): GraphNode[] { - const result: GraphNode[] = []; - for (const node of this.protoNameNodeMap.get(injectObject.objName) ?? []) { - if ( - node.val.selectProto({ - name: injectObject.objName, - qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, qualifiers), - moduleName: proto.instanceModuleName, - }) - ) { - result.push(node); - } - } - return result; - } - findDependencyProtoNode( proto: ProtoDescriptor, injectObject: InjectObjectDescriptor, ): GraphNode | undefined { - // 1. find proto with request - // 2. try to add Context qualifier to find - // 3. try to add self init type qualifier to find - const protos = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, []); - if (protos.length === 0) { - return; - // throw FrameworkErrorFormater.formatError(new EggPrototypeNotFound(injectObject.objName, proto.instanceModuleName)); - } - if (protos.length === 1) { - return protos[0]; - } - - const protoWithContext = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, [ - { - attribute: InitTypeQualifierAttribute, - value: ObjectInitType.CONTEXT, - }, - ]); - if (protoWithContext.length === 1) { - return protoWithContext[0]; - } - - const protoWithSelfInitType = this.#findDependencyProtoWithDefaultQualifiers(proto, injectObject, [ - { - attribute: InitTypeQualifierAttribute, - value: proto.initType, - }, - ]); - if (protoWithSelfInitType.length === 1) { - return protoWithSelfInitType[0]; - } - const loadUnitQualifier = injectObject.qualifiers.find((t) => t.attribute === LoadUnitNameQualifierAttribute); - if (!loadUnitQualifier) { - return this.findDependencyProtoNode(proto, { - ...injectObject, - qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, [ - { - attribute: LoadUnitNameQualifierAttribute, - value: proto.instanceModuleName, - }, - ]), - }); - } - throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(injectObject.objName, injectObject.qualifiers)); + this.#protoNameIndex ??= ProtoGraphUtils.buildProtoNameIndex(this.protoGraph); + return ProtoGraphUtils.findDependencyProtoNode(this.protoGraph, proto, injectObject, this.#protoNameIndex); } findModuleNode(moduleName: string): GraphNode | undefined { diff --git a/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts b/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts new file mode 100644 index 0000000000..b12f9ea5b5 --- /dev/null +++ b/tegg/core/metadata/src/model/graph/ProtoGraphUtils.ts @@ -0,0 +1,131 @@ +import { QualifierUtil } from '@eggjs/core-decorator'; +import { FrameworkErrorFormatter } from '@eggjs/errors'; +import type { Graph, GraphNode } from '@eggjs/tegg-common-util'; +import { + type EggPrototypeName, + InitTypeQualifierAttribute, + type InjectObjectDescriptor, + LoadUnitNameQualifierAttribute, + ObjectInitType, + type ProtoDescriptor, + type QualifierInfo, +} from '@eggjs/tegg-types'; + +import { MultiPrototypeFound } from '../../errors.ts'; +import { type ProtoDependencyMeta, type ProtoNode } from './ProtoNode.ts'; +import { type ProtoSelectorContext } from './ProtoSelector.ts'; + +export type ProtoGraph = Graph; +export type ProtoGraphNode = GraphNode; +/** + * Proto nodes grouped by proto name. `selectProto` requires an exact name + * match, so the index is a safe pre-filter that avoids scanning every node + * for every inject object. + */ +export type ProtoNameIndex = Map; + +export class ProtoGraphUtils { + static buildProtoNameIndex(graph: ProtoGraph): ProtoNameIndex { + const index: ProtoNameIndex = new Map(); + for (const node of graph.nodes.values()) { + const name = node.val.proto.name; + let nodes = index.get(name); + if (!nodes) { + nodes = []; + index.set(name, nodes); + } + nodes.push(node); + } + return index; + } + + static findDependencyProtoNode( + graph: ProtoGraph, + proto: ProtoDescriptor, + injectObject: InjectObjectDescriptor, + index?: ProtoNameIndex, + ): ProtoGraphNode | undefined { + // 1. find proto with request + // 2. try to add Context qualifier to find + // 3. try to add self init type qualifier to find + const protos = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers(graph, proto, injectObject, [], index); + if (protos.length === 0) { + return; + } + if (protos.length === 1) { + return protos[0]; + } + + const protoWithContext = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers( + graph, + proto, + injectObject, + [ + { + attribute: InitTypeQualifierAttribute, + value: ObjectInitType.CONTEXT, + }, + ], + index, + ); + if (protoWithContext.length === 1) { + return protoWithContext[0]; + } + + const protoWithSelfInitType = ProtoGraphUtils.#findDependencyProtoWithDefaultQualifiers( + graph, + proto, + injectObject, + [ + { + attribute: InitTypeQualifierAttribute, + value: proto.initType, + }, + ], + index, + ); + if (protoWithSelfInitType.length === 1) { + return protoWithSelfInitType[0]; + } + const loadUnitQualifier = injectObject.qualifiers.find((t) => t.attribute === LoadUnitNameQualifierAttribute); + if (!loadUnitQualifier) { + return ProtoGraphUtils.findDependencyProtoNode( + graph, + proto, + { + ...injectObject, + qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, [ + { + attribute: LoadUnitNameQualifierAttribute, + value: proto.instanceModuleName, + }, + ]), + }, + index, + ); + } + throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(injectObject.objName, injectObject.qualifiers)); + } + + static #findDependencyProtoWithDefaultQualifiers( + graph: ProtoGraph, + proto: ProtoDescriptor, + injectObject: InjectObjectDescriptor, + qualifiers: QualifierInfo[], + index?: ProtoNameIndex, + ): ProtoGraphNode[] { + const candidates: Iterable = index ? (index.get(injectObject.objName) ?? []) : graph.nodes.values(); + const result: ProtoGraphNode[] = []; + const ctx: ProtoSelectorContext = { + name: injectObject.objName, + qualifiers: QualifierUtil.mergeQualifiers(injectObject.qualifiers, qualifiers), + moduleName: proto.instanceModuleName, + }; + for (const node of candidates) { + if (node.val.selectProto(ctx)) { + result.push(node); + } + } + return result; + } +} diff --git a/tegg/core/metadata/src/model/graph/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 90fe8234061b3474c351c802c150ca06c90ea70f Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 19:09:18 +0800 Subject: [PATCH 02/74] 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 90b66f4607..f7ab01cc11 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 bad4f25f3e841d2c74183bf00f469b11382f3bdb Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 19:18:04 +0800 Subject: [PATCH 03/74] 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 a1ad0eba87933fffbd846e290e71ccbf9568eea6 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 20:09:32 +0800 Subject: [PATCH 04/74] 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 14b2d3a179..760d41e23c 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 6c9c5c0d06..406105c6fd 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); @@ -26,7 +27,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 d941ee41393de8b07e7cdd4236b46a188e996d71 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sat, 4 Jul 2026 20:10:42 +0800 Subject: [PATCH 05/74] 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 bb28f2f2d2700e76ca57124d94480b5073ef04d5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 18:10:04 +0800 Subject: [PATCH 06/74] 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 a97ca3e0b633a82cb288d14667cf1835e47930eb Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 20:57:26 +0800 Subject: [PATCH 07/74] 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 5f860b0754..50f9b58303 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -85,9 +85,13 @@ export class LoaderUtil { '!**/*.d.cts', // test runner configuration is not an application module '!**/vitest.config.*', - // 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 97851ed8e00489a79ce5c8c7a87a0e31c01736c5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:15:39 +0800 Subject: [PATCH 08/74] 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 b9016c156d..0624a9e0b5 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 8d902f9b547efff3c5850e541fe4165f71d53745 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:38:15 +0800 Subject: [PATCH 09/74] 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 760d41e23c..89bdfa2a7c 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 f7ab01cc11..dbefc6efbc 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 2afbd54fdfa774b79e0caa5b18c068687c23a0c6 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:45:57 +0800 Subject: [PATCH 10/74] 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 f63b0a8e5e09fae9c7c54292df16d96ced6b7827 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 21:50:58 +0800 Subject: [PATCH 11/74] 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 bb0e787a132b4cad55fd0724e7e15432b295ce75 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:33:06 +0800 Subject: [PATCH 12/74] 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 37cfe28823..cc9cddb381 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 @@ -10,5 +10,8 @@ }, { "path": "../modules/multi-module-controller" + }, + { + "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 5d1d9af5678b7665bc2d277e393b09af2526752b Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:37:51 +0800 Subject: [PATCH 13/74] 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 8c80e5998e27eae5b415b00fd7f1e41f1b847256 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 22:57:37 +0800 Subject: [PATCH 14/74] 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 52bed8695f28cd1cb9659de7c7b9ccb8875b57ef Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:25:49 +0800 Subject: [PATCH 15/74] 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 e1afbef33dbc197881b887b5804a1a80b596c4da Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:30:41 +0800 Subject: [PATCH 16/74] 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 541a0325721752548b8802b1a9e6bb4e54265a53 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:41:07 +0800 Subject: [PATCH 17/74] 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 0376ae657a..50f0f5b491 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 cc9cddb381..37cfe28823 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 @@ -10,8 +10,5 @@ }, { "path": "../modules/multi-module-controller" - }, - { - "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 dbefc6efbc..5a3916d659 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 14b9ad68913bcbadafe48a09c2230d1797ae935e Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:46:59 +0800 Subject: [PATCH 18/74] 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 6f51f315fa6b994b6f6df0bcde13d983ee6ed923 Mon Sep 17 00:00:00 2001 From: gxkl Date: Sun, 5 Jul 2026 23:56:25 +0800 Subject: [PATCH 19/74] 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 b2d71ce3b9b96e273591960df63bfd468dcd26ec Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:26:59 +0800 Subject: [PATCH 20/74] 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 7712d670436144785a25dfe96abcaf73e95fe81e Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:29:27 +0800 Subject: [PATCH 21/74] 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 86ed7f6b3a504f3192f20e8cc1b3819b32cc2e75 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:32:25 +0800 Subject: [PATCH 22/74] 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 7c95e766c0b710212791e6d9f1d814646d30d26b Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:36:30 +0800 Subject: [PATCH 23/74] 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 a219f1dd8d519590c204378bdd7db5a2e9cd1850 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:41:32 +0800 Subject: [PATCH 24/74] 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 930826fdf736463cbfa90ec14fb4e349297488f7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:47:40 +0800 Subject: [PATCH 25/74] 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 8140576afe67522e8e740b49cf7a845add0af993 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 00:51:48 +0800 Subject: [PATCH 26/74] 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 7be195f5eec019415cf84bb3e0a506fa6b29344d Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:05:19 +0800 Subject: [PATCH 27/74] 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 4e7dcbb1a9e1cb8e2ab62bf8deafdd129e52a2c4 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:22:46 +0800 Subject: [PATCH 28/74] 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 8034122cadaaa69615ac8d28d6a66dfc13961b6a Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:33:08 +0800 Subject: [PATCH 29/74] 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 044e7d91e685b325dbd227356ce62cb036f8e484 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:40:51 +0800 Subject: [PATCH 30/74] 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); } From 4bdda2c39d732da4b195911703005797380e4809 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 01:53:31 +0800 Subject: [PATCH 31/74] feat(dal): declare the mysqlDataSourceManager injection surface in the dal module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PUBLIC `@Inject() mysqlDataSourceManager` surface (consumed by business modules; no in-repo framework consumer) is now provided by the dal module itself via an @InnerObjectProto accessor whose constructor return-override hands out the per-app TeggScope singleton the dal hooks populate. Both hosts get the surface uniformly through the module scan; StandaloneApp drops its dal-plugin import again — this time for real. Adds a standalone regression test pinning the injection surface. Co-Authored-By: Claude Fable 5 --- .../src/lib/MysqlDataSourceManagerObject.ts | 20 +++++++++++++++++++ .../test/fixtures/dal-manager-inject/foo.ts | 19 ++++++++++++++++++ .../fixtures/dal-manager-inject/package.json | 7 +++++++ tegg/standalone/standalone/test/index.test.ts | 7 +++++++ 4 files changed, 53 insertions(+) create mode 100644 tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts create mode 100644 tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts create mode 100644 tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts new file mode 100644 index 0000000000..3e005b6160 --- /dev/null +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts @@ -0,0 +1,20 @@ +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; + +import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; + +/** + * PUBLIC injection surface declared by the dal module itself: business + * modules `@Inject() mysqlDataSourceManager` on any host (the counterpart of + * the egg-side `app.mysqlDataSourceManager` extend) — hosts carry no dal + * knowledge. + * + * The constructor return-override hands out the per-app TeggScope singleton + * the dal hooks populate — NOT a second instance — so injected consumers see + * the datasources created by DalModuleLoadUnitHook. + */ +@InnerObjectProto({ name: 'mysqlDataSourceManager', accessLevel: AccessLevel.PUBLIC }) +export class MysqlDataSourceManagerObject { + constructor() { + return MysqlDataSourceManager.instance as unknown as MysqlDataSourceManagerObject; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts new file mode 100644 index 0000000000..9357c28754 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts @@ -0,0 +1,19 @@ +import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import { Inject, SingletonProto } from '@eggjs/tegg'; +import { Runner, type MainRunner } from '@eggjs/tegg/standalone'; + +/** + * Pins the PUBLIC `mysqlDataSourceManager` injection surface: business + * modules inject the dal manager by name (the standalone counterpart of the + * dal plugin's `app.mysqlDataSourceManager` egg extend). + */ +@Runner() +@SingletonProto() +export class Foo implements MainRunner { + @Inject() + mysqlDataSourceManager: MysqlDataSourceManager; + + async main(): Promise { + return typeof this.mysqlDataSourceManager.createDataSource === 'function'; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json new file mode 100644 index 0000000000..7aeb0c5280 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/package.json @@ -0,0 +1,7 @@ +{ + "name": "dalmanagerinject", + "type": "module", + "eggModule": { + "name": "dalmanagerinject" + } +} diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 63c4b3581a..2b34671865 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -291,6 +291,13 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('inject mysqlDataSourceManager inner object', () => { + it('should stay injectable for business modules', async () => { + const ok = await main(path.join(__dirname, './fixtures/dal-manager-inject')); + assert.equal(ok, true); + }); + }); + describe('dal runner', () => { it('should work', async () => { const foo: Foo = await main(path.join(__dirname, './fixtures/dal-module'), { From c5f56ced315931bd5b81724fce470702973185ef Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:02:26 +0800 Subject: [PATCH 32/74] fix(dal): declare manager cleanup via @LifecycleDestroy Inner objects never fall back to interface method names for self lifecycle (EggInnerObjectImpl#callObjectLifecycle guards against hook callback collisions), so the bare destroy() added earlier was never invoked. Decorate the renamed destroyManagers with @LifecycleDestroy and pin the behavior with a deterministic standalone test: the app's per-scope MysqlDataSourceManager holds the datasource after init and is empty after destroy. Co-Authored-By: Claude Fable 5 --- .../dal/src/lib/DalModuleLoadUnitHook.ts | 16 ++++++++------- tegg/standalone/standalone/test/index.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index e83c334387..70d5b4eaec 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -1,6 +1,6 @@ import { Inject, InjectOptional, LoadUnitLifecycleProto } from '@eggjs/core-decorator'; import { DatabaseForker, type DataSourceOptions } from '@eggjs/dal-runtime'; -import type { LifecycleHook } from '@eggjs/lifecycle'; +import { LifecycleDestroy, type LifecycleHook } from '@eggjs/lifecycle'; import type { LoadUnit, LoadUnitLifecycleContext } from '@eggjs/metadata'; import type { ModuleConfigs, RuntimeConfig } from '@eggjs/tegg-common-util'; import type { Logger } from '@eggjs/tegg-types'; @@ -55,13 +55,15 @@ export class DalModuleLoadUnitHook implements LifecycleHook { + @LifecycleDestroy() + async destroyManagers(): Promise { MysqlDataSourceManager.instance.clear(); SqlMapManager.instance.clear(); TableModelManager.instance.clear(); diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 2b34671865..86a28a222f 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; +import { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import { TeggScope } from '@eggjs/tegg-types'; import { type ModuleConfig, ModuleConfigs, ModuleDescriptorDumper } from '@eggjs/tegg/helper'; import { importResolve } from '@eggjs/utils'; import { mm } from 'mm'; @@ -298,6 +300,24 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('dal manager cleanup', () => { + it('should clear dal managers when the app is destroyed', async () => { + const app = new StandaloneApp(); + // THIS app's per-scope manager instance — survives destroy as a plain + // object reference, so we can observe the cleanup. + const manager = TeggScope.run(app.scopeBag, () => MysqlDataSourceManager.instance); + try { + await app.init({ baseDir: path.join(__dirname, './fixtures/dal-module'), env: 'unittest' }); + assert(manager.get('dal', 'foo'), 'datasource created during init'); + } finally { + await app.destroy(); + } + // Cleared by DalModuleLoadUnitHook#destroyManagers (@LifecycleDestroy) + // when the InnerObjectLoadUnit instance goes down. + assert.equal(manager.get('dal', 'foo'), undefined); + }); + }); + describe('dal runner', () => { it('should work', async () => { const foo: Foo = await main(path.join(__dirname, './fixtures/dal-module'), { From eddaf9cb5dc6fb7b034727058dcc083da4033b91 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:08:08 +0800 Subject: [PATCH 33/74] refactor(dal): type MysqlDataSourceManagerObject via declaration merging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged interface gives the wrapper the manager's full public type surface — matching what its constructor actually returns — so the unknown-cast goes away and typed usage is sound. Export it from the package entry. Co-Authored-By: Claude Fable 5 --- tegg/plugin/dal/src/index.ts | 1 + .../plugin/dal/src/lib/MysqlDataSourceManagerObject.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tegg/plugin/dal/src/index.ts b/tegg/plugin/dal/src/index.ts index 7caa89bffd..9eb39f822c 100644 --- a/tegg/plugin/dal/src/index.ts +++ b/tegg/plugin/dal/src/index.ts @@ -4,6 +4,7 @@ export * from './lib/DalModuleLoadUnitHook.ts'; export * from './lib/DalTableEggPrototypeHook.ts'; export * from './lib/DataSource.ts'; export * from './lib/MysqlDataSourceManager.ts'; +export * from './lib/MysqlDataSourceManagerObject.ts'; export * from './lib/SqlMapManager.ts'; export * from './lib/TableModelManager.ts'; export * from './lib/TransactionalAOP.ts'; diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts index 3e005b6160..fcd0b125c0 100644 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts @@ -2,6 +2,14 @@ import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; +/** + * Declaration merging: the wrapper's instance type IS the manager's public + * surface — matching what the constructor actually hands out — so typing an + * injection as MysqlDataSourceManagerObject is as sound as typing it as + * MysqlDataSourceManager. + */ +export interface MysqlDataSourceManagerObject extends MysqlDataSourceManager {} + /** * PUBLIC injection surface declared by the dal module itself: business * modules `@Inject() mysqlDataSourceManager` on any host (the counterpart of @@ -15,6 +23,6 @@ import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; @InnerObjectProto({ name: 'mysqlDataSourceManager', accessLevel: AccessLevel.PUBLIC }) export class MysqlDataSourceManagerObject { constructor() { - return MysqlDataSourceManager.instance as unknown as MysqlDataSourceManagerObject; + return MysqlDataSourceManager.instance; } } From 8096f550247a87d8bb257082e2d83b218842497e Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:13:57 +0800 Subject: [PATCH 34/74] fix(standalone): allowlist scan-declaration deps for unplugin-unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aop/dal/config plugin packages are consumed through the package.json-driven module scan, not code imports — being a dependency IS the declaration. The unused check cannot see that, so ignore them explicitly. Co-Authored-By: Claude Fable 5 --- tegg/standalone/standalone/tsdown.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tegg/standalone/standalone/tsdown.config.ts b/tegg/standalone/standalone/tsdown.config.ts index 6dfb63bdb8..9ab856ad7b 100644 --- a/tegg/standalone/standalone/tsdown.config.ts +++ b/tegg/standalone/standalone/tsdown.config.ts @@ -4,4 +4,12 @@ export default defineConfig({ entry: { index: 'src/index.ts', }, + unused: { + level: 'error', + // Built-in framework module plugins: consumed through the package.json + // driven module scan (readModuleFromNodeModules reads this package's + // dependencies), NOT through code imports — being a dependency IS the + // declaration that makes them join the scan. + ignore: ['@eggjs/aop-plugin', '@eggjs/dal-plugin', '@eggjs/tegg-config'], + }, }); From 060167d47f2405203734249d501b35f6bcf43bd5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:20:23 +0800 Subject: [PATCH 35/74] fix(core-decorator): explicit return types for isolatedDeclarations The dts build (rolldown-plugin-dts with --isolatedDeclarations) requires explicit return annotations on the new module-plugin decorators: EggLifecycleProto/InnerObjectProto return PrototypeDecorator like the existing proto decorators, and the five lifecycle decorator factories get a named factory type. Co-Authored-By: Claude Fable 5 --- .../src/decorator/EggLifecycleProto.ts | 18 +++++++++++------- .../src/decorator/InnerObjectProto.ts | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts index 044b2af14d..ba50e1c9a2 100644 --- a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts +++ b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts @@ -4,8 +4,9 @@ import type { CommonEggLifecycleProtoParams, EggLifecycleProtoParams, EggProtoIm import { PrototypeUtil } from '../util/PrototypeUtil.ts'; import { InnerObjectProto } from './InnerObjectProto.ts'; +import type { PrototypeDecorator } from './Prototype.ts'; -export function EggLifecycleProto(params: CommonEggLifecycleProtoParams) { +export function EggLifecycleProto(params: CommonEggLifecycleProtoParams): PrototypeDecorator { return function (clazz: EggProtoImplClass) { const { type, ...protoParams } = params || {}; assert(type, 'EggLifecycle decorator should have type property'); @@ -17,12 +18,15 @@ export function EggLifecycleProto(params: CommonEggLifecycleProtoParams) { }; } -const createLifecycleProto = (type: CommonEggLifecycleProtoParams['type']) => { +type EggLifecycleProtoDecoratorFactory = (params?: EggLifecycleProtoParams) => PrototypeDecorator; + +const createLifecycleProto = (type: CommonEggLifecycleProtoParams['type']): EggLifecycleProtoDecoratorFactory => { 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'); +export const LoadUnitLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('LoadUnit'); +export const LoadUnitInstanceLifecycleProto: EggLifecycleProtoDecoratorFactory = + createLifecycleProto('LoadUnitInstance'); +export const EggObjectLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggObject'); +export const EggPrototypeLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggPrototype'); +export const EggContextLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('EggContext'); diff --git a/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts b/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts index afc4592044..b0c91ffea0 100644 --- a/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts +++ b/tegg/core/core-decorator/src/decorator/InnerObjectProto.ts @@ -2,9 +2,10 @@ import { EGG_INNER_OBJECT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; import type { EggProtoImplClass, InnerObjectProtoParams } from '@eggjs/tegg-types'; import { PrototypeUtil } from '../util/PrototypeUtil.ts'; +import type { PrototypeDecorator } from './Prototype.ts'; import { SingletonProto } from './SingletonProto.ts'; -export function InnerObjectProto(params?: InnerObjectProtoParams) { +export function InnerObjectProto(params?: InnerObjectProtoParams): PrototypeDecorator { return function (clazz: EggProtoImplClass) { const protoParams = { protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, From f38bfad1bd1c15dd752b69acabcea65539aa5ad7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:43:04 +0800 Subject: [PATCH 36/74] fix(config): default the framework module scan to egg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cnpmcore E2E exposed the gap: apps on the base framework declare no pkg.egg.framework (cnpmcore's egg section is just {typescript:true}), so ModuleScanner skipped the framework scan entirely — aop/dal/config module plugins were never discovered and their hooks silently did not load (AsyncTimer @Advice logs missing). Default the framework to 'egg' per egg-core convention, tolerate unresolvable frameworks for bare fixtures, and drop the egg.framework declarations that earlier review rounds added to plugin test fixtures so the suites now cover the cnpmcore shape. Also: update the @eggjs/tegg helper export snapshot for PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE, sync generated exports maps for files added/moved by this PR, and make ReadModule assertions robust to framework-discovered optional references. Co-Authored-By: Claude Fable 5 --- .../test/__snapshots__/helper.test.ts.snap | 1 + tegg/core/types/package.json | 6 ++++ tegg/plugin/aop/package.json | 2 ++ .../test/fixtures/apps/aop-app/package.json | 5 +-- tegg/plugin/config/package.json | 2 ++ tegg/plugin/config/src/lib/ModuleScanner.ts | 18 ++++++++--- tegg/plugin/config/test/ReadModule.test.ts | 31 ++++++++++--------- .../fixtures/apps/controller-app/package.json | 5 +-- tegg/plugin/dal/package.json | 2 ++ .../test/fixtures/apps/dal-app/package.json | 5 +-- tegg/plugin/tegg/package.json | 2 -- .../apps/inject-module-config/package.json | 5 +-- 12 files changed, 47 insertions(+), 37 deletions(-) diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index d527e95c79..d9fa904669 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -139,6 +139,7 @@ exports[`should helper exports stable 1`] = ` "MultiPrototypeFound": [Function], "NameUtil": [Function], "ObjectUtils": [Function], + "PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE": "PROVIDED_INNER_OBJECT", "ProtoDependencyMeta": [Function], "ProtoDescriptorHelper": [Function], "ProtoDescriptorType": { diff --git a/tegg/core/types/package.json b/tegg/core/types/package.json index edc6614632..45322fc0d8 100644 --- a/tegg/core/types/package.json +++ b/tegg/core/types/package.json @@ -60,6 +60,7 @@ "./controller-decorator/model/types": "./src/controller-decorator/model/types.ts", "./core-decorator": "./src/core-decorator/index.ts", "./core-decorator/ContextProto": "./src/core-decorator/ContextProto.ts", + "./core-decorator/EggLifecycleProto": "./src/core-decorator/EggLifecycleProto.ts", "./core-decorator/enum": "./src/core-decorator/enum/index.ts", "./core-decorator/enum/AccessLevel": "./src/core-decorator/enum/AccessLevel.ts", "./core-decorator/enum/EggType": "./src/core-decorator/enum/EggType.ts", @@ -68,8 +69,10 @@ "./core-decorator/enum/ObjectInitType": "./src/core-decorator/enum/ObjectInitType.ts", "./core-decorator/enum/Qualifier": "./src/core-decorator/enum/Qualifier.ts", "./core-decorator/Inject": "./src/core-decorator/Inject.ts", + "./core-decorator/InnerObjectProto": "./src/core-decorator/InnerObjectProto.ts", "./core-decorator/Metadata": "./src/core-decorator/Metadata.ts", "./core-decorator/model": "./src/core-decorator/model/index.ts", + "./core-decorator/model/EggLifecycleInfo": "./src/core-decorator/model/EggLifecycleInfo.ts", "./core-decorator/model/EggMultiInstancePrototypeInfo": "./src/core-decorator/model/EggMultiInstancePrototypeInfo.ts", "./core-decorator/model/EggPrototypeInfo": "./src/core-decorator/model/EggPrototypeInfo.ts", "./core-decorator/model/InjectConstructorInfo": "./src/core-decorator/model/InjectConstructorInfo.ts", @@ -165,6 +168,7 @@ "./controller-decorator/model/types": "./dist/controller-decorator/model/types.js", "./core-decorator": "./dist/core-decorator/index.js", "./core-decorator/ContextProto": "./dist/core-decorator/ContextProto.js", + "./core-decorator/EggLifecycleProto": "./dist/core-decorator/EggLifecycleProto.js", "./core-decorator/enum": "./dist/core-decorator/enum/index.js", "./core-decorator/enum/AccessLevel": "./dist/core-decorator/enum/AccessLevel.js", "./core-decorator/enum/EggType": "./dist/core-decorator/enum/EggType.js", @@ -173,8 +177,10 @@ "./core-decorator/enum/ObjectInitType": "./dist/core-decorator/enum/ObjectInitType.js", "./core-decorator/enum/Qualifier": "./dist/core-decorator/enum/Qualifier.js", "./core-decorator/Inject": "./dist/core-decorator/Inject.js", + "./core-decorator/InnerObjectProto": "./dist/core-decorator/InnerObjectProto.js", "./core-decorator/Metadata": "./dist/core-decorator/Metadata.js", "./core-decorator/model": "./dist/core-decorator/model/index.js", + "./core-decorator/model/EggLifecycleInfo": "./dist/core-decorator/model/EggLifecycleInfo.js", "./core-decorator/model/EggMultiInstancePrototypeInfo": "./dist/core-decorator/model/EggMultiInstancePrototypeInfo.js", "./core-decorator/model/EggPrototypeInfo": "./dist/core-decorator/model/EggPrototypeInfo.js", "./core-decorator/model/InjectConstructorInfo": "./dist/core-decorator/model/InjectConstructorInfo.js", diff --git a/tegg/plugin/aop/package.json b/tegg/plugin/aop/package.json index 0624a9e0b5..4f56671a32 100644 --- a/tegg/plugin/aop/package.json +++ b/tegg/plugin/aop/package.json @@ -28,6 +28,7 @@ "exports": { ".": "./src/index.ts", "./app": "./src/app.ts", + "./InnerObjects": "./src/InnerObjects.ts", "./lib/AopContextHook": "./src/lib/AopContextHook.ts", "./types": "./src/types.ts", "./package.json": "./package.json" @@ -37,6 +38,7 @@ "exports": { ".": "./dist/index.js", "./app": "./dist/app.js", + "./InnerObjects": "./dist/InnerObjects.js", "./lib/AopContextHook": "./dist/lib/AopContextHook.js", "./types": "./dist/types.js", "./package.json": "./package.json" 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 b592def620..a0496f0b11 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/package.json +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/package.json @@ -1,7 +1,4 @@ { "name": "egg-app", - "type": "module", - "egg": { - "framework": "egg" - } + "type": "module" } diff --git a/tegg/plugin/config/package.json b/tegg/plugin/config/package.json index 50f0f5b491..6d690b57b2 100644 --- a/tegg/plugin/config/package.json +++ b/tegg/plugin/config/package.json @@ -31,6 +31,7 @@ "./agent": "./src/agent.ts", "./app": "./src/app.ts", "./config/config.default": "./src/config/config.default.ts", + "./lib/ConfigSourceLoadUnitHook": "./src/lib/ConfigSourceLoadUnitHook.ts", "./lib/ModuleScanner": "./src/lib/ModuleScanner.ts", "./types": "./src/types.ts", "./package.json": "./package.json" @@ -42,6 +43,7 @@ "./agent": "./dist/agent.js", "./app": "./dist/app.js", "./config/config.default": "./dist/config/config.default.js", + "./lib/ConfigSourceLoadUnitHook": "./dist/lib/ConfigSourceLoadUnitHook.js", "./lib/ModuleScanner": "./dist/lib/ModuleScanner.js", "./types": "./dist/types.js", "./package.json": "./package.json" diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 2b5a02ee54..cf9abd3d95 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -25,13 +25,21 @@ export class ModuleScanner { const appPkg: { egg?: { framework?: string } } = JSON.parse( readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), ); - const framework = appPkg.egg?.framework; - if (!framework) { + // Same convention as egg-core: apps on a custom framework declare it in + // pkg.egg.framework; apps on the base framework declare nothing — default + // to `egg` so framework-shipped module plugins (aop/dal/config) are still + // discovered. + const framework = appPkg.egg?.framework ?? 'egg'; + let frameworkPkg: string; + try { + frameworkPkg = importResolve(`${framework}/package.json`, { + paths: [this.baseDir], + }); + } catch { + // No resolvable framework package next to the app (e.g. unit fixtures + // without node_modules) — app modules only. return ModuleConfigUtil.deduplicateModules(moduleReferences); } - const frameworkPkg = importResolve(`${framework}/package.json`, { - paths: [this.baseDir], - }); const frameworkDir = path.dirname(frameworkPkg); debug('loadModuleReferences from framework:%o, frameworkDir:%o', framework, frameworkDir); const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index 2381eb28e8..ba0d8cdcc8 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -18,24 +18,27 @@ describe('plugin/config/test/ReadModule.test.ts', () => { }); it('should work', () => { - expect(app.moduleConfigs).toEqual({ - moduleA: { - config: {}, - name: 'moduleA', - reference: { - optional: undefined, - name: 'moduleA', - path: getFixtures('apps/app-with-modules/app/module-a'), - }, - }, - }); - expect(app.moduleReferences).toEqual([ - { + // The app's own module, exactly as scanned. + expect(app.moduleConfigs.moduleA).toEqual({ + config: {}, + name: 'moduleA', + reference: { optional: undefined, name: 'moduleA', path: getFixtures('apps/app-with-modules/app/module-a'), }, - ]); + }); + expect(app.moduleReferences).toContainEqual({ + optional: undefined, + name: 'moduleA', + path: getFixtures('apps/app-with-modules/app/module-a'), + }); + // Framework module plugins are discovered through the default framework + // (`egg`) scan even when the app declares no pkg.egg.framework — the + // cnpmcore shape. They join as OPTIONAL references until plugin + // promotion. + const teggConfigRef = app.moduleReferences.find((ref) => ref.name === 'teggConfig'); + expect(teggConfigRef?.optional).toBe(true); }); it('should type defines work', () => { diff --git a/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json b/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json index 6afd4fd704..44ed5faf32 100644 --- a/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json +++ b/tegg/plugin/controller/test/fixtures/apps/controller-app/package.json @@ -1,7 +1,4 @@ { "name": "controller-app", - "type": "module", - "egg": { - "framework": "egg" - } + "type": "module" } diff --git a/tegg/plugin/dal/package.json b/tegg/plugin/dal/package.json index 13f405a79f..1e0080c15b 100644 --- a/tegg/plugin/dal/package.json +++ b/tegg/plugin/dal/package.json @@ -34,6 +34,7 @@ "./lib/DalTableEggPrototypeHook": "./src/lib/DalTableEggPrototypeHook.ts", "./lib/DataSource": "./src/lib/DataSource.ts", "./lib/MysqlDataSourceManager": "./src/lib/MysqlDataSourceManager.ts", + "./lib/MysqlDataSourceManagerObject": "./src/lib/MysqlDataSourceManagerObject.ts", "./lib/SqlMapManager": "./src/lib/SqlMapManager.ts", "./lib/TableModelManager": "./src/lib/TableModelManager.ts", "./lib/TransactionalAOP": "./src/lib/TransactionalAOP.ts", @@ -51,6 +52,7 @@ "./lib/DalTableEggPrototypeHook": "./dist/lib/DalTableEggPrototypeHook.js", "./lib/DataSource": "./dist/lib/DataSource.js", "./lib/MysqlDataSourceManager": "./dist/lib/MysqlDataSourceManager.js", + "./lib/MysqlDataSourceManagerObject": "./dist/lib/MysqlDataSourceManagerObject.js", "./lib/SqlMapManager": "./dist/lib/SqlMapManager.js", "./lib/TableModelManager": "./dist/lib/TableModelManager.js", "./lib/TransactionalAOP": "./dist/lib/TransactionalAOP.js", 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 5ec33feadd..089e700fc8 100644 --- a/tegg/plugin/dal/test/fixtures/apps/dal-app/package.json +++ b/tegg/plugin/dal/test/fixtures/apps/dal-app/package.json @@ -1,7 +1,4 @@ { "name": "dal-app", - "type": "module", - "egg": { - "framework": "egg" - } + "type": "module" } diff --git a/tegg/plugin/tegg/package.json b/tegg/plugin/tegg/package.json index 1e5439f30f..6d48e8ab04 100644 --- a/tegg/plugin/tegg/package.json +++ b/tegg/plugin/tegg/package.json @@ -35,7 +35,6 @@ "./lib/AppLoadUnit": "./src/lib/AppLoadUnit.ts", "./lib/AppLoadUnitInstance": "./src/lib/AppLoadUnitInstance.ts", "./lib/CompatibleUtil": "./src/lib/CompatibleUtil.ts", - "./lib/ConfigSourceLoadUnitHook": "./src/lib/ConfigSourceLoadUnitHook.ts", "./lib/ctx_lifecycle_middleware": "./src/lib/ctx_lifecycle_middleware.ts", "./lib/EggAppLoader": "./src/lib/EggAppLoader.ts", "./lib/EggCompatibleObject": "./src/lib/EggCompatibleObject.ts", @@ -64,7 +63,6 @@ "./lib/AppLoadUnit": "./dist/lib/AppLoadUnit.js", "./lib/AppLoadUnitInstance": "./dist/lib/AppLoadUnitInstance.js", "./lib/CompatibleUtil": "./dist/lib/CompatibleUtil.js", - "./lib/ConfigSourceLoadUnitHook": "./dist/lib/ConfigSourceLoadUnitHook.js", "./lib/ctx_lifecycle_middleware": "./dist/lib/ctx_lifecycle_middleware.js", "./lib/EggAppLoader": "./dist/lib/EggAppLoader.js", "./lib/EggCompatibleObject": "./dist/lib/EggCompatibleObject.js", diff --git a/tegg/plugin/tegg/test/fixtures/apps/inject-module-config/package.json b/tegg/plugin/tegg/test/fixtures/apps/inject-module-config/package.json index 64c27536c1..2e715e183c 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,7 +1,4 @@ { "name": "inject-module-config", - "type": "module", - "egg": { - "framework": "egg" - } + "type": "module" } From 4a47fa723e8abbfcbb541069bccd7695713f1000 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 02:54:00 +0800 Subject: [PATCH 37/74] refactor(config): resolve the framework dir with getFrameworkPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegate to the canonical @eggjs/utils convention: explicit pkg.egg.framework wins, default egg otherwise. Tried and rejected deriving from loader.eggPaths — the outermost chain entry is @eggjs/mock under tests, not the business framework. Co-Authored-By: Claude Fable 5 --- tegg/plugin/config/src/lib/ModuleScanner.ts | 46 +++++++++++---------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index cf9abd3d95..d973f0103f 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -1,9 +1,7 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { debuglog } from 'node:util'; import { ModuleConfigUtil, type ModuleReference, type ReadModuleReferenceOptions } from '@eggjs/tegg-common-util'; -import { importResolve } from '@eggjs/utils'; +import { getFrameworkPath } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); @@ -16,32 +14,36 @@ export class ModuleScanner { this.readModuleOptions = readModuleOptions; } + /** + * Directory of THE framework the app runs on — its own package.json + * dependencies declare the module plugins it ships. Resolved with the + * canonical convention (`getFrameworkPath`): explicit `pkg.egg.framework` + * first, default `egg` otherwise — so apps that declare nothing (e.g. + * cnpmcore) still get the egg-shipped module plugins, and chair apps + * (which declare their framework) automatically scan chair. + */ + private resolveFrameworkDir(): string | undefined { + try { + return getFrameworkPath({ baseDir: this.baseDir }); + } catch { + // No package.json or no resolvable framework next to the app (e.g. + // bare unit fixtures without node_modules) — app modules only. + return undefined; + } + } + /** * - load module references from config or scan from baseDir - * - load framework module as optional module reference + * - load the framework's module plugins as OPTIONAL references + * (plugin promotion flips the enabled ones to non-optional) */ loadModuleReferences(): readonly ModuleReference[] { const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); - const appPkg: { egg?: { framework?: string } } = JSON.parse( - readFileSync(path.join(this.baseDir, 'package.json'), 'utf-8'), - ); - // Same convention as egg-core: apps on a custom framework declare it in - // pkg.egg.framework; apps on the base framework declare nothing — default - // to `egg` so framework-shipped module plugins (aop/dal/config) are still - // discovered. - const framework = appPkg.egg?.framework ?? 'egg'; - let frameworkPkg: string; - try { - frameworkPkg = importResolve(`${framework}/package.json`, { - paths: [this.baseDir], - }); - } catch { - // No resolvable framework package next to the app (e.g. unit fixtures - // without node_modules) — app modules only. + const frameworkDir = this.resolveFrameworkDir(); + if (!frameworkDir) { return ModuleConfigUtil.deduplicateModules(moduleReferences); } - const frameworkDir = path.dirname(frameworkPkg); - debug('loadModuleReferences from framework:%o, frameworkDir:%o', framework, frameworkDir); + debug('loadModuleReferences from frameworkDir:%o', frameworkDir); const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); // Merge all module references and deduplicate From 1159258ae293f24fca8ba0674f3c1b2657893514 Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 03:08:02 +0800 Subject: [PATCH 38/74] fix(config): tolerate non-materialized module dirs from bundle manifests cnpmcore-snapshot E2E: framework plugin module references restored from a bundle manifest point at directories the bundler never materializes (their code ships externally), so loadModuleConfigs crashed the snapshot build boot reading package.json. Use the manifest-carried name and an empty config when the module dir is absent; disk stays authoritative when it exists. Also refresh the @eggjs/tegg dal export snapshot (hand-fed clazz list removed, MysqlDataSourceManagerObject added). Co-Authored-By: Claude Fable 5 --- tegg/core/tegg/test/__snapshots__/dal.test.ts.snap | 10 +--------- tegg/plugin/config/src/app.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap index 1c1a84dd23..46c5585efd 100644 --- a/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap @@ -58,15 +58,6 @@ 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), @@ -98,6 +89,7 @@ exports[`should dal exports stable 1`] = ` "NO": "NO", }, "MysqlDataSourceManager": [Function], + "MysqlDataSourceManagerObject": [Function], "RowFormat": { "COMPACT": "COMPACT", "COMPRESSED": "COMPRESSED", diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 4346a65f91..9f92704ea9 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -104,11 +104,20 @@ export default class App implements ILifecycleBoot { optional: reference.optional, }; - const moduleName = ModuleConfigUtil.readModuleNameSync(absoluteRef.path); + // Framework plugin modules restored from a bundle manifest are NOT + // materialized inside the bundle output — their code ships externally + // (real node_modules outside the bundle). Fall back to the + // manifest-carried name and an empty config instead of reading their + // package.json/module.yml from a directory that does not exist. + // (Follow-up: carry module configs in the manifest so a module.yml of + // an external module survives bundling.) + const moduleDirExists = fs.existsSync(absoluteRef.path); + const moduleName = + !moduleDirExists && reference.name ? reference.name : ModuleConfigUtil.readModuleNameSync(absoluteRef.path); this.app.moduleConfigs[moduleName] = { name: moduleName, reference: absoluteRef, - config: ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path), + config: moduleDirExists ? ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path) : {}, }; } From 1e4ffba497ec60f0a8b9d6faf99e2b711cbbd99f Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 07:25:18 +0800 Subject: [PATCH 39/74] fix(tegg): JSON-safe descriptor dump and bundle-tolerant module config load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModuleDescriptorDumper: JSON.stringify the string fields; raw interpolation of unitPath/filePath produced invalid JSON on Windows (backslash escapes) — exposed by the new LoaderInnerObject dump test. - ModuleConfigLoader: same tolerance as the config plugin for framework plugin modules restored from a bundle manifest whose directories are not materialized in the bundle output — use the manifest-carried name and skip module.yml loading when the dir is absent. Fixes the cnpmcore-snapshot restore boot (EggAppLoader.load crashed on ENOENT). Co-Authored-By: Claude Fable 5 --- .../metadata/src/model/ModuleDescriptor.ts | 10 ++++++---- tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index cca6fff06c..abf66ac7fc 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -24,8 +24,10 @@ export class ModuleDescriptorDumper { static stringifyDescriptor(moduleDescriptor: ModuleDescriptor): string { return ( '{' + - `"name": "${moduleDescriptor.name}",` + - `"unitPath": "${moduleDescriptor.unitPath}",` + + // JSON.stringify the string fields — unitPath/filePath contain + // backslashes on Windows, raw interpolation produces invalid JSON. + `"name": ${JSON.stringify(moduleDescriptor.name)},` + + `"unitPath": ${JSON.stringify(moduleDescriptor.unitPath)},` + (typeof moduleDescriptor.optional !== 'undefined' ? `"optional": ${moduleDescriptor.optional},` : '') + `"clazzList": [${moduleDescriptor.clazzList .map((t) => { @@ -54,9 +56,9 @@ export class ModuleDescriptorDumper { static stringifyClazz(clazz: EggProtoImplClass, moduleDescriptor: ModuleDescriptor): string { return ( '{' + - `"name": "${clazz.name}",` + + `"name": ${JSON.stringify(clazz.name)},` + (PrototypeUtil.getFilePath(clazz) - ? `"filePath": "${path.relative(moduleDescriptor.unitPath, PrototypeUtil.getFilePath(clazz)!)}"` + ? `"filePath": ${JSON.stringify(path.relative(moduleDescriptor.unitPath, PrototypeUtil.getFilePath(clazz)!))}` : '') + '}' ); diff --git a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts index c8fc646506..ca833fec39 100644 --- a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts +++ b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + import { AccessLevel, type EggProtoImplClass, @@ -53,8 +56,19 @@ export class ModuleConfigLoader { const result: EggProtoImplClass[] = []; const moduleConfigMap: Record = {}; for (const reference of this.app.moduleReferences) { - const moduleName = ModuleConfigUtil.readModuleNameSync(reference.path); - const defaultConfig = ModuleConfigUtil.loadModuleConfigSync(reference.path, undefined, this.app.config.env); + // Same tolerance as the config plugin's loadModuleConfigs: framework + // plugin modules restored from a bundle manifest are NOT materialized + // inside the bundle output (their code ships externally), so read the + // manifest-carried name instead of a package.json that does not exist. + const modulePath = path.isAbsolute(reference.path) + ? reference.path + : path.resolve(this.app.baseDir, reference.path); + const moduleDirExists = fs.existsSync(modulePath); + const moduleName = + !moduleDirExists && reference.name ? reference.name : ModuleConfigUtil.readModuleNameSync(modulePath); + const defaultConfig = moduleDirExists + ? ModuleConfigUtil.loadModuleConfigSync(modulePath, undefined, this.app.config.env) + : undefined; // @eggjs/tegg-config moduleConfigs[module].config overwrite const config = extend(true, {}, defaultConfig, this.app.moduleConfigs[moduleName]?.config); moduleConfigMap[moduleName] = { From 9d904d51e663b35bd6d65df74051d11d03dfd2ef Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 07:47:58 +0800 Subject: [PATCH 40/74] test(tegg): explicit boot-hook timeouts for slow Windows runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tegg vitest projects do not inherit the root hookTimeout (extends is disabled), so app-boot beforeAll hooks run with the 10s default — windows-latest/node22 tipped over on the aop boot and the BundledAppBoot double boot while every test passed. Co-Authored-By: Claude Fable 5 --- tegg/plugin/aop/test/aop.test.ts | 3 ++- tegg/plugin/tegg/test/BundledAppBoot.test.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tegg/plugin/aop/test/aop.test.ts b/tegg/plugin/aop/test/aop.test.ts index d5d817c479..6b06c22370 100644 --- a/tegg/plugin/aop/test/aop.test.ts +++ b/tegg/plugin/aop/test/aop.test.ts @@ -14,12 +14,13 @@ describe('plugin/aop/test/aop.test.ts', () => { return mm.restore(); }); + // App boot can exceed the default 10s hook timeout on slow Windows runners. beforeAll(async () => { app = mm.app({ baseDir: path.join(import.meta.dirname, 'fixtures/apps/aop-app'), }); await app.ready(); - }); + }, 60_000); it('module aop should work', async () => { app.mockCsrf(); diff --git a/tegg/plugin/tegg/test/BundledAppBoot.test.ts b/tegg/plugin/tegg/test/BundledAppBoot.test.ts index 08d8001d26..4c71720126 100644 --- a/tegg/plugin/tegg/test/BundledAppBoot.test.ts +++ b/tegg/plugin/tegg/test/BundledAppBoot.test.ts @@ -65,7 +65,9 @@ describe('plugin/tegg/test/BundledAppBoot.test.ts', () => { app = mm.app({ baseDir, mode: 'single', loaderFS } as Parameters[0]); await app.ready(); bootFallbackGlobTargets = [...fallbackGlobTargets]; - }); + // The double app boot above can exceed the default 10s hook timeout on + // slow Windows runners. + }, 120_000); afterEach(async () => { return mm.restore(); From cd2a241d7ad35303f1339c7aa2a4503b899caa5e Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 6 Jul 2026 07:50:01 +0800 Subject: [PATCH 41/74] test(tegg): align boot-hook timeouts with the root config values 20s matches the root hookTimeout the tegg projects fail to inherit; BundledAppBoot gets 3x for its two boots plus manifest generation. Co-Authored-By: Claude Fable 5 --- tegg/plugin/aop/test/aop.test.ts | 6 ++++-- tegg/plugin/tegg/test/BundledAppBoot.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tegg/plugin/aop/test/aop.test.ts b/tegg/plugin/aop/test/aop.test.ts index 6b06c22370..96538b47ae 100644 --- a/tegg/plugin/aop/test/aop.test.ts +++ b/tegg/plugin/aop/test/aop.test.ts @@ -14,13 +14,15 @@ describe('plugin/aop/test/aop.test.ts', () => { return mm.restore(); }); - // App boot can exceed the default 10s hook timeout on slow Windows runners. + // App boot exceeds the 10s vitest default on slow Windows runners; the tegg + // projects do not inherit the root config's 20s hookTimeout, so state it + // explicitly with the same value. beforeAll(async () => { app = mm.app({ baseDir: path.join(import.meta.dirname, 'fixtures/apps/aop-app'), }); await app.ready(); - }, 60_000); + }, 20_000); it('module aop should work', async () => { app.mockCsrf(); diff --git a/tegg/plugin/tegg/test/BundledAppBoot.test.ts b/tegg/plugin/tegg/test/BundledAppBoot.test.ts index 4c71720126..e5fead707c 100644 --- a/tegg/plugin/tegg/test/BundledAppBoot.test.ts +++ b/tegg/plugin/tegg/test/BundledAppBoot.test.ts @@ -65,9 +65,9 @@ describe('plugin/tegg/test/BundledAppBoot.test.ts', () => { app = mm.app({ baseDir, mode: 'single', loaderFS } as Parameters[0]); await app.ready(); bootFallbackGlobTargets = [...fallbackGlobTargets]; - // The double app boot above can exceed the default 10s hook timeout on - // slow Windows runners. - }, 120_000); + // TWO app boots plus manifest generation: 3x the root config's 20s + // single-boot hookTimeout (which tegg projects do not inherit). + }, 60_000); afterEach(async () => { return mm.restore(); From 555cfcb0608969a08ffd1e1640217964b6890b6b Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 15:56:55 +0800 Subject: [PATCH 42/74] fix(loader): remove redundant test glob excludes --- tegg/core/loader/src/LoaderUtil.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tegg/core/loader/src/LoaderUtil.ts b/tegg/core/loader/src/LoaderUtil.ts index 50f9b58303..5f860b0754 100644 --- a/tegg/core/loader/src/LoaderUtil.ts +++ b/tegg/core/loader/src/LoaderUtil.ts @@ -85,13 +85,9 @@ export class LoaderUtil { '!**/*.d.cts', // test runner configuration is not an application module '!**/vitest.config.*', - // 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) + // not load test/coverage files '!**/test', - '!**/test/**', '!**/coverage', - '!**/coverage/**', // extra file pattern ...(this.config.extraFilePattern || []), ]; From 04365fdc43da8126a732318798ffe3f4d6eba50a Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 15:58:12 +0800 Subject: [PATCH 43/74] chore(tegg-config): debug framework resolution failures --- tegg/plugin/config/src/lib/ModuleScanner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index d973f0103f..7f0a49ddc4 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -25,9 +25,14 @@ export class ModuleScanner { private resolveFrameworkDir(): string | undefined { try { return getFrameworkPath({ baseDir: this.baseDir }); - } catch { + } catch (err) { // No package.json or no resolvable framework next to the app (e.g. // bare unit fixtures without node_modules) — app modules only. + debug( + 'resolve framework dir failed, baseDir: %s, err: %s', + this.baseDir, + err instanceof Error ? err.message : String(err), + ); return undefined; } } From a759f508bd962a66edef3b20e2c4adc8dbeb2e69 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 15:59:05 +0800 Subject: [PATCH 44/74] chore(tegg): remove empty plugin boot hooks --- tegg/plugin/aop/src/app.ts | 9 --------- tegg/plugin/dal/src/app.ts | 8 -------- 2 files changed, 17 deletions(-) diff --git a/tegg/plugin/aop/src/app.ts b/tegg/plugin/aop/src/app.ts index 84a680d53f..ce68f8835d 100644 --- a/tegg/plugin/aop/src/app.ts +++ b/tegg/plugin/aop/src/app.ts @@ -13,15 +13,6 @@ export default class AopAppHook implements ILifecycleBoot { this.app = app; } - configDidLoad(): void { - // The AOP hooks are module plugin classes (@XxxLifecycleProto / - // @InnerObjectProto, incl. the graph build hook registrar): buffer them on - // the moduleHandler (created in the tegg plugin's configDidLoad, which runs - // before ours) so they are instantiated inside the InnerObjectLoadUnit — - // after the business GlobalGraph is created and before build() runs. - // Registration/deregistration is automatic. - } - async didLoad(): Promise { await this.app.moduleHandler.ready(); // The graph already ran the declaratively registered build hooks during diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index 76bae5a497..abd7e1c3cf 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -12,14 +12,6 @@ export default class DalAppBootHook implements ILifecycleBoot { this.app = app; } - configDidLoad(): void { - // The DAL hooks are module plugin classes (@XxxLifecycleProto): buffer them - // on the moduleHandler (created in the tegg plugin's configDidLoad, which - // runs before ours) so they are instantiated inside the InnerObjectLoadUnit - // — with moduleConfigs/runtimeConfig/logger injected — before any business - // load unit is created. Registration/deregistration is automatic. - } - async beforeClose(): Promise { // The per-app DAL managers are resolved/cleared within this app's scope. await TeggScope.run(this.app._teggScopeBag, async () => { From 7f0debe5a1516b8c226ea9bef479dd4eb7a36773 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:00:01 +0800 Subject: [PATCH 45/74] fix(metadata): emit valid module descriptor JSON --- .../core/metadata/src/model/ModuleDescriptor.ts | 13 +++++-------- .../test/ModuleDescriptorDumper.test.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index abf66ac7fc..4b9984a8c8 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -54,14 +54,11 @@ export class ModuleDescriptorDumper { } static stringifyClazz(clazz: EggProtoImplClass, moduleDescriptor: ModuleDescriptor): string { - return ( - '{' + - `"name": ${JSON.stringify(clazz.name)},` + - (PrototypeUtil.getFilePath(clazz) - ? `"filePath": ${JSON.stringify(path.relative(moduleDescriptor.unitPath, PrototypeUtil.getFilePath(clazz)!))}` - : '') + - '}' - ); + const filePath = PrototypeUtil.getFilePath(clazz); + return JSON.stringify({ + name: clazz.name, + ...(filePath ? { filePath: path.relative(moduleDescriptor.unitPath, filePath) } : {}), + }); } static dumpPath(desc: ModuleDescriptor, options?: ModuleDumpOptions): string { diff --git a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts index dd62efe5dc..fb1b15ae6d 100644 --- a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -8,6 +8,23 @@ import { ModuleDescriptorDumper } from '../src/index.js'; import type { ModuleDescriptor } from '../src/index.js'; describe('test/ModuleDescriptorDumper.test.ts', () => { + describe('stringifyDescriptor()', () => { + it('should emit valid JSON for clazz without filePath', () => { + class MissingFilePath {} + const desc: ModuleDescriptor = { + name: 'no-file-path', + unitPath: '/tmp/no-file-path', + clazzList: [MissingFilePath as any], + multiInstanceClazzList: [], + innerObjectClazzList: [], + protos: [], + }; + + const json = JSON.parse(ModuleDescriptorDumper.stringifyDescriptor(desc)); + assert.deepEqual(json.clazzList, [{ name: 'MissingFilePath' }]); + }); + }); + describe('getDecoratedFiles()', () => { const loadUnitPath = path.join(__dirname, 'fixtures/modules/load-unit'); From 7a44edceb10284aee5cb3c5131b103a48794e788 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:01:17 +0800 Subject: [PATCH 46/74] fix(standalone): pass framework deps during preload --- tegg/standalone/standalone/src/main.ts | 8 ++++++-- tegg/standalone/standalone/test/index.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 69c10c257e..7aae30e79e 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -7,9 +7,13 @@ import { type StandaloneAppOptions, } from './StandaloneApp.ts'; -export async function preLoad(cwd: string, dependencies?: StandaloneAppOptions['dependencies']): Promise { +export async function preLoad( + cwd: string, + dependencies?: StandaloneAppOptions['dependencies'], + frameworkDeps?: StandaloneAppOptions['frameworkDeps'], +): Promise { try { - await StandaloneApp.preLoad(cwd, dependencies); + await StandaloneApp.preLoad(cwd, dependencies, frameworkDeps); } catch (e) { if (e instanceof Error) { e.message = `[tegg/standalone] bootstrap standalone preLoad failed: ${e.message}`; diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 86a28a222f..4b38825c45 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -18,6 +18,23 @@ import { Foo } from './fixtures/dal-module/src/Foo.ts'; const __dirname = import.meta.dirname; describe('standalone/standalone/test/index.test.ts', () => { + describe('preLoad', () => { + afterEach(() => { + mm.restore(); + }); + + it('should pass frameworkDeps to StandaloneApp.preLoad', async () => { + const calls: unknown[][] = []; + mm(StandaloneApp, 'preLoad', async (...args: unknown[]) => { + calls.push(args); + }); + + await preLoad('/tmp/app', ['dep'], ['framework']); + + assert.deepEqual(calls, [['/tmp/app', ['dep'], ['framework']]]); + }); + }); + describe('simple runner', () => { const fixture = path.join(__dirname, './fixtures/simple'); From 73204be7cf54efbc7be60d01bfbf37149200715f Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:02:42 +0800 Subject: [PATCH 47/74] fix(standalone): await app destroy on success --- .../standalone/src/StandaloneApp.ts | 10 +++-- tegg/standalone/standalone/src/main.ts | 10 +++-- tegg/standalone/standalone/test/index.test.ts | 45 ++++++++++++++++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 55ac731450..cd5f7c7984 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -348,9 +348,13 @@ export class StandaloneApp { return await runner.main(); } finally { if (ctx.destroy) { - ctx.destroy(lifecycle).catch((e) => { - e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; - console.warn(e); + await ctx.destroy(lifecycle).catch((e: unknown) => { + if (e instanceof Error) { + e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; + console.warn(e); + return; + } + console.warn('[tegg/standalone] destroy tegg context failed:', e); }); } } diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 7aae30e79e..87720203b6 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -44,9 +44,13 @@ export async function appMain( try { return await app.run(ctx); } finally { - app.destroy().catch((e) => { - e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; - console.warn(e); + await app.destroy().catch((e: unknown) => { + if (e instanceof Error) { + e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`; + console.warn(e); + return; + } + console.warn('[tegg/standalone] destroy tegg failed:', e); }); } } diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 4b38825c45..b5db9332d7 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -11,7 +11,7 @@ import { importResolve } from '@eggjs/utils'; import { mm } from 'mm'; import { describe, it, afterEach, beforeEach } from 'vitest'; -import { main, StandaloneContext, StandaloneApp, preLoad } from '../src/index.ts'; +import { main, StandaloneContext, StandaloneApp, preLoad, appMain } from '../src/index.ts'; import { crosscutAdviceParams, pointcutAdviceParams } from './fixtures/aop-module/Hello.ts'; import { Foo } from './fixtures/dal-module/src/Foo.ts'; @@ -35,6 +35,49 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('appMain', () => { + afterEach(() => { + mm.restore(); + }); + + it('should await app destroy on success', async () => { + const events: string[] = []; + mm(StandaloneApp.prototype, 'init', async () => { + events.push('init'); + }); + mm(StandaloneApp.prototype, 'run', async () => { + events.push('run'); + return 'done'; + }); + mm(StandaloneApp.prototype, 'destroy', async () => { + await sleep(10); + events.push('destroy'); + }); + + const result = await appMain({ baseDir: '/tmp/app' }); + + assert.equal(result, 'done'); + assert.deepEqual(events, ['init', 'run', 'destroy']); + }); + + it('should warn when destroy rejects with non-Error value', async () => { + const warnings: unknown[][] = []; + mm(StandaloneApp.prototype, 'init', async () => {}); + mm(StandaloneApp.prototype, 'run', async () => 'done'); + mm(StandaloneApp.prototype, 'destroy', async () => { + throw 'boom'; + }); + mm(console, 'warn', (...args: unknown[]) => { + warnings.push(args); + }); + + const result = await appMain({ baseDir: '/tmp/app' }); + + assert.equal(result, 'done'); + assert.deepEqual(warnings, [['[tegg/standalone] destroy tegg failed:', 'boom']]); + }); + }); + describe('simple runner', () => { const fixture = path.join(__dirname, './fixtures/simple'); From 20cc7fdb36a0239d9b1e208e03c66fcb334b1a9a Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:03:45 +0800 Subject: [PATCH 48/74] fix(standalone): reject removed innerObjects option --- tegg/standalone/standalone/src/main.ts | 3 +++ tegg/standalone/standalone/test/index.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 87720203b6..4f586d7a4e 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -56,6 +56,9 @@ export async function appMain( } export async function main(cwd: string, options?: StandaloneAppOptions): Promise { + if (options && 'innerObjects' in options) { + throw new Error('[tegg/standalone] options.innerObjects has been removed, use options.innerObjectHandlers instead'); + } return await appMain( { baseDir: cwd, diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index b5db9332d7..390fee8400 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -128,6 +128,17 @@ describe('standalone/standalone/test/index.test.ts', () => { }); assert.equal(msg, 'hello, inner'); }); + + it('should reject removed innerObjects option', async () => { + await assert.rejects( + main(path.join(__dirname, './fixtures/inner-object'), { + innerObjects: { + hello: [{ obj: {} }], + }, + } as any), + /options\.innerObjects has been removed, use options\.innerObjectHandlers instead/, + ); + }); }); describe('custom logger option', () => { From c4d249b5af42099db8ddd88ea6e60fbd9d31fee7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:04:59 +0800 Subject: [PATCH 49/74] docs(tegg): correct module plugin feeding rules --- wiki/concepts/tegg-module-plugin.md | 28 ++++++++++++++++++---------- wiki/log.md | 6 ++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/wiki/concepts/tegg-module-plugin.md b/wiki/concepts/tegg-module-plugin.md index 7aa4b6c1d9..4e5ca44cd1 100644 --- a/wiki/concepts/tegg-module-plugin.md +++ b/wiki/concepts/tegg-module-plugin.md @@ -11,7 +11,10 @@ source_files: - 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 + - tegg/plugin/aop/src/app.ts + - tegg/plugin/config/src/app.ts + - tegg/plugin/dal/src/app.ts +updated_at: 2026-07-09 status: active --- @@ -49,15 +52,20 @@ Hosts: `StandaloneApp.init()` (standalone) and `ModuleHandler.init()` via ## 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). +- Module scanning is the single feed path: the loader diverts + `@InnerObjectProto` / lifecycle proto classes into + `ModuleDescriptor.innerObjectClazzList`. +- Egg (`ModuleHandler.instantiateInnerObjectLoadUnit`) and standalone + (`StandaloneApp.#instantiateInnerObjectLoadUnit`) both iterate loaded + module descriptors and call + `InnerObjectLoadUnitBuilder.addInnerObjectClazzList()`. +- Built-in AOP / DAL / ConfigSource hooks are ordinary module plugin classes + discovered through module references. There are no + `AOP_INNER_OBJECT_CLAZZ_LIST` / `DAL_INNER_OBJECT_CLAZZ_LIST` hard-fed + lists and no `moduleHandler.registerInnerObjectClazzList()` API. +- The builder does not silently dedupe classes: duplicate inner-object proto + ids are hard errors. Package/path dedupe belongs to module reference + discovery before descriptors are loaded. - Host-provided instances (`innerObjects` / `innerObjectHandlers`) become `ProvidedInnerObjectProto`s. Standalone keeps them PUBLIC (business modules inject `moduleConfigs` etc.); the egg host passes PRIVATE for its diff --git a/wiki/log.md b/wiki/log.md index c48831ee38..8b2cdb59f8 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -120,3 +120,9 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - sources touched: `tegg/core/{types,core-decorator,loader,metadata,runtime}`, `tegg/core/aop-runtime`, `tegg/plugin/{tegg,aop,dal}`, `tegg/standalone/standalone` - pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` - note: Ported tegg#325's declarative module plugin core to next and completed it: @InnerObjectProto/@EggLifecycleProto five variants, host-agnostic InnerObjectLoadUnit instantiated before the business graph builds (restores the two-phase ordering so declarative graph build hooks land in-window), egg-host wiring (#325 left app mode out), and conversion of the built-in AOP/DAL/ConfigSource hooks to module plugins on both hosts. Runner renamed to StandaloneApp (no alias). Also fixed plugin/controller's middlewareGraphHook silent no-op (registered on a not-yet-created graph) on branch fix/controller-middleware-graph-hook. + +## [2026-07-09] docs | correct tegg module plugin feeding rules + +- sources touched: `tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/plugin/{aop,config,dal}/src/app.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Corrected stale feeding-rule notes: inner object/lifecycle classes now arrive only through `ModuleDescriptor.innerObjectClazzList`; built-in AOP/DAL/ConfigSource hooks are discovered as normal module plugin classes rather than hard-fed lists, and duplicate inner-object proto ids are errors instead of class-level dedupe. From 5f9de5bca67fce8490e7b336378590f5fb82c262 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:06:02 +0800 Subject: [PATCH 50/74] fix(standalone): reuse manifest module references --- .../standalone/src/StandaloneApp.ts | 7 +++-- tegg/standalone/standalone/test/index.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index cd5f7c7984..4308692703 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -323,8 +323,11 @@ export class StandaloneApp { } await this.runInScope(async () => { this.#initRuntime(opts); - // The module scan happens here — baseDir only arrives at init(). - this.#moduleReferences = StandaloneApp.getModuleReferences(opts.baseDir, opts.dependencies, this.#frameworkDeps); + // In manifest-consume mode the module reference graph was already + // captured at build time; reuse it instead of re-scanning the filesystem. + this.#moduleReferences = opts.manifest?.moduleReferences?.length + ? opts.manifest.moduleReferences + : StandaloneApp.getModuleReferences(opts.baseDir, opts.dependencies, this.#frameworkDeps); this.#loadModuleConfigs(); await this.#initLoaderInstance(opts); await this.#instantiateInnerObjectLoadUnit(); diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 390fee8400..b845cf42ae 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -78,6 +78,34 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); + describe('manifest consume', () => { + afterEach(() => { + mm.restore(); + }); + + it('should reuse manifest moduleReferences without scanning modules', async () => { + const fixture = path.join(__dirname, './fixtures/simple'); + const moduleReferences = StandaloneApp.getModuleReferences(fixture); + mm(StandaloneApp, 'getModuleReferences', () => { + throw new Error('should not scan module references when manifest provides them'); + }); + + const app = new StandaloneApp(); + await app.init({ + baseDir: fixture, + manifest: { + moduleReferences, + moduleDescriptors: [], + }, + }); + try { + assert.deepEqual(app.moduleReferences, moduleReferences); + } finally { + await app.destroy(); + } + }); + }); + describe('simple runner', () => { const fixture = path.join(__dirname, './fixtures/simple'); From e58bbfb900728cc6c0b1969208aa84f0c57b7ead Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:08:00 +0800 Subject: [PATCH 51/74] fix(standalone): let provided inner objects override defaults --- .../standalone/src/StandaloneApp.ts | 35 +++++++++++-------- tegg/standalone/standalone/test/index.test.ts | 27 ++++++++++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 4308692703..e4518fb335 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -134,21 +134,26 @@ export class StandaloneApp { } #createInnerObjects(init?: StandaloneAppInit): Record { - return Object.assign( - { - // Framework hooks (e.g. DAL) inject `logger`; an init.innerObjects - // entry wins over init.logger, console is the last resort. - logger: [{ obj: init?.logger ?? console }], - }, - init?.innerObjects, - { - // Framework placeholders pre-created at construction and filled during - // init() — the inner objects hold these same references. - moduleConfigs: [{ obj: new ModuleConfigs(this.#moduleConfigs) }], - moduleConfig: [] as InnerObject[], - runtimeConfig: [{ obj: this.#runtimeConfig }], - }, - ); + const frameworkInnerObjects = { + // Framework hooks (e.g. DAL) inject `logger`; an init.innerObjects + // entry wins over init.logger, console is the last resort. + logger: [{ obj: init?.logger ?? console }], + // Framework placeholders pre-created at construction and filled during + // init() — the inner objects hold these same references unless the caller + // intentionally overrides them below. + moduleConfigs: [{ obj: new ModuleConfigs(this.#moduleConfigs) }], + moduleConfig: [] as InnerObject[], + runtimeConfig: [{ obj: this.#runtimeConfig }], + }; + const reservedNames = ['moduleConfigs', 'moduleConfig', 'runtimeConfig']; + for (const name of reservedNames) { + if (init?.innerObjects?.[name]) { + (init.logger ?? console).warn( + `[tegg/standalone] innerObjectHandlers.${name} overrides the framework provided inner object`, + ); + } + } + return Object.assign(frameworkInnerObjects, init?.innerObjects); } /** Fill the runtimeConfig placeholder and bind module config names. */ diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index b845cf42ae..1ed123f3bb 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -278,6 +278,33 @@ describe('standalone/standalone/test/index.test.ts', () => { env: 'unittest', }); }); + + it('should let innerObjectHandlers runtimeConfig override framework placeholder with warning', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const runtimeConfig = { + baseDir: 'custom-base-dir', + env: 'custom-env', + name: 'custom-name', + }; + + const injected = await main(path.join(__dirname, './fixtures/runtime-config'), { + logger, + innerObjectHandlers: { + runtimeConfig: [{ obj: runtimeConfig }], + }, + }); + + assert.equal(injected, runtimeConfig); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjectHandlers.runtimeConfig overrides the framework provided inner object'], + ]); + }); }); describe('multi instance prototype runner', () => { From f86cb737e33865b0fc331ce186ad9bb5b6372aee Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:10:46 +0800 Subject: [PATCH 52/74] fix(tegg): narrow lifecycle proto types --- .../src/decorator/EggLifecycleProto.ts | 9 +++++++-- .../test/inner-object-decorators.test.ts | 14 +++++++------- .../types/src/core-decorator/EggLifecycleProto.ts | 3 ++- .../src/core-decorator/model/EggLifecycleInfo.ts | 4 +++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts index ba50e1c9a2..725be55f5f 100644 --- a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts +++ b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts @@ -1,6 +1,11 @@ import assert from 'node:assert'; -import type { CommonEggLifecycleProtoParams, EggLifecycleProtoParams, EggProtoImplClass } from '@eggjs/tegg-types'; +import type { + CommonEggLifecycleProtoParams, + EggLifecycleProtoParams, + EggLifecycleType, + EggProtoImplClass, +} from '@eggjs/tegg-types'; import { PrototypeUtil } from '../util/PrototypeUtil.ts'; import { InnerObjectProto } from './InnerObjectProto.ts'; @@ -20,7 +25,7 @@ export function EggLifecycleProto(params: CommonEggLifecycleProtoParams): Protot type EggLifecycleProtoDecoratorFactory = (params?: EggLifecycleProtoParams) => PrototypeDecorator; -const createLifecycleProto = (type: CommonEggLifecycleProtoParams['type']): EggLifecycleProtoDecoratorFactory => { +const createLifecycleProto = (type: EggLifecycleType): EggLifecycleProtoDecoratorFactory => { return (params?: EggLifecycleProtoParams) => EggLifecycleProto({ type, ...params }); }; diff --git a/tegg/core/core-decorator/test/inner-object-decorators.test.ts b/tegg/core/core-decorator/test/inner-object-decorators.test.ts index dae8a0acdf..4eeab2158d 100644 --- a/tegg/core/core-decorator/test/inner-object-decorators.test.ts +++ b/tegg/core/core-decorator/test/inner-object-decorators.test.ts @@ -41,11 +41,11 @@ class ControllerPrototypeLifecycle {} class ControllerContextLifecycle {} @EggLifecycleProto({ - type: 'CustomLifecycle', + type: 'EggObject', name: 'customName', accessLevel: AccessLevel.PUBLIC, }) -class ControllerOtherLifecycle {} +class CustomNamedLifecycle {} describe('core/core-decorator/test/inner-object-decorators.test.ts', () => { describe('InnerObjectProto', () => { @@ -103,17 +103,17 @@ describe('core/core-decorator/test/inner-object-decorators.test.ts', () => { assertLifecycleProtoMetadata(ControllerContextLifecycle, 'EggContext'); }); - it('should params work with open lifecycle type', () => { + it('should params work with explicit supported lifecycle type', () => { const expectObjectProperty: EggPrototypeInfo = { name: 'customName', initType: ObjectInitType.SINGLETON, accessLevel: AccessLevel.PUBLIC, protoImplType: EGG_INNER_OBJECT_PROTO_IMPL_TYPE, - className: 'ControllerOtherLifecycle', + className: 'CustomNamedLifecycle', }; - assert.deepEqual(PrototypeUtil.getProperty(ControllerOtherLifecycle), expectObjectProperty); - assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(ControllerOtherLifecycle), { - type: 'CustomLifecycle', + assert.deepEqual(PrototypeUtil.getProperty(CustomNamedLifecycle), expectObjectProperty); + assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(CustomNamedLifecycle), { + type: 'EggObject', }); }); diff --git a/tegg/core/types/src/core-decorator/EggLifecycleProto.ts b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts index e744d1195c..c68c38919d 100644 --- a/tegg/core/types/src/core-decorator/EggLifecycleProto.ts +++ b/tegg/core/types/src/core-decorator/EggLifecycleProto.ts @@ -1,7 +1,8 @@ import type { InnerObjectProtoParams } from './InnerObjectProto.ts'; +import type { EggLifecycleType } from './model/EggLifecycleInfo.ts'; export interface CommonEggLifecycleProtoParams extends InnerObjectProtoParams { - type: 'LoadUnit' | 'LoadUnitInstance' | 'EggObject' | 'EggPrototype' | 'EggContext' | string; + type: EggLifecycleType; } export type EggLifecycleProtoParams = Omit; diff --git a/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts b/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts index 4829600bf4..342266bfd6 100644 --- a/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts +++ b/tegg/core/types/src/core-decorator/model/EggLifecycleInfo.ts @@ -1,3 +1,5 @@ +export type EggLifecycleType = 'LoadUnit' | 'LoadUnitInstance' | 'EggObject' | 'EggPrototype' | 'EggContext'; + export interface EggLifecycleInfo { - type: string; + type: EggLifecycleType; } From da6f2cba8d70627dc223dc8f13e7bdb35827b243 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:13:57 +0800 Subject: [PATCH 53/74] fix(tegg): clean partial module init failures --- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 23 +++- .../tegg/test/lib/ModuleHandler.test.ts | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 tegg/plugin/tegg/test/lib/ModuleHandler.test.ts diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index ab9e50065b..5ba14ad406 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -90,8 +90,8 @@ export class ModuleHandler extends Base { await this.loadUnitLoader.initGraph(); const innerObjectInstance = await this.instantiateInnerObjectLoadUnit(); + this.loadUnitInstances.push(innerObjectInstance); await this.loadUnitLoader.load(); - const instances: LoadUnitInstance[] = [innerObjectInstance]; this.app.module = {} as any; const businessInstances: LoadUnitInstance[] = []; @@ -100,11 +100,10 @@ export class ModuleHandler extends Base { if (instance.loadUnit.type !== EggLoadUnitType.APP) { CompatibleUtil.appCompatible(this.app, instance); } - instances.push(instance); + this.loadUnitInstances.push(instance); businessInstances.push(instance); } CompatibleUtil.contextModuleCompatible(this.app.context, businessInstances); - this.loadUnitInstances = instances; this.ready(true); } catch (e) { this.ready(e as Error); @@ -113,22 +112,34 @@ export class ModuleHandler extends Base { } async destroy(): Promise { + const errors: unknown[] = []; + const safe = async (destroy: () => Promise) => { + try { + await destroy(); + } catch (e) { + errors.push(e); + } + }; + // Reverse creation order: business load units go down first, the // InnerObjectLoadUnit last — its lifecycle protos stay registered until // every object they may hook has been destroyed. if (this.loadUnitInstances) { for (const instance of [...this.loadUnitInstances].reverse()) { - await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(instance)); } } if (this.loadUnits) { for (const loadUnit of [...this.loadUnits].reverse()) { - await LoadUnitFactory.destroyLoadUnit(loadUnit); + await safe(() => LoadUnitFactory.destroyLoadUnit(loadUnit)); } } if (this.#innerObjectLoadUnit) { - await LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit); + await safe(() => LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit!)); this.#innerObjectLoadUnit = undefined; } + if (errors.length) { + throw new AggregateError(errors, 'destroy tegg module handler failed'); + } } } diff --git a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts new file mode 100644 index 0000000000..82ec73f6ae --- /dev/null +++ b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; + +import { EggLoadUnitType, LoadUnitFactory, type LoadUnit } from '@eggjs/metadata'; +import { LoadUnitInstanceFactory, type LoadUnitInstance } from '@eggjs/tegg-runtime'; +import { afterEach, describe, it } from 'vitest'; + +import { ModuleHandler } from '../../src/lib/ModuleHandler.ts'; + +function createLoadUnit(name: string): LoadUnit { + return { + name, + type: EggLoadUnitType.APP, + } as unknown as LoadUnit; +} + +function createInstance(name: string): LoadUnitInstance { + return { + loadUnit: createLoadUnit(name), + } as unknown as LoadUnitInstance; +} + +function createHandler(): ModuleHandler { + const app = { + baseDir: '/tmp/app', + config: { env: 'test' }, + context: {}, + eggPrototypeCreatorFactory: { + registerPrototypeCreator() {}, + }, + logger: console, + moduleConfigs: {}, + name: 'test-app', + } as any; + const handler = new ModuleHandler(app); + app.moduleHandler = handler; + (handler as any).loadUnitLoader = { + async initGraph() {}, + async load() {}, + moduleDescriptors: [], + }; + return handler; +} + +describe('plugin/tegg/test/lib/ModuleHandler.test.ts', () => { + afterEach(() => { + mock.reset(); + }); + + it('should track initialized load unit instances before init finishes', async () => { + const handler = createHandler(); + const innerInstance = createInstance('inner'); + const firstLoadUnit = createLoadUnit('first'); + const secondLoadUnit = createLoadUnit('second'); + handler.loadUnits.push(firstLoadUnit, secondLoadUnit); + (handler as any).instantiateInnerObjectLoadUnit = async () => innerInstance; + + mock.method(LoadUnitInstanceFactory, 'createLoadUnitInstance', async (loadUnit: LoadUnit) => { + if (loadUnit === secondLoadUnit) { + throw new Error('create failed'); + } + return { loadUnit } as LoadUnitInstance; + }); + + await assert.rejects(() => handler.init(), /create failed/); + assert.deepEqual( + handler.loadUnitInstances.map((instance) => String(instance.loadUnit.name)), + ['inner', 'first'], + ); + }); + + it('should continue destroying remaining load units and aggregate errors', async () => { + const handler = createHandler(); + const firstLoadUnit = createLoadUnit('first'); + const secondLoadUnit = createLoadUnit('second'); + handler.loadUnitInstances.push(createInstance('inner'), createInstance('business')); + handler.loadUnits.push(firstLoadUnit, secondLoadUnit); + + const destroyedInstances: string[] = []; + const destroyedLoadUnits: string[] = []; + mock.method(LoadUnitInstanceFactory, 'destroyLoadUnitInstance', async (instance: LoadUnitInstance) => { + destroyedInstances.push(String(instance.loadUnit.name)); + if (instance.loadUnit.name === 'business') { + throw new Error('destroy instance failed'); + } + }); + mock.method(LoadUnitFactory, 'destroyLoadUnit', async (loadUnit: LoadUnit) => { + destroyedLoadUnits.push(String(loadUnit.name)); + if (loadUnit === firstLoadUnit) { + throw new Error('destroy load unit failed'); + } + }); + + await assert.rejects( + () => handler.destroy(), + (e: unknown) => { + assert(e instanceof AggregateError); + assert.equal(e.message, 'destroy tegg module handler failed'); + assert.equal(e.errors.length, 2); + return true; + }, + ); + assert.deepEqual(destroyedInstances, ['business', 'inner']); + assert.deepEqual(destroyedLoadUnits, ['second', 'first']); + }); +}); From ee6212ec8488fb3ab5399ffe65f0279648e691e5 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:17:44 +0800 Subject: [PATCH 54/74] refactor(tegg): share tolerant module config resolution --- tegg/core/common-util/src/ModuleConfig.ts | 32 +++++++++++++++++ .../common-util/test/ModuleConfig.test.ts | 34 +++++++++++++++++++ tegg/plugin/config/src/app.ts | 31 +++++------------ .../plugin/tegg/src/lib/ModuleConfigLoader.ts | 27 ++++----------- .../standalone/src/StandaloneApp.ts | 17 +++++----- tegg/standalone/standalone/test/index.test.ts | 2 +- 6 files changed, 90 insertions(+), 53 deletions(-) diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index 25b8ea2aca..b0c87099d4 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -36,6 +36,12 @@ const DEFAULT_READ_MODULE_REF_OPTS = { const CONFIG_NAMES_SLOT = Symbol('tegg:common-util:moduleConfigNames'); +export interface ResolvedModuleConfig { + name: string; + path: string; + config: ModuleConfig; +} + export class ModuleConfigUtil { // Per-app/per-Runner: each standalone Runner (and app) has distinct config // names (env-based); a process-global static races across them (the standalone @@ -306,6 +312,32 @@ export class ModuleConfigUtil { return target; } + public static resolveModuleConfigTolerant( + reference: ModuleReference, + baseDir?: string, + env?: string, + ): ResolvedModuleConfig { + if (!path.isAbsolute(reference.path)) { + assert(baseDir, 'baseDir is required for relative module reference path'); + } + const modulePath = path.isAbsolute(reference.path) ? reference.path : path.resolve(baseDir!, reference.path); + + if (!fs.existsSync(modulePath) && reference.name) { + return { + name: reference.name, + path: modulePath, + config: {}, + }; + } + + const name = ModuleConfigUtil.readModuleNameSync(modulePath); + return { + name, + path: modulePath, + config: ModuleConfigUtil.loadModuleConfigSync(modulePath, undefined, env), + }; + } + static #loadOneSync(moduleDir: string, configName: string): ModuleConfig | undefined { const yamlConfigPath = path.join(moduleDir, `${configName}.yml`); let config = ModuleConfigUtil.#loadYamlSync(yamlConfigPath); diff --git a/tegg/core/common-util/test/ModuleConfig.test.ts b/tegg/core/common-util/test/ModuleConfig.test.ts index 3679c2ff4d..eb77c60023 100644 --- a/tegg/core/common-util/test/ModuleConfig.test.ts +++ b/tegg/core/common-util/test/ModuleConfig.test.ts @@ -54,6 +54,40 @@ describe('test/ModuleConfig.test.ts', () => { // }); }); + describe('resolve module config tolerant', () => { + it('should read existing module config', () => { + const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-module-json'); + const modulePath = path.join(fixturesPath, 'app/module-a'); + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant({ + path: modulePath, + name: 'moduleA', + }); + + assert.deepStrictEqual(resolved, { + name: 'moduleA', + path: modulePath, + config: {}, + }); + }); + + it('should use reference name and empty config when module dir does not exist', () => { + const baseDir = path.join(__dirname, './fixtures/apps/app-with-module-json'); + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant( + { + path: 'external/module-a', + name: 'externalModule', + }, + baseDir, + ); + + assert.deepStrictEqual(resolved, { + name: 'externalModule', + path: path.resolve(baseDir, 'external/module-a'), + config: {}, + }); + }); + }); + describe('load module reference', () => { describe('module.json not exits', () => { it('should work', () => { diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 9f92704ea9..5ef1477a13 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -92,32 +92,17 @@ export default class App implements ILifecycleBoot { #loadModuleConfigs(): void { this.app.moduleConfigs = {}; for (const reference of this.app.moduleReferences) { - // Module reference paths from the manifest / ModuleScanner are absolute or - // relative to baseDir. `ModuleConfigUtil.resolveModuleDir` resolves a - // relative path against `baseDir/config` (the `config/module.json` - // convention), which is wrong here, so resolve against baseDir directly. In - // bundle mode baseDir is the output dir where the bundler copied each - // module's package.json. - const absoluteRef: ModuleReference = { - path: path.isAbsolute(reference.path) ? reference.path : path.resolve(this.app.baseDir, reference.path), + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.app.baseDir); + const resolvedRef: ModuleReference = { + path: resolved.path, name: reference.name, optional: reference.optional, + loaderType: reference.loaderType, }; - - // Framework plugin modules restored from a bundle manifest are NOT - // materialized inside the bundle output — their code ships externally - // (real node_modules outside the bundle). Fall back to the - // manifest-carried name and an empty config instead of reading their - // package.json/module.yml from a directory that does not exist. - // (Follow-up: carry module configs in the manifest so a module.yml of - // an external module survives bundling.) - const moduleDirExists = fs.existsSync(absoluteRef.path); - const moduleName = - !moduleDirExists && reference.name ? reference.name : ModuleConfigUtil.readModuleNameSync(absoluteRef.path); - this.app.moduleConfigs[moduleName] = { - name: moduleName, - reference: absoluteRef, - config: moduleDirExists ? ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path) : {}, + this.app.moduleConfigs[resolved.name] = { + name: resolved.name, + reference: resolvedRef, + config: resolved.config, }; } diff --git a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts index ca833fec39..df22b51be4 100644 --- a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts +++ b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts @@ -1,6 +1,3 @@ -import fs from 'node:fs'; -import path from 'node:path'; - import { AccessLevel, type EggProtoImplClass, @@ -56,25 +53,13 @@ export class ModuleConfigLoader { const result: EggProtoImplClass[] = []; const moduleConfigMap: Record = {}; for (const reference of this.app.moduleReferences) { - // Same tolerance as the config plugin's loadModuleConfigs: framework - // plugin modules restored from a bundle manifest are NOT materialized - // inside the bundle output (their code ships externally), so read the - // manifest-carried name instead of a package.json that does not exist. - const modulePath = path.isAbsolute(reference.path) - ? reference.path - : path.resolve(this.app.baseDir, reference.path); - const moduleDirExists = fs.existsSync(modulePath); - const moduleName = - !moduleDirExists && reference.name ? reference.name : ModuleConfigUtil.readModuleNameSync(modulePath); - const defaultConfig = moduleDirExists - ? ModuleConfigUtil.loadModuleConfigSync(modulePath, undefined, this.app.config.env) - : undefined; + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.app.baseDir, this.app.config.env); // @eggjs/tegg-config moduleConfigs[module].config overwrite - const config = extend(true, {}, defaultConfig, this.app.moduleConfigs[moduleName]?.config); - moduleConfigMap[moduleName] = { - name: moduleName, + const config = extend(true, {}, resolved.config, this.app.moduleConfigs[resolved.name]?.config); + moduleConfigMap[resolved.name] = { + name: resolved.name, reference: { - name: moduleName, + name: resolved.name, path: reference.path, }, config, @@ -101,7 +86,7 @@ export class ModuleConfigLoader { QualifierUtil.addProtoQualifier(func, LoadUnitNameQualifierAttribute, 'app'); QualifierUtil.addProtoQualifier(func, InitTypeQualifierAttribute, ObjectInitType.SINGLETON); QualifierUtil.addProtoQualifier(func, EggQualifierAttribute, EggType.APP); - QualifierUtil.addProtoQualifier(func, ConfigSourceQualifierAttribute, moduleName); + QualifierUtil.addProtoQualifier(func, ConfigSourceQualifierAttribute, resolved.name); result.push(func); } const moduleConfigs = this.loadModuleConfigs(moduleConfigMap); diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index e4518fb335..57d7e9a5aa 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -171,16 +171,17 @@ export class StandaloneApp { /** Load every module's config and expose it as a qualified `moduleConfig` inner object. */ #loadModuleConfigs(): void { for (const reference of this.#moduleReferences) { - const absoluteRef = { - path: ModuleConfigUtil.resolveModuleDir(reference.path, this.#runtimeConfig.baseDir), + const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.#runtimeConfig.baseDir); + const resolvedRef = { + path: resolved.path, name: reference.name, + optional: reference.optional, + loaderType: reference.loaderType, }; - - const moduleName = ModuleConfigUtil.readModuleNameSync(absoluteRef.path); - this.#moduleConfigs[moduleName] = { - name: moduleName, - reference: absoluteRef, - config: ModuleConfigUtil.loadModuleConfigSync(absoluteRef.path), + this.#moduleConfigs[resolved.name] = { + name: resolved.name, + reference: resolvedRef, + config: resolved.config, }; } for (const moduleConfig of Object.values(this.#moduleConfigs)) { diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 1ed123f3bb..c16e3e01a9 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -94,7 +94,7 @@ describe('standalone/standalone/test/index.test.ts', () => { await app.init({ baseDir: fixture, manifest: { - moduleReferences, + moduleReferences: [...moduleReferences], moduleDescriptors: [], }, }); From 695f15aa03a5b817d594df4dfd75f35e4354c1fe Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:20:14 +0800 Subject: [PATCH 55/74] test(tegg): cover inner object multi-app isolation --- tegg/plugin/tegg/test/MultiApp.test.ts | 25 +++++++++++++++++- .../modules/counter-module/CounterService.ts | 26 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tegg/plugin/tegg/test/MultiApp.test.ts b/tegg/plugin/tegg/test/MultiApp.test.ts index 76fd32117c..e0018fb58e 100644 --- a/tegg/plugin/tegg/test/MultiApp.test.ts +++ b/tegg/plugin/tegg/test/MultiApp.test.ts @@ -5,7 +5,10 @@ import { describe, it } from 'vitest'; import { BackgroundCounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/BackgroundCounterService.ts'; import { CounterProducer } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterEvent.ts'; -import { CounterService } from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; +import { + CounterInnerState, + CounterService, +} from './fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts'; import { getAppBaseDir } from './utils.ts'; async function waitFor(predicate: () => boolean, timeout = 2000): Promise { @@ -59,6 +62,26 @@ describe('plugin/tegg/test/MultiApp.test.ts', () => { } }); + it('should isolate public inner object state between two concurrent apps', async () => { + const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); + const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); + await Promise.all([app1.ready(), app2.ready()]); + try { + const inner1 = await app1.getEggObject(CounterInnerState); + const inner2 = await app2.getEggObject(CounterInnerState); + assert.notStrictEqual(inner1, inner2, 'each app must have its own CounterInnerState inner object'); + + const counter1 = await app1.getEggObject(CounterService); + const counter2 = await app2.getEggObject(CounterService); + counter1.incrementInnerState(); + counter1.incrementInnerState(); + assert.equal(counter1.getInnerStateCount(), 2); + assert.equal(counter2.getInnerStateCount(), 0, 'app2 inner object must not be affected by app1 mutations'); + } finally { + await Promise.all([app1.close(), app2.close()]); + } + }); + it('should isolate EventBus emit/handler dispatch between two concurrent apps', async () => { const app1 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation') }); const app2 = mm.app({ baseDir: getAppBaseDir('multi-app-isolation-b') }); diff --git a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts index 118f05bc5a..f388993628 100644 --- a/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts +++ b/tegg/plugin/tegg/test/fixtures/apps/multi-app-isolation/modules/counter-module/CounterService.ts @@ -1,4 +1,17 @@ -import { AccessLevel, SingletonProto } from '@eggjs/tegg'; +import { AccessLevel, Inject, InnerObjectProto, SingletonProto } from '@eggjs/tegg'; + +@InnerObjectProto({ accessLevel: AccessLevel.PUBLIC }) +export class CounterInnerState { + private count = 0; + + increment(): void { + this.count++; + } + + getCount(): number { + return this.count; + } +} /** * Per-app singleton state. The SAME class is loaded by two apps; each app must @@ -11,6 +24,9 @@ import { AccessLevel, SingletonProto } from '@eggjs/tegg'; */ @SingletonProto({ accessLevel: AccessLevel.PUBLIC }) export class CounterService { + @Inject() + innerState: CounterInnerState; + private count = 0; private eventCount = 0; private readonly store = new Map(); @@ -23,6 +39,14 @@ export class CounterService { return this.count; } + incrementInnerState(): void { + this.innerState.increment(); + } + + getInnerStateCount(): number { + return this.innerState.getCount(); + } + onEvent(delta: number): void { this.eventCount += delta; } From 7f8cfade69182c56be4dd1a416859a6e87da59af Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:27:34 +0800 Subject: [PATCH 56/74] fix(tegg): match module plugins by package --- tegg/core/common-util/src/ModuleConfig.ts | 35 +++++++--- .../common-util/test/ModuleConfig.test.ts | 23 ++++-- tegg/core/loader/src/LoaderFactory.ts | 1 + tegg/core/types/src/common/ModuleConfig.ts | 1 + tegg/plugin/config/src/app.ts | 1 + .../test/ManifestModuleReference.test.ts | 6 +- tegg/plugin/config/test/ReadModule.test.ts | 3 + tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 31 +++----- .../plugin/tegg/src/lib/ModuleConfigLoader.ts | 1 + .../tegg/test/ManifestCollection.test.ts | 4 ++ .../tegg/test/lib/EggModuleLoader.test.ts | 70 +++++++++++++++++++ .../standalone/src/EggModuleLoader.ts | 1 + .../standalone/src/StandaloneApp.ts | 1 + tools/egg-bundler/src/lib/ManifestLoader.ts | 1 + 14 files changed, 140 insertions(+), 39 deletions(-) diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index b0c87099d4..b9d9f2f5d0 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -86,15 +86,19 @@ export class ModuleConfigUtil { const pkgJson = path.posix.join(moduleReferenceConfig.package, 'package.json'); const file = importResolve(pkgJson, options); const modulePath = path.dirname(file); + const pkg = ModuleConfigUtil.readPackageJsonSync(modulePath); moduleReference = { path: modulePath, - name: ModuleConfigUtil.readModuleNameSync(modulePath), + name: ModuleConfigUtil.getModuleName(pkg), + package: ModuleConfigUtil.getPackageName(pkg), }; } else if (ModuleReferenceConfigHelp.isInlineModuleReference(moduleReferenceConfig)) { const modulePath = path.join(configDir, moduleReferenceConfig.path); + const pkg = ModuleConfigUtil.readPackageJsonSync(modulePath); moduleReference = { path: modulePath, - name: ModuleConfigUtil.readModuleNameSync(modulePath), + name: ModuleConfigUtil.getModuleName(pkg), + package: ModuleConfigUtil.getPackageName(pkg), }; } else { throw new Error('unknown type of module reference config: ' + JSON.stringify(moduleReferenceConfig)); @@ -144,15 +148,18 @@ export class ModuleConfigUtil { } moduleDirSet.add(moduleDir); + let pkg: any; let name: string; try { - name = this.readModuleNameSync(moduleDir); + pkg = this.readPackageJsonSync(moduleDir); + name = this.getModuleName(pkg); } catch { continue; } ref.push({ path: moduleDir, name, + package: this.getPackageName(pkg), }); } const moduleReferences = this.readModuleFromNodeModules(baseDir); @@ -163,10 +170,7 @@ export class ModuleConfigUtil { throw new Error('duplicate import of module reference: ' + moduleBasePath); } }); - ref.push({ - path: moduleReference.path, - name: moduleReference.name, - }); + ref.push(moduleReference); } return ref; } @@ -194,10 +198,11 @@ export class ModuleConfigUtil { const absolutePkgPath = path.dirname(packageJsonPath); const realPkgPath = fs.realpathSync(absolutePkgPath); try { - const name = this.readModuleNameSync(realPkgPath); + const pkg = this.readPackageJsonSync(realPkgPath); ref.push({ path: realPkgPath, - name, + name: this.getModuleName(pkg), + package: this.getPackageName(pkg), }); } catch { continue; @@ -219,6 +224,15 @@ export class ModuleConfigUtil { return pkg.eggModule.name; } + private static getPackageName(pkg: any): string | undefined { + return pkg.name; + } + + private static readPackageJsonSync(moduleDir: string): any { + const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); + return JSON.parse(pkgContent); + } + public static async readModuleName(baseDir: string, moduleDir: string): Promise { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); const pkgContent = await fsPromise.readFile(path.join(moduleDir, 'package.json'), 'utf8'); @@ -228,8 +242,7 @@ export class ModuleConfigUtil { public static readModuleNameSync(moduleDir: string, baseDir?: string): string { moduleDir = ModuleConfigUtil.resolveModuleDir(moduleDir, baseDir); - const pkgContent = fs.readFileSync(path.join(moduleDir, 'package.json'), 'utf8'); - const pkg = JSON.parse(pkgContent); + const pkg = ModuleConfigUtil.readPackageJsonSync(moduleDir); return ModuleConfigUtil.getModuleName(pkg); } diff --git a/tegg/core/common-util/test/ModuleConfig.test.ts b/tegg/core/common-util/test/ModuleConfig.test.ts index eb77c60023..86c0f38055 100644 --- a/tegg/core/common-util/test/ModuleConfig.test.ts +++ b/tegg/core/common-util/test/ModuleConfig.test.ts @@ -94,15 +94,17 @@ describe('test/ModuleConfig.test.ts', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-no-module-json'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); assert.deepStrictEqual(ref, [ - { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }, - { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB' }, + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b' }, { path: path.join(fixturesPath, 'app/module-b/test/fixtures/module-e'), name: 'moduleE', + package: 'module-e', }, { path: path.join(fixturesPath, 'node_modules/module-c'), name: 'moduleC', + package: 'module-c', }, ]); }); @@ -122,7 +124,9 @@ describe('test/ModuleConfig.test.ts', () => { it('should work', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-symlink'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); - assert.deepStrictEqual(ref, [{ path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }]); + assert.deepStrictEqual(ref, [ + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + ]); }); }); }); @@ -132,8 +136,8 @@ describe('test/ModuleConfig.test.ts', () => { const fixturesPath = path.join(__dirname, './fixtures/apps/app-with-module-json'); const ref = ModuleConfigUtil.readModuleReference(fixturesPath); assert.deepStrictEqual(ref, [ - { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }, - { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB' }, + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b' }, ]); }); }); @@ -148,6 +152,7 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.join(fixturesPath, 'node_modules/module-a'), name: 'moduleA', + package: 'module-a', }, ]); }); @@ -161,7 +166,9 @@ describe('test/ModuleConfig.test.ts', () => { extraFilePattern: ['!**/dist'], }; const ref = ModuleConfigUtil.readModuleReference(fixturesPath, readModuleOptions); - assert.deepStrictEqual(ref, [{ path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA' }]); + assert.deepStrictEqual(ref, [ + { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, + ]); }); }); }); @@ -180,10 +187,12 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/d/node_modules/e'), name: 'e', + package: 'e', }, { path: path.resolve(__dirname, './fixtures/monorepo/packages/d/node_modules/f'), name: 'f', + package: 'f', }, ]); }); @@ -195,6 +204,7 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/a/node_modules/c'), name: 'c', + package: 'c', }, ]); }); @@ -206,6 +216,7 @@ describe('test/ModuleConfig.test.ts', () => { { path: path.resolve(__dirname, './fixtures/monorepo/packages/a'), name: 'a', + package: 'b', }, ]); }); diff --git a/tegg/core/loader/src/LoaderFactory.ts b/tegg/core/loader/src/LoaderFactory.ts index c3d6ca0fe0..fdb08e87f3 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -13,6 +13,7 @@ export type LoaderCreator = (unitPath: string, loaderFS?: LoaderFS) => Loader; export interface ManifestModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; diff --git a/tegg/core/types/src/common/ModuleConfig.ts b/tegg/core/types/src/common/ModuleConfig.ts index b30c8561ff..472bac514e 100644 --- a/tegg/core/types/src/common/ModuleConfig.ts +++ b/tegg/core/types/src/common/ModuleConfig.ts @@ -1,5 +1,6 @@ export interface ModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 5ef1477a13..53fd2974eb 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -96,6 +96,7 @@ export default class App implements ILifecycleBoot { const resolvedRef: ModuleReference = { path: resolved.path, name: reference.name, + package: reference.package, optional: reference.optional, loaderType: reference.loaderType, }; diff --git a/tegg/plugin/config/test/ManifestModuleReference.test.ts b/tegg/plugin/config/test/ManifestModuleReference.test.ts index 4cbfe84583..ceef974469 100644 --- a/tegg/plugin/config/test/ManifestModuleReference.test.ts +++ b/tegg/plugin/config/test/ManifestModuleReference.test.ts @@ -13,7 +13,9 @@ const moduleDir = getFixtures('apps/app-with-modules/app/module-a'); // Build a minimal fake Application that feeds module references straight from a // tegg manifest extension, mirroring how a bundled worker entry primes the loader // without running the globby module scan. -function createFakeApp(moduleReferences: { name?: string; path: string; optional?: boolean }[]): Application { +function createFakeApp( + moduleReferences: { name?: string; package?: string; path: string; optional?: boolean }[], +): Application { return { baseDir, config: { tegg: { readModuleOptions: {} } }, @@ -51,7 +53,9 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { reference: { optional: undefined, name: 'moduleA', + package: undefined, path: moduleDir, + loaderType: undefined, }, }, }); diff --git a/tegg/plugin/config/test/ReadModule.test.ts b/tegg/plugin/config/test/ReadModule.test.ts index ba0d8cdcc8..bf3c4a9657 100644 --- a/tegg/plugin/config/test/ReadModule.test.ts +++ b/tegg/plugin/config/test/ReadModule.test.ts @@ -25,12 +25,15 @@ describe('plugin/config/test/ReadModule.test.ts', () => { reference: { optional: undefined, name: 'moduleA', + package: 'module-a', path: getFixtures('apps/app-with-modules/app/module-a'), + loaderType: undefined, }, }); expect(app.moduleReferences).toContainEqual({ optional: undefined, name: 'moduleA', + package: 'module-a', path: getFixtures('apps/app-with-modules/app/module-a'), }); // Framework module plugins are discovered through the default framework diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 9a553c5119..9967289bef 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,6 +1,3 @@ -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'; @@ -31,17 +28,11 @@ 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; + static #isModulePluginReference(moduleReference: ModuleReference, plugin: { package?: string; path?: string }) { + if (moduleReference.package && plugin.package) { + return moduleReference.package === plugin.package; } - return undefined; + return !!plugin.path && moduleReference.path === plugin.path; } private async loadApp(): Promise { @@ -51,15 +42,12 @@ 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. + // Promote enabled plugins' module references to non-optional. Prefer the + // npm package identity captured at scan/manifest time; fall back to the old + // direct path comparison for pre-package manifest data. for (const plugin of Object.values(this.app.plugins)) { - 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 (!plugin.enable) continue; + const modulePlugin = this.app.moduleReferences.find((t) => EggModuleLoader.#isModulePluginReference(t, plugin)); if (modulePlugin) { modulePlugin.optional = false; } @@ -104,6 +92,7 @@ export class EggModuleLoader { return { moduleReferences: moduleReferences.map((ref) => ({ name: ref.name, + package: ref.package, path: ref.path, optional: ref.optional, loaderType: ref.loaderType, diff --git a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts index df22b51be4..d20e944813 100644 --- a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts +++ b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts @@ -60,6 +60,7 @@ export class ModuleConfigLoader { name: resolved.name, reference: { name: resolved.name, + package: reference.package, path: reference.path, }, config, diff --git a/tegg/plugin/tegg/test/ManifestCollection.test.ts b/tegg/plugin/tegg/test/ManifestCollection.test.ts index 765276d3ba..fa38cf2d0f 100644 --- a/tegg/plugin/tegg/test/ManifestCollection.test.ts +++ b/tegg/plugin/tegg/test/ManifestCollection.test.ts @@ -49,6 +49,10 @@ describe('plugin/tegg/test/ManifestCollection.test.ts', () => { .map((r) => r.name) .sort((a: string, b: string) => a.localeCompare(b)); assert.deepStrictEqual(manifestRefNames, appRefNames); + + const appPackages = app.moduleReferences.map((r: any) => r.package).sort(); + const manifestPackages = teggExt.moduleReferences.map((r) => r.package).sort(); + assert.deepStrictEqual(manifestPackages, appPackages); }); it('should have moduleDescriptors with decoratedFiles', () => { diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index f66aaed7cb..fcb7ea16b1 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -1,16 +1,86 @@ import assert from 'node:assert/strict'; +import { mock } from 'node:test'; // import { scheduler } from 'node:timers/promises'; +import { GlobalGraph } from '@eggjs/metadata'; import { mm } from '@eggjs/mock'; +import { LoaderFactory } from '@eggjs/tegg-loader'; import { describe, it, afterEach } from 'vitest'; +import { EggModuleLoader } from '../../src/lib/EggModuleLoader.ts'; import { getAppBaseDir } from '../utils.ts'; describe('test/lib/EggModuleLoader.test.ts', () => { afterEach(() => { + mock.reset(); return mm.restore(); }); + it('should promote enabled plugin module references by package with path fallback', async () => { + const moduleReferences = [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config-package-root', + optional: true, + }, + { + name: 'legacyPlugin', + path: '/legacy/plugin/path', + optional: true, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: true, + }, + ]; + const app = { + baseDir: '/virtual/app', + loader: { + loaderFS: undefined, + manifest: { + getExtension: () => undefined, + setExtension() {}, + }, + }, + logger: { + warn() {}, + }, + moduleReferences, + plugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/some/plugin/path/inside-package', + }, + legacyPlugin: { + enable: true, + path: '/legacy/plugin/path', + }, + disabledPlugin: { + enable: false, + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + }, + }, + } as any; + + mock.method(LoaderFactory, 'loadApp', async () => []); + mock.method(GlobalGraph, 'create', async () => { + return { + registerBuildHook() {}, + } as any; + }); + + await new EggModuleLoader(app).initGraph(); + + assert.equal(moduleReferences[0].optional, false); + assert.equal(moduleReferences[1].optional, false); + assert.equal(moduleReferences[2].optional, true); + }); + describe('has recursive dependency module', () => { it('should throw error', async () => { const app = mm.app({ diff --git a/tegg/standalone/standalone/src/EggModuleLoader.ts b/tegg/standalone/standalone/src/EggModuleLoader.ts index 3f4cb4ef8e..6b3abcd14e 100644 --- a/tegg/standalone/standalone/src/EggModuleLoader.ts +++ b/tegg/standalone/standalone/src/EggModuleLoader.ts @@ -81,6 +81,7 @@ export class EggModuleLoader { return { moduleReferences: moduleReferences.map((ref) => ({ name: ref.name, + package: ref.package, path: ref.path, optional: ref.optional, loaderType: ref.loaderType, diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 57d7e9a5aa..c9b1ff4217 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -175,6 +175,7 @@ export class StandaloneApp { const resolvedRef = { path: resolved.path, name: reference.name, + package: reference.package, optional: reference.optional, loaderType: reference.loaderType, }; diff --git a/tools/egg-bundler/src/lib/ManifestLoader.ts b/tools/egg-bundler/src/lib/ManifestLoader.ts index eb12f6a80c..5e0e056768 100644 --- a/tools/egg-bundler/src/lib/ManifestLoader.ts +++ b/tools/egg-bundler/src/lib/ManifestLoader.ts @@ -30,6 +30,7 @@ interface TeggModuleDescriptor { interface TeggModuleReference { name: string; + package?: string; path: string; optional?: boolean; loaderType?: string; From ee85741dc396e570d007d1e90cf51ff0066c06f6 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:29:49 +0800 Subject: [PATCH 57/74] refactor(tegg): share manifest data builder --- tegg/core/loader/src/LoaderFactory.ts | 23 ++++++++++++++++++- tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 18 ++------------- .../standalone/src/EggModuleLoader.ts | 18 ++------------- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/tegg/core/loader/src/LoaderFactory.ts b/tegg/core/loader/src/LoaderFactory.ts index fdb08e87f3..5c5be597c0 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -1,6 +1,6 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; import type { LoaderFS } from '@eggjs/loader-fs'; -import type { ModuleDescriptor } from '@eggjs/metadata'; +import { ModuleDescriptorDumper, type ModuleDescriptor } from '@eggjs/metadata'; import { EggLoadUnitType, type EggLoadUnitTypeLike, @@ -35,6 +35,27 @@ export interface TeggManifestExtension { export const TEGG_MANIFEST_KEY = 'tegg'; +export function buildTeggManifestData( + moduleReferences: readonly ModuleReference[], + moduleDescriptors: readonly ModuleDescriptor[], +): TeggManifestExtension { + return { + moduleReferences: moduleReferences.map((ref) => ({ + name: ref.name, + package: ref.package, + path: ref.path, + optional: ref.optional, + loaderType: ref.loaderType, + })), + moduleDescriptors: moduleDescriptors.map((desc) => ({ + name: desc.name, + unitPath: desc.unitPath, + optional: desc.optional, + decoratedFiles: ModuleDescriptorDumper.getDecoratedFiles(desc), + })), + }; +} + export interface LoadAppManifest { moduleDescriptors: ManifestModuleDescriptor[]; } diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 9967289bef..62a902fdd8 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -1,6 +1,6 @@ import { EggLoadUnitType, LoadUnitFactory, GlobalGraph, ModuleDescriptorDumper } from '@eggjs/metadata'; import type { GlobalGraphBuildHook, ModuleDescriptor } from '@eggjs/metadata'; -import { LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import { buildTeggManifestData, LoaderFactory, ModuleLoader, TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; import type { TeggManifestExtension } from '@eggjs/tegg-loader'; import type { ModuleReference } from '@eggjs/tegg-types'; import type { Application } from 'egg'; @@ -89,21 +89,7 @@ export class EggModuleLoader { moduleReferences: readonly ModuleReference[], moduleDescriptors: readonly ModuleDescriptor[], ): TeggManifestExtension { - return { - moduleReferences: moduleReferences.map((ref) => ({ - name: ref.name, - package: ref.package, - path: ref.path, - optional: ref.optional, - loaderType: ref.loaderType, - })), - moduleDescriptors: moduleDescriptors.map((desc) => ({ - name: desc.name, - unitPath: desc.unitPath, - optional: desc.optional, - decoratedFiles: ModuleDescriptorDumper.getDecoratedFiles(desc), - })), - }; + return buildTeggManifestData(moduleReferences, moduleDescriptors); } /** diff --git a/tegg/standalone/standalone/src/EggModuleLoader.ts b/tegg/standalone/standalone/src/EggModuleLoader.ts index 6b3abcd14e..bae43c220d 100644 --- a/tegg/standalone/standalone/src/EggModuleLoader.ts +++ b/tegg/standalone/standalone/src/EggModuleLoader.ts @@ -9,7 +9,7 @@ import { } from '@eggjs/metadata'; import type { Logger } from '@eggjs/tegg'; import type { ModuleReference } from '@eggjs/tegg-common-util'; -import { LoaderFactory, ModuleLoader, type TeggManifestExtension } from '@eggjs/tegg-loader'; +import { buildTeggManifestData, LoaderFactory, ModuleLoader, type TeggManifestExtension } from '@eggjs/tegg-loader'; import { TeggScope } from '@eggjs/tegg-types'; export interface EggModuleLoaderOptions { @@ -78,21 +78,7 @@ export class EggModuleLoader { moduleReferences: readonly ModuleReference[], moduleDescriptors: readonly ModuleDescriptor[], ): TeggManifestExtension { - return { - moduleReferences: moduleReferences.map((ref) => ({ - name: ref.name, - package: ref.package, - path: ref.path, - optional: ref.optional, - loaderType: ref.loaderType, - })), - moduleDescriptors: moduleDescriptors.map((desc) => ({ - name: desc.name, - unitPath: desc.unitPath, - optional: desc.optional, - decoratedFiles: ModuleDescriptorDumper.getDecoratedFiles(desc), - })), - }; + return buildTeggManifestData(moduleReferences, moduleDescriptors); } #createModuleLoader(modulePath: string) { From bd79a6ce977dad233bd1f4b87784352e3b063e33 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:32:14 +0800 Subject: [PATCH 58/74] fix(tegg-config): scan parent framework modules --- tegg/plugin/config/src/lib/ModuleScanner.ts | 48 ++++++++++++++++--- tegg/plugin/config/test/ModuleScanner.test.ts | 28 +++++++++++ .../node_modules/base-module/package.json | 7 +++ .../node_modules/base-framework/package.json | 7 +++ .../node_modules/chair-module/package.json | 7 +++ .../node_modules/chair-framework/package.json | 10 ++++ .../fixtures/framework-chain/app/package.json | 7 +++ 7 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tegg/plugin/config/test/ModuleScanner.test.ts create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/package.json diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 7f0a49ddc4..05c65e5586 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { debuglog } from 'node:util'; import { ModuleConfigUtil, type ModuleReference, type ReadModuleReferenceOptions } from '@eggjs/tegg-common-util'; @@ -22,21 +24,51 @@ export class ModuleScanner { * cnpmcore) still get the egg-shipped module plugins, and chair apps * (which declare their framework) automatically scan chair. */ - private resolveFrameworkDir(): string | undefined { + private resolveFrameworkDir(baseDir: string): string | undefined { try { - return getFrameworkPath({ baseDir: this.baseDir }); + return getFrameworkPath({ baseDir }); } catch (err) { // No package.json or no resolvable framework next to the app (e.g. // bare unit fixtures without node_modules) — app modules only. debug( 'resolve framework dir failed, baseDir: %s, err: %s', - this.baseDir, + baseDir, err instanceof Error ? err.message : String(err), ); return undefined; } } + private resolveParentFrameworkDir(frameworkDir: string): string | undefined { + let pkg: { egg?: { framework?: string } }; + try { + pkg = JSON.parse(fs.readFileSync(path.join(frameworkDir, 'package.json'), 'utf8')); + } catch (err) { + debug( + 'read framework package failed, frameworkDir: %s, err: %s', + frameworkDir, + err instanceof Error ? err.message : String(err), + ); + return undefined; + } + if (!pkg.egg?.framework) { + return undefined; + } + return this.resolveFrameworkDir(frameworkDir); + } + + private resolveFrameworkDirs(): readonly string[] { + const frameworkDirs: string[] = []; + const seen = new Set(); + let frameworkDir = this.resolveFrameworkDir(this.baseDir); + while (frameworkDir && !seen.has(frameworkDir)) { + seen.add(frameworkDir); + frameworkDirs.push(frameworkDir); + frameworkDir = this.resolveParentFrameworkDir(frameworkDir); + } + return frameworkDirs; + } + /** * - load module references from config or scan from baseDir * - load the framework's module plugins as OPTIONAL references @@ -44,12 +76,14 @@ export class ModuleScanner { */ loadModuleReferences(): readonly ModuleReference[] { const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); - const frameworkDir = this.resolveFrameworkDir(); - if (!frameworkDir) { + const frameworkDirs = this.resolveFrameworkDirs(); + if (!frameworkDirs.length) { return ModuleConfigUtil.deduplicateModules(moduleReferences); } - debug('loadModuleReferences from frameworkDir:%o', frameworkDir); - const optionalModuleReferences = ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}); + debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); + const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => + ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}), + ); // Merge all module references and deduplicate const allModuleReferences = [ diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts new file mode 100644 index 0000000000..268331ffbf --- /dev/null +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { ModuleScanner } from '../src/lib/ModuleScanner.ts'; +import { getFixtures } from './utils.ts'; + +describe('plugin/config/test/ModuleScanner.test.ts', () => { + it('should scan module plugins from every framework layer', () => { + const baseDir = getFixtures('framework-chain/app'); + const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + optional: true, + }, + { + name: 'baseModule', + package: 'base-module', + path: path.join(baseDir, 'node_modules/base-framework/node_modules/base-module'), + optional: true, + }, + ]); + }); +}); diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json new file mode 100644 index 0000000000..3cae39e962 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/base-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "base-module", + "type": "module", + "eggModule": { + "name": "baseModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json new file mode 100644 index 0000000000..f3df328628 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "base-framework", + "type": "module", + "dependencies": { + "base-module": "*" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..50aed7984b --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json @@ -0,0 +1,10 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*" + }, + "egg": { + "framework": "base-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/package.json new file mode 100644 index 0000000000..750960de6f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-chain-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} From 847f8b8093171151873891d5807d617c0b31cef9 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:39:32 +0800 Subject: [PATCH 59/74] fix(tegg): qualify inner objects by defining module --- .../src/decorator/DefineModuleQualifier.ts | 16 ++++ .../core-decorator/src/decorator/index.ts | 1 + .../test/__snapshots__/index.test.ts.snap | 2 + .../core-decorator/test/decorators.test.ts | 5 + .../decators/QualifierCacheService.ts | 10 +- .../src/impl/InnerObjectLoadUnitBuilder.ts | 28 +++++- .../runtime/test/InnerObjectLoadUnit.test.ts | 96 +++++++++++++++++++ .../test/__snapshots__/exports.test.ts.snap | 2 + .../src/core-decorator/enum/Qualifier.ts | 2 + .../test/__snapshots__/index.test.ts.snap | 1 + 10 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts diff --git a/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts b/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts new file mode 100644 index 0000000000..9e3a7e0a36 --- /dev/null +++ b/tegg/core/core-decorator/src/decorator/DefineModuleQualifier.ts @@ -0,0 +1,16 @@ +import { DefineModuleQualifierAttribute } from '@eggjs/tegg-types'; +import type { EggProtoImplClass } from '@eggjs/tegg-types'; + +import { QualifierUtil } from '../util/index.ts'; + +export function DefineModuleQualifier(moduleName: string) { + return function (target: any, propertyKey?: PropertyKey, parameterIndex?: number): void { + QualifierUtil.addInjectQualifier( + target as EggProtoImplClass, + propertyKey, + parameterIndex, + DefineModuleQualifierAttribute, + moduleName, + ); + }; +} diff --git a/tegg/core/core-decorator/src/decorator/index.ts b/tegg/core/core-decorator/src/decorator/index.ts index 923338bb4f..a6925c75cb 100644 --- a/tegg/core/core-decorator/src/decorator/index.ts +++ b/tegg/core/core-decorator/src/decorator/index.ts @@ -1,5 +1,6 @@ export * from './ConfigSource.ts'; export * from './ContextProto.ts'; +export * from './DefineModuleQualifier.ts'; export * from './EggLifecycleProto.ts'; export * from './EggQualifier.ts'; export * from './InitTypeQualifier.ts'; 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 79a5e581ed..35afc58349 100644 --- a/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/core-decorator/test/__snapshots__/index.test.ts.snap @@ -11,6 +11,8 @@ exports[`should export stable 1`] = ` "ConfigSourceQualifierAttribute": Symbol(Qualifier.ConfigSource), "ContextProto": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "DefineModuleQualifier": [Function], + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", "EggContextLifecycleProto": [Function], "EggLifecycleProto": [Function], diff --git a/tegg/core/core-decorator/test/decorators.test.ts b/tegg/core/core-decorator/test/decorators.test.ts index d9da64cd0a..cc6113971e 100644 --- a/tegg/core/core-decorator/test/decorators.test.ts +++ b/tegg/core/core-decorator/test/decorators.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { AccessLevel, + DefineModuleQualifierAttribute, ObjectInitType, LoadUnitNameQualifierAttribute, InitTypeQualifierAttribute, @@ -134,6 +135,10 @@ describe('test/decorators.test.ts', () => { QualifierUtil.getProperQualifier(QualifierCacheService, property, InitTypeQualifierAttribute) === ObjectInitType.SINGLETON, ); + assert( + QualifierUtil.getProperQualifier(QualifierCacheService, property, DefineModuleQualifierAttribute) === + 'define-module', + ); }); it('should set default initType in inject', () => { diff --git a/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts b/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts index 91d3219201..293cbf2979 100644 --- a/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts +++ b/tegg/core/core-decorator/test/fixtures/decators/QualifierCacheService.ts @@ -1,6 +1,13 @@ import { ObjectInitType } from '@eggjs/tegg-types'; -import { ContextProto, InitTypeQualifier, Inject, ModuleQualifier, SingletonProto } from '../../../src/index.ts'; +import { + ContextProto, + DefineModuleQualifier, + InitTypeQualifier, + Inject, + ModuleQualifier, + SingletonProto, +} from '../../../src/index.ts'; import { type ICache } from './ICache.ts'; @ContextProto() @@ -15,6 +22,7 @@ export default class CacheService { name: 'fooCache', }) @InitTypeQualifier(ObjectInitType.SINGLETON) + @DefineModuleQualifier('define-module') @ModuleQualifier('foo') cache: ICache; diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index 6e1251fa6f..d75d433f9b 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -8,8 +8,8 @@ import { ProtoNode, } from '@eggjs/metadata'; import { Graph, GraphNode } from '@eggjs/tegg-common-util'; -import type { EggProtoImplClass, LoadUnit, ProtoDescriptor } from '@eggjs/tegg-types'; -import { AccessLevel, ObjectInitType } from '@eggjs/tegg-types'; +import type { EggProtoImplClass, LoadUnit, ProtoDescriptor, QualifierInfo } from '@eggjs/tegg-types'; +import { AccessLevel, DefineModuleQualifierAttribute, ObjectInitType } from '@eggjs/tegg-types'; import { INNER_OBJECT_LOAD_UNIT_NAME, @@ -34,6 +34,8 @@ export interface CreateInnerObjectLoadUnitOptions { unitPath?: string; } +const PROVIDED_DEFINE_MODULE_NAME = 'app'; + /** * Collects `@InnerObjectProto` / `@XxxLifecycleProto` classes from scanned * modules, resolves their mutual dependencies on a dedicated proto graph @@ -46,6 +48,19 @@ export interface CreateInnerObjectLoadUnitOptions { export class InnerObjectLoadUnitBuilder { readonly #protoGraph: Graph = new Graph(); + static #addDefaultDefineModuleQualifier(qualifiers: QualifierInfo[], moduleName: string): QualifierInfo[] { + if (qualifiers.find((t) => t.attribute === DefineModuleQualifierAttribute)) { + return qualifiers; + } + return [ + ...qualifiers, + { + attribute: DefineModuleQualifierAttribute, + value: moduleName, + }, + ]; + } + addInnerObjectClazzList(clazzList: readonly EggProtoImplClass[], moduleReference: InnerObjectModuleReference): void { for (const clazz of clazzList) { const descriptor = ProtoDescriptorHelper.createByInstanceClazz(clazz, { @@ -54,6 +69,10 @@ export class InnerObjectLoadUnitBuilder { defineModuleName: moduleReference.name, defineUnitPath: moduleReference.path, }); + descriptor.qualifiers = InnerObjectLoadUnitBuilder.#addDefaultDefineModuleQualifier( + descriptor.qualifiers, + moduleReference.name, + ); const protoGraphNode = new GraphNode(new ProtoNode(descriptor)); if (!this.#protoGraph.addVertex(protoGraphNode)) { throw new Error(`duplicate inner object proto: ${protoGraphNode.val}`); @@ -81,7 +100,10 @@ export class InnerObjectLoadUnitBuilder { initType: ObjectInitType.SINGLETON, protoImplType: PROVIDED_INNER_OBJECT_PROTO_IMPL_TYPE, qualifiers: ProtoDescriptorHelper.addDefaultQualifier( - innerObject.qualifiers ?? [], + InnerObjectLoadUnitBuilder.#addDefaultDefineModuleQualifier( + innerObject.qualifiers ?? [], + PROVIDED_DEFINE_MODULE_NAME, + ), ObjectInitType.SINGLETON, INNER_OBJECT_LOAD_UNIT_NAME, ), diff --git a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts index f9a29a4250..1c7bc8b666 100644 --- a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts +++ b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { mock } from 'node:test'; import { + DefineModuleQualifier, + DefineModuleQualifierAttribute, EggObjectLifecycleProto, Inject, InjectOptional, @@ -224,4 +226,98 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { await LoadUnitFactory.destroyLoadUnit(loadUnit); } }); + + it('should resolve same-name inner objects by define module qualifier', async () => { + @InnerObjectProto({ name: 'sharedInner', accessLevel: AccessLevel.PUBLIC }) + class ModuleAInner { + value = 'module-a'; + } + + @InnerObjectProto({ name: 'sharedInner', accessLevel: AccessLevel.PUBLIC }) + class ModuleBInner { + value = 'module-b'; + } + + @InnerObjectProto() + class QualifiedInnerConsumer { + @Inject() + @DefineModuleQualifier('module-b') + sharedInner: { value: string }; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([ModuleAInner], { + name: 'module-a', + path: '/module-a', + }); + builder.addInnerObjectClazzList([ModuleBInner, QualifiedInnerConsumer], { + name: 'module-b', + path: '/module-b', + }); + const loadUnit = await builder.createLoadUnit({ innerObjects: {} }); + let instance: LoadUnitInstance | undefined; + try { + assert.throws(() => { + EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit); + }, /multi proto found/); + const moduleAProto = EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit, [ + { + attribute: DefineModuleQualifierAttribute, + value: 'module-a', + }, + ]); + assert(moduleAProto.verifyQualifier({ attribute: DefineModuleQualifierAttribute, value: 'module-a' })); + + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const consumerProto = EggPrototypeFactory.instance.getPrototype('qualifiedInnerConsumer', loadUnit); + const consumer = (instance as any).getEggObject('qualifiedInnerConsumer', consumerProto) + .obj as QualifiedInnerConsumer; + assert.equal(consumer.sharedInner.value, 'module-b'); + } finally { + if (instance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); + + it('should use app as default define module qualifier for provided inner objects', async () => { + const providedShared = { value: 'provided' }; + + @InnerObjectProto({ name: 'sharedHostObject', accessLevel: AccessLevel.PUBLIC }) + class ModuleSharedHostObject { + value = 'module'; + } + + @InnerObjectProto() + class ProvidedInnerConsumer { + @Inject() + @DefineModuleQualifier('app') + sharedHostObject: { value: string }; + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([ModuleSharedHostObject, ProvidedInnerConsumer], { + name: 'host-module', + path: '/host-module', + }); + const loadUnit = await builder.createLoadUnit({ + innerObjects: { + sharedHostObject: [{ obj: providedShared }], + }, + }); + let instance: LoadUnitInstance | undefined; + try { + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const consumerProto = EggPrototypeFactory.instance.getPrototype('providedInnerConsumer', loadUnit); + const consumer = (instance as any).getEggObject('providedInnerConsumer', consumerProto) + .obj as ProvidedInnerConsumer; + assert.equal(consumer.sharedHostObject, providedShared); + } finally { + if (instance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); }); diff --git a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap index e8b4b7f6ae..70249544b8 100644 --- a/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/exports.test.ts.snap @@ -64,6 +64,8 @@ exports[`should export stable 1`] = ` "Cookies": [Function], "CookiesParamMeta": [Function], "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", + "DefineModuleQualifier": [Function], + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", "EVENT_CONTEXT_INJECT": Symbol(EggPrototype#event#handler#context#inject), "EVENT_NAME": Symbol(EggPrototype#eventName), diff --git a/tegg/core/types/src/core-decorator/enum/Qualifier.ts b/tegg/core/types/src/core-decorator/enum/Qualifier.ts index 0a6a5fc0d7..6e8cd8bfec 100644 --- a/tegg/core/types/src/core-decorator/enum/Qualifier.ts +++ b/tegg/core/types/src/core-decorator/enum/Qualifier.ts @@ -1,5 +1,7 @@ export const ConfigSourceQualifierAttribute: symbol = Symbol.for('Qualifier.ConfigSource'); +export const DefineModuleQualifierAttribute: symbol = Symbol.for('Qualifier.DefineModule'); + export const EggQualifierAttribute: symbol = Symbol.for('Qualifier.Egg'); export const InitTypeQualifierAttribute: symbol = Symbol.for('Qualifier.InitType'); diff --git a/tegg/core/types/test/__snapshots__/index.test.ts.snap b/tegg/core/types/test/__snapshots__/index.test.ts.snap index 37c7b77504..230c00c924 100644 --- a/tegg/core/types/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/types/test/__snapshots__/index.test.ts.snap @@ -121,6 +121,7 @@ exports[`should export stable 1`] = ` "DEFAULT_PROTO_IMPL_TYPE": "DEFAULT", "DataSourceInjectName": "dataSource", "DataSourceQualifierAttribute": Symbol(Qualifier.DataSource), + "DefineModuleQualifierAttribute": Symbol(Qualifier.DefineModule), "EGG_INNER_OBJECT_PROTO_IMPL_TYPE": "EGG_INNER_OBJECT_PROTOTYPE", "EggLoadUnitType": { "APP": "APP", From 6afd983d4cef120e2d8605e59ee5b9f8ed2a6eb9 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:45:25 +0800 Subject: [PATCH 60/74] refactor(aop): register context hook as inner object --- .../src/AopContextAdviceRegistry.ts | 27 ++++++++++ tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 6 +++ tegg/core/aop-runtime/src/index.ts | 1 + .../test/__snapshots__/index.test.ts.snap | 1 + tegg/plugin/aop/src/InnerObjects.ts | 10 +++- tegg/plugin/aop/src/app.ts | 18 ------- tegg/plugin/aop/src/lib/AopContextHook.ts | 49 +++---------------- .../apps/aop-app/modules/aop-module/Hello.ts | 4 +- 8 files changed, 52 insertions(+), 64 deletions(-) create mode 100644 tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts diff --git a/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts b/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts new file mode 100644 index 0000000000..aa02bab107 --- /dev/null +++ b/tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts @@ -0,0 +1,27 @@ +import { InnerObjectProto } from '@eggjs/core-decorator'; +import { ObjectInitType } from '@eggjs/tegg-types'; +import type { EggPrototype } from '@eggjs/tegg-types'; + +export interface ProtoToCreate { + name: string; + proto: EggPrototype; +} + +@InnerObjectProto() +export class AopContextAdviceRegistry { + readonly #requestProtoList: ProtoToCreate[] = []; + + addAdvice(name: string, proto: EggPrototype): void { + if (proto.initType !== ObjectInitType.CONTEXT) { + return; + } + if (this.#requestProtoList.some((t) => t.name === name && t.proto === proto)) { + return; + } + this.#requestProtoList.push({ name, proto }); + } + + getRequestProtos(): readonly ProtoToCreate[] { + return this.#requestProtoList; + } +} diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index 5bd0caaeab..f09313eb39 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -9,11 +9,16 @@ import type { LoadUnitLifecycleContext, } from '@eggjs/tegg-types'; +import { AopContextAdviceRegistry } from './AopContextAdviceRegistry.js'; + @LoadUnitLifecycleProto() export class LoadUnitAopHook implements LifecycleHook { @Inject() private readonly crosscutAdviceFactory: CrosscutAdviceFactory; + @Inject() + private readonly aopContextAdviceRegistry: AopContextAdviceRegistry = new AopContextAdviceRegistry(); + async postCreate(_: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { for (const proto of loadUnit.iterateEggPrototype()) { const protoWithClazz = proto as EggPrototypeWithClazz; @@ -38,6 +43,7 @@ export class LoadUnitAopHook implements LifecycleHook { - this.app.eggContextLifecycleUtil.deleteLifecycle(this.aopContextHook); } } diff --git a/tegg/plugin/aop/src/lib/AopContextHook.ts b/tegg/plugin/aop/src/lib/AopContextHook.ts index e7a8b3bcbe..0d1df2b99d 100644 --- a/tegg/plugin/aop/src/lib/AopContextHook.ts +++ b/tegg/plugin/aop/src/lib/AopContextHook.ts @@ -1,56 +1,19 @@ -import { AspectInfoUtil } from '@eggjs/aop-decorator'; -import { PrototypeUtil, ObjectInitType, type EggProtoImplClass } from '@eggjs/core-decorator'; +import { AopContextAdviceRegistry } from '@eggjs/aop-runtime'; +import { EggContextLifecycleProto, Inject } from '@eggjs/core-decorator'; import type { LifecycleHook } from '@eggjs/lifecycle'; -import { type EggPrototype, TeggError } from '@eggjs/metadata'; import { ROOT_PROTO } from '@eggjs/module-common'; import type { EggContext, EggContextLifecycleContext } from '@eggjs/tegg-runtime'; -import type { Application } from 'egg'; - -export interface EggPrototypeWithClazz extends EggPrototype { - clazz?: EggProtoImplClass; -} - -export interface ProtoToCreate { - name: string; - proto: EggPrototype; -} +@EggContextLifecycleProto() export class AopContextHook implements LifecycleHook { - private readonly moduleHandler: Application['moduleHandler']; - private requestProtoList: Array = []; - - constructor(moduleHandler: Application['moduleHandler']) { - this.moduleHandler = moduleHandler; - for (const loadUnitInstance of this.moduleHandler.loadUnitInstances) { - const iterator = loadUnitInstance.loadUnit.iterateEggPrototype(); - for (const proto of iterator) { - const protoWithClazz = proto as EggPrototypeWithClazz; - const clazz = protoWithClazz.clazz; - if (!clazz) continue; - const aspects = AspectInfoUtil.getAspectList(clazz); - for (const aspect of aspects) { - for (const advice of aspect.adviceList) { - const adviceProto = PrototypeUtil.getClazzProto(advice.clazz) as EggPrototype | undefined; - if (!adviceProto) { - throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found'); - } - if (adviceProto.initType === ObjectInitType.CONTEXT) { - this.requestProtoList.push({ - name: advice.name, - proto: adviceProto, - }); - } - } - } - } - } - } + @Inject() + private readonly aopContextAdviceRegistry: AopContextAdviceRegistry; async preCreate(_: unknown, ctx: EggContext): Promise { // compatible with egg controller // add context aspect to ctx if (!ctx.get(ROOT_PROTO)) { - for (const proto of this.requestProtoList) { + for (const proto of this.aopContextAdviceRegistry.getRequestProtos()) { ctx.addProtoToCreate(proto.name, proto.proto); } } diff --git a/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts b/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts index 8f510729e0..37426b5a70 100644 --- a/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts +++ b/tegg/plugin/aop/test/fixtures/apps/aop-app/modules/aop-module/Hello.ts @@ -1,4 +1,4 @@ -import { AccessLevel, ContextProto, Inject, SingletonProto } from '@eggjs/tegg'; +import { AccessLevel, ContextProto, Inject, ObjectInitType, SingletonProto } from '@eggjs/tegg'; import { Advice, type AdviceContext, Crosscut, type IAdvice, Pointcut, PointcutType } from '@eggjs/tegg/aop'; import type { EggLogger } from 'egg'; @@ -49,7 +49,7 @@ export class CrosscutAdvice implements IAdvice { } } -@Advice() +@Advice({ initType: ObjectInitType.CONTEXT }) export class ContextPointcutAdvice implements IAdvice { async around(ctx: AdviceContext, next: () => Promise): Promise { ctx.args[0] = `withContextPointAroundParam(${ctx.args[0]})`; From 525a91f56074768a7bad81b76b10698b3bc0c50c Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:53:24 +0800 Subject: [PATCH 61/74] refactor(dal): inject mysql data source manager --- .../tegg/test/__snapshots__/dal.test.ts.snap | 1 - tegg/plugin/dal/package.json | 2 -- tegg/plugin/dal/src/app.ts | 2 -- tegg/plugin/dal/src/app/extend/application.ts | 14 +--------- tegg/plugin/dal/src/index.ts | 1 - .../dal/src/lib/DalModuleLoadUnitHook.ts | 7 +++-- tegg/plugin/dal/src/lib/DataSource.ts | 3 +- .../dal/src/lib/MysqlDataSourceManager.ts | 16 ++--------- .../src/lib/MysqlDataSourceManagerObject.ts | 28 ------------------- .../dal/src/lib/TransactionPrototypeHook.ts | 5 +++- tegg/plugin/dal/test/transaction.test.ts | 4 ++- .../test/fixtures/dal-manager-inject/foo.ts | 3 +- tegg/standalone/standalone/test/index.test.ts | 14 +++++++--- 13 files changed, 28 insertions(+), 72 deletions(-) delete mode 100644 tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts diff --git a/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap index 46c5585efd..1eb8a44f62 100644 --- a/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/dal.test.ts.snap @@ -89,7 +89,6 @@ exports[`should dal exports stable 1`] = ` "NO": "NO", }, "MysqlDataSourceManager": [Function], - "MysqlDataSourceManagerObject": [Function], "RowFormat": { "COMPACT": "COMPACT", "COMPRESSED": "COMPRESSED", diff --git a/tegg/plugin/dal/package.json b/tegg/plugin/dal/package.json index 1e0080c15b..13f405a79f 100644 --- a/tegg/plugin/dal/package.json +++ b/tegg/plugin/dal/package.json @@ -34,7 +34,6 @@ "./lib/DalTableEggPrototypeHook": "./src/lib/DalTableEggPrototypeHook.ts", "./lib/DataSource": "./src/lib/DataSource.ts", "./lib/MysqlDataSourceManager": "./src/lib/MysqlDataSourceManager.ts", - "./lib/MysqlDataSourceManagerObject": "./src/lib/MysqlDataSourceManagerObject.ts", "./lib/SqlMapManager": "./src/lib/SqlMapManager.ts", "./lib/TableModelManager": "./src/lib/TableModelManager.ts", "./lib/TransactionalAOP": "./src/lib/TransactionalAOP.ts", @@ -52,7 +51,6 @@ "./lib/DalTableEggPrototypeHook": "./dist/lib/DalTableEggPrototypeHook.js", "./lib/DataSource": "./dist/lib/DataSource.js", "./lib/MysqlDataSourceManager": "./dist/lib/MysqlDataSourceManager.js", - "./lib/MysqlDataSourceManagerObject": "./dist/lib/MysqlDataSourceManagerObject.js", "./lib/SqlMapManager": "./dist/lib/SqlMapManager.js", "./lib/TableModelManager": "./dist/lib/TableModelManager.js", "./lib/TransactionalAOP": "./dist/lib/TransactionalAOP.js", diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts index abd7e1c3cf..2a0abbf9de 100644 --- a/tegg/plugin/dal/src/app.ts +++ b/tegg/plugin/dal/src/app.ts @@ -1,7 +1,6 @@ import { TeggScope } from '@eggjs/tegg-types'; import type { Application, ILifecycleBoot } from 'egg'; -import { MysqlDataSourceManager } from './lib/MysqlDataSourceManager.ts'; import { SqlMapManager } from './lib/SqlMapManager.ts'; import { TableModelManager } from './lib/TableModelManager.ts'; @@ -15,7 +14,6 @@ export default class DalAppBootHook implements ILifecycleBoot { async beforeClose(): Promise { // The per-app DAL managers are resolved/cleared within this app's scope. await TeggScope.run(this.app._teggScopeBag, async () => { - MysqlDataSourceManager.instance.clear(); SqlMapManager.instance.clear(); TableModelManager.instance.clear(); }); diff --git a/tegg/plugin/dal/src/app/extend/application.ts b/tegg/plugin/dal/src/app/extend/application.ts index 3c3b4d90c2..ff8b4c5632 100644 --- a/tegg/plugin/dal/src/app/extend/application.ts +++ b/tegg/plugin/dal/src/app/extend/application.ts @@ -1,13 +1 @@ -import { TeggScope } from '@eggjs/tegg-types'; -import type { Application } from 'egg'; - -import { MysqlDataSourceManager } from '../../lib/MysqlDataSourceManager.ts'; - -export default { - // Pin to THIS app's scope so `app.mysqlDataSourceManager` returns the app's - // per-app manager even when accessed outside a request/boot scope. - get mysqlDataSourceManager(): MysqlDataSourceManager { - const app = this as unknown as Application; - return TeggScope.run(app._teggScopeBag, () => MysqlDataSourceManager.instance); - }, -}; +export default {}; diff --git a/tegg/plugin/dal/src/index.ts b/tegg/plugin/dal/src/index.ts index 9eb39f822c..7caa89bffd 100644 --- a/tegg/plugin/dal/src/index.ts +++ b/tegg/plugin/dal/src/index.ts @@ -4,7 +4,6 @@ export * from './lib/DalModuleLoadUnitHook.ts'; export * from './lib/DalTableEggPrototypeHook.ts'; export * from './lib/DataSource.ts'; export * from './lib/MysqlDataSourceManager.ts'; -export * from './lib/MysqlDataSourceManagerObject.ts'; export * from './lib/SqlMapManager.ts'; export * from './lib/TableModelManager.ts'; export * from './lib/TransactionalAOP.ts'; diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index 70d5b4eaec..97f27375e4 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -20,6 +20,9 @@ export class DalModuleLoadUnitHook implements LifecycleHook { - MysqlDataSourceManager.instance.clear(); + this.mysqlDataSourceManager.clear(); SqlMapManager.instance.clear(); TableModelManager.instance.clear(); } diff --git a/tegg/plugin/dal/src/lib/DataSource.ts b/tegg/plugin/dal/src/lib/DataSource.ts index acc1959289..82eb315efd 100644 --- a/tegg/plugin/dal/src/lib/DataSource.ts +++ b/tegg/plugin/dal/src/lib/DataSource.ts @@ -61,6 +61,7 @@ export class DataSourceDelegate extends DataSource { objInfo: ObjectInfo; constructor( + @Inject() mysqlDataSourceManager: MysqlDataSourceManager, @Inject({ name: 'transactionalAOP' }) transactionalAOP: TransactionalAOP, @MultiInstanceInfo([DataSourceQualifierAttribute, LoadUnitNameQualifierAttribute]) objInfo: ObjectInfo, @@ -72,7 +73,7 @@ export class DataSourceDelegate extends DataSource { const [moduleName, dataSource, clazzName] = (dataSourceQualifierValue as string).split('.'); const tableModel = TableModelManager.instance.get(moduleName, clazzName); assert(tableModel, `not found table ${dataSourceQualifierValue}`); - const mysqlDataSource = MysqlDataSourceManager.instance.get(moduleName, dataSource); + const mysqlDataSource = mysqlDataSourceManager.get(moduleName, dataSource); assert(mysqlDataSource, `not found dataSource ${dataSource} in module ${moduleName}`); const sqlMap = SqlMapManager.instance.get(moduleName, clazzName); assert(sqlMap, `not found SqlMap ${clazzName} in module ${moduleName}`); diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts index a73f925fdc..8a5a39f3df 100644 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts +++ b/tegg/plugin/dal/src/lib/MysqlDataSourceManager.ts @@ -1,22 +1,10 @@ import crypto from 'node:crypto'; +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import { type DataSourceOptions, MysqlDataSource } from '@eggjs/dal-runtime'; -import { TeggScope } from '@eggjs/tegg-types'; - -const MYSQL_DATA_SOURCE_MANAGER_SLOT = Symbol('tegg:dal:mysqlDataSourceManager'); +@InnerObjectProto({ name: 'mysqlDataSourceManager', accessLevel: AccessLevel.PUBLIC }) export class MysqlDataSourceManager { - // Per-app: holds live MysqlDataSource connections keyed by config hash. Made - // per-app so two apps with identical DB config no longer share one connection - // object (and each app's teardown only disposes its own). - static get instance(): MysqlDataSourceManager { - return TeggScope.resolve( - MYSQL_DATA_SOURCE_MANAGER_SLOT, - () => new MysqlDataSourceManager(), - 'MysqlDataSourceManager.instance', - ); - } - private readonly dataSourceIndices: Map< string /* moduleName */, Map diff --git a/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts b/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts deleted file mode 100644 index fcd0b125c0..0000000000 --- a/tegg/plugin/dal/src/lib/MysqlDataSourceManagerObject.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; - -import { MysqlDataSourceManager } from './MysqlDataSourceManager.ts'; - -/** - * Declaration merging: the wrapper's instance type IS the manager's public - * surface — matching what the constructor actually hands out — so typing an - * injection as MysqlDataSourceManagerObject is as sound as typing it as - * MysqlDataSourceManager. - */ -export interface MysqlDataSourceManagerObject extends MysqlDataSourceManager {} - -/** - * PUBLIC injection surface declared by the dal module itself: business - * modules `@Inject() mysqlDataSourceManager` on any host (the counterpart of - * the egg-side `app.mysqlDataSourceManager` extend) — hosts carry no dal - * knowledge. - * - * The constructor return-override hands out the per-app TeggScope singleton - * the dal hooks populate — NOT a second instance — so injected consumers see - * the datasources created by DalModuleLoadUnitHook. - */ -@InnerObjectProto({ name: 'mysqlDataSourceManager', accessLevel: AccessLevel.PUBLIC }) -export class MysqlDataSourceManagerObject { - constructor() { - return MysqlDataSourceManager.instance; - } -} diff --git a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts index 406105c6fd..e26b255d36 100644 --- a/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts +++ b/tegg/plugin/dal/src/lib/TransactionPrototypeHook.ts @@ -20,6 +20,9 @@ export class TransactionPrototypeHook implements LifecycleHook { const builder = new TransactionMetaBuilder(ctx.clazz); const transactionMetadataList = builder.build(); @@ -56,7 +59,7 @@ export class TransactionPrototypeHook implements LifecycleHook { - const mysqlDataSource = MysqlDataSourceManager.instance.get(moduleName, datasourceName); + const mysqlDataSource = this.mysqlDataSourceManager.get(moduleName, datasourceName); if (!mysqlDataSource) { throw new Error(`method ${clazzName} not found datasource ${datasourceName}`); } diff --git a/tegg/plugin/dal/test/transaction.test.ts b/tegg/plugin/dal/test/transaction.test.ts index 5523006737..6fe00f0e68 100644 --- a/tegg/plugin/dal/test/transaction.test.ts +++ b/tegg/plugin/dal/test/transaction.test.ts @@ -1,3 +1,4 @@ +import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; import { mm, type MockApplication } from '@eggjs/mock'; import { describe, afterEach, beforeAll, afterAll, it, expect } from 'vitest'; @@ -20,7 +21,8 @@ describe('plugin/dal/test/transaction.test.ts', () => { }); afterEach(async () => { - const dataSource = app.mysqlDataSourceManager.get('dal', 'foo')!; + const mysqlDataSourceManager = await app.getEggObjectFromName('mysqlDataSourceManager'); + const dataSource = mysqlDataSourceManager.get('dal', 'foo')!; await dataSource.query('delete from egg_foo;'); }); diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts index 9357c28754..21fab4ce35 100644 --- a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts @@ -4,8 +4,7 @@ import { Runner, type MainRunner } from '@eggjs/tegg/standalone'; /** * Pins the PUBLIC `mysqlDataSourceManager` injection surface: business - * modules inject the dal manager by name (the standalone counterpart of the - * dal plugin's `app.mysqlDataSourceManager` egg extend). + * modules inject the dal manager by name. */ @Runner() @SingletonProto() diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index c16e3e01a9..ff850c2383 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; -import { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import { EggContainerFactory } from '@eggjs/tegg-runtime'; import { TeggScope } from '@eggjs/tegg-types'; import { type ModuleConfig, ModuleConfigs, ModuleDescriptorDumper } from '@eggjs/tegg/helper'; import { importResolve } from '@eggjs/utils'; @@ -429,17 +430,22 @@ describe('standalone/standalone/test/index.test.ts', () => { describe('dal manager cleanup', () => { it('should clear dal managers when the app is destroyed', async () => { const app = new StandaloneApp(); - // THIS app's per-scope manager instance — survives destroy as a plain - // object reference, so we can observe the cleanup. - const manager = TeggScope.run(app.scopeBag, () => MysqlDataSourceManager.instance); + let manager: MysqlDataSourceManager | undefined; try { await app.init({ baseDir: path.join(__dirname, './fixtures/dal-module'), env: 'unittest' }); + // The inner object instance survives destroy as a plain object + // reference, so we can observe the cleanup. + manager = await TeggScope.run(app.scopeBag, async () => { + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName('mysqlDataSourceManager'); + return eggObject.obj as MysqlDataSourceManager; + }); assert(manager.get('dal', 'foo'), 'datasource created during init'); } finally { await app.destroy(); } // Cleared by DalModuleLoadUnitHook#destroyManagers (@LifecycleDestroy) // when the InnerObjectLoadUnit instance goes down. + assert(manager); assert.equal(manager.get('dal', 'foo'), undefined); }); }); From 6c84283c8fac21cef1ca6ce77bfcf3d2161dd74a Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:55:56 +0800 Subject: [PATCH 62/74] refactor(metadata): reuse prototype builder for inner objects --- .../src/impl/EggInnerObjectPrototypeImpl.ts | 142 +----------------- .../metadata/src/impl/EggPrototypeBuilder.ts | 26 +++- .../metadata/src/impl/EggPrototypeImpl.ts | 2 +- 3 files changed, 32 insertions(+), 138 deletions(-) diff --git a/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts index 86dc8277b3..7ab3ac5131 100644 --- a/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts +++ b/tegg/core/metadata/src/impl/EggInnerObjectPrototypeImpl.ts @@ -1,143 +1,13 @@ -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 type { EggPrototype, EggPrototypeLifecycleContext } from '@eggjs/tegg-types'; +import { EGG_INNER_OBJECT_PROTO_IMPL_TYPE } from '@eggjs/tegg-types'; import { EggPrototypeCreatorFactory } from '../factory/EggPrototypeCreatorFactory.ts'; -import { 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); - } +import { EggPrototypeBuilder } from './EggPrototypeBuilder.ts'; +import { EggPrototypeImpl } from './EggPrototypeImpl.ts'; +export class EggInnerObjectPrototypeImpl extends EggPrototypeImpl { 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, - ); + return EggPrototypeBuilder.createWithProtoImpl(ctx, EggInnerObjectPrototypeImpl); } } diff --git a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts index fb5bbc3bbe..cf687912af 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts @@ -9,7 +9,9 @@ import type { EggPrototypeLifecycleContext, EggPrototypeName, InjectConstructor, + InjectConstructorProto, InjectObject, + InjectObjectProto, LoadUnit, ObjectInitTypeLike, QualifierInfo, @@ -20,6 +22,22 @@ import { EggPrototypeCreatorFactory } from '../factory/index.ts'; import { EggPrototypeImpl } from './EggPrototypeImpl.ts'; import { InjectObjectPrototypeFinder } from './InjectObjectPrototypeFinder.ts'; +export type EggPrototypeImplClass = new ( + id: string, + name: EggPrototypeName, + clazz: EggProtoImplClass, + filepath: string, + initType: ObjectInitTypeLike, + accessLevel: AccessLevel, + injectObjectProtos: Array, + loadUnitId: string, + qualifiers: QualifierInfo[], + className?: string, + injectType?: InjectType, + multiInstanceConstructorIndex?: number, + multiInstanceConstructorAttributes?: QualifierAttribute[], +) => EggPrototype; + export class EggPrototypeBuilder { private clazz: EggProtoImplClass; private name: EggPrototypeName; @@ -34,12 +52,18 @@ export class EggPrototypeBuilder { private className?: string; private multiInstanceConstructorIndex?: number; private multiInstanceConstructorAttributes?: QualifierAttribute[]; + private protoImplClass: EggPrototypeImplClass = EggPrototypeImpl; static create(ctx: EggPrototypeLifecycleContext): EggPrototype { + return EggPrototypeBuilder.createWithProtoImpl(ctx, EggPrototypeImpl); + } + + static createWithProtoImpl(ctx: EggPrototypeLifecycleContext, protoImplClass: EggPrototypeImplClass): EggPrototype { const { clazz, loadUnit } = ctx; const filepath = PrototypeUtil.getFilePath(clazz); assert(filepath, 'not find filepath'); const builder = new EggPrototypeBuilder(); + builder.protoImplClass = protoImplClass; builder.clazz = clazz; builder.name = ctx.prototypeInfo.name; builder.className = ctx.prototypeInfo.className; @@ -69,7 +93,7 @@ export class EggPrototypeBuilder { injectObjects: this.injectObjects, }); const id = IdenticalUtil.createProtoId(this.loadUnit.id, this.name); - return new EggPrototypeImpl( + return new this.protoImplClass( id, this.name, this.clazz, diff --git a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts index 8e329cc417..694fa068e1 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts @@ -74,7 +74,7 @@ export class EggPrototypeImpl implements EggPrototype { return selfQualifiers?.value === qualifier.value; } - getQualifier(attribute: string): QualifierValue | undefined { + getQualifier(attribute: QualifierAttribute): QualifierValue | undefined { return this.qualifiers.find((t) => t.attribute === attribute)?.value; } From 2fcc7e791f101b5ba44c3f6153d386bf8ad1dc01 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 16:59:14 +0800 Subject: [PATCH 63/74] test(standalone): expect destroy lifecycle after main --- tegg/standalone/standalone/test/index.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index ff850c2383..f8ff65545e 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -538,7 +538,16 @@ describe('standalone/standalone/test/index.test.ts', () => { it('should work', async () => { await preLoad(fixturePath); await main(fixturePath); - assert.deepEqual(Foo.staticCalled, ['preLoad', 'construct', 'postConstruct', 'preInject', 'postInject', 'init']); + assert.deepEqual(Foo.staticCalled, [ + 'preLoad', + 'construct', + 'postConstruct', + 'preInject', + 'postInject', + 'init', + 'preDestroy', + 'destroy', + ]); // app module + the three built-in framework modules (teggAop/teggDal/teggConfig) assert.equal((ModuleDescriptorDumper.dump as any).called, 4); }); From 1ad38c0cb4568988917d620b483fd28438b4ea87 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:00:20 +0800 Subject: [PATCH 64/74] fix(tegg-config): keep nearest duplicate framework modules --- tegg/plugin/config/src/app.ts | 2 +- tegg/plugin/config/src/lib/ModuleScanner.ts | 53 +++++++++++++++++-- tegg/plugin/config/test/ModuleScanner.test.ts | 19 +++++++ .../shared-module-base/package.json | 7 +++ .../node_modules/base-framework/package.json | 3 +- .../shared-module-chair/package.json | 7 +++ .../node_modules/chair-framework/package.json | 3 +- 7 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 53fd2974eb..2dc76894be 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -77,7 +77,7 @@ 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, this.app.coreLogger); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 05c65e5586..f7242e5be5 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -7,13 +7,19 @@ import { getFrameworkPath } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); +interface WarnLogger { + warn(message: string): void; +} + export class ModuleScanner { private readonly baseDir: string; private readonly readModuleOptions: ReadModuleReferenceOptions; + private readonly logger?: WarnLogger; - constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions) { + constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions, logger?: WarnLogger) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; + this.logger = logger; } /** @@ -69,20 +75,57 @@ export class ModuleScanner { return frameworkDirs; } + private readAndDeduplicateModuleReferences(baseDir: string): readonly ModuleReference[] { + return ModuleConfigUtil.deduplicateModules( + ModuleConfigUtil.readModuleReference(baseDir, this.readModuleOptions || {}), + ); + } + + private warnDuplicateModuleName(kept: ModuleReference, skipped: ModuleReference): void { + if (kept.path === skipped.path) { + return; + } + const message = + `[egg/tegg/plugin/config] Duplicate module name "${skipped.name}" found while scanning framework modules, ` + + `keep ${kept.path}, skip ${skipped.path}`; + if (this.logger) { + this.logger.warn(message); + } else { + debug(message); + } + } + + private deduplicateLayeredModuleReferences(moduleReferences: readonly ModuleReference[]): readonly ModuleReference[] { + const result: ModuleReference[] = []; + const nameMap = new Map(); + + for (const moduleReference of moduleReferences) { + const existing = nameMap.get(moduleReference.name); + if (existing) { + this.warnDuplicateModuleName(existing, moduleReference); + continue; + } + nameMap.set(moduleReference.name, moduleReference); + result.push(moduleReference); + } + + return result; + } + /** * - load module references from config or scan from baseDir * - load the framework's module plugins as OPTIONAL references * (plugin promotion flips the enabled ones to non-optional) */ loadModuleReferences(): readonly ModuleReference[] { - const moduleReferences = ModuleConfigUtil.readModuleReference(this.baseDir, this.readModuleOptions || {}); + const moduleReferences = this.readAndDeduplicateModuleReferences(this.baseDir); const frameworkDirs = this.resolveFrameworkDirs(); if (!frameworkDirs.length) { - return ModuleConfigUtil.deduplicateModules(moduleReferences); + return moduleReferences; } debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => - ModuleConfigUtil.readModuleReference(frameworkDir, this.readModuleOptions || {}), + this.readAndDeduplicateModuleReferences(frameworkDir), ); // Merge all module references and deduplicate @@ -91,6 +134,6 @@ export class ModuleScanner { ...optionalModuleReferences.map((ref) => ({ ...ref, optional: true })), ]; - return ModuleConfigUtil.deduplicateModules(allModuleReferences); + return this.deduplicateLayeredModuleReferences(allModuleReferences); } } diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts index 268331ffbf..f5660cd66f 100644 --- a/tegg/plugin/config/test/ModuleScanner.test.ts +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -8,6 +8,7 @@ import { getFixtures } from './utils.ts'; describe('plugin/config/test/ModuleScanner.test.ts', () => { it('should scan module plugins from every framework layer', () => { const baseDir = getFixtures('framework-chain/app'); + const warnings: string[] = []; const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); expect(refs).toEqual([ @@ -17,6 +18,12 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), optional: true, }, + { + name: 'sharedModule', + package: 'shared-module-chair', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair'), + optional: true, + }, { name: 'baseModule', package: 'base-module', @@ -24,5 +31,17 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { optional: true, }, ]); + + const refsWithLogger = new ModuleScanner( + baseDir, + {}, + { warn: (message) => warnings.push(message) }, + ).loadModuleReferences(); + expect(refsWithLogger).toEqual(refs); + expect(warnings).toEqual([ + expect.stringContaining('Duplicate module name "sharedModule" found while scanning framework modules'), + ]); + expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair')); + expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/base-framework/node_modules/shared-module-base')); }); }); diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json new file mode 100644 index 0000000000..811f8ccbff --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/node_modules/shared-module-base/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared-module-base", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json index f3df328628..3d1b209664 100644 --- a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json @@ -2,6 +2,7 @@ "name": "base-framework", "type": "module", "dependencies": { - "base-module": "*" + "base-module": "*", + "shared-module-base": "*" } } diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json new file mode 100644 index 0000000000..5d6b676bb7 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/node_modules/shared-module-chair/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared-module-chair", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json index 50aed7984b..ae8e9dc342 100644 --- a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/chair-framework/package.json @@ -2,7 +2,8 @@ "name": "chair-framework", "type": "module", "dependencies": { - "chair-module": "*" + "chair-module": "*", + "shared-module-chair": "*" }, "egg": { "framework": "base-framework" From 7bed7a7266b93592c6be9fd7a226fa3d8f8bccd2 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:03:53 +0800 Subject: [PATCH 65/74] fix(tegg-config): stop at boolean framework roots --- tegg/plugin/config/src/lib/ModuleScanner.ts | 5 +++-- .../app/node_modules/base-framework/package.json | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index f7242e5be5..047dcc26aa 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -46,7 +46,7 @@ export class ModuleScanner { } private resolveParentFrameworkDir(frameworkDir: string): string | undefined { - let pkg: { egg?: { framework?: string } }; + let pkg: { egg?: { framework?: unknown } }; try { pkg = JSON.parse(fs.readFileSync(path.join(frameworkDir, 'package.json'), 'utf8')); } catch (err) { @@ -57,7 +57,8 @@ export class ModuleScanner { ); return undefined; } - if (!pkg.egg?.framework) { + const framework = pkg.egg?.framework; + if (typeof framework !== 'string' || !framework) { return undefined; } return this.resolveFrameworkDir(frameworkDir); diff --git a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json index 3d1b209664..360d64c0dd 100644 --- a/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json +++ b/tegg/plugin/config/test/fixtures/framework-chain/app/node_modules/base-framework/package.json @@ -4,5 +4,8 @@ "dependencies": { "base-module": "*", "shared-module-base": "*" + }, + "egg": { + "framework": true } } From ba38856ed276857b049637fefeb9e7815ad8e482 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:11:10 +0800 Subject: [PATCH 66/74] fix(standalone): clarify inner object override warnings --- .../standalone/src/StandaloneApp.ts | 23 +++++--- tegg/standalone/standalone/src/main.ts | 1 + tegg/standalone/standalone/test/index.test.ts | 54 ++++++++++++++++++- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index c9b1ff4217..255a7ae57e 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -47,11 +47,13 @@ export interface StandaloneAppInit { 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. + * Host-provided inner objects. Entries here win over the framework default + * placeholders, except `moduleConfig` entries are extended with module config + * objects loaded during init(). */ innerObjects?: Record; + /** User-facing option name for diagnostics. Defaults to `innerObjects`. */ + innerObjectsName?: string; /** * Logger used by the framework (loader, hooks) and injectable as the * `logger` inner object. Defaults to console. @@ -145,14 +147,19 @@ export class StandaloneApp { moduleConfig: [] as InnerObject[], runtimeConfig: [{ obj: this.#runtimeConfig }], }; - const reservedNames = ['moduleConfigs', 'moduleConfig', 'runtimeConfig']; - for (const name of reservedNames) { + const innerObjectsName = init?.innerObjectsName ?? 'innerObjects'; + const logger = init?.logger ?? console; + if (init?.innerObjects?.logger) { + logger.warn(`[tegg/standalone] ${innerObjectsName}.logger overrides the framework provided inner object`); + } + for (const name of ['moduleConfigs', 'runtimeConfig']) { if (init?.innerObjects?.[name]) { - (init.logger ?? console).warn( - `[tegg/standalone] innerObjectHandlers.${name} overrides the framework provided inner object`, - ); + logger.warn(`[tegg/standalone] ${innerObjectsName}.${name} overrides the framework provided inner object`); } } + if (init?.innerObjects?.moduleConfig) { + logger.warn(`[tegg/standalone] ${innerObjectsName}.moduleConfig extends the framework provided inner objects`); + } return Object.assign(frameworkInnerObjects, init?.innerObjects); } diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index 4f586d7a4e..b045ea50ca 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -72,6 +72,7 @@ export async function main(cwd: string, options?: StandaloneAppOptions frameworkDeps: options?.frameworkDeps, dump: options?.dump, innerObjects: options?.innerObjectHandlers, + innerObjectsName: 'innerObjectHandlers', logger: options?.logger, }, ); diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index f8ff65545e..0537b23fde 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -180,7 +180,13 @@ describe('standalone/standalone/test/index.test.ts', () => { }); it('should let an innerObjectHandlers logger entry win over options.logger', async () => { - const optionLogger = { ...console }; + const warnings: unknown[][] = []; + const optionLogger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; const handlerLogger = { ...console }; const injected = await main(path.join(__dirname, './fixtures/logger-option'), { logger: optionLogger, @@ -189,6 +195,30 @@ describe('standalone/standalone/test/index.test.ts', () => { }, }); assert.equal(injected, handlerLogger); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjectHandlers.logger overrides the framework provided inner object'], + ]); + }); + + it('should warn when innerObjects logger overrides the framework logger', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const handlerLogger = { ...console }; + const app = new StandaloneApp({ + logger, + innerObjects: { + logger: [{ obj: handlerLogger }], + }, + }); + await app.destroy(); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjects.logger overrides the framework provided inner object'], + ]); }); }); @@ -306,6 +336,28 @@ describe('standalone/standalone/test/index.test.ts', () => { ['[tegg/standalone] innerObjectHandlers.runtimeConfig overrides the framework provided inner object'], ]); }); + + it('should use low-level innerObjects name in framework object warnings', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const app = new StandaloneApp({ + logger, + innerObjects: { + runtimeConfig: [{ obj: {} }], + moduleConfig: [{ obj: {} }], + }, + }); + await app.destroy(); + assert.deepEqual(warnings, [ + ['[tegg/standalone] innerObjects.runtimeConfig overrides the framework provided inner object'], + ['[tegg/standalone] innerObjects.moduleConfig extends the framework provided inner objects'], + ]); + }); }); describe('multi instance prototype runner', () => { From d132713b1bcd31c2e9582c8c5519b3fb6d88c297 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:11:47 +0800 Subject: [PATCH 67/74] test(tegg-config): cover framework module dedupe edges --- tegg/plugin/config/test/ModuleScanner.test.ts | 37 ++++++++++++++++++- .../node_modules/base-module/package.json | 7 ++++ .../node_modules/base-framework/package.json | 10 +++++ .../node_modules/chair-module/package.json | 7 ++++ .../node_modules/chair-framework/package.json | 10 +++++ .../fixtures/framework-cycle/app/package.json | 7 ++++ .../app/config/module.json | 5 +++ .../node_modules/chair-module/package.json | 7 ++++ .../node_modules/chair-framework/package.json | 7 ++++ .../framework-same-path/app/package.json | 7 ++++ 10 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-cycle/app/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json create mode 100644 tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-same-path/app/package.json diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts index f5660cd66f..53b74e55a2 100644 --- a/tegg/plugin/config/test/ModuleScanner.test.ts +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -6,7 +6,7 @@ import { ModuleScanner } from '../src/lib/ModuleScanner.ts'; import { getFixtures } from './utils.ts'; describe('plugin/config/test/ModuleScanner.test.ts', () => { - it('should scan module plugins from every framework layer', () => { + it('should scan module plugins from every framework layer and keep nearest duplicate names', () => { const baseDir = getFixtures('framework-chain/app'); const warnings: string[] = []; const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); @@ -44,4 +44,39 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair')); expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/base-framework/node_modules/shared-module-base')); }); + + it('should keep the app reference when a framework scans the same module path', () => { + const baseDir = getFixtures('framework-same-path/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + }, + ]); + expect(warnings).toEqual([]); + }); + + it('should stop scanning when framework chain has a cycle', () => { + const baseDir = getFixtures('framework-cycle/app'); + const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'chairModule', + package: 'chair-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/chair-module'), + optional: true, + }, + { + name: 'baseModule', + package: 'base-module', + path: path.join(baseDir, 'node_modules/base-framework/node_modules/base-module'), + optional: true, + }, + ]); + }); }); diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json new file mode 100644 index 0000000000..3cae39e962 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/node_modules/base-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "base-module", + "type": "module", + "eggModule": { + "name": "baseModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json new file mode 100644 index 0000000000..cfbb57804e --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/base-framework/package.json @@ -0,0 +1,10 @@ +{ + "name": "base-framework", + "type": "module", + "dependencies": { + "base-module": "*" + }, + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..50aed7984b --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/node_modules/chair-framework/package.json @@ -0,0 +1,10 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*" + }, + "egg": { + "framework": "base-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json b/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json new file mode 100644 index 0000000000..703b8787e6 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-cycle/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-cycle-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json new file mode 100644 index 0000000000..0a74110cad --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../node_modules/chair-framework/node_modules/chair-module" + } +] diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json new file mode 100644 index 0000000000..8243b692fc --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/node_modules/chair-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-module", + "type": "module", + "eggModule": { + "name": "chairModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..b106add274 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "dependencies": { + "chair-module": "*" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json b/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json new file mode 100644 index 0000000000..f2c32d2045 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-same-path/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-same-path-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} From b3edc3f1efb0f15afad4eaa4c291dd27cd77e20e Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:22:55 +0800 Subject: [PATCH 68/74] refactor(dal): inject table and sql map managers --- tegg/plugin/dal/package.json | 4 ---- tegg/plugin/dal/src/app.ts | 21 ------------------- tegg/plugin/dal/src/app/extend/application.ts | 1 - .../dal/src/lib/DalModuleLoadUnitHook.ts | 10 +++++++-- .../dal/src/lib/DalTableEggPrototypeHook.ts | 10 +++++++-- tegg/plugin/dal/src/lib/DataSource.ts | 6 ++++-- tegg/plugin/dal/src/lib/SqlMapManager.ts | 10 ++------- tegg/plugin/dal/src/lib/TableModelManager.ts | 11 ++-------- .../test/fixtures/dal-manager-inject/foo.ts | 18 ++++++++++++---- 9 files changed, 38 insertions(+), 53 deletions(-) delete mode 100644 tegg/plugin/dal/src/app.ts delete mode 100644 tegg/plugin/dal/src/app/extend/application.ts diff --git a/tegg/plugin/dal/package.json b/tegg/plugin/dal/package.json index 13f405a79f..552eec0df2 100644 --- a/tegg/plugin/dal/package.json +++ b/tegg/plugin/dal/package.json @@ -28,8 +28,6 @@ "types": "./dist/index.d.ts", "exports": { ".": "./src/index.ts", - "./app": "./src/app.ts", - "./app/extend/application": "./src/app/extend/application.ts", "./lib/DalModuleLoadUnitHook": "./src/lib/DalModuleLoadUnitHook.ts", "./lib/DalTableEggPrototypeHook": "./src/lib/DalTableEggPrototypeHook.ts", "./lib/DataSource": "./src/lib/DataSource.ts", @@ -45,8 +43,6 @@ "access": "public", "exports": { ".": "./dist/index.js", - "./app": "./dist/app.js", - "./app/extend/application": "./dist/app/extend/application.js", "./lib/DalModuleLoadUnitHook": "./dist/lib/DalModuleLoadUnitHook.js", "./lib/DalTableEggPrototypeHook": "./dist/lib/DalTableEggPrototypeHook.js", "./lib/DataSource": "./dist/lib/DataSource.js", diff --git a/tegg/plugin/dal/src/app.ts b/tegg/plugin/dal/src/app.ts deleted file mode 100644 index 2a0abbf9de..0000000000 --- a/tegg/plugin/dal/src/app.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TeggScope } from '@eggjs/tegg-types'; -import type { Application, ILifecycleBoot } from 'egg'; - -import { SqlMapManager } from './lib/SqlMapManager.ts'; -import { TableModelManager } from './lib/TableModelManager.ts'; - -export default class DalAppBootHook implements ILifecycleBoot { - private readonly app: Application; - - constructor(app: Application) { - this.app = app; - } - - async beforeClose(): Promise { - // The per-app DAL managers are resolved/cleared within this app's scope. - await TeggScope.run(this.app._teggScopeBag, async () => { - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); - }); - } -} diff --git a/tegg/plugin/dal/src/app/extend/application.ts b/tegg/plugin/dal/src/app/extend/application.ts deleted file mode 100644 index ff8b4c5632..0000000000 --- a/tegg/plugin/dal/src/app/extend/application.ts +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts index 97f27375e4..6ad8165ca5 100644 --- a/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts +++ b/tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts @@ -23,6 +23,12 @@ export class DalModuleLoadUnitHook implements LifecycleHook { this.mysqlDataSourceManager.clear(); - SqlMapManager.instance.clear(); - TableModelManager.instance.clear(); + this.sqlMapManager.clear(); + this.tableModelManager.clear(); } } diff --git a/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts b/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts index bf3f699a83..d2bf4924a5 100644 --- a/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts +++ b/tegg/plugin/dal/src/lib/DalTableEggPrototypeHook.ts @@ -13,15 +13,21 @@ export class DalTableEggPrototypeHook implements LifecycleHook { if (!DaoInfoUtil.getIsDao(ctx.clazz)) { return; } const tableClazz = ctx.clazz.clazzModel; const tableModel: TableModel = TableModel.build(tableClazz); - TableModelManager.instance.set(ctx.loadUnit.name, tableModel); + this.tableModelManager.set(ctx.loadUnit.name, tableModel); const loader = new SqlMapLoader(tableModel, ctx.clazz, this.logger); const sqlMap = loader.load(); - SqlMapManager.instance.set(ctx.loadUnit.name, sqlMap); + this.sqlMapManager.set(ctx.loadUnit.name, sqlMap); } } diff --git a/tegg/plugin/dal/src/lib/DataSource.ts b/tegg/plugin/dal/src/lib/DataSource.ts index 82eb315efd..dbdfdbbfd3 100644 --- a/tegg/plugin/dal/src/lib/DataSource.ts +++ b/tegg/plugin/dal/src/lib/DataSource.ts @@ -62,6 +62,8 @@ export class DataSourceDelegate extends DataSource { constructor( @Inject() mysqlDataSourceManager: MysqlDataSourceManager, + @Inject() sqlMapManager: SqlMapManager, + @Inject() tableModelManager: TableModelManager, @Inject({ name: 'transactionalAOP' }) transactionalAOP: TransactionalAOP, @MultiInstanceInfo([DataSourceQualifierAttribute, LoadUnitNameQualifierAttribute]) objInfo: ObjectInfo, @@ -71,11 +73,11 @@ export class DataSourceDelegate extends DataSource { )?.value; assert(dataSourceQualifierValue); const [moduleName, dataSource, clazzName] = (dataSourceQualifierValue as string).split('.'); - const tableModel = TableModelManager.instance.get(moduleName, clazzName); + const tableModel = tableModelManager.get(moduleName, clazzName); assert(tableModel, `not found table ${dataSourceQualifierValue}`); const mysqlDataSource = mysqlDataSourceManager.get(moduleName, dataSource); assert(mysqlDataSource, `not found dataSource ${dataSource} in module ${moduleName}`); - const sqlMap = SqlMapManager.instance.get(moduleName, clazzName); + const sqlMap = sqlMapManager.get(moduleName, clazzName); assert(sqlMap, `not found SqlMap ${clazzName} in module ${moduleName}`); super(tableModel as TableModel, mysqlDataSource, sqlMap); diff --git a/tegg/plugin/dal/src/lib/SqlMapManager.ts b/tegg/plugin/dal/src/lib/SqlMapManager.ts index 14d9825624..3f6842dbae 100644 --- a/tegg/plugin/dal/src/lib/SqlMapManager.ts +++ b/tegg/plugin/dal/src/lib/SqlMapManager.ts @@ -1,14 +1,8 @@ +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import type { TableSqlMap } from '@eggjs/dal-runtime'; -import { TeggScope } from '@eggjs/tegg-types'; - -const SQL_MAP_MANAGER_SLOT = Symbol('tegg:dal:sqlMapManager'); +@InnerObjectProto({ name: 'sqlMapManager', accessLevel: AccessLevel.PUBLIC }) export class SqlMapManager { - // Per-app: keyed by module name (collides across apps); resolved from scope. - static get instance(): SqlMapManager { - return TeggScope.resolve(SQL_MAP_MANAGER_SLOT, () => new SqlMapManager(), 'SqlMapManager.instance'); - } - private sqlMaps: Map>; constructor() { diff --git a/tegg/plugin/dal/src/lib/TableModelManager.ts b/tegg/plugin/dal/src/lib/TableModelManager.ts index af516b252f..73c3c4f3ec 100644 --- a/tegg/plugin/dal/src/lib/TableModelManager.ts +++ b/tegg/plugin/dal/src/lib/TableModelManager.ts @@ -1,15 +1,8 @@ +import { AccessLevel, InnerObjectProto } from '@eggjs/core-decorator'; import type { TableModel } from '@eggjs/dal-decorator'; -import { TeggScope } from '@eggjs/tegg-types'; - -const TABLE_MODEL_MANAGER_SLOT = Symbol('tegg:dal:tableModelManager'); +@InnerObjectProto({ name: 'tableModelManager', accessLevel: AccessLevel.PUBLIC }) export class TableModelManager { - // Per-app: keyed by module name, which collides across apps; resolved from the - // active TeggScope bag so two apps never share table-model registrations. - static get instance(): TableModelManager { - return TeggScope.resolve(TABLE_MODEL_MANAGER_SLOT, () => new TableModelManager(), 'TableModelManager.instance'); - } - private tableModels: Map>; constructor() { diff --git a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts index 21fab4ce35..328b357d2c 100644 --- a/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts +++ b/tegg/standalone/standalone/test/fixtures/dal-manager-inject/foo.ts @@ -1,10 +1,10 @@ -import type { MysqlDataSourceManager } from '@eggjs/dal-plugin'; +import type { MysqlDataSourceManager, SqlMapManager, TableModelManager } from '@eggjs/dal-plugin'; import { Inject, SingletonProto } from '@eggjs/tegg'; import { Runner, type MainRunner } from '@eggjs/tegg/standalone'; /** - * Pins the PUBLIC `mysqlDataSourceManager` injection surface: business - * modules inject the dal manager by name. + * Pins the PUBLIC dal manager injection surface: business modules inject the + * dal managers by name. */ @Runner() @SingletonProto() @@ -12,7 +12,17 @@ export class Foo implements MainRunner { @Inject() mysqlDataSourceManager: MysqlDataSourceManager; + @Inject() + sqlMapManager: SqlMapManager; + + @Inject() + tableModelManager: TableModelManager; + async main(): Promise { - return typeof this.mysqlDataSourceManager.createDataSource === 'function'; + return ( + typeof this.mysqlDataSourceManager.createDataSource === 'function' && + typeof this.sqlMapManager.get === 'function' && + typeof this.tableModelManager.get === 'function' + ); } } From fad5597f6c874d54d03b744e9da905db8c16a8f0 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:49:40 +0800 Subject: [PATCH 69/74] fix(tegg-config): resolve framework module json packages locally --- tegg/plugin/config/src/lib/ModuleScanner.ts | 9 ++++++--- tegg/plugin/config/test/ModuleScanner.test.ts | 14 ++++++++++++++ .../chair-framework/config/module.json | 5 +++++ .../framework-config-module/package.json | 7 +++++++ .../app/node_modules/chair-framework/package.json | 7 +++++++ .../framework-config-module/package.json | 7 +++++++ .../framework-module-json/app/package.json | 7 +++++++ tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts | 12 ++++++++++++ 8 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json create mode 100644 tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-module-json/app/package.json diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 047dcc26aa..e5616a86df 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -76,9 +76,12 @@ export class ModuleScanner { return frameworkDirs; } - private readAndDeduplicateModuleReferences(baseDir: string): readonly ModuleReference[] { + private readAndDeduplicateModuleReferences(baseDir: string, cwd?: string): readonly ModuleReference[] { return ModuleConfigUtil.deduplicateModules( - ModuleConfigUtil.readModuleReference(baseDir, this.readModuleOptions || {}), + ModuleConfigUtil.readModuleReference(baseDir, { + ...this.readModuleOptions, + ...(cwd ? { cwd } : {}), + }), ); } @@ -126,7 +129,7 @@ export class ModuleScanner { } debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => - this.readAndDeduplicateModuleReferences(frameworkDir), + this.readAndDeduplicateModuleReferences(frameworkDir, frameworkDir), ); // Merge all module references and deduplicate diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts index 53b74e55a2..b10199a03b 100644 --- a/tegg/plugin/config/test/ModuleScanner.test.ts +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -60,6 +60,20 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { expect(warnings).toEqual([]); }); + it('should resolve framework module.json package references from the framework directory', () => { + const baseDir = getFixtures('framework-module-json/app'); + const refs = new ModuleScanner(baseDir, { cwd: baseDir }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'frameworkConfigModule', + package: 'framework-config-module', + path: path.join(baseDir, 'node_modules/chair-framework/node_modules/framework-config-module'), + optional: true, + }, + ]); + }); + it('should stop scanning when framework chain has a cycle', () => { const baseDir = getFixtures('framework-cycle/app'); const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json new file mode 100644 index 0000000000..6a32c04891 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/config/module.json @@ -0,0 +1,5 @@ +[ + { + "package": "framework-config-module" + } +] diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json new file mode 100644 index 0000000000..e56b63a263 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/node_modules/framework-config-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-config-module", + "type": "module", + "eggModule": { + "name": "frameworkConfigModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..3e840b548d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json new file mode 100644 index 0000000000..561b429610 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/node_modules/framework-config-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-config-module", + "type": "module", + "eggModule": { + "name": "appWrongModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json b/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json new file mode 100644 index 0000000000..7d52ceb0a7 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-module-json/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-module-json-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index fcb7ea16b1..f2e3a0d477 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -35,6 +35,12 @@ describe('test/lib/EggModuleLoader.test.ts', () => { path: '/virtual/disabled-plugin', optional: true, }, + { + name: 'samePathDifferentPackage', + package: 'module-package', + path: '/virtual/same-path', + optional: true, + }, ]; const app = { baseDir: '/virtual/app', @@ -64,6 +70,11 @@ describe('test/lib/EggModuleLoader.test.ts', () => { package: 'disabled-plugin', path: '/virtual/disabled-plugin', }, + samePathDifferentPackage: { + enable: true, + package: 'plugin-package', + path: '/virtual/same-path', + }, }, } as any; @@ -79,6 +90,7 @@ describe('test/lib/EggModuleLoader.test.ts', () => { assert.equal(moduleReferences[0].optional, false); assert.equal(moduleReferences[1].optional, false); assert.equal(moduleReferences[2].optional, true); + assert.equal(moduleReferences[3].optional, true); }); describe('has recursive dependency module', () => { From ec775a399cfeef72f60e6ed50c6cb9c31115e3e7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 18:50:51 +0800 Subject: [PATCH 70/74] docs(standalone): update inner object handler option --- tegg/standalone/standalone/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tegg/standalone/standalone/README.md b/tegg/standalone/standalone/README.md index d5546b0cfc..772df8ee89 100644 --- a/tegg/standalone/standalone/README.md +++ b/tegg/standalone/standalone/README.md @@ -43,11 +43,11 @@ export class Foo implements MainRunner { - cwd 为当前应用工作目录 - options: - - innerObjects: 当前运行环境中内置的对象 + - innerObjectHandlers: 当前运行环境中内置的对象 ``` await main(cwd, { - innerObjects: { + innerObjectHandlers: { hello: { hello: () => { return 'hello, inner'; From bcc790a10f2c60327d47e94e96f5ab5f0fa7cdd7 Mon Sep 17 00:00:00 2001 From: gxkl Date: Thu, 9 Jul 2026 20:39:08 +0800 Subject: [PATCH 71/74] fix(tegg): address review follow-ups --- tegg/core/aop-runtime/src/LoadUnitAopHook.ts | 2 +- .../core/aop-runtime/test/aop-runtime.test.ts | 17 +- tegg/core/common-util/src/ModuleConfig.ts | 2 + .../common-util/test/ModuleConfig.test.ts | 3 +- .../app-with-module-json/config/module.json | 2 +- .../config/module.json | 2 +- .../test/__snapshots__/index.test.ts.snap | 1 + tegg/core/metadata/src/errors.ts | 6 +- .../src/factory/EggPrototypeFactory.ts | 15 +- .../metadata/src/factory/LoadUnitFactory.ts | 16 +- .../metadata/src/impl/EggPrototypeBuilder.ts | 24 ++- .../metadata/src/impl/EggPrototypeImpl.ts | 6 + tegg/core/metadata/src/impl/ModuleLoadUnit.ts | 3 + .../metadata/src/model/ModuleDescriptor.ts | 12 +- .../metadata/src/model/graph/ProtoNode.ts | 11 +- tegg/core/metadata/test/LoadUnit.test.ts | 18 ++ .../test/ModuleDescriptorDumper.test.ts | 28 +++ .../modules/no-package-module/.gitkeep | 1 + .../src/impl/ProvidedInnerObjectProto.ts | 23 ++- .../runtime/test/InnerObjectLoadUnit.test.ts | 25 ++- .../test/__snapshots__/helper.test.ts.snap | 1 + .../types/src/metadata/model/EggPrototype.ts | 2 + .../core/types/src/metadata/model/LoadUnit.ts | 1 + tegg/plugin/config/src/app.ts | 15 +- tegg/plugin/config/src/lib/ModuleScanner.ts | 31 ++-- .../test/ManifestModuleReference.test.ts | 8 + tegg/plugin/config/test/ModuleScanner.test.ts | 46 ++++- .../app/node_modules/far-module/package.json | 7 + .../app/node_modules/near-module/package.json | 7 + .../app/package.json | 11 ++ .../dist/modules/app-dist-module/package.json | 7 + .../framework-dist-module/package.json | 7 + .../node_modules/chair-framework/package.json | 7 + .../framework-dist-scan/app/package.json | 7 + tegg/plugin/dal/package.json | 3 +- tegg/plugin/tegg/src/app.ts | 1 + tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 35 ++-- .../plugin/tegg/src/lib/ModuleConfigLoader.ts | 2 +- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 16 +- .../tegg/test/lib/EggModuleLoader.test.ts | 169 +++++++++++++++++- .../tegg/test/lib/ModuleHandler.test.ts | 4 + .../standalone/src/StandaloneApp.ts | 7 +- wiki/concepts/tegg-module-plugin.md | 14 +- wiki/log.md | 6 + 44 files changed, 545 insertions(+), 86 deletions(-) create mode 100644 tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep create mode 100644 tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json diff --git a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts index f09313eb39..4cb93463d4 100644 --- a/tegg/core/aop-runtime/src/LoadUnitAopHook.ts +++ b/tegg/core/aop-runtime/src/LoadUnitAopHook.ts @@ -17,7 +17,7 @@ export class LoadUnitAopHook implements LifecycleHook { for (const proto of loadUnit.iterateEggPrototype()) { diff --git a/tegg/core/aop-runtime/test/aop-runtime.test.ts b/tegg/core/aop-runtime/test/aop-runtime.test.ts index 28f321c5df..8c7804913c 100644 --- a/tegg/core/aop-runtime/test/aop-runtime.test.ts +++ b/tegg/core/aop-runtime/test/aop-runtime.test.ts @@ -13,6 +13,7 @@ import { describe, beforeEach, afterEach, it } from 'vitest'; import { Hello } from './fixtures/modules/hello_succeed/Hello.js'; import { crossCutGraphHook } from '../src/CrossCutGraphHook.js'; +import { AopContextAdviceRegistry } from '../src/AopContextAdviceRegistry.js'; import { EggObjectAopHook } from '../src/EggObjectAopHook.js'; import { EggPrototypeCrossCutHook } from '../src/EggPrototypeCrossCutHook.js'; import { LoadUnitAopHook } from '../src/LoadUnitAopHook.js'; @@ -22,6 +23,13 @@ import { CallTrace } from './fixtures/modules/hello_cross_cut/CallTrace.js'; import { crosscutAdviceParams } from './fixtures/modules/hello_cross_cut/HelloCrossCut.js'; import { pointcutAdviceParams } from './fixtures/modules/hello_point_cut/HelloPointCut.js'; +function createLoadUnitAopHook(crosscutAdviceFactory: CrosscutAdviceFactory): LoadUnitAopHook { + const loadUnitAopHook = new LoadUnitAopHook(); + Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', crosscutAdviceFactory); + Reflect.set(loadUnitAopHook, 'aopContextAdviceRegistry', new AopContextAdviceRegistry()); + return loadUnitAopHook; +} + describe('test/aop-runtime.test.ts', () => { afterEach(() => { mock.reset(); @@ -37,8 +45,7 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(); - Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); @@ -167,8 +174,7 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(); - Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); @@ -195,8 +201,7 @@ describe('test/aop-runtime.test.ts', () => { beforeEach(async () => { crosscutAdviceFactory = new CrosscutAdviceFactory(); eggObjectAopHook = new EggObjectAopHook(); - loadUnitAopHook = new LoadUnitAopHook(); - Reflect.set(loadUnitAopHook, 'crosscutAdviceFactory', crosscutAdviceFactory); + loadUnitAopHook = createLoadUnitAopHook(crosscutAdviceFactory); eggPrototypeCrossCutHook = new EggPrototypeCrossCutHook(); Reflect.set(eggPrototypeCrossCutHook, 'crosscutAdviceFactory', crosscutAdviceFactory); EggPrototypeLifecycleUtil.registerLifecycle(eggPrototypeCrossCutHook); diff --git a/tegg/core/common-util/src/ModuleConfig.ts b/tegg/core/common-util/src/ModuleConfig.ts index b9d9f2f5d0..74d86b857a 100644 --- a/tegg/core/common-util/src/ModuleConfig.ts +++ b/tegg/core/common-util/src/ModuleConfig.ts @@ -91,6 +91,7 @@ export class ModuleConfigUtil { path: modulePath, name: ModuleConfigUtil.getModuleName(pkg), package: ModuleConfigUtil.getPackageName(pkg), + ...(moduleReferenceConfig.optional === undefined ? {} : { optional: moduleReferenceConfig.optional }), }; } else if (ModuleReferenceConfigHelp.isInlineModuleReference(moduleReferenceConfig)) { const modulePath = path.join(configDir, moduleReferenceConfig.path); @@ -99,6 +100,7 @@ export class ModuleConfigUtil { path: modulePath, name: ModuleConfigUtil.getModuleName(pkg), package: ModuleConfigUtil.getPackageName(pkg), + ...(moduleReferenceConfig.optional === undefined ? {} : { optional: moduleReferenceConfig.optional }), }; } else { throw new Error('unknown type of module reference config: ' + JSON.stringify(moduleReferenceConfig)); diff --git a/tegg/core/common-util/test/ModuleConfig.test.ts b/tegg/core/common-util/test/ModuleConfig.test.ts index 86c0f38055..4f47f50d51 100644 --- a/tegg/core/common-util/test/ModuleConfig.test.ts +++ b/tegg/core/common-util/test/ModuleConfig.test.ts @@ -137,7 +137,7 @@ describe('test/ModuleConfig.test.ts', () => { const ref = ModuleConfigUtil.readModuleReference(fixturesPath); assert.deepStrictEqual(ref, [ { path: path.join(fixturesPath, 'app/module-a'), name: 'moduleA', package: 'module-a' }, - { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b' }, + { path: path.join(fixturesPath, 'app/module-b'), name: 'moduleB', package: 'module-b', optional: true }, ]); }); }); @@ -153,6 +153,7 @@ describe('test/ModuleConfig.test.ts', () => { path: path.join(fixturesPath, 'node_modules/module-a'), name: 'moduleA', package: 'module-a', + optional: true, }, ]); }); diff --git a/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json b/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json index 1d83090be1..fe99c54f79 100644 --- a/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json +++ b/tegg/core/common-util/test/fixtures/apps/app-with-module-json/config/module.json @@ -1 +1 @@ -[{ "path": "../app/module-a" }, { "path": "../app/module-b" }] +[{ "path": "../app/module-a" }, { "path": "../app/module-b", "optional": true }] diff --git a/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json b/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json index c1552b1b26..2efcce69b3 100644 --- a/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json +++ b/tegg/core/common-util/test/fixtures/apps/app-with-module-pkg-json/config/module.json @@ -1 +1 @@ -[{ "package": "module-a" }] +[{ "package": "module-a", "optional": true }] diff --git a/tegg/core/loader/test/__snapshots__/index.test.ts.snap b/tegg/core/loader/test/__snapshots__/index.test.ts.snap index 55d1af2d9a..b1442790fd 100644 --- a/tegg/core/loader/test/__snapshots__/index.test.ts.snap +++ b/tegg/core/loader/test/__snapshots__/index.test.ts.snap @@ -6,5 +6,6 @@ exports[`should export stable 1`] = ` "LoaderUtil": [Function], "ModuleLoader": [Function], "TEGG_MANIFEST_KEY": "tegg", + "buildTeggManifestData": [Function], } `; diff --git a/tegg/core/metadata/src/errors.ts b/tegg/core/metadata/src/errors.ts index ebc593e9b2..c7b0f4e660 100644 --- a/tegg/core/metadata/src/errors.ts +++ b/tegg/core/metadata/src/errors.ts @@ -2,6 +2,10 @@ import { FrameworkBaseError } from '@eggjs/errors'; import { ErrorCodes } from '@eggjs/tegg-types'; import type { EggPrototypeName, QualifierInfo } from '@eggjs/tegg-types'; +function formatQualifiers(qualifiers: readonly QualifierInfo[]): string { + return `[${qualifiers.map((qualifier) => `${String(qualifier.attribute)}=${String(qualifier.value)}`).join(',')}]`; +} + export class TeggError extends FrameworkBaseError { get module() { return 'TEGG'; @@ -19,7 +23,7 @@ export class EggPrototypeNotFound extends TeggError { export class MultiPrototypeFound extends TeggError { constructor(name: EggPrototypeName, qualifier: QualifierInfo[], result?: string) { - const msg = `multi proto found for name:${String(name)} and qualifiers ${JSON.stringify(qualifier)}${ + const msg = `multi proto found for name:${String(name)} and qualifiers ${formatQualifiers(qualifier)}${ result ? `, result is ${result}` : '' }`; super(msg, ErrorCodes.MULTI_PROTO_FOUND); diff --git a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts index 400b8e2c76..dfbc21824e 100644 --- a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts +++ b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts @@ -1,7 +1,7 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; import { FrameworkErrorFormatter } from '@eggjs/errors'; import { MapUtil } from '@eggjs/tegg-common-util'; -import { AccessLevel, TeggScope } from '@eggjs/tegg-types'; +import { AccessLevel, DefineModuleQualifierAttribute, TeggScope } from '@eggjs/tegg-types'; import type { EggProtoImplClass, EggPrototypeName, @@ -98,7 +98,9 @@ export class EggPrototypeFactory { if (protos.length === 1) { return protos[0]; } - throw FrameworkErrorFormatter.formatError(new MultiPrototypeFound(name, qualifiers)); + throw FrameworkErrorFormatter.formatError( + new MultiPrototypeFound(name, qualifiers, JSON.stringify(protos.map(EggPrototypeFactory.formatPrototype))), + ); } private doGetPrototype(name: EggPrototypeName, qualifiers: QualifierInfo[], loadUnit?: LoadUnit): EggPrototype[] { @@ -114,4 +116,13 @@ export class EggPrototypeFactory { const protos = this.publicProtoMap.get(name); return protos?.filter((proto) => proto.verifyQualifiers(qualifiers)) || []; } + + private static formatPrototype(proto: EggPrototype): string { + return ( + `${String(proto.name)}@${proto.loadUnitId}` + + ` define:${String(proto.defineModuleName ?? proto.getQualifier(DefineModuleQualifierAttribute))}` + + `@${String(proto.defineUnitPath ?? proto.loadUnitId)}` + + ` qualifiers:[${String(DefineModuleQualifierAttribute)}=${String(proto.getQualifier(DefineModuleQualifierAttribute))}]` + ); + } } diff --git a/tegg/core/metadata/src/factory/LoadUnitFactory.ts b/tegg/core/metadata/src/factory/LoadUnitFactory.ts index 7996899113..413eef6ebf 100644 --- a/tegg/core/metadata/src/factory/LoadUnitFactory.ts +++ b/tegg/core/metadata/src/factory/LoadUnitFactory.ts @@ -45,13 +45,19 @@ export class LoadUnitFactory { return await creator(ctx); } - static async createLoadUnit(unitPath: string, type: EggLoadUnitTypeLike, loader: Loader): Promise { + static async createLoadUnit( + unitPath: string, + type: EggLoadUnitTypeLike, + loader: Loader, + unitName?: string, + ): Promise { const loadUnitMap = LoadUnitFactory.loadUnitMap; if (loadUnitMap.has(unitPath)) { return loadUnitMap.get(unitPath)!.loadUnit; } const ctx: LoadUnitLifecycleContext = { unitPath, + unitName, loader, }; const loadUnit = await LoadUnitFactory.getLoanUnit(ctx, type); @@ -65,9 +71,15 @@ export class LoadUnitFactory { return loadUnit; } - static async createPreloadLoadUnit(unitPath: string, type: EggLoadUnitTypeLike, loader: Loader): Promise { + static async createPreloadLoadUnit( + unitPath: string, + type: EggLoadUnitTypeLike, + loader: Loader, + unitName?: string, + ): Promise { const ctx: LoadUnitLifecycleContext = { unitPath, + unitName, loader, }; return await LoadUnitFactory.getLoanUnit(ctx, type); diff --git a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts index cf687912af..a23075a218 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeBuilder.ts @@ -36,6 +36,8 @@ export type EggPrototypeImplClass = new ( injectType?: InjectType, multiInstanceConstructorIndex?: number, multiInstanceConstructorAttributes?: QualifierAttribute[], + defineModuleName?: string, + defineUnitPath?: string, ) => EggPrototype; export class EggPrototypeBuilder { @@ -52,6 +54,8 @@ export class EggPrototypeBuilder { private className?: string; private multiInstanceConstructorIndex?: number; private multiInstanceConstructorAttributes?: QualifierAttribute[]; + private defineModuleName?: string; + private defineUnitPath?: string; private protoImplClass: EggPrototypeImplClass = EggPrototypeImpl; static create(ctx: EggPrototypeLifecycleContext): EggPrototype { @@ -60,26 +64,32 @@ export class EggPrototypeBuilder { static createWithProtoImpl(ctx: EggPrototypeLifecycleContext, protoImplClass: EggPrototypeImplClass): EggPrototype { const { clazz, loadUnit } = ctx; + const prototypeInfo = ctx.prototypeInfo as typeof ctx.prototypeInfo & { + defineModuleName?: string; + defineUnitPath?: string; + }; const filepath = PrototypeUtil.getFilePath(clazz); assert(filepath, 'not find filepath'); const builder = new EggPrototypeBuilder(); builder.protoImplClass = protoImplClass; builder.clazz = clazz; - builder.name = ctx.prototypeInfo.name; - builder.className = ctx.prototypeInfo.className; - builder.initType = ctx.prototypeInfo.initType; - builder.accessLevel = ctx.prototypeInfo.accessLevel; + builder.name = prototypeInfo.name; + builder.className = prototypeInfo.className; + builder.initType = prototypeInfo.initType; + builder.accessLevel = prototypeInfo.accessLevel; builder.filepath = filepath!; builder.injectType = PrototypeUtil.getInjectType(clazz); builder.injectObjects = PrototypeUtil.getInjectObjects(clazz) || []; builder.loadUnit = loadUnit; builder.qualifiers = QualifierUtil.mergeQualifiers( QualifierUtil.getProtoQualifiers(clazz), - ctx.prototypeInfo.qualifiers ?? [], + prototypeInfo.qualifiers ?? [], ); - builder.properQualifiers = ctx.prototypeInfo.properQualifiers ?? {}; + builder.properQualifiers = prototypeInfo.properQualifiers ?? {}; builder.multiInstanceConstructorIndex = PrototypeUtil.getMultiInstanceConstructorIndex(clazz); builder.multiInstanceConstructorAttributes = PrototypeUtil.getMultiInstanceConstructorAttributes(clazz); + builder.defineModuleName = prototypeInfo.defineModuleName; + builder.defineUnitPath = prototypeInfo.defineUnitPath; return builder.build(); } @@ -107,6 +117,8 @@ export class EggPrototypeBuilder { this.injectType, this.multiInstanceConstructorIndex, this.multiInstanceConstructorAttributes, + this.defineModuleName, + this.defineUnitPath, ); } } diff --git a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts index 694fa068e1..1845a9a1a1 100644 --- a/tegg/core/metadata/src/impl/EggPrototypeImpl.ts +++ b/tegg/core/metadata/src/impl/EggPrototypeImpl.ts @@ -25,6 +25,8 @@ export class EggPrototypeImpl implements EggPrototype { readonly injectObjects: Array; readonly injectType: InjectType; readonly loadUnitId: Id; + readonly defineModuleName?: string; + readonly defineUnitPath?: string; readonly className?: string; readonly multiInstanceConstructorIndex?: number; readonly multiInstanceConstructorAttributes?: QualifierAttribute[]; @@ -44,6 +46,8 @@ export class EggPrototypeImpl implements EggPrototype { injectType?: InjectType, multiInstanceConstructorIndex?: number, multiInstanceConstructorAttributes?: QualifierAttribute[], + defineModuleName?: string, + defineUnitPath?: string, ) { this.id = id; this.clazz = clazz; @@ -58,6 +62,8 @@ export class EggPrototypeImpl implements EggPrototype { this.injectType = injectType || InjectType.PROPERTY; this.multiInstanceConstructorIndex = multiInstanceConstructorIndex; this.multiInstanceConstructorAttributes = multiInstanceConstructorAttributes; + this.defineModuleName = defineModuleName; + this.defineUnitPath = defineUnitPath; } verifyQualifiers(qualifiers: QualifierInfo[]): boolean { diff --git a/tegg/core/metadata/src/impl/ModuleLoadUnit.ts b/tegg/core/metadata/src/impl/ModuleLoadUnit.ts index bc9c43a4d1..6ff5283a47 100644 --- a/tegg/core/metadata/src/impl/ModuleLoadUnit.ts +++ b/tegg/core/metadata/src/impl/ModuleLoadUnit.ts @@ -319,6 +319,9 @@ export class ModuleLoadUnit implements LoadUnit { } static createModule(ctx: LoadUnitLifecycleContext): ModuleLoadUnit { + if (ctx.unitName) { + return new ModuleLoadUnit(ctx.unitName, ctx.unitPath); + } const pkgPath = path.join(ctx.unitPath, 'package.json'); const pkg: { eggModule?: { name: string } } = JSON.parse(readFileSync(pkgPath, 'utf-8')); assert(pkg.eggModule, `module config not found in package ${pkgPath}`); diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index 4b9984a8c8..1b9629fba7 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -92,7 +92,15 @@ export class ModuleDescriptorDumper { static async dump(desc: ModuleDescriptor, options?: ModuleDumpOptions): Promise { const dumpPath = ModuleDescriptorDumper.dumpPath(desc, options); - await fs.mkdir(path.dirname(dumpPath), { recursive: true }); - await fs.writeFile(dumpPath, ModuleDescriptorDumper.stringifyDescriptor(desc)); + const dumpDir = path.dirname(dumpPath); + await fs.mkdir(dumpDir, { recursive: true }); + const tmpDir = await fs.mkdtemp(path.join(dumpDir, '.tmp-')); + const tmpPath = path.join(tmpDir, path.basename(dumpPath)); + try { + await fs.writeFile(tmpPath, ModuleDescriptorDumper.stringifyDescriptor(desc)); + await fs.rename(tmpPath, dumpPath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } } } diff --git a/tegg/core/metadata/src/model/graph/ProtoNode.ts b/tegg/core/metadata/src/model/graph/ProtoNode.ts index 03c851fd9a..b49e5c0b97 100644 --- a/tegg/core/metadata/src/model/graph/ProtoNode.ts +++ b/tegg/core/metadata/src/model/graph/ProtoNode.ts @@ -29,8 +29,15 @@ export class ProtoNode implements GraphNodeObj { this.proto = proto; } - toString() { - return `${String(this.proto.name)}@${this.proto.instanceDefineUnitPath}`; + toString(): string { + const qualifiers = this.proto.qualifiers + .map((qualifier) => `${String(qualifier.attribute)}=${String(qualifier.value)}`) + .join(','); + return ( + `${String(this.proto.name)}@${this.proto.instanceDefineUnitPath}` + + ` define:${this.proto.defineModuleName}@${this.proto.defineUnitPath}` + + ` qualifiers:[${qualifiers}]` + ); } selectProto(ctx: ProtoSelectorContext): boolean { diff --git a/tegg/core/metadata/test/LoadUnit.test.ts b/tegg/core/metadata/test/LoadUnit.test.ts index c517bee72d..fdf4f0862b 100644 --- a/tegg/core/metadata/test/LoadUnit.test.ts +++ b/tegg/core/metadata/test/LoadUnit.test.ts @@ -91,6 +91,24 @@ describe('test/LoadUnit/LoadUnit.test.ts', () => { await LoadUnitFactory.destroyLoadUnit(loadUnit); }); + it('should create from provided unit name without reading package.json', async () => { + const modulePath = path.join(__dirname, './fixtures/modules/no-package-module'); + const loader = new TestLoader(modulePath); + await buildGlobalGraph([], []); + + const loadUnit = await LoadUnitFactory.createLoadUnit( + modulePath, + EggLoadUnitType.MODULE, + loader, + 'manifestModule', + ); + + assert(loadUnit.id === 'LOAD_UNIT:manifestModule'); + assert(loadUnit.name === 'manifestModule'); + assert(loadUnit.unitPath === modulePath); + await LoadUnitFactory.destroyLoadUnit(loadUnit); + }); + it('recursive deps should should throw error', async () => { const repoModulePath = path.join(__dirname, './fixtures/modules/recursive-load-unit'); const loader = new TestLoader(repoModulePath); diff --git a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts index fb1b15ae6d..a2b77f7491 100644 --- a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { PrototypeUtil } from '@eggjs/core-decorator'; @@ -102,4 +104,30 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { assert.equal(files.length, 0); }); }); + + describe('dump()', () => { + it('should write descriptor to deterministic path and cleanup temp dir', async () => { + const dumpDir = await fs.mkdtemp(path.join(tmpdir(), 'module-desc-dump-')); + try { + const desc: ModuleDescriptor = { + name: 'dumped', + unitPath: '/tmp/dumped', + clazzList: [], + multiInstanceClazzList: [], + innerObjectClazzList: [], + protos: [], + }; + + await ModuleDescriptorDumper.dump(desc, { dumpDir }); + + const dumpPath = ModuleDescriptorDumper.dumpPath(desc, { dumpDir }); + const json = JSON.parse(await fs.readFile(dumpPath, 'utf8')); + assert.equal(json.name, 'dumped'); + const dumpEntries = await fs.readdir(path.join(dumpDir, '.egg')); + assert.deepEqual(dumpEntries, ['dumped_module_desc.json']); + } finally { + await fs.rm(dumpDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep b/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/tegg/core/metadata/test/fixtures/modules/no-package-module/.gitkeep @@ -0,0 +1 @@ + diff --git a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts index b61d0bd70a..39058f812a 100644 --- a/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts +++ b/tegg/core/runtime/src/impl/ProvidedInnerObjectProto.ts @@ -12,6 +12,7 @@ import type { InjectObjectProto, MetaDataKey, ObjectInitTypeLike, + QualifierAttribute, QualifierInfo, QualifierValue, } from '@eggjs/tegg-types'; @@ -36,6 +37,8 @@ export class ProvidedInnerObjectProto implements EggPrototype { readonly accessLevel: AccessLevel; readonly injectObjects: InjectObjectProto[]; readonly loadUnitId: Id; + readonly defineModuleName?: string; + readonly defineUnitPath?: string; constructor( id: string, @@ -45,6 +48,8 @@ export class ProvidedInnerObjectProto implements EggPrototype { loadUnitId: Id, qualifiers: QualifierInfo[], accessLevel?: AccessLevel, + defineModuleName?: string, + defineUnitPath?: string, ) { this.id = id; this.objFactory = objFactory; @@ -54,6 +59,8 @@ export class ProvidedInnerObjectProto implements EggPrototype { this.injectObjects = []; this.loadUnitId = loadUnitId; this.qualifiers = qualifiers; + this.defineModuleName = defineModuleName; + this.defineUnitPath = defineUnitPath; } verifyQualifiers(qualifiers: QualifierInfo[]): boolean { @@ -79,7 +86,7 @@ export class ProvidedInnerObjectProto implements EggPrototype { return MetadataUtil.getMetaData(metadataKey, this.objFactory as unknown as EggProtoImplClass); } - getQualifier(attribute: string): QualifierValue | undefined { + getQualifier(attribute: QualifierAttribute): QualifierValue | undefined { return this.qualifiers.find((t) => t.attribute === attribute)?.value; } @@ -87,16 +94,22 @@ export class ProvidedInnerObjectProto implements 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 prototypeInfo = ctx.prototypeInfo as typeof ctx.prototypeInfo & { + defineModuleName?: string; + defineUnitPath?: string; + }; + const name = prototypeInfo.name; const id = IdenticalUtil.createProtoId(loadUnit.id, name); return new ProvidedInnerObjectProto( id, name, clazz as unknown as () => object, - ctx.prototypeInfo.initType, + prototypeInfo.initType, loadUnit.id, - ctx.prototypeInfo.qualifiers ?? [], - ctx.prototypeInfo.accessLevel, + prototypeInfo.qualifiers ?? [], + prototypeInfo.accessLevel, + prototypeInfo.defineModuleName, + prototypeInfo.defineUnitPath, ); } } diff --git a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts index 1c7bc8b666..cdf9ea011c 100644 --- a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts +++ b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts @@ -257,9 +257,20 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { const loadUnit = await builder.createLoadUnit({ innerObjects: {} }); let instance: LoadUnitInstance | undefined; try { - assert.throws(() => { - EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit); - }, /multi proto found/); + assert.throws( + () => { + EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit); + }, + (err) => { + assert(err instanceof Error); + assert.match(err.message, /multi proto found/); + assert.match(err.message, /define:module-a@\/module-a/); + assert.match(err.message, /define:module-b@\/module-b/); + assert.match(err.message, /Symbol\(Qualifier\.DefineModule\)=module-a/); + assert.match(err.message, /Symbol\(Qualifier\.DefineModule\)=module-b/); + return true; + }, + ); const moduleAProto = EggPrototypeFactory.instance.getPrototype('sharedInner', loadUnit, [ { attribute: DefineModuleQualifierAttribute, @@ -308,6 +319,14 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { }); let instance: LoadUnitInstance | undefined; try { + const providedProto = EggPrototypeFactory.instance.getPrototype('sharedHostObject', loadUnit, [ + { + attribute: DefineModuleQualifierAttribute, + value: 'app', + }, + ]); + assert.equal(providedProto.getQualifier(DefineModuleQualifierAttribute), 'app'); + instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); const consumerProto = EggPrototypeFactory.instance.getPrototype('providedInnerConsumer', loadUnit); const consumer = (instance as any).getEggObject('providedInnerConsumer', consumerProto) diff --git a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap index d9fa904669..21c4c73fc5 100644 --- a/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap +++ b/tegg/core/tegg/test/__snapshots__/helper.test.ts.snap @@ -155,6 +155,7 @@ exports[`should helper exports stable 1`] = ` "TEGG_MANIFEST_KEY": "tegg", "TeggError": [Function], "TimerUtil": [Function], + "buildTeggManifestData": [Function], "eggContextLifecycleUtilFromBag": [Function], "eggObjectLifecycleUtilFromBag": [Function], "eggPrototypeLifecycleUtilFromBag": [Function], diff --git a/tegg/core/types/src/metadata/model/EggPrototype.ts b/tegg/core/types/src/metadata/model/EggPrototype.ts index a207508fd1..992fb302d7 100644 --- a/tegg/core/types/src/metadata/model/EggPrototype.ts +++ b/tegg/core/types/src/metadata/model/EggPrototype.ts @@ -125,6 +125,8 @@ export interface EggPrototype extends LifecycleObject; readonly injectType?: InjectType; readonly className?: string; diff --git a/tegg/core/types/src/metadata/model/LoadUnit.ts b/tegg/core/types/src/metadata/model/LoadUnit.ts index c3864b7503..ebc11b0179 100644 --- a/tegg/core/types/src/metadata/model/LoadUnit.ts +++ b/tegg/core/types/src/metadata/model/LoadUnit.ts @@ -14,6 +14,7 @@ export type EggLoadUnitTypeLike = EggLoadUnitType | string; export interface LoadUnitLifecycleContext extends LifecycleContext { unitPath: string; + unitName?: string; loader: Loader; } diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index 2dc76894be..e65e34f5ef 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -70,14 +70,23 @@ export default class App implements ILifecycleBoot { // Auto-exclude outDir (e.g. dist/) from module scanning to avoid // duplicate modules when both source and compiled output exist const outDir = this.app.loader.outDir; + let appReadModuleOptions = readModuleOptions; if (outDir) { const extraFilePattern = readModuleOptions.extraFilePattern || []; const excludePattern = `!**/${outDir}`; if (!extraFilePattern.includes(excludePattern)) { - readModuleOptions.extraFilePattern = [...extraFilePattern, excludePattern]; + appReadModuleOptions = { + ...readModuleOptions, + extraFilePattern: [...extraFilePattern, excludePattern], + }; } } - const moduleScanner = new ModuleScanner(this.app.baseDir, readModuleOptions, this.app.coreLogger); + const moduleScanner = new ModuleScanner( + this.app.baseDir, + readModuleOptions, + this.app.coreLogger, + appReadModuleOptions, + ); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { @@ -95,7 +104,7 @@ export default class App implements ILifecycleBoot { const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.app.baseDir); const resolvedRef: ModuleReference = { path: resolved.path, - name: reference.name, + name: resolved.name, package: reference.package, optional: reference.optional, loaderType: reference.loaderType, diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index e5616a86df..65472546da 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -14,11 +14,18 @@ interface WarnLogger { export class ModuleScanner { private readonly baseDir: string; private readonly readModuleOptions: ReadModuleReferenceOptions; + private readonly appReadModuleOptions: ReadModuleReferenceOptions; private readonly logger?: WarnLogger; - constructor(baseDir: string, readModuleOptions: ReadModuleReferenceOptions, logger?: WarnLogger) { + constructor( + baseDir: string, + readModuleOptions: ReadModuleReferenceOptions, + logger?: WarnLogger, + appReadModuleOptions: ReadModuleReferenceOptions = readModuleOptions, + ) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; + this.appReadModuleOptions = appReadModuleOptions; this.logger = logger; } @@ -76,13 +83,12 @@ export class ModuleScanner { return frameworkDirs; } - private readAndDeduplicateModuleReferences(baseDir: string, cwd?: string): readonly ModuleReference[] { - return ModuleConfigUtil.deduplicateModules( - ModuleConfigUtil.readModuleReference(baseDir, { - ...this.readModuleOptions, - ...(cwd ? { cwd } : {}), - }), - ); + private readModuleReferences(baseDir: string, cwd?: string): readonly ModuleReference[] { + const readModuleOptions = cwd ? this.readModuleOptions : this.appReadModuleOptions; + return ModuleConfigUtil.readModuleReference(baseDir, { + ...readModuleOptions, + ...(cwd ? { cwd } : {}), + }); } private warnDuplicateModuleName(kept: ModuleReference, skipped: ModuleReference): void { @@ -90,7 +96,7 @@ export class ModuleScanner { return; } const message = - `[egg/tegg/plugin/config] Duplicate module name "${skipped.name}" found while scanning framework modules, ` + + `[egg/tegg/plugin/config] Duplicate module name "${skipped.name}" found while scanning module references, ` + `keep ${kept.path}, skip ${skipped.path}`; if (this.logger) { this.logger.warn(message); @@ -122,14 +128,11 @@ export class ModuleScanner { * (plugin promotion flips the enabled ones to non-optional) */ loadModuleReferences(): readonly ModuleReference[] { - const moduleReferences = this.readAndDeduplicateModuleReferences(this.baseDir); + const moduleReferences = this.readModuleReferences(this.baseDir); const frameworkDirs = this.resolveFrameworkDirs(); - if (!frameworkDirs.length) { - return moduleReferences; - } debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => - this.readAndDeduplicateModuleReferences(frameworkDir, frameworkDir), + this.readModuleReferences(frameworkDir, frameworkDir), ); // Merge all module references and deduplicate diff --git a/tegg/plugin/config/test/ManifestModuleReference.test.ts b/tegg/plugin/config/test/ManifestModuleReference.test.ts index ceef974469..b4867bf52a 100644 --- a/tegg/plugin/config/test/ManifestModuleReference.test.ts +++ b/tegg/plugin/config/test/ManifestModuleReference.test.ts @@ -71,4 +71,12 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { expect(app.moduleConfigs.moduleA.reference.path).toBe(moduleDir); }); + + it('stores the resolved module name on manifest references without name', () => { + const app = createFakeApp([{ path: 'app/module-a' }]); + + new App(app).configWillLoad(); + + expect(app.moduleConfigs.moduleA.reference.name).toBe('moduleA'); + }); }); diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts index b10199a03b..692a227f81 100644 --- a/tegg/plugin/config/test/ModuleScanner.test.ts +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { ModuleScanner } from '../src/lib/ModuleScanner.ts'; -import { getFixtures } from './utils.ts'; +import { ModuleScanner } from '../src/lib/ModuleScanner.js'; +import { getFixtures } from './utils.js'; describe('plugin/config/test/ModuleScanner.test.ts', () => { it('should scan module plugins from every framework layer and keep nearest duplicate names', () => { @@ -39,12 +39,36 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { ).loadModuleReferences(); expect(refsWithLogger).toEqual(refs); expect(warnings).toEqual([ - expect.stringContaining('Duplicate module name "sharedModule" found while scanning framework modules'), + expect.stringContaining('Duplicate module name "sharedModule" found while scanning module references'), ]); expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/chair-framework/node_modules/shared-module-chair')); expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/base-framework/node_modules/shared-module-base')); }); + it('should keep the first app reference when one scan root has duplicate module names', () => { + const baseDir = getFixtures('app-duplicate-name-first-wins/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toHaveLength(1); + expect(refs[0].name).toBe('sharedModule'); + expect(['near-module', 'far-module']).toContain(refs[0].package); + expect([path.join(baseDir, 'node_modules/near-module'), path.join(baseDir, 'node_modules/far-module')]).toContain( + refs[0].path, + ); + expect(warnings).toEqual([ + expect.stringContaining('Duplicate module name "sharedModule" found while scanning module references'), + ]); + expect(warnings[0]).toContain(`keep ${refs[0].path}`); + expect(warnings[0]).toContain( + `skip ${ + refs[0].package === 'near-module' + ? path.join(baseDir, 'node_modules/far-module') + : path.join(baseDir, 'node_modules/near-module') + }`, + ); + }); + it('should keep the app reference when a framework scans the same module path', () => { const baseDir = getFixtures('framework-same-path/app'); const warnings: string[] = []; @@ -74,6 +98,22 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { ]); }); + it('should apply auto outDir exclusion only to the app scan', () => { + const baseDir = getFixtures('framework-dist-scan/app'); + const refs = new ModuleScanner(baseDir, {}, undefined, { + extraFilePattern: ['!**/dist'], + }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'frameworkDistModule', + package: 'framework-dist-module', + path: path.join(baseDir, 'node_modules/chair-framework/dist/modules/framework-dist-module'), + optional: true, + }, + ]); + }); + it('should stop scanning when framework chain has a cycle', () => { const baseDir = getFixtures('framework-cycle/app'); const refs = new ModuleScanner(baseDir, {}).loadModuleReferences(); diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json new file mode 100644 index 0000000000..70120f994f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/far-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "far-module", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json new file mode 100644 index 0000000000..216610b5c1 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/node_modules/near-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "near-module", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json new file mode 100644 index 0000000000..7f1c11947d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-duplicate-name-first-wins/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "app-duplicate-name-first-wins", + "type": "module", + "dependencies": { + "far-module": "*", + "near-module": "*" + }, + "egg": { + "framework": "missing-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json new file mode 100644 index 0000000000..027dc4a7a3 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/dist/modules/app-dist-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-dist-module", + "type": "module", + "eggModule": { + "name": "appDistModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json new file mode 100644 index 0000000000..94b25d8d55 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/dist/modules/framework-dist-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-dist-module", + "type": "module", + "eggModule": { + "name": "frameworkDistModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json new file mode 100644 index 0000000000..3e840b548d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/node_modules/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json new file mode 100644 index 0000000000..107dcb7ff3 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/framework-dist-scan/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-dist-scan-app", + "type": "module", + "egg": { + "framework": "chair-framework" + } +} diff --git a/tegg/plugin/dal/package.json b/tegg/plugin/dal/package.json index 552eec0df2..1a8e75d544 100644 --- a/tegg/plugin/dal/package.json +++ b/tegg/plugin/dal/package.json @@ -83,8 +83,7 @@ "typescript": "catalog:" }, "peerDependencies": { - "@eggjs/tegg-plugin": "workspace:*", - "egg": "workspace:*" + "@eggjs/tegg-plugin": "workspace:*" }, "engines": { "node": ">=22.18.0" diff --git a/tegg/plugin/tegg/src/app.ts b/tegg/plugin/tegg/src/app.ts index 7d02ac6384..271bb21de0 100644 --- a/tegg/plugin/tegg/src/app.ts +++ b/tegg/plugin/tegg/src/app.ts @@ -64,6 +64,7 @@ export default class TeggAppBoot implements ILifecycleBoot { async loadMetadata(): Promise { if (!this.app.moduleReferences) return; + EggModuleLoader.reconcileModulePluginReferences(this.app); const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences); EggModuleLoader.collectTeggManifest(this.app, this.app.moduleReferences, moduleDescriptors); } diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index 62a902fdd8..b62d800c8b 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -35,6 +35,21 @@ export class EggModuleLoader { return !!plugin.path && moduleReference.path === plugin.path; } + static reconcileModulePluginReferences(app: Application): void { + const enabledPlugins = Object.values(app.plugins).filter((plugin) => plugin.enable); + const allPlugins = Object.values(app.loader.allPlugins ?? {}); + + for (const moduleReference of app.moduleReferences) { + if (enabledPlugins.some((plugin) => EggModuleLoader.#isModulePluginReference(moduleReference, plugin))) { + moduleReference.optional = false; + continue; + } + if (allPlugins.some((plugin) => EggModuleLoader.#isModulePluginReference(moduleReference, plugin))) { + moduleReference.optional = true; + } + } + } + private async loadApp(): Promise { const loader = new EggAppLoader(this.app); const loadUnit = await LoadUnitFactory.createLoadUnit(this.app.baseDir, EggLoadUnitType.APP, loader); @@ -42,16 +57,9 @@ export class EggModuleLoader { } private async buildAppGraph(): Promise { - // Promote enabled plugins' module references to non-optional. Prefer the - // npm package identity captured at scan/manifest time; fall back to the old - // direct path comparison for pre-package manifest data. - for (const plugin of Object.values(this.app.plugins)) { - if (!plugin.enable) continue; - const modulePlugin = this.app.moduleReferences.find((t) => EggModuleLoader.#isModulePluginReference(t, plugin)); - if (modulePlugin) { - modulePlugin.optional = false; - } - } + // Normalize module plugin references against the Egg plugin enable state. + // Ordinary app modules keep their original optional semantics. + EggModuleLoader.reconcileModulePluginReferences(this.app); // Pass manifest data to LoaderFactory if available const manifest = this.app.loader.manifest; @@ -131,7 +139,12 @@ export class EggModuleLoader { const loader = precomputedFiles ? new ModuleLoader(modulePath, { precomputedFiles, loaderFS }) : LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, loaderFS); - const loadUnit = await LoadUnitFactory.createLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); + const loadUnit = await LoadUnitFactory.createLoadUnit( + modulePath, + EggLoadUnitType.MODULE, + loader, + precomputedFiles ? moduleConfig.name : undefined, + ); this.app.moduleHandler.loadUnits.push(loadUnit); } } diff --git a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts index d20e944813..deb7fe072a 100644 --- a/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts +++ b/tegg/plugin/tegg/src/lib/ModuleConfigLoader.ts @@ -61,7 +61,7 @@ export class ModuleConfigLoader { reference: { name: resolved.name, package: reference.package, - path: reference.path, + path: resolved.path, }, config, }; diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index 5ba14ad406..48098a9c12 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -16,6 +16,7 @@ export class ModuleHandler extends Base { // units: init iterates business units without filtering, destroy tears it // down last (its lifecycle protos must outlive every hooked object). #innerObjectLoadUnit?: LoadUnit; + #innerObjectLoadUnitInstance?: LoadUnitInstance; loadUnitInstances: LoadUnitInstance[] = []; private readonly loadUnitLoader: EggModuleLoader; @@ -90,6 +91,7 @@ export class ModuleHandler extends Base { await this.loadUnitLoader.initGraph(); const innerObjectInstance = await this.instantiateInnerObjectLoadUnit(); + this.#innerObjectLoadUnitInstance = innerObjectInstance; this.loadUnitInstances.push(innerObjectInstance); await this.loadUnitLoader.load(); this.app.module = {} as any; @@ -121,11 +123,13 @@ export class ModuleHandler extends Base { } }; - // 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. + // Reverse creation order: business load units go down first; the inner + // instance and load unit go down after business load-unit metadata so its + // lifecycle protos still observe LoadUnitFactory.destroyLoadUnit(). + const innerObjectLoadUnitInstance = this.#innerObjectLoadUnitInstance ?? this.loadUnitInstances[0]; if (this.loadUnitInstances) { - for (const instance of [...this.loadUnitInstances].reverse()) { + const businessInstances = this.loadUnitInstances.filter((instance) => instance !== innerObjectLoadUnitInstance); + for (const instance of [...businessInstances].reverse()) { await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(instance)); } } @@ -134,6 +138,10 @@ export class ModuleHandler extends Base { await safe(() => LoadUnitFactory.destroyLoadUnit(loadUnit)); } } + if (innerObjectLoadUnitInstance) { + await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(innerObjectLoadUnitInstance)); + this.#innerObjectLoadUnitInstance = undefined; + } if (this.#innerObjectLoadUnit) { await safe(() => LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit!)); this.#innerObjectLoadUnit = undefined; diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index f2e3a0d477..263075b50c 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -2,11 +2,12 @@ import assert from 'node:assert/strict'; import { mock } from 'node:test'; // import { scheduler } from 'node:timers/promises'; -import { GlobalGraph } from '@eggjs/metadata'; +import { EggLoadUnitType, GlobalGraph, LoadUnitFactory } from '@eggjs/metadata'; import { mm } from '@eggjs/mock'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { describe, it, afterEach } from 'vitest'; +import TeggAppBoot from '../../src/app.ts'; import { EggModuleLoader } from '../../src/lib/EggModuleLoader.ts'; import { getAppBaseDir } from '../utils.ts'; @@ -33,7 +34,7 @@ describe('test/lib/EggModuleLoader.test.ts', () => { name: 'disabledPlugin', package: 'disabled-plugin', path: '/virtual/disabled-plugin', - optional: true, + optional: false, }, { name: 'samePathDifferentPackage', @@ -41,10 +42,46 @@ describe('test/lib/EggModuleLoader.test.ts', () => { path: '/virtual/same-path', optional: true, }, + { + name: 'regularAppModule', + package: 'regular-app-module', + path: '/virtual/regular-app-module', + optional: false, + }, + { + name: 'disabledPathPlugin', + path: '/disabled/path-plugin', + optional: false, + }, ]; const app = { baseDir: '/virtual/app', loader: { + allPlugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/some/plugin/path/inside-package', + }, + legacyPlugin: { + enable: true, + path: '/legacy/plugin/path', + }, + disabledPlugin: { + enable: false, + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + }, + samePathDifferentPackage: { + enable: true, + package: 'plugin-package', + path: '/virtual/same-path', + }, + disabledPathPlugin: { + enable: false, + path: '/disabled/path-plugin', + }, + }, loaderFS: undefined, manifest: { getExtension: () => undefined, @@ -65,11 +102,6 @@ describe('test/lib/EggModuleLoader.test.ts', () => { enable: true, path: '/legacy/plugin/path', }, - disabledPlugin: { - enable: false, - package: 'disabled-plugin', - path: '/virtual/disabled-plugin', - }, samePathDifferentPackage: { enable: true, package: 'plugin-package', @@ -91,6 +123,129 @@ describe('test/lib/EggModuleLoader.test.ts', () => { assert.equal(moduleReferences[1].optional, false); assert.equal(moduleReferences[2].optional, true); assert.equal(moduleReferences[3].optional, true); + assert.equal(moduleReferences[4].optional, false); + assert.equal(moduleReferences[5].optional, true); + }); + + it('should reconcile module plugin references before collecting metadata manifest', async () => { + const moduleReferences = [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + optional: true, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: false, + }, + ]; + const extensions = new Map(); + const app = { + moduleReferences, + loader: { + allPlugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + }, + disabledPlugin: { + enable: false, + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + }, + }, + manifest: { + setExtension(key: string, value: unknown) { + extensions.set(key, value); + }, + }, + }, + plugins: { + teggConfig: { + enable: true, + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + }, + }, + } as any; + + mock.method(LoaderFactory, 'loadApp', async () => []); + + await new TeggAppBoot(app).loadMetadata(); + + assert.equal(moduleReferences[0].optional, false); + assert.equal(moduleReferences[1].optional, true); + assert.deepEqual(extensions.get('tegg'), { + moduleReferences: [ + { + name: 'teggConfig', + package: '@eggjs/tegg-config', + path: '/virtual/tegg-config', + optional: false, + loaderType: undefined, + }, + { + name: 'disabledPlugin', + package: 'disabled-plugin', + path: '/virtual/disabled-plugin', + optional: true, + loaderType: undefined, + }, + ], + moduleDescriptors: [], + }); + }); + + it('should pass manifest module name when creating bundled module load units', async () => { + const app = { + loader: { + loaderFS: undefined, + manifest: { + getExtension: () => ({ + moduleDescriptors: [ + { + name: 'bundledModule', + unitPath: '/virtual/bundled-module', + decoratedFiles: ['src/index.ts'], + }, + ], + }), + }, + }, + moduleHandler: { + loadUnits: [], + }, + } as any; + const moduleLoader = new EggModuleLoader(app); + moduleLoader.globalGraph = { + build() {}, + sort() {}, + moduleConfigList: [ + { + name: 'bundledModule', + path: '/virtual/bundled-module', + }, + ], + } as any; + (moduleLoader as any).loadedFromManifest = true; + + const calls: unknown[][] = []; + mock.method(LoadUnitFactory, 'createLoadUnit', async (...args: unknown[]) => { + calls.push(args); + return { name: 'bundledModule', unitPath: '/virtual/bundled-module' }; + }); + + await (moduleLoader as any).loadModule(); + + assert.equal(calls.length, 1); + assert.equal(calls[0][0], '/virtual/bundled-module'); + assert.equal(calls[0][1], EggLoadUnitType.MODULE); + assert.equal(calls[0][3], 'bundledModule'); + assert.equal(app.moduleHandler.loadUnits.length, 1); }); describe('has recursive dependency module', () => { diff --git a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts index 82ec73f6ae..e05c71ca12 100644 --- a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts +++ b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts @@ -76,15 +76,18 @@ describe('plugin/tegg/test/lib/ModuleHandler.test.ts', () => { handler.loadUnitInstances.push(createInstance('inner'), createInstance('business')); handler.loadUnits.push(firstLoadUnit, secondLoadUnit); + const destroyed: string[] = []; const destroyedInstances: string[] = []; const destroyedLoadUnits: string[] = []; mock.method(LoadUnitInstanceFactory, 'destroyLoadUnitInstance', async (instance: LoadUnitInstance) => { + destroyed.push(`instance:${String(instance.loadUnit.name)}`); destroyedInstances.push(String(instance.loadUnit.name)); if (instance.loadUnit.name === 'business') { throw new Error('destroy instance failed'); } }); mock.method(LoadUnitFactory, 'destroyLoadUnit', async (loadUnit: LoadUnit) => { + destroyed.push(`loadUnit:${String(loadUnit.name)}`); destroyedLoadUnits.push(String(loadUnit.name)); if (loadUnit === firstLoadUnit) { throw new Error('destroy load unit failed'); @@ -102,5 +105,6 @@ describe('plugin/tegg/test/lib/ModuleHandler.test.ts', () => { ); assert.deepEqual(destroyedInstances, ['business', 'inner']); assert.deepEqual(destroyedLoadUnits, ['second', 'first']); + assert.deepEqual(destroyed, ['instance:business', 'loadUnit:second', 'loadUnit:first', 'instance:inner']); }); }); diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 255a7ae57e..ad829a0004 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -169,9 +169,8 @@ export class StandaloneApp { 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. + // Load module.yml and module.env.yml by default. The override is scoped to + // this app's TeggScope bag. ModuleConfigUtil.configNames = opts.env ? ['module.default', `module.${opts.env}`] : ['module.default']; } @@ -406,7 +405,7 @@ export class StandaloneApp { // Framework hooks (ConfigSource/AOP/DAL) live in the InnerObjectLoadUnit // and deregister themselves — and clean up their own managers — when it // is destroyed above (dal: DalModuleLoadUnitHook#destroy). - // clear configNames + // Release this app's scoped config name override before unregistering the scope. ModuleConfigUtil.setConfigNames(undefined); } } diff --git a/wiki/concepts/tegg-module-plugin.md b/wiki/concepts/tegg-module-plugin.md index 4e5ca44cd1..90e0043f23 100644 --- a/wiki/concepts/tegg-module-plugin.md +++ b/wiki/concepts/tegg-module-plugin.md @@ -11,9 +11,14 @@ source_files: - tegg/core/runtime/src/impl/EggInnerObjectImpl.ts - tegg/standalone/standalone/src/StandaloneApp.ts - tegg/plugin/tegg/src/lib/ModuleHandler.ts + - tegg/plugin/tegg/src/lib/EggModuleLoader.ts - tegg/plugin/aop/src/app.ts + - tegg/plugin/aop/src/lib/AopContextHook.ts + - tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts + - tegg/core/aop-runtime/src/LoadUnitAopHook.ts - tegg/plugin/config/src/app.ts - - tegg/plugin/dal/src/app.ts + - tegg/plugin/dal/src/index.ts + - tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts updated_at: 2026-07-09 status: active --- @@ -82,6 +87,7 @@ Hosts: `StandaloneApp.init()` (standalone) and `ModuleHandler.init()` via - `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. +- Inference: hooks that truly capture egg-only resources + (`EggQualifierProtoHook`, `EggContextCompatibleHook`) stay host-registered. + `AopContextHook` is now an `@EggContextLifecycleProto` inner object and + reads request-scope advice from `AopContextAdviceRegistry`. diff --git a/wiki/log.md b/wiki/log.md index 8b2cdb59f8..e0df9a006e 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -126,3 +126,9 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - sources touched: `tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/plugin/{aop,config,dal}/src/app.ts` - pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` - note: Corrected stale feeding-rule notes: inner object/lifecycle classes now arrive only through `ModuleDescriptor.innerObjectClazzList`; built-in AOP/DAL/ConfigSource hooks are discovered as normal module plugin classes rather than hard-fed lists, and duplicate inner-object proto ids are errors instead of class-level dedupe. + +## [2026-07-09] docs | align tegg module plugin notes with review fixes + +- sources touched: `tegg/plugin/aop/src/lib/AopContextHook.ts`, `tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts`, `tegg/core/aop-runtime/src/LoadUnitAopHook.ts`, `tegg/plugin/dal/src/index.ts`, `tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Replaced stale DAL source paths and updated the AOP note after `AopContextHook` moved to lifecycle-proto/inner-object registration backed by `AopContextAdviceRegistry`. From e9b69ea5b3ebef8d01dda51b3285e8eeb0c7701c Mon Sep 17 00:00:00 2001 From: gxkl Date: Fri, 10 Jul 2026 11:27:49 +0800 Subject: [PATCH 72/74] fix(standalone): do not await ctx.destroy on the request path Awaiting the per-request context destroy in StandaloneApp.run deadlocks hosts that return a response whose body is drained by the caller after run() returns (e.g. the service-worker pipes a streaming Response body and keeps context protos alive via a BackgroundTaskHelper drain task). ctx.destroy() drains those tasks, so awaiting it blocks run() from returning the response the caller must consume first. Fire-and-forget it; app-level teardown determinism stays with the awaited app.destroy(). Co-Authored-By: Claude Opus 4.8 --- tegg/standalone/standalone/src/StandaloneApp.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index ad829a0004..0a3f749260 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -364,7 +364,15 @@ export class StandaloneApp { return await runner.main(); } finally { if (ctx.destroy) { - await ctx.destroy(lifecycle).catch((e: unknown) => { + // Fire-and-forget on purpose: do NOT await here. A host may return a + // response whose body is drained by the CALLER after run() returns + // (e.g. the service-worker pipes a streaming Response body and keeps + // context protos alive via a BackgroundTaskHelper drain task). + // ctx.destroy() drains those tasks, so awaiting it here would block + // run() from returning the Response the caller must consume first — + // a deadlock. App-level teardown determinism is handled by the + // awaited app.destroy() in appMain (main.ts). + void ctx.destroy(lifecycle).catch((e: unknown) => { if (e instanceof Error) { e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`; console.warn(e); From a31d57951328dc647ee43848ed907f4b59417e37 Mon Sep 17 00:00:00 2001 From: gxkl Date: Fri, 10 Jul 2026 11:28:00 +0800 Subject: [PATCH 73/74] fix(tegg-config): compare realpaths when detecting duplicate modules An app inline module path is not realpath'd while the same module resolved from node_modules is, so under pnpm/symlinked layouts one physical module produced two path strings and triggered a spurious duplicate-name warning. Compare realpaths before warning. Co-Authored-By: Claude Opus 4.8 --- tegg/plugin/config/src/lib/ModuleScanner.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 65472546da..0ee4757d55 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -91,8 +91,21 @@ export class ModuleScanner { }); } + // Same physical module reached via two different path strings — e.g. an app + // inline module.json path (not realpath'd) vs the same module resolved from + // node_modules with fs.realpathSync — must NOT warn. Compare realpaths, not + // raw strings, so pnpm/symlinked layouts don't trigger a spurious duplicate. + private static isSamePath(a: string, b: string): boolean { + if (a === b) return true; + try { + return fs.realpathSync(a) === fs.realpathSync(b); + } catch { + return false; + } + } + private warnDuplicateModuleName(kept: ModuleReference, skipped: ModuleReference): void { - if (kept.path === skipped.path) { + if (ModuleScanner.isSamePath(kept.path, skipped.path)) { return; } const message = From b7a6718ed6b757be47827c7624e322d4f85136ea Mon Sep 17 00:00:00 2001 From: gxkl Date: Mon, 13 Jul 2026 00:26:54 +0800 Subject: [PATCH 74/74] fix(tegg): address module plugin review feedback --- .../core/aop-runtime/test/aop-runtime.test.ts | 37 +-- .../src/decorator/EggLifecycleProto.ts | 2 +- .../test/inner-object-decorators.test.ts | 9 + .../dynamic-inject-runtime/test/index.test.ts | 8 +- .../eventbus-runtime/test/EventBus.test.ts | 9 +- tegg/core/loader/src/LoaderFactory.ts | 2 +- .../loader/test/LoaderInnerObject.test.ts | 2 +- .../src/factory/EggPrototypeFactory.ts | 2 +- .../metadata/src/model/ModuleDescriptor.ts | 7 +- .../metadata/src/model/graph/GlobalGraph.ts | 28 ++- tegg/core/metadata/test/GlobalGraph.test.ts | 61 +++++ .../test/ModuleDescriptorDumper.test.ts | 15 ++ .../runtime/src/impl/EggInnerObjectImpl.ts | 4 - .../runtime/src/impl/InnerObjectLoadUnit.ts | 22 +- .../src/impl/InnerObjectLoadUnitBuilder.ts | 2 +- .../src/impl/InnerObjectLoadUnitInstance.ts | 35 ++- .../src/impl/ModuleLoadUnitInstance.ts | 4 +- .../runtime/test/InnerObjectLoadUnit.test.ts | 105 +++++++- .../runtime/test/LoadUnitInstance.test.ts | 10 +- tegg/core/runtime/test/util.ts | 19 +- tegg/core/test-util/src/CoreTestHelper.ts | 25 +- tegg/core/test-util/src/LoaderUtil.ts | 47 +++- .../test-util/test/CoreTestHelper.test.ts | 32 +++ .../test/fixtures/inner-lifecycle/Service.ts | 4 + .../inner-lifecycle/TestLifecycleHook.ts | 27 +++ .../fixtures/inner-lifecycle/package.json | 7 + tegg/plugin/config/src/app.ts | 46 ++-- tegg/plugin/config/src/lib/ModuleScanner.ts | 167 +++++++++++-- .../test/DuplicateOptionalModule.test.ts | 13 +- .../test/ManifestModuleReference.test.ts | 41 +++- tegg/plugin/config/test/ModuleScanner.test.ts | 104 ++++++-- .../config/test/PluginModuleDiscovery.test.ts | 71 ++++++ .../app/chair-framework/config/module.json | 5 + .../modules/framework-shared/package.json | 7 + .../app/chair-framework/package.json | 7 + .../app/config/module.json | 5 + .../app/modules/app-shared/package.json | 7 + .../app-framework-conflict/app/package.json | 7 + .../plugin-module-json/app/config/module.json | 5 + .../app/modules/app-module/package.json | 7 + .../node_modules/aop-like-plugin/package.json | 7 + .../custom-framework/package.json | 7 + .../plugin-module-json/app/package.json | 7 + .../dal/test/TransactionPrototypeHook.test.ts | 34 +-- tegg/plugin/tegg/src/lib/ModuleHandler.ts | 24 +- tegg/plugin/tegg/test/ModulePlugin.test.ts | 15 ++ .../apps/module-plugin-app/config/plugin.ts | 4 + .../module-plugin-app/framework/package.json | 7 + .../modules/plugin-module/AopService.ts | 18 ++ .../apps/module-plugin-app/package.json | 5 +- .../tegg/test/lib/EggModuleLoader.test.ts | 13 + .../tegg/test/lib/ModuleHandler.test.ts | 65 +++-- tegg/standalone/standalone/README.md | 4 + .../standalone/src/StandaloneApp.ts | 188 ++++++++------- tegg/standalone/standalone/src/main.ts | 7 +- .../standalone/test/ModulePlugin.test.ts | 5 +- .../fixtures/init-failure/CleanupProbe.ts | 17 ++ .../fixtures/init-failure/InitFailureInner.ts | 23 ++ .../test/fixtures/init-failure/package.json | 7 + .../FooLoadUnitHook.ts | 15 +- tegg/standalone/standalone/test/index.test.ts | 225 ++++++++++-------- wiki/concepts/tegg-module-plugin.md | 92 ++++++- wiki/log.md | 48 ++++ 63 files changed, 1426 insertions(+), 428 deletions(-) create mode 100644 tegg/core/test-util/test/CoreTestHelper.test.ts create mode 100644 tegg/core/test-util/test/fixtures/inner-lifecycle/Service.ts create mode 100644 tegg/core/test-util/test/fixtures/inner-lifecycle/TestLifecycleHook.ts create mode 100644 tegg/core/test-util/test/fixtures/inner-lifecycle/package.json create mode 100644 tegg/plugin/config/test/PluginModuleDiscovery.test.ts create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/config/module.json create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/modules/framework-shared/package.json create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/config/module.json create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/modules/app-shared/package.json create mode 100644 tegg/plugin/config/test/fixtures/app-framework-conflict/app/package.json create mode 100644 tegg/plugin/config/test/fixtures/plugin-module-json/app/config/module.json create mode 100644 tegg/plugin/config/test/fixtures/plugin-module-json/app/modules/app-module/package.json create mode 100644 tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/aop-like-plugin/package.json create mode 100644 tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/custom-framework/package.json create mode 100644 tegg/plugin/config/test/fixtures/plugin-module-json/app/package.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/framework/package.json create mode 100644 tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/AopService.ts create mode 100644 tegg/standalone/standalone/test/fixtures/init-failure/CleanupProbe.ts create mode 100644 tegg/standalone/standalone/test/fixtures/init-failure/InitFailureInner.ts create mode 100644 tegg/standalone/standalone/test/fixtures/init-failure/package.json diff --git a/tegg/core/aop-runtime/test/aop-runtime.test.ts b/tegg/core/aop-runtime/test/aop-runtime.test.ts index 8c7804913c..5e11609980 100644 --- a/tegg/core/aop-runtime/test/aop-runtime.test.ts +++ b/tegg/core/aop-runtime/test/aop-runtime.test.ts @@ -3,9 +3,9 @@ import path from 'node:path'; import { mock } from 'node:test'; import { CrosscutAdviceFactory } from '@eggjs/aop-decorator'; -import { EggPrototypeLifecycleUtil, LoadUnitFactory, LoadUnitLifecycleUtil } from '@eggjs/metadata'; -import { CoreTestHelper, EggTestContext } from '@eggjs/module-test-util'; -import { EggObjectLifecycleUtil, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { EggPrototypeLifecycleUtil, LoadUnitLifecycleUtil } from '@eggjs/metadata'; +import { CoreTestHelper, EggTestContext, LoaderUtil } from '@eggjs/module-test-util'; +import { EggObjectLifecycleUtil } from '@eggjs/tegg-runtime'; import type { LoadUnitInstance } from '@eggjs/tegg-types'; import { describe, beforeEach, afterEach, it } from 'vitest'; @@ -64,10 +64,7 @@ describe('test/aop-runtime.test.ts', () => { }); afterEach(async () => { - for (const module of modules) { - await LoadUnitFactory.destroyLoadUnit(module.loadUnit); - await LoadUnitInstanceFactory.destroyLoadUnitInstance(module); - } + await CoreTestHelper.destroyModules(modules); EggPrototypeLifecycleUtil.deleteLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.deleteLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.deleteLifecycle(eggObjectAopHook); @@ -182,12 +179,23 @@ describe('test/aop-runtime.test.ts', () => { EggObjectLifecycleUtil.registerLifecycle(eggObjectAopHook); }); + afterEach(() => { + EggPrototypeLifecycleUtil.deleteLifecycle(eggPrototypeCrossCutHook); + LoadUnitLifecycleUtil.deleteLifecycle(loadUnitAopHook); + EggObjectLifecycleUtil.deleteLifecycle(eggObjectAopHook); + }); + it('should throw', async () => { - await assert.rejects(async () => { - await CoreTestHelper.prepareModules([ - path.join(__dirname, 'fixtures/modules/should_throw'), - ]); - }, /Aop Advice\(PointcutAdvice\) not found in loadUnits/); + const modulePath = path.join(__dirname, 'fixtures/modules/should_throw'); + const { innerObjectLoadUnitInstance } = await LoaderUtil.buildGlobalGraph([modulePath]); + try { + await assert.rejects( + () => CoreTestHelper.getLoadUnitInstance(modulePath), + /Aop Advice\(PointcutAdvice\) not found in loadUnits/, + ); + } finally { + await CoreTestHelper.destroyModules([innerObjectLoadUnitInstance]); + } }); }); @@ -219,10 +227,7 @@ describe('test/aop-runtime.test.ts', () => { }); afterEach(async () => { - for (const module of modules) { - await LoadUnitFactory.destroyLoadUnit(module.loadUnit); - await LoadUnitInstanceFactory.destroyLoadUnitInstance(module); - } + await CoreTestHelper.destroyModules(modules); EggPrototypeLifecycleUtil.deleteLifecycle(eggPrototypeCrossCutHook); LoadUnitLifecycleUtil.deleteLifecycle(loadUnitAopHook); EggObjectLifecycleUtil.deleteLifecycle(eggObjectAopHook); diff --git a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts index 725be55f5f..98b2d9537c 100644 --- a/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts +++ b/tegg/core/core-decorator/src/decorator/EggLifecycleProto.ts @@ -26,7 +26,7 @@ export function EggLifecycleProto(params: CommonEggLifecycleProtoParams): Protot type EggLifecycleProtoDecoratorFactory = (params?: EggLifecycleProtoParams) => PrototypeDecorator; const createLifecycleProto = (type: EggLifecycleType): EggLifecycleProtoDecoratorFactory => { - return (params?: EggLifecycleProtoParams) => EggLifecycleProto({ type, ...params }); + return (params?: EggLifecycleProtoParams) => EggLifecycleProto({ ...params, type }); }; export const LoadUnitLifecycleProto: EggLifecycleProtoDecoratorFactory = createLifecycleProto('LoadUnit'); diff --git a/tegg/core/core-decorator/test/inner-object-decorators.test.ts b/tegg/core/core-decorator/test/inner-object-decorators.test.ts index 4eeab2158d..9dcc1f97cb 100644 --- a/tegg/core/core-decorator/test/inner-object-decorators.test.ts +++ b/tegg/core/core-decorator/test/inner-object-decorators.test.ts @@ -123,6 +123,15 @@ describe('core/core-decorator/test/inner-object-decorators.test.ts', () => { }, /EggLifecycle decorator should have type property/); }); + it('should not let factory params override the fixed lifecycle type', () => { + class FixedLoadUnitLifecycle {} + LoadUnitLifecycleProto({ type: 'EggObject' } as any)(FixedLoadUnitLifecycle); + + assert.deepEqual(PrototypeUtil.getEggLifecyclePrototypeMetadata(FixedLoadUnitLifecycle), { + type: 'LoadUnit', + }); + }); + it('should return undefined metadata for non lifecycle proto', () => { assert.equal(PrototypeUtil.getEggLifecyclePrototypeMetadata(Router), undefined); }); diff --git a/tegg/core/dynamic-inject-runtime/test/index.test.ts b/tegg/core/dynamic-inject-runtime/test/index.test.ts index eafba2ad05..cbde070b6d 100644 --- a/tegg/core/dynamic-inject-runtime/test/index.test.ts +++ b/tegg/core/dynamic-inject-runtime/test/index.test.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import path from 'node:path'; -import { LoadUnitFactory } from '@eggjs/metadata'; import { EggTestContext, CoreTestHelper } from '@eggjs/module-test-util'; -import { type LoadUnitInstance, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { type LoadUnitInstance } from '@eggjs/tegg-runtime'; import { describe, it, beforeEach, afterEach } from 'vitest'; import { HelloService } from './fixtures/modules/dynamic-inject-module/HelloService.js'; @@ -18,10 +17,7 @@ describe('test/dynamic-inject-runtime.test.ts', () => { }); afterEach(async () => { - for (const module of modules) { - await LoadUnitFactory.destroyLoadUnit(module.loadUnit); - await LoadUnitInstanceFactory.destroyLoadUnitInstance(module); - } + await CoreTestHelper.destroyModules(modules); }); it('should work', async () => { diff --git a/tegg/core/eventbus-runtime/test/EventBus.test.ts b/tegg/core/eventbus-runtime/test/EventBus.test.ts index 8f9c4a70bb..1665e87d98 100644 --- a/tegg/core/eventbus-runtime/test/EventBus.test.ts +++ b/tegg/core/eventbus-runtime/test/EventBus.test.ts @@ -4,10 +4,10 @@ import { mock } from 'node:test'; import { PrototypeUtil } from '@eggjs/core-decorator'; import { EventInfoUtil, CORK_ID } from '@eggjs/eventbus-decorator'; -import { type EggPrototype, LoadUnitFactory } from '@eggjs/metadata'; +import { type EggPrototype } from '@eggjs/metadata'; import { CoreTestHelper, EggTestContext } from '@eggjs/module-test-util'; import { TimerUtil } from '@eggjs/tegg-common-util'; -import { type LoadUnitInstance, LoadUnitInstanceFactory } from '@eggjs/tegg-runtime'; +import { type LoadUnitInstance } from '@eggjs/tegg-runtime'; import { describe, it, beforeEach, afterEach } from 'vitest'; import { EventContextFactory, EventHandlerFactory, SingletonEventBus } from '../src/index.ts'; @@ -26,10 +26,7 @@ describe('test/EventBus.test.ts', () => { }); afterEach(async () => { - for (const module of modules) { - await LoadUnitFactory.destroyLoadUnit(module.loadUnit); - await LoadUnitInstanceFactory.destroyLoadUnitInstance(module); - } + await CoreTestHelper.destroyModules(modules); mock.reset(); }); diff --git a/tegg/core/loader/src/LoaderFactory.ts b/tegg/core/loader/src/LoaderFactory.ts index 5c5be597c0..59b8832c93 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -125,7 +125,7 @@ export class LoaderFactory { for (const clazz of clazzList) { // Inner object protos are also egg prototypes, so this branch must come first. if (PrototypeUtil.isEggInnerObject(clazz)) { - res.innerObjectClazzList.push(clazz); + res.innerObjectClazzList!.push(clazz); } else if (PrototypeUtil.isEggPrototype(clazz)) { res.clazzList.push(clazz); } else if (PrototypeUtil.isEggMultiInstancePrototype(clazz)) { diff --git a/tegg/core/loader/test/LoaderInnerObject.test.ts b/tegg/core/loader/test/LoaderInnerObject.test.ts index e4d84c7224..42bc2d8dfb 100644 --- a/tegg/core/loader/test/LoaderInnerObject.test.ts +++ b/tegg/core/loader/test/LoaderInnerObject.test.ts @@ -19,7 +19,7 @@ describe('core/loader/test/LoaderInnerObject.test.ts', () => { it('should divert inner object clazz to innerObjectClazzList', async () => { const [descriptor] = await LoaderFactory.loadApp([moduleRef]); - const innerNames = descriptor.innerObjectClazzList.map((t) => t.name).sort(); + const innerNames = descriptor.innerObjectClazzList!.map((t) => t.name).sort(); assert.deepEqual(innerNames, ['ControllerHook', 'FetchRouter']); // Inner object classes must NOT stay in clazzList. diff --git a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts index dfbc21824e..659582541c 100644 --- a/tegg/core/metadata/src/factory/EggPrototypeFactory.ts +++ b/tegg/core/metadata/src/factory/EggPrototypeFactory.ts @@ -121,7 +121,7 @@ export class EggPrototypeFactory { return ( `${String(proto.name)}@${proto.loadUnitId}` + ` define:${String(proto.defineModuleName ?? proto.getQualifier(DefineModuleQualifierAttribute))}` + - `@${String(proto.defineUnitPath ?? proto.loadUnitId)}` + + `@${String(proto.defineUnitPath ?? '')}` + ` qualifiers:[${String(DefineModuleQualifierAttribute)}=${String(proto.getQualifier(DefineModuleQualifierAttribute))}]` ); } diff --git a/tegg/core/metadata/src/model/ModuleDescriptor.ts b/tegg/core/metadata/src/model/ModuleDescriptor.ts index 1b9629fba7..771e5f8bfe 100644 --- a/tegg/core/metadata/src/model/ModuleDescriptor.ts +++ b/tegg/core/metadata/src/model/ModuleDescriptor.ts @@ -12,7 +12,7 @@ export interface ModuleDescriptor { optional?: boolean; clazzList: EggProtoImplClass[]; multiInstanceClazzList: EggProtoImplClass[]; - innerObjectClazzList: EggProtoImplClass[]; + innerObjectClazzList?: EggProtoImplClass[]; protos: ProtoDescriptor[]; } @@ -39,7 +39,7 @@ export class ModuleDescriptorDumper { return ModuleDescriptorDumper.stringifyClazz(t, moduleDescriptor); }) .join(',')}],` + - `"innerObjectClazzList": [${moduleDescriptor.innerObjectClazzList + `"innerObjectClazzList": [${(moduleDescriptor.innerObjectClazzList ?? []) .map((t) => { return ModuleDescriptorDumper.stringifyClazz(t, moduleDescriptor); }) @@ -86,7 +86,7 @@ export class ModuleDescriptorDumper { 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); + for (const clazz of desc.innerObjectClazzList ?? []) addClazz(clazz); return Array.from(fileSet); } @@ -98,6 +98,7 @@ export class ModuleDescriptorDumper { const tmpPath = path.join(tmpDir, path.basename(dumpPath)); try { await fs.writeFile(tmpPath, ModuleDescriptorDumper.stringifyDescriptor(desc)); + await fs.rm(dumpPath, { force: true }); await fs.rename(tmpPath, dumpPath); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/tegg/core/metadata/src/model/graph/GlobalGraph.ts b/tegg/core/metadata/src/model/graph/GlobalGraph.ts index d540afce9e..52fb265edc 100644 --- a/tegg/core/metadata/src/model/graph/GlobalGraph.ts +++ b/tegg/core/metadata/src/model/graph/GlobalGraph.ts @@ -60,6 +60,7 @@ export class GlobalGraph { moduleProtoDescriptorMap: Map; strict: boolean; private buildHooks: GlobalGraphBuildHook[]; + #buildState: 'created' | 'building' | 'built' = 'created'; /** Lazily built proto-name index for dependency resolution; invalidated on vertex changes. */ #protoNameIndex: ProtoNameIndex | null = null; @@ -94,6 +95,9 @@ export class GlobalGraph { } registerBuildHook(hook: GlobalGraphBuildHook): void { + if (this.#buildState !== 'created') { + throw new Error(`cannot register global graph build hook after build has started (state: ${this.#buildState})`); + } this.buildHooks.push(hook); } @@ -101,24 +105,32 @@ export class GlobalGraph { if (!this.moduleGraph.addVertex(new GraphNode(moduleNode))) { throw new Error(`duplicate module: ${moduleNode}`); } + this.#protoNameIndex = null; for (const protoNode of moduleNode.protos) { if (!this.protoGraph.addVertex(protoNode)) { throw new Error(`duplicate proto: ${protoNode.val}`); } } - this.#protoNameIndex = null; } build(): void { - for (const moduleNode of this.moduleGraph.nodes.values()) { - for (const protoNode of moduleNode.val.protos) { - for (const injectObj of protoNode.val.proto.injectObjects) { - this.buildInjectEdge(moduleNode, protoNode, injectObj); + if (this.#buildState !== 'created') { + throw new Error(`global graph can only be built once (state: ${this.#buildState})`); + } + this.#buildState = 'building'; + try { + for (const moduleNode of this.moduleGraph.nodes.values()) { + for (const protoNode of moduleNode.val.protos) { + for (const injectObj of protoNode.val.proto.injectObjects) { + this.buildInjectEdge(moduleNode, protoNode, injectObj); + } } } - } - for (const buildHook of this.buildHooks) { - buildHook(this); + for (const buildHook of this.buildHooks) { + buildHook(this); + } + } finally { + this.#buildState = 'built'; } } diff --git a/tegg/core/metadata/test/GlobalGraph.test.ts b/tegg/core/metadata/test/GlobalGraph.test.ts index 74808b7c0d..47735bc48d 100644 --- a/tegg/core/metadata/test/GlobalGraph.test.ts +++ b/tegg/core/metadata/test/GlobalGraph.test.ts @@ -184,4 +184,65 @@ describe('test/LoadUnit/GlobalGraph.test.ts', () => { ['logger', 'bar', 'constructorBase', 'fooConstructor', 'fooConstructorLogger'], ); }); + + it('should reject late build hooks and repeated builds', () => { + const graph = new GlobalGraph(); + let hookCalls = 0; + graph.registerBuildHook(() => hookCalls++); + + graph.build(); + + assert.equal(hookCalls, 1); + assert.throws( + () => graph.registerBuildHook(() => {}), + /cannot register global graph build hook after build has started/, + ); + assert.throws(() => graph.build(), /global graph can only be built once/); + }); + + it('should reject build hook registration while build hooks are running', () => { + const graph = new GlobalGraph(); + let nestedHookCalled = false; + graph.registerBuildHook(() => { + graph.registerBuildHook(() => { + nestedHookCalled = true; + }); + }); + + assert.throws( + () => graph.build(), + /cannot register global graph build hook after build has started \(state: building\)/, + ); + assert.equal(nestedHookCalled, false); + assert.throws(() => graph.build(), /global graph can only be built once \(state: built\)/); + }); + + it('should invalidate the proto name index before a partial add failure', () => { + const graph = new GlobalGraph(); + const consumer = createProtoDescriptor({ name: 'consumer' }); + const existing = createProtoDescriptor({ name: 'existing' }); + const initialNode = new GlobalModuleNode({ name: 'initial', unitPath: '/fixtures/initial', optional: false }); + initialNode.addProto(consumer); + initialNode.addProto(existing); + graph.addModuleNode(initialNode); + + assert(graph.findDependencyProtoNode(consumer, { refName: 'existing', objName: 'existing', qualifiers: [] })); + + const addedBeforeFailure = createProtoDescriptor({ name: 'addedBeforeFailure', moduleName: 'partial' }); + const duplicate = createProtoDescriptor({ name: 'existing', moduleName: 'partial' }); + duplicate.instanceModuleName = existing.instanceModuleName; + duplicate.instanceDefineUnitPath = existing.instanceDefineUnitPath; + const partialNode = new GlobalModuleNode({ name: 'partial', unitPath: '/fixtures/partial', optional: false }); + partialNode.addProto(addedBeforeFailure); + partialNode.addProto(duplicate); + + assert.throws(() => graph.addModuleNode(partialNode), /duplicate proto/); + assert( + graph.findDependencyProtoNode(consumer, { + refName: 'addedBeforeFailure', + objName: 'addedBeforeFailure', + qualifiers: [], + }), + ); + }); }); diff --git a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts index a2b77f7491..b6ff57c09a 100644 --- a/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts +++ b/tegg/core/metadata/test/ModuleDescriptorDumper.test.ts @@ -25,6 +25,20 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { const json = JSON.parse(ModuleDescriptorDumper.stringifyDescriptor(desc)); assert.deepEqual(json.clazzList, [{ name: 'MissingFilePath' }]); }); + + it('should treat a legacy descriptor without innerObjectClazzList as empty', () => { + const desc: ModuleDescriptor = { + name: 'legacy', + unitPath: '/tmp/legacy', + clazzList: [], + multiInstanceClazzList: [], + protos: [], + }; + + const json = JSON.parse(ModuleDescriptorDumper.stringifyDescriptor(desc)); + assert.deepEqual(json.innerObjectClazzList, []); + assert.deepEqual(ModuleDescriptorDumper.getDecoratedFiles(desc), []); + }); }); describe('getDecoratedFiles()', () => { @@ -118,6 +132,7 @@ describe('test/ModuleDescriptorDumper.test.ts', () => { protos: [], }; + await ModuleDescriptorDumper.dump(desc, { dumpDir }); await ModuleDescriptorDumper.dump(desc, { dumpDir }); const dumpPath = ModuleDescriptorDumper.dumpPath(desc, { dumpDir }); diff --git a/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts b/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts index 9a55fd98f5..061395c443 100644 --- a/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts +++ b/tegg/core/runtime/src/impl/EggInnerObjectImpl.ts @@ -171,12 +171,8 @@ export class EggInnerObjectImpl implements EggObject { 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; diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts index 5da51b7cd1..779b410cb0 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnit.ts @@ -1,3 +1,5 @@ +import assert from 'node:assert/strict'; + import { ClassProtoDescriptor, EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; import { MapUtil } from '@eggjs/tegg-common-util'; import type { @@ -52,6 +54,7 @@ export class InnerObjectLoadUnit implements LoadUnit { readonly #protos: ProtoDescriptor[]; readonly #protoMap: Map = new Map(); + readonly #orderedProtos: EggPrototype[] = []; constructor(options: InnerObjectLoadUnitOptions) { this.name = options.name ?? INNER_OBJECT_LOAD_UNIT_NAME; @@ -62,11 +65,13 @@ export class InnerObjectLoadUnit implements LoadUnit { async init(): Promise { for (const protoDescriptor of this.#protos) { - if (!ClassProtoDescriptor.isClassProtoDescriptor(protoDescriptor)) { - continue; - } + assert( + ClassProtoDescriptor.isClassProtoDescriptor(protoDescriptor), + `InnerObjectLoadUnit only accepts ClassProtoDescriptor, got ${protoDescriptor.protoImplType}`, + ); const proto = await EggPrototypeCreatorFactory.createProtoByDescriptor(protoDescriptor, this); EggPrototypeFactory.instance.registerPrototype(proto, this); + this.#orderedProtos.push(proto); } } @@ -92,6 +97,10 @@ export class InnerObjectLoadUnit implements LoadUnit { protos.splice(index, 1); } } + const orderedIndex = this.#orderedProtos.indexOf(proto); + if (orderedIndex !== -1) { + this.#orderedProtos.splice(orderedIndex, 1); + } } async destroy(): Promise { @@ -101,13 +110,10 @@ export class InnerObjectLoadUnit implements LoadUnit { } } this.#protoMap.clear(); + this.#orderedProtos.length = 0; } iterateEggPrototype(): IterableIterator { - const protos: EggPrototype[] = []; - for (const namedProtos of this.#protoMap.values()) { - protos.push(...namedProtos); - } - return protos.values(); + return this.#orderedProtos.values(); } } diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts index d75d433f9b..d8df2c43e7 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts @@ -28,7 +28,7 @@ export interface InnerObjectModuleReference { } export interface CreateInnerObjectLoadUnitOptions { - /** Host-provided, already-constructed objects (logger, router, ...). */ + /** Host-provided, already-constructed objects (logger, router, config, ...). */ innerObjects: Record; name?: string; unitPath?: string; diff --git a/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts b/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts index e130e8ab34..2a9eb602a3 100644 --- a/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts +++ b/tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts @@ -1,8 +1,16 @@ 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 type { + EggLifecycleInfo, + EggObject, + EggPrototype, + EggPrototypeName, + LoadUnitInstance, + LoadUnitInstanceLifecycleContext, +} from '@eggjs/tegg-types'; +import { EggObjectFactory } from '../factory/EggObjectFactory.ts'; import { LoadUnitInstanceFactory } from '../factory/LoadUnitInstanceFactory.ts'; import { EggContextLifecycleUtil } from '../model/EggContext.ts'; import { EggObjectLifecycleUtil } from '../model/EggObject.ts'; @@ -28,6 +36,17 @@ export class InnerObjectLoadUnitInstance extends ModuleLoadUnitInstance { }; readonly #lifecycleObjects: [string, object][] = []; + readonly #createdObjects: EggObject[] = []; + readonly #createdObjectIds = new Set(); + + override async getOrCreateEggObject(name: EggPrototypeName, proto: EggPrototype): Promise { + const object = await super.getOrCreateEggObject(name, proto); + if (!this.#createdObjectIds.has(object.id)) { + this.#createdObjectIds.add(object.id); + this.#createdObjects.push(object); + } + return object; + } async init(ctx: LoadUnitInstanceLifecycleContext): Promise { await super.init(ctx); @@ -50,14 +69,22 @@ export class InnerObjectLoadUnitInstance extends ModuleLoadUnitInstance { } async destroy(): Promise { - let toBeDeleted = this.#lifecycleObjects.shift(); + let toBeDeleted = this.#lifecycleObjects.pop(); while (toBeDeleted) { const [type, lifecycle] = toBeDeleted; InnerObjectLoadUnitInstance.LifecycleUtils[type]?.deleteLifecycle(lifecycle); - toBeDeleted = this.#lifecycleObjects.shift(); + toBeDeleted = this.#lifecycleObjects.pop(); } - await super.destroy(); + this.eggObjectMap.clear(); + this.eggObjectPromiseMap.clear(); + + let object = this.#createdObjects.pop(); + while (object) { + await EggObjectFactory.destroyObject(object); + this.#createdObjectIds.delete(object.id); + object = this.#createdObjects.pop(); + } } static createInnerObjectLoadUnitInstance(ctx: LoadUnitInstanceLifecycleContext): LoadUnitInstance { diff --git a/tegg/core/runtime/src/impl/ModuleLoadUnitInstance.ts b/tegg/core/runtime/src/impl/ModuleLoadUnitInstance.ts index cc86f57b1c..91cd4506c1 100644 --- a/tegg/core/runtime/src/impl/ModuleLoadUnitInstance.ts +++ b/tegg/core/runtime/src/impl/ModuleLoadUnitInstance.ts @@ -21,8 +21,8 @@ export class ModuleLoadUnitInstance implements LoadUnitInstance { readonly id: string; readonly name: string; private protoToCreateMap: [EggPrototypeName, EggPrototype][] = []; - private eggObjectMap: Map> = new Map(); - private eggObjectPromiseMap: Map>> = new Map(); + protected eggObjectMap: Map> = new Map(); + protected eggObjectPromiseMap: Map>> = new Map(); constructor(loadUnit: LoadUnit) { this.loadUnit = loadUnit; diff --git a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts index cdf9ea011c..3251c30d76 100644 --- a/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts +++ b/tegg/core/runtime/test/InnerObjectLoadUnit.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import { mock } from 'node:test'; import { @@ -10,8 +11,9 @@ import { InnerObjectProto, LoadUnitLifecycleProto, } from '@eggjs/core-decorator'; -import { LifecycleInit, LifecyclePostInject } from '@eggjs/lifecycle'; -import { EggPrototypeFactory, EggPrototypeNotFound, LoadUnitFactory } from '@eggjs/metadata'; +import { LifecycleDestroy, LifecycleInit, LifecyclePostInject } from '@eggjs/lifecycle'; +import { EggPrototypeFactory, EggPrototypeNotFound, GlobalGraph, LoadUnitFactory } from '@eggjs/metadata'; +import { LoaderUtil } from '@eggjs/module-test-util'; import type { EggObject, EggObjectLifeCycleContext, @@ -23,7 +25,7 @@ import type { import { AccessLevel } from '@eggjs/tegg-types'; import { afterEach, beforeEach, describe, it } from 'vitest'; -import { InnerObjectLoadUnitBuilder, LoadUnitInstanceFactory } from '../src/index.ts'; +import { InnerObjectLoadUnit, InnerObjectLoadUnitBuilder, LoadUnitInstanceFactory } from '../src/index.ts'; import { ContextHandler } from '../src/model/ContextHandler.ts'; import { EggTestContext } from './fixtures/EggTestContext.ts'; import TestUtil from './util.ts'; @@ -128,7 +130,12 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { 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'); + const businessModulePath = path.join(import.meta.dirname, 'fixtures/modules/module-for-load-unit-instance'); + const descriptors = await LoaderUtil.loadModuleDescriptors([businessModulePath]); + GlobalGraph.instance = await GlobalGraph.create(descriptors); + GlobalGraph.instance.build(); + GlobalGraph.instance.sort(); + businessInstance = await TestUtil.createLoadUnitInstance('module-for-load-unit-instance', false); assert(UnitHook.createdUnits.includes(String(businessInstance.loadUnit.name))); assert(ObjectHook.hookedObjects.length > 0); @@ -139,7 +146,7 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { await LoadUnitInstanceFactory.destroyLoadUnitInstance(innerInstance); innerInstance = undefined; const createdCount = UnitHook.createdUnits.length; - businessInstance = await TestUtil.createLoadUnitInstance('module-for-load-unit-instance'); + businessInstance = await TestUtil.createLoadUnitInstance('module-for-load-unit-instance', false); assert.equal(UnitHook.createdUnits.length, createdCount); } finally { if (businessInstance) { @@ -175,6 +182,13 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { }, /recursive deps/); }); + it('should reject non-class descriptors instead of silently skipping them', async () => { + const loadUnit = new InnerObjectLoadUnit({ + protos: [{ protoImplType: 'non-class' } as any], + }); + await assert.rejects(() => loadUnit.init(), /only accepts ClassProtoDescriptor/); + }); + it('should throw on missing non-optional dependency', async () => { @InnerObjectProto() class BadInner { @@ -339,4 +353,85 @@ describe('core/runtime/test/InnerObjectLoadUnit.test.ts', () => { await LoadUnitFactory.destroyLoadUnit(loadUnit); } }); + + it('should preserve prototype order and destroy inner objects in reverse actual creation order', async () => { + const events: string[] = []; + + @InnerObjectProto({ name: 'dependency' }) + class Dependency { + alive = true; + + @LifecycleDestroy() + destroy(): void { + events.push('destroy:dependency'); + this.alive = false; + } + } + + @InnerObjectProto({ name: 'consumer' }) + class Consumer { + @Inject() + dependency: Dependency; + + @LifecycleDestroy() + destroy(): void { + assert.equal(this.dependency.alive, true); + events.push('destroy:consumer'); + } + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([Consumer, Dependency], { name: 'ordered', path: '/ordered' }); + const loadUnit = await builder.createLoadUnit({ innerObjects: {} }); + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + try { + const consumerProto = EggPrototypeFactory.instance.getPrototype('consumer', loadUnit); + await (instance as any).getOrCreateEggObject('consumer', consumerProto); + } finally { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + assert.deepEqual(events, ['destroy:consumer', 'destroy:dependency']); + }); + + it('should preserve interleaved order for same-name qualified prototypes', async () => { + const events: string[] = []; + + @InnerObjectProto({ name: 'shared' }) + class FirstShared { + @LifecycleInit() + init(): void { + events.push('shared:first'); + } + } + + @InnerObjectProto() + class Middle { + @LifecycleInit() + init(): void { + events.push('middle'); + } + } + + @InnerObjectProto({ name: 'shared' }) + class SecondShared { + @LifecycleInit() + init(): void { + events.push('shared:second'); + } + } + + const builder = new InnerObjectLoadUnitBuilder(); + builder.addInnerObjectClazzList([FirstShared], { name: 'first', path: '/first' }); + builder.addInnerObjectClazzList([Middle], { name: 'middle', path: '/middle' }); + builder.addInnerObjectClazzList([SecondShared], { name: 'second', path: '/second' }); + const loadUnit = await builder.createLoadUnit({ innerObjects: {} }); + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + try { + assert.deepEqual(events, ['shared:first', 'middle', 'shared:second']); + } finally { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + await LoadUnitFactory.destroyLoadUnit(loadUnit); + } + }); }); diff --git a/tegg/core/runtime/test/LoadUnitInstance.test.ts b/tegg/core/runtime/test/LoadUnitInstance.test.ts index 6ff3afe1e5..931de45e67 100644 --- a/tegg/core/runtime/test/LoadUnitInstance.test.ts +++ b/tegg/core/runtime/test/LoadUnitInstance.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { mock } from 'node:test'; import { EggPrototypeFactory } from '@eggjs/metadata'; -import { LoaderUtil } from '@eggjs/module-test-util'; +import { CoreTestHelper, LoaderUtil } from '@eggjs/module-test-util'; import { type LoadUnitInstance } from '@eggjs/tegg-types'; import { describe, beforeEach, afterEach, beforeAll, afterAll, it } from 'vitest'; @@ -169,23 +169,23 @@ describe('test/LoadUnit/LoadUnitInstance.test.ts', () => { let commonInstance: LoadUnitInstance; let repoInstance: LoadUnitInstance; let serviceInstance: LoadUnitInstance; + let innerInstance: LoadUnitInstance; beforeAll(async () => { EggContextStorage.register(); - await LoaderUtil.buildGlobalGraph([ + const builtGraph = await LoaderUtil.buildGlobalGraph([ path.join(__dirname, 'fixtures/modules/multi-module/multi-module-common'), path.join(__dirname, 'fixtures/modules/multi-module/multi-module-repo'), path.join(__dirname, 'fixtures/modules/multi-module/multi-module-service'), ]); + innerInstance = builtGraph.innerObjectLoadUnitInstance; commonInstance = await TestUtil.createLoadUnitInstance('multi-module/multi-module-common', false); repoInstance = await TestUtil.createLoadUnitInstance('multi-module/multi-module-repo', false); serviceInstance = await TestUtil.createLoadUnitInstance('multi-module/multi-module-service', false); }); afterAll(async () => { - await TestUtil.destroyLoadUnitInstance(commonInstance); - await TestUtil.destroyLoadUnitInstance(repoInstance); - await TestUtil.destroyLoadUnitInstance(serviceInstance); + await CoreTestHelper.destroyModules([commonInstance, repoInstance, serviceInstance, innerInstance]); }); it('should get appService', async () => { diff --git a/tegg/core/runtime/test/util.ts b/tegg/core/runtime/test/util.ts index d6dacafa58..40cb5fe1e6 100644 --- a/tegg/core/runtime/test/util.ts +++ b/tegg/core/runtime/test/util.ts @@ -1,25 +1,30 @@ import path from 'node:path'; import { LoadUnitFactory } from '@eggjs/metadata'; -import { LoaderUtil } from '@eggjs/module-test-util'; +import { CoreTestHelper, LoaderUtil } from '@eggjs/module-test-util'; import { LoaderFactory } from '@eggjs/tegg-loader'; import { EggLoadUnitType, type LoadUnitInstance } from '@eggjs/tegg-types'; import { LoadUnitInstanceFactory } from '../src/index.ts'; export default class TestUtil { + static readonly #ownedInnerInstances = new Map(); + static async createLoadUnitInstance(modulePath: string, buildGraph = true): Promise { const absolutePath = path.join(__dirname, 'fixtures/modules', modulePath); - if (buildGraph) { - await LoaderUtil.buildGlobalGraph([absolutePath]); - } + const builtGraph = buildGraph ? await LoaderUtil.buildGlobalGraph([absolutePath]) : undefined; const loader = LoaderFactory.createLoader(absolutePath, EggLoadUnitType.MODULE); const loadUnit = await LoadUnitFactory.createLoadUnit(absolutePath, EggLoadUnitType.MODULE, loader); - return await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); + if (builtGraph) { + this.#ownedInnerInstances.set(instance, builtGraph.innerObjectLoadUnitInstance); + } + return instance; } static async destroyLoadUnitInstance(loadUnitInstance: LoadUnitInstance): Promise { - await LoadUnitInstanceFactory.destroyLoadUnitInstance(loadUnitInstance); - await LoadUnitFactory.destroyLoadUnit(loadUnitInstance.loadUnit); + const innerInstance = this.#ownedInnerInstances.get(loadUnitInstance); + this.#ownedInnerInstances.delete(loadUnitInstance); + await CoreTestHelper.destroyModules(innerInstance ? [loadUnitInstance, innerInstance] : [loadUnitInstance]); } } diff --git a/tegg/core/test-util/src/CoreTestHelper.ts b/tegg/core/test-util/src/CoreTestHelper.ts index e43a462696..f9d35cb9de 100644 --- a/tegg/core/test-util/src/CoreTestHelper.ts +++ b/tegg/core/test-util/src/CoreTestHelper.ts @@ -12,6 +12,7 @@ import { LoaderFactory } from '@eggjs/tegg-loader'; import { ContextHandler, EggContainerFactory, + INNER_OBJECT_LOAD_UNIT_TYPE, type EggContext, type LoadUnitInstance, LoadUnitInstanceFactory, @@ -41,14 +42,34 @@ export class CoreTestHelper { return await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); } static async prepareModules(moduleDirs: string[], hooks?: GlobalGraphBuildHook[]): Promise> { - await LoaderUtil.buildGlobalGraph(moduleDirs, hooks); EggContextStorage.register(); const instances: Array = []; + const { innerObjectLoadUnitInstance: innerInstance } = await LoaderUtil.buildGlobalGraph(moduleDirs, hooks); for (const { path } of GlobalGraph.instance!.moduleConfigList) { - instances.push(await CoreTestHelper.getLoadUnitInstance(path)); + const loader = LoaderFactory.createLoader(path, EggLoadUnitType.MODULE); + const loadUnit = await LoadUnitFactory.createLoadUnit(path, EggLoadUnitType.MODULE, loader); + instances.push(await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit)); } + instances.push(innerInstance); return instances; } + + static async destroyModules(instances: LoadUnitInstance[]): Promise { + const innerInstance = instances.find((instance) => instance.loadUnit.type === INNER_OBJECT_LOAD_UNIT_TYPE); + const businessInstances = instances.filter((instance) => instance !== innerInstance); + instances.length = 0; + + for (const instance of businessInstances.reverse()) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + } + for (const instance of businessInstances) { + await LoadUnitFactory.destroyLoadUnit(instance.loadUnit); + } + if (innerInstance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(innerInstance); + await LoadUnitFactory.destroyLoadUnit(innerInstance.loadUnit); + } + } static async getObject(clazz: EggProtoImplClass): Promise { const proto = PrototypeUtil.getClazzProto(clazz as any) as EggPrototype; const eggObj = await EggContainerFactory.getOrCreateEggObject(proto, proto.name); diff --git a/tegg/core/test-util/src/LoaderUtil.ts b/tegg/core/test-util/src/LoaderUtil.ts index 16188bbf6f..b23eb27625 100644 --- a/tegg/core/test-util/src/LoaderUtil.ts +++ b/tegg/core/test-util/src/LoaderUtil.ts @@ -3,10 +3,23 @@ import { GlobalGraph, type GlobalGraphBuildHook, GlobalModuleNodeBuilder, + LoadUnitFactory, type GlobalModuleNode, + type ModuleDescriptor, } from '@eggjs/metadata'; import { ModuleConfigUtil } from '@eggjs/tegg-common-util'; import { LoaderFactory } from '@eggjs/tegg-loader'; +import { + INNER_OBJECT_LOAD_UNIT_NAME, + InnerObjectLoadUnitBuilder, + type LoadUnitInstance, + LoadUnitInstanceFactory, +} from '@eggjs/tegg-runtime'; + +export interface BuiltGlobalGraph { + moduleDescriptors: ModuleDescriptor[]; + innerObjectLoadUnitInstance: LoadUnitInstance; +} export class LoaderUtil { static async loadFile(filePath: string): Promise { @@ -44,21 +57,43 @@ export class LoaderUtil { return builder.build(); } - static async buildGlobalGraph(modulePaths: string[], hooks?: GlobalGraphBuildHook[]): Promise { + static async buildGlobalGraph(modulePaths: string[], hooks?: GlobalGraphBuildHook[]): Promise { + if (LoadUnitFactory.getLoadUnitById(INNER_OBJECT_LOAD_UNIT_NAME)) { + throw new Error('inner object load unit already exists; LoaderUtil.buildGlobalGraph requires an empty host'); + } // 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 moduleDescriptors = await LoaderUtil.loadModuleDescriptors(modulePaths); const globalGraph = await GlobalGraph.create(moduleDescriptors); for (const hook of hooks ?? []) { globalGraph.registerBuildHook(hook); } GlobalGraph.instance = globalGraph; + + const builder = new InnerObjectLoadUnitBuilder(); + for (const descriptor of moduleDescriptors) { + builder.addInnerObjectClazzList(descriptor.innerObjectClazzList ?? [], { + name: descriptor.name, + path: descriptor.unitPath, + }); + } + const innerObjectLoadUnit = await builder.createLoadUnit({ innerObjects: {} }); + + const innerObjectLoadUnitInstance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); globalGraph.build(); globalGraph.sort(); + return { + moduleDescriptors, + innerObjectLoadUnitInstance, + }; + } + + static async loadModuleDescriptors(modulePaths: string[]): Promise { + const moduleReferences = modulePaths.map((modulePath) => ({ + path: modulePath, + name: ModuleConfigUtil.readModuleNameSync(modulePath), + })); + return await LoaderFactory.loadApp(moduleReferences); } } diff --git a/tegg/core/test-util/test/CoreTestHelper.test.ts b/tegg/core/test-util/test/CoreTestHelper.test.ts new file mode 100644 index 0000000000..0ec71aa675 --- /dev/null +++ b/tegg/core/test-util/test/CoreTestHelper.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { INNER_OBJECT_LOAD_UNIT_TYPE } from '@eggjs/tegg-runtime'; +import { describe, it } from 'vitest'; + +import { CoreTestHelper } from '../src/CoreTestHelper.ts'; +import { LoaderUtil } from '../src/LoaderUtil.ts'; +import { TestLifecycleHook } from './fixtures/inner-lifecycle/TestLifecycleHook.ts'; + +describe('core/test-util/test/CoreTestHelper.test.ts', () => { + it('should instantiate inner lifecycle protos and keep them through business teardown', async () => { + TestLifecycleHook.events.length = 0; + const modules = await CoreTestHelper.prepareModules([path.join(import.meta.dirname, 'fixtures/inner-lifecycle')]); + assert.deepEqual(TestLifecycleHook.events, ['build:graph', 'create:business']); + assert.equal(modules.at(-1)?.loadUnit.type, INNER_OBJECT_LOAD_UNIT_TYPE); + + await CoreTestHelper.destroyModules(modules); + + assert.deepEqual(TestLifecycleHook.events, ['build:graph', 'create:business', 'destroy:business', 'destroy:inner']); + }); + + it('should reject implicit reuse of an existing inner load unit', async () => { + const modulePath = path.join(import.meta.dirname, 'fixtures/inner-lifecycle'); + const modules = await CoreTestHelper.prepareModules([modulePath]); + try { + await assert.rejects(() => LoaderUtil.buildGlobalGraph([modulePath]), /inner object load unit already exists/); + } finally { + await CoreTestHelper.destroyModules(modules); + } + }); +}); diff --git a/tegg/core/test-util/test/fixtures/inner-lifecycle/Service.ts b/tegg/core/test-util/test/fixtures/inner-lifecycle/Service.ts new file mode 100644 index 0000000000..52786f7051 --- /dev/null +++ b/tegg/core/test-util/test/fixtures/inner-lifecycle/Service.ts @@ -0,0 +1,4 @@ +import { SingletonProto } from '@eggjs/core-decorator'; + +@SingletonProto() +export class Service {} diff --git a/tegg/core/test-util/test/fixtures/inner-lifecycle/TestLifecycleHook.ts b/tegg/core/test-util/test/fixtures/inner-lifecycle/TestLifecycleHook.ts new file mode 100644 index 0000000000..940a0222aa --- /dev/null +++ b/tegg/core/test-util/test/fixtures/inner-lifecycle/TestLifecycleHook.ts @@ -0,0 +1,27 @@ +import { LoadUnitLifecycleProto } from '@eggjs/core-decorator'; +import { LifecycleDestroy, LifecyclePostInject } from '@eggjs/lifecycle'; +import { GlobalGraph } from '@eggjs/metadata'; +import type { LifecycleHook, LoadUnit, LoadUnitLifecycleContext } from '@eggjs/tegg-types'; + +@LoadUnitLifecycleProto() +export class TestLifecycleHook implements LifecycleHook { + static events: string[] = []; + + @LifecyclePostInject() + registerGraphHook(): void { + GlobalGraph.instance!.registerBuildHook(() => TestLifecycleHook.events.push('build:graph')); + } + + async postCreate(_ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.name === 'testUtilInnerLifecycle') TestLifecycleHook.events.push('create:business'); + } + + async preDestroy(_ctx: LoadUnitLifecycleContext, loadUnit: LoadUnit): Promise { + if (loadUnit.name === 'testUtilInnerLifecycle') TestLifecycleHook.events.push('destroy:business'); + } + + @LifecycleDestroy() + destroy(): void { + TestLifecycleHook.events.push('destroy:inner'); + } +} diff --git a/tegg/core/test-util/test/fixtures/inner-lifecycle/package.json b/tegg/core/test-util/test/fixtures/inner-lifecycle/package.json new file mode 100644 index 0000000000..e8772cbb89 --- /dev/null +++ b/tegg/core/test-util/test/fixtures/inner-lifecycle/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-util-inner-lifecycle", + "type": "module", + "eggModule": { + "name": "testUtilInnerLifecycle" + } +} diff --git a/tegg/plugin/config/src/app.ts b/tegg/plugin/config/src/app.ts index e65e34f5ef..488b7e79cd 100644 --- a/tegg/plugin/config/src/app.ts +++ b/tegg/plugin/config/src/app.ts @@ -54,6 +54,31 @@ export default class App implements ILifecycleBoot { #scanModuleReferences(): void { const { readModuleOptions } = this.app.config.tegg; + // Auto-exclude outDir (e.g. dist/) from app module scanning to avoid + // duplicate modules when both source and compiled output exist. + const outDir = this.app.loader.outDir; + let appReadModuleOptions = readModuleOptions; + if (outDir) { + const extraFilePattern = readModuleOptions.extraFilePattern || []; + const excludePattern = `!**/${outDir}`; + if (!extraFilePattern.includes(excludePattern)) { + appReadModuleOptions = { + ...readModuleOptions, + extraFilePattern: [...extraFilePattern, excludePattern], + }; + } + } + const moduleScanner = new ModuleScanner( + this.app.baseDir, + readModuleOptions, + this.app.coreLogger, + appReadModuleOptions, + { + allPlugins: this.app.loader.allPlugins, + lookupDirs: this.app.loader.lookupDirs, + }, + ); + // Try to use manifest for module references (skip expensive globby scan) const manifest = this.app.loader.manifest; const manifestTegg = manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; @@ -65,28 +90,9 @@ export default class App implements ILifecycleBoot { // `LoaderFactory.loadApp`. Path resolution to an absolute directory is done // in `#loadModuleConfigs` below. moduleReferences = manifestTegg.moduleReferences; + moduleScanner.validateManifestModulePlugins(manifestTegg); debug('load moduleReferences from manifest: %o', moduleReferences); } else { - // Auto-exclude outDir (e.g. dist/) from module scanning to avoid - // duplicate modules when both source and compiled output exist - const outDir = this.app.loader.outDir; - let appReadModuleOptions = readModuleOptions; - if (outDir) { - const extraFilePattern = readModuleOptions.extraFilePattern || []; - const excludePattern = `!**/${outDir}`; - if (!extraFilePattern.includes(excludePattern)) { - appReadModuleOptions = { - ...readModuleOptions, - extraFilePattern: [...extraFilePattern, excludePattern], - }; - } - } - const moduleScanner = new ModuleScanner( - this.app.baseDir, - readModuleOptions, - this.app.coreLogger, - appReadModuleOptions, - ); moduleReferences = moduleScanner.loadModuleReferences(); if (outDir) { diff --git a/tegg/plugin/config/src/lib/ModuleScanner.ts b/tegg/plugin/config/src/lib/ModuleScanner.ts index 0ee4757d55..8b45e437c2 100644 --- a/tegg/plugin/config/src/lib/ModuleScanner.ts +++ b/tegg/plugin/config/src/lib/ModuleScanner.ts @@ -3,7 +3,8 @@ import path from 'node:path'; import { debuglog } from 'node:util'; import { ModuleConfigUtil, type ModuleReference, type ReadModuleReferenceOptions } from '@eggjs/tegg-common-util'; -import { getFrameworkPath } from '@eggjs/utils'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { getFrameworkPath, importResolve } from '@eggjs/utils'; const debug = debuglog('egg/tegg/plugin/config/ModuleScanner'); @@ -11,22 +12,100 @@ interface WarnLogger { warn(message: string): void; } +interface ModulePluginInfo { + enable: boolean; + package?: string; + path?: string; +} + +export interface ModulePluginOptions { + allPlugins?: Readonly>; + lookupDirs?: Iterable; +} + export class ModuleScanner { private readonly baseDir: string; private readonly readModuleOptions: ReadModuleReferenceOptions; private readonly appReadModuleOptions: ReadModuleReferenceOptions; private readonly logger?: WarnLogger; + private readonly modulePluginOptions?: ModulePluginOptions; constructor( baseDir: string, readModuleOptions: ReadModuleReferenceOptions, logger?: WarnLogger, appReadModuleOptions: ReadModuleReferenceOptions = readModuleOptions, + modulePluginOptions?: ModulePluginOptions, ) { this.baseDir = baseDir; this.readModuleOptions = readModuleOptions; this.appReadModuleOptions = appReadModuleOptions; this.logger = logger; + this.modulePluginOptions = modulePluginOptions; + } + + private loadPluginModuleReferences(enabledOnly = false): readonly ModuleReference[] { + const references: ModuleReference[] = []; + for (const plugin of Object.values(this.modulePluginOptions?.allPlugins ?? {})) { + if (enabledOnly && !plugin.enable) continue; + + let packageJsonPath: string | undefined; + if (plugin.package) { + try { + packageJsonPath = importResolve(`${plugin.package}/package.json`, { + paths: [...(this.modulePluginOptions?.lookupDirs ?? [])], + }); + } catch { + continue; + } + } else if (plugin.path) { + const directPackageJsonPath = path.join(plugin.path, 'package.json'); + if (fs.existsSync(directPackageJsonPath)) { + packageJsonPath = directPackageJsonPath; + } + } + if (!packageJsonPath) continue; + + let pkg: { name?: string; eggModule?: { name?: string } }; + try { + pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch { + continue; + } + if (!pkg.eggModule?.name) continue; + + references.push({ + name: pkg.eggModule.name, + package: pkg.name, + path: fs.realpathSync(path.dirname(packageJsonPath)), + optional: true, + }); + } + return references; + } + + validateManifestModulePlugins(manifest: TeggManifestExtension): void { + for (const pluginReference of this.loadPluginModuleReferences(true)) { + const reference = manifest.moduleReferences.find((reference) => { + if (pluginReference.package && reference.package) { + return pluginReference.package === reference.package; + } + return pluginReference.path === reference.path; + }); + if (!reference) { + throw new Error( + `[egg/tegg/plugin/config] manifest is missing enabled module plugin "${pluginReference.name}" (${pluginReference.package ?? pluginReference.path})`, + ); + } + const descriptor = manifest.moduleDescriptors?.find( + (descriptor) => descriptor.unitPath === reference.path && descriptor.name === reference.name, + ); + if (!descriptor) { + throw new Error( + `[egg/tegg/plugin/config] manifest is missing descriptor for enabled module plugin "${pluginReference.name}" (${reference.path})`, + ); + } + } } /** @@ -75,8 +154,15 @@ export class ModuleScanner { const frameworkDirs: string[] = []; const seen = new Set(); let frameworkDir = this.resolveFrameworkDir(this.baseDir); - while (frameworkDir && !seen.has(frameworkDir)) { - seen.add(frameworkDir); + while (frameworkDir) { + let canonicalFrameworkDir = frameworkDir; + try { + canonicalFrameworkDir = fs.realpathSync(frameworkDir); + } catch { + canonicalFrameworkDir = path.resolve(frameworkDir); + } + if (seen.has(canonicalFrameworkDir)) break; + seen.add(canonicalFrameworkDir); frameworkDirs.push(frameworkDir); frameworkDir = this.resolveParentFrameworkDir(frameworkDir); } @@ -118,16 +204,43 @@ export class ModuleScanner { } } - private deduplicateLayeredModuleReferences(moduleReferences: readonly ModuleReference[]): readonly ModuleReference[] { + private deduplicateRootModuleReferences(moduleReferences: readonly ModuleReference[]): readonly ModuleReference[] { const result: ModuleReference[] = []; + const pathMap = new Map(); const nameMap = new Map(); for (const moduleReference of moduleReferences) { - const existing = nameMap.get(moduleReference.name); - if (existing) { - this.warnDuplicateModuleName(existing, moduleReference); + let canonicalPath = moduleReference.path; + try { + canonicalPath = fs.realpathSync(moduleReference.path); + } catch { + // Keep the unresolved path. Missing optional modules are valid inputs. + } + + const existingByPath = pathMap.get(canonicalPath); + if (existingByPath) { + if (existingByPath.optional === true && moduleReference.optional !== true) { + const index = result.indexOf(existingByPath); + const promoted = { + ...existingByPath, + ...(!existingByPath.package && moduleReference.package ? { package: moduleReference.package } : {}), + optional: false, + }; + result[index] = promoted; + pathMap.set(canonicalPath, promoted); + nameMap.set(existingByPath.name, promoted); + } continue; } + + const existingByName = nameMap.get(moduleReference.name); + if (existingByName) { + throw new Error( + `Duplicate module name "${moduleReference.name}" found: existing at ${existingByName.path}, duplicate at ${moduleReference.path}`, + ); + } + + pathMap.set(canonicalPath, moduleReference); nameMap.set(moduleReference.name, moduleReference); result.push(moduleReference); } @@ -135,25 +248,45 @@ export class ModuleScanner { return result; } + private deduplicateLayeredModuleReferences( + moduleReferenceLayers: readonly (readonly ModuleReference[])[], + ): readonly ModuleReference[] { + const result: ModuleReference[] = []; + const nameMap = new Map(); + + for (const moduleReferences of moduleReferenceLayers) { + for (const moduleReference of moduleReferences) { + const existing = nameMap.get(moduleReference.name); + if (existing) { + this.warnDuplicateModuleName(existing, moduleReference); + continue; + } + nameMap.set(moduleReference.name, moduleReference); + result.push(moduleReference); + } + } + + return result; + } + /** * - load module references from config or scan from baseDir * - load the framework's module plugins as OPTIONAL references * (plugin promotion flips the enabled ones to non-optional) */ loadModuleReferences(): readonly ModuleReference[] { - const moduleReferences = this.readModuleReferences(this.baseDir); + const appModuleReferences = this.deduplicateRootModuleReferences([ + ...this.readModuleReferences(this.baseDir), + ...this.loadPluginModuleReferences(), + ]); const frameworkDirs = this.resolveFrameworkDirs(); debug('loadModuleReferences from frameworkDirs:%o', frameworkDirs); - const optionalModuleReferences = frameworkDirs.flatMap((frameworkDir) => - this.readModuleReferences(frameworkDir, frameworkDir), + const frameworkModuleReferenceLayers = frameworkDirs.map((frameworkDir) => + this.deduplicateRootModuleReferences( + this.readModuleReferences(frameworkDir, frameworkDir).map((ref) => ({ ...ref, optional: true })), + ), ); - // Merge all module references and deduplicate - const allModuleReferences = [ - ...moduleReferences, - ...optionalModuleReferences.map((ref) => ({ ...ref, optional: true })), - ]; - - return this.deduplicateLayeredModuleReferences(allModuleReferences); + return this.deduplicateLayeredModuleReferences([appModuleReferences, ...frameworkModuleReferenceLayers]); } } diff --git a/tegg/plugin/config/test/DuplicateOptionalModule.test.ts b/tegg/plugin/config/test/DuplicateOptionalModule.test.ts index 318b74e316..570d399ea2 100644 --- a/tegg/plugin/config/test/DuplicateOptionalModule.test.ts +++ b/tegg/plugin/config/test/DuplicateOptionalModule.test.ts @@ -17,9 +17,14 @@ describe('plugin/config/test/DuplicateOptionalModule.test.ts', () => { }); it('should work', async () => { - console.log(app.moduleReferences); - console.log(app.moduleConfigs); - expect(app.moduleReferences.length).toBe(2); - expect(Object.keys(app.moduleConfigs).length).toBe(2); + expect(app.moduleReferences.map((reference) => reference.name)).toEqual([ + 'used', + 'teggConfig', + 'teggAjv', + 'teggAop', + 'teggDal', + 'unused', + ]); + expect(Object.keys(app.moduleConfigs)).toEqual(['used', 'teggConfig', 'teggAjv', 'teggAop', 'teggDal', 'unused']); }); }); diff --git a/tegg/plugin/config/test/ManifestModuleReference.test.ts b/tegg/plugin/config/test/ManifestModuleReference.test.ts index b4867bf52a..871bf54c55 100644 --- a/tegg/plugin/config/test/ManifestModuleReference.test.ts +++ b/tegg/plugin/config/test/ManifestModuleReference.test.ts @@ -53,7 +53,6 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { reference: { optional: undefined, name: 'moduleA', - package: undefined, path: moduleDir, loaderType: undefined, }, @@ -79,4 +78,44 @@ describe('plugin/config/test/ManifestModuleReference.test.ts', () => { expect(app.moduleConfigs.moduleA.reference.name).toBe('moduleA'); }); + + it('rejects a manifest missing an enabled module plugin', () => { + const pluginRoot = getFixtures('plugin-module-json/app/node_modules/aop-like-plugin'); + const app = createFakeApp([{ name: 'moduleA', path: moduleDir }]); + Object.assign(app.loader, { + allPlugins: { + teggAop: { enable: true, package: 'aop-like-plugin', path: pluginRoot }, + }, + lookupDirs: new Set([getFixtures('plugin-module-json/app')]), + }); + + expect(() => new App(app).configWillLoad()).toThrow(/manifest is missing enabled module plugin "teggAop"/); + }); + + it('rejects a manifest missing the descriptor for an enabled module plugin', () => { + const pluginRoot = getFixtures('plugin-module-json/app/node_modules/aop-like-plugin'); + const app = createFakeApp([ + { name: 'moduleA', path: moduleDir }, + { name: 'teggAop', package: 'aop-like-plugin', path: pluginRoot }, + ]); + Object.assign(app.loader, { + allPlugins: { + teggAop: { enable: true, package: 'aop-like-plugin', path: pluginRoot }, + }, + lookupDirs: new Set([getFixtures('plugin-module-json/app')]), + manifest: { + getExtension: () => ({ + moduleReferences: [ + { name: 'moduleA', path: moduleDir }, + { name: 'teggAop', package: 'aop-like-plugin', path: pluginRoot }, + ], + moduleDescriptors: [{ name: 'moduleA', unitPath: moduleDir, decoratedFiles: [] }], + }), + }, + }); + + expect(() => new App(app).configWillLoad()).toThrow( + /manifest is missing descriptor for enabled module plugin "teggAop"/, + ); + }); }); diff --git a/tegg/plugin/config/test/ModuleScanner.test.ts b/tegg/plugin/config/test/ModuleScanner.test.ts index 692a227f81..bb13a613d9 100644 --- a/tegg/plugin/config/test/ModuleScanner.test.ts +++ b/tegg/plugin/config/test/ModuleScanner.test.ts @@ -1,11 +1,15 @@ +import fs from 'node:fs'; import path from 'node:path'; +import { mock } from 'node:test'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ModuleScanner } from '../src/lib/ModuleScanner.js'; import { getFixtures } from './utils.js'; describe('plugin/config/test/ModuleScanner.test.ts', () => { + afterEach(() => mock.reset()); + it('should scan module plugins from every framework layer and keep nearest duplicate names', () => { const baseDir = getFixtures('framework-chain/app'); const warnings: string[] = []; @@ -45,28 +49,13 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { expect(warnings[0]).toContain(path.join(baseDir, 'node_modules/base-framework/node_modules/shared-module-base')); }); - it('should keep the first app reference when one scan root has duplicate module names', () => { + it('should reject duplicate module names within one scan root', () => { const baseDir = getFixtures('app-duplicate-name-first-wins/app'); const warnings: string[] = []; - const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); - - expect(refs).toHaveLength(1); - expect(refs[0].name).toBe('sharedModule'); - expect(['near-module', 'far-module']).toContain(refs[0].package); - expect([path.join(baseDir, 'node_modules/near-module'), path.join(baseDir, 'node_modules/far-module')]).toContain( - refs[0].path, - ); - expect(warnings).toEqual([ - expect.stringContaining('Duplicate module name "sharedModule" found while scanning module references'), - ]); - expect(warnings[0]).toContain(`keep ${refs[0].path}`); - expect(warnings[0]).toContain( - `skip ${ - refs[0].package === 'near-module' - ? path.join(baseDir, 'node_modules/far-module') - : path.join(baseDir, 'node_modules/near-module') - }`, - ); + expect(() => + new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(), + ).toThrow(`Duplicate module name "sharedModule" found: existing at`); + expect(warnings).toEqual([]); }); it('should keep the app reference when a framework scans the same module path', () => { @@ -84,6 +73,64 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { expect(warnings).toEqual([]); }); + it('should keep the app module over a different framework path and warn', () => { + const baseDir = getFixtures('app-framework-conflict/app'); + const warnings: string[] = []; + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toEqual([ + { + name: 'sharedModule', + package: 'app-shared', + path: path.join(baseDir, 'modules/app-shared'), + }, + ]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(`keep ${path.join(baseDir, 'modules/app-shared')}`); + expect(warnings[0]).toContain(`skip ${path.join(baseDir, 'chair-framework/modules/framework-shared')}`); + }); + + it('should silently merge app and framework paths resolving to the same realpath through the public entry', () => { + const baseDir = getFixtures('app-framework-conflict/app'); + const appModulePath = path.join(baseDir, 'modules/app-shared'); + const frameworkModulePath = path.join(baseDir, 'chair-framework/modules/framework-shared'); + const warnings: string[] = []; + const originalRealpath = fs.realpathSync.bind(fs); + mock.method(fs, 'realpathSync', (target: fs.PathLike) => { + if (target === frameworkModulePath) return appModulePath; + return originalRealpath(target); + }); + + const refs = new ModuleScanner(baseDir, {}, { warn: (message) => warnings.push(message) }).loadModuleReferences(); + + expect(refs).toEqual([{ name: 'sharedModule', package: 'app-shared', path: appModulePath }]); + expect(warnings).toEqual([]); + }); + + it('should silently merge layered symlink paths resolving to the same realpath', () => { + const warnings: string[] = []; + const scanner = new ModuleScanner( + getFixtures('framework-chain/app'), + {}, + { + warn: (message) => warnings.push(message), + }, + ); + const originalRealpath = fs.realpathSync.bind(fs); + mock.method(fs, 'realpathSync', (target: fs.PathLike) => { + if (target === '/symlink/near' || target === '/symlink/far') return '/real/shared-module'; + return originalRealpath(target); + }); + + const refs = (scanner as any).deduplicateLayeredModuleReferences([ + [{ name: 'sharedModule', path: '/symlink/near' }], + [{ name: 'sharedModule', path: '/symlink/far', optional: true }], + ]); + + expect(refs).toEqual([{ name: 'sharedModule', path: '/symlink/near' }]); + expect(warnings).toEqual([]); + }); + it('should resolve framework module.json package references from the framework directory', () => { const baseDir = getFixtures('framework-module-json/app'); const refs = new ModuleScanner(baseDir, { cwd: baseDir }).loadModuleReferences(); @@ -133,4 +180,19 @@ describe('plugin/config/test/ModuleScanner.test.ts', () => { }, ]); }); + + it('should stop a framework cycle reached through different symlink aliases', () => { + const scanner = new ModuleScanner(getFixtures('framework-cycle/app'), {}); + (scanner as any).resolveFrameworkDir = () => '/alias/framework-a'; + (scanner as any).resolveParentFrameworkDir = (frameworkDir: string) => + frameworkDir === '/alias/framework-a' ? '/alias/framework-b' : '/alias/framework-a-again'; + const originalRealpath = fs.realpathSync.bind(fs); + mock.method(fs, 'realpathSync', (target: fs.PathLike) => { + if (target === '/alias/framework-a' || target === '/alias/framework-a-again') return '/real/framework-a'; + if (target === '/alias/framework-b') return '/real/framework-b'; + return originalRealpath(target); + }); + + expect((scanner as any).resolveFrameworkDirs()).toEqual(['/alias/framework-a', '/alias/framework-b']); + }); }); diff --git a/tegg/plugin/config/test/PluginModuleDiscovery.test.ts b/tegg/plugin/config/test/PluginModuleDiscovery.test.ts new file mode 100644 index 0000000000..499cd76de3 --- /dev/null +++ b/tegg/plugin/config/test/PluginModuleDiscovery.test.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { mock } from 'node:test'; + +import type { Application } from 'egg'; +import { afterEach, describe, expect, it } from 'vitest'; + +import App from '../src/app.ts'; +import { getFixtures } from './utils.ts'; + +describe('plugin/config/test/PluginModuleDiscovery.test.ts', () => { + afterEach(() => mock.reset()); + + it('should discover plugin modules even when app module.json short-circuits dependency scanning', () => { + const baseDir = getFixtures('plugin-module-json/app'); + const pluginRoot = getFixtures('plugin-module-json/app/node_modules/aop-like-plugin'); + const fakeApp = { + baseDir, + config: { tegg: { readModuleOptions: {} } }, + coreLogger: { warn() {} }, + loader: { + allPlugins: { + teggAop: { enable: true, package: 'aop-like-plugin', path: pluginRoot }, + }, + getTypeFiles: () => [], + lookupDirs: new Set([baseDir]), + manifest: { getExtension: () => undefined }, + outDir: undefined, + }, + } as unknown as Application; + + new App(fakeApp).configWillLoad(); + + expect(fakeApp.moduleReferences.map((ref) => ref.name)).toEqual(['appModule', 'teggAop']); + expect(fakeApp.moduleReferences[1]).toEqual({ + name: 'teggAop', + package: 'aop-like-plugin', + path: pluginRoot, + optional: true, + }); + }); + + it('should canonicalize plugin module paths', () => { + const baseDir = getFixtures('plugin-module-json/app'); + const pluginRoot = getFixtures('plugin-module-json/app/node_modules/aop-like-plugin'); + const realPluginRoot = path.join(baseDir, 'real-aop-like-plugin'); + const originalRealpath = fs.realpathSync.bind(fs); + mock.method(fs, 'realpathSync', (target: fs.PathLike) => { + if (target === pluginRoot) return realPluginRoot; + return originalRealpath(target); + }); + const fakeApp = { + baseDir, + config: { tegg: { readModuleOptions: {} } }, + coreLogger: { warn() {} }, + loader: { + allPlugins: { + teggAop: { enable: true, package: 'aop-like-plugin', path: pluginRoot }, + }, + getTypeFiles: () => [], + lookupDirs: new Set([baseDir]), + manifest: { getExtension: () => undefined }, + outDir: undefined, + }, + } as unknown as Application; + + new App(fakeApp).configWillLoad(); + + expect(fakeApp.moduleReferences.find((ref) => ref.name === 'teggAop')?.path).toBe(realPluginRoot); + }); +}); diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/config/module.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/config/module.json new file mode 100644 index 0000000000..05c3d4e02a --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/framework-shared" + } +] diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/modules/framework-shared/package.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/modules/framework-shared/package.json new file mode 100644 index 0000000000..c98100f294 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/modules/framework-shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "framework-shared", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/package.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/package.json new file mode 100644 index 0000000000..3e840b548d --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/chair-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "chair-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/config/module.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/config/module.json new file mode 100644 index 0000000000..b9463d3bf5 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/app-shared" + } +] diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/modules/app-shared/package.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/modules/app-shared/package.json new file mode 100644 index 0000000000..d6482da849 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/modules/app-shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-shared", + "type": "module", + "eggModule": { + "name": "sharedModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/app-framework-conflict/app/package.json b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/package.json new file mode 100644 index 0000000000..079e24f49f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/app-framework-conflict/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-framework-conflict", + "type": "module", + "egg": { + "framework": "../chair-framework" + } +} diff --git a/tegg/plugin/config/test/fixtures/plugin-module-json/app/config/module.json b/tegg/plugin/config/test/fixtures/plugin-module-json/app/config/module.json new file mode 100644 index 0000000000..863161c4b1 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/plugin-module-json/app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/app-module" + } +] diff --git a/tegg/plugin/config/test/fixtures/plugin-module-json/app/modules/app-module/package.json b/tegg/plugin/config/test/fixtures/plugin-module-json/app/modules/app-module/package.json new file mode 100644 index 0000000000..fd0bbee71f --- /dev/null +++ b/tegg/plugin/config/test/fixtures/plugin-module-json/app/modules/app-module/package.json @@ -0,0 +1,7 @@ +{ + "name": "app-module", + "type": "module", + "eggModule": { + "name": "appModule" + } +} diff --git a/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/aop-like-plugin/package.json b/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/aop-like-plugin/package.json new file mode 100644 index 0000000000..480a575bed --- /dev/null +++ b/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/aop-like-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "aop-like-plugin", + "type": "module", + "eggModule": { + "name": "teggAop" + } +} diff --git a/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/custom-framework/package.json b/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/custom-framework/package.json new file mode 100644 index 0000000000..9927c00702 --- /dev/null +++ b/tegg/plugin/config/test/fixtures/plugin-module-json/app/node_modules/custom-framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "custom-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/config/test/fixtures/plugin-module-json/app/package.json b/tegg/plugin/config/test/fixtures/plugin-module-json/app/package.json new file mode 100644 index 0000000000..3efbabe5da --- /dev/null +++ b/tegg/plugin/config/test/fixtures/plugin-module-json/app/package.json @@ -0,0 +1,7 @@ +{ + "name": "plugin-module-json-app", + "type": "module", + "egg": { + "framework": "custom-framework" + } +} diff --git a/tegg/plugin/dal/test/TransactionPrototypeHook.test.ts b/tegg/plugin/dal/test/TransactionPrototypeHook.test.ts index bdaf1c539b..7ee8a4641c 100644 --- a/tegg/plugin/dal/test/TransactionPrototypeHook.test.ts +++ b/tegg/plugin/dal/test/TransactionPrototypeHook.test.ts @@ -1,3 +1,4 @@ +import { ModuleConfigs } from '@eggjs/tegg-common-util'; import { Transactional } from '@eggjs/transaction-decorator'; import { describe, expect, it } from 'vitest'; @@ -8,20 +9,26 @@ describe('plugin/dal/test/TransactionPrototypeHook.test.ts', () => { info() {}, } as any; + function createHook(moduleConfigs: ConstructorParameters[0]): TransactionPrototypeHook { + const hook = new TransactionPrototypeHook(); + Object.assign(hook, { + moduleConfigs: new ModuleConfigs(moduleConfigs), + logger, + }); + return hook; + } + it('should skip transaction hook when module has no dataSource config', async () => { class FooService { @Transactional() async doSomething() {} } - const hook = new TransactionPrototypeHook( - { - foo: { - config: {}, - }, + const hook = createHook({ + foo: { + config: {}, } as any, - logger, - ); + }); await expect( hook.preCreate({ @@ -41,16 +48,13 @@ describe('plugin/dal/test/TransactionPrototypeHook.test.ts', () => { async doSomething() {} } - const hook = new TransactionPrototypeHook( - { - bar: { - config: { - dataSource: {}, - }, + const hook = createHook({ + bar: { + config: { + dataSource: {}, }, } as any, - logger, - ); + }); await expect( hook.preCreate({ diff --git a/tegg/plugin/tegg/src/lib/ModuleHandler.ts b/tegg/plugin/tegg/src/lib/ModuleHandler.ts index 48098a9c12..1d405c8768 100644 --- a/tegg/plugin/tegg/src/lib/ModuleHandler.ts +++ b/tegg/plugin/tegg/src/lib/ModuleHandler.ts @@ -49,7 +49,7 @@ export class ModuleHandler extends Base { if (moduleDescriptor.optional === true) { continue; } - builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { + builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList ?? [], { name: moduleDescriptor.name, path: moduleDescriptor.unitPath, }); @@ -64,6 +64,7 @@ export class ModuleHandler extends Base { // its own resolution surface for these names (egg compatible objects), // the provided protos must stay visible to inner objects only. innerObjects: { + logger: [{ obj: this.app.logger, accessLevel: AccessLevel.PRIVATE }], moduleConfigs: [{ obj: new ModuleConfigs(this.app.moduleConfigs), accessLevel: AccessLevel.PRIVATE }], runtimeConfig: [ { @@ -75,7 +76,6 @@ export class ModuleHandler extends Base { accessLevel: AccessLevel.PRIVATE, }, ], - logger: [{ obj: this.app.logger, accessLevel: AccessLevel.PRIVATE }], }, }); this.#innerObjectLoadUnit = innerObjectLoadUnit; @@ -114,15 +114,6 @@ export class ModuleHandler extends Base { } async destroy(): Promise { - const errors: unknown[] = []; - const safe = async (destroy: () => Promise) => { - try { - await destroy(); - } catch (e) { - errors.push(e); - } - }; - // Reverse creation order: business load units go down first; the inner // instance and load unit go down after business load-unit metadata so its // lifecycle protos still observe LoadUnitFactory.destroyLoadUnit(). @@ -130,24 +121,21 @@ export class ModuleHandler extends Base { if (this.loadUnitInstances) { const businessInstances = this.loadUnitInstances.filter((instance) => instance !== innerObjectLoadUnitInstance); for (const instance of [...businessInstances].reverse()) { - await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(instance)); + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); } } if (this.loadUnits) { for (const loadUnit of [...this.loadUnits].reverse()) { - await safe(() => LoadUnitFactory.destroyLoadUnit(loadUnit)); + await LoadUnitFactory.destroyLoadUnit(loadUnit); } } if (innerObjectLoadUnitInstance) { - await safe(() => LoadUnitInstanceFactory.destroyLoadUnitInstance(innerObjectLoadUnitInstance)); + await LoadUnitInstanceFactory.destroyLoadUnitInstance(innerObjectLoadUnitInstance); this.#innerObjectLoadUnitInstance = undefined; } if (this.#innerObjectLoadUnit) { - await safe(() => LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit!)); + await LoadUnitFactory.destroyLoadUnit(this.#innerObjectLoadUnit); this.#innerObjectLoadUnit = undefined; } - if (errors.length) { - throw new AggregateError(errors, 'destroy tegg module handler failed'); - } } } diff --git a/tegg/plugin/tegg/test/ModulePlugin.test.ts b/tegg/plugin/tegg/test/ModulePlugin.test.ts index e4ac459f10..216f978252 100644 --- a/tegg/plugin/tegg/test/ModulePlugin.test.ts +++ b/tegg/plugin/tegg/test/ModulePlugin.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import { mm, type MockApplication } from '@eggjs/mock'; import { afterAll, afterEach, beforeAll, describe, it } from 'vitest'; +import { AopService } from './fixtures/apps/module-plugin-app/modules/plugin-module/AopService.ts'; import { HelloService } from './fixtures/apps/module-plugin-app/modules/plugin-module/HelloService.ts'; import { getAppBaseDir } from './utils.ts'; @@ -12,6 +14,10 @@ describe('plugin/tegg/test/ModulePlugin.test.ts', () => { beforeAll(async () => { app = mm.app({ baseDir: getAppBaseDir('module-plugin-app'), + // Run on the real Egg classes while leaving package.json pointed at a + // minimal framework with no AOP dependency. Module discovery must get + // teggAop from the enabled plugin itself, not from framework scanning. + framework: path.join(import.meta.dirname, '../../../../packages/egg'), }); await app.ready(); }); @@ -48,4 +54,13 @@ describe('plugin/tegg/test/ModulePlugin.test.ts', () => { assert(helloService.innerRegistry); assert.equal(typeof helloService.innerRegistry.record, 'function'); }); + + it('should keep AOP active when config/module.json short-circuits dependency scanning', async () => { + const reference = app.moduleReferences.find((item) => item.name === 'teggAop'); + assert(reference); + assert.equal(reference.optional, false); + + const service = await app.getEggObject(AopService); + assert.equal(await service.greet('module-json'), 'hello advised:module-json'); + }); }); 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 index 09c3e380f2..3a8d475107 100644 --- 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 @@ -11,5 +11,9 @@ export default { package: '@eggjs/tegg-plugin', enable: true, }, + teggAop: { + package: '@eggjs/aop-plugin', + enable: true, + }, watcher: false, }; diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/framework/package.json b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/framework/package.json new file mode 100644 index 0000000000..283db7a16a --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/framework/package.json @@ -0,0 +1,7 @@ +{ + "name": "module-plugin-test-framework", + "type": "module", + "egg": { + "framework": true + } +} diff --git a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/AopService.ts b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/AopService.ts new file mode 100644 index 0000000000..a608d1df2f --- /dev/null +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/modules/plugin-module/AopService.ts @@ -0,0 +1,18 @@ +import { AccessLevel, SingletonProto } from '@eggjs/tegg'; +import { Advice, type AdviceContext, type IAdvice, Pointcut } from '@eggjs/tegg/aop'; + +@Advice() +export class ModuleJsonAdvice implements IAdvice { + async around(context: AdviceContext, next: () => Promise): Promise { + context.args[0] = `advised:${context.args[0]}`; + return await next(); + } +} + +@SingletonProto({ accessLevel: AccessLevel.PUBLIC }) +export class AopService { + @Pointcut(ModuleJsonAdvice) + async greet(name: string): Promise { + return `hello ${name}`; + } +} 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 index 696ab1d9bf..5aa6ea8ca7 100644 --- a/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json +++ b/tegg/plugin/tegg/test/fixtures/apps/module-plugin-app/package.json @@ -1,4 +1,7 @@ { "name": "module-plugin-app", - "type": "module" + "type": "module", + "egg": { + "framework": "../framework" + } } diff --git a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts index 263075b50c..dd2a03cfad 100644 --- a/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts +++ b/tegg/plugin/tegg/test/lib/EggModuleLoader.test.ts @@ -109,6 +109,8 @@ describe('test/lib/EggModuleLoader.test.ts', () => { }, }, } as any; + const originalReferences = [...moduleReferences]; + const originalNames = moduleReferences.map((reference) => reference.name); mock.method(LoaderFactory, 'loadApp', async () => []); mock.method(GlobalGraph, 'create', async () => { @@ -119,6 +121,17 @@ describe('test/lib/EggModuleLoader.test.ts', () => { await new EggModuleLoader(app).initGraph(); + assert.equal(app.moduleReferences, moduleReferences); + assert.deepEqual(app.moduleReferences, originalReferences); + assert.equal(app.moduleReferences.length, originalReferences.length); + for (const [index, reference] of app.moduleReferences.entries()) { + assert.equal(reference, originalReferences[index]); + } + assert.deepEqual( + app.moduleReferences.map((reference: { name: string }) => reference.name), + originalNames, + ); + assert.equal(moduleReferences[0].optional, false); assert.equal(moduleReferences[1].optional, false); assert.equal(moduleReferences[2].optional, true); diff --git a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts index e05c71ca12..13ede9444b 100644 --- a/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts +++ b/tegg/plugin/tegg/test/lib/ModuleHandler.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { mock } from 'node:test'; -import { EggLoadUnitType, LoadUnitFactory, type LoadUnit } from '@eggjs/metadata'; -import { LoadUnitInstanceFactory, type LoadUnitInstance } from '@eggjs/tegg-runtime'; +import { EggLoadUnitType, type LoadUnit } from '@eggjs/metadata'; +import { InnerObjectLoadUnitBuilder, LoadUnitInstanceFactory, type LoadUnitInstance } from '@eggjs/tegg-runtime'; import { afterEach, describe, it } from 'vitest'; import { ModuleHandler } from '../../src/lib/ModuleHandler.ts'; @@ -69,42 +69,35 @@ describe('plugin/tegg/test/lib/ModuleHandler.test.ts', () => { ); }); - it('should continue destroying remaining load units and aggregate errors', async () => { + it('should not add optional module inner objects to the inner builder', async () => { const handler = createHandler(); - const firstLoadUnit = createLoadUnit('first'); - const secondLoadUnit = createLoadUnit('second'); - handler.loadUnitInstances.push(createInstance('inner'), createInstance('business')); - handler.loadUnits.push(firstLoadUnit, secondLoadUnit); - - const destroyed: string[] = []; - const destroyedInstances: string[] = []; - const destroyedLoadUnits: string[] = []; - mock.method(LoadUnitInstanceFactory, 'destroyLoadUnitInstance', async (instance: LoadUnitInstance) => { - destroyed.push(`instance:${String(instance.loadUnit.name)}`); - destroyedInstances.push(String(instance.loadUnit.name)); - if (instance.loadUnit.name === 'business') { - throw new Error('destroy instance failed'); - } - }); - mock.method(LoadUnitFactory, 'destroyLoadUnit', async (loadUnit: LoadUnit) => { - destroyed.push(`loadUnit:${String(loadUnit.name)}`); - destroyedLoadUnits.push(String(loadUnit.name)); - if (loadUnit === firstLoadUnit) { - throw new Error('destroy load unit failed'); - } + const requiredInner = class RequiredInner {}; + const optionalInner = class OptionalInner {}; + const innerLoadUnit = createLoadUnit('inner'); + const innerInstance = createInstance('inner'); + (handler as any).loadUnitLoader.moduleDescriptors = [ + { + name: 'required', + unitPath: '/required', + optional: false, + innerObjectClazzList: [requiredInner], + }, + { + name: 'optional', + unitPath: '/optional', + optional: true, + innerObjectClazzList: [optionalInner], + }, + ]; + const added: unknown[][] = []; + mock.method(InnerObjectLoadUnitBuilder.prototype, 'addInnerObjectClazzList', (...args: unknown[]) => { + added.push(args); }); + mock.method(InnerObjectLoadUnitBuilder.prototype, 'createLoadUnit', async () => innerLoadUnit); + mock.method(LoadUnitInstanceFactory, 'createLoadUnitInstance', async () => innerInstance); - await assert.rejects( - () => handler.destroy(), - (e: unknown) => { - assert(e instanceof AggregateError); - assert.equal(e.message, 'destroy tegg module handler failed'); - assert.equal(e.errors.length, 2); - return true; - }, - ); - assert.deepEqual(destroyedInstances, ['business', 'inner']); - assert.deepEqual(destroyedLoadUnits, ['second', 'first']); - assert.deepEqual(destroyed, ['instance:business', 'loadUnit:second', 'loadUnit:first', 'instance:inner']); + await (handler as any).instantiateInnerObjectLoadUnit(); + + assert.deepEqual(added, [[[requiredInner], { name: 'required', path: '/required' }]]); }); }); diff --git a/tegg/standalone/standalone/README.md b/tegg/standalone/standalone/README.md index 772df8ee89..0ddebaf3b5 100644 --- a/tegg/standalone/standalone/README.md +++ b/tegg/standalone/standalone/README.md @@ -44,6 +44,10 @@ export class Foo implements MainRunner { - cwd 为当前应用工作目录 - options: - innerObjectHandlers: 当前运行环境中内置的对象 + - logger: standalone 框架及模块注入使用的 logger;不要放入 innerObjectHandlers + +`moduleConfigs`、`moduleConfig` 和 `runtimeConfig` 由 standalone 框架维护; +`innerObjectHandlers` 中的同名项会被忽略。 ``` await main(cwd, { diff --git a/tegg/standalone/standalone/src/StandaloneApp.ts b/tegg/standalone/standalone/src/StandaloneApp.ts index 0a3f749260..fce8e23105 100644 --- a/tegg/standalone/standalone/src/StandaloneApp.ts +++ b/tegg/standalone/standalone/src/StandaloneApp.ts @@ -47,16 +47,17 @@ export interface StandaloneAppInit { frameworkDeps?: (string | ModuleDependency)[]; dump?: boolean; /** - * Host-provided inner objects. Entries here win over the framework default - * placeholders, except `moduleConfig` entries are extended with module config - * objects loaded during init(). + * Host-provided inner objects. Framework-owned `moduleConfigs`, `moduleConfig`, + * and `runtimeConfig` entries are ignored. `logger` is reserved; use the + * dedicated `logger` option instead. */ innerObjects?: Record; /** User-facing option name for diagnostics. Defaults to `innerObjects`. */ innerObjectsName?: string; /** - * Logger used by the framework (loader, hooks) and injectable as the - * `logger` inner object. Defaults to console. + * Logger used by the framework (loader, hooks) and exposed as the injectable + * `logger` provided object. This is the only supported logger input. Defaults + * to console. */ logger?: Logger; } @@ -82,6 +83,7 @@ export interface StandaloneAppOptions { env?: string; name?: string; logger?: Logger; + /** Host-provided objects other than `logger`; use the dedicated logger option. */ innerObjectHandlers?: Record; dependencies?: (string | ModuleDependency)[]; frameworkDeps?: (string | ModuleDependency)[]; @@ -90,6 +92,8 @@ export interface StandaloneAppOptions { loaderFS?: LoaderFS; } +type StandaloneAppState = 'new' | 'initializing' | 'ready' | 'closed'; + export class StandaloneApp { readonly #frameworkDeps: (string | ModuleDependency)[]; readonly #dump: boolean; @@ -100,12 +104,12 @@ export class StandaloneApp { // so the runtimeConfig inner object can hold it; init() fills the values. readonly #runtimeConfig = {} as RuntimeConfig; #moduleReferences: readonly ModuleReference[] = []; - #initialized = false; - #loadUnitLoader: EggModuleLoader; - #runnerProto: EggPrototype; - - loadUnits: LoadUnit[] = []; - loadUnitInstances: LoadUnitInstance[] = []; + #state: StandaloneAppState = 'new'; + #runnerProto?: EggPrototype; + #innerLoadUnit?: LoadUnit; + #innerLoadUnitInstance?: LoadUnitInstance; + readonly #loadUnits: LoadUnit[] = []; + readonly #loadUnitInstances: LoadUnitInstance[] = []; // This app's own per-app TeggScope bag — all factories/managers/graph/config // names resolve here, so multiple StandaloneApps in one process stay isolated. @@ -114,20 +118,13 @@ export class StandaloneApp { constructor(init?: StandaloneAppInit) { this.#frameworkDeps = init?.frameworkDeps ?? []; this.#dump = init?.dump !== false; + this.#logger = init?.logger ?? console; + const innerObjectsName = init?.innerObjectsName ?? 'innerObjects'; + if (init?.innerObjects && Object.hasOwn(init.innerObjects, 'logger')) { + throw new Error(`[tegg/standalone] ${innerObjectsName}.logger is reserved; use the logger option instead`); + } this.#innerObjects = this.#createInnerObjects(init); - this.#logger = this.#innerObjects.logger[0].obj as Logger; this.scopeBag = TeggScope.createBag(); - TeggScope.registerScope(this.scopeBag); - } - - /** Scanned during init(); empty before that. */ - get moduleReferences(): readonly ModuleReference[] { - return this.#moduleReferences; - } - - /** Loaded during init(); empty before that. */ - get moduleConfigs(): Record { - return this.#moduleConfigs; } /** Run `fn` within THIS app's per-app scope so factories/managers resolve here. */ @@ -136,31 +133,16 @@ export class StandaloneApp { } #createInnerObjects(init?: StandaloneAppInit): Record { - const frameworkInnerObjects = { - // Framework hooks (e.g. DAL) inject `logger`; an init.innerObjects - // entry wins over init.logger, console is the last resort. - logger: [{ obj: init?.logger ?? console }], + return { + ...init?.innerObjects, // Framework placeholders pre-created at construction and filled during // init() — the inner objects hold these same references unless the caller - // intentionally overrides them below. + // supplies objects with other names. + logger: [{ obj: this.#logger }], moduleConfigs: [{ obj: new ModuleConfigs(this.#moduleConfigs) }], moduleConfig: [] as InnerObject[], runtimeConfig: [{ obj: this.#runtimeConfig }], }; - const innerObjectsName = init?.innerObjectsName ?? 'innerObjects'; - const logger = init?.logger ?? console; - if (init?.innerObjects?.logger) { - logger.warn(`[tegg/standalone] ${innerObjectsName}.logger overrides the framework provided inner object`); - } - for (const name of ['moduleConfigs', 'runtimeConfig']) { - if (init?.innerObjects?.[name]) { - logger.warn(`[tegg/standalone] ${innerObjectsName}.${name} overrides the framework provided inner object`); - } - } - if (init?.innerObjects?.moduleConfig) { - logger.warn(`[tegg/standalone] ${innerObjectsName}.moduleConfig extends the framework provided inner objects`); - } - return Object.assign(frameworkInnerObjects, init?.innerObjects); } /** Fill the runtimeConfig placeholder and bind module config names. */ @@ -176,11 +158,12 @@ export class StandaloneApp { /** Load every module's config and expose it as a qualified `moduleConfig` inner object. */ #loadModuleConfigs(): void { + const resolvedReferences: ModuleReference[] = []; for (const reference of this.#moduleReferences) { const resolved = ModuleConfigUtil.resolveModuleConfigTolerant(reference, this.#runtimeConfig.baseDir); const resolvedRef = { path: resolved.path, - name: reference.name, + name: resolved.name, package: reference.package, optional: reference.optional, loaderType: reference.loaderType, @@ -190,7 +173,9 @@ export class StandaloneApp { reference: resolvedRef, config: resolved.config, }; + resolvedReferences.push(resolvedRef); } + this.#moduleReferences = resolvedReferences; for (const moduleConfig of Object.values(this.#moduleConfigs)) { this.#innerObjects.moduleConfig.push({ obj: moduleConfig.config, @@ -271,15 +256,16 @@ export class StandaloneApp { }); } - async #initLoaderInstance(opts: InitStandaloneAppOptions): Promise { - this.#loadUnitLoader = new EggModuleLoader(this.#moduleReferences, { + async #createLoadUnitLoader(opts: InitStandaloneAppOptions): Promise { + const loader = new EggModuleLoader(this.#moduleReferences, { logger: this.#logger, baseDir: opts.baseDir, dump: this.#dump, manifest: opts.manifest, loaderFS: opts.loaderFS, }); - await this.#loadUnitLoader.init(); + await loader.init(); + return loader; } /** @@ -287,30 +273,28 @@ export class StandaloneApp { * graph is built, so `@XxxLifecycleProto` hooks (including graph build hooks * they register in `@LifecyclePostInject`) are live for every later phase. */ - async #instantiateInnerObjectLoadUnit(): Promise { + async #instantiateInnerObjectLoadUnit(loader: EggModuleLoader): Promise { StandaloneContextHandler.register(); const builder = new InnerObjectLoadUnitBuilder(); - for (const moduleDescriptor of this.#loadUnitLoader.moduleDescriptors) { - builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList, { + for (const moduleDescriptor of loader.moduleDescriptors) { + builder.addInnerObjectClazzList(moduleDescriptor.innerObjectClazzList ?? [], { name: moduleDescriptor.name, path: moduleDescriptor.unitPath, }); } - const innerObjectLoadUnit = await builder.createLoadUnit({ + const loadUnit = await builder.createLoadUnit({ innerObjects: this.#innerObjects, }); - this.loadUnits.push(innerObjectLoadUnit); - const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(innerObjectLoadUnit); - this.loadUnitInstances.push(instance); + this.#innerLoadUnit = loadUnit; + this.#innerLoadUnitInstance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); } /** Phase 3/4: build + sort the business graph, then create and instantiate module load units. */ - async #instantiateModuleLoadUnits(): Promise { - const loadUnits = await this.#loadUnitLoader.load(); - this.loadUnits.push(...loadUnits); + async #instantiateModuleLoadUnits(loader: EggModuleLoader): Promise { + const loadUnits = await loader.load(); + this.#loadUnits.push(...loadUnits); for (const loadUnit of loadUnits) { - const instance = await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit); - this.loadUnitInstances.push(instance); + this.#loadUnitInstances.push(await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit)); } } @@ -329,28 +313,39 @@ export class StandaloneApp { } 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; + if (this.#state === 'ready') return; + if (this.#state !== 'new') { + throw new Error(`[tegg/standalone] cannot init app in ${this.#state} state`); + } + + this.#state = 'initializing'; + TeggScope.registerScope(this.scopeBag); + try { + await this.runInScope(async () => { + this.#initRuntime(opts); + // In manifest-consume mode the module reference graph was already + // captured at build time; reuse it instead of re-scanning the filesystem. + this.#moduleReferences = opts.manifest?.moduleReferences?.length + ? opts.manifest.moduleReferences + : StandaloneApp.getModuleReferences(opts.baseDir, opts.dependencies, this.#frameworkDeps); + this.#loadModuleConfigs(); + const loader = await this.#createLoadUnitLoader(opts); + await this.#instantiateInnerObjectLoadUnit(loader); + await this.#instantiateModuleLoadUnits(loader); + this.#initRunner(); + }); + this.#state = 'ready'; + } catch (error) { + TeggScope.unregisterScope(this.scopeBag); + this.#state = 'closed'; + throw error; } - await this.runInScope(async () => { - this.#initRuntime(opts); - // In manifest-consume mode the module reference graph was already - // captured at build time; reuse it instead of re-scanning the filesystem. - this.#moduleReferences = opts.manifest?.moduleReferences?.length - ? opts.manifest.moduleReferences - : StandaloneApp.getModuleReferences(opts.baseDir, opts.dependencies, this.#frameworkDeps); - this.#loadModuleConfigs(); - await this.#initLoaderInstance(opts); - await this.#instantiateInnerObjectLoadUnit(); - await this.#instantiateModuleLoadUnits(); - this.#initRunner(); - }); - this.#initialized = true; } async run(aCtx?: EggContext): Promise { + if (this.#state !== 'ready') { + throw new Error(`[tegg/standalone] cannot run app in ${this.#state} state`); + } return this.runInScope(async () => { const lifecycle = {}; const ctx = aCtx || new StandaloneContext(); @@ -358,6 +353,9 @@ export class StandaloneApp { if (ctx.init) { await ctx.init(lifecycle); } + if (!this.#runnerProto) { + throw new Error('[tegg/standalone] app is not initialized'); + } const eggObject = await EggContainerFactory.getOrCreateEggObject(this.#runnerProto); const runner = eggObject.obj as MainRunner; try { @@ -387,33 +385,41 @@ export class StandaloneApp { } async destroy(): Promise { + if (this.#state === 'closed') return; + if (this.#state === 'initializing') { + throw new Error('[tegg/standalone] cannot destroy app while it is initializing'); + } + + this.#state = 'closed'; try { - await this.runInScope(() => this.doDestroy()); + await this.runInScope(() => this.#disposeResources()); } finally { - // Always release the scope, even if doDestroy rejects, so liveScopeBags - // (and thus isMultiApp / the sole-app fallback) never leaks a dead app. TeggScope.unregisterScope(this.scopeBag); } } - private async doDestroy(): Promise { - // Reverse creation order: business load units go down first, the - // InnerObjectLoadUnit last — its lifecycle protos stay registered until - // every object they may hook has been destroyed. - if (this.loadUnitInstances) { - for (const instance of [...this.loadUnitInstances].reverse()) { + async #disposeResources(): Promise { + while (this.#loadUnitInstances.length > 0) { + const instance = this.#loadUnitInstances.pop(); + if (instance) { await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); } } - if (this.loadUnits) { - for (const loadUnit of [...this.loadUnits].reverse()) { + while (this.#loadUnits.length > 0) { + const loadUnit = this.#loadUnits.pop(); + if (loadUnit) { await LoadUnitFactory.destroyLoadUnit(loadUnit); } } - // Framework hooks (ConfigSource/AOP/DAL) live in the InnerObjectLoadUnit - // and deregister themselves — and clean up their own managers — when it - // is destroyed above (dal: DalModuleLoadUnitHook#destroy). - // Release this app's scoped config name override before unregistering the scope. + if (this.#innerLoadUnitInstance) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(this.#innerLoadUnitInstance); + this.#innerLoadUnitInstance = undefined; + } + if (this.#innerLoadUnit) { + await LoadUnitFactory.destroyLoadUnit(this.#innerLoadUnit); + this.#innerLoadUnit = undefined; + } ModuleConfigUtil.setConfigNames(undefined); + this.#runnerProto = undefined; } } diff --git a/tegg/standalone/standalone/src/main.ts b/tegg/standalone/standalone/src/main.ts index b045ea50ca..e5569eb2d5 100644 --- a/tegg/standalone/standalone/src/main.ts +++ b/tegg/standalone/standalone/src/main.ts @@ -34,11 +34,6 @@ export async function appMain( 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 app's TeggScope so it does not leak into liveScopeBags. - await app.destroy().catch(() => { - /* swallow: surface the original boot error */ - }); throw e; } try { @@ -56,7 +51,7 @@ export async function appMain( } export async function main(cwd: string, options?: StandaloneAppOptions): Promise { - if (options && 'innerObjects' in options) { + if ((options as { innerObjects?: unknown } | undefined)?.innerObjects !== undefined) { throw new Error('[tegg/standalone] options.innerObjects has been removed, use options.innerObjectHandlers instead'); } return await appMain( diff --git a/tegg/standalone/standalone/test/ModulePlugin.test.ts b/tegg/standalone/standalone/test/ModulePlugin.test.ts index fef9bf68c2..76cb27df30 100644 --- a/tegg/standalone/standalone/test/ModulePlugin.test.ts +++ b/tegg/standalone/standalone/test/ModulePlugin.test.ts @@ -5,9 +5,10 @@ import { EggPrototypeNotFound } from '@eggjs/metadata'; import { describe, it } from 'vitest'; import { main } from '../src/index.ts'; +import { FooLoadUnitHook } from './fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts'; describe('standalone/standalone/test/ModulePlugin.test.ts', () => { - const getFixture = (name: string) => path.join(__dirname, 'fixtures', name); + const getFixture = (name: string) => path.join(import.meta.dirname, 'fixtures', name); describe('EggLifecycleProto', () => { it('should LoadUnitLifecycleProto work', async () => { @@ -16,6 +17,8 @@ describe('standalone/standalone/test/ModulePlugin.test.ts', () => { // (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'); + assert.deepEqual(FooLoadUnitHook.events, ['business-load-unit-destroy', 'inner-hook-destroy']); + FooLoadUnitHook.events.length = 0; }); it('should LoadUnitInstanceLifecycleProto work', async () => { diff --git a/tegg/standalone/standalone/test/fixtures/init-failure/CleanupProbe.ts b/tegg/standalone/standalone/test/fixtures/init-failure/CleanupProbe.ts new file mode 100644 index 0000000000..de23888e32 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/init-failure/CleanupProbe.ts @@ -0,0 +1,17 @@ +import { InnerObjectProto, LifecycleDestroy, LifecycleInit } from '@eggjs/tegg'; + +@InnerObjectProto() +export class CleanupProbe { + static initialized: CleanupProbe[] = []; + static destroyed: CleanupProbe[] = []; + + @LifecycleInit() + init(): void { + CleanupProbe.initialized.push(this); + } + + @LifecycleDestroy() + destroy(): void { + CleanupProbe.destroyed.push(this); + } +} diff --git a/tegg/standalone/standalone/test/fixtures/init-failure/InitFailureInner.ts b/tegg/standalone/standalone/test/fixtures/init-failure/InitFailureInner.ts new file mode 100644 index 0000000000..2cd86e8687 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/init-failure/InitFailureInner.ts @@ -0,0 +1,23 @@ +import { Inject, InnerObjectProto, LifecycleDestroy, LifecycleInit } from '@eggjs/tegg'; + +import { CleanupProbe } from './CleanupProbe.ts'; + +@InnerObjectProto() +export class InitFailureInner { + static attempts = 0; + static destroyed = 0; + + @Inject() + cleanupProbe: CleanupProbe; + + @LifecycleInit() + init(): void { + InitFailureInner.attempts++; + throw new Error('expected init failure'); + } + + @LifecycleDestroy() + destroy(): void { + InitFailureInner.destroyed++; + } +} diff --git a/tegg/standalone/standalone/test/fixtures/init-failure/package.json b/tegg/standalone/standalone/test/fixtures/init-failure/package.json new file mode 100644 index 0000000000..59ed14e0e6 --- /dev/null +++ b/tegg/standalone/standalone/test/fixtures/init-failure/package.json @@ -0,0 +1,7 @@ +{ + "name": "init-failure", + "type": "module", + "eggModule": { + "name": "initFailure" + } +} 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 index 36514ece75..a9b7a45400 100644 --- a/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts +++ b/tegg/standalone/standalone/test/fixtures/load-unit-lifecycle-proto/FooLoadUnitHook.ts @@ -1,11 +1,13 @@ import { EggPrototypeCreatorFactory, EggPrototypeFactory } from '@eggjs/metadata'; -import { Inject, LoadUnitLifecycleProto, SingletonProto } from '@eggjs/tegg'; +import { Inject, LifecycleDestroy, 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 { + static events: string[] = []; + @Inject() foo: Foo; @@ -25,4 +27,15 @@ export class FooLoadUnitHook implements LifecycleHook { + if (loadUnit.name === 'loadUnitLifecycleApp') { + FooLoadUnitHook.events.push('business-load-unit-destroy'); + } + } + + @LifecycleDestroy() + destroy(): void { + FooLoadUnitHook.events.push('inner-hook-destroy'); + } } diff --git a/tegg/standalone/standalone/test/index.test.ts b/tegg/standalone/standalone/test/index.test.ts index 0537b23fde..ca7a1925e7 100644 --- a/tegg/standalone/standalone/test/index.test.ts +++ b/tegg/standalone/standalone/test/index.test.ts @@ -15,6 +15,8 @@ import { describe, it, afterEach, beforeEach } from 'vitest'; import { main, StandaloneContext, StandaloneApp, preLoad, appMain } from '../src/index.ts'; import { crosscutAdviceParams, pointcutAdviceParams } from './fixtures/aop-module/Hello.ts'; import { Foo } from './fixtures/dal-module/src/Foo.ts'; +import { CleanupProbe } from './fixtures/init-failure/CleanupProbe.ts'; +import { InitFailureInner } from './fixtures/init-failure/InitFailureInner.ts'; const __dirname = import.meta.dirname; @@ -100,13 +102,74 @@ describe('standalone/standalone/test/index.test.ts', () => { }, }); try { - assert.deepEqual(app.moduleReferences, moduleReferences); + assert.equal(await app.run(), 'hello!hello from ctx'); + } finally { + await app.destroy(); + } + }); + + it('should store the resolved module name when a manifest reference name drifts', async () => { + const fixture = path.join(__dirname, './fixtures/simple'); + const moduleReferences = [...StandaloneApp.getModuleReferences(fixture)]; + const appReference = moduleReferences.find((reference) => reference.path === fixture); + assert(appReference); + appReference.name = 'staleManifestName'; + + const app = new StandaloneApp({ dump: false }); + await app.init({ + baseDir: fixture, + manifest: { moduleReferences, moduleDescriptors: [] }, + }); + try { + const moduleConfigs = await TeggScope.run(app.scopeBag, async () => { + const eggObject = await EggContainerFactory.getOrCreateEggObjectFromName('moduleConfigs'); + return eggObject.obj as ModuleConfigs; + }); + assert(moduleConfigs.inner.simple); + assert.equal(moduleConfigs.inner.simple.reference.name, 'simple'); + assert.equal(await app.run(), 'hello!hello from ctx'); } finally { await app.destroy(); } }); }); + describe('app lifecycle state', () => { + const fixture = path.join(__dirname, './fixtures/simple'); + + it('should ignore repeated init and destroy after each operation completes', async () => { + const app = new StandaloneApp({ dump: false }); + await app.init({ baseDir: fixture }); + await app.init({ baseDir: fixture }); + await app.destroy(); + await app.destroy(); + await assert.rejects(() => app.run(), /cannot run app in closed state/); + }); + + it('should close the app after init fails', async () => { + const failureFixture = path.join(__dirname, './fixtures/init-failure'); + InitFailureInner.attempts = 0; + InitFailureInner.destroyed = 0; + CleanupProbe.initialized.length = 0; + CleanupProbe.destroyed.length = 0; + const app = new StandaloneApp({ dump: false, frameworkDeps: [failureFixture] }); + await assert.rejects(() => app.init({ baseDir: fixture }), /expected init failure/); + assert.equal(CleanupProbe.initialized.length, 1); + assert.equal(CleanupProbe.destroyed.length, 0); + assert.equal(InitFailureInner.attempts, 1); + assert.equal(InitFailureInner.destroyed, 0); + await assert.rejects(() => app.init({ baseDir: fixture }), /cannot init app in closed state/); + await app.destroy(); + assert.equal(CleanupProbe.destroyed.length, 0); + }); + + it('should reject init after destroy', async () => { + const app = new StandaloneApp({ dump: false }); + await app.destroy(); + await assert.rejects(() => app.init({ baseDir: fixture }), /cannot init app in closed state/); + }); + }); + describe('simple runner', () => { const fixture = path.join(__dirname, './fixtures/simple'); @@ -168,6 +231,16 @@ describe('standalone/standalone/test/index.test.ts', () => { /options\.innerObjects has been removed, use options\.innerObjectHandlers instead/, ); }); + + it('should ignore a removed innerObjects option whose value is undefined', async () => { + const result = await main(path.join(__dirname, './fixtures/inner-object'), { + innerObjects: undefined, + innerObjectHandlers: { + hello: [{ obj: { hello: () => 'hello from handler' } }], + }, + } as any); + assert.equal(result, 'hello from handler'); + }); }); describe('custom logger option', () => { @@ -179,46 +252,27 @@ describe('standalone/standalone/test/index.test.ts', () => { assert.equal(injected, customLogger); }); - it('should let an innerObjectHandlers logger entry win over options.logger', async () => { - const warnings: unknown[][] = []; - const optionLogger = { - ...console, - warn: (...args: unknown[]) => { - warnings.push(args); - }, - }; + it('should reject logger from innerObjectHandlers', async () => { const handlerLogger = { ...console }; - const injected = await main(path.join(__dirname, './fixtures/logger-option'), { - logger: optionLogger, - innerObjectHandlers: { - logger: [{ obj: handlerLogger }], - }, - }); - assert.equal(injected, handlerLogger); - assert.deepEqual(warnings, [ - ['[tegg/standalone] innerObjectHandlers.logger overrides the framework provided inner object'], - ]); + await assert.rejects( + main(path.join(__dirname, './fixtures/logger-option'), { + innerObjectHandlers: { + logger: [{ obj: handlerLogger }], + }, + }), + /innerObjectHandlers\.logger is reserved; use the logger option instead/, + ); }); - it('should warn when innerObjects logger overrides the framework logger', async () => { - const warnings: unknown[][] = []; - const logger = { - ...console, - warn: (...args: unknown[]) => { - warnings.push(args); - }, - }; + it('should reject logger from low-level innerObjects', () => { const handlerLogger = { ...console }; - const app = new StandaloneApp({ - logger, - innerObjects: { - logger: [{ obj: handlerLogger }], - }, - }); - await app.destroy(); - assert.deepEqual(warnings, [ - ['[tegg/standalone] innerObjects.logger overrides the framework provided inner object'], - ]); + assert.throws( + () => + new StandaloneApp({ + innerObjects: { logger: [{ obj: handlerLogger }] }, + }), + /innerObjects\.logger is reserved; use the logger option instead/, + ); }); }); @@ -286,6 +340,31 @@ describe('standalone/standalone/test/index.test.ts', () => { assert.deepEqual(configs.get('foo'), foo); assert.deepEqual(configs.get('bar'), bar); }); + + it('should silently ignore framework-owned config handlers', async () => { + const warnings: unknown[][] = []; + const logger = { + ...console, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const moduleConfigs = new ModuleConfigs({}); + const moduleConfig = { custom: true }; + + const injected = (await main(path.join(__dirname, './fixtures/multi-modules'), { + logger, + innerObjectHandlers: { + moduleConfigs: [{ obj: moduleConfigs }], + moduleConfig: [{ obj: moduleConfig }], + }, + })) as { configs: ModuleConfigs; foo: ModuleConfig }; + + assert.notEqual(injected.configs, moduleConfigs); + assert.notEqual(injected.foo, moduleConfig); + assert.deepEqual(injected.configs.get('foo'), injected.foo); + assert.deepEqual(warnings, []); + }); }); describe('runner with runtimeConfig', () => { @@ -310,7 +389,7 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); - it('should let innerObjectHandlers runtimeConfig override framework placeholder with warning', async () => { + it('should silently ignore the framework-owned runtimeConfig handler', async () => { const warnings: unknown[][] = []; const logger = { ...console, @@ -324,39 +403,18 @@ describe('standalone/standalone/test/index.test.ts', () => { name: 'custom-name', }; - const injected = await main(path.join(__dirname, './fixtures/runtime-config'), { + const injectedRuntimeConfig = await main(path.join(__dirname, './fixtures/runtime-config'), { logger, innerObjectHandlers: { runtimeConfig: [{ obj: runtimeConfig }], }, }); - - assert.equal(injected, runtimeConfig); - assert.deepEqual(warnings, [ - ['[tegg/standalone] innerObjectHandlers.runtimeConfig overrides the framework provided inner object'], - ]); - }); - - it('should use low-level innerObjects name in framework object warnings', async () => { - const warnings: unknown[][] = []; - const logger = { - ...console, - warn: (...args: unknown[]) => { - warnings.push(args); - }, - }; - const app = new StandaloneApp({ - logger, - innerObjects: { - runtimeConfig: [{ obj: {} }], - moduleConfig: [{ obj: {} }], - }, + assert.deepEqual(injectedRuntimeConfig, { + baseDir: path.join(__dirname, './fixtures/runtime-config'), + env: '', + name: '', }); - await app.destroy(); - assert.deepEqual(warnings, [ - ['[tegg/standalone] innerObjects.runtimeConfig overrides the framework provided inner object'], - ['[tegg/standalone] innerObjects.moduleConfig extends the framework provided inner objects'], - ]); + assert.deepEqual(warnings, []); }); }); @@ -435,43 +493,6 @@ describe('standalone/standalone/test/index.test.ts', () => { }); }); - describe('load', () => { - let runner: StandaloneApp; - afterEach(async () => { - if (runner) await runner.destroy(); - }); - - it('should work', async () => { - runner = new StandaloneApp(); - await runner.init({ baseDir: path.join(__dirname, './fixtures/simple') }); - const loadunits = runner.loadUnits; - for (const loadunit of loadunits) { - for (const proto of loadunit.iterateEggPrototype()) { - if (proto.id.match(/:hello$/)) { - assert.equal(proto.className, 'Hello'); - } else if (proto.id.match(/:moduleConfigs$/)) { - assert.equal(proto.className, undefined); - } else if (proto.id.match(/:moduleConfig$/)) { - assert.equal(proto.className, undefined); - } - } - } - }); - - it('should work with multi', async () => { - runner = new StandaloneApp(); - await runner.init({ baseDir: path.join(__dirname, './fixtures/multi-callback-instance-module') }); - const loadunits = runner.loadUnits; - for (const loadunit of loadunits) { - for (const proto of loadunit.iterateEggPrototype()) { - if (proto.id.match(/:dynamicLogger$/)) { - assert.equal(proto.className, 'DynamicLogger'); - } - } - } - }); - }); - describe('inject mysqlDataSourceManager inner object', () => { it('should stay injectable for business modules', async () => { const ok = await main(path.join(__dirname, './fixtures/dal-manager-inject')); diff --git a/wiki/concepts/tegg-module-plugin.md b/wiki/concepts/tegg-module-plugin.md index 90e0043f23..e3fd8bb408 100644 --- a/wiki/concepts/tegg-module-plugin.md +++ b/wiki/concepts/tegg-module-plugin.md @@ -9,8 +9,15 @@ source_files: - tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts - tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts - tegg/core/runtime/src/impl/EggInnerObjectImpl.ts + - tegg/core/runtime/src/impl/EggObjectImpl.ts + - tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts + - tegg/core/runtime/src/factory/EggObjectFactory.ts + - tegg/core/runtime/src/impl/ModuleLoadUnitInstance.ts + - tegg/core/lifecycle/src/LifycycleUtil.ts + - tegg/core/metadata/src/factory/LoadUnitFactory.ts - tegg/standalone/standalone/src/StandaloneApp.ts - tegg/plugin/tegg/src/lib/ModuleHandler.ts + - tegg/plugin/tegg/src/lib/AppLoadUnitInstance.ts - tegg/plugin/tegg/src/lib/EggModuleLoader.ts - tegg/plugin/aop/src/app.ts - tegg/plugin/aop/src/lib/AopContextHook.ts @@ -19,7 +26,7 @@ source_files: - tegg/plugin/config/src/app.ts - tegg/plugin/dal/src/index.ts - tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts -updated_at: 2026-07-09 +updated_at: 2026-07-13 status: active --- @@ -40,8 +47,9 @@ no host boot code: ## 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: +`registerBuildHook` hooks once at its end. The graph accepts hooks only while +its state is `created`; registration after `build()` starts and repeated +`build()` calls fail explicitly. Both hosts therefore boot in this order: 1. scan modules → `GlobalGraph.create` (nodes only) 2. create AND instantiate the `InnerObjectLoadUnit` (own topologically @@ -72,10 +80,80 @@ Hosts: `StandaloneApp.init()` (standalone) and `ModuleHandler.init()` via ids are hard errors. Package/path dedupe belongs to module reference discovery before descriptors are loaded. - Host-provided instances (`innerObjects` / `innerObjectHandlers`) become - `ProvidedInnerObjectProto`s. Standalone keeps them PUBLIC (business - modules inject `moduleConfigs` etc.); the egg host passes PRIVATE for its - base objects (`moduleConfigs`/`runtimeConfig`/`logger`) so they never - pollute cross-unit resolution. + `ProvidedInnerObjectProto`s. Hosts pass one complete object map to the + builder; the builder does not special-case names such as `logger`. Standalone + keeps provided objects PUBLIC (business modules may inject `logger`, + `moduleConfigs`, etc.); the egg host passes PRIVATE for its base objects so + they never pollute cross-unit resolution. + +## Access and qualifier boundary + +All module plugins contribute their inner objects to one shared +`InnerObjectLoadUnit`. `AccessLevel.PRIVATE` is the boundary between that +inner unit and business load units; it is not a plugin-isolation boundary. +Inner objects from AOP, DAL, config, or application module plugins may inject +one another intentionally. + +Each decorated inner object receives a `DefineModuleQualifier` for the module +that defined it. When different modules define the same object name, callers +must select one with `@DefineModuleQualifier(...)`; an unqualified ambiguous +lookup fails with `MultiPrototypeFound`. Host-provided objects use `app` as +their default define-module qualifier. Keeping one inner unit is deliberate: +it permits framework plugins to collaborate while still hiding PRIVATE +objects from business modules. + +## Standalone compatibility notes + +- The deprecated `Runner` app class was replaced by `StandaloneApp` without + an alias. +- `main(options.innerObjects)` is removed. A defined value fails with a clear + migration error; `innerObjects: undefined` is ignored. Use + `innerObjectHandlers` for the flat `main()` API or `StandaloneAppInit.innerObjects` + for the low-level API. +- `logger` has one dedicated input: `StandaloneAppOptions.logger` or + `StandaloneAppInit.logger`. Supplying `logger` through `innerObjectHandlers` + or low-level `innerObjects` is rejected. `StandaloneApp` adds that validated + value to its internal provided-object map, so it remains injectable by both + framework hooks and business modules without a logger-specific runtime API. +- Standalone owns `moduleConfigs`, `moduleConfig`, and `runtimeConfig`. + Host-provided entries with those names are silently ignored so the framework + objects always win. +- `StandaloneApp` does not expose module references, configs, load units, or + load-unit instances as mutable runtime state. Low-level callers interact + through `init()` / `run()` / `destroy()` and may use the owning `scopeBag` + when scoped object resolution is required. +- `runtimeConfig.name` and `runtimeConfig.env` normalize omitted values to + empty strings because the `RuntimeConfig` contract requires strings. +- Standalone discovery includes the AOP, DAL, and config framework modules by + design. Manifest consumption reuses the captured references and avoids a + second scan; there is no feature gate for these core module plugins. +- DAL managers are inner objects. `app.mysqlDataSourceManager` and the DAL + `./app` export are removed; inject `MysqlDataSourceManager` or resolve it + through `getEggObjectFromName()` within the owning app scope. + +## Startup failure semantics + +- A `StandaloneApp` is single-use. Its linear lifecycle is `new` -> + `initializing` -> `ready` -> `closed`; failed initialization unregisters the + app scope and ends at `closed`. Retry means constructing a new app, not + reusing partially initialized state. Concurrent init/destroy is not a + supported lifecycle: calls made while initialization is active are rejected + instead of being coordinated through shared promises. +- Creation is fail-fast. `LoadUnit`, `LoadUnitInstance`, standard `EggObject`, + and inner `EggObject` initialization errors propagate directly; factories do + not run compensating destroy lifecycles for partial initialization. +- `LoadUnitInstanceFactory` publishes an instance before `init()` because + singleton construction and dependency injection must resolve the owning + instance during initialization. There is no single-flight or recursive-create + guard beyond the established factory map behavior. +- Destruction is also fail-fast. Lifecycle phases run in their defined order and + the first failure stops later phases; errors are not aggregated across phases. +- Inner objects still destroy in reverse actual-creation order, and their + lifecycle registrations are removed before their object destroy hooks run. +- `StandaloneApp.destroy()` is a linear, fail-fast teardown. It destroys + business load-unit instances and load units in reverse order, then destroys + the inner-object instance and load unit last. Scope registration is released + in `finally`, without coordinating concurrent init/destroy calls. ## Semantics worth remembering diff --git a/wiki/log.md b/wiki/log.md index e0df9a006e..144d026aa9 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -132,3 +132,51 @@ Full **isolate:false suite validated GREEN** under CI-faithful parallelism (`--m - sources touched: `tegg/plugin/aop/src/lib/AopContextHook.ts`, `tegg/core/aop-runtime/src/AopContextAdviceRegistry.ts`, `tegg/core/aop-runtime/src/LoadUnitAopHook.ts`, `tegg/plugin/dal/src/index.ts`, `tegg/plugin/dal/src/lib/DalModuleLoadUnitHook.ts` - pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` - note: Replaced stale DAL source paths and updated the AOP note after `AopContextHook` moved to lifecycle-proto/inner-object registration backed by `AopContextAdviceRegistry`. + +## [2026-07-12] architecture | harden module plugin discovery and lifecycle contracts + +- sources touched: `tegg/plugin/config/src/app.ts`, `tegg/plugin/config/src/lib/ModuleScanner.ts`, `tegg/core/runtime/src/impl/InnerObjectLoadUnitInstance.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/core/metadata/src/model/graph/GlobalGraph.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Recorded plugin-aware module discovery, strict per-root versus nearest-framework reference dedupe, explicit GlobalGraph build state, reverse actual-creation teardown for inner objects, the shared inner-unit PRIVATE boundary, qualifier rules, and standalone migration details. + +## [2026-07-13] architecture | make tegg startup failure cleanup atomic + +- sources touched: `tegg/core/metadata/src/factory/LoadUnitFactory.ts`, `tegg/core/lifecycle/src/LifycycleUtil.ts`, `tegg/core/runtime/src/factory/{LoadUnitInstanceFactory,EggObjectFactory}.ts`, `tegg/core/runtime/src/impl/{EggObjectImpl,EggInnerObjectImpl,ModuleLoadUnitInstance}.ts`, `tegg/plugin/tegg/src/lib/AppLoadUnitInstance.ts`, `tegg/standalone/standalone/src/{EggModuleLoader,StandaloneApp}.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Made load-unit and instance creation single-flight and atomic on failure, restricted provisional instance lookup to the active DI chain, added failed-init rollback for standard business/inner EggObjects, made object/lifecycle teardown await and aggregate every cleanup, removed host-side partial-instance recovery, made standalone business-unit loading transactional, and defined StandaloneApp as single-use with terminal cleanup and re-entrant init-chain destroy rejection. + +## [2026-07-13] decision | keep module-plugin lifecycle failures fail-fast + +- sources touched: `tegg/core/metadata/src/factory/LoadUnitFactory.ts`, `tegg/core/runtime/src/factory/LoadUnitInstanceFactory.ts`, `tegg/core/runtime/src/impl/{EggObjectImpl,EggInnerObjectImpl,InnerObjectLoadUnitInstance}.ts`, `tegg/core/runtime/src/model/AbstractEggContext.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/{EggModuleLoader,StandaloneApp}.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Reverted the broad single-flight, failed-init rollback, and multi-phase error aggregation hardening because it was not required by module-plugin startup. Kept the core inner-object behavior: decorator-only self lifecycle dispatch, lifecycle registration, and reverse actual-creation teardown. + +## [2026-07-13] decision | keep StandaloneApp lifecycle linear + +- sources touched: `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/standalone/standalone/test/index.test.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Removed shared init/destroy promises, AsyncLocalStorage re-entry detection, concurrent lifecycle coordination, and partial-resource wrappers from StandaloneApp. Kept a linear single-use lifecycle, fail-fast teardown, scope release, and the required business-before-inner destroy order. + +## [2026-07-13] architecture | give host logger a dedicated inner-unit input + +- sources touched: `tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/standalone/standalone/README.md` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Separated the host logger from generic `innerObjects` input. Standalone accepts logger only through its dedicated logger option and rejects `innerObjectHandlers.logger`; the builder still represents that value as an injectable provided proto internally. + +## [2026-07-13] api | keep StandaloneApp runtime state private + +- sources touched: `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/standalone/standalone/test/index.test.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Removed test-only getters for module references, module configs, load units, and load-unit instances. Tests now verify manifest, config, and teardown behavior through the public lifecycle; `scopeBag` remains available for owning-scope object resolution. + +## [2026-07-13] behavior | reserve StandaloneApp framework inner objects + +- sources touched: `tegg/standalone/standalone/src/StandaloneApp.ts`, `tegg/standalone/standalone/test/index.test.ts`, `tegg/standalone/standalone/README.md` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Framework-owned `moduleConfigs`, `moduleConfig`, and `runtimeConfig` now take precedence over host input. Same-name host entries are silently ignored. + +## [2026-07-13] refactor | keep logger specialization at the standalone API boundary + +- sources touched: `tegg/core/runtime/src/impl/InnerObjectLoadUnitBuilder.ts`, `tegg/plugin/tegg/src/lib/ModuleHandler.ts`, `tegg/standalone/standalone/src/StandaloneApp.ts` +- pages updated: `wiki/log.md`, `wiki/concepts/tegg-module-plugin.md` +- note: Kept logger as a dedicated Standalone public option, but removed the logger-specific builder channel. Each host now adds its logger to the complete provided-inner-object map before invoking the host-agnostic builder.