@linagora/rabbitmq-client 0.4.0 → 0.5.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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RabbitMQClientOptions, a as RabbitMQMessage, P as PublishOptions, b as RabbitMQMessageHandler, S as SubscribeOptions, I as ILogger } from './types-D29GyoZX.cjs';
2
- export { c as RabbitMQHooks, d as RabbitMQMessageProperties, e as RabbitMQSubscription } from './types-D29GyoZX.cjs';
1
+ import { R as RabbitMQClientOptions, a as RabbitMQMessage, P as PublishOptions, b as RabbitMQMessageHandler, S as SubscribeOptions, I as ILogger } from './types-Cjg-65Ef.cjs';
2
+ export { c as RabbitMQBinding, d as RabbitMQHooks, e as RabbitMQMessageProperties, f as RabbitMQSubscription } from './types-Cjg-65Ef.cjs';
3
3
 
4
4
  /** A `mandatory` publish the broker could route to no queue. */
5
5
  declare class UnroutableMessageError extends Error {
@@ -7,6 +7,12 @@ declare class UnroutableMessageError extends Error {
7
7
  readonly routingKey: string;
8
8
  constructor(exchange: string, routingKey: string);
9
9
  }
10
+ /** Thrown by a handler to send its message to the DLQ at once, without retrying. */
11
+ declare class DeadLetterError extends Error {
12
+ constructor(message: string, options?: {
13
+ cause?: unknown;
14
+ });
15
+ }
10
16
  /**
11
17
  * Production-grade RabbitMQ client with confirm channels, automatic DLQ
12
18
  * infrastructure, reconnection with subscription restoration, and
@@ -24,6 +30,7 @@ declare class RabbitMQClient {
24
30
  private connection;
25
31
  private channel;
26
32
  private connected;
33
+ private closing;
27
34
  private subscriptions;
28
35
  private readonly options;
29
36
  private readonly logger;
@@ -34,6 +41,7 @@ declare class RabbitMQClient {
34
41
  private returnWaiters;
35
42
  private consumerTags;
36
43
  private subscriptionSemaphores;
44
+ private retryWakers;
37
45
  private inflightCount;
38
46
  private drainResolve;
39
47
  constructor(options: RabbitMQClientOptions);
@@ -78,6 +86,7 @@ declare class RabbitMQClient {
78
86
  */
79
87
  unsubscribe(queue: string): Promise<void>;
80
88
  private setupSubscription;
89
+ private checkExchanges;
81
90
  /**
82
91
  * Gates a delivered message on the subscription's concurrency semaphore, then
83
92
  * processes it. A message counts as in-flight from delivery until its handler
@@ -104,6 +113,10 @@ declare class RabbitMQClient {
104
113
  /** Invokes a hook callback, swallowing errors so hooks never break message flow. */
105
114
  private callHook;
106
115
  private sleep;
116
+ private retryDelayFor;
117
+ private wakeRetries;
118
+ /** Also cut short by a reconnect, via wakeRetries(). */
119
+ private sleepUntilClose;
107
120
  }
108
121
 
109
122
  /**
@@ -122,4 +135,4 @@ declare const defaultLogger: ILogger;
122
135
  */
123
136
  declare const silentLogger: ILogger;
124
137
 
125
- export { ILogger, PublishOptions, RabbitMQClient, RabbitMQClientOptions, RabbitMQMessage, RabbitMQMessageHandler, SubscribeOptions, UnroutableMessageError, defaultLogger, silentLogger };
138
+ export { DeadLetterError, ILogger, PublishOptions, RabbitMQClient, RabbitMQClientOptions, RabbitMQMessage, RabbitMQMessageHandler, SubscribeOptions, UnroutableMessageError, defaultLogger, silentLogger };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RabbitMQClientOptions, a as RabbitMQMessage, P as PublishOptions, b as RabbitMQMessageHandler, S as SubscribeOptions, I as ILogger } from './types-D29GyoZX.js';
2
- export { c as RabbitMQHooks, d as RabbitMQMessageProperties, e as RabbitMQSubscription } from './types-D29GyoZX.js';
1
+ import { R as RabbitMQClientOptions, a as RabbitMQMessage, P as PublishOptions, b as RabbitMQMessageHandler, S as SubscribeOptions, I as ILogger } from './types-Cjg-65Ef.js';
2
+ export { c as RabbitMQBinding, d as RabbitMQHooks, e as RabbitMQMessageProperties, f as RabbitMQSubscription } from './types-Cjg-65Ef.js';
3
3
 
