@drarzter/kafka-client 0.5.5 → 0.5.7

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";
@@ -133,7 +133,123 @@ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
133
133
  }
134
134
  };
135
135
 
136
- // src/client/consumer-pipeline.ts
136
+ // src/client/kafka.client/producer-ops.ts
137
+ function resolveTopicName(topicOrDescriptor) {
138
+ if (typeof topicOrDescriptor === "string") return topicOrDescriptor;
139
+ if (topicOrDescriptor && typeof topicOrDescriptor === "object" && "__topic" in topicOrDescriptor) {
140
+ return topicOrDescriptor.__topic;
141
+ }
142
+ return String(topicOrDescriptor);
143
+ }
144
+ function registerSchema(topicOrDesc, schemaRegistry) {
145
+ if (topicOrDesc?.__schema) {
146
+ const topic2 = resolveTopicName(topicOrDesc);
147
+ schemaRegistry.set(topic2, topicOrDesc.__schema);
148
+ }
149
+ }
150
+ async function validateMessage(topicOrDesc, message, deps) {
151
+ const topicName = resolveTopicName(topicOrDesc);
152
+ if (topicOrDesc?.__schema) {
153
+ try {
154
+ return await topicOrDesc.__schema.parse(message);
155
+ } catch (error) {
156
+ throw new KafkaValidationError(topicName, message, {
157
+ cause: error instanceof Error ? error : new Error(String(error))
158
+ });
159
+ }
160
+ }
161
+ if (deps.strictSchemasEnabled && typeof topicOrDesc === "string") {
162
+ const schema = deps.schemaRegistry.get(topicOrDesc);
163
+ if (schema) {
164
+ try {
165
+ return await schema.parse(message);
166
+ } catch (error) {
167
+ throw new KafkaValidationError(topicName, message, {
168
+ cause: error instanceof Error ? error : new Error(String(error))
169
+ });
170
+ }
171
+ }
172
+ }
173
+ return message;
174
+ }
175
+ async function buildSendPayload(topicOrDesc, messages, deps) {
176
+ const topic2 = resolveTopicName(topicOrDesc);
177
+ const builtMessages = await Promise.all(
178
+ messages.map(async (m) => {
179
+ const envelopeHeaders = buildEnvelopeHeaders({
180
+ correlationId: m.correlationId,
181
+ schemaVersion: m.schemaVersion,
182
+ eventId: m.eventId,
183
+ headers: m.headers
184
+ });
185
+ for (const inst of deps.instrumentation) {
186
+ inst.beforeSend?.(topic2, envelopeHeaders);
187
+ }
188
+ return {
189
+ value: JSON.stringify(
190
+ await validateMessage(topicOrDesc, m.value, deps)
191
+ ),
192
+ key: m.key ?? null,
193
+ headers: envelopeHeaders
194
+ };
195
+ })
196
+ );
197
+ return { topic: topic2, messages: builtMessages };
198
+ }
199
+
200
+ // src/client/kafka.client/consumer-ops.ts
201
+ function getOrCreateConsumer(groupId, fromBeginning, autoCommit, deps) {
202
+ const { consumers, consumerCreationOptions, kafka, onRebalance, logger } = deps;
203
+ if (consumers.has(groupId)) {
204
+ const prev = consumerCreationOptions.get(groupId);
205
+ if (prev.fromBeginning !== fromBeginning || prev.autoCommit !== autoCommit) {
206
+ logger.warn(
207
+ `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.`
208
+ );
209
+ }
210
+ return consumers.get(groupId);
211
+ }
212
+ consumerCreationOptions.set(groupId, { fromBeginning, autoCommit });
213
+ const config = {
214
+ kafkaJS: { groupId, fromBeginning, autoCommit }
215
+ };
216
+ if (onRebalance) {
217
+ const cb = onRebalance;
218
+ config["rebalance_cb"] = (err, assignment) => {
219
+ const type = err.code === -175 ? "assign" : "revoke";
220
+ try {
221
+ cb(
222
+ type,
223
+ assignment.map((p) => ({ topic: p.topic, partition: p.partition }))
224
+ );
225
+ } catch (e) {
226
+ logger.warn(`onRebalance callback threw: ${e.message}`);
227
+ }
228
+ };
229
+ }
230
+ const consumer = kafka.consumer(config);
231
+ consumers.set(groupId, consumer);
232
+ return consumer;
233
+ }
234
+ function buildSchemaMap(topics, schemaRegistry, optionSchemas) {
235
+ const schemaMap = /* @__PURE__ */ new Map();
236
+ for (const t of topics) {
237
+ if (t?.__schema) {
238
+ const name = resolveTopicName(t);
239
+ schemaMap.set(name, t.__schema);
240
+ schemaRegistry.set(name, t.__schema);
241
+ }
242
+ }
243
+ if (optionSchemas) {
244
+ for (const [k, v] of optionSchemas) {
245
+ schemaMap.set(k, v);
246
+ schemaRegistry.set(k, v);
247
+ }
248
+ }
249
+ return schemaMap;
250
+ }
251
+
252
+ // src/client/consumer/pipeline.ts
137
253
  function toError(error) {
138
254
  return error instanceof Error ? error : new Error(String(error));
139
255
  }
