Skip to content

[WIP] feat(tegg): declarative module plugin mechanism#6021

Draft
gxkl wants to merge 6 commits into
nextfrom
feat/module-plugin-core
Draft

[WIP] feat(tegg): declarative module plugin mechanism#6021
gxkl wants to merge 6 commits into
nextfrom
feat/module-plugin-core

Conversation

@gxkl

@gxkl gxkl commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Motivation

Tegg's framework-level extensions (AOP, DAL, controller registration, config source, ...) all rely on the HOST hand-registering lifecycle hooks in its boot code. A module cannot declare "run this when a load unit is created" by itself, so:

  • the same registration logic is duplicated between the egg plugin boot and the standalone Runner;
  • every new runtime shape (e.g. a service worker runtime) has to copy the whole assembly again;
  • modules are not self-contained.

This PR ports the declarative module plugin design from eggjs/tegg#325 (merged to the stale standalone-next branch, never reached master) to the next architecture, and completes the two parts #325 left open: egg-host wiring (inner object protos used to be silently ignored in app mode) and bootstrapping the built-in hooks themselves (the #handleCompatibility TODO).

Design

A plain eggModule package can now provide framework extensions declaratively:

  • @InnerObjectProto — framework inner object (a SingletonProto with EGG_INNER_OBJECT_PROTO_IMPL_TYPE), diverted by the loader into ModuleDescriptor.innerObjectClazzList, never into business load units.
  • @EggLifecycleProto five typed variants (LoadUnit / LoadUnitInstance / EggPrototype / EggObject / EggContext) — a DI-capable hook object, auto-registered into the matching scope-aware LifecycleUtil and deregistered symmetrically.

Two-phase ordering (the load-bearing constraint). GlobalGraph.create() only adds nodes; build() adds inject edges and consumes registerBuildHook hooks once at its end (late registration is silently lost). Both hosts now boot as:

  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) — hooks register here, including graph build hooks from @LifecyclePostInject (see AopGraphHookRegistrar)
  3. build() / sort() → business load units → business instances
  4. destroy in reverse creation order (inner unit last)

Commits

commit scope
feat(core): add module plugin core mechanism decorators + metadata (EggInnerObjectPrototypeImpl, InjectObjectPrototypeFinder / ProtoGraphUtils extracted with a proto-name index replacing the O(n·m·n) scan) + runtime (EggInnerObjectImpl runs only decorator-declared self lifecycle; host-agnostic InnerObjectLoadUnit(Builder/Instance)) + loader diversion (manifest getDecoratedFiles covers the new list so bundle mode still re-imports those files)
feat(standalone): two-phase StandaloneApp with module plugin support BREAKING: Runner renamed to StandaloneApp (no alias; main()/preLoad() unchanged). Explicit boot phases, frameworkDeps option, bundle-mode manifest/loaderFS consumption + StandaloneApp.loadMetadata(), tegg#325 test suite ported
feat(tegg-plugin): instantiate InnerObjectLoadUnit in app mode ModuleHandler.init() phases via EggModuleLoader.initGraph()/load() split; the same module plugin package behaves identically under both hosts
feat(tegg): convert built-in framework hooks to module plugins AOP (incl. AopGraphHookRegistrar for the crossCut/pointCut build hooks), DAL (constructor args → @Inject of host-provided moduleConfigs/runtimeConfig/logger inner objects, PRIVATE on the egg host), ConfigSource; vestigial LoadUnitMultiInstanceProtoHook registration dropped (empty preCreate, unconsumed static set); builder dedupes by class since a package may be both hard-fed and scanned as an eggModule (e.g. @eggjs/dal-plugin)
docs(wiki): record tegg module plugin architecture concept page + log

Test evidence

  • New coverage: decorator metadata (7), loader diversion (3), InnerObjectLoadUnit integration incl. decorator-only-lifecycle semantics / auto register+deregister / cycle detection / missing-dep hard error (4), standalone module plugin suite ported from tegg#325 — five lifecycle proto types + inner object PUBLIC/PRIVATE semantics (7), app-mode module plugin fixture (2).
  • Regression: 15 tegg projects (core + plugins + standalone) — 76 files / 296 tests green, MultiApp isolation green; full-repo run has zero failures related to this change (remaining ones verified as pre-existing/env: dns-cache needs a fresh ut install, multipart fails on clean next too, onerror/development are flaky-on-rerun).

Notes

  • Independent of fix(controller): register middlewareGraphHook before global graph build #6020 (no overlapping files; git merge-tree clean; combined-tree run of both test suites green).
  • TeggScope rules kept: no new process-global mutable state; lifecycle registration happens inside the host scope via the scope-aware utils; type-keyed creator registries stay global per the multi-app guidelines.

🤖 Generated with Claude Code

gxkl and others added 5 commits July 4, 2026 16:40
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba24e9f2-6a35-4e40-90a5-1853114ae503

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/module-plugin-core

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploying egg with  Cloudflare Pages  Cloudflare Pages

Latest commit: f4267df
Status: ✅  Deploy successful!
Preview URL: https://5f0686fb.egg-cci.pages.dev
Branch Preview URL: https://feat-module-plugin-core.egg-cci.pages.dev

View logs

@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.88%. Comparing base (767ac19) to head (f4267df).
⚠️ Report is 1 commits behind head on next.

❗ There is a different number of reports uploaded between BASE (767ac19) and HEAD (f4267df). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (767ac19) HEAD (f4267df)
3 2
Additional details and impacted files
@@             Coverage Diff             @@
##             next    #6021       +/-   ##
===========================================
- Coverage   81.95%   70.88%   -11.08%     
===========================================
  Files         678       18      -660     
  Lines       20800      735    -20065     
  Branches     4147      181     -3966     
