@grupodiariodaregiao/bunstone 0.3.7 → 0.3.9

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.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  import "reflect-metadata";
2
+ /**
3
+ * CONVENÇÃO DE EXPORTS:
4
+ * Toda nova implementação adicionada em lib/ DEVE ser exportada neste arquivo
5
+ * para que consumidores da biblioteca possam utilizá-la.
6
+ *
7
+ * Regras:
8
+ * - Classes, serviços e módulos: export { NomeDaClasse } from "./lib/..."
9
+ * - Decorators e utilitários: export * from "./lib/..."
10
+ * - Interfaces e tipos públicos: export type * from "./lib/..." (ou export type { ... })
11
+ * - Constantes públicas (ex: symbols de metadata): export { CONSTANTE } from "./lib/..."
12
+ *
13
+ * Nunca deixe uma nova feature da lib sem a respectiva entrada aqui.
14
+ */
2
15
  export * from "./lib/adapters/cache-adapter";
3
16
  export { EmailModule, EmailService } from "./lib/email/email-module";
4
17
  export { EmailLayout } from "./lib/email/email-layout";
@@ -26,12 +39,12 @@ export * from "./lib/bullmq/decorators/processor.decorator";
26
39
  export * from "./lib/bullmq/decorators/process.decorator";
27
40
  export { RabbitMQModule } from "./lib/rabbitmq/rabbitmq-module";
28
41
  export { RabbitMQService } from "./lib/rabbitmq/rabbitmq.service";
42
+ export { RabbitMQDeadLetterService } from "./lib/rabbitmq/dead-letter.service";
29
43
  export { RabbitMQConnection } from "./lib/rabbitmq/rabbitmq-connection";
30
44
  export * from "./lib/rabbitmq/decorators/consumer.decorator";
31
45
  export * from "./lib/rabbitmq/decorators/subscribe.decorator";
32
- export type { RabbitMQModuleOptions } from "./lib/rabbitmq/interfaces/rabbitmq-options.interface";
33
- export type { RabbitMQExchangeConfig, RabbitMQQueueConfig, RabbitMQQueueBinding } from "./lib/rabbitmq/interfaces/rabbitmq-options.interface";
34
- export type { RabbitMessage, RabbitPublishOptions } from "./lib/rabbitmq/interfaces/rabbitmq-message.interface";
46
+ export type * from "./lib/rabbitmq/interfaces/rabbitmq-options.interface";
47
+ export type * from "./lib/rabbitmq/interfaces/rabbitmq-message.interface";
35
48
  export * from "./lib/errors";
36
49
  export * from "./lib/guard";
37
50
  export * from "./lib/http-exceptions";
package/dist/index.js CHANGED
@@ -32209,7 +32209,7 @@ var require_node_cron = __commonJS((exports) => {
32209
32209
  });
32210
32210
 
32211
32211
  // index.ts
32212
- var import_reflect_metadata28 = __toESM(require_Reflect(), 1);
32212
+ var import_reflect_metadata29 = __toESM(require_Reflect(), 1);
32213
32213
 
32214
32214
  // lib/adapters/cache-adapter.ts
32215
32215
  var {RedisClient, redis } = globalThis.Bun;
@@ -116845,6 +116845,23 @@ class RabbitMQConnection {
116845
116845
  await channel.bindQueue(q4.name, binding.exchange, binding.routingKey ?? "", binding.arguments);
116846
116846
  logger2.log(`Queue "${q4.name}" bound to exchange "${binding.exchange}" with key "${binding.routingKey ?? ""}"`);
116847
116847
  }
116848
+ if (q4.deadLetterQueue && q4.deadLetterExchange) {
116849
+ const dlxType = q4.deadLetterExchangeType ?? "direct";
116850
+ await channel.assertExchange(q4.deadLetterExchange, dlxType, {
116851
+ durable: true,
116852
+ autoDelete: false
116853
+ });
116854
+ logger2.log(`Dead letter exchange asserted: [${dlxType}] ${q4.deadLetterExchange}`);
116855
+ await channel.assertQueue(q4.deadLetterQueue, {
116856
+ durable: true,
116857
+ exclusive: false,
116858
+ autoDelete: false
116859
+ });
116860
+ logger2.log(`Dead letter queue asserted: ${q4.deadLetterQueue}`);
116861
+ const dlxBindingKey = q4.deadLetterRoutingKey ?? "";
116862
+ await channel.bindQueue(q4.deadLetterQueue, q4.deadLetterExchange, dlxBindingKey);
116863
+ logger2.log(`DLQ "${q4.deadLetterQueue}" bound to DLX "${q4.deadLetterExchange}" with key "${dlxBindingKey}"`);
116864
+ }
116848
116865
  }
116849
116866
  } finally {
116850
116867
  await channel.close().catch(() => {});
@@ -117880,8 +117897,144 @@ function Process(name3) {
117880
117897
  Reflect.defineMetadata(BULLMQ_PROCESS_METADATA, { name: name3 }, target2, propertyKey);
117881
117898
  };
117882
117899
  }
117883
- // lib/rabbitmq/rabbitmq.service.ts
117900
+ // lib/rabbitmq/dead-letter.service.ts
117884
117901
  var import_reflect_metadata20 = __toESM(require_Reflect(), 1);