@@ -220,7 +336,7 @@ var RETRY_HEADER_AFTER = "x-retry-after";
220
336
  var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
221
337
  var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
222
338
  async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
223
- const retryTopic = `${originalTopic}.retry`;
339
+ const retryTopic = `${originalTopic}.retry.${attempt}`;
224
340
  const {
225
341
  [RETRY_HEADER_ATTEMPT]: _a,
226
342
  [RETRY_HEADER_AFTER]: _b,
@@ -252,6 +368,53 @@ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries,
252
368
  );
253
369
  }
254
370
  }
371
+ async function broadcastToInterceptors(envelopes, interceptors, cb) {
372
+ for (const env of envelopes) {
373
+ for (const interceptor of interceptors) {
374
+ await cb(interceptor, env);
375
+ }
376
+ }
377
+ }
378
+ async function runHandlerWithPipeline(fn, envelopes, interceptors, instrumentation) {
379
+ const cleanups = [];
380
+ try {
381
+ for (const env of envelopes) {
382
+ for (const inst of instrumentation) {
383
+ const cleanup = inst.beforeConsume?.(env);
384
+ if (typeof cleanup === "function") cleanups.push(cleanup);
385
+ }
386
+ }
387
+ for (const env of envelopes) {
388
+ for (const interceptor of interceptors) {
389
+ await interceptor.before?.(env);
390
+ }
391
+ }
392
+ await fn();
393
+ for (const env of envelopes) {
394
+ for (const interceptor of interceptors) {
395
+ await interceptor.after?.(env);
396
+ }
397
+ }
398
+ for (const cleanup of cleanups) cleanup();
399
+ return null;
400
+ } catch (error) {
401
+ const err = toError(error);
402
+ for (const env of envelopes) {
403
+ for (const inst of instrumentation) {
404
+ inst.onConsumeError?.(env, err);
405
+ }
406
+ }
407
+ for (const cleanup of cleanups) cleanup();
408
+ return err;
409
+ }
410
+ }
411
+ async function notifyInterceptorsOnError(envelopes, interceptors, error) {
412
+ await broadcastToInterceptors(
413
+ envelopes,
414
+ interceptors,
415
+ (i, env) => i.onError?.(env, error)
416
+ );
417
+ }
255
418
  async function executeWithRetry(fn, ctx, deps) {
256
419
  const {
257
420
  envelope,
@@ -268,98 +431,181 @@ async function executeWithRetry(fn, ctx, deps) {
268
431
  const envelopes = Array.isArray(envelope) ? envelope : [envelope];
269
432
  const topic2 = envelopes[0]?.topic ?? "unknown";
270
433
  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
434
+ const error = await runHandlerWithPipeline(
435
+ fn,
436
+ envelopes,
437
+ interceptors,
438
+ deps.instrumentation
439
+ );
440
+ if (!error) return;
441
+ const isLastAttempt = attempt === maxAttempts;
442
+ const reportedError = isLastAttempt && maxAttempts > 1 ? new KafkaRetryExhaustedError(
443
+ topic2,
444
+ envelopes.map((e) => e.payload),
445
+ maxAttempts,
446
+ { cause: error }
447
+ ) : error;
448
+ await notifyInterceptorsOnError(envelopes, interceptors, reportedError);
449
+ deps.logger.error(
450
+ `Error processing ${isBatch ? "batch" : "message"} from topic ${topic2} (attempt ${attempt}/${maxAttempts}):`,
451
+ error.stack
452
+ );
453
+ if (retryTopics && retry) {
454
+ const cap = Math.min(backoffMs, maxBackoffMs);
455
+ const delay = Math.floor(Math.random() * cap);
456
+ await sendToRetryTopic(
457
+ topic2,
458
+ rawMessages,
459
+ 1,
460
+ retry.maxRetries,
461
+ delay,
462
+ envelopes[0]?.headers ?? {},
463
+ deps
323
464
  );
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
- });
465
+ } else if (isLastAttempt) {
466
+ if (dlq) {
467
+ const dlqMeta = {
468
+ error,
469
+ attempt,
470
+ originalHeaders: envelopes[0]?.headers
471
+ };
472
+ for (const raw of rawMessages) {
473
+ await sendToDlq(topic2, raw, deps, dlqMeta);
353
474
  }
354
475
  } else {
355
- const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
356
- await sleep(Math.random() * cap);
476
+ await deps.onMessageLost?.({
477
+ topic: topic2,
478
+ error,
479
+ attempt,
480
+ headers: envelopes[0]?.headers ?? {}
481
+ });
357
482
  }
483
+ } else {
484
+ const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
485
+ await sleep(Math.random() * cap);
358
486
  }
359
487
  }
