@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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests for the error hierarchy.
3
+ *
4
+ * Covers every error class: construction, message formatting,
5
+ * name property, cause chaining, and custom properties.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import {
10
+ RocketMQError,
11
+ ConnectionError,
12
+ QueueError,
13
+ PublishError,
14
+ ConsumeError,
15
+ SerializationError,
16
+ SchemaError,
17
+ TimeoutError,
18
+ } from './errors.js';
19
+
20
+ describe('RocketMQError', () => {
21
+ it('sets message and name', () => {
22
+ const err = new RocketMQError('test error');
23
+ expect(err.message).toBe('test error');
24
+ expect(err.name).toBe('RocketMQError');
25
+ expect(err).toBeInstanceOf(Error);
26
+ });
27
+
28
+ it('preserves cause', () => {
29
+ const cause = new Error('root cause');
30
+ const err = new RocketMQError('wrapped', cause);
31
+ expect(err.cause).toBe(cause);
32
+ });
33
+ });
34
+
35
+ describe('ConnectionError', () => {
36
+ it('extends RocketMQError', () => {
37
+ const err = new ConnectionError('conn failed');
38
+ expect(err).toBeInstanceOf(RocketMQError);
39
+ expect(err.name).toBe('ConnectionError');
40
+ });
41
+
42
+ it('preserves cause', () => {
43
+ const cause = new Error('ECONNREFUSED');
44
+ const err = new ConnectionError('failed', cause);
45
+ expect(err.cause).toBe(cause);
46
+ });
47
+ });
48
+
49
+ describe('QueueError', () => {
50
+ it('extends RocketMQError', () => {
51
+ const err = new QueueError('queue fail');
52
+ expect(err).toBeInstanceOf(RocketMQError);
53
+ expect(err.name).toBe('QueueError');
54
+ });
55
+
56
+ it('preserves cause', () => {
57
+ const cause = new Error('PRECONDITION_FAILED');
58
+ const err = new QueueError('failed', cause);
59
+ expect(err.cause).toBe(cause);
60
+ });
61
+ });
62
+
63
+ describe('PublishError', () => {
64
+ it('formats message with queue name', () => {
65
+ const err = new PublishError('orders');
66
+ expect(err.message).toBe("Failed to publish to 'orders'");
67
+ expect(err.queue).toBe('orders');
68
+ expect(err.name).toBe('PublishError');
69
+ });
70
+
71
+ it('preserves cause', () => {
72
+ const cause = new Error('channel closed');
73
+ const err = new PublishError('q', cause);
74
+ expect(err.cause).toBe(cause);
75
+ });
76
+ });
77
+
78
+ describe('ConsumeError', () => {
79
+ it('extends RocketMQError', () => {
80
+ const err = new ConsumeError('consume fail');
81
+ expect(err).toBeInstanceOf(RocketMQError);
82
+ expect(err.name).toBe('ConsumeError');
83
+ });
84
+
85
+ it('preserves cause', () => {
86
+ const cause = new Error('NOT_FOUND');
87
+ const err = new ConsumeError('failed', cause);
88
+ expect(err.cause).toBe(cause);
89
+ });
90
+ });
91
+
92
+ describe('SerializationError', () => {
93
+ it('extends RocketMQError', () => {
94
+ const err = new SerializationError('bad json');
95
+ expect(err).toBeInstanceOf(RocketMQError);
96
+ expect(err.name).toBe('SerializationError');
97
+ });
98
+
99
+ it('preserves cause', () => {
100
+ const cause = new SyntaxError('Unexpected token');
101
+ const err = new SerializationError('parse failed', cause);
102
+ expect(err.cause).toBe(cause);
103
+ });
104
+ });
105
+
106
+ describe('SchemaError', () => {
107
+ it('extends RocketMQError', () => {
108
+ const err = new SchemaError('no fields');
109
+ expect(err).toBeInstanceOf(RocketMQError);
110
+ expect(err.name).toBe('SchemaError');
111
+ });
112
+
113
+ it('preserves cause', () => {
114
+ const cause = new Error('compile failed');
115
+ const err = new SchemaError('schema issue', cause);
116
+ expect(err.cause).toBe(cause);
117
+ });
118
+ });
119
+
120
+ describe('TimeoutError', () => {
121
+ it('extends RocketMQError', () => {
122
+ const err = new TimeoutError('timed out after 5s');
123
+ expect(err).toBeInstanceOf(RocketMQError);
124
+ expect(err.name).toBe('TimeoutError');
125
+ });
126
+
127
+ it('preserves cause', () => {
128
+ const cause = new Error('deadline');
129
+ const err = new TimeoutError('timeout', cause);
130
+ expect(err.cause).toBe(cause);
131
+ });
132
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Error hierarchy for the RocketMQ SDK.
3
+ *
4
+ * Every error includes the offending context (queue name, schema name, etc.)
5
+ * and preserves the original cause via the standard `cause` property.
6
+ *
7
+ * Usage:
8
+ * try { ... } catch (err) {
9
+ * if (err instanceof PublishError) console.error(err.queue);
10
+ * }
11
+ */
12
+
13
+ export class RocketMQError extends Error {
14
+ constructor(message: string, cause?: unknown) {
15
+ super(message, { cause });
16
+ this.name = 'RocketMQError';
17
+ }
18
+ }
19
+
20
+ export class ConnectionError extends RocketMQError {
21
+ constructor(message: string, cause?: unknown) {
22
+ super(message, cause);
23
+ this.name = 'ConnectionError';
24
+ }
25
+ }
26
+
27
+ export class QueueError extends RocketMQError {
28
+ constructor(message: string, cause?: unknown) {
29
+ super(message, cause);
30
+ this.name = 'QueueError';
31
+ }
32
+ }
33
+
34
+ export class PublishError extends RocketMQError {
35
+ constructor(
36
+ public readonly queue: string,
37
+ cause?: unknown,
38
+ ) {
39
+ super(`Failed to publish to '${queue}'`, cause);
40
+ this.name = 'PublishError';
41
+ }
42
+ }
43
+
44
+ export class ConsumeError extends RocketMQError {
45
+ constructor(message: string, cause?: unknown) {
46
+ super(message, cause);
47
+ this.name = 'ConsumeError';
48
+ }
49
+ }
50
+
51
+ export class SerializationError extends RocketMQError {
52
+ constructor(message: string, cause?: unknown) {
53
+ super(message, cause);
54
+ this.name = 'SerializationError';
55
+ }
56
+ }
57
+
58
+ export class SchemaError extends RocketMQError {
59
+ constructor(message: string, cause?: unknown) {
60
+ super(message, cause);
61
+ this.name = 'SchemaError';
62
+ }
63
+ }
64
+
65
+ export class TimeoutError extends RocketMQError {
66
+ constructor(message: string, cause?: unknown) {
67
+ super(message, cause);
68
+ this.name = 'TimeoutError';
69
+ }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Re-export decorators so users only need one import
2
+ export { Schema, Field } from '@rocketmq/schema';
3
+ export type { ProtoType, FieldMeta, SchemaEntry } from '@rocketmq/schema';
4
+
5
+ // Re-export serializer interface for custom implementations
6
+ export type { Serializer } from '@rocketmq/serializer';
7
+
8
+ // Re-export AMQP types needed by consumers
9
+ export type { ConsumeMessage } from '@rocketmq/amqp';
10
+
11
+ // Core API
12
+ export { connect, RocketMQ, type RocketOptions } from './client.js';
13
+ export { QueueHandle } from './queue-handle.js';
14
+
15
+ // Errors
16
+ export {
17
+ RocketMQError,
18
+ ConnectionError,
19
+ QueueError,
20
+ PublishError,
21
+ ConsumeError,
22
+ SerializationError,
23
+ SchemaError,
24
+ TimeoutError,
25
+ } from './errors.js';
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Tests for QueueHandle<T>.
3
+ *
4
+ * Uses FakeAmqpChannel and FakeSerializer mocks.
5
+ * Covers: send (valid, invalid, serialization error),
6
+ * consume (success, deserialization error, consume failure).
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { QueueHandle } from './queue-handle.js';
11
+ import { SchemaRegistry } from '@rocketmq/schema';
12
+ import type { SchemaEntry } from '@rocketmq/schema';
13
+ import type { Serializer } from '@rocketmq/serializer';
14
+ import { PublishError, ConsumeError } from './errors.js';
15
+
16
+ /** Named fake per user rules. */
17
+ class FakeAmqpChannel {
18
+ sendToQueue = vi.fn().mockReturnValue(true);
19
+ consume = vi.fn().mockResolvedValue({ consumerTag: 'tag-1' });
20
+ }
21
+
22
+ /** Named fake serializer. */
23
+ class FakeSerializer implements Serializer {
24
+ readonly contentType = 'application/json';
25
+ serialize = vi.fn((v: unknown) => Buffer.from(JSON.stringify(v)));
26
+ deserialize = vi.fn((buf: Buffer) => JSON.parse(buf.toString()));
27
+ }
28
+
29
+ interface TestMsg {
30
+ id: string;
31
+ qty: number;
32
+ }
33
+
34
+ describe('QueueHandle', () => {
35
+ let registry: SchemaRegistry;
36
+ let channel: FakeAmqpChannel;
37
+ let serializer: FakeSerializer;
38
+ let handle: QueueHandle<TestMsg>;
39
+
40
+ beforeEach(() => {
41
+ registry = new SchemaRegistry();
42
+ channel = new FakeAmqpChannel();
43
+ serializer = new FakeSerializer();
44
+
45
+ const entry: SchemaEntry = {
46
+ ctor: class {},
47
+ name: 'TestMsg',
48
+ fields: [
49
+ { name: 'id', protoType: 'string', number: 1 },
50
+ { name: 'qty', protoType: 'int32', number: 2 },
51
+ ],
52
+ };
53
+ registry.register('test-queue', entry);
54
+
55
+ handle = new QueueHandle<TestMsg>('test-queue', channel as never, registry, serializer);
56
+ });
57
+
58
+ describe('send', () => {
59
+ it('serializes and sends a payload', () => {
60
+ const result = handle.send({ id: '1', qty: 5 });
61
+ expect(result).toBe(true);
62
+ expect(serializer.serialize).toHaveBeenCalledWith({ id: '1', qty: 5 });
63
+ expect(channel.sendToQueue).toHaveBeenCalledWith(
64
+ 'test-queue',
65
+ expect.any(Buffer),
66
+ expect.objectContaining({
67
+ contentType: 'application/json',
68
+ persistent: true,
69
+ }),
70
+ );
71
+ });
72
+
73
+ it('passes custom publish options', () => {
74
+ handle.send({ id: '1', qty: 5 }, { priority: 5 });
75
+ expect(channel.sendToQueue).toHaveBeenCalledWith(
76
+ 'test-queue',
77
+ expect.any(Buffer),
78
+ expect.objectContaining({ priority: 5 }),
79
+ );
80
+ });
81
+
82
+ it('throws PublishError when channel.sendToQueue throws', () => {
83
+ channel.sendToQueue.mockImplementation(() => {
84
+ throw new Error('channel closed');
85
+ });
86
+ expect(() => handle.send({ id: '1', qty: 5 })).toThrow(PublishError);
87
+ });
88
+ });
89
+
90
+ describe('consume', () => {
91
+ it('subscribes and returns consumer tag', async () => {
92
+ const handler = vi.fn();
93
+ const tag = await handle.consume(handler);
94
+ expect(tag).toBe('tag-1');
95
+ expect(channel.consume).toHaveBeenCalledWith('test-queue', expect.any(Function), {
96
+ arguments: {},
97
+ });
98
+ });
99
+
100
+ it('deserializes and calls handler with typed message', async () => {
101
+ const handler = vi.fn();
102
+ // Capture the internal callback
103
+ channel.consume.mockImplementation(async (_q: string, cb: Function) => {
104
+ const raw = {
105
+ content: Buffer.from(JSON.stringify({ id: 'x', qty: 3 })),
106
+ fields: {},
107
+ properties: {},
108
+ };
109
+ cb(raw);
110
+ return { consumerTag: 'tag-2' };
111
+ });
112
+
113
+ await handle.consume(handler);
114
+ expect(handler).toHaveBeenCalledWith(
115
+ { id: 'x', qty: 3 },
116
+ expect.objectContaining({ content: expect.any(Buffer) }),
117
+ );
118
+ });
119
+
120
+ it('ignores null messages', async () => {
121
+ const handler = vi.fn();
122
+ channel.consume.mockImplementation(async (_q: string, cb: Function) => {
123
+ cb(null);
124
+ return { consumerTag: 'tag-3' };
125
+ });
126
+
127
+ await handle.consume(handler);
128
+ expect(handler).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('logs deserialization errors without crashing', async () => {
132
+ const handler = vi.fn();
133
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
134
+
135
+ serializer.deserialize.mockImplementation(() => {
136
+ throw new SyntaxError('bad json');
137
+ });
138
+
139
+ channel.consume.mockImplementation(async (_q: string, cb: Function) => {
140
+ cb({ content: Buffer.from('not json'), fields: {}, properties: {} });
141
+ return { consumerTag: 'tag-4' };
142
+ });
143
+
144
+ await handle.consume(handler);
145
+ expect(handler).not.toHaveBeenCalled();
146
+ expect(consoleSpy).toHaveBeenCalledWith(
147
+ expect.stringContaining('deserialization error'),
148
+ expect.any(SyntaxError),
149
+ );
150
+ consoleSpy.mockRestore();
151
+ });
152
+
153
+ it('throws ConsumeError when channel.consume rejects', async () => {
154
+ channel.consume.mockRejectedValue(new Error('NOT_FOUND'));
155
+ const handler = vi.fn();
156
+ await expect(handle.consume(handler)).rejects.toThrow(ConsumeError);
157
+ });
158
+
159
+ it('passes consume options through', async () => {
160
+ const handler = vi.fn();
161
+ await handle.consume(handler, { noAck: true });
162
+ expect(channel.consume).toHaveBeenCalledWith('test-queue', expect.any(Function), {
163
+ noAck: true,
164
+ arguments: {},
165
+ });
166
+ });
167
+ });
168
+ });
169
+
170
+ describe('QueueHandle without schema', () => {
171
+ it('sends without errors when no schema registered', () => {
172
+ const registry = new SchemaRegistry();
173
+ const channel = new FakeAmqpChannel();
174
+ const serializer = new FakeSerializer();
175
+ const handle = new QueueHandle<Record<string, unknown>>(
176
+ 'unregistered',
177
+ channel as never,
178
+ registry,
179
+ serializer,
180
+ );
181
+
182
+ const result = handle.send({ anything: 'goes' });
183
+ expect(result).toBe(true);
184
+ });
185
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Typed queue handle — the core DX innovation.
3
+ *
4
+ * Wraps a queue name with its schema and serializer so every send()
5
+ * and consume() call is type-safe at compile time. Runtime validation
6
+ * is delegated to the broker's compiled proto schema.
7
+ *
8
+ * Usage:
9
+ * const orders = mq.queue("orders", Order);
10
+ * await orders.send({ id: "1", customerId: "c1", qty: 5 });
11
+ * await orders.consume((msg) => console.log(msg.id));
12
+ */
13
+
14
+ import type { AmqpChannel, ConsumeMessage, ConsumeOptions, PublishOptions } from '@rocketmq/amqp';
15
+ import type { SchemaRegistry } from '@rocketmq/schema';
16
+ import type { Serializer } from '@rocketmq/serializer';
17
+ import { toProto } from '@rocketmq/protobuf';
18
+ import { PublishError, ConsumeError } from './errors.js';
19
+
20
+ export class QueueHandle<T> {
21
+ constructor(
22
+ private readonly queueName: string,
23
+ private readonly channel: AmqpChannel,
24
+ private readonly registry: SchemaRegistry,
25
+ private readonly serializer: Serializer,
26
+ ) {}
27
+
28
+ /**
29
+ * Publishes a typed payload to this queue.
30
+ *
31
+ * Pipeline: serialize → send. Validation is broker-side.
32
+ */
33
+ send(payload: T, opts?: PublishOptions): boolean {
34
+ try {
35
+ const buf = this.serializer.serialize(payload);
36
+ return this.channel.sendToQueue(this.queueName, buf, {
37
+ contentType: this.serializer.contentType,
38
+ persistent: true,
39
+ ...opts,
40
+ });
41
+ } catch (err) {
42
+ throw new PublishError(this.queueName, err);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Subscribes to this queue with typed message handling.
48
+ *
49
+ * Sends the consumer's proto definition to the broker via AMQP
50
+ * arguments so the broker can verify schema subset compatibility.
51
+ * Malformed messages are logged but not re-thrown to keep the loop alive.
52
+ */
53
+ async consume(
54
+ handler: (msg: T, raw: ConsumeMessage) => void,
55
+ opts?: ConsumeOptions,
56
+ ): Promise<string> {
57
+ const consumerArgs = this.buildConsumerSchemaArgs();
58
+
59
+ try {
60
+ const reply = await this.channel.consume(
61
+ this.queueName,
62
+ (raw) => {
63
+ if (!raw) return;
64
+ try {
65
+ const body = this.serializer.deserialize(raw.content) as T;
66
+ handler(body, raw);
67
+ } catch (err) {
68
+ // WHY: log instead of throw to keep the consumer loop alive
69
+ console.error(`[rocketmq] deserialization error on queue '${this.queueName}':`, err);
70
+ }
71
+ },
72
+ {
73
+ ...opts,
74
+ arguments: {
75
+ ...opts?.arguments,
76
+ ...consumerArgs,
77
+ },
78
+ },
79
+ );
80
+ return reply.consumerTag;
81
+ } catch (err) {
82
+ throw new ConsumeError(`Failed to consume from queue '${this.queueName}'`, err);
83
+ }
84
+ }
85
+
86
+ /** Builds AMQP arguments for consumer schema subset checking. */
87
+ private buildConsumerSchemaArgs(): Record<string, string> {
88
+ const meta = this.registry.lookup(this.queueName);
89
+ if (!meta) return {};
90
+
91
+ try {
92
+ const proto = toProto(meta.ctor);
93
+ return {
94
+ 'x-consumer-schema': proto,
95
+ 'x-consumer-schema-message': meta.name,
96
+ };
97
+ } catch {
98
+ // WHY: ctor may lack @Field() decorators (e.g. untyped consumers)
99
+ return {};
100
+ }
101
+ }
102
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm", "cjs"],
6
+ dts: true,
7
+ clean: true,
8
+ });