4
4
  /** A `mandatory` publish the broker could route to no queue. */
5
5
  declare class UnroutableMessageError extends Error {
@@ -7,6 +7,12 @@ declare class UnroutableMessageError extends Error {
7
7
  readonly routingKey: string;
8
8
  constructor(exchange: string, routingKey: string);
9
9
  }
10
+ /** Thrown by a handler to send its message to the DLQ at once, without retrying. */
11
+ declare class DeadLetterError extends Error {
12
+ constructor(message: string, options?: {
13
+ cause?: unknown;
14
+ });
15
+ }
10
16
  /**
11
17
  * Production-grade RabbitMQ client with confirm channels, automatic DLQ
12
18
  * infrastructure, reconnection with subscription restoration, and
@@ -24,6 +30,7 @@ declare class RabbitMQClient {
24
30
  private connection;
25
31
  private channel;
26
32
  private connected;
33
+ private closing;
27
34
  private subscriptions;
28
35
  private readonly options;
29
36
  private readonly logger;
@@ -34,6 +41,7 @@ declare class RabbitMQClient {
34
41
  private returnWaiters;
35
42
  private consumerTags;
36
43
  private subscriptionSemaphores;
44
+ private retryWakers;
37
45
  private inflightCount;
38
46
  private drainResolve;
39
47
  constructor(options: RabbitMQClientOptions);
@@ -78,6 +86,7 @@ declare class RabbitMQClient {
78
86
  */
79
87
  unsubscribe(queue: string): Promise<void>;
80
88
  private setupSubscription;
89
+ private checkExchanges;
81
90
  /**
82
91
  * Gates a delivered message on the subscription's concurrency semaphore, then
83
92
  * processes it. A message counts as in-flight from delivery until its handler
@@ -104,6 +113,10 @@ declare class RabbitMQClient {
104
113
  /** Invokes a hook callback, swallowing errors so hooks never break message flow. */
105
114
  private callHook;
106
115
  private sleep;
116
+ private retryDelayFor;
117
+ private wakeRetries;
118
+ /** Also cut short by a reconnect, via wakeRetries(). */
119
+ private sleepUntilClose;
107
120
  }
108
121
 
109
122
  /**
@@ -122,4 +135,4 @@ declare const defaultLogger: ILogger;
122
135
  */
123
136
  declare const silentLogger: ILogger;
124
137
 
125
- export { ILogger, PublishOptions, RabbitMQClient, RabbitMQClientOptions, RabbitMQMessage, RabbitMQMessageHandler, SubscribeOptions, UnroutableMessageError, defaultLogger, silentLogger };
138
+ export { DeadLetterError, ILogger, PublishOptions, RabbitMQClient, RabbitMQClientOptions, RabbitMQMessage, RabbitMQMessageHandler, SubscribeOptions, UnroutableMessageError, defaultLogger, silentLogger };
package/dist/index.js CHANGED
@@ -89,6 +89,9 @@ var DEFAULTS = {
89
89
  prefetch: DEFAULT_PREFETCH,
90
90
  closeTimeout: 5e3
91
91
  };