360
488
  }
361
489
 
362
- // src/client/subscribe-retry.ts
490
+ // src/client/kafka.client/message-handler.ts
491
+ async function parseSingleMessage(message, topic2, partition, schemaMap, interceptors, dlq, deps) {
492
+ if (!message.value) {
493
+ deps.logger.warn(`Received empty message from topic ${topic2}`);
494
+ return null;
495
+ }
496
+ const raw = message.value.toString();
497
+ const parsed = parseJsonMessage(raw, topic2, deps.logger);
498
+ if (parsed === null) return null;
499
+ const headers = decodeHeaders(message.headers);
500
+ const validated = await validateWithSchema(
501
+ parsed,
502
+ raw,
503
+ topic2,
504
+ schemaMap,
505
+ interceptors,
506
+ dlq,
507
+ { ...deps, originalHeaders: headers }
508
+ );
509
+ if (validated === null) return null;
510
+ return extractEnvelope(validated, headers, topic2, partition, message.offset);
511
+ }
512
+ async function handleEachMessage(payload, opts, deps) {
513
+ const { topic: topic2, partition, message } = payload;
514
+ const {
515
+ schemaMap,
516
+ handleMessage,
517
+ interceptors,
518
+ dlq,
519
+ retry,
520
+ retryTopics,
521
+ timeoutMs,
522
+ wrapWithTimeout
523
+ } = opts;
524
+ const envelope = await parseSingleMessage(
525
+ message,
526
+ topic2,
527
+ partition,
528
+ schemaMap,
529
+ interceptors,
530
+ dlq,
531
+ deps
532
+ );
533
+ if (envelope === null) return;
534
+ await executeWithRetry(
535
+ () => {
536
+ const fn = () => runWithEnvelopeContext(
537
+ {
538
+ correlationId: envelope.correlationId,
539
+ traceparent: envelope.traceparent
540
+ },
541
+ () => handleMessage(envelope)
542
+ );
543
+ return timeoutMs ? wrapWithTimeout(fn, timeoutMs, topic2) : fn();
544
+ },
545
+ {
546
+ envelope,
547
+ rawMessages: [message.value.toString()],
548
+ interceptors,
549
+ dlq,
550
+ retry,
551
+ retryTopics
552
+ },
553
+ deps
554
+ );
555
+ }
556
+ async function handleEachBatch(payload, opts, deps) {
557
+ const { batch, heartbeat, resolveOffset, commitOffsetsIfNecessary } = payload;
558
+ const {
559
+ schemaMap,
560
+ handleBatch,
561
+ interceptors,
562
+ dlq,
563
+ retry,
564
+ timeoutMs,
565
+ wrapWithTimeout
566
+ } = opts;
567
+ const envelopes = [];
568
+ const rawMessages = [];
569
+ for (const message of batch.messages) {
570
+ const envelope = await parseSingleMessage(
571
+ message,
572
+ batch.topic,
573
+ batch.partition,
574
+ schemaMap,
575
+ interceptors,
576
+ dlq,
577
+ deps
578
+ );
579
+ if (envelope === null) continue;
580
+ envelopes.push(envelope);
581
+ rawMessages.push(message.value.toString());
582
+ }
583
+ if (envelopes.length === 0) return;
584
+ const meta = {
585
+ partition: batch.partition,
586
+ highWatermark: batch.highWatermark,
587
+ heartbeat,
588
+ resolveOffset,
589
+ commitOffsetsIfNecessary
590
+ };
591
+ await executeWithRetry(
592
+ () => {
593
+ const fn = () => handleBatch(envelopes, meta);
594
+ return timeoutMs ? wrapWithTimeout(fn, timeoutMs, batch.topic) : fn();
595
+ },
596
+ {
597
+ envelope: envelopes,
598
+ rawMessages: batch.messages.filter((m) => m.value).map((m) => m.value.toString()),
599
+ interceptors,
600
+ dlq,
601
+ retry,
602
+ isBatch: true
603
+ },
604
+ deps
605
+ );
606
+ }
607
+
608
+ // src/client/consumer/subscribe-retry.ts
363
609
  async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
