@relaymesh/relaybus-kafka 0.0.4 → 0.0.6

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 CHANGED
@@ -10,6 +10,8 @@ npm install @relaymesh/relaybus-kafka
10
10
 
11
11
  ## Example
12
12
 
13
+ Publisher will attempt to create the topic on first publish (requires broker permissions).
14
+
13
15
  ```ts
14
16
  import { KafkaPublisher, KafkaSubscriber } from "@relaymesh/relaybus-kafka";
15
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymesh/relaybus-kafka",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "scripts": {
@@ -8,7 +8,6 @@
8
8
  "test": "vitest run"
9
9
  },
10
10
  "dependencies": {
11
- "@relaymesh/relaybus-core": "workspace:*",
12
11
  "kafkajs": "^2.2.4"
13
12
  }
14
13
  }
package/src/core.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export type DecodedMessage = {
4
+ v: "v1";
5
+ id: string;
6
+ topic: string;
7
+ ts: Date;
8
+ contentType: string;
9
+ payload: Buffer;
10
+ meta: Record<string, string>;
11
+ };
12
+
13
+ export type OutgoingMessage = {
14
+ id?: string;
15
+ topic: string;
16
+ ts?: Date;
17
+ contentType?: string;
18
+ payload: Buffer | Uint8Array;
19
+ meta?: Record<string, string>;
20
+ v?: "v1";
21
+ };
22
+
23
+ export type NormalizeOptions = {
24
+ now?: () => Date;
25
+ idGenerator?: () => string;
26
+ };
27
+
28
+ type EnvelopeJSON = {
29
+ v: string;
30
+ id: string;
31
+ topic: string;
32
+ ts: string;
33
+ content_type: string;
34
+ payload_b64: string;
35
+ meta: Record<string, string>;
36
+ };
37
+
38
+ type NormalizedMessage = {
39
+ id: string;
40
+ topic: string;
41
+ ts: Date;
42
+ contentType: string;
43
+ payload: Buffer;
44
+ meta: Record<string, string>;
45
+ v: "v1";
46
+ };
47
+
48
+ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function assertStringField(value: unknown, field: string): string {
55
+ if (typeof value !== "string" || value.length === 0) {
56
+ throw new Error(`invalid ${field}`);
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function assertMeta(value: unknown): Record<string, string> {
62
+ if (!isRecord(value)) {
63
+ throw new Error("invalid meta");
64
+ }
65
+ const meta: Record<string, string> = {};
66
+ for (const [k, v] of Object.entries(value)) {
67
+ if (typeof v !== "string") {
68
+ throw new Error("invalid meta");
69
+ }
70
+ meta[k] = v;
71
+ }
72
+ return meta;
73
+ }
74
+
75
+ function normalizeMessage(message: OutgoingMessage, options?: NormalizeOptions): NormalizedMessage {
76
+ if (!message) {
77
+ throw new Error("message is required");
78
+ }
79
+ if (!message.topic) {
80
+ throw new Error("topic is required");
81
+ }
82
+ if (message.payload === undefined || message.payload === null) {
83
+ throw new Error("payload is required");
84
+ }
85
+ const payload = Buffer.isBuffer(message.payload)
86
+ ? message.payload
87
+ : Buffer.from(message.payload);
88
+
89
+ const now = options?.now ?? (() => new Date());
90
+ const idGenerator = options?.idGenerator ?? randomUUID;
91
+ const id = message.id && message.id.length > 0 ? message.id : idGenerator();
92
+ if (!id) {
93
+ throw new Error("id is required");
94
+ }
95
+ const ts = message.ts ?? now();
96
+ if (!(ts instanceof Date) || Number.isNaN(ts.getTime())) {
97
+ throw new Error("invalid ts");
98
+ }
99
+
100
+ const meta: Record<string, string> = {};
101
+ if (message.meta) {
102
+ for (const [k, v] of Object.entries(message.meta)) {
103
+ if (typeof v !== "string") {
104
+ throw new Error("invalid meta");
105
+ }
106
+ meta[k] = v;
107
+ }
108
+ }
109
+
110
+ const contentType = message.contentType && message.contentType.length > 0
111
+ ? message.contentType
112
+ : DEFAULT_CONTENT_TYPE;
113
+
114
+ return {
115
+ id,
116
+ topic: message.topic,
117
+ ts,
118
+ contentType,
119
+ payload,
120
+ meta,
121
+ v: "v1"
122
+ };
123
+ }
124
+
125
+ function isValidBase64(value: string): boolean {
126
+ if (value === "") {
127
+ return true;
128
+ }
129
+ if (value.length % 4 !== 0) {
130
+ return false;
131
+ }
132
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
133
+ return false;
134
+ }
135
+ const decoded = Buffer.from(value, "base64");
136
+ return decoded.toString("base64") === value;
137
+ }
138
+
139
+ export function decodeEnvelope(jsonBytes: Buffer | string): DecodedMessage {
140
+ const raw = Buffer.isBuffer(jsonBytes) ? jsonBytes.toString("utf8") : jsonBytes;
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch (err) {
145
+ throw new Error("invalid json");
146
+ }
147
+ if (!isRecord(parsed)) {
148
+ throw new Error("invalid envelope");
149
+ }
150
+
151
+ const env = parsed as EnvelopeJSON;
152
+ if (env.v !== "v1") {
153
+ throw new Error("invalid v");
154
+ }
155
+
156
+ const id = assertStringField(env.id, "id");
157
+ const topic = assertStringField(env.topic, "topic");
158
+ const tsRaw = assertStringField(env.ts, "ts");
159
+ const contentType = assertStringField(env.content_type, "content_type");
160
+
161
+ if (typeof env.payload_b64 !== "string") {
162
+ throw new Error("invalid payload_b64");
163
+ }
164
+ if (!isValidBase64(env.payload_b64)) {
165
+ throw new Error("invalid payload_b64");
166
+ }
167
+ const payload = Buffer.from(env.payload_b64, "base64");
168
+
169
+ const meta = assertMeta(env.meta);
170
+ const ts = new Date(tsRaw);
171
+ if (Number.isNaN(ts.getTime())) {
172
+ throw new Error("invalid ts");
173
+ }
174
+
175
+ return {
176
+ v: "v1",
177
+ id,
178
+ topic,
179
+ ts,
180
+ contentType,
181
+ payload,
182
+ meta
183
+ };
184
+ }
185
+
186
+ export function encodeEnvelope(message: OutgoingMessage, options?: NormalizeOptions): Buffer {
187
+ const normalized = normalizeMessage(message, options);
188
+ const envelope: EnvelopeJSON = {
189
+ v: normalized.v,
190
+ id: normalized.id,
191
+ topic: normalized.topic,
192
+ ts: normalized.ts.toISOString(),
193
+ content_type: normalized.contentType,
194
+ payload_b64: normalized.payload.toString("base64"),
195
+ meta: normalized.meta
196
+ };
197
+
198
+ return Buffer.from(JSON.stringify(envelope), "utf8");
199
+ }
200
+
201
+ export const defaults = {
202
+ DEFAULT_CONTENT_TYPE
203
+ };
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "@relaymesh/relaybus-core";
2
- import { Kafka, Consumer, Producer } from "kafkajs";
1
+ import { decodeEnvelope, encodeEnvelope, DecodedMessage, OutgoingMessage } from "./core";
2
+ import { Kafka, Consumer, Producer, Admin } from "kafkajs";
3
3
 
4
4
  export type KafkaProducer = {
5
5
  send: (record: { topic: string; key?: Buffer; value: Buffer }) => Promise<void> | void;
@@ -8,6 +8,7 @@ export type KafkaProducer = {
8
8
  export type KafkaPublisherConfig = {
9
9
  producer: KafkaProducer;
10
10
  topicPrefix?: string;
11
+ admin?: Admin;
11
12
  };
12
13
 
13
14
  export type KafkaPublisherConnectConfig = {
@@ -20,6 +21,9 @@ export class KafkaPublisher {
20
21
  private readonly producer: KafkaProducer;
21
22
  private readonly prefix: string;
22
23
  private rawProducer?: Producer;
24
+ private admin?: Admin;
25
+ private ownsAdmin = false;
26
+ private readonly ensuredTopics = new Set<string>();
23
27
 
24
28
  constructor(config: KafkaPublisherConfig) {
25
29
  if (!config.producer) {
@@ -27,6 +31,8 @@ export class KafkaPublisher {
27
31
  }
28
32
  this.producer = config.producer;
29
33
  this.prefix = config.topicPrefix ?? "";
34
+ this.admin = config.admin;
35
+ this.ownsAdmin = false;
30
36
  }
31
37
 
32
38
  static async connect(config: KafkaPublisherConnectConfig): Promise<KafkaPublisher> {
@@ -35,7 +41,9 @@ export class KafkaPublisher {
35
41
  brokers: config.brokers
36
42
  });
37
43
  const producer = kafka.producer();
44
+ const admin = kafka.admin();
38
45
  await producer.connect();
46
+ await admin.connect();
39
47
  const publisher = new KafkaPublisher({
40
48
  producer: {
41
49
  send: async (record) => {
@@ -48,6 +56,8 @@ export class KafkaPublisher {
48
56
  topicPrefix: config.topicPrefix
49
57
  });
50
58
  publisher.rawProducer = producer;
59
+ publisher.admin = admin;
60
+ publisher.ownsAdmin = true;
51
61
  return publisher;
52
62
  }
53
63
 
@@ -59,6 +69,7 @@ export class KafkaPublisher {
59
69
  key: message.id ? Buffer.from(message.id) : undefined,
60
70
  value: payload
61
71
  };
72
+ await this.ensureTopic(record.topic);
62
73
  await Promise.resolve(this.producer.send(record));
63
74
  }
64
75
 
@@ -66,6 +77,31 @@ export class KafkaPublisher {
66
77
  if (this.rawProducer) {
67
78
  await this.rawProducer.disconnect();
68
79
  }
80
+ if (this.admin && this.ownsAdmin) {
81
+ await this.admin.disconnect();
82
+ }
83
+ }
84
+
85
+ private async ensureTopic(topic: string): Promise<void> {
86
+ if (!this.admin) {
87
+ return;
88
+ }
89
+ if (this.ensuredTopics.has(topic)) {
90
+ return;
91
+ }
92
+ try {
93
+ await this.admin.createTopics({
94
+ topics: [{ topic, numPartitions: 1, replicationFactor: 1 }],
95
+ waitForLeaders: true
96
+ });
97
+ this.ensuredTopics.add(topic);
98
+ } catch (err) {
99
+ if (isTopicAlreadyExists(err)) {
100
+ this.ensuredTopics.add(topic);
101
+ return;
102
+ }
103
+ // Best-effort provisioning: allow publish to proceed even if create fails.
104
+ }
69
105
  }
70
106
  }
71
107
 
@@ -179,3 +215,21 @@ function resolveTopic(argumentTopic: string, messageTopic?: string): string {
179
215
  }
180
216
  return topic;
181
217
  }
218
+
219
+ function isTopicAlreadyExists(err: unknown): boolean {
220
+ if (!err || typeof err !== "object") {
221
+ return false;
222
+ }
223
+ const record = err as {
224
+ type?: string;
225
+ name?: string;
226
+ topicErrors?: Array<{ error?: { type?: string } }>;
227
+ };
228
+ if (record.type === "TOPIC_ALREADY_EXISTS") {
229
+ return true;
230
+ }
231
+ if (record.name === "KafkaJSCreateTopicError" && Array.isArray(record.topicErrors)) {
232
+ return record.topicErrors.every((topicError) => topicError?.error?.type === "TOPIC_ALREADY_EXISTS");
233
+ }
234
+ return false;
235
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { KafkaPublisher, KafkaSubscriber } from "../src/index";
3
- import { decodeEnvelope } from "@relaymesh/relaybus-core";
3
+ import { decodeEnvelope } from "../src/core";
4
4
 
5
5
  describe("KafkaPublisher", () => {
6
6
  it("sends encoded envelope", async () => {
package/tsconfig.json CHANGED
@@ -4,11 +4,7 @@
4
4
  "rootDir": "src",
5
5
  "outDir": "dist",
6
6
  "declaration": true,
7
- "types": ["node"],
8
- "baseUrl": ".",
9
- "paths": {
10
- "@relaymesh/relaybus-core": ["../../core/typescript/dist"]
11
- }
7
+ "types": ["node"]
12
8
  },
13
9
  "include": ["src/**/*.ts"]
14
10
  }