@quatrain/queue-amqp 1.2.12 → 1.2.13
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.md +15 -0
- package/lib/AmqpQueueAdapter.d.ts +7 -0
- package/lib/AmqpQueueAdapter.js +11 -1
- package/package.json +41 -43
- package/src/AmqpQueueAdapter.test.ts +150 -0
- package/src/AmqpQueueAdapter.ts +127 -0
- package/src/index.ts +3 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# LICENSE UPDATE NOTICE
|
|
2
|
+
|
|
3
|
+
As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
|
|
4
|
+
Previous versions remain under the MIT License.
|
|
5
|
+
|
|
6
|
+
## Why AGPL?
|
|
7
|
+
|
|
8
|
+
We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
|
|
9
|
+
|
|
10
|
+
## Commercial Services & Enterprise Usage
|
|
11
|
+
|
|
12
|
+
We provide official deployment services, technical training, and certification for Quatrain Core.
|
|
13
|
+
For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
|
|
14
|
+
|
|
15
|
+
Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
|
|
@@ -5,5 +5,12 @@ export declare class AmqpQueueAdapter extends AbstractQueueAdapter {
|
|
|
5
5
|
protected _connect(): Promise<ChannelModel>;
|
|
6
6
|
protected _disconnect(): Promise<void>;
|
|
7
7
|
send(data: any, topic: string): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Listen to given queue and process messages with messageHandler function
|
|
10
|
+
* @param topic
|
|
11
|
+
* @param messageHandler
|
|
12
|
+
* @param params
|
|
13
|
+
* @returns
|
|
14
|
+
*/
|
|
8
15
|
listen(topic: string | undefined, messageHandler: Function, params?: any): Promise<amqplib.Replies.Consume>;
|
|
9
16
|
}
|
package/lib/AmqpQueueAdapter.js
CHANGED
|
@@ -45,6 +45,13 @@ class AmqpQueueAdapter extends queue_1.AbstractQueueAdapter {
|
|
|
45
45
|
return String(id);
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Listen to given queue and process messages with messageHandler function
|
|
50
|
+
* @param topic
|
|
51
|
+
* @param messageHandler
|
|
52
|
+
* @param params
|
|
53
|
+
* @returns
|
|
54
|
+
*/
|
|
48
55
|
listen(topic = this._params.topic, messageHandler, params) {
|
|
49
56
|
return __awaiter(this, void 0, void 0, function* () {
|
|
50
57
|
if (!topic) {
|
|
@@ -65,7 +72,10 @@ class AmqpQueueAdapter extends queue_1.AbstractQueueAdapter {
|
|
|
65
72
|
queue_1.Queue.info(`Job #${concurrents} of ${concurrency > 0 ? concurrency : 'unlimited'} started`);
|
|
66
73
|
try {
|
|
67
74
|
// 1. Process the message synchronously (wait for it to finish)
|
|
68
|
-
yield messageHandler(msg.content.toString(), params);
|
|
75
|
+
const result = yield messageHandler(msg.content.toString(), params);
|
|
76
|
+
if (result === false) {
|
|
77
|
+
throw new Error('messageHandler function failed, see status log in jobExecution for more information');
|
|
78
|
+
}
|
|
69
79
|
// 2. Acknowledge the message only after successful processing
|
|
70
80
|
channel.ack(msg);
|
|
71
81
|
// 3. Check if we reached the limit of messages to process
|
package/package.json
CHANGED
|
@@ -1,44 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
}
|
|
2
|
+
"name": "@quatrain/queue-amqp",
|
|
3
|
+
"version": "1.2.13",
|
|
4
|
+
"description": "Queue adapter for AMQP compatible queue",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"bun": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"LICENSE.md",
|
|
10
|
+
"src/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
15
|
+
"license": "AGPL-3.0-only",
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@tsconfig/recommended": "^1.0.1",
|
|
18
|
+
"@types/amqplib": "^0.10.8",
|
|
19
|
+
"@types/fs-extra": "^11.0.4",
|
|
20
|
+
"@types/jest": "^27.0.3",
|
|
21
|
+
"@types/node": "^22.10.1",
|
|
22
|
+
"@types/object-hash": "^3.0.6",
|
|
23
|
+
"jest": "^30.2.0",
|
|
24
|
+
"jest-node-exports-resolver": "^1.1.6",
|
|
25
|
+
"trace-unhandled": "^2.0.1",
|
|
26
|
+
"ts-jest": "^27.1.2",
|
|
27
|
+
"ts-node": "^10.4.0",
|
|
28
|
+
"typescript": "^5.1.5"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@cloudamqp/amqp-client": "^3.4.1",
|
|
32
|
+
"@quatrain/core": "^1.1.43",
|
|
33
|
+
"@quatrain/queue": "^1.1.20",
|
|
34
|
+
"amqplib": "^0.10.9"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test-ci": "jest --runInBand",
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"wbuild": "tsc --watch",
|
|
40
|
+
"bump-to": "yarn version"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { AmqpQueueAdapter } from './AmqpQueueAdapter'
|
|
2
|
+
import { AMQPClient, AMQPMessage } from '@cloudamqp/amqp-client'
|
|
3
|
+
import { Queue } from '@quatrain/queue'
|
|
4
|
+
|
|
5
|
+
// Mock the @cloudamqp/amqp-client library
|
|
6
|
+
jest.mock('@cloudamqp/amqp-client')
|
|
7
|
+
|
|
8
|
+
// Mock @quatrain/queue to spy on Queue.info
|
|
9
|
+
jest.mock('@quatrain/queue', () => ({
|
|
10
|
+
...jest.requireActual('@quatrain/queue'),
|
|
11
|
+
Queue: {
|
|
12
|
+
info: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const mockPublish = jest
|
|
17
|
+
.fn()
|
|
18
|
+
.mockResolvedValue({ confirmId: 'mock-confirm-id' })
|
|
19
|
+
const mockSubscribe = jest.fn()
|
|
20
|
+
const mockQueue = jest.fn().mockResolvedValue({
|
|
21
|
+
publish: mockPublish,
|
|
22
|
+
subscribe: mockSubscribe,
|
|
23
|
+
})
|
|
24
|
+
const mockChannel = jest.fn().mockResolvedValue({
|
|
25
|
+
queue: mockQueue,
|
|
26
|
+
})
|
|
27
|
+
const mockConnect = jest.fn().mockResolvedValue({
|
|
28
|
+
channel: mockChannel,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const AMQPClientMock = AMQPClient as jest.Mock
|
|
32
|
+
|
|
33
|
+
AMQPClientMock.mockImplementation(() => {
|
|
34
|
+
return {
|
|
35
|
+
connect: mockConnect,
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('AmqpQueueAdapter', () => {
|
|
40
|
+
const params = {
|
|
41
|
+
config: {
|
|
42
|
+
host: 'test-host',
|
|
43
|
+
user: 'test-user',
|
|
44
|
+
password: 'test-password',
|
|
45
|
+
port: 1234,
|
|
46
|
+
},
|
|
47
|
+
topic: 'default-topic',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
// Clear all instances and calls to constructor and all methods:
|
|
52
|
+
AMQPClientMock.mockClear()
|
|
53
|
+
mockConnect.mockClear()
|
|
54
|
+
mockChannel.mockClear()
|
|
55
|
+
mockQueue.mockClear()
|
|
56
|
+
mockPublish.mockClear()
|
|
57
|
+
mockSubscribe.mockClear()
|
|
58
|
+
;(Queue.info as jest.Mock).mockClear()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should construct with default and custom parameters', () => {
|
|
62
|
+
new AmqpQueueAdapter({ config: {} })
|
|
63
|
+
expect(AMQPClientMock).toHaveBeenCalledWith(
|
|
64
|
+
'amqp://guest:guest@localhost:5672?frameMax=0'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
new AmqpQueueAdapter(params)
|
|
68
|
+
expect(AMQPClientMock).toHaveBeenCalledWith(
|
|
69
|
+
'amqp://test-user:test-password@test-host:1234?frameMax=0'
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('send', () => {
|
|
74
|
+
it('should send a message to the specified topic', async () => {
|
|
75
|
+
const adapter = new AmqpQueueAdapter(params)
|
|
76
|
+
const data = { key: 'value' }
|
|
77
|
+
const topic = 'test-topic'
|
|
78
|
+
|
|
79
|
+
const confirmId = await adapter.send(data, topic)
|
|
80
|
+
|
|
81
|
+
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
82
|
+
expect(mockChannel).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(mockQueue).toHaveBeenCalledWith(topic)
|
|
84
|
+
expect(mockPublish).toHaveBeenCalledWith(
|
|
85
|
+
Buffer.from(JSON.stringify(data)),
|
|
86
|
+
{ deliveryMode: 2 }
|
|
87
|
+
)
|
|
88
|
+
expect(Queue.info).toHaveBeenCalledWith(
|
|
89
|
+
`[AMQP] Sending message to ${topic}`
|
|
90
|
+
)
|
|
91
|
+
expect(Queue.info).toHaveBeenCalledWith(
|
|
92
|
+
`[AMQP] Message send with id mock-confirm-id`
|
|
93
|
+
)
|
|
94
|
+
expect(confirmId).toBe('mock-confirm-id')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('listen', () => {
|
|
99
|
+
it('should listen to the default topic if none is provided', async () => {
|
|
100
|
+
const adapter = new AmqpQueueAdapter(params)
|
|
101
|
+
const handler = jest.fn()
|
|
102
|
+
|
|
103
|
+
await adapter.listen(undefined, handler)
|
|
104
|
+
|
|
105
|
+
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
106
|
+
expect(mockChannel).toHaveBeenCalledTimes(1)
|
|
107
|
+
expect(mockQueue).toHaveBeenCalledWith(params.topic)
|
|
108
|
+
expect(mockSubscribe).toHaveBeenCalledWith(
|
|
109
|
+
{ noAck: true },
|
|
110
|
+
expect.any(Function)
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should listen to a specific topic', async () => {
|
|
115
|
+
const adapter = new AmqpQueueAdapter(params)
|
|
116
|
+
const handler = jest.fn()
|
|
117
|
+
const topic = 'specific-topic'
|
|
118
|
+
|
|
119
|
+
await adapter.listen(topic, handler)
|
|
120
|
+
|
|
121
|
+
expect(mockQueue).toHaveBeenCalledWith(topic)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should throw an error if no topic is available', async () => {
|
|
125
|
+
const adapter = new AmqpQueueAdapter({ config: {} })
|
|
126
|
+
const handler = jest.fn()
|
|
127
|
+
|
|
128
|
+
await expect(adapter.listen(undefined, handler)).rejects.toThrow(
|
|
129
|
+
'No topic provided for listening.'
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should call the message handler with the message body', async () => {
|
|
134
|
+
const adapter = new AmqpQueueAdapter(params)
|
|
135
|
+
const handler = jest.fn()
|
|
136
|
+
const messageContent = { data: 'test message' }
|
|
137
|
+
const message = {
|
|
138
|
+
bodyToString: () => JSON.stringify(messageContent),
|
|
139
|
+
} as AMQPMessage
|
|
140
|
+
|
|
141
|
+
mockSubscribe.mockImplementation(async (options, callback) => {
|
|
142
|
+
await callback(message)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await adapter.listen('any-topic', handler)
|
|
146
|
+
|
|
147
|
+
expect(handler).toHaveBeenCalledWith(JSON.stringify(messageContent))
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AbstractQueueAdapter, Queue } from '@quatrain/queue'
|
|
2
|
+
import amqplib, { ChannelModel, ConsumeMessage, Message } from 'amqplib'
|
|
3
|
+
|
|
4
|
+
export class AmqpQueueAdapter extends AbstractQueueAdapter {
|
|
5
|
+
protected _client: ChannelModel | undefined
|
|
6
|
+
|
|
7
|
+
protected async _connect(): Promise<ChannelModel> {
|
|
8
|
+
if (!this._client) {
|
|
9
|
+
const {
|
|
10
|
+
host = 'localhost',
|
|
11
|
+
user = 'guest',
|
|
12
|
+
password = 'guest',
|
|
13
|
+
port = 5672,
|
|
14
|
+
} = this._params.config || {}
|
|
15
|
+
|
|
16
|
+
this._client = await amqplib.connect(
|
|
17
|
+
`amqp://${user}:${password}@${host}:${port}`
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return this._client
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
protected async _disconnect() {
|
|
25
|
+
await this._client?.close()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async send(data: any, topic: string): Promise<string> {
|
|
29
|
+
await this._connect()
|
|
30
|
+
|
|
31
|
+
const channel = await this._client?.createChannel()
|
|
32
|
+
|
|
33
|
+
const dataBuffer = Buffer.from(JSON.stringify(data))
|
|
34
|
+
Queue.info(`[AMQP] Sending message to ${topic}`)
|
|
35
|
+
|
|
36
|
+
channel?.sendToQueue(topic, dataBuffer)
|
|
37
|
+
channel?.close()
|
|
38
|
+
|
|
39
|
+
const id = Date.now() // fake
|
|
40
|
+
Queue.info(`[AMQP] Message send with id ${id}`)
|
|
41
|
+
|
|
42
|
+
return String(id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Listen to given queue and process messages with messageHandler function
|
|
47
|
+
* @param topic
|
|
48
|
+
* @param messageHandler
|
|
49
|
+
* @param params
|
|
50
|
+
* @returns
|
|
51
|
+
*/
|
|
52
|
+
async listen(
|
|
53
|
+
topic: string | undefined = this._params.topic,
|
|
54
|
+
messageHandler: Function,
|
|
55
|
+
params?: any
|
|
56
|
+
) {
|
|
57
|
+
if (!topic) {
|
|
58
|
+
throw new Error(`No topic provided for listening.`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const concurrency = params?.concurrency || 0
|
|
62
|
+
|
|
63
|
+
Queue.info(
|
|
64
|
+
`Starting to listen to topic ${topic} with max concurrency set to ${concurrency}`
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
let concurrents = 0
|
|
68
|
+
const client = await this._connect()
|
|
69
|
+
const channel = await client.createChannel()
|
|
70
|
+
|
|
71
|
+
await channel.assertQueue(topic, { durable: true, autoDelete: false })
|
|
72
|
+
await channel.prefetch(concurrency) // only get one message at a time
|
|
73
|
+
|
|
74
|
+
return channel.consume(
|
|
75
|
+
topic,
|
|
76
|
+
async (msg: ConsumeMessage | null) => {
|
|
77
|
+
if (msg === null || !channel) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
concurrents++
|
|
82
|
+
Queue.info(
|
|
83
|
+
`Job #${concurrents} of ${
|
|
84
|
+
concurrency > 0 ? concurrency : 'unlimited'
|
|
85
|
+
} started`
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// 1. Process the message synchronously (wait for it to finish)
|
|
90
|
+
const result = await messageHandler(
|
|
91
|
+
msg.content.toString(),
|
|
92
|
+
params
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (result === false) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'messageHandler function failed, see status log in jobExecution for more information'
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Acknowledge the message only after successful processing
|
|
102
|
+
channel.ack(msg)
|
|
103
|
+
|
|
104
|
+
// 3. Check if we reached the limit of messages to process
|
|
105
|
+
if (concurrency > 0 && concurrents >= concurrency) {
|
|
106
|
+
Queue.info(
|
|
107
|
+
`Reached max concurrency of ${concurrency}, disconnecting from queue`
|
|
108
|
+
)
|
|
109
|
+
await channel.close()
|
|
110
|
+
await client.close()
|
|
111
|
+
process.exit(0)
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
Queue.error(
|
|
115
|
+
`messageHandler function failed with message: ${
|
|
116
|
+
(err as Error).message
|
|
117
|
+
}`
|
|
118
|
+
)
|
|
119
|
+
// Negative acknowledge the message so it can be requeued or handled by DLQ
|
|
120
|
+
channel.nack(msg)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{ noAck: false } // disable auto-hack
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/index.ts
ADDED