@drarzter/kafka-client 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1160 @@
1
+ // src/client/kafka.client/index.ts
2
+ import { KafkaJS } from "@confluentinc/kafka-javascript";
3
+
4
+ // src/client/message/envelope.ts
5
+ import { AsyncLocalStorage } from "async_hooks";
6
+ import { randomUUID } from "crypto";
7
+ var HEADER_EVENT_ID = "x-event-id";
8
+ var HEADER_CORRELATION_ID = "x-correlation-id";
9
+ var HEADER_TIMESTAMP = "x-timestamp";
10
+ var HEADER_SCHEMA_VERSION = "x-schema-version";
11
+ var HEADER_TRACEPARENT = "traceparent";
12
+ var envelopeStorage = new AsyncLocalStorage();
13
+ function getEnvelopeContext() {
14
+ return envelopeStorage.getStore();
15
+ }
16
+ function runWithEnvelopeContext(ctx, fn) {
17
+ return envelopeStorage.run(ctx, fn);
18
+ }
19
+ function buildEnvelopeHeaders(options = {}) {
20
+ const ctx = getEnvelopeContext();
21
+ const correlationId = options.correlationId ?? ctx?.correlationId ?? randomUUID();
22
+ const eventId = options.eventId ?? randomUUID();
23
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
24
+ const schemaVersion = String(options.schemaVersion ?? 1);
25
+ const envelope = {
26
+ [HEADER_EVENT_ID]: eventId,
27
+ [HEADER_CORRELATION_ID]: correlationId,
28
+ [HEADER_TIMESTAMP]: timestamp,
29
+ [HEADER_SCHEMA_VERSION]: schemaVersion
30
+ };
31
+ if (ctx?.traceparent) {
32
+ envelope[HEADER_TRACEPARENT] = ctx.traceparent;
33
+ }
34
+ return { ...envelope, ...options.headers };
35
+ }
36
+ function decodeHeaders(raw) {
37
+ if (!raw) return {};
38
+ const result = {};
39
+ for (const [key, value] of Object.entries(raw)) {
40
+ if (value === void 0) continue;
41
+ if (Array.isArray(value)) {
42
+ result[key] = value.map((v) => Buffer.isBuffer(v) ? v.toString() : v).join(",");
43
+ } else {
44
+ result[key] = Buffer.isBuffer(value) ? value.toString() : value;
45
+ }
46
+ }
47
+ return result;
48
+ }
49
+ function extractEnvelope(payload, headers, topic2, partition, offset) {
50
+ return {
51
+ payload,
52
+ topic: topic2,
53
+ partition,
54
+ offset,
55
+ eventId: headers[HEADER_EVENT_ID] ?? randomUUID(),
56
+ correlationId: headers[HEADER_CORRELATION_ID] ?? randomUUID(),
57
+ timestamp: headers[HEADER_TIMESTAMP] ?? (/* @__PURE__ */ new Date()).toISOString(),
58
+ schemaVersion: Number(headers[HEADER_SCHEMA_VERSION] ?? 1),
59
+ traceparent: headers[HEADER_TRACEPARENT],
60
+ headers
61
+ };
62
+ }
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
+
166
+ // src/client/errors.ts
167
+ var KafkaProcessingError = class extends Error {
168
+ constructor(message, topic2, originalMessage, options) {
169
+ super(message, options);
170
+ this.topic = topic2;
171
+ this.originalMessage = originalMessage;
172
+ this.name = "KafkaProcessingError";
173
+ if (options?.cause) this.cause = options.cause;
174
+ }
175
+ };
176
+ var KafkaValidationError = class extends Error {
177
+ constructor(topic2, originalMessage, options) {
178
+ super(`Schema validation failed for topic "${topic2}"`, options);
179
+ this.topic = topic2;
180
+ this.originalMessage = originalMessage;
181
+ this.name = "KafkaValidationError";
182
+ if (options?.cause) this.cause = options.cause;
183
+ }
184
+ };
185
+ var KafkaRetryExhaustedError = class extends KafkaProcessingError {
186
+ constructor(topic2, originalMessage, attempts, options) {
187
+ super(
188
+ `Message processing failed after ${attempts} attempts on topic "${topic2}"`,
189
+ topic2,
190
+ originalMessage,
191
+ options
192
+ );
193
+ this.attempts = attempts;
194
+ this.name = "KafkaRetryExhaustedError";
195
+ }
196
+ };
197
+
198
+ // src/client/consumer/pipeline.ts
199
+ function toError(error) {
200
+ return error instanceof Error ? error : new Error(String(error));
201
+ }
202
+ function sleep(ms) {
203
+ return new Promise((resolve) => setTimeout(resolve, ms));
204
+ }
205
+ function parseJsonMessage(raw, topic2, logger) {
206
+ try {
207
+ return JSON.parse(raw);
208
+ } catch (error) {
209
+ logger.error(
210
+ `Failed to parse message from topic ${topic2}:`,
211
+ toError(error).stack
212
+ );
213
+ return null;
214
+ }
215
+ }
216
+ async function validateWithSchema(message, raw, topic2, schemaMap, interceptors, dlq, deps) {
217
+ const schema = schemaMap.get(topic2);
218
+ if (!schema) return message;
219
+ try {
220
+ return await schema.parse(message);
221
+ } catch (error) {
222
+ const err = toError(error);
223
+ const validationError = new KafkaValidationError(topic2, message, {
224
+ cause: err
225
+ });
226
+ deps.logger.error(
227
+ `Schema validation failed for topic ${topic2}:`,
228
+ err.message
229
+ );
230
+ if (dlq) {
231
+ await sendToDlq(topic2, raw, deps, {
232
+ error: validationError,
233
+ attempt: 0,
234
+ originalHeaders: deps.originalHeaders
235
+ });
236
+ } else {
237
+ await deps.onMessageLost?.({
238
+ topic: topic2,
239
+ error: validationError,
240
+ attempt: 0,
241
+ headers: deps.originalHeaders ?? {}
242
+ });
243
+ }
244
+ const errorEnvelope = extractEnvelope(
245
+ message,
246
+ deps.originalHeaders ?? {},
247
+ topic2,
248
+ -1,
249
+ ""
250
+ );
251
+ for (const interceptor of interceptors) {
252
+ await interceptor.onError?.(errorEnvelope, validationError);
253
+ }
254
+ return null;
255
+ }
256
+ }
257
+ async function sendToDlq(topic2, rawMessage, deps, meta) {
258
+ const dlqTopic = `${topic2}.dlq`;
259
+ const headers = {
260
+ ...meta?.originalHeaders ?? {},
261
+ "x-dlq-original-topic": topic2,
262
+ "x-dlq-failed-at": (/* @__PURE__ */ new Date()).toISOString(),
263
+ "x-dlq-error-message": meta?.error.message ?? "unknown",
264
+ "x-dlq-error-stack": meta?.error.stack?.slice(0, 2e3) ?? "",
265
+ "x-dlq-attempt-count": String(meta?.attempt ?? 0)
266
+ };
267
+ try {
268
+ await deps.producer.send({
269
+ topic: dlqTopic,
270
+ messages: [{ value: rawMessage, headers }]
271
+ });
272
+ deps.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
273
+ } catch (error) {
274
+ deps.logger.error(
275
+ `Failed to send message to DLQ ${dlqTopic}:`,
276
+ toError(error).stack
277
+ );
278
+ }
279
+ }
280
+ var RETRY_HEADER_ATTEMPT = "x-retry-attempt";
281
+ var RETRY_HEADER_AFTER = "x-retry-after";
282
+ var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
283
+ var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
284
+ async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
285
+ const retryTopic = `${originalTopic}.retry`;
286
+ const {
287
+ [RETRY_HEADER_ATTEMPT]: _a,
288
+ [RETRY_HEADER_AFTER]: _b,
289
+ [RETRY_HEADER_MAX_RETRIES]: _c,
290
+ [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
291
+ ...userHeaders
292
+ } = originalHeaders;
293
+ const headers = {
294
+ ...userHeaders,
295
+ [RETRY_HEADER_ATTEMPT]: String(attempt),
296
+ [RETRY_HEADER_AFTER]: String(Date.now() + delayMs),
297
+ [RETRY_HEADER_MAX_RETRIES]: String(maxRetries),
298
+ [RETRY_HEADER_ORIGINAL_TOPIC]: originalTopic
299
+ };
300
+ try {
301
+ for (const raw of rawMessages) {
302
+ await deps.producer.send({
303
+ topic: retryTopic,
304
+ messages: [{ value: raw, headers }]
305
+ });
306
+ }
307
+ deps.logger.warn(
308
+ `Message queued in retry topic ${retryTopic} (attempt ${attempt}/${maxRetries})`
309
+ );
310
+ } catch (error) {
311
+ deps.logger.error(
312
+ `Failed to send message to retry topic ${retryTopic}:`,
313
+ toError(error).stack
314
+ );
315
+ }
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
+ }
364
+ async function executeWithRetry(fn, ctx, deps) {
365
+ const {
366
+ envelope,
367
+ rawMessages,
368
+ interceptors,
369
+ dlq,
370
+ retry,
371
+ isBatch,
372
+ retryTopics
373
+ } = ctx;
374
+ const maxAttempts = retryTopics ? 1 : retry ? retry.maxRetries + 1 : 1;
375
+ const backoffMs = retry?.backoffMs ?? 1e3;
376
+ const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
377
+ const envelopes = Array.isArray(envelope) ? envelope : [envelope];
378
+ const topic2 = envelopes[0]?.topic ?? "unknown";
379
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
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
410
+ );
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);
420
+ }
421
+ } else {
422
+ await deps.onMessageLost?.({
423
+ topic: topic2,
424
+ error,
425
+ attempt,
426
+ headers: envelopes[0]?.headers ?? {}
427
+ });
428
+ }
429
+ } else {
430
+ const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
431
+ await sleep(Math.random() * cap);
432
+ }
433
+ }
434
+ }
435
+
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
555
+ async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
556
+ const maxAttempts = retryOpts?.retries ?? 5;
557
+ const backoffMs = retryOpts?.backoffMs ?? 5e3;
558
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
559
+ try {
560
+ await consumer.subscribe({ topics });
561
+ return;
562
+ } catch (error) {
563
+ if (attempt === maxAttempts) throw error;
564
+ const msg = toError(error).message;
565
+ logger.warn(
566
+ `Failed to subscribe to [${topics.join(", ")}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${backoffMs}ms...`
567
+ );
568
+ await sleep(backoffMs);
569
+ }
570
+ }
571
+ }
572
+
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
723
+ var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
724
+ var KafkaClient = class {
725
+ kafka;
726
+ producer;
727
+ txProducer;
728
+ consumers = /* @__PURE__ */ new Map();
729
+ admin;
730
+ logger;
731
+ autoCreateTopicsEnabled;
732
+ strictSchemasEnabled;
733
+ numPartitions;
734
+ ensuredTopics = /* @__PURE__ */ new Set();
735
+ defaultGroupId;
736
+ schemaRegistry = /* @__PURE__ */ new Map();
737
+ runningConsumers = /* @__PURE__ */ new Map();
738
+ consumerCreationOptions = /* @__PURE__ */ new Map();
739
+ instrumentation;
740
+ onMessageLost;
741
+ onRebalance;
742
+ isAdminConnected = false;
743
+ clientId;
744
+ constructor(clientId, groupId, brokers, options) {
745
+ this.clientId = clientId;
746
+ this.defaultGroupId = groupId;
747
+ this.logger = options?.logger ?? {
748
+ log: (msg) => console.log(`[KafkaClient:${clientId}] ${msg}`),
749
+ warn: (msg, ...args) => console.warn(`[KafkaClient:${clientId}] ${msg}`, ...args),
750
+ error: (msg, ...args) => console.error(`[KafkaClient:${clientId}] ${msg}`, ...args)
751
+ };
752
+ this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
753
+ this.strictSchemasEnabled = options?.strictSchemas ?? true;
754
+ this.numPartitions = options?.numPartitions ?? 1;
755
+ this.instrumentation = options?.instrumentation ?? [];
756
+ this.onMessageLost = options?.onMessageLost;
757
+ this.onRebalance = options?.onRebalance;
758
+ this.kafka = new KafkaClass({
759
+ kafkaJS: {
760
+ clientId: this.clientId,
761
+ brokers,
762
+ logLevel: KafkaLogLevel.ERROR
763
+ }
764
+ });
765
+ this.producer = this.kafka.producer({
766
+ kafkaJS: {
767
+ acks: -1
768
+ }
769
+ });
770
+ this.admin = this.kafka.admin();
771
+ }
772
+ async sendMessage(topicOrDesc, message, options = {}) {
773
+ const payload = await this.preparePayload(topicOrDesc, [
774
+ {
775
+ value: message,
776
+ key: options.key,
777
+ headers: options.headers,
778
+ correlationId: options.correlationId,
779
+ schemaVersion: options.schemaVersion,
780
+ eventId: options.eventId
781
+ }
782
+ ]);
783
+ await this.producer.send(payload);
784
+ this.notifyAfterSend(payload.topic, payload.messages.length);
785
+ }
786
+ async sendBatch(topicOrDesc, messages) {
787
+ const payload = await this.preparePayload(topicOrDesc, messages);
788
+ await this.producer.send(payload);
789
+ this.notifyAfterSend(payload.topic, payload.messages.length);
790
+ }
791
+ /** Execute multiple sends atomically. Commits on success, aborts on error. */
792
+ async transaction(fn) {
793
+ if (!this.txProducer) {
794
+ this.txProducer = this.kafka.producer({
795
+ kafkaJS: {
796
+ acks: -1,
797
+ idempotent: true,
798
+ transactionalId: `${this.clientId}-tx`,
799
+ maxInFlightRequests: 1
800
+ }
801
+ });
802
+ await this.txProducer.connect();
803
+ }
804
+ const tx = await this.txProducer.transaction();
805
+ try {
806
+ const ctx = {
807
+ send: async (topicOrDesc, message, options = {}) => {
808
+ const payload = await this.preparePayload(topicOrDesc, [
809
+ {
810
+ value: message,
811
+ key: options.key,
812
+ headers: options.headers,
813
+ correlationId: options.correlationId,
814
+ schemaVersion: options.schemaVersion,
815
+ eventId: options.eventId
816
+ }
817
+ ]);
818
+ await tx.send(payload);
819
+ },
820
+ sendBatch: async (topicOrDesc, messages) => {
821
+ await tx.send(await this.preparePayload(topicOrDesc, messages));
822
+ }
823
+ };
824
+ await fn(ctx);
825
+ await tx.commit();
826
+ } catch (error) {
827
+ try {
828
+ await tx.abort();
829
+ } catch (abortError) {
830
+ this.logger.error(
831
+ "Failed to abort transaction:",
832
+ toError(abortError).message
833
+ );
834
+ }
835
+ throw error;
836
+ }
837
+ }
838
+ // ── Producer lifecycle ───────────────────────────────────────────
839
+ /** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
840
+ async connectProducer() {
841
+ await this.producer.connect();
842
+ this.logger.log("Producer connected");
843
+ }
844
+ async disconnectProducer() {
845
+ await this.producer.disconnect();
846
+ this.logger.log("Producer disconnected");
847
+ }
848
+ async startConsumer(topics, handleMessage, options = {}) {
849
+ if (options.retryTopics && !options.retry) {
850
+ throw new Error(
851
+ "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
852
+ );
853
+ }
854
+ const { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
855
+ const deps = this.messageDeps;
856
+ const timeoutMs = options.handlerTimeoutMs;
857
+ await consumer.run({
858
+ eachMessage: (payload) => handleEachMessage(
859
+ payload,
860
+ {
861
+ schemaMap,
862
+ handleMessage,
863
+ interceptors,
864
+ dlq,
865
+ retry,
866
+ retryTopics: options.retryTopics,
867
+ timeoutMs,
868
+ wrapWithTimeout: this.wrapWithTimeoutWarning.bind(this)
869
+ },
870
+ deps
871
+ )
872
+ });
873
+ this.runningConsumers.set(gid, "eachMessage");
874
+ if (options.retryTopics && retry) {
875
+ await startRetryTopicConsumers(
876
+ topicNames,
877
+ gid,
878
+ handleMessage,
879
+ retry,
880
+ dlq,
881
+ interceptors,
882
+ schemaMap,
883
+ this.retryTopicDeps
884
+ );
885
+ }
886
+ return { groupId: gid, stop: () => this.stopConsumer(gid) };
887
+ }
888
+ async startBatchConsumer(topics, handleBatch, options = {}) {
889
+ const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
890
+ const deps = this.messageDeps;
891
+ const timeoutMs = options.handlerTimeoutMs;
892
+ await consumer.run({
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
+ )
906
+ });
907
+ this.runningConsumers.set(gid, "eachBatch");
908
+ return { groupId: gid, stop: () => this.stopConsumer(gid) };
909
+ }
910
+ // ── Consumer lifecycle ───────────────────────────────────────────
911
+ async stopConsumer(groupId) {
912
+ if (groupId !== void 0) {
913
+ const consumer = this.consumers.get(groupId);
914
+ if (!consumer) {
915
+ this.logger.warn(
916
+ `stopConsumer: no active consumer for group "${groupId}"`
917
+ );
918
+ return;
919
+ }
920
+ await consumer.disconnect().catch(() => {
921
+ });
922
+ this.consumers.delete(groupId);
923
+ this.runningConsumers.delete(groupId);
924
+ this.consumerCreationOptions.delete(groupId);
925
+ this.logger.log(`Consumer disconnected: group "${groupId}"`);
926
+ } else {
927
+ const tasks = Array.from(this.consumers.values()).map(
928
+ (c) => c.disconnect().catch(() => {
929
+ })
930
+ );
931
+ await Promise.allSettled(tasks);
932
+ this.consumers.clear();
933
+ this.runningConsumers.clear();
934
+ this.consumerCreationOptions.clear();
935
+ this.logger.log("All consumers disconnected");
936
+ }
937
+ }
938
+ /**
939
+ * Query consumer group lag per partition.
940
+ * Lag = broker high-watermark − last committed offset.
941
+ * A committed offset of -1 (nothing committed yet) counts as full lag.
942
+ */
943
+ async getConsumerLag(groupId) {
944
+ const gid = groupId ?? this.defaultGroupId;
945
+ if (!this.isAdminConnected) {
946
+ await this.admin.connect();
947
+ this.isAdminConnected = true;
948
+ }
949
+ const committedByTopic = await this.admin.fetchOffsets({ groupId: gid });
950
+ const result = [];
951
+ for (const { topic: topic2, partitions } of committedByTopic) {
952
+ const brokerOffsets = await this.admin.fetchTopicOffsets(topic2);
953
+ for (const { partition, offset } of partitions) {
954
+ const broker = brokerOffsets.find((o) => o.partition === partition);
955
+ if (!broker) continue;
956
+ const committed = parseInt(offset, 10);
957
+ const high = parseInt(broker.high, 10);
958
+ const lag = committed === -1 ? high : Math.max(0, high - committed);
959
+ result.push({ topic: topic2, partition, lag });
960
+ }
961
+ }
962
+ return result;
963
+ }
964
+ /** Check broker connectivity and return status, clientId, and available topics. */
965
+ async checkStatus() {
966
+ if (!this.isAdminConnected) {
967
+ await this.admin.connect();
968
+ this.isAdminConnected = true;
969
+ }
970
+ const topics = await this.admin.listTopics();
971
+ return { status: "up", clientId: this.clientId, topics };
972
+ }
973
+ getClientId() {
974
+ return this.clientId;
975
+ }
976
+ /** Gracefully disconnect producer, all consumers, and admin. */
977
+ async disconnect() {
978
+ const tasks = [this.producer.disconnect()];
979
+ if (this.txProducer) {
980
+ tasks.push(this.txProducer.disconnect());
981
+ this.txProducer = void 0;
982
+ }
983
+ for (const consumer of this.consumers.values()) {
984
+ tasks.push(consumer.disconnect());
985
+ }
986
+ if (this.isAdminConnected) {
987
+ tasks.push(this.admin.disconnect());
988
+ this.isAdminConnected = false;
989
+ }
990
+ await Promise.allSettled(tasks);
991
+ this.consumers.clear();
992
+ this.runningConsumers.clear();
993
+ this.consumerCreationOptions.clear();
994
+ this.logger.log("All connections closed");
995
+ }
996
+ // ── Private helpers ──────────────────────────────────────────────
997
+ async preparePayload(topicOrDesc, messages) {
998
+ const payload = await buildSendPayload(
999
+ topicOrDesc,
1000
+ messages,
1001
+ this.producerOpsDeps
1002
+ );
1003
+ await this.ensureTopic(payload.topic);
1004
+ return payload;
1005
+ }
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);
1011
+ }
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
1016
+ * The handler itself is not cancelled — the warning is diagnostic only.
1017
+ */
1018
+ wrapWithTimeoutWarning(fn, timeoutMs, topic2) {
1019
+ let timer;
1020
+ const promise = fn().finally(() => {
1021
+ if (timer !== void 0) clearTimeout(timer);
1022
+ });
1023
+ timer = setTimeout(() => {
1024
+ this.logger.warn(
1025
+ `Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
1026
+ );
1027
+ }, timeoutMs);
1028
+ return promise;
1029
+ }
1030
+ async ensureTopic(topic2) {
1031
+ if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
1032
+ if (!this.isAdminConnected) {
1033
+ await this.admin.connect();
1034
+ this.isAdminConnected = true;
1035
+ }
1036
+ await this.admin.createTopics({
1037
+ topics: [{ topic: topic2, numPartitions: this.numPartitions }]
1038
+ });
1039
+ this.ensuredTopics.add(topic2);
1040
+ }
1041
+ /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
1042
+ async setupConsumer(topics, mode, options) {
1043
+ const {
1044
+ groupId: optGroupId,
1045
+ fromBeginning = false,
1046
+ retry,
1047
+ dlq = false,
1048
+ interceptors = [],
1049
+ schemas: optionSchemas
1050
+ } = options;
1051
+ const gid = optGroupId || this.defaultGroupId;
1052
+ const existingMode = this.runningConsumers.get(gid);
1053
+ const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
1054
+ if (existingMode === oppositeMode) {
1055
+ throw new Error(
1056
+ `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
1057
+ );
1058
+ }
1059
+ const consumer = getOrCreateConsumer(
1060
+ gid,
1061
+ fromBeginning,
1062
+ options.autoCommit ?? true,
1063
+ this.consumerOpsDeps
1064
+ );
1065
+ const schemaMap = buildSchemaMap(
1066
+ topics,
1067
+ this.schemaRegistry,
1068
+ optionSchemas
1069
+ );
1070
+ const topicNames = topics.map((t) => resolveTopicName(t));
1071
+ for (const t of topicNames) {
1072
+ await this.ensureTopic(t);
1073
+ }
1074
+ if (dlq) {
1075
+ for (const t of topicNames) {
1076
+ await this.ensureTopic(`${t}.dlq`);
1077
+ }
1078
+ }
1079
+ await consumer.connect();
1080
+ await subscribeWithRetry(
1081
+ consumer,
1082
+ topicNames,
1083
+ this.logger,
1084
+ options.subscribeRetry
1085
+ );
1086
+ this.logger.log(
1087
+ `${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
1088
+ );
1089
+ return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
1090
+ }
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
+ };
1126
+ }
1127
+ };
1128
+
1129
+ // src/client/message/topic.ts
1130
+ function topic(name) {
1131
+ const fn = () => ({
1132
+ __topic: name,
1133
+ __type: void 0
1134
+ });
1135
+ fn.schema = (schema) => ({
1136
+ __topic: name,
1137
+ __type: void 0,
1138
+ __schema: schema
1139
+ });
1140
+ return fn;
1141
+ }
1142
+
1143
+ export {
1144
+ HEADER_EVENT_ID,
1145
+ HEADER_CORRELATION_ID,
1146
+ HEADER_TIMESTAMP,
1147
+ HEADER_SCHEMA_VERSION,
1148
+ HEADER_TRACEPARENT,
1149
+ getEnvelopeContext,
1150
+ runWithEnvelopeContext,
1151
+ buildEnvelopeHeaders,
1152
+ decodeHeaders,
1153
+ extractEnvelope,
1154
+ KafkaProcessingError,
1155
+ KafkaValidationError,
1156
+ KafkaRetryExhaustedError,
1157
+ KafkaClient,
1158
+ topic
1159
+ };
1160
+ //# sourceMappingURL=chunk-6B72MJPU.mjs.map