92
+ function backoff(baseDelay, attempt, maxDelay) {
93
+ return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
94
+ }
92
95
  var UnroutableMessageError = class extends Error {
93
96
  constructor(exchange, routingKey) {
94
97
  super(`No queue is bound to receive ${exchange || "(default exchange)"}/${routingKey}`);
@@ -99,10 +102,17 @@ var UnroutableMessageError = class extends Error {
99
102
  exchange;
100
103
  routingKey;
101
104
  };
105
+ var DeadLetterError = class extends Error {
106
+ constructor(message, options) {
107
+ super(message, options);
108
+ this.name = "DeadLetterError";
109
+ }
110
+ };
102
111
  var RabbitMQClient = class {
103
112
  connection = null;
104
113
  channel = null;
105
114
  connected = false;
115
+ closing = false;
106
116
  subscriptions = [];
107
117
  options;
108
118
  logger;
@@ -118,6 +128,7 @@ var RabbitMQClient = class {
118
128
  // connection drops keep holding their permits, and the new consumer cannot
119
129
  // exceed the ceiling while the old pipeline drains. `null` = no limit.
120
130
  subscriptionSemaphores = /* @__PURE__ */ new Map();
131
+ retryWakers = /* @__PURE__ */ new Set();
121
132
  inflightCount = 0;
122
133
  drainResolve = null;
123
134
  constructor(options) {
@@ -162,34 +173,43 @@ var RabbitMQClient = class {
162
173
  async doConnect(maxAttempts) {
163
174
  this.assertedExchanges.clear();
164
175
  this.consumerTags.clear();
176
+ this.connection?.close().catch(() => void 0);
177
+ this.connection = null;
165
178
  let attempts = 0;
166
- while (!this.connected) {
179
+ while (!this.connected && !this.closing) {
167
180
  try {
168
- this.connection = await amqp.connect(this.options.url);
181
+ const connection = await amqp.connect(this.options.url);
182
+ this.connection = connection;
169
183
  this.logger.info("Connected to server");
170
- this.channel = await this.connection.createConfirmChannel();
184
+ const channel = await connection.createConfirmChannel();
185
+ this.channel = channel;
186
+ this.wakeRetries();
171
187
  const returnWaiters = /* @__PURE__ */ new Set();
172
188
  this.returnWaiters = returnWaiters;
173
- this.channel.on("return", (msg) => {
189
+ channel.on("return", (msg) => {
174
190
  for (const waiter of returnWaiters) waiter(msg);
175
191
  });
176
192
  this.logger.info("Confirm channel created");
177
- await this.channel.prefetch(this.options.prefetch);
193
+ await channel.prefetch(this.options.prefetch);
178
194
  this.logger.info("Channel prefetch set", { prefetch: this.options.prefetch });
179
- this.connection.on("error", (error) => {
195
+ connection.on("error", (error) => {
196
+ if (connection !== this.connection) return;
180
197
  this.connected = false;
181
198
  this.logger.error("Connection error", { error });
182
199
  });
183
- this.connection.on("close", () => {
200
+ connection.on("close", () => {
201
+ if (connection !== this.connection) return;
184
202
  this.connected = false;
185
203
  this.logger.warn("Connection closed");
186
204
  this.reconnectWithRetry();
187
205
  });
188
- this.channel.on("error", (error) => {
206
+ channel.on("error", (error) => {
207
+ if (channel !== this.channel) return;
189
208
  this.logger.error("Channel error", { error });
190
209
  this.handleChannelFailure();
191
210
  });
192
- this.channel.on("close", () => {
211
+ channel.on("close", () => {
212
+ if (channel !== this.channel) return;
193
213
  this.logger.warn("Channel closed");
194
214
  this.handleChannelFailure();
195
215
  });
@@ -213,6 +233,9 @@ var RabbitMQClient = class {
213
233
  await this.sleep(this.options.connectionRetryDelay);
214
234
  }
215
235
  }
236
+ if (!this.connected) {
237
+ throw new Error("RabbitMQ client closed while connecting");
238
+ }
216
239
  }
217
240
  handleChannelFailure() {
218
241
  if (this.connected) {
@@ -299,10 +322,7 @@ var RabbitMQClient = class {
299
322
  `Failed to publish to ${exchange || "(default exchange)"}/${routingKey} after ${attempts} attempts: ${error instanceof Error ? error.message : String(error)}`
300
323
  );
301
324
  }
302
- const retryDelay = Math.min(
303
- baseDelay * Math.pow(2, attempts - 1),
304
- MAX_PUBLISH_RETRY_DELAY_MS
305
- );
325
+ const retryDelay = backoff(baseDelay, attempts, MAX_PUBLISH_RETRY_DELAY_MS);
306
326
  this.logger.warn("Publish attempt failed, retrying", { error, exchange, routingKey, attempt: attempts, maxAttempts, retryDelayMs: retryDelay });
307
327
  await this.sleep(retryDelay);
308
328
  }
@@ -315,13 +335,19 @@ var RabbitMQClient = class {
315
335
  * `init()` / reconnect cycle.
316
336
  */
317
337
  async close(clearSubscriptions = true) {
338
+ this.closing = true;
318
339
  try {
340
+ await Promise.allSettled([this.initializationPromise, this.reconnectionPromise]);
341
+ this.wakeRetries();
319
342
  if (this.inflightCount > 0) {
320
343
  this.logger.info("Waiting for in-flight messages to drain", { inflightCount: this.inflightCount });
321
344
  await this.waitForDrain(this.options.closeTimeout);
322
345
  }
323
- await this.channel?.close();
324
- await this.connection?.close();
346
+ try {
347
+ await this.channel?.close();
348
+ } finally {
349
+ await this.connection?.close();
350
+ }
325
351
  this.connection = null;
326
352
  this.channel = null;
327
353
  this.connected = false;
@@ -337,9 +363,12 @@ var RabbitMQClient = class {
337
363
  } catch (error) {
338
364
  this.logger.error("Error closing connection", { error });
339
365
  throw error;
366
+ } finally {
367
+ this.closing = false;
340
368
  }
341
369
  }
342
370
  async reconnectWithRetry() {
371
+ if (this.closing) return;
343
372
  if (this.reconnectionPromise) {
344
373
  return this.reconnectionPromise;
345
374
  }
@@ -349,6 +378,8 @@ var RabbitMQClient = class {
349
378
  this.reconnectionPromise.catch(() => void 0);
350
379
  try {
351
380
  await this.reconnectionPromise;
381
+ } catch (error) {
382
+ if (!this.closing) throw error;
352
383
  } finally {
353
384
  this.reconnectionPromise = null;
354
385
  }
@@ -400,13 +431,19 @@ var RabbitMQClient = class {
400
431
  }
401
432
  const sub = { exchange, routingKey, queue, handler, options };
402
433
  const existingIndex = this.subscriptions.findIndex((s) => s.queue === queue);
434
+ const previous = existingIndex === -1 ? void 0 : this.subscriptions[existingIndex];
403
435
  if (existingIndex === -1) {
404
436
  this.subscriptions.push(sub);
405
437
  } else {
406
438
  this.subscriptions[existingIndex] = sub;
407
439
  this.subscriptionSemaphores.delete(queue);
408
440
  }
409
- await this.setupSubscription(sub);
441
+ try {
442
+ await this.setupSubscription(sub);
443
+ } catch (error) {
444
+ this.subscriptions = previous ? this.subscriptions.map((s) => s === sub ? previous : s) : this.subscriptions.filter((s) => s !== sub);
445
+ throw error;
446
+ }
410
447
  }
411
448
  /**
412
449
  * Cancels a queue subscription and removes it from the restoration list.
@@ -427,16 +464,22 @@ var RabbitMQClient = class {
427
464
  }
428
465
  const channel = this.channel;
429
466
  const { exchange, routingKey, queue, handler, options } = sub;
430
- const dlxExchange = `${exchange}.dlx`;
467
+ const dlxExchange = options?.deadLetterExchange ?? `${exchange}.dlx`;
431
468
  const dlqQueue = `${queue}.dlq`;
432
469
  const dlqRoutingKey = `${routingKey}.dead`;
470
+ const bindings = [{ exchange, routingKey }, ...options?.bindings ?? []];
433
471
  await channel.assertExchange(dlxExchange, "topic", { durable: true });
434
472
  await channel.assertQueue(dlqQueue, { durable: true });
435
473
  await channel.bindQueue(dlqQueue, dlxExchange, dlqRoutingKey);
436
- if (!this.assertedExchanges.has(exchange)) {
437
- await channel.assertExchange(exchange, "topic", { durable: true });
438
- this.assertedExchanges.add(exchange);
474
+ const missing = bindings.map((b) => b.exchange).filter((name) => !this.assertedExchanges.has(name));
475
+ if (options?.passiveExchanges && missing.length > 0) {
476
+ await this.checkExchanges(missing);
477
+ } else {
478
+ for (const name of missing) {
479
+ await channel.assertExchange(name, "topic", { durable: true });
480
+ }
439
481
  }
482
+ for (const name of missing) this.assertedExchanges.add(name);
440
483
  const queueType = options?.queueArguments?.["x-queue-type"] ?? "quorum";
441
484
  const queueArgs = {
442
485
  "x-queue-type": queueType,
@@ -451,7 +494,9 @@ var RabbitMQClient = class {
451
494
  deadLetterRoutingKey: dlqRoutingKey,
452
495
  arguments: { ...queueArgs, ...options?.queueArguments }
453
496
  });
454
- await channel.bindQueue(queue, exchange, routingKey);
497
+ for (const binding of bindings) {
498
+ await channel.bindQueue(queue, binding.exchange, binding.routingKey);
499
+ }
455
500
  if (!this.subscriptionSemaphores.has(queue)) {
456
501
  const concurrency = options?.concurrency ?? this.options.concurrency;
457
502
  this.subscriptionSemaphores.set(queue, concurrency > 0 ? new Semaphore(concurrency) : null);
@@ -461,13 +506,29 @@ var RabbitMQClient = class {
461
506
  queue,
462
507
  (message) => {
463
508
  if (message) {
464
- this.dispatch(message, handler, semaphore, channel);
509
+ this.dispatch(message, handler, semaphore, channel, options);
465
510
  }
466
511
  },
467
512
  { noAck: false }
468
513
  );
469
514
  this.consumerTags.set(queue, consumerTag);
470
- this.logger.info("Subscribed to queue", { exchange, routingKey, queue });
515
+ this.logger.info("Subscribed to queue", { queue, bindings });
516
+ }
517
+ // A failed check closes the channel it ran on, so it runs on a throwaway one
518
+ // rather than take down the shared channel and every consumer on it.
519
+ async checkExchanges(exchanges) {
520
+ if (!this.connection) {
521
+ throw new Error("Connection not available");
522
+ }
523
+ const probe = await this.connection.createChannel();
524
+ probe.on("error", () => void 0);
525
+ try {
526
+ for (const name of exchanges) {
527
+ await probe.checkExchange(name);
528
+ }
529
+ } finally {
530
+ await probe.close().catch(() => void 0);
531
+ }
471
532
  }
472
533
  /**
473
534
  * Gates a delivered message on the subscription's concurrency semaphore, then
@@ -475,10 +536,10 @@ var RabbitMQClient = class {
475
536
  * settles (including time spent waiting for a permit), so graceful `close()`
476
537
  * drains queued messages as well as actively-processing ones.
477
538
  */
478
- dispatch(message, handler, semaphore, channel) {
539
+ dispatch(message, handler, semaphore, channel, options) {
479
540
  this.inflightCount++;
480
541
  const acquire = semaphore ? semaphore.acquire() : Promise.resolve();
481
- acquire.then(() => this.handleWithRetry(message, handler, channel)).catch((error) => {
542
+ acquire.then(() => this.handleWithRetry(message, handler, channel, options)).catch((error) => {
482
543
  this.logger.error("Unhandled error in message handler", { error });
483
544
  }).finally(() => {
484
545
  semaphore?.release();
@@ -488,7 +549,7 @@ var RabbitMQClient = class {
488
549
  }
489
550
  });
490
551
  }
491
- async handleWithRetry(message, handler, channel) {
552
+ async handleWithRetry(message, handler, channel, options) {
492
553
  if (channel !== this.channel) {
493
554
  this.logger.warn("Skipping message from a superseded channel; it will be redelivered");
494
555
  return;
@@ -516,26 +577,41 @@ var RabbitMQClient = class {
516
577
  }
517
578
  this.logger.debug("Message received, processing", { exchange, routingKey, payload: content });
518
579
  const properties = {
580
+ exchange,
581
+ routingKey,
519
582
  headers: message.properties.headers ?? {},
520
583
  timestamp: message.properties.timestamp,
521
584
  messageId: message.properties.messageId,
522
585
  correlationId: message.properties.correlationId
523
586
  };
524
- while (attempts < this.options.maxRetries) {
587
+ const maxRetries = options?.maxRetries ?? this.options.maxRetries;
588
+ while (attempts < maxRetries) {
525
589
  try {
526
590
  await handler(content, properties);
527
591
  } catch (error) {
528
592
  attempts++;
593
+ if (error instanceof Error && error.name === "DeadLetterError") {
594
+ const duration3 = Date.now() - startTime;
595
+ this.logger.warn("Handler dead-lettered the message", { error: error.message, exchange, routingKey });
596
+ if (this.settle(channel, message, "nack")) {
597
+ this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration: duration3, reason: "dead_letter_error" });
598
+ }
599
+ return;
600
+ }
529
601
  this.logger.error("Handler failed", {
530
602
  error: error instanceof Error ? error.message : error,
531
603
  stack: error instanceof Error ? error.stack : void 0,
532
604
  exchange,
533
605
  routingKey,
534
606
  attempt: attempts,
535
- maxRetries: this.options.maxRetries
607
+ maxRetries
536
608
  });
537
- if (attempts < this.options.maxRetries) {
538
- await this.sleep(this.options.retryDelay);
609
+ if (attempts < maxRetries) {
610
+ await this.sleepUntilClose(this.retryDelayFor(attempts, options));
611
+ if (this.closing || channel !== this.channel) {
612
+ this.logger.warn("Stopped retrying a message on close or reconnect; it will be redelivered", { exchange, routingKey });
613
+ return;
614
+ }
539
615
  }
540
616
  continue;
541
617
  }
@@ -547,7 +623,7 @@ var RabbitMQClient = class {
547
623
  return;
548
624
  }
549
625
  const duration = Date.now() - startTime;
550
- this.logger.error("Message failed after max retries, sending to DLQ", { exchange, routingKey, maxRetries: this.options.maxRetries, duration });
626
+ this.logger.error("Message failed after max retries, sending to DLQ", { exchange, routingKey, maxRetries, duration });
551
627
  if (this.settle(channel, message, "nack")) {
552
628
  this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration, reason: "max_retries_exhausted" });
553
629
  }
@@ -628,8 +704,30 @@ var RabbitMQClient = class {
628
704
  sleep(ms) {
629
705
  return new Promise((resolve) => setTimeout(resolve, ms));
630
706
  }
707
+ retryDelayFor(attempts, options) {
708
+ const { retryDelay } = this.options;
709
+ if (options?.maxRetryDelay === void 0) return retryDelay;
710
+ return backoff(retryDelay, attempts, options.maxRetryDelay);
711
+ }
712
+ wakeRetries() {
713
+ for (const wake of [...this.retryWakers]) wake();
714
+ }
715
+ /** Also cut short by a reconnect, via wakeRetries(). */
716
+ sleepUntilClose(ms) {
717
+ if (this.closing) return Promise.resolve();
718
+ return new Promise((resolve) => {
719
+ const wake = () => {
720
+ clearTimeout(timer);
721
+ this.retryWakers.delete(wake);
722
+ resolve();
723
+ };
724
+ const timer = setTimeout(wake, ms);
725
+ this.retryWakers.add(wake);
726
+ });
727
+ }
631
728
  };
632
729
  export {
730
+ DeadLetterError,
633
731
  RabbitMQClient,
634
732
  UnroutableMessageError,
635
733
  defaultLogger,