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