364
610
  const maxAttempts = retryOpts?.retries ?? 5;
365
611
  const backoffMs = retryOpts?.backoffMs ?? 5e3;
@@ -378,7 +624,183 @@ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
378
624
  }
379
625
  }
380
626
 
381
- // src/client/kafka.client.ts
627
+ // src/client/kafka.client/retry-topic.ts
628
+ async function waitForPartitionAssignment(consumer, topics, logger, timeoutMs = 1e4) {
629
+ const topicSet = new Set(topics);
630
+ const deadline = Date.now() + timeoutMs;
631
+ while (Date.now() < deadline) {
632
+ try {
633
+ const assigned = consumer.assignment();
634
+ if (assigned.some((a) => topicSet.has(a.topic))) return;
635
+ } catch {
636
+ }
637
+ await sleep(200);
638
+ }
639
+ logger.warn(
640
+ `Retry consumer did not receive partition assignments for [${topics.join(", ")}] within ${timeoutMs}ms`
641
+ );
642
+ }
643
+ async function startLevelConsumer(level, levelTopics, levelGroupId, originalTopics, handleMessage, retry, dlq, interceptors, schemaMap, deps, assignmentTimeoutMs) {
644
+ const {
645
+ logger,
646
+ producer,
647
+ instrumentation,
648
+ onMessageLost,
649
+ ensureTopic,
650
+ getOrCreateConsumer: getOrCreateConsumer2,
651
+ runningConsumers
652
+ } = deps;
653
+ const backoffMs = retry.backoffMs ?? 1e3;
654
+ const maxBackoffMs = retry.maxBackoffMs ?? 3e4;
655
+ const pipelineDeps = { logger, producer, instrumentation, onMessageLost };
656
+ for (const lt of levelTopics) {
657
+ await ensureTopic(lt);
658
+ }
659
+ const consumer = getOrCreateConsumer2(levelGroupId, false, false);
660
+ await consumer.connect();
661
+ await subscribeWithRetry(consumer, levelTopics, logger);
662
+ await consumer.run({
663
+ eachMessage: async ({ topic: levelTopic, partition, message }) => {
664
+ const nextOffset = {
665
+ topic: levelTopic,
666
+ partition,
667
+ offset: (parseInt(message.offset, 10) + 1).toString()
668
+ };
669
+ if (!message.value) {
670
+ await consumer.commitOffsets([nextOffset]);
671
+ return;
672
+ }
673
+ const headers = decodeHeaders(message.headers);
674
+ const retryAfter = parseInt(
675
+ headers[RETRY_HEADER_AFTER] ?? "0",
676
+ 10
677
+ );
678
+ const remaining = retryAfter - Date.now();
679
+ if (remaining > 0) {
680
+ consumer.pause([{ topic: levelTopic, partitions: [partition] }]);
681
+ await sleep(remaining);
682
+ consumer.resume([{ topic: levelTopic, partitions: [partition] }]);
683
+ }
684
+ const raw = message.value.toString();
685
+ const parsed = parseJsonMessage(raw, levelTopic, logger);
686
+ if (parsed === null) {
687
+ await consumer.commitOffsets([nextOffset]);
688
+ return;
689
+ }
690
+ const currentMaxRetries = parseInt(
691
+ headers[RETRY_HEADER_MAX_RETRIES] ?? String(retry.maxRetries),
692
+ 10
693
+ );
694
+ const originalTopic = headers[RETRY_HEADER_ORIGINAL_TOPIC] ?? levelTopic.replace(/\.retry\.\d+$/, "");
695
+ const validated = await validateWithSchema(
696
+ parsed,
697
+ raw,
698
+ originalTopic,
699
+ schemaMap,
700
+ interceptors,
701
+ dlq,
702
+ { ...pipelineDeps, originalHeaders: headers }
703
+ );
704
+ if (validated === null) {
705
+ await consumer.commitOffsets([nextOffset]);
706
+ return;
707
+ }
708
+ const envelope = extractEnvelope(
709
+ validated,
710
+ headers,
711
+ originalTopic,
712
+ partition,
713
+ message.offset
714
+ );
715
+ const error = await runHandlerWithPipeline(
716
+ () => runWithEnvelopeContext(
717
+ {
718
+ correlationId: envelope.correlationId,
719
+ traceparent: envelope.traceparent
720
+ },
721
+ () => handleMessage(envelope)
722
+ ),
723
+ [envelope],
724
+ interceptors,
725
+ instrumentation
726
+ );
727
+ if (!error) {
728
+ await consumer.commitOffsets([nextOffset]);
729
+ return;
730
+ }
731
+ const exhausted = level >= currentMaxRetries;
732
+ const reportedError = exhausted && currentMaxRetries > 1 ? new KafkaRetryExhaustedError(
733
+ originalTopic,
734
+ [envelope.payload],
735
+ currentMaxRetries,
736
+ { cause: error }
737
+ ) : error;
738
+ await notifyInterceptorsOnError([envelope], interceptors, reportedError);
739
+ logger.error(
740
+ `Retry consumer error for ${originalTopic} (level ${level}/${currentMaxRetries}):`,
741
+ error.stack
742
+ );
743
+ if (!exhausted) {
744
+ const nextLevel = level + 1;
745
+ const cap = Math.min(backoffMs * 2 ** level, maxBackoffMs);
746
+ const delay = Math.floor(Math.random() * cap);
747
+ await sendToRetryTopic(
748
+ originalTopic,
749
+ [raw],
750
+ nextLevel,
751
+ currentMaxRetries,
752
+ delay,
753
+ headers,
754
+ pipelineDeps
755
+ );
756
+ } else if (dlq) {
757
+ await sendToDlq(originalTopic, raw, pipelineDeps, {
758
+ error,
759
+ // +1 to account for the main consumer's initial attempt before routing.
760
+ attempt: level + 1,
761
+ originalHeaders: headers
762
+ });
763
+ } else {
764
+ await onMessageLost?.({
765
+ topic: originalTopic,
766
+ error,
767
+ attempt: level,
768
+ headers
769
+ });
770
+ }
771
+ await consumer.commitOffsets([nextOffset]);
772
+ }
773
+ });
774
+ runningConsumers.set(levelGroupId, "eachMessage");
775
+ await waitForPartitionAssignment(consumer, levelTopics, logger, assignmentTimeoutMs);
776
+ logger.log(
777
+ `Retry level ${level}/${retry.maxRetries} consumer started for: ${originalTopics.join(", ")} (group: ${levelGroupId})`
778
+ );
779
+ }
780
+ async function startRetryTopicConsumers(originalTopics, originalGroupId, handleMessage, retry, dlq, interceptors, schemaMap, deps, assignmentTimeoutMs) {
781
+ const levelGroupIds = [];
782
+ for (let level = 1; level <= retry.maxRetries; level++) {
783
+ const levelTopics = originalTopics.map((t) => `${t}.retry.${level}`);
784
+ const levelGroupId = `${originalGroupId}-retry.${level}`;
785
+ await startLevelConsumer(
786
+ level,
787
+ levelTopics,
788
+ levelGroupId,
789
+ originalTopics,
790
+ handleMessage,
791
+ retry,
792
+ dlq,
793
+ interceptors,
794
+ schemaMap,
795
+ deps,
796
+ assignmentTimeoutMs
797
+ );
798
+ levelGroupIds.push(levelGroupId);
799
+ }
800
+ return levelGroupIds;
801
+ }
802
+
803
+ // src/client/kafka.client/index.ts
382
804
  var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = import_kafka_javascript.KafkaJS;
