@relaybus/rabbitmq 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +236 -2
- package/dist/index.cjs +1113 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +281 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.js +1105 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@relaybus/core');
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var RabbitMqUnavailableError = class extends core.RelayBusError {
|
|
7
|
+
};
|
|
8
|
+
var RabbitMqPublishRejectedError = class extends core.RelayBusError {
|
|
9
|
+
};
|
|
10
|
+
var UnroutableMessageError = class extends core.RelayBusError {
|
|
11
|
+
};
|
|
12
|
+
var MalformedMessageError = class extends core.RelayBusError {
|
|
13
|
+
};
|
|
14
|
+
var UnserializablePayloadError = class extends core.NonRetryableError {
|
|
15
|
+
name = "UnserializablePayloadError";
|
|
16
|
+
};
|
|
17
|
+
var AmqpLimitExceededError = class extends core.NonRetryableError {
|
|
18
|
+
name = "AmqpLimitExceededError";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/abortable-sleep.ts
|
|
22
|
+
function abortableSleep(milliseconds, signal) {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const finish = () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
signal.removeEventListener("abort", finish);
|
|
30
|
+
resolve();
|
|
31
|
+
};
|
|
32
|
+
const timer = setTimeout(finish, milliseconds);
|
|
33
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/connection-supervisor.ts
|
|
38
|
+
var ConnectionSupervisor = class {
|
|
39
|
+
#settings;
|
|
40
|
+
#stop = new AbortController();
|
|
41
|
+
#current;
|
|
42
|
+
#ready = false;
|
|
43
|
+
#started = false;
|
|
44
|
+
#closed = false;
|
|
45
|
+
#reconnecting;
|
|
46
|
+
constructor(settings) {
|
|
47
|
+
this.#settings = settings;
|
|
48
|
+
}
|
|
49
|
+
/** True while a connection is open and `onConnected` has completed for it. */
|
|
50
|
+
isConnected() {
|
|
51
|
+
return this.#ready;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Connects once. When that fails it rejects if `failOnError`, otherwise it
|
|
55
|
+
* resolves and keeps trying in the background. Does nothing when already started.
|
|
56
|
+
*/
|
|
57
|
+
async start(failOnError) {
|
|
58
|
+
if (this.#started) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.#closed) {
|
|
62
|
+
throw new RabbitMqUnavailableError("[ConnectionSupervisor](start) Transport is closed");
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await this.#establish();
|
|
66
|
+
this.#started = true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (failOnError) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
this.#settings.logger.warn(
|
|
72
|
+
`[ConnectionSupervisor](start) Broker unreachable, retrying in the background | error=${core.toErrorInfo(error).message}`
|
|
73
|
+
);
|
|
74
|
+
this.#started = true;
|
|
75
|
+
this.#startReconnecting();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Drops the current connection because one of its channels failed, then
|
|
80
|
+
* reconnects. Closing the channel alone would leave topology half-restored.
|
|
81
|
+
*/
|
|
82
|
+
recover(reason) {
|
|
83
|
+
const connection = this.#current;
|
|
84
|
+
if (connection === void 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.#handleLoss(connection, reason);
|
|
88
|
+
connection.close().catch(() => void 0);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Stops reconnecting, runs `beforeDisconnect` while the connection is still
|
|
92
|
+
* open (to drain consumers) and closes the connection.
|
|
93
|
+
*/
|
|
94
|
+
async close(beforeDisconnect) {
|
|
95
|
+
this.#closed = true;
|
|
96
|
+
this.#stop.abort();
|
|
97
|
+
await this.#reconnecting;
|
|
98
|
+
try {
|
|
99
|
+
await beforeDisconnect();
|
|
100
|
+
} finally {
|
|
101
|
+
const connection = this.#current;
|
|
102
|
+
this.#current = void 0;
|
|
103
|
+
this.#ready = false;
|
|
104
|
+
await connection?.close().catch(() => void 0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async #establish() {
|
|
108
|
+
const connection = await this.#settings.connect();
|
|
109
|
+
connection.on("error", (error) => {
|
|
110
|
+
this.#settings.logger.warn(
|
|
111
|
+
`[ConnectionSupervisor](establish) Connection error | error=${error.message}`
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
connection.on("close", (error) => {
|
|
115
|
+
this.#handleLoss(connection, error?.message ?? "closed by peer");
|
|
116
|
+
});
|
|
117
|
+
this.#current = connection;
|
|
118
|
+
try {
|
|
119
|
+
await this.#settings.onConnected(connection);
|
|
120
|
+
if (this.#current !== connection) {
|
|
121
|
+
throw new RabbitMqUnavailableError(
|
|
122
|
+
"Connection was lost while the topology was being set up"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await this.#discard(connection);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
this.#ready = true;
|
|
130
|
+
}
|
|
131
|
+
async #discard(connection) {
|
|
132
|
+
if (this.#current === connection) {
|
|
133
|
+
this.#current = void 0;
|
|
134
|
+
}
|
|
135
|
+
this.#settings.onDisconnected();
|
|
136
|
+
await connection.close().catch(() => void 0);
|
|
137
|
+
}
|
|
138
|
+
#handleLoss(connection, reason) {
|
|
139
|
+
if (connection !== this.#current) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.#current = void 0;
|
|
143
|
+
this.#ready = false;
|
|
144
|
+
this.#settings.logger.warn(
|
|
145
|
+
`[ConnectionSupervisor](handleLoss) Connection lost | reason=${reason}`
|
|
146
|
+
);
|
|
147
|
+
this.#settings.onDisconnected();
|
|
148
|
+
this.#startReconnecting();
|
|
149
|
+
}
|
|
150
|
+
#startReconnecting() {
|
|
151
|
+
if (this.#settings.reconnect === void 0 || this.#closed || !this.#started || this.#reconnecting !== void 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const policy = this.#settings.reconnect;
|
|
155
|
+
this.#reconnecting = this.#reconnectUntilConnected(policy).finally(() => {
|
|
156
|
+
this.#reconnecting = void 0;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async #reconnectUntilConnected(policy) {
|
|
160
|
+
for (let attempt = 1; !this.#closed; attempt += 1) {
|
|
161
|
+
const delayMs = core.computeRetryDelay(policy, attempt, this.#settings.random);
|
|
162
|
+
this.#settings.logger.log(
|
|
163
|
+
`[ConnectionSupervisor](reconnect) Reconnecting | attempt=${attempt} delayMs=${delayMs}`
|
|
164
|
+
);
|
|
165
|
+
await abortableSleep(delayMs, this.#stop.signal);
|
|
166
|
+
if (this.#closed) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await this.#establish();
|
|
171
|
+
this.#settings.logger.log(
|
|
172
|
+
`[ConnectionSupervisor](reconnect) Reconnected | attempt=${attempt}`
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
this.#settings.logger.warn(
|
|
177
|
+
`[ConnectionSupervisor](reconnect) Attempt failed | attempt=${attempt} error=${core.toErrorInfo(error).message}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
var REDELIVERIES_HEADER = "x-relaybus-redeliveries";
|
|
184
|
+
var FIRST_FAILURE_HEADER = "x-relaybus-first-failure-at";
|
|
185
|
+
var DEAD_LETTER_REASON_HEADER = "x-relaybus-dead-letter-reason";
|
|
186
|
+
var RELAYBUS_STATE_HEADERS = [REDELIVERIES_HEADER, FIRST_FAILURE_HEADER];
|
|
187
|
+
var BROKER_COUNTER_HEADERS = ["x-delivery-count", "x-acquired-count"];
|
|
188
|
+
var MAXIMUM_LADDER_TIERS = 16;
|
|
189
|
+
function buildRetryLadder(policy) {
|
|
190
|
+
if (policy.initialDelayMs < 1) {
|
|
191
|
+
throw new core.ConfigurationError("redelivery.initialDelay must be at least 1ms");
|
|
192
|
+
}
|
|
193
|
+
const tiers = [];
|
|
194
|
+
let delay = policy.initialDelayMs;
|
|
195
|
+
for (; ; ) {
|
|
196
|
+
const tier = Math.round(Math.min(delay, policy.maxDelayMs));
|
|
197
|
+
tiers.push(tier);
|
|
198
|
+
if (tier >= policy.maxDelayMs || policy.factor === 1) {
|
|
199
|
+
return tiers;
|
|
200
|
+
}
|
|
201
|
+
if (tiers.length === MAXIMUM_LADDER_TIERS) {
|
|
202
|
+
throw new core.ConfigurationError(
|
|
203
|
+
`redelivery needs more than ${MAXIMUM_LADDER_TIERS} retry queues; raise redelivery.factor or lower redelivery.maxDelay`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
delay *= policy.factor;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function chooseRetryTier(ladder, delayMs, random) {
|
|
210
|
+
const upperIndex = ladder.findIndex((tier) => tier >= delayMs);
|
|
211
|
+
if (upperIndex === -1) {
|
|
212
|
+
return ladder.at(-1);
|
|
213
|
+
}
|
|
214
|
+
if (upperIndex === 0) {
|
|
215
|
+
return ladder[0];
|
|
216
|
+
}
|
|
217
|
+
const lower = ladder[upperIndex - 1];
|
|
218
|
+
const upper = ladder[upperIndex];
|
|
219
|
+
return random() < (delayMs - lower) / (upper - lower) ? upper : lower;
|
|
220
|
+
}
|
|
221
|
+
function readCounterHeader(message, name) {
|
|
222
|
+
const value = message.properties.headers[name];
|
|
223
|
+
const parsed = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
|
|
224
|
+
return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
|
|
225
|
+
}
|
|
226
|
+
function copyForRepublish(message, headerChanges) {
|
|
227
|
+
const { properties } = message;
|
|
228
|
+
const headers = { ...properties.headers };
|
|
229
|
+
for (const name of [
|
|
230
|
+
...RELAYBUS_STATE_HEADERS,
|
|
231
|
+
DEAD_LETTER_REASON_HEADER,
|
|
232
|
+
...BROKER_COUNTER_HEADERS
|
|
233
|
+
]) {
|
|
234
|
+
delete headers[name];
|
|
235
|
+
}
|
|
236
|
+
Object.assign(headers, headerChanges);
|
|
237
|
+
return {
|
|
238
|
+
content: message.content,
|
|
239
|
+
options: {
|
|
240
|
+
contentType: typeof properties.contentType === "string" ? properties.contentType : "application/json",
|
|
241
|
+
persistent: true,
|
|
242
|
+
...typeof properties.messageId === "string" ? { messageId: properties.messageId } : {},
|
|
243
|
+
...typeof properties.type === "string" ? { type: properties.type } : {},
|
|
244
|
+
...typeof properties.appId === "string" ? { appId: properties.appId } : {},
|
|
245
|
+
...typeof properties.correlationId === "string" ? { correlationId: properties.correlationId } : {},
|
|
246
|
+
...typeof properties.timestamp === "number" ? { timestamp: properties.timestamp } : {},
|
|
247
|
+
headers
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/options.ts
|
|
253
|
+
var DEFAULT_HANDLER_RETRY = {
|
|
254
|
+
attempts: 3,
|
|
255
|
+
initialDelayMs: 200,
|
|
256
|
+
factor: 2,
|
|
257
|
+
maxDelayMs: 5e3,
|
|
258
|
+
jitter: "equal",
|
|
259
|
+
retryIf: void 0
|
|
260
|
+
};
|
|
261
|
+
var DEFAULT_REDELIVERY = {
|
|
262
|
+
attempts: Number.POSITIVE_INFINITY,
|
|
263
|
+
initialDelayMs: 2e3,
|
|
264
|
+
factor: 2,
|
|
265
|
+
maxDelayMs: 3e5,
|
|
266
|
+
jitter: "equal",
|
|
267
|
+
retryIf: void 0
|
|
268
|
+
};
|
|
269
|
+
var DEFAULT_REDELIVERY_MAX_AGE = "1h";
|
|
270
|
+
var DEFAULT_RECONNECT = {
|
|
271
|
+
attempts: 1,
|
|
272
|
+
initialDelayMs: 500,
|
|
273
|
+
factor: 2,
|
|
274
|
+
maxDelayMs: 3e4,
|
|
275
|
+
jitter: "equal",
|
|
276
|
+
retryIf: void 0
|
|
277
|
+
};
|
|
278
|
+
function resolveSettings(options) {
|
|
279
|
+
const exchange = nonEmpty(options.exchange ?? "relaybus", "exchange");
|
|
280
|
+
const failOnConnectError = options.failOnConnectError ?? true;
|
|
281
|
+
if (!failOnConnectError && options.reconnect === false) {
|
|
282
|
+
throw new core.ConfigurationError(
|
|
283
|
+
"failOnConnectError=false needs reconnect enabled, otherwise the transport would never connect"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const ordered = options.ordered ?? false;
|
|
287
|
+
const prefetch = positiveInteger(options.prefetch ?? (ordered ? 1 : 50), "prefetch");
|
|
288
|
+
if (ordered && prefetch !== 1) {
|
|
289
|
+
throw new core.ConfigurationError(`ordered consumption needs prefetch 1, received ${prefetch}`);
|
|
290
|
+
}
|
|
291
|
+
const redelivery = core.createRetryPolicy(options.redelivery, DEFAULT_REDELIVERY);
|
|
292
|
+
return {
|
|
293
|
+
name: nonEmpty(options.name ?? "rabbitmq", "name"),
|
|
294
|
+
connect: resolveConnect(options),
|
|
295
|
+
exchange,
|
|
296
|
+
prefetch,
|
|
297
|
+
queuePrefix: nonEmpty(options.queuePrefix ?? "relaybus", "queuePrefix"),
|
|
298
|
+
consumerGroup: optionalNonEmpty(options.consumerGroup, "consumerGroup"),
|
|
299
|
+
queue: optionalNonEmpty(options.queue, "queue"),
|
|
300
|
+
deadLetterExchange: nonEmpty(
|
|
301
|
+
options.deadLetterExchange ?? `${exchange}.dead`,
|
|
302
|
+
"deadLetterExchange"
|
|
303
|
+
),
|
|
304
|
+
deadLetterQueue: optionalNonEmpty(options.deadLetterQueue, "deadLetterQueue"),
|
|
305
|
+
deliveryLimit: positiveInteger(options.deliveryLimit ?? 10, "deliveryLimit"),
|
|
306
|
+
handlerRetry: core.createRetryPolicy(options.handlerRetry, DEFAULT_HANDLER_RETRY),
|
|
307
|
+
redelivery,
|
|
308
|
+
retryLadder: ordered ? [] : buildRetryLadder(redelivery),
|
|
309
|
+
redeliveryMaxAgeMs: positiveDuration(
|
|
310
|
+
options.redelivery?.maxAge ?? DEFAULT_REDELIVERY_MAX_AGE,
|
|
311
|
+
"redelivery.maxAge"
|
|
312
|
+
),
|
|
313
|
+
ordered,
|
|
314
|
+
reconnect: resolveReconnect(options.reconnect),
|
|
315
|
+
failOnConnectError,
|
|
316
|
+
allowUnroutable: options.allowUnroutable ?? false,
|
|
317
|
+
publishTimeoutMs: positiveDuration(options.publishTimeout ?? "30s", "publishTimeout"),
|
|
318
|
+
shutdownTimeoutMs: core.toMilliseconds(options.shutdownTimeout ?? "30s", "shutdownTimeout"),
|
|
319
|
+
logger: options.logger ?? console,
|
|
320
|
+
random: options.random ?? Math.random
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function resolveQueueName(settings) {
|
|
324
|
+
if (settings.queue !== void 0) {
|
|
325
|
+
return settings.queue;
|
|
326
|
+
}
|
|
327
|
+
if (settings.consumerGroup === void 0) {
|
|
328
|
+
throw new core.ConfigurationError(
|
|
329
|
+
`Transport '${settings.name}' needs 'consumerGroup' or 'queue' to subscribe`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return `${settings.queuePrefix}.${settings.consumerGroup}`;
|
|
333
|
+
}
|
|
334
|
+
function resolveConnect(options) {
|
|
335
|
+
const { url, connect } = options;
|
|
336
|
+
if (url === void 0 === (connect === void 0)) {
|
|
337
|
+
throw new core.ConfigurationError("Give exactly one of 'url' and 'connect'");
|
|
338
|
+
}
|
|
339
|
+
if (connect !== void 0) {
|
|
340
|
+
return connect;
|
|
341
|
+
}
|
|
342
|
+
return async () => {
|
|
343
|
+
const amqplib = await import('amqplib');
|
|
344
|
+
return amqplib.connect(url);
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function resolveReconnect(reconnect) {
|
|
348
|
+
if (reconnect === false) {
|
|
349
|
+
return void 0;
|
|
350
|
+
}
|
|
351
|
+
return core.createRetryPolicy({ ...reconnect, attempts: 1 }, DEFAULT_RECONNECT);
|
|
352
|
+
}
|
|
353
|
+
function nonEmpty(value, name) {
|
|
354
|
+
if (value.length === 0) {
|
|
355
|
+
throw new core.ConfigurationError(`${name} must not be empty`);
|
|
356
|
+
}
|
|
357
|
+
return value;
|
|
358
|
+
}
|
|
359
|
+
function optionalNonEmpty(value, name) {
|
|
360
|
+
return value === void 0 ? void 0 : nonEmpty(value, name);
|
|
361
|
+
}
|
|
362
|
+
function positiveInteger(value, name) {
|
|
363
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
364
|
+
throw new core.ConfigurationError(`${name} must be a positive integer, received ${value}`);
|
|
365
|
+
}
|
|
366
|
+
return value;
|
|
367
|
+
}
|
|
368
|
+
function positiveDuration(value, name) {
|
|
369
|
+
const milliseconds = core.toMilliseconds(value, name);
|
|
370
|
+
if (milliseconds === 0) {
|
|
371
|
+
throw new core.ConfigurationError(`${name} must be greater than zero`);
|
|
372
|
+
}
|
|
373
|
+
return milliseconds;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/topology.ts
|
|
377
|
+
function retryQueueName(queue, delayMs) {
|
|
378
|
+
return `${queue}.retry.${delayMs}`;
|
|
379
|
+
}
|
|
380
|
+
async function assertExchange(channel, exchange) {
|
|
381
|
+
await channel.assertExchange(exchange, "topic", { durable: true });
|
|
382
|
+
}
|
|
383
|
+
async function assertConsumerTopology(channel, topology) {
|
|
384
|
+
await assertExchange(channel, topology.exchange);
|
|
385
|
+
await channel.assertExchange(topology.deadLetterExchange, "direct", { durable: true });
|
|
386
|
+
await channel.assertQueue(topology.deadLetterQueue, {
|
|
387
|
+
durable: true,
|
|
388
|
+
arguments: { "x-queue-type": "quorum" }
|
|
389
|
+
});
|
|
390
|
+
await channel.bindQueue(topology.deadLetterQueue, topology.deadLetterExchange, topology.queue);
|
|
391
|
+
for (const delayMs of topology.retryDelaysMs) {
|
|
392
|
+
await channel.assertQueue(retryQueueName(topology.queue, delayMs), {
|
|
393
|
+
durable: true,
|
|
394
|
+
arguments: {
|
|
395
|
+
"x-queue-type": "quorum",
|
|
396
|
+
"x-message-ttl": delayMs,
|
|
397
|
+
"x-dead-letter-exchange": "",
|
|
398
|
+
"x-dead-letter-routing-key": topology.queue,
|
|
399
|
+
"x-dead-letter-strategy": "at-least-once",
|
|
400
|
+
"x-overflow": "reject-publish"
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
await channel.assertQueue(topology.queue, {
|
|
405
|
+
durable: true,
|
|
406
|
+
arguments: {
|
|
407
|
+
"x-queue-type": "quorum",
|
|
408
|
+
"x-delivery-limit": topology.deliveryLimit,
|
|
409
|
+
"x-dead-letter-exchange": topology.deadLetterExchange,
|
|
410
|
+
"x-dead-letter-routing-key": topology.queue,
|
|
411
|
+
"x-dead-letter-strategy": "at-least-once",
|
|
412
|
+
"x-overflow": "reject-publish",
|
|
413
|
+
...topology.ordered ? { "x-single-active-consumer": true } : {}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
for (const destination of topology.destinations) {
|
|
417
|
+
await channel.bindQueue(topology.queue, topology.exchange, destination);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/json-payload.ts
|
|
422
|
+
function assertJsonPayload(payload, eventId) {
|
|
423
|
+
visit(payload, "$", /* @__PURE__ */ new Set(), eventId);
|
|
424
|
+
}
|
|
425
|
+
function visit(value, path, ancestors, eventId) {
|
|
426
|
+
switch (typeof value) {
|
|
427
|
+
case "string":
|
|
428
|
+
case "boolean":
|
|
429
|
+
return;
|
|
430
|
+
case "number":
|
|
431
|
+
if (!Number.isFinite(value)) {
|
|
432
|
+
throw refuse(eventId, path, `is ${value}, which JSON turns into null`);
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
case "object":
|
|
436
|
+
if (value !== null) {
|
|
437
|
+
visitObject(value, path, ancestors, eventId);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
default:
|
|
441
|
+
throw refuse(eventId, path, `is ${typeof value}, which JSON cannot represent`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function visitObject(value, path, ancestors, eventId) {
|
|
445
|
+
if (ancestors.has(value)) {
|
|
446
|
+
throw refuse(eventId, path, "is a circular reference");
|
|
447
|
+
}
|
|
448
|
+
ancestors.add(value);
|
|
449
|
+
if (Array.isArray(value)) {
|
|
450
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
451
|
+
if (!(index in value)) {
|
|
452
|
+
throw refuse(eventId, `${path}[${index}]`, "is an array hole, which JSON turns into null");
|
|
453
|
+
}
|
|
454
|
+
visit(value[index], `${path}[${index}]`, ancestors, eventId);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
const prototype = Object.getPrototypeOf(value);
|
|
458
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
459
|
+
throw refuse(eventId, path, "is not a plain object (class instance, Date, Map or Set)");
|
|
460
|
+
}
|
|
461
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
462
|
+
throw refuse(eventId, path, "has a symbol key, which JSON drops");
|
|
463
|
+
}
|
|
464
|
+
for (const key of Object.keys(value)) {
|
|
465
|
+
visit(value[key], `${path}.${key}`, ancestors, eventId);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
ancestors.delete(value);
|
|
469
|
+
}
|
|
470
|
+
function refuse(eventId, path, reason) {
|
|
471
|
+
return new UnserializablePayloadError(
|
|
472
|
+
`Event ${eventId} has a payload JSON cannot represent faithfully: ${path} ${reason}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/wire-format.ts
|
|
477
|
+
var ATTRIBUTE_PREFIX = "cloudEvents:";
|
|
478
|
+
var EVENT_HEADER_PREFIX = "relaybus:";
|
|
479
|
+
var SPEC_VERSION = "1.0";
|
|
480
|
+
var JSON_CONTENT_TYPE = "application/json";
|
|
481
|
+
var SHORT_STRING_LIMIT_BYTES = 255;
|
|
482
|
+
var RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
483
|
+
function encodeEnvelope(envelope) {
|
|
484
|
+
assertJsonPayload(envelope.payload, envelope.id);
|
|
485
|
+
assertShortString("Event id", envelope.id);
|
|
486
|
+
const headers = {
|
|
487
|
+
[`${ATTRIBUTE_PREFIX}specversion`]: SPEC_VERSION,
|
|
488
|
+
[`${ATTRIBUTE_PREFIX}id`]: envelope.id,
|
|
489
|
+
[`${ATTRIBUTE_PREFIX}type`]: envelope.type,
|
|
490
|
+
[`${ATTRIBUTE_PREFIX}source`]: envelope.source,
|
|
491
|
+
[`${ATTRIBUTE_PREFIX}time`]: envelope.occurredAt.toISOString(),
|
|
492
|
+
[`${ATTRIBUTE_PREFIX}datacontenttype`]: JSON_CONTENT_TYPE
|
|
493
|
+
};
|
|
494
|
+
if (envelope.key !== null) {
|
|
495
|
+
headers[`${ATTRIBUTE_PREFIX}subject`] = envelope.key;
|
|
496
|
+
}
|
|
497
|
+
if (envelope.causationId !== null) {
|
|
498
|
+
headers[`${ATTRIBUTE_PREFIX}causationid`] = envelope.causationId;
|
|
499
|
+
}
|
|
500
|
+
if (envelope.correlationId !== null) {
|
|
501
|
+
headers[`${ATTRIBUTE_PREFIX}correlationid`] = envelope.correlationId;
|
|
502
|
+
}
|
|
503
|
+
for (const [name, value] of Object.entries(envelope.headers)) {
|
|
504
|
+
assertShortString(
|
|
505
|
+
`Header name '${EVENT_HEADER_PREFIX}${name}'`,
|
|
506
|
+
`${EVENT_HEADER_PREFIX}${name}`
|
|
507
|
+
);
|
|
508
|
+
headers[`${EVENT_HEADER_PREFIX}${name}`] = value;
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
content: Buffer.from(JSON.stringify(envelope.payload), "utf8"),
|
|
512
|
+
options: {
|
|
513
|
+
contentType: JSON_CONTENT_TYPE,
|
|
514
|
+
persistent: true,
|
|
515
|
+
messageId: envelope.id,
|
|
516
|
+
timestamp: Math.floor(envelope.occurredAt.getTime() / 1e3),
|
|
517
|
+
...mirror("type", envelope.type),
|
|
518
|
+
...mirror("appId", envelope.source),
|
|
519
|
+
...envelope.correlationId === null ? {} : mirror("correlationId", envelope.correlationId),
|
|
520
|
+
headers
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function assertShortString(label, value) {
|
|
525
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
526
|
+
if (bytes > SHORT_STRING_LIMIT_BYTES) {
|
|
527
|
+
throw new AmqpLimitExceededError(
|
|
528
|
+
`${label} is ${bytes} bytes, above the AMQP limit of ${SHORT_STRING_LIMIT_BYTES} bytes`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function mirror(name, value) {
|
|
533
|
+
return Buffer.byteLength(value, "utf8") > SHORT_STRING_LIMIT_BYTES ? {} : { [name]: value };
|
|
534
|
+
}
|
|
535
|
+
function decodeEnvelope(message) {
|
|
536
|
+
const { properties } = message;
|
|
537
|
+
const attributes = properties.headers ?? {};
|
|
538
|
+
if (attributes[`${ATTRIBUTE_PREFIX}specversion`] !== SPEC_VERSION) {
|
|
539
|
+
throw new MalformedMessageError(
|
|
540
|
+
`Message is not a CloudEvents ${SPEC_VERSION} event (header ${ATTRIBUTE_PREFIX}specversion)`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
id: required("id", text(attributes[`${ATTRIBUTE_PREFIX}id`]) ?? text(properties.messageId)),
|
|
545
|
+
type: required("type", text(attributes[`${ATTRIBUTE_PREFIX}type`]) ?? text(properties.type)),
|
|
546
|
+
source: required(
|
|
547
|
+
"source",
|
|
548
|
+
text(attributes[`${ATTRIBUTE_PREFIX}source`]) ?? text(properties.appId)
|
|
549
|
+
),
|
|
550
|
+
key: text(attributes[`${ATTRIBUTE_PREFIX}subject`]) ?? null,
|
|
551
|
+
payload: parsePayload(message.content),
|
|
552
|
+
headers: extractEventHeaders(attributes),
|
|
553
|
+
correlationId: text(attributes[`${ATTRIBUTE_PREFIX}correlationid`]) ?? text(properties.correlationId) ?? null,
|
|
554
|
+
causationId: text(attributes[`${ATTRIBUTE_PREFIX}causationid`]) ?? null,
|
|
555
|
+
occurredAt: parseTime(attributes[`${ATTRIBUTE_PREFIX}time`], properties.timestamp)
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function extractEventHeaders(attributes) {
|
|
559
|
+
const headers = {};
|
|
560
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
561
|
+
if (name.startsWith(EVENT_HEADER_PREFIX) && typeof value === "string") {
|
|
562
|
+
Object.defineProperty(headers, name.slice(EVENT_HEADER_PREFIX.length), {
|
|
563
|
+
value,
|
|
564
|
+
enumerable: true,
|
|
565
|
+
writable: true,
|
|
566
|
+
configurable: true
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return headers;
|
|
571
|
+
}
|
|
572
|
+
function parsePayload(content) {
|
|
573
|
+
try {
|
|
574
|
+
return JSON.parse(content.toString("utf8"));
|
|
575
|
+
} catch {
|
|
576
|
+
throw new MalformedMessageError("Message body is not valid JSON");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function parseTime(rawTime, timestampSeconds) {
|
|
580
|
+
if (rawTime === void 0) {
|
|
581
|
+
const fallback = typeof timestampSeconds === "number" ? new Date(timestampSeconds * 1e3) : void 0;
|
|
582
|
+
if (fallback === void 0 || Number.isNaN(fallback.getTime())) {
|
|
583
|
+
throw new MalformedMessageError("Message has no valid time (header cloudEvents:time)");
|
|
584
|
+
}
|
|
585
|
+
return fallback;
|
|
586
|
+
}
|
|
587
|
+
const occurredAt = typeof rawTime === "string" && RFC3339_PATTERN.test(rawTime) ? new Date(rawTime) : void 0;
|
|
588
|
+
if (occurredAt === void 0 || Number.isNaN(occurredAt.getTime())) {
|
|
589
|
+
throw new MalformedMessageError(
|
|
590
|
+
"Message time (header cloudEvents:time) is not an RFC 3339 timestamp"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return occurredAt;
|
|
594
|
+
}
|
|
595
|
+
function text(value) {
|
|
596
|
+
return typeof value === "string" ? value : void 0;
|
|
597
|
+
}
|
|
598
|
+
function required(attribute, value) {
|
|
599
|
+
if (value === void 0 || value.length === 0) {
|
|
600
|
+
throw new MalformedMessageError(
|
|
601
|
+
`Message has no ${attribute} (header ${ATTRIBUTE_PREFIX}${attribute})`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return value;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/queue-consumer.ts
|
|
608
|
+
var QueueConsumer = class {
|
|
609
|
+
#settings;
|
|
610
|
+
#inFlight = /* @__PURE__ */ new Set();
|
|
611
|
+
#stopping = new AbortController();
|
|
612
|
+
#channel;
|
|
613
|
+
#consumerTag;
|
|
614
|
+
constructor(settings) {
|
|
615
|
+
this.#settings = settings;
|
|
616
|
+
}
|
|
617
|
+
/** True while a consumer is registered on a live channel. */
|
|
618
|
+
isActive() {
|
|
619
|
+
return this.#consumerTag !== void 0;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Starts consuming on a channel whose topology is already declared.
|
|
623
|
+
*
|
|
624
|
+
* @throws {RabbitMqUnavailableError} the channel closed while it was being set up
|
|
625
|
+
*/
|
|
626
|
+
async attach(channel) {
|
|
627
|
+
let closedDuringSetup = false;
|
|
628
|
+
channel.on("error", (error) => {
|
|
629
|
+
this.#settings.logger.warn(
|
|
630
|
+
`[QueueConsumer](attach) Channel error | queue=${this.#settings.queue} error=${error.message}`
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
channel.on("close", () => {
|
|
634
|
+
closedDuringSetup = true;
|
|
635
|
+
if (this.#channel === channel) {
|
|
636
|
+
this.#channel = void 0;
|
|
637
|
+
this.#consumerTag = void 0;
|
|
638
|
+
this.#settings.onConsumerLost("consumer channel closed");
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
await channel.prefetch(this.#settings.prefetch);
|
|
642
|
+
const { consumerTag } = await channel.consume(
|
|
643
|
+
this.#settings.queue,
|
|
644
|
+
(message) => this.#receive(channel, message),
|
|
645
|
+
{ noAck: false }
|
|
646
|
+
);
|
|
647
|
+
if (closedDuringSetup) {
|
|
648
|
+
throw new RabbitMqUnavailableError(
|
|
649
|
+
`[QueueConsumer](attach) Consumer channel closed while the consumer was being set up | queue=${this.#settings.queue}`
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
this.#channel = channel;
|
|
653
|
+
this.#consumerTag = consumerTag;
|
|
654
|
+
}
|
|
655
|
+
/** Forgets the channel without closing it, because its connection is gone. */
|
|
656
|
+
detach() {
|
|
657
|
+
this.#channel = void 0;
|
|
658
|
+
this.#consumerTag = void 0;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Stops taking messages, aborts pending handler backoff (those messages are
|
|
662
|
+
* requeued), waits up to the shutdown timeout for running handlers, then
|
|
663
|
+
* closes the channel. Unacknowledged messages return to the queue.
|
|
664
|
+
*/
|
|
665
|
+
async stop() {
|
|
666
|
+
this.#stopping.abort();
|
|
667
|
+
const channel = this.#channel;
|
|
668
|
+
const consumerTag = this.#consumerTag;
|
|
669
|
+
this.#channel = void 0;
|
|
670
|
+
this.#consumerTag = void 0;
|
|
671
|
+
if (channel !== void 0 && consumerTag !== void 0) {
|
|
672
|
+
await channel.cancel(consumerTag).catch((error) => {
|
|
673
|
+
this.#settings.logger.warn(
|
|
674
|
+
`[QueueConsumer](stop) Could not cancel consumer | queue=${this.#settings.queue} error=${core.toErrorInfo(error).message}`
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
await this.#awaitInFlight();
|
|
679
|
+
await channel?.close().catch(() => void 0);
|
|
680
|
+
}
|
|
681
|
+
async #awaitInFlight() {
|
|
682
|
+
if (this.#inFlight.size === 0) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const timeout = new AbortController();
|
|
686
|
+
const timedOut = abortableSleep(this.#settings.shutdownTimeoutMs, timeout.signal);
|
|
687
|
+
await Promise.race([Promise.allSettled([...this.#inFlight]), timedOut]);
|
|
688
|
+
timeout.abort();
|
|
689
|
+
}
|
|
690
|
+
#receive(channel, message) {
|
|
691
|
+
if (message === null) {
|
|
692
|
+
this.#consumerTag = void 0;
|
|
693
|
+
this.#settings.onConsumerLost("consumer cancelled by the broker");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const task = this.#process(channel, message).finally(() => {
|
|
697
|
+
this.#inFlight.delete(task);
|
|
698
|
+
});
|
|
699
|
+
this.#inFlight.add(task);
|
|
700
|
+
}
|
|
701
|
+
async #process(channel, message) {
|
|
702
|
+
const { queue, handlerRetry } = this.#settings;
|
|
703
|
+
let envelope;
|
|
704
|
+
try {
|
|
705
|
+
envelope = decodeEnvelope(message);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
this.#settings.logger.error(
|
|
708
|
+
`[QueueConsumer](process) Dead-lettering malformed message | queue=${queue} routingKey=${message.fields.routingKey} error=${core.toErrorInfo(error).message}`
|
|
709
|
+
);
|
|
710
|
+
await this.#deadLetter(channel, message, "malformed message");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
let redeliveries = readCounterHeader(message, REDELIVERIES_HEADER) ?? 0;
|
|
714
|
+
let firstFailureAt = readCounterHeader(message, FIRST_FAILURE_HEADER);
|
|
715
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
716
|
+
try {
|
|
717
|
+
await this.#settings.onMessage(envelope);
|
|
718
|
+
this.#settle(channel, message, "ack");
|
|
719
|
+
return;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
const failure = `queue=${queue} eventId=${envelope.id} attempt=${attempt} error=${core.toErrorInfo(error).message}`;
|
|
722
|
+
if (!core.isRetryableError(handlerRetry, error)) {
|
|
723
|
+
this.#settings.logger.error(
|
|
724
|
+
`[QueueConsumer](process) Dead-lettering message the handler cannot process | ${failure}`
|
|
725
|
+
);
|
|
726
|
+
await this.#deadLetter(channel, message, "non-retryable handler error");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (attempt < handlerRetry.attempts) {
|
|
730
|
+
const delayMs2 = core.computeRetryDelay(handlerRetry, attempt, this.#settings.random);
|
|
731
|
+
this.#settings.logger.warn(
|
|
732
|
+
`[QueueConsumer](process) Handler failed, retrying | ${failure} delayMs=${delayMs2}`
|
|
733
|
+
);
|
|
734
|
+
if (await this.#interrupted(delayMs2)) {
|
|
735
|
+
this.#settle(channel, message, "requeue");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
redeliveries += 1;
|
|
741
|
+
const now = Date.now();
|
|
742
|
+
firstFailureAt ??= now;
|
|
743
|
+
const delayMs = this.#redeliveryDelay(redeliveries);
|
|
744
|
+
if (now + delayMs - firstFailureAt > this.#settings.redeliveryMaxAgeMs) {
|
|
745
|
+
this.#settings.logger.error(
|
|
746
|
+
`[QueueConsumer](process) Dead-lettering message that kept failing | ${failure} redeliveries=${redeliveries - 1} failingForMs=${now - firstFailureAt} maxAgeMs=${this.#settings.redeliveryMaxAgeMs}`
|
|
747
|
+
);
|
|
748
|
+
await this.#deadLetter(channel, message, "redelivery window exhausted");
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
this.#settings.logger.warn(
|
|
752
|
+
`[QueueConsumer](process) Handler attempts spent, retrying later | ${failure} redeliveries=${redeliveries} delayMs=${delayMs}`
|
|
753
|
+
);
|
|
754
|
+
if (!this.#settings.ordered) {
|
|
755
|
+
await this.#redeliverLater(channel, message, delayMs, redeliveries, firstFailureAt);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (await this.#interrupted(delayMs)) {
|
|
759
|
+
this.#settle(channel, message, "requeue");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** The backoff before the next delayed redelivery: a retry queue's TTL, or an exact wait when ordered. */
|
|
766
|
+
#redeliveryDelay(redeliveries) {
|
|
767
|
+
const { redelivery, retryLadder, ordered, random } = this.#settings;
|
|
768
|
+
const delayMs = core.computeRetryDelay(redelivery, redeliveries, random);
|
|
769
|
+
return ordered ? delayMs : chooseRetryTier(retryLadder, delayMs, random);
|
|
770
|
+
}
|
|
771
|
+
/** Sleeps; true when the consumer is stopping, so the caller must give the message back. */
|
|
772
|
+
async #interrupted(delayMs) {
|
|
773
|
+
await abortableSleep(delayMs, this.#stopping.signal);
|
|
774
|
+
return this.#stopping.signal.aborted;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Parks a copy in the retry queue of `delayMs` and acknowledges the original
|
|
778
|
+
* only once the broker confirmed the copy. When the copy cannot be confirmed
|
|
779
|
+
* the original is requeued after the same delay, so it is neither lost nor
|
|
780
|
+
* hot-looped.
|
|
781
|
+
*/
|
|
782
|
+
async #redeliverLater(channel, message, delayMs, redeliveries, firstFailureAt) {
|
|
783
|
+
const { content, options } = copyForRepublish(message, {
|
|
784
|
+
[REDELIVERIES_HEADER]: String(redeliveries),
|
|
785
|
+
[FIRST_FAILURE_HEADER]: String(firstFailureAt)
|
|
786
|
+
});
|
|
787
|
+
try {
|
|
788
|
+
await this.#settings.republish(
|
|
789
|
+
"",
|
|
790
|
+
retryQueueName(this.#settings.queue, delayMs),
|
|
791
|
+
content,
|
|
792
|
+
options
|
|
793
|
+
);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
this.#settings.logger.warn(
|
|
796
|
+
`[QueueConsumer](redeliverLater) Could not park the message in the retry queue, requeueing it | queue=${this.#settings.queue} delayMs=${delayMs} error=${core.toErrorInfo(error).message}`
|
|
797
|
+
);
|
|
798
|
+
await this.#interrupted(delayMs);
|
|
799
|
+
this.#settle(channel, message, "requeue");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
this.#settle(channel, message, "ack");
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Moves the message to the dead-letter queue through a confirmed copy that
|
|
806
|
+
* carries the reason and no retry state. If the copy cannot be confirmed the
|
|
807
|
+
* message is rejected without requeue, which makes the broker dead-letter it
|
|
808
|
+
* (at-least-once), so it still ends up in the dead-letter queue.
|
|
809
|
+
*/
|
|
810
|
+
async #deadLetter(channel, message, reason) {
|
|
811
|
+
const { content, options } = copyForRepublish(message, {
|
|
812
|
+
[DEAD_LETTER_REASON_HEADER]: reason
|
|
813
|
+
});
|
|
814
|
+
try {
|
|
815
|
+
await this.#settings.republish(
|
|
816
|
+
this.#settings.deadLetterExchange,
|
|
817
|
+
this.#settings.queue,
|
|
818
|
+
content,
|
|
819
|
+
options
|
|
820
|
+
);
|
|
821
|
+
} catch (error) {
|
|
822
|
+
this.#settings.logger.warn(
|
|
823
|
+
`[QueueConsumer](deadLetter) Could not publish to the dead-letter exchange, leaving it to the broker | queue=${this.#settings.queue} error=${core.toErrorInfo(error).message}`
|
|
824
|
+
);
|
|
825
|
+
this.#settle(channel, message, "dead-letter");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
this.#settle(channel, message, "ack");
|
|
829
|
+
}
|
|
830
|
+
#settle(channel, message, settlement) {
|
|
831
|
+
try {
|
|
832
|
+
if (settlement === "ack") {
|
|
833
|
+
channel.ack(message);
|
|
834
|
+
} else {
|
|
835
|
+
channel.nack(message, false, settlement === "requeue");
|
|
836
|
+
}
|
|
837
|
+
} catch (error) {
|
|
838
|
+
this.#settings.logger.warn(
|
|
839
|
+
`[QueueConsumer](settle) Could not ${settlement} message, the broker will redeliver it | queue=${this.#settings.queue} error=${core.toErrorInfo(error).message}`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// src/serial-queue.ts
|
|
846
|
+
var SerialQueue = class {
|
|
847
|
+
#tail = Promise.resolve();
|
|
848
|
+
run(task) {
|
|
849
|
+
const result = this.#tail.then(task);
|
|
850
|
+
this.#tail = result.catch(() => void 0);
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// src/rabbitmq-transport.ts
|
|
856
|
+
var NACK_MESSAGE = "message nacked";
|
|
857
|
+
var RabbitMqTransport = class {
|
|
858
|
+
name;
|
|
859
|
+
#settings;
|
|
860
|
+
#supervisor;
|
|
861
|
+
/** Serializes topology work so a subscription never races a reconnect. */
|
|
862
|
+
#topologyWork = new SerialQueue();
|
|
863
|
+
#live;
|
|
864
|
+
#subscription;
|
|
865
|
+
#closed = false;
|
|
866
|
+
constructor(options) {
|
|
867
|
+
this.#settings = resolveSettings(options);
|
|
868
|
+
this.name = this.#settings.name;
|
|
869
|
+
this.#supervisor = new ConnectionSupervisor({
|
|
870
|
+
connect: this.#settings.connect,
|
|
871
|
+
reconnect: this.#settings.reconnect,
|
|
872
|
+
onConnected: (connection) => this.#establishChannels(connection),
|
|
873
|
+
onDisconnected: () => this.#forgetConnection(),
|
|
874
|
+
logger: this.#settings.logger,
|
|
875
|
+
random: this.#settings.random
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* True while the connection is open and publishing and consuming are set up.
|
|
880
|
+
* Use it for readiness probes.
|
|
881
|
+
*/
|
|
882
|
+
isConnected() {
|
|
883
|
+
return this.#supervisor.isConnected();
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* True when the transport can publish and, once subscribed, is consuming: the
|
|
887
|
+
* connection is up, its channels are open and the consumer is registered. False while reconnecting and after a cancelled consumer.
|
|
888
|
+
*/
|
|
889
|
+
isReady() {
|
|
890
|
+
return this.#supervisor.isConnected() && this.#live !== void 0 && (this.#subscription === void 0 || this.#subscription.consumer.isActive());
|
|
891
|
+
}
|
|
892
|
+
/** Connects to the broker. Rejects when it is unreachable unless `failOnConnectError` is false. */
|
|
893
|
+
async connect() {
|
|
894
|
+
await this.#supervisor.start(this.#settings.failOnConnectError);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Publishes to the exchange with `destination` as routing key and resolves
|
|
898
|
+
* after the broker confirmed the message.
|
|
899
|
+
*
|
|
900
|
+
* @throws {UnserializablePayloadError} the payload is not representable as JSON (never retried)
|
|
901
|
+
* @throws {AmqpLimitExceededError} the event id, a header name or `destination` exceeds 255 bytes (never retried)
|
|
902
|
+
* @throws {RabbitMqUnavailableError} not connected, channel lost, or confirm timed out
|
|
903
|
+
* @throws {RabbitMqPublishRejectedError} the broker nacked the message
|
|
904
|
+
* @throws {UnroutableMessageError} no queue is bound to `destination` (unless `allowUnroutable`)
|
|
905
|
+
*/
|
|
906
|
+
async publish(envelope, destination) {
|
|
907
|
+
assertShortString("Destination (routing key)", destination);
|
|
908
|
+
const { content, options } = encodeEnvelope(envelope);
|
|
909
|
+
await this.#publishConfirmed(
|
|
910
|
+
this.#settings.exchange,
|
|
911
|
+
destination,
|
|
912
|
+
content,
|
|
913
|
+
options,
|
|
914
|
+
!this.#settings.allowUnroutable
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
async #publishConfirmed(exchange, routingKey, content, options, mandatory) {
|
|
918
|
+
const publisher = this.#live?.publisher;
|
|
919
|
+
const eventId = options.messageId ?? "unknown";
|
|
920
|
+
if (publisher === void 0) {
|
|
921
|
+
throw new RabbitMqUnavailableError(
|
|
922
|
+
`[RabbitMqTransport](publish) Not connected to the broker | transport=${this.name} destination=${routingKey}`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const returnKey = unroutableKey(options.messageId, routingKey);
|
|
926
|
+
await new Promise((resolve, reject) => {
|
|
927
|
+
const timer = setTimeout(() => {
|
|
928
|
+
reject(
|
|
929
|
+
new RabbitMqUnavailableError(
|
|
930
|
+
`[RabbitMqTransport](publish) No broker confirm within ${this.#settings.publishTimeoutMs}ms | transport=${this.name} eventId=${eventId} destination=${routingKey}`
|
|
931
|
+
)
|
|
932
|
+
);
|
|
933
|
+
}, this.#settings.publishTimeoutMs);
|
|
934
|
+
const onConfirm = (error) => {
|
|
935
|
+
clearTimeout(timer);
|
|
936
|
+
const returned = takeUnroutable(publisher.unroutable, returnKey);
|
|
937
|
+
if (error) {
|
|
938
|
+
reject(this.#toPublishError(error, eventId, routingKey));
|
|
939
|
+
} else if (returned) {
|
|
940
|
+
reject(
|
|
941
|
+
new UnroutableMessageError(
|
|
942
|
+
`[RabbitMqTransport](publish) No queue is bound to the routing key | transport=${this.name} eventId=${eventId} destination=${routingKey}`
|
|
943
|
+
)
|
|
944
|
+
);
|
|
945
|
+
} else {
|
|
946
|
+
resolve();
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
try {
|
|
950
|
+
publisher.channel.publish(
|
|
951
|
+
exchange,
|
|
952
|
+
routingKey,
|
|
953
|
+
content,
|
|
954
|
+
{ ...options, mandatory },
|
|
955
|
+
onConfirm
|
|
956
|
+
);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
clearTimeout(timer);
|
|
959
|
+
reject(this.#toPublishError(error, eventId, routingKey));
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Binds one durable quorum queue to `destinations` and delivers its messages
|
|
965
|
+
* to `onMessage`. Call it once per transport; give each subscription its own
|
|
966
|
+
* transport instance, with its own `name` and `consumerGroup`.
|
|
967
|
+
*/
|
|
968
|
+
async subscribe(destinations, onMessage) {
|
|
969
|
+
if (this.#subscription !== void 0) {
|
|
970
|
+
throw new core.ConfigurationError(
|
|
971
|
+
`Transport '${this.name}' already has a subscription; create another transport for another consumer group`
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
if (destinations.length === 0) {
|
|
975
|
+
throw new core.ConfigurationError(`Transport '${this.name}' needs at least one destination`);
|
|
976
|
+
}
|
|
977
|
+
const queue = resolveQueueName(this.#settings);
|
|
978
|
+
const subscription = {
|
|
979
|
+
topology: {
|
|
980
|
+
exchange: this.#settings.exchange,
|
|
981
|
+
queue,
|
|
982
|
+
deadLetterExchange: this.#settings.deadLetterExchange,
|
|
983
|
+
deadLetterQueue: this.#settings.deadLetterQueue ?? `${queue}.dead`,
|
|
984
|
+
deliveryLimit: this.#settings.deliveryLimit,
|
|
985
|
+
ordered: this.#settings.ordered,
|
|
986
|
+
retryDelaysMs: this.#settings.retryLadder,
|
|
987
|
+
destinations
|
|
988
|
+
},
|
|
989
|
+
consumer: new QueueConsumer({
|
|
990
|
+
queue,
|
|
991
|
+
prefetch: this.#settings.prefetch,
|
|
992
|
+
handlerRetry: this.#settings.handlerRetry,
|
|
993
|
+
redelivery: this.#settings.redelivery,
|
|
994
|
+
retryLadder: this.#settings.retryLadder,
|
|
995
|
+
redeliveryMaxAgeMs: this.#settings.redeliveryMaxAgeMs,
|
|
996
|
+
ordered: this.#settings.ordered,
|
|
997
|
+
deadLetterExchange: this.#settings.deadLetterExchange,
|
|
998
|
+
republish: (exchange, routingKey, content, options) => this.#publishConfirmed(exchange, routingKey, content, options, true),
|
|
999
|
+
shutdownTimeoutMs: this.#settings.shutdownTimeoutMs,
|
|
1000
|
+
onMessage,
|
|
1001
|
+
onConsumerLost: (reason) => this.#supervisor.recover(reason),
|
|
1002
|
+
logger: this.#settings.logger,
|
|
1003
|
+
random: this.#settings.random
|
|
1004
|
+
})
|
|
1005
|
+
};
|
|
1006
|
+
await this.#topologyWork.run(async () => {
|
|
1007
|
+
this.#subscription = subscription;
|
|
1008
|
+
if (this.#live !== void 0) {
|
|
1009
|
+
await this.#startConsuming(this.#live.connection, subscription);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Stops consuming, waits up to `shutdownTimeout` for running handlers
|
|
1015
|
+
* (messages still being retried go back to the queue), then closes the
|
|
1016
|
+
* channels and the connection.
|
|
1017
|
+
*/
|
|
1018
|
+
async close() {
|
|
1019
|
+
if (this.#closed) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
this.#closed = true;
|
|
1023
|
+
await this.#supervisor.close(
|
|
1024
|
+
() => this.#topologyWork.run(async () => {
|
|
1025
|
+
const live = this.#live;
|
|
1026
|
+
this.#live = void 0;
|
|
1027
|
+
await this.#subscription?.consumer.stop();
|
|
1028
|
+
await live?.publisher.channel.close().catch(() => void 0);
|
|
1029
|
+
})
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
async #establishChannels(connection) {
|
|
1033
|
+
await this.#topologyWork.run(async () => {
|
|
1034
|
+
const channel = await connection.createConfirmChannel();
|
|
1035
|
+
const publisher = { channel, unroutable: /* @__PURE__ */ new Map(), closed: false };
|
|
1036
|
+
channel.on("error", (error) => {
|
|
1037
|
+
this.#settings.logger.warn(
|
|
1038
|
+
`[RabbitMqTransport](establishChannels) Publisher channel error | transport=${this.name} error=${error.message}`
|
|
1039
|
+
);
|
|
1040
|
+
});
|
|
1041
|
+
channel.on("close", () => {
|
|
1042
|
+
publisher.closed = true;
|
|
1043
|
+
if (this.#live?.publisher === publisher) {
|
|
1044
|
+
this.#supervisor.recover("publisher channel closed");
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
channel.on("return", (message) => {
|
|
1048
|
+
recordUnroutable(
|
|
1049
|
+
publisher.unroutable,
|
|
1050
|
+
unroutableKey(message.properties.messageId, message.fields.routingKey)
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
await assertExchange(channel, this.#settings.exchange);
|
|
1054
|
+
if (this.#subscription !== void 0) {
|
|
1055
|
+
await this.#startConsuming(connection, this.#subscription);
|
|
1056
|
+
}
|
|
1057
|
+
if (publisher.closed) {
|
|
1058
|
+
throw new RabbitMqUnavailableError(
|
|
1059
|
+
`[RabbitMqTransport](establishChannels) Publisher channel closed while the transport was being set up | transport=${this.name}`
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
this.#live = { connection, publisher };
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
async #startConsuming(connection, subscription) {
|
|
1066
|
+
const channel = await connection.createChannel();
|
|
1067
|
+
await assertConsumerTopology(channel, subscription.topology);
|
|
1068
|
+
await subscription.consumer.attach(channel);
|
|
1069
|
+
}
|
|
1070
|
+
#forgetConnection() {
|
|
1071
|
+
this.#live = void 0;
|
|
1072
|
+
this.#subscription?.consumer.detach();
|
|
1073
|
+
}
|
|
1074
|
+
#toPublishError(error, eventId, destination) {
|
|
1075
|
+
const { message } = core.toErrorInfo(error);
|
|
1076
|
+
const detail = `transport=${this.name} eventId=${eventId} destination=${destination} error=${message}`;
|
|
1077
|
+
return message === NACK_MESSAGE ? new RabbitMqPublishRejectedError(
|
|
1078
|
+
`[RabbitMqTransport](publish) Broker nacked the message | ${detail}`,
|
|
1079
|
+
{ cause: error }
|
|
1080
|
+
) : new RabbitMqUnavailableError(`[RabbitMqTransport](publish) Publish failed | ${detail}`, {
|
|
1081
|
+
cause: error
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
function unroutableKey(messageId, routingKey) {
|
|
1086
|
+
return `${String(messageId)}
|
|
1087
|
+
${routingKey}`;
|
|
1088
|
+
}
|
|
1089
|
+
function recordUnroutable(unroutable, key) {
|
|
1090
|
+
unroutable.set(key, (unroutable.get(key) ?? 0) + 1);
|
|
1091
|
+
}
|
|
1092
|
+
function takeUnroutable(unroutable, key) {
|
|
1093
|
+
const count = unroutable.get(key);
|
|
1094
|
+
if (count === void 0) {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
if (count === 1) {
|
|
1098
|
+
unroutable.delete(key);
|
|
1099
|
+
} else {
|
|
1100
|
+
unroutable.set(key, count - 1);
|
|
1101
|
+
}
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
exports.AmqpLimitExceededError = AmqpLimitExceededError;
|
|
1106
|
+
exports.MalformedMessageError = MalformedMessageError;
|
|
1107
|
+
exports.RabbitMqPublishRejectedError = RabbitMqPublishRejectedError;
|
|
1108
|
+
exports.RabbitMqTransport = RabbitMqTransport;
|
|
1109
|
+
exports.RabbitMqUnavailableError = RabbitMqUnavailableError;
|
|
1110
|
+
exports.UnroutableMessageError = UnroutableMessageError;
|
|
1111
|
+
exports.UnserializablePayloadError = UnserializablePayloadError;
|
|
1112
|
+
//# sourceMappingURL=index.cjs.map
|
|
1113
|
+
//# sourceMappingURL=index.cjs.map
|