@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.
@@ -1,1090 +0,0 @@
1
- // src/client/kafka.client.ts
2
- import { KafkaJS } from "@confluentinc/kafka-javascript";
3
-
4
- // src/client/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/errors.ts
65
- var KafkaProcessingError = class extends Error {
66
- constructor(message, topic2, originalMessage, options) {
67
- super(message, options);
68
- this.topic = topic2;
69
- this.originalMessage = originalMessage;
70
- this.name = "KafkaProcessingError";
71
- if (options?.cause) this.cause = options.cause;
72
- }
73
- };
74
- var KafkaValidationError = class extends Error {
75
- constructor(topic2, originalMessage, options) {
76
- super(`Schema validation failed for topic "${topic2}"`, options);
77
- this.topic = topic2;
78
- this.originalMessage = originalMessage;
79
- this.name = "KafkaValidationError";
80
- if (options?.cause) this.cause = options.cause;
81
- }
82
- };
83
- var KafkaRetryExhaustedError = class extends KafkaProcessingError {
84
- constructor(topic2, originalMessage, attempts, options) {
85
- super(
86
- `Message processing failed after ${attempts} attempts on topic "${topic2}"`,
87
- topic2,
88
- originalMessage,
89
- options
90
- );
91
- this.attempts = attempts;
92
- this.name = "KafkaRetryExhaustedError";
93
- }
94
- };
95
-
96
- // src/client/consumer-pipeline.ts
97
- function toError(error) {
98
- return error instanceof Error ? error : new Error(String(error));
99
- }
100
- function sleep(ms) {
101
- return new Promise((resolve) => setTimeout(resolve, ms));
102
- }
103
- function parseJsonMessage(raw, topic2, logger) {
104
- try {
105
- return JSON.parse(raw);
106
- } catch (error) {
107
- logger.error(
108
- `Failed to parse message from topic ${topic2}:`,
109
- toError(error).stack
110
- );
111
- return null;
112
- }
113
- }
114
- async function validateWithSchema(message, raw, topic2, schemaMap, interceptors, dlq, deps) {
115
- const schema = schemaMap.get(topic2);
116
- if (!schema) return message;
117
- try {
118
- return await schema.parse(message);
119
- } catch (error) {
120
- const err = toError(error);
121
- const validationError = new KafkaValidationError(topic2, message, {
122
- cause: err
123
- });
124
- deps.logger.error(
125
- `Schema validation failed for topic ${topic2}:`,
126
- err.message
127
- );
128
- if (dlq) {
129
- await sendToDlq(topic2, raw, deps, {
130
- error: validationError,
131
- attempt: 0,
132
- originalHeaders: deps.originalHeaders
133
- });
134
- } else {
135
- await deps.onMessageLost?.({
136
- topic: topic2,
137
- error: validationError,
138
- attempt: 0,
139
- headers: deps.originalHeaders ?? {}
140
- });
141
- }
142
- const errorEnvelope = extractEnvelope(
143
- message,
144
- deps.originalHeaders ?? {},
145
- topic2,
146
- -1,
147
- ""
148
- );
149
- for (const interceptor of interceptors) {
150
- await interceptor.onError?.(errorEnvelope, validationError);
151
- }
152
- return null;
153
- }
154
- }
155
- async function sendToDlq(topic2, rawMessage, deps, meta) {
156
- const dlqTopic = `${topic2}.dlq`;
157
- const headers = {
158
- ...meta?.originalHeaders ?? {},
159
- "x-dlq-original-topic": topic2,
160
- "x-dlq-failed-at": (/* @__PURE__ */ new Date()).toISOString(),
161
- "x-dlq-error-message": meta?.error.message ?? "unknown",
162
- "x-dlq-error-stack": meta?.error.stack?.slice(0, 2e3) ?? "",
163
- "x-dlq-attempt-count": String(meta?.attempt ?? 0)
164
- };
165
- try {
166
- await deps.producer.send({
167
- topic: dlqTopic,
168
- messages: [{ value: rawMessage, headers }]
169
- });
170
- deps.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
171
- } catch (error) {
172
- deps.logger.error(
173
- `Failed to send message to DLQ ${dlqTopic}:`,
174
- toError(error).stack
175
- );
176
- }
177
- }
178
- var RETRY_HEADER_ATTEMPT = "x-retry-attempt";
179
- var RETRY_HEADER_AFTER = "x-retry-after";
180
- var RETRY_HEADER_MAX_RETRIES = "x-retry-max-retries";
181
- var RETRY_HEADER_ORIGINAL_TOPIC = "x-retry-original-topic";
182
- async function sendToRetryTopic(originalTopic, rawMessages, attempt, maxRetries, delayMs, originalHeaders, deps) {
183
- const retryTopic = `${originalTopic}.retry`;
184
- const {
185
- [RETRY_HEADER_ATTEMPT]: _a,
186
- [RETRY_HEADER_AFTER]: _b,
187
- [RETRY_HEADER_MAX_RETRIES]: _c,
188
- [RETRY_HEADER_ORIGINAL_TOPIC]: _d,
189
- ...userHeaders
190
- } = originalHeaders;
191
- const headers = {
192
- ...userHeaders,
193
- [RETRY_HEADER_ATTEMPT]: String(attempt),
194
- [RETRY_HEADER_AFTER]: String(Date.now() + delayMs),
195
- [RETRY_HEADER_MAX_RETRIES]: String(maxRetries),
196
- [RETRY_HEADER_ORIGINAL_TOPIC]: originalTopic
197
- };
198
- try {
199
- for (const raw of rawMessages) {
200
- await deps.producer.send({
201
- topic: retryTopic,
202
- messages: [{ value: raw, headers }]
203
- });
204
- }
205
- deps.logger.warn(
206
- `Message queued in retry topic ${retryTopic} (attempt ${attempt}/${maxRetries})`
207
- );
208
- } catch (error) {
209
- deps.logger.error(
210
- `Failed to send message to retry topic ${retryTopic}:`,
211
- toError(error).stack
212
- );
213
- }
214
- }
215
- async function executeWithRetry(fn, ctx, deps) {
216
- const {
217
- envelope,
218
- rawMessages,
219
- interceptors,
220
- dlq,
221
- retry,
222
- isBatch,
223
- retryTopics
224
- } = ctx;
225
- const maxAttempts = retryTopics ? 1 : retry ? retry.maxRetries + 1 : 1;
226
- const backoffMs = retry?.backoffMs ?? 1e3;
227
- const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
228
- const envelopes = Array.isArray(envelope) ? envelope : [envelope];
229
- const topic2 = envelopes[0]?.topic ?? "unknown";
230
- 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
283
- );
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
- });
313
- }
314
- } else {
315
- const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
316
- await sleep(Math.random() * cap);
317
- }
318
- }
319
- }
320
- }
321
-
322
- // src/client/subscribe-retry.ts
323
- async function subscribeWithRetry(consumer, topics, logger, retryOpts) {
324
- const maxAttempts = retryOpts?.retries ?? 5;
325
- const backoffMs = retryOpts?.backoffMs ?? 5e3;
326
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
327
- try {
328
- await consumer.subscribe({ topics });
329
- return;
330
- } catch (error) {
331
- if (attempt === maxAttempts) throw error;
332
- const msg = toError(error).message;
333
- logger.warn(
334
- `Failed to subscribe to [${topics.join(", ")}] (attempt ${attempt}/${maxAttempts}): ${msg}. Retrying in ${backoffMs}ms...`
335
- );
336
- await sleep(backoffMs);
337
- }
338
- }
339
- }
340
-
341
- // src/client/kafka.client.ts
342
- var { Kafka: KafkaClass, logLevel: KafkaLogLevel } = KafkaJS;
343
- var KafkaClient = class {
344
- kafka;
345
- producer;
346
- txProducer;
347
- consumers = /* @__PURE__ */ new Map();
348
- admin;
349
- logger;
350
- autoCreateTopicsEnabled;
351
- strictSchemasEnabled;
352
- numPartitions;
353
- ensuredTopics = /* @__PURE__ */ new Set();
354
- defaultGroupId;
355
- schemaRegistry = /* @__PURE__ */ new Map();
356
- runningConsumers = /* @__PURE__ */ new Map();
357
- instrumentation;
358
- onMessageLost;
359
- onRebalance;
360
- isAdminConnected = false;
361
- clientId;
362
- constructor(clientId, groupId, brokers, options) {
363
- this.clientId = clientId;
364
- this.defaultGroupId = groupId;
365
- this.logger = options?.logger ?? {
366
- log: (msg) => console.log(`[KafkaClient:${clientId}] ${msg}`),
367
- warn: (msg, ...args) => console.warn(`[KafkaClient:${clientId}] ${msg}`, ...args),
368
- error: (msg, ...args) => console.error(`[KafkaClient:${clientId}] ${msg}`, ...args)
369
- };
370
- this.autoCreateTopicsEnabled = options?.autoCreateTopics ?? false;
371
- this.strictSchemasEnabled = options?.strictSchemas ?? true;
372
- this.numPartitions = options?.numPartitions ?? 1;
373
- this.instrumentation = options?.instrumentation ?? [];
374
- this.onMessageLost = options?.onMessageLost;
375
- this.onRebalance = options?.onRebalance;
376
- this.kafka = new KafkaClass({
377
- kafkaJS: {
378
- clientId: this.clientId,
379
- brokers,
380
- logLevel: KafkaLogLevel.ERROR
381
- }
382
- });
383
- this.producer = this.kafka.producer({
384
- kafkaJS: {
385
- acks: -1
386
- }
387
- });
388
- this.admin = this.kafka.admin();
389
- }
390
- async sendMessage(topicOrDesc, message, options = {}) {
391
- const payload = await this.buildSendPayload(topicOrDesc, [
392
- {
393
- value: message,
394
- key: options.key,
395
- headers: options.headers,
396
- correlationId: options.correlationId,
397
- schemaVersion: options.schemaVersion,
398
- eventId: options.eventId
399
- }
400
- ]);
401
- await this.ensureTopic(payload.topic);
402
- await this.producer.send(payload);
403
- for (const inst of this.instrumentation) {
404
- inst.afterSend?.(payload.topic);
405
- }
406
- }
407
- async sendBatch(topicOrDesc, messages) {
408
- const payload = await this.buildSendPayload(topicOrDesc, messages);
409
- await this.ensureTopic(payload.topic);
410
- await this.producer.send(payload);
411
- for (const inst of this.instrumentation) {
412
- inst.afterSend?.(payload.topic);
413
- }
414
- }
415
- /** Execute multiple sends atomically. Commits on success, aborts on error. */
416
- async transaction(fn) {
417
- if (!this.txProducer) {
418
- this.txProducer = this.kafka.producer({
419
- kafkaJS: {
420
- acks: -1,
421
- idempotent: true,
422
- transactionalId: `${this.clientId}-tx`,
423
- maxInFlightRequests: 1
424
- }
425
- });
426
- await this.txProducer.connect();
427
- }
428
- const tx = await this.txProducer.transaction();
429
- try {
430
- const ctx = {
431
- send: async (topicOrDesc, message, options = {}) => {
432
- const payload = await this.buildSendPayload(topicOrDesc, [
433
- {
434
- value: message,
435
- key: options.key,
436
- headers: options.headers,
437
- correlationId: options.correlationId,
438
- schemaVersion: options.schemaVersion,
439
- eventId: options.eventId
440
- }
441
- ]);
442
- await this.ensureTopic(payload.topic);
443
- await tx.send(payload);
444
- },
445
- sendBatch: async (topicOrDesc, messages) => {
446
- const payload = await this.buildSendPayload(topicOrDesc, messages);
447
- await this.ensureTopic(payload.topic);
448
- await tx.send(payload);
449
- }
450
- };
451
- await fn(ctx);
452
- await tx.commit();
453
- } catch (error) {
454
- try {
455
- await tx.abort();
456
- } catch (abortError) {
457
- this.logger.error(
458
- "Failed to abort transaction:",
459
- toError(abortError).message
460
- );
461
- }
462
- throw error;
463
- }
464
- }
465
- // ── Producer lifecycle ───────────────────────────────────────────
466
- /** Connect the idempotent producer. Called automatically by `KafkaModule.register()`. */
467
- async connectProducer() {
468
- await this.producer.connect();
469
- this.logger.log("Producer connected");
470
- }
471
- async disconnectProducer() {
472
- await this.producer.disconnect();
473
- this.logger.log("Producer disconnected");
474
- }
475
- async startConsumer(topics, handleMessage, options = {}) {
476
- if (options.retryTopics && !options.retry) {
477
- throw new Error(
478
- "retryTopics requires retry to be configured \u2014 set retry.maxRetries to enable the retry topic chain"
479
- );
480
- }
481
- 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
- };
488
- const timeoutMs = options.handlerTimeoutMs;
489
- 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,
503
- schemaMap,
504
- interceptors,
505
- 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
- }
538
- });
539
- this.runningConsumers.set(gid, "eachMessage");
540
- if (options.retryTopics && retry) {
541
- await this.startRetryTopicConsumers(
542
- topicNames,
543
- gid,
544
- handleMessage,
545
- retry,
546
- dlq,
547
- interceptors,
548
- schemaMap
549
- );
550
- }
551
- return { groupId: gid, stop: () => this.stopConsumer(gid) };
552
- }
553
- async startBatchConsumer(topics, handleBatch, options = {}) {
554
- 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
- };
561
- const timeoutMs = options.handlerTimeoutMs;
562
- 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
- }
627
- });
628
- this.runningConsumers.set(gid, "eachBatch");
629
- return { groupId: gid, stop: () => this.stopConsumer(gid) };
630
- }
631
- // ── Consumer lifecycle ───────────────────────────────────────────
632
- async stopConsumer(groupId) {
633
- if (groupId !== void 0) {
634
- const consumer = this.consumers.get(groupId);
635
- if (!consumer) {
636
- this.logger.warn(
637
- `stopConsumer: no active consumer for group "${groupId}"`
638
- );
639
- return;
640
- }
641
- await consumer.disconnect().catch(() => {
642
- });
643
- this.consumers.delete(groupId);
644
- this.runningConsumers.delete(groupId);
645
- this.logger.log(`Consumer disconnected: group "${groupId}"`);
646
- } else {
647
- const tasks = Array.from(this.consumers.values()).map(
648
- (c) => c.disconnect().catch(() => {
649
- })
650
- );
651
- await Promise.allSettled(tasks);
652
- this.consumers.clear();
653
- this.runningConsumers.clear();
654
- this.logger.log("All consumers disconnected");
655
- }
656
- }
657
- /**
658
- * Query consumer group lag per partition.
659
- * Lag = broker high-watermark − last committed offset.
660
- * A committed offset of -1 (nothing committed yet) counts as full lag.
661
- */
662
- async getConsumerLag(groupId) {
663
- const gid = groupId ?? this.defaultGroupId;
664
- if (!this.isAdminConnected) {
665
- await this.admin.connect();
666
- this.isAdminConnected = true;
667
- }
668
- const committedByTopic = await this.admin.fetchOffsets({ groupId: gid });
669
- const result = [];
670
- for (const { topic: topic2, partitions } of committedByTopic) {
671
- const brokerOffsets = await this.admin.fetchTopicOffsets(topic2);
672
- for (const { partition, offset } of partitions) {
673
- const broker = brokerOffsets.find((o) => o.partition === partition);
674
- if (!broker) continue;
675
- const committed = parseInt(offset, 10);
676
- const high = parseInt(broker.high, 10);
677
- const lag = committed === -1 ? high : Math.max(0, high - committed);
678
- result.push({ topic: topic2, partition, lag });
679
- }
680
- }
681
- return result;
682
- }
683
- /** Check broker connectivity and return status, clientId, and available topics. */
684
- async checkStatus() {
685
- if (!this.isAdminConnected) {
686
- await this.admin.connect();
687
- this.isAdminConnected = true;
688
- }
689
- const topics = await this.admin.listTopics();
690
- return { status: "up", clientId: this.clientId, topics };
691
- }
692
- getClientId() {
693
- return this.clientId;
694
- }
695
- /** Gracefully disconnect producer, all consumers, and admin. */
696
- async disconnect() {
697
- const tasks = [this.producer.disconnect()];
698
- if (this.txProducer) {
699
- tasks.push(this.txProducer.disconnect());
700
- this.txProducer = void 0;
701
- }
702
- for (const consumer of this.consumers.values()) {
703
- tasks.push(consumer.disconnect());
704
- }
705
- if (this.isAdminConnected) {
706
- tasks.push(this.admin.disconnect());
707
- this.isAdminConnected = false;
708
- }
709
- await Promise.allSettled(tasks);
710
- this.consumers.clear();
711
- this.runningConsumers.clear();
712
- this.logger.log("All connections closed");
713
- }
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
- // ── 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`
881
- );
882
- }
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
- };
906
- }
907
- this.consumers.set(groupId, this.kafka.consumer(config));
908
- }
909
- return this.consumers.get(groupId);
910
- }
911
- /**
912
- * Start a timer that logs a warning if `fn` hasn't resolved within `timeoutMs`.
913
- * The handler itself is not cancelled — the warning is diagnostic only.
914
- */
915
- wrapWithTimeoutWarning(fn, timeoutMs, topic2) {
916
- let timer;
917
- const promise = fn().finally(() => {
918
- if (timer !== void 0) clearTimeout(timer);
919
- });
920
- timer = setTimeout(() => {
921
- this.logger.warn(
922
- `Handler for topic "${topic2}" has not resolved after ${timeoutMs}ms \u2014 possible stuck handler`
923
- );
924
- }, timeoutMs);
925
- return promise;
926
- }
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
- async ensureTopic(topic2) {
935
- if (!this.autoCreateTopicsEnabled || this.ensuredTopics.has(topic2)) return;
936
- if (!this.isAdminConnected) {
937
- await this.admin.connect();
938
- this.isAdminConnected = true;
939
- }
940
- await this.admin.createTopics({
941
- topics: [{ topic: topic2, numPartitions: this.numPartitions }]
942
- });
943
- this.ensuredTopics.add(topic2);
944
- }
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
- /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
994
- async setupConsumer(topics, mode, options) {
995
- const {
996
- groupId: optGroupId,
997
- fromBeginning = false,
998
- retry,
999
- dlq = false,
1000
- interceptors = [],
1001
- schemas: optionSchemas
1002
- } = options;
1003
- const gid = optGroupId || this.defaultGroupId;
1004
- const existingMode = this.runningConsumers.get(gid);
1005
- const oppositeMode = mode === "eachMessage" ? "eachBatch" : "eachMessage";
1006
- if (existingMode === oppositeMode) {
1007
- throw new Error(
1008
- `Cannot use ${mode} on consumer group "${gid}" \u2014 it is already running with ${oppositeMode}. Use a different groupId for this consumer.`
1009
- );
1010
- }
1011
- const consumer = this.getOrCreateConsumer(
1012
- gid,
1013
- fromBeginning,
1014
- options.autoCommit ?? true
1015
- );
1016
- const schemaMap = this.buildSchemaMap(topics, optionSchemas);
1017
- const topicNames = topics.map(
1018
- (t) => this.resolveTopicName(t)
1019
- );
1020
- for (const t of topicNames) {
1021
- await this.ensureTopic(t);
1022
- }
1023
- if (dlq) {
1024
- for (const t of topicNames) {
1025
- await this.ensureTopic(`${t}.dlq`);
1026
- }
1027
- }
1028
- await consumer.connect();
1029
- await subscribeWithRetry(
1030
- consumer,
1031
- topicNames,
1032
- this.logger,
1033
- options.subscribeRetry
1034
- );
1035
- this.logger.log(
1036
- `${mode === "eachBatch" ? "Batch consumer" : "Consumer"} subscribed to topics: ${topicNames.join(", ")}`
1037
- );
1038
- return { consumer, schemaMap, topicNames, gid, dlq, interceptors, retry };
1039
- }
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;
1056
- }
1057
- };
1058
-
1059
- // src/client/topic.ts
1060
- function topic(name) {
1061
- const fn = () => ({
1062
- __topic: name,
1063
- __type: void 0
1064
- });
1065
- fn.schema = (schema) => ({
1066
- __topic: name,
1067
- __type: void 0,
1068
- __schema: schema
1069
- });
1070
- return fn;
1071
- }
1072
-
1073
- export {
1074
- HEADER_EVENT_ID,
1075
- HEADER_CORRELATION_ID,
1076
- HEADER_TIMESTAMP,
1077
- HEADER_SCHEMA_VERSION,
1078
- HEADER_TRACEPARENT,
1079
- getEnvelopeContext,
1080
- runWithEnvelopeContext,
1081
- buildEnvelopeHeaders,
1082
- decodeHeaders,
1083
- extractEnvelope,
1084
- KafkaProcessingError,
1085
- KafkaValidationError,
1086
- KafkaRetryExhaustedError,
1087
- KafkaClient,
1088
- topic
1089
- };
1090
- //# sourceMappingURL=chunk-Z3O5GTS7.mjs.map