@velajs/vela 1.2.0 → 1.3.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 CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ Module boundaries are enforced. NestJS-shape: a service cannot resolve dependencies from a module it didn't import. Bootstrap is consolidated into a single primitive, discovery failures are diagnostically routed, and a generic plugin composer is included.
6
+
7
+ ### New
8
+
9
+ - **Module visibility enforcement.** `VelaFactory.create(Mod)` checks every constructor injection: the token must be declared locally, exported by an imported module, marked `@Global`, or be an `InjectionToken` with a default factory. Throws `ModuleVisibilityError` with an actionable message on violation. Auto-registration of unknown class tokens is gone — declare every dependency in a module's `providers`. There is no opt-out flag; `ModuleRef.create()` is the sandbox escape hatch for transient instantiation.
10
+
11
+ - **`bootstrap(rootModule, options)`** — the wiring primitive shared by `VelaFactory.create`, `@velajs/testing`, and any non-HTTP consumer (CLI tools, custom runtimes). Returns `{ container, routeManager, loader }` without running lifecycle hooks or building the Hono app. `VelaFactory.create` is now a thin wrapper that calls `bootstrap()` then runs `OnModuleInit` / `OnApplicationBootstrap` and builds routes. Exported from `@velajs/vela` and `@velajs/vela/internal`.
12
+
13
+ - **Module visibility primitives.** `Container({ diagnostics })`, `ModuleScope`, `Container.registerScope`, `Container.markGlobalToken`, and `ModuleVisibilityError`. `useExisting` aliases honor visibility (alias targets the caller can't see are rejected).
14
+
15
+ - **Self-providing tokens stay visible.** `new InjectionToken('X', { factory: () => Y })` is implicitly globally visible — the token's factory IS its provider, so no explicit declaration is required.
16
+
17
+ - **Factory inject is a framework-level escape hatch.** `useFactory` provider deps (including `forRootAsync`, `registerAsync`) resolve without a module-visibility requester, so factory `inject: [...]` arrays can pull from the importing module's scope. A future `forRootAsync({ imports })` will tighten this; for now it's permissive by design.
18
+
19
+ - **Discovery diagnostics: `{ diagnostics: 'silent' | 'log' | 'throw' }`** (default `'log'`). Failed provider/controller resolution at `loader.resolveAllInstances`, schedule discovery, event-emitter discovery, and runtime job execution route through one dispatcher. `ModuleVisibilityError` always propagates regardless of mode.
20
+
21
+ - **Live Cloudflare Workers smoke tests.** `pnpm test:workers` runs vela inside workerd via `@cloudflare/vitest-pool-workers` (driven by a test-only `wrangler.toml`). Validates the edge-runtime contract end-to-end — boot, request lifecycle, per-request DI without `AsyncLocalStorage`, handler-chain order, OpenAPI mount — beyond what the static edge-audit can catch. Wired into CI alongside `pnpm test`.
22
+
23
+ - **Framework primitives are globally visible.** `Container`, `ModuleRef`, and `APP_GUARD`/`APP_PIPE`/`APP_INTERCEPTOR`/`APP_FILTER`/`APP_MIDDLEWARE` are marked global at boot — resolvable from any module without explicit imports, in both strict and non-strict mode. `ModuleRef.create()` continues to be the sandbox escape hatch (visibility check skipped for transient instantiation).
24
+
25
+ - **Plugin manifest + composer** — `definePlugin({ id, version, module, dependsOn?, metadata? })` and `composePlugins(plugins): DynamicModule` with topological sort, cycle detection, and missing-dep detection. Produces a global module that exposes a queryable `PluginRegistry` via `PLUGIN_REGISTRY_TOKEN` (`list`, `get(id)`, `dependents(id)`).
26
+
27
+ - **New types**: `ModuleScope`, `ContainerOptions`, `BootstrapOptions`, `BootstrapResult`, `Diagnostics`, `Plugin` — exported from both root and `/internal`.
28
+
29
+ ### Internal cleanup
30
+
31
+ - **`Container` constructor accepts `ContainerOptions`** (`{ diagnostics? }`). Threads `requestingModuleId` through `resolve` / `resolveAsync` / `resolveAll`. Per-module scopes are tracked via `registerScope`; framework-internal globals via `markGlobalToken`. `providerOrigin: Map<Token, string>` records each provider's declaring module so constructor injections resolve from the *class's* module, not the caller's. Visibility enforcement runs whenever a `requestingModuleId` is supplied — there is no on/off switch.
32
+
33
+ - **`ModuleLoader` registers a `ModuleScope` per module** before recursing into imports — `localProviders` includes the module class itself (so `NestModule.configure()` resolution stays inside its own scope), controllers, and every provider token. Synthetic `APP_*` tokens are marked global at mint time so RouteManager's request-time resolutions (no requester) keep working.
34
+
35
+ - **`createChild()` shares container state by reference** (providers, scopes, globals, providerOrigin); `createDetached()` copies providers + providerOrigin and shares scopes + globals. Re-registering a token on a detached container without a moduleId clears any stale `providerOrigin` so sandbox-local registrations don't inherit a misleading owner.
36
+
37
+ - **`resolveAsync` factory branch now unwraps `ForwardRef` in `inject`** (mirroring the sync `resolveFactory`). Previously asymmetric — sync path worked, async path silently failed during `loader.resolveAllInstances` and was swallowed by the discovery `try/catch`.
38
+
39
+ - **Dropped unused `Container.parent` field** (audit #10). Was assigned in `createChild()` but never read.
40
+
41
+ - **Bootstrap consolidated into `src/factory/bootstrap.ts`** — `VelaFactory.create` no longer hand-rolls the APP_* / consumer-middleware / global-prefix wiring sequence. Net code reduction in `factory.ts`.
42
+
3
43
  ## 1.1.0 (2026-04-30)
4
44
 
5
45
  Architectural remodel: one metadata model, one storage, public surface trimmed, internal primitives exposed via a stable subpath.
@@ -0,0 +1,9 @@
1
+ declare const _default: {
2
+ fetch(request: Request, _env: unknown, _ctx: unknown): Promise<Response>;
3
+ };
4
+ export default _default;
5
+ declare global {
6
+ interface ExportedHandler<Env = unknown> {
7
+ fetch?(request: Request, env: Env, ctx: unknown): Response | Promise<Response>;
8
+ }
9
+ }
@@ -1,14 +1,23 @@
1
1
  import { Scope } from '../constants';
2
- import type { ProviderOptions, Token, Type } from './types';
2
+ import type { ContainerOptions, Diagnostics, ModuleScope, ProviderOptions, Token, Type } from './types';
3
3
  export declare class Container {
4
4
  private providers;
5
5
  private resolutionStack;
6
- private parent;
7
6
  private requestInstances;
8
- register<T>(provider: Type<T> | ProviderOptions<T>): this;
7
+ private scopes;
8
+ private globals;
9
+ private providerOrigin;
10
+ private diagnostics;
11
+ constructor(options?: ContainerOptions);
12
+ register<T>(provider: Type<T> | ProviderOptions<T>, declaringModuleId?: string): this;
9
13
  private registerClass;
10
14
  private registerOptions;
11
- resolve<T>(token: Token<T>): T;
15
+ private recordOrigin;
16
+ registerScope(scope: ModuleScope): void;
17
+ markGlobalToken(token: Token): void;
18
+ getDiagnostics(): Diagnostics;
19
+ resolve<T>(token: Token<T>, requestingModuleId?: string): T;
20
+ resolveAll<T>(token: Token<T>, requestingModuleId?: string): T[];
12
21
  has(token: Token): boolean;
13
22
  getProviderScope(token: Token): Scope | undefined;
14
23
  getTokens(): Token[];
@@ -23,7 +32,9 @@ export declare class Container {
23
32
  private resolveRegistration;
24
33
  private resolveClass;
25
34
  private resolveFactory;
26
- resolveAsync<T>(token: Token<T>): Promise<T>;
35
+ resolveAsync<T>(token: Token<T>, requestingModuleId?: string): Promise<T>;
27
36
  private createLazyProxy;
37
+ private assertVisible;
38
+ private isExportedFromImports;
28
39
  private tokenToString;
29
40
  }
@@ -1,21 +1,27 @@
1
1
  import { Scope } from "../constants.js";
2
2
  import { getConstructorDependencies, getInjectMetadata, getScope, isInjectable } from "./decorators.js";
3
- import { ForwardRef, InjectionToken } from "./types.js";
3
+ import { ForwardRef, InjectionToken, ModuleVisibilityError } from "./types.js";
4
4
  const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips type-only imports at ' + 'runtime and `design:paramtypes` emits `Object`/`undefined` for their ' + 'positions. Use a runtime `import { X }` for DI tokens.';
5
5
  export class Container {
6
6
  providers = new Map();
7
7
  resolutionStack = new Set();
8
- parent = null;
9
8
  requestInstances = new Map();
10
- register(provider) {
9
+ scopes = new Map();
10
+ globals = new Set();
11
+ providerOrigin = new Map();
12
+ diagnostics;
13
+ constructor(options = {}){
14
+ this.diagnostics = options.diagnostics ?? 'log';
15
+ }
16
+ register(provider, declaringModuleId) {
11
17
  if (typeof provider === 'function') {
12
- this.registerClass(provider);
18
+ this.registerClass(provider, declaringModuleId);
13
19
  } else {
14
- this.registerOptions(provider);
20
+ this.registerOptions(provider, declaringModuleId);
15
21
  }
16
22
  return this;
17
23
  }
18
- registerClass(target) {
24
+ registerClass(target, declaringModuleId) {
19
25
  if (!isInjectable(target)) {
20
26
  console.warn(`Warning: ${target.name} is not decorated with @Injectable(). ` + `It will be registered but dependency resolution may not work correctly.`);
21
27
  }
@@ -25,8 +31,9 @@ export class Container {
25
31
  scope,
26
32
  useClass: target
27
33
  });
34
+ this.recordOrigin(target, declaringModuleId);
28
35
  }
29
- registerOptions(options) {
36
+ registerOptions(options, declaringModuleId) {
30
37
  const token = options.provide;
31
38
  if (!token) {
32
39
  throw new Error('Provider registration requires a token');
@@ -49,30 +56,72 @@ export class Container {
49
56
  registration.useClass = token;
50
57
  }
51
58
  this.providers.set(token, registration);
59
+ this.recordOrigin(token, declaringModuleId);
60
+ // For aliases like `{ provide: Foo, useClass: Bar }`, also record Bar's
61
+ // origin so `resolveClass(Bar)` resolves Bar's deps from the alias's
62
+ // declaring module. Idempotent if Bar === Foo.
63
+ if (options.useClass !== undefined) {
64
+ this.recordOrigin(options.useClass, declaringModuleId);
65
+ }
66
+ }
67
+ recordOrigin(token, moduleId) {
68
+ if (moduleId !== undefined) {
69
+ this.providerOrigin.set(token, moduleId);
70
+ } else {
71
+ // Sandbox containers (createDetached) re-register tokens with no
72
+ // moduleId; clearing keeps the token "module-less" so it's only
73
+ // resolvable when the requester is also undefined.
74
+ this.providerOrigin.delete(token);
75
+ }
76
+ }
77
+ registerScope(scope) {
78
+ this.scopes.set(scope.moduleId, scope);
79
+ if (scope.isGlobal) {
80
+ // Match NestJS / the loader's existing globalExports semantic: only
81
+ // exported tokens become globally visible. Non-exported providers of
82
+ // a @Global module still need explicit imports.
83
+ for (const token of scope.exportedTokens)this.globals.add(token);
84
+ }
85
+ }
86
+ markGlobalToken(token) {
87
+ this.globals.add(token);
88
+ }
89
+ getDiagnostics() {
90
+ return this.diagnostics;
52
91
  }
53
- resolve(token) {
92
+ resolve(token, requestingModuleId) {
93
+ if (requestingModuleId !== undefined) {
94
+ this.assertVisible(requestingModuleId, token);
95
+ }
54
96
  const registration = this.providers.get(token);
55
97
  if (!registration) {
56
98
  // `Object`/undefined at a token position is the fingerprint of a
57
- // type-only import that TypeScript stripped — emit the hint BEFORE
58
- // auto-registering Object as a provider (which would silently "succeed").
99
+ // type-only import that TypeScript stripped — emit the hint before
100
+ // attempting any other recovery.
59
101
  if (token === Object || token == null) {
60
102
  throw new Error(`No provider found for token: ${this.tokenToString(token)}. ` + IMPORT_TYPE_HINT);
61
103
  }
62
104
  if (typeof token === 'function') {
63
- this.register(token);
64
- return this.resolve(token);
105
+ throw new Error(`No provider found for token: ${this.tokenToString(token)}. ` + `Declare it in a module's providers.`);
65
106
  }
107
+ // InjectionToken default factories are explicit user declarations
108
+ // attached to the token at construction — self-providing singletons.
66
109
  if (token instanceof InjectionToken && token.options?.factory) {
67
110
  this.register({
68
111
  provide: token,
69
112
  useFactory: token.options.factory
70
113
  });
71
- return this.resolve(token);
114
+ return this.resolve(token, requestingModuleId);
72
115
  }
73
116
  throw new Error(`No provider found for token: ${this.tokenToString(token)}`);
74
117
  }
75
- return this.resolveRegistration(registration);
118
+ return this.resolveRegistration(registration, requestingModuleId);
119
+ }
120
+ resolveAll(token, requestingModuleId) {
121
+ if (!this.has(token)) return [];
122
+ return [
123
+ this.resolve(token, requestingModuleId)
124
+ ];
76
125
  }
77
126
  has(token) {
78
127
  return this.providers.has(token);
@@ -88,27 +137,44 @@ export class Container {
88
137
  * The child shares the parent's providers but caches REQUEST-scoped
89
138
  * instances separately per child (per request).
90
139
  */ createChild() {
91
- const child = new Container();
92
- child.parent = this;
93
- child.providers = this.providers; // share provider registrations
140
+ const child = new Container({
141
+ diagnostics: this.diagnostics
142
+ });
143
+ // Share state by reference — request-scope children must see the same
144
+ // module graph as the root.
145
+ child.providers = this.providers;
146
+ child.scopes = this.scopes;
147
+ child.globals = this.globals;
148
+ child.providerOrigin = this.providerOrigin;
94
149
  return child;
95
150
  }
96
151
  createDetached() {
97
- const child = new Container();
98
- child.providers = new Map(this.providers); // copy, not share
152
+ const child = new Container({
153
+ diagnostics: this.diagnostics
154
+ });
155
+ // Copy mutable per-resolution state; share static module-graph metadata
156
+ // so sandbox resolutions can still see exported providers.
157
+ child.providers = new Map(this.providers);
158
+ child.providerOrigin = new Map(this.providerOrigin);
159
+ child.scopes = this.scopes;
160
+ child.globals = this.globals;
99
161
  return child;
100
162
  }
101
163
  clear() {
102
164
  this.providers.clear();
103
165
  this.resolutionStack.clear();
104
166
  this.requestInstances.clear();
167
+ this.scopes.clear();
168
+ this.globals.clear();
169
+ this.providerOrigin.clear();
105
170
  }
106
- resolveRegistration(registration) {
171
+ resolveRegistration(registration, requestingModuleId) {
107
172
  if (registration.useValue !== undefined) {
108
173
  return registration.useValue;
109
174
  }
110
175
  if (registration.useExisting) {
111
- return this.resolve(registration.useExisting);
176
+ // Pass through the original requester to catch alias leaks
177
+ return this.resolve(registration.useExisting, requestingModuleId);
112
178
  }
113
179
  // Singleton: return cached from registration (shared across all containers)
114
180
  if (registration.scope === Scope.SINGLETON && registration.instance !== undefined) {
@@ -132,6 +198,10 @@ export class Container {
132
198
  try {
133
199
  let instance;
134
200
  if (registration.useFactory) {
201
+ // Factories (forRootAsync, useFactory) commonly inject deps from the
202
+ // importing module's scope. Vela has no `forRootAsync({ imports })`
203
+ // surface to track that, so factory inject deps resolve without a
204
+ // requester — same escape-hatch shape as ModuleRef.
135
205
  instance = this.resolveFactory(registration);
136
206
  } else if (registration.useClass) {
137
207
  instance = this.resolveClass(registration.useClass);
@@ -148,7 +218,9 @@ export class Container {
148
218
  this.resolutionStack.delete(registration.provide);
149
219
  }
150
220
  }
151
- resolveClass(target) {
221
+ resolveClass(target, callerModuleId) {
222
+ // The class's dependencies resolve from ITS module's POV, not the caller's.
223
+ const ownerModuleId = this.providerOrigin.get(target) ?? callerModuleId;
152
224
  const paramTypes = getConstructorDependencies(target);
153
225
  const injectMetadata = getInjectMetadata(target);
154
226
  const injectMap = new Map(injectMetadata.map((m)=>[
@@ -169,19 +241,19 @@ export class Container {
169
241
  }
170
242
  // forwardRef with circular dep — break the cycle with a lazy Proxy
171
243
  if (isForwardRef && this.resolutionStack.has(token)) {
172
- return this.createLazyProxy(token);
244
+ return this.createLazyProxy(token, ownerModuleId);
173
245
  }
174
- return this.resolve(token);
246
+ return this.resolve(token, ownerModuleId);
175
247
  });
176
248
  return new target(...dependencies);
177
249
  }
178
- resolveFactory(registration) {
250
+ resolveFactory(registration, requestingModuleId) {
179
251
  if (!registration.useFactory) {
180
252
  throw new Error('Factory function is missing');
181
253
  }
182
254
  const dependencies = (registration.inject || []).map((token)=>{
183
255
  const resolved = token instanceof ForwardRef ? token.factory() : token;
184
- return this.resolve(resolved);
256
+ return this.resolve(resolved, requestingModuleId);
185
257
  });
186
258
  const result = registration.useFactory(...dependencies);
187
259
  if (result instanceof Promise) {
@@ -189,12 +261,22 @@ export class Container {
189
261
  }
190
262
  return result;
191
263
  }
192
- async resolveAsync(token) {
264
+ async resolveAsync(token, requestingModuleId) {
265
+ if (requestingModuleId !== undefined) {
266
+ this.assertVisible(requestingModuleId, token);
267
+ }
193
268
  const registration = this.providers.get(token);
194
269
  if (!registration) {
195
270
  if (typeof token === 'function') {
196
- this.register(token);
197
- return this.resolveAsync(token);
271
+ throw new Error(`No provider found for token: ${this.tokenToString(token)}. ` + `Declare it in a module's providers.`);
272
+ }
273
+ if (token instanceof InjectionToken && token.options?.factory) {
274
+ // Self-providing token — register on demand from its declared factory.
275
+ this.register({
276
+ provide: token,
277
+ useFactory: token.options.factory
278
+ });
279
+ return this.resolveAsync(token, requestingModuleId);
198
280
  }
199
281
  throw new Error(`No provider found for token: ${this.tokenToString(token)}`);
200
282
  }
@@ -202,31 +284,58 @@ export class Container {
202
284
  if (registration.scope === Scope.SINGLETON && registration.instance !== undefined) {
203
285
  return registration.instance;
204
286
  }
205
- const dependencies = await Promise.all((registration.inject || []).map((t)=>this.resolveAsync(t)));
287
+ // Factory inject deps resolve without a requester (escape hatch — see
288
+ // resolveRegistration's useFactory branch).
289
+ const dependencies = await Promise.all((registration.inject || []).map((t)=>{
290
+ const resolved = t instanceof ForwardRef ? t.factory() : t;
291
+ return this.resolveAsync(resolved);
292
+ }));
206
293
  const instance = await registration.useFactory(...dependencies);
207
294
  if (registration.scope === Scope.SINGLETON) {
208
295
  registration.instance = instance;
209
296
  }
210
297
  return instance;
211
298
  }
212
- return this.resolve(token);
299
+ return this.resolve(token, requestingModuleId);
213
300
  }
214
- createLazyProxy(token) {
301
+ createLazyProxy(token, requestingModuleId) {
215
302
  const container = this;
216
303
  const target = Object.create(null);
217
304
  return new Proxy(target, {
218
305
  get (_target, prop) {
219
- const instance = container.resolve(token);
306
+ const instance = container.resolve(token, requestingModuleId);
220
307
  const value = instance[prop];
221
308
  return typeof value === 'function' ? value.bind(instance) : value;
222
309
  },
223
310
  set (_target, prop, value) {
224
- const instance = container.resolve(token);
311
+ const instance = container.resolve(token, requestingModuleId);
225
312
  instance[prop] = value;
226
313
  return true;
227
314
  }
228
315
  });
229
316
  }
317
+ assertVisible(moduleId, token) {
318
+ if (this.globals.has(token)) return;
319
+ // InjectionTokens with a default factory are self-providing singletons —
320
+ // visible from any module without requiring explicit declaration.
321
+ if (token instanceof InjectionToken && token.options?.factory) return;
322
+ const scope = this.scopes.get(moduleId);
323
+ if (!scope) {
324
+ // No scope registered for this moduleId — typically synthetic /
325
+ // framework-internal callers. Allow rather than break primitives.
326
+ return;
327
+ }
328
+ if (scope.localProviders.has(token)) return;
329
+ if (this.isExportedFromImports(scope, token)) return;
330
+ throw new ModuleVisibilityError(moduleId, token);
331
+ }
332
+ isExportedFromImports(scope, token) {
333
+ for (const importedId of scope.importedModules){
334
+ const imported = this.scopes.get(importedId);
335
+ if (imported?.exportedTokens.has(token)) return true;
336
+ }
337
+ return false;
338
+ }
230
339
  tokenToString(token) {
231
340
  if (token instanceof InjectionToken) {
232
341
  return token.toString();
@@ -1,6 +1,6 @@
1
1
  export { Container } from './container';
2
2
  export { Injectable, Inject, Optional, isInjectable, getScope } from './decorators';
3
- export { InjectionToken, ForwardRef, forwardRef } from './types';
3
+ export { InjectionToken, ForwardRef, forwardRef, ModuleVisibilityError } from './types';
4
4
  export { ModuleRef } from './module-ref';
5
5
  export { mixin } from './mixin';
6
- export type { Type, Token, InjectableOptions, InjectMetadata, ProviderOptions, ProviderRegistration, InjectionTokenOptions, } from './types';
6
+ export type { Type, Token, InjectableOptions, InjectMetadata, ProviderOptions, ProviderRegistration, InjectionTokenOptions, ModuleScope, ContainerOptions, Diagnostics, } from './types';
@@ -1,5 +1,5 @@
1
1
  export { Container } from "./container.js";
2
2
  export { Injectable, Inject, Optional, isInjectable, getScope } from "./decorators.js";
3
- export { InjectionToken, ForwardRef, forwardRef } from "./types.js";
3
+ export { InjectionToken, ForwardRef, forwardRef, ModuleVisibilityError } from "./types.js";
4
4
  export { ModuleRef } from "./module-ref.js";
5
5
  export { mixin } from "./mixin.js";
@@ -43,3 +43,19 @@ export interface ProviderRegistration<T = unknown> {
43
43
  inject?: Token[];
44
44
  useExisting?: Token<T>;
45
45
  }
46
+ export interface ModuleScope {
47
+ moduleId: string;
48
+ localProviders: Set<Token>;
49
+ importedModules: Set<string>;
50
+ exportedTokens: Set<Token>;
51
+ isGlobal: boolean;
52
+ }
53
+ export type Diagnostics = 'silent' | 'log' | 'throw';
54
+ export interface ContainerOptions {
55
+ diagnostics?: Diagnostics;
56
+ }
57
+ export declare class ModuleVisibilityError extends Error {
58
+ readonly moduleId: string;
59
+ readonly token: Token;
60
+ constructor(moduleId: string, token: Token);
61
+ }
@@ -18,3 +18,17 @@ export class ForwardRef {
18
18
  export function forwardRef(factory) {
19
19
  return new ForwardRef(factory);
20
20
  }
21
+ function describeToken(token) {
22
+ if (token instanceof InjectionToken) return token.toString();
23
+ if (typeof token === 'function') return token.name;
24
+ if (typeof token === 'symbol') return token.toString();
25
+ return String(token);
26
+ }
27
+ export class ModuleVisibilityError extends Error {
28
+ moduleId;
29
+ token;
30
+ constructor(moduleId, token){
31
+ super(`Module '${moduleId}' cannot resolve '${describeToken(token)}': ` + `not declared in providers, not imported from another module's exports, not @Global. ` + `Either add to imports/exports or mark as @Global.`), this.moduleId = moduleId, this.token = token;
32
+ this.name = 'ModuleVisibilityError';
33
+ }
34
+ }
@@ -34,7 +34,12 @@ export class EventEmitterSubscriber {
34
34
  let instance;
35
35
  try {
36
36
  instance = this.container.resolve(token);
37
- } catch {
37
+ } catch (err) {
38
+ const mode = this.container.getDiagnostics();
39
+ if (mode === 'throw') throw err;
40
+ if (mode === 'log') {
41
+ console.warn(`[vela] event subscriber discovery: cannot resolve ${token.name || String(token)}:`, err);
42
+ }
38
43
  continue;
39
44
  }
40
45
  for (const { event, methodName } of metadata){
@@ -0,0 +1,23 @@
1
+ import { Container } from '../container/container';
2
+ import type { Diagnostics, Type } from '../container/types';
3
+ import { RouteManager } from '../http/route.manager';
4
+ import type { RouteManagerOptions } from '../http/route.manager';
5
+ import { ModuleLoader } from '../module/module-loader';
6
+ export interface BootstrapOptions extends RouteManagerOptions {
7
+ diagnostics?: Diagnostics;
8
+ }
9
+ export interface BootstrapResult {
10
+ container: Container;
11
+ routeManager: RouteManager;
12
+ loader: ModuleLoader;
13
+ }
14
+ /**
15
+ * Wire the DI graph for `rootModule` and prepare the route manager — without
16
+ * running lifecycle hooks or building the Hono app. The single primitive
17
+ * shared by `VelaFactory.create` (HTTP), `@velajs/testing` (test), and any
18
+ * non-HTTP consumer (CLI tools, custom runtimes).
19
+ *
20
+ * Framework-internal tokens (`Container`, `ModuleRef`, `APP_*`) are marked
21
+ * global so they are resolvable from any module.
22
+ */
23
+ export declare function bootstrap(rootModule: Type, options?: BootstrapOptions): Promise<BootstrapResult>;
@@ -0,0 +1,62 @@
1
+ import { Container } from "../container/container.js";
2
+ import { ModuleRef } from "../container/module-ref.js";
3
+ import { RouteManager } from "../http/route.manager.js";
4
+ import { ModuleLoader } from "../module/module-loader.js";
5
+ import { bindAppProviders } from "../pipeline/app-providers.js";
6
+ import { ComponentManager } from "../pipeline/component.manager.js";
7
+ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from "../pipeline/tokens.js";
8
+ /**
9
+ * Wire the DI graph for `rootModule` and prepare the route manager — without
10
+ * running lifecycle hooks or building the Hono app. The single primitive
11
+ * shared by `VelaFactory.create` (HTTP), `@velajs/testing` (test), and any
12
+ * non-HTTP consumer (CLI tools, custom runtimes).
13
+ *
14
+ * Framework-internal tokens (`Container`, `ModuleRef`, `APP_*`) are marked
15
+ * global so they are resolvable from any module.
16
+ */ export async function bootstrap(rootModule, options = {}) {
17
+ const container = new Container({
18
+ diagnostics: options.diagnostics
19
+ });
20
+ container.register({
21
+ provide: Container,
22
+ useValue: container
23
+ });
24
+ container.markGlobalToken(Container);
25
+ container.register({
26
+ provide: ModuleRef,
27
+ useFactory: (c)=>new ModuleRef(c),
28
+ inject: [
29
+ Container
30
+ ]
31
+ });
32
+ container.markGlobalToken(ModuleRef);
33
+ for (const t of [
34
+ APP_GUARD,
35
+ APP_PIPE,
36
+ APP_INTERCEPTOR,
37
+ APP_FILTER,
38
+ APP_MIDDLEWARE
39
+ ]){
40
+ container.markGlobalToken(t);
41
+ }
42
+ const routeManager = new RouteManager(container, options);
43
+ ComponentManager.init(container);
44
+ const loader = new ModuleLoader(container, routeManager);
45
+ loader.load(rootModule);
46
+ bindAppProviders(routeManager, container, loader);
47
+ routeManager.registerConsumerMiddleware(loader.getConsumerMiddlewareDefinitions());
48
+ if (options.globalPrefix) {
49
+ routeManager.setGlobalPrefix(options.globalPrefix);
50
+ }
51
+ for (const handler of options.middleware ?? []){
52
+ const mw = {
53
+ use: handler
54
+ };
55
+ routeManager.useGlobalMiddleware(mw);
56
+ }
57
+ return {
58
+ container,
59
+ routeManager,
60
+ loader
61
+ };
62
+ }
package/dist/factory.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { VelaApplication } from './application';
2
2
  import type { Type } from './container/types';
3
- import type { RouteManagerOptions } from './http/route.manager';
3
+ import type { BootstrapOptions } from './factory/bootstrap';
4
4
  export declare const VelaFactory: {
5
- create(rootModule: Type, options?: RouteManagerOptions): Promise<VelaApplication>;
5
+ create(rootModule: Type, options?: BootstrapOptions): Promise<VelaApplication>;
6
6
  };
package/dist/factory.js CHANGED
@@ -1,39 +1,8 @@
1
1
  import { VelaApplication } from "./application.js";
2
- import { Container } from "./container/container.js";
3
- import { ModuleRef } from "./container/module-ref.js";
4
- import { bindAppProviders } from "./pipeline/app-providers.js";
5
- import { RouteManager } from "./http/route.manager.js";
6
- import { ModuleLoader } from "./module/module-loader.js";
7
- import { ComponentManager } from "./pipeline/component.manager.js";
2
+ import { bootstrap } from "./factory/bootstrap.js";
8
3
  export const VelaFactory = {
9
4
  async create (rootModule, options = {}) {
10
- const container = new Container();
11
- container.register({
12
- provide: Container,
13
- useValue: container
14
- });
15
- container.register({
16
- provide: ModuleRef,
17
- useFactory: (c)=>new ModuleRef(c),
18
- inject: [
19
- Container
20
- ]
21
- });
22
- const routeManager = new RouteManager(container, options);
23
- ComponentManager.init(container);
24
- const loader = new ModuleLoader(container, routeManager);
25
- loader.load(rootModule);
26
- bindAppProviders(routeManager, container, loader);
27
- routeManager.registerConsumerMiddleware(loader.getConsumerMiddlewareDefinitions());
28
- if (options.globalPrefix) {
29
- routeManager.setGlobalPrefix(options.globalPrefix);
30
- }
31
- for (const handler of options.middleware ?? []){
32
- const mw = {
33
- use: handler
34
- };
35
- routeManager.useGlobalMiddleware(mw);
36
- }
5
+ const { container, routeManager, loader } = await bootstrap(rootModule, options);
37
6
  const app = new VelaApplication(container, routeManager);
38
7
  const instances = await loader.resolveAllInstances();
39
8
  app.setInstances(instances);
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@ import './metadata';
2
2
  export { defineMetadata, getMetadata } from './metadata';
3
3
  export { VelaFactory } from './factory';
4
4
  export { VelaApplication } from './application';
5
+ export { bootstrap } from './factory/bootstrap';
6
+ export type { BootstrapOptions, BootstrapResult } from './factory/bootstrap';
5
7
  export { createOpenApiDocument, ApiDoc, ApiTags, ApiResponse, zodToJsonSchema, } from './openapi/index';
6
8
  export type { OpenApiDocument, OpenApiInfo, OpenApiOperation, OpenApiParameter, OpenApiPathItem, OpenApiRequestBody, OpenApiResponse, ApiDocMetadata, ApiResponseEntry, ApiResponseOptions, CreateOpenApiDocumentOptions, HttpVerb, JsonSchema, } from './openapi/index';
7
- export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, mixin } from './container/index';
8
- export type { Type, Token, InjectableOptions, ProviderOptions, } from './container/index';
9
+ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, mixin, } from './container/index';
10
+ export type { Type, Token, InjectableOptions, ProviderOptions, ModuleScope, ContainerOptions, Diagnostics, } from './container/index';
9
11
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from './constants';
10
12
  export { Controller, Version, Get, Post, Put, Patch, Delete, Options, Head, All, Sse, Param, Query, Body, Headers, Req, Res, Ip, Cookie, Cookies, RawBody, HttpCode, Header, Redirect, createParamDecorator, applyDecorators, } from './http/index';
11
13
  export { Logger, LogLevel } from './services/index';
@@ -29,6 +31,8 @@ export type { ThrottlerModuleOptions, ThrottleConfig, ThrottlerStore, ThrottlerS
29
31
  export { Global, Module, createModuleRef } from './module/index';
30
32
  export type { ModuleOptions, DynamicModule, AsyncModuleOptions, ModuleImport } from './module/index';
31
33
  export type { MiddlewareConsumer, NestModule, RouteInfo } from './http/index';
34
+ export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
35
+ export type { Plugin } from './plugin/plugin';
32
36
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
33
37
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, ReflectableDecorator, CreateDecoratorOptions, } from './pipeline/index';
34
38
  export type { Constructor, MiddlewareType, GuardType, PipeType, InterceptorType, FilterType, } from './registry/index';
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@ export { defineMetadata, getMetadata } from "./metadata.js";
3
3
  // Factory & Application
4
4
  export { VelaFactory } from "./factory.js";
5
5
  export { VelaApplication } from "./application.js";
6
+ export { bootstrap } from "./factory/bootstrap.js";
6
7
  // OpenAPI
7
8
  export { createOpenApiDocument, ApiDoc, ApiTags, ApiResponse, zodToJsonSchema } from "./openapi/index.js";
8
9
  // DI Container
9
- export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, mixin } from "./container/index.js";
10
+ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, mixin } from "./container/index.js";
10
11
  // Constants
11
12
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from "./constants.js";
12
13
  // HTTP Decorators
@@ -31,6 +32,8 @@ export { HealthModule, HealthCheckService, HealthIndicatorService, HttpHealthInd
31
32
  export { ThrottlerModule, ThrottlerGuard, ThrottlerStorage, Throttle, SkipThrottle, THROTTLER_OPTIONS, THROTTLER_STORAGE, THROTTLE_METADATA, SKIP_THROTTLE_METADATA } from "./throttler/index.js";
32
33
  // Module
33
34
  export { Global, Module, createModuleRef } from "./module/index.js";
35
+ // Plugin manifest + composer
36
+ export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN } from "./plugin/plugin.js";
34
37
  // Pipeline Decorators
35
38
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from "./pipeline/index.js";
36
39
  // Built-in Pipes
@@ -1,5 +1,7 @@
1
1
  export { Container } from './container/container';
2
2
  export { ModuleRef } from './container/module-ref';
3
+ export { ModuleVisibilityError } from './container/types';
4
+ export type { ModuleScope, ContainerOptions, Diagnostics, } from './container/types';
3
5
  export { bindAppProviders } from './pipeline/app-providers';
4
6
  export { RouteManager } from './http/route.manager';
5
7
  export type { RouteManagerOptions } from './http/route.manager';
@@ -9,3 +11,7 @@ export { VelaApplication } from './application';
9
11
  export { MetadataRegistry } from './registry/metadata.registry';
10
12
  export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/tokens';
11
13
  export { getModuleMetadata, isModule } from './module/decorators';
14
+ export { bootstrap } from './factory/bootstrap';
15
+ export type { BootstrapOptions, BootstrapResult } from './factory/bootstrap';
16
+ export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
17
+ export type { Plugin } from './plugin/plugin';
package/dist/internal.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // to the same degree as the public surface (./index).
4
4
  export { Container } from "./container/container.js";
5
5
  export { ModuleRef } from "./container/module-ref.js";
6
+ export { ModuleVisibilityError } from "./container/types.js";
6
7
  export { bindAppProviders } from "./pipeline/app-providers.js";
7
8
  export { RouteManager } from "./http/route.manager.js";
8
9
  export { ModuleLoader } from "./module/module-loader.js";
@@ -11,3 +12,8 @@ export { VelaApplication } from "./application.js";
11
12
  export { MetadataRegistry } from "./registry/metadata.registry.js";
12
13
  export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from "./pipeline/tokens.js";
13
14
  export { getModuleMetadata, isModule } from "./module/decorators.js";
15
+ // Bootstrap primitive — used by VelaFactory.create, @velajs/testing, and any
16
+ // non-HTTP consumer (CLI tools, custom runtimes).
17
+ export { bootstrap } from "./factory/bootstrap.js";
18
+ // Plugin manifest + composer
19
+ export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN } from "./plugin/plugin.js";
@@ -14,8 +14,11 @@ export declare class ModuleLoader {
14
14
  private consumerMiddlewareDefinitions;
15
15
  private appProviderCounter;
16
16
  private appProviderTokens;
17
+ private moduleIdByClass;
18
+ private seenModuleIds;
17
19
  constructor(container: Container, router: RouteManager);
18
20
  load(rootModule: Type): void;
21
+ private getModuleId;
19
22
  private processModule;
20
23
  private registerProvider;
21
24
  private isAppToken;
@@ -25,4 +28,5 @@ export declare class ModuleLoader {
25
28
  getAppProviderTokens(token: Token): Token[];
26
29
  getConsumerMiddlewareDefinitions(): MiddlewareRouteDefinition[];
27
30
  resolveAllInstances(): Promise<unknown[]>;
31
+ private routeError;
28
32
  }
@@ -1,5 +1,5 @@
1
1
  import { Scope } from "../constants.js";
2
- import { ForwardRef, InjectionToken } from "../container/types.js";
2
+ import { ForwardRef, InjectionToken, ModuleVisibilityError } from "../container/types.js";
3
3
  import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from "../pipeline/tokens.js";
4
4
  import { MetadataRegistry } from "../registry/metadata.registry.js";
5
5
  import { getOrCreateArray } from "../registry/util.js";
@@ -18,6 +18,9 @@ function isDynamicModule(value) {
18
18
  function implementsNestModule(cls) {
19
19
  return typeof cls.prototype?.configure === "function";
20
20
  }
21
+ function tokenOfProvider(provider) {
22
+ return typeof provider === "function" ? provider : provider.provide;
23
+ }
21
24
  export class ModuleLoader {
22
25
  container;
23
26
  router;
@@ -51,6 +54,8 @@ export class ModuleLoader {
51
54
  []
52
55
  ]
53
56
  ]);
57
+ moduleIdByClass = new Map();
58
+ seenModuleIds = new Set();
54
59
  constructor(container, router){
55
60
  this.container = container;
56
61
  this.router = router;
@@ -61,6 +66,19 @@ export class ModuleLoader {
61
66
  this.router.registerController(controller);
62
67
  }
63
68
  }
69
+ getModuleId(moduleClass) {
70
+ const cached = this.moduleIdByClass.get(moduleClass);
71
+ if (cached) return cached;
72
+ const baseName = moduleClass.name || "AnonModule";
73
+ let id = baseName;
74
+ let counter = 0;
75
+ while(this.seenModuleIds.has(id)){
76
+ id = `${baseName}#${++counter}`;
77
+ }
78
+ this.seenModuleIds.add(id);
79
+ this.moduleIdByClass.set(moduleClass, id);
80
+ return id;
81
+ }
64
82
  processModule(moduleClassOrDynamic) {
65
83
  // Handle dynamic modules ({ module, imports, controllers, providers })
66
84
  let moduleClass;
@@ -103,8 +121,12 @@ export class ModuleLoader {
103
121
  throw new Error(`Failed to get module metadata for ${moduleClass.name}`);
104
122
  }
105
123
  this.processingStack.add(moduleClass);
124
+ const moduleId = this.getModuleId(moduleClass);
106
125
  try {
107
126
  const importedProviders = new Set(this.globalExports);
127
+ // Determine importedModuleIds eagerly (before recursing) so child
128
+ // modules can be referenced in our scope's importedModules set.
129
+ const importedModuleIds = new Set();
108
130
  for (const entry of [
109
131
  ...metadata.imports,
110
132
  ...extraImports
@@ -113,6 +135,7 @@ export class ModuleLoader {
113
135
  const isForwardRef = entry instanceof ForwardRef;
114
136
  const importedModule = isForwardRef ? entry.factory() : entry;
115
137
  const importedModuleClass = isDynamicModule(importedModule) ? importedModule.module : importedModule;
138
+ importedModuleIds.add(this.getModuleId(importedModuleClass));
116
139
  // If this forwardRef-wrapped import is currently being processed, skip it to
117
140
  // break the circular chain. Non-forwardRef circular imports still throw.
118
141
  if (isForwardRef && this.processingStack.has(importedModuleClass)) {
@@ -123,19 +146,42 @@ export class ModuleLoader {
123
146
  importedProviders.add(token);
124
147
  }
125
148
  }
126
- // Register metadata providers + dynamic module extra providers
127
149
  const allProviders = [
128
150
  ...metadata.providers,
129
151
  ...extraProviders
130
152
  ];
131
- for (const provider of allProviders){
132
- this.registerProvider(provider);
133
- }
134
- // Collect metadata controllers + dynamic module extra controllers
135
153
  const allControllers = [
136
154
  ...metadata.controllers,
137
155
  ...extraControllers
138
156
  ];
157
+ const allExports = [
158
+ ...metadata.exports,
159
+ ...extraExports
160
+ ];
161
+ // Build the ModuleScope BEFORE registering providers so the visibility
162
+ // check sees the local-provider set as we register.
163
+ const localProviders = new Set();
164
+ for (const provider of allProviders){
165
+ const tk = tokenOfProvider(provider);
166
+ if (tk !== undefined) localProviders.add(tk);
167
+ }
168
+ for (const controller of allControllers){
169
+ localProviders.add(controller);
170
+ }
171
+ // The module class itself can be DI-resolved (NestModule.configure path);
172
+ // include it in its own scope.
173
+ localProviders.add(moduleClass);
174
+ const isGlobal = metadata.isGlobal || isDynamicModule(moduleClassOrDynamic) && moduleClassOrDynamic.global === true;
175
+ this.container.registerScope({
176
+ moduleId,
177
+ localProviders,
178
+ importedModules: importedModuleIds,
179
+ exportedTokens: new Set(allExports),
180
+ isGlobal
181
+ });
182
+ for (const provider of allProviders){
183
+ this.registerProvider(provider, moduleId);
184
+ }
139
185
  for (const controller of allControllers){
140
186
  this.collectedControllers.add(controller);
141
187
  }
@@ -144,13 +190,8 @@ export class ModuleLoader {
144
190
  MetadataRegistry.propagateControllerComponents(moduleClass, controller);
145
191
  }
146
192
  this.processedModules.add(moduleClass);
147
- const allExports = [
148
- ...metadata.exports,
149
- ...extraExports
150
- ];
151
193
  const exports = this.buildExportSet(allExports, allProviders, importedProviders);
152
194
  this.moduleExportsCache.set(moduleClass, exports);
153
- const isGlobal = metadata.isGlobal || isDynamicModule(moduleClassOrDynamic) && moduleClassOrDynamic.global === true;
154
195
  if (isGlobal) {
155
196
  for (const token of exports){
156
197
  this.globalExports.add(token);
@@ -160,9 +201,9 @@ export class ModuleLoader {
160
201
  // resolved through the container so it can have its own DI dependencies.
161
202
  if (implementsNestModule(moduleClass)) {
162
203
  if (!this.container.has(moduleClass)) {
163
- this.container.register(moduleClass);
204
+ this.container.register(moduleClass, moduleId);
164
205
  }
165
- const instance = this.container.resolve(moduleClass);
206
+ const instance = this.container.resolve(moduleClass, moduleId);
166
207
  const builder = new MiddlewareBuilder();
167
208
  instance.configure(builder);
168
209
  this.consumerMiddlewareDefinitions.push(...builder.getDefinitions());
@@ -172,16 +213,16 @@ export class ModuleLoader {
172
213
  this.processingStack.delete(moduleClass);
173
214
  }
174
215
  }
175
- registerProvider(provider) {
216
+ registerProvider(provider, moduleId) {
176
217
  if (typeof provider === "function") {
177
218
  if (!this.container.has(provider)) {
178
- this.container.register(provider);
219
+ this.container.register(provider, moduleId);
179
220
  this.registeredProviders.push(provider);
180
221
  }
181
222
  } else {
182
223
  const token = provider.provide;
183
224
  if (!token) {
184
- this.container.register(provider);
225
+ this.container.register(provider, moduleId);
185
226
  return;
186
227
  }
187
228
  if (this.isAppToken(token)) {
@@ -189,13 +230,16 @@ export class ModuleLoader {
189
230
  this.container.register({
190
231
  ...provider,
191
232
  provide: syntheticToken
192
- });
233
+ }, moduleId);
234
+ // Synthetic APP_* tokens are resolved at request time by RouteManager
235
+ // with no requester; mark them global so the visibility check passes.
236
+ this.container.markGlobalToken(syntheticToken);
193
237
  this.registeredProviders.push(syntheticToken);
194
238
  getOrCreateArray(this.appProviderTokens, token).push(syntheticToken);
195
239
  return;
196
240
  }
197
241
  if (!this.container.has(token)) {
198
- this.container.register(provider);
242
+ this.container.register(provider, moduleId);
199
243
  this.registeredProviders.push(token);
200
244
  }
201
245
  }
@@ -207,7 +251,7 @@ export class ModuleLoader {
207
251
  const exportSet = new Set();
208
252
  const providerTokens = new Set();
209
253
  for (const p of providers){
210
- const token = typeof p === "function" ? p : p.provide;
254
+ const token = tokenOfProvider(p);
211
255
  if (token !== undefined) providerTokens.add(token);
212
256
  }
213
257
  for (const exported of exports){
@@ -250,8 +294,11 @@ export class ModuleLoader {
250
294
  }
251
295
  const instance = await this.container.resolveAsync(token);
252
296
  instanceSet.add(instance);
253
- } catch {
254
- // Skip unresolvable tokens
297
+ } catch (err) {
298
+ // Module visibility errors must always propagate — they indicate a
299
+ // genuine wiring bug, not something to swallow as a discovery skip.
300
+ if (err instanceof ModuleVisibilityError) throw err;
301
+ this.routeError(err, `resolve provider`);
255
302
  }
256
303
  }
257
304
  for (const controller of this.collectedControllers){
@@ -261,12 +308,22 @@ export class ModuleLoader {
261
308
  }
262
309
  const instance = await this.container.resolveAsync(controller);
263
310
  instanceSet.add(instance);
264
- } catch {
265
- // Skip unresolvable controllers
311
+ } catch (err) {
312
+ if (err instanceof ModuleVisibilityError) throw err;
313
+ this.routeError(err, `resolve controller ${controller.name}`);
266
314
  }
267
315
  }
268
316
  return [
269
317
  ...instanceSet
270
318
  ];
271
319
  }
320
+ routeError(err, context) {
321
+ const mode = this.container.getDiagnostics();
322
+ if (mode === "silent") return;
323
+ if (mode === "throw") {
324
+ throw err instanceof Error ? err : new Error(String(err));
325
+ }
326
+ // 'log'
327
+ console.warn(`[vela] ${context} failed:`, err);
328
+ }
272
329
  }
@@ -0,0 +1,31 @@
1
+ import { InjectionToken } from '../container/types';
2
+ import type { Type } from '../container/types';
3
+ import type { DynamicModule } from '../registry/types';
4
+ export interface Plugin {
5
+ id: string;
6
+ version: string;
7
+ module: Type | DynamicModule;
8
+ dependsOn?: string[];
9
+ metadata?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Freeze and validate a plugin manifest. Returns the same object so it can
13
+ * be used in `composePlugins([definePlugin(...), ...])`.
14
+ */
15
+ export declare function definePlugin(plugin: Plugin): Plugin;
16
+ export declare class PluginRegistry {
17
+ private byId;
18
+ constructor(plugins: readonly Plugin[]);
19
+ list(): Plugin[];
20
+ get(id: string): Plugin | undefined;
21
+ dependents(id: string): Plugin[];
22
+ }
23
+ export declare const PLUGIN_REGISTRY_TOKEN: InjectionToken<PluginRegistry>;
24
+ export declare class PluginRootModule {
25
+ }
26
+ /**
27
+ * Compose a list of plugins into a single dynamic module that imports each
28
+ * plugin's module in dependency order, registers a `PluginRegistry`, and
29
+ * exposes it globally so any plugin can introspect peers.
30
+ */
31
+ export declare function composePlugins(plugins: readonly Plugin[]): DynamicModule;
@@ -0,0 +1,111 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ import { InjectionToken } from "../container/types.js";
8
+ import { Module } from "../module/decorators.js";
9
+ /**
10
+ * Freeze and validate a plugin manifest. Returns the same object so it can
11
+ * be used in `composePlugins([definePlugin(...), ...])`.
12
+ */ export function definePlugin(plugin) {
13
+ if (!plugin.id) throw new Error('Plugin requires an id');
14
+ if (!plugin.version) throw new Error('Plugin requires a version');
15
+ if (!plugin.module) throw new Error('Plugin requires a module');
16
+ return Object.freeze({
17
+ ...plugin
18
+ });
19
+ }
20
+ export class PluginRegistry {
21
+ byId;
22
+ constructor(plugins){
23
+ this.byId = new Map();
24
+ for (const p of plugins){
25
+ if (this.byId.has(p.id)) {
26
+ throw new Error(`Duplicate plugin id: '${p.id}'`);
27
+ }
28
+ this.byId.set(p.id, p);
29
+ }
30
+ }
31
+ list() {
32
+ return [
33
+ ...this.byId.values()
34
+ ];
35
+ }
36
+ get(id) {
37
+ return this.byId.get(id);
38
+ }
39
+ dependents(id) {
40
+ return this.list().filter((p)=>p.dependsOn?.includes(id) ?? false);
41
+ }
42
+ }
43
+ export const PLUGIN_REGISTRY_TOKEN = new InjectionToken('PLUGIN_REGISTRY');
44
+ export class PluginRootModule {
45
+ }
46
+ PluginRootModule = _ts_decorate([
47
+ Module({})
48
+ ], PluginRootModule);
49
+ /**
50
+ * Topologically sort plugins so each plugin's dependencies appear first.
51
+ * Throws on missing dependencies and cycles.
52
+ */ function topologicalSort(plugins) {
53
+ const byId = new Map();
54
+ for (const p of plugins){
55
+ if (byId.has(p.id)) {
56
+ throw new Error(`Duplicate plugin id: '${p.id}'`);
57
+ }
58
+ byId.set(p.id, p);
59
+ }
60
+ const result = [];
61
+ const visiting = new Set();
62
+ const visited = new Set();
63
+ const visit = (p, path)=>{
64
+ if (visited.has(p.id)) return;
65
+ if (visiting.has(p.id)) {
66
+ const cycle = [
67
+ ...path,
68
+ p.id
69
+ ].join(' -> ');
70
+ throw new Error(`Plugin cycle detected: ${cycle}`);
71
+ }
72
+ visiting.add(p.id);
73
+ for (const depId of p.dependsOn ?? []){
74
+ const dep = byId.get(depId);
75
+ if (!dep) {
76
+ throw new Error(`Plugin '${p.id}' depends on missing plugin '${depId}'`);
77
+ }
78
+ visit(dep, [
79
+ ...path,
80
+ p.id
81
+ ]);
82
+ }
83
+ visiting.delete(p.id);
84
+ visited.add(p.id);
85
+ result.push(p);
86
+ };
87
+ for (const p of plugins)visit(p, []);
88
+ return result;
89
+ }
90
+ /**
91
+ * Compose a list of plugins into a single dynamic module that imports each
92
+ * plugin's module in dependency order, registers a `PluginRegistry`, and
93
+ * exposes it globally so any plugin can introspect peers.
94
+ */ export function composePlugins(plugins) {
95
+ const sorted = topologicalSort(plugins);
96
+ const registry = new PluginRegistry(sorted);
97
+ return {
98
+ module: PluginRootModule,
99
+ imports: sorted.map((p)=>p.module),
100
+ providers: [
101
+ {
102
+ provide: PLUGIN_REGISTRY_TOKEN,
103
+ useValue: registry
104
+ }
105
+ ],
106
+ exports: [
107
+ PLUGIN_REGISTRY_TOKEN
108
+ ],
109
+ global: true
110
+ };
111
+ }
@@ -33,7 +33,12 @@ export class ScheduleRegistry {
33
33
  let instance;
34
34
  try {
35
35
  instance = this.container.resolve(token);
36
- } catch {
36
+ } catch (err) {
37
+ const mode = this.container.getDiagnostics();
38
+ if (mode === 'throw') throw err;
39
+ if (mode === 'log') {
40
+ console.warn(`[vela] schedule discovery: cannot resolve ${token.name || String(token)}:`, err);
41
+ }
37
42
  continue;
38
43
  }
39
44
  if (cronMeta) {
@@ -1,13 +1,15 @@
1
+ import { Container } from '../container/container';
1
2
  import type { OnApplicationBootstrap, OnModuleDestroy } from '../lifecycle/index';
2
3
  import { ScheduleRegistry } from '../schedule/schedule.registry';
3
4
  export declare class ScheduleExecutor implements OnApplicationBootstrap, OnModuleDestroy {
4
5
  private registry;
6
+ private container;
5
7
  private intervalTimers;
6
8
  private cronTimers;
7
9
  private cronMatcherCache;
8
10
  private lastCronMinute;
9
11
  private running;
10
- constructor(registry: ScheduleRegistry);
12
+ constructor(registry: ScheduleRegistry, container: Container);
11
13
  onApplicationBootstrap(): void;
12
14
  private scheduleInterval;
13
15
  private scheduleCron;
@@ -7,18 +7,26 @@ function _ts_decorate(decorators, target, key, desc) {
7
7
  function _ts_metadata(k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  }
10
- import { Injectable } from "../container/index.js";
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { Inject, Injectable } from "../container/index.js";
16
+ import { Container } from "../container/container.js";
11
17
  import { parseCron } from "../schedule/cron-matcher.js";
12
18
  import { ScheduleRegistry } from "../schedule/schedule.registry.js";
13
19
  export class ScheduleExecutor {
14
20
  registry;
21
+ container;
15
22
  intervalTimers = [];
16
23
  cronTimers = [];
17
24
  cronMatcherCache = new Map();
18
25
  lastCronMinute = new Map();
19
26
  running = true;
20
- constructor(registry){
27
+ constructor(registry, container){
21
28
  this.registry = registry;
29
+ this.container = container;
22
30
  }
23
31
  onApplicationBootstrap() {
24
32
  if (typeof setInterval !== 'function') {
@@ -61,8 +69,14 @@ export class ScheduleExecutor {
61
69
  if (typeof method === 'function') {
62
70
  await method.call(instance);
63
71
  }
64
- } catch {
65
- // Swallow errors so the scheduler keeps running
72
+ } catch (err) {
73
+ // Runtime job error — keep the scheduler running by default. Users
74
+ // can opt into rethrowing by setting diagnostics: 'throw'.
75
+ const mode = this.container.getDiagnostics();
76
+ if (mode === 'throw') throw err;
77
+ if (mode === 'log') {
78
+ console.warn(`[vela] scheduled job ${methodName} failed:`, err);
79
+ }
66
80
  }
67
81
  }
68
82
  getMatcher(expression) {
@@ -84,8 +98,10 @@ export class ScheduleExecutor {
84
98
  }
85
99
  ScheduleExecutor = _ts_decorate([
86
100
  Injectable(),
101
+ _ts_param(1, Inject(Container)),
87
102
  _ts_metadata("design:type", Function),
88
103
  _ts_metadata("design:paramtypes", [
89
- typeof ScheduleRegistry === "undefined" ? Object : ScheduleRegistry
104
+ typeof ScheduleRegistry === "undefined" ? Object : ScheduleRegistry,
105
+ typeof Container === "undefined" ? Object : Container
90
106
  ])
91
107
  ], ScheduleExecutor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -61,17 +61,21 @@
61
61
  "hono": "^4"
62
62
  },
63
63
  "devDependencies": {
64
+ "@cloudflare/vitest-pool-workers": "^0.15.2",
64
65
  "@swc/cli": "^0.8.0",
65
66
  "@swc/core": "^1.15.11",
66
67
  "@types/bun": "latest",
68
+ "@vitest/runner": "^4.1.5",
69
+ "@vitest/snapshot": "^4.1.5",
67
70
  "typescript": "^5",
68
71
  "unplugin-swc": "^1.5.9",
69
- "vitest": "^4.0.18",
72
+ "vitest": "^4.1.5",
70
73
  "zod": "^4.3.6"
71
74
  },
72
75
  "scripts": {
73
76
  "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
74
77
  "test": "vitest run",
78
+ "test:workers": "vitest run --config vitest.config.workers.ts",
75
79
  "typecheck": "tsc --noEmit"
76
80
  }
77
81
  }