@znight/messaging 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +622 -2
- package/dist/async-CFh_bvex.mjs +216 -0
- package/dist/async-CFh_bvex.mjs.map +1 -0
- package/dist/codec-Dku-NXXU.mjs +83 -0
- package/dist/codec-Dku-NXXU.mjs.map +1 -0
- package/dist/compiler.d.mts +75 -0
- package/dist/compiler.mjs +1023 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/driver-ZxLqy_Ap.d.mts +216 -0
- package/dist/index.d.mts +459 -0
- package/dist/index.mjs +870 -0
- package/dist/index.mjs.map +1 -0
- package/dist/kafka.d.mts +51 -0
- package/dist/kafka.mjs +517 -0
- package/dist/kafka.mjs.map +1 -0
- package/dist/memory.d.mts +124 -0
- package/dist/memory.mjs +429 -0
- package/dist/memory.mjs.map +1 -0
- package/dist/rabbitmq.d.mts +65 -0
- package/dist/rabbitmq.mjs +686 -0
- package/dist/rabbitmq.mjs.map +1 -0
- package/dist/shared-BJBpLS8V.mjs +52 -0
- package/dist/shared-BJBpLS8V.mjs.map +1 -0
- package/dist/sqs.d.mts +38 -0
- package/dist/sqs.mjs +421 -0
- package/dist/sqs.mjs.map +1 -0
- package/package.json +123 -4
- package/src/broker.ts +156 -0
- package/src/codec.ts +98 -0
- package/src/compiler/analyze.ts +800 -0
- package/src/compiler/constants.ts +73 -0
- package/src/compiler/data.ts +75 -0
- package/src/compiler/generate.ts +131 -0
- package/src/compiler/index.ts +52 -0
- package/src/compiler/patterns.ts +54 -0
- package/src/compiler/retry.ts +136 -0
- package/src/compiler/validate.ts +57 -0
- package/src/compiler/values.ts +76 -0
- package/src/consumers.ts +260 -0
- package/src/decorators.ts +131 -0
- package/src/driver.ts +142 -0
- package/src/drivers/kafka.ts +765 -0
- package/src/drivers/memory.ts +609 -0
- package/src/drivers/rabbitmq.ts +944 -0
- package/src/drivers/shared.ts +81 -0
- package/src/drivers/sqs.ts +583 -0
- package/src/errors.ts +95 -0
- package/src/headers.ts +70 -0
- package/src/hooks.ts +2 -0
- package/src/index.ts +89 -0
- package/src/internal/async.ts +115 -0
- package/src/message.ts +96 -0
- package/src/metadata.ts +65 -0
- package/src/plugin.ts +236 -0
- package/src/retry.ts +129 -0
- package/src/runtime.ts +424 -0
- package/src/standard-schema.ts +68 -0
- package/src/tokens.ts +14 -0
- package/src/validation.ts +61 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import { _ as consumerError, a as sleep, c as attemptFromHeaders, d as AttemptsExhaustedError, f as MessageDecodeError, g as brokerUnavailableError, h as NonRetryableError, i as isPositiveInteger, l as headerRecord, m as NoHandlerError, n as Semaphore, p as MessageValidationError, r as errorMessage, s as MessagingHeaders, u as messageHeader, v as isNonRetryable } from "./async-CFh_bvex.mjs";
|
|
2
|
+
import { a as bodyToText, i as bodyToBytes, n as JSON_CONTENT_TYPE, o as jsonCodec, r as TEXT_CONTENT_TYPE, s as mediaType, t as BINARY_CONTENT_TYPE } from "./codec-Dku-NXXU.mjs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { displayName, resolveOptions } from "@znight/core";
|
|
5
|
+
// src/broker.ts
|
|
6
|
+
/**
|
|
7
|
+
* Publishes messages. Inject it in any provider (it is a singleton external provided by
|
|
8
|
+
* `MessagingPlugin`):
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* @Injectable()
|
|
12
|
+
* export class OrderService {
|
|
13
|
+
* constructor(private readonly broker: MessageBroker) {}
|
|
14
|
+
*
|
|
15
|
+
* async place(order: Order) {
|
|
16
|
+
* await this.broker.publish('orders', order, { type: 'order.placed', key: order.customerId })
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `publish()` resolves once the broker confirmed the message (publisher confirms, SQS response,
|
|
22
|
+
* Kafka acknowledgement). It is usable from `onBootstrap` of the messaging plugin until the
|
|
23
|
+
* application is disposed (including providers' `onStart` / `onStop`).
|
|
24
|
+
*/
|
|
25
|
+
var MessageBroker = class {};
|
|
26
|
+
const HEADER_NAME = /^[\x21-\x7e]+$/;
|
|
27
|
+
/** The {@link MessageBroker} registered by `MessagingPlugin` (internal). */
|
|
28
|
+
var DriverMessageBroker = class extends MessageBroker {
|
|
29
|
+
#driver;
|
|
30
|
+
#codec;
|
|
31
|
+
#hooks;
|
|
32
|
+
#closed = false;
|
|
33
|
+
/** Connects the broker to the driver (plugin `onBootstrap`). */
|
|
34
|
+
bind(driver, codec, hooks) {
|
|
35
|
+
this.#driver = driver;
|
|
36
|
+
this.#codec = codec;
|
|
37
|
+
this.#hooks = hooks;
|
|
38
|
+
this.#closed = false;
|
|
39
|
+
}
|
|
40
|
+
/** Rejects further publications (plugin `onDispose`). */
|
|
41
|
+
unbind() {
|
|
42
|
+
this.#closed = true;
|
|
43
|
+
}
|
|
44
|
+
async publish(queue, payload, options = {}) {
|
|
45
|
+
const driver = this.#driver;
|
|
46
|
+
const codec = this.#codec;
|
|
47
|
+
if (driver === void 0 || codec === void 0) throw brokerUnavailableError(`MessageBroker.publish('${queue}'): the messaging plugin is not bootstrapped yet (publish from onStart or later)`);
|
|
48
|
+
if (this.#closed) throw brokerUnavailableError(`MessageBroker.publish('${queue}'): the messaging plugin has been disposed`);
|
|
49
|
+
const message = this.#prepare(queue, payload, options, codec, driver);
|
|
50
|
+
const hooks = this.#hooks;
|
|
51
|
+
const wrapped = hooks?.hasWrappers("messaging:publish") ?? false;
|
|
52
|
+
const observed = hooks?.hasListeners("messaging:publish") ?? false;
|
|
53
|
+
if (hooks === void 0 || !wrapped && !observed) {
|
|
54
|
+
await driver.publish(queue, message.outbound());
|
|
55
|
+
return { id: message.id };
|
|
56
|
+
}
|
|
57
|
+
const event = {
|
|
58
|
+
queue,
|
|
59
|
+
type: options.type,
|
|
60
|
+
messageId: message.id,
|
|
61
|
+
headers: message.headers,
|
|
62
|
+
startTime: performance.now()
|
|
63
|
+
};
|
|
64
|
+
const send = async () => {
|
|
65
|
+
try {
|
|
66
|
+
await driver.publish(queue, message.outbound());
|
|
67
|
+
} catch (error) {
|
|
68
|
+
event.error = error;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
if (wrapped) await hooks.run("messaging:publish", event, send);
|
|
74
|
+
else await send();
|
|
75
|
+
} finally {
|
|
76
|
+
if (observed) hooks.emit("messaging:publish", event);
|
|
77
|
+
}
|
|
78
|
+
return { id: message.id };
|
|
79
|
+
}
|
|
80
|
+
#prepare(queue, payload, options, codec, driver) {
|
|
81
|
+
if (typeof queue !== "string" || queue === "") throw new TypeError(`[znight] MessageBroker.publish(): the queue must be a non-empty string, got ${String(queue)}`);
|
|
82
|
+
const delayMs = options.delay ?? 0;
|
|
83
|
+
if (typeof delayMs !== "number" || !Number.isFinite(delayMs) || delayMs < 0) throw new TypeError(`[znight] MessageBroker.publish('${queue}'): "delay" must be a number of milliseconds >= 0`);
|
|
84
|
+
if (delayMs > driver.capabilities.maxPublishDelayMs) throw new RangeError(driver.capabilities.maxPublishDelayMs === 0 ? `[znight] MessageBroker.publish('${queue}'): the ${driver.name} driver does not support delayed publishing` : `[znight] MessageBroker.publish('${queue}'): the ${driver.name} driver supports delays up to ${driver.capabilities.maxPublishDelayMs} ms (got ${delayMs})`);
|
|
85
|
+
const headers = headerRecord();
|
|
86
|
+
if (options.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
|
|
87
|
+
if (!HEADER_NAME.test(name)) throw new TypeError(`[znight] MessageBroker.publish('${queue}'): invalid header name "${name}"`);
|
|
88
|
+
if (typeof value !== "string") throw new TypeError(`[znight] MessageBroker.publish('${queue}'): header "${name}" must be a string`);
|
|
89
|
+
headers[name] = value;
|
|
90
|
+
}
|
|
91
|
+
const id = options.id ?? randomUUID();
|
|
92
|
+
const encoded = codec.encode(payload, options.contentType);
|
|
93
|
+
const timestamp = Date.now();
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
headers,
|
|
97
|
+
outbound: () => ({
|
|
98
|
+
id,
|
|
99
|
+
type: options.type,
|
|
100
|
+
key: options.key,
|
|
101
|
+
headers: { ...headers },
|
|
102
|
+
body: encoded.body,
|
|
103
|
+
contentType: encoded.contentType,
|
|
104
|
+
timestamp,
|
|
105
|
+
delayMs
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// src/decorators.ts
|
|
111
|
+
function marker() {}
|
|
112
|
+
const classMarker = marker;
|
|
113
|
+
const methodMarker = marker;
|
|
114
|
+
const parameterMarker = marker;
|
|
115
|
+
const errorHandlerMarker = marker;
|
|
116
|
+
function Consumer(_queueOrOptions) {
|
|
117
|
+
return classMarker;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Declares a message handler. With `type` (static), it handles the messages of that type (exact
|
|
121
|
+
* match, or a pattern where `*` matches any sequence: `'order.*'`); without type it is the consumer's
|
|
122
|
+
* default handler (messages without type, or whose type no other handler matches). A message no
|
|
123
|
+
* handler matches is dead-lettered (reason `no-handler`).
|
|
124
|
+
*/
|
|
125
|
+
function OnMessage(_type) {
|
|
126
|
+
return methodMarker;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Retry policy of a handler (static options). `attempts` counts the first delivery:
|
|
130
|
+
* `{ attempts: 3 }` means one delivery plus two retries. Without `@Retry`, the plugin's
|
|
131
|
+
* `defaults.retry` applies (3 attempts, exponential from 1 s).
|
|
132
|
+
*/
|
|
133
|
+
function Retry(_options) {
|
|
134
|
+
return methodMarker;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Declares the consumer's error handler, called once a message will not be retried anymore (last
|
|
138
|
+
* attempt failed, `NonRetryableError`, invalid payload, no matching handler). Returning `'ack'`
|
|
139
|
+
* acknowledges the message; returning anything else (or throwing) dead-letters it.
|
|
140
|
+
*
|
|
141
|
+
* ```ts
|
|
142
|
+
* @OnError()
|
|
143
|
+
* onError(error: unknown, message: InboundMessage) {
|
|
144
|
+
* if (message.type?.startsWith('legacy.')) return 'ack'
|
|
145
|
+
* }
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
function OnError() {
|
|
149
|
+
return errorHandlerMarker;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Injects the message payload. With a Standard Schema (Zod, Valibot...), the payload is validated and
|
|
153
|
+
* the parameter receives the schema output; an invalid payload is dead-lettered without retry
|
|
154
|
+
* (reason `validation`). The schema is a _value_ argument (inline, or an exported binding).
|
|
155
|
+
*/
|
|
156
|
+
function Message(_schema) {
|
|
157
|
+
return parameterMarker;
|
|
158
|
+
}
|
|
159
|
+
/** Injects all the message headers, or the value of one header (`string | undefined`). `name` is static. */
|
|
160
|
+
function Headers(_name) {
|
|
161
|
+
return parameterMarker;
|
|
162
|
+
}
|
|
163
|
+
/** Injects the {@link InboundMessage} (id, type, attempt, headers, raw message, abort signal...). */
|
|
164
|
+
function Ctx() {
|
|
165
|
+
return parameterMarker;
|
|
166
|
+
}
|
|
167
|
+
// src/retry.ts
|
|
168
|
+
/** Default base delay of a retry policy, in milliseconds. */
|
|
169
|
+
const DEFAULT_RETRY_DELAY_MS = 1e3;
|
|
170
|
+
/** Default backoff strategy. */
|
|
171
|
+
const DEFAULT_BACKOFF = "exponential";
|
|
172
|
+
/** Policy applied to handlers without `@Retry` when the plugin sets no `defaults.retry`. */
|
|
173
|
+
const DEFAULT_RETRY_POLICY = Object.freeze({
|
|
174
|
+
attempts: 3,
|
|
175
|
+
backoff: "exponential",
|
|
176
|
+
delay: DEFAULT_RETRY_DELAY_MS,
|
|
177
|
+
maxDelay: 3e4
|
|
178
|
+
});
|
|
179
|
+
/** The policy of `retry: false` / `{ attempts: 1 }`. */
|
|
180
|
+
const NO_RETRY_POLICY = Object.freeze({
|
|
181
|
+
attempts: 1,
|
|
182
|
+
backoff: "fixed",
|
|
183
|
+
delay: 0
|
|
184
|
+
});
|
|
185
|
+
const MAX_ATTEMPTS = 1e4;
|
|
186
|
+
/**
|
|
187
|
+
* Validates retry options and fills defaults.
|
|
188
|
+
*
|
|
189
|
+
* @throws ZnightError ZNIGHT011 for invalid values (`where` names the handler in the message).
|
|
190
|
+
*/
|
|
191
|
+
function normalizeRetry(options, where = "@Retry()") {
|
|
192
|
+
if (options === null || typeof options !== "object") throw consumerError(`${where}: retry options must be an object, got ${String(options)}`);
|
|
193
|
+
const { attempts, backoff = DEFAULT_BACKOFF, delay = DEFAULT_RETRY_DELAY_MS, maxDelay } = options;
|
|
194
|
+
if (!Number.isInteger(attempts) || attempts < 1 || attempts > MAX_ATTEMPTS) throw consumerError(`${where}: "attempts" must be an integer between 1 and ${MAX_ATTEMPTS}, got ${String(attempts)}`, ["attempts counts the first delivery: { attempts: 3 } means 1 delivery + 2 retries"]);
|
|
195
|
+
if (backoff !== "fixed" && backoff !== "exponential") throw consumerError(`${where}: "backoff" must be 'fixed' or 'exponential', got ${String(backoff)}`);
|
|
196
|
+
if (!isDuration(delay)) throw consumerError(`${where}: "delay" must be a finite number of milliseconds >= 0, got ${String(delay)}`);
|
|
197
|
+
if (maxDelay !== void 0 && (!isDuration(maxDelay) || maxDelay < delay)) throw consumerError(`${where}: "maxDelay" must be a finite number of milliseconds >= delay (${delay}), got ${String(maxDelay)}`);
|
|
198
|
+
return maxDelay === void 0 ? {
|
|
199
|
+
attempts,
|
|
200
|
+
backoff,
|
|
201
|
+
delay
|
|
202
|
+
} : {
|
|
203
|
+
attempts,
|
|
204
|
+
backoff,
|
|
205
|
+
delay,
|
|
206
|
+
maxDelay
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function isDuration(value) {
|
|
210
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Delay before the next attempt once `attempt` (1-based) failed: `delay` for `fixed`,
|
|
214
|
+
* `delay * 2^(attempt - 1)` capped by `maxDelay` for `exponential`.
|
|
215
|
+
*/
|
|
216
|
+
function computeRetryDelay(policy, attempt) {
|
|
217
|
+
if (policy.backoff === "fixed") return policy.delay;
|
|
218
|
+
const exponent = Math.max(0, attempt - 1);
|
|
219
|
+
const raw = policy.delay * 2 ** exponent;
|
|
220
|
+
return Math.min(raw, policy.maxDelay ?? Number.MAX_SAFE_INTEGER);
|
|
221
|
+
}
|
|
222
|
+
/** Every delay the policy may request (one per retry), in attempt order. */
|
|
223
|
+
function retryDelays(policy) {
|
|
224
|
+
const delays = [];
|
|
225
|
+
for (let attempt = 1; attempt < policy.attempts; attempt++) delays.push(computeRetryDelay(policy, attempt));
|
|
226
|
+
return delays;
|
|
227
|
+
}
|
|
228
|
+
/** Whether a delay is waited in-process (`true`) or delegated to the broker (`false`). */
|
|
229
|
+
function isInProcessDelay(delayMs, capabilities) {
|
|
230
|
+
return delayMs <= capabilities.maxInProcessDelayMs;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Checks that the driver can honour every delay of `policy`.
|
|
234
|
+
*
|
|
235
|
+
* @throws ZnightError ZNIGHT011 naming `where` and the offending delay.
|
|
236
|
+
*/
|
|
237
|
+
function assertRetrySupported(policy, capabilities, driver, where) {
|
|
238
|
+
for (const delay of new Set(retryDelays(policy))) {
|
|
239
|
+
if (isInProcessDelay(delay, capabilities)) continue;
|
|
240
|
+
if (capabilities.nativeDelay && delay <= capabilities.maxNativeDelayMs) continue;
|
|
241
|
+
const limits = capabilities.nativeDelay ? `in-process delays up to ${capabilities.maxInProcessDelayMs} ms and native delays up to ${capabilities.maxNativeDelayMs} ms` : `in-process delays up to ${capabilities.maxInProcessDelayMs} ms and no native delay`;
|
|
242
|
+
throw consumerError(`${where}: the ${driver} driver cannot wait ${delay} ms between attempts (it supports ${limits})`, ["Lower \"delay\"/\"maxDelay\" of the retry policy", "Or raise the driver budget (e.g. maxInProcessDelayMs) if holding messages that long is acceptable"]);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// src/consumers.ts
|
|
246
|
+
/** Normalizes `defaults.retry` of the plugin. */
|
|
247
|
+
function defaultRetryPolicy(retry) {
|
|
248
|
+
if (retry === void 0) return DEFAULT_RETRY_POLICY;
|
|
249
|
+
if (retry === false) return NO_RETRY_POLICY;
|
|
250
|
+
return normalizeRetry(retry, "MessagingPlugin defaults.retry");
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Validates `metadata.extensions.messaging` against the container and the driver capabilities.
|
|
254
|
+
*
|
|
255
|
+
* @throws ZnightError ZNIGHT011 describing the first invalid consumer.
|
|
256
|
+
*/
|
|
257
|
+
function prepareConsumers(metadata, container, driver, defaults) {
|
|
258
|
+
if (metadata === void 0) return [];
|
|
259
|
+
if (metadata === null || typeof metadata !== "object" || !Array.isArray(metadata.consumers)) throw consumerError("Invalid messaging metadata: \"consumers\" must be an array", ["Regenerate the metadata with the @znight/messaging compiler extension"]);
|
|
260
|
+
const byQueue = /* @__PURE__ */ new Map();
|
|
261
|
+
const prepared = [];
|
|
262
|
+
for (const definition of metadata.consumers) {
|
|
263
|
+
const consumer = prepareConsumer(definition, container, driver, defaults);
|
|
264
|
+
const previous = byQueue.get(consumer.queue);
|
|
265
|
+
if (previous !== void 0) throw consumerError(`${previous} and ${consumer.name} both consume "${consumer.queue}"`, ["A queue is consumed by a single @Consumer class: merge the handlers, or route by type with @OnMessage(type)"]);
|
|
266
|
+
byQueue.set(consumer.queue, consumer.name);
|
|
267
|
+
prepared.push(consumer);
|
|
268
|
+
}
|
|
269
|
+
return prepared;
|
|
270
|
+
}
|
|
271
|
+
function prepareConsumer(definition, container, driver, defaults) {
|
|
272
|
+
if (definition === null || typeof definition !== "object" || typeof definition.token !== "string" || definition.token === "") throw consumerError("Invalid consumer definition: missing provider token");
|
|
273
|
+
const token = definition.token;
|
|
274
|
+
const name = displayName(token);
|
|
275
|
+
if (typeof definition.queue !== "string" || definition.queue === "") throw consumerError(`@Consumer ${name}: the queue must be a non-empty string`);
|
|
276
|
+
if (!container.has(token)) throw consumerError(`@Consumer ${name}: no provider is registered for ${token}`, ["Regenerate the metadata: the consumer class must be a compiled provider"]);
|
|
277
|
+
const { concurrency: rawConcurrency, deadLetter, ...driverOptions } = definition.options ?? {};
|
|
278
|
+
const concurrency = rawConcurrency ?? defaults.concurrency;
|
|
279
|
+
if (!isPositiveInteger(concurrency)) throw consumerError(`@Consumer ${name}: "concurrency" must be a positive integer, got ${String(concurrency)}`);
|
|
280
|
+
if (deadLetter !== void 0 && deadLetter !== false && (typeof deadLetter !== "string" || deadLetter === "")) throw consumerError(`@Consumer ${name}: "deadLetter" must be a queue name or false, got ${String(deadLetter)}`);
|
|
281
|
+
if (deadLetter === definition.queue) throw consumerError(`@Consumer ${name}: the dead-letter queue cannot be the consumed queue "${definition.queue}"`);
|
|
282
|
+
const handlers = definition.handlers;
|
|
283
|
+
if (!Array.isArray(handlers) || handlers.length === 0) throw consumerError(`@Consumer ${name} has no @OnMessage handler`, ["Decorate at least one method with @OnMessage()"]);
|
|
284
|
+
const exact = /* @__PURE__ */ new Map();
|
|
285
|
+
const patterns = [];
|
|
286
|
+
let fallback;
|
|
287
|
+
let maxAttempts = 1;
|
|
288
|
+
const nativeDelays = /* @__PURE__ */ new Set();
|
|
289
|
+
for (const handlerDefinition of handlers) {
|
|
290
|
+
const handler = prepareHandler(handlerDefinition, name, driver, defaults.retry);
|
|
291
|
+
maxAttempts = Math.max(maxAttempts, handler.policy.attempts);
|
|
292
|
+
for (const delay of retryDelays(handler.policy)) if (!isInProcessDelay(delay, driver.capabilities)) nativeDelays.add(delay);
|
|
293
|
+
const type = handlerDefinition.type;
|
|
294
|
+
if (type === void 0) {
|
|
295
|
+
if (fallback !== void 0) throw consumerError(`@Consumer ${name}: ${fallback.method}() and ${handler.method}() are both default @OnMessage() handlers`, ["Give one of them a message type: @OnMessage(\"order.created\")"]);
|
|
296
|
+
fallback = handler;
|
|
297
|
+
} else if (type.includes("*")) {
|
|
298
|
+
if (patterns.some((entry) => entry.handler.definition.type === type)) throw duplicateType(name, type);
|
|
299
|
+
patterns.push({
|
|
300
|
+
regex: patternToRegExp(type),
|
|
301
|
+
handler
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
if (exact.has(type)) throw duplicateType(name, type);
|
|
305
|
+
exact.set(type, handler);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const errorHandler = definition.errorHandler;
|
|
309
|
+
if (errorHandler !== void 0) {
|
|
310
|
+
if (typeof errorHandler !== "string" || errorHandler === "") throw consumerError(`@Consumer ${name}: invalid @OnError method name ${String(errorHandler)}`);
|
|
311
|
+
const providers = container.metadata.providers;
|
|
312
|
+
const prototype = (Object.hasOwn(providers, token) ? providers[token] : void 0)?.class?.prototype;
|
|
313
|
+
if (prototype !== void 0 && typeof prototype[errorHandler] !== "function") throw consumerError(`@Consumer ${name}: the @OnError method ${errorHandler}() does not exist`);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
definition,
|
|
317
|
+
token,
|
|
318
|
+
name,
|
|
319
|
+
queue: definition.queue,
|
|
320
|
+
concurrency,
|
|
321
|
+
deadLetterQueue: resolveDeadLetter(definition.queue, deadLetter, defaults.deadLetter, driver),
|
|
322
|
+
errorHandler,
|
|
323
|
+
maxAttempts,
|
|
324
|
+
retryDelays: [...nativeDelays].sort((a, b) => a - b),
|
|
325
|
+
driverOptions,
|
|
326
|
+
select(type) {
|
|
327
|
+
if (type === void 0) return fallback;
|
|
328
|
+
const handler = exact.get(type);
|
|
329
|
+
if (handler !== void 0) return handler;
|
|
330
|
+
for (const entry of patterns) if (entry.regex.test(type)) return entry.handler;
|
|
331
|
+
return fallback;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function prepareHandler(definition, consumer, driver, defaultPolicy) {
|
|
336
|
+
if (definition === null || typeof definition !== "object" || typeof definition.method !== "string" || definition.method === "") throw consumerError(`@Consumer ${consumer}: invalid @OnMessage handler definition`);
|
|
337
|
+
const where = `${consumer}.${definition.method}()`;
|
|
338
|
+
if (typeof definition.invoke !== "function") throw consumerError(`${where}: the handler has no invoker`, ["Regenerate the metadata"]);
|
|
339
|
+
if (definition.type !== void 0 && (typeof definition.type !== "string" || definition.type === "")) throw consumerError(`${where}: the @OnMessage type must be a non-empty string`);
|
|
340
|
+
const policy = definition.retry === void 0 ? defaultPolicy : normalizeRetry(definition.retry, `@Retry on ${where}`);
|
|
341
|
+
assertRetrySupported(policy, driver.capabilities, driver.name, definition.retry === void 0 ? `${where} (default retry policy)` : `@Retry on ${where}`);
|
|
342
|
+
return {
|
|
343
|
+
definition,
|
|
344
|
+
method: definition.method,
|
|
345
|
+
policy
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function duplicateType(consumer, type) {
|
|
349
|
+
return consumerError(`@Consumer ${consumer}: several @OnMessage handlers for type "${type}"`);
|
|
350
|
+
}
|
|
351
|
+
/** `order.*` → `/^order\..*$/`: `*` matches any sequence of characters. */
|
|
352
|
+
function patternToRegExp(pattern) {
|
|
353
|
+
const source = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
354
|
+
return new RegExp(`^${source}$`, "s");
|
|
355
|
+
}
|
|
356
|
+
function resolveDeadLetter(queue, option, defaults, driver) {
|
|
357
|
+
if (option === false) return void 0;
|
|
358
|
+
if (typeof option === "string") return option;
|
|
359
|
+
if (defaults === false) return void 0;
|
|
360
|
+
const name = defaults === void 0 ? driver.deadLetterName(queue) : defaults(queue);
|
|
361
|
+
if (typeof name !== "string" || name === "" || name === queue) throw consumerError(`Invalid dead-letter queue name for "${queue}": ${String(name)}`);
|
|
362
|
+
return name;
|
|
363
|
+
}
|
|
364
|
+
// src/tokens.ts
|
|
365
|
+
/** Tokens of the externals provided by `MessagingPlugin` (declared in the package manifest). */
|
|
366
|
+
const MessagingTokens = Object.freeze({
|
|
367
|
+
MessageBroker: "@znight/messaging#MessageBroker",
|
|
368
|
+
InboundMessage: "@znight/messaging#InboundMessage"
|
|
369
|
+
});
|
|
370
|
+
// src/runtime.ts
|
|
371
|
+
/** Runs the deliveries of one consumer. */
|
|
372
|
+
var ConsumerRuntime = class {
|
|
373
|
+
consumer;
|
|
374
|
+
#services;
|
|
375
|
+
#slots;
|
|
376
|
+
#abort = new AbortController();
|
|
377
|
+
#inflight = /* @__PURE__ */ new Set();
|
|
378
|
+
#subscription;
|
|
379
|
+
#state = "created";
|
|
380
|
+
constructor(consumer, services) {
|
|
381
|
+
this.consumer = consumer;
|
|
382
|
+
this.#services = services;
|
|
383
|
+
this.#slots = new Semaphore(consumer.concurrency);
|
|
384
|
+
}
|
|
385
|
+
get state() {
|
|
386
|
+
return this.#state;
|
|
387
|
+
}
|
|
388
|
+
/** Deliveries currently handled (including those waiting for a slot or an in-process retry delay). */
|
|
389
|
+
get inFlight() {
|
|
390
|
+
return this.#inflight.size;
|
|
391
|
+
}
|
|
392
|
+
/** Subscribes to the queue. */
|
|
393
|
+
async start() {
|
|
394
|
+
if (this.#state !== "created") throw new Error(`[znight] Consumer ${this.consumer.name} was already started`);
|
|
395
|
+
this.#state = "starting";
|
|
396
|
+
const { consumer } = this;
|
|
397
|
+
try {
|
|
398
|
+
this.#subscription = await this.#services.driver.subscribe(consumer.queue, (delivery) => this.handle(delivery), {
|
|
399
|
+
consumer: consumer.name,
|
|
400
|
+
concurrency: consumer.concurrency,
|
|
401
|
+
deadLetterQueue: consumer.deadLetterQueue,
|
|
402
|
+
maxAttempts: consumer.maxAttempts,
|
|
403
|
+
retryDelays: consumer.retryDelays,
|
|
404
|
+
options: consumer.driverOptions
|
|
405
|
+
});
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.#state = "stopped";
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
this.#state = "running";
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Graceful stop: stops receiving, aborts in-process retry delays (those messages are released to
|
|
414
|
+
* the broker), awaits the in-flight handlers, then closes the subscription. Never throws.
|
|
415
|
+
*/
|
|
416
|
+
async stop() {
|
|
417
|
+
if (this.#state === "stopping" || this.#state === "stopped") return;
|
|
418
|
+
this.#state = "stopping";
|
|
419
|
+
this.#abort.abort();
|
|
420
|
+
const subscription = this.#subscription;
|
|
421
|
+
if (subscription !== void 0) try {
|
|
422
|
+
await subscription.cancel();
|
|
423
|
+
} catch (error) {
|
|
424
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to cancel the subscription`, { err: error });
|
|
425
|
+
}
|
|
426
|
+
while (this.#inflight.size > 0) await Promise.allSettled([...this.#inflight]);
|
|
427
|
+
if (subscription !== void 0) try {
|
|
428
|
+
await subscription.close();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to close the subscription`, { err: error });
|
|
431
|
+
}
|
|
432
|
+
this.#state = "stopped";
|
|
433
|
+
}
|
|
434
|
+
/** Handles one delivery (the driver's `DeliveryHandler`). Never rejects. */
|
|
435
|
+
handle(delivery) {
|
|
436
|
+
const promise = this.#handle(delivery);
|
|
437
|
+
this.#inflight.add(promise);
|
|
438
|
+
const done = () => {
|
|
439
|
+
this.#inflight.delete(promise);
|
|
440
|
+
};
|
|
441
|
+
promise.then(done, done);
|
|
442
|
+
return promise;
|
|
443
|
+
}
|
|
444
|
+
async #handle(delivery) {
|
|
445
|
+
const signal = this.#abort.signal;
|
|
446
|
+
if (signal.aborted) return this.#release(delivery);
|
|
447
|
+
try {
|
|
448
|
+
await this.#slots.acquire(signal);
|
|
449
|
+
} catch {
|
|
450
|
+
return this.#release(delivery);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
await this.#process(delivery);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: unexpected delivery failure`, { err: error });
|
|
456
|
+
await this.#release(delivery);
|
|
457
|
+
} finally {
|
|
458
|
+
this.#slots.release();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async #process(delivery) {
|
|
462
|
+
const { container, hooks, codec, driver } = this.#services;
|
|
463
|
+
const { consumer } = this;
|
|
464
|
+
const handler = consumer.select(delivery.type);
|
|
465
|
+
let payload;
|
|
466
|
+
let decodeError;
|
|
467
|
+
try {
|
|
468
|
+
payload = codec.decode(delivery.body, delivery.contentType);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
decodeError = isNonRetryable(error) ? error : new MessageDecodeError(`Cannot decode the message body: ${errorMessage(error)}`, { cause: error });
|
|
471
|
+
}
|
|
472
|
+
const message = {
|
|
473
|
+
id: delivery.id,
|
|
474
|
+
queue: consumer.queue,
|
|
475
|
+
type: delivery.type,
|
|
476
|
+
payload,
|
|
477
|
+
headers: delivery.headers,
|
|
478
|
+
key: delivery.key,
|
|
479
|
+
attempt: delivery.attempt,
|
|
480
|
+
maxAttempts: handler?.policy.attempts ?? 1,
|
|
481
|
+
redelivered: delivery.redelivered,
|
|
482
|
+
timestamp: delivery.timestamp,
|
|
483
|
+
receivedAt: Date.now(),
|
|
484
|
+
contentType: delivery.contentType,
|
|
485
|
+
body: delivery.body,
|
|
486
|
+
driver: driver.name,
|
|
487
|
+
raw: delivery.raw,
|
|
488
|
+
signal: this.#abort.signal
|
|
489
|
+
};
|
|
490
|
+
const event = {
|
|
491
|
+
queue: consumer.queue,
|
|
492
|
+
type: delivery.type,
|
|
493
|
+
messageId: delivery.id,
|
|
494
|
+
attempt: delivery.attempt,
|
|
495
|
+
headers: delivery.headers,
|
|
496
|
+
consumer: consumer.name,
|
|
497
|
+
handler: handler?.method,
|
|
498
|
+
message,
|
|
499
|
+
startTime: performance.now()
|
|
500
|
+
};
|
|
501
|
+
const context = {
|
|
502
|
+
delivery,
|
|
503
|
+
message,
|
|
504
|
+
handler,
|
|
505
|
+
event,
|
|
506
|
+
decodeError,
|
|
507
|
+
executed: false,
|
|
508
|
+
followup: void 0
|
|
509
|
+
};
|
|
510
|
+
let failure = /* @__PURE__ */ new Error("A messaging:message hook wrapper did not call next()");
|
|
511
|
+
try {
|
|
512
|
+
await container.runInScope(() => hooks.run("messaging:message", event, () => this.#execute(context)), [[MessagingTokens.InboundMessage, message]]);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
failure = error;
|
|
515
|
+
if (context.executed) this.#services.logger.error(`Consumer ${consumer.name}: error after handling message ${delivery.id}`, {
|
|
516
|
+
err: error,
|
|
517
|
+
queue: consumer.queue
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (!context.executed) await this.#failure(context, failure, false);
|
|
521
|
+
if (hooks.hasListeners("messaging:message")) hooks.emit("messaging:message", event);
|
|
522
|
+
await this.#followup(context);
|
|
523
|
+
}
|
|
524
|
+
/** Runs inside the request scope and the hooks: handler, then settlement. Never throws. */
|
|
525
|
+
async #execute(context) {
|
|
526
|
+
if (context.executed) return;
|
|
527
|
+
context.executed = true;
|
|
528
|
+
const { handler, message } = context;
|
|
529
|
+
if (context.decodeError !== void 0) return this.#failure(context, context.decodeError, true);
|
|
530
|
+
if (handler === void 0) return this.#terminal(context, new NoHandlerError(this.consumer.name, message.type), "no-handler", true);
|
|
531
|
+
if (message.attempt > message.maxAttempts) return this.#terminal(context, new AttemptsExhaustedError(message.attempt, message.maxAttempts), "max-attempts", false);
|
|
532
|
+
try {
|
|
533
|
+
const instance = this.#services.container.get(this.consumer.token);
|
|
534
|
+
await handler.definition.invoke(instance, message);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
return this.#failure(context, error, true);
|
|
537
|
+
}
|
|
538
|
+
return this.#ack(context);
|
|
539
|
+
}
|
|
540
|
+
/** A failed attempt: retry while attempts remain, dead-letter otherwise. */
|
|
541
|
+
#failure(context, error, withErrorHandler) {
|
|
542
|
+
if (isNonRetryable(error)) return this.#terminal(context, error, reasonOf(error), withErrorHandler);
|
|
543
|
+
if (context.message.attempt < context.message.maxAttempts) return this.#retry(context, error);
|
|
544
|
+
return this.#terminal(context, error, "max-attempts", withErrorHandler);
|
|
545
|
+
}
|
|
546
|
+
async #retry(context, error) {
|
|
547
|
+
const { message, delivery, event } = context;
|
|
548
|
+
const policy = context.handler?.policy;
|
|
549
|
+
const delayMs = policy === void 0 ? 0 : computeRetryDelay(policy, message.attempt);
|
|
550
|
+
event.outcome = "retry";
|
|
551
|
+
event.error = error;
|
|
552
|
+
this.#services.logger.warn(`Consumer ${this.consumer.name}: attempt ${message.attempt}/${message.maxAttempts} of message ${message.id} failed, retrying in ${delayMs} ms`, {
|
|
553
|
+
queue: message.queue,
|
|
554
|
+
messageId: message.id,
|
|
555
|
+
type: message.type,
|
|
556
|
+
...this.#errorContext(error)
|
|
557
|
+
});
|
|
558
|
+
if (isInProcessDelay(delayMs, this.#services.driver.capabilities)) {
|
|
559
|
+
context.followup = {
|
|
560
|
+
kind: "retry",
|
|
561
|
+
delayMs,
|
|
562
|
+
error
|
|
563
|
+
};
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
await delivery.retry({
|
|
568
|
+
attempt: message.attempt + 1,
|
|
569
|
+
delayMs,
|
|
570
|
+
error
|
|
571
|
+
});
|
|
572
|
+
} catch (retryError) {
|
|
573
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to schedule the retry of message ${message.id}`, {
|
|
574
|
+
err: retryError,
|
|
575
|
+
queue: message.queue
|
|
576
|
+
});
|
|
577
|
+
context.followup = { kind: "requeue" };
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** No more attempts: `@OnError` may acknowledge, otherwise the message is dead-lettered. */
|
|
581
|
+
async #terminal(context, error, reason, withErrorHandler) {
|
|
582
|
+
context.event.error = error;
|
|
583
|
+
const method = this.consumer.errorHandler;
|
|
584
|
+
if (withErrorHandler && method !== void 0) try {
|
|
585
|
+
if (await this.#services.container.get(this.consumer.token)[method]?.(error, context.message) === "ack") return this.#ack(context);
|
|
586
|
+
} catch (handlerError) {
|
|
587
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: @OnError ${method}() failed`, {
|
|
588
|
+
err: handlerError,
|
|
589
|
+
queue: context.message.queue,
|
|
590
|
+
messageId: context.message.id
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return this.#deadLetter(context, error, reason);
|
|
594
|
+
}
|
|
595
|
+
async #deadLetter(context, error, reason) {
|
|
596
|
+
const { message, delivery, event } = context;
|
|
597
|
+
const queue = this.consumer.deadLetterQueue;
|
|
598
|
+
event.outcome = "dead-letter";
|
|
599
|
+
try {
|
|
600
|
+
await delivery.deadLetter({
|
|
601
|
+
queue,
|
|
602
|
+
reason,
|
|
603
|
+
error,
|
|
604
|
+
attempt: message.attempt
|
|
605
|
+
});
|
|
606
|
+
} catch (deadLetterError) {
|
|
607
|
+
event.outcome = "retry";
|
|
608
|
+
event.error = deadLetterError;
|
|
609
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to dead-letter message ${message.id}, it will be redelivered`, {
|
|
610
|
+
err: deadLetterError,
|
|
611
|
+
cause: error,
|
|
612
|
+
queue: message.queue,
|
|
613
|
+
deadLetterQueue: queue
|
|
614
|
+
});
|
|
615
|
+
context.followup = { kind: "requeue" };
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
this.#services.logger.error(queue === void 0 ? `Consumer ${this.consumer.name}: message ${message.id} rejected (${reason})` : `Consumer ${this.consumer.name}: message ${message.id} dead-lettered to ${queue} (${reason})`, {
|
|
619
|
+
queue: message.queue,
|
|
620
|
+
messageId: message.id,
|
|
621
|
+
type: message.type,
|
|
622
|
+
attempt: message.attempt,
|
|
623
|
+
reason,
|
|
624
|
+
...this.#errorContext(error)
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The error of a failed delivery in the retry / dead-letter log entries: the error itself (with its
|
|
629
|
+
* stack), or only its message when a `messaging:message` wrapper is installed. Such a wrapper (the
|
|
630
|
+
* ObservabilityPlugin delivery log) writes its own entry for the delivery with the error: the stack
|
|
631
|
+
* is logged once. Settlement failures always log their error.
|
|
632
|
+
*/
|
|
633
|
+
#errorContext(error) {
|
|
634
|
+
return this.#services.hooks.hasWrappers("messaging:message") ? { error: errorMessage(error) } : { err: error };
|
|
635
|
+
}
|
|
636
|
+
async #ack(context) {
|
|
637
|
+
context.event.outcome = "ack";
|
|
638
|
+
try {
|
|
639
|
+
await context.delivery.ack();
|
|
640
|
+
} catch (error) {
|
|
641
|
+
this.#services.logger.warn(`Consumer ${this.consumer.name}: failed to acknowledge message ${context.message.id}, the broker will redeliver it`, {
|
|
642
|
+
err: error,
|
|
643
|
+
queue: context.message.queue
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Work done after the scope closed: in-process retry delays and requeues. */
|
|
648
|
+
async #followup(context) {
|
|
649
|
+
const followup = context.followup;
|
|
650
|
+
if (followup === void 0) return;
|
|
651
|
+
const { delivery, message } = context;
|
|
652
|
+
if (followup.kind === "retry") {
|
|
653
|
+
try {
|
|
654
|
+
await sleep(followup.delayMs, this.#abort.signal);
|
|
655
|
+
} catch {
|
|
656
|
+
return this.#release(delivery);
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
await delivery.retry({
|
|
660
|
+
attempt: message.attempt + 1,
|
|
661
|
+
delayMs: 0,
|
|
662
|
+
error: followup.error
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
} catch (error) {
|
|
666
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to redeliver message ${message.id}`, {
|
|
667
|
+
err: error,
|
|
668
|
+
queue: message.queue
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await sleep(this.#services.requeueDelayMs, this.#abort.signal);
|
|
674
|
+
} catch {}
|
|
675
|
+
return this.#release(delivery);
|
|
676
|
+
}
|
|
677
|
+
async #release(delivery) {
|
|
678
|
+
try {
|
|
679
|
+
await delivery.release();
|
|
680
|
+
} catch (error) {
|
|
681
|
+
this.#services.logger.error(`Consumer ${this.consumer.name}: failed to release message ${delivery.id}, the broker will redeliver it`, {
|
|
682
|
+
err: error,
|
|
683
|
+
queue: this.consumer.queue
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
function reasonOf(error) {
|
|
689
|
+
if (error instanceof MessageValidationError) return "validation";
|
|
690
|
+
if (error instanceof MessageDecodeError) return "decode";
|
|
691
|
+
if (error instanceof NoHandlerError) return "no-handler";
|
|
692
|
+
if (error instanceof AttemptsExhaustedError) return "max-attempts";
|
|
693
|
+
return "non-retryable";
|
|
694
|
+
}
|
|
695
|
+
// src/plugin.ts
|
|
696
|
+
const DEFAULT_REQUEUE_DELAY_MS = 1e3;
|
|
697
|
+
/** Period of the keep-alive timer (it never does anything: it only keeps the event loop alive). */
|
|
698
|
+
const KEEP_ALIVE_INTERVAL_MS = 2 ** 30;
|
|
699
|
+
/**
|
|
700
|
+
* Message consumers and publishing.
|
|
701
|
+
*
|
|
702
|
+
* ```ts
|
|
703
|
+
* import { MessagingPlugin } from '@znight/messaging'
|
|
704
|
+
* import { rabbitmq } from '@znight/messaging/rabbitmq'
|
|
705
|
+
*
|
|
706
|
+
* const app = await Application.create({
|
|
707
|
+
* metadata: __ZNIGHT_METADATA__,
|
|
708
|
+
* plugins: [MessagingPlugin.configure({ driver: rabbitmq({ url: 'amqp://localhost' }) })],
|
|
709
|
+
* })
|
|
710
|
+
* ```
|
|
711
|
+
*
|
|
712
|
+
* Options may be computed from the container (evaluated in `onBootstrap`):
|
|
713
|
+
* `MessagingPlugin.configure(c => ({ driver: rabbitmq({ url: c.get(AmqpConfig).url }) }))`.
|
|
714
|
+
*/
|
|
715
|
+
const MessagingPlugin = Object.freeze({ configure(options) {
|
|
716
|
+
return createPlugin(options);
|
|
717
|
+
} });
|
|
718
|
+
function createPlugin(rawOptions) {
|
|
719
|
+
const broker = new DriverMessageBroker();
|
|
720
|
+
let driver;
|
|
721
|
+
let connected = false;
|
|
722
|
+
let consume = true;
|
|
723
|
+
let runtimes = [];
|
|
724
|
+
/**
|
|
725
|
+
* Keeps the process alive while consumers run: they are its ingress, and some drivers hold no
|
|
726
|
+
* handle of their own (the memory driver only uses microtasks and short timers), so a process
|
|
727
|
+
* without other handle would exit right after `app.start()`, without graceful shutdown.
|
|
728
|
+
*/
|
|
729
|
+
let keepAlive;
|
|
730
|
+
const stopKeepAlive = () => {
|
|
731
|
+
if (keepAlive !== void 0) clearInterval(keepAlive);
|
|
732
|
+
keepAlive = void 0;
|
|
733
|
+
};
|
|
734
|
+
return {
|
|
735
|
+
name: "messaging",
|
|
736
|
+
broker,
|
|
737
|
+
get driver() {
|
|
738
|
+
return driver;
|
|
739
|
+
},
|
|
740
|
+
consumers() {
|
|
741
|
+
return runtimes.map((runtime) => ({
|
|
742
|
+
consumer: runtime.consumer.name,
|
|
743
|
+
queue: runtime.consumer.queue,
|
|
744
|
+
state: runtime.state,
|
|
745
|
+
concurrency: runtime.consumer.concurrency,
|
|
746
|
+
inFlight: runtime.inFlight,
|
|
747
|
+
deadLetterQueue: runtime.consumer.deadLetterQueue
|
|
748
|
+
}));
|
|
749
|
+
},
|
|
750
|
+
register(ctx) {
|
|
751
|
+
ctx.container.registerExternal({
|
|
752
|
+
token: MessagingTokens.InboundMessage,
|
|
753
|
+
scope: "request",
|
|
754
|
+
providedBy: "MessagingPlugin"
|
|
755
|
+
});
|
|
756
|
+
ctx.container.registerValue(MessagingTokens.MessageBroker, broker);
|
|
757
|
+
},
|
|
758
|
+
async onBootstrap(ctx) {
|
|
759
|
+
const options = validateOptions(resolveOptions(rawOptions, ctx.container));
|
|
760
|
+
driver = options.driver;
|
|
761
|
+
consume = options.consume ?? true;
|
|
762
|
+
const defaults = options.defaults ?? {};
|
|
763
|
+
const consumerDefaults = {
|
|
764
|
+
retry: defaultRetryPolicy(defaults.retry),
|
|
765
|
+
concurrency: defaults.concurrency ?? 1,
|
|
766
|
+
deadLetter: defaults.deadLetter
|
|
767
|
+
};
|
|
768
|
+
const prepared = prepareConsumers(ctx.metadata.extensions?.messaging, ctx.container, driver, consumerDefaults);
|
|
769
|
+
const codec = options.codec ?? jsonCodec;
|
|
770
|
+
connected = true;
|
|
771
|
+
await driver.connect({ logger: ctx.logger });
|
|
772
|
+
broker.bind(driver, codec, ctx.hooks);
|
|
773
|
+
const services = {
|
|
774
|
+
container: ctx.container,
|
|
775
|
+
hooks: ctx.hooks,
|
|
776
|
+
logger: ctx.logger,
|
|
777
|
+
codec,
|
|
778
|
+
driver,
|
|
779
|
+
requeueDelayMs: defaults.requeueDelay ?? DEFAULT_REQUEUE_DELAY_MS
|
|
780
|
+
};
|
|
781
|
+
runtimes = prepared.map((consumer) => new ConsumerRuntime(consumer, services));
|
|
782
|
+
},
|
|
783
|
+
async onStart(ctx) {
|
|
784
|
+
if (!consume || runtimes.length === 0) return;
|
|
785
|
+
const failure = (await Promise.allSettled(runtimes.map((runtime) => runtime.start()))).find((result) => result.status === "rejected");
|
|
786
|
+
if (failure !== void 0) {
|
|
787
|
+
await Promise.all(runtimes.map((runtime) => runtime.stop()));
|
|
788
|
+
throw failure.reason;
|
|
789
|
+
}
|
|
790
|
+
keepAlive ??= setInterval(() => void 0, KEEP_ALIVE_INTERVAL_MS);
|
|
791
|
+
ctx.logger.info(`Messaging: ${runtimes.length} consumer(s) subscribed with the ${driver?.name} driver`, { queues: runtimes.map((runtime) => runtime.consumer.queue) });
|
|
792
|
+
},
|
|
793
|
+
async onStop() {
|
|
794
|
+
try {
|
|
795
|
+
await Promise.all(runtimes.map((runtime) => runtime.stop()));
|
|
796
|
+
} finally {
|
|
797
|
+
stopKeepAlive();
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
async onDispose() {
|
|
801
|
+
stopKeepAlive();
|
|
802
|
+
broker.unbind();
|
|
803
|
+
if (driver !== void 0 && connected) {
|
|
804
|
+
connected = false;
|
|
805
|
+
await driver.close();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
function validateOptions(options) {
|
|
811
|
+
if (options === null || typeof options !== "object") throw consumerError("MessagingPlugin.configure(): options must be an object");
|
|
812
|
+
const driver = options.driver;
|
|
813
|
+
if (driver === void 0 || driver === null || typeof driver.connect !== "function" || typeof driver.subscribe !== "function" || typeof driver.publish !== "function" || typeof driver.close !== "function" || typeof driver.deadLetterName !== "function" || typeof driver.capabilities !== "object") throw consumerError("MessagingPlugin.configure(): \"driver\" must be a messaging driver", ["Use rabbitmq() from '@znight/messaging/rabbitmq', sqs(), kafka() or memory() from their subpaths"]);
|
|
814
|
+
const defaults = options.defaults;
|
|
815
|
+
if (defaults?.concurrency !== void 0 && !isPositiveInteger(defaults.concurrency)) throw consumerError(`MessagingPlugin defaults.concurrency must be a positive integer, got ${String(defaults.concurrency)}`);
|
|
816
|
+
if (defaults?.requeueDelay !== void 0 && !(Number.isFinite(defaults.requeueDelay) && defaults.requeueDelay >= 0)) throw consumerError(`MessagingPlugin defaults.requeueDelay must be a number of milliseconds >= 0, got ${String(defaults.requeueDelay)}`);
|
|
817
|
+
if (defaults?.deadLetter !== void 0 && defaults.deadLetter !== false && typeof defaults.deadLetter !== "function") throw consumerError("MessagingPlugin defaults.deadLetter must be a function (queue => name) or false");
|
|
818
|
+
return options;
|
|
819
|
+
}
|
|
820
|
+
// src/standard-schema.ts
|
|
821
|
+
/** Whether `value` looks like a Standard Schema v1 (has a `~standard.validate` function). */
|
|
822
|
+
function isStandardSchema(value) {
|
|
823
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return false;
|
|
824
|
+
const props = value["~standard"];
|
|
825
|
+
return props !== null && typeof props === "object" && typeof props.validate === "function";
|
|
826
|
+
}
|
|
827
|
+
// src/validation.ts
|
|
828
|
+
/**
|
|
829
|
+
* Validates `value` with a Standard Schema (Zod, Valibot, ArkType...) and returns the schema
|
|
830
|
+
* **output** (defaults and transforms applied). On failure it throws a {@link MessageValidationError},
|
|
831
|
+
* a `NonRetryableError`: the message is dead-lettered without further attempts.
|
|
832
|
+
*
|
|
833
|
+
* Returns synchronously when the schema validates synchronously, a promise otherwise. Called by the
|
|
834
|
+
* code generated for `@Message(schema)` parameters:
|
|
835
|
+
*
|
|
836
|
+
* ```ts
|
|
837
|
+
* invoke: async (c, m) => c.handle(await validateMessage(OrderCreated, m.payload))
|
|
838
|
+
* ```
|
|
839
|
+
*
|
|
840
|
+
* @param label Describes the value in the error message. Default: `'message payload'`.
|
|
841
|
+
*/
|
|
842
|
+
function validateMessage(schema, value, label = "message payload") {
|
|
843
|
+
if (!isStandardSchema(schema)) throw new TypeError(`[znight] validateMessage(): the schema of the ${label} is not a Standard Schema v1`);
|
|
844
|
+
const result = schema["~standard"].validate(value);
|
|
845
|
+
if (isPromiseLike(result)) return Promise.resolve(result).then((settled) => unwrap(settled, label));
|
|
846
|
+
return unwrap(result, label);
|
|
847
|
+
}
|
|
848
|
+
function unwrap(result, label) {
|
|
849
|
+
if (result.issues === void 0) return result.value;
|
|
850
|
+
throw new MessageValidationError(label, result.issues.map(toIssue));
|
|
851
|
+
}
|
|
852
|
+
function toIssue(issue) {
|
|
853
|
+
return {
|
|
854
|
+
path: formatPath(issue.path),
|
|
855
|
+
message: String(issue.message)
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function formatPath(path) {
|
|
859
|
+
if (path === void 0 || path.length === 0) return "";
|
|
860
|
+
return path.map((segment) => String(isSegment(segment) ? segment.key : segment)).join(".");
|
|
861
|
+
}
|
|
862
|
+
function isPromiseLike(value) {
|
|
863
|
+
return typeof value?.then === "function";
|
|
864
|
+
}
|
|
865
|
+
function isSegment(segment) {
|
|
866
|
+
return typeof segment === "object" && segment !== null && "key" in segment;
|
|
867
|
+
}
|
|
868
|
+
export { AttemptsExhaustedError, BINARY_CONTENT_TYPE, Consumer, Ctx, DEFAULT_BACKOFF, DEFAULT_RETRY_DELAY_MS, DEFAULT_RETRY_POLICY, Headers, JSON_CONTENT_TYPE, Message, MessageBroker, MessageDecodeError, MessageValidationError, MessagingHeaders, MessagingPlugin, MessagingTokens, NoHandlerError, NonRetryableError, OnError, OnMessage, Retry, TEXT_CONTENT_TYPE, attemptFromHeaders, bodyToBytes, bodyToText, computeRetryDelay, isNonRetryable, isStandardSchema, jsonCodec, mediaType, messageHeader, normalizeRetry, validateMessage };
|
|
869
|
+
|
|
870
|
+
//# sourceMappingURL=index.mjs.map
|