@linagora/rabbitmq-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/dist/index.cjs +509 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +161 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +472 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/index.cjs +198 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +99 -0
- package/dist/testing/index.d.ts +99 -0
- package/dist/testing/index.js +171 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# @linagora/rabbitmq-client
|
|
2
|
+
|
|
3
|
+
An opinionated RabbitMQ client for Node.js that handles the boilerplate you'd otherwise copy-paste between services: confirm channels, dead letter queues, exponential backoff on publish, and automatic reconnection that restores your subscriptions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @linagora/rabbitmq-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { RabbitMQClient } from '@linagora/rabbitmq-client'
|
|
15
|
+
|
|
16
|
+
const client = new RabbitMQClient({ url: 'amqp://localhost' })
|
|
17
|
+
await client.init()
|
|
18
|
+
|
|
19
|
+
await client.publish('auth', 'user.created', { userId: '123' })
|
|
20
|
+
|
|
21
|
+
await client.subscribe('auth', 'user.created', 'my-service.user-created', async (msg) => {
|
|
22
|
+
// your logic here
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
await client.close()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
Only `url` is required. Everything else has defaults.
|
|
31
|
+
|
|
32
|
+
| Option | Default | Description |
|
|
33
|
+
|--------|---------|-------------|
|
|
34
|
+
| `url` | -- | AMQP connection string |
|
|
35
|
+
| `maxRetries` | `3` | How many times to retry a failed handler before sending the message to DLQ |
|
|
36
|
+
| `retryDelay` | `1000` | Milliseconds between handler retries |
|
|
37
|
+
| `connectionRetryDelay` | `5000` | Milliseconds between reconnection attempts |
|
|
38
|
+
| `initMaxAttempts` | `5` | Connection attempts on startup before giving up |
|
|
39
|
+
| `publishMaxAttempts` | `5` | Publish retries (exponential backoff, capped at 60s) |
|
|
40
|
+
| `prefetch` | `10` | Channel prefetch count |
|
|
41
|
+
| `closeTimeout` | `5000` | Milliseconds to wait for in-flight messages when closing |
|
|
42
|
+
| `logger` | -- | Parent [pino](https://github.com/pinojs/pino) instance (the library creates its own if omitted) |
|
|
43
|
+
| `hooks` | -- | Observability callbacks (see [Hooks](#hooks)) |
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
### Publishing
|
|
48
|
+
|
|
49
|
+
Publishes go through a confirm channel, so you know the broker accepted the message. If something goes wrong, the client retries with exponential backoff and forces a new channel on each attempt to avoid retrying on a silently dead connection. Exchange assertions are cached per connection to avoid unnecessary AMQP round-trips on the hot path.
|
|
50
|
+
|
|
51
|
+
### Subscribing
|
|
52
|
+
|
|
53
|
+
Each call to `subscribe` wires up the DLQ plumbing for you:
|
|
54
|
+
|
|
55
|
+
- A dead letter exchange (`<exchange>.dlx`)
|
|
56
|
+
- A dead letter queue (`<queue>.dlq`)
|
|
57
|
+
- A dead letter routing key (`<routingKey>.dead`)
|
|
58
|
+
|
|
59
|
+
The main queue is created as a quorum queue with `at-least-once` delivery and `reject-publish` overflow. Messages are manually acknowledged.
|
|
60
|
+
|
|
61
|
+
You can override the default queue arguments by passing a fifth argument:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
await client.subscribe('events', 'order.placed', 'order-queue', handler, {
|
|
65
|
+
queueArguments: { 'x-queue-type': 'classic', 'x-max-length': 10_000 },
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Custom arguments are merged with the DLQ wiring defaults, so you can swap the queue type or add a max-length without losing dead-letter routing.
|
|
70
|
+
|
|
71
|
+
### Unsubscribing
|
|
72
|
+
|
|
73
|
+
Cancel a consumer and remove it from the auto-restoration list:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
await client.unsubscribe('order-queue')
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
After unsubscribing, the queue will not be re-subscribed on reconnection.
|
|
80
|
+
|
|
81
|
+
### Message handling
|
|
82
|
+
|
|
83
|
+
Incoming messages are JSON-parsed first. If that fails, the message goes straight to the DLQ (no point retrying garbage). Otherwise, your handler runs up to `maxRetries` times. Success means ack, final failure means nack to the DLQ.
|
|
84
|
+
|
|
85
|
+
### Reconnection
|
|
86
|
+
|
|
87
|
+
If the connection or channel drops, the client reconnects and re-subscribes to everything automatically. Multiple reconnection triggers (e.g. connection close + channel close firing at the same time) are collapsed into a single attempt.
|
|
88
|
+
|
|
89
|
+
### Graceful shutdown
|
|
90
|
+
|
|
91
|
+
`close()` waits for in-flight message handlers to finish before tearing down the channel, up to `closeTimeout` milliseconds. If handlers don't drain in time, the client closes anyway and logs a warning.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// wait for handlers to finish, then close
|
|
95
|
+
await client.close()
|
|
96
|
+
|
|
97
|
+
// close but keep subscriptions for a later init()
|
|
98
|
+
await client.close(false)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Health check
|
|
102
|
+
|
|
103
|
+
`checkHealth()` creates and immediately deletes a temporary queue. Useful for Kubernetes readiness probes.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const healthy = await client.checkHealth()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Hooks
|
|
110
|
+
|
|
111
|
+
Optional callbacks for wiring metrics, tracing, or alerting. Hook errors are swallowed so they never break message flow.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
const client = new RabbitMQClient({
|
|
115
|
+
url: 'amqp://localhost',
|
|
116
|
+
hooks: {
|
|
117
|
+
onPublish({ exchange, routingKey, attempts }) {
|
|
118
|
+
metrics.increment('rabbitmq.publish', { exchange })
|
|
119
|
+
},
|
|
120
|
+
onMessageProcessed({ exchange, routingKey, duration, attempts }) {
|
|
121
|
+
metrics.histogram('rabbitmq.handler.duration', duration)
|
|
122
|
+
},
|
|
123
|
+
onMessageDlq({ exchange, routingKey, duration, reason }) {
|
|
124
|
+
alerting.warn(`Message sent to DLQ: ${reason}`)
|
|
125
|
+
},
|
|
126
|
+
onReconnect({ subscriptionsRestored, subscriptionsFailed }) {
|
|
127
|
+
metrics.increment('rabbitmq.reconnect')
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| Hook | Fires when |
|
|
134
|
+
|------|-----------|
|
|
135
|
+
| `onPublish` | A message is confirmed by the broker |
|
|
136
|
+
| `onMessageProcessed` | A handler completes successfully (includes duration and retry count) |
|
|
137
|
+
| `onMessageDlq` | A message is nacked to the DLQ — reason is `'invalid_json'` or `'max_retries_exhausted'` |
|
|
138
|
+
| `onReconnect` | The client reconnects and re-establishes subscriptions |
|
|
139
|
+
|
|
140
|
+
## Test helpers
|
|
141
|
+
|
|
142
|
+
The `@linagora/rabbitmq-client/testing` entrypoint provides mocks that don't depend on any test framework.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { createMockAmqplib } from '@linagora/rabbitmq-client/testing'
|
|
146
|
+
|
|
147
|
+
const { mockConnection, amqpMock } = createMockAmqplib()
|
|
148
|
+
|
|
149
|
+
// check what was published
|
|
150
|
+
mockConnection.channel.getPublishedMessages()
|
|
151
|
+
|
|
152
|
+
// feed a message into a consumer
|
|
153
|
+
mockConnection.channel.simulateMessage({ userId: '123' })
|
|
154
|
+
|
|
155
|
+
// simulate a broker going down
|
|
156
|
+
mockConnection.simulateClose()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
AGPL-3.0
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
RabbitMQClient: () => RabbitMQClient
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(src_exports);
|
|
36
|
+
|
|
37
|
+
// src/client.ts
|
|
38
|
+
var import_amqplib = __toESM(require("amqplib"), 1);
|
|
39
|
+
|
|
40
|
+
// src/logger.ts
|
|
41
|
+
var import_pino = __toESM(require("pino"), 1);
|
|
42
|
+
var createLogger = (parent) => {
|
|
43
|
+
if (parent) {
|
|
44
|
+
return parent.child({ service: "rabbitmq-client" });
|
|
45
|
+
}
|
|
46
|
+
return (0, import_pino.default)({ name: "rabbitmq-client" });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/client.ts
|
|
50
|
+
var DEFAULT_PREFETCH = 10;
|
|
51
|
+
var MAX_PUBLISH_RETRY_DELAY_MS = 6e4;
|
|
52
|
+
var DEFAULTS = {
|
|
53
|
+
maxRetries: 3,
|
|
54
|
+
retryDelay: 1e3,
|
|
55
|
+
connectionRetryDelay: 5e3,
|
|
56
|
+
initMaxAttempts: 5,
|
|
57
|
+
publishMaxAttempts: 5,
|
|
58
|
+
prefetch: DEFAULT_PREFETCH,
|
|
59
|
+
closeTimeout: 5e3
|
|
60
|
+
};
|
|
61
|
+
var RabbitMQClient = class {
|
|
62
|
+
connection = null;
|
|
63
|
+
channel = null;
|
|
64
|
+
connected = false;
|
|
65
|
+
subscriptions = [];
|
|
66
|
+
options;
|
|
67
|
+
logger;
|
|
68
|
+
hooks;
|
|
69
|
+
initializationPromise = null;
|
|
70
|
+
reconnectionPromise = null;
|
|
71
|
+
assertedExchanges = /* @__PURE__ */ new Set();
|
|
72
|
+
consumerTags = /* @__PURE__ */ new Map();
|
|
73
|
+
inflightCount = 0;
|
|
74
|
+
drainResolve = null;
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.options = {
|
|
77
|
+
url: options.url,
|
|
78
|
+
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
79
|
+
retryDelay: options.retryDelay ?? DEFAULTS.retryDelay,
|
|
80
|
+
connectionRetryDelay: options.connectionRetryDelay ?? DEFAULTS.connectionRetryDelay,
|
|
81
|
+
initMaxAttempts: options.initMaxAttempts ?? DEFAULTS.initMaxAttempts,
|
|
82
|
+
publishMaxAttempts: options.publishMaxAttempts ?? DEFAULTS.publishMaxAttempts,
|
|
83
|
+
prefetch: options.prefetch ?? DEFAULTS.prefetch,
|
|
84
|
+
closeTimeout: options.closeTimeout ?? DEFAULTS.closeTimeout
|
|
85
|
+
};
|
|
86
|
+
this.logger = createLogger(options.logger);
|
|
87
|
+
this.hooks = options.hooks ?? {};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Opens a connection and creates a confirm channel. Retries up to
|
|
91
|
+
* `initMaxAttempts` times, then throws. Idempotent and safe to call
|
|
92
|
+
* concurrently — duplicate calls share the same in-flight promise.
|
|
93
|
+
*/
|
|
94
|
+
async init() {
|
|
95
|
+
if (this.connection && this.connected) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.initializationPromise) {
|
|
99
|
+
return this.initializationPromise;
|
|
100
|
+
}
|
|
101
|
+
this.initializationPromise = this.doConnect(this.options.initMaxAttempts);
|
|
102
|
+
this.initializationPromise.catch(() => void 0);
|
|
103
|
+
try {
|
|
104
|
+
await this.initializationPromise;
|
|
105
|
+
} finally {
|
|
106
|
+
this.initializationPromise = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async doConnect(maxAttempts) {
|
|
110
|
+
this.assertedExchanges.clear();
|
|
111
|
+
this.consumerTags.clear();
|
|
112
|
+
let attempts = 0;
|
|
113
|
+
while (!this.connected) {
|
|
114
|
+
try {
|
|
115
|
+
this.connection = await import_amqplib.default.connect(this.options.url);
|
|
116
|
+
this.logger.info("Connected to server");
|
|
117
|
+
this.channel = await this.connection.createConfirmChannel();
|
|
118
|
+
this.logger.info("Confirm channel created");
|
|
119
|
+
await this.channel.prefetch(this.options.prefetch);
|
|
120
|
+
this.logger.info({ prefetch: this.options.prefetch }, "Channel prefetch set");
|
|
121
|
+
this.connection.on("error", (error) => {
|
|
122
|
+
this.connected = false;
|
|
123
|
+
this.logger.error({ error }, "Connection error");
|
|
124
|
+
});
|
|
125
|
+
this.connection.on("close", () => {
|
|
126
|
+
this.connected = false;
|
|
127
|
+
this.logger.warn("Connection closed");
|
|
128
|
+
this.reconnectWithRetry();
|
|
129
|
+
});
|
|
130
|
+
this.channel.on("error", (error) => {
|
|
131
|
+
this.logger.error({ error }, "Channel error");
|
|
132
|
+
this.handleChannelFailure();
|
|
133
|
+
});
|
|
134
|
+
this.channel.on("close", () => {
|
|
135
|
+
this.logger.warn("Channel closed");
|
|
136
|
+
this.handleChannelFailure();
|
|
137
|
+
});
|
|
138
|
+
this.connected = true;
|
|
139
|
+
this.logger.info("Client initialized successfully");
|
|
140
|
+
} catch (error) {
|
|
141
|
+
attempts++;
|
|
142
|
+
this.connected = false;
|
|
143
|
+
if (maxAttempts !== void 0 && attempts >= maxAttempts) {
|
|
144
|
+
this.logger.error({ error, attempts, maxAttempts }, "Connection failed after maximum attempts");
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Failed to connect to RabbitMQ after ${attempts} attempts. Check RABBITMQ_URL configuration and RabbitMQ server availability.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
this.logger.warn(
|
|
150
|
+
{
|
|
151
|
+
error,
|
|
152
|
+
attempt: attempts,
|
|
153
|
+
maxAttempts: maxAttempts ?? "unlimited",
|
|
154
|
+
retryDelayMs: this.options.connectionRetryDelay
|
|
155
|
+
},
|
|
156
|
+
"Connection attempt failed, retrying..."
|
|
157
|
+
);
|
|
158
|
+
await this.sleep(this.options.connectionRetryDelay);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
handleChannelFailure() {
|
|
163
|
+
if (this.connected) {
|
|
164
|
+
this.connected = false;
|
|
165
|
+
this.reconnectWithRetry();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Returns whether the client currently has an active connection and channel.
|
|
170
|
+
*/
|
|
171
|
+
isConnected() {
|
|
172
|
+
return this.connected;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Publishes a JSON message to a topic exchange with publisher confirms.
|
|
176
|
+
* Retries with exponential backoff (capped at 60 s), forcing a reconnect
|
|
177
|
+
* on each failure. Throws after `publishMaxAttempts` exhausted.
|
|
178
|
+
*/
|
|
179
|
+
async publish(exchange, routingKey, message, options) {
|
|
180
|
+
let attempts = 0;
|
|
181
|
+
const maxAttempts = options?.maxAttempts ?? this.options.publishMaxAttempts;
|
|
182
|
+
const baseDelay = this.options.connectionRetryDelay;
|
|
183
|
+
const content = Buffer.from(JSON.stringify(message));
|
|
184
|
+
while (attempts < maxAttempts) {
|
|
185
|
+
try {
|
|
186
|
+
if (!this.connected) {
|
|
187
|
+
await this.reconnectWithRetry();
|
|
188
|
+
}
|
|
189
|
+
if (!this.channel) {
|
|
190
|
+
throw new Error("Channel not available");
|
|
191
|
+
}
|
|
192
|
+
if (!this.assertedExchanges.has(exchange)) {
|
|
193
|
+
await this.channel.assertExchange(exchange, "topic", { durable: true });
|
|
194
|
+
this.assertedExchanges.add(exchange);
|
|
195
|
+
}
|
|
196
|
+
this.channel.publish(exchange, routingKey, content, {
|
|
197
|
+
persistent: true
|
|
198
|
+
});
|
|
199
|
+
await this.channel.waitForConfirms();
|
|
200
|
+
if (attempts > 0) {
|
|
201
|
+
this.logger.info({ exchange, routingKey, messageSize: content.length, attempts }, "Published message after retries");
|
|
202
|
+
} else {
|
|
203
|
+
this.logger.info({ exchange, routingKey, messageSize: content.length }, "Published message");
|
|
204
|
+
}
|
|
205
|
+
this.logger.debug({ exchange, routingKey, payload: message }, "Published message payload");
|
|
206
|
+
this.callHook(this.hooks.onPublish, { exchange, routingKey, attempts: attempts + 1 });
|
|
207
|
+
return;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
attempts++;
|
|
210
|
+
this.connected = false;
|
|
211
|
+
if (attempts >= maxAttempts) {
|
|
212
|
+
this.logger.error(
|
|
213
|
+
{ error, exchange, routingKey, attempts, maxAttempts },
|
|
214
|
+
"Publish failed after max attempts"
|
|
215
|
+
);
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Failed to publish to ${exchange}/${routingKey} after ${attempts} attempts: ${error instanceof Error ? error.message : String(error)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const retryDelay = Math.min(
|
|
221
|
+
baseDelay * Math.pow(2, attempts - 1),
|
|
222
|
+
MAX_PUBLISH_RETRY_DELAY_MS
|
|
223
|
+
);
|
|
224
|
+
this.logger.warn(
|
|
225
|
+
{ error, exchange, routingKey, attempt: attempts, maxAttempts, retryDelayMs: retryDelay },
|
|
226
|
+
"Publish attempt failed, retrying"
|
|
227
|
+
);
|
|
228
|
+
await this.sleep(retryDelay);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Gracefully shuts down the client. Waits up to `closeTimeout` ms for
|
|
234
|
+
* in-flight message handlers to finish before closing the channel and
|
|
235
|
+
* connection. Pass `false` to preserve subscriptions for a later
|
|
236
|
+
* `init()` / reconnect cycle.
|
|
237
|
+
*/
|
|
238
|
+
async close(clearSubscriptions = true) {
|
|
239
|
+
try {
|
|
240
|
+
if (this.inflightCount > 0) {
|
|
241
|
+
this.logger.info({ inflightCount: this.inflightCount }, "Waiting for in-flight messages to drain");
|
|
242
|
+
await this.waitForDrain(this.options.closeTimeout);
|
|
243
|
+
}
|
|
244
|
+
await this.channel?.close();
|
|
245
|
+
await this.connection?.close();
|
|
246
|
+
this.connection = null;
|
|
247
|
+
this.channel = null;
|
|
248
|
+
this.connected = false;
|
|
249
|
+
this.initializationPromise = null;
|
|
250
|
+
this.reconnectionPromise = null;
|
|
251
|
+
this.assertedExchanges.clear();
|
|
252
|
+
this.consumerTags.clear();
|
|
253
|
+
if (clearSubscriptions) {
|
|
254
|
+
this.subscriptions = [];
|
|
255
|
+
}
|
|
256
|
+
this.logger.info("Connection closed");
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.logger.error({ error }, "Error closing connection");
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async reconnectWithRetry() {
|
|
263
|
+
if (this.reconnectionPromise) {
|
|
264
|
+
return this.reconnectionPromise;
|
|
265
|
+
}
|
|
266
|
+
this.connected = false;
|
|
267
|
+
this.logger.info("Starting reconnection...");
|
|
268
|
+
this.reconnectionPromise = this.doReconnect();
|
|
269
|
+
this.reconnectionPromise.catch(() => void 0);
|
|
270
|
+
try {
|
|
271
|
+
await this.reconnectionPromise;
|
|
272
|
+
} finally {
|
|
273
|
+
this.reconnectionPromise = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async doReconnect() {
|
|
277
|
+
await this.doConnect();
|
|
278
|
+
await this.resubscribeAll();
|
|
279
|
+
}
|
|
280
|
+
async resubscribeAll() {
|
|
281
|
+
if (this.subscriptions.length === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.logger.info({ count: this.subscriptions.length }, "Re-establishing subscriptions");
|
|
285
|
+
const subs = [...this.subscriptions];
|
|
286
|
+
const results = await Promise.allSettled(
|
|
287
|
+
subs.map(async (sub) => {
|
|
288
|
+
await this.setupSubscription(sub);
|
|
289
|
+
return sub.queue;
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
const succeeded = [];
|
|
293
|
+
const failed = [];
|
|
294
|
+
results.forEach((result, index) => {
|
|
295
|
+
if (result.status === "fulfilled") {
|
|
296
|
+
succeeded.push(result.value);
|
|
297
|
+
} else {
|
|
298
|
+
const queue = subs[index].queue;
|
|
299
|
+
failed.push(queue);
|
|
300
|
+
this.logger.error({ error: result.reason, queue }, "Failed to re-subscribe to queue");
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
if (succeeded.length > 0) {
|
|
304
|
+
this.logger.info({ count: succeeded.length, queues: succeeded }, "Successfully re-subscribed to queues");
|
|
305
|
+
}
|
|
306
|
+
if (failed.length > 0) {
|
|
307
|
+
this.logger.warn({ count: failed.length, queues: failed }, "Some subscriptions failed to restore");
|
|
308
|
+
}
|
|
309
|
+
this.callHook(this.hooks.onReconnect, { subscriptionsRestored: succeeded.length, subscriptionsFailed: failed.length });
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Subscribes to a queue with automatic DLQ infrastructure setup.
|
|
313
|
+
*
|
|
314
|
+
* Pass `options.queueArguments` to override the default quorum-queue
|
|
315
|
+
* arguments (merged with the DLQ wiring defaults).
|
|
316
|
+
*/
|
|
317
|
+
async subscribe(exchange, routingKey, queue, handler, options) {
|
|
318
|
+
if (!this.connected || !this.channel) {
|
|
319
|
+
throw new Error("RabbitMQ client not connected. Call init() first.");
|
|
320
|
+
}
|
|
321
|
+
const sub = { exchange, routingKey, queue, handler, options };
|
|
322
|
+
const existingIndex = this.subscriptions.findIndex((s) => s.queue === queue);
|
|
323
|
+
if (existingIndex === -1) {
|
|
324
|
+
this.subscriptions.push(sub);
|
|
325
|
+
} else {
|
|
326
|
+
this.subscriptions[existingIndex] = sub;
|
|
327
|
+
}
|
|
328
|
+
await this.setupSubscription(sub);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Cancels a queue subscription and removes it from the restoration list.
|
|
332
|
+
*/
|
|
333
|
+
async unsubscribe(queue) {
|
|
334
|
+
const tag = this.consumerTags.get(queue);
|
|
335
|
+
if (tag && this.channel) {
|
|
336
|
+
await this.channel.cancel(tag);
|
|
337
|
+
}
|
|
338
|
+
this.consumerTags.delete(queue);
|
|
339
|
+
this.subscriptions = this.subscriptions.filter((s) => s.queue !== queue);
|
|
340
|
+
this.logger.info({ queue }, "Unsubscribed from queue");
|
|
341
|
+
}
|
|
342
|
+
async setupSubscription(sub) {
|
|
343
|
+
if (!this.channel) {
|
|
344
|
+
throw new Error("Channel not available");
|
|
345
|
+
}
|
|
346
|
+
const { exchange, routingKey, queue, handler, options } = sub;
|
|
347
|
+
const dlxExchange = `${exchange}.dlx`;
|
|
348
|
+
const dlqQueue = `${queue}.dlq`;
|
|
349
|
+
const dlqRoutingKey = `${routingKey}.dead`;
|
|
350
|
+
await this.channel.assertExchange(dlxExchange, "topic", { durable: true });
|
|
351
|
+
await this.channel.assertQueue(dlqQueue, { durable: true });
|
|
352
|
+
await this.channel.bindQueue(dlqQueue, dlxExchange, dlqRoutingKey);
|
|
353
|
+
if (!this.assertedExchanges.has(exchange)) {
|
|
354
|
+
await this.channel.assertExchange(exchange, "topic", { durable: true });
|
|
355
|
+
this.assertedExchanges.add(exchange);
|
|
356
|
+
}
|
|
357
|
+
await this.channel.assertQueue(queue, {
|
|
358
|
+
durable: true,
|
|
359
|
+
deadLetterExchange: dlxExchange,
|
|
360
|
+
deadLetterRoutingKey: dlqRoutingKey,
|
|
361
|
+
arguments: {
|
|
362
|
+
"x-dead-letter-strategy": "at-least-once",
|
|
363
|
+
"x-queue-type": "quorum",
|
|
364
|
+
"x-overflow": "reject-publish",
|
|
365
|
+
...options?.queueArguments
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
await this.channel.bindQueue(queue, exchange, routingKey);
|
|
369
|
+
const { consumerTag } = await this.channel.consume(
|
|
370
|
+
queue,
|
|
371
|
+
(message) => {
|
|
372
|
+
if (message) {
|
|
373
|
+
this.handleWithRetry(message, handler);
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
{ noAck: false }
|
|
377
|
+
);
|
|
378
|
+
this.consumerTags.set(queue, consumerTag);
|
|
379
|
+
this.logger.info({ exchange, routingKey, queue }, "Subscribed to queue");
|
|
380
|
+
}
|
|
381
|
+
async handleWithRetry(message, handler) {
|
|
382
|
+
const channel = this.channel;
|
|
383
|
+
if (!channel) {
|
|
384
|
+
this.logger.warn("Cannot process message -- channel unavailable");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.inflightCount++;
|
|
388
|
+
try {
|
|
389
|
+
const startTime = Date.now();
|
|
390
|
+
let attempts = 0;
|
|
391
|
+
const routingKey = message.fields.routingKey;
|
|
392
|
+
const exchange = message.fields.exchange;
|
|
393
|
+
let content;
|
|
394
|
+
try {
|
|
395
|
+
content = JSON.parse(message.content.toString());
|
|
396
|
+
} catch (parseError) {
|
|
397
|
+
const rawContent = message.content.toString();
|
|
398
|
+
const rawPreview = rawContent.substring(0, 100);
|
|
399
|
+
this.logger.error(
|
|
400
|
+
{
|
|
401
|
+
error: parseError,
|
|
402
|
+
exchange,
|
|
403
|
+
routingKey,
|
|
404
|
+
rawContentPreview: rawPreview + (rawContent.length > 100 ? "..." : "")
|
|
405
|
+
},
|
|
406
|
+
"Failed to parse message JSON, sending to DLQ"
|
|
407
|
+
);
|
|
408
|
+
channel.nack(message, false, false);
|
|
409
|
+
this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration: 0, reason: "invalid_json" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
this.logger.debug({ exchange, routingKey, payload: content }, "Message received, processing");
|
|
413
|
+
while (attempts < this.options.maxRetries) {
|
|
414
|
+
try {
|
|
415
|
+
await handler(content);
|
|
416
|
+
const duration2 = Date.now() - startTime;
|
|
417
|
+
this.logger.info(
|
|
418
|
+
{ exchange, routingKey, duration: duration2, attempts: attempts + 1 },
|
|
419
|
+
"Message processed successfully"
|
|
420
|
+
);
|
|
421
|
+
channel.ack(message);
|
|
422
|
+
this.callHook(this.hooks.onMessageProcessed, { exchange, routingKey, duration: duration2, attempts: attempts + 1 });
|
|
423
|
+
return;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
attempts++;
|
|
426
|
+
this.logger.error(
|
|
427
|
+
{
|
|
428
|
+
error: error instanceof Error ? error.message : error,
|
|
429
|
+
stack: error instanceof Error ? error.stack : void 0,
|
|
430
|
+
exchange,
|
|
431
|
+
routingKey,
|
|
432
|
+
attempt: attempts,
|
|
433
|
+
maxRetries: this.options.maxRetries
|
|
434
|
+
},
|
|
435
|
+
"Handler failed"
|
|
436
|
+
);
|
|
437
|
+
if (attempts < this.options.maxRetries) {
|
|
438
|
+
await this.sleep(this.options.retryDelay);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const duration = Date.now() - startTime;
|
|
443
|
+
this.logger.error(
|
|
444
|
+
{ exchange, routingKey, maxRetries: this.options.maxRetries, duration },
|
|
445
|
+
"Message failed after max retries, sending to DLQ"
|
|
446
|
+
);
|
|
447
|
+
channel.nack(message, false, false);
|
|
448
|
+
this.callHook(this.hooks.onMessageDlq, { exchange, routingKey, duration, reason: "max_retries_exhausted" });
|
|
449
|
+
} finally {
|
|
450
|
+
this.inflightCount--;
|
|
451
|
+
if (this.inflightCount === 0 && this.drainResolve) {
|
|
452
|
+
this.drainResolve();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Lightweight liveness probe: creates and immediately deletes a temporary
|
|
458
|
+
* exclusive queue. Returns `false` when disconnected or on broker error.
|
|
459
|
+
*/
|
|
460
|
+
async checkHealth() {
|
|
461
|
+
if (!this.connected || !this.channel) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const { queue } = await this.channel.assertQueue("", {
|
|
466
|
+
exclusive: true,
|
|
467
|
+
autoDelete: true
|
|
468
|
+
});
|
|
469
|
+
await this.channel.deleteQueue(queue);
|
|
470
|
+
return true;
|
|
471
|
+
} catch (error) {
|
|
472
|
+
this.logger.warn({ error }, "Health check failed");
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
waitForDrain(timeout) {
|
|
477
|
+
if (this.inflightCount === 0) return Promise.resolve();
|
|
478
|
+
return new Promise((resolve) => {
|
|
479
|
+
const timer = setTimeout(() => {
|
|
480
|
+
this.logger.warn(
|
|
481
|
+
{ inflightCount: this.inflightCount },
|
|
482
|
+
"Close timeout reached with messages still in flight"
|
|
483
|
+
);
|
|
484
|
+
this.drainResolve = null;
|
|
485
|
+
resolve();
|
|
486
|
+
}, timeout);
|
|
487
|
+
this.drainResolve = () => {
|
|
488
|
+
clearTimeout(timer);
|
|
489
|
+
this.drainResolve = null;
|
|
490
|
+
resolve();
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/** Invokes a hook callback, swallowing errors so hooks never break message flow. */
|
|
495
|
+
callHook(hook, info) {
|
|
496
|
+
try {
|
|
497
|
+
hook?.(info);
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
sleep(ms) {
|
|
502
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
506
|
+
0 && (module.exports = {
|
|
507
|
+
RabbitMQClient
|
|
508
|
+
});
|
|
509
|
+
//# sourceMappingURL=index.cjs.map
|