117902
+ class RabbitMQDeadLetterService {
117903
+ logger = new Logger(RabbitMQDeadLetterService.name);
117904
+ async inspect(queue2, count = 10) {
117905
+ const pubCh = await RabbitMQConnection.getPublisherChannel();
117906
+ const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_inspect_${queue2}`);
117907
+ const results = [];
117908
+ for (let i = 0;i < count; i++) {
117909
+ const raw = await ch.get(queue2, { noAck: false });
117910
+ if (!raw)
117911
+ break;
117912
+ const data = this.parsePayload(raw);
117913
+ const deathInfo = this.extractDeathInfo(raw);
117914
+ results.push(this.buildWrapper(ch, raw, data, deathInfo, pubCh));
117915
+ ch.nack(raw, false, true);
117916
+ }
117917
+ await ch.close().catch(() => {});
117918
+ return results;
117919
+ }
117920
+ async requeueMessages(options) {
117921
+ const { fromQueue, toExchange, routingKey, count, publishOptions } = options;
117922
+ const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_requeue_${fromQueue}`);
117923
+ const pubCh = await RabbitMQConnection.getPublisherChannel();
117924
+ let requeued = 0;
117925
+ const max = count ?? Number.POSITIVE_INFINITY;
117926
+ while (requeued < max) {
117927
+ const raw = await ch.get(fromQueue, { noAck: false });
117928
+ if (!raw)
117929
+ break;
117930
+ try {
117931
+ const pubOpts = this.buildPublishOpts(raw, publishOptions);
117932
+ await new Promise((resolve2, reject) => {
117933
+ pubCh.publish(toExchange, routingKey, raw.content, pubOpts, (err) => {
117934
+ if (err)
117935
+ reject(err);
117936
+ else
117937
+ resolve2();
117938
+ });
117939
+ });
117940
+ ch.ack(raw);
117941
+ requeued++;
117942
+ } catch (err) {
117943
+ ch.nack(raw, false, true);
117944
+ this.logger.error(`Failed to republish message from "${fromQueue}" to "${toExchange}/${routingKey}": ${err.message}`);
117945
+ break;
117946
+ }
117947
+ }
117948
+ await ch.close().catch(() => {});
117949
+ this.logger.log(`Requeued ${requeued} message(s) from "${fromQueue}" to exchange "${toExchange}" (key: "${routingKey}")`);
117950
+ return requeued;
117951
+ }
117952
+ async discardMessages(queue2, count) {
117953
+ const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_discard_${queue2}`);
117954
+ let discarded = 0;
117955
+ const max = count ?? Number.POSITIVE_INFINITY;
117956
+ while (discarded < max) {
117957
+ const raw = await ch.get(queue2, { noAck: false });
117958
+ if (!raw)
117959
+ break;
117960
+ ch.ack(raw);
117961
+ discarded++;
117962
+ }
117963
+ await ch.close().catch(() => {});
117964
+ this.logger.log(`Discarded ${discarded} message(s) from queue "${queue2}"`);
117965
+ return discarded;
117966
+ }
117967
+ async messageCount(queue2) {
117968
+ const ch = await RabbitMQConnection.getConsumerChannel(`__dlq_count_${queue2}`);
117969
+ const info = await ch.checkQueue(queue2);
117970
+ await ch.close().catch(() => {});
117971
+ return info.messageCount;
117972
+ }
117973
+ parsePayload(raw) {
117974
+ try {
117975
+ return JSON.parse(raw.content.toString("utf-8"));
117976
+ } catch {
117977
+ return raw.content.toString("utf-8");
117978
+ }
117979
+ }
117980
+ extractDeathInfo(raw) {
117981
+ const xDeath = raw.properties.headers?.["x-death"];
117982
+ if (!Array.isArray(xDeath) || xDeath.length === 0)
117983
+ return null;
117984
+ const record2 = xDeath[0];
117985
+ return {
117986
+ queue: String(record2["queue"] ?? ""),
117987
+ exchange: String(record2["exchange"] ?? ""),
117988
+ routingKeys: Array.isArray(record2["routing-keys"]) ? record2["routing-keys"].map(String) : [],
117989
+ count: Number(record2["count"] ?? 1),
117990
+ reason: String(record2["reason"] ?? "rejected"),
117991
+ time: record2["time"] instanceof Date ? record2["time"] : new Date(String(record2["time"] ?? Date.now()))
117992
+ };
117993
+ }
117994
+ buildPublishOpts(raw, overrides) {
117995
+ return {
117996
+ persistent: overrides?.persistent ?? raw.properties.deliveryMode === 2,
117997
+ contentType: overrides?.contentType ?? raw.properties.contentType ?? "application/json",
117998
+ contentEncoding: overrides?.contentEncoding ?? raw.properties.contentEncoding ?? "utf-8",
117999
+ headers: {
118000
+ ...raw.properties.headers,
118001
+ ...overrides?.headers,
118002
+ "x-dlq-requeued": Number(raw.properties.headers?.["x-dlq-requeued"] ?? 0) + 1
118003
+ },
118004
+ correlationId: overrides?.correlationId ?? raw.properties.correlationId,
118005
+ messageId: overrides?.messageId ?? raw.properties.messageId,
118006
+ priority: overrides?.priority ?? raw.properties.priority,
118007
+ expiration: overrides?.expiration !== undefined ? String(overrides.expiration) : undefined
118008
+ };
118009
+ }
118010
+ buildWrapper(ch, raw, data, deathInfo, pubCh) {
118011
+ const asMsg = raw;
118012
+ return {
118013
+ data,
118014
+ raw: asMsg,
118015
+ deathInfo,
118016
+ ack: () => ch.ack(raw),
118017
+ nack: (requeue = false) => ch.nack(raw, false, requeue),
118018
+ republish: async (exchange, routingKey, options) => {
118019
+ const pubOpts = this.buildPublishOpts(raw, options);
118020
+ await new Promise((resolve2, reject) => {
118021
+ pubCh.publish(exchange, routingKey, raw.content, pubOpts, (err) => {
118022
+ if (err)
118023
+ reject(err);
118024
+ else
118025
+ resolve2();
118026
+ });
118027
+ });
118028
+ }
118029
+ };
118030
+ }
118031
+ }
118032
+ RabbitMQDeadLetterService = __legacyDecorateClassTS([
118033
+ Injectable()
118034
+ ], RabbitMQDeadLetterService);
118035
+
118036
+ // lib/rabbitmq/rabbitmq.service.ts
118037
+ var import_reflect_metadata21 = __toESM(require_Reflect(), 1);
117885
118038
  class RabbitMQService {
117886
118039
  logger = new Logger(RabbitMQService.name);
117887
118040
  async publish(exchange, routingKey, data, options) {
@@ -117948,13 +118101,13 @@ class RabbitMQModule {
117948
118101
  }
117949
118102
  RabbitMQModule = __legacyDecorateClassTS([
117950
118103
  Module({
117951
- providers: [RabbitMQService],
117952
- exports: [RabbitMQService],
118104
+ providers: [RabbitMQService, RabbitMQDeadLetterService],
118105
+ exports: [RabbitMQService, RabbitMQDeadLetterService],
117953
118106
  global: true
117954
118107
  })
117955
118108
  ], RabbitMQModule);
117956
118109
  // lib/rabbitmq/decorators/consumer.decorator.ts
117957
- var import_reflect_metadata21 = __toESM(require_Reflect(), 1);
118110
+ var import_reflect_metadata22 = __toESM(require_Reflect(), 1);
117958
118111
  function RabbitConsumer() {
117959
118112
  return (target2) => {
117960
118113
  Reflect.defineMetadata(RABBITMQ_CONSUMER_METADATA, true, target2);
@@ -117962,7 +118115,7 @@ function RabbitConsumer() {
117962
118115
  };
117963
118116
  }
117964
118117
  // lib/rabbitmq/decorators/subscribe.decorator.ts
117965
- var import_reflect_metadata22 = __toESM(require_Reflect(), 1);
118118
+ var import_reflect_metadata23 = __toESM(require_Reflect(), 1);
117966
118119
  function RabbitSubscribe(options) {
117967
118120
  return (target2, propertyKey) => {
117968
118121
  const existing = target2[RABBITMQ_SUBSCRIBE_SYMBOL] ?? [];
@@ -117976,7 +118129,7 @@ function RabbitSubscribe(options) {
117976
118129
  };
117977
118130
  }
117978
118131
  // lib/guard.ts
117979
- var import_reflect_metadata23 = __toESM(require_Reflect(), 1);
118132
+ var import_reflect_metadata24 = __toESM(require_Reflect(), 1);
117980
118133
 
117981
118134
  // lib/utils/is-class.ts
117982
118135
  function isClass(fn3) {
@@ -118048,7 +118201,7 @@ function Jwt() {
118048
118201
  };
118049
118202
  }
118050
118203
  // lib/jwt/jwt-module.ts
118051
- var import_reflect_metadata24 = __toESM(require_Reflect(), 1);
118204
+ var import_reflect_metadata25 = __toESM(require_Reflect(), 1);
118052
118205
 
118053
118206
  class JwtModule {
118054
118207
  static options;
@@ -118064,7 +118217,7 @@ class JwtModule {
118064
118217
  }
118065
118218
  }
118066
118219
  // lib/schedule/cron/cron.ts
118067
- var import_reflect_metadata25 = __toESM(require_Reflect(), 1);
118220
+ var import_reflect_metadata26 = __toESM(require_Reflect(), 1);
118068
118221
  function Cron(expression) {
118069
118222
  if (!expression) {
118070
118223
  throw new Error("Invalid cron expression.");
@@ -118084,7 +118237,7 @@ function Cron(expression) {
118084
118237
  };
118085
118238
  }
118086
118239
  // lib/schedule/timeout/timeout.ts
118087
- var import_reflect_metadata26 = __toESM(require_Reflect(), 1);
118240
+ var import_reflect_metadata27 = __toESM(require_Reflect(), 1);
118088
118241
  function Timeout(delay2) {
118089
118242
  if (!delay2 || delay2 < 0) {
118090
118243
  throw new Error("Delay must be a positive number.");
@@ -118104,7 +118257,7 @@ function Timeout(delay2) {
118104
118257
  };
118105
118258
  }
118106
118259
  // lib/testing/testing-module-builder.ts
118107
- var import_reflect_metadata27 = __toESM(require_Reflect(), 1);
118260
+ var import_reflect_metadata28 = __toESM(require_Reflect(), 1);
118108
118261
 
118109
118262
  // lib/testing/test-app.ts
118110
118263
  class TestApp {
@@ -118266,6 +118419,7 @@ export {
118266
118419
  RabbitSubscribe,
118267
118420
  RabbitMQService,
118268
118421
  RabbitMQModule,
118422
+ RabbitMQDeadLetterService,
118269
118423
  RabbitMQConnection,
118270
118424
  RabbitConsumer,
118271
118425
  RENDER_METADATA,
@@ -0,0 +1,103 @@
1
+ import "reflect-metadata";
2
+ import type { DeadLetterMessage, RequeueOptions } from "./interfaces/rabbitmq-message.interface";
3
+ /**
4
+ * Service for inspecting and reprocessing messages in Dead Letter Queues.
5
+ *
6
+ * Inject `RabbitMQDeadLetterService` wherever you need to manage failed messages –
7
+ * in controllers (for admin REST endpoints), scheduled tasks, or one-off scripts.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * // Requeue all stuck orders
12
+ * @Get('/admin/dlq/requeue')
13
+ * async requeueOrders() {
14
+ * const count = await this.dlq.requeueMessages({
15
+ * fromQueue: 'orders.dlq',
16
+ * toExchange: 'events',
17
+ * routingKey: 'orders.created',
18
+ * });
19
+ * return { requeued: count };
20
+ * }
21
+ * ```
22
+ */
23
+ export declare class RabbitMQDeadLetterService {
24
+ private readonly logger;
25
+ /**
26
+ * Peek at messages in a Dead Letter Queue **without permanently consuming them**.
27
+ *
28
+ * Internally uses `basic.get` + `nack(requeue=true)` so every inspected message
29
+ * is put back at the tail of the queue after inspection.
30
+ *
31
+ * > **Note:** Because AMQP has no native "peek" operation, there is a small
32
+ * > window where another consumer could receive the message between the `get`
33
+ * > and the `nack`. Use a dedicated DLQ that is only consumed by this service
34
+ * > (or consumed manually) to avoid race conditions.
35
+ *
36
+ * @param queue - Name of the dead letter queue to inspect
37
+ * @param count - Maximum number of messages to return. Default: `10`
38
+ * @returns Array of `DeadLetterMessage` wrappers with death metadata
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const messages = await this.dlq.inspect<OrderPayload>('orders.dlq', 5);
43
+ * for (const msg of messages) {
44
+ * console.log(msg.deathInfo?.reason, msg.data);
45
+ * }
46
+ * ```
47
+ */
48
+ inspect<T = unknown>(queue: string, count?: number): Promise<DeadLetterMessage<T>[]>;
49
+ /**
50
+ * Move messages from a Dead Letter Queue back to an exchange for reprocessing.
51
+ *
52
+ * Each message is `ack`-ed from the DLQ only **after** it is successfully
53
+ * republished, so no messages are lost even if publishing fails mid-batch.
54
+ *
55
+ * @param options - Source queue, target exchange/routing-key, optional count cap
56
+ * @returns Number of messages successfully requeued
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * // Requeue up to 50 messages back to the original exchange
61
+ * const n = await this.dlq.requeueMessages({
62
+ * fromQueue: 'orders.dlq',
63
+ * toExchange: 'events',
64
+ * routingKey: 'orders.created',
65
+ * count: 50,
66
+ * });
67
+ * console.log(`Requeued ${n} messages`);
68
+ * ```
69
+ */
70
+ requeueMessages(options: RequeueOptions): Promise<number>;
71
+ /**
72
+ * Permanently discard messages from a Dead Letter Queue by acknowledging them
73
+ * without republishing.
74
+ *
75
+ * @param queue - Dead letter queue name
76
+ * @param count - Number of messages to discard. Omit to discard **all**.
77
+ * @returns Number of messages discarded
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * await this.dlq.discardMessages('orders.dlq'); // purge all
82
+ * await this.dlq.discardMessages('orders.dlq', 10); // discard first 10
83
+ * ```
84
+ */
85
+ discardMessages(queue: string, count?: number): Promise<number>;
86
+ /**
87
+ * Returns the number of messages currently in a queue (including DLQs).
88
+ *
89
+ * @param queue - Queue name to check
90
+ * @returns Message count
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * const pending = await this.dlq.messageCount('orders.dlq');
95
+ * console.log(`${pending} failed orders waiting`);
96
+ * ```
97
+ */
98
+ messageCount(queue: string): Promise<number>;
99
+ private parsePayload;
100
+ private extractDeathInfo;
101
+ private buildPublishOpts;
102
+ private buildWrapper;
103
+ }
@@ -1,4 +1,76 @@
1
1
  import type { ConsumeMessage } from "amqplib";
2
+ /**
3
+ * Information extracted from the `x-death` header that RabbitMQ automatically
4
+ * attaches to every dead-lettered message.
5
+ */
6
+ export interface DeadLetterDeathInfo {
7
+ /** Queue where the message was dead-lettered */
8
+ queue: string;
9
+ /** Exchange where the message was originally published */
10
+ exchange: string;
11
+ /** Routing keys the message was published with */
12
+ routingKeys: string[];
13
+ /** How many times this message has been dead-lettered */
14
+ count: number;
15
+ /** Reason for dead-lettering */
16
+ reason: "rejected" | "expired" | "maxlen" | "delivery-limit";
17
+ /** Timestamp when the message was dead-lettered */
18
+ time: Date;
19
+ }
20
+ /**
21
+ * A message consumed from a Dead Letter Queue with extra context and
22
+ * a `republish()` helper for reprocessing.
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * @RabbitConsumer()
27
+ * export class OrderDLQConsumer {
28
+ * constructor(private readonly dlq: RabbitMQDeadLetterService) {}
29
+ *
30
+ * @RabbitSubscribe({ queue: 'orders.dlq' })
31
+ * async handle(msg: DeadLetterMessage<OrderPayload>) {
32
+ * console.log('Failed', msg.deathInfo?.reason, msg.data);
33
+ * await msg.republish('events', 'orders.created'); // retry
34
+ * msg.ack();
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ export interface DeadLetterMessage<T = unknown> {
40
+ /** Deserialized message payload */
41
+ data: T;
42
+ /** Raw amqplib ConsumeMessage */
43
+ raw: ConsumeMessage;
44
+ /**
45
+ * Death metadata provided by RabbitMQ via `x-death` headers.
46
+ * `null` if the message was not dead-lettered by the broker.
47
+ */
48
+ deathInfo: DeadLetterDeathInfo | null;
49
+ /** Acknowledge and remove the message from the DLQ */
50
+ ack: () => void;
51
+ /** Negative-ack (optionally requeue back to DLQ). Default requeue: false */
52
+ nack: (requeue?: boolean) => void;
53
+ /**
54
+ * Republish the message to an exchange for reprocessing.
55
+ * The original payload is preserved as-is.
56
+ */
57
+ republish: (exchange: string, routingKey: string, options?: RabbitPublishOptions) => Promise<void>;
58
+ }
59
+ /**
60
+ * Options for `RabbitMQDeadLetterService.requeueMessages()`.
61
+ */
62
+ export interface RequeueOptions {
63
+ /** Dead letter queue to consume from */
64
+ fromQueue: string;
65
+ /** Exchange to republish the messages to */
66
+ toExchange: string;
67
+ /** Routing key for the republished messages */
68
+ routingKey: string;
69
+ /** Maximum number of messages to requeue. Omit to requeue all. */
70
+ count?: number;
71
+ /** Additional publish options applied during requeue */
72
+ publishOptions?: RabbitPublishOptions;
73
+ }
2
74
  /**
3
75
  * A typed RabbitMQ message received by a `@RabbitSubscribe` handler.
4
76
  *
@@ -42,6 +42,21 @@ export interface RabbitMQQueueConfig {
42
42
  deadLetterExchange?: string;
43
43
  /** Dead letter routing key */
