amqp-resilient 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/connection/ConnectionManager.d.ts +84 -0
- package/dist/connection/ConnectionManager.d.ts.map +1 -0
- package/dist/connection/ConnectionManager.js +312 -0
- package/dist/connection/ConnectionManager.js.map +1 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.d.ts.map +1 -0
- package/dist/connection/index.js +2 -0
- package/dist/connection/index.js.map +1 -0
- package/dist/consumer/BaseConsumer.d.ts +131 -0
- package/dist/consumer/BaseConsumer.d.ts.map +1 -0
- package/dist/consumer/BaseConsumer.js +398 -0
- package/dist/consumer/BaseConsumer.js.map +1 -0
- package/dist/consumer/index.d.ts +2 -0
- package/dist/consumer/index.d.ts.map +1 -0
- package/dist/consumer/index.js +2 -0
- package/dist/consumer/index.js.map +1 -0
- package/dist/health/HealthService.d.ts +46 -0
- package/dist/health/HealthService.d.ts.map +1 -0
- package/dist/health/HealthService.js +85 -0
- package/dist/health/HealthService.js.map +1 -0
- package/dist/health/index.d.ts +2 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +2 -0
- package/dist/health/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/CircuitBreaker.d.ts +76 -0
- package/dist/patterns/CircuitBreaker.d.ts.map +1 -0
- package/dist/patterns/CircuitBreaker.js +156 -0
- package/dist/patterns/CircuitBreaker.js.map +1 -0
- package/dist/patterns/index.d.ts +2 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +2 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/publisher/BasePublisher.d.ts +87 -0
- package/dist/publisher/BasePublisher.d.ts.map +1 -0
- package/dist/publisher/BasePublisher.js +275 -0
- package/dist/publisher/BasePublisher.js.map +1 -0
- package/dist/publisher/index.d.ts +2 -0
- package/dist/publisher/index.d.ts.map +1 -0
- package/dist/publisher/index.js +2 -0
- package/dist/publisher/index.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 berkeerdo
|
|
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,257 @@
|
|
|
1
|
+
# amqp-resilient
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/amqp-resilient)
|
|
4
|
+
[](https://www.npmjs.com/package/amqp-resilient)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
|
|
9
|
+
Production-ready AMQP client for Node.js with built-in resilience patterns.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Auto-Reconnection** - Exponential backoff with jitter for stable reconnects
|
|
14
|
+
- **Circuit Breaker** - Prevent cascading failures with configurable thresholds
|
|
15
|
+
- **Retry with DLQ** - Failed messages retry with backoff, then route to Dead Letter Queue
|
|
16
|
+
- **Publisher Confirms** - Guaranteed message delivery with confirmation
|
|
17
|
+
- **Health Monitoring** - Built-in health checks for all connections
|
|
18
|
+
- **TypeScript** - Full type safety with generics support
|
|
19
|
+
- **ESM** - Native ES modules support
|
|
20
|
+
- **Zero Dependencies** - Only amqplib as peer dependency
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install amqp-resilient amqplib
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
### Connection
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { ConnectionManager } from 'amqp-resilient';
|
|
34
|
+
|
|
35
|
+
const connection = new ConnectionManager({
|
|
36
|
+
url: 'amqp://localhost:5672',
|
|
37
|
+
connectionName: 'my-service',
|
|
38
|
+
logger: console, // optional
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await connection.connect();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Consumer
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { BaseConsumer, type MessageContext } from 'amqp-resilient';
|
|
48
|
+
|
|
49
|
+
interface OrderMessage {
|
|
50
|
+
orderId: string;
|
|
51
|
+
amount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class OrderConsumer extends BaseConsumer<OrderMessage> {
|
|
55
|
+
constructor(connection: ConnectionManager) {
|
|
56
|
+
super(connection, {
|
|
57
|
+
queue: 'orders',
|
|
58
|
+
exchange: 'orders.exchange',
|
|
59
|
+
routingKeys: ['order.created', 'order.updated'],
|
|
60
|
+
prefetch: 10,
|
|
61
|
+
maxRetries: 3,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected async handle(message: OrderMessage, context: MessageContext): Promise<void> {
|
|
66
|
+
console.log(`Processing order ${message.orderId}`);
|
|
67
|
+
// Throw an error to trigger retry
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const consumer = new OrderConsumer(connection);
|
|
72
|
+
await consumer.start();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Publisher
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
import { BasePublisher } from 'amqp-resilient';
|
|
79
|
+
|
|
80
|
+
const publisher = new BasePublisher(connection, {
|
|
81
|
+
exchange: 'orders.exchange',
|
|
82
|
+
confirm: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await publisher.publish('order.created', {
|
|
86
|
+
orderId: '123',
|
|
87
|
+
amount: 99.99,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (result.success) {
|
|
91
|
+
console.log(`Published message ${result.messageId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Or throw on failure
|
|
95
|
+
await publisher.publishOrThrow('order.created', { orderId: '456' });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
### ConnectionManager
|
|
101
|
+
|
|
102
|
+
| Option | Type | Default | Description |
|
|
103
|
+
|--------|------|---------|-------------|
|
|
104
|
+
| `url` | string | - | Full AMQP URL (amqp://host:port) |
|
|
105
|
+
| `host` | string | - | RabbitMQ host (alternative to url) |
|
|
106
|
+
| `port` | number | 5672 | RabbitMQ port |
|
|
107
|
+
| `username` | string | guest | Username |
|
|
108
|
+
| `password` | string | guest | Password |
|
|
109
|
+
| `vhost` | string | / | Virtual host |
|
|
110
|
+
| `connectionName` | string | **required** | Name for logging and health checks |
|
|
111
|
+
| `prefetch` | number | 10 | Default channel prefetch count |
|
|
112
|
+
| `heartbeat` | number | 60 | Heartbeat interval in seconds |
|
|
113
|
+
| `maxReconnectAttempts` | number | 0 | Max reconnect attempts (0 = unlimited) |
|
|
114
|
+
| `initialReconnectDelay` | number | 1000 | Initial reconnect delay (ms) |
|
|
115
|
+
| `maxReconnectDelay` | number | 30000 | Max reconnect delay (ms) |
|
|
116
|
+
| `logger` | AmqpLogger | noop | Logger instance |
|
|
117
|
+
|
|
118
|
+
### BaseConsumer
|
|
119
|
+
|
|
120
|
+
| Option | Type | Default | Description |
|
|
121
|
+
|--------|------|---------|-------------|
|
|
122
|
+
| `queue` | string | **required** | Queue name |
|
|
123
|
+
| `exchange` | string | **required** | Exchange to bind |
|
|
124
|
+
| `routingKeys` | string[] | **required** | Routing keys to bind |
|
|
125
|
+
| `prefetch` | number | 10 | Consumer prefetch count |
|
|
126
|
+
| `maxRetries` | number | 3 | Max retries before DLQ |
|
|
127
|
+
| `initialRetryDelay` | number | 1000 | Initial retry delay (ms) |
|
|
128
|
+
| `maxRetryDelay` | number | 30000 | Max retry delay (ms) |
|
|
129
|
+
| `useCircuitBreaker` | boolean | true | Enable circuit breaker |
|
|
130
|
+
| `exchangeType` | ExchangeType | topic | Exchange type |
|
|
131
|
+
|
|
132
|
+
### BasePublisher
|
|
133
|
+
|
|
134
|
+
| Option | Type | Default | Description |
|
|
135
|
+
|--------|------|---------|-------------|
|
|
136
|
+
| `exchange` | string | **required** | Exchange name |
|
|
137
|
+
| `exchangeType` | ExchangeType | topic | Exchange type |
|
|
138
|
+
| `confirm` | boolean | true | Use publisher confirms |
|
|
139
|
+
| `maxRetries` | number | 3 | Max publish retries |
|
|
140
|
+
| `initialRetryDelay` | number | 100 | Initial retry delay (ms) |
|
|
141
|
+
| `maxRetryDelay` | number | 5000 | Max retry delay (ms) |
|
|
142
|
+
| `useCircuitBreaker` | boolean | true | Enable circuit breaker |
|
|
143
|
+
|
|
144
|
+
## Health & Metrics
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import { HealthService, ConnectionStatus } from 'amqp-resilient';
|
|
148
|
+
|
|
149
|
+
// Get overall health status
|
|
150
|
+
const status = HealthService.getOverallStatus();
|
|
151
|
+
// 'healthy' | 'degraded' | 'dead' | 'not_configured'
|
|
152
|
+
|
|
153
|
+
// Get specific connection status
|
|
154
|
+
const connStatus = HealthService.getStatus('my-connection');
|
|
155
|
+
// ConnectionStatus.CONNECTED | CONNECTING | DISCONNECTED | RECONNECTING | CLOSED
|
|
156
|
+
|
|
157
|
+
// Get all connection statuses
|
|
158
|
+
const allStatuses = HealthService.getAllStatuses();
|
|
159
|
+
// { 'my-connection': 'connected', 'other-connection': 'reconnecting' }
|
|
160
|
+
|
|
161
|
+
// Get connection stats
|
|
162
|
+
const stats = connection.getStats();
|
|
163
|
+
// {
|
|
164
|
+
// connected: true,
|
|
165
|
+
// reconnectAttempts: 0,
|
|
166
|
+
// channelCount: 2,
|
|
167
|
+
// lastConnectedAt: Date,
|
|
168
|
+
// }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Events
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
connection.on('connected', () => {
|
|
175
|
+
console.log('AMQP connected');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
connection.on('disconnected', () => {
|
|
179
|
+
console.log('AMQP disconnected');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
connection.on('reconnecting', (attempt) => {
|
|
183
|
+
console.log(`Reconnecting attempt ${attempt}`);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
connection.on('error', (error) => {
|
|
187
|
+
console.error('Connection error:', error);
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Circuit Breaker
|
|
192
|
+
|
|
193
|
+
Standalone circuit breaker that can be used independently:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { CircuitBreaker, CircuitBreakerOpenError } from 'amqp-resilient';
|
|
197
|
+
|
|
198
|
+
const breaker = new CircuitBreaker({
|
|
199
|
+
name: 'external-api',
|
|
200
|
+
failureThreshold: 5, // Open after 5 failures
|
|
201
|
+
resetTimeout: 30000, // Try half-open after 30s
|
|
202
|
+
successThreshold: 3, // Close after 3 successes in half-open
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await breaker.execute(async () => {
|
|
207
|
+
return await externalApiCall();
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof CircuitBreakerOpenError) {
|
|
211
|
+
console.log(`Circuit open, retry in ${error.remainingResetTime}ms`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Get circuit state
|
|
216
|
+
const state = breaker.getState();
|
|
217
|
+
// 'CLOSED' | 'OPEN' | 'HALF_OPEN'
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Logger Interface
|
|
221
|
+
|
|
222
|
+
Any logger that implements this interface works:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
interface AmqpLogger {
|
|
226
|
+
info(obj: object, msg?: string): void;
|
|
227
|
+
warn(obj: object, msg?: string): void;
|
|
228
|
+
error(obj: object, msg?: string): void;
|
|
229
|
+
debug(obj: object, msg?: string): void;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Examples: pino, winston, bunyan, console
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Dead Letter Queue (DLQ)
|
|
236
|
+
|
|
237
|
+
Messages that fail after max retries are automatically sent to a DLQ:
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
// Queue: orders
|
|
241
|
+
// DLQ: orders.dlq (auto-created)
|
|
242
|
+
|
|
243
|
+
// Message headers in DLQ:
|
|
244
|
+
// x-death-reason: 'max-retries-exceeded'
|
|
245
|
+
// x-death-count: 3
|
|
246
|
+
// x-original-routing-key: 'order.created'
|
|
247
|
+
// x-first-death-queue: 'orders'
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Requirements
|
|
251
|
+
|
|
252
|
+
- Node.js >= 18.0.0
|
|
253
|
+
- amqplib >= 0.10.0
|
|
254
|
+
|
|
255
|
+
## License
|
|
256
|
+
|
|
257
|
+
MIT © [berkeerdo](https://github.com/berkeerdo)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Channel, ConfirmChannel } from 'amqplib';
|
|
2
|
+
import { type AmqpLogger, type ConnectionOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* ConnectionManager - Manages AMQP connection with auto-reconnect
|
|
5
|
+
* Use one instance per logical connection purpose
|
|
6
|
+
*/
|
|
7
|
+
export declare class ConnectionManager {
|
|
8
|
+
private connection;
|
|
9
|
+
private sharedChannel;
|
|
10
|
+
private confirmChannel;
|
|
11
|
+
private reconnectAttempts;
|
|
12
|
+
private isShuttingDown;
|
|
13
|
+
private reconnectTimer;
|
|
14
|
+
private readonly createdChannels;
|
|
15
|
+
private readonly logger;
|
|
16
|
+
private readonly connectionUrl;
|
|
17
|
+
private readonly connectionName;
|
|
18
|
+
private readonly prefetch;
|
|
19
|
+
private readonly heartbeat;
|
|
20
|
+
private readonly maxReconnectAttempts;
|
|
21
|
+
private readonly initialReconnectDelay;
|
|
22
|
+
private readonly maxReconnectDelay;
|
|
23
|
+
constructor(options: ConnectionOptions);
|
|
24
|
+
/**
|
|
25
|
+
* Setup connection event handlers
|
|
26
|
+
*/
|
|
27
|
+
private setupConnectionHandlers;
|
|
28
|
+
/**
|
|
29
|
+
* Reset connection state on disconnect
|
|
30
|
+
*/
|
|
31
|
+
private resetConnectionState;
|
|
32
|
+
/**
|
|
33
|
+
* Connect to AMQP server
|
|
34
|
+
*/
|
|
35
|
+
connect(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Create a managed channel with error handlers
|
|
38
|
+
*/
|
|
39
|
+
private createManagedChannel;
|
|
40
|
+
/**
|
|
41
|
+
* Schedule reconnection with exponential backoff and jitter
|
|
42
|
+
*/
|
|
43
|
+
private scheduleReconnect;
|
|
44
|
+
/**
|
|
45
|
+
* Get the shared channel (for simple operations)
|
|
46
|
+
* Note: For consumers, use createChannel() instead
|
|
47
|
+
*/
|
|
48
|
+
getChannel(): Promise<Channel>;
|
|
49
|
+
/**
|
|
50
|
+
* Create a dedicated channel for a consumer
|
|
51
|
+
* Best Practice: Each consumer should have its own channel
|
|
52
|
+
*/
|
|
53
|
+
createChannel(): Promise<Channel>;
|
|
54
|
+
/**
|
|
55
|
+
* Get confirm channel for guaranteed delivery
|
|
56
|
+
*/
|
|
57
|
+
getConfirmChannel(): Promise<ConfirmChannel>;
|
|
58
|
+
/**
|
|
59
|
+
* Close connection gracefully
|
|
60
|
+
*/
|
|
61
|
+
close(): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Check if connected
|
|
64
|
+
*/
|
|
65
|
+
isConnected(): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Get connection name
|
|
68
|
+
*/
|
|
69
|
+
getConnectionName(): string;
|
|
70
|
+
/**
|
|
71
|
+
* Get logger instance (for consumers/publishers to inherit)
|
|
72
|
+
*/
|
|
73
|
+
getLogger(): AmqpLogger;
|
|
74
|
+
/**
|
|
75
|
+
* Get connection stats
|
|
76
|
+
*/
|
|
77
|
+
getStats(): {
|
|
78
|
+
connected: boolean;
|
|
79
|
+
reconnectAttempts: number;
|
|
80
|
+
channelCount: number;
|
|
81
|
+
hasConfirmChannel: boolean;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=ConnectionManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ConnectionManager.d.ts","sourceRoot":"","sources":["../../src/connection/ConnectionManager.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACvD,OAAO,EAAgC,KAAK,UAAU,EAAE,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAepG;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,aAAa,CAAwB;IAC7C,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAsB;IACtD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAC9C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;IAC/C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;gBAE/B,OAAO,EAAE,iBAAiB;IAwBtC;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAkC/B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAO5B;;OAEG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA0B9B;;OAEG;YACW,oBAAoB;IAwBlC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA2CzB;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAUpC;;;OAGG;IACG,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC;IAiBvC;;OAEG;IACG,iBAAiB,IAAI,OAAO,CAAC,cAAc,CAAC;IA8BlD;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0D5B;;OAEG;IACH,WAAW,IAAI,OAAO;IAItB;;OAEG;IACH,iBAAiB,IAAI,MAAM;IAI3B;;OAEG;IACH,SAAS,IAAI,UAAU;IAIvB;;OAEG;IACH,QAAQ,IAAI;QACV,SAAS,EAAE,OAAO,CAAC;QACnB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,YAAY,EAAE,MAAM,CAAC;QACrB,iBAAiB,EAAE,OAAO,CAAC;KAC5B;CAQF"}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AMQP Connection Manager
|
|
3
|
+
* Production-ready implementation with:
|
|
4
|
+
* - Automatic reconnection with exponential backoff and jitter
|
|
5
|
+
* - Connection heartbeat for health monitoring
|
|
6
|
+
* - Dedicated channels for consumers (one channel per consumer)
|
|
7
|
+
* - Confirm channels for publishers (guaranteed delivery)
|
|
8
|
+
* - Graceful shutdown handling
|
|
9
|
+
*
|
|
10
|
+
* Best Practices:
|
|
11
|
+
* 1. Use separate channels for publishing and consuming
|
|
12
|
+
* 2. Never share channels between consumers
|
|
13
|
+
* 3. Use confirm channels for critical messages
|
|
14
|
+
* 4. Implement proper error handling and reconnection
|
|
15
|
+
* 5. Use heartbeats to detect dead connections
|
|
16
|
+
*/
|
|
17
|
+
import amqplib from 'amqplib';
|
|
18
|
+
import { ConnectionStatus, noopLogger } from '../types.js';
|
|
19
|
+
import { HealthService } from '../health/HealthService.js';
|
|
20
|
+
/** Default configuration values */
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
INITIAL_RECONNECT_DELAY: 1000,
|
|
23
|
+
MAX_RECONNECT_DELAY: 60000,
|
|
24
|
+
MAX_RECONNECT_ATTEMPTS: 0, // unlimited
|
|
25
|
+
HEARTBEAT_SECONDS: 60,
|
|
26
|
+
PREFETCH: 10,
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* ConnectionManager - Manages AMQP connection with auto-reconnect
|
|
30
|
+
* Use one instance per logical connection purpose
|
|
31
|
+
*/
|
|
32
|
+
export class ConnectionManager {
|
|
33
|
+
connection = null;
|
|
34
|
+
sharedChannel = null;
|
|
35
|
+
confirmChannel = null;
|
|
36
|
+
reconnectAttempts = 0;
|
|
37
|
+
isShuttingDown = false;
|
|
38
|
+
reconnectTimer = null;
|
|
39
|
+
createdChannels = new Set();
|
|
40
|
+
logger;
|
|
41
|
+
connectionUrl;
|
|
42
|
+
connectionName;
|
|
43
|
+
prefetch;
|
|
44
|
+
heartbeat;
|
|
45
|
+
maxReconnectAttempts;
|
|
46
|
+
initialReconnectDelay;
|
|
47
|
+
maxReconnectDelay;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.connectionName = options.connectionName ?? 'default';
|
|
50
|
+
this.prefetch = options.prefetch ?? DEFAULTS.PREFETCH;
|
|
51
|
+
this.heartbeat = options.heartbeat ?? DEFAULTS.HEARTBEAT_SECONDS;
|
|
52
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULTS.MAX_RECONNECT_ATTEMPTS;
|
|
53
|
+
this.initialReconnectDelay = options.initialReconnectDelay ?? DEFAULTS.INITIAL_RECONNECT_DELAY;
|
|
54
|
+
this.maxReconnectDelay = options.maxReconnectDelay ?? DEFAULTS.MAX_RECONNECT_DELAY;
|
|
55
|
+
this.logger = options.logger ?? noopLogger;
|
|
56
|
+
// Build connection URL from individual params or use provided URL
|
|
57
|
+
if (options.url) {
|
|
58
|
+
this.connectionUrl = options.url;
|
|
59
|
+
}
|
|
60
|
+
else if (options.host) {
|
|
61
|
+
const username = encodeURIComponent(options.username ?? 'guest');
|
|
62
|
+
const password = encodeURIComponent(options.password ?? 'guest');
|
|
63
|
+
const host = options.host;
|
|
64
|
+
const port = options.port ?? 5672;
|
|
65
|
+
const vhost = encodeURIComponent(options.vhost ?? '/');
|
|
66
|
+
this.connectionUrl = `amqp://${username}:${password}@${host}:${port}/${vhost}`;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
throw new Error('ConnectionManager requires either url or host parameter');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Setup connection event handlers
|
|
74
|
+
*/
|
|
75
|
+
setupConnectionHandlers() {
|
|
76
|
+
if (!this.connection) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.connection.on('error', (err) => {
|
|
80
|
+
this.logger.error({ err, connectionName: this.connectionName }, 'AMQP connection error');
|
|
81
|
+
});
|
|
82
|
+
this.connection.on('close', () => {
|
|
83
|
+
if (this.isShuttingDown) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.logger.warn({ connectionName: this.connectionName }, 'AMQP connection closed unexpectedly, scheduling reconnect...');
|
|
87
|
+
this.resetConnectionState();
|
|
88
|
+
HealthService.registerStatus(this.connectionName, ConnectionStatus.RECONNECTING);
|
|
89
|
+
this.scheduleReconnect();
|
|
90
|
+
});
|
|
91
|
+
this.connection.on('blocked', (reason) => {
|
|
92
|
+
this.logger.warn({ connectionName: this.connectionName, reason }, 'AMQP connection blocked by broker');
|
|
93
|
+
});
|
|
94
|
+
this.connection.on('unblocked', () => {
|
|
95
|
+
this.logger.info({ connectionName: this.connectionName }, 'AMQP connection unblocked');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reset connection state on disconnect
|
|
100
|
+
*/
|
|
101
|
+
resetConnectionState() {
|
|
102
|
+
this.connection = null;
|
|
103
|
+
this.sharedChannel = null;
|
|
104
|
+
this.confirmChannel = null;
|
|
105
|
+
this.createdChannels.clear();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Connect to AMQP server
|
|
109
|
+
*/
|
|
110
|
+
async connect() {
|
|
111
|
+
if (this.connection) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
HealthService.registerStatus(this.connectionName, ConnectionStatus.CONNECTING);
|
|
115
|
+
try {
|
|
116
|
+
this.logger.info({ connectionName: this.connectionName }, 'Connecting to AMQP server...');
|
|
117
|
+
this.connection = await amqplib.connect(this.connectionUrl, { heartbeat: this.heartbeat });
|
|
118
|
+
this.reconnectAttempts = 0;
|
|
119
|
+
this.setupConnectionHandlers();
|
|
120
|
+
this.sharedChannel = await this.createManagedChannel();
|
|
121
|
+
HealthService.registerStatus(this.connectionName, ConnectionStatus.CONNECTED);
|
|
122
|
+
this.logger.info({ connectionName: this.connectionName }, 'AMQP connected successfully');
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.logger.error({ err: error, connectionName: this.connectionName }, 'Failed to connect to AMQP server');
|
|
126
|
+
HealthService.registerStatus(this.connectionName, ConnectionStatus.DISCONNECTED);
|
|
127
|
+
this.scheduleReconnect();
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create a managed channel with error handlers
|
|
133
|
+
*/
|
|
134
|
+
async createManagedChannel() {
|
|
135
|
+
if (!this.connection) {
|
|
136
|
+
throw new Error('Not connected to AMQP server');
|
|
137
|
+
}
|
|
138
|
+
const channel = await this.connection.createChannel();
|
|
139
|
+
await channel.prefetch(this.prefetch);
|
|
140
|
+
channel.on('error', (err) => {
|
|
141
|
+
this.logger.error({ err, connectionName: this.connectionName }, 'AMQP channel error');
|
|
142
|
+
this.createdChannels.delete(channel);
|
|
143
|
+
});
|
|
144
|
+
channel.on('close', () => {
|
|
145
|
+
if (!this.isShuttingDown) {
|
|
146
|
+
this.logger.warn({ connectionName: this.connectionName }, 'AMQP channel closed');
|
|
147
|
+
}
|
|
148
|
+
this.createdChannels.delete(channel);
|
|
149
|
+
});
|
|
150
|
+
this.createdChannels.add(channel);
|
|
151
|
+
return channel;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Schedule reconnection with exponential backoff and jitter
|
|
155
|
+
*/
|
|
156
|
+
scheduleReconnect() {
|
|
157
|
+
if (this.isShuttingDown || this.reconnectTimer) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.reconnectAttempts++;
|
|
161
|
+
// Check max attempts (0 = unlimited)
|
|
162
|
+
if (this.maxReconnectAttempts > 0 && this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
163
|
+
this.logger.error({ connectionName: this.connectionName, attempts: this.reconnectAttempts }, 'Max reconnection attempts reached, marking connection as dead');
|
|
164
|
+
HealthService.registerStatus(this.connectionName, ConnectionStatus.DEAD);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Calculate delay with exponential backoff and jitter
|
|
168
|
+
const exponentialDelay = Math.min(this.initialReconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
|
|
169
|
+
// Add jitter (0-25% of delay) to prevent thundering herd
|
|
170
|
+
const jitter = Math.random() * exponentialDelay * 0.25;
|
|
171
|
+
const delay = Math.floor(exponentialDelay + jitter);
|
|
172
|
+
this.logger.info({
|
|
173
|
+
connectionName: this.connectionName,
|
|
174
|
+
attempt: this.reconnectAttempts,
|
|
175
|
+
delayMs: delay,
|
|
176
|
+
}, 'Scheduling AMQP reconnection...');
|
|
177
|
+
this.reconnectTimer = setTimeout(() => {
|
|
178
|
+
this.reconnectTimer = null;
|
|
179
|
+
this.connect().catch(() => {
|
|
180
|
+
// Error already logged in connect()
|
|
181
|
+
});
|
|
182
|
+
}, delay);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Get the shared channel (for simple operations)
|
|
186
|
+
* Note: For consumers, use createChannel() instead
|
|
187
|
+
*/
|
|
188
|
+
async getChannel() {
|
|
189
|
+
if (!this.sharedChannel) {
|
|
190
|
+
await this.connect();
|
|
191
|
+
}
|
|
192
|
+
if (!this.sharedChannel) {
|
|
193
|
+
throw new Error('Failed to get AMQP channel');
|
|
194
|
+
}
|
|
195
|
+
return this.sharedChannel;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create a dedicated channel for a consumer
|
|
199
|
+
* Best Practice: Each consumer should have its own channel
|
|
200
|
+
*/
|
|
201
|
+
async createChannel() {
|
|
202
|
+
if (!this.connection) {
|
|
203
|
+
await this.connect();
|
|
204
|
+
}
|
|
205
|
+
if (!this.connection) {
|
|
206
|
+
throw new Error('Failed to get AMQP connection');
|
|
207
|
+
}
|
|
208
|
+
const channel = await this.createManagedChannel();
|
|
209
|
+
this.logger.debug({ connectionName: this.connectionName, channelCount: this.createdChannels.size }, 'Created dedicated channel');
|
|
210
|
+
return channel;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Get confirm channel for guaranteed delivery
|
|
214
|
+
*/
|
|
215
|
+
async getConfirmChannel() {
|
|
216
|
+
if (!this.confirmChannel) {
|
|
217
|
+
if (!this.connection) {
|
|
218
|
+
await this.connect();
|
|
219
|
+
}
|
|
220
|
+
if (!this.connection) {
|
|
221
|
+
throw new Error('Failed to get AMQP connection');
|
|
222
|
+
}
|
|
223
|
+
this.confirmChannel = await this.connection.createConfirmChannel();
|
|
224
|
+
await this.confirmChannel.prefetch(this.prefetch);
|
|
225
|
+
this.confirmChannel.on('error', (err) => {
|
|
226
|
+
this.logger.error({ err, connectionName: this.connectionName }, 'AMQP confirm channel error');
|
|
227
|
+
this.confirmChannel = null;
|
|
228
|
+
});
|
|
229
|
+
this.confirmChannel.on('close', () => {
|
|
230
|
+
if (!this.isShuttingDown) {
|
|
231
|
+
this.logger.warn({ connectionName: this.connectionName }, 'AMQP confirm channel closed');
|
|
232
|
+
}
|
|
233
|
+
this.confirmChannel = null;
|
|
234
|
+
});
|
|
235
|
+
this.logger.debug({ connectionName: this.connectionName }, 'Created confirm channel');
|
|
236
|
+
}
|
|
237
|
+
return this.confirmChannel;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Close connection gracefully
|
|
241
|
+
*/
|
|
242
|
+
async close() {
|
|
243
|
+
this.isShuttingDown = true;
|
|
244
|
+
// Cancel reconnection timer
|
|
245
|
+
if (this.reconnectTimer) {
|
|
246
|
+
clearTimeout(this.reconnectTimer);
|
|
247
|
+
this.reconnectTimer = null;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
// Close all created channels
|
|
251
|
+
const closePromises = [];
|
|
252
|
+
for (const channel of this.createdChannels) {
|
|
253
|
+
closePromises.push(channel.close().catch((err) => {
|
|
254
|
+
this.logger.debug({ err }, 'Error closing channel');
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
if (this.sharedChannel && !this.createdChannels.has(this.sharedChannel)) {
|
|
258
|
+
closePromises.push(this.sharedChannel.close().catch((err) => {
|
|
259
|
+
this.logger.debug({ err }, 'Error closing shared channel');
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
if (this.confirmChannel) {
|
|
263
|
+
closePromises.push(this.confirmChannel.close().catch((err) => {
|
|
264
|
+
this.logger.debug({ err }, 'Error closing confirm channel');
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
await Promise.all(closePromises);
|
|
268
|
+
if (this.connection) {
|
|
269
|
+
await this.connection.close();
|
|
270
|
+
}
|
|
271
|
+
this.connection = null;
|
|
272
|
+
this.sharedChannel = null;
|
|
273
|
+
this.confirmChannel = null;
|
|
274
|
+
this.createdChannels.clear();
|
|
275
|
+
HealthService.unregisterConnection(this.connectionName);
|
|
276
|
+
this.logger.info({ connectionName: this.connectionName }, 'AMQP connection closed gracefully');
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
this.logger.error({ err: error, connectionName: this.connectionName }, 'Error closing AMQP connection');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Check if connected
|
|
284
|
+
*/
|
|
285
|
+
isConnected() {
|
|
286
|
+
return this.connection !== null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Get connection name
|
|
290
|
+
*/
|
|
291
|
+
getConnectionName() {
|
|
292
|
+
return this.connectionName;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Get logger instance (for consumers/publishers to inherit)
|
|
296
|
+
*/
|
|
297
|
+
getLogger() {
|
|
298
|
+
return this.logger;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get connection stats
|
|
302
|
+
*/
|
|
303
|
+
getStats() {
|
|
304
|
+
return {
|
|
305
|
+
connected: this.isConnected(),
|
|
306
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
307
|
+
channelCount: this.createdChannels.size,
|
|
308
|
+
hasConfirmChannel: this.confirmChannel !== null,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
//# sourceMappingURL=ConnectionManager.js.map
|