@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 +46 -1
- package/dist/application.js +10 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/scoped-components.d.ts +26 -0
- package/dist/pipeline/scoped-components.js +27 -0
- package/dist/queue/index.d.ts +10 -0
- package/dist/queue/index.js +12 -0
- package/dist/queue/inline.driver.d.ts +30 -0
- package/dist/queue/inline.driver.js +58 -0
- package/dist/queue/queue.binding.d.ts +23 -0
- package/dist/queue/queue.binding.js +53 -0
- package/dist/queue/queue.client.d.ts +20 -0
- package/dist/queue/queue.client.js +33 -0
- package/dist/queue/queue.decorators.d.ts +23 -0
- package/dist/queue/queue.decorators.js +46 -0
- package/dist/queue/queue.dispatch.d.ts +36 -0
- package/dist/queue/queue.dispatch.js +102 -0
- package/dist/queue/queue.module.d.ts +32 -0
- package/dist/queue/queue.module.js +80 -0
- package/dist/queue/queue.tokens.d.ts +21 -0
- package/dist/queue/queue.tokens.js +34 -0
- package/dist/queue/queue.types.d.ts +61 -0
- package/dist/queue/queue.types.js +4 -0
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
package/dist/application.js
CHANGED
|
@@ -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
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velajs/vela",
|
|
3
|
-
"version": "1.
|
|
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"
|