44
44
  deadLetterRoutingKey?: string;
45
+ /**
46
+ * When provided, the lib will automatically:
47
+ * 1. Assert the `deadLetterExchange` (type controlled by `deadLetterExchangeType`)
48
+ * 2. Assert a queue with this name
49
+ * 3. Bind that queue to the DLX using `deadLetterRoutingKey` (or `""`)
50
+ *
51
+ * This removes the need to manually declare the DLX exchange + queue in your
52
+ * `exchanges` and `queues` arrays.
53
+ */
54
+ deadLetterQueue?: string;
55
+ /**
56
+ * Exchange type used when auto-asserting the dead letter exchange.
57
+ * Only used when `deadLetterQueue` is set. Default: `"direct"`.
58
+ */
59
+ deadLetterExchangeType?: "direct" | "topic" | "fanout";
45
60
  /** Maximum time (ms) a message can remain in the queue undelivered */
46
61
  messageTtl?: number;
47
62
  /** Maximum number of messages the queue can hold */
@@ -0,0 +1,324 @@
1
+ import "reflect-metadata";
2
+ import type { GetMessage, Message, Options } from "amqplib";
3
+ import { Injectable } from "../injectable";
4
+ import { Logger } from "../utils/logger";
5
+ import type {
6
+ DeadLetterDeathInfo,
7
+ DeadLetterMessage,
8
+ RabbitPublishOptions,
9
+ RequeueOptions,
10
+ } from "./interfaces/rabbitmq-message.interface";
11
+ import { RabbitMQConnection } from "./rabbitmq-connection";
12
+
13
+ /**
14
+ * Service for inspecting and reprocessing messages in Dead Letter Queues.
15
+ *
16
+ * Inject `RabbitMQDeadLetterService` wherever you need to manage failed messages –
17
+ * in controllers (for admin REST endpoints), scheduled tasks, or one-off scripts.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * // Requeue all stuck orders
22
+ * @Get('/admin/dlq/requeue')
23
+ * async requeueOrders() {
24
+ * const count = await this.dlq.requeueMessages({
25
+ * fromQueue: 'orders.dlq',
26
+ * toExchange: 'events',
27
+ * routingKey: 'orders.created',
28
+ * });
29
+ * return { requeued: count };
30
+ * }
31
+ * ```
32
+ */
33
+ @Injectable()
34
+ export class RabbitMQDeadLetterService {
35
+ private readonly logger = new Logger(RabbitMQDeadLetterService.name);
36
+
37
+ // ─── Inspect ────────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Peek at messages in a Dead Letter Queue **without permanently consuming them**.
41
+ *
42
+ * Internally uses `basic.get` + `nack(requeue=true)` so every inspected message
43
+ * is put back at the tail of the queue after inspection.
44
+ *
45
+ * > **Note:** Because AMQP has no native "peek" operation, there is a small
46
+ * > window where another consumer could receive the message between the `get`
47
+ * > and the `nack`. Use a dedicated DLQ that is only consumed by this service
48
+ * > (or consumed manually) to avoid race conditions.
49
+ *
50
+ * @param queue - Name of the dead letter queue to inspect
51
+ * @param count - Maximum number of messages to return. Default: `10`
52
+ * @returns Array of `DeadLetterMessage` wrappers with death metadata
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * const messages = await this.dlq.inspect<OrderPayload>('orders.dlq', 5);
57
+ * for (const msg of messages) {
58
+ * console.log(msg.deathInfo?.reason, msg.data);
59
+ * }
60
+ * ```
61
+ */
62
+ async inspect<T = unknown>(
63
+ queue: string,
64
+ count = 10,
65
+ ): Promise<DeadLetterMessage<T>[]> {
66
+ const pubCh = await RabbitMQConnection.getPublisherChannel();
67
+ const ch = await RabbitMQConnection.getConsumerChannel(
68
+ `__dlq_inspect_${queue}`,
69
+ );
70
+
71
+ const results: DeadLetterMessage<T>[] = [];
72
+
73
+ for (let i = 0; i < count; i++) {
74
+ const raw = await ch.get(queue, { noAck: false });
75
+ if (!raw) break;
76
+
77
+ const data = this.parsePayload<T>(raw);
78
+ const deathInfo = this.extractDeathInfo(raw);
79
+
80
+ results.push(this.buildWrapper<T>(ch, raw, data, deathInfo, pubCh));
81
+
82
+ // Put message back so it is not permanently consumed
83
+ ch.nack(raw, false, true);
84
+ }
85
+
86
+ // Release the inspection channel slot
87
+ await ch.close().catch(() => {});
88
+
89
+ return results;
90
+ }
91
+
92
+ // ─── Requeue ─────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Move messages from a Dead Letter Queue back to an exchange for reprocessing.
96
+ *
97
+ * Each message is `ack`-ed from the DLQ only **after** it is successfully
98
+ * republished, so no messages are lost even if publishing fails mid-batch.
99
+ *
100
+ * @param options - Source queue, target exchange/routing-key, optional count cap
101
+ * @returns Number of messages successfully requeued
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * // Requeue up to 50 messages back to the original exchange
106
+ * const n = await this.dlq.requeueMessages({
107
+ * fromQueue: 'orders.dlq',
108
+ * toExchange: 'events',
109
+ * routingKey: 'orders.created',
110
+ * count: 50,
111
+ * });
112
+ * console.log(`Requeued ${n} messages`);
113
+ * ```
114
+ */
115
+ async requeueMessages(options: RequeueOptions): Promise<number> {
116
+ const { fromQueue, toExchange, routingKey, count, publishOptions } =
117
+ options;
118
+
119
+ const ch = await RabbitMQConnection.getConsumerChannel(
120
+ `__dlq_requeue_${fromQueue}`,
121
+ );
122
+ const pubCh = await RabbitMQConnection.getPublisherChannel();
123
+
124
+ let requeued = 0;
125
+ const max = count ?? Number.POSITIVE_INFINITY;
126
+
127
+ while (requeued < max) {
128
+ const raw = await ch.get(fromQueue, { noAck: false });
129
+ if (!raw) break;
130
+
131
+ try {
132
+ const pubOpts = this.buildPublishOpts(raw, publishOptions);
133
+
134
+ await new Promise<void>((resolve, reject) => {
135
+ pubCh.publish(toExchange, routingKey, raw.content, pubOpts, (err) => {
136
+ if (err) reject(err);
137
+ else resolve();
138
+ });
139
+ });
140
+
141
+ ch.ack(raw);
142
+ requeued++;
143
+ } catch (err: any) {
144
+ // Put the message back so it is not lost
145
+ ch.nack(raw, false, true);
146
+ this.logger.error(
147
+ `Failed to republish message from "${fromQueue}" to "${toExchange}/${routingKey}": ${err.message}`,
148
+ );
149
+ break;
150
+ }
151
+ }
152
+
153
+ await ch.close().catch(() => {});
154
+
155
+ this.logger.log(
156
+ `Requeued ${requeued} message(s) from "${fromQueue}" to exchange "${toExchange}" (key: "${routingKey}")`,
157
+ );
158
+
159
+ return requeued;
160
+ }
161
+
162
+ // ─── Discard ─────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Permanently discard messages from a Dead Letter Queue by acknowledging them
166
+ * without republishing.
167
+ *
168
+ * @param queue - Dead letter queue name
169
+ * @param count - Number of messages to discard. Omit to discard **all**.
170
+ * @returns Number of messages discarded
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * await this.dlq.discardMessages('orders.dlq'); // purge all
175
+ * await this.dlq.discardMessages('orders.dlq', 10); // discard first 10
176
+ * ```
177
+ */
178
+ async discardMessages(queue: string, count?: number): Promise<number> {
179
+ const ch = await RabbitMQConnection.getConsumerChannel(
180
+ `__dlq_discard_${queue}`,
181
+ );
182
+
183
+ let discarded = 0;
184
+ const max = count ?? Number.POSITIVE_INFINITY;
185
+
186
+ while (discarded < max) {
187
+ const raw = await ch.get(queue, { noAck: false });
188
+ if (!raw) break;
189
+
190
+ ch.ack(raw);
191
+ discarded++;
192
+ }
193
+
194
+ await ch.close().catch(() => {});
195
+
196
+ this.logger.log(`Discarded ${discarded} message(s) from queue "${queue}"`);
197
+ return discarded;
198
+ }
199
+
200
+ // ─── Count ───────────────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Returns the number of messages currently in a queue (including DLQs).
204
+ *
205
+ * @param queue - Queue name to check
206
+ * @returns Message count
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * const pending = await this.dlq.messageCount('orders.dlq');
211
+ * console.log(`${pending} failed orders waiting`);
212
+ * ```
213
+ */
214
+ async messageCount(queue: string): Promise<number> {
215
+ const ch = await RabbitMQConnection.getConsumerChannel(
216
+ `__dlq_count_${queue}`,
217
+ );
218
+
219
+ const info = await ch.checkQueue(queue);
220
+ await ch.close().catch(() => {});
221
+ return info.messageCount;
222
+ }
223
+
224
+ // ─── Private helpers ──────────────────────────────────────────────────────
225
+
226
+ private parsePayload<T>(raw: GetMessage): T {
227
+ try {
228
+ return JSON.parse(raw.content.toString("utf-8")) as T;
229
+ } catch {
230
+ return raw.content.toString("utf-8") as unknown as T;
231
+ }
232
+ }
233
+
234
+ private extractDeathInfo(raw: GetMessage): DeadLetterDeathInfo | null {
235
+ const xDeath = raw.properties.headers?.["x-death"];
236
+ if (!Array.isArray(xDeath) || xDeath.length === 0) return null;
237
+
238
+ // The most recent death record is the first entry
239
+ const record = xDeath[0] as unknown as Record<string, unknown>;
240
+
241
+ return {
242
+ queue: String(record["queue"] ?? ""),
243
+ exchange: String(record["exchange"] ?? ""),
244
+ routingKeys: Array.isArray(record["routing-keys"])
245
+ ? (record["routing-keys"] as any[]).map(String)
246
+ : [],
247
+ count: Number(record["count"] ?? 1),
248
+ reason: String(
249
+ record["reason"] ?? "rejected",
250
+ ) as DeadLetterDeathInfo["reason"],
251
+ time:
252
+ record["time"] instanceof Date
253
+ ? record["time"]
254
+ : new Date(String(record["time"] ?? Date.now())),
255
+ };
256
+ }
257
+
258
+ private buildPublishOpts(
259
+ raw: GetMessage,
260
+ overrides?: RabbitPublishOptions,
261
+ ): Options.Publish {
262
+ return {
263
+ persistent: overrides?.persistent ?? raw.properties.deliveryMode === 2,
264
+ contentType:
265
+ overrides?.contentType ??
266
+ raw.properties.contentType ??
267
+ "application/json",
268
+ contentEncoding:
269
+ overrides?.contentEncoding ?? raw.properties.contentEncoding ?? "utf-8",
270
+ headers: {
271
+ ...raw.properties.headers,
272
+ ...overrides?.headers,
273
+ // Track how many times this message has been manually requeued from a DLQ
274
+ "x-dlq-requeued":
275
+ Number(raw.properties.headers?.["x-dlq-requeued"] ?? 0) + 1,
276
+ },
277
+ correlationId: overrides?.correlationId ?? raw.properties.correlationId,
278
+ messageId: overrides?.messageId ?? raw.properties.messageId,
279
+ priority: overrides?.priority ?? raw.properties.priority,
280
+ expiration:
281
+ overrides?.expiration !== undefined
282
+ ? String(overrides.expiration)
283
+ : undefined,
284
+ };
285
+ }
286
+
287
+ private buildWrapper<T>(
288
+ ch: any,
289
+ raw: GetMessage,
290
+ data: T,
291
+ deathInfo: DeadLetterDeathInfo | null,
292
+ pubCh: any,
293
+ ): DeadLetterMessage<T> {
294
+ // GetMessage and ConsumeMessage share the same structure except for the
295
+ // consumerTag field in fields. Cast here for interface compatibility.
296
+ const asMsg = raw as unknown as Message;
297
+ return {
298
+ data,
299
+ raw: asMsg as any,
300
+ deathInfo,
301
+ ack: () => ch.ack(raw),
302
+ nack: (requeue = false) => ch.nack(raw, false, requeue),
303
+ republish: async (
304
+ exchange: string,
305
+ routingKey: string,
306
+ options?: RabbitPublishOptions,
307
+ ) => {
308
+ const pubOpts = this.buildPublishOpts(raw, options);
309
+ await new Promise<void>((resolve, reject) => {
310
+ pubCh.publish(
311
+ exchange,
312
+ routingKey,
313
+ raw.content,
314
+ pubOpts,
315
+ (err: Error | null) => {
316
+ if (err) reject(err);
317
+ else resolve();
318
+ },
319
+ );
320
+ });
321
+ },
322
+ };
323
+ }
324
+ }
@@ -1,5 +1,88 @@
1
1
  import type { ConsumeMessage } from "amqplib";
