@drarzter/kafka-client 0.5.0 → 0.5.2

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.
@@ -9,7 +9,7 @@
9
9
  * ```
10
10
  */
11
11
  interface SchemaLike<T = any> {
12
- parse(data: unknown): T;
12
+ parse(data: unknown): T | Promise<T>;
13
13
  }
14
14
  /** Infer the output type from a SchemaLike. */
15
15
  type InferSchema<S extends SchemaLike> = S extends SchemaLike<infer T> ? T : never;
@@ -158,8 +158,10 @@ interface ConsumerOptions<T extends TopicMapConstraint<T> = TTopicMessageMap> {
158
158
  interface RetryOptions {
159
159
  /** Maximum number of retry attempts before giving up. */
160
160
  maxRetries: number;
161
- /** Base delay between retries in ms (multiplied by attempt number). Default: `1000`. */
161
+ /** Base delay for exponential backoff in ms. Default: `1000`. */
162
162
  backoffMs?: number;
163
+ /** Maximum delay cap for exponential backoff in ms. Default: `30000`. */
164
+ maxBackoffMs?: number;
163
165
  }
164
166
  /**
165
167
  * Interceptor hooks for consumer message processing.
@@ -202,6 +204,8 @@ interface TransactionContext<T extends TopicMapConstraint<T>> {
202
204
  /** Interface describing all public methods of the Kafka client. */
203
205
  interface IKafkaClient<T extends TopicMapConstraint<T>> {
204
206
  checkStatus(): Promise<{
207
+ status: 'up';
208
+ clientId: string;
205
209
  topics: string[];
206
210
  }>;
207
211
  startConsumer<K extends Array<keyof T>>(topics: K, handleMessage: (envelope: EventEnvelope<T[K[number]]>) => Promise<void>, options?: ConsumerOptions<T>): Promise<void>;
@@ -224,6 +228,20 @@ interface KafkaLogger {
224
228
  warn(message: string, ...args: any[]): void;
225
229
  error(message: string, ...args: any[]): void;
226
230
  }
231
+ /**
232
+ * Context passed to `onMessageLost` when a message is silently dropped
233
+ * (handler threw and `dlq` is not enabled).
234
+ */
235
+ interface MessageLostContext {
236
+ /** Topic the message was consumed from. */
237
+ topic: string;
238
+ /** Error that caused the message to be dropped. */
239
+ error: Error;
240
+ /** Number of processing attempts (0 = validation failure, before handler ran). */
241
+ attempt: number;
242
+ /** Original Kafka message headers (correlationId, traceparent, etc.). */
243
+ headers: MessageHeaders;
244
+ }
227
245
  /** Options for `KafkaClient` constructor. */
228
246
  interface KafkaClientOptions {
229
247
  /** Auto-create topics via admin before the first `sendMessage`, `sendBatch`, or `transaction` for each topic. Useful for development — not recommended in production. */
@@ -236,6 +254,12 @@ interface KafkaClientOptions {
236
254
  numPartitions?: number;
237
255
  /** Client-wide instrumentation hooks (e.g. OTel). Applied to both send and consume paths. */
238
256
  instrumentation?: KafkaInstrumentation[];
257
+ /**
258
+ * Called when a message is dropped without being sent to a DLQ.
259
+ * Fires when the handler throws after all retries, or schema validation fails — and `dlq` is not enabled.
260
+ * Use this to alert, log to external systems, or trigger fallback logic.
261
+ */
262
+ onMessageLost?: (ctx: MessageLostContext) => void | Promise<void>;
239
263
  }
240
264
  /** Options for consumer subscribe retry when topic doesn't exist yet. */
241
265
  interface SubscribeRetryOptions {
@@ -316,4 +340,4 @@ declare function decodeHeaders(raw: Record<string, Buffer | string | (Buffer | s
316
340
  */
317
341
  declare function extractEnvelope<T>(payload: T, headers: MessageHeaders, topic: string, partition: number, offset: string): EventEnvelope<T>;
318
342
 
319
- export { type BatchMessageItem as B, type ClientId as C, type EnvelopeHeaderOptions as E, type GroupId as G, HEADER_CORRELATION_ID as H, type IKafkaClient as I, type KafkaInstrumentation as K, type MessageHeaders as M, type RetryOptions as R, type SchemaLike as S, type TopicMapConstraint as T, type ConsumerOptions as a, type TopicDescriptor as b, type BatchMeta as c, type ConsumerInterceptor as d, type EventEnvelope as e, HEADER_EVENT_ID as f, HEADER_SCHEMA_VERSION as g, HEADER_TIMESTAMP as h, HEADER_TRACEPARENT as i, type InferSchema as j, type KafkaClientOptions as k, type KafkaLogger as l, type SendOptions as m, type SubscribeRetryOptions as n, type TTopicMessageMap as o, type TopicsFrom as p, type TransactionContext as q, buildEnvelopeHeaders as r, decodeHeaders as s, extractEnvelope as t, getEnvelopeContext as u, runWithEnvelopeContext as v, topic as w };
343
+ export { type BatchMessageItem as B, type ClientId as C, type EnvelopeHeaderOptions as E, type GroupId as G, HEADER_CORRELATION_ID as H, type IKafkaClient as I, type KafkaInstrumentation as K, type MessageHeaders as M, type RetryOptions as R, type SchemaLike as S, type TopicMapConstraint as T, type ConsumerOptions as a, type TopicDescriptor as b, type BatchMeta as c, type ConsumerInterceptor as d, type EventEnvelope as e, HEADER_EVENT_ID as f, HEADER_SCHEMA_VERSION as g, HEADER_TIMESTAMP as h, HEADER_TRACEPARENT as i, type InferSchema as j, type KafkaClientOptions as k, type KafkaLogger as l, type MessageLostContext as m, type SendOptions as n, type SubscribeRetryOptions as o, type TTopicMessageMap as p, type TopicsFrom as q, type TransactionContext as r, buildEnvelopeHeaders as s, decodeHeaders as t, extractEnvelope as u, getEnvelopeContext as v, runWithEnvelopeContext as w, topic as x };
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { KafkaClient } from './core.mjs';
2
2
  export { KafkaProcessingError, KafkaRetryExhaustedError, KafkaValidationError } from './core.mjs';
3
- import { T as TopicMapConstraint, C as ClientId, G as GroupId, K as KafkaInstrumentation, S as SchemaLike, a as ConsumerOptions, b as TopicDescriptor } from './envelope-QK1trQu4.mjs';
4
- export { B as BatchMessageItem, c as BatchMeta, d as ConsumerInterceptor, E as EnvelopeHeaderOptions, e as EventEnvelope, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, I as IKafkaClient, j as InferSchema, k as KafkaClientOptions, l as KafkaLogger, M as MessageHeaders, R as RetryOptions, m as SendOptions, n as SubscribeRetryOptions, o as TTopicMessageMap, p as TopicsFrom, q as TransactionContext, r as buildEnvelopeHeaders, s as decodeHeaders, t as extractEnvelope, u as getEnvelopeContext, v as runWithEnvelopeContext, w as topic } from './envelope-QK1trQu4.mjs';
3
+ import { T as TopicMapConstraint, C as ClientId, G as GroupId, K as KafkaInstrumentation, S as SchemaLike, a as ConsumerOptions, b as TopicDescriptor } from './envelope-C66_h8r_.mjs';
4
+ export { B as BatchMessageItem, c as BatchMeta, d as ConsumerInterceptor, E as EnvelopeHeaderOptions, e as EventEnvelope, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, I as IKafkaClient, j as InferSchema, k as KafkaClientOptions, l as KafkaLogger, M as MessageHeaders, m as MessageLostContext, R as RetryOptions, n as SendOptions, o as SubscribeRetryOptions, p as TTopicMessageMap, q as TopicsFrom, r as TransactionContext, s as buildEnvelopeHeaders, t as decodeHeaders, u as extractEnvelope, v as getEnvelopeContext, w as runWithEnvelopeContext, x as topic } from './envelope-C66_h8r_.mjs';
5
5
  import { DynamicModule, OnModuleInit } from '@nestjs/common';
6
6
  import { DiscoveryService, ModuleRef } from '@nestjs/core';
7
7
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { KafkaClient } from './core.js';
2
2
  export { KafkaProcessingError, KafkaRetryExhaustedError, KafkaValidationError } from './core.js';
3
- import { T as TopicMapConstraint, C as ClientId, G as GroupId, K as KafkaInstrumentation, S as SchemaLike, a as ConsumerOptions, b as TopicDescriptor } from './envelope-QK1trQu4.js';
4
- export { B as BatchMessageItem, c as BatchMeta, d as ConsumerInterceptor, E as EnvelopeHeaderOptions, e as EventEnvelope, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, I as IKafkaClient, j as InferSchema, k as KafkaClientOptions, l as KafkaLogger, M as MessageHeaders, R as RetryOptions, m as SendOptions, n as SubscribeRetryOptions, o as TTopicMessageMap, p as TopicsFrom, q as TransactionContext, r as buildEnvelopeHeaders, s as decodeHeaders, t as extractEnvelope, u as getEnvelopeContext, v as runWithEnvelopeContext, w as topic } from './envelope-QK1trQu4.js';
3
+ import { T as TopicMapConstraint, C as ClientId, G as GroupId, K as KafkaInstrumentation, S as SchemaLike, a as ConsumerOptions, b as TopicDescriptor } from './envelope-C66_h8r_.js';
4
+ export { B as BatchMessageItem, c as BatchMeta, d as ConsumerInterceptor, E as EnvelopeHeaderOptions, e as EventEnvelope, H as HEADER_CORRELATION_ID, f as HEADER_EVENT_ID, g as HEADER_SCHEMA_VERSION, h as HEADER_TIMESTAMP, i as HEADER_TRACEPARENT, I as IKafkaClient, j as InferSchema, k as KafkaClientOptions, l as KafkaLogger, M as MessageHeaders, m as MessageLostContext, R as RetryOptions, n as SendOptions, o as SubscribeRetryOptions, p as TTopicMessageMap, q as TopicsFrom, r as TransactionContext, s as buildEnvelopeHeaders, t as decodeHeaders, u as extractEnvelope, v as getEnvelopeContext, w as runWithEnvelopeContext, x as topic } from './envelope-C66_h8r_.js';
5
5
  import { DynamicModule, OnModuleInit } from '@nestjs/common';
6
6
  import { DiscoveryService, ModuleRef } from '@nestjs/core';
7
7
 
package/dist/index.js CHANGED
@@ -172,7 +172,7 @@ async function validateWithSchema(message, raw, topic2, schemaMap, interceptors,
172
172
  const schema = schemaMap.get(topic2);
173
173
  if (!schema) return message;
174
174
  try {
175
- return schema.parse(message);
175
+ return await schema.parse(message);
176
176
  } catch (error) {
177
177
  const err = toError(error);
178
178
  const validationError = new KafkaValidationError(topic2, message, {
@@ -182,20 +182,36 @@ async function validateWithSchema(message, raw, topic2, schemaMap, interceptors,
182
182
  `Schema validation failed for topic ${topic2}:`,
183
183
  err.message
184
184
  );
185
- if (dlq) await sendToDlq(topic2, raw, deps);
186
- const errorEnvelope = extractEnvelope(message, {}, topic2, -1, "");
185
+ if (dlq) {
186
+ await sendToDlq(topic2, raw, deps, {
187
+ error: validationError,
188
+ attempt: 0,
189
+ originalHeaders: deps.originalHeaders
190
+ });
191
+ } else {
192
+ await deps.onMessageLost?.({ topic: topic2, error: validationError, attempt: 0, headers: deps.originalHeaders ?? {} });
193
+ }
194
+ const errorEnvelope = extractEnvelope(message, deps.originalHeaders ?? {}, topic2, -1, "");
187
195
  for (const interceptor of interceptors) {
188
196
  await interceptor.onError?.(errorEnvelope, validationError);
189
197
  }
190
198
  return null;
191
199
  }
192
200
  }
193
- async function sendToDlq(topic2, rawMessage, deps) {
201
+ async function sendToDlq(topic2, rawMessage, deps, meta) {
194
202
  const dlqTopic = `${topic2}.dlq`;
203
+ const headers = {
204
+ ...meta?.originalHeaders ?? {},
205
+ "x-dlq-original-topic": topic2,
206
+ "x-dlq-failed-at": (/* @__PURE__ */ new Date()).toISOString(),
207
+ "x-dlq-error-message": meta?.error.message ?? "unknown",
208
+ "x-dlq-error-stack": meta?.error.stack?.slice(0, 2e3) ?? "",
209
+ "x-dlq-attempt-count": String(meta?.attempt ?? 0)
210
+ };
195
211
  try {
196
212
  await deps.producer.send({
197
213
  topic: dlqTopic,
198
- messages: [{ value: rawMessage }]
214
+ messages: [{ value: rawMessage, headers }]
199
215
  });
200
216
  deps.logger.warn(`Message sent to DLQ: ${dlqTopic}`);
201
217
  } catch (error) {
@@ -209,6 +225,7 @@ async function executeWithRetry(fn, ctx, deps) {
209
225
  const { envelope, rawMessages, interceptors, dlq, retry, isBatch } = ctx;
210
226
  const maxAttempts = retry ? retry.maxRetries + 1 : 1;
211
227
  const backoffMs = retry?.backoffMs ?? 1e3;
228
+ const maxBackoffMs = retry?.maxBackoffMs ?? 3e4;
212
229
  const envelopes = Array.isArray(envelope) ? envelope : [envelope];
213
230
  const topic2 = envelopes[0]?.topic ?? "unknown";
214
231
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -267,12 +284,25 @@ async function executeWithRetry(fn, ctx, deps) {
267
284
  );
268
285
  if (isLastAttempt) {
269
286
  if (dlq) {
287
+ const dlqMeta = {
288
+ error: err,
289
+ attempt,
290
+ originalHeaders: envelopes[0]?.headers
291
+ };
270
292
  for (const raw of rawMessages) {
271
- await sendToDlq(topic2, raw, deps);
293
+ await sendToDlq(topic2, raw, deps, dlqMeta);
272
294
  }
295
+ } else {
296
+ await deps.onMessageLost?.({
297
+ topic: topic2,
298
+ error: err,
299
+ attempt,
300
+ headers: envelopes[0]?.headers ?? {}
301
+ });
273
302
  }
274
303
  } else {
275
- await sleep(backoffMs * attempt);
304
+ const cap = Math.min(backoffMs * 2 ** (attempt - 1), maxBackoffMs);
305
+ await sleep(Math.random() * cap);
276
306
  }
277
307
  }
278
308
  }
@@ -314,6 +344,7 @@ var KafkaClient = class {
314
344
  schemaRegistry = /* @__PURE__ */ new Map();
315
345
  runningConsumers = /* @__PURE__ */ new Map();
316
346
  instrumentation;
347
+ onMessageLost;
317
348
  isAdminConnected = false;
318
349
  clientId;
319
350
  constructor(clientId, groupId, brokers, options) {
@@ -328,6 +359,7 @@ var KafkaClient = class {
328
359
  this.strictSchemasEnabled = options?.strictSchemas ?? true;
329
360
  this.numPartitions = options?.numPartitions ?? 1;
330
361
  this.instrumentation = options?.instrumentation ?? [];
362
+ this.onMessageLost = options?.onMessageLost;
331
363
  this.kafka = new KafkaClass({
332
364
  kafkaJS: {
333
365
  clientId: this.clientId,
@@ -343,7 +375,7 @@ var KafkaClient = class {
343
375
  this.admin = this.kafka.admin();
344
376
  }
345
377
  async sendMessage(topicOrDesc, message, options = {}) {
346
- const payload = this.buildSendPayload(topicOrDesc, [
378
+ const payload = await this.buildSendPayload(topicOrDesc, [
347
379
  {
348
380
  value: message,
349
381
  key: options.key,
@@ -360,7 +392,7 @@ var KafkaClient = class {
360
392
  }
361
393
  }
362
394
  async sendBatch(topicOrDesc, messages) {
363
- const payload = this.buildSendPayload(topicOrDesc, messages);
395
+ const payload = await this.buildSendPayload(topicOrDesc, messages);
364
396
  await this.ensureTopic(payload.topic);
365
397
  await this.producer.send(payload);
366
398
  for (const inst of this.instrumentation) {
@@ -384,7 +416,7 @@ var KafkaClient = class {
384
416
  try {
385
417
  const ctx = {
386
418
  send: async (topicOrDesc, message, options = {}) => {
387
- const payload = this.buildSendPayload(topicOrDesc, [
419
+ const payload = await this.buildSendPayload(topicOrDesc, [
388
420
  {
389
421
  value: message,
390
422
  key: options.key,
@@ -398,7 +430,7 @@ var KafkaClient = class {
398
430
  await tx.send(payload);
399
431
  },
400
432
  sendBatch: async (topicOrDesc, messages) => {
401
- const payload = this.buildSendPayload(topicOrDesc, messages);
433
+ const payload = await this.buildSendPayload(topicOrDesc, messages);
402
434
  await this.ensureTopic(payload.topic);
403
435
  await tx.send(payload);
404
436
  }
@@ -429,7 +461,7 @@ var KafkaClient = class {
429
461
  }
430
462
  async startConsumer(topics, handleMessage, options = {}) {
431
463
  const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachMessage", options);
432
- const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation };
464
+ const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
433
465
  await consumer.run({
434
466
  eachMessage: async ({ topic: topic2, partition, message }) => {
435
467
  if (!message.value) {
@@ -439,6 +471,7 @@ var KafkaClient = class {
439
471
  const raw = message.value.toString();
440
472
  const parsed = parseJsonMessage(raw, topic2, this.logger);
441
473
  if (parsed === null) return;
474
+ const headers = decodeHeaders(message.headers);
442
475
  const validated = await validateWithSchema(
443
476
  parsed,
444
477
  raw,
@@ -446,10 +479,9 @@ var KafkaClient = class {
446
479
  schemaMap,
447
480
  interceptors,
448
481
  dlq,
449
- deps
482
+ { ...deps, originalHeaders: headers }
450
483
  );
451
484
  if (validated === null) return;
452
- const headers = decodeHeaders(message.headers);
453
485
  const envelope = extractEnvelope(
454
486
  validated,
455
487
  headers,
@@ -471,7 +503,7 @@ var KafkaClient = class {
471
503
  }
472
504
  async startBatchConsumer(topics, handleBatch, options = {}) {
473
505
  const { consumer, schemaMap, gid, dlq, interceptors, retry } = await this.setupConsumer(topics, "eachBatch", options);
474
- const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation };
506
+ const deps = { logger: this.logger, producer: this.producer, instrumentation: this.instrumentation, onMessageLost: this.onMessageLost };
475
507
  await consumer.run({
476
508
  eachBatch: async ({
477
509
  batch,
@@ -491,6 +523,7 @@ var KafkaClient = class {
491
523
  const raw = message.value.toString();
492
524
  const parsed = parseJsonMessage(raw, batch.topic, this.logger);
493
525
  if (parsed === null) continue;
526
+ const headers = decodeHeaders(message.headers);
494
527
  const validated = await validateWithSchema(
495
528
  parsed,
496
529
  raw,
@@ -498,10 +531,9 @@ var KafkaClient = class {
498
531
  schemaMap,
499
532
  interceptors,
500
533
  dlq,
501
- deps
534
+ { ...deps, originalHeaders: headers }
502
535
  );
503
536
  if (validated === null) continue;
504
- const headers = decodeHeaders(message.headers);
505
537
  envelopes.push(
506
538
  extractEnvelope(validated, headers, batch.topic, batch.partition, message.offset)
507
539
  );
@@ -542,14 +574,14 @@ var KafkaClient = class {
542
574
  this.runningConsumers.clear();
543
575
  this.logger.log("All consumers disconnected");
544
576
  }
545
- /** Check broker connectivity and return available topics. */
577
+ /** Check broker connectivity and return status, clientId, and available topics. */
546
578
  async checkStatus() {
547
579
  if (!this.isAdminConnected) {
548
580
  await this.admin.connect();
549
581
  this.isAdminConnected = true;
550
582
  }
551
583
  const topics = await this.admin.listTopics();
552
- return { topics };
584
+ return { status: "up", clientId: this.clientId, topics };
553
585
  }
554
586
  getClientId() {
555
587
  return this.clientId;
@@ -611,13 +643,13 @@ var KafkaClient = class {
611
643
  }
612
644
  }
613
645
  /** Validate message against schema. Pure — no side-effects on registry. */
614
- validateMessage(topicOrDesc, message) {
646
+ async validateMessage(topicOrDesc, message) {
615
647
  if (topicOrDesc?.__schema) {
616
- return topicOrDesc.__schema.parse(message);
648
+ return await topicOrDesc.__schema.parse(message);
617
649
  }
618
650
  if (this.strictSchemasEnabled && typeof topicOrDesc === "string") {
619
651
  const schema = this.schemaRegistry.get(topicOrDesc);
620
- if (schema) return schema.parse(message);
652
+ if (schema) return await schema.parse(message);
621
653
  }
622
654
  return message;
623
655
  }
@@ -626,12 +658,11 @@ var KafkaClient = class {
626
658
  * Handles: topic resolution, schema registration, validation, JSON serialization,
627
659
  * envelope header generation, and instrumentation hooks.
628
660
  */
629
- buildSendPayload(topicOrDesc, messages) {
661
+ async buildSendPayload(topicOrDesc, messages) {
630
662
  this.registerSchema(topicOrDesc);
631
663
  const topic2 = this.resolveTopicName(topicOrDesc);
632
- return {
633
- topic: topic2,
634
- messages: messages.map((m) => {
664
+ const builtMessages = await Promise.all(
665
+ messages.map(async (m) => {
635
666
  const envelopeHeaders = buildEnvelopeHeaders({
636
667
  correlationId: m.correlationId,
637
668
  schemaVersion: m.schemaVersion,
@@ -642,12 +673,13 @@ var KafkaClient = class {
642
673
  inst.beforeSend?.(topic2, envelopeHeaders);
643
674
  }
644
675
  return {
645
- value: JSON.stringify(this.validateMessage(topicOrDesc, m.value)),
676
+ value: JSON.stringify(await this.validateMessage(topicOrDesc, m.value)),
646
677
  key: m.key ?? null,
647
678
  headers: envelopeHeaders
648
679
  };
649
680
  })
650
- };
681
+ );
682
+ return { topic: topic2, messages: builtMessages };
651
683
  }
652
684
  /** Shared consumer setup: groupId check, schema map, connect, subscribe. */
653
685
  async setupConsumer(topics, mode, options) {
@@ -917,12 +949,7 @@ var import_common4 = require("@nestjs/common");
917
949
  var KafkaHealthIndicator = class {
918
950
  async check(client) {
919
951
  try {
920
- const { topics } = await client.checkStatus();
921
- return {
922
- status: "up",
923
- clientId: client.clientId,
924
- topics
925
- };
952
+ return await client.checkStatus();
926
953
  } catch (error) {
927
954
  return {
928
955
  status: "down",