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