2
2
 
3
+ // ─── Dead Letter types ──────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Information extracted from the `x-death` header that RabbitMQ automatically
7
+ * attaches to every dead-lettered message.
8
+ */
9
+ export interface DeadLetterDeathInfo {
10
+ /** Queue where the message was dead-lettered */
11
+ queue: string;
12
+ /** Exchange where the message was originally published */
13
+ exchange: string;
14
+ /** Routing keys the message was published with */
15
+ routingKeys: string[];
16
+ /** How many times this message has been dead-lettered */
17
+ count: number;
18
+ /** Reason for dead-lettering */
19
+ reason: "rejected" | "expired" | "maxlen" | "delivery-limit";
20
+ /** Timestamp when the message was dead-lettered */
21
+ time: Date;
22
+ }
23
+
24
+ /**
25
+ * A message consumed from a Dead Letter Queue with extra context and
26
+ * a `republish()` helper for reprocessing.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * @RabbitConsumer()
31
+ * export class OrderDLQConsumer {
32
+ * constructor(private readonly dlq: RabbitMQDeadLetterService) {}
33
+ *
34
+ * @RabbitSubscribe({ queue: 'orders.dlq' })
35
+ * async handle(msg: DeadLetterMessage<OrderPayload>) {
36
+ * console.log('Failed', msg.deathInfo?.reason, msg.data);
37
+ * await msg.republish('events', 'orders.created'); // retry
38
+ * msg.ack();
39
+ * }
40
+ * }
41
+ * ```
42
+ */
43
+ export interface DeadLetterMessage<T = unknown> {
44
+ /** Deserialized message payload */
45
+ data: T;
46
+ /** Raw amqplib ConsumeMessage */
47
+ raw: ConsumeMessage;
48
+ /**
49
+ * Death metadata provided by RabbitMQ via `x-death` headers.
50
+ * `null` if the message was not dead-lettered by the broker.
51
+ */
52
+ deathInfo: DeadLetterDeathInfo | null;
53
+ /** Acknowledge and remove the message from the DLQ */
54
+ ack: () => void;
55
+ /** Negative-ack (optionally requeue back to DLQ). Default requeue: false */
56
+ nack: (requeue?: boolean) => void;
57
+ /**
58
+ * Republish the message to an exchange for reprocessing.
59
+ * The original payload is preserved as-is.
60
+ */
61
+ republish: (
62
+ exchange: string,
63
+ routingKey: string,
64
+ options?: RabbitPublishOptions,
65
+ ) => Promise<void>;
66
+ }
67
+
68
+ /**
69
+ * Options for `RabbitMQDeadLetterService.requeueMessages()`.
70
+ */
71
+ export interface RequeueOptions {
72
+ /** Dead letter queue to consume from */
73
+ fromQueue: string;
74
+ /** Exchange to republish the messages to */
75
+ toExchange: string;
76
+ /** Routing key for the republished messages */
77
+ routingKey: string;
78
+ /** Maximum number of messages to requeue. Omit to requeue all. */
79
+ count?: number;
80
+ /** Additional publish options applied during requeue */
81
+ publishOptions?: RabbitPublishOptions;
82
+ }
83
+
84
+ // ─── Standard message ───────────────────────────────────────────────────────
85
+
3
86
  /**
4
87
  * A typed RabbitMQ message received by a `@RabbitSubscribe` handler.
5
88
  *
@@ -44,6 +44,21 @@ export interface RabbitMQQueueConfig {
44
44
  deadLetterExchange?: string;
45
45
  /** Dead letter routing key */
