@drarzter/kafka-client 0.2.0 → 0.2.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 +25 -1
- package/dist/index.d.mts +44 -7
- package/dist/index.d.ts +44 -7
- package/dist/index.js +263 -278
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +263 -278
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -51,6 +51,10 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
// src/client/kafka.client.ts
|
|
54
|
+
var ACKS_ALL = -1;
|
|
55
|
+
function toError(error) {
|
|
56
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
57
|
+
}
|
|
54
58
|
var KafkaClient = class {
|
|
55
59
|
kafka;
|
|
56
60
|
producer;
|
|
@@ -58,8 +62,11 @@ var KafkaClient = class {
|
|
|
58
62
|
admin;
|
|
59
63
|
logger;
|
|
60
64
|
autoCreateTopicsEnabled;
|
|
65
|
+
strictSchemasEnabled;
|
|
61
66
|
ensuredTopics = /* @__PURE__ */ new Set();
|
|
62
67
|
defaultGroupId;
|
|
68
|
+
schemaRegistry = /* @__PURE__ */ new Map();
|
|
69
|
+
runningConsumers = /* @__PURE__ */ new Map();
|
|
63
70
|
isAdminConnected = false;
|
|
64
71
|
clientId;
|
|
65
72
|
constructor(clientId, groupId, brokers, options) {
|
|
@@ -67,6 +74,7 @@ var KafkaClient = class {
|
|
|
67
74
|
this.defaultGroupId = groupId;
|
|
68
75
|
this.logger = new Logger(`KafkaClient:${clientId}`);
|
|
69
76
|
this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
|
|
77
|
+
this.strictSchemasEnabled = options?.strictSchemas ?? true;
|
|
70
78
|
this.kafka = new Kafka({
|
|
71
79
|
clientId: this.clientId,
|
|
72
80
|
brokers
|
|
@@ -79,65 +87,17 @@ var KafkaClient = class {
|
|
|
79
87
|
});
|
|
80
88
|
this.admin = this.kafka.admin();
|
|
81
89
|
}
|
|
82
|
-
getOrCreateConsumer(groupId) {
|
|
83
|
-
const gid = groupId || this.defaultGroupId;
|
|
84
|
-
if (!this.consumers.has(gid)) {
|
|
85
|
-
this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
|
|
86
|
-
}
|
|
87
|
-
return this.consumers.get(gid);
|
|
88
|
-
}
|
|
89
|
-
resolveTopicName(topicOrDescriptor) {
|
|
90
|
-
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
91
|
-
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
92
|
-
return topicOrDescriptor.__topic;
|
|
93
|
-
}
|
|
94
|
-
return String(topicOrDescriptor);
|
|
95
|
-
}
|
|
96
|
-
async ensureTopic(topic2) {
|
|
97
|
-
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
98
|
-
if (!this.isAdminConnected) {
|
|
99
|
-
await this.admin.connect();
|
|
100
|
-
this.isAdminConnected = true;
|
|
101
|
-
}
|
|
102
|
-
await this.admin.createTopics({
|
|
103
|
-
topics: [{ topic: topic2, numPartitions: 1 }]
|
|
104
|
-
});
|
|
105
|
-
this.ensuredTopics.add(topic2);
|
|
106
|
-
}
|
|
107
|
-
validateMessage(topicOrDesc, message) {
|
|
108
|
-
if (topicOrDesc?.__schema) {
|
|
109
|
-
return topicOrDesc.__schema.parse(message);
|
|
110
|
-
}
|
|
111
|
-
return message;
|
|
112
|
-
}
|
|
113
90
|
async sendMessage(topicOrDesc, message, options = {}) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
await this.
|
|
118
|
-
|
|
119
|
-
messages: [
|
|
120
|
-
{
|
|
121
|
-
value: JSON.stringify(validated),
|
|
122
|
-
key: options.key ?? null,
|
|
123
|
-
headers: options.headers
|
|
124
|
-
}
|
|
125
|
-
],
|
|
126
|
-
acks: -1
|
|
127
|
-
});
|
|
91
|
+
const payload = this.buildSendPayload(topicOrDesc, [
|
|
92
|
+
{ value: message, key: options.key, headers: options.headers }
|
|
93
|
+
]);
|
|
94
|
+
await this.ensureTopic(payload.topic);
|
|
95
|
+
await this.producer.send(payload);
|
|
128
96
|
}
|
|
129
97
|
async sendBatch(topicOrDesc, messages) {
|
|
130
|
-
const
|
|
131
|
-
await this.ensureTopic(
|
|
132
|
-
await this.producer.send(
|
|
133
|
-
topic: topic2,
|
|
134
|
-
messages: messages.map((m) => ({
|
|
135
|
-
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
136
|
-
key: m.key ?? null,
|
|
137
|
-
headers: m.headers
|
|
138
|
-
})),
|
|
139
|
-
acks: -1
|
|
140
|
-
});
|
|
98
|
+
const payload = this.buildSendPayload(topicOrDesc, messages);
|
|
99
|
+
await this.ensureTopic(payload.topic);
|
|
100
|
+
await this.producer.send(payload);
|
|
141
101
|
}
|
|
142
102
|
/** Execute multiple sends atomically. Commits on success, aborts on error. */
|
|
143
103
|
async transaction(fn) {
|
|
@@ -145,33 +105,16 @@ var KafkaClient = class {
|
|
|
145
105
|
try {
|
|
146
106
|
const ctx = {
|
|
147
107
|
send: async (topicOrDesc, message, options = {}) => {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
messages: [
|
|
154
|
-
{
|
|
155
|
-
value: JSON.stringify(validated),
|
|
156
|
-
key: options.key ?? null,
|
|
157
|
-
headers: options.headers
|
|
158
|
-
}
|
|
159
|
-
],
|
|
160
|
-
acks: -1
|
|
161
|
-
});
|
|
108
|
+
const payload = this.buildSendPayload(topicOrDesc, [
|
|
109
|
+
{ value: message, key: options.key, headers: options.headers }
|
|
110
|
+
]);
|
|
111
|
+
await this.ensureTopic(payload.topic);
|
|
112
|
+
await tx.send(payload);
|
|
162
113
|
},
|
|
163
114
|
sendBatch: async (topicOrDesc, messages) => {
|
|
164
|
-
const
|
|
165
|
-
await this.ensureTopic(
|
|
166
|
-
await tx.send(
|
|
167
|
-
topic: topic2,
|
|
168
|
-
messages: messages.map((m) => ({
|
|
169
|
-
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
170
|
-
key: m.key ?? null,
|
|
171
|
-
headers: m.headers
|
|
172
|
-
})),
|
|
173
|
-
acks: -1
|
|
174
|
-
});
|
|
115
|
+
const payload = this.buildSendPayload(topicOrDesc, messages);
|
|
116
|
+
await this.ensureTopic(payload.topic);
|
|
117
|
+
await tx.send(payload);
|
|
175
118
|
}
|
|
176
119
|
};
|
|
177
120
|
await fn(ctx);
|
|
@@ -181,6 +124,7 @@ var KafkaClient = class {
|
|
|
181
124
|
throw error;
|
|
182
125
|
}
|
|
183
126
|
}
|
|
127
|
+
// ── Producer lifecycle ───────────────────────────────────────────
|
|
184
128
|
/** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
|
|
185
129
|
async connectProducer() {
|
|
186
130
|
await this.producer.connect();
|
|
@@ -191,103 +135,38 @@ var KafkaClient = class {
|
|
|
191
135
|
this.logger.log("Producer disconnected");
|
|
192
136
|
}
|
|
193
137
|
async startConsumer(topics, handleMessage, options = {}) {
|
|
194
|
-
const {
|
|
195
|
-
groupId: optGroupId,
|
|
196
|
-
fromBeginning = false,
|
|
197
|
-
autoCommit = true,
|
|
198
|
-
retry,
|
|
199
|
-
dlq = false,
|
|
200
|
-
interceptors = [],
|
|
201
|
-
schemas: optionSchemas
|
|
202
|
-
} = options;
|
|
203
|
-
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
204
|
-
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
205
|
-
const topicNames = topics.map(
|
|
206
|
-
(t) => this.resolveTopicName(t)
|
|
207
|
-
);
|
|
208
|
-
await consumer.connect();
|
|
209
|
-
for (const t of topicNames) {
|
|
210
|
-
await this.ensureTopic(t);
|
|
211
|
-
}
|
|
212
|
-
await consumer.subscribe({ topics: topicNames, fromBeginning });
|
|
213
|
-
this.logger.log(`Consumer subscribed to topics: ${topicNames.join(", ")}`);
|
|
138
|
+
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
|
|
214
139
|
await consumer.run({
|
|
215
|
-
autoCommit,
|
|
140
|
+
autoCommit: options.autoCommit ?? true,
|
|
216
141
|
eachMessage: async ({ topic: topic2, message }) => {
|
|
217
142
|
if (!message.value) {
|
|
218
143
|
this.logger.warn(`Received empty message from topic ${topic2}`);
|
|
219
144
|
return;
|
|
220
145
|
}
|
|
221
146
|
const raw = message.value.toString();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
238
|
-
const validationError = new KafkaValidationError(
|
|
239
|
-
topic2,
|
|
240
|
-
parsedMessage,
|
|
241
|
-
{ cause: err }
|
|
242
|
-
);
|
|
243
|
-
this.logger.error(
|
|
244
|
-
`Schema validation failed for topic ${topic2}:`,
|
|
245
|
-
err.message
|
|
246
|
-
);
|
|
247
|
-
if (dlq) await this.sendToDlq(topic2, raw);
|
|
248
|
-
for (const interceptor of interceptors) {
|
|
249
|
-
await interceptor.onError?.(
|
|
250
|
-
parsedMessage,
|
|
251
|
-
topic2,
|
|
252
|
-
validationError
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
await this.processMessage(parsedMessage, raw, topic2, handleMessage, {
|
|
259
|
-
retry,
|
|
260
|
-
dlq,
|
|
261
|
-
interceptors
|
|
262
|
-
});
|
|
147
|
+
const parsed = this.parseJsonMessage(raw, topic2);
|
|
148
|
+
if (parsed === null) return;
|
|
149
|
+
const validated = await this.validateWithSchema(
|
|
150
|
+
parsed,
|
|
151
|
+
raw,
|
|
152
|
+
topic2,
|
|
153
|
+
schemaMap,
|
|
154
|
+
interceptors,
|
|
155
|
+
dlq
|
|
156
|
+
);
|
|
157
|
+
if (validated === null) return;
|
|
158
|
+
await this.executeWithRetry(
|
|
159
|
+
() => handleMessage(validated, topic2),
|
|
160
|
+
{ topic: topic2, messages: validated, rawMessages: [raw], interceptors, dlq, retry }
|
|
161
|
+
);
|
|
263
162
|
}
|
|
264
163
|
});
|
|
164
|
+
this.runningConsumers.set(gid, "eachMessage");
|
|
265
165
|
}
|
|
266
166
|
async startBatchConsumer(topics, handleBatch, options = {}) {
|
|
267
|
-
const {
|
|
268
|
-
groupId: optGroupId,
|
|
269
|
-
fromBeginning = false,
|
|
270
|
-
autoCommit = true,
|
|
271
|
-
retry,
|
|
272
|
-
dlq = false,
|
|
273
|
-
interceptors = [],
|
|
274
|
-
schemas: optionSchemas
|
|
275
|
-
} = options;
|
|
276
|
-
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
277
|
-
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
278
|
-
const topicNames = topics.map(
|
|
279
|
-
(t) => this.resolveTopicName(t)
|
|
280
|
-
);
|
|
281
|
-
await consumer.connect();
|
|
282
|
-
for (const t of topicNames) {
|
|
283
|
-
await this.ensureTopic(t);
|
|
284
|
-
}
|
|
285
|
-
await consumer.subscribe({ topics: topicNames, fromBeginning });
|
|
286
|
-
this.logger.log(
|
|
287
|
-
`Batch consumer subscribed to topics: ${topicNames.join(", ")}`
|
|
288
|
-
);
|
|
167
|
+
const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
|
|
289
168
|
await consumer.run({
|
|
290
|
-
autoCommit,
|
|
169
|
+
autoCommit: options.autoCommit ?? true,
|
|
291
170
|
eachBatch: async ({
|
|
292
171
|
batch,
|
|
293
172
|
heartbeat,
|
|
@@ -295,6 +174,7 @@ var KafkaClient = class {
|
|
|
295
174
|
commitOffsetsIfNecessary
|
|
296
175
|
}) => {
|
|
297
176
|
const validMessages = [];
|
|
177
|
+
const rawMessages = [];
|
|
298
178
|
for (const message of batch.messages) {
|
|
299
179
|
if (!message.value) {
|
|
300
180
|
this.logger.warn(
|
|
@@ -303,43 +183,19 @@ var KafkaClient = class {
|
|
|
303
183
|
continue;
|
|
304
184
|
}
|
|
305
185
|
const raw = message.value.toString();
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
parsedMessage = schema.parse(parsedMessage);
|
|
320
|
-
} catch (error) {
|
|
321
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
322
|
-
const validationError = new KafkaValidationError(
|
|
323
|
-
batch.topic,
|
|
324
|
-
parsedMessage,
|
|
325
|
-
{ cause: err }
|
|
326
|
-
);
|
|
327
|
-
this.logger.error(
|
|
328
|
-
`Schema validation failed for topic ${batch.topic}:`,
|
|
329
|
-
err.message
|
|
330
|
-
);
|
|
331
|
-
if (dlq) await this.sendToDlq(batch.topic, raw);
|
|
332
|
-
for (const interceptor of interceptors) {
|
|
333
|
-
await interceptor.onError?.(
|
|
334
|
-
parsedMessage,
|
|
335
|
-
batch.topic,
|
|
336
|
-
validationError
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
validMessages.push(parsedMessage);
|
|
186
|
+
const parsed = this.parseJsonMessage(raw, batch.topic);
|
|
187
|
+
if (parsed === null) continue;
|
|
188
|
+
const validated = await this.validateWithSchema(
|
|
189
|
+
parsed,
|
|
190
|
+
raw,
|
|
191
|
+
batch.topic,
|
|
192
|
+
schemaMap,
|
|
193
|
+
interceptors,
|
|
194
|
+
dlq
|
|
195
|
+
);
|
|
196
|
+
if (validated === null) continue;
|
|
197
|
+
validMessages.push(validated);
|
|
198
|
+
rawMessages.push(raw);
|
|
343
199
|
}
|
|
344
200
|
if (validMessages.length === 0) return;
|
|
345
201
|
const meta = {
|
|
@@ -349,68 +205,23 @@ var KafkaClient = class {
|
|
|
349
205
|
resolveOffset,
|
|
350
206
|
commitOffsetsIfNecessary
|
|
351
207
|
};
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
for (const interceptor of interceptors) {
|
|
363
|
-
for (const msg of validMessages) {
|
|
364
|
-
await interceptor.after?.(msg, batch.topic);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return;
|
|
368
|
-
} catch (error) {
|
|
369
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
370
|
-
const isLastAttempt = attempt === maxAttempts;
|
|
371
|
-
if (isLastAttempt && maxAttempts > 1) {
|
|
372
|
-
const exhaustedError = new KafkaRetryExhaustedError(
|
|
373
|
-
batch.topic,
|
|
374
|
-
validMessages,
|
|
375
|
-
maxAttempts,
|
|
376
|
-
{ cause: err }
|
|
377
|
-
);
|
|
378
|
-
for (const interceptor of interceptors) {
|
|
379
|
-
await interceptor.onError?.(
|
|
380
|
-
validMessages,
|
|
381
|
-
batch.topic,
|
|
382
|
-
exhaustedError
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
} else {
|
|
386
|
-
for (const interceptor of interceptors) {
|
|
387
|
-
await interceptor.onError?.(
|
|
388
|
-
validMessages,
|
|
389
|
-
batch.topic,
|
|
390
|
-
err
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
this.logger.error(
|
|
395
|
-
`Error processing batch from topic ${batch.topic} (attempt ${attempt}/${maxAttempts}):`,
|
|
396
|
-
err.stack
|
|
397
|
-
);
|
|
398
|
-
if (isLastAttempt) {
|
|
399
|
-
if (dlq) {
|
|
400
|
-
for (const msg of batch.messages) {
|
|
401
|
-
if (msg.value) {
|
|
402
|
-
await this.sendToDlq(batch.topic, msg.value.toString());
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
await this.sleep(backoffMs * attempt);
|
|
408
|
-
}
|
|
208
|
+
await this.executeWithRetry(
|
|
209
|
+
() => handleBatch(validMessages, batch.topic, meta),
|
|
210
|
+
{
|
|
211
|
+
topic: batch.topic,
|
|
212
|
+
messages: validMessages,
|
|
213
|
+
rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
|
|
214
|
+
interceptors,
|
|
215
|
+
dlq,
|
|
216
|
+
retry,
|
|
217
|
+
isBatch: true
|
|
409
218
|
}
|
|
410
|
-
|
|
219
|
+
);
|
|
411
220
|
}
|
|
412
221
|
});
|
|
222
|
+
this.runningConsumers.set(gid, "eachBatch");
|
|
413
223
|
}
|
|
224
|
+
// ── Consumer lifecycle ───────────────────────────────────────────
|
|
414
225
|
async stopConsumer() {
|
|
415
226
|
const tasks = [];
|
|
416
227
|
for (const consumer of this.consumers.values()) {
|
|
@@ -418,6 +229,7 @@ var KafkaClient = class {
|
|
|
418
229
|
}
|
|
419
230
|
await Promise.allSettled(tasks);
|
|
420
231
|
this.consumers.clear();
|
|
232
|
+
this.runningConsumers.clear();
|
|
421
233
|
this.logger.log("All consumers disconnected");
|
|
422
234
|
}
|
|
423
235
|
/** Check broker connectivity and return available topics. */
|
|
@@ -444,61 +256,217 @@ var KafkaClient = class {
|
|
|
444
256
|
}
|
|
445
257
|
await Promise.allSettled(tasks);
|
|
446
258
|
this.consumers.clear();
|
|
259
|
+
this.runningConsumers.clear();
|
|
447
260
|
this.logger.log("All connections closed");
|
|
448
261
|
}
|
|
449
|
-
//
|
|
262
|
+
// ── Private helpers ──────────────────────────────────────────────
|
|
263
|
+
getOrCreateConsumer(groupId) {
|
|
264
|
+
const gid = groupId || this.defaultGroupId;
|
|
265
|
+
if (!this.consumers.has(gid)) {
|
|
266
|
+
this.consumers.set(gid, this.kafka.consumer({ groupId: gid }));
|
|
267
|
+
}
|
|
268
|
+
return this.consumers.get(gid);
|
|
269
|
+
}
|
|
270
|
+
resolveTopicName(topicOrDescriptor) {
|
|
271
|
+
if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
|
|
272
|
+
if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
|
|
273
|
+
return topicOrDescriptor.__topic;
|
|
274
|
+
}
|
|
275
|
+
return String(topicOrDescriptor);
|
|
276
|
+
}
|
|
277
|
+
async ensureTopic(topic2) {
|
|
278
|
+
if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
|
|
279
|
+
if (!this.isAdminConnected) {
|
|
280
|
+
await this.admin.connect();
|
|
281
|
+
this.isAdminConnected = true;
|
|
282
|
+
}
|
|
283
|
+
await this.admin.createTopics({
|
|
284
|
+
topics: [{ topic: topic2, numPartitions: 1 }]
|
|
285
|
+
});
|
|
286
|
+
this.ensuredTopics.add(topic2);
|
|
287
|
+
}
|
|
288
|
+
/** Register schema from descriptor into global registry (side-effect). */
|
|
289
|
+
registerSchema(topicOrDesc) {
|
|
290
|
+
if (topicOrDesc?.__schema) {
|
|
291
|
+
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
292
|
+
this.schemaRegistry.set(topic2, topicOrDesc.__schema);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Validate message against schema. Pure — no side-effects on registry. */
|
|
296
|
+
validateMessage(topicOrDesc, message) {
|
|
297
|
+
if (topicOrDesc?.__schema) {
|
|
298
|
+
return topicOrDesc.__schema.parse(message);
|
|
299
|
+
}
|
|
300
|
+
if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
|
|
301
|
+
const schema = this.schemaRegistry.get(topicOrDesc);
|
|
302
|
+
if (schema) return schema.parse(message);
|
|
303
|
+
}
|
|
304
|
+
return message;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Build a kafkajs-ready send payload.
|
|
308
|
+
* Handles: topic resolution, schema registration, validation, JSON serialization.
|
|
309
|
+
*/
|
|
310
|
+
buildSendPayload(topicOrDesc, messages) {
|
|
311
|
+
this.registerSchema(topicOrDesc);
|
|
312
|
+
const topic2 = this.resolveTopicName(topicOrDesc);
|
|
313
|
+
return {
|
|
314
|
+
topic: topic2,
|
|
315
|
+
messages: messages.map((m) => ({
|
|
316
|
+
value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
|
|
317
|
+
key: m.key ?? null,
|
|
318
|
+
headers: m.headers
|
|
319
|
+
})),
|
|
320
|
+
acks: ACKS_ALL
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Shared consumer setup: groupId check, schema map, connect, subscribe. */
|
|
324
|
+
async setupConsumer(topics, mode, options) {
|
|
325
|
+
const {
|
|
326
|
+
groupId: optGroupId,
|
|
327
|
+
fromBeginning = false,
|
|
328
|
+
retry,
|
|
329
|
+
dlq = false,
|
|
330
|
+
interceptors = [],
|
|
331
|
+
schemas: optionSchemas
|
|
332
|
+
} = options;
|
|
333
|
+
const gid = optGroupId || this.defaultGroupId;
|
|
334
|
+
const existingMode = this.runningConsumers.get(gid);
|
|
335
|
+
const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
|
|
336
|
+
if (existingMode === oppositeMode) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const consumer = this.getOrCreateConsumer(optGroupId);
|
|
342
|
+
const schemaMap = this.buildSchemaMap(topics, optionSchemas);
|
|
343
|
+
const topicNames = topics.map(
|
|
344
|
+
(t) => this.resolveTopicName(t)
|
|
345
|
+
);
|
|
346
|
+
await consumer.connect();
|
|
347
|
+
await this.subscribeWithRetry(consumer, topicNames, fromBeginning, options.subscribeRetry);
|
|
348
|
+
this.logger.log(
|
|
349
|
+
`${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
|
|
350
|
+
);
|
|
351
|
+
return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
|
|
352
|
+
}
|
|
450
353
|
buildSchemaMap(topics, optionSchemas) {
|
|
451
354
|
const schemaMap = /* @__PURE__ */ new Map();
|
|
452
355
|
for (const t of topics) {
|
|
453
356
|
if (t?.__schema) {
|
|
454
|
-
|
|
357
|
+
const name = this.resolveTopicName(t);
|
|
358
|
+
schemaMap.set(name, t.__schema);
|
|
359
|
+
this.schemaRegistry.set(name, t.__schema);
|
|
455
360
|
}
|
|
456
361
|
}
|
|
457
362
|
if (optionSchemas) {
|
|
458
363
|
for (const [k, v] of optionSchemas) {
|
|
459
364
|
schemaMap.set(k, v);
|
|
365
|
+
this.schemaRegistry.set(k, v);
|
|
460
366
|
}
|
|
461
367
|
}
|
|
462
368
|
return schemaMap;
|
|
463
369
|
}
|
|
464
|
-
|
|
465
|
-
|
|
370
|
+
/** Parse raw message as JSON. Returns null on failure (logs error). */
|
|
371
|
+
parseJsonMessage(raw, topic2) {
|
|
372
|
+
try {
|
|
373
|
+
return JSON.parse(raw);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
this.logger.error(
|
|
376
|
+
`Failed to parse message from topic ${topic2}:`,
|
|
377
|
+
toError(error).stack
|
|
378
|
+
);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Validate a parsed message against the schema map.
|
|
384
|
+
* On failure: logs error, sends to DLQ if enabled, calls interceptor.onError.
|
|
385
|
+
* Returns validated message or null.
|
|
386
|
+
*/
|
|
387
|
+
async validateWithSchema(message, raw, topic2, schemaMap, interceptors, dlq) {
|
|
388
|
+
const schema = schemaMap.get(topic2);
|
|
389
|
+
if (!schema) return message;
|
|
390
|
+
try {
|
|
391
|
+
return schema.parse(message);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const err = toError(error);
|
|
394
|
+
const validationError = new KafkaValidationError(topic2, message, {
|
|
395
|
+
cause: err
|
|
396
|
+
});
|
|
397
|
+
this.logger.error(
|
|
398
|
+
`Schema validation failed for topic ${topic2}:`,
|
|
399
|
+
err.message
|
|
400
|
+
);
|
|
401
|
+
if (dlq) await this.sendToDlq(topic2, raw);
|
|
402
|
+
for (const interceptor of interceptors) {
|
|
403
|
+
await interceptor.onError?.(message, topic2, validationError);
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Execute a handler with retry, interceptors, and DLQ support.
|
|
410
|
+
* Used by both single-message and batch consumers.
|
|
411
|
+
*/
|
|
412
|
+
async executeWithRetry(fn, ctx) {
|
|
413
|
+
const { topic: topic2, messages, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
|
|
466
414
|
const maxAttempts = retry ? retry.maxRetries + 1 : 1;
|
|
467
415
|
const backoffMs = retry?.backoffMs ?? 1e3;
|
|
468
416
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
469
417
|
try {
|
|
470
|
-
|
|
471
|
-
|
|
418
|
+
if (isBatch) {
|
|
419
|
+
for (const interceptor of interceptors) {
|
|
420
|
+
for (const msg of messages) {
|
|
421
|
+
await interceptor.before?.(msg, topic2);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
for (const interceptor of interceptors) {
|
|
426
|
+
await interceptor.before?.(messages, topic2);
|
|
427
|
+
}
|
|
472
428
|
}
|
|
473
|
-
await
|
|
474
|
-
|
|
475
|
-
|
|
429
|
+
await fn();
|
|
430
|
+
if (isBatch) {
|
|
431
|
+
for (const interceptor of interceptors) {
|
|
432
|
+
for (const msg of messages) {
|
|
433
|
+
await interceptor.after?.(msg, topic2);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
for (const interceptor of interceptors) {
|
|
438
|
+
await interceptor.after?.(messages, topic2);
|
|
439
|
+
}
|
|
476
440
|
}
|
|
477
441
|
return;
|
|
478
442
|
} catch (error) {
|
|
479
|
-
const err =
|
|
443
|
+
const err = toError(error);
|
|
480
444
|
const isLastAttempt = attempt === maxAttempts;
|
|
481
445
|
if (isLastAttempt && maxAttempts > 1) {
|
|
482
446
|
const exhaustedError = new KafkaRetryExhaustedError(
|
|
483
447
|
topic2,
|
|
484
|
-
|
|
448
|
+
messages,
|
|
485
449
|
maxAttempts,
|
|
486
450
|
{ cause: err }
|
|
487
451
|
);
|
|
488
452
|
for (const interceptor of interceptors) {
|
|
489
|
-
await interceptor.onError?.(
|
|
453
|
+
await interceptor.onError?.(messages, topic2, exhaustedError);
|
|
490
454
|
}
|
|
491
455
|
} else {
|
|
492
456
|
for (const interceptor of interceptors) {
|
|
493
|
-
await interceptor.onError?.(
|
|
457
|
+
await interceptor.onError?.(messages, topic2, err);
|
|
494
458
|
}
|
|
495
459
|
}
|
|
496
460
|
this.logger.error(
|
|
497
|
-
`Error processing message from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
461
|
+
`Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
|
|
498
462
|
err.stack
|
|
499
463
|
);
|
|
500
464
|
if (isLastAttempt) {
|
|
501
|
-
if (dlq)
|
|
465
|
+
if (dlq) {
|
|
466
|
+
for (const raw of rawMessages) {
|
|
467
|
+
await this.sendToDlq(topic2, raw);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
502
470
|
} else {
|
|
503
471
|
await this.sleep(backoffMs * attempt);
|
|
504
472
|
}
|
|
@@ -511,16 +479,33 @@ var KafkaClient = class {
|
|
|
511
479
|
await this.producer.send({
|
|
512
480
|
topic: dlqTopic,
|
|
513
481
|
messages: [{ value: rawMessage }],
|
|
514
|
-
acks:
|
|
482
|
+
acks: ACKS_ALL
|
|
515
483
|
});
|
|
516
484
|
this.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
|
|
517
485
|
} catch (error) {
|
|
518
486
|
this.logger.error(
|
|
519
487
|
`Failed to send message to DLQ ${dlqTopic}:`,
|
|
520
|
-
error
|
|
488
|
+
toError(error).stack
|
|
521
489
|
);
|
|
522
490
|
}
|
|
523
491
|
}
|
|
492
|
+
async subscribeWithRetry(consumer, topics, fromBeginning, retryOpts) {
|
|
493
|
+
const maxAttempts = retryOpts?.retries ?? 5;
|
|
494
|
+
const backoffMs = retryOpts?.backoffMs ?? 5e3;
|
|
495
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
496
|
+
try {
|
|
497
|
+
await consumer.subscribe({ topics, fromBeginning });
|
|
498
|
+
return;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (attempt === maxAttempts) throw error;
|
|
501
|
+
const msg = toError(error).message;
|
|
502
|
+
this.logger.warn(
|
|
503
|
+
`Failed to subscribe to [${topics.join(", ")}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${backoffMs}ms...`
|
|
504
|
+
);
|
|
505
|
+
await this.sleep(backoffMs);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
524
509
|
sleep(ms) {
|
|
525
510
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
526
511
|
}
|