@vercube/queue 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025-present - Vercube
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/vercube/vercube/refs/heads/main/.github/assets/cover.png" width="100%" alt="Vercube - Unleash your server development." />
3
+ <br>
4
+ <br>
5
+
6
+ # @vercube/queue
7
+
8
+ ### Background jobs for Vercube apps
9
+
10
+ [![Ask DeepWiki](<https://img.shields.io/badge/ask-deepwiki-%20blue?style=for-the-badge&logo=bookstack&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)](https://deepwiki.com/vercube/vercube)
11
+ ![NPM Version](<https://img.shields.io/npm/v/%40vercube%2Fqueue?style=for-the-badge&logo=npm&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232e2e2e&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vercube%2Fqueue>)
12
+ ![GitHub License](<https://img.shields.io/github/license/vercube/vercube?style=for-the-badge&logo=gitbook&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)
13
+ ![Codecov](<https://img.shields.io/codecov/c/github/vercube/vercube?style=for-the-badge&logo=vitest&logoColor=rgba(255%2C%20255%2C%20255%2C%200.6)&labelColor=%23000&color=%232f2f2f>)
14
+
15
+ **One job model over BullMQ, RabbitMQ, Kafka or plain memory - publish with `add()`, consume with a decorator, and let the module handle retries, timeouts and validation.**
16
+
17
+ [Website](https://vercube.dev) • [Documentation](https://vercube.dev/docs/getting-started)
18
+
19
+ </div>
20
+
21
+ ## ✨ Features
22
+
23
+ - **One API, four transports** - BullMQ, RabbitMQ, Kafka and an in-memory strategy, mounted side by side
24
+ - **Decorator driven consumers** - `@Consumer()` on the class, `@Job()` on the method, `@AnyJob()` for everything else
25
+ - **Retries that work everywhere** - attempts, fixed or exponential backoff, and timeouts, even on transports without them
26
+ - **Payload validation** - any Standard Schema validates a job before the handler runs
27
+ - **Type-safe queues** - augment the registry and every `add()` is checked against it
28
+ - **Devtools ready** - queues, handlers and processed jobs show up in `@vercube/devtools`
29
+
30
+ ## 📦 Installation
31
+
32
+ ```bash
33
+ pnpm add @vercube/queue
34
+ ```
35
+
36
+ Install the client of the transport you use, they are all optional:
37
+
38
+ ```bash
39
+ pnpm add bullmq # BullMQ, backed by Redis
40
+ pnpm add amqplib # RabbitMQ
41
+ pnpm add kafkajs # Kafka
42
+ ```
43
+
44
+ ## 📖 Usage
45
+
46
+ ```ts
47
+ @Consumer({ queue: 'emails', concurrency: 5 })
48
+ export class EmailConsumer {
49
+ @Job('welcome', { attempts: 3, backoff: { type: 'exponential', delay: 1000 } })
50
+ public async welcome(payload: { userId: string }): Promise<void> {
51
+ await this.mailer.send(payload.userId);
52
+ }
53
+ }
54
+ ```
55
+
56
+ Check out the full [documentation](https://vercube.dev/docs/modules/queue/overview)
57
+
58
+ ## 📜 License
59
+
60
+ [MIT](https://github.com/vercube/vercube/blob/main/LICENSE)
@@ -0,0 +1,237 @@
1
+ //#region src/Errors/QueueError.ts
2
+ /**
3
+ * Error thrown by the queue module.
4
+ * Wraps transport errors with a stable shape so callers can tell what failed
5
+ * and whether the job is worth retrying.
6
+ */
7
+ var QueueError = class QueueError extends Error {
8
+ /** The original error that caused this one. */
9
+ cause;
10
+ /** The queue operation that failed, for example `publish` or `consume`. */
11
+ operation;
12
+ /** Whether processing the job again could succeed. */
13
+ retryable;
14
+ /** Additional non-sensitive context about the failure. */
15
+ metadata;
16
+ /**
17
+ * @param message - Human readable description of the failure.
18
+ * @param operation - Queue operation that failed.
19
+ * @param cause - Underlying error, when there is one.
20
+ * @param metadata - Additional non-sensitive context.
21
+ * @param retryable - Whether processing the job again could succeed, defaults to true.
22
+ */
23
+ constructor(message, operation, cause, metadata, retryable = true) {
24
+ super(message);
25
+ this.name = "QueueError";
26
+ this.operation = operation;
27
+ this.cause = cause;
28
+ this.metadata = metadata;
29
+ this.retryable = retryable;
30
+ if (Error.captureStackTrace) Error.captureStackTrace(this, QueueError);
31
+ }
32
+ };
33
+ //#endregion
34
+ //#region src/Utils/Job.ts
35
+ /**
36
+ * Job name a handler registers under to receive every job of its queue that no
37
+ * other handler claims.
38
+ */
39
+ const WILDCARD_JOB = "*";
40
+ /** Header carrying the job name across transports that have no native notion of one. */
41
+ const JOB_HEADER = "x-job";
42
+ /** Header carrying the current attempt number. */
43
+ const ATTEMPT_HEADER = "x-attempt";
44
+ /** Header carrying the total number of attempts the publisher asked for. */
45
+ const ATTEMPTS_HEADER = "x-attempts";
46
+ /** Header carrying the partition or routing key a job was published with. */
47
+ const KEY_HEADER = "x-key";
48
+ /** Header carrying the priority a job was published with. */
49
+ const PRIORITY_HEADER = "x-priority";
50
+ /**
51
+ * Most attempts a job may ever take.
52
+ *
53
+ * On transports that do not retry natively the budget is read off the wire, so a
54
+ * producer could otherwise ask for an arbitrarily large one and turn a single
55
+ * poison message into an unbounded republish loop.
56
+ */
57
+ const MAX_ATTEMPTS = 50;
58
+ /**
59
+ * Longest a retry may be held back, one day.
60
+ *
61
+ * An exponential backoff over a large attempt count overflows to `Infinity`,
62
+ * which `setTimeout` clamps to one millisecond: the backoff meant to slow
63
+ * retries down would make them as fast as the runtime allows.
64
+ */
65
+ const MAX_BACKOFF_MS = 864e5;
66
+ /**
67
+ * Reads a positive integer from a raw header value.
68
+ *
69
+ * @param raw - Header value as received from the transport, in any shape.
70
+ * @param fallback - Value returned when the header is absent or unusable.
71
+ * @param max - Largest value accepted, so a value off the wire cannot be unbounded.
72
+ * @returns The parsed integer, or the fallback.
73
+ */
74
+ function readNumericHeader(raw, fallback, max = Number.MAX_SAFE_INTEGER) {
75
+ if (raw === null || raw === void 0) return fallback;
76
+ const value = Number(typeof raw === "object" ? String(raw) : raw);
77
+ return Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), max) : fallback;
78
+ }
79
+ /**
80
+ * Normalizes transport headers into plain strings, so handlers never have to deal
81
+ * with buffers or numbers coming from the wire.
82
+ *
83
+ * @param headers - Raw headers as received from the transport.
84
+ * @returns Headers with string values only.
85
+ */
86
+ function normalizeHeaders(headers) {
87
+ if (!headers) return {};
88
+ const normalized = {};
89
+ for (const [key, value] of Object.entries(headers)) if (value !== null && value !== void 0) normalized[key] = String(value);
90
+ return normalized;
91
+ }
92
+ /**
93
+ * Computes how long to wait before the next attempt of a job.
94
+ *
95
+ * @param backoff - Backoff policy, a number being a fixed delay in milliseconds.
96
+ * @param attempt - Attempt that just failed, starting at 1.
97
+ * @returns Delay in milliseconds, zero when no backoff is configured and never above {@link MAX_BACKOFF_MS}.
98
+ */
99
+ function resolveBackoff(backoff, attempt) {
100
+ if (!backoff) return 0;
101
+ if (typeof backoff === "number") return Math.min(Math.max(0, backoff), MAX_BACKOFF_MS);
102
+ const delay = Math.max(0, backoff.delay);
103
+ const resolved = backoff.type === "exponential" ? delay * 2 ** Math.max(0, attempt - 1) : delay;
104
+ return Math.min(resolved, MAX_BACKOFF_MS);
105
+ }
106
+ /**
107
+ * Serializes a payload for transports that only carry bytes.
108
+ *
109
+ * @param payload - Payload to serialize.
110
+ * @returns The payload as a UTF-8 JSON buffer.
111
+ * @throws {QueueError} When the payload cannot be serialized.
112
+ */
113
+ function encodePayload(payload) {
114
+ try {
115
+ return Buffer.from(JSON.stringify(payload ?? null), "utf8");
116
+ } catch (error) {
117
+ throw new QueueError("Job payload is not serializable", "encode", error, void 0, false);
118
+ }
119
+ }
120
+ /**
121
+ * Deserializes a payload received from a transport that only carries bytes.
122
+ * Content that is not JSON is returned as text, so foreign producers do not
123
+ * break the consumer.
124
+ *
125
+ * @param content - Raw bytes received from the transport.
126
+ * @returns The parsed payload, or the raw text when it is not JSON.
127
+ */
128
+ function decodePayload(content) {
129
+ if (content === null || content === void 0) return null;
130
+ const text = typeof content === "string" ? content : Buffer.from(content).toString("utf8");
131
+ if (text.length === 0) return null;
132
+ try {
133
+ return JSON.parse(text);
134
+ } catch {
135
+ return text;
136
+ }
137
+ }
138
+ /**
139
+ * Generates an id for transports that do not assign one themselves.
140
+ *
141
+ * @returns A unique job id.
142
+ */
143
+ function generateJobId() {
144
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
145
+ }
146
+ /**
147
+ * Waits for the given number of milliseconds.
148
+ *
149
+ * @param ms - Milliseconds to wait, values below one resolve immediately.
150
+ * @returns Resolves once the delay elapsed.
151
+ */
152
+ function delay(ms) {
153
+ if (ms <= 0) return Promise.resolve();
154
+ return new Promise((resolve) => {
155
+ setTimeout(resolve, ms).unref?.();
156
+ });
157
+ }
158
+ /**
159
+ * Drops the keys whose value is undefined.
160
+ *
161
+ * Options objects are built by spreading whatever the caller set, which leaves
162
+ * own properties holding `undefined` behind. A library that merges options with
163
+ * `Object.assign` cannot tell those apart from a deliberate value, so they have
164
+ * to go before the object is handed over.
165
+ *
166
+ * @param source - Object to clean up.
167
+ * @returns A copy without the undefined entries.
168
+ */
169
+ function prune(source) {
170
+ const pruned = {};
171
+ for (const [key, value] of Object.entries(source)) if (value !== void 0) pruned[key] = value;
172
+ return pruned;
173
+ }
174
+ //#endregion
175
+ //#region src/Services/QueueStrategy.ts
176
+ /**
177
+ * Base class every queue transport implements.
178
+ *
179
+ * A strategy owns the connection to a broker and translates between the broker's
180
+ * own vocabulary and the module's job model. It stays deliberately thin: routing
181
+ * jobs to handlers, retries, timeouts and metrics all live in the
182
+ * {@link QueueManager}, so every transport behaves the same way.
183
+ *
184
+ * @typeParam InitOptions - Options the strategy needs to connect. Use `undefined`
185
+ * for strategies that need none.
186
+ *
187
+ * @example
188
+ * ```ts
189
+ * export class LogStrategy extends QueueStrategy {
190
+ * public readonly transport = 'log';
191
+ *
192
+ * public initialize(): void {}
193
+ *
194
+ * public async publish(request: QueueTypes.PublishRequest): Promise<QueueTypes.JobRef> {
195
+ * console.log(request.queue, request.job, request.payload);
196
+ * return { id: '1', queue: request.queue, job: request.job, strategy: this.transport };
197
+ * }
198
+ *
199
+ * public async consume(): Promise<QueueTypes.ConsumerHandle> {
200
+ * throw new Error('This strategy only publishes');
201
+ * }
202
+ *
203
+ * public async close(): Promise<void> {}
204
+ * }
205
+ * ```
206
+ */
207
+ var QueueStrategy = class {
208
+ /**
209
+ * What the transport can do on its own. Anything reported as unsupported is
210
+ * either emulated by the manager or ignored.
211
+ */
212
+ get capabilities() {
213
+ return {
214
+ retries: false,
215
+ delay: false,
216
+ priority: false,
217
+ progress: false,
218
+ stats: false,
219
+ peek: false
220
+ };
221
+ }
222
+ /**
223
+ * Publishes many jobs of the same kind. The default implementation publishes
224
+ * them one by one, transports with a batch API should override it.
225
+ *
226
+ * @param requests - Jobs to publish, all targeting the same queue.
227
+ * @returns References to the published jobs, in the same order.
228
+ * @throws {QueueError} When the jobs cannot be published.
229
+ */
230
+ async publishMany(requests) {
231
+ const refs = [];
232
+ for (const request of requests) refs.push(await this.publish(request));
233
+ return refs;
234
+ }
235
+ };
236
+ //#endregion
237
+ export { resolveBackoff as _, KEY_HEADER as a, PRIORITY_HEADER as c, delay as d, encodePayload as f, readNumericHeader as g, prune as h, JOB_HEADER as i, WILDCARD_JOB as l, normalizeHeaders as m, ATTEMPTS_HEADER as n, MAX_ATTEMPTS as o, generateJobId as p, ATTEMPT_HEADER as r, MAX_BACKOFF_MS as s, QueueStrategy as t, decodePayload as u, QueueError as v };