383
805
  var KafkaClient = class {
384
806
  kafka;
@@ -394,6 +816,9 @@ var KafkaClient = class {
394
816
  defaultGroupId;
395
817
  schemaRegistry = /* @__PURE__ */ new Map();
396
818
  runningConsumers = /* @__PURE__ */ new Map();
819
+ consumerCreationOptions = /* @__PURE__ */ new Map();
820
+ /** Maps each main consumer groupId to its companion retry level groupIds. */
821
+ companionGroupIds = /* @__PURE__ */ new Map();
397
822
  instrumentation;
398
823
  onMessageLost;
399
824
  onRebalance;
@@ -428,7 +853,7 @@ var KafkaClient = class {
428
853
  this.admin = this.kafka.admin();
429
854
  }
430
855
  async sendMessage(topicOrDesc, message, options = {}) {
431
- const payload = await this.buildSendPayload(topicOrDesc, [
856
+ const payload = await this.preparePayload(topicOrDesc, [
432
857
  {
433
858
  value: message,
434
859
  key: options.key,
@@ -438,19 +863,13 @@ var KafkaClient = class {
438
863
  eventId: options.eventId
439
864
  }
440
865
  ]);
441
- await this.ensureTopic(payload.topic);
442
866
  await this.producer.send(payload);
443
- for (const inst of this.instrumentation) {
444
- inst.afterSend?.(payload.topic);
445
- }
867
+ this.notifyAfterSend(payload.topic, payload.messages.length);
446
868
  }
447
869
  async sendBatch(topicOrDesc, messages) {
448
- const payload = await this.buildSendPayload(topicOrDesc, messages);
449
- await this.ensureTopic(payload.topic);
870
+ const payload = await this.preparePayload(topicOrDesc, messages);
450
871
  await this.producer.send(payload);
451
- for (const inst of this.instrumentation) {
452
- inst.afterSend?.(payload.topic);
453
- }
872
+ this.notifyAfterSend(payload.topic, payload.messages.length);
454
873
  }
455
874
  /** Execute multiple sends atomically. Commits on success, aborts on error. */
456
875
  async transaction(fn) {
@@ -469,7 +888,7 @@ var KafkaClient = class {
469
888
  try {
470
889
  const ctx = {
471
890
  send: async (topicOrDesc, message, options = {}) => {
472
- const payload = await this.buildSendPayload(topicOrDesc, [
891
+ const payload = await this.preparePayload(topicOrDesc, [
473
892
  {
474
893
  value: message,
475
894
  key: options.key,
@@ -479,13 +898,10 @@ var KafkaClient = class {
479
898
  eventId: options.eventId
480
899
  }
481
900
  ]);
482
- await this.ensureTopic(payload.topic);
483
901
  await tx.send(payload);
484
902
  },
485
903
  sendBatch: async (topicOrDesc, messages) => {
486
- const payload = await this.buildSendPayload(topicOrDesc, messages);
487
- await this.ensureTopic(payload.topic);
488
- await tx.send(payload);
904
+ await tx.send(await this.preparePayload(topicOrDesc, messages));
489
905
  }
490
906
  };
491
907
  await fn(ctx);
@@ -519,151 +935,59 @@ var KafkaClient = class {
519
935
  );
520
936
  }
521
937
  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
- };
938
+ const deps = this.messageDeps;
528
939
  const timeoutMs = options.handlerTimeoutMs;
529
940
  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,
941
+ eachMessage: (payload) => handleEachMessage(
942
+ payload,
943
+ {
543
944
  schemaMap,
945
+ handleMessage,
544
946
  interceptors,
545
947
  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
- }
948
+ retry,
949
+ retryTopics: options.retryTopics,
950
+ timeoutMs,
951
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
952
+ },
953
+ deps
954
+ )
578
955
  });
579
956
  this.runningConsumers.set(gid, "eachMessage");
580
957
  if (options.retryTopics && retry) {
581
- await this.startRetryTopicConsumers(
958
+ const companions = await startRetryTopicConsumers(
582
959
  topicNames,
583
960
  gid,
584
961
  handleMessage,
585
962
  retry,
586
963
  dlq,
587
964
  interceptors,
588
- schemaMap
965
+ schemaMap,
966
+ this.retryTopicDeps,
967
+ options.retryTopicAssignmentTimeoutMs
589
968
  );
969
+ this.companionGroupIds.set(gid, companions);
590
970
  }
591
971
  return { groupId: gid, stop: () => this.stopConsumer(gid) };
592
972
  }
593
973
  async startBatchConsumer(topics, handleBatch, options = {}) {
594
974
  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
- };
975
+ const deps = this.messageDeps;
601
976
  const timeoutMs = options.handlerTimeoutMs;
602
977
  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
- }
978
+ eachBatch: (payload) => handleEachBatch(
979
+ payload,
980
+ {
981
+ schemaMap,
982
+ handleBatch,
983
+ interceptors,
984
+ dlq,
985
+ retry,
986
+ timeoutMs,
987
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
988
+ },
989
+ deps
990
+ )
667
991
  });
668
992
  this.runningConsumers.set(gid, "eachBatch");
669
993
  return { groupId: gid, stop: () => this.stopConsumer(gid) };
@@ -682,7 +1006,21 @@ var KafkaClient = class {
682
1006
  });
683
1007
  this.consumers.delete(groupId);
684
1008
  this.runningConsumers.delete(groupId);
1009
+ this.consumerCreationOptions.delete(groupId);
685
1010
  this.logger.log(`Consumer disconnected: group "${groupId}"`);
1011
+ const companions = this.companionGroupIds.get(groupId) ?? [];
1012
+ for (const cGroupId of companions) {
1013
+ const cConsumer = this.consumers.get(cGroupId);
1014
+ if (cConsumer) {
1015
+ await cConsumer.disconnect().catch(() => {
1016
+ });
1017
+ this.consumers.delete(cGroupId);
1018
+ this.runningConsumers.delete(cGroupId);
1019
+ this.consumerCreationOptions.delete(cGroupId);
1020
+ this.logger.log(`Retry consumer disconnected: group "${cGroupId}"`);
1021
+ }
1022
+ }
1023
+ this.companionGroupIds.delete(groupId);
686
1024
  } else {
687
1025
  const tasks = Array.from(this.consumers.values()).map(
688
1026
  (c) => c.disconnect().catch(() => {
@@ -691,6 +1029,8 @@ var KafkaClient = class {
691
1029
  await Promise.allSettled(tasks);
692
1030
  this.consumers.clear();
693
1031
  this.runningConsumers.clear();
1032
+ this.consumerCreationOptions.clear();
1033
+ this.companionGroupIds.clear();
694
1034
  this.logger.log("All consumers disconnected");
695
1035
  }
696
1036
  }
@@ -720,14 +1060,22 @@ var KafkaClient = class {
720
1060
  }
721
1061
  return result;
722
1062
  }
723
- /** Check broker connectivity and return status, clientId, and available topics. */
1063
+ /** Check broker connectivity. Never throws returns a discriminated union. */
724
1064
  async checkStatus() {
725
- if (!this.isAdminConnected) {
726
- await this.admin.connect();
727
- this.isAdminConnected = true;
1065
+ try {
1066
+ if (!this.isAdminConnected) {
1067
+ await this.admin.connect();
1068
+ this.isAdminConnected = true;
1069
+ }
1070
+ const topics = await this.admin.listTopics();
1071
+ return { status: "up", clientId: this.clientId, topics };
1072
+ } catch (error) {
1073
+ return {
1074
+ status: "down",
1075
+ clientId: this.clientId,
1076
+ error: error instanceof Error ? error.message : String(error)
1077
+ };
728
1078
  }
729
- const topics = await this.admin.listTopics();
730
- return { status: "up", clientId: this.clientId, topics };
731
1079
  }
732
1080
  getClientId() {
733
1081
  return this.clientId;
@@ -749,204 +1097,28 @@ var KafkaClient = class {
749
1097
  await Promise.allSettled(tasks);
750
1098
  this.consumers.clear();
751
1099
  this.runningConsumers.clear();
1100
+ this.consumerCreationOptions.clear();
1101
+ this.companionGroupIds.clear();
752
1102
  this.logger.log("All connections closed");
753
1103
  }
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
1104
  // ── 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`
1105
+ async preparePayload(topicOrDesc, messages) {
1106
+ registerSchema(topicOrDesc, this.schemaRegistry);
1107
+ const payload = await buildSendPayload(
1108
+ topicOrDesc,
1109
+ messages,
1110
+ this.producerOpsDeps
921
1111
  );
1112
+ await this.ensureTopic(payload.topic);
1113
+ return payload;
922
1114
  }
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
- };
1115
+ // afterSend is called once per message — symmetric with beforeSend in buildSendPayload.
1116
+ notifyAfterSend(topic2, count) {
1117
+ for (let i = 0; i < count; i++) {
1118
+ for (const inst of this.instrumentation) {
1119
+ inst.afterSend?.(topic2);
946
1120
  }
947
- this.consumers.set(groupId, this.kafka.consumer(config));
948
1121
  }
949
- return this.consumers.get(groupId);
950
1122
  }
951
1123
  /**
952
1124
  * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
@@ -964,13 +1136,6 @@ var KafkaClient = class {
964
1136
  }, timeoutMs);
965
1137
  return promise;
966
1138
  }
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
1139
  async ensureTopic(topic2) {
975
1140
  if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
976
1141
  if (!this.isAdminConnected) {
@@ -982,54 +1147,6 @@ var KafkaClient = class {
982
1147
  });
983
1148
  this.ensuredTopics.add(topic2);
984
1149
  }
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
1150
  /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
1034
1151
  async setupConsumer(topics, mode, options) {
1035
1152
  const {
@@ -1048,15 +1165,18 @@ var KafkaClient = class {
1048
1165
  `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
1049
1166
  );
1050
1167
  }
1051
- const consumer = this.getOrCreateConsumer(
1168
+ const consumer = getOrCreateConsumer(
1052
1169
  gid,
1053
1170
  fromBeginning,
1054
- options.autoCommit ?? true
1171
+ options.autoCommit ?? true,
1172
+ this.consumerOpsDeps
1055
1173
  );
1056
- const schemaMap = this.buildSchemaMap(topics, optionSchemas);
1057
- const topicNames = topics.map(
1058
- (t) => this.resolveTopicName(t)
1174
+ const schemaMap = buildSchemaMap(
1175
+ topics,
1176
+ this.schemaRegistry,
1177
+ optionSchemas
1059
1178
  );
1179
+ const topicNames = topics.map((t) => resolveTopicName(t));
1060
1180
  for (const t of topicNames) {
1061
1181
  await this.ensureTopic(t);
1062
1182
  }
@@ -1077,37 +1197,58 @@ var KafkaClient = class {
1077
1197
  );
1078
1198
  return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
1079
1199
  }
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;
1200
+ // ── Deps object getters ──────────────────────────────────────────
1201
+ get producerOpsDeps() {
1202
+ return {
1203
+ schemaRegistry: this.schemaRegistry,
1204
+ strictSchemasEnabled: this.strictSchemasEnabled,
1205
+ instrumentation: this.instrumentation
1206
+ };
1207
+ }
1208
+ get consumerOpsDeps() {
1209
+ return {
1210
+ consumers: this.consumers,
1211
+ consumerCreationOptions: this.consumerCreationOptions,
1212
+ kafka: this.kafka,
1213
+ onRebalance: this.onRebalance,
1214
+ logger: this.logger
1215
+ };
1216
+ }
1217
+ get messageDeps() {
1218
+ return {
1219
+ logger: this.logger,
1220
+ producer: this.producer,
1221
+ instrumentation: this.instrumentation,
1222
+ onMessageLost: this.onMessageLost
1223
+ };
1224
+ }
1225
+ get retryTopicDeps() {
1226
+ return {
1227
+ logger: this.logger,
1228
+ producer: this.producer,
1229
+ instrumentation: this.instrumentation,
1230
+ onMessageLost: this.onMessageLost,
1231
+ ensureTopic: (t) => this.ensureTopic(t),
1232
+ getOrCreateConsumer: (gid, fb, ac) => getOrCreateConsumer(gid, fb, ac, this.consumerOpsDeps),
1233
+ runningConsumers: this.runningConsumers
1234
+ };
1096
1235
  }
1097
1236
  };
1098
1237
 
1099
- // src/client/topic.ts
1238
+ // src/client/message/topic.ts
1100
1239
  function topic(name) {
1101
- const fn = () => ({
1102
- __topic: name,
1103
- __type: void 0
1104
- });
1105
- fn.schema = (schema) => ({
1106
- __topic: name,
1107
- __type: void 0,
1108
- __schema: schema
1109
- });
1110
- return fn;
1240
+ return {
1241
+ /** Provide an explicit message type without a runtime schema. */
1242
+ type: () => ({
1243
+ __topic: name,
1244
+ __type: void 0
1245
+ }),
1246
+ schema: (schema) => ({
1247
+ __topic: name,
1248
+ __type: void 0,
1249
+ __schema: schema
1250
+ })
1251
+ };
1111
1252
  }
1112
1253
  // Annotate the CommonJS export names for ESM import in node:
1113
1254
  0 && (module.exports = {