@relaymesh/relaybus-amqp 0.0.2

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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # relaybus-amqp (TypeScript)
2
+
3
+ AMQP publisher and subscriber utilities for Relaybus.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install @relaymesh/relaybus-amqp
9
+ ```
10
+
11
+ ## Example
12
+
13
+ ```ts
14
+ import { AmqpPublisher, AmqpSubscriber } from "@relaymesh/relaybus-amqp";
15
+
16
+ async function main() {
17
+ const publisher = await AmqpPublisher.connect({
18
+ url: "amqp://guest:guest@localhost:5672/"
19
+ });
20
+ await publisher.publish("relaybus.demo", {
21
+ topic: "relaybus.demo",
22
+ payload: Buffer.from("hello")
23
+ });
24
+ await publisher.close();
25
+
26
+ const subscriber = await AmqpSubscriber.connect({
27
+ url: "amqp://guest:guest@localhost:5672/",
28
+ onMessage: (msg) => console.log(msg.topic, msg.payload.toString())
29
+ });
30
+ await subscriber.start("relaybus.demo");
31
+ await subscriber.close();
32
+ }
33
+
34
+ main().catch(console.error);
35
+ ```
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@relaymesh/relaybus-amqp",
3
+ "version": "0.0.2",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc -p tsconfig.json",
8
+ "test": "vitest run"
9
+ },
10
+ "devDependencies": {
11
+ "@types/amqplib": "^0.10.5"
12
+ },
13
+ "dependencies": {
14
+ "@relaymesh/relaybus-core": "workspace:*",
15
+ "amqplib": "^0.10.9"
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,195 @@
1
+ import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "@relaybus/relaybus-core";
2
+ import { connect, Channel, ChannelModel, ConsumeMessage, ConfirmChannel } from "amqplib";
3
+
4
+ export type Delivery = {
5
+ content: Buffer | string;
6
+ };
7
+
8
+ export type PublishOptions = {
9
+ contentType?: string;
10
+ messageId?: string;
11
+ timestamp?: number;
12
+ headers?: Record<string, string>;
13
+ };
14
+
15
+ export type AmqpChannel = {
16
+ publish: (
17
+ exchange: string,
18
+ routingKey: string,
19
+ content: Buffer,
20
+ options?: PublishOptions
21
+ ) => Promise<void> | void;
22
+ };
23
+
24
+ export type AmqpSubscriberConfig = {
25
+ onMessage: (msg: DecodedMessage) => void | Promise<void>;
26
+ };
27
+
28
+ export type AmqpPublisherConfig = {
29
+ channel: AmqpChannel;
30
+ exchange?: string;
31
+ routingKeyTemplate?: string;
32
+ };
33
+
34
+ export type AmqpPublisherConnectConfig = {
35
+ url: string;
36
+ exchange?: string;
37
+ routingKeyTemplate?: string;
38
+ };
39
+
40
+ export type AmqpSubscriberConnectConfig = {
41
+ url: string;
42
+ onMessage: (msg: DecodedMessage) => void | Promise<void>;
43
+ exchange?: string;
44
+ routingKeyTemplate?: string;
45
+ queue?: string;
46
+ };
47
+
48
+ export class AmqpSubscriber {
49
+ private readonly onMessage: (msg: DecodedMessage) => void | Promise<void>;
50
+ private channel?: Channel;
51
+ private connection?: ChannelModel;
52
+ private exchange?: string;
53
+ private routingKeyTemplate?: string;
54
+ private queue?: string;
55
+
56
+ constructor(config: AmqpSubscriberConfig) {
57
+ this.onMessage = config.onMessage;
58
+ }
59
+
60
+ static async connect(config: AmqpSubscriberConnectConfig): Promise<AmqpSubscriber> {
61
+ const connection = await connect(config.url);
62
+ const channel = await connection.createChannel();
63
+ const subscriber = new AmqpSubscriber({ onMessage: config.onMessage });
64
+ subscriber.channel = channel;
65
+ subscriber.connection = connection;
66
+ subscriber.exchange = config.exchange ?? "";
67
+ subscriber.routingKeyTemplate = config.routingKeyTemplate ?? "{topic}";
68
+ subscriber.queue = config.queue;
69
+ return subscriber;
70
+ }
71
+
72
+ async handleDelivery(delivery: Delivery): Promise<void> {
73
+ const decoded = decodeEnvelope(delivery.content);
74
+ await this.onMessage(decoded);
75
+ }
76
+
77
+ async start(topic: string): Promise<void> {
78
+ if (!this.channel) {
79
+ throw new Error("channel is not initialized");
80
+ }
81
+ const queueName = this.queue ?? topic;
82
+ await this.channel.assertQueue(queueName, { durable: false, autoDelete: true });
83
+ if (this.exchange) {
84
+ const key = buildRoutingKey(this.routingKeyTemplate ?? "{topic}", topic);
85
+ await this.channel.bindQueue(queueName, this.exchange, key);
86
+ }
87
+
88
+ await new Promise<void>((resolve, reject) => {
89
+ this.channel!.consume(queueName, async (msg: ConsumeMessage | null) => {
90
+ if (!msg) {
91
+ return;
92
+ }
93
+ try {
94
+ await this.handleDelivery({ content: msg.content });
95
+ this.channel!.ack(msg);
96
+ resolve();
97
+ } catch (err) {
98
+ this.channel!.nack(msg, false, true);
99
+ reject(err);
100
+ }
101
+ });
102
+ });
103
+ }
104
+
105
+ async close(): Promise<void> {
106
+ if (this.channel) {
107
+ await this.channel.close();
108
+ }
109
+ if (this.connection) {
110
+ await this.connection.close();
111
+ }
112
+ }
113
+ }
114
+
115
+ export class AmqpPublisher {
116
+ private readonly channel: AmqpChannel;
117
+ private readonly exchange: string;
118
+ private readonly routingKeyTemplate: string;
119
+ private connection?: ChannelModel;
120
+ private confirmChannel?: ConfirmChannel;
121
+
122
+ constructor(config: AmqpPublisherConfig) {
123
+ this.channel = config.channel;
124
+ this.exchange = config.exchange ?? "";
125
+ this.routingKeyTemplate = config.routingKeyTemplate ?? "{topic}";
126
+ }
127
+
128
+ static async connect(config: AmqpPublisherConnectConfig): Promise<AmqpPublisher> {
129
+ const connection = await connect(config.url);
130
+ const channel = await connection.createConfirmChannel();
131
+ const publisher = new AmqpPublisher({
132
+ channel: {
133
+ publish: (exchange, routingKey, content, options) => {
134
+ return new Promise<void>((resolve, reject) => {
135
+ channel.publish(exchange, routingKey, content, options, (err) => {
136
+ if (err) {
137
+ reject(err);
138
+ return;
139
+ }
140
+ resolve();
141
+ });
142
+ });
143
+ }
144
+ },
145
+ exchange: config.exchange,
146
+ routingKeyTemplate: config.routingKeyTemplate
147
+ });
148
+ publisher.connection = connection;
149
+ publisher.confirmChannel = channel;
150
+ return publisher;
151
+ }
152
+
153
+ async publish(topic: string, message: OutgoingMessage): Promise<void> {
154
+ const resolved = resolveTopic(topic, message.topic);
155
+ const payload = encodeEnvelope({ ...message, topic: resolved });
156
+ const routingKey = buildRoutingKey(this.routingKeyTemplate, resolved);
157
+ const options: PublishOptions = {
158
+ contentType: "application/json",
159
+ messageId: message.id,
160
+ timestamp: message.ts ? Math.floor(message.ts.getTime() / 1000) : undefined,
161
+ headers: message.meta ?? undefined
162
+ };
163
+ await this.channel.publish(this.exchange, routingKey, payload, options);
164
+ }
165
+
166
+ async close(): Promise<void> {
167
+ if (this.confirmChannel) {
168
+ await this.confirmChannel.close();
169
+ }
170
+ if (this.connection) {
171
+ await this.connection.close();
172
+ }
173
+ }
174
+ }
175
+
176
+ function resolveTopic(argumentTopic: string, messageTopic?: string): string {
177
+ const topic = messageTopic && messageTopic.length > 0 ? messageTopic : argumentTopic;
178
+ if (!topic) {
179
+ throw new Error("topic is required");
180
+ }
181
+ if (argumentTopic && messageTopic && argumentTopic !== messageTopic) {
182
+ throw new Error(`topic mismatch: ${messageTopic} vs ${argumentTopic}`);
183
+ }
184
+ return topic;
185
+ }
186
+
187
+ function buildRoutingKey(template: string, topic: string): string {
188
+ if (!template) {
189
+ return topic;
190
+ }
191
+ if (template.includes("{topic}")) {
192
+ return template.split("{topic}").join(topic);
193
+ }
194
+ return template;
195
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { AmqpPublisher } from "../src/index";
3
+ import { decodeEnvelope } from "@relaybus/relaybus-core";
4
+
5
+ type PublishCall = {
6
+ exchange: string;
7
+ routingKey: string;
8
+ content: Buffer;
9
+ options?: any;
10
+ };
11
+
12
+ describe("AmqpPublisher", () => {
13
+ it("publishes encoded envelope with routing key", async () => {
14
+ const calls: PublishCall[] = [];
15
+ const channel = {
16
+ publish: async (exchange: string, routingKey: string, content: Buffer, options?: any) => {
17
+ calls.push({ exchange, routingKey, content, options });
18
+ }
19
+ };
20
+
21
+ const publisher = new AmqpPublisher({
22
+ channel,
23
+ exchange: "ex",
24
+ routingKeyTemplate: "events.{topic}"
25
+ });
26
+
27
+ await publisher.publish("alpha", {
28
+ id: "id-1",
29
+ topic: "alpha",
30
+ payload: Buffer.from("hi"),
31
+ meta: { source: "unit" }
32
+ });
33
+
34
+ expect(calls).toHaveLength(1);
35
+ const call = calls[0];
36
+ expect(call.exchange).toBe("ex");
37
+ expect(call.routingKey).toBe("events.alpha");
38
+ const decoded = decodeEnvelope(call.content);
39
+ expect(decoded.id).toBe("id-1");
40
+ expect(decoded.topic).toBe("alpha");
41
+ expect(decoded.payload.toString()).toBe("hi");
42
+ });
43
+
44
+ it("rejects topic mismatch", async () => {
45
+ const channel = {
46
+ publish: async () => {}
47
+ };
48
+ const publisher = new AmqpPublisher({ channel });
49
+ await expect(
50
+ publisher.publish("alpha", { topic: "beta", payload: Buffer.from("hi") })
51
+ ).rejects.toThrow(/topic mismatch/);
52
+ });
53
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { AmqpSubscriber } from "../src/index";
5
+
6
+ const rootDir = path.resolve(__dirname, "../../../..", "spec", "corpus");
7
+ const samplePath = path.join(rootDir, "samples", "sample1.json");
8
+ const expectedPath = path.join(rootDir, "expected", "sample1.json");
9
+
10
+ type Expected = {
11
+ v: "v1";
12
+ id: string;
13
+ topic: string;
14
+ ts: string;
15
+ content_type: string;
16
+ meta: Record<string, string>;
17
+ payload_bytes_b64: string;
18
+ };
19
+
20
+ describe("AmqpSubscriber", () => {
21
+ it("invokes handler with decoded message", async () => {
22
+ const sample = readFileSync(samplePath, "utf8");
23
+ const expected = JSON.parse(readFileSync(expectedPath, "utf8")) as Expected;
24
+
25
+ let received: any = null;
26
+ const subscriber = new AmqpSubscriber({
27
+ onMessage: (msg) => {
28
+ received = msg;
29
+ }
30
+ });
31
+
32
+ await subscriber.handleDelivery({ content: Buffer.from(sample) });
33
+
34
+ expect(received).not.toBeNull();
35
+ expect(received.id).toBe(expected.id);
36
+ expect(received.topic).toBe(expected.topic);
37
+ const expectedDate = new Date(expected.ts);
38
+ expect(received.ts.toISOString()).toBe(expectedDate.toISOString());
39
+ expect(received.contentType).toBe(expected.content_type);
40
+ expect(received.meta).toEqual(expected.meta);
41
+ expect(received.payload.toString("base64")).toBe(expected.payload_bytes_b64);
42
+ });
43
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "declaration": true,
7
+ "types": ["node"],
8
+ "baseUrl": ".",
9
+ "paths": {
10
+ "@relaybus/relaybus-core": ["../../core/typescript/dist"]
11
+ }
12
+ },
13
+ "include": ["src/**/*.ts"]
14
+ }