@velajs/vela 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/dist/application.d.ts +17 -0
- package/dist/application.js +84 -3
- package/dist/container/container.d.ts +21 -1
- package/dist/container/container.js +89 -2
- package/dist/container/types.d.ts +27 -0
- package/dist/discovery/discovery.service.d.ts +8 -0
- package/dist/discovery/discovery.service.js +11 -0
- package/dist/entrypoint/entrypoint.registry.d.ts +3 -1
- package/dist/entrypoint/entrypoint.registry.js +9 -3
- package/dist/event-emitter/event-emitter.module.js +1 -0
- package/dist/factory/bootstrap.js +4 -0
- package/dist/http/route.manager.js +5 -0
- package/dist/i18n/i18n.module.js +5 -0
- package/dist/module/decorators.js +4 -2
- package/dist/module/define-module.d.ts +7 -0
- package/dist/module/define-module.js +9 -4
- package/dist/module/lazy-modules.d.ts +82 -0
- package/dist/module/lazy-modules.js +231 -0
- package/dist/module/module-loader.d.ts +21 -0
- package/dist/module/module-loader.js +99 -24
- package/dist/registry/types.d.ts +10 -0
- package/dist/schedule/schedule.module.js +3 -0
- package/dist/seeder/seeder.module.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
Cold-start laziness (roadmap phase 3): modules can defer their entire init to
|
|
6
|
+
first use, and the in-core subsystems an HTTP-only worker doesn't touch now
|
|
7
|
+
cost it nothing at bootstrap.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Lazy modules** — `@Module({ lazy: true })`, `DynamicModule.lazy`, and
|
|
12
|
+
`defineModule({ lazy: true })` (also recognized per call site like
|
|
13
|
+
`isGlobal`) defer a module *instance*'s entire provider/controller group:
|
|
14
|
+
nothing constructs during `VelaFactory.create`. The first resolution of any
|
|
15
|
+
of its tokens (injection, `app.get()`, a request hitting its controller, a
|
|
16
|
+
dispatcher re-resolving an entrypoint token) claims the module; when the
|
|
17
|
+
resolution stack unwinds, the group materializes and its
|
|
18
|
+
`onModuleInit`/`onApplicationBootstrap` hooks replay in registration order,
|
|
19
|
+
exactly once (memoized). Materialized instances join the instance flow so
|
|
20
|
+
shutdown hooks stay symmetric; untouched modules get neither init nor
|
|
21
|
+
shutdown hooks. Triggers during bootstrap absorb the group into the normal
|
|
22
|
+
hook phases, ordered dependency-before-consumer. `useValue` reads (options
|
|
23
|
+
tokens) do not trigger. Sync seams (`app.get`, the request pipeline) throw
|
|
24
|
+
a descriptive error for lazy modules with async providers/hooks — reach
|
|
25
|
+
those through `app.materializeLazyModules()` (the new warmup escape hatch)
|
|
26
|
+
or keep them sync. Authoring contract: MODULE_AUTHORING.md "Lazy modules".
|
|
27
|
+
- **`app.materializeLazyModules()`** — materialize every still-pending lazy
|
|
28
|
+
module (async-safe); warmup/eager-everything escape hatch.
|
|
29
|
+
- **`Container.isLazyPending(token)` / `Container.isInstantiated(token)`** —
|
|
30
|
+
non-triggering diagnostics (build-time probes, cold-start regression tests).
|
|
31
|
+
- **`DiscoveryFilter.deferLazy`** — discovery returns providers of
|
|
32
|
+
unmaterialized lazy modules as metadata-only entries (`instance:
|
|
33
|
+
undefined`, mirroring the request-scoped convention) instead of forcing the
|
|
34
|
+
group. `EntrypointRegistry.build` uses it: declared-kind entrypoints of
|
|
35
|
+
lazy modules are metadata-only in `app.entrypoints`; dispatchers that
|
|
36
|
+
re-resolve by token (cloudflare cron/queue/scheduled already do)
|
|
37
|
+
materialize the owning module at dispatch time. `ContributesEntrypoints`
|
|
38
|
+
providers in lazy modules are materialized right before the snapshot —
|
|
39
|
+
computed contributions can't defer (documented cost).
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`EventEmitterModule`, `ScheduleModule`, `SeederModule`, `I18nModule` are
|
|
44
|
+
now lazy.** An HTTP-only worker that imports them but never emits an event,
|
|
45
|
+
reads the schedule registry, runs seeders, or translates pays zero
|
|
46
|
+
cold-start cost for them — no subscriber wiring pass, no `@Cron` discovery
|
|
47
|
+
walk, no merged-message snapshot. Every consumer path is a trigger, so
|
|
48
|
+
observable behavior is unchanged (`app.get(EventEmitter).emit(...)` wires
|
|
49
|
+
subscribers first; `runSeeders()` populates the registry via hook replay).
|
|
50
|
+
`WebSocketModule` and `ScheduleNodeModule` deliberately stay eager (gateway
|
|
51
|
+
injection drags the WS chain in anyway; the node executor is self-driving).
|
|
52
|
+
- `ModuleLoader.resolveAllInstances()` skips tokens owned exclusively by lazy
|
|
53
|
+
module instances; a token also registered by a non-lazy module stays on the
|
|
54
|
+
eager pass. Route building no longer instantiate-probes middleware tokens
|
|
55
|
+
that are lazy-pending (default priority 0) — the probe would have defeated
|
|
56
|
+
i18n's deferral at route build.
|
|
57
|
+
|
|
58
|
+
## 1.12.0 (2026-07-04)
|
|
4
59
|
|
|
5
60
|
The module-model release: one blessed authoring path plus public kernel
|
|
6
61
|
extension points, so feature modules (websocket, storage, queue, …) are built
|
package/dist/application.d.ts
CHANGED
|
@@ -12,7 +12,10 @@ export declare class VelaApplication {
|
|
|
12
12
|
private honoApp;
|
|
13
13
|
private entrypointRegistry;
|
|
14
14
|
private disposed;
|
|
15
|
+
private readonly lazyManager;
|
|
16
|
+
private readonly knownInstances;
|
|
15
17
|
constructor(container: Container, routeManager: RouteManager);
|
|
18
|
+
private trackNew;
|
|
16
19
|
/** Pre-build routes (handles async CRUD imports). Called by VelaFactory. */
|
|
17
20
|
initRoutes(): Promise<void>;
|
|
18
21
|
private getApp;
|
|
@@ -44,8 +47,22 @@ export declare class VelaApplication {
|
|
|
44
47
|
* ```
|
|
45
48
|
*/
|
|
46
49
|
mountOpenApi(options: MountOpenApiOptions): this;
|
|
50
|
+
/**
|
|
51
|
+
* Pull instances materialized during the bootstrap phase (lazy modules
|
|
52
|
+
* dragged in by eager consumers) into the front of the instance list —
|
|
53
|
+
* dependency-before-consumer: a group absorbed because an eager provider
|
|
54
|
+
* injected it must be initialized before that consumer's hooks read it.
|
|
55
|
+
*/
|
|
56
|
+
private absorbLazyInstances;
|
|
47
57
|
callOnModuleInit(): Promise<void>;
|
|
48
58
|
callOnApplicationBootstrap(): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Materialize every still-pending lazy module (async-safe): construct the
|
|
61
|
+
* groups, replay their lifecycle hooks, and add their instances to the
|
|
62
|
+
* shutdown flow. Warmup escape hatch for tests and node runtimes that want
|
|
63
|
+
* eager-everything semantics back after bootstrap.
|
|
64
|
+
*/
|
|
65
|
+
materializeLazyModules(): Promise<void>;
|
|
49
66
|
/**
|
|
50
67
|
* Every entrypoint contributed by the module graph, grouped by kind —
|
|
51
68
|
* what runtime adapters/transports query instead of re-scanning providers:
|
package/dist/application.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DiscoveryService } from "./discovery/discovery.service.js";
|
|
2
2
|
import { EntrypointRegistry } from "./entrypoint/entrypoint.registry.js";
|
|
3
|
+
import { LazyModuleManager } from "./module/lazy-modules.js";
|
|
3
4
|
import { hasBeforeApplicationShutdown, hasOnApplicationBootstrap, hasOnApplicationShutdown, hasOnModuleDestroy, hasOnModuleInit } from "./lifecycle/index.js";
|
|
4
5
|
import { renderScalarUi } from "./openapi/scalar-ui.js";
|
|
5
6
|
import { renderSwaggerUi } from "./openapi/swagger-ui.js";
|
|
@@ -11,9 +12,27 @@ export class VelaApplication {
|
|
|
11
12
|
honoApp = null;
|
|
12
13
|
entrypointRegistry = null;
|
|
13
14
|
disposed = false;
|
|
15
|
+
lazyManager;
|
|
16
|
+
// Identity guard for the instance flow: a token registered by BOTH a lazy
|
|
17
|
+
// and an eager module reaches us through the eager pass AND the absorbed
|
|
18
|
+
// batch — without dedup its hooks would run twice.
|
|
19
|
+
knownInstances = new Set();
|
|
14
20
|
constructor(container, routeManager){
|
|
15
21
|
this.container = container;
|
|
16
22
|
this.routeManager = routeManager;
|
|
23
|
+
// bootstrap() registers the manager; a hand-built container may not have
|
|
24
|
+
// one (container unit tests) — lazy semantics simply don't engage then.
|
|
25
|
+
this.lazyManager = container.has(LazyModuleManager) ? container.resolve(LazyModuleManager) : undefined;
|
|
26
|
+
// Live-phase materializations join the instance flow so close()/dispose()
|
|
27
|
+
// run shutdown hooks over them (LIFO — appended last, destroyed first).
|
|
28
|
+
this.lazyManager?.setOnMaterialized((instances)=>{
|
|
29
|
+
this.instances.push(...this.trackNew(instances));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
trackNew(batch) {
|
|
33
|
+
const fresh = batch.filter((i)=>!this.knownInstances.has(i));
|
|
34
|
+
for (const i of fresh)this.knownInstances.add(i);
|
|
35
|
+
return fresh;
|
|
17
36
|
}
|
|
18
37
|
/** Pre-build routes (handles async CRUD imports). Called by VelaFactory. */ async initRoutes() {
|
|
19
38
|
this.honoApp = await this.routeManager.build();
|
|
@@ -35,6 +54,8 @@ export class VelaApplication {
|
|
|
35
54
|
}
|
|
36
55
|
setInstances(instances) {
|
|
37
56
|
this.instances = instances;
|
|
57
|
+
this.knownInstances.clear();
|
|
58
|
+
for (const i of instances)this.knownInstances.add(i);
|
|
38
59
|
}
|
|
39
60
|
get(token) {
|
|
40
61
|
return this.container.resolve(token);
|
|
@@ -127,26 +148,86 @@ export class VelaApplication {
|
|
|
127
148
|
return this;
|
|
128
149
|
}
|
|
129
150
|
// Lifecycle hooks
|
|
151
|
+
/**
|
|
152
|
+
* Pull instances materialized during the bootstrap phase (lazy modules
|
|
153
|
+
* dragged in by eager consumers) into the front of the instance list —
|
|
154
|
+
* dependency-before-consumer: a group absorbed because an eager provider
|
|
155
|
+
* injected it must be initialized before that consumer's hooks read it.
|
|
156
|
+
*/ absorbLazyInstances(prepend) {
|
|
157
|
+
const batch = this.trackNew(this.lazyManager?.takeAbsorbed() ?? []);
|
|
158
|
+
if (batch.length === 0) return batch;
|
|
159
|
+
if (prepend) {
|
|
160
|
+
this.instances = [
|
|
161
|
+
...batch,
|
|
162
|
+
...this.instances
|
|
163
|
+
];
|
|
164
|
+
} else {
|
|
165
|
+
this.instances.push(...batch);
|
|
166
|
+
}
|
|
167
|
+
return batch;
|
|
168
|
+
}
|
|
130
169
|
async callOnModuleInit() {
|
|
131
|
-
|
|
170
|
+
this.absorbLazyInstances(true);
|
|
171
|
+
// Index loop: hooks can trigger further absorptions, which append —
|
|
172
|
+
// the loop naturally covers them.
|
|
173
|
+
for(let i = 0; i < this.instances.length; i++){
|
|
174
|
+
const instance = this.instances[i];
|
|
132
175
|
if (hasOnModuleInit(instance)) {
|
|
133
176
|
await instance.onModuleInit();
|
|
134
177
|
}
|
|
178
|
+
this.absorbLazyInstances(false);
|
|
135
179
|
}
|
|
136
180
|
}
|
|
137
181
|
async callOnApplicationBootstrap() {
|
|
138
|
-
for
|
|
182
|
+
for(let i = 0; i < this.instances.length; i++){
|
|
183
|
+
const instance = this.instances[i];
|
|
139
184
|
if (hasOnApplicationBootstrap(instance)) {
|
|
140
185
|
await instance.onApplicationBootstrap();
|
|
141
186
|
}
|
|
187
|
+
// Instances absorbed mid-phase already missed the init pass — run
|
|
188
|
+
// onModuleInit now; the loop then reaches them for the bootstrap hook.
|
|
189
|
+
for (const late of this.absorbLazyInstances(false)){
|
|
190
|
+
if (hasOnModuleInit(late)) await late.onModuleInit();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Computed-entrypoint contributors (ContributesEntrypoints) in lazy
|
|
194
|
+
// modules must exist before the snapshot below — materialize them now
|
|
195
|
+
// (the documented cost of contributing computed entrypoints), then run
|
|
196
|
+
// their hooks through the same absorb loop.
|
|
197
|
+
if (this.lazyManager) {
|
|
198
|
+
await this.lazyManager.materializeContributors();
|
|
199
|
+
let batch;
|
|
200
|
+
while((batch = this.absorbLazyInstances(false)).length > 0){
|
|
201
|
+
for (const late of batch){
|
|
202
|
+
if (hasOnModuleInit(late)) await late.onModuleInit();
|
|
203
|
+
}
|
|
204
|
+
for (const late of batch){
|
|
205
|
+
if (hasOnApplicationBootstrap(late)) await late.onApplicationBootstrap();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// From here on, materializations replay their hooks at the trigger.
|
|
209
|
+
this.lazyManager.setPhaseLive();
|
|
142
210
|
}
|
|
143
211
|
// Build the per-app entrypoint registry AFTER the hooks: dispatchers that
|
|
144
212
|
// implement ContributesEntrypoints (WsDispatcher) finish their own
|
|
145
213
|
// discovery inside onApplicationBootstrap. Built here — not in
|
|
146
214
|
// VelaFactory/initRoutes — so slim bootstrap paths that never build HTTP
|
|
147
215
|
// routes (the Cloudflare Durable Object) still get `app.entrypoints`.
|
|
216
|
+
// Lazy-pending providers of declared kinds yield metadata-only entries
|
|
217
|
+
// (instance: undefined) — dispatchers re-resolve by token per event,
|
|
218
|
+
// which materializes the owning module at dispatch time.
|
|
148
219
|
const discovery = this.container.has(DiscoveryService) ? this.container.resolve(DiscoveryService) : new DiscoveryService(this.container);
|
|
149
|
-
this.entrypointRegistry = await EntrypointRegistry.build(discovery, this.instances
|
|
220
|
+
this.entrypointRegistry = await EntrypointRegistry.build(discovery, this.instances, {
|
|
221
|
+
deferLazy: true
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Materialize every still-pending lazy module (async-safe): construct the
|
|
226
|
+
* groups, replay their lifecycle hooks, and add their instances to the
|
|
227
|
+
* shutdown flow. Warmup escape hatch for tests and node runtimes that want
|
|
228
|
+
* eager-everything semantics back after bootstrap.
|
|
229
|
+
*/ async materializeLazyModules() {
|
|
230
|
+
await this.lazyManager?.materializeAll();
|
|
150
231
|
}
|
|
151
232
|
/**
|
|
152
233
|
* Every entrypoint contributed by the module graph, grouped by kind —
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Scope } from '../constants';
|
|
2
|
-
import type { ContainerOptions, Diagnostics, ModuleScope, ProviderOptions, Token, Type } from './types';
|
|
2
|
+
import type { ContainerOptions, Diagnostics, LazyResolutionHook, ModuleScope, ProviderOptions, Token, Type } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Per-module provider buckets. Each module instance owns its providers under
|
|
5
5
|
* its `moduleId`; the same logical token can have distinct registrations in
|
|
@@ -19,7 +19,13 @@ export declare class Container {
|
|
|
19
19
|
private diagnostics;
|
|
20
20
|
private root;
|
|
21
21
|
private disposables;
|
|
22
|
+
private lazyHook?;
|
|
23
|
+
private asyncDepth;
|
|
22
24
|
constructor(options?: ContainerOptions);
|
|
25
|
+
/** Install the lazy-module seam (bootstrap-time; root container only). */
|
|
26
|
+
setLazyHook(hook: LazyResolutionHook): void;
|
|
27
|
+
private claimLazyModule;
|
|
28
|
+
private maybeDrainSync;
|
|
23
29
|
register<T>(provider: Type<T> | ProviderOptions<T>, declaringModuleId?: string): this;
|
|
24
30
|
private registerClass;
|
|
25
31
|
private registerOptions;
|
|
@@ -67,6 +73,19 @@ export declare class Container {
|
|
|
67
73
|
replaceProvider<T>(provider: Type<T> | ProviderOptions<T>, options?: {
|
|
68
74
|
buckets?: 'all-existing' | 'root' | string[];
|
|
69
75
|
}): this;
|
|
76
|
+
/**
|
|
77
|
+
* True when the token belongs exclusively to lazy modules that have not
|
|
78
|
+
* been materialized yet — resolving it would trigger materialization.
|
|
79
|
+
* Build-time probes (route-manager middleware priority, entrypoint
|
|
80
|
+
* snapshots) use this to defer instead of forcing the group.
|
|
81
|
+
*/
|
|
82
|
+
isLazyPending(token: Token): boolean;
|
|
83
|
+
/**
|
|
84
|
+
* True when any registration of the token holds a constructed instance.
|
|
85
|
+
* Diagnostic helper (cold-start tests): checks WITHOUT resolving, so it
|
|
86
|
+
* never triggers lazy materialization.
|
|
87
|
+
*/
|
|
88
|
+
isInstantiated(token: Token): boolean;
|
|
70
89
|
getProviderScope(token: Token): Scope | undefined;
|
|
71
90
|
getTokens(): Token[];
|
|
72
91
|
/**
|
|
@@ -122,6 +141,7 @@ export declare class Container {
|
|
|
122
141
|
private resolveFactoryDependencyAsync;
|
|
123
142
|
private resolveFactory;
|
|
124
143
|
resolveAsync<T>(token: Token<T>, requestingModuleId?: string): Promise<T>;
|
|
144
|
+
private resolveAsyncInner;
|
|
125
145
|
private createLazyProxy;
|
|
126
146
|
private tokenToString;
|
|
127
147
|
}
|
|
@@ -33,9 +33,40 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
33
33
|
// Container-constructed instances in creation order, for LIFO disposal.
|
|
34
34
|
// useValue providers are never tracked (they return before construction).
|
|
35
35
|
disposables = [];
|
|
36
|
+
// Lazy-module seam (root-owned; children reach it via this.root). A
|
|
37
|
+
// resolution of a deferred registration CLAIMS its module; claimed groups
|
|
38
|
+
// are completed (constructed + hooks replayed) only when the resolution
|
|
39
|
+
// stack has unwound and no resolveAsync cascade is in flight — running the
|
|
40
|
+
// replay mid-construction could force-resolve a class currently on the
|
|
41
|
+
// resolution stack (discovery cascades) and mint a spurious
|
|
42
|
+
// circular-dependency error.
|
|
43
|
+
lazyHook;
|
|
44
|
+
asyncDepth = 0;
|
|
36
45
|
constructor(options = {}){
|
|
37
46
|
this.diagnostics = options.diagnostics ?? 'log';
|
|
38
47
|
}
|
|
48
|
+
/** Install the lazy-module seam (bootstrap-time; root container only). */ setLazyHook(hook) {
|
|
49
|
+
this.root.lazyHook = hook;
|
|
50
|
+
}
|
|
51
|
+
claimLazyModule(declaringModuleId) {
|
|
52
|
+
const hook = this.root.lazyHook;
|
|
53
|
+
if (hook?.isPending(declaringModuleId)) {
|
|
54
|
+
hook.claim(declaringModuleId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
maybeDrainSync() {
|
|
58
|
+
const root = this.root;
|
|
59
|
+
const hook = root.lazyHook;
|
|
60
|
+
if (!hook?.hasClaimed()) return;
|
|
61
|
+
// Never replay hooks while construction is in flight; an async cascade
|
|
62
|
+
// drains (with await) at its own end instead. A running drain picks
|
|
63
|
+
// pending claims up itself — re-entering it is at best a no-op and on
|
|
64
|
+
// the async path a self-deadlock.
|
|
65
|
+
if (this.resolutionStack.size > 0) return;
|
|
66
|
+
if (root.asyncDepth > 0) return;
|
|
67
|
+
if (hook.isDraining()) return;
|
|
68
|
+
hook.drainSync();
|
|
69
|
+
}
|
|
39
70
|
register(provider, declaringModuleId) {
|
|
40
71
|
const moduleId = declaringModuleId ?? ROOT_MODULE_ID;
|
|
41
72
|
if (typeof provider === 'function') {
|
|
@@ -125,7 +156,9 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
125
156
|
resolve(token, requestingModuleId) {
|
|
126
157
|
const registration = this.findRegistration(token, requestingModuleId);
|
|
127
158
|
if (registration) {
|
|
128
|
-
|
|
159
|
+
const instance = this.resolveRegistration(registration, requestingModuleId);
|
|
160
|
+
this.maybeDrainSync();
|
|
161
|
+
return instance;
|
|
129
162
|
}
|
|
130
163
|
// Visibility error fires first when the token exists in some bucket but
|
|
131
164
|
// isn't reachable from the requester's scope — the user wants to know
|
|
@@ -155,7 +188,9 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
155
188
|
}
|
|
156
189
|
resolveAll(token, requestingModuleId) {
|
|
157
190
|
const registrations = this.findAllRegistrations(token, requestingModuleId);
|
|
158
|
-
|
|
191
|
+
const instances = registrations.map((r)=>this.resolveRegistration(r, requestingModuleId));
|
|
192
|
+
this.maybeDrainSync();
|
|
193
|
+
return instances;
|
|
159
194
|
}
|
|
160
195
|
/**
|
|
161
196
|
* Find a single reachable registration for a token, applying the visibility
|
|
@@ -321,6 +356,32 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
321
356
|
}
|
|
322
357
|
return this.register(provider);
|
|
323
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* True when the token belongs exclusively to lazy modules that have not
|
|
361
|
+
* been materialized yet — resolving it would trigger materialization.
|
|
362
|
+
* Build-time probes (route-manager middleware priority, entrypoint
|
|
363
|
+
* snapshots) use this to defer instead of forcing the group.
|
|
364
|
+
*/ isLazyPending(token) {
|
|
365
|
+
const hook = this.root.lazyHook;
|
|
366
|
+
if (!hook) return false;
|
|
367
|
+
const owners = this.exporterIndex.get(token);
|
|
368
|
+
if (!owners || owners.size === 0) return false;
|
|
369
|
+
for (const owner of owners){
|
|
370
|
+
if (!hook.isPending(owner)) return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* True when any registration of the token holds a constructed instance.
|
|
376
|
+
* Diagnostic helper (cold-start tests): checks WITHOUT resolving, so it
|
|
377
|
+
* never triggers lazy materialization.
|
|
378
|
+
*/ isInstantiated(token) {
|
|
379
|
+
for (const owner of this.exporterIndex.get(token) ?? []){
|
|
380
|
+
const reg = this.providers.get(owner)?.get(token);
|
|
381
|
+
if (reg && reg.instance !== undefined) return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
324
385
|
getProviderScope(token) {
|
|
325
386
|
const exporters = this.exporterIndex.get(token);
|
|
326
387
|
if (!exporters) return undefined;
|
|
@@ -467,6 +528,13 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
467
528
|
child.exporterIndex = clonedIndex;
|
|
468
529
|
child.scopes = this.scopes;
|
|
469
530
|
child.globals = this.globals;
|
|
531
|
+
// Registration objects are shallow-shared, so a sandbox resolve of a lazy
|
|
532
|
+
// module's singleton caches onto the SHARED registration. Point the
|
|
533
|
+
// sandbox at the real root so that resolution claims the module and
|
|
534
|
+
// replays its hooks like any other trigger — otherwise ModuleRef.create
|
|
535
|
+
// would leave a hook-less instance poisoning the shared cache (and its
|
|
536
|
+
// disposables tracked on an ephemeral root).
|
|
537
|
+
child.root = this.root;
|
|
470
538
|
return child;
|
|
471
539
|
}
|
|
472
540
|
clear() {
|
|
@@ -517,8 +585,12 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
517
585
|
}
|
|
518
586
|
resolveRegistration(registration, requestingModuleId) {
|
|
519
587
|
if (registration.useValue !== undefined) {
|
|
588
|
+
// Deliberately BEFORE the lazy claim: reading a lazy module's useValue
|
|
589
|
+
// (options tokens) has no construction cost to defer and must not
|
|
590
|
+
// materialize the group.
|
|
520
591
|
return registration.useValue;
|
|
521
592
|
}
|
|
593
|
+
this.claimLazyModule(registration.declaringModuleId);
|
|
522
594
|
if (registration.useExisting) {
|
|
523
595
|
// Pass through the original requester to catch alias leaks
|
|
524
596
|
return this.resolve(registration.useExisting, requestingModuleId);
|
|
@@ -652,6 +724,20 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
652
724
|
return result;
|
|
653
725
|
}
|
|
654
726
|
async resolveAsync(token, requestingModuleId) {
|
|
727
|
+
const root = this.root;
|
|
728
|
+
root.asyncDepth++;
|
|
729
|
+
let result;
|
|
730
|
+
try {
|
|
731
|
+
result = await this.resolveAsyncInner(token, requestingModuleId);
|
|
732
|
+
} finally{
|
|
733
|
+
root.asyncDepth--;
|
|
734
|
+
}
|
|
735
|
+
if (root.asyncDepth === 0 && root.lazyHook?.hasClaimed() && !root.lazyHook.isDraining()) {
|
|
736
|
+
await root.lazyHook.drainAsync();
|
|
737
|
+
}
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
async resolveAsyncInner(token, requestingModuleId) {
|
|
655
741
|
const registration = this.findRegistration(token, requestingModuleId);
|
|
656
742
|
if (!registration) {
|
|
657
743
|
if (requestingModuleId !== undefined && this.scopes.has(requestingModuleId) && this.exporterIndex.has(token)) {
|
|
@@ -674,6 +760,7 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
|
|
|
674
760
|
if (scope === Scope.SINGLETON && registration.instance !== undefined) {
|
|
675
761
|
return registration.instance;
|
|
676
762
|
}
|
|
763
|
+
this.claimLazyModule(registration.declaringModuleId);
|
|
677
764
|
// Module scope first, legacy no-requester fallback — same policy as the
|
|
678
765
|
// sync resolveFactory path.
|
|
679
766
|
const dependencies = await Promise.all((registration.inject || []).map((t)=>{
|
|
@@ -85,6 +85,33 @@ export interface ModuleScope {
|
|
|
85
85
|
importedModules: Set<string>;
|
|
86
86
|
exportedTokens: Set<Token>;
|
|
87
87
|
isGlobal: boolean;
|
|
88
|
+
/** Module instance opted into deferred (first-use) materialization. */
|
|
89
|
+
lazy?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The container's seam into lazy-module materialization (implemented by
|
|
93
|
+
* `LazyModuleManager`). The container only ever *claims* a pending module at
|
|
94
|
+
* a resolution trigger and *drains* completed claims when the resolution
|
|
95
|
+
* stack has fully unwound — construction and hook replay live behind this
|
|
96
|
+
* interface so the container stays module-system-agnostic.
|
|
97
|
+
*/
|
|
98
|
+
export interface LazyResolutionHook {
|
|
99
|
+
/** Is this module instance still deferred (untriggered)? */
|
|
100
|
+
isPending(moduleId: string): boolean;
|
|
101
|
+
/** Mark a pending module as triggered; idempotent. */
|
|
102
|
+
claim(moduleId: string): void;
|
|
103
|
+
/** Any claimed-but-unmaterialized groups? (cheap fast-path check) */
|
|
104
|
+
hasClaimed(): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* A drain loop is currently running. The container must NOT start (or
|
|
107
|
+
* await) another drain from inside it — the running loop picks pending
|
|
108
|
+
* claims up; awaiting would self-deadlock on the async path.
|
|
109
|
+
*/
|
|
110
|
+
isDraining(): boolean;
|
|
111
|
+
/** Complete claimed groups synchronously; throws if async work surfaces. */
|
|
112
|
+
drainSync(): void;
|
|
113
|
+
/** Complete claimed groups, awaiting async construction and hooks. */
|
|
114
|
+
drainAsync(): Promise<void>;
|
|
88
115
|
}
|
|
89
116
|
export type Diagnostics = 'silent' | 'log' | 'throw';
|
|
90
117
|
export interface ContainerOptions {
|
|
@@ -31,6 +31,14 @@ export interface DiscoveryFilter {
|
|
|
31
31
|
* request-scoped hits are skipped with a diagnostics warning.
|
|
32
32
|
*/
|
|
33
33
|
includeRequestScoped?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Return providers of not-yet-materialized lazy modules as metadata-only
|
|
36
|
+
* entries (`instance: undefined`, mirroring the request-scoped convention)
|
|
37
|
+
* instead of resolving them — which would force the whole module group.
|
|
38
|
+
* Used by `EntrypointRegistry.build`; default false so every existing
|
|
39
|
+
* scanner keeps its transparent cascade-materialization semantics.
|
|
40
|
+
*/
|
|
41
|
+
deferLazy?: boolean;
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
36
44
|
* Decorator-driven provider discovery — the public replacement for the
|
|
@@ -124,6 +124,17 @@ export class DiscoveryService {
|
|
|
124
124
|
if (!moduleIds.some((id)=>wanted.includes(id))) return undefined;
|
|
125
125
|
}
|
|
126
126
|
const scope = this.container.getProviderScope(metatype) ?? Scope.SINGLETON;
|
|
127
|
+
if (filter?.deferLazy && this.container.isLazyPending(metatype)) {
|
|
128
|
+
// Deliberate deferral — no diagnostics warning: the entry is complete
|
|
129
|
+
// metadata-wise and consumers re-resolve by token at dispatch time.
|
|
130
|
+
return {
|
|
131
|
+
token: metatype,
|
|
132
|
+
metatype,
|
|
133
|
+
moduleIds,
|
|
134
|
+
scope,
|
|
135
|
+
instance: undefined
|
|
136
|
+
};
|
|
137
|
+
}
|
|
127
138
|
if (scope === Scope.REQUEST && !filter?.includeRequestScoped) {
|
|
128
139
|
if (this.container.getDiagnostics() === 'log') {
|
|
129
140
|
console.warn(`[vela] ${label}: ${metatype.name} is request-scoped and cannot be ` + `materialized at bootstrap — skipped. Pass { includeRequestScoped: true } to override.`);
|
|
@@ -32,7 +32,9 @@ export declare function _resetEntrypointKinds(): void;
|
|
|
32
32
|
*/
|
|
33
33
|
export declare class EntrypointRegistry {
|
|
34
34
|
private readonly byKind;
|
|
35
|
-
static build(discovery: DiscoveryService, eagerInstances: readonly unknown[]
|
|
35
|
+
static build(discovery: DiscoveryService, eagerInstances: readonly unknown[], options?: {
|
|
36
|
+
deferLazy?: boolean;
|
|
37
|
+
}): Promise<EntrypointRegistry>;
|
|
36
38
|
private add;
|
|
37
39
|
ofKind<M = unknown>(kind: string): Entrypoint<M>[];
|
|
38
40
|
kinds(): string[];
|
|
@@ -44,11 +44,17 @@ function kindStore() {
|
|
|
44
44
|
* own registry from their own container.
|
|
45
45
|
*/ export class EntrypointRegistry {
|
|
46
46
|
byKind = new Map();
|
|
47
|
-
static async build(discovery, eagerInstances) {
|
|
47
|
+
static async build(discovery, eagerInstances, options = {}) {
|
|
48
48
|
const registry = new EntrypointRegistry();
|
|
49
|
+
// With deferLazy, providers of unmaterialized lazy modules yield
|
|
50
|
+
// metadata-only entries (instance: undefined). Dispatchers re-resolve by
|
|
51
|
+
// token per event, so the owning module materializes at dispatch time.
|
|
52
|
+
const filter = options.deferLazy ? {
|
|
53
|
+
deferLazy: true
|
|
54
|
+
} : undefined;
|
|
49
55
|
for (const kind of getEntrypointKinds()){
|
|
50
56
|
if (kind.level === 'class') {
|
|
51
|
-
for (const found of discovery.providersWithMeta(kind.metaKey)){
|
|
57
|
+
for (const found of discovery.providersWithMeta(kind.metaKey, filter)){
|
|
52
58
|
registry.add({
|
|
53
59
|
kind: kind.kind,
|
|
54
60
|
token: found.token,
|
|
@@ -57,7 +63,7 @@ function kindStore() {
|
|
|
57
63
|
});
|
|
58
64
|
}
|
|
59
65
|
} else {
|
|
60
|
-
for (const found of discovery.methodsWithMeta(kind.metaKey)){
|
|
66
|
+
for (const found of discovery.methodsWithMeta(kind.metaKey, filter)){
|
|
61
67
|
registry.add({
|
|
62
68
|
kind: kind.kind,
|
|
63
69
|
token: found.class.token,
|
|
@@ -71,6 +71,10 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from
|
|
|
71
71
|
});
|
|
72
72
|
container.markGlobalToken(RouteManager);
|
|
73
73
|
const loader = new ModuleLoader(container, routeManager);
|
|
74
|
+
// loader.load() also arms the deferred-init seam (LazyModuleManager) — kept
|
|
75
|
+
// inside the loader so hand-rolled bootstrap paths that never call this
|
|
76
|
+
// function (@velajs/testing's TestingModuleBuilder.compile) get identical
|
|
77
|
+
// lazy semantics.
|
|
74
78
|
loader.load(rootModule);
|
|
75
79
|
bindAppProviders(routeManager, container, loader);
|
|
76
80
|
routeManager.registerConsumerMiddleware(loader.getConsumerMiddlewareDefinitions());
|
|
@@ -177,6 +177,11 @@ export class RouteManager {
|
|
|
177
177
|
if (typeof inst.priority === 'number') return inst.priority;
|
|
178
178
|
if (typeof inst.constructor?.priority === 'number') return inst.constructor.priority;
|
|
179
179
|
}
|
|
180
|
+
// A token owned exclusively by unmaterialized lazy modules must not be
|
|
181
|
+
// instantiate-probed here — the probe at route build would defeat the
|
|
182
|
+
// module's deferral (i18n's APP_MIDDLEWARE). Default priority instead;
|
|
183
|
+
// the middleware still materializes on its first request.
|
|
184
|
+
if (this.container.isLazyPending(entry)) return 0;
|
|
180
185
|
try {
|
|
181
186
|
const resolved = instantiate(entry, this.container);
|
|
182
187
|
if (resolved && typeof resolved === 'object') {
|
package/dist/i18n/i18n.module.js
CHANGED
|
@@ -44,6 +44,11 @@ export class I18nModule extends ConfigurableModuleClass {
|
|
|
44
44
|
}
|
|
45
45
|
I18nModule = _ts_decorate([
|
|
46
46
|
Module({
|
|
47
|
+
// Lazy: the merged-message snapshot (MessageLoaderService constructor) and
|
|
48
|
+
// locale middleware materialize on the first request that reaches them —
|
|
49
|
+
// message registration itself is import-time and unaffected. Safe because
|
|
50
|
+
// the route-build priority probe skips lazy-pending APP_MIDDLEWARE tokens.
|
|
51
|
+
lazy: true,
|
|
47
52
|
providers: [
|
|
48
53
|
MessageRegistry,
|
|
49
54
|
MessageLoaderService,
|
|
@@ -16,7 +16,8 @@ export function Module(options = {}) {
|
|
|
16
16
|
providers: options.providers,
|
|
17
17
|
controllers: options.controllers,
|
|
18
18
|
exports: options.exports,
|
|
19
|
-
isGlobal: options.isGlobal
|
|
19
|
+
isGlobal: options.isGlobal,
|
|
20
|
+
lazy: options.lazy
|
|
20
21
|
});
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -40,6 +41,7 @@ export function getModuleMetadata(target) {
|
|
|
40
41
|
controllers: options.controllers ?? [],
|
|
41
42
|
imports: options.imports ?? [],
|
|
42
43
|
exports: options.exports ?? [],
|
|
43
|
-
isGlobal: options.isGlobal === true
|
|
44
|
+
isGlobal: options.isGlobal === true,
|
|
45
|
+
lazy: options.lazy === true
|
|
44
46
|
};
|
|
45
47
|
}
|
|
@@ -66,6 +66,13 @@ export interface DefineModuleSpec<Opts, Extras extends ConfigurableModuleExtras
|
|
|
66
66
|
methodName?: MethodKey;
|
|
67
67
|
/** Method a `useClass`/`useExisting` options factory must implement (default `create`). */
|
|
68
68
|
factoryMethodName?: FactoryMethodKey;
|
|
69
|
+
/**
|
|
70
|
+
* Default every generated module instance to deferred (first-use)
|
|
71
|
+
* materialization. Call sites can also opt in per instance by passing
|
|
72
|
+
* `lazy: true` alongside the options (recognized like `isGlobal`).
|
|
73
|
+
* See MODULE_AUTHORING.md "Lazy modules".
|
|
74
|
+
*/
|
|
75
|
+
lazy?: boolean;
|
|
69
76
|
}
|
|
70
77
|
/**
|
|
71
78
|
* The one blessed module-authoring engine. Generates `forRoot` AND
|
|
@@ -76,6 +76,11 @@ const ASYNC_OPTION_KEYS = new Set([
|
|
|
76
76
|
const extrasDefaults = spec.extras ?? DEFAULT_EXTRAS;
|
|
77
77
|
const transform = spec.transform ?? DEFAULT_TRANSFORM;
|
|
78
78
|
const deriveKey = (explicit, structural)=>explicit ?? spec.key?.(structural) ?? stableHash(structural);
|
|
79
|
+
// Laziness is OR-composed: the spec defaults it, a call site can add it.
|
|
80
|
+
const applyLazy = (definition, callSiteLazy)=>spec.lazy === true || callSiteLazy === true ? {
|
|
81
|
+
...definition,
|
|
82
|
+
lazy: true
|
|
83
|
+
} : definition;
|
|
79
84
|
const applyContributions = (definition, structural, key)=>{
|
|
80
85
|
if (!spec.setup) return definition;
|
|
81
86
|
const contributions = spec.setup({
|
|
@@ -127,10 +132,10 @@ const ASYNC_OPTION_KEYS = new Set([
|
|
|
127
132
|
}
|
|
128
133
|
]
|
|
129
134
|
};
|
|
130
|
-
return transform(applyContributions(definition, rest, key), {
|
|
135
|
+
return applyLazy(transform(applyContributions(definition, rest, key), {
|
|
131
136
|
...extrasDefaults,
|
|
132
137
|
...rest
|
|
133
|
-
});
|
|
138
|
+
}), rest.lazy);
|
|
134
139
|
}
|
|
135
140
|
});
|
|
136
141
|
Object.defineProperty(GeneratedModuleClass, asyncName, {
|
|
@@ -159,10 +164,10 @@ const ASYNC_OPTION_KEYS = new Set([
|
|
|
159
164
|
imports: bag.imports ?? [],
|
|
160
165
|
providers: buildAsyncOptionsProviders(optionsToken, factoryMethodName, bag, structural)
|
|
161
166
|
};
|
|
162
|
-
return transform(applyContributions(definition, structural, key), {
|
|
167
|
+
return applyLazy(transform(applyContributions(definition, structural, key), {
|
|
163
168
|
...extrasDefaults,
|
|
164
169
|
...structural
|
|
165
|
-
});
|
|
170
|
+
}), structural.lazy);
|
|
166
171
|
}
|
|
167
172
|
});
|
|
168
173
|
return {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Container } from '../container/container';
|
|
2
|
+
import type { LazyResolutionHook, Token } from '../container/types';
|
|
3
|
+
/** One lazy module instance's deferral unit: its own tokens, in registration order. */
|
|
4
|
+
export interface LazyModuleGroup {
|
|
5
|
+
moduleId: string;
|
|
6
|
+
tokens: Token[];
|
|
7
|
+
/**
|
|
8
|
+
* A class provider's prototype declares `collectEntrypoints` — the module
|
|
9
|
+
* must be materialized before the entrypoint registry snapshot or its
|
|
10
|
+
* computed contributions would be silently absent.
|
|
11
|
+
*/
|
|
12
|
+
hasEntrypointContributor: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Owns deferred-module state for one application: which module instances are
|
|
16
|
+
* still pending, which have been claimed by a container trigger, and how
|
|
17
|
+
* claimed groups get completed (construction + lifecycle-hook replay).
|
|
18
|
+
*
|
|
19
|
+
* Phases:
|
|
20
|
+
* - `'bootstrap'` (until the end of `callOnApplicationBootstrap`): completed
|
|
21
|
+
* groups are *absorbed* — their instances queue up for the application's
|
|
22
|
+
* normal hook phases, so a lazy module dragged in by an eager consumer gets
|
|
23
|
+
* today-identical semantics.
|
|
24
|
+
* - `'live'`: hooks replay at drain time (memoized, exactly once), then the
|
|
25
|
+
* instances are handed to the application for shutdown-hook symmetry.
|
|
26
|
+
*
|
|
27
|
+
* Sync/async duality: sync drains throw a descriptive error when a member
|
|
28
|
+
* factory or hook yields a thenable — lazy modules with async providers or
|
|
29
|
+
* hooks must be reached through an async seam (`materializeLazyModules()`,
|
|
30
|
+
* `resolveAsync`) or stay eager.
|
|
31
|
+
*/
|
|
32
|
+
export declare class LazyModuleManager implements LazyResolutionHook {
|
|
33
|
+
private readonly container;
|
|
34
|
+
private readonly pending;
|
|
35
|
+
private readonly claimed;
|
|
36
|
+
private readonly absorbed;
|
|
37
|
+
private phase;
|
|
38
|
+
private draining;
|
|
39
|
+
private drainPromise?;
|
|
40
|
+
private onMaterialized?;
|
|
41
|
+
constructor(container: Container);
|
|
42
|
+
registerGroup(group: LazyModuleGroup): void;
|
|
43
|
+
/** Application sink for live-phase materializations (shutdown symmetry). */
|
|
44
|
+
setOnMaterialized(sink: (instances: unknown[]) => void): void;
|
|
45
|
+
setPhaseLive(): void;
|
|
46
|
+
isPending(moduleId: string): boolean;
|
|
47
|
+
/** Tokens whose owning modules are ALL still pending are deferred. */
|
|
48
|
+
hasPendingModules(): boolean;
|
|
49
|
+
claim(moduleId: string): void;
|
|
50
|
+
hasClaimed(): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* A drain loop is running. The container consults this before its
|
|
53
|
+
* post-resolution drain: `constructGroupAsync` resolves members through
|
|
54
|
+
* `resolveAsync`, whose end-of-cascade check would otherwise re-enter and
|
|
55
|
+
* AWAIT the very drain promise it is executing inside — a self-referential
|
|
56
|
+
* await that never settles (with ≥2 claimed groups). Claims made while
|
|
57
|
+
* draining are picked up by the running loop instead.
|
|
58
|
+
*/
|
|
59
|
+
isDraining(): boolean;
|
|
60
|
+
/** Instances constructed during the bootstrap phase, owed their hooks. */
|
|
61
|
+
takeAbsorbed(): unknown[];
|
|
62
|
+
drainSync(): void;
|
|
63
|
+
drainAsync(): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Materialize every still-pending group whose providers compute entrypoints
|
|
66
|
+
* (`collectEntrypoints` on a class prototype). Called by the application
|
|
67
|
+
* right before the entrypoint-registry snapshot so computed contributions
|
|
68
|
+
* are never silently absent. The cost — those modules are effectively
|
|
69
|
+
* eager — is the documented price of contributing computed entrypoints.
|
|
70
|
+
*/
|
|
71
|
+
materializeContributors(): Promise<void>;
|
|
72
|
+
/** Materialize everything still pending (warmup / tests / node runtimes). */
|
|
73
|
+
materializeAll(): Promise<void>;
|
|
74
|
+
private groupTokensToConstruct;
|
|
75
|
+
private constructGroupSync;
|
|
76
|
+
private constructGroupAsync;
|
|
77
|
+
/** Reversed batch = dependency-before-consumer (see drainSync comment). */
|
|
78
|
+
private orderBatch;
|
|
79
|
+
private finishBatchSync;
|
|
80
|
+
private finishBatchAsync;
|
|
81
|
+
private describeSyncFailure;
|
|
82
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Scope } from "../constants.js";
|
|
2
|
+
import { hasOnApplicationBootstrap, hasOnModuleInit } from "../lifecycle/index.js";
|
|
3
|
+
function isThenable(x) {
|
|
4
|
+
return x !== null && (typeof x === 'object' || typeof x === 'function') && typeof x.then === 'function';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Owns deferred-module state for one application: which module instances are
|
|
8
|
+
* still pending, which have been claimed by a container trigger, and how
|
|
9
|
+
* claimed groups get completed (construction + lifecycle-hook replay).
|
|
10
|
+
*
|
|
11
|
+
* Phases:
|
|
12
|
+
* - `'bootstrap'` (until the end of `callOnApplicationBootstrap`): completed
|
|
13
|
+
* groups are *absorbed* — their instances queue up for the application's
|
|
14
|
+
* normal hook phases, so a lazy module dragged in by an eager consumer gets
|
|
15
|
+
* today-identical semantics.
|
|
16
|
+
* - `'live'`: hooks replay at drain time (memoized, exactly once), then the
|
|
17
|
+
* instances are handed to the application for shutdown-hook symmetry.
|
|
18
|
+
*
|
|
19
|
+
* Sync/async duality: sync drains throw a descriptive error when a member
|
|
20
|
+
* factory or hook yields a thenable — lazy modules with async providers or
|
|
21
|
+
* hooks must be reached through an async seam (`materializeLazyModules()`,
|
|
22
|
+
* `resolveAsync`) or stay eager.
|
|
23
|
+
*/ export class LazyModuleManager {
|
|
24
|
+
container;
|
|
25
|
+
pending = new Map();
|
|
26
|
+
claimed = [];
|
|
27
|
+
absorbed = [];
|
|
28
|
+
phase = 'bootstrap';
|
|
29
|
+
draining = false;
|
|
30
|
+
// In-flight async drain: concurrent drainAsync callers must await the SAME
|
|
31
|
+
// completion (the running loop picks their claims up), never resolve early
|
|
32
|
+
// with a group's hooks still pending.
|
|
33
|
+
drainPromise;
|
|
34
|
+
onMaterialized;
|
|
35
|
+
constructor(container){
|
|
36
|
+
this.container = container;
|
|
37
|
+
}
|
|
38
|
+
registerGroup(group) {
|
|
39
|
+
this.pending.set(group.moduleId, group);
|
|
40
|
+
}
|
|
41
|
+
/** Application sink for live-phase materializations (shutdown symmetry). */ setOnMaterialized(sink) {
|
|
42
|
+
this.onMaterialized = sink;
|
|
43
|
+
}
|
|
44
|
+
setPhaseLive() {
|
|
45
|
+
this.phase = 'live';
|
|
46
|
+
}
|
|
47
|
+
isPending(moduleId) {
|
|
48
|
+
return this.pending.has(moduleId);
|
|
49
|
+
}
|
|
50
|
+
/** Tokens whose owning modules are ALL still pending are deferred. */ hasPendingModules() {
|
|
51
|
+
return this.pending.size > 0;
|
|
52
|
+
}
|
|
53
|
+
claim(moduleId) {
|
|
54
|
+
const group = this.pending.get(moduleId);
|
|
55
|
+
if (!group) return;
|
|
56
|
+
this.pending.delete(moduleId);
|
|
57
|
+
this.claimed.push(group);
|
|
58
|
+
}
|
|
59
|
+
hasClaimed() {
|
|
60
|
+
return this.claimed.length > 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A drain loop is running. The container consults this before its
|
|
64
|
+
* post-resolution drain: `constructGroupAsync` resolves members through
|
|
65
|
+
* `resolveAsync`, whose end-of-cascade check would otherwise re-enter and
|
|
66
|
+
* AWAIT the very drain promise it is executing inside — a self-referential
|
|
67
|
+
* await that never settles (with ≥2 claimed groups). Claims made while
|
|
68
|
+
* draining are picked up by the running loop instead.
|
|
69
|
+
*/ isDraining() {
|
|
70
|
+
return this.draining;
|
|
71
|
+
}
|
|
72
|
+
/** Instances constructed during the bootstrap phase, owed their hooks. */ takeAbsorbed() {
|
|
73
|
+
if (this.absorbed.length === 0) return [];
|
|
74
|
+
return this.absorbed.splice(0, this.absorbed.length);
|
|
75
|
+
}
|
|
76
|
+
drainSync() {
|
|
77
|
+
if (this.draining) return;
|
|
78
|
+
this.draining = true;
|
|
79
|
+
try {
|
|
80
|
+
// Construct-all-then-hook, batch by batch: a cascade's groups arrive in
|
|
81
|
+
// consumer-before-dependency claim order (the consumer's token resolves
|
|
82
|
+
// first; constructing it claims its deps), so hook phases run over the
|
|
83
|
+
// REVERSED batch — dependency-before-consumer, matching what the eager
|
|
84
|
+
// bootstrap would have produced. Hooks can claim further groups
|
|
85
|
+
// (discovery cascades) → outer loop.
|
|
86
|
+
while(this.claimed.length > 0){
|
|
87
|
+
const batch = [];
|
|
88
|
+
while(this.claimed.length > 0){
|
|
89
|
+
const group = this.claimed.shift();
|
|
90
|
+
batch.push({
|
|
91
|
+
group,
|
|
92
|
+
instances: this.constructGroupSync(group)
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
this.finishBatchSync(batch);
|
|
96
|
+
}
|
|
97
|
+
} finally{
|
|
98
|
+
this.draining = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
drainAsync() {
|
|
102
|
+
if (this.draining) {
|
|
103
|
+
// External concurrent callers await the in-flight completion (its loop
|
|
104
|
+
// picks their claims up). Callers INSIDE the drain's own await chain
|
|
105
|
+
// never reach here — the container skips its post-resolution drain
|
|
106
|
+
// while isDraining() (self-await would deadlock).
|
|
107
|
+
return this.drainPromise ?? Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
this.draining = true;
|
|
110
|
+
const run = (async ()=>{
|
|
111
|
+
try {
|
|
112
|
+
while(this.claimed.length > 0){
|
|
113
|
+
const batch = [];
|
|
114
|
+
while(this.claimed.length > 0){
|
|
115
|
+
const group = this.claimed.shift();
|
|
116
|
+
batch.push({
|
|
117
|
+
group,
|
|
118
|
+
instances: await this.constructGroupAsync(group)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await this.finishBatchAsync(batch);
|
|
122
|
+
}
|
|
123
|
+
} finally{
|
|
124
|
+
this.draining = false;
|
|
125
|
+
this.drainPromise = undefined;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
this.drainPromise = run;
|
|
129
|
+
return run;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Materialize every still-pending group whose providers compute entrypoints
|
|
133
|
+
* (`collectEntrypoints` on a class prototype). Called by the application
|
|
134
|
+
* right before the entrypoint-registry snapshot so computed contributions
|
|
135
|
+
* are never silently absent. The cost — those modules are effectively
|
|
136
|
+
* eager — is the documented price of contributing computed entrypoints.
|
|
137
|
+
*/ async materializeContributors() {
|
|
138
|
+
for (const group of [
|
|
139
|
+
...this.pending.values()
|
|
140
|
+
]){
|
|
141
|
+
if (group.hasEntrypointContributor) this.claim(group.moduleId);
|
|
142
|
+
}
|
|
143
|
+
await this.drainAsync();
|
|
144
|
+
}
|
|
145
|
+
/** Materialize everything still pending (warmup / tests / node runtimes). */ async materializeAll() {
|
|
146
|
+
for (const moduleId of [
|
|
147
|
+
...this.pending.keys()
|
|
148
|
+
])this.claim(moduleId);
|
|
149
|
+
await this.drainAsync();
|
|
150
|
+
}
|
|
151
|
+
groupTokensToConstruct(group) {
|
|
152
|
+
return group.tokens.filter((token)=>this.container.getProviderScope(token) !== Scope.REQUEST);
|
|
153
|
+
}
|
|
154
|
+
constructGroupSync(group) {
|
|
155
|
+
const out = new Set();
|
|
156
|
+
for (const token of this.groupTokensToConstruct(group)){
|
|
157
|
+
try {
|
|
158
|
+
out.add(this.container.resolve(token, group.moduleId));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Only the sync-resolution-of-async-factory error is a seam problem;
|
|
161
|
+
// genuine provider failures must surface untouched.
|
|
162
|
+
if (error instanceof Error && error.message.includes('returned a Promise')) {
|
|
163
|
+
throw this.describeSyncFailure(group.moduleId, error);
|
|
164
|
+
}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return [
|
|
169
|
+
...out
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
async constructGroupAsync(group) {
|
|
173
|
+
const out = new Set();
|
|
174
|
+
for (const token of this.groupTokensToConstruct(group)){
|
|
175
|
+
out.add(await this.container.resolveAsync(token, group.moduleId));
|
|
176
|
+
}
|
|
177
|
+
return [
|
|
178
|
+
...out
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
/** Reversed batch = dependency-before-consumer (see drainSync comment). */ orderBatch(batch) {
|
|
182
|
+
return [
|
|
183
|
+
...batch
|
|
184
|
+
].reverse();
|
|
185
|
+
}
|
|
186
|
+
finishBatchSync(batch) {
|
|
187
|
+
const ordered = this.orderBatch(batch);
|
|
188
|
+
if (this.phase === 'bootstrap') {
|
|
189
|
+
for (const { instances } of ordered)this.absorbed.push(...instances);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const { group, instances } of ordered){
|
|
193
|
+
for (const instance of instances){
|
|
194
|
+
if (hasOnModuleInit(instance) && isThenable(instance.onModuleInit())) {
|
|
195
|
+
throw this.describeSyncFailure(group.moduleId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const { group, instances } of ordered){
|
|
200
|
+
for (const instance of instances){
|
|
201
|
+
if (hasOnApplicationBootstrap(instance) && isThenable(instance.onApplicationBootstrap())) {
|
|
202
|
+
throw this.describeSyncFailure(group.moduleId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.onMaterialized?.(ordered.flatMap((b)=>b.instances));
|
|
207
|
+
}
|
|
208
|
+
async finishBatchAsync(batch) {
|
|
209
|
+
const ordered = this.orderBatch(batch);
|
|
210
|
+
if (this.phase === 'bootstrap') {
|
|
211
|
+
for (const { instances } of ordered)this.absorbed.push(...instances);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const { instances } of ordered){
|
|
215
|
+
for (const instance of instances){
|
|
216
|
+
if (hasOnModuleInit(instance)) await instance.onModuleInit();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const { instances } of ordered){
|
|
220
|
+
for (const instance of instances){
|
|
221
|
+
if (hasOnApplicationBootstrap(instance)) await instance.onApplicationBootstrap();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.onMaterialized?.(ordered.flatMap((b)=>b.instances));
|
|
225
|
+
}
|
|
226
|
+
describeSyncFailure(moduleId, cause) {
|
|
227
|
+
return new Error(`[vela] lazy module '${moduleId}' has async providers or lifecycle hooks and was ` + `triggered through a synchronous resolution path. Reach it through an async seam ` + `first (app.materializeLazyModules(), an async provider) or remove lazy: true.`, cause !== undefined ? {
|
|
228
|
+
cause
|
|
229
|
+
} : undefined);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -2,6 +2,12 @@ import type { Container } from "../container/container";
|
|
|
2
2
|
import type { Token, Type } from "../container/types";
|
|
3
3
|
import type { RouteManager } from "../http/route.manager";
|
|
4
4
|
import type { MiddlewareRouteDefinition } from "./middleware";
|
|
5
|
+
/** What the loader records per lazy module instance (see LazyModuleManager). */
|
|
6
|
+
export interface LazyModuleGroupSpec {
|
|
7
|
+
moduleId: string;
|
|
8
|
+
tokens: Token[];
|
|
9
|
+
hasEntrypointContributor: boolean;
|
|
10
|
+
}
|
|
5
11
|
export declare class ModuleLoader {
|
|
6
12
|
private container;
|
|
7
13
|
private router;
|
|
@@ -16,6 +22,8 @@ export declare class ModuleLoader {
|
|
|
16
22
|
private appProviderTokens;
|
|
17
23
|
private moduleIdByClassKey;
|
|
18
24
|
private seenModuleIds;
|
|
25
|
+
private lazyModuleIds;
|
|
26
|
+
private lazyGroups;
|
|
19
27
|
constructor(container: Container, router: RouteManager);
|
|
20
28
|
load(rootModule: Type): void;
|
|
21
29
|
private getModuleId;
|
|
@@ -25,6 +33,7 @@ export declare class ModuleLoader {
|
|
|
25
33
|
private cacheExports;
|
|
26
34
|
private processModule;
|
|
27
35
|
private warnOnMixedDefaultAndKeyed;
|
|
36
|
+
/** Registers the provider and returns the token it was registered under. */
|
|
28
37
|
private registerProvider;
|
|
29
38
|
private isAppToken;
|
|
30
39
|
private buildExportSet;
|
|
@@ -32,6 +41,18 @@ export declare class ModuleLoader {
|
|
|
32
41
|
getRegisteredProviders(): Token[];
|
|
33
42
|
getAppProviderTokens(token: Token): Token[];
|
|
34
43
|
getConsumerMiddlewareDefinitions(): MiddlewareRouteDefinition[];
|
|
44
|
+
/**
|
|
45
|
+
* Every lazy module instance recorded during load. Handed to the
|
|
46
|
+
* LazyModuleManager at bootstrap; empty when no module opted in.
|
|
47
|
+
*/
|
|
48
|
+
getLazyGroups(): LazyModuleGroupSpec[];
|
|
49
|
+
/**
|
|
50
|
+
* A token is deferred iff EVERY module bucket holding it belongs to a lazy
|
|
51
|
+
* module instance — a token also registered by a non-lazy module stays on
|
|
52
|
+
* the eager pass (and resolving it there claims the lazy sibling, which is
|
|
53
|
+
* the "any use = init" semantic, not a bug).
|
|
54
|
+
*/
|
|
55
|
+
private isLazyOnlyToken;
|
|
35
56
|
resolveAllInstances(): Promise<unknown[]>;
|
|
36
57
|
private routeError;
|
|
37
58
|
}
|
|
@@ -4,6 +4,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from
|
|
|
4
4
|
import { MetadataRegistry } from "../registry/metadata.registry.js";
|
|
5
5
|
import { getOrCreateArray } from "../registry/util.js";
|
|
6
6
|
import { getModuleMetadata, isModule } from "./decorators.js";
|
|
7
|
+
import { LazyModuleManager } from "./lazy-modules.js";
|
|
7
8
|
import { MiddlewareBuilder } from "./middleware.js";
|
|
8
9
|
const APP_TOKENS = new Set([
|
|
9
10
|
APP_GUARD,
|
|
@@ -13,6 +14,16 @@ const APP_TOKENS = new Set([
|
|
|
13
14
|
APP_MIDDLEWARE
|
|
14
15
|
]);
|
|
15
16
|
const DEFAULT_KEY = "default";
|
|
17
|
+
/**
|
|
18
|
+
* Structural check for `ContributesEntrypoints` on a provider's class —
|
|
19
|
+
* deliberately string-coupled to the interface's method name so the loader
|
|
20
|
+
* does not import from `entrypoint/` (layering).
|
|
21
|
+
*/ function declaresEntrypointContributor(provider) {
|
|
22
|
+
const cls = typeof provider === "function" ? provider : provider.useClass;
|
|
23
|
+
if (typeof cls !== "function") return false;
|
|
24
|
+
const proto = cls.prototype;
|
|
25
|
+
return typeof proto?.collectEntrypoints === "function";
|
|
26
|
+
}
|
|
16
27
|
function isDynamicModule(value) {
|
|
17
28
|
// TS 4.9+ narrows `'module' in value` so `value.module` is typed `unknown`
|
|
18
29
|
// — no cast required for the typeof check below.
|
|
@@ -78,6 +89,10 @@ export class ModuleLoader {
|
|
|
78
89
|
// (class, key) → composed moduleId (cached for stable identity within a load)
|
|
79
90
|
moduleIdByClassKey = new Map();
|
|
80
91
|
seenModuleIds = new Set();
|
|
92
|
+
// Lazy (deferred-init) module instances: moduleId → its own tokens in
|
|
93
|
+
// registration order. Consumed by LazyModuleManager via getLazyGroups().
|
|
94
|
+
lazyModuleIds = new Set();
|
|
95
|
+
lazyGroups = new Map();
|
|
81
96
|
constructor(container, router){
|
|
82
97
|
this.container = container;
|
|
83
98
|
this.router = router;
|
|
@@ -87,6 +102,23 @@ export class ModuleLoader {
|
|
|
87
102
|
for (const controller of this.collectedControllers){
|
|
88
103
|
this.router.registerController(controller);
|
|
89
104
|
}
|
|
105
|
+
// Arm the deferred-init seam HERE — at the end of load(), not in
|
|
106
|
+
// bootstrap() — so every consumer of the loader gets identical lazy
|
|
107
|
+
// semantics, including hand-rolled bootstrap paths that never call
|
|
108
|
+
// bootstrap() (@velajs/testing's TestingModuleBuilder.compile builds its
|
|
109
|
+
// own container). Still after all module-load-time resolutions
|
|
110
|
+
// (NestModule.configure), so load-time resolution never claims a group.
|
|
111
|
+
// Registered as a provider so VelaApplication can drive the phase
|
|
112
|
+
// transitions from any of those paths.
|
|
113
|
+
const lazyManager = new LazyModuleManager(this.container);
|
|
114
|
+
for (const group of this.getLazyGroups()){
|
|
115
|
+
lazyManager.registerGroup(group);
|
|
116
|
+
}
|
|
117
|
+
this.container.setLazyHook(lazyManager);
|
|
118
|
+
this.container.register({
|
|
119
|
+
provide: LazyModuleManager,
|
|
120
|
+
useValue: lazyManager
|
|
121
|
+
});
|
|
90
122
|
}
|
|
91
123
|
getModuleId(moduleClass, key) {
|
|
92
124
|
let perClass = this.moduleIdByClassKey.get(moduleClass);
|
|
@@ -238,21 +270,38 @@ export class ModuleLoader {
|
|
|
238
270
|
// include it in its own scope.
|
|
239
271
|
localProviders.add(moduleClass);
|
|
240
272
|
const isGlobal = metadata.isGlobal || isDynamicModule(moduleClassOrDynamic) && moduleClassOrDynamic.global === true;
|
|
273
|
+
const isLazy = metadata.lazy || isDynamicModule(moduleClassOrDynamic) && moduleClassOrDynamic.lazy === true;
|
|
241
274
|
this.container.registerScope({
|
|
242
275
|
moduleId,
|
|
243
276
|
localProviders,
|
|
244
277
|
importedModules: importedModuleIds,
|
|
245
278
|
exportedTokens: new Set(allExports),
|
|
246
|
-
isGlobal
|
|
279
|
+
isGlobal,
|
|
280
|
+
lazy: isLazy
|
|
247
281
|
});
|
|
282
|
+
const lazyTokens = [];
|
|
283
|
+
let hasEntrypointContributor = false;
|
|
248
284
|
for (const provider of allProviders){
|
|
249
|
-
this.registerProvider(provider, moduleId);
|
|
285
|
+
const registered = this.registerProvider(provider, moduleId);
|
|
286
|
+
if (isLazy && registered !== undefined) {
|
|
287
|
+
lazyTokens.push(registered);
|
|
288
|
+
hasEntrypointContributor ||= declaresEntrypointContributor(provider);
|
|
289
|
+
}
|
|
250
290
|
}
|
|
251
291
|
for (const controller of allControllers){
|
|
252
292
|
// Register the controller in its owning module's bucket so its
|
|
253
293
|
// dependencies resolve from the module's POV (vs `__root__`'s).
|
|
254
294
|
this.container.register(controller, moduleId);
|
|
255
295
|
this.collectedControllers.add(controller);
|
|
296
|
+
if (isLazy) lazyTokens.push(controller);
|
|
297
|
+
}
|
|
298
|
+
if (isLazy) {
|
|
299
|
+
this.lazyModuleIds.add(moduleId);
|
|
300
|
+
this.lazyGroups.set(moduleId, {
|
|
301
|
+
moduleId,
|
|
302
|
+
tokens: lazyTokens,
|
|
303
|
+
hasEntrypointContributor
|
|
304
|
+
});
|
|
256
305
|
}
|
|
257
306
|
// Propagate module-level Use* decorators (@UseGuards, @UseInterceptors, etc.) to each controller
|
|
258
307
|
for (const controller of allControllers){
|
|
@@ -293,35 +342,36 @@ export class ModuleLoader {
|
|
|
293
342
|
console.warn(message);
|
|
294
343
|
}
|
|
295
344
|
}
|
|
296
|
-
registerProvider(provider, moduleId) {
|
|
345
|
+
/** Registers the provider and returns the token it was registered under. */ registerProvider(provider, moduleId) {
|
|
297
346
|
if (typeof provider === "function") {
|
|
298
347
|
// Per-module bucket: the same class can be registered in multiple
|
|
299
348
|
// modules' buckets simultaneously without collision.
|
|
300
349
|
this.container.register(provider, moduleId);
|
|
301
350
|
this.registeredProviders.push(provider);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (this.isAppToken(token)) {
|
|
309
|
-
// Multiple APP_* providers within the same module bucket need
|
|
310
|
-
// distinct tokens so they don't overwrite each other in the bucket
|
|
311
|
-
// Map. Across buckets, `container.resolveAll(APP_GUARD)` walks
|
|
312
|
-
// every bucket — no need to mark synthetic tokens global.
|
|
313
|
-
const syntheticToken = new InjectionToken(`${token.toString()}:${this.appProviderCounter++}`);
|
|
314
|
-
this.container.register({
|
|
315
|
-
...provider,
|
|
316
|
-
provide: syntheticToken
|
|
317
|
-
}, moduleId);
|
|
318
|
-
this.registeredProviders.push(syntheticToken);
|
|
319
|
-
getOrCreateArray(this.appProviderTokens, token).push(syntheticToken);
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
351
|
+
return provider;
|
|
352
|
+
}
|
|
353
|
+
const token = provider.provide;
|
|
354
|
+
if (!token) {
|
|
322
355
|
this.container.register(provider, moduleId);
|
|
323
|
-
|
|
356
|
+
return undefined;
|
|
324
357
|
}
|
|
358
|
+
if (this.isAppToken(token)) {
|
|
359
|
+
// Multiple APP_* providers within the same module bucket need
|
|
360
|
+
// distinct tokens so they don't overwrite each other in the bucket
|
|
361
|
+
// Map. Across buckets, `container.resolveAll(APP_GUARD)` walks
|
|
362
|
+
// every bucket — no need to mark synthetic tokens global.
|
|
363
|
+
const syntheticToken = new InjectionToken(`${token.toString()}:${this.appProviderCounter++}`);
|
|
364
|
+
this.container.register({
|
|
365
|
+
...provider,
|
|
366
|
+
provide: syntheticToken
|
|
367
|
+
}, moduleId);
|
|
368
|
+
this.registeredProviders.push(syntheticToken);
|
|
369
|
+
getOrCreateArray(this.appProviderTokens, token).push(syntheticToken);
|
|
370
|
+
return syntheticToken;
|
|
371
|
+
}
|
|
372
|
+
this.container.register(provider, moduleId);
|
|
373
|
+
this.registeredProviders.push(token);
|
|
374
|
+
return token;
|
|
325
375
|
}
|
|
326
376
|
isAppToken(token) {
|
|
327
377
|
return APP_TOKENS.has(token);
|
|
@@ -364,6 +414,25 @@ export class ModuleLoader {
|
|
|
364
414
|
...this.consumerMiddlewareDefinitions
|
|
365
415
|
];
|
|
366
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Every lazy module instance recorded during load. Handed to the
|
|
419
|
+
* LazyModuleManager at bootstrap; empty when no module opted in.
|
|
420
|
+
*/ getLazyGroups() {
|
|
421
|
+
return [
|
|
422
|
+
...this.lazyGroups.values()
|
|
423
|
+
];
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* A token is deferred iff EVERY module bucket holding it belongs to a lazy
|
|
427
|
+
* module instance — a token also registered by a non-lazy module stays on
|
|
428
|
+
* the eager pass (and resolving it there claims the lazy sibling, which is
|
|
429
|
+
* the "any use = init" semantic, not a bug).
|
|
430
|
+
*/ isLazyOnlyToken(token) {
|
|
431
|
+
if (this.lazyModuleIds.size === 0) return false;
|
|
432
|
+
const owners = this.container.getOwnerModuleIds(token);
|
|
433
|
+
if (owners.length === 0) return false;
|
|
434
|
+
return owners.every((id)=>this.lazyModuleIds.has(id));
|
|
435
|
+
}
|
|
367
436
|
async resolveAllInstances() {
|
|
368
437
|
const instanceSet = new Set();
|
|
369
438
|
for (const token of this.registeredProviders){
|
|
@@ -371,6 +440,9 @@ export class ModuleLoader {
|
|
|
371
440
|
if (this.container.getProviderScope(token) === Scope.REQUEST) {
|
|
372
441
|
continue;
|
|
373
442
|
}
|
|
443
|
+
if (this.isLazyOnlyToken(token)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
374
446
|
const instance = await this.container.resolveAsync(token);
|
|
375
447
|
instanceSet.add(instance);
|
|
376
448
|
} catch (err) {
|
|
@@ -385,6 +457,9 @@ export class ModuleLoader {
|
|
|
385
457
|
if (this.container.getProviderScope(controller) === Scope.REQUEST) {
|
|
386
458
|
continue;
|
|
387
459
|
}
|
|
460
|
+
if (this.isLazyOnlyToken(controller)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
388
463
|
const instance = await this.container.resolveAsync(controller);
|
|
389
464
|
instanceSet.add(instance);
|
|
390
465
|
} catch (err) {
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -81,6 +81,13 @@ export interface DynamicModule {
|
|
|
81
81
|
controllers?: Type[];
|
|
82
82
|
exports?: Array<Type | InjectionToken>;
|
|
83
83
|
global?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Defer this module instance's providers/controllers to first use: nothing
|
|
86
|
+
* constructs at bootstrap; the first resolution of any of its tokens
|
|
87
|
+
* materializes the whole group and replays its lifecycle hooks (memoized).
|
|
88
|
+
* See MODULE_AUTHORING.md "Lazy modules" for the contract.
|
|
89
|
+
*/
|
|
90
|
+
lazy?: boolean;
|
|
84
91
|
}
|
|
85
92
|
export interface ModuleOptions {
|
|
86
93
|
providers?: Array<Type | ProviderOptions>;
|
|
@@ -88,6 +95,8 @@ export interface ModuleOptions {
|
|
|
88
95
|
imports?: ModuleImport[];
|
|
89
96
|
exports?: Array<Type | InjectionToken>;
|
|
90
97
|
isGlobal?: boolean;
|
|
98
|
+
/** Defer to first use (see {@link DynamicModule.lazy}). */
|
|
99
|
+
lazy?: boolean;
|
|
91
100
|
}
|
|
92
101
|
export interface ModuleMetadata {
|
|
93
102
|
providers: Array<Type | ProviderOptions>;
|
|
@@ -95,4 +104,5 @@ export interface ModuleMetadata {
|
|
|
95
104
|
imports: ModuleImport[];
|
|
96
105
|
exports: Array<Type | InjectionToken>;
|
|
97
106
|
isGlobal: boolean;
|
|
107
|
+
lazy: boolean;
|
|
98
108
|
}
|
|
@@ -15,6 +15,9 @@ export class ScheduleModule {
|
|
|
15
15
|
}
|
|
16
16
|
ScheduleModule = _ts_decorate([
|
|
17
17
|
Module({
|
|
18
|
+
// Lazy: the @Cron/@Interval discovery pass runs when ScheduleRegistry is
|
|
19
|
+
// first resolved (executor bootstrap, introspection, first dispatch).
|
|
20
|
+
lazy: true,
|
|
18
21
|
providers: [
|
|
19
22
|
ScheduleRegistry
|
|
20
23
|
],
|
|
@@ -28,6 +28,9 @@ export class SeederModule extends ConfigurableModuleClass {
|
|
|
28
28
|
}
|
|
29
29
|
SeederModule = _ts_decorate([
|
|
30
30
|
Module({
|
|
31
|
+
// Lazy: the @Seeder metadata scan runs when SeederRegistry is first
|
|
32
|
+
// resolved (runSeeders) — hook replay repopulates it correctly.
|
|
33
|
+
lazy: true,
|
|
31
34
|
providers: [
|
|
32
35
|
SeederRegistry
|
|
33
36
|
],
|