@zmdb/transport-rabbitmq 1.0.0-beta.1

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/src/index.ts ADDED
@@ -0,0 +1,442 @@
1
+ import {
2
+ abortError,
3
+ decodeDelivery,
4
+ decodeReply,
5
+ encodeDelivery,
6
+ encodeReply,
7
+ InFlight,
8
+ MessageTimeoutError,
9
+ reportTransportError,
10
+ withinGrace,
11
+ type MessageReply,
12
+ type TransportErrorSink,
13
+ type TransportStrategy,
14
+ } from '@zmdb/app/messaging';
15
+ import {
16
+ connect,
17
+ type Channel,
18
+ type ChannelModel,
19
+ type ConfirmChannel,
20
+ type ConsumeMessage,
21
+ type Options,
22
+ type SocketOptions,
23
+ } from 'amqplib';
24
+
25
+ export interface RabbitMqDeadLetterOptions {
26
+ readonly exchange: string;
27
+ readonly queue: string;
28
+ /** Topic binding on the dead-letter exchange. Defaults to `#`. */
29
+ readonly binding?: string;
30
+ }
31
+
32
+ export interface RabbitMqRetryOptions {
33
+ readonly exchange?: string;
34
+ readonly queue?: string;
35
+ }
36
+
37
+ export interface RabbitMqStrategyOptions {
38
+ readonly bindings: readonly string[];
39
+ readonly connection: string;
40
+ readonly deadLetter: RabbitMqDeadLetterOptions;
41
+ readonly durable?: boolean;
42
+ readonly exchange: string;
43
+ readonly name?: string;
44
+ readonly onError: TransportErrorSink;
45
+ /** Consumer prefetch is RabbitMQ's backpressure control and is required. */
46
+ readonly prefetch: number;
47
+ readonly queue: string;
48
+ readonly retry?: RabbitMqRetryOptions;
49
+ readonly socketOptions?: SocketOptions;
50
+ }
51
+
52
+ interface PendingReply {
53
+ reject(error: unknown): void;
54
+ resolve(reply: MessageReply): void;
55
+ }
56
+
57
+ function requiredName(value: string, description: string): string {
58
+ if (value.length === 0) {
59
+ throw new RangeError(`@zmdb/transport-rabbitmq: RabbitMQ ${description} cannot be empty`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function positiveInteger(value: number, description: string): number {
65
+ if (!Number.isInteger(value) || value <= 0) {
66
+ throw new RangeError(`@zmdb/transport-rabbitmq: RabbitMQ ${description} must be a positive integer`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function deliveryAttempt(message: ConsumeMessage): number {
76
+ const rawHeaders: unknown = message.properties.headers;
77
+ let deadLetters = 0;
78
+ if (isRecord(rawHeaders)) {
79
+ const deaths = rawHeaders['x-death'];
80
+ if (Array.isArray(deaths)) {
81
+ for (const death of deaths) {
82
+ if (!isRecord(death)) {
83
+ continue;
84
+ }
85
+ const count = death.count;
86
+ if (typeof count === 'number' && Number.isFinite(count) && count > 0) {
87
+ deadLetters += Math.floor(count);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return Math.max(deadLetters + 1, message.fields.redelivered ? 2 : 1);
93
+ }
94
+
95
+ function optionalProperty(value: unknown): string | undefined {
96
+ return typeof value === 'string' ? value : undefined;
97
+ }
98
+
99
+ async function publishConfirmed(
100
+ channel: ConfirmChannel,
101
+ exchange: string,
102
+ routingKey: string,
103
+ content: Parameters<ConfirmChannel['publish']>[2],
104
+ properties: Options.Publish,
105
+ ): Promise<void> {
106
+ channel.publish(exchange, routingKey, content, properties);
107
+ await channel.waitForConfirms();
108
+ }
109
+
110
+ function amqpBytes(value: string): Parameters<ConfirmChannel['publish']>[2] {
111
+ // amqplib checks Buffer.isBuffer at runtime and rejects an ordinary
112
+ // Uint8Array, so the Node global is the narrow broker-client boundary.
113
+ return globalThis.Buffer.from(value);
114
+ }
115
+
116
+ function rejectPending(pending: Map<string, PendingReply>, error: unknown): void {
117
+ for (const waiter of pending.values()) {
118
+ waiter.reject(error);
119
+ }
120
+ pending.clear();
121
+ }
122
+
123
+ /**
124
+ * RabbitMQ topic-exchange strategy with bounded prefetch, publisher-confirmed
125
+ * delayed retries and an owned dead-letter queue.
126
+ *
127
+ * A retry is copied to a TTL retry queue and confirmed before the original is
128
+ * acknowledged. `nack(requeue: true)` is intentionally absent: it would put a
129
+ * deterministic failure straight back at the queue head.
130
+ */
131
+ export function createRabbitMqStrategy(options: RabbitMqStrategyOptions): TransportStrategy {
132
+ const exchange = requiredName(options.exchange, 'exchange');
133
+ const queue = requiredName(options.queue, 'queue');
134
+ const bindings = options.bindings.map(binding => requiredName(binding, 'binding'));
135
+ if (bindings.length === 0) {
136
+ throw new RangeError('@zmdb/transport-rabbitmq: RabbitMQ requires at least one binding');
137
+ }
138
+ const deadLetterExchange = requiredName(options.deadLetter.exchange, 'dead-letter exchange');
139
+ const deadLetterQueue = requiredName(options.deadLetter.queue, 'dead-letter queue');
140
+ const deadLetterBinding = requiredName(options.deadLetter.binding ?? '#', 'dead-letter binding');
141
+ const retryExchange = requiredName(options.retry?.exchange ?? `${exchange}.retry`, 'retry exchange');
142
+ const retryQueue = requiredName(options.retry?.queue ?? `${queue}.retry`, 'retry queue');
143
+ const prefetch = positiveInteger(options.prefetch, 'prefetch');
144
+ const durable = options.durable ?? true;
145
+ const name = options.name ?? 'rabbitmq';
146
+ const inFlight = new InFlight(options.onError);
147
+ const pending = new Map<string, PendingReply>();
148
+
149
+ let model: ChannelModel | undefined;
150
+ let consumerChannel: Channel | undefined;
151
+ let publisherChannel: ConfirmChannel | undefined;
152
+ let consumerTag: string | undefined;
153
+ let replyConsumerTag: string | undefined;
154
+ let replyQueue: string | undefined;
155
+ let started = false;
156
+ let closed = false;
157
+
158
+ return {
159
+ name,
160
+ capabilities: { redelivery: true, deadLetter: true, requestResponse: true },
161
+
162
+ async listen(dispatch): Promise<void> {
163
+ if (started) {
164
+ throw new Error('@zmdb/transport-rabbitmq: RabbitMQ strategy is already listening');
165
+ }
166
+ if (closed) {
167
+ throw new Error('@zmdb/transport-rabbitmq: RabbitMQ strategy is closed');
168
+ }
169
+ started = true;
170
+
171
+ const nextModel =
172
+ options.socketOptions === undefined
173
+ ? await connect(options.connection)
174
+ : await connect(options.connection, options.socketOptions);
175
+ nextModel.on('error', error => reportTransportError(options.onError, error));
176
+ let nextConsumerChannel: Channel;
177
+ try {
178
+ nextConsumerChannel = await nextModel.createChannel();
179
+ } catch (error) {
180
+ await nextModel.close().catch(() => undefined);
181
+ throw error;
182
+ }
183
+ let nextPublisherChannel: ConfirmChannel;
184
+ try {
185
+ nextPublisherChannel = await nextModel.createConfirmChannel();
186
+ } catch (error) {
187
+ await nextConsumerChannel.close().catch(() => undefined);
188
+ await nextModel.close().catch(() => undefined);
189
+ throw error;
190
+ }
191
+ nextConsumerChannel.on('error', error => reportTransportError(options.onError, error));
192
+ nextPublisherChannel.on('error', error => reportTransportError(options.onError, error));
193
+ try {
194
+ await nextConsumerChannel.assertExchange(exchange, 'topic', { durable });
195
+ await nextConsumerChannel.assertExchange(retryExchange, 'topic', { durable });
196
+ await nextConsumerChannel.assertExchange(deadLetterExchange, 'topic', { durable });
197
+ await nextConsumerChannel.assertQueue(queue, {
198
+ durable,
199
+ deadLetterExchange,
200
+ });
201
+ for (const binding of bindings) {
202
+ await nextConsumerChannel.bindQueue(queue, exchange, binding);
203
+ }
204
+ await nextConsumerChannel.assertQueue(retryQueue, {
205
+ durable,
206
+ deadLetterExchange: exchange,
207
+ });
208
+ await nextConsumerChannel.bindQueue(retryQueue, retryExchange, '#');
209
+ await nextConsumerChannel.assertQueue(deadLetterQueue, { durable });
210
+ await nextConsumerChannel.bindQueue(deadLetterQueue, deadLetterExchange, deadLetterBinding);
211
+ await nextConsumerChannel.prefetch(prefetch);
212
+
213
+ const assertedReplyQueue = await nextConsumerChannel.assertQueue('', {
214
+ autoDelete: true,
215
+ durable: false,
216
+ exclusive: true,
217
+ });
218
+ const replyConsumer = await nextConsumerChannel.consume(
219
+ assertedReplyQueue.queue,
220
+ message => {
221
+ if (message === null) {
222
+ return;
223
+ }
224
+ const correlationId = optionalProperty(message.properties.correlationId);
225
+ if (correlationId === undefined) {
226
+ reportTransportError(
227
+ options.onError,
228
+ new TypeError('@zmdb/transport-rabbitmq: RabbitMQ reply has no correlation id'),
229
+ );
230
+ return;
231
+ }
232
+ const waiter = pending.get(correlationId);
233
+ if (waiter === undefined) {
234
+ return;
235
+ }
236
+ try {
237
+ waiter.resolve(decodeReply(message.content.toString('utf8')));
238
+ } catch (error) {
239
+ waiter.reject(error);
240
+ }
241
+ },
242
+ { noAck: true },
243
+ );
244
+
245
+ nextPublisherChannel.on('return', message => {
246
+ const correlationId = optionalProperty(message.properties.correlationId);
247
+ if (correlationId !== undefined) {
248
+ pending
249
+ .get(correlationId)
250
+ ?.reject(
251
+ new Error(`@zmdb/transport-rabbitmq: RabbitMQ request "${message.fields.routingKey}" was not routed`),
252
+ );
253
+ }
254
+ });
255
+
256
+ const consumer = await nextConsumerChannel.consume(
257
+ queue,
258
+ message => {
259
+ if (message === null) {
260
+ return;
261
+ }
262
+ void inFlight.run(async () => {
263
+ const correlationId = optionalProperty(message.properties.correlationId);
264
+ const messageReplyTo = optionalProperty(message.properties.replyTo);
265
+ const delivery = decodeDelivery(
266
+ message.fields.routingKey,
267
+ message.content.toString('utf8'),
268
+ deliveryAttempt(message),
269
+ {
270
+ ...(correlationId === undefined ? {} : { correlationId }),
271
+ ...(messageReplyTo === undefined ? {} : { replyTo: messageReplyTo }),
272
+ },
273
+ );
274
+ const outcome = await dispatch(delivery);
275
+ if (outcome.reply !== undefined && messageReplyTo !== undefined) {
276
+ await publishConfirmed(
277
+ nextPublisherChannel,
278
+ '',
279
+ messageReplyTo,
280
+ amqpBytes(encodeReply(outcome.reply)),
281
+ {
282
+ contentType: 'application/json',
283
+ correlationId: outcome.reply.correlationId,
284
+ },
285
+ );
286
+ }
287
+
288
+ if (outcome.settlement.kind === 'ack') {
289
+ nextConsumerChannel.ack(message);
290
+ return;
291
+ }
292
+ if (outcome.settlement.kind === 'retry') {
293
+ await publishConfirmed(
294
+ nextPublisherChannel,
295
+ retryExchange,
296
+ message.fields.routingKey,
297
+ message.content,
298
+ {
299
+ contentType: 'application/json',
300
+ expiration: outcome.settlement.afterMs,
301
+ persistent: true,
302
+ ...(correlationId === undefined ? {} : { correlationId }),
303
+ ...(messageReplyTo === undefined ? {} : { replyTo: messageReplyTo }),
304
+ ...(message.properties.headers === undefined ? {} : { headers: message.properties.headers }),
305
+ },
306
+ );
307
+ nextConsumerChannel.ack(message);
308
+ return;
309
+ }
310
+ nextConsumerChannel.nack(message, false, false);
311
+ });
312
+ },
313
+ { noAck: false },
314
+ );
315
+ model = nextModel;
316
+ consumerChannel = nextConsumerChannel;
317
+ publisherChannel = nextPublisherChannel;
318
+ consumerTag = consumer.consumerTag;
319
+ replyConsumerTag = replyConsumer.consumerTag;
320
+ replyQueue = assertedReplyQueue.queue;
321
+ } catch (error) {
322
+ await nextConsumerChannel.close().catch(() => undefined);
323
+ await nextPublisherChannel.close().catch(() => undefined);
324
+ await nextModel.close().catch(() => undefined);
325
+ throw error;
326
+ }
327
+ },
328
+
329
+ async send(request): Promise<MessageReply> {
330
+ const activePublisher = publisherChannel;
331
+ const activeReplyQueue = replyQueue;
332
+ if (activePublisher === undefined || activeReplyQueue === undefined) {
333
+ throw new Error('@zmdb/transport-rabbitmq: RabbitMQ strategy is not listening');
334
+ }
335
+ if (request.signal.aborted) {
336
+ throw abortError(request.signal);
337
+ }
338
+
339
+ let timer: ReturnType<typeof setTimeout> | undefined;
340
+ let abort = (): void => undefined;
341
+ const reply = new Promise<MessageReply>((resolve, reject) => {
342
+ const finish = (): void => {
343
+ pending.delete(request.correlationId);
344
+ request.signal.removeEventListener('abort', abort);
345
+ if (timer !== undefined) {
346
+ clearTimeout(timer);
347
+ }
348
+ };
349
+ abort = (): void => {
350
+ finish();
351
+ reject(abortError(request.signal));
352
+ };
353
+ pending.set(request.correlationId, {
354
+ reject(error): void {
355
+ finish();
356
+ reject(error);
357
+ },
358
+ resolve(value): void {
359
+ finish();
360
+ resolve(value);
361
+ },
362
+ });
363
+ request.signal.addEventListener('abort', abort, { once: true });
364
+ timer = setTimeout(() => {
365
+ pending
366
+ .get(request.correlationId)
367
+ ?.reject(new MessageTimeoutError(request.pattern, request.timeoutMs, request.correlationId));
368
+ }, request.timeoutMs);
369
+ });
370
+
371
+ try {
372
+ await publishConfirmed(
373
+ activePublisher,
374
+ exchange,
375
+ request.pattern,
376
+ amqpBytes(encodeDelivery(request.payload, request)),
377
+ {
378
+ contentType: 'application/json',
379
+ correlationId: request.correlationId,
380
+ mandatory: true,
381
+ persistent: true,
382
+ replyTo: activeReplyQueue,
383
+ },
384
+ );
385
+ } catch (error) {
386
+ pending.get(request.correlationId)?.reject(error);
387
+ }
388
+ return reply;
389
+ },
390
+
391
+ async emit(pattern, payload, carrier): Promise<void> {
392
+ const activePublisher = publisherChannel;
393
+ if (activePublisher === undefined) {
394
+ throw new Error('@zmdb/transport-rabbitmq: RabbitMQ strategy is not listening');
395
+ }
396
+ await publishConfirmed(activePublisher, exchange, pattern, amqpBytes(encodeDelivery(payload, carrier)), {
397
+ contentType: 'application/json',
398
+ persistent: true,
399
+ });
400
+ },
401
+
402
+ async close(graceMs): Promise<void> {
403
+ if (closed) {
404
+ return;
405
+ }
406
+ closed = true;
407
+ const activeModel = model;
408
+ const activeConsumer = consumerChannel;
409
+ const activePublisher = publisherChannel;
410
+ const activeConsumerTag = consumerTag;
411
+ const activeReplyConsumerTag = replyConsumerTag;
412
+ model = undefined;
413
+ consumerChannel = undefined;
414
+ publisherChannel = undefined;
415
+ consumerTag = undefined;
416
+ replyConsumerTag = undefined;
417
+ replyQueue = undefined;
418
+ rejectPending(pending, new Error('@zmdb/transport-rabbitmq: RabbitMQ strategy closed before receiving a reply'));
419
+ if (activeModel === undefined || activeConsumer === undefined || activePublisher === undefined) {
420
+ return;
421
+ }
422
+
423
+ const graceful = (async (): Promise<void> => {
424
+ if (activeConsumerTag !== undefined) {
425
+ await activeConsumer.cancel(activeConsumerTag);
426
+ }
427
+ if (activeReplyConsumerTag !== undefined) {
428
+ await activeConsumer.cancel(activeReplyConsumerTag);
429
+ }
430
+ inFlight.stop();
431
+ await inFlight.settled();
432
+ await activeConsumer.close();
433
+ await activePublisher.close();
434
+ await activeModel.close();
435
+ })();
436
+ if (!(await withinGrace(graceful, graceMs))) {
437
+ await activeModel.close().catch(() => undefined);
438
+ throw new Error(`@zmdb/transport-rabbitmq: RabbitMQ strategy did not drain within ${String(graceMs)}ms`);
439
+ }
440
+ },
441
+ };
442
+ }