46
46
  deadLetterRoutingKey?: string;
47
+ /**
48
+ * When provided, the lib will automatically:
49
+ * 1. Assert the `deadLetterExchange` (type controlled by `deadLetterExchangeType`)
50
+ * 2. Assert a queue with this name
51
+ * 3. Bind that queue to the DLX using `deadLetterRoutingKey` (or `""`)
52
+ *
53
+ * This removes the need to manually declare the DLX exchange + queue in your
54
+ * `exchanges` and `queues` arrays.
55
+ */
56
+ deadLetterQueue?: string;
57
+ /**
58
+ * Exchange type used when auto-asserting the dead letter exchange.
59
+ * Only used when `deadLetterQueue` is set. Default: `"direct"`.
60
+ */
61
+ deadLetterExchangeType?: "direct" | "topic" | "fanout";
47
62
  /** Maximum time (ms) a message can remain in the queue undelivered */
48
63
  messageTtl?: number;
49
64
  /** Maximum number of messages the queue can hold */
@@ -243,6 +243,38 @@ export class RabbitMQConnection {
243
243
  `Queue "${q.name}" bound to exchange "${binding.exchange}" with key "${binding.routingKey ?? ""}"`,
244
244
  );
245
245
  }
246
+
247
+ // ── Auto Dead Letter topology ──────────────────────────────────
248
+ // When `deadLetterQueue` + `deadLetterExchange` are both set, the lib
249
+ // automatically asserts the DLX exchange, the DLQ queue, and their binding.
250
+ // This removes the need to declare them manually in exchanges/queues arrays.
251
+ if (q.deadLetterQueue && q.deadLetterExchange) {
252
+ const dlxType = q.deadLetterExchangeType ?? "direct";
253
+ await channel.assertExchange(q.deadLetterExchange, dlxType, {
254
+ durable: true,
255
+ autoDelete: false,
256
+ });
257
+ logger.log(
258
+ `Dead letter exchange asserted: [${dlxType}] ${q.deadLetterExchange}`,
259
+ );
260
+
261
+ await channel.assertQueue(q.deadLetterQueue, {
262
+ durable: true,
263
+ exclusive: false,
264
+ autoDelete: false,
265
+ });
266
+ logger.log(`Dead letter queue asserted: ${q.deadLetterQueue}`);
267
+
268
+ const dlxBindingKey = q.deadLetterRoutingKey ?? "";
269
+ await channel.bindQueue(
270
+ q.deadLetterQueue,
271
+ q.deadLetterExchange,
272
+ dlxBindingKey,
273
+ );
274
+ logger.log(
275
+ `DLQ "${q.deadLetterQueue}" bound to DLX "${q.deadLetterExchange}" with key "${dlxBindingKey}"`,
276
+ );
277
+ }
246
278
  }
