Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9bbad5f
feat(tegg): isolate per-app state for concurrent multi-app via TeggScope
killagu Jun 23, 2026
8777fb5
docs(tegg): document TeggScope multi-app isolation constraints
killagu Jun 23, 2026
0814a0b
perf(tegg): converge per-app scope wrapping and speed up the hot path
killagu Jun 23, 2026
f5cfc0b
Merge branch 'next' into feat/tegg-multiapp-isolation
killagu Jun 26, 2026
babd8e1
fix(tegg): drop unused @eggjs/tegg-types dep from aop/eventbus plugins
killagu Jun 26, 2026
70e71d1
fix(tegg): resolve single-app state outside an explicit scope
killagu Jun 26, 2026
30caefb
refactor(tegg): address review feedback on TeggScope facades
killagu Jun 26, 2026
87cc089
test(tegg): cover TeggScope scope/fallback/escape behavior
killagu Jun 26, 2026
061f23d
refactor(tegg): simplify multi-app scoping boilerplate
killagu Jun 27, 2026
acd2558
fix(tegg): close multi-app scope-isolation gaps found in review
killagu Jun 27, 2026
e8d0f03
fix(tegg): revert destructured lifecycle-util exports (isolatedDeclar…
killagu Jun 27, 2026
35f5d0b
fix(tegg): scope teggConfig configNames in the per-app TeggScope bag
killagu Jun 27, 2026
b87a2b8
test(tegg): cover concurrent multi-app boot under vitest parallel
killagu Jun 27, 2026
e582e5b
fix(tegg): correct standalone preload loader + scope Runner proto lookup
killagu Jun 27, 2026
3923892
fix(tegg): close remaining per-app scope/teardown gaps
killagu Jun 27, 2026
45482bd
test(tegg): tighten multi-app fixtures
killagu Jun 27, 2026
84d4762
docs(tegg): match lifecycle-util guidance to the shipped facade
killagu Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ Then re-run tests.
- keep file names lowercase with hyphens
- keep public API changes deliberate and documented
- use `oxfmt` and `oxlint --type-aware` conventions already present in the repo
- **tegg multi-app isolation**: do NOT introduce new process-global mutable
runtime state in `tegg/`; per-app state must be backed by a `TeggScope` slot.
Hooks registered through the bag-pinned `app.*LifecycleUtil` getters need no
extra wrap; detached/escape-point access (timers, emitter listeners, proxy
handlers, module-level lifecycle-util statics) must run inside
`TeggScope.run(app._teggScopeBag, ...)`. See the "Multi-App Isolation
(TeggScope)" section in `tegg/CLAUDE.md` for the full rules.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## TypeScript Global Types

Expand Down
83 changes: 83 additions & 0 deletions tegg/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,89 @@ const impl = await eggObjectFactory.getEggObject(
);
```

## Multi-App Isolation (TeggScope) — MUST follow

Tegg supports multiple apps booting and serving requests **concurrently in one
process** without cross-talk. This is built on `TeggScope`
(`@eggjs/tegg-types`), a type-free `AsyncLocalStorage<Map<symbol, unknown>>`.
Each app owns a per-app "bag" (`app._teggScopeBag`); per-app state lives in
slots inside that bag, and the active bag is established with
`TeggScope.run(app._teggScopeBag, ...)`. Per-app singletons resolve via the same
old static call sites (e.g. `EggPrototypeFactory.instance`) — they now read the
current scope instead of a process global.

When you touch tegg core/plugins, follow these rules:

1. **Never add new process-global mutable runtime state.** A `static` field /
`Map` / singleton that holds per-app data WILL leak across concurrent apps.
If you need such state, back it with a `TeggScope` slot:

```ts
import { TeggScope } from '@eggjs/tegg-types';
const X_SLOT = Symbol('tegg:<pkg>:<name>'); // module-private, never exported
export class X {
static get instance(): X {
return TeggScope.resolve(X_SLOT, () => new X(), 'X.instance');
}
}
```

Import `TeggScope` **only** from `@eggjs/tegg-types`; never import another
package's slot. A package that imports `TeggScope` must declare
`@eggjs/tegg-types` as a direct dependency.

2. **Shared, app-agnostic registries stay global.** Class/type-keyed maps
populated at import time with app-agnostic values (e.g.
`EggPrototypeCreatorFactory` creator map, `registerEggObjectCreateMethod`,
`registerLoadUnitInstanceClass`) must NOT be scoped. Only state that holds
per-app instances/data is scoped. (`LoadUnitFactory`'s creator map is
two-tier: a global base for import-time creators + a per-app overlay for
boot-time, app-capturing creators.)

3. **Lifecycle-hook registration via `app.*LifecycleUtil` is bag-pinned.**
Calling `app.{loadUnit,eggPrototype,eggObject,eggContext,loadUnitInstance}LifecycleUtil.registerLifecycle(hook)`
(and the matching `deleteLifecycle` in `beforeClose`) does **not** need a
`TeggScope.run` wrap — these app getters are pinned to this app's bag (via
`xxxLifecycleUtilFromBag`), so they resolve the correct per-app util even with
no active scope. Wrapping is still fine when the same block does other
scope-dependent work (as the tegg plugin's own boot does). Do **not** register
lifecycle hooks in the boot **constructor** — `app._teggScopeBag` does not
exist yet; do it in `configWillLoad`/`configDidLoad`/`didLoad`. (Accessing a
lifecycle util through a module-level static instead of `app.*LifecycleUtil`
still needs an active scope.)

4. **Resolve egg objects per-app.** To get a proto from a class, prefer
`EggPrototypeFactory.instance.getPrototypeByClazz(clazz)` (per-app) before
falling back to `PrototypeUtil.getClazzProto(clazz)` (a process-global slot
on the class, overwritten by concurrent boot). `ctx.getEggObject` /
`app.getEggObject` already do this and wrap in the app scope.

5. **Escape points** — code that runs **detached** from the request must
re-establish the scope. Capture `const bag = TeggScope.current()` at
registration/scheduling and re-enter `TeggScope.run(bag, cb)` inside the
callback for: emitter listeners triggered later (`res.on('close')`,
`signal.addEventListener('abort')`), fire-and-forget `EventBus.emit` from a
detached context, and timers created outside a scope. Timers/promises created
**inside** an active scope inherit it automatically — no wrap needed.

6. **Strict-mode fuse.** Under true multi-app (`> 1` live app) any access that
escapes to the process-default bag throws in dev / warns in prod. If you see
`[tegg] TeggScope escaped to the process-default bag`, you have an unwrapped
access — wrap the relevant boot/request/escape path in `TeggScope.run`.

7. **Single app is unchanged.** With one app the default bag is used silently
and the fuse never fires, so existing single-app behavior and tests are
unaffected. Add multi-app regression coverage (two concurrent apps sharing a
module) when you change loader/runtime/lifecycle/eventbus behavior — see
`tegg/plugin/tegg/test/MultiApp.test.ts`.

**Performance:** `TeggScope.resolve` adds ~8 ns/access and `TeggScope.run`
~5 ns/call over a plain static read (Node 22); egg already runs on
AsyncLocalStorage, so there is no new process-wide async penalty. The cost is
negligible relative to real request work. The per-app lifecycle-util facade is an
explicit delegating object (not a `Proxy`) — each method is a direct slot-resolve
plus a method call, with no per-access trap or bound-function allocation.

## Common Patterns

### Creating a New Core Package
Expand Down
5 changes: 2 additions & 3 deletions tegg/core/aop-runtime/src/LoadUnitAopHook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { AspectInfoUtil, AspectMetaBuilder, CrosscutAdviceFactory } from '@eggjs/aop-decorator';
import { PrototypeUtil } from '@eggjs/core-decorator';
import { TeggError } from '@eggjs/metadata';
import { EggPrototypeFactory, TeggError } from '@eggjs/metadata';
import type {
EggPrototype,
EggPrototypeWithClazz,
Expand Down Expand Up @@ -29,7 +28,7 @@ export class LoadUnitAopHook implements LifecycleHook<LoadUnitLifecycleContext,
AspectInfoUtil.setAspectList(aspectList, clazz);
for (const aspect of aspectList) {
for (const advice of aspect.adviceList) {
const adviceProto = PrototypeUtil.getClazzProto(advice.clazz);
const adviceProto = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(advice.clazz);
if (!adviceProto) {
throw TeggError.create(`Aop Advice(${advice.clazz.name}) not found in loadUnits`, 'advice_not_found');
}
Expand Down
15 changes: 14 additions & 1 deletion tegg/core/common-util/src/ModuleConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
NpmModuleReferenceConfig,
ReadModuleReferenceOptions,
} from '@eggjs/tegg-types';
import { TeggScope } from '@eggjs/tegg-types';
import { importResolve } from '@eggjs/utils';
import { extend } from 'extend2';
import globby from 'globby';
Expand All @@ -33,8 +34,20 @@ const DEFAULT_READ_MODULE_REF_OPTS = {
deep: 10,
};

const CONFIG_NAMES_SLOT = Symbol('tegg:common-util:moduleConfigNames');

export class ModuleConfigUtil {
static configNames: string[] | undefined;
// Per-app/per-Runner: each standalone Runner (and app) has distinct config
// names (env-based); a process-global static races across them (the standalone
// "should work with env" ordering bug). Backed by TeggScope; with no active
// scope it uses the single process-default bag (single-app / config-plugin boot).
static get configNames(): string[] | undefined {
return TeggScope.getOr(CONFIG_NAMES_SLOT, () => undefined, 'ModuleConfigUtil.configNames');
}

static set configNames(configNames: string[] | undefined) {
TeggScope.set(CONFIG_NAMES_SLOT, configNames);
}

public static setConfigNames(configNames: string[] | undefined): void {
ModuleConfigUtil.configNames = configNames;
Expand Down
5 changes: 3 additions & 2 deletions tegg/core/dynamic-inject-runtime/src/EggObjectFactory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PrototypeUtil, SingletonProto } from '@eggjs/core-decorator';
import { SingletonProto } from '@eggjs/core-decorator';
import { QualifierImplUtil } from '@eggjs/dynamic-inject';
import { EggPrototypeFactory } from '@eggjs/metadata';
import type { EggContainerFactory } from '@eggjs/tegg-runtime';
import { AccessLevel } from '@eggjs/tegg-types';
import type { QualifierValue, EggAbstractClazz, EggObjectFactory as IEggObjectFactory } from '@eggjs/tegg-types';
Expand All @@ -19,7 +20,7 @@ export class EggObjectFactory implements IEggObjectFactory {
if (!implClazz) {
throw new Error(`has no impl for ${abstractClazz.name} with qualifier ${qualifierValue}`);
}
const protoObj: any = PrototypeUtil.getClazzProto(implClazz);
const protoObj: any = EggPrototypeFactory.instance.getPrototypeByClazzOrGlobal(implClazz);
if (!protoObj) {
throw new Error(`can not get proto for clazz ${implClazz.name}`);
}
Expand Down
71 changes: 40 additions & 31 deletions tegg/core/eventbus-runtime/src/SingletonEventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { Inject, SingletonProto } from '@eggjs/core-decorator';
import { type EventBus, type Events, type EventWaiter, type EventName, CORK_ID } from '@eggjs/eventbus-decorator';
import type { Arguments } from '@eggjs/eventbus-decorator';
import { ContextHandler } from '@eggjs/tegg-runtime';
import { AccessLevel } from '@eggjs/tegg-types';
import type { EggRuntimeContext } from '@eggjs/tegg-types';
import { AccessLevel, TeggScope } from '@eggjs/tegg-types';
import type { EggRuntimeContext, TeggScopeBag } from '@eggjs/tegg-types';
// @ts-expect-error await-event is not typed
import awaitEvent from 'await-event';
// @ts-expect-error await-first is not typed
Expand Down Expand Up @@ -49,6 +49,12 @@ export class SingletonEventBus implements EventBus, EventWaiter {

private readonly corkedEvents = new Map<string /* corkId */, CorkEvents>();

// The per-app TeggScope bag captured when this (per-app) singleton was created.
// emit() is fire-and-forget, so handlers may run on a later tick or be triggered
// from a detached context; re-establishing this bag in doEmit keeps event
// handlers bound to the owning app's factories regardless of the ambient scope.
private readonly teggScopeBag: TeggScopeBag | undefined = TeggScope.current();

/**
* only use for ensure event will happen
*/
Expand Down Expand Up @@ -144,36 +150,39 @@ export class SingletonEventBus implements EventBus, EventWaiter {
}

private async doEmit(ctx: EggRuntimeContext, event: EventName, args: Array<any>) {
await ContextHandler.run(ctx, async () => {
const lifecycle = {};
if (ctx.init) {
await ctx.init(lifecycle);
}
try {
const handlerProtos = this.eventHandlerFactory.getHandlerProtos(event);
await Promise.all(
handlerProtos.map(async (proto) => {
try {
await this.eventHandlerFactory.handle(event, proto, args);
} catch (e: any) {
// should wait all handlers done then destroy ctx
e.message = `[EventBus] process event ${String(event)} for handler ${String(proto.name)} failed: ${e.message}`;
const bag = this.teggScopeBag ?? TeggScope.current();
const doRun = () =>
ContextHandler.run(ctx, async () => {
const lifecycle = {};
if (ctx.init) {
await ctx.init(lifecycle);
}
try {
const handlerProtos = this.eventHandlerFactory.getHandlerProtos(event);
await Promise.all(
handlerProtos.map(async (proto) => {
try {
await this.eventHandlerFactory.handle(event, proto, args);
} catch (e: any) {
// should wait all handlers done then destroy ctx
e.message = `[EventBus] process event ${String(event)} for handler ${String(proto.name)} failed: ${e.message}`;
this.logger.error(e);
}
}),
);
} catch (e: any) {
e.message = `[EventBus] process event ${String(event)} failed: ${e.message}`;
this.logger.error(e);
} finally {
if (ctx.destroy) {
ctx.destroy(lifecycle).catch((e) => {
e.message = '[tegg/SingletonEventBus] destroy tegg ctx failed:' + e.message;
this.logger.error(e);
}
}),
);
} catch (e: any) {
e.message = `[EventBus] process event ${String(event)} failed: ${e.message}`;
this.logger.error(e);
} finally {
if (ctx.destroy) {
ctx.destroy(lifecycle).catch((e) => {
e.message = '[tegg/SingletonEventBus] destroy tegg ctx failed:' + e.message;
this.logger.error(e);
});
});
}
}
}
this.doOnceEmit(event, args);
});
this.doOnceEmit(event, args);
});
await TeggScope.runMaybe(bag, doRun);
}
}
71 changes: 71 additions & 0 deletions tegg/core/lifecycle/src/ScopedLifecycleUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { LifecycleContext, LifecycleObject } from '@eggjs/tegg-types';
import { TeggScope, type TeggScopeBag } from '@eggjs/tegg-types';

import { LifecycleUtil } from './LifycycleUtil.ts';

/**
* Get-or-create the concrete per-app {@link LifecycleUtil} stored at `slot` in a
* specific bag. Used to **pin** a lifecycle util to a known app's bag
* (`app._teggScopeBag`) without depending on the active async scope — e.g. the
* `app.xxxLifecycleUtil` facades, so plugins can register hooks during boot
* WITHOUT wrapping every call in `TeggScope.run(...)`.
*/
export function lifecycleUtilFromBag<T extends LifecycleContext, R extends LifecycleObject<T>>(
bag: TeggScopeBag,
slot: symbol,
): LifecycleUtil<T, R> {
let util = bag.get(slot) as LifecycleUtil<T, R> | undefined;
if (!util) {
util = new LifecycleUtil<T, R>();
bag.set(slot, util);
}
return util;
}

/**
* Create a per-app {@link LifecycleUtil} facade backed by {@link TeggScope}.
*
* The returned object has the same shape/type as a `LifecycleUtil`, but every
* method resolves the per-app instance from the active {@link TeggScope} bag (the
* SAME instance {@link lifecycleUtilFromBag} returns for that bag). This lets
* module-level lifecycle-util singletons (e.g. `EggPrototypeLifecycleUtil`)
* become per-app WITHOUT changing any call site, so hooks fired deep in
* metadata/runtime (where there is no `app` reference) hit the current app's util.
*
* It is an explicit delegating object (NOT a Proxy) — each call is one slot
* resolve + a direct method call, with no per-access trap or bound-function
* allocation.
*/
export function createScopedLifecycleUtil<T extends LifecycleContext, R extends LifecycleObject<T>>(
slot: symbol,
desc: string,
): LifecycleUtil<T, R> {
const get = (): LifecycleUtil<T, R> => TeggScope.resolve(slot, () => new LifecycleUtil<T, R>(), desc);
const facade: Pick<
LifecycleUtil<T, R>,
| 'registerLifecycle'
| 'deleteLifecycle'
| 'getLifecycleList'
| 'registerObjectLifecycle'
| 'deleteObjectLifecycle'
| 'clearObjectLifecycle'
| 'getObjectLifecycleList'
| 'objectPreCreate'
| 'objectPostCreate'
| 'objectPreDestroy'
| 'getLifecycleHook'
> = {
registerLifecycle: (lifecycle) => get().registerLifecycle(lifecycle),
deleteLifecycle: (lifecycle) => get().deleteLifecycle(lifecycle),
getLifecycleList: () => get().getLifecycleList(),
registerObjectLifecycle: (obj, lifecycle) => get().registerObjectLifecycle(obj, lifecycle),
deleteObjectLifecycle: (obj, lifecycle) => get().deleteObjectLifecycle(obj, lifecycle),
clearObjectLifecycle: (obj) => get().clearObjectLifecycle(obj),
getObjectLifecycleList: (obj) => get().getObjectLifecycleList(obj),
objectPreCreate: (ctx, obj) => get().objectPreCreate(ctx, obj),
objectPostCreate: (ctx, obj) => get().objectPostCreate(ctx, obj),
objectPreDestroy: (ctx, obj) => get().objectPreDestroy(ctx, obj),
getLifecycleHook: (hookName, proto) => get().getLifecycleHook(hookName, proto),
};
return facade as LifecycleUtil<T, R>;
}
1 change: 1 addition & 0 deletions tegg/core/lifecycle/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from '@eggjs/tegg-types/lifecycle';

export * from './LifycycleUtil.ts';
export * from './ScopedLifecycleUtil.ts';
export * from './IdenticalObject.ts';
export * from './decorator/index.ts';
2 changes: 2 additions & 0 deletions tegg/core/lifecycle/test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ exports[`should export stable 1`] = `
"LifecyclePreInject": [Function],
"LifecyclePreLoad": [Function],
"LifecycleUtil": [Function],
"createScopedLifecycleUtil": [Function],
"lifecycleUtilFromBag": [Function],
}
`;
Loading
Loading