@velajs/vela 1.13.0 → 1.15.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,6 +1,76 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.15.0 (2026-07-04)
4
+
5
+ Introspection seams for `@velajs/cli` (roadmap phase 3, CLI introspection):
6
+ serializable, zero-instantiation views of the app the CLI renders instead of
7
+ re-deriving framework internals.
8
+
9
+ ### Added
10
+
11
+ - **`app.describeRoutes(): RouteDescription[]`** — every explicit controller
12
+ route exactly as `build()` registered it: method AS DECLARED (`@Head()`
13
+ reports HEAD even though Hono serves it under GET), fully composed path
14
+ (global prefix + version segment + controller prefix + route path),
15
+ controller name, handler name, version. Contributed routes
16
+ (`RouteContributor`/CRUD) mount directly on Hono and are observable via
17
+ `getHonoApp().routes`.
18
+ - **`app.getGlobalPrefix()`** — the prefix in effect ('' when none); also
19
+ what `openapi` tooling threads into `createOpenApiDocument`.
20
+ - **`Container.getModuleDescriptions(): ModuleDescription[]`** — the loaded
21
+ module graph (moduleId, imports, isGlobal, lazy, provider/export token
22
+ labels), load order plus the `__root__` bucket; reads registration state
23
+ only — safe pre/post bootstrap, never constructs, never claims lazy
24
+ modules.
25
+ - **`describeToken(token)`** — the token-label helper vela's own errors use,
26
+ exported for tooling.
27
+
28
+ ## 1.14.0 (2026-07-04)
29
+
30
+ First-party `QueueModule` (roadmap phase 3, "the openness proof"): a whole
31
+ feature module authored on the public API alone — `defineModule` (+ `lazy`),
32
+ `createDiscoverableDecorator`, `registerEntrypointKind`, `app.entrypoints`,
33
+ `runInEntrypointScope`, `buildEntrypointExecutionContext`, `PipelineRunner` —
34
+ machine-verified by an import audit test.
35
+
36
+ ### Added
37
+
38
+ - **`@velajs/vela/queue`** — platform-agnostic queue subsystem (subpath-only;
39
+ deliberately NOT re-exported from the main barrel because
40
+ `@velajs/cloudflare` already exports an unrelated CF-binding `QueueModule`).
41
+ Producers: `QueueModule.forRoot({ queues: ['email'] })` + per-queue
42
+ `QueueClient` injected via `queueToken(name)` (`add(jobName, data,
43
+ { delayMs? })`). Consumers: `@Processor(queue)` classes with
44
+ `@Process(jobName?)` handlers (named wins over wildcard; duplicates warn,
45
+ first-wins). Dispatch runs each job in `runInEntrypointScope`
46
+ (request-scoped deps rebuild per job), re-resolves processors by token
47
+ through the async seam (lazy consumer modules — async init hooks included —
48
+ materialize on first job), and applies scoped
49
+ guards/interceptors/filters through the shared pipeline; unclaimed errors
50
+ rethrow for platform retry. App-wide `APP_*` components deliberately do NOT
51
+ run around queue jobs (cloudflare queue/scheduled parity; diverges from the
52
+ WebSocket dispatcher — revisit framework-wide). The in-core `inline()`
53
+ driver (edge-pure, no timers) delivers on a microtask (`immediate`) or via
54
+ `flush()` (`manual`, rejects with `AggregateError` on unclaimed handler
55
+ errors); platform drivers implement `QueueDriver` out-of-core and call
56
+ `dispatchQueueJob(container, app.entrypoints, job)`. The module is
57
+ `lazy: true` (dogfoods 1.13): consumer-only workers defer it entirely;
58
+ an eager producer's client injection materializes it at bootstrap.
59
+ `queues` is structural — `forRootAsync({ queues, useFactory })`.
60
+ - **`resolveScopedComponents(type, class, method, container)`** — public
61
+ pipeline seam surfaced by the openness proof: scoped
62
+ `@UseGuards`/`@UsePipes`/`@UseInterceptors`/`@UseFilters` resolution for
63
+ custom dispatchers (declaration order preserved; conventions like
64
+ closest-first filter reversal stay with the caller).
65
+ - **`EntrypointRegistry` is injectable** — the per-app registry registers
66
+ into the container (global token) at the end of
67
+ `callOnApplicationBootstrap()`, so providers that dispatch entrypoints
68
+ themselves (the queue module's in-process driver binding) resolve it
69
+ instead of needing a back-reference to the app; `container.has(...)` probes
70
+ it safely pre-bootstrap (the queue binding falls back to
71
+ `DiscoveryService` + `deferLazy` for deliveries during bootstrap).
72
+
73
+ ## 1.13.0 (2026-07-04)
4
74
 
5
75
  Cold-start laziness (roadmap phase 3): modules can defer their entire init to
6
76
  first use, and the in-core subsystems an HTTP-only worker doesn't touch now
@@ -2,7 +2,7 @@ import type { Hono } from 'hono';
2
2
  import type { Container } from './container/container';
3
3
  import type { Token } from './container/types';
4
4
  import { EntrypointRegistry } from './entrypoint/entrypoint.registry';
5
- import type { RouteManager } from './http/route.manager';
5
+ import type { RouteDescription, RouteManager } from './http/route.manager';
6
6
  import type { MountOpenApiOptions } from './openapi/types';
7
7
  import type { FilterType, GuardType, InterceptorType, PipeType } from './registry/types';
8
8
  export declare class VelaApplication {
@@ -25,6 +25,15 @@ export declare class VelaApplication {
25
25
  setInstances(instances: unknown[]): void;
26
26
  get<T>(token: Token<T>): T;
27
27
  getHonoApp(): Hono;
28
+ /**
29
+ * Every explicit controller route as the framework registered it (fully
30
+ * composed paths — the `vela route list` seam). Requires built routes;
31
+ * contributed routes (`@velajs/crud`) mount directly on Hono and are
32
+ * observable via `getHonoApp().routes` instead.
33
+ */
34
+ describeRoutes(): RouteDescription[];
35
+ /** The global route prefix in effect ('' when none). */
36
+ getGlobalPrefix(): string;
28
37
  useGlobalPipes(...pipes: PipeType[]): this;
29
38
  useGlobalGuards(...guards: GuardType[]): this;
30
39
  useGlobalInterceptors(...interceptors: InterceptorType[]): this;
@@ -63,6 +63,18 @@ export class VelaApplication {
63
63
  getHonoApp() {
64
64
  return this.getApp();
65
65
  }
66
+ /**
67
+ * Every explicit controller route as the framework registered it (fully
68
+ * composed paths — the `vela route list` seam). Requires built routes;
69
+ * contributed routes (`@velajs/crud`) mount directly on Hono and are
70
+ * observable via `getHonoApp().routes` instead.
71
+ */ describeRoutes() {
72
+ this.getApp(); // same routes-not-built guard as every HTTP accessor
73
+ return this.routeManager.getRouteDescriptions();
74
+ }
75
+ /** The global route prefix in effect ('' when none). */ getGlobalPrefix() {
76
+ return this.routeManager.getGlobalPrefix();
77
+ }
66
78
  // Pipeline components — applied at request time, no rebuild needed
67
79
  useGlobalPipes(...pipes) {
68
80
  this.routeManager.useGlobalPipes(...pipes);
@@ -220,6 +232,16 @@ export class VelaApplication {
220
232
  this.entrypointRegistry = await EntrypointRegistry.build(discovery, this.instances, {
221
233
  deferLazy: true
222
234
  });
235
+ // Make the per-app registry injectable (global token): providers that
236
+ // dispatch entrypoints themselves (the queue module's in-process driver
237
+ // binding) resolve it instead of needing a back-reference to the app.
238
+ // Registered AFTER build so anything resolving it sees the final registry;
239
+ // pre-bootstrap resolution attempts fail the `has()` probe and defer.
240
+ this.container.register({
241
+ provide: EntrypointRegistry,
242
+ useValue: this.entrypointRegistry
243
+ });
244
+ this.container.markGlobalToken(EntrypointRegistry);
223
245
  }
224
246
  /**
225
247
  * Materialize every still-pending lazy module (async-safe): construct the
@@ -1,5 +1,5 @@
1
1
  import { Scope } from '../constants';
2
- import type { ContainerOptions, Diagnostics, LazyResolutionHook, ModuleScope, ProviderOptions, Token, Type } from './types';
2
+ import type { ContainerOptions, Diagnostics, LazyResolutionHook, ModuleDescription, 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
@@ -95,6 +95,13 @@ export declare class Container {
95
95
  * binding module — so framework adapters can initialize all of them.
96
96
  */
97
97
  getUseValues(): unknown[];
98
+ /**
99
+ * Serializable description of every loaded module instance (load order),
100
+ * plus the `__root__` bucket (bootstrap primitives) when non-empty —
101
+ * the `vela module graph` seam. Reads registration state only: safe pre-
102
+ * and post-bootstrap, never constructs, never claims lazy modules.
103
+ */
104
+ getModuleDescriptions(): ModuleDescription[];
98
105
  /**
99
106
  * Compute request-scope bubbling for every registration (call once at
100
107
  * bootstrap, after all providers are registered). A provider whose declared
@@ -1,7 +1,7 @@
1
1
  import { Scope } from "../constants.js";
2
2
  import { disposeInstance, isDisposable } from "./disposable.js";
3
3
  import { getConstructorDependencies, getInjectMetadata, getScope, isInjectable } from "./decorators.js";
4
- import { ForwardRef, InjectionToken, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID } from "./types.js";
4
+ import { describeToken, ForwardRef, InjectionToken, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID } from "./types.js";
5
5
  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.';
6
6
  /**
7
7
  * Sentinel comparing a token to the bare `Object` class — `design:paramtypes`
@@ -415,6 +415,44 @@ const IMPORT_TYPE_HINT = 'Did you use `import type { X }`? TypeScript strips typ
415
415
  return out;
416
416
  }
417
417
  /**
418
+ * Serializable description of every loaded module instance (load order),
419
+ * plus the `__root__` bucket (bootstrap primitives) when non-empty —
420
+ * the `vela module graph` seam. Reads registration state only: safe pre-
421
+ * and post-bootstrap, never constructs, never claims lazy modules.
422
+ */ getModuleDescriptions() {
423
+ const out = [];
424
+ for (const scope of this.scopes.values()){
425
+ out.push({
426
+ moduleId: scope.moduleId,
427
+ imports: [
428
+ ...scope.importedModules
429
+ ],
430
+ isGlobal: scope.isGlobal,
431
+ lazy: scope.lazy === true,
432
+ providers: [
433
+ ...this.providers.get(scope.moduleId)?.keys() ?? []
434
+ ].map(describeToken),
435
+ exports: [
436
+ ...scope.exportedTokens
437
+ ].map(describeToken)
438
+ });
439
+ }
440
+ const rootBucket = this.providers.get(ROOT_MODULE_ID);
441
+ if (rootBucket && rootBucket.size > 0) {
442
+ out.push({
443
+ moduleId: ROOT_MODULE_ID,
444
+ imports: [],
445
+ isGlobal: false,
446
+ lazy: false,
447
+ providers: [
448
+ ...rootBucket.keys()
449
+ ].map(describeToken),
450
+ exports: []
451
+ });
452
+ }
453
+ return out;
454
+ }
455
+ /**
418
456
  * Compute request-scope bubbling for every registration (call once at
419
457
  * bootstrap, after all providers are registered). A provider whose declared
420
458
  * scope is not REQUEST but which (transitively) depends on a request-scoped
@@ -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, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, } from './types';
3
+ export { InjectionToken, ForwardRef, forwardRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, describeToken, } from './types';
4
4
  export { ModuleRef } from './module-ref';
5
5
  export { mixin } from './mixin';
6
- export type { Type, Token, InferToken, InferTokens, InjectableOptions, InjectMetadata, ProviderOptions, ProviderRegistration, InjectionTokenOptions, ModuleScope, ContainerOptions, Diagnostics, } from './types';
6
+ export type { Type, Token, InferToken, InferTokens, InjectableOptions, InjectMetadata, ProviderOptions, ProviderRegistration, InjectionTokenOptions, ModuleScope, ModuleDescription, 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, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID } from "./types.js";
3
+ export { InjectionToken, ForwardRef, forwardRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, describeToken } from "./types.js";
4
4
  export { ModuleRef } from "./module-ref.js";
5
5
  export { mixin } from "./mixin.js";
@@ -117,6 +117,29 @@ export type Diagnostics = 'silent' | 'log' | 'throw';
117
117
  export interface ContainerOptions {
118
118
  diagnostics?: Diagnostics;
119
119
  }
120
+ /**
121
+ * Human-readable token label — class name, `InjectionToken(desc)`, symbol
122
+ * string, or String() fallback. Public so introspection tooling (module
123
+ * graphs, entrypoint listings) renders tokens the same way vela's own errors
124
+ * do.
125
+ */
126
+ export declare function describeToken(token: Token): string;
127
+ /**
128
+ * One module instance in the loaded graph, serializable (strings only) —
129
+ * what `Container.getModuleDescriptions()` returns for introspection tooling
130
+ * (`vela module graph`). Reads registration state only; never constructs.
131
+ */
132
+ export interface ModuleDescription {
133
+ moduleId: string;
134
+ /** moduleIds this instance imports. */
135
+ imports: string[];
136
+ isGlobal: boolean;
137
+ lazy: boolean;
138
+ /** Token labels registered in this instance's bucket (registration order). */
139
+ providers: string[];
140
+ /** Token labels this instance exports. */
141
+ exports: string[];
142
+ }
120
143
  export declare class ModuleVisibilityError extends Error {
121
144
  readonly moduleId: string;
122
145
  readonly token: Token;
@@ -18,7 +18,12 @@ export class ForwardRef {
18
18
  export function forwardRef(factory) {
19
19
  return new ForwardRef(factory);
20
20
  }
21
- function describeToken(token) {
21
+ /**
22
+ * Human-readable token label — class name, `InjectionToken(desc)`, symbol
23
+ * string, or String() fallback. Public so introspection tooling (module
24
+ * graphs, entrypoint listings) renders tokens the same way vela's own errors
25
+ * do.
26
+ */ export function describeToken(token) {
22
27
  if (token instanceof InjectionToken) return token.toString();
23
28
  if (typeof token === 'function') return token.name;
24
29
  if (typeof token === 'symbol') return token.toString();
@@ -5,6 +5,23 @@ import type { MiddlewareRouteDefinition } from '../module/middleware';
5
5
  import type { CanActivate, ExceptionFilter, NestInterceptor, NestMiddleware, PipeTransform } from '../pipeline/types';
6
6
  import type { FilterType, GuardType, InterceptorType, MiddlewareType, PipeType } from '../registry/types';
7
7
  import type { ControllerRegistration } from './types';
8
+ /**
9
+ * One explicit controller route as the framework registered it — recorded by
10
+ * `build()` with the SAME composed path handed to Hono (global prefix +
11
+ * version segment + controller prefix + route path), so introspection tooling
12
+ * (`vela route list`) never re-derives composition. Contributed routes
13
+ * (`RouteContributor`, e.g. `@velajs/crud`) mount directly on the Hono app
14
+ * and are not described here — diff `app.getHonoApp().routes` for those.
15
+ */
16
+ export interface RouteDescription {
17
+ /** As declared on the handler — `@Head()` reports HEAD (Hono serves it under GET). */
18
+ method: string;
19
+ /** Fully composed path exactly as registered. */
20
+ path: string;
21
+ controller: string;
22
+ handler: string;
23
+ version?: number;
24
+ }
8
25
  export interface RouteManagerOptions {
9
26
  getClientIp?: (c: Context) => string | null;
10
27
  middleware?: MiddlewareHandler[];
@@ -27,6 +44,7 @@ export declare class RouteManager {
27
44
  private globalFilters;
28
45
  private globalPrefix;
29
46
  private consumerMiddlewareDefinitions;
47
+ private routeDescriptions;
30
48
  private readonly handlerExecutor;
31
49
  private readonly ambientContainer;
32
50
  constructor(container: Container, options?: RouteManagerOptions);
@@ -59,6 +77,10 @@ export declare class RouteManager {
59
77
  private wrapMiddlewareWithFilters;
60
78
  private mapMiddlewareError;
61
79
  registerController(controller: Type): this;
80
+ /** The explicit controller routes recorded by the last `build()`. */
81
+ getRouteDescriptions(): RouteDescription[];
82
+ /** The global prefix in effect (set at bootstrap; '' when none). */
83
+ getGlobalPrefix(): string;
62
84
  build(): Promise<Hono>;
63
85
  private registerRoute;
64
86
  private buildVersionedPaths;
@@ -92,6 +92,7 @@ export class RouteManager {
92
92
  globalFilters = [];
93
93
  globalPrefix = '';
94
94
  consumerMiddlewareDefinitions = [];
95
+ routeDescriptions = [];
95
96
  handlerExecutor;
96
97
  ambientContainer;
97
98
  constructor(container, options = {}){
@@ -273,8 +274,17 @@ export class RouteManager {
273
274
  });
274
275
  return this;
275
276
  }
277
+ /** The explicit controller routes recorded by the last `build()`. */ getRouteDescriptions() {
278
+ return [
279
+ ...this.routeDescriptions
280
+ ];
281
+ }
282
+ /** The global prefix in effect (set at bootstrap; '' when none). */ getGlobalPrefix() {
283
+ return this.globalPrefix;
284
+ }
276
285
  async build() {
277
286
  const app = new Hono();
287
+ this.routeDescriptions = [];
278
288
  // Outermost: dispose the per-request child container once the request is
279
289
  // fully done. Fast-paths out when the child has no request-scoped
280
290
  // disposables (the common case → zero overhead / no behavior change).
@@ -353,11 +363,18 @@ export class RouteManager {
353
363
  for (const route of routes){
354
364
  const effectiveVersion = route.version ?? metadata.version;
355
365
  const versionedPaths = this.buildVersionedPaths(this.globalPrefix, metadata.prefix, route.path, effectiveVersion);
366
+ // Parallel to versionedPaths: buildVersionedPaths maps versions to
367
+ // paths 1:1 in order, so index i of both arrays belongs together.
368
+ const versionsForPaths = effectiveVersion === undefined ? [
369
+ undefined
370
+ ] : Array.isArray(effectiveVersion) ? effectiveVersion : [
371
+ effectiveVersion
372
+ ];
356
373
  // Scoped (controller/handler) middleware only — global middleware is
357
374
  // applied once by this manager's own global pass, never re-read here.
358
375
  const middlewareItems = ComponentManager.getScopedComponents('middleware', controller, route.handlerName);
359
376
  const handler = this.handlerExecutor.create(route, controller, allParamMetadata);
360
- for (const fullPath of versionedPaths){
377
+ for (const [pathIndex, fullPath] of versionedPaths.entries()){
361
378
  for (const middlewareItem of middlewareItems){
362
379
  app.use(fullPath, this.wrapMiddlewareWithFilters((c, next)=>{
363
380
  const requestContainer = this.getRequestContainer(c);
@@ -366,6 +383,13 @@ export class RouteManager {
366
383
  }));
367
384
  }
368
385
  this.registerRoute(app, route.method, fullPath, handler);
386
+ this.routeDescriptions.push({
387
+ method: String(route.method),
388
+ path: fullPath || '/',
389
+ controller: controller.name,
390
+ handler: String(route.handlerName),
391
+ version: versionsForPaths[pathIndex]
392
+ });
369
393
  }
370
394
  }
371
395
  }
package/dist/index.d.ts CHANGED
@@ -6,8 +6,9 @@ export { bootstrap } from './factory/bootstrap';
6
6
  export type { BootstrapOptions, BootstrapResult } from './factory/bootstrap';
7
7
  export { createOpenApiDocument, ApiDoc, ApiTags, ApiResponse, zodToJsonSchema, } from './openapi/index';
8
8
  export type { OpenApiDocument, OpenApiInfo, OpenApiOperation, OpenApiParameter, OpenApiPathItem, OpenApiRequestBody, OpenApiResponse, ApiDocMetadata, ApiResponseEntry, ApiResponseOptions, CreateOpenApiDocumentOptions, HttpVerb, JsonSchema, } from './openapi/index';
9
- export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, mixin, } from './container/index';
10
- export type { Type, Token, InferToken, InferTokens, InjectableOptions, ProviderOptions, ModuleScope, ContainerOptions, Diagnostics, } from './container/index';
9
+ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, mixin, describeToken, } from './container/index';
10
+ export type { Type, Token, InferToken, InferTokens, InjectableOptions, ProviderOptions, ModuleScope, ModuleDescription, ContainerOptions, Diagnostics, } from './container/index';
11
+ export type { RouteDescription } from './http/route.manager';
11
12
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from './constants';
12
13
  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, createLazyParamDecorator, applyDecorators, } from './http/index';
13
14
  export { REQUEST_CONTEXT } from './http/request-context';
@@ -46,8 +47,8 @@ export type { AdapterContext, RuntimeAdapter } from './factory/adapter';
46
47
  export type { VelaCreateOptions } from './factory';
47
48
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
48
49
  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';
50
+ 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';
51
+ export type { PipelineRunOptions, ResolvedComponentMap } from './pipeline/index';
51
52
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, ReflectableDecorator, CreateDecoratorOptions, } from './pipeline/index';
52
53
  export type { Constructor, MiddlewareType, GuardType, PipeType, InterceptorType, FilterType, } from './registry/index';
53
54
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe, } from './pipeline/index';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { bootstrap } from "./factory/bootstrap.js";
7
7
  // OpenAPI
8
8
  export { createOpenApiDocument, ApiDoc, ApiTags, ApiResponse, zodToJsonSchema } from "./openapi/index.js";
9
9
  // DI Container
10
- export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, mixin } from "./container/index.js";
10
+ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, ModuleVisibilityError, MultipleProvidersFoundError, ROOT_MODULE_ID, mixin, describeToken } from "./container/index.js";
11
11
  // Constants
12
12
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from "./constants.js";
13
13
  // HTTP Decorators
@@ -48,7 +48,7 @@ export { registerRouteContributor, getRouteContributors } from "./http/route-con
48
48
  // Plugin manifest + composer
49
49
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN } from "./plugin/plugin.js";
50
50
  // Pipeline Decorators
51
- 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.js";
51
+ 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.js";
52
52
  // Built-in Pipes
53
53
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe } from "./pipeline/index.js";
54
54
  // Errors
@@ -1,6 +1,8 @@
1
1
  export { ComponentManager } from './component.manager';
2
2
  export { PipelineRunner } from './pipeline-runner';
3
3
  export type { PipelineRunOptions } from './pipeline-runner';
4
+ export { resolveScopedComponents } from './scoped-components';
5
+ export type { ResolvedComponentMap } from './scoped-components';
4
6
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, getCatchTypes, shouldFilterCatch, } from './decorators';
5
7
  export { SetMetadata, Reflector } from './reflector';
6
8
  export type { ReflectableDecorator, CreateDecoratorOptions } from './reflector';
@@ -1,5 +1,6 @@
1
1
  export { ComponentManager } from "./component.manager.js";
2
2
  export { PipelineRunner } from "./pipeline-runner.js";
3
+ export { resolveScopedComponents } from "./scoped-components.js";
3
4
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, getCatchTypes, shouldFilterCatch } from "./decorators.js";
4
5
  export { SetMetadata, Reflector } from "./reflector.js";
5
6
  export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from "./tokens.js";
@@ -0,0 +1,26 @@
1
+ import type { Container } from '../container/container';
2
+ import type { ComponentType, Constructor } from '../registry/types';
3
+ import type { CanActivate, ExceptionFilter, NestInterceptor, NestMiddleware, PipeTransform } from './types';
4
+ export interface ResolvedComponentMap {
5
+ guard: CanActivate;
6
+ pipe: PipeTransform;
7
+ interceptor: NestInterceptor;
8
+ filter: ExceptionFilter;
9
+ middleware: NestMiddleware;
10
+ }
11
+ /**
12
+ * Public seam for custom dispatchers: the `@UseGuards`/`@UsePipes`/
13
+ * `@UseInterceptors`/`@UseFilters` components scoped to a handler (class-level
14
+ * then method-level, declaration order), resolved to instances through the
15
+ * given container (classes constructed with DI; instances passed through).
16
+ *
17
+ * Order is preserved exactly as declared — conventions stay with the CALLER:
18
+ * filters run closest-first, so dispatchers reverse them
19
+ * (`resolveScopedComponents('filter', ...).reverse()`), and app-wide `APP_*`
20
+ * components are a separate, transport-specific decision.
21
+ *
22
+ * Promoted to the public API by the QueueModule work (the "openness proof"):
23
+ * it was the one capability custom entrypoint dispatchers needed that only
24
+ * the internal `ComponentManager` provided.
25
+ */
26
+ export declare function resolveScopedComponents<T extends ComponentType>(type: T, targetClass: Constructor, methodName: string | symbol, container: Container): ResolvedComponentMap[T][];
@@ -0,0 +1,27 @@
1
+ import { ComponentManager } from "./component.manager.js";
2
+ const RESOLVERS = {
3
+ guard: ComponentManager.resolveGuards.bind(ComponentManager),
4
+ pipe: ComponentManager.resolvePipes.bind(ComponentManager),
5
+ interceptor: ComponentManager.resolveInterceptors.bind(ComponentManager),
6
+ filter: ComponentManager.resolveFilters.bind(ComponentManager),
7
+ middleware: ComponentManager.resolveMiddleware.bind(ComponentManager)
8
+ };
9
+ /**
10
+ * Public seam for custom dispatchers: the `@UseGuards`/`@UsePipes`/
11
+ * `@UseInterceptors`/`@UseFilters` components scoped to a handler (class-level
12
+ * then method-level, declaration order), resolved to instances through the
13
+ * given container (classes constructed with DI; instances passed through).
14
+ *
15
+ * Order is preserved exactly as declared — conventions stay with the CALLER:
16
+ * filters run closest-first, so dispatchers reverse them
17
+ * (`resolveScopedComponents('filter', ...).reverse()`), and app-wide `APP_*`
18
+ * components are a separate, transport-specific decision.
19
+ *
20
+ * Promoted to the public API by the QueueModule work (the "openness proof"):
21
+ * it was the one capability custom entrypoint dispatchers needed that only
22
+ * the internal `ComponentManager` provided.
23
+ */ export function resolveScopedComponents(type, targetClass, methodName, container) {
24
+ const scoped = ComponentManager.getScopedComponents(type, targetClass, methodName);
25
+ const resolve = RESOLVERS[type];
26
+ return resolve(scoped, container);
27
+ }
@@ -0,0 +1,10 @@
1
+ export { QueueModule, QUEUE_MODULE_OPTIONS } from './queue.module';
2
+ export { Processor, Process, getProcessHandlers } from './queue.decorators';
3
+ export { queueToken, QUEUE_DRIVER, PROCESSOR_METADATA, PROCESS_METADATA } from './queue.tokens';
4
+ export { QueueClient } from './queue.client';
5
+ export { QueueDispatchBinding } from './queue.binding';
6
+ export { dispatchQueueJob } from './queue.dispatch';
7
+ export type { QueueDispatchResult, QueueEntry } from './queue.dispatch';
8
+ export { inline } from './inline.driver';
9
+ export type { InlineQueueDriver, InlineQueueOptions } from './inline.driver';
10
+ export type { AddJobOptions, ProcessMetadata, ProcessorMetadata, QueueDispatchFn, QueueDriver, QueueDriverBindHooks, QueueJob, QueueModuleOptions, } from './queue.types';
@@ -0,0 +1,12 @@
1
+ // @velajs/vela/queue — first-party queue subsystem, authored entirely on the
2
+ // public API (every vela import in src/queue/* comes from '../index'; the
3
+ // openness audit test enforces it). Deliberately NOT re-exported from the
4
+ // main barrel: @velajs/cloudflare already exports an (unrelated) QueueModule
5
+ // for the CF Queues binding, and subpath-only avoids the collision.
6
+ export { QueueModule, QUEUE_MODULE_OPTIONS } from "./queue.module.js";
7
+ export { Processor, Process, getProcessHandlers } from "./queue.decorators.js";
8
+ export { queueToken, QUEUE_DRIVER, PROCESSOR_METADATA, PROCESS_METADATA } from "./queue.tokens.js";
9
+ export { QueueClient } from "./queue.client.js";
10
+ export { QueueDispatchBinding } from "./queue.binding.js";
11
+ export { dispatchQueueJob } from "./queue.dispatch.js";
12
+ export { inline } from "./inline.driver.js";
@@ -0,0 +1,30 @@
1
+ import type { QueueDriver } from './queue.types';
2
+ export interface InlineQueueOptions {
3
+ /**
4
+ * `immediate` (default): deliver on a microtask after `enqueue` resolves —
5
+ * `add()` resolving does NOT mean the job was handled. Handler errors are
6
+ * routed to the binding's error hook (container diagnostics), never thrown
7
+ * into the detached microtask.
8
+ *
9
+ * `manual`: buffer until `flush()` — deterministic delivery for tests.
10
+ */
11
+ mode?: 'immediate' | 'manual';
12
+ }
13
+ export interface InlineQueueDriver extends QueueDriver {
14
+ /**
15
+ * Deliver everything buffered (manual mode; also drains jobs enqueued
16
+ * before the driver was bound). Resolves with the delivered count; rejects
17
+ * with an `AggregateError` after attempting ALL buffered jobs if any
18
+ * handler error went unclaimed — the awaiter exists here, so the
19
+ * platform-retry rethrow contract is meaningful.
20
+ */
21
+ flush(): Promise<number>;
22
+ /** Jobs currently buffered (unbound immediate + all manual). */
23
+ readonly size: number;
24
+ }
25
+ /**
26
+ * In-process driver for dev, tests, and single-isolate apps. Edge-pure: no
27
+ * timers — `immediate` mode uses `queueMicrotask`. `delayMs` is not
28
+ * supported (warns once through the bound error hook's diagnostics side).
29
+ */
30
+ export declare function inline(options?: InlineQueueOptions): InlineQueueDriver;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * In-process driver for dev, tests, and single-isolate apps. Edge-pure: no
3
+ * timers — `immediate` mode uses `queueMicrotask`. `delayMs` is not
4
+ * supported (warns once through the bound error hook's diagnostics side).
5
+ */ export function inline(options = {}) {
6
+ const mode = options.mode ?? 'immediate';
7
+ const buffer = [];
8
+ let dispatch;
9
+ let onError;
10
+ let warnedDelay = false;
11
+ const deliverDetached = (job)=>{
12
+ queueMicrotask(()=>{
13
+ void dispatch(job).catch((error)=>onError?.(error, job));
14
+ });
15
+ };
16
+ return {
17
+ kind: 'inline',
18
+ get size () {
19
+ return buffer.length;
20
+ },
21
+ async enqueue (job, addOptions) {
22
+ if (addOptions?.delayMs !== undefined && !warnedDelay) {
23
+ warnedDelay = true;
24
+ console.warn(`[vela] inline() queue driver does not support delayMs — job '${job.name}' delivers without delay.`);
25
+ }
26
+ if (mode === 'manual' || !dispatch) {
27
+ buffer.push(job);
28
+ return;
29
+ }
30
+ deliverDetached(job);
31
+ },
32
+ bind (fn, hooks) {
33
+ dispatch = fn;
34
+ onError = hooks?.onError;
35
+ if (mode === 'immediate' && buffer.length > 0) {
36
+ for (const job of buffer.splice(0))deliverDetached(job);
37
+ }
38
+ },
39
+ async flush () {
40
+ if (!dispatch) return 0;
41
+ const jobs = buffer.splice(0);
42
+ const errors = [];
43
+ let delivered = 0;
44
+ for (const job of jobs){
45
+ try {
46
+ await dispatch(job);
47
+ delivered++;
48
+ } catch (error) {
49
+ errors.push(error);
50
+ }
51
+ }
52
+ if (errors.length > 0) {
53
+ throw new AggregateError(errors, `queue flush: ${errors.length} of ${jobs.length} jobs failed (${delivered} delivered)`);
54
+ }
55
+ return delivered;
56
+ }
57
+ };
58
+ }
@@ -0,0 +1,23 @@
1
+ import type { Container, DiscoveryService } from '../index';
2
+ import type { QueueDriver } from './queue.types';
3
+ /**
4
+ * Wires a `QueueModule` instance's driver to the app: binds in-process
5
+ * delivery to the dispatch core and validates that no other module instance
6
+ * provides the same queue names.
7
+ *
8
+ * Delivery resolves processors from the per-app `EntrypointRegistry` once it
9
+ * exists (registered into the container at the end of
10
+ * `callOnApplicationBootstrap`); deliveries that arrive earlier (a producer's
11
+ * `onModuleInit` calling `add()`) fall back to `DiscoveryService` with
12
+ * `deferLazy` — identical entries, no buffering, no lost jobs.
13
+ *
14
+ * Every `QueueClient` injects this binding, so the driver is always bound
15
+ * before the first `add()` — including when the module materializes lazily.
16
+ */
17
+ export declare class QueueDispatchBinding {
18
+ private readonly container;
19
+ private readonly discovery;
20
+ constructor(container: Container, discovery: DiscoveryService, driver: QueueDriver, queues: string[]);
21
+ private deliver;
22
+ private routeError;
23
+ }
@@ -0,0 +1,53 @@
1
+ import { EntrypointRegistry } from "../index.js";
2
+ import { dispatchJobToEntries } from "./queue.dispatch.js";
3
+ import { PROCESSOR_METADATA, queueToken } from "./queue.tokens.js";
4
+ /**
5
+ * Wires a `QueueModule` instance's driver to the app: binds in-process
6
+ * delivery to the dispatch core and validates that no other module instance
7
+ * provides the same queue names.
8
+ *
9
+ * Delivery resolves processors from the per-app `EntrypointRegistry` once it
10
+ * exists (registered into the container at the end of
11
+ * `callOnApplicationBootstrap`); deliveries that arrive earlier (a producer's
12
+ * `onModuleInit` calling `add()`) fall back to `DiscoveryService` with
13
+ * `deferLazy` — identical entries, no buffering, no lost jobs.
14
+ *
15
+ * Every `QueueClient` injects this binding, so the driver is always bound
16
+ * before the first `add()` — including when the module materializes lazily.
17
+ */ export class QueueDispatchBinding {
18
+ container;
19
+ discovery;
20
+ constructor(container, discovery, driver, queues){
21
+ this.container = container;
22
+ this.discovery = discovery;
23
+ for (const queue of queues){
24
+ const owners = container.getOwnerModuleIds(queueToken(queue));
25
+ if (owners.length > 1) {
26
+ throw new Error(`Queue '${queue}' is provided by multiple QueueModule instances (${owners.join(', ')}). ` + `Queue names must be unique per app — either dedup the forRoot options or rename the queue.`);
27
+ }
28
+ }
29
+ driver.bind?.((job)=>this.deliver(job), {
30
+ onError: (error, job)=>this.routeError(error, job)
31
+ });
32
+ }
33
+ async deliver(job) {
34
+ const entries = this.container.has(EntrypointRegistry) ? this.container.resolve(EntrypointRegistry).ofKind('queue').map((ep)=>({
35
+ token: ep.token,
36
+ meta: ep.meta
37
+ })) : this.discovery.providersWithMeta(PROCESSOR_METADATA, {
38
+ deferLazy: true
39
+ }).map((found)=>({
40
+ token: found.token,
41
+ meta: found.meta
42
+ }));
43
+ await dispatchJobToEntries(this.container, entries, job);
44
+ }
45
+ routeError(error, job) {
46
+ const mode = this.container.getDiagnostics();
47
+ if (mode === 'silent') return;
48
+ if (mode === 'throw') {
49
+ throw error instanceof Error ? error : new Error(String(error));
50
+ }
51
+ console.error(`[vela] unhandled error processing queue job '${job.name}' on '${job.queue}':`, error);
52
+ }
53
+ }
@@ -0,0 +1,20 @@
1
+ import type { AddJobOptions, QueueDriver, QueueJob } from './queue.types';
2
+ /**
3
+ * Producer handle for one named queue — inject via `queueToken(name)`:
4
+ *
5
+ * ```ts
6
+ * constructor(@Inject(queueToken('email')) private readonly email: QueueClient) {}
7
+ * await this.email.add('welcome', { userId });
8
+ * ```
9
+ *
10
+ * `add()` resolves when the DRIVER accepted the job, not when a processor
11
+ * handled it (the inline driver's `immediate` mode delivers on a following
12
+ * microtask; platform drivers deliver in another isolate entirely).
13
+ */
14
+ export declare class QueueClient {
15
+ private readonly queue;
16
+ private readonly driver;
17
+ constructor(queue: string, driver: QueueDriver);
18
+ get name(): string;
19
+ add<T>(jobName: string, data: T, options?: AddJobOptions): Promise<QueueJob<T>>;
20
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Producer handle for one named queue — inject via `queueToken(name)`:
3
+ *
4
+ * ```ts
5
+ * constructor(@Inject(queueToken('email')) private readonly email: QueueClient) {}
6
+ * await this.email.add('welcome', { userId });
7
+ * ```
8
+ *
9
+ * `add()` resolves when the DRIVER accepted the job, not when a processor
10
+ * handled it (the inline driver's `immediate` mode delivers on a following
11
+ * microtask; platform drivers deliver in another isolate entirely).
12
+ */ export class QueueClient {
13
+ queue;
14
+ driver;
15
+ constructor(queue, driver){
16
+ this.queue = queue;
17
+ this.driver = driver;
18
+ }
19
+ get name() {
20
+ return this.queue;
21
+ }
22
+ async add(jobName, data, options = {}) {
23
+ const job = {
24
+ id: crypto.randomUUID(),
25
+ queue: this.queue,
26
+ name: jobName,
27
+ data,
28
+ attempt: 1
29
+ };
30
+ await this.driver.enqueue(job, options);
31
+ return job;
32
+ }
33
+ }
@@ -0,0 +1,23 @@
1
+ import type { ProcessMetadata } from './queue.types';
2
+ /**
3
+ * Marks a provider class as a processor for one queue:
4
+ *
5
+ * ```ts
6
+ * @Processor('email')
7
+ * @Injectable()
8
+ * class EmailProcessor {
9
+ * @Process('welcome')
10
+ * async sendWelcome(job: QueueJob<{ userId: string }>) { ... }
11
+ * }
12
+ * ```
13
+ */
14
+ export declare function Processor(queueName: string): ClassDecorator;
15
+ /**
16
+ * Marks a processor method as the handler for a job name. Omit the name for
17
+ * the wildcard handler (receives every job on the queue that has no named
18
+ * handler). Named handlers win over the wildcard; a duplicate registration
19
+ * for the same name is first-wins with a diagnostics warning at dispatch.
20
+ */
21
+ export declare function Process(jobName?: string): MethodDecorator;
22
+ /** `@Process` entries declared on a processor class (declaration order). */
23
+ export declare function getProcessHandlers(processorClass: object): ProcessMetadata[];
@@ -0,0 +1,46 @@
1
+ import { createDiscoverableDecorator, defineMetadata, getMetadata, registerEntrypointKind } from "../index.js";
2
+ import { PROCESS_METADATA, PROCESSOR_METADATA } from "./queue.tokens.js";
3
+ const ProcessorMeta = createDiscoverableDecorator(PROCESSOR_METADATA);
4
+ // The open entrypoint kind: adapters enumerate processors via
5
+ // `app.entrypoints.ofKind<ProcessorMetadata>('queue')`. Declared at import
6
+ // time next to the decorator — zero kernel involvement.
7
+ registerEntrypointKind({
8
+ kind: 'queue',
9
+ metaKey: PROCESSOR_METADATA,
10
+ level: 'class'
11
+ });
12
+ /**
13
+ * Marks a provider class as a processor for one queue:
14
+ *
15
+ * ```ts
16
+ * @Processor('email')
17
+ * @Injectable()
18
+ * class EmailProcessor {
19
+ * @Process('welcome')
20
+ * async sendWelcome(job: QueueJob<{ userId: string }>) { ... }
21
+ * }
22
+ * ```
23
+ */ export function Processor(queueName) {
24
+ return ProcessorMeta({
25
+ queueName
26
+ });
27
+ }
28
+ /**
29
+ * Marks a processor method as the handler for a job name. Omit the name for
30
+ * the wildcard handler (receives every job on the queue that has no named
31
+ * handler). Named handlers win over the wildcard; a duplicate registration
32
+ * for the same name is first-wins with a diagnostics warning at dispatch.
33
+ */ export function Process(jobName) {
34
+ return (target, propertyKey)=>{
35
+ const ctor = target.constructor;
36
+ const existing = getMetadata(PROCESS_METADATA, ctor) ?? [];
37
+ existing.push({
38
+ jobName,
39
+ methodName: propertyKey
40
+ });
41
+ defineMetadata(PROCESS_METADATA, existing, ctor);
42
+ };
43
+ }
44
+ /** `@Process` entries declared on a processor class (declaration order). */ export function getProcessHandlers(processorClass) {
45
+ return getMetadata(PROCESS_METADATA, processorClass) ?? [];
46
+ }
@@ -0,0 +1,36 @@
1
+ import type { Container, EntrypointRegistry, Token } from '../index';
2
+ import type { ProcessorMetadata, QueueJob } from './queue.types';
3
+ export interface QueueDispatchResult {
4
+ /** Processors that ran a handler for this job. */
5
+ handled: number;
6
+ }
7
+ /** One dispatchable processor: its class token + `@Processor` meta. */
8
+ export interface QueueEntry {
9
+ token: Token;
10
+ meta: ProcessorMetadata;
11
+ }
12
+ /**
13
+ * Deliver one job to every `@Processor` of its queue — the primitive both the
14
+ * in-core `inline()` driver and platform adapters call.
15
+ *
16
+ * Each matching processor runs inside `runInEntrypointScope` (request-scoped
17
+ * dependencies rebuild per job) and is re-resolved BY TOKEN through the async
18
+ * seam, so processors living in `lazy: true` modules materialize cleanly on
19
+ * first dispatch — async providers and lifecycle hooks included. Scoped
20
+ * guards/interceptors/filters run through `PipelineRunner`
21
+ * (`getType() === 'queue'`, `getPayload()` is the job); app-wide `APP_*`
22
+ * components deliberately do NOT apply (cloudflare queue/scheduled parity —
23
+ * documented divergence from the WebSocket dispatcher).
24
+ *
25
+ * Errors no scoped filter claims RETHROW so awaiting platforms keep their
26
+ * retry semantics; fire-and-forget callers (inline `immediate` mode) must
27
+ * catch — `QueueDispatchBinding` routes those to diagnostics.
28
+ */
29
+ export declare function dispatchQueueJob(container: Container, entrypoints: EntrypointRegistry, job: QueueJob): Promise<QueueDispatchResult>;
30
+ /**
31
+ * Entry-list core shared by `dispatchQueueJob` (registry) and the in-process
32
+ * driver binding (discovery fallback before the registry exists). Entries are
33
+ * tokens + meta ONLY — every processor is re-resolved by token in its own
34
+ * scope, so request-scoped and lazy-module processors work on both paths.
35
+ */
36
+ export declare function dispatchJobToEntries(container: Container, entries: QueueEntry[], job: QueueJob): Promise<QueueDispatchResult>;
@@ -0,0 +1,102 @@
1
+ import { buildEntrypointExecutionContext, PipelineRunner, resolveScopedComponents, runInEntrypointScope, shouldFilterCatch } from "../index.js";
2
+ import { getProcessHandlers } from "./queue.decorators.js";
3
+ const warnedDuplicates = new WeakSet();
4
+ function selectHandler(container, processorClass, handlers, jobName) {
5
+ const named = handlers.filter((h)=>h.jobName === jobName);
6
+ const pool = named.length > 0 ? named : handlers.filter((h)=>h.jobName === undefined);
7
+ if (pool.length > 1 && !warnedDuplicates.has(processorClass)) {
8
+ warnedDuplicates.add(processorClass);
9
+ const label = named.length > 0 ? `@Process('${jobName}')` : '@Process() (wildcard)';
10
+ const message = `[vela] duplicate ${label} handlers on ${processorClass.name}; ` + `keeping the first ('${String(pool[0].methodName)}').`;
11
+ if (container.getDiagnostics() === 'throw') throw new Error(message);
12
+ if (container.getDiagnostics() === 'log') console.warn(message);
13
+ }
14
+ return pool[0];
15
+ }
16
+ /**
17
+ * Deliver one job to every `@Processor` of its queue — the primitive both the
18
+ * in-core `inline()` driver and platform adapters call.
19
+ *
20
+ * Each matching processor runs inside `runInEntrypointScope` (request-scoped
21
+ * dependencies rebuild per job) and is re-resolved BY TOKEN through the async
22
+ * seam, so processors living in `lazy: true` modules materialize cleanly on
23
+ * first dispatch — async providers and lifecycle hooks included. Scoped
24
+ * guards/interceptors/filters run through `PipelineRunner`
25
+ * (`getType() === 'queue'`, `getPayload()` is the job); app-wide `APP_*`
26
+ * components deliberately do NOT apply (cloudflare queue/scheduled parity —
27
+ * documented divergence from the WebSocket dispatcher).
28
+ *
29
+ * Errors no scoped filter claims RETHROW so awaiting platforms keep their
30
+ * retry semantics; fire-and-forget callers (inline `immediate` mode) must
31
+ * catch — `QueueDispatchBinding` routes those to diagnostics.
32
+ */ export async function dispatchQueueJob(container, entrypoints, job) {
33
+ const entries = entrypoints.ofKind('queue').map((ep)=>({
34
+ token: ep.token,
35
+ meta: ep.meta
36
+ }));
37
+ return dispatchJobToEntries(container, entries, job);
38
+ }
39
+ /**
40
+ * Entry-list core shared by `dispatchQueueJob` (registry) and the in-process
41
+ * driver binding (discovery fallback before the registry exists). Entries are
42
+ * tokens + meta ONLY — every processor is re-resolved by token in its own
43
+ * scope, so request-scoped and lazy-module processors work on both paths.
44
+ */ export async function dispatchJobToEntries(container, entries, job) {
45
+ const processors = entries.filter((entry)=>entry.meta.queueName === job.queue);
46
+ if (processors.length === 0) {
47
+ if (container.getDiagnostics() === 'log') {
48
+ console.warn(`[vela] queue job '${job.name}' on '${job.queue}' has no @Processor('${job.queue}') — dropped.`);
49
+ }
50
+ return {
51
+ handled: 0
52
+ };
53
+ }
54
+ // All matching processors receive the job; the first unclaimed error
55
+ // rejects the whole dispatch (platform retries the delivery — CF parity).
56
+ const outcomes = await Promise.all(processors.map((entry)=>dispatchToProcessor(container, entry.token, job)));
57
+ return {
58
+ handled: outcomes.filter(Boolean).length
59
+ };
60
+ }
61
+ async function dispatchToProcessor(container, processorClass, job) {
62
+ const handler = selectHandler(container, processorClass, getProcessHandlers(processorClass), job.name);
63
+ if (!handler) {
64
+ if (container.getDiagnostics() === 'log') {
65
+ console.warn(`[vela] no @Process('${job.name}') (or wildcard) handler on ` + `${processorClass.name} for queue '${job.queue}' — skipped.`);
66
+ }
67
+ return false;
68
+ }
69
+ return runInEntrypointScope(container, async (scope)=>{
70
+ // Async seam: materializes lazy processor modules (drainAsync awaits
71
+ // their async providers/hooks) and rebuilds request-scoped processors.
72
+ const instance = await scope.resolveAsync(processorClass);
73
+ const context = buildEntrypointExecutionContext('queue', processorClass, handler.methodName, job);
74
+ const guards = resolveScopedComponents('guard', processorClass, handler.methodName, scope);
75
+ const interceptors = resolveScopedComponents('interceptor', processorClass, handler.methodName, scope);
76
+ // Closest-first: handler/class filters reversed by the caller (WS/CF convention).
77
+ const filters = resolveScopedComponents('filter', processorClass, handler.methodName, scope).reverse();
78
+ try {
79
+ await PipelineRunner.run({
80
+ context,
81
+ guards,
82
+ interceptors,
83
+ resolveArgs: async ()=>[
84
+ job
85
+ ],
86
+ invoke: async (args)=>{
87
+ const method = instance[handler.methodName];
88
+ return method.apply(instance, args);
89
+ }
90
+ });
91
+ return true;
92
+ } catch (error) {
93
+ for (const filter of filters){
94
+ if (shouldFilterCatch(filter, error)) {
95
+ await filter.catch(error, context);
96
+ return true;
97
+ }
98
+ }
99
+ throw error;
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,32 @@
1
+ import type { QueueModuleOptions } from './queue.types';
2
+ /**
3
+ * First-party queue module — authored 100% on vela's public API (the
4
+ * roadmap's "openness proof"). Producers inject a per-queue `QueueClient`
5
+ * via `queueToken(name)`; consumers are `@Processor`/`@Process` classes in
6
+ * ordinary user modules; platforms deliver through `dispatchQueueJob` or the
7
+ * in-core `inline()` driver.
8
+ *
9
+ * ```ts
10
+ * imports: [QueueModule.forRoot({ queues: ['email'] })]
11
+ * ```
12
+ *
13
+ * `lazy: true` (dogfoods 1.13): the module materializes when an eager
14
+ * producer injects a client (bootstrap — same structural reality that keeps
15
+ * WebSocketModule eager) or, in consumer-only workers, at the first
16
+ * delivered job.
17
+ *
18
+ * `queues` is STRUCTURAL: clients are options-derived providers, so
19
+ * `forRootAsync` callers pass it alongside the factory —
20
+ * `forRootAsync({ queues: ['email'], useFactory: () => ({ driver }) })`.
21
+ * Note that async options inherit the 1.13 lazy-module contract: the module
22
+ * must first materialize through an async seam (an eager producer's injection
23
+ * during the bootstrap sweep — the common case — or
24
+ * `app.materializeLazyModules()`); a synchronous first touch throws the
25
+ * descriptive sync-seam error.
26
+ */
27
+ declare const ConfigurableModuleClass: import("../module").ConfigurableModuleClassType<QueueModuleOptions, "forRoot", "create", {
28
+ isGlobal?: boolean;
29
+ }>, MODULE_OPTIONS_TOKEN: import("../container").InjectionToken<QueueModuleOptions>;
30
+ export declare class QueueModule extends ConfigurableModuleClass {
31
+ }
32
+ export { MODULE_OPTIONS_TOKEN as QUEUE_MODULE_OPTIONS };
@@ -0,0 +1,80 @@
1
+ import { Container, defineModule, DiscoveryService, stableHash } from "../index.js";
2
+ import { inline } from "./inline.driver.js";
3
+ import { QueueClient } from "./queue.client.js";
4
+ import { QueueDispatchBinding } from "./queue.binding.js";
5
+ import { QUEUE_DRIVER, queueToken } from "./queue.tokens.js";
6
+ /**
7
+ * First-party queue module — authored 100% on vela's public API (the
8
+ * roadmap's "openness proof"). Producers inject a per-queue `QueueClient`
9
+ * via `queueToken(name)`; consumers are `@Processor`/`@Process` classes in
10
+ * ordinary user modules; platforms deliver through `dispatchQueueJob` or the
11
+ * in-core `inline()` driver.
12
+ *
13
+ * ```ts
14
+ * imports: [QueueModule.forRoot({ queues: ['email'] })]
15
+ * ```
16
+ *
17
+ * `lazy: true` (dogfoods 1.13): the module materializes when an eager
18
+ * producer injects a client (bootstrap — same structural reality that keeps
19
+ * WebSocketModule eager) or, in consumer-only workers, at the first
20
+ * delivered job.
21
+ *
22
+ * `queues` is STRUCTURAL: clients are options-derived providers, so
23
+ * `forRootAsync` callers pass it alongside the factory —
24
+ * `forRootAsync({ queues: ['email'], useFactory: () => ({ driver }) })`.
25
+ * Note that async options inherit the 1.13 lazy-module contract: the module
26
+ * must first materialize through an async seam (an eager producer's injection
27
+ * during the bootstrap sweep — the common case — or
28
+ * `app.materializeLazyModules()`); a synchronous first touch throws the
29
+ * descriptive sync-seam error.
30
+ */ const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = defineModule({
31
+ name: 'Queue',
32
+ lazy: true,
33
+ key: (o)=>stableHash({
34
+ queues: o.queues ?? [],
35
+ driver: o.driver?.kind ?? 'inline'
36
+ }),
37
+ setup: ({ OPTIONS, options })=>{
38
+ const queues = options.queues;
39
+ if (!queues || queues.length === 0) {
40
+ throw new Error("QueueModule requires 'queues' as a structural option: " + "QueueModule.forRoot({ queues: ['email'] }) — for forRootAsync, pass it alongside " + 'the factory: forRootAsync({ queues: [...], useFactory }).');
41
+ }
42
+ const clientProviders = queues.map((name)=>({
43
+ provide: queueToken(name),
44
+ useFactory: (driver, _binding)=>new QueueClient(name, driver),
45
+ inject: [
46
+ QUEUE_DRIVER,
47
+ QueueDispatchBinding
48
+ ]
49
+ }));
50
+ return {
51
+ providers: [
52
+ {
53
+ provide: QUEUE_DRIVER,
54
+ useFactory: (o)=>o.driver ?? inline(),
55
+ inject: [
56
+ OPTIONS
57
+ ]
58
+ },
59
+ {
60
+ provide: QueueDispatchBinding,
61
+ useFactory: (container, discovery, driver)=>new QueueDispatchBinding(container, discovery, driver, queues),
62
+ inject: [
63
+ Container,
64
+ DiscoveryService,
65
+ QUEUE_DRIVER
66
+ ]
67
+ },
68
+ ...clientProviders
69
+ ],
70
+ exports: [
71
+ QUEUE_DRIVER,
72
+ QueueDispatchBinding,
73
+ ...queues.map((name)=>queueToken(name))
74
+ ]
75
+ };
76
+ }
77
+ });
78
+ export class QueueModule extends ConfigurableModuleClass {
79
+ }
80
+ export { MODULE_OPTIONS_TOKEN as QUEUE_MODULE_OPTIONS };
@@ -0,0 +1,21 @@
1
+ import { InjectionToken } from '../index';
2
+ import type { QueueClient } from './queue.client';
3
+ import type { QueueDriver } from './queue.types';
4
+ export declare const PROCESSOR_METADATA = "vela:queue:processor";
5
+ export declare const PROCESS_METADATA = "vela:queue:process";
6
+ /** The driver in effect for a `QueueModule` instance. */
7
+ export declare const QUEUE_DRIVER: InjectionToken<QueueDriver>;
8
+ /**
9
+ * The injection token for a named queue's `QueueClient`:
10
+ *
11
+ * ```ts
12
+ * constructor(@Inject(queueToken('email')) private readonly email: QueueClient) {}
13
+ * ```
14
+ *
15
+ * Memoized per name (HMR-stable). Deliberately NO `InjectionToken` default
16
+ * factory: the container resolves default-factory tokens from the root bucket
17
+ * BEFORE walking module imports, which would break the exported-provider
18
+ * path — the token description (`vela:queue:client:<name>`) keeps the
19
+ * no-provider error readable instead.
20
+ */
21
+ export declare function queueToken(name: string): InjectionToken<QueueClient>;
@@ -0,0 +1,34 @@
1
+ import { InjectionToken } from "../index.js";
2
+ export const PROCESSOR_METADATA = 'vela:queue:processor';
3
+ export const PROCESS_METADATA = 'vela:queue:process';
4
+ /** The driver in effect for a `QueueModule` instance. */ export const QUEUE_DRIVER = new InjectionToken('vela:queue:driver');
5
+ // Token identity must survive Vite HMR re-evals (a consumer module that was
6
+ // NOT re-evaluated still holds the token minted by the previous generation),
7
+ // so the name → token map is anchored on globalThis exactly like the
8
+ // entrypoint kind store and MetadataRegistry state.
9
+ const TOKEN_STORE_KEY = Symbol.for('vela:queue:client-tokens:v1');
10
+ function tokenStore() {
11
+ const g = globalThis;
12
+ return g[TOKEN_STORE_KEY] ??= new Map();
13
+ }
14
+ /**
15
+ * The injection token for a named queue's `QueueClient`:
16
+ *
17
+ * ```ts
18
+ * constructor(@Inject(queueToken('email')) private readonly email: QueueClient) {}
19
+ * ```
20
+ *
21
+ * Memoized per name (HMR-stable). Deliberately NO `InjectionToken` default
22
+ * factory: the container resolves default-factory tokens from the root bucket
23
+ * BEFORE walking module imports, which would break the exported-provider
24
+ * path — the token description (`vela:queue:client:<name>`) keeps the
25
+ * no-provider error readable instead.
26
+ */ export function queueToken(name) {
27
+ const store = tokenStore();
28
+ let token = store.get(name);
29
+ if (!token) {
30
+ token = new InjectionToken(`vela:queue:client:${name}`);
31
+ store.set(name, token);
32
+ }
33
+ return token;
34
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One job as handed to `@Process` handlers and drivers. Ids are minted by the
3
+ * client via `crypto.randomUUID()` (Web Crypto — edge-safe).
4
+ */
5
+ export interface QueueJob<T = unknown> {
6
+ id: string;
7
+ /** Queue the job was added to (`QueueModule.forRoot({ queues })` name). */
8
+ queue: string;
9
+ /** Job name — matched against `@Process(name)`; unnamed handlers catch the rest. */
10
+ name: string;
11
+ data: T;
12
+ /** Delivery attempt, 1-based. Platform drivers increment on retry. */
13
+ attempt: number;
14
+ }
15
+ export interface AddJobOptions {
16
+ /**
17
+ * Requested delivery delay. Honored only by drivers that support it — the
18
+ * in-core `inline()` driver does not and warns once (log diagnostics).
19
+ */
20
+ delayMs?: number;
21
+ }
22
+ /** The function a driver calls to deliver one job into the app's processors. */
23
+ export type QueueDispatchFn = (job: QueueJob) => Promise<void>;
24
+ export interface QueueDriverBindHooks {
25
+ /**
26
+ * Where fire-and-forget delivery errors go (the inline driver's
27
+ * `immediate` mode has no awaiter to rethrow into). Wired to the
28
+ * container's diagnostics by `QueueDispatchBinding`.
29
+ */
30
+ onError?: (error: unknown, job: QueueJob) => void;
31
+ }
32
+ /**
33
+ * Producer/transport seam. `enqueue` accepts a job for later (or immediate)
34
+ * delivery; drivers that deliver in-process implement `bind` to receive the
35
+ * app's dispatch function. Platform packages (Cloudflare Queues, Redis, …)
36
+ * implement this interface out-of-core.
37
+ */
38
+ export interface QueueDriver {
39
+ readonly kind: string;
40
+ enqueue(job: QueueJob, options?: AddJobOptions): Promise<void>;
41
+ bind?(dispatch: QueueDispatchFn, hooks?: QueueDriverBindHooks): void;
42
+ }
43
+ export interface QueueModuleOptions {
44
+ /**
45
+ * Queue names this instance provides clients for. STRUCTURAL — must be
46
+ * known at `forRoot`/`forRootAsync` call time (clients are options-derived
47
+ * providers); `forRootAsync` callers pass it alongside the factory.
48
+ */
49
+ queues?: string[];
50
+ /** Defaults to the in-core `inline()` driver. */
51
+ driver?: QueueDriver;
52
+ }
53
+ /** Class-level meta written by `@Processor(queueName)` (the 'queue' entrypoint kind). */
54
+ export interface ProcessorMetadata {
55
+ queueName: string;
56
+ }
57
+ /** Per-handler meta written by `@Process(jobName?)`. */
58
+ export interface ProcessMetadata {
59
+ jobName?: string;
60
+ methodName: string | symbol;
61
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * One job as handed to `@Process` handlers and drivers. Ids are minted by the
3
+ * client via `crypto.randomUUID()` (Web Crypto — edge-safe).
4
+ */ /** Per-handler meta written by `@Process(jobName?)`. */ export { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/i18n/index.d.ts",
35
35
  "import": "./dist/i18n/index.js"
36
36
  },
37
+ "./queue": {
38
+ "types": "./dist/queue/index.d.ts",
39
+ "import": "./dist/queue/index.js"
40
+ },
37
41
  "./storage": {
38
42
  "types": "./dist/storage/index.d.ts",
39
43
  "import": "./dist/storage/index.js"