247
279
  } finally {
248
280
  await channel.close().catch(() => {});
@@ -1,4 +1,5 @@
1
1
  import { Module } from "../module";
2
+ import { RabbitMQDeadLetterService } from "./dead-letter.service";
2
3
  import type { RabbitMQModuleOptions } from "./interfaces/rabbitmq-options.interface";
3
4
  import { RabbitMQService } from "./rabbitmq.service";
4
5
  import { RabbitMQConnection } from "./rabbitmq-connection";
@@ -33,8 +34,8 @@ import { RabbitMQConnection } from "./rabbitmq-connection";
33
34
  * ```
34
35
  */
35
36
  @Module({
36
- providers: [RabbitMQService],
37
- exports: [RabbitMQService],
37
+ providers: [RabbitMQService, RabbitMQDeadLetterService],
38
+ exports: [RabbitMQService, RabbitMQDeadLetterService],
38
39
  global: true,
39
40
  })
40
41
  export class RabbitMQModule {
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "types": "./dist/*.d.ts"
14
14
  }
15
15
  },
16
- "version": "0.3.7",
16
+ "version": "0.3.9",
17
17
  "homepage": "https://bunstone.diario.one/",
18
18
  "repository": {
19
19
  "url": "https://github.com/diariodaregiao/bunstone.git",