@relaymesh/relaybus-kafka 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 +35 -0
- package/package.json +14 -0
- package/src/index.ts +181 -0
- package/test/kafka.test.ts +61 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# relaybus-kafka (TypeScript)
|
|
2
|
+
|
|
3
|
+
Kafka publisher and subscriber utilities for Relaybus.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @relaymesh/relaybus-kafka
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Example
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { KafkaPublisher, KafkaSubscriber } from "@relaymesh/relaybus-kafka";
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
const publisher = await KafkaPublisher.connect({
|
|
18
|
+
brokers: ["localhost:29092"]
|
|
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 KafkaSubscriber.connect({
|
|
27
|
+
brokers: ["localhost:29092"],
|
|
28
|
+
groupId: "relaybus",
|
|
29
|
+
onMessage: (msg) => console.log(msg.topic, msg.payload.toString())
|
|
30
|
+
});
|
|
31
|
+
await subscriber.start("relaybus.demo");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
main().catch(console.error);
|
|
35
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relaymesh/relaybus-kafka",
|
|
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
|
+
"dependencies": {
|
|
11
|
+
"@relaymesh/relaybus-core": "workspace:*",
|
|
12
|
+
"kafkajs": "^2.2.4"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "@relaybus/relaybus-core";
|
|
2
|
+
import { Kafka, Consumer, Producer } from "kafkajs";
|
|
3
|
+
|
|
4
|
+
export type KafkaProducer = {
|
|
5
|
+
send: (record: { topic: string; key?: Buffer; value: Buffer }) => Promise<void> | void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type KafkaPublisherConfig = {
|
|
9
|
+
producer: KafkaProducer;
|
|
10
|
+
topicPrefix?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type KafkaPublisherConnectConfig = {
|
|
14
|
+
brokers: string[];
|
|
15
|
+
topicPrefix?: string;
|
|
16
|
+
clientId?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class KafkaPublisher {
|
|
20
|
+
private readonly producer: KafkaProducer;
|
|
21
|
+
private readonly prefix: string;
|
|
22
|
+
private rawProducer?: Producer;
|
|
23
|
+
|
|
24
|
+
constructor(config: KafkaPublisherConfig) {
|
|
25
|
+
if (!config.producer) {
|
|
26
|
+
throw new Error("producer is required");
|
|
27
|
+
}
|
|
28
|
+
this.producer = config.producer;
|
|
29
|
+
this.prefix = config.topicPrefix ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static async connect(config: KafkaPublisherConnectConfig): Promise<KafkaPublisher> {
|
|
33
|
+
const kafka = new Kafka({
|
|
34
|
+
clientId: config.clientId ?? "relaybus",
|
|
35
|
+
brokers: config.brokers
|
|
36
|
+
});
|
|
37
|
+
const producer = kafka.producer();
|
|
38
|
+
await producer.connect();
|
|
39
|
+
const publisher = new KafkaPublisher({
|
|
40
|
+
producer: {
|
|
41
|
+
send: async (record) => {
|
|
42
|
+
await producer.send({
|
|
43
|
+
topic: record.topic,
|
|
44
|
+
messages: [{ key: record.key, value: record.value }]
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
topicPrefix: config.topicPrefix
|
|
49
|
+
});
|
|
50
|
+
publisher.rawProducer = producer;
|
|
51
|
+
return publisher;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async publish(topic: string, message: OutgoingMessage): Promise<void> {
|
|
55
|
+
const resolved = resolveTopic(topic, message.topic);
|
|
56
|
+
const payload = encodeEnvelope({ ...message, topic: resolved });
|
|
57
|
+
const record = {
|
|
58
|
+
topic: `${this.prefix}${resolved}`,
|
|
59
|
+
key: message.id ? Buffer.from(message.id) : undefined,
|
|
60
|
+
value: payload
|
|
61
|
+
};
|
|
62
|
+
await Promise.resolve(this.producer.send(record));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async close(): Promise<void> {
|
|
66
|
+
if (this.rawProducer) {
|
|
67
|
+
await this.rawProducer.disconnect();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type KafkaSubscriberConfig = {
|
|
73
|
+
onMessage: (msg: DecodedMessage) => void | Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type KafkaSubscriberConnectConfig = {
|
|
77
|
+
brokers: string[];
|
|
78
|
+
onMessage: (msg: DecodedMessage) => void | Promise<void>;
|
|
79
|
+
groupId?: string;
|
|
80
|
+
clientId?: string;
|
|
81
|
+
maxMessages?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export class KafkaSubscriber {
|
|
85
|
+
private readonly onMessage: (msg: DecodedMessage) => void | Promise<void>;
|
|
86
|
+
private consumer?: Consumer;
|
|
87
|
+
private maxMessages?: number;
|
|
88
|
+
|
|
89
|
+
constructor(config: KafkaSubscriberConfig) {
|
|
90
|
+
this.onMessage = config.onMessage;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static async connect(config: KafkaSubscriberConnectConfig): Promise<KafkaSubscriber> {
|
|
94
|
+
const kafka = new Kafka({
|
|
95
|
+
clientId: config.clientId ?? "relaybus",
|
|
96
|
+
brokers: config.brokers
|
|
97
|
+
});
|
|
98
|
+
const consumer = kafka.consumer({ groupId: config.groupId ?? "relaybus" });
|
|
99
|
+
await consumer.connect();
|
|
100
|
+
const subscriber = new KafkaSubscriber({ onMessage: config.onMessage });
|
|
101
|
+
subscriber.consumer = consumer;
|
|
102
|
+
subscriber.maxMessages = config.maxMessages ?? 1;
|
|
103
|
+
return subscriber;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async handleMessage(data: Buffer | string): Promise<void> {
|
|
107
|
+
const decoded = decodeEnvelope(data);
|
|
108
|
+
await this.onMessage(decoded);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async start(topic: string): Promise<void> {
|
|
112
|
+
if (!this.consumer) {
|
|
113
|
+
throw new Error("consumer is not initialized");
|
|
114
|
+
}
|
|
115
|
+
await this.consumer.subscribe({ topic, fromBeginning: true });
|
|
116
|
+
const consumer = this.consumer;
|
|
117
|
+
const max = this.maxMessages ?? 1;
|
|
118
|
+
let count = 0;
|
|
119
|
+
let finished = false;
|
|
120
|
+
|
|
121
|
+
let finishError: Error | undefined;
|
|
122
|
+
const finish = async (err?: Error) => {
|
|
123
|
+
if (finished) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
finished = true;
|
|
127
|
+
if (err) {
|
|
128
|
+
finishError = err;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await consumer.stop();
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore stop failures.
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await consumer.disconnect();
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore disconnect failures.
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await consumer.run({
|
|
144
|
+
eachMessage: async ({ message }) => {
|
|
145
|
+
const value = message.value ? Buffer.from(message.value) : Buffer.from("");
|
|
146
|
+
await this.handleMessage(value);
|
|
147
|
+
count++;
|
|
148
|
+
if (max > 0 && count >= max) {
|
|
149
|
+
await finish();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
await finish(err as Error);
|
|
155
|
+
}
|
|
156
|
+
if (finishError) {
|
|
157
|
+
throw finishError;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async close(): Promise<void> {
|
|
162
|
+
if (this.consumer) {
|
|
163
|
+
try {
|
|
164
|
+
await this.consumer.disconnect();
|
|
165
|
+
} catch {
|
|
166
|
+
// Ignore double-disconnects.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveTopic(argumentTopic: string, messageTopic?: string): string {
|
|
173
|
+
const topic = messageTopic && messageTopic.length > 0 ? messageTopic : argumentTopic;
|
|
174
|
+
if (!topic) {
|
|
175
|
+
throw new Error("topic is required");
|
|
176
|
+
}
|
|
177
|
+
if (argumentTopic && messageTopic && argumentTopic !== messageTopic) {
|
|
178
|
+
throw new Error(`topic mismatch: ${messageTopic} vs ${argumentTopic}`);
|
|
179
|
+
}
|
|
180
|
+
return topic;
|
|
181
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { KafkaPublisher, KafkaSubscriber } from "../src/index";
|
|
3
|
+
import { decodeEnvelope } from "@relaybus/relaybus-core";
|
|
4
|
+
|
|
5
|
+
describe("KafkaPublisher", () => {
|
|
6
|
+
it("sends encoded envelope", async () => {
|
|
7
|
+
const records: any[] = [];
|
|
8
|
+
const publisher = new KafkaPublisher({
|
|
9
|
+
producer: {
|
|
10
|
+
send: async (record) => {
|
|
11
|
+
records.push(record);
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
topicPrefix: "rb-"
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
await publisher.publish("alpha", {
|
|
18
|
+
id: "id-1",
|
|
19
|
+
topic: "alpha",
|
|
20
|
+
payload: Buffer.from("hi")
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(records).toHaveLength(1);
|
|
24
|
+
expect(records[0].topic).toBe("rb-alpha");
|
|
25
|
+
const decoded = decodeEnvelope(records[0].value);
|
|
26
|
+
expect(decoded.id).toBe("id-1");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("KafkaSubscriber", () => {
|
|
31
|
+
it("decodes envelope", async () => {
|
|
32
|
+
let seen = "";
|
|
33
|
+
const subscriber = new KafkaSubscriber({
|
|
34
|
+
onMessage: (msg) => {
|
|
35
|
+
seen = msg.id;
|
|
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("id");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects missing fields", async () => {
|
|
57
|
+
const subscriber = new KafkaSubscriber({ onMessage: () => {} });
|
|
58
|
+
await expect(subscriber.handleMessage("{}"))
|
|
59
|
+
.rejects.toThrow(/invalid v|invalid id/);
|
|
60
|
+
});
|
|
61
|
+
});
|
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
|
+
}
|