@velajs/vela 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,51 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.14.0 (2026-07-04)
4
+
5
+ First-party `QueueModule` (roadmap phase 3, "the openness proof"): a whole
6
+ feature module authored on the public API alone — `defineModule` (+ `lazy`),
7
+ `createDiscoverableDecorator`, `registerEntrypointKind`, `app.entrypoints`,
8
+ `runInEntrypointScope`, `buildEntrypointExecutionContext`, `PipelineRunner` —
9
+ machine-verified by an import audit test.
10
+
11
+ ### Added
12
+
13
+ - **`@velajs/vela/queue`** — platform-agnostic queue subsystem (subpath-only;
14
+ deliberately NOT re-exported from the main barrel because
15
+ `@velajs/cloudflare` already exports an unrelated CF-binding `QueueModule`).
16
+ Producers: `QueueModule.forRoot({ queues: ['email'] })` + per-queue
17
+ `QueueClient` injected via `queueToken(name)` (`add(jobName, data,
18
+ { delayMs? })`). Consumers: `@Processor(queue)` classes with
19
+ `@Process(jobName?)` handlers (named wins over wildcard; duplicates warn,
20
+ first-wins). Dispatch runs each job in `runInEntrypointScope`
21
+ (request-scoped deps rebuild per job), re-resolves processors by token
22
+ through the async seam (lazy consumer modules — async init hooks included —
23
+ materialize on first job), and applies scoped
24
+ guards/interceptors/filters through the shared pipeline; unclaimed errors
25
+ rethrow for platform retry. App-wide `APP_*` components deliberately do NOT
26
+ run around queue jobs (cloudflare queue/scheduled parity; diverges from the
27
+ WebSocket dispatcher — revisit framework-wide). The in-core `inline()`
28
+ driver (edge-pure, no timers) delivers on a microtask (`immediate`) or via
29
+ `flush()` (`manual`, rejects with `AggregateError` on unclaimed handler
30
+ errors); platform drivers implement `QueueDriver` out-of-core and call
31
+ `dispatchQueueJob(container, app.entrypoints, job)`. The module is
32
+ `lazy: true` (dogfoods 1.13): consumer-only workers defer it entirely;
33
+ an eager producer's client injection materializes it at bootstrap.
34
+ `queues` is structural — `forRootAsync({ queues, useFactory })`.
35
+ - **`resolveScopedComponents(type, class, method, container)`** — public
36
+ pipeline seam surfaced by the openness proof: scoped
37
+ `@UseGuards`/`@UsePipes`/`@UseInterceptors`/`@UseFilters` resolution for
38
+ custom dispatchers (declaration order preserved; conventions like
39
+ closest-first filter reversal stay with the caller).
40
+ - **`EntrypointRegistry` is injectable** — the per-app registry registers
41
+ into the container (global token) at the end of
42
+ `callOnApplicationBootstrap()`, so providers that dispatch entrypoints
43
+ themselves (the queue module's in-process driver binding) resolve it
44
+ instead of needing a back-reference to the app; `container.has(...)` probes
45
+ it safely pre-bootstrap (the queue binding falls back to
46
+ `DiscoveryService` + `deferLazy` for deliveries during bootstrap).
47
+
48
+ ## 1.13.0 (2026-07-04)
4
49
 
5
50
  Cold-start laziness (roadmap phase 3): modules can defer their entire init to
6
51
  first use, and the in-core subsystems an HTTP-only worker doesn't touch now
@@ -220,6 +220,16 @@ export class VelaApplication {
220
220
  this.entrypointRegistry = await EntrypointRegistry.build(discovery, this.instances, {
221
221
  deferLazy: true
222
222
  });
223
+ // Make the per-app registry injectable (global token): providers that
224
+ // dispatch entrypoints themselves (the queue module's in-process driver
225
+ // binding) resolve it instead of needing a back-reference to the app.
226
+ // Registered AFTER build so anything resolving it sees the final registry;
227
+ // pre-bootstrap resolution attempts fail the `has()` probe and defer.
228
+ this.container.register({
229
+ provide: EntrypointRegistry,
230
+ useValue: this.entrypointRegistry
231
+ });
232
+ this.container.markGlobalToken(EntrypointRegistry);
223
233
  }
224
234
  /**
225
235
  * Materialize every still-pending lazy module (async-safe): construct the
package/dist/index.d.ts CHANGED
@@ -46,8 +46,8 @@ export type { AdapterContext, RuntimeAdapter } from './factory/adapter';
46
46
  export type { VelaCreateOptions } from './factory';
47
47
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
48
48
  export type { Plugin } from './plugin/plugin';
49
- export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
50
- export type { PipelineRunOptions } from './pipeline/index';
49
+ export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
50
+ export type { PipelineRunOptions, ResolvedComponentMap } from './pipeline/index';
51
51
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, ReflectableDecorator, CreateDecoratorOptions, } from './pipeline/index';
52
52
  export type { Constructor, MiddlewareType, GuardType, PipeType, InterceptorType, FilterType, } from './registry/index';
53
53
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe, } from './pipeline/index';
package/dist/index.js CHANGED
@@ -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.14.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"