@rocketmq/core 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.
- package/.turbo/turbo-build.log +21 -0
- package/dist/index.cjs +385 -0
- package/dist/index.d.cts +160 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +350 -0
- package/package.json +21 -0
- package/src/client.test.ts +333 -0
- package/src/client.ts +272 -0
- package/src/errors.test.ts +132 -0
- package/src/errors.ts +70 -0
- package/src/index.ts +25 -0
- package/src/queue-handle.test.ts +185 -0
- package/src/queue-handle.ts +102 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +8 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for RocketMQ client and connect().
|
|
3
|
+
*
|
|
4
|
+
* Mocks AmqpConnection to avoid real AMQP connections.
|
|
5
|
+
* Covers: connect (success/failure), assertQueue (with/without schema),
|
|
6
|
+
* assertExchange, bindQueue, sendToQueue (valid/invalid),
|
|
7
|
+
* publish, consume, ack, nack, prefetch, close.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Field, Schema, SchemaRegistry } from '@rocketmq/schema';
|
|
11
|
+
import { JsonSerializer } from '@rocketmq/serializer';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { connect, RocketMQ } from './client.js';
|
|
14
|
+
import { ConnectionError, ConsumeError, PublishError, QueueError } from './errors.js';
|
|
15
|
+
|
|
16
|
+
// Mock AmqpConnection for connect() tests
|
|
17
|
+
vi.mock('@rocketmq/amqp', async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import('@rocketmq/amqp')>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
AmqpConnection: {
|
|
22
|
+
connect: vi.fn().mockResolvedValue({
|
|
23
|
+
createChannel: vi.fn().mockResolvedValue({
|
|
24
|
+
assertQueue: vi.fn().mockResolvedValue({ queue: 'q', messageCount: 0, consumerCount: 0 }),
|
|
25
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
}),
|
|
27
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
/** Named fake channel mock. */
|
|
33
|
+
class FakeAmqpChannel {
|
|
34
|
+
raw = this;
|
|
35
|
+
assertQueue = vi.fn().mockResolvedValue({ queue: 'q', messageCount: 0, consumerCount: 0 });
|
|
36
|
+
assertExchange = vi.fn().mockResolvedValue({ exchange: 'ex' });
|
|
37
|
+
bindQueue = vi.fn().mockResolvedValue({});
|
|
38
|
+
sendToQueue = vi.fn().mockReturnValue(true);
|
|
39
|
+
publish = vi.fn().mockReturnValue(true);
|
|
40
|
+
consume = vi.fn().mockResolvedValue({ consumerTag: 'tag-1' });
|
|
41
|
+
ack = vi.fn();
|
|
42
|
+
nack = vi.fn();
|
|
43
|
+
prefetch = vi.fn().mockResolvedValue(undefined);
|
|
44
|
+
close = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Named fake connection mock. */
|
|
48
|
+
class FakeAmqpConnection {
|
|
49
|
+
close = vi.fn().mockResolvedValue(undefined);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('RocketMQ', () => {
|
|
53
|
+
let ch: FakeAmqpChannel;
|
|
54
|
+
let conn: FakeAmqpConnection;
|
|
55
|
+
let registry: SchemaRegistry;
|
|
56
|
+
let mq: RocketMQ;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
ch = new FakeAmqpChannel();
|
|
60
|
+
conn = new FakeAmqpConnection();
|
|
61
|
+
registry = new SchemaRegistry();
|
|
62
|
+
mq = new RocketMQ(conn as never, ch as never, registry, new JsonSerializer());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('exposes channel via .channel', () => {
|
|
66
|
+
expect(mq.channel).toBe(ch);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('assertQueue', () => {
|
|
70
|
+
it('asserts queue without schema', async () => {
|
|
71
|
+
await mq.assertQueue('plain-q');
|
|
72
|
+
expect(ch.assertQueue).toHaveBeenCalledWith('plain-q', undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('asserts queue with schema and registers entry', async () => {
|
|
76
|
+
@Schema()
|
|
77
|
+
class TestItem {
|
|
78
|
+
@Field()
|
|
79
|
+
id!: string;
|
|
80
|
+
}
|
|
81
|
+
new TestItem();
|
|
82
|
+
|
|
83
|
+
await mq.assertQueue('items', TestItem);
|
|
84
|
+
|
|
85
|
+
expect(ch.assertQueue).toHaveBeenCalledWith(
|
|
86
|
+
'items',
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
arguments: expect.objectContaining({
|
|
89
|
+
'x-schema': expect.stringContaining('proto3'),
|
|
90
|
+
'x-schema-type': 'protobuf',
|
|
91
|
+
'x-schema-message': 'TestItem',
|
|
92
|
+
}),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Verify registry was populated
|
|
97
|
+
const entry = registry.lookup('items');
|
|
98
|
+
expect(entry).toBeDefined();
|
|
99
|
+
expect(entry?.name).toBe('TestItem');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('merges user-provided queue options with schema args', async () => {
|
|
103
|
+
@Schema()
|
|
104
|
+
class MergeTest {
|
|
105
|
+
@Field()
|
|
106
|
+
x!: string;
|
|
107
|
+
}
|
|
108
|
+
new MergeTest();
|
|
109
|
+
|
|
110
|
+
await mq.assertQueue('merge-q', MergeTest, {
|
|
111
|
+
durable: true,
|
|
112
|
+
arguments: { 'x-max-length': 1000 },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(ch.assertQueue).toHaveBeenCalledWith(
|
|
116
|
+
'merge-q',
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
durable: true,
|
|
119
|
+
arguments: expect.objectContaining({
|
|
120
|
+
'x-max-length': 1000,
|
|
121
|
+
'x-schema': expect.any(String),
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws QueueError when channel rejects', async () => {
|
|
128
|
+
@Schema()
|
|
129
|
+
class QErr {
|
|
130
|
+
@Field()
|
|
131
|
+
v!: string;
|
|
132
|
+
}
|
|
133
|
+
new QErr();
|
|
134
|
+
|
|
135
|
+
ch.assertQueue.mockRejectedValue(new Error('PRECONDITION_FAILED'));
|
|
136
|
+
await expect(mq.assertQueue('fail-q', QErr)).rejects.toThrow(QueueError);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('never sends x-schema-subject as queue arg (triggers Confluent wire validation)', async () => {
|
|
140
|
+
@Schema('notifications.v1')
|
|
141
|
+
class SubjectTest {
|
|
142
|
+
@Field()
|
|
143
|
+
id!: string;
|
|
144
|
+
}
|
|
145
|
+
new SubjectTest();
|
|
146
|
+
|
|
147
|
+
await mq.assertQueue('subject-q', SubjectTest);
|
|
148
|
+
const callArgs = ch.assertQueue.mock.calls[0][1];
|
|
149
|
+
expect(callArgs.arguments).not.toHaveProperty('x-schema-subject');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('stores subject in client registry even though it is not sent to broker', async () => {
|
|
153
|
+
@Schema('orders.v2')
|
|
154
|
+
class RegistrySubject {
|
|
155
|
+
@Field()
|
|
156
|
+
x!: string;
|
|
157
|
+
}
|
|
158
|
+
new RegistrySubject();
|
|
159
|
+
|
|
160
|
+
await mq.assertQueue('reg-q', RegistrySubject);
|
|
161
|
+
const entry = registry.lookup('reg-q');
|
|
162
|
+
expect(entry?.subject).toBe('orders.v2');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('queue (typed handle)', () => {
|
|
167
|
+
it('returns a QueueHandle after asserting', async () => {
|
|
168
|
+
@Schema()
|
|
169
|
+
class HandleTest {
|
|
170
|
+
@Field()
|
|
171
|
+
id!: string;
|
|
172
|
+
}
|
|
173
|
+
new HandleTest();
|
|
174
|
+
|
|
175
|
+
const handle = await mq.queue('handle-q', HandleTest);
|
|
176
|
+
expect(handle).toBeDefined();
|
|
177
|
+
expect(typeof handle.send).toBe('function');
|
|
178
|
+
expect(typeof handle.consume).toBe('function');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('assertExchange', () => {
|
|
183
|
+
it('delegates to channel', async () => {
|
|
184
|
+
await mq.assertExchange('my-ex', 'direct', { durable: true });
|
|
185
|
+
expect(ch.assertExchange).toHaveBeenCalledWith('my-ex', 'direct', { durable: true });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('bindQueue', () => {
|
|
190
|
+
it('delegates to channel', async () => {
|
|
191
|
+
await mq.bindQueue('q', 'ex', 'key');
|
|
192
|
+
expect(ch.bindQueue).toHaveBeenCalledWith('q', 'ex', 'key');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('sendToQueue (untyped)', () => {
|
|
197
|
+
it('serializes and sends valid payload', () => {
|
|
198
|
+
const result = mq.sendToQueue('plain-q', { key: 'value' });
|
|
199
|
+
expect(result).toBe(true);
|
|
200
|
+
expect(ch.sendToQueue).toHaveBeenCalledWith(
|
|
201
|
+
'plain-q',
|
|
202
|
+
expect.any(Buffer),
|
|
203
|
+
expect.objectContaining({ contentType: 'application/json', persistent: true }),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('throws PublishError when channel.sendToQueue throws', () => {
|
|
208
|
+
ch.sendToQueue.mockImplementation(() => {
|
|
209
|
+
throw new Error('closed');
|
|
210
|
+
});
|
|
211
|
+
expect(() => mq.sendToQueue('q', { ok: true })).toThrow(PublishError);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('publish', () => {
|
|
216
|
+
it('serializes and publishes to exchange', () => {
|
|
217
|
+
const result = mq.publish('ex', 'key', { data: 1 });
|
|
218
|
+
expect(result).toBe(true);
|
|
219
|
+
expect(ch.publish).toHaveBeenCalledWith(
|
|
220
|
+
'ex',
|
|
221
|
+
'key',
|
|
222
|
+
expect.any(Buffer),
|
|
223
|
+
expect.objectContaining({ contentType: 'application/json' }),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('throws PublishError when channel.publish throws', () => {
|
|
228
|
+
ch.publish.mockImplementation(() => {
|
|
229
|
+
throw new Error('closed');
|
|
230
|
+
});
|
|
231
|
+
expect(() => mq.publish('ex', 'key', {})).toThrow(PublishError);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('consume (untyped)', () => {
|
|
236
|
+
it('subscribes and returns consumer tag', async () => {
|
|
237
|
+
const handler = vi.fn();
|
|
238
|
+
const tag = await mq.consume('q', handler);
|
|
239
|
+
expect(tag).toBe('tag-1');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('deserializes and calls handler', async () => {
|
|
243
|
+
const handler = vi.fn();
|
|
244
|
+
ch.consume.mockImplementation(async (_q: string, cb: Function) => {
|
|
245
|
+
cb({ content: Buffer.from('{"a":1}'), fields: {}, properties: {} });
|
|
246
|
+
return { consumerTag: 't' };
|
|
247
|
+
});
|
|
248
|
+
await mq.consume('q', handler);
|
|
249
|
+
expect(handler).toHaveBeenCalledWith({ a: 1 }, expect.anything());
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('ignores null messages', async () => {
|
|
253
|
+
const handler = vi.fn();
|
|
254
|
+
ch.consume.mockImplementation(async (_q: string, cb: Function) => {
|
|
255
|
+
cb(null);
|
|
256
|
+
return { consumerTag: 't' };
|
|
257
|
+
});
|
|
258
|
+
await mq.consume('q', handler);
|
|
259
|
+
expect(handler).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('logs deserialization errors without crashing', async () => {
|
|
263
|
+
const handler = vi.fn();
|
|
264
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
265
|
+
ch.consume.mockImplementation(async (_q: string, cb: Function) => {
|
|
266
|
+
cb({ content: Buffer.from('not-json'), fields: {}, properties: {} });
|
|
267
|
+
return { consumerTag: 't' };
|
|
268
|
+
});
|
|
269
|
+
await mq.consume('q', handler);
|
|
270
|
+
expect(handler).not.toHaveBeenCalled();
|
|
271
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
272
|
+
consoleSpy.mockRestore();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('throws ConsumeError when channel.consume rejects', async () => {
|
|
276
|
+
ch.consume.mockRejectedValue(new Error('NOT_FOUND'));
|
|
277
|
+
await expect(mq.consume('q', vi.fn())).rejects.toThrow(ConsumeError);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('ack / nack', () => {
|
|
282
|
+
it('delegates ack to channel', () => {
|
|
283
|
+
const msg = { content: Buffer.from('') } as never;
|
|
284
|
+
mq.ack(msg);
|
|
285
|
+
expect(ch.ack).toHaveBeenCalledWith(msg);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('delegates nack to channel', () => {
|
|
289
|
+
const msg = { content: Buffer.from('') } as never;
|
|
290
|
+
mq.nack(msg, true);
|
|
291
|
+
expect(ch.nack).toHaveBeenCalledWith(msg, true);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('prefetch', () => {
|
|
296
|
+
it('delegates to channel', async () => {
|
|
297
|
+
await mq.prefetch(10);
|
|
298
|
+
expect(ch.prefetch).toHaveBeenCalledWith(10);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('close', () => {
|
|
303
|
+
it('closes channel and connection', async () => {
|
|
304
|
+
await mq.close();
|
|
305
|
+
expect(ch.close).toHaveBeenCalled();
|
|
306
|
+
expect(conn.close).toHaveBeenCalled();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('connect()', () => {
|
|
312
|
+
it('returns a RocketMQ instance on success', async () => {
|
|
313
|
+
const mq = await connect({ url: 'amqp://localhost' });
|
|
314
|
+
expect(mq).toBeInstanceOf(RocketMQ);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('uses default URL when none provided', async () => {
|
|
318
|
+
const mq = await connect();
|
|
319
|
+
expect(mq).toBeInstanceOf(RocketMQ);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('accepts a custom serializer', async () => {
|
|
323
|
+
const custom = new JsonSerializer();
|
|
324
|
+
const mq = await connect({ serializer: custom });
|
|
325
|
+
expect(mq).toBeInstanceOf(RocketMQ);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('throws ConnectionError when connection fails', async () => {
|
|
329
|
+
const { AmqpConnection } = await import('@rocketmq/amqp');
|
|
330
|
+
vi.mocked(AmqpConnection.connect).mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
331
|
+
await expect(connect({ url: 'amqp://bad-host' })).rejects.toThrow(ConnectionError);
|
|
332
|
+
});
|
|
333
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RocketMQ TypeScript client — schema-aware AMQP wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Composes schema, validation, serialization, and AMQP packages into
|
|
5
|
+
* a single user-facing API. Hides all internal wiring behind `connect()`
|
|
6
|
+
* and `mq.queue()`.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const mq = await connect();
|
|
10
|
+
* const orders = mq.queue("orders", Order);
|
|
11
|
+
* orders.send({ id: "1", customerId: "c1", qty: 5 });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AmqpConnection,
|
|
16
|
+
type AmqpChannel,
|
|
17
|
+
type AssertExchangeOptions,
|
|
18
|
+
type AssertExchangeReply,
|
|
19
|
+
type AssertQueueOptions,
|
|
20
|
+
type AssertQueueReply,
|
|
21
|
+
type ConsumeMessage,
|
|
22
|
+
type EmptyReply,
|
|
23
|
+
type PublishOptions,
|
|
24
|
+
} from '@rocketmq/amqp';
|
|
25
|
+
import { toProto } from '@rocketmq/protobuf';
|
|
26
|
+
import { defaultRegistry, type SchemaRegistry } from '@rocketmq/schema';
|
|
27
|
+
import { JsonSerializer, type Serializer } from '@rocketmq/serializer';
|
|
28
|
+
import { ConnectionError, ConsumeError, PublishError, QueueError } from './errors.js';
|
|
29
|
+
import { QueueHandle } from './queue-handle.js';
|
|
30
|
+
|
|
31
|
+
export interface RocketOptions {
|
|
32
|
+
/** AMQP connection URL. Default: amqp://guest:guest@localhost:5672 */
|
|
33
|
+
url?: string;
|
|
34
|
+
/** Custom serializer. Default: JsonSerializer. */
|
|
35
|
+
serializer?: Serializer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Opens a connection + channel ready for schema-aware operations. */
|
|
39
|
+
export async function connect(opts: RocketOptions = {}): Promise<RocketMQ> {
|
|
40
|
+
const url = opts.url ?? 'amqp://guest:guest@localhost:5672';
|
|
41
|
+
const serializer = opts.serializer ?? new JsonSerializer();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const conn = await AmqpConnection.connect(url);
|
|
45
|
+
const ch = await conn.createChannel();
|
|
46
|
+
return new RocketMQ(conn, ch, defaultRegistry, serializer);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new ConnectionError(`Failed to connect to ${url}`, err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class RocketMQ {
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly conn: AmqpConnection,
|
|
55
|
+
private readonly ch: AmqpChannel,
|
|
56
|
+
private readonly registry: SchemaRegistry,
|
|
57
|
+
private readonly serializer: Serializer,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
/** Exposes the raw AmqpChannel for event listeners (e.g. broker errors). */
|
|
61
|
+
get channel(): AmqpChannel {
|
|
62
|
+
return this.ch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Creates a typed queue handle bound to a schema class.
|
|
67
|
+
*
|
|
68
|
+
* Declares the queue with schema metadata in AMQP arguments so the
|
|
69
|
+
* broker compiles and validates messages. Returns a QueueHandle<T>
|
|
70
|
+
* for type-safe send/consume.
|
|
71
|
+
*/
|
|
72
|
+
async queue<T>(
|
|
73
|
+
name: string,
|
|
74
|
+
schema: new (...args: unknown[]) => T,
|
|
75
|
+
opts?: AssertQueueOptions,
|
|
76
|
+
): Promise<QueueHandle<T>> {
|
|
77
|
+
await this.assertQueue(name, schema, opts);
|
|
78
|
+
return new QueueHandle<T>(name, this.ch, this.registry, this.serializer);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Declares a queue with an optional schema class.
|
|
83
|
+
*
|
|
84
|
+
* When a schema is provided the proto3 definition is sent as AMQP
|
|
85
|
+
* queue arguments (`x-schema`, `x-schema-type`, `x-schema-message`)
|
|
86
|
+
* so the broker compiles and validates messages inline.
|
|
87
|
+
*/
|
|
88
|
+
async assertQueue(
|
|
89
|
+
name: string,
|
|
90
|
+
schema?: Function,
|
|
91
|
+
opts?: AssertQueueOptions,
|
|
92
|
+
): Promise<AssertQueueReply> {
|
|
93
|
+
if (!schema) {
|
|
94
|
+
return this.ch.assertQueue(name, opts);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const proto = toProto(schema);
|
|
98
|
+
// WHY: decorators write to defaultRegistry, not the instance registry
|
|
99
|
+
const fields = defaultRegistry.getFields(schema);
|
|
100
|
+
const subject = defaultRegistry.getSubject(schema);
|
|
101
|
+
|
|
102
|
+
this.registry.register(name, {
|
|
103
|
+
ctor: schema,
|
|
104
|
+
name: schema.name,
|
|
105
|
+
subject,
|
|
106
|
+
fields: [...fields],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Schema metadata sent as queue arguments so the broker compiles
|
|
110
|
+
// a proto descriptor and validates JSON payloads on publish.
|
|
111
|
+
const schemaArgs: Record<string, string> = {
|
|
112
|
+
'x-schema': proto,
|
|
113
|
+
'x-schema-type': 'protobuf',
|
|
114
|
+
'x-schema-message': schema.name,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
return await this.ch.assertQueue(name, {
|
|
119
|
+
...opts,
|
|
120
|
+
arguments: {
|
|
121
|
+
...opts?.arguments,
|
|
122
|
+
...schemaArgs,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new QueueError(`Failed to assert queue '${name}'`, err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Declares an exchange (passthrough to AMQP layer). */
|
|
131
|
+
async assertExchange(
|
|
132
|
+
name: string,
|
|
133
|
+
type: string,
|
|
134
|
+
opts?: AssertExchangeOptions,
|
|
135
|
+
): Promise<AssertExchangeReply> {
|
|
136
|
+
return this.ch.assertExchange(name, type, opts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Binds a queue to an exchange (passthrough to AMQP layer). */
|
|
140
|
+
async bindQueue(queue: string, exchange: string, routingKey: string): Promise<EmptyReply> {
|
|
141
|
+
return this.ch.bindQueue(queue, exchange, routingKey);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Publishes a JSON message directly to a queue (untyped path).
|
|
146
|
+
*
|
|
147
|
+
* Prefer `mq.queue("name", Schema).send()` for type safety.
|
|
148
|
+
* Validation is handled broker-side via the queue's compiled schema.
|
|
149
|
+
*/
|
|
150
|
+
sendToQueue(queue: string, payload: Record<string, unknown>, opts?: PublishOptions): boolean {
|
|
151
|
+
try {
|
|
152
|
+
const buf = this.serializer.serialize(payload);
|
|
153
|
+
return this.ch.sendToQueue(queue, buf, {
|
|
154
|
+
contentType: this.serializer.contentType,
|
|
155
|
+
persistent: true,
|
|
156
|
+
...opts,
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new PublishError(queue, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Publishes a JSON message to an exchange with a routing key.
|
|
165
|
+
*/
|
|
166
|
+
publish(
|
|
167
|
+
exchange: string,
|
|
168
|
+
routingKey: string,
|
|
169
|
+
payload: Record<string, unknown>,
|
|
170
|
+
opts?: PublishOptions,
|
|
171
|
+
): boolean {
|
|
172
|
+
try {
|
|
173
|
+
const buf = this.serializer.serialize(payload);
|
|
174
|
+
return this.ch.publish(exchange, routingKey, buf, {
|
|
175
|
+
contentType: this.serializer.contentType,
|
|
176
|
+
persistent: true,
|
|
177
|
+
...opts,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
throw new PublishError(`exchange:${exchange} routingKey:${routingKey}`, err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Subscribes to a queue with JSON deserialization.
|
|
186
|
+
*
|
|
187
|
+
* When a schema was registered via `assertQueue(name, Schema)`,
|
|
188
|
+
* the consumer's proto definition is sent to the broker for subset
|
|
189
|
+
* checking — the broker rejects consumers expecting fields the
|
|
190
|
+
* queue's schema doesn't define.
|
|
191
|
+
*
|
|
192
|
+
* Usage:
|
|
193
|
+
* await mq.assertQueue("orders", Order);
|
|
194
|
+
* await mq.consume("orders", (msg) => console.log(msg));
|
|
195
|
+
*/
|
|
196
|
+
async consume<T = Record<string, unknown>>(
|
|
197
|
+
queue: string,
|
|
198
|
+
handler: (msg: T, raw: ConsumeMessage) => void,
|
|
199
|
+
opts?: import('@rocketmq/amqp').ConsumeOptions,
|
|
200
|
+
): Promise<string> {
|
|
201
|
+
// Build consumer schema arguments for broker-side subset validation.
|
|
202
|
+
const consumerArgs = this.buildConsumerSchemaArgs(queue);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const reply = await this.ch.consume(
|
|
206
|
+
queue,
|
|
207
|
+
(msg) => {
|
|
208
|
+
if (!msg) return;
|
|
209
|
+
try {
|
|
210
|
+
const body = this.serializer.deserialize(msg.content) as T;
|
|
211
|
+
handler(body, msg);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`[rocketmq] deserialization error on queue '${queue}':`, err);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
...opts,
|
|
218
|
+
arguments: {
|
|
219
|
+
...opts?.arguments,
|
|
220
|
+
...consumerArgs,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
return reply.consumerTag;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new ConsumeError(`Failed to consume from queue '${queue}'`, err);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Builds AMQP arguments for consumer schema compatibility checking.
|
|
232
|
+
*
|
|
233
|
+
* Returns `x-consumer-schema` + `x-consumer-schema-message` if the
|
|
234
|
+
* queue has a registered schema, otherwise an empty object.
|
|
235
|
+
*/
|
|
236
|
+
private buildConsumerSchemaArgs(queue: string): Record<string, string> {
|
|
237
|
+
const meta = this.registry.lookup(queue);
|
|
238
|
+
if (!meta) return {};
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const proto = toProto(meta.ctor);
|
|
242
|
+
return {
|
|
243
|
+
'x-consumer-schema': proto,
|
|
244
|
+
'x-consumer-schema-message': meta.name,
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
// WHY: ctor may lack @Field() decorators (e.g. untyped consumers)
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Acknowledges a message. */
|
|
253
|
+
ack(msg: ConsumeMessage): void {
|
|
254
|
+
this.ch.ack(msg);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Negative-acknowledges a message. */
|
|
258
|
+
nack(msg: ConsumeMessage, requeue?: boolean): void {
|
|
259
|
+
this.ch.nack(msg, requeue);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Sets prefetch count on the channel. */
|
|
263
|
+
async prefetch(count: number): Promise<void> {
|
|
264
|
+
await this.ch.prefetch(count);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Closes channel and connection. */
|
|
268
|
+
async close(): Promise<void> {
|
|
269
|
+
await this.ch.close();
|
|
270
|
+
await this.conn.close();
|
|
271
|
+
}
|
|
272
|
+
}
|