@velajs/vela 1.12.0 → 1.14.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/dist/application.d.ts +17 -0
  3. package/dist/application.js +94 -3
  4. package/dist/container/container.d.ts +21 -1
  5. package/dist/container/container.js +89 -2
  6. package/dist/container/types.d.ts +27 -0
  7. package/dist/discovery/discovery.service.d.ts +8 -0
  8. package/dist/discovery/discovery.service.js +11 -0
  9. package/dist/entrypoint/entrypoint.registry.d.ts +3 -1
  10. package/dist/entrypoint/entrypoint.registry.js +9 -3
  11. package/dist/event-emitter/event-emitter.module.js +1 -0
  12. package/dist/factory/bootstrap.js +4 -0
  13. package/dist/http/route.manager.js +5 -0
  14. package/dist/i18n/i18n.module.js +5 -0
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/module/decorators.js +4 -2
  18. package/dist/module/define-module.d.ts +7 -0
  19. package/dist/module/define-module.js +9 -4
  20. package/dist/module/lazy-modules.d.ts +82 -0
  21. package/dist/module/lazy-modules.js +231 -0
  22. package/dist/module/module-loader.d.ts +21 -0
  23. package/dist/module/module-loader.js +99 -24
  24. package/dist/pipeline/index.d.ts +2 -0
  25. package/dist/pipeline/index.js +1 -0
  26. package/dist/pipeline/scoped-components.d.ts +26 -0
  27. package/dist/pipeline/scoped-components.js +27 -0
  28. package/dist/queue/index.d.ts +10 -0
  29. package/dist/queue/index.js +12 -0
  30. package/dist/queue/inline.driver.d.ts +30 -0
  31. package/dist/queue/inline.driver.js +58 -0
  32. package/dist/queue/queue.binding.d.ts +23 -0
  33. package/dist/queue/queue.binding.js +53 -0
  34. package/dist/queue/queue.client.d.ts +20 -0
  35. package/dist/queue/queue.client.js +33 -0
  36. package/dist/queue/queue.decorators.d.ts +23 -0
  37. package/dist/queue/queue.decorators.js +46 -0
  38. package/dist/queue/queue.dispatch.d.ts +36 -0
  39. package/dist/queue/queue.dispatch.js +102 -0
  40. package/dist/queue/queue.module.d.ts +32 -0
  41. package/dist/queue/queue.module.js +80 -0
  42. package/dist/queue/queue.tokens.d.ts +21 -0
  43. package/dist/queue/queue.tokens.js +34 -0
  44. package/dist/queue/queue.types.d.ts +61 -0
  45. package/dist/queue/queue.types.js +4 -0
  46. package/dist/registry/types.d.ts +10 -0
  47. package/dist/schedule/schedule.module.js +3 -0
  48. package/dist/seeder/seeder.module.js +3 -0
  49. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,106 @@
1
1
  # Changelog
2
2
 
