@drarzter/kafka-client 0.5.4 → 0.5.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/dist/index.js CHANGED
@@ -55,10 +55,10 @@ __export(index_exports, {
55
55
  });
56
56
  module.exports = __toCommonJS(index_exports);
57
57
 
58
- // src/client/kafka.client.ts
58
+ // src/client/kafka.client/index.ts
59
59
  var import_kafka_javascript = require("@confluentinc/kafka-javascript");
60
60
 
61
- // src/client/envelope.ts
61
+ // src/client/message/envelope.ts
62
62
  var import_node_async_hooks = require("async_hooks");
63
63
  var import_node_crypto = require("crypto");
64
64
  var HEADER_EVENT_ID = "x-event-id";
@@ -118,6 +118,108 @@ function extractEnvelope(payload, headers, topic2, partition, offset) {
118
118
  };
119
119
  }
120
120
 
121
+ // src/client/kafka.client/producer-ops.ts
122
+ function resolveTopicName(topicOrDescriptor) {
123
+ if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
124
+ if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
125
+ return topicOrDescriptor.__topic;
126
+ }
127
+ return String(topicOrDescriptor);
128
+ }
129
+ function registerSchema(topicOrDesc, schemaRegistry) {
130
+ if (topicOrDesc?.__schema) {
131
+ const topic2 = resolveTopicName(topicOrDesc);
132
+ schemaRegistry.set(topic2, topicOrDesc.__schema);
133
+ }
134
+ }
135
+ async function validateMessage(topicOrDesc, message, deps) {
136
+ if (topicOrDesc?.__schema) {
137
+ return await topicOrDesc.__schema.parse(message);
138
+ }
139
+ if (deps.strictSchemasEnabled && typeof topicOrDesc === "string") {
140
+ const schema = deps.schemaRegistry.get(topicOrDesc);
141
+ if (schema) return await schema.parse(message);
142
+ }
143
+ return message;
144
+ }
145
+ async function buildSendPayload(topicOrDesc, messages, deps) {
146
+ registerSchema(topicOrDesc, deps.schemaRegistry);
147
+ const topic2 = resolveTopicName(topicOrDesc);
148
+ const builtMessages = await Promise.all(
149
+ messages.map(async (m) => {
150
+ const envelopeHeaders = buildEnvelopeHeaders({
151
+ correlationId: m.correlationId,
152
+ schemaVersion: m.schemaVersion,
153
+ eventId: m.eventId,
154
+ headers: m.headers
155
+ });
156
+ for (const inst of deps.instrumentation) {
157
+ inst.beforeSend?.(topic2, envelopeHeaders);
158
+ }
159
+ return {
160
+ value: JSON.stringify(
161
+ await validateMessage(topicOrDesc, m.value, deps)
162
+ ),
163
+ key: m.key ?? null,
164
+ headers: envelopeHeaders
165
+ };
166
+ })
167
+ );
168
+ return { topic: topic2, messages: builtMessages };
169
+ }
170
+
171
+ // src/client/kafka.client/consumer-ops.ts
172
+ function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
173
+ const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
174
+ if (consumers.has(groupId)) {
175
+ const prev = consumerCreationOptions.get(groupId);
176
+ if (prev.fromBeginning !== fromBeginning || prev.autoCommit !== autoCommit) {
177
+ logger.warn(
178
+ `Consumer group "${groupId}" already exists with options (fromBeginning: ${prev.fromBeginning}, autoCommit: ${prev.autoCommit}) \u2014 new options (fromBeginning: ${fromBeginning}, autoCommit: ${autoCommit}) ignored. Use a different groupId to apply different options.`
179
+ );
180
+ }
181
+ return consumers.get(groupId);
182
+ }
183
+ consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
184
+ const config = {
185
+ kafkaJS: { groupId, fromBeginning, autoCommit }
186
+ };
187
+ if (onRebalance) {
188
+ const cb = onRebalance;
189
+ config["rebalance_cb"] = (err, assignment) => {
190
+ const type = err.code === -175 ? "assign" : "revoke";
191
+ try {
192
+ cb(
193
+ type,
194
+ assignment.map((p) => ({ topic: p.topic, partition: p.partition }))
195
+ );
196
+ } catch (e) {
197
+ logger.warn(`onRebalance callback threw: ${e.message}`);
198
+ }
199
+ };
200
+ }
201
+ const consumer = kafka.consumer(config);
202
+ consumers.set(groupId, consumer);
203
+ return consumer;
204
+ }
205
+ function buildSchemaMap(topics, schemaRegistry, optionSchemas) {
206
+ const schemaMap = /* @__PURE__ */ new Map();
207
+ for (const t of topics) {
208
+ if (t?.__schema) {
209
+ const name = resolveTopicName(t);
210
+ schemaMap.set(name, t.__schema);
211
+ schemaRegistry.set(name, t.__schema);
212
+ }
213
+ }
214
+ if (optionSchemas) {
215
+ for (const [k, v] of optionSchemas) {
216
+ schemaMap.set(k, v);
217
+ schemaRegistry.set(k, v);
218
+ }
219
+ }
220
+ return schemaMap;
221
+ }
222
+
121
223
  // src/client/errors.ts
122
224
  var KafkaProcessingError = class extends Error {
123
225
  constructor(message, topic2, originalMessage, options) {
@@ -150,7 +252,7 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
150
252
  }
151
253
  };
152
254
 
153
- // src/client/consumer-pipeline.ts
255
+ // src/client/consumer/pipeline.ts
154
256
  function toError(error) {
155
257
  return error instanceof Error ? error : new Error(String(error));
156
258
  }
@@ -189,9 +291,20 @@ async function validateWithSchema(message, raw, topic2, schemaMap, interceptors,
189
291
  originalHeaders: deps.originalHeaders
190
292
  });
191
293
  } else {
192
- await deps.onMessageLost?.({ topic: topic2, error: validationError, attempt: 0, headers: deps.originalHeaders ?? {} });
294
+ await deps.onMessageLost?.({
295
+ topic: topic2,
296
+ error: validationError,
297
+ attempt: 0,
298
+ headers: deps.originalHeaders ?? {}
299
+ });
193
300
  }
194
- const errorEnvelope = extractEnvelope(message, deps.originalHeaders ?? {}, topic2, -1, "");
301
+ const errorEnvelope = extractEnvelope(
302
+ message,
303
+ deps.originalHeaders ?? {},
304
+ topic2,
305
+ -1,
306
+ ""
307
+ );
195
308
  for (const interceptor of interceptors) {
196
309
  await interceptor.onError?.(errorEnvelope, validationError);
197
310
  }
