amqp-suite 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.
@@ -0,0 +1,33 @@
1
+ # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
3
+
4
+ name: Node.js Package
5
+
6
+ on:
7
+ release:
8
+ types: [created]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: 20
18
+ - run: npm ci
19
+ - run: npm run test:run
20
+
21
+ publish-npm:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 20
29
+ registry-url: https://registry.npmjs.org/
30
+ - run: npm ci
31
+ - run: npm publish
32
+ env:
33
+ NODE_AUTH_TOKEN: ${{secrets.npm_token}}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Carlos Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # amqp-suite
2
+
3
+ [![NPM version](https://img.shields.io/npm/v/amqp-suite)](https://www.npmjs.com/package/amqp-suite)
4
+ [![NPM downloads](https://img.shields.io/npm/dw/amqp-suite.svg)](https://www.npmjs.com/package/amqp-suite)
5
+ [![MIT license](https://img.shields.io/badge/License-MIT-bridhtgreen)](https://opensource.org/licenses/MIT)
6
+ [![stars](https://img.shields.io/github/stars/iamcarlosdaniel/amqp-suite)](https://github.com/iamcarlosdaniel/amqp-suite)
7
+
8
+ ![](docs/assets/repository_banner.png)
9
+
10
+ `amqp-suite` is a simple and efficient AMQP (Advanced Message Queuing Protocol) client wrapper for Node.js that handles connection management, message publishing, and consuming messages from queues with a topic exchange. This package abstracts complex connection handling and simplifies AMQP usage in applications by providing easy-to-use methods for connecting, publishing, consuming, and gracefully shutting down the connection.
11
+
12
+ ## Features
13
+
14
+ - Automatic Reconnection: Built-in retry logic for connection failures and drops.
15
+ - Simplified Pub/Sub: Designed for 'topic' exchanges to allow flexible routing.
16
+ - Structured Messaging: Automatic JSON serialization and deserialization.
17
+ - Error Handling: Graceful handling of malformed messages and channel crashes.
18
+ - Flow Control: Integrated prefetch support to prevent consumer saturation.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install amqp-suite
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ ### 1. Initialize and Connect
29
+
30
+ Create an instance of the `AmqpClient` and establish a connection to the RabbitMQ broker.
31
+
32
+ ```javascript
33
+ import { AmqpClient } from "amqp-suite";
34
+
35
+ const amqpClient = new AmqpClient("amqp://localhost", "my_exchange");
36
+
37
+ // Connect with optional retry config (retries, delay in ms)
38
+ await amqpClient.connect(5, 2000);
39
+ ```
40
+
41
+ ### 2. Publish Messages
42
+
43
+ The `publish` method ensures that the message is stringified and sent as a persistent buffer.
44
+
45
+ ```javascript
46
+ const payload = {
47
+ id: 123,
48
+ event: "user_created",
49
+ timestamp: new Date(),
50
+ };
51
+
52
+ await amqpClient.publish("user.events.create", payload);
53
+ ```
54
+
55
+ ### 3. Consume Messages
56
+
57
+ The `consume` method automatically asserts queues, binds them to the exchange, and handles acknowledgments (`ack`/`nack`).
58
+
59
+ ```javascript
60
+ await amqpClient.consume(
61
+ "user_service_queue",
62
+ async (content, msg) => {
63
+ console.log("Received data:", content);
64
+ // Business logic here...
65
+ },
66
+ { prefetch: 10 }, // Optional: defaults to 10
67
+ "user.events.*" // Binding key (Topic pattern)
68
+ );
69
+ ```
70
+
71
+ ## API Reference
72
+
73
+ ### `new AmqpClient(amqpUrl, exchange)`
74
+
75
+ - **amqpUrl**: The connection string (e.g., `amqp://user:pass@localhost:5672`).
76
+ - **exchange**: The name of the topic exchange to use.
77
+
78
+ ### `.connect(retries = 5, delay = 5000)`
79
+
80
+ Establishes the connection and creates the channel. If the connection drops, it will automatically attempt to reconnect.
81
+
82
+ - **retries** (optional): Number of reconnection attempts (default: `5`).
83
+ - **delay** (optional): Time interval (in milliseconds) between retries (default: `5000`).
84
+
85
+ ### `.publish(routingKey, message, options = {})`
86
+
87
+ Publishes a message to the configured exchange with the given routing key.
88
+
89
+ - **routingKey**: The routing key used to route the message (e.g., `'user.events.create'`).
90
+ - **message**: The message payload, which will be stringified into JSON.
91
+ - **options** (optional): Additional publish options from `amqplib`.
92
+
93
+ ### `.consume(queue, onMessage, options = {}, bindingKey = "#")`
94
+
95
+ Consumes messages from the specified queue. The callback `onMessage` is executed when a message is received.
96
+
97
+ - **queue**: The name of the queue to consume messages from.
98
+ - **onMessage**: An async callback function that will be called with the message content and the original message.
99
+
100
+ ```javascript
101
+ async (content, rawMessage) => {
102
+ /* logic */
103
+ };
104
+ ```
105
+
106
+ - **options** (optional): Additional options like `prefetch` (default is 10).
107
+ - **bindingKey** (optional): The routing pattern to bind the queue (default is `#`).
108
+
109
+ ### `.close()`
110
+
111
+ Gracefully closes the channel and the connection.
112
+
113
+ ## License
114
+
115
+ This project is licensed under the terms of the [MIT License](https://opensource.org/licenses/MIT).
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "amqp-suite",
3
+ "version": "0.1.0",
4
+ "description": "A simple wrapper for AMQP 0-9-1 messaging.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Carlos Daniel Menchaca Arauz",
8
+ "email": "contact@iamcarlosdaniel.com",
9
+ "url": "http://iamcarlosdaniel.com"
10
+ },
11
+ "type": "module",
12
+ "main": "src/index.js",
13
+ "exports": {
14
+ ".": "./src/index.js"
15
+ },
16
+ "scripts": {
17
+ "test": "vitest",
18
+ "test:ui": "vitest --ui",
19
+ "test:run": "vitest run"
20
+ },
21
+ "keywords": [
22
+ "amqp",
23
+ "exchange",
24
+ "pubsub",
25
+ "publisher",
26
+ "consumer",
27
+ "rabbitmq"
28
+ ],
29
+ "dependencies": {
30
+ "amqplib": "^0.10.9"
31
+ },
32
+ "devDependencies": {
33
+ "@vitest/ui": "^4.0.16",
34
+ "vitest": "^4.0.16"
35
+ }
36
+ }
@@ -0,0 +1,183 @@
1
+ import amqp from "amqplib";
2
+
3
+ /**
4
+ * @fileoverview
5
+ * AMQP Service Class to manage AMQP communication.
6
+ * Handles automatic connection, channel management, and structured
7
+ * pub/sub patterns using 'topic' exchanges.
8
+ */
9
+ class AmqpClient {
10
+ /**
11
+ * Creates an instance of the AMQP helper.
12
+ * @param {string} amqpUrl - The connection string (e.g., 'amqp://localhost').
13
+ * @param {string} exchange - The name of the exchange to be used.
14
+ */
15
+ constructor(amqpUrl, exchange) {
16
+ /** @type {string} */
17
+ this.amqpUrl = amqpUrl;
18
+ /** @type {string} */
19
+ this.exchange = exchange;
20
+ /** @type {import('amqplib').Connection|null} */
21
+ this.connection = null;
22
+ /** @type {import('amqplib').Channel|null} */
23
+ this.channel = null;
24
+ /** @type {boolean} State flag to prevent multiple simultaneous connection attempts. */
25
+ this.isConnecting = false;
26
+ }
27
+
28
+ /**
29
+ * Establishes a connection to the AMQP broker and sets up the infrastructure.
30
+ * Implements a recursive retry logic in case of connection failure or drops.
31
+ * @param {number} [retries=5] - Maximum number of reconnection attempts.
32
+ * @param {number} [delay=5000] - Time interval in milliseconds between retries.
33
+ * @returns {Promise<void>}
34
+ * @throws {Error} Throws an error if all retry attempts fail.
35
+ */
36
+ async connect(retries = 5, delay = 5000) {
37
+ if (this.isConnecting) return;
38
+ this.isConnecting = true;
39
+
40
+ try {
41
+ this.connection = await amqp.connect(this.amqpUrl);
42
+ this.channel = await this.connection.createChannel();
43
+
44
+ // Infrastructure initial setup: Using 'topic' for flexible routing
45
+ await this.channel.assertExchange(this.exchange, "topic", {
46
+ durable: true,
47
+ });
48
+
49
+ this.connection.on("error", (err) => {
50
+ console.error("AMQP Connection Error:", err);
51
+ });
52
+
53
+ this.connection.on("close", () => {
54
+ this.isConnecting = false;
55
+ this.connection = null;
56
+ this.channel = null;
57
+ console.warn(`AMQP connection lost. Retrying in ${delay}ms...`);
58
+ setTimeout(() => this.connect(), delay);
59
+ });
60
+
61
+ console.log("AMQP: Connection established successfully");
62
+ this.isConnecting = false;
63
+ } catch (error) {
64
+ this.isConnecting = false;
65
+ if (retries > 0) {
66
+ console.error(
67
+ `AMQP: Connection failed. Retries left: ${retries}. Retrying in ${delay}ms...`
68
+ );
69
+ setTimeout(() => this.connect(retries - 1, delay), delay);
70
+ } else {
71
+ console.error(
72
+ "AMQP: Critical failure. Could not connect after maximum attempts."
73
+ );
74
+ throw error;
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Publishes a message to the configured exchange.
81
+ * @param {string} routingKey - The routing key used to direct the message.
82
+ * @param {Object} message - The message payload (will be stringified to JSON).
83
+ * @param {import('amqplib').Options.Publish} [options={}] - Additional publish options.
84
+ * @returns {Promise<void>}
85
+ */
86
+ async publish(routingKey, message, options = {}) {
87
+ if (!this.channel) {
88
+ console.log("AMQP: Channel not initialized. Attempting to connect...");
89
+ await this.connect();
90
+ }
91
+
92
+ try {
93
+ const published = this.channel.publish(
94
+ this.exchange,
95
+ routingKey,
96
+ Buffer.from(JSON.stringify(message)),
97
+ { persistent: true, ...options }
98
+ );
99
+
100
+ if (published) {
101
+ console.log(`AMQP: Message published to [${routingKey}]`);
102
+ } else {
103
+ console.warn(
104
+ "AMQP: Message was buffered locally. Check broker capacity or channel drain."
105
+ );
106
+ }
107
+ } catch (err) {
108
+ console.error("AMQP: Failed to publish message:", err);
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Consumes messages from a specific queue.
115
+ * @param {string} queue - The name of the queue to consume from.
116
+ * @param {function(Object, import('amqplib').ConsumeMessage): Promise<void>} onMessage -
117
+ * Callback executed when a message is received.
118
+ * @param {Object} [options={}] - Additional queue and prefetch options.
119
+ * @param {string} [bindingKey="#"] - The binding pattern to link the queue to the exchange.
120
+ * @returns {Promise<void>}
121
+ */
122
+ async consume(queue, onMessage, options = {}, bindingKey = "#") {
123
+ if (!this.channel) {
124
+ console.log("AMQP: Channel not initialized. Attempting to connect...");
125
+ await this.connect();
126
+ }
127
+
128
+ try {
129
+ const { prefetch = 10, ...queueOptions } = options;
130
+
131
+ // Ensure the queue exists and is durable by default
132
+ await this.channel.assertQueue(queue, { durable: true, ...queueOptions });
133
+ await this.channel.bindQueue(queue, this.exchange, bindingKey);
134
+
135
+ // Limit unacknowledged messages to avoid overwhelming the consumer
136
+ await this.channel.prefetch(prefetch);
137
+
138
+ await this.channel.consume(
139
+ queue,
140
+ async (msg) => {
141
+ if (msg !== null) {
142
+ try {
143
+ const content = JSON.parse(msg.content.toString());
144
+ await onMessage(content, msg);
145
+ this.channel.ack(msg);
146
+ } catch (err) {
147
+ console.error(
148
+ `AMQP: Error processing message on queue [${queue}]:`,
149
+ err.message
150
+ );
151
+ // nack(message, requeue: false) to prevent infinite loops on malformed messages
152
+ this.channel.nack(msg, false, false);
153
+ }
154
+ }
155
+ },
156
+ { noAck: false }
157
+ );
158
+
159
+ console.log(
160
+ `AMQP: Consumer started for queue [${queue}] with binding [${bindingKey}]`
161
+ );
162
+ } catch (err) {
163
+ console.error("AMQP: Failed to initialize consumer:", err);
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Gracefully closes the AMQP channel and connection.
170
+ * @returns {Promise<void>}
171
+ */
172
+ async close() {
173
+ try {
174
+ await this.channel?.close();
175
+ await this.connection?.close();
176
+ console.log("AMQP: Connection closed cleanly.");
177
+ } catch (err) {
178
+ console.error("AMQP: Error during shutdown:", err);
179
+ }
180
+ }
181
+ }
182
+
183
+ export { AmqpClient };
package/src/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ declare module "amqp-wrapper" {
2
+ export class AmqpClient {
3
+ constructor(amqpUrl: string, exchange: string);
4
+ connect(retries?: number, delay?: number): Promise<void>;
5
+ publish(routingKey: string, message: any, options?: any): Promise<void>;
6
+ consume(
7
+ queue: string,
8
+ onMessage: (content: any, msg: any) => Promise<void>,
9
+ options?: any,
10
+ bindingKey?: string
11
+ ): Promise<void>;
12
+ close(): Promise<void>;
13
+ }
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { AmqpClient } from "./amqp-client";
2
+
3
+ export { AmqpClient };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @fileoverview Unit tests for the AMQP class using Vitest and amqplib mocks.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+ import amqp from "amqplib";
7
+ import { AmqpClient } from "amqp-suite";
8
+
9
+ vi.mock("amqplib");
10
+
11
+ /**
12
+ * Test suite for AMQP integration and message handling.
13
+ */
14
+ describe("AMQP Class", () => {
15
+ /** @type {AmqpClient} */
16
+ let amqpInstance;
17
+
18
+ /** @type {string} */
19
+ const mockUrl = "amqp://localhost";
20
+
21
+ /** @type {string} */
22
+ const mockExchange = "test_exchange";
23
+
24
+ /**
25
+ * Mocked AMQP Channel object with Vitest spy functions.
26
+ * @type {Object}
27
+ */
28
+ const mockChannel = {
29
+ assertExchange: vi.fn().mockResolvedValue(undefined),
30
+ assertQueue: vi.fn().mockResolvedValue({}),
31
+ bindQueue: vi.fn().mockResolvedValue(undefined),
32
+ prefetch: vi.fn().mockResolvedValue(undefined),
33
+ publish: vi.fn().mockReturnValue(true),
34
+ consume: vi.fn().mockResolvedValue({ consumerTag: "abc" }),
35
+ ack: vi.fn(),
36
+ nack: vi.fn(),
37
+ close: vi.fn().mockResolvedValue(undefined),
38
+ };
39
+
40
+ /**
41
+ * Mocked AMQP Connection object.
42
+ * @type {Object}
43
+ */
44
+ const mockConnection = {
45
+ createChannel: vi.fn().mockResolvedValue(mockChannel),
46
+ on: vi.fn(),
47
+ close: vi.fn().mockResolvedValue(undefined),
48
+ };
49
+
50
+ /**
51
+ * Setup hook to reset mocks and re-initialize the AMQP instance before each test.
52
+ */
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ amqpInstance = new AmqpClient(mockUrl, mockExchange);
56
+ amqp.connect.mockResolvedValue(mockConnection);
57
+ });
58
+
59
+ /**
60
+ * Test case: Constructor property assignment.
61
+ */
62
+ it("should correctly initialize values in the constructor", () => {
63
+ expect(amqpInstance.amqpUrl).toBe(mockUrl);
64
+ expect(amqpInstance.exchange).toBe(mockExchange);
65
+ });
66
+
67
+ /**
68
+ * Test case: Connection workflow and exchange assertion.
69
+ */
70
+ it("should connect and create a channel successfully", async () => {
71
+ await amqpInstance.connect();
72
+
73
+ expect(amqp.connect).toHaveBeenCalledWith(mockUrl);
74
+ expect(mockConnection.createChannel).toHaveBeenCalled();
75
+ expect(mockChannel.assertExchange).toHaveBeenCalledWith(
76
+ mockExchange,
77
+ "topic",
78
+ { durable: true }
79
+ );
80
+ expect(amqpInstance.connection).not.toBeNull();
81
+ });
82
+
83
+ /**
84
+ * Test case: Message publishing logic and buffer conversion.
85
+ */
86
+ it("should publish a message correctly", async () => {
87
+ await amqpInstance.connect();
88
+ const routingKey = "user.created";
89
+ const message = { id: 1, name: "Test" };
90
+
91
+ await amqpInstance.publish(routingKey, message);
92
+
93
+ expect(mockChannel.publish).toHaveBeenCalledWith(
94
+ mockExchange,
95
+ routingKey,
96
+ expect.any(Buffer),
97
+ expect.objectContaining({ persistent: true })
98
+ );
99
+ });
100
+
101
+ /**
102
+ * Test case: Consumer setup and successful message processing.
103
+ */
104
+ it("should setup consumer and acknowledge messages on success", async () => {
105
+ await amqpInstance.connect();
106
+ const queueName = "test_queue";
107
+ const bindingKey = "test.key";
108
+ const mockPayload = { data: "hello world" };
109
+ const mockOnMessage = vi.fn().mockResolvedValue(undefined);
110
+
111
+ await amqpInstance.consume(
112
+ queueName,
113
+ mockOnMessage,
114
+ { prefetch: 5 },
115
+ bindingKey
116
+ );
117
+
118
+ expect(mockChannel.assertQueue).toHaveBeenCalledWith(
119
+ queueName,
120
+ expect.objectContaining({ durable: true })
121
+ );
122
+ expect(mockChannel.bindQueue).toHaveBeenCalledWith(
123
+ queueName,
124
+ mockExchange,
125
+ bindingKey
126
+ );
127
+ expect(mockChannel.prefetch).toHaveBeenCalledWith(5);
128
+
129
+ const consumerCallback = mockChannel.consume.mock.calls[0][1];
130
+ const fakeMsg = {
131
+ content: Buffer.from(JSON.stringify(mockPayload)),
132
+ };
133
+
134
+ await consumerCallback(fakeMsg);
135
+
136
+ expect(mockOnMessage).toHaveBeenCalledWith(mockPayload, fakeMsg);
137
+ expect(mockChannel.ack).toHaveBeenCalledWith(fakeMsg);
138
+ });
139
+
140
+ /**
141
+ * Test case: Consumer error handling (nack).
142
+ */
143
+ it("should nack messages if the processing fails", async () => {
144
+ await amqpInstance.connect();
145
+ const mockOnMessage = vi
146
+ .fn()
147
+ .mockRejectedValue(new Error("Processing failed"));
148
+
149
+ await amqpInstance.consume("error_queue", mockOnMessage);
150
+
151
+ const consumerCallback = mockChannel.consume.mock.calls[0][1];
152
+ const fakeMsg = {
153
+ content: Buffer.from(JSON.stringify({ some: "data" })),
154
+ };
155
+
156
+ await consumerCallback(fakeMsg);
157
+
158
+ expect(mockChannel.nack).toHaveBeenCalledWith(fakeMsg, false, false);
159
+ expect(mockChannel.ack).not.toHaveBeenCalled();
160
+ });
161
+
162
+ /**
163
+ * Test case: Graceful shutdown of channel and connection.
164
+ */
165
+ it("should close the connection cleanly", async () => {
166
+ await amqpInstance.connect();
167
+ await amqpInstance.close();
168
+
169
+ expect(mockChannel.close).toHaveBeenCalled();
170
+ expect(mockConnection.close).toHaveBeenCalled();
171
+ });
172
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true, // Permite usar 'describe', 'it', 'expect' sin importarlos
6
+ environment: "node",
7
+ },
8
+ });