@relaymesh/relaybus-nats 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/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # relaybus-nats (TypeScript)
2
+
3
+ NATS publisher and subscriber utilities for Relaybus.
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@relaymesh/relaybus-nats",
3
+ "version": "0.1.0",
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
+ "dependencies": {
11
+ "@relaymesh/relaybus-core": "workspace:*",
12
+ "nats": "^2.29.3"
13
+ }
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "@relaybus/relaybus-core";
2
+ import { connect, NatsConnection } from "nats";
3
+
4
+ export type NatsClient = {
5
+ publish: (subject: string, data: Buffer) => Promise<void> | void;
6
+ };
7
+
8
+ export type NatsPublisherConfig = {
9
+ client: NatsClient;
10
+ subjectPrefix?: string;
11
+ };
12
+
13
+ export type NatsPublisherConnectConfig = {
14
+ url: string;
15
+ subjectPrefix?: string;
16
+ };
17
+
18
+ export class NatsPublisher {
19
+ private readonly client: NatsClient;
20
+ private readonly prefix: string;
21
+ private connection?: NatsConnection;
22
+
23
+ constructor(config: NatsPublisherConfig) {
24
+ if (!config.client) {
25
+ throw new Error("client is required");
26
+ }
27
+ this.client = config.client;
28
+ this.prefix = config.subjectPrefix ?? "";
29
+ }
30
+
31
+ static async connect(config: NatsPublisherConnectConfig): Promise<NatsPublisher> {
32
+ const connection = await connect({ servers: config.url });
33
+ const publisher = new NatsPublisher({
34
+ client: {
35
+ publish: async (subject, data) => {
36
+ connection.publish(subject, data);
37
+ }
38
+ },
39
+ subjectPrefix: config.subjectPrefix
40
+ });
41
+ publisher.connection = connection;
42
+ return publisher;
43
+ }
44
+
45
+ async publish(topic: string, message: OutgoingMessage): Promise<void> {
46
+ const resolved = resolveTopic(topic, message.topic);
47
+ const payload = encodeEnvelope({ ...message, topic: resolved });
48
+ const subject = joinSubject(this.prefix, resolved);
49
+ await Promise.resolve(this.client.publish(subject, payload));
50
+ }
51
+
52
+ async close(): Promise<void> {
53
+ if (this.connection) {
54
+ await this.connection.drain();
55
+ }
56
+ }
57
+ }
58
+
59
+ export type NatsSubscriberConfig = {
60
+ onMessage: (msg: DecodedMessage) => void | Promise<void>;
61
+ };
62
+
63
+ export type NatsSubscriberConnectConfig = {
64
+ url: string;
65
+ onMessage: (msg: DecodedMessage) => void | Promise<void>;
66
+ subjectPrefix?: string;
67
+ maxMessages?: number;
68
+ };
69
+
70
+ export class NatsSubscriber {
71
+ private readonly onMessage: (msg: DecodedMessage) => void | Promise<void>;
72
+ private connection?: NatsConnection;
73
+ private prefix?: string;
74
+ private maxMessages?: number;
75
+
76
+ constructor(config: NatsSubscriberConfig) {
77
+ this.onMessage = config.onMessage;
78
+ }
79
+
80
+ static async connect(config: NatsSubscriberConnectConfig): Promise<NatsSubscriber> {
81
+ const connection = await connect({ servers: config.url });
82
+ const subscriber = new NatsSubscriber({ onMessage: config.onMessage });
83
+ subscriber.connection = connection;
84
+ subscriber.prefix = config.subjectPrefix ?? "";
85
+ subscriber.maxMessages = config.maxMessages ?? 1;
86
+ return subscriber;
87
+ }
88
+
89
+ async handleMessage(data: Buffer | string): Promise<void> {
90
+ const decoded = decodeEnvelope(data);
91
+ await this.onMessage(decoded);
92
+ }
93
+
94
+ async start(topic: string): Promise<void> {
95
+ if (!this.connection) {
96
+ throw new Error("connection is not initialized");
97
+ }
98
+ const subject = joinSubject(this.prefix ?? "", topic);
99
+ const sub = this.connection.subscribe(subject);
100
+ let count = 0;
101
+ for await (const msg of sub) {
102
+ const data = Buffer.from(msg.data);
103
+ await this.handleMessage(data);
104
+ count++;
105
+ if (this.maxMessages && count >= this.maxMessages) {
106
+ sub.unsubscribe();
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ async close(): Promise<void> {
113
+ if (this.connection) {
114
+ await this.connection.drain();
115
+ }
116
+ }
117
+ }
118
+
119
+ function resolveTopic(argumentTopic: string, messageTopic?: string): string {
120
+ const topic = messageTopic && messageTopic.length > 0 ? messageTopic : argumentTopic;
121
+ if (!topic) {
122
+ throw new Error("topic is required");
123
+ }
124
+ if (argumentTopic && messageTopic && argumentTopic !== messageTopic) {
125
+ throw new Error(`topic mismatch: ${messageTopic} vs ${argumentTopic}`);
126
+ }
127
+ return topic;
128
+ }
129
+
130
+ function joinSubject(prefix: string, topic: string): string {
131
+ if (!prefix) {
132
+ return topic;
133
+ }
134
+ if (prefix.endsWith(".")) {
135
+ return `${prefix}${topic}`;
136
+ }
137
+ return `${prefix}.${topic}`;
138
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NatsPublisher, NatsSubscriber } from "../src/index";
3
+ import { decodeEnvelope } from "@relaybus/relaybus-core";
4
+
5
+ describe("NatsPublisher", () => {
6
+ it("publishes encoded envelope with subject prefix", async () => {
7
+ const calls: any[] = [];
8
+ const publisher = new NatsPublisher({
9
+ client: {
10
+ publish: async (subject, data) => {
11
+ calls.push({ subject, data });
12
+ }
13
+ },
14
+ subjectPrefix: "events"
15
+ });
16
+
17
+ await publisher.publish("alpha", {
18
+ id: "id-1",
19
+ topic: "alpha",
20
+ payload: Buffer.from("hi")
21
+ });
22
+
23
+ expect(calls).toHaveLength(1);
24
+ expect(calls[0].subject).toBe("events.alpha");
25
+ const decoded = decodeEnvelope(calls[0].data);
26
+ expect(decoded.id).toBe("id-1");
27
+ });
28
+ });
29
+
30
+ describe("NatsSubscriber", () => {
31
+ it("decodes messages", async () => {
32
+ let seen = "";
33
+ const subscriber = new NatsSubscriber({
34
+ onMessage: (msg) => {
35
+ seen = msg.topic;
36
+ }
37
+ });
38
+
39
+ await subscriber.handleMessage(
40
+ Buffer.from(
41
+ JSON.stringify({
42
+ v: "v1",
43
+ id: "id",
44
+ topic: "alpha",
45
+ ts: "2024-01-01T00:00:00Z",
46
+ content_type: "text/plain",
47
+ payload_b64: "aGVsbG8=",
48
+ meta: {}
49
+ })
50
+ )
51
+ );
52
+
53
+ expect(seen).toBe("alpha");
54
+ });
55
+
56
+ it("rejects invalid base64", async () => {
57
+ const subscriber = new NatsSubscriber({ onMessage: () => {} });
58
+ await expect(
59
+ subscriber.handleMessage(
60
+ Buffer.from(
61
+ JSON.stringify({
62
+ v: "v1",
63
+ id: "id",
64
+ topic: "alpha",
65
+ ts: "2024-01-01T00:00:00Z",
66
+ content_type: "text/plain",
67
+ payload_b64: "???",
68
+ meta: {}
69
+ })
70
+ )
71
+ )
72
+ ).rejects.toThrow(/invalid payload_b64/);
73
+ });
74
+ });
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
+ }