@@ -243,7 +356,10 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
243
356
  };
244
357
  try {
245
358
  for (const raw of rawMessages) {
246
- await deps.producer.send({ topic: retryTopic, messages: [{ value: raw, headers }] });
359
+ await deps.producer.send({
360
+ topic: retryTopic,
361
+ messages: [{ value: raw, headers }]
362
+ });
247
363
  }
248
364
  deps.logger.warn(
249
365
  `Message queued in retry topic ${retryTopic} (attempt ${attempt}/${maxRetries})`
@@ -255,106 +371,244 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
255
371
  );
256
372
  }
257
373
  }
374
+ async function broadcastToInterceptors(envelopes, interceptors, cb) {
375
+ for (const env of envelopes) {
376
+ for (const interceptor of interceptors) {
377
+ await cb(interceptor, env);
378
+ }
379
+ }
380
+ }
381
+ async function runHandlerWithPipeline(fn, envelopes, interceptors, instrumentation) {
382
+ const cleanups = [];
383
+ try {
384
+ for (const env of envelopes) {
385
+ for (const inst of instrumentation) {
386
+ const cleanup = inst.beforeConsume?.(env);
387
+ if (typeof cleanup === "function") cleanups.push(cleanup);
388
+ }
389
+ }
390
+ for (const env of envelopes) {
391
+ for (const interceptor of interceptors) {
392
+ await interceptor.before?.(env);
393
+ }
394
+ }
395
+ await fn();
396
+ for (const env of envelopes) {
397
+ for (const interceptor of interceptors) {
398
+ await interceptor.after?.(env);
399
+ }
400
+ }
401
+ for (const cleanup of cleanups) cleanup();
402
+ return null;
403
+ } catch (error) {
404
+ const err = toError(error);
405
+ for (const env of envelopes) {
406
+ for (const inst of instrumentation) {
407
+ inst.onConsumeError?.(env, err);
408
+ }
409
+ }
410
+ for (const cleanup of cleanups) cleanup();
411
+ return err;
412
+ }
413
+ }
414
+ async function notifyInterceptorsOnError(envelopes, interceptors, error) {
415
+ await broadcastToInterceptors(
416
+ envelopes,
417
+ interceptors,
418
+ (i, env) => i.onError?.(env, error)
419
+ );
420
+ }
258
421
  async function executeWithRetry(fn, ctx, deps) {
259
- const { envelope, rawMessages, interceptors, dlq, retry, isBatch, retryTopics } = ctx;
422
+ const {
423
+ envelope,
424
+ rawMessages,
425
+ interceptors,
426
+ dlq,
427
+ retry,
428
+ isBatch,
429
+ retryTopics
430
+ } = ctx;
260
431
  const maxAttempts = retryTopics ? 1 : retry ? retry.maxRetries + 1 : 1;
261
432
  const backoffMs = retry?.backoffMs ?? 1e3;
262
433
  const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
263
434
  const envelopes = Array.isArray(envelope) ? envelope : [envelope];
264
435
  const topic2 = envelopes[0]?.topic ?? "unknown";
265
436
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
266
- const cleanups = [];
267
- try {
268
- for (const env of envelopes) {
269
- for (const inst of deps.instrumentation) {
270
- const cleanup = inst.beforeConsume?.(env);
271
- if (typeof cleanup === "function") cleanups.push(cleanup);
272
- }
273
- }
274
- for (const env of envelopes) {
275
- for (const interceptor of interceptors) {
276
- await interceptor.before?.(env);
277
- }
278
- }
279
- await fn();
280
- for (const env of envelopes) {
281
- for (const interceptor of interceptors) {
282
- await interceptor.after?.(env);
283
- }
284
- }
285
- for (const cleanup of cleanups) cleanup();
286
- return;
287
- } catch (error) {
288
- const err = toError(error);
289
- const isLastAttempt = attempt === maxAttempts;
290
- for (const env of envelopes) {
291
- for (const inst of deps.instrumentation) {
292
- inst.onConsumeError?.(env, err);
293
- }
294
- }
295
- for (const cleanup of cleanups) cleanup();
296
- if (isLastAttempt && maxAttempts > 1) {
297
- const exhaustedError = new KafkaRetryExhaustedError(
298
- topic2,
299
- envelopes.map((e) => e.payload),
300
- maxAttempts,
301
- { cause: err }
302
- );
303
- for (const env of envelopes) {
304
- for (const interceptor of interceptors) {
305
- await interceptor.onError?.(env, exhaustedError);
306
- }
307
- }
308
- } else {
309
- for (const env of envelopes) {
310
- for (const interceptor of interceptors) {
311
- await interceptor.onError?.(env, err);
312
- }
313
- }
314
- }
315
- deps.logger.error(
316
- `Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
317
- err.stack
437
+ const error = await runHandlerWithPipeline(
438
+ fn,
439
+ envelopes,
440
+ interceptors,
441
+ deps.instrumentation
442
+ );
443
+ if (!error) return;
444
+ const isLastAttempt = attempt === maxAttempts;
445
+ const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
446
+ topic2,
447
+ envelopes.map((e) => e.payload),
448
+ maxAttempts,
449
+ { cause: error }
450
+ ) : error;
451
+ await notifyInterceptorsOnError(envelopes, interceptors, reportedError);
452
+ deps.logger.error(
453
+ `Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
454
+ error.stack
455
+ );
456
+ if (retryTopics && retry) {
457
+ const cap = Math.min(backoffMs, maxBackoffMs);
458
+ const delay = Math.floor(Math.random() * cap);
459
+ await sendToRetryTopic(
460
+ topic2,
461
+ rawMessages,
462
+ 1,
463
+ retry.maxRetries,
464
+ delay,
465
+ envelopes[0]?.headers ?? {},
466
+ deps
318
467
  );
319
- if (retryTopics && retry) {
320
- const cap = Math.min(backoffMs, maxBackoffMs);
321
- const delay = Math.floor(Math.random() * cap);
322
- await sendToRetryTopic(
323
- topic2,
324
- rawMessages,
325
- 1,
326
- retry.maxRetries,
327
- delay,
328
- envelopes[0]?.headers ?? {},
329
- deps
330
- );
331
- } else if (isLastAttempt) {
332
- if (dlq) {
333
- const dlqMeta = {
334
- error: err,
335
- attempt,
336
- originalHeaders: envelopes[0]?.headers
337
- };
338
- for (const raw of rawMessages) {
339
- await sendToDlq(topic2, raw, deps, dlqMeta);
340
- }
341
- } else {
342
- await deps.onMessageLost?.({
343
- topic: topic2,
344
- error: err,
345
- attempt,
346
- headers: envelopes[0]?.headers ?? {}
347
- });
468
+ } else if (isLastAttempt) {
469
+ if (dlq) {
470
+ const dlqMeta = {
471
+ error,
472
+ attempt,
473
+ originalHeaders: envelopes[0]?.headers
474
+ };
475
+ for (const raw of rawMessages) {
476
+ await sendToDlq(topic2, raw, deps, dlqMeta);
348
477
  }
349
478
  } else {
350
- const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
351
- await sleep(Math.random() * cap);
479
+ await deps.onMessageLost?.({
480
+ topic: topic2,
481
+ error,
482
+ attempt,
483
+ headers: envelopes[0]?.headers ?? {}
484
+ });
352
485
  }
486
+ } else {
487
+ const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
488
+ await sleep(Math.random() * cap);
353
489
  }
354
490
  }
355
491
  }
356
492
 
357
- // src/client/subscribe-retry.ts
493
+ // src/client/kafka.client/message-handler.ts
494
+ async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
495
+ if (!message.value) {
496
+ deps.logger.warn(`Received empty message from topic ${topic2}`);
497
+ return null;
498
+ }
499
+ const raw = message.value.toString();
500
+ const parsed = parseJsonMessage(raw, topic2, deps.logger);
501
+ if (parsed === null) return null;
502
+ const headers = decodeHeaders(message.headers);
503
+ const validated = await validateWithSchema(
504
+ parsed,
505
+ raw,
506
+ topic2,
507
+ schemaMap,
508
+ interceptors,
509
+ dlq,
510
+ { ...deps, originalHeaders: headers }
511
+ );
512
+ if (validated === null) return null;
513
+ return extractEnvelope(validated, headers, topic2, partition, message.offset);
514
+ }
515
+ async function handleEachMessage(payload, opts, deps) {
516
+ const { topic: topic2, partition, message } = payload;
517
+ const {
518
+ schemaMap,
519
+ handleMessage,
520
+ interceptors,
521
+ dlq,
522
+ retry,
523
+ retryTopics,
524
+ timeoutMs,
525
+ wrapWithTimeout
526
+ } = opts;
527
+ const envelope = await parseSingleMessage(
528
+ message,
529
+ topic2,
530
+ partition,
531
+ schemaMap,
532
+ interceptors,
533
+ dlq,
534
+ deps
535
+ );
536
+ if (envelope === null) return;
537
+ await executeWithRetry(
538
+ () => {
539
+ const fn = () => runWithEnvelopeContext(
540
+ {
541
+ correlationId: envelope.correlationId,
542
+ traceparent: envelope.traceparent
543
+ },
544
+ () => handleMessage(envelope)
545
+ );
546
+ return timeoutMs ? wrapWithTimeout(fn, timeoutMs, topic2) : fn();
547
+ },
548
+ {
549
+ envelope,
550
+ rawMessages: [message.value.toString()],
551
+ interceptors,
552
+ dlq,
553
+ retry,
554
+ retryTopics
555
+ },
556
+ deps
557
+ );
558
+ }
559
+ async function handleEachBatch(payload, opts, deps) {
560
+ const { batch, heartbeat, resolveOffset, commitOffsetsIfNecessary } = payload;
561
+ const {
562
+ schemaMap,
563
+ handleBatch,
564
+ interceptors,
565
+ dlq,
566
+ retry,
567
+ timeoutMs,
568
+ wrapWithTimeout
569
+ } = opts;
570
+ const envelopes = [];
571
+ const rawMessages = [];
572
+ for (const message of batch.messages) {
573
+ const envelope = await parseSingleMessage(
574
+ message,
575
+ batch.topic,
576
+ batch.partition,
577
+ schemaMap,
578
+ interceptors,
579
+ dlq,
580
+ deps
581
+ );
582
+ if (envelope === null) continue;
583
+ envelopes.push(envelope);
584
+ rawMessages.push(message.value.toString());
585
+ }
586
+ if (envelopes.length === 0) return;
587
+ const meta = {
588
+ partition: batch.partition,
589
+ highWatermark: batch.highWatermark,
590
+ heartbeat,
591
+ resolveOffset,
592
+ commitOffsetsIfNecessary
593
+ };
594
+ await executeWithRetry(
595
+ () => {
596
+ const fn = () => handleBatch(envelopes, meta);
597
+ return timeoutMs ? wrapWithTimeout(fn, timeoutMs, batch.topic) : fn();
598
+ },
599
+ {
600
+ envelope: envelopes,
601
+ rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
602
+ interceptors,
603
+ dlq,
604
+ retry,
605
+ isBatch: true
606
+ },
607
+ deps
608
+ );
609
+ }
610
+
611
+ // src/client/consumer/subscribe-retry.ts
358
612
  async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
359
613
  const maxAttempts = retryOpts?.retries ?? 5;
360
614
  const backoffMs = retryOpts?.backoffMs ?? 5e3;
@@ -373,7 +627,156 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
373
627
  }
