@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
@@ -0,0 +1,944 @@
1
+ /**
2
+ * `@znight/messaging/rabbitmq`: RabbitMQ driver built on [amqplib](https://amqp-node.github.io/amqplib/)
3
+ * 2.x (optional peer dependency).
4
+ *
5
+ * - Publishes through a confirm channel (`publish()` resolves on broker confirmation) with
6
+ * `mandatory` routing: publishing to a queue that does not exist fails instead of dropping the message.
7
+ * - One channel per consumer, `prefetch = concurrency`, manual acknowledgements.
8
+ * - Retries are republished copies carrying `x-znight-attempt`: immediately to the queue, or through a
9
+ * delay queue (`<queue>.delay.<ms>`, queue-level TTL dead-lettering back into the queue: no
10
+ * head-of-line blocking between delays). The original is acknowledged once the copy is confirmed.
11
+ * - Delayed publications go through a fixed set of 32 delay levels shared by every queue
12
+ * (`znight.delays.<n>`: a topic exchange and a queue with a TTL of 2^n ms each). The delay is
13
+ * written in binary in the routing key: the message waits in the level of each set bit, then the last
14
+ * level dead-letters it to the default exchange, which routes it to its queue (`CC` header).
15
+ * - Attempts = `x-znight-attempt` + broker redeliveries (`x-delivery-count` of quorum queues, or the
16
+ * `redelivered` flag of classic queues).
17
+ * - Dead letters are published (confirmed) to `<queue>.dlq`, then the original is acknowledged; without
18
+ * dead-letter queue the message is rejected (`nack` without requeue), so a queue DLX policy applies.
19
+ * - Declares the queues it uses (`assertTopology`, default) or verifies they exist. On RabbitMQ >= 4,
20
+ * quorum queues are declared with `x-delivery-limit: -1`: Znight counts the attempts itself, the
21
+ * broker's default limit (20 deliveries) would drop messages released repeatedly.
22
+ * - Recovers from connection losses (reconnection with exponential backoff), closed consumer channels
23
+ * and consumers cancelled by the broker: every subscription is resumed, with backoff, until it
24
+ * consumes again.
25
+ */
26
+ import type {
27
+ Channel,
28
+ ChannelModel,
29
+ ConfirmChannel,
30
+ ConsumeMessage,
31
+ Message,
32
+ MessageProperties,
33
+ Options,
34
+ SocketOptions,
35
+ } from 'amqplib'
36
+ import type {
37
+ DeadLetterRequest,
38
+ DeliveryHandler,
39
+ DriverCapabilities,
40
+ DriverContext,
41
+ DriverDelivery,
42
+ MessagingDriver,
43
+ RetryRequest,
44
+ SubscribeOptions,
45
+ Subscription,
46
+ } from '../driver.ts'
47
+ import type { OutboundMessage } from '../message.ts'
48
+
49
+ import { connect as amqpConnect } from 'amqplib'
50
+
51
+ import { brokerUnavailableError, consumerError } from '../errors.ts'
52
+ import { attemptFromHeaders, MessagingHeaders } from '../headers.ts'
53
+ import { errorMessage, sleep, truncate } from '../internal/async.ts'
54
+ import { durationOption, MAX_ERROR_HEADER_LENGTH, readHeader, toHeaderRecord } from './shared.ts'
55
+
56
+ /** Reconnection settings. */
57
+ export interface RabbitMQReconnectOptions {
58
+ /** First delay before reconnecting. Default: 100 ms. */
59
+ readonly initialDelayMs?: number
60
+ /** Longest delay between attempts. Default: 30 000 ms. */
61
+ readonly maxDelayMs?: number
62
+ }
63
+
64
+ /** Options of {@link rabbitmq}. */
65
+ export interface RabbitMQDriverOptions {
66
+ /** `amqp://user:pass@host:5672/vhost` or amqplib connection options. */
67
+ readonly url: string | Options.Connect
68
+ /** Socket / TLS options passed to amqplib. */
69
+ readonly socketOptions?: SocketOptions
70
+ /**
71
+ * Declare the queues used by consumers (consumed, dead-letter and delay queues) and by delayed
72
+ * publications. When `false`, consumed and dead-letter queues must exist (verified at subscribe).
73
+ * Default: `true`.
74
+ */
75
+ readonly assertTopology?: boolean
76
+ /** Type of the declared queues. Default: `quorum` (replicated, native `x-delivery-count`). */
77
+ readonly queueType?: 'quorum' | 'classic'
78
+ /**
79
+ * Extra arguments of declared consumed and dead-letter queues (`x-max-length`...). Quorum queues get
80
+ * `x-delivery-limit: -1` on RabbitMQ >= 4 unless this sets `x-delivery-limit`.
81
+ */
82
+ readonly queueArguments?: Readonly<Record<string, unknown>>
83
+ /** Longest retry delay waited in-process (holding the message). Default: 5000 ms. */
84
+ readonly maxInProcessDelayMs?: number
85
+ /** Name of the retry delay queue of `queue` for `delayMs`. Default: `${queue}.delay.${delayMs}`. */
86
+ readonly delayQueueName?: (queue: string, delayMs: number) => string
87
+ /**
88
+ * Prefix of the delay levels of delayed publications: `<prefix>.0` to `<prefix>.31` (one topic
89
+ * exchange and one queue each, shared by every queue of the virtual host). Default: `znight.delays`.
90
+ */
91
+ readonly delayLevelPrefix?: string
92
+ /** Default dead-letter queue name. Default: `${queue}.dlq`. */
93
+ readonly deadLetterName?: (queue: string) => string
94
+ /** Publish with `mandatory`: unroutable messages make `publish()` fail. Default: `true`. */
95
+ readonly mandatory?: boolean
96
+ /** Persistent messages (delivery mode 2). Default: `true`. */
97
+ readonly persistent?: boolean
98
+ /** Use the routing key as message type when the message has none (topic exchanges). Default: `false`. */
99
+ readonly typeFromRoutingKey?: boolean
100
+ /** Reconnect after a connection loss. `false` disables. Default: enabled. */
101
+ readonly reconnect?: RabbitMQReconnectOptions | false
102
+ /** Connection name shown in the management UI. Default: `znight`. */
103
+ readonly connectionName?: string
104
+ }
105
+
106
+ /** Largest TTL RabbitMQ accepts (2^32 - 1 ms). */
107
+ const MAX_TTL_MS = 4_294_967_295
108
+ const DEFAULT_IN_PROCESS_DELAY_MS = 5000
109
+ const DEFAULT_DELAY_LEVEL_PREFIX = 'znight.delays'
110
+ /** Delay levels of delayed publications: level n waits 2^n ms, all of them together `MAX_TTL_MS`. */
111
+ const DELAY_LEVELS = 32
112
+ /** First word of the routing key of a delayed publication, followed by one bit per level (highest first). */
113
+ const DELAY_ROUTING_WORD = 'znight-delay'
114
+ /**
115
+ * Headers never copied to republished messages (retries, dead letters): the ones managed by the broker,
116
+ * and the sender-selected destinations (`CC`, `BCC`), which would route the copy to other queues.
117
+ */
118
+ const UNCOPIED_HEADERS = new Set([
119
+ 'x-delivery-count',
120
+ 'x-death',
121
+ 'x-first-death-exchange',
122
+ 'x-first-death-queue',
123
+ 'x-first-death-reason',
124
+ 'x-last-death-exchange',
125
+ 'x-last-death-queue',
126
+ 'x-last-death-reason',
127
+ 'CC',
128
+ 'BCC',
129
+ ])
130
+
131
+ /** Creates a {@link RabbitMQDriver}. */
132
+ export function rabbitmq(options: RabbitMQDriverOptions): RabbitMQDriver {
133
+ return new RabbitMQDriver(options)
134
+ }
135
+
136
+ interface PendingConfirm {
137
+ returned: boolean
138
+ }
139
+
140
+ interface ConsumerSubscription {
141
+ readonly queue: string
142
+ readonly handler: DeliveryHandler
143
+ readonly options: SubscribeOptions
144
+ channel: Channel | undefined
145
+ consumerTag: string | undefined
146
+ active: boolean
147
+ /** In-progress (re)subscription. */
148
+ consuming: Promise<void> | undefined
149
+ /** The broker cancelled the consumer (queue deleted...): declare the queues again before consuming. */
150
+ redeclare: boolean
151
+ }
152
+
153
+ /** RabbitMQ {@link MessagingDriver}. See the module documentation. */
154
+ export class RabbitMQDriver implements MessagingDriver {
155
+ readonly name = 'rabbitmq'
156
+ readonly capabilities: DriverCapabilities
157
+ readonly #options: RabbitMQDriverOptions
158
+ readonly #queueType: 'quorum' | 'classic'
159
+ readonly #assertTopology: boolean
160
+ readonly #subscriptions = new Set<ConsumerSubscription>()
161
+ /** Retry delay queues already declared, by name. */
162
+ readonly #delayQueues = new Map<string, Promise<void>>()
163
+ /** Publications awaiting their confirmation, by destination and message id (to detect returned messages). */
164
+ readonly #pending = new Map<string, PendingConfirm[]>()
165
+ readonly #abort = new AbortController()
166
+ #logger: DriverContext['logger'] = { debug() {}, info() {}, warn() {}, error() {} }
167
+ #model: ChannelModel | undefined
168
+ #publisher: Promise<ConfirmChannel> | undefined
169
+ /** Declaration of the delay levels on the current connection. */
170
+ #delayLevels: Promise<void> | undefined
171
+ #closing = false
172
+ /** Running recovery loop (reconnection and resubscriptions). */
173
+ #recovering: Promise<void> | undefined
174
+
175
+ constructor(options: RabbitMQDriverOptions) {
176
+ if (options === null || typeof options !== 'object' || options.url === undefined) {
177
+ throw new TypeError('[znight] rabbitmq(): the "url" option is required')
178
+ }
179
+ if (
180
+ options.delayLevelPrefix !== undefined &&
181
+ (typeof options.delayLevelPrefix !== 'string' || options.delayLevelPrefix === '')
182
+ ) {
183
+ throw new TypeError('[znight] rabbitmq(): "delayLevelPrefix" must be a non-empty string')
184
+ }
185
+ this.#options = options
186
+ this.#queueType = options.queueType ?? 'quorum'
187
+ this.#assertTopology = options.assertTopology ?? true
188
+ this.capabilities = Object.freeze({
189
+ nativeDelay: true,
190
+ maxNativeDelayMs: MAX_TTL_MS,
191
+ maxInProcessDelayMs: durationOption(
192
+ options.maxInProcessDelayMs,
193
+ DEFAULT_IN_PROCESS_DELAY_MS,
194
+ 'rabbitmq maxInProcessDelayMs',
195
+ ),
196
+ deliveryCount: this.#queueType === 'quorum' ? 'native' : 'header',
197
+ maxPublishDelayMs: MAX_TTL_MS,
198
+ })
199
+ }
200
+
201
+ // -----------------------------------------------------------------------------------------------
202
+ // Connection
203
+ // -----------------------------------------------------------------------------------------------
204
+
205
+ async connect(context: DriverContext): Promise<void> {
206
+ this.#logger = context.logger
207
+ if (this.#model !== undefined) return
208
+ if (this.#closing) throw brokerUnavailableError('RabbitMQ: the driver is closed, create a new one')
209
+ await this.#open()
210
+ }
211
+
212
+ async close(): Promise<void> {
213
+ if (this.#closing) return
214
+ this.#closing = true
215
+ this.#abort.abort()
216
+ for (const subscription of this.#subscriptions) {
217
+ subscription.active = false
218
+ const channel = subscription.channel
219
+ subscription.channel = undefined
220
+ if (channel !== undefined) await closeQuietly(channel)
221
+ }
222
+ this.#subscriptions.clear()
223
+ const publisher = this.#publisher
224
+ this.#publisher = undefined
225
+ if (publisher !== undefined) {
226
+ try {
227
+ await closeQuietly(await publisher)
228
+ } catch {
229
+ // the channel never opened
230
+ }
231
+ }
232
+ const model = this.#model
233
+ this.#model = undefined
234
+ if (model !== undefined) {
235
+ try {
236
+ await model.close()
237
+ } catch {
238
+ // already closed
239
+ }
240
+ }
241
+ }
242
+
243
+ deadLetterName(queue: string): string {
244
+ return this.#options.deadLetterName?.(queue) ?? `${queue}.dlq`
245
+ }
246
+
247
+ async #open(): Promise<void> {
248
+ const socketOptions: SocketOptions = {
249
+ ...this.#options.socketOptions,
250
+ clientProperties: {
251
+ connection_name: this.#options.connectionName ?? 'znight',
252
+ ...this.#options.socketOptions?.clientProperties,
253
+ },
254
+ }
255
+ const model = await amqpConnect(this.#options.url, socketOptions)
256
+ model.on('error', (error: Error) => {
257
+ this.#logger.error('RabbitMQ connection error', { err: error })
258
+ })
259
+ model.on('close', (error?: Error) => this.#onConnectionClosed(model, error))
260
+ model.on('blocked', (reason: string) => {
261
+ this.#logger.warn(`RabbitMQ connection blocked: ${reason}`)
262
+ })
263
+ model.on('unblocked', () => {
264
+ this.#logger.info('RabbitMQ connection unblocked')
265
+ })
266
+ this.#model = model
267
+ this.#publisher = undefined
268
+ this.#delayQueues.clear()
269
+ this.#delayLevels = undefined
270
+ }
271
+
272
+ #onConnectionClosed(model: ChannelModel, error: Error | undefined): void {
273
+ if (this.#model !== model) return
274
+ this.#model = undefined
275
+ this.#publisher = undefined
276
+ for (const subscription of this.#subscriptions) {
277
+ subscription.channel = undefined
278
+ subscription.consumerTag = undefined
279
+ }
280
+ if (this.#closing) return
281
+ const reconnect = this.#options.reconnect
282
+ this.#logger.error(
283
+ reconnect === false ? 'RabbitMQ connection lost' : 'RabbitMQ connection lost, reconnecting',
284
+ error === undefined ? {} : { err: error },
285
+ )
286
+ if (reconnect !== false) this.#recover()
287
+ }
288
+
289
+ /**
290
+ * Starts the recovery loop unless it is running. The loop re-reads the whole state (connection,
291
+ * subscriptions without channel) before it ends, so a failure happening while it runs is never lost.
292
+ */
293
+ #recover(): void {
294
+ if (this.#closing) return
295
+ this.#recovering ??= this.#recoverLoop()
296
+ }
297
+
298
+ /** Reconnects if needed, then resumes every subscription, with backoff, until everything consumes. */
299
+ async #recoverLoop(): Promise<void> {
300
+ const reconnect = this.#options.reconnect
301
+ const settings = reconnect === false ? {} : (reconnect ?? {})
302
+ const initialDelay = settings.initialDelayMs ?? 100
303
+ const maxDelay = settings.maxDelayMs ?? 30_000
304
+ let delay = initialDelay
305
+ let reconnected = false
306
+ try {
307
+ while (!this.#closing) {
308
+ try {
309
+ await sleep(delay, this.#abort.signal)
310
+ } catch {
311
+ return
312
+ }
313
+ if (this.#model === undefined) {
314
+ if (reconnect === false) return
315
+ try {
316
+ await this.#open()
317
+ reconnected = true
318
+ } catch (error) {
319
+ delay = Math.min(delay * 2, maxDelay)
320
+ this.#logger.warn(`RabbitMQ reconnection failed, next attempt in ${delay} ms`, { err: error })
321
+ continue
322
+ }
323
+ }
324
+ const model = this.#model
325
+ for (const subscription of this.#subscriptions) {
326
+ if (!subscription.active || subscription.channel !== undefined) continue
327
+ try {
328
+ if (subscription.redeclare) await this.#redeclare(subscription)
329
+ await this.#ensureConsuming(subscription)
330
+ if (!reconnected) this.#logger.info(`RabbitMQ: consuming "${subscription.queue}" again`)
331
+ } catch (error) {
332
+ this.#logger.error(`RabbitMQ: cannot consume "${subscription.queue}" again`, { err: error })
333
+ }
334
+ }
335
+ if (this.#closing) return
336
+ if (this.#model !== model) {
337
+ // the connection was lost again while the consumers were resuming
338
+ delay = Math.min(delay * 2, maxDelay)
339
+ continue
340
+ }
341
+ const waiting = [...this.#subscriptions].filter(
342
+ subscription => subscription.active && subscription.channel === undefined,
343
+ )
344
+ if (waiting.length > 0) {
345
+ delay = Math.min(delay * 2, maxDelay)
346
+ this.#logger.warn(
347
+ `RabbitMQ: ${waiting.map(subscription => `"${subscription.queue}"`).join(', ')} not consumed yet, next attempt in ${delay} ms`,
348
+ )
349
+ continue
350
+ }
351
+ if (reconnected) this.#logger.info('RabbitMQ connection restored')
352
+ return
353
+ }
354
+ } finally {
355
+ this.#recovering = undefined
356
+ }
357
+ }
358
+
359
+ #connection(action: string): ChannelModel {
360
+ const model = this.#model
361
+ if (model === undefined) {
362
+ throw brokerUnavailableError(
363
+ this.#closing
364
+ ? `RabbitMQ: cannot ${action}, the driver is closed`
365
+ : `RabbitMQ: cannot ${action}, the connection is not available`,
366
+ )
367
+ }
368
+ return model
369
+ }
370
+
371
+ /** Runs `fn` on a temporary channel (failed declarations close the channel they run on). */
372
+ async #withChannel<T>(action: string, fn: (channel: Channel) => Promise<T>): Promise<T> {
373
+ const channel = await this.#connection(action).createChannel()
374
+ channel.on('error', () => {
375
+ // reported through the rejected operation
376
+ })
377
+ try {
378
+ return await fn(channel)
379
+ } finally {
380
+ await closeQuietly(channel)
381
+ }
382
+ }
383
+
384
+ // -----------------------------------------------------------------------------------------------
385
+ // Publishing
386
+ // -----------------------------------------------------------------------------------------------
387
+
388
+ async publish(queue: string, message: OutboundMessage): Promise<void> {
389
+ const headers: Record<string, unknown> = { ...message.headers }
390
+ const properties: Options.Publish = {
391
+ messageId: message.id,
392
+ contentType: message.contentType,
393
+ timestamp: Math.floor(message.timestamp / 1000),
394
+ headers,
395
+ persistent: this.#options.persistent ?? true,
396
+ ...(message.type === undefined ? {} : { type: message.type }),
397
+ }
398
+ const content = Buffer.from(message.body)
399
+ if (message.delayMs > 0) {
400
+ await this.#publishDelayed(queue, content, properties, message.delayMs)
401
+ return
402
+ }
403
+ await this.#confirmPublish('', queue, content, properties, queue)
404
+ }
405
+
406
+ #publisherChannel(): Promise<ConfirmChannel> {
407
+ const existing = this.#publisher
408
+ if (existing !== undefined) return existing
409
+ const model = this.#connection('publish')
410
+ const opening = model.createConfirmChannel().then(channel => {
411
+ channel.on('error', (error: Error) => {
412
+ this.#logger.error('RabbitMQ publisher channel error', { err: error })
413
+ })
414
+ channel.on('close', () => {
415
+ if (this.#publisher === opening) this.#publisher = undefined
416
+ })
417
+ // a return precedes the confirmation of its publication: mark the oldest pending publication
418
+ // of the same exchange, routing key and message id (never one already returned)
419
+ channel.on('return', (returned: Message) => {
420
+ const key = pendingKey(returned.fields.exchange, returned.fields.routingKey, returned.properties.messageId)
421
+ const pending = this.#pending.get(key)?.find(entry => !entry.returned)
422
+ if (pending !== undefined) pending.returned = true
423
+ })
424
+ return channel
425
+ })
426
+ opening.catch(() => {
427
+ if (this.#publisher === opening) this.#publisher = undefined
428
+ })
429
+ this.#publisher = opening
430
+ return opening
431
+ }
432
+
433
+ /**
434
+ * Publishes to `exchange` with `routingKey` and waits for the broker confirmation. `destination`
435
+ * names the target queue in errors.
436
+ */
437
+ async #confirmPublish(
438
+ exchange: string,
439
+ routingKey: string,
440
+ content: Buffer,
441
+ properties: Options.Publish,
442
+ destination: string,
443
+ ): Promise<void> {
444
+ const channel = await this.#publisherChannel()
445
+ const mandatory = this.#options.mandatory ?? true
446
+ const id = properties.messageId as string
447
+ const key = pendingKey(exchange, routingKey, id)
448
+ const pending: PendingConfirm = { returned: false }
449
+ let waiting = this.#pending.get(key)
450
+ if (waiting === undefined) {
451
+ waiting = []
452
+ this.#pending.set(key, waiting)
453
+ }
454
+ waiting.push(pending)
455
+ try {
456
+ await new Promise<void>((resolve, reject) => {
457
+ try {
458
+ channel.publish(exchange, routingKey, content, { ...properties, mandatory }, (error: unknown) => {
459
+ if (error !== null && error !== undefined) reject(error instanceof Error ? error : new Error(String(error)))
460
+ else resolve()
461
+ })
462
+ } catch (error) {
463
+ reject(error)
464
+ }
465
+ })
466
+ } catch (error) {
467
+ throw brokerUnavailableError(
468
+ `RabbitMQ: publishing to "${destination}" was not confirmed: ${errorMessage(error)}`,
469
+ error,
470
+ )
471
+ } finally {
472
+ const index = waiting.indexOf(pending)
473
+ if (index !== -1) waiting.splice(index, 1)
474
+ if (waiting.length === 0 && this.#pending.get(key) === waiting) this.#pending.delete(key)
475
+ }
476
+ if (pending.returned) {
477
+ throw brokerUnavailableError(
478
+ exchange === ''
479
+ ? `RabbitMQ: message ${id} is unroutable, queue "${destination}" does not exist`
480
+ : `RabbitMQ: message ${id} to "${destination}" is unroutable through the exchange "${exchange}"`,
481
+ )
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Publishes through the delay levels: the delay, rounded up to an odd number of milliseconds (so
487
+ * that every route ends with level 0, whose queue dead-letters to the default exchange), is written
488
+ * in binary in the routing key; the `CC` header routes the message to `queue` at the end.
489
+ */
490
+ async #publishDelayed(queue: string, content: Buffer, properties: Options.Publish, delayMs: number): Promise<void> {
491
+ const rounded = Math.min(MAX_TTL_MS, Math.ceil(delayMs))
492
+ const delay = rounded % 2 === 0 ? rounded + 1 : rounded
493
+ await this.#declareDelayLevels()
494
+ let top = 0
495
+ while (top < DELAY_LEVELS - 1 && 2 ** (top + 1) <= delay) top++
496
+ try {
497
+ await this.#confirmPublish(
498
+ this.#delayLevelName(top),
499
+ delayRoutingKey(delay),
500
+ content,
501
+ { ...properties, headers: { ...properties.headers, CC: [queue] } },
502
+ queue,
503
+ )
504
+ } catch (error) {
505
+ // the levels may have been deleted: declare them again on the next delayed publication
506
+ this.#delayLevels = undefined
507
+ throw error
508
+ }
509
+ }
510
+
511
+ #delayLevelName(level: number): string {
512
+ return `${this.#options.delayLevelPrefix ?? DEFAULT_DELAY_LEVEL_PREFIX}.${level}`
513
+ }
514
+
515
+ /**
516
+ * Declares (once per connection) the delay levels: for each level n, a topic exchange routing the
517
+ * messages whose bit n is set to the queue of the level (TTL 2^n ms, dead-lettering to the level
518
+ * below), and the others straight to the level below.
519
+ */
520
+ async #declareDelayLevels(): Promise<void> {
521
+ let declared = this.#delayLevels
522
+ if (declared === undefined) {
523
+ const levels: Promise<void> = this.#withChannel('declare the delay levels', async channel => {
524
+ for (let level = 0; level < DELAY_LEVELS; level++) {
525
+ const name = this.#delayLevelName(level)
526
+ const below = level === 0 ? '' : this.#delayLevelName(level - 1)
527
+ await channel.assertExchange(name, 'topic', { durable: true })
528
+ await channel.assertQueue(name, { durable: true, arguments: this.#delayQueueArguments(2 ** level, below) })
529
+ await channel.bindQueue(name, name, delayBindingKey(level, '1'))
530
+ if (level > 0) await channel.bindExchange(below, name, delayBindingKey(level, '0'))
531
+ }
532
+ })
533
+ declared = levels
534
+ this.#delayLevels = levels
535
+ levels.catch(() => {
536
+ if (this.#delayLevels === levels) this.#delayLevels = undefined
537
+ })
538
+ }
539
+ try {
540
+ await declared
541
+ } catch (error) {
542
+ const message = errorMessage(error)
543
+ throw consumerError(
544
+ `RabbitMQ: cannot declare the delay levels "${this.#delayLevelName(0)}" to "${this.#delayLevelName(DELAY_LEVELS - 1)}": ${message}`,
545
+ message.includes('PRECONDITION_FAILED')
546
+ ? [
547
+ 'They exist with other arguments (another queueType?): set rabbitmq({ delayLevelPrefix }) to use other names',
548
+ ]
549
+ : [],
550
+ )
551
+ }
552
+ }
553
+
554
+ /** Arguments of a delay queue: TTL, then dead-lettering (at-least-once on quorum queues). */
555
+ #delayQueueArguments(ttl: number, exchange: string, routingKey?: string): Record<string, unknown> {
556
+ const args: Record<string, unknown> = {
557
+ 'x-queue-type': this.#queueType,
558
+ 'x-message-ttl': ttl,
559
+ 'x-dead-letter-exchange': exchange,
560
+ }
561
+ if (routingKey !== undefined) args['x-dead-letter-routing-key'] = routingKey
562
+ if (this.#queueType === 'quorum') {
563
+ // never lose a message while moving it on
564
+ args['x-dead-letter-strategy'] = 'at-least-once'
565
+ args['x-overflow'] = 'reject-publish'
566
+ }
567
+ return args
568
+ }
569
+
570
+ /** Declares (once per connection) the retry delay queue of `queue` for `delayMs` and returns its name. */
571
+ async #delayQueue(queue: string, delayMs: number): Promise<string> {
572
+ const name = this.#options.delayQueueName?.(queue, delayMs) ?? `${queue}.delay.${delayMs}`
573
+ let declared = this.#delayQueues.get(name)
574
+ if (declared === undefined) {
575
+ const args = this.#delayQueueArguments(delayMs, '', queue)
576
+ declared = this.#withChannel(`declare the delay queue "${name}"`, async channel => {
577
+ await channel.assertQueue(name, { durable: true, arguments: args })
578
+ })
579
+ this.#delayQueues.set(name, declared)
580
+ declared.catch(() => this.#delayQueues.delete(name))
581
+ }
582
+ try {
583
+ await declared
584
+ } catch (error) {
585
+ throw topologyError(name, error)
586
+ }
587
+ return name
588
+ }
589
+
590
+ // -----------------------------------------------------------------------------------------------
591
+ // Consuming
592
+ // -----------------------------------------------------------------------------------------------
593
+
594
+ async subscribe(queue: string, handler: DeliveryHandler, options: SubscribeOptions): Promise<Subscription> {
595
+ await this.#prepareTopology(queue, options)
596
+ const subscription: ConsumerSubscription = {
597
+ queue,
598
+ handler,
599
+ options,
600
+ channel: undefined,
601
+ consumerTag: undefined,
602
+ active: true,
603
+ consuming: undefined,
604
+ redeclare: false,
605
+ }
606
+ // registered first: a channel closing right after the subscription is resumed by the recovery
607
+ this.#subscriptions.add(subscription)
608
+ try {
609
+ await this.#ensureConsuming(subscription)
610
+ } catch (error) {
611
+ subscription.active = false
612
+ this.#subscriptions.delete(subscription)
613
+ throw error
614
+ }
615
+ return {
616
+ cancel: async (): Promise<void> => {
617
+ subscription.active = false
618
+ const { channel, consumerTag } = subscription
619
+ if (channel !== undefined && consumerTag !== undefined) {
620
+ subscription.consumerTag = undefined
621
+ try {
622
+ await channel.cancel(consumerTag)
623
+ } catch (error) {
624
+ this.#logger.warn(`RabbitMQ: failed to cancel the consumer of "${queue}"`, { err: error })
625
+ }
626
+ }
627
+ },
628
+ close: async (): Promise<void> => {
629
+ subscription.active = false
630
+ this.#subscriptions.delete(subscription)
631
+ const channel = subscription.channel
632
+ subscription.channel = undefined
633
+ if (channel !== undefined) await closeQuietly(channel)
634
+ },
635
+ }
636
+ }
637
+
638
+ async #prepareTopology(queue: string, options: SubscribeOptions): Promise<void> {
639
+ const dlq = options.deadLetterQueue
640
+ const names = dlq === undefined ? [queue] : [queue, dlq]
641
+ if (this.#assertTopology) {
642
+ for (const name of names) {
643
+ try {
644
+ await this.#declareQueue(name)
645
+ } catch (error) {
646
+ throw topologyError(name, error)
647
+ }
648
+ }
649
+ } else {
650
+ for (const name of names) {
651
+ try {
652
+ await this.#withChannel(`check the queue "${name}"`, channel => channel.checkQueue(name))
653
+ } catch (error) {
654
+ throw consumerError(
655
+ `RabbitMQ: queue "${name}" of consumer ${options.consumer} does not exist (${errorMessage(error)})`,
656
+ ['Declare it, or set assertTopology: true to let Znight declare it'],
657
+ )
658
+ }
659
+ }
660
+ }
661
+ for (const delay of options.retryDelays) await this.#delayQueue(queue, Math.round(delay))
662
+ }
663
+
664
+ /**
665
+ * Declares a consumed or dead-letter queue. Quorum queues get `x-delivery-limit: -1` on RabbitMQ >= 4
666
+ * (whose default limit of 20 deliveries counts the releases); an existing queue declared without it
667
+ * cannot be changed by a declaration: it is declared as it is, with a warning.
668
+ */
669
+ async #declareQueue(name: string): Promise<void> {
670
+ const args: Record<string, unknown> = { 'x-queue-type': this.#queueType, ...this.#options.queueArguments }
671
+ const declare = (queueArgs: Record<string, unknown>): Promise<void> =>
672
+ this.#withChannel(`declare the queue "${name}"`, async channel => {
673
+ await channel.assertQueue(name, { durable: true, arguments: queueArgs })
674
+ })
675
+ if (args['x-queue-type'] === 'quorum' && !Object.hasOwn(args, 'x-delivery-limit') && this.#serverMajor() >= 4) {
676
+ try {
677
+ await declare({ ...args, 'x-delivery-limit': -1 })
678
+ return
679
+ } catch (error) {
680
+ if (!errorMessage(error).includes("'x-delivery-limit'")) throw error
681
+ this.#logger.warn(
682
+ `RabbitMQ: queue "${name}" exists without "x-delivery-limit": unless a policy sets "delivery-limit" to -1, RabbitMQ drops (or dead-letters through a queue DLX) a message delivered 20 times, releases included (shutdowns, failed dead-lettering). Apply such a policy, or re-create the queue`,
683
+ )
684
+ }
685
+ }
686
+ await declare(args)
687
+ }
688
+
689
+ /** Major version of the connected RabbitMQ (0 when unknown). */
690
+ #serverMajor(): number {
691
+ const version = this.#model?.connection?.serverProperties?.version
692
+ const major = typeof version === 'string' ? Number.parseInt(version, 10) : Number.NaN
693
+ return Number.isNaN(major) ? 0 : major
694
+ }
695
+
696
+ /** After a consumer cancellation, declares its queues again (`assertTopology`) before consuming. */
697
+ async #redeclare(subscription: ConsumerSubscription): Promise<void> {
698
+ if (this.#assertTopology) {
699
+ const dlq = subscription.options.deadLetterQueue
700
+ for (const name of dlq === undefined ? [subscription.queue] : [subscription.queue, dlq]) {
701
+ try {
702
+ await this.#declareQueue(name)
703
+ } catch (error) {
704
+ throw topologyError(name, error)
705
+ }
706
+ }
707
+ }
708
+ subscription.redeclare = false
709
+ }
710
+
711
+ /** Opens the consumer channel of `subscription` unless it is open or opening. */
712
+ #ensureConsuming(subscription: ConsumerSubscription): Promise<void> {
713
+ if (!subscription.active || subscription.channel !== undefined) return Promise.resolve()
714
+ subscription.consuming ??= this.#consume(subscription).finally(() => {
715
+ subscription.consuming = undefined
716
+ })
717
+ return subscription.consuming
718
+ }
719
+
720
+ async #consume(subscription: ConsumerSubscription): Promise<void> {
721
+ const channel = await this.#connection(`consume "${subscription.queue}"`).createChannel()
722
+ channel.on('error', (error: Error) => {
723
+ this.#logger.error(`RabbitMQ consumer channel of "${subscription.queue}" failed`, { err: error })
724
+ })
725
+ channel.on('close', () => {
726
+ if (subscription.channel !== channel) return
727
+ subscription.channel = undefined
728
+ subscription.consumerTag = undefined
729
+ // a channel error (or the loss of the connection, handled by the same loop): consume again
730
+ if (subscription.active) this.#recover()
731
+ })
732
+ try {
733
+ await channel.prefetch(subscription.options.concurrency)
734
+ subscription.channel = channel
735
+ const { consumerTag } = await channel.consume(
736
+ subscription.queue,
737
+ message => {
738
+ if (message === null) {
739
+ this.#onConsumerCancelled(subscription, channel)
740
+ return
741
+ }
742
+ this.#deliver(subscription, channel, message)
743
+ },
744
+ { noAck: false },
745
+ )
746
+ subscription.consumerTag = consumerTag
747
+ } catch (error) {
748
+ subscription.channel = undefined
749
+ await closeQuietly(channel)
750
+ throw brokerUnavailableError(`RabbitMQ: cannot consume "${subscription.queue}": ${errorMessage(error)}`, error)
751
+ }
752
+ }
753
+
754
+ /**
755
+ * The broker cancelled the consumer (`basic.cancel`: queue deleted, node down...). The channel stays
756
+ * open, so nothing else would notice: close it (its unsettled messages go back to the queue, when it
757
+ * still exists) and resume the subscription.
758
+ */
759
+ #onConsumerCancelled(subscription: ConsumerSubscription, channel: Channel): void {
760
+ if (subscription.channel !== channel || !subscription.active) return
761
+ this.#logger.error(`RabbitMQ cancelled the consumer of "${subscription.queue}" (queue deleted?), consuming again`)
762
+ subscription.channel = undefined
763
+ subscription.consumerTag = undefined
764
+ subscription.redeclare = true
765
+ closeQuietly(channel).then(
766
+ () => undefined,
767
+ () => undefined,
768
+ )
769
+ this.#recover()
770
+ }
771
+
772
+ #deliver(subscription: ConsumerSubscription, channel: Channel, message: ConsumeMessage): void {
773
+ const { queue } = subscription
774
+ const properties = message.properties
775
+ const rawHeaders: Record<string, unknown> = { ...(properties.headers ?? {}) }
776
+ const headers = toHeaderRecord(Object.entries(rawHeaders))
777
+ // quorum queues count redeliveries; classic queues only flag them
778
+ const deliveryCount = Number(rawHeaders['x-delivery-count'])
779
+ let redeliveries = message.fields.redelivered ? 1 : 0
780
+ if (Number.isSafeInteger(deliveryCount) && deliveryCount > 0) redeliveries = deliveryCount
781
+ const type =
782
+ stringProperty(properties.type) ??
783
+ readHeader(headers, MessagingHeaders.Type) ??
784
+ (this.#options.typeFromRoutingKey === true && message.fields.exchange !== ''
785
+ ? message.fields.routingKey
786
+ : undefined)
787
+ const id = stringProperty(properties.messageId) ?? `${queue}:${message.fields.deliveryTag}`
788
+ const timestamp = typeof properties.timestamp === 'number' ? properties.timestamp * 1000 : undefined
789
+ let settled = false
790
+ const settle = (action: string): void => {
791
+ if (settled) throw new Error(`[rabbitmq] message ${id} was already settled (${action})`)
792
+ if (subscription.channel !== channel) {
793
+ throw brokerUnavailableError(
794
+ `RabbitMQ: cannot ${action} message ${id}, its channel is closed (it will be redelivered)`,
795
+ )
796
+ }
797
+ }
798
+ /** Properties of a republished copy: the original ones, with the type that selected the handler. */
799
+ const copyProperties = (extraHeaders: Record<string, unknown>, removeAttempt: boolean): Options.Publish => {
800
+ const copied: Record<string, unknown> = {}
801
+ for (const [name, value] of Object.entries(rawHeaders)) {
802
+ if (UNCOPIED_HEADERS.has(name) || (removeAttempt && name === MessagingHeaders.Attempt)) continue
803
+ copied[name] = value
804
+ }
805
+ return {
806
+ ...definedProperties(properties),
807
+ // a type read from the routing key would be lost: copies go through the default exchange
808
+ ...(type === undefined ? {} : { type }),
809
+ messageId: id,
810
+ headers: { ...copied, ...extraHeaders },
811
+ persistent: this.#options.persistent ?? true,
812
+ }
813
+ }
814
+ const delivery: DriverDelivery = {
815
+ id,
816
+ body: message.content,
817
+ contentType: stringProperty(properties.contentType),
818
+ headers,
819
+ type,
820
+ key: undefined,
821
+ attempt: attemptFromHeaders(headers) + redeliveries,
822
+ redelivered: message.fields.redelivered,
823
+ timestamp,
824
+ raw: message,
825
+ ack: async (): Promise<void> => {
826
+ await Promise.resolve()
827
+ settle('ack')
828
+ channel.ack(message)
829
+ settled = true
830
+ },
831
+ release: async (): Promise<void> => {
832
+ await Promise.resolve()
833
+ settle('release')
834
+ channel.nack(message, false, true)
835
+ settled = true
836
+ },
837
+ retry: async (request: RetryRequest): Promise<void> => {
838
+ settle('retry')
839
+ const target = request.delayMs > 0 ? await this.#delayQueue(queue, Math.round(request.delayMs)) : queue
840
+ try {
841
+ await this.#confirmPublish(
842
+ '',
843
+ target,
844
+ message.content,
845
+ copyProperties({ [MessagingHeaders.Attempt]: String(request.attempt) }, false),
846
+ target,
847
+ )
848
+ } catch (error) {
849
+ // the delay queue may have been deleted: declare it again on the next retry
850
+ if (target !== queue) this.#delayQueues.delete(target)
851
+ throw error
852
+ }
853
+ settle('retry')
854
+ channel.ack(message)
855
+ settled = true
856
+ },
857
+ deadLetter: async (request: DeadLetterRequest): Promise<void> => {
858
+ settle('dead-letter')
859
+ if (request.queue === undefined) {
860
+ channel.nack(message, false, false)
861
+ settled = true
862
+ return
863
+ }
864
+ await this.#confirmPublish(
865
+ '',
866
+ request.queue,
867
+ message.content,
868
+ copyProperties(
869
+ {
870
+ [MessagingHeaders.DeadLetterReason]: request.reason,
871
+ [MessagingHeaders.Error]: truncate(errorMessage(request.error), MAX_ERROR_HEADER_LENGTH),
872
+ [MessagingHeaders.OriginalQueue]: queue,
873
+ [MessagingHeaders.Attempts]: String(request.attempt),
874
+ },
875
+ true,
876
+ ),
877
+ request.queue,
878
+ )
879
+ settle('dead-letter')
880
+ channel.ack(message)
881
+ settled = true
882
+ },
883
+ }
884
+ subscription.handler(delivery).catch((error: unknown) => {
885
+ this.#logger.error(`RabbitMQ: unexpected failure handling message ${id}`, { err: error })
886
+ })
887
+ }
888
+ }
889
+
890
+ function stringProperty(value: unknown): string | undefined {
891
+ return typeof value === 'string' && value !== '' ? value : undefined
892
+ }
893
+
894
+ /** Key of the publications awaiting their confirmation (a returned message carries the same fields). */
895
+ function pendingKey(exchange: string, routingKey: string, messageId: unknown): string {
896
+ return `${exchange}\u0000${routingKey}\u0000${String(messageId)}`
897
+ }
898
+
899
+ /** Routing key of a delayed publication: `znight-delay.<bit 31>.<bit 30>...<bit 0>`. */
900
+ function delayRoutingKey(delayMs: number): string {
901
+ const words = [DELAY_ROUTING_WORD]
902
+ for (let level = DELAY_LEVELS - 1; level >= 0; level--) {
903
+ words.push(Math.floor(delayMs / 2 ** level) % 2 === 1 ? '1' : '0')
904
+ }
905
+ return words.join('.')
906
+ }
907
+
908
+ /** Binding key matching the delayed publications whose bit `level` is `bit` (exact word count). */
909
+ function delayBindingKey(level: number, bit: '0' | '1'): string {
910
+ const words = [DELAY_ROUTING_WORD]
911
+ for (let index = DELAY_LEVELS - 1; index >= 0; index--) words.push(index === level ? bit : '*')
912
+ return words.join('.')
913
+ }
914
+
915
+ /** AMQP properties of a received message, reusable for a republication. */
916
+ function definedProperties(properties: MessageProperties): Options.Publish {
917
+ const result: Options.Publish = {}
918
+ if (properties.contentType !== undefined) result.contentType = properties.contentType
919
+ if (properties.contentEncoding !== undefined) result.contentEncoding = properties.contentEncoding
920
+ if (properties.correlationId !== undefined) result.correlationId = properties.correlationId
921
+ if (properties.replyTo !== undefined) result.replyTo = properties.replyTo
922
+ if (properties.timestamp !== undefined) result.timestamp = properties.timestamp
923
+ if (properties.type !== undefined) result.type = properties.type
924
+ if (properties.appId !== undefined) result.appId = properties.appId
925
+ if (properties.priority !== undefined) result.priority = properties.priority
926
+ if (properties.userId !== undefined) result.userId = properties.userId
927
+ return result
928
+ }
929
+
930
+ function topologyError(queue: string, error: unknown): Error {
931
+ const message = errorMessage(error)
932
+ const hints = message.includes('PRECONDITION_FAILED')
933
+ ? ['The queue exists with different arguments: align queueType/queueArguments, or set assertTopology: false']
934
+ : []
935
+ return consumerError(`RabbitMQ: cannot declare the queue "${queue}": ${message}`, hints)
936
+ }
937
+
938
+ async function closeQuietly(channel: Channel): Promise<void> {
939
+ try {
940
+ await channel.close()
941
+ } catch {
942
+ // already closed
943
+ }
944
+ }