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