374
628
  }
375
629
 
376
- // src/client/kafka.client.ts
630
+ // src/client/kafka.client/retry-topic.ts
631
+ async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
632
+ const topicSet = new Set(topics);
633
+ const deadline = Date.now() + timeoutMs;
634
+ while (Date.now() < deadline) {
635
+ try {
636
+ const assigned = consumer.assignment();
637
+ if (assigned.some((a) => topicSet.has(a.topic))) return;
638
+ } catch {
639
+ }
640
+ await sleep(200);
641
+ }
642
+ logger.warn(
643
+ `Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
644
+ );
645
+ }
646
+ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap, deps) {
647
+ const {
648
+ logger,
649
+ producer,
650
+ instrumentation,
651
+ onMessageLost,
652
+ ensureTopic,
653
+ getOrCreateConsumer: getOrCreateConsumer2,
654
+ runningConsumers
655
+ } = deps;
656
+ const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
657
+ const retryGroupId = `${originalGroupId}-retry`;
658
+ const backoffMs = retry.backoffMs ?? 1e3;
659
+ const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
660
+ const pipelineDeps = { logger, producer, instrumentation, onMessageLost };
661
+ for (const rt of retryTopicNames) {
662
+ await ensureTopic(rt);
663
+ }
664
+ const consumer = getOrCreateConsumer2(retryGroupId, false, true);
665
+ await consumer.connect();
666
+ await subscribeWithRetry(consumer, retryTopicNames, logger);
667
+ await consumer.run({
668
+ eachMessage: async ({ topic: retryTopic, partition, message }) => {
669
+ if (!message.value) return;
670
+ const raw = message.value.toString();
671
+ const parsed = parseJsonMessage(raw, retryTopic, logger);
672
+ if (parsed === null) return;
673
+ const headers = decodeHeaders(message.headers);
674
+ const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
675
+ const currentAttempt = parseInt(
676
+ headers[RETRY_HEADER_ATTEMPT] ?? "1",
677
+ 10
678
+ );
679
+ const maxRetries = parseInt(
680
+ headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
681
+ 10
682
+ );
683
+ const retryAfter = parseInt(
684
+ headers[RETRY_HEADER_AFTER] ?? "0",
685
+ 10
686
+ );
687
+ const remaining = retryAfter - Date.now();
688
+ if (remaining > 0) {
689
+ consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
690
+ await sleep(remaining);
691
+ consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
692
+ }
693
+ const validated = await validateWithSchema(
694
+ parsed,
695
+ raw,
696
+ originalTopic,
697
+ schemaMap,
698
+ interceptors,
699
+ dlq,
700
+ { ...pipelineDeps, originalHeaders: headers }
701
+ );
702
+ if (validated === null) return;
703
+ const envelope = extractEnvelope(
704
+ validated,
705
+ headers,
706
+ originalTopic,
707
+ partition,
708
+ message.offset
709
+ );
710
+ const error = await runHandlerWithPipeline(
711
+ () => runWithEnvelopeContext(
712
+ {
713
+ correlationId: envelope.correlationId,
714
+ traceparent: envelope.traceparent
715
+ },
716
+ () => handleMessage(envelope)
717
+ ),
718
+ [envelope],
719
+ interceptors,
720
+ instrumentation
721
+ );
722
+ if (error) {
723
+ const nextAttempt = currentAttempt + 1;
724
+ const exhausted = currentAttempt >= maxRetries;
725
+ const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(
726
+ originalTopic,
727
+ [envelope.payload],
728
+ maxRetries,
729
+ { cause: error }
730
+ ) : error;
731
+ await notifyInterceptorsOnError(
732
+ [envelope],
733
+ interceptors,
734
+ reportedError
735
+ );
736
+ logger.error(
737
+ `Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
738
+ error.stack
739
+ );
740
+ if (!exhausted) {
741
+ const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
742
+ const delay = Math.floor(Math.random() * cap);
743
+ await sendToRetryTopic(
744
+ originalTopic,
745
+ [raw],
746
+ nextAttempt,
747
+ maxRetries,
748
+ delay,
749
+ headers,
750
+ pipelineDeps
751
+ );
752
+ } else if (dlq) {
753
+ await sendToDlq(originalTopic, raw, pipelineDeps, {
754
+ error,
755
+ // +1 to account for the main consumer's initial attempt before
756
+ // routing to the retry topic, making this consistent with the
757
+ // in-process retry path where attempt counts all tries.
758
+ attempt: currentAttempt + 1,
759
+ originalHeaders: headers
760
+ });
761
+ } else {
762
+ await onMessageLost?.({
763
+ topic: originalTopic,
764
+ error,
765
+ attempt: currentAttempt,
766
+ headers
767
+ });
768
+ }
769
+ }
770
+ }
771
+ });
772
+ runningConsumers.set(retryGroupId, "eachMessage");
773
+ await waitForPartitionAssignment(consumer, retryTopicNames, logger);
774
+ logger.log(
775
+ `Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
776
+ );
777
+ }
778
+
779
+ // src/client/kafka.client/index.ts
377
780
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
378
781
  var KafkaClient = class {
379
782
  kafka;
@@ -389,8 +792,10 @@ var KafkaClient = class {
389
792
  defaultGroupId;
390
793
  schemaRegistry = /* @__PURE__ */ new Map();
391
794
  runningConsumers = /* @__PURE__ */ new Map();
795
+ consumerCreationOptions = /* @__PURE__ */ new Map();
392
796
  instrumentation;
393
797
  onMessageLost;
798
+ onRebalance;
394
799
  isAdminConnected = false;
395
800
  clientId;
396
801
  constructor(clientId, groupId, brokers, options) {
@@ -406,6 +811,7 @@ var KafkaClient = class {
406
811
  this.numPartitions = options?.numPartitions ?? 1;
407
812
  this.instrumentation = options?.instrumentation ?? [];
408
813
  this.onMessageLost = options?.onMessageLost;
814
+ this.onRebalance = options?.onRebalance;
409
815
  this.kafka = new KafkaClass({
410
816
  kafkaJS: {
411
817
  clientId: this.clientId,
@@ -421,7 +827,7 @@ var KafkaClient = class {
421
827
  this.admin = this.kafka.admin();
422
828
  }
423
829
  async sendMessage(topicOrDesc, message, options = {}) {
424
- const payload = await this.buildSendPayload(topicOrDesc, [
830
+ const payload = await this.preparePayload(topicOrDesc, [
425
831
  {
426
832
  value: message,
427
833
  key: options.key,
@@ -431,19 +837,13 @@ var KafkaClient = class {
431
837
  eventId: options.eventId
432
838
  }
433
839
  ]);
434
- await this.ensureTopic(payload.topic);
435
840
  await this.producer.send(payload);
436
- for (const inst of this.instrumentation) {
437
- inst.afterSend?.(payload.topic);
438
- }
841
+ this.notifyAfterSend(payload.topic, payload.messages.length);
439
842
  }
440
843
  async sendBatch(topicOrDesc, messages) {
441
- const payload = await this.buildSendPayload(topicOrDesc, messages);
442
- await this.ensureTopic(payload.topic);
844
+ const payload = await this.preparePayload(topicOrDesc, messages);
443
845
  await this.producer.send(payload);
444
- for (const inst of this.instrumentation) {
445
- inst.afterSend?.(payload.topic);
446
- }
846
+ this.notifyAfterSend(payload.topic, payload.messages.length);
447
847
  }
448
848
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
449
849
  async transaction(fn) {
@@ -462,7 +862,7 @@ var KafkaClient = class {
462
862
  try {
463
863
  const ctx = {
464
864
  send: async (topicOrDesc, message, options = {}) => {
465
- const payload = await this.buildSendPayload(topicOrDesc, [
865
+ const payload = await this.preparePayload(topicOrDesc, [
466
866
  {
467
867
  value: message,
468
868
  key: options.key,
@@ -472,13 +872,10 @@ var KafkaClient = class {
472
872
  eventId: options.eventId
473
873
  }
474
874
  ]);
475
- await this.ensureTopic(payload.topic);
476
875
  await tx.send(payload);
477
876
  },
478
877
  sendBatch: async (topicOrDesc, messages) => {
479
- const payload = await this.buildSendPayload(topicOrDesc, messages);
480
- await this.ensureTopic(payload.topic);
481
- await tx.send(payload);
878
+ await tx.send(await this.preparePayload(topicOrDesc, messages));
482
879
  }
483
880
  };
484
881
  await fn(ctx);
@@ -512,131 +909,76 @@ var KafkaClient = class {
512
909
  );
513
910
  }
514
911
  const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
515
- const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
912
+ const deps = this.messageDeps;
913
+ const timeoutMs = options.handlerTimeoutMs;
516
914
  await consumer.run({
517
- eachMessage: async ({ topic: topic2, partition, message }) => {
518
- if (!message.value) {
519
- this.logger.warn(`Received empty message from topic ${topic2}`);
520
- return;
521
- }
522
- const raw = message.value.toString();
523
- const parsed = parseJsonMessage(raw, topic2, this.logger);
524
- if (parsed === null) return;
525
- const headers = decodeHeaders(message.headers);
526
- const validated = await validateWithSchema(
527
- parsed,
528
- raw,
529
- topic2,
915
+ eachMessage: (payload) => handleEachMessage(
916
+ payload,
917
+ {
530
918
  schemaMap,
919
+ handleMessage,
531
920
  interceptors,
532
921
  dlq,
533
- { ...deps, originalHeaders: headers }
534
- );
535
- if (validated === null) return;
536
- const envelope = extractEnvelope(
537
- validated,
538
- headers,
539
- topic2,
540
- partition,
541
- message.offset
542
- );
543
- await executeWithRetry(
544
- () => runWithEnvelopeContext(
545
- { correlationId: envelope.correlationId, traceparent: envelope.traceparent },
546
- () => handleMessage(envelope)
547
- ),
548
- { envelope, rawMessages: [raw], interceptors, dlq, retry, retryTopics: options.retryTopics },
549
- deps
550
- );
551
- }
922
+ retry,
923
+ retryTopics: options.retryTopics,
924
+ timeoutMs,
925
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
926
+ },
927
+ deps
928
+ )
552
929
  });
553
930
  this.runningConsumers.set(gid, "eachMessage");
554
931
  if (options.retryTopics && retry) {
555
- await this.startRetryTopicConsumers(
932
+ await startRetryTopicConsumers(
556
933
  topicNames,
557
934
  gid,
558
935
  handleMessage,
559
936
  retry,
560
937
  dlq,
561
938
  interceptors,
562
- schemaMap
939
+ schemaMap,
940
+ this.retryTopicDeps
563
941
  );
564
942
  }
943
+ return { groupId: gid, stop: () => this.stopConsumer(gid) };
565
944
  }
566
945
  async startBatchConsumer(topics, handleBatch, options = {}) {
567
946
  const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
568
- const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
947
+ const deps = this.messageDeps;
948
+ const timeoutMs = options.handlerTimeoutMs;
569
949
  await consumer.run({
570
- eachBatch: async ({
571
- batch,
572
- heartbeat,
573
- resolveOffset,
574
- commitOffsetsIfNecessary
575
- }) => {
576
- const envelopes = [];
577
- const rawMessages = [];
578
- for (const message of batch.messages) {
579
- if (!message.value) {
580
- this.logger.warn(
581
- `Received empty message from topic ${batch.topic}`
582
- );
583
- continue;
584
- }
585
- const raw = message.value.toString();
586
- const parsed = parseJsonMessage(raw, batch.topic, this.logger);
587
- if (parsed === null) continue;
588
- const headers = decodeHeaders(message.headers);
589
- const validated = await validateWithSchema(
590
- parsed,
591
- raw,
592
- batch.topic,
593
- schemaMap,
594
- interceptors,
595
- dlq,
596
- { ...deps, originalHeaders: headers }
597
- );
598
- if (validated === null) continue;
599
- envelopes.push(
600
- extractEnvelope(validated, headers, batch.topic, batch.partition, message.offset)
601
- );
602
- rawMessages.push(raw);
603
- }
604
- if (envelopes.length === 0) return;
605
- const meta = {
606
- partition: batch.partition,
607
- highWatermark: batch.highWatermark,
608
- heartbeat,
609
- resolveOffset,
610
- commitOffsetsIfNecessary
611
- };
612
- await executeWithRetry(
613
- () => handleBatch(envelopes, meta),
614
- {
615
- envelope: envelopes,
616
- rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
617
- interceptors,
618
- dlq,
619
- retry,
620
- isBatch: true
621
- },
622
- deps
623
- );
624
- }
950
+ eachBatch: (payload) => handleEachBatch(
951
+ payload,
952
+ {
953
+ schemaMap,
954
+ handleBatch,
955
+ interceptors,
956
+ dlq,
957
+ retry,
958
+ timeoutMs,
959
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
960
+ },
961
+ deps
962
+ )
625
963
  });
626
964
  this.runningConsumers.set(gid, "eachBatch");
965
+ return { groupId: gid, stop: () => this.stopConsumer(gid) };
627
966
  }
628
967
  // ── Consumer lifecycle ───────────────────────────────────────────
629
968
  async stopConsumer(groupId) {
630
969
  if (groupId !== void 0) {
631
970
  const consumer = this.consumers.get(groupId);
632
971
  if (!consumer) {
633
- this.logger.warn(`stopConsumer: no active consumer for group "${groupId}"`);
972
+ this.logger.warn(
973
+ `stopConsumer: no active consumer for group "${groupId}"`
974
+ );
634
975
  return;
635
976
  }
636
977
  await consumer.disconnect().catch(() => {
637
978
  });
638
979
  this.consumers.delete(groupId);
639
980
  this.runningConsumers.delete(groupId);
981
+ this.consumerCreationOptions.delete(groupId);
640
982
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
641
983
  } else {
642
984
  const tasks = Array.from(this.consumers.values()).map(
@@ -646,9 +988,36 @@ var KafkaClient = class {
646
988
  await Promise.allSettled(tasks);
647
989
  this.consumers.clear();
648
990
  this.runningConsumers.clear();
991
+ this.consumerCreationOptions.clear();
649
992
  this.logger.log("All consumers disconnected");
650
993
  }
651
994
  }
995
+ /**
996
+ * Query consumer group lag per partition.
997
+ * Lag = broker high-watermark − last committed offset.
998
+ * A committed offset of -1 (nothing committed yet) counts as full lag.
999
+ */
1000
+ async getConsumerLag(groupId) {
1001
+ const gid = groupId ?? this.defaultGroupId;
1002
+ if (!this.isAdminConnected) {
1003
+ await this.admin.connect();
1004
+ this.isAdminConnected = true;
1005
+ }
1006
+ const committedByTopic = await this.admin.fetchOffsets({ groupId: gid });
1007
+ const result = [];
1008
+ for (const { topic: topic2, partitions } of committedByTopic) {
1009
+ const brokerOffsets = await this.admin.fetchTopicOffsets(topic2);
1010
+ for (const { partition, offset } of partitions) {
1011
+ const broker = brokerOffsets.find((o) => o.partition === partition);
1012
+ if (!broker) continue;
1013
+ const committed = parseInt(offset, 10);
1014
+ const high = parseInt(broker.high, 10);
1015
+ const lag = committed === -1 ? high : Math.max(0, high - committed);
1016
+ result.push({ topic: topic2, partition, lag });
1017
+ }
1018
+ }
1019
+ return result;
1020
+ }
652
1021
  /** Check broker connectivity and return status, clientId, and available topics. */
653
1022
  async checkStatus() {
654
1023
  if (!this.isAdminConnected) {
@@ -678,183 +1047,42 @@ var KafkaClient = class {
678
1047
  await Promise.allSettled(tasks);
679
1048
  this.consumers.clear();
680
1049
  this.runningConsumers.clear();
1050
+ this.consumerCreationOptions.clear();
681
1051
  this.logger.log("All connections closed");
682
1052
  }
683
- // ── Retry topic chain ────────────────────────────────────────────
684
- /**
685
- * Auto-start companion consumers on `<topic>.retry` for each original topic.
686
- * Called by `startConsumer` when `retryTopics: true`.
687
- *
688
- * Flow per message:
689
- * 1. Sleep until `x-retry-after` (scheduled by the main consumer or previous retry hop)
690
- * 2. Call the original handler
691
- * 3. On failure: if retries remain → re-send to `<originalTopic>.retry` with incremented attempt
692
- * if exhausted → DLQ or onMessageLost
693
- */
694
- async startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap) {
695
- const retryTopicNames = originalTopics.map((t) => `${t}.retry`);
696
- const retryGroupId = `${originalGroupId}-retry`;
697
- const backoffMs = retry.backoffMs ?? 1e3;
698
- const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
699
- const deps = {
700
- logger: this.logger,
701
- producer: this.producer,
702
- instrumentation: this.instrumentation,
703
- onMessageLost: this.onMessageLost
704
- };
705
- for (const rt of retryTopicNames) {
706
- await this.ensureTopic(rt);
707
- }
708
- const consumer = this.getOrCreateConsumer(retryGroupId, false, true);
709
- await consumer.connect();
710
- await subscribeWithRetry(consumer, retryTopicNames, this.logger);
711
- await consumer.run({
712
- eachMessage: async ({ topic: retryTopic, partition, message }) => {
713
- if (!message.value) return;
714
- const raw = message.value.toString();
715
- const parsed = parseJsonMessage(raw, retryTopic, this.logger);
716
- if (parsed === null) return;
717
- const headers = decodeHeaders(message.headers);
718
- const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? retryTopic.replace(/\.retry$/, "");
719
- const currentAttempt = parseInt(
720
- headers[RETRY_HEADER_ATTEMPT] ?? "1",
721
- 10
722
- );
723
- const maxRetries = parseInt(
724
- headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
725
- 10
726
- );
727
- const retryAfter = parseInt(
728
- headers[RETRY_HEADER_AFTER] ?? "0",
729
- 10
730
- );
731
- const remaining = retryAfter - Date.now();
732
- if (remaining > 0) {
733
- consumer.pause([{ topic: retryTopic, partitions: [partition] }]);
734
- await sleep(remaining);
735
- consumer.resume([{ topic: retryTopic, partitions: [partition] }]);
736
- }
737
- const validated = await validateWithSchema(
738
- parsed,
739
- raw,
740
- originalTopic,
741
- schemaMap,
742
- interceptors,
743
- dlq,
744
- { ...deps, originalHeaders: headers }
745
- );
746
- if (validated === null) return;
747
- const envelope = extractEnvelope(
748
- validated,
749
- headers,
750
- originalTopic,
751
- partition,
752
- message.offset
753
- );
754
- try {
755
- const cleanups = [];
756
- for (const inst of this.instrumentation) {
757
- const c = inst.beforeConsume?.(envelope);
758
- if (typeof c === "function") cleanups.push(c);
759
- }
760
- for (const interceptor of interceptors) await interceptor.before?.(envelope);
761
- await runWithEnvelopeContext(
762
- { correlationId: envelope.correlationId, traceparent: envelope.traceparent },
763
- () => handleMessage(envelope)
764
- );
765
- for (const interceptor of interceptors) await interceptor.after?.(envelope);
766
- for (const cleanup of cleanups) cleanup();
767
- } catch (error) {
768
- const err = toError(error);
769
- const nextAttempt = currentAttempt + 1;
770
- const exhausted = currentAttempt >= maxRetries;
771
- for (const inst of this.instrumentation) inst.onConsumeError?.(envelope, err);
772
- const reportedError = exhausted && maxRetries > 1 ? new KafkaRetryExhaustedError(originalTopic, [envelope.payload], maxRetries, { cause: err }) : err;
773
- for (const interceptor of interceptors) {
774
- await interceptor.onError?.(envelope, reportedError);
775
- }
776
- this.logger.error(
777
- `Retry consumer error for ${originalTopic} (attempt ${currentAttempt}/${maxRetries}):`,
778
- err.stack
779
- );
780
- if (!exhausted) {
781
- const cap = Math.min(backoffMs * 2 ** currentAttempt, maxBackoffMs);
782
- const delay = Math.floor(Math.random() * cap);
783
- await sendToRetryTopic(
784
- originalTopic,
785
- [raw],
786
- nextAttempt,
787
- maxRetries,
788
- delay,
789
- headers,
790
- deps
791
- );
792
- } else if (dlq) {
793
- await sendToDlq(originalTopic, raw, deps, {
794
- error: err,
795
- // +1 to account for the main consumer's initial attempt before
796
- // routing to the retry topic, making this consistent with the
797
- // in-process retry path where attempt counts all tries.
798
- attempt: currentAttempt + 1,
799
- originalHeaders: headers
800
- });
801
- } else {
802
- await deps.onMessageLost?.({
803
- topic: originalTopic,
804
- error: err,
805
- attempt: currentAttempt,
806
- headers
807
- });
808
- }
809
- }
810
- }
811
- });
812
- this.runningConsumers.set(retryGroupId, "eachMessage");
813
- await this.waitForPartitionAssignment(consumer, retryTopicNames);
814
- this.logger.log(
815
- `Retry topic consumers started for: ${originalTopics.join(", ")} (group: ${retryGroupId})`
1053
+ // ── Private helpers ──────────────────────────────────────────────
1054
+ async preparePayload(topicOrDesc, messages) {
1055
+ const payload = await buildSendPayload(
1056
+ topicOrDesc,
1057
+ messages,
1058
+ this.producerOpsDeps
816
1059
  );
1060
+ await this.ensureTopic(payload.topic);
1061
+ return payload;
817
1062
  }
818
- // ── Private helpers ──────────────────────────────────────────────
819
- /**
820
- * Poll `consumer.assignment()` until the consumer has received at least one
821
- * partition for the given topics, then return. Logs a warning and returns
822
- * (rather than throwing) on timeout so that a slow broker does not break
823
- * the caller — in the worst case a message sent immediately after would be
824
- * missed, which is the same behaviour as before this guard was added.
825
- */
826
- async waitForPartitionAssignment(consumer, topics, timeoutMs = 1e4) {
827
- const topicSet = new Set(topics);
828
- const deadline = Date.now() + timeoutMs;
829
- while (Date.now() < deadline) {
830
- try {
831
- const assigned = consumer.assignment();
832
- if (assigned.some((a) => topicSet.has(a.topic))) return;
833
- } catch {
1063
+ // afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
1064
+ notifyAfterSend(topic2, count) {
1065
+ for (let i = 0; i < count; i++) {
1066
+ for (const inst of this.instrumentation) {
1067
+ inst.afterSend?.(topic2);
834
1068
  }
835
- await sleep(200);
836
1069
  }
837
- this.logger.warn(
838
- `Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
839
- );
840
1070
  }
841
- getOrCreateConsumer(groupId, fromBeginning, autoCommit) {
842
- if (!this.consumers.has(groupId)) {
843
- this.consumers.set(
844
- groupId,
845
- this.kafka.consumer({
846
- kafkaJS: { groupId, fromBeginning, autoCommit }
847
- })
1071
+ /**
1072
+ * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
1073
+ * The handler itself is not cancelled — the warning is diagnostic only.
1074
+ */
1075
+ wrapWithTimeoutWarning(fn, timeoutMs, topic2) {
1076
+ let timer;
1077
+ const promise = fn().finally(() => {
1078
+ if (timer !== void 0) clearTimeout(timer);
1079
+ });
1080
+ timer = setTimeout(() => {
1081
+ this.logger.warn(
1082
+ `Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
848
1083
  );
849
- }
850
- return this.consumers.get(groupId);
851
- }
852
- resolveTopicName(topicOrDescriptor) {
853
- if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
854
- if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
855
- return topicOrDescriptor.__topic;
856
- }
857
- return String(topicOrDescriptor);
1084
+ }, timeoutMs);
1085
+ return promise;
858
1086
  }
859
1087
  async ensureTopic(topic2) {
860
1088
  if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
@@ -867,52 +1095,6 @@ var KafkaClient = class {
867
1095
  });
868
1096
  this.ensuredTopics.add(topic2);
869
1097
  }
870
- /** Register schema from descriptor into global registry (side-effect). */
871
- registerSchema(topicOrDesc) {
872
- if (topicOrDesc?.__schema) {
873
- const topic2 = this.resolveTopicName(topicOrDesc);
874
- this.schemaRegistry.set(topic2, topicOrDesc.__schema);
875
- }
876
- }
877
- /** Validate message against schema. Pure — no side-effects on registry. */
878
- async validateMessage(topicOrDesc, message) {
879
- if (topicOrDesc?.__schema) {
880
- return await topicOrDesc.__schema.parse(message);
881
- }
882
- if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
883
- const schema = this.schemaRegistry.get(topicOrDesc);
884
- if (schema) return await schema.parse(message);
885
- }
886
- return message;
887
- }
888
- /**
889
- * Build a kafkajs-ready send payload.
890
- * Handles: topic resolution, schema registration, validation, JSON serialization,
891
- * envelope header generation, and instrumentation hooks.
892
- */
893
- async buildSendPayload(topicOrDesc, messages) {
894
- this.registerSchema(topicOrDesc);
895
- const topic2 = this.resolveTopicName(topicOrDesc);
896
- const builtMessages = await Promise.all(
897
- messages.map(async (m) => {
898
- const envelopeHeaders = buildEnvelopeHeaders({
899
- correlationId: m.correlationId,
900
- schemaVersion: m.schemaVersion,
901
- eventId: m.eventId,
902
- headers: m.headers
903
- });
904
- for (const inst of this.instrumentation) {
905
- inst.beforeSend?.(topic2, envelopeHeaders);
906
- }
907
- return {
908
- value: JSON.stringify(await this.validateMessage(topicOrDesc, m.value)),
909
- key: m.key ?? null,
910
- headers: envelopeHeaders
911
- };
912
- })
913
- );
914
- return { topic: topic2, messages: builtMessages };
915
- }
916
1098
  /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
917
1099
  async setupConsumer(topics, mode, options) {
918
1100
  const {
@@ -931,11 +1113,18 @@ var KafkaClient = class {
931
1113
  `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
932
1114
  );
933
1115
  }
934
- const consumer = this.getOrCreateConsumer(gid, fromBeginning, options.autoCommit ?? true);
935
- const schemaMap = this.buildSchemaMap(topics, optionSchemas);
936
- const topicNames = topics.map(
937
- (t) => this.resolveTopicName(t)
1116
+ const consumer = getOrCreateConsumer(
1117
+ gid,
1118
+ fromBeginning,
1119
+ options.autoCommit ?? true,
1120
+ this.consumerOpsDeps
938
1121
  );
1122
+ const schemaMap = buildSchemaMap(
1123
+ topics,
1124
+ this.schemaRegistry,
1125
+ optionSchemas
1126
+ );
1127
+ const topicNames = topics.map((t) => resolveTopicName(t));
939
1128
  for (const t of topicNames) {
940
1129
  await this.ensureTopic(t);
941
1130
  }
@@ -945,32 +1134,56 @@ var KafkaClient = class {
945
1134
  }
946
1135
  }
947
1136
  await consumer.connect();
948
- await subscribeWithRetry(consumer, topicNames, this.logger, options.subscribeRetry);
1137
+ await subscribeWithRetry(
1138
+ consumer,
1139
+ topicNames,
1140
+ this.logger,
1141
+ options.subscribeRetry
1142
+ );
949
1143
  this.logger.log(
950
1144
  `${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
951
1145
  );
952
1146
  return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
953
1147
  }
954
- buildSchemaMap(topics, optionSchemas) {
955
- const schemaMap = /* @__PURE__ */ new Map();
956
- for (const t of topics) {
957
- if (t?.__schema) {
958
- const name = this.resolveTopicName(t);
959
- schemaMap.set(name, t.__schema);
960
- this.schemaRegistry.set(name, t.__schema);
961
- }
962
- }
963
- if (optionSchemas) {
964
- for (const [k, v] of optionSchemas) {
965
- schemaMap.set(k, v);
966
- this.schemaRegistry.set(k, v);
967
- }
968
- }
969
- return schemaMap;
1148
+ // ── Deps object getters ──────────────────────────────────────────
1149
+ get producerOpsDeps() {
1150
+ return {
1151
+ schemaRegistry: this.schemaRegistry,
1152
+ strictSchemasEnabled: this.strictSchemasEnabled,
1153
+ instrumentation: this.instrumentation
1154
+ };
1155
+ }
1156
+ get consumerOpsDeps() {
1157
+ return {
1158
+ consumers: this.consumers,
1159
+ consumerCreationOptions: this.consumerCreationOptions,
1160
+ kafka: this.kafka,
1161
+ onRebalance: this.onRebalance,
1162
+ logger: this.logger
1163
+ };
1164
+ }
1165
+ get messageDeps() {
1166
+ return {
1167
+ logger: this.logger,
1168
+ producer: this.producer,
1169
+ instrumentation: this.instrumentation,
1170
+ onMessageLost: this.onMessageLost
1171
+ };
1172
+ }
1173
+ get retryTopicDeps() {
1174
+ return {
1175
+ logger: this.logger,
1176
+ producer: this.producer,
1177
+ instrumentation: this.instrumentation,
1178
+ onMessageLost: this.onMessageLost,
1179
+ ensureTopic: (t) => this.ensureTopic(t),
1180
+ getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
1181
+ runningConsumers: this.runningConsumers
1182
+ };
970
1183
  }
971
1184
  };
972
1185
 
973
- // src/client/topic.ts
1186
+ // src/client/message/topic.ts
974
1187
  function topic(name) {
975
1188
  const fn = () => ({
976
1189
  __topic: name,
@@ -1100,35 +1313,17 @@ var KafkaModule = class {
1100
1313
  const token = getKafkaClientToken(options.name);
1101
1314
  const kafkaClientProvider = {
1102
1315
  provide: token,
1103
- useFactory: async () => {
1104
- const client = new KafkaClient(
1105
- options.clientId,
1106
- options.groupId,
1107
- options.brokers,
1108
- {
1109
- autoCreateTopics: options.autoCreateTopics,
1110
- strictSchemas: options.strictSchemas,
1111
- numPartitions: options.numPartitions,
1112
- instrumentation: options.instrumentation,
1113
- logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
1114
- }
1115
- );
1116
- await client.connectProducer();
1117
- return client;
1118
- }
1119
- };
1120
- const destroyProvider = {
1121
- provide: `${token}_DESTROY`,
1122
- useFactory: (client) => ({
1123
- onModuleDestroy: () => client.disconnect()
1124
- }),
1125
- inject: [token]
1316
+ useFactory: () => KafkaModule.buildClient(options)
1126
1317
  };
1127
1318
  return {
1128
1319
  global: options.isGlobal ?? false,
1129
1320
  module: KafkaModule,
1130
1321
  imports: [import_core2.DiscoveryModule],
1131
- providers: [kafkaClientProvider, destroyProvider, KafkaExplorer],
1322
+ providers: [
1323
+ kafkaClientProvider,
1324
+ KafkaModule.buildDestroyProvider(token),
1325
+ KafkaExplorer
1326
+ ],
1132
1327
  exports: [kafkaClientProvider]
1133
1328
  };
1134
1329
  }
@@ -1137,40 +1332,48 @@ var KafkaModule = class {
1137
1332
  const token = getKafkaClientToken(asyncOptions.name);
1138
1333
  const kafkaClientProvider = {
1139
1334
  provide: token,
1140
- useFactory: async (...args) => {
1141
- const options = await asyncOptions.useFactory(...args);
1142
- const client = new KafkaClient(
1143
- options.clientId,
1144
- options.groupId,
1145
- options.brokers,
1146
- {
1147
- autoCreateTopics: options.autoCreateTopics,
1148
- strictSchemas: options.strictSchemas,
1149
- numPartitions: options.numPartitions,
1150
- instrumentation: options.instrumentation,
1151
- logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
1152
- }
1153
- );
1154
- await client.connectProducer();
1155
- return client;
1156
- },
1335
+ useFactory: async (...args) => KafkaModule.buildClient(await asyncOptions.useFactory(...args)),
1157
1336
  inject: asyncOptions.inject || []
1158
1337
  };
1159
- const destroyProvider = {
1160
- provide: `${token}_DESTROY`,
1161
- useFactory: (client) => ({
1162
- onModuleDestroy: () => client.disconnect()
1163
- }),
1164
- inject: [token]
1165
- };
1166
1338
  return {
1167
1339
  global: asyncOptions.isGlobal ?? false,
1168
1340
  module: KafkaModule,
1169
1341
  imports: [...asyncOptions.imports || [], import_core2.DiscoveryModule],
1170
- providers: [kafkaClientProvider, destroyProvider, KafkaExplorer],
1342
+ providers: [
1343
+ kafkaClientProvider,
1344
+ KafkaModule.buildDestroyProvider(token),
1345
+ KafkaExplorer
1346
+ ],
1171
1347
  exports: [kafkaClientProvider]
1172
1348
  };
1173
1349
  }
1350
+ static async buildClient(options) {
1351
+ const client = new KafkaClient(
1352
+ options.clientId,
1353
+ options.groupId,
1354
+ options.brokers,
1355
+ {
1356
+ autoCreateTopics: options.autoCreateTopics,
1357
+ strictSchemas: options.strictSchemas,
1358
+ numPartitions: options.numPartitions,
1359
+ instrumentation: options.instrumentation,
1360
+ onMessageLost: options.onMessageLost,
1361
+ onRebalance: options.onRebalance,
1362
+ logger: new import_common3.Logger(`KafkaClient:${options.clientId}`)
1363
+ }
1364
+ );
1365
+ await client.connectProducer();
1366
+ return client;
1367
+ }
1368
+ static buildDestroyProvider(token) {
1369
+ return {
1370
+ provide: `${token}_DESTROY`,
1371
+ useFactory: (client) => ({
1372
+ onModuleDestroy: () => client.disconnect()
1373
+ }),
1374
+ inject: [token]
1375
+ };
1376
+ }
1174
1377
  };
1175
1378
  KafkaModule = __decorateClass([
1176
1379
  (0, import_common3.Module)({})