3
- ## 1.11.0 (2026-07-04)
3
+ ## 1.14.0 (2026-07-04)
4
+
5
+ First-party `QueueModule` (roadmap phase 3, "the openness proof"): a whole
6
+ feature module authored on the public API alone — `defineModule` (+ `lazy`),
7
+ `createDiscoverableDecorator`, `registerEntrypointKind`, `app.entrypoints`,
8
+ `runInEntrypointScope`, `buildEntrypointExecutionContext`, `PipelineRunner` —
9
+ machine-verified by an import audit test.
10
+
11
+ ### Added
12
+
13
+ - **`@velajs/vela/queue`** — platform-agnostic queue subsystem (subpath-only;
14
+ deliberately NOT re-exported from the main barrel because
15
+ `@velajs/cloudflare` already exports an unrelated CF-binding `QueueModule`).
16
+ Producers: `QueueModule.forRoot({ queues: ['email'] })` + per-queue
17
+ `QueueClient` injected via `queueToken(name)` (`add(jobName, data,
18
+ { delayMs? })`). Consumers: `@Processor(queue)` classes with
19
+ `@Process(jobName?)` handlers (named wins over wildcard; duplicates warn,
20
+ first-wins). Dispatch runs each job in `runInEntrypointScope`
21
+ (request-scoped deps rebuild per job), re-resolves processors by token
22
+ through the async seam (lazy consumer modules — async init hooks included —
23
+ materialize on first job), and applies scoped
24
+ guards/interceptors/filters through the shared pipeline; unclaimed errors
25
+ rethrow for platform retry. App-wide `APP_*` components deliberately do NOT
26
+ run around queue jobs (cloudflare queue/scheduled parity; diverges from the
27
+ WebSocket dispatcher — revisit framework-wide). The in-core `inline()`
28
+ driver (edge-pure, no timers) delivers on a microtask (`immediate`) or via
29
+ `flush()` (`manual`, rejects with `AggregateError` on unclaimed handler
30
+ errors); platform drivers implement `QueueDriver` out-of-core and call
31
+ `dispatchQueueJob(container, app.entrypoints, job)`. The module is
32
+ `lazy: true` (dogfoods 1.13): consumer-only workers defer it entirely;
33
+ an eager producer's client injection materializes it at bootstrap.
34
+ `queues` is structural — `forRootAsync({ queues, useFactory })`.
35
+ - **`resolveScopedComponents(type, class, method, container)`** — public
36
+ pipeline seam surfaced by the openness proof: scoped
37
+ `@UseGuards`/`@UsePipes`/`@UseInterceptors`/`@UseFilters` resolution for
38
+ custom dispatchers (declaration order preserved; conventions like
39
+ closest-first filter reversal stay with the caller).
40
+ - **`EntrypointRegistry` is injectable** — the per-app registry registers
41
+ into the container (global token) at the end of
42
+ `callOnApplicationBootstrap()`, so providers that dispatch entrypoints
43
+ themselves (the queue module's in-process driver binding) resolve it
44
+ instead of needing a back-reference to the app; `container.has(...)` probes
45
+ it safely pre-bootstrap (the queue binding falls back to
46
+ `DiscoveryService` + `deferLazy` for deliveries during bootstrap).
47
+
48
+ ## 1.13.0 (2026-07-04)
49
+
50
+ Cold-start laziness (roadmap phase 3): modules can defer their entire init to
51
+ first use, and the in-core subsystems an HTTP-only worker doesn't touch now
52
+ cost it nothing at bootstrap.
53
+
54
+ ### Added
55
+
56
+ - **Lazy modules** — `@Module({ lazy: true })`, `DynamicModule.lazy`, and
57
+ `defineModule({ lazy: true })` (also recognized per call site like
58
+ `isGlobal`) defer a module *instance*'s entire provider/controller group:
59
+ nothing constructs during `VelaFactory.create`. The first resolution of any
60
+ of its tokens (injection, `app.get()`, a request hitting its controller, a
61
+ dispatcher re-resolving an entrypoint token) claims the module; when the
62
+ resolution stack unwinds, the group materializes and its
63
+ `onModuleInit`/`onApplicationBootstrap` hooks replay in registration order,
64
+ exactly once (memoized). Materialized instances join the instance flow so
65
+ shutdown hooks stay symmetric; untouched modules get neither init nor
66
+ shutdown hooks. Triggers during bootstrap absorb the group into the normal
67
+ hook phases, ordered dependency-before-consumer. `useValue` reads (options
68
+ tokens) do not trigger. Sync seams (`app.get`, the request pipeline) throw
69
+ a descriptive error for lazy modules with async providers/hooks — reach
70
+ those through `app.materializeLazyModules()` (the new warmup escape hatch)
71
+ or keep them sync. Authoring contract: MODULE_AUTHORING.md "Lazy modules".
72
+ - **`app.materializeLazyModules()`** — materialize every still-pending lazy
73
+ module (async-safe); warmup/eager-everything escape hatch.
74
+ - **`Container.isLazyPending(token)` / `Container.isInstantiated(token)`** —
75
+ non-triggering diagnostics (build-time probes, cold-start regression tests).
76
+ - **`DiscoveryFilter.deferLazy`** — discovery returns providers of
77
+ unmaterialized lazy modules as metadata-only entries (`instance:
78
+ undefined`, mirroring the request-scoped convention) instead of forcing the
79
+ group. `EntrypointRegistry.build` uses it: declared-kind entrypoints of
80
+ lazy modules are metadata-only in `app.entrypoints`; dispatchers that
81
+ re-resolve by token (cloudflare cron/queue/scheduled already do)
82
+ materialize the owning module at dispatch time. `ContributesEntrypoints`
83
+ providers in lazy modules are materialized right before the snapshot —
84
+ computed contributions can't defer (documented cost).
85
+
86
+ ### Changed
87
+
88
+ - **`EventEmitterModule`, `ScheduleModule`, `SeederModule`, `I18nModule` are
89
+ now lazy.** An HTTP-only worker that imports them but never emits an event,
90
+ reads the schedule registry, runs seeders, or translates pays zero
91
+ cold-start cost for them — no subscriber wiring pass, no `@Cron` discovery
92
+ walk, no merged-message snapshot. Every consumer path is a trigger, so
93
+ observable behavior is unchanged (`app.get(EventEmitter).emit(...)` wires
94
+ subscribers first; `runSeeders()` populates the registry via hook replay).
95
+ `WebSocketModule` and `ScheduleNodeModule` deliberately stay eager (gateway
96
+ injection drags the WS chain in anyway; the node executor is self-driving).
97
+ - `ModuleLoader.resolveAllInstances()` skips tokens owned exclusively by lazy
98
+ module instances; a token also registered by a non-lazy module stays on the
99
+ eager pass. Route building no longer instantiate-probes middleware tokens
100
+ that are lazy-pending (default priority 0) — the probe would have defeated
101
+ i18n's deferral at route build.
102
+
103
+ ## 1.12.0 (2026-07-04)
4
104
 
5
105
  The module-model release: one blessed authoring path plus public kernel
6
106
  extension points, so feature modules (websocket, storage, queue, …) are built
@@ -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:
@@ -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,96 @@ 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
- for (const instance of this.instances){
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 (const instance of this.instances){
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
+ // Make the per-app registry injectable (global token): providers that
224
+ // dispatch entrypoints themselves (the queue module's in-process driver
225
+ // binding) resolve it instead of needing a back-reference to the app.
226
+ // Registered AFTER build so anything resolving it sees the final registry;
227
+ // pre-bootstrap resolution attempts fail the `has()` probe and defer.
228
+ this.container.register({
229
+ provide: EntrypointRegistry,
230
+ useValue: this.entrypointRegistry
231
+ });
232
+ this.container.markGlobalToken(EntrypointRegistry);
233
+ }
234
+ /**
235
+ * Materialize every still-pending lazy module (async-safe): construct the
236
+ * groups, replay their lifecycle hooks, and add their instances to the
237
+ * shutdown flow. Warmup escape hatch for tests and node runtimes that want
238
+ * eager-everything semantics back after bootstrap.
239
+ */ async materializeLazyModules() {
240
+ await this.lazyManager?.materializeAll();
150
241
  }
151
242
  /**
152
243
  * 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
- return this.resolveRegistration(registration, requestingModuleId);
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
- return registrations.map((r)=>this.resolveRegistration(r, requestingModuleId));
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[]): Promise<EntrypointRegistry>;
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,
@@ -11,6 +11,7 @@ export class EventEmitterModule {
11
11
  }
12
12
  EventEmitterModule = _ts_decorate([
13
13
  Module({
14
+ lazy: true,
14
15
  providers: [
15
16
  EventEmitter,
16
17
  EventEmitterSubscriber
@@ -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') {
@@ -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,
package/dist/index.d.ts CHANGED
@@ -46,8 +46,8 @@ export type { AdapterContext, RuntimeAdapter } from './factory/adapter';
46
46
  export type { VelaCreateOptions } from './factory';
47
47
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
48
48
  export type { Plugin } from './plugin/plugin';
49
- export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
50
- export type { PipelineRunOptions } from './pipeline/index';
49
+ export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
50
+ export type { PipelineRunOptions, ResolvedComponentMap } from './pipeline/index';
51
51
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, ReflectableDecorator, CreateDecoratorOptions, } from './pipeline/index';
52
52
  export type { Constructor, MiddlewareType, GuardType, PipeType, InterceptorType, FilterType, } from './registry/index';
53
53
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe, } from './pipeline/index';