===========================================
- Hits        17047      521    -16526     
+ Misses       3235      181     -3054     
+ Partials      518       33      -485     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@socket-security

socket-security Bot commented Jul 4, 2026

Copy link
Copy Markdown

Dependency limit exceeded — report not shown.

This pull request scan exceeded the 10,000-dependency limit applied to this scan, so the results are incomplete and may be inaccurate. To avoid reporting false positives, Socket has not posted a report.

Upgrade your plan to raise the dependency limit and get complete reports, or view the partial scan in the dashboard.

Socket is always free for open source. If this is a non-commercial open source project, contact us to request a free Team account.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploying egg-v3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: f4267df
Status: ✅  Deploy successful!
Preview URL: https://2fdfe456.egg-v3.pages.dev
Branch Preview URL: https://feat-module-plugin-core.egg-v3.pages.dev

View logs

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a declarative module plugin mechanism using @InnerObjectProto and @EggLifecycleProto decorators, enabling framework extensions to be loaded into an InnerObjectLoadUnit before the business graph is built for both standalone and egg hosts. It also converts AOP, DAL, and ConfigSource hooks into module plugins and renames Runner to StandaloneApp. The review feedback suggests maintaining consistency by using .ts extensions instead of .js in import/export paths within aop-runtime, and defensively checking if caught exceptions are Error instances before accessing their message property in StandaloneApp.ts and main.ts to avoid runtime type errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +5 to +6
import { crossCutGraphHook } from './CrossCutGraphHook.js';
import { pointCutGraphHook } from './PointCutGraphHook.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository's general rules, TypeScript source file imports should use the .ts extension instead of .js to maintain consistency across the codebase.

Suggested change
import { crossCutGraphHook } from './CrossCutGraphHook.js';
import { pointCutGraphHook } from './PointCutGraphHook.js';
import { crossCutGraphHook } from './CrossCutGraphHook.ts';
import { pointCutGraphHook } from './PointCutGraphHook.ts';
References
  1. In this repository, use '.ts' extensions in import/export paths for TypeScript source files to maintain consistency with the existing convention across source and test files.

Comment on lines +4 to +7
import { AopGraphHookRegistrar } from './AopGraphHookRegistrar.js';
import { EggObjectAopHook } from './EggObjectAopHook.js';
import { EggPrototypeCrossCutHook } from './EggPrototypeCrossCutHook.js';
import { LoadUnitAopHook } from './LoadUnitAopHook.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository's general rules, TypeScript source file imports should use the .ts extension instead of .js to maintain consistency across the codebase.

Suggested change
import { AopGraphHookRegistrar } from './AopGraphHookRegistrar.js';
import { EggObjectAopHook } from './EggObjectAopHook.js';
import { EggPrototypeCrossCutHook } from './EggPrototypeCrossCutHook.js';
import { LoadUnitAopHook } from './LoadUnitAopHook.js';
import { AopGraphHookRegistrar } from './AopGraphHookRegistrar.ts';
import { EggObjectAopHook } from './EggObjectAopHook.ts';
import { EggPrototypeCrossCutHook } from './EggPrototypeCrossCutHook.ts';
import { LoadUnitAopHook } from './LoadUnitAopHook.ts';
References
  1. In this repository, use '.ts' extensions in import/export paths for TypeScript source files to maintain consistency with the existing convention across source and test files.

Comment on lines +7 to +8
export * from './AopGraphHookRegistrar.js';
export * from './AopInnerObjectClazzList.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository's general rules, TypeScript source file exports should use the .ts extension instead of .js to maintain consistency across the codebase.

Suggested change
export * from './AopGraphHookRegistrar.js';
export * from './AopInnerObjectClazzList.js';
export * from './AopGraphHookRegistrar.ts';
export * from './AopInnerObjectClazzList.ts';
References
  1. In this repository, use '.ts' extensions in import/export paths for TypeScript source files to maintain consistency with the existing convention across source and test files.

Comment on lines +321 to +324
ctx.destroy(lifecycle).catch((e) => {
e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`;
console.warn(e);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When handling a caught exception, defensively check if the caught value is an Error instance before accessing or mutating its message property. Mutating e.message directly can throw a TypeError in strict mode if e is a primitive value. Use String(e) as a fallback to safely format the error message.

Suggested change
ctx.destroy(lifecycle).catch((e) => {
e.message = `[tegg/standalone] destroy tegg context failed: ${e.message}`;
console.warn(e);
});
ctx.destroy(lifecycle).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[tegg/standalone] destroy tegg context failed: ' + msg);
});
References
  1. When creating a new Error from a caught exception, check if the caught value is an 'Error' instance before accessing its 'message' property. Use 'String(err)' as a fallback for non-Error values to ensure a meaningful error message.

Comment on lines +32 to 35
app.destroy().catch((e) => {
e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`;
console.warn(e);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When handling a caught exception, defensively check if the caught value is an Error instance before accessing or mutating its message property. Mutating e.message directly can throw a TypeError in strict mode if e is a primitive value. Use String(e) as a fallback to safely format the error message.

Suggested change
app.destroy().catch((e) => {
e.message = `[tegg/standalone] destroy tegg failed: ${e.message}`;
console.warn(e);
});
app.destroy().catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
console.warn('[tegg/standalone] destroy tegg failed: ' + msg);
});
References
  1. When creating a new Error from a caught exception, check if the caught value is an 'Error' instance before accessing its 'message' property. Use 'String(err)' as a fallback for non-Error values to ensure a meaningful error message.

…inner unit

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant