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