@drarzter/kafka-client 0.1.9 → 0.2.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/dist/index.js CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  KafkaModule: () => KafkaModule,
39
39
  KafkaProcessingError: () => KafkaProcessingError,
40
40
  KafkaRetryExhaustedError: () => KafkaRetryExhaustedError,
41
+ KafkaValidationError: () => KafkaValidationError,
41
42
  SubscribeTo: () => SubscribeTo,
42
43
  getKafkaClientToken: () => getKafkaClientToken,
43
44
  topic: () => topic
@@ -62,6 +63,15 @@ var KafkaProcessingError = class extends Error {
62
63
  if (options?.cause) this.cause = options.cause;
63
64
  }
64
65
  };
66
+ var KafkaValidationError = class extends Error {
67
+ constructor(topic2, originalMessage, options) {
68
+ super(`Schema validation failed for topic "${topic2}"`, options);
69
+ this.topic = topic2;
70
+ this.originalMessage = originalMessage;
71
+ this.name = "KafkaValidationError";
72
+ if (options?.cause) this.cause = options.cause;
73
+ }
74
+ };
65
75
  var KafkaRetryExhaustedError = class extends KafkaProcessingError {
66
76
  constructor(topic2, originalMessage, attempts, options) {
67
77
  super(
@@ -79,15 +89,17 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
79
89
  var KafkaClient = class {
80
90
  kafka;
81
91
  producer;
82
- consumer;
92
+ consumers = /* @__PURE__ */ new Map();
83
93
  admin;
84
94
  logger;
85
95
  autoCreateTopicsEnabled;
86
96
  ensuredTopics = /* @__PURE__ */ new Set();
97
+ defaultGroupId;
87
98
  isAdminConnected = false;
88
99
  clientId;
89
100
  constructor(clientId, groupId, brokers, options) {
90
101
  this.clientId = clientId;
102
+ this.defaultGroupId = groupId;
91
103
  this.logger = new import_common.Logger(`KafkaClient:${clientId}`);
92
104
  this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
93
105
  this.kafka = new import_kafkajs.Kafka({
@@ -100,9 +112,15 @@ var KafkaClient = class {
100
112
  transactionalId: `${clientId}-tx`,
101
113
  maxInFlightRequests: 1
102
114
  });
103
- this.consumer = this.kafka.consumer({ groupId });
104
115
  this.admin = this.kafka.admin();
105
116
  }
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
+ }
106
124
  resolveTopicName(topicOrDescriptor) {
107
125
  if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
108
126
  if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
@@ -121,14 +139,21 @@ var KafkaClient = class {
121
139
  });
122
140
  this.ensuredTopics.add(topic2);
123
141
  }
142
+ validateMessage(topicOrDesc, message) {
143
+ if (topicOrDesc?.__schema) {
144
+ return topicOrDesc.__schema.parse(message);
145
+ }
146
+ return message;
147
+ }
124
148
  async sendMessage(topicOrDesc, message, options = {}) {
149
+ const validated = this.validateMessage(topicOrDesc, message);
125
150
  const topic2 = this.resolveTopicName(topicOrDesc);
126
151
  await this.ensureTopic(topic2);
127
152
  await this.producer.send({
128
153
  topic: topic2,
129
154
  messages: [
130
155
  {
131
- value: JSON.stringify(message),
156
+ value: JSON.stringify(validated),
132
157
  key: options.key ?? null,
133
158
  headers: options.headers
134
159
  }
@@ -142,7 +167,7 @@ var KafkaClient = class {
142
167
  await this.producer.send({
143
168
  topic: topic2,
144
169
  messages: messages.map((m) => ({
145
- value: JSON.stringify(m.value),
170
+ value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
146
171
  key: m.key ?? null,
147
172
  headers: m.headers
148
173
  })),
@@ -155,13 +180,14 @@ var KafkaClient = class {
155
180
  try {
156
181
  const ctx = {
157
182
  send: async (topicOrDesc, message, options = {}) => {
183
+ const validated = this.validateMessage(topicOrDesc, message);
158
184
  const topic2 = this.resolveTopicName(topicOrDesc);
159
185
  await this.ensureTopic(topic2);
160
186
  await tx.send({
161
187
  topic: topic2,
162
188
  messages: [
163
189
  {
164
- value: JSON.stringify(message),
190
+ value: JSON.stringify(validated),
165
191
  key: options.key ?? null,
166
192
  headers: options.headers
167
193
  }
@@ -175,7 +201,7 @@ var KafkaClient = class {
175
201
  await tx.send({
176
202
  topic: topic2,
177
203
  messages: messages.map((m) => ({
178
- value: JSON.stringify(m.value),
204
+ value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
179
205
  key: m.key ?? null,
180
206
  headers: m.headers
181
207
  })),
@@ -201,22 +227,26 @@ var KafkaClient = class {
201
227
  }
202
228
  async startConsumer(topics, handleMessage, options = {}) {
203
229
  const {
230
+ groupId: optGroupId,
204
231
  fromBeginning = false,
205
232
  autoCommit = true,
206
233
  retry,
207
234
  dlq = false,
208
- interceptors = []
235
+ interceptors = [],
236
+ schemas: optionSchemas
209
237
  } = options;
238
+ const consumer = this.getOrCreateConsumer(optGroupId);
239
+ const schemaMap = this.buildSchemaMap(topics, optionSchemas);
210
240
  const topicNames = topics.map(
211
241
  (t) => this.resolveTopicName(t)
212
242
  );
213
- await this.consumer.connect();
243
+ await consumer.connect();
214
244
  for (const t of topicNames) {
215
245
  await this.ensureTopic(t);
216
246
  }
217
- await this.consumer.subscribe({ topics: topicNames, fromBeginning });
247
+ await consumer.subscribe({ topics: topicNames, fromBeginning });
218
248
  this.logger.log(`Consumer subscribed to topics: ${topicNames.join(", ")}`);
219
- await this.consumer.run({
249
+ await consumer.run({
220
250
  autoCommit,
221
251
  eachMessage: async ({ topic: topic2, message }) => {
222
252
  if (!message.value) {
@@ -234,6 +264,32 @@ var KafkaClient = class {
234
264
  );
235
265
  return;
236
266
  }
267
+ const schema = schemaMap.get(topic2);
268
+ if (schema) {
269
+ try {
270
+ parsedMessage = schema.parse(parsedMessage);
271
+ } catch (error) {
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
+ }
237
293
  await this.processMessage(parsedMessage, raw, topic2, handleMessage, {
238
294
  retry,
239
295
  dlq,
@@ -242,9 +298,162 @@ var KafkaClient = class {
242
298
  }
243
299
  });
244
300
  }
301
+ 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
+ );
324
+ await consumer.run({
325
+ autoCommit,
326
+ eachBatch: async ({
327
+ batch,
328
+ heartbeat,
329
+ resolveOffset,
330
+ commitOffsetsIfNecessary
331
+ }) => {
332
+ const validMessages = [];
333
+ for (const message of batch.messages) {
334
+ if (!message.value) {
335
+ this.logger.warn(
336
+ `Received empty message from topic ${batch.topic}`
337
+ );
338
+ continue;
339
+ }
340
+ const raw = message.value.toString();
341
+ let parsedMessage;
342
+ try {
343
+ parsedMessage = JSON.parse(raw);
344
+ } catch (error) {
345
+ this.logger.error(
346
+ `Failed to parse message from topic ${batch.topic}:`,
347
+ error instanceof Error ? error.stack : String(error)
348
+ );
349
+ continue;
350
+ }
351
+ const schema = schemaMap.get(batch.topic);
352
+ if (schema) {
353
+ try {
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);
378
+ }
379
+ if (validMessages.length === 0) return;
380
+ const meta = {
381
+ partition: batch.partition,
382
+ highWatermark: batch.highWatermark,
383
+ heartbeat,
384
+ resolveOffset,
385
+ commitOffsetsIfNecessary
386
+ };
387
+ const maxAttempts = retry ? retry.maxRetries + 1 : 1;
388
+ const backoffMs = retry?.backoffMs ?? 1e3;
389
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
390
+ try {
391
+ for (const interceptor of interceptors) {
392
+ for (const msg of validMessages) {
393
+ await interceptor.before?.(msg, batch.topic);
394
+ }
395
+ }
396
+ await handleBatch(validMessages, batch.topic, meta);
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
+ }
444
+ }
445
+ }
446
+ }
447
+ });
448
+ }
245
449
  async stopConsumer() {
246
- await this.consumer.disconnect();
247
- this.logger.log("Consumer disconnected");
450
+ const tasks = [];
451
+ for (const consumer of this.consumers.values()) {
452
+ tasks.push(consumer.disconnect());
453
+ }
454
+ await Promise.allSettled(tasks);
455
+ this.consumers.clear();
456
+ this.logger.log("All consumers disconnected");
248
457
  }
249
458
  /** Check broker connectivity and return available topics. */
250
459
  async checkStatus() {
@@ -258,17 +467,35 @@ var KafkaClient = class {
258
467
  getClientId() {
259
468
  return this.clientId;
260
469
  }
261
- /** Gracefully disconnect producer, consumer, and admin. */
470
+ /** Gracefully disconnect producer, all consumers, and admin. */
262
471
  async disconnect() {
263
- const tasks = [this.producer.disconnect(), this.consumer.disconnect()];
472
+ const tasks = [this.producer.disconnect()];
473
+ for (const consumer of this.consumers.values()) {
474
+ tasks.push(consumer.disconnect());
475
+ }
264
476
  if (this.isAdminConnected) {
265
477
  tasks.push(this.admin.disconnect());
266
478
  this.isAdminConnected = false;
267
479
  }
268
480
  await Promise.allSettled(tasks);
481
+ this.consumers.clear();
269
482
  this.logger.log("All connections closed");
270
483
  }
271
484
  // --- Private helpers ---
485
+ buildSchemaMap(topics, optionSchemas) {
486
+ const schemaMap = /* @__PURE__ */ new Map();
487
+ for (const t of topics) {
488
+ if (t?.__schema) {
489
+ schemaMap.set(this.resolveTopicName(t), t.__schema);
490
+ }
491
+ }
492
+ if (optionSchemas) {
493
+ for (const [k, v] of optionSchemas) {
494
+ schemaMap.set(k, v);
495
+ }
496
+ }
497
+ return schemaMap;
498
+ }
272
499
  async processMessage(parsedMessage, raw, topic2, handleMessage, opts) {
273
500
  const { retry, dlq = false, interceptors = [] } = opts;
274
501
  const maxAttempts = retry ? retry.maxRetries + 1 : 1;
@@ -348,10 +575,14 @@ var KAFKA_SUBSCRIBER_METADATA = "KAFKA_SUBSCRIBER_METADATA";
348
575
  var InjectKafkaClient = (name) => (0, import_common2.Inject)(getKafkaClientToken(name));
349
576
  var SubscribeTo = (topics, options) => {
350
577
  const arr = Array.isArray(topics) ? topics : [topics];
351
- const topicsArray = arr.map(
352
- (t) => typeof t === "string" ? t : t.__topic
353
- );
354
- const { clientName, ...consumerOptions } = options || {};
578
+ const topicsArray = arr.map((t) => typeof t === "string" ? t : t.__topic);
579
+ const schemas = /* @__PURE__ */ new Map();
580
+ for (const t of arr) {
581
+ if (typeof t !== "string" && t.__schema) {
582
+ schemas.set(t.__topic, t.__schema);
583
+ }
584
+ }
585
+ const { clientName, batch, ...consumerOptions } = options || {};
355
586
  return (target, propertyKey, _descriptor) => {
356
587
  const existing = Reflect.getMetadata(KAFKA_SUBSCRIBER_METADATA, target.constructor) || [];
357
588
  Reflect.defineMetadata(
@@ -360,8 +591,10 @@ var SubscribeTo = (topics, options) => {
360
591
  ...existing,
361
592
  {
362
593
  topics: topicsArray,
594
+ schemas: schemas.size > 0 ? schemas : void 0,
363
595
  options: Object.keys(consumerOptions).length ? consumerOptions : void 0,
364
596
  clientName,
597
+ batch,
365
598
  methodName: propertyKey
366
599
  }
367
600
  ],
@@ -399,15 +632,29 @@ var KafkaExplorer = class {
399
632
  continue;
400
633
  }
401
634
  const handler = instance[entry.methodName].bind(instance);
402
- await client.startConsumer(
403
- entry.topics,
404
- async (message, topic2) => {
405
- await handler(message, topic2);
406
- },
407
- entry.options
408
- );
635
+ const consumerOptions = { ...entry.options };
636
+ if (entry.schemas) {
637
+ consumerOptions.schemas = entry.schemas;
638
+ }
639
+ if (entry.batch) {
640
+ await client.startBatchConsumer(
641
+ entry.topics,
642
+ async (messages, topic2, meta) => {
643
+ await handler(messages, topic2, meta);
644
+ },
645
+ consumerOptions
646
+ );
647
+ } else {
648
+ await client.startConsumer(
649
+ entry.topics,
650
+ async (message, topic2) => {
651
+ await handler(message, topic2);
652
+ },
653
+ consumerOptions
654
+ );
655
+ }
409
656
  this.logger.log(
410
- `Registered @SubscribeTo(${entry.topics.join(", ")}) on ${instance.constructor.name}.${String(entry.methodName)}`
657
+ `Registered @SubscribeTo(${entry.topics.join(", ")})${entry.batch ? " [batch]" : ""} on ${instance.constructor.name}.${String(entry.methodName)}`
411
658
  );
412
659
  }
413
660
  }
@@ -492,10 +739,16 @@ KafkaModule = __decorateClass([
492
739
 
493
740
  // src/client/topic.ts
494
741
  function topic(name) {
495
- return () => ({
742
+ const fn = () => ({
496
743
  __topic: name,
497
744
  __type: void 0
498
745
  });
746
+ fn.schema = (schema) => ({
747
+ __topic: name,
748
+ __type: void 0,
749
+ __schema: schema
750
+ });
751
+ return fn;
499
752
  }
500
753
 
501
754
  // src/health/kafka.health.ts
@@ -532,6 +785,7 @@ KafkaHealthIndicator = __decorateClass([
532
785
  KafkaModule,
533
786
  KafkaProcessingError,
534
787
  KafkaRetryExhaustedError,
788
+ KafkaValidationError,
535
789
  SubscribeTo,
536
790
  getKafkaClientToken,
537
791
  topic