@adatechnology/rabbitmq-provider 0.1.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.
@@ -0,0 +1,82 @@
1
+ import { Options } from 'amqplib';
2
+
3
+ /**
4
+ * Copyright (c) 2026 Ada Technology. MIT License.
5
+ */
6
+
7
+ type RabbitMqConnectionConfig = string | Options.Connect;
8
+ type RabbitMqRoute = {
9
+ readonly exchange: string;
10
+ readonly queue: string;
11
+ readonly routingKey: string;
12
+ };
13
+ type RabbitMqRetryRoute = RabbitMqRoute & {
14
+ readonly delayMs: number;
15
+ readonly maxRetries: number;
16
+ };
17
+ type RabbitMqTopology = RabbitMqRoute & {
18
+ readonly retry: RabbitMqRetryRoute;
19
+ readonly deadLetter: RabbitMqRoute;
20
+ };
21
+ type RabbitMqProviderConfig = {
22
+ readonly connection: RabbitMqConnectionConfig;
23
+ readonly topology: RabbitMqTopology;
24
+ };
25
+ type RabbitMqDisposition = {
26
+ readonly type: 'ack';
27
+ } | {
28
+ readonly type: 'retry';
29
+ } | {
30
+ readonly type: 'dead-letter';
31
+ };
32
+ type RabbitMqConsumerMessage<TPayload> = {
33
+ readonly payload: TPayload;
34
+ readonly headers: Readonly<Record<string, unknown>>;
35
+ readonly messageId?: string;
36
+ readonly correlationId?: string;
37
+ readonly redelivered: boolean;
38
+ readonly retryCount: number;
39
+ };
40
+ type RabbitMqMessageHandler<TPayload> = (message: RabbitMqConsumerMessage<TPayload>) => RabbitMqDisposition | Promise<RabbitMqDisposition>;
41
+ type RabbitMqMessageDecoder<TPayload> = (value: unknown) => TPayload;
42
+ type RabbitMqConsumeParams<TPayload> = {
43
+ readonly decode: RabbitMqMessageDecoder<TPayload>;
44
+ readonly handler: RabbitMqMessageHandler<TPayload>;
45
+ readonly prefetch: number;
46
+ readonly consumerTag?: string;
47
+ };
48
+ type RabbitMqConsumer = {
49
+ readonly consumerTag: string;
50
+ cancel(): Promise<void>;
51
+ };
52
+ type RabbitMqPublishOptions = {
53
+ readonly headers?: Readonly<Record<string, unknown>>;
54
+ readonly messageId?: string;
55
+ readonly correlationId?: string;
56
+ readonly type?: string;
57
+ };
58
+ type RabbitMqProviderHealth = {
59
+ readonly healthy: true;
60
+ };
61
+ type RabbitMqProvider = {
62
+ publish<TPayload>(payload: TPayload, options?: RabbitMqPublishOptions): Promise<void>;
63
+ consume<TPayload>(params: RabbitMqConsumeParams<TPayload>): Promise<RabbitMqConsumer>;
64
+ healthCheck(): Promise<RabbitMqProviderHealth>;
65
+ close(): Promise<void>;
66
+ };
67
+
68
+ declare function createRabbitMqProvider(config: RabbitMqProviderConfig): Promise<RabbitMqProvider>;
69
+
70
+ /**
71
+ * Copyright (c) 2026 Ada Technology. MIT License.
72
+ */
73
+ declare class RabbitMqProviderClosedError extends Error {
74
+ readonly name = "RabbitMqProviderClosedError";
75
+ constructor();
76
+ }
77
+ declare class RabbitMqConfigurationError extends Error {
78
+ readonly name = "RabbitMqConfigurationError";
79
+ constructor(message: string);
80
+ }
81
+
82
+ export { RabbitMqConfigurationError, type RabbitMqConnectionConfig, type RabbitMqConsumeParams, type RabbitMqConsumer, type RabbitMqConsumerMessage, type RabbitMqDisposition, type RabbitMqMessageDecoder, type RabbitMqMessageHandler, type RabbitMqProvider, RabbitMqProviderClosedError, type RabbitMqProviderConfig, type RabbitMqProviderHealth, type RabbitMqPublishOptions, type RabbitMqRetryRoute, type RabbitMqRoute, type RabbitMqTopology, createRabbitMqProvider };
package/dist/index.js ADDED
@@ -0,0 +1,410 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/rabbitmq-provider.factory.ts
5
+ import { connect } from "amqplib";
6
+
7
+ // src/rabbitmq-provider.error.ts
8
+ var RabbitMqProviderClosedError = class extends Error {
9
+ static {
10
+ __name(this, "RabbitMqProviderClosedError");
11
+ }
12
+ name = "RabbitMqProviderClosedError";
13
+ constructor() {
14
+ super("RabbitMQ provider is closing or already closed");
15
+ }
16
+ };
17
+ var RabbitMqConfigurationError = class extends Error {
18
+ static {
19
+ __name(this, "RabbitMqConfigurationError");
20
+ }
21
+ name = "RabbitMqConfigurationError";
22
+ constructor(message) {
23
+ super(message);
24
+ }
25
+ };
26
+
27
+ // src/rabbitmq-provider.service.ts
28
+ var RabbitMqProviderService = class {
29
+ static {
30
+ __name(this, "RabbitMqProviderService");
31
+ }
32
+ connection;
33
+ publisherChannel;
34
+ topology;
35
+ consumers = /* @__PURE__ */ new Map();
36
+ openingConsumerChannels = /* @__PURE__ */ new Set();
37
+ closePromise;
38
+ closing = false;
39
+ constructor({ connection, publisherChannel, topology }) {
40
+ this.connection = connection;
41
+ this.publisherChannel = publisherChannel;
42
+ this.topology = topology;
43
+ }
44
+ async publish(payload, options = {}) {
45
+ this.assertOpen();
46
+ await this.publishConfirmed({
47
+ content: Buffer.from(JSON.stringify(payload)),
48
+ exchange: this.topology.exchange,
49
+ options: {
50
+ ...options,
51
+ contentType: "application/json",
52
+ persistent: true
53
+ },
54
+ routingKey: this.topology.routingKey
55
+ });
56
+ }
57
+ async consume({ decode, handler, prefetch, consumerTag }) {
58
+ this.assertOpen();
59
+ assertValidPrefetch(prefetch);
60
+ const channel = await this.connection.createChannel();
61
+ const inFlight = /* @__PURE__ */ new Set();
62
+ this.openingConsumerChannels.add(channel);
63
+ try {
64
+ this.assertOpen();
65
+ await channel.prefetch(prefetch);
66
+ this.assertOpen();
67
+ const reply = await channel.consume(this.topology.queue, (message) => {
68
+ if (!message) {
69
+ return;
70
+ }
71
+ const processing = this.processDelivery({
72
+ channel,
73
+ decode,
74
+ handler,
75
+ message
76
+ });
77
+ inFlight.add(processing);
78
+ void processing.then(() => inFlight.delete(processing), () => inFlight.delete(processing));
79
+ }, {
80
+ consumerTag,
81
+ noAck: false
82
+ });
83
+ this.assertOpen();
84
+ this.openingConsumerChannels.delete(channel);
85
+ this.consumers.set(reply.consumerTag, {
86
+ channel,
87
+ inFlight
88
+ });
89
+ return {
90
+ consumerTag: reply.consumerTag,
91
+ cancel: /* @__PURE__ */ __name(() => this.cancelConsumer(reply.consumerTag), "cancel")
92
+ };
93
+ } catch (error) {
94
+ this.openingConsumerChannels.delete(channel);
95
+ await closeChannel(channel);
96
+ throw error;
97
+ }
98
+ }
99
+ async healthCheck() {
100
+ this.assertOpen();
101
+ await this.publisherChannel.checkQueue(this.topology.queue);
102
+ return {
103
+ healthy: true
104
+ };
105
+ }
106
+ close() {
107
+ this.closePromise ??= this.shutdown();
108
+ return this.closePromise;
109
+ }
110
+ async processDelivery({ channel, decode, handler, message }) {
111
+ const decoded = decodeDelivery({
112
+ decode,
113
+ message,
114
+ retryQueue: this.topology.retry.queue
115
+ });
116
+ if (!decoded) {
117
+ channel.reject(message, false);
118
+ return;
119
+ }
120
+ try {
121
+ const disposition = await handler(decoded);
122
+ await this.applyDisposition({
123
+ channel,
124
+ disposition,
125
+ message,
126
+ retryCount: decoded.retryCount
127
+ });
128
+ } catch {
129
+ await this.handleFailure({
130
+ channel,
131
+ message,
132
+ metadata: decoded
133
+ });
134
+ }
135
+ }
136
+ async applyDisposition({ channel, disposition, message, retryCount: retryCount2 }) {
137
+ if (disposition.type === "ack") {
138
+ channel.ack(message);
139
+ return;
140
+ }
141
+ if (disposition.type === "dead-letter") {
142
+ channel.reject(message, false);
143
+ return;
144
+ }
145
+ if (retryCount2 >= this.topology.retry.maxRetries) {
146
+ channel.reject(message, false);
147
+ return;
148
+ }
149
+ await this.publishRetry({
150
+ channel,
151
+ message,
152
+ retryCount: retryCount2
153
+ });
154
+ }
155
+ async handleFailure({ channel, message, metadata }) {
156
+ if (metadata.retryCount === 0 && !metadata.redelivered) {
157
+ channel.nack(message, false, true);
158
+ return;
159
+ }
160
+ if (metadata.retryCount >= this.topology.retry.maxRetries) {
161
+ channel.reject(message, false);
162
+ return;
163
+ }
164
+ await this.publishRetry({
165
+ channel,
166
+ message,
167
+ retryCount: metadata.retryCount
168
+ });
169
+ }
170
+ async publishRetry({ channel, message, retryCount: retryCount2 }) {
171
+ try {
172
+ await this.publishConfirmed({
173
+ content: message.content,
174
+ exchange: this.topology.retry.exchange,
175
+ options: retryPublishOptions({
176
+ message,
177
+ retryCount: retryCount2
178
+ }),
179
+ routingKey: this.topology.retry.routingKey
180
+ });
181
+ channel.ack(message);
182
+ } catch {
183
+ channel.nack(message, false, true);
184
+ }
185
+ }
186
+ async publishConfirmed({ content, exchange, options, routingKey }) {
187
+ const confirmation = createDeferred();
188
+ const canWrite = this.publisherChannel.publish(exchange, routingKey, content, options, (error) => {
189
+ if (error) {
190
+ confirmation.reject(toError(error));
191
+ return;
192
+ }
193
+ confirmation.resolve();
194
+ });
195
+ if (canWrite) {
196
+ await confirmation.promise;
197
+ return;
198
+ }
199
+ await Promise.all([
200
+ confirmation.promise,
201
+ waitForDrain(this.publisherChannel)
202
+ ]);
203
+ }
204
+ cancelConsumer(consumerTag) {
205
+ const consumer = this.consumers.get(consumerTag);
206
+ if (!consumer) {
207
+ return Promise.resolve();
208
+ }
209
+ consumer.cancellation ??= this.cancelAndDrain({
210
+ consumer,
211
+ consumerTag
212
+ });
213
+ return consumer.cancellation;
214
+ }
215
+ async cancelAndDrain({ consumer, consumerTag }) {
216
+ await consumer.channel.cancel(consumerTag);
217
+ await Promise.all([
218
+ ...consumer.inFlight
219
+ ]);
220
+ await consumer.channel.close();
221
+ this.consumers.delete(consumerTag);
222
+ }
223
+ async shutdown() {
224
+ this.closing = true;
225
+ await Promise.all([
226
+ ...this.consumers.keys()
227
+ ].map((consumerTag) => this.cancelConsumer(consumerTag)));
228
+ await Promise.all([
229
+ ...this.openingConsumerChannels
230
+ ].map(closeChannel));
231
+ await this.publisherChannel.waitForConfirms();
232
+ await this.publisherChannel.close();
233
+ await this.connection.close();
234
+ }
235
+ assertOpen() {
236
+ if (this.closing) {
237
+ throw new RabbitMqProviderClosedError();
238
+ }
239
+ }
240
+ };
241
+ function decodeDelivery({ decode, message, retryQueue }) {
242
+ try {
243
+ const headers = normalizeHeaders(message.properties.headers);
244
+ return {
245
+ payload: decode(JSON.parse(message.content.toString())),
246
+ headers,
247
+ messageId: stringProperty(message.properties.messageId),
248
+ correlationId: stringProperty(message.properties.correlationId),
249
+ redelivered: message.fields.redelivered,
250
+ retryCount: retryCount({
251
+ headers,
252
+ retryQueue
253
+ })
254
+ };
255
+ } catch {
256
+ return void 0;
257
+ }
258
+ }
259
+ __name(decodeDelivery, "decodeDelivery");
260
+ function normalizeHeaders(headers) {
261
+ return headers ?? {};
262
+ }
263
+ __name(normalizeHeaders, "normalizeHeaders");
264
+ function stringProperty(value) {
265
+ return typeof value === "string" ? value : void 0;
266
+ }
267
+ __name(stringProperty, "stringProperty");
268
+ function retryCount({ headers, retryQueue }) {
269
+ const providerRetryCount = headers["x-ada-retry-count"];
270
+ if (typeof providerRetryCount === "number" && Number.isInteger(providerRetryCount) && providerRetryCount >= 0) {
271
+ return providerRetryCount;
272
+ }
273
+ const deaths = headers["x-death"];
274
+ if (!Array.isArray(deaths)) {
275
+ return 0;
276
+ }
277
+ return deaths.reduce((count, death) => {
278
+ if (!isRetryDeath(death, retryQueue)) {
279
+ return count;
280
+ }
281
+ return count + death.count;
282
+ }, 0);
283
+ }
284
+ __name(retryCount, "retryCount");
285
+ function isRetryDeath(death, retryQueue) {
286
+ if (!death || typeof death !== "object") {
287
+ return false;
288
+ }
289
+ return "count" in death && typeof death.count === "number" && "queue" in death && death.queue === retryQueue;
290
+ }
291
+ __name(isRetryDeath, "isRetryDeath");
292
+ function retryPublishOptions({ message, retryCount: retryCount2 }) {
293
+ return {
294
+ appId: stringProperty(message.properties.appId),
295
+ contentEncoding: stringProperty(message.properties.contentEncoding),
296
+ contentType: stringProperty(message.properties.contentType),
297
+ correlationId: stringProperty(message.properties.correlationId),
298
+ headers: {
299
+ ...message.properties.headers,
300
+ "x-ada-retry-count": retryCount2 + 1
301
+ },
302
+ messageId: stringProperty(message.properties.messageId),
303
+ persistent: true,
304
+ type: stringProperty(message.properties.type)
305
+ };
306
+ }
307
+ __name(retryPublishOptions, "retryPublishOptions");
308
+ function createDeferred() {
309
+ let resolve;
310
+ let reject;
311
+ const promise = new Promise((promiseResolve, promiseReject) => {
312
+ resolve = promiseResolve;
313
+ reject = promiseReject;
314
+ });
315
+ return {
316
+ promise,
317
+ resolve,
318
+ reject
319
+ };
320
+ }
321
+ __name(createDeferred, "createDeferred");
322
+ function waitForDrain(channel) {
323
+ return new Promise((resolve) => {
324
+ channel.once("drain", resolve);
325
+ });
326
+ }
327
+ __name(waitForDrain, "waitForDrain");
328
+ function toError(error) {
329
+ return error instanceof Error ? error : new Error("RabbitMQ publish failed");
330
+ }
331
+ __name(toError, "toError");
332
+ function assertValidPrefetch(prefetch) {
333
+ if (!Number.isInteger(prefetch) || prefetch <= 0) {
334
+ throw new RabbitMqConfigurationError("RabbitMQ consumer prefetch must be a positive integer");
335
+ }
336
+ }
337
+ __name(assertValidPrefetch, "assertValidPrefetch");
338
+ async function closeChannel(channel) {
339
+ try {
340
+ await channel.close();
341
+ } catch {
342
+ }
343
+ }
344
+ __name(closeChannel, "closeChannel");
345
+
346
+ // src/rabbitmq-provider.factory.ts
347
+ async function createRabbitMqProvider(config) {
348
+ assertValidTopology(config.topology);
349
+ const connection = await connect(config.connection);
350
+ const publisherChannel = await connection.createConfirmChannel();
351
+ try {
352
+ await assertTopology({
353
+ channel: publisherChannel,
354
+ topology: config.topology
355
+ });
356
+ } catch (error) {
357
+ await publisherChannel.close();
358
+ await connection.close();
359
+ throw error;
360
+ }
361
+ return new RabbitMqProviderService({
362
+ connection,
363
+ publisherChannel,
364
+ topology: config.topology
365
+ });
366
+ }
367
+ __name(createRabbitMqProvider, "createRabbitMqProvider");
368
+ async function assertTopology({ channel, topology }) {
369
+ await channel.assertExchange(topology.exchange, "direct", {
370
+ durable: true
371
+ });
372
+ await channel.assertExchange(topology.retry.exchange, "direct", {
373
+ durable: true
374
+ });
375
+ await channel.assertExchange(topology.deadLetter.exchange, "direct", {
376
+ durable: true
377
+ });
378
+ await channel.assertQueue(topology.queue, {
379
+ durable: true,
380
+ deadLetterExchange: topology.deadLetter.exchange,
381
+ deadLetterRoutingKey: topology.deadLetter.routingKey
382
+ });
383
+ await channel.bindQueue(topology.queue, topology.exchange, topology.routingKey);
384
+ await channel.assertQueue(topology.retry.queue, {
385
+ durable: true,
386
+ messageTtl: topology.retry.delayMs,
387
+ deadLetterExchange: topology.exchange,
388
+ deadLetterRoutingKey: topology.routingKey
389
+ });
390
+ await channel.bindQueue(topology.retry.queue, topology.retry.exchange, topology.retry.routingKey);
391
+ await channel.assertQueue(topology.deadLetter.queue, {
392
+ durable: true
393
+ });
394
+ await channel.bindQueue(topology.deadLetter.queue, topology.deadLetter.exchange, topology.deadLetter.routingKey);
395
+ }
396
+ __name(assertTopology, "assertTopology");
397
+ function assertValidTopology(topology) {
398
+ if (!Number.isInteger(topology.retry.delayMs) || topology.retry.delayMs <= 0) {
399
+ throw new RabbitMqConfigurationError("RabbitMQ retry delayMs must be a positive integer");
400
+ }
401
+ if (!Number.isInteger(topology.retry.maxRetries) || topology.retry.maxRetries < 0) {
402
+ throw new RabbitMqConfigurationError("RabbitMQ retry maxRetries must be a non-negative integer");
403
+ }
404
+ }
405
+ __name(assertValidTopology, "assertValidTopology");
406
+ export {
407
+ RabbitMqConfigurationError,
408
+ RabbitMqProviderClosedError,
409
+ createRabbitMqProvider
410
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@adatechnology/rabbitmq-provider",
3
+ "version": "0.1.1",
4
+ "description": "Typed RabbitMQ provider for Bun workers and APIs",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "bun": ">=1.3.0"
21
+ },
22
+ "keywords": [
23
+ "amqp",
24
+ "bun",
25
+ "rabbitmq",
26
+ "worker"
27
+ ],
28
+ "author": "Ada Technology",
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "amqplib": "2.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "1.3.14",
38
+ "tsup": "^8.5.1",
39
+ "typescript": "^5.9.3"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "check": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "test:integration": "bun test --preload ./src/require-test-rabbitmq.ts",
46
+ "format": "prettier --write .",
47
+ "format:check": "prettier --check ."
48
+ }
49
+ }