@relaybus/kafka 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { RelayBusError, NonRetryableError, assertEventType, ConfigurationError, systemClock, toErrorInfo, toMilliseconds } from '@relaybus/core';
2
- import { createCloudEventReaders, mediaTypeOf, isJsonObject, createJsonEnvelopeFormat, schedule, assertPositiveInteger, writeStructuredCloudEvent } from '@relaybus/core/internal';
1
+ import { RelayBusError, NonRetryableError, assertEventType, toErrorInfo, systemClock, ConfigurationError } from '@relaybus/core';
2
+ import { createCloudEventReaders, DecodedEventReader, mediaTypeOf, isJsonObject, observeRejection, createJsonEnvelopeFormat, SubscribingTransport, writeStructuredCloudEvent, schedule, BoundedInteger, PositiveInteger, ExponentialBackoff, Milliseconds } from '@relaybus/core/internal';
3
3
  export { fromHeader, fromPayloadField } from '@relaybus/core/internal';
4
4
 
5
5
  // src/errors.ts
@@ -311,25 +311,17 @@ function plainJsonDecoder(options) {
311
311
  if (record.headers?.[SPEC_VERSION_HEADER] !== void 0 || mediaTypeOf(headers[CONTENT_TYPE]) === STRUCTURED_MEDIA_TYPE2) {
312
312
  return decodeCloudEvent(record);
313
313
  }
314
- const plain = {
315
- topic: record.topic,
316
- partition: record.partition,
317
- offset: record.offset,
318
- key: readKeyLeniently(record.key),
319
- headers,
320
- payload: parsePayload(record.value),
321
- timestamp: record.timestamp
322
- };
323
- const type = resolveText(options.type, { record: plain, name: "type" });
314
+ const plain = plainRecordOf(record, headers);
315
+ const type = KAFKA_EVENTS.text(options.type, { plain, name: "type" });
324
316
  if (type === void 0) {
325
317
  throw invalid("Record has no event type: the type option found none");
326
318
  }
327
319
  assertValidType(type, "The event type");
328
320
  return {
329
- id: resolveText(options.id, { record: plain, name: "id" }) ?? `kafka:${record.topic}:${record.partition}:${record.offset}`,
321
+ id: KAFKA_EVENTS.text(options.id, { plain, name: "id" }) ?? `kafka:${record.topic}:${record.partition}:${record.offset}`,
330
322
  type,
331
- source: resolveText(options.source, { record: plain, name: "source" }) ?? record.topic,
332
- key: options.key === void 0 ? plain.key : resolveText(options.key, { record: plain, name: "key" }) ?? null,
323
+ source: KAFKA_EVENTS.text(options.source, { plain, name: "source" }) ?? record.topic,
324
+ key: options.key === void 0 ? plain.key : KAFKA_EVENTS.text(options.key, { plain, name: "key" }) ?? null,
333
325
  payload: plain.payload,
334
326
  headers,
335
327
  correlationId: null,
@@ -338,27 +330,38 @@ function plainJsonDecoder(options) {
338
330
  };
339
331
  };
340
332
  }
341
- function completeDecodedEvent(decoded, record) {
342
- if (typeof decoded !== "object" || decoded === null) {
343
- throw invalid("The decoder must return an event object synchronously");
344
- }
345
- if ("then" in decoded) {
346
- observeRejection(decoded);
347
- throw invalid("The decoder must return an event object synchronously");
348
- }
349
- const event = decoded;
333
+ function plainRecordOf(record, headers) {
350
334
  return {
351
- id: event.id,
352
- type: event.type,
353
- source: event.source ?? record.topic,
354
- key: event.key === void 0 ? readRecordKey(record.key) : event.key,
355
- payload: event.payload,
356
- headers: event.headers ?? {},
357
- correlationId: event.correlationId ?? null,
358
- causationId: event.causationId ?? null,
359
- occurredAt: event.occurredAt ?? requireRecordTimestamp(record)
335
+ topic: record.topic,
336
+ partition: record.partition,
337
+ offset: record.offset,
338
+ key: readKeyLeniently(record.key),
339
+ headers,
340
+ payload: parsePayload(record.value),
341
+ timestamp: record.timestamp
360
342
  };
361
343
  }
344
+ var KafkaDecodedEvents = class extends DecodedEventReader {
345
+ invalid(reason) {
346
+ return invalid(reason);
347
+ }
348
+ /** The topic. */
349
+ defaultSource(record) {
350
+ return record.topic;
351
+ }
352
+ /** The record key as UTF-8 text. */
353
+ defaultKey(record) {
354
+ return readRecordKey(record.key);
355
+ }
356
+ /** The record timestamp; a record without a usable one is malformed. */
357
+ defaultTime(record) {
358
+ return requireRecordTimestamp(record);
359
+ }
360
+ };
361
+ var KAFKA_EVENTS = new KafkaDecodedEvents();
362
+ function completeDecodedEvent(decoded, record) {
363
+ return KAFKA_EVENTS.complete(decoded, record);
364
+ }
362
365
  function extensionAttributes(body) {
363
366
  return Object.fromEntries(
364
367
  Object.entries(body).filter(
@@ -404,17 +407,6 @@ function readKeyLeniently(key) {
404
407
  return Buffer.from(key).toString("base64");
405
408
  }
406
409
  }
407
- function resolveText(option, { record, name }) {
408
- const value = typeof option === "function" ? option(record) : option;
409
- if (value === void 0 || value === null) {
410
- return void 0;
411
- }
412
- if (typeof value !== "string") {
413
- observeRejection(value);
414
- throw invalid(`The ${name} option returned ${typeof value}, expected a string`);
415
- }
416
- return value.length === 0 ? void 0 : value;
417
- }
418
410
  function resolveOccurredAt(option, { plain, record }) {
419
411
  const value = option?.(plain);
420
412
  if (value === void 0) {
@@ -426,11 +418,6 @@ function resolveOccurredAt(option, { plain, record }) {
426
418
  }
427
419
  return value;
428
420
  }
429
- function observeRejection(value) {
430
- if (typeof value === "object" && value !== null && "then" in value) {
431
- Promise.resolve(value).catch(() => void 0);
432
- }
433
- }
434
421
 
435
422
  // src/codecs.ts
436
423
  var cloudEventsBinary = Object.freeze({
@@ -453,32 +440,34 @@ function jsonEnvelope(mapping) {
453
440
  const stampsTime = mapping.occurredAt === void 0;
454
441
  const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? { [CONTENT_TYPE]: contentType, [EVENT_ID_HEADER]: envelope.id } : { [CONTENT_TYPE]: contentType };
455
442
  return {
456
- encode: (envelope) => {
457
- assertRepresentable(envelope);
458
- const { body, headers } = format.write(envelope);
459
- const taken = [CONTENT_TYPE, EVENT_ID_HEADER].find((name) => Object.hasOwn(headers, name));
460
- if (taken !== void 0) {
461
- throw new UnencodableEventError(
462
- `Event ${envelope.id} has a header '${taken}', which the codec writes itself`
463
- );
464
- }
465
- return {
466
- key: envelope.key,
467
- value: serializeJson(envelope, body),
468
- headers: { ...headers, ...ownHeaders(envelope) },
469
- ...stampsTime ? { timestamp: recordTimestamp(envelope) } : {}
470
- };
471
- },
472
- decode: (record) => {
473
- const { [EVENT_ID_HEADER]: eventId, ...headers } = withoutContentType(
474
- readHeadersLeniently(record.headers)
475
- );
476
- const fields = format.read({ body: parsePayload(record.value), headers });
477
- return {
478
- ...fields,
479
- id: fields.id ?? (eventId === void 0 || eventId === "" ? `kafka:${record.topic}:${record.partition}:${record.offset}` : eventId)
480
- };
481
- }
443
+ encode: (envelope) => encodeJson(envelope, { format, stampsTime, ownHeaders }),
444
+ decode: (record) => decodeJson(record, format)
445
+ };
446
+ }
447
+ function encodeJson(envelope, { format, stampsTime, ownHeaders }) {
448
+ assertRepresentable(envelope);
449
+ const { body, headers } = format.write(envelope);
450
+ const taken = [CONTENT_TYPE, EVENT_ID_HEADER].find((name) => Object.hasOwn(headers, name));
451
+ if (taken !== void 0) {
452
+ throw new UnencodableEventError(
453
+ `Event ${envelope.id} has a header '${taken}', which the codec writes itself`
454
+ );
455
+ }
456
+ return {
457
+ key: envelope.key,
458
+ value: serializeJson(envelope, body),
459
+ headers: { ...headers, ...ownHeaders(envelope) },
460
+ ...stampsTime ? { timestamp: recordTimestamp(envelope) } : {}
461
+ };
462
+ }
463
+ function decodeJson(record, format) {
464
+ const { [EVENT_ID_HEADER]: eventId, ...headers } = withoutContentType(
465
+ readHeadersLeniently(record.headers)
466
+ );
467
+ const fields = format.read({ body: parsePayload(record.value), headers });
468
+ return {
469
+ ...fields,
470
+ id: fields.id ?? (eventId === void 0 || eventId === "" ? `kafka:${record.topic}:${record.partition}:${record.offset}` : eventId)
482
471
  };
483
472
  }
484
473
  function withoutContentType(headers) {
@@ -496,6 +485,139 @@ function recordTimestamp(envelope) {
496
485
  formatTime(envelope);
497
486
  return String(envelope.occurredAt.getTime());
498
487
  }
488
+ function tracksMembership(consumer) {
489
+ return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
490
+ }
491
+ var GroupJoinWatch = class {
492
+ #settings;
493
+ #waiter;
494
+ constructor(settings) {
495
+ this.#settings = settings;
496
+ }
497
+ /** The consumer joined its group. */
498
+ joined() {
499
+ this.#waiter?.resolve();
500
+ }
501
+ /** The consumer crashed while it was joining. */
502
+ failed(error) {
503
+ this.#waiter?.reject(error);
504
+ }
505
+ /** The transport closed while the consumer was joining. */
506
+ closed() {
507
+ this.#waiter?.reject(new TransportClosedError(`Transport '${this.#settings.name}' is closed`));
508
+ }
509
+ /** Connects and subscribes the consumer, runs it and resolves once it joined its group. */
510
+ async start(consumer, {
511
+ topics,
512
+ fromBeginning,
513
+ eachMessage
514
+ }) {
515
+ await consumer.connect();
516
+ await consumer.subscribe({ topics, fromBeginning });
517
+ const joined = this.#wait(consumer);
518
+ try {
519
+ await consumer.run({ eachMessage });
520
+ await joined.promise;
521
+ } finally {
522
+ joined.dispose();
523
+ }
524
+ }
525
+ #wait(consumer) {
526
+ if (!tracksMembership(consumer)) {
527
+ return { promise: Promise.resolve(), dispose: () => void 0 };
528
+ }
529
+ const { name, groupId, timings } = this.#settings;
530
+ let cancel;
531
+ const promise = new Promise((resolve, reject) => {
532
+ this.#waiter = { resolve, reject };
533
+ cancel = schedule(systemClock, {
534
+ run: () => {
535
+ reject(
536
+ new GroupJoinTimeoutError(
537
+ `Consumer of transport '${name}' did not join group '${groupId}' within ${timings.startupTimeoutMs}ms`
538
+ )
539
+ );
540
+ },
541
+ delayMs: timings.startupTimeoutMs
542
+ });
543
+ });
544
+ promise.catch(() => void 0);
545
+ return {
546
+ promise,
547
+ dispose: () => {
548
+ cancel?.();
549
+ this.#waiter = void 0;
550
+ }
551
+ };
552
+ }
553
+ };
554
+ var KafkaProducerSession = class {
555
+ #settings;
556
+ /** Whether the transport is closed: then nothing connects any more. */
557
+ #isClosed;
558
+ #producer;
559
+ #connected = false;
560
+ constructor(settings, isClosed) {
561
+ this.#settings = settings;
562
+ this.#isClosed = isClosed;
563
+ }
564
+ /** True once the producer connected, until the transport closes. */
565
+ get isConnected() {
566
+ return this.#connected;
567
+ }
568
+ /** The producer opened so far, if any: what closing must disconnect. */
569
+ get pending() {
570
+ return this.#producer;
571
+ }
572
+ markClosed() {
573
+ this.#connected = false;
574
+ }
575
+ connected() {
576
+ if (this.#isClosed()) {
577
+ return Promise.reject(
578
+ new TransportClosedError(`Transport '${this.#settings.name}' is closed`)
579
+ );
580
+ }
581
+ this.#producer ??= this.#open();
582
+ return this.#producer;
583
+ }
584
+ /** Sends with the publish timeout; the late outcome of a timed-out send is ignored. */
585
+ async send(producer, record) {
586
+ const { name, timings } = this.#settings;
587
+ let cancel;
588
+ const timeout = new Promise((_resolve, reject) => {
589
+ cancel = schedule(systemClock, {
590
+ run: () => {
591
+ reject(
592
+ new PublishTimeoutError(
593
+ `Transport '${name}' got no broker ack for topic '${record.topic}' within ${timings.publishTimeoutMs}ms`
594
+ )
595
+ );
596
+ },
597
+ delayMs: timings.publishTimeoutMs
598
+ });
599
+ });
600
+ const sending = producer.send(record);
601
+ sending.catch(() => void 0);
602
+ try {
603
+ await Promise.race([sending, timeout]);
604
+ } finally {
605
+ cancel?.();
606
+ }
607
+ }
608
+ async #open() {
609
+ const { kafka, producerConfig, maxInFlightRequests } = this.#settings;
610
+ const producer = kafka.producer({ ...producerConfig, idempotent: true, maxInFlightRequests });
611
+ try {
612
+ await producer.connect();
613
+ } catch (error) {
614
+ this.#producer = void 0;
615
+ throw error;
616
+ }
617
+ this.#connected = !this.#isClosed();
618
+ return producer;
619
+ }
620
+ };
499
621
  var DEFAULTS = {
500
622
  name: "kafka",
501
623
  maxInFlightRequests: 1,
@@ -508,44 +630,224 @@ var DEFAULTS = {
508
630
  restartInitialBackoff: "1s",
509
631
  restartMaxBackoff: "30s"
510
632
  };
511
- var KafkaTransport = class {
633
+ var MAXIMUM_IN_FLIGHT_REQUESTS = 5;
634
+ function resolveKafkaSettings(options) {
635
+ return {
636
+ name: options.name ?? DEFAULTS.name,
637
+ kafka: options.kafka,
638
+ groupId: options.groupId,
639
+ fromBeginning: options.fromBeginning ?? true,
640
+ maxInFlightRequests: BoundedInteger.of(
641
+ options.maxInFlightRequests ?? DEFAULTS.maxInFlightRequests,
642
+ { name: "maxInFlightRequests", minimum: 1, maximum: MAXIMUM_IN_FLIGHT_REQUESTS }
643
+ ),
644
+ producerConfig: options.producerConfig ?? {},
645
+ consumerConfig: options.consumerConfig ?? {},
646
+ invalidPolicy: options.onInvalidMessage ?? "dead-letter",
647
+ codec: resolveCodec(options),
648
+ logger: options.logger ?? console,
649
+ timings: resolveTimings(options)
650
+ };
651
+ }
652
+ function resolveCodec({ codec, decode }) {
653
+ if (decode === void 0) {
654
+ return codec ?? cloudEventsBinary;
655
+ }
656
+ if (codec !== void 0) {
657
+ throw new ConfigurationError("Give 'codec' or the deprecated 'decode', not both");
658
+ }
659
+ return { encode: encodeEnvelope, decode };
660
+ }
661
+ function resolveTimings(options) {
662
+ const processing = options.processing ?? {};
663
+ const maxAttempts = PositiveInteger.of(
664
+ processing.maxAttempts ?? DEFAULTS.maxAttempts,
665
+ "processing.maxAttempts"
666
+ );
667
+ return {
668
+ maxAttempts,
669
+ processingBackoff: new ExponentialBackoff({
670
+ initialMs: Milliseconds.of(
671
+ processing.initialBackoff ?? DEFAULTS.initialBackoff,
672
+ "processing.initialBackoff"
673
+ ),
674
+ maxMs: Milliseconds.of(processing.maxBackoff ?? DEFAULTS.maxBackoff, "processing.maxBackoff")
675
+ }),
676
+ startupTimeoutMs: Milliseconds.of(
677
+ options.startupTimeout ?? DEFAULTS.startupTimeout,
678
+ "startupTimeout"
679
+ ),
680
+ publishTimeoutMs: Milliseconds.of(
681
+ options.publishTimeout ?? DEFAULTS.publishTimeout,
682
+ "publishTimeout"
683
+ ),
684
+ heartbeatIntervalMs: Math.max(
685
+ 1,
686
+ Milliseconds.of(
687
+ processing.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
688
+ "processing.heartbeatInterval"
689
+ )
690
+ ),
691
+ restartBackoff: resolveRestartBackoff(options.restart ?? {})
692
+ };
693
+ }
694
+ function resolveRestartBackoff(restart) {
695
+ return new ExponentialBackoff({
696
+ initialMs: Milliseconds.of(
697
+ restart.initialBackoff ?? DEFAULTS.restartInitialBackoff,
698
+ "restart.initialBackoff"
699
+ ),
700
+ maxMs: Milliseconds.of(restart.maxBackoff ?? DEFAULTS.restartMaxBackoff, "restart.maxBackoff")
701
+ });
702
+ }
703
+ function deadLetterTopicFor(policy, sourceTopic) {
704
+ if (policy === "dead-letter") {
705
+ return `${sourceTopic}.dead`;
706
+ }
707
+ if (typeof policy === "object") {
708
+ const { deadLetterTopic } = policy;
709
+ return typeof deadLetterTopic === "function" ? deadLetterTopic(sourceTopic) : deadLetterTopic;
710
+ }
711
+ return void 0;
712
+ }
713
+ function toHandlerFailure(error) {
714
+ const { name, message } = toErrorInfo(error);
715
+ return new HandlerFailedError(`${name}: ${message}`);
716
+ }
717
+ function assertCanSubscribe(state, {
718
+ settings,
719
+ destinations
720
+ }) {
721
+ const { name, groupId, invalidPolicy } = settings;
722
+ if (state === "closed") {
723
+ throw new TransportClosedError(`Transport '${name}' is closed`);
724
+ }
725
+ if (state === "subscribed") {
726
+ throw new ConfigurationError(
727
+ `Transport '${name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
728
+ );
729
+ }
730
+ if (groupId === void 0 || groupId.length === 0) {
731
+ throw new ConfigurationError(`Transport '${name}' needs a groupId to subscribe`);
732
+ }
733
+ if (destinations.length === 0) {
734
+ throw new ConfigurationError(`Transport '${name}' subscribed to no topics`);
735
+ }
736
+ for (const topic of destinations) {
737
+ const deadLetterTopic = deadLetterTopicFor(invalidPolicy, topic);
738
+ if (deadLetterTopic !== void 0 && destinations.includes(deadLetterTopic)) {
739
+ throw new ConfigurationError(
740
+ `Dead letter topic '${deadLetterTopic}' is also consumed, which would loop`
741
+ );
742
+ }
743
+ }
744
+ }
745
+
746
+ // src/kafka-records.ts
747
+ function decodeRecord(codec, { topic, partition, message }) {
748
+ if (codec === cloudEventsBinary) {
749
+ return decodeEnvelope(message);
750
+ }
751
+ const record = {
752
+ topic,
753
+ partition,
754
+ offset: message.offset,
755
+ key: message.key,
756
+ value: message.value,
757
+ headers: message.headers,
758
+ timestamp: message.timestamp
759
+ };
760
+ return completeDecodedEvent(codec.decode(record), record);
761
+ }
762
+ function deadLetterRecord({ topic, partition, message }, {
763
+ reason,
764
+ name,
765
+ code,
766
+ deadLetterTopic
767
+ }) {
768
+ const headers = {
769
+ ...toOutgoingHeaders(message.headers),
770
+ "relaybus-invalid-reason": reason,
771
+ "relaybus-source-topic": topic,
772
+ "relaybus-source-partition": String(partition),
773
+ "relaybus-source-offset": message.offset,
774
+ "relaybus-error-name": name
775
+ };
776
+ if (code === void 0) {
777
+ delete headers["relaybus-error-code"];
778
+ } else {
779
+ headers["relaybus-error-code"] = code;
780
+ }
781
+ return {
782
+ topic: deadLetterTopic,
783
+ acks: -1,
784
+ messages: [
785
+ {
786
+ key: message.key === null ? null : Buffer.from(message.key),
787
+ value: message.value === null ? null : Buffer.from(message.value),
788
+ headers
789
+ }
790
+ ]
791
+ };
792
+ }
793
+ var WakeableSleeps = class {
794
+ #sleepers = /* @__PURE__ */ new Set();
795
+ sleep(milliseconds) {
796
+ return new Promise((resolve) => {
797
+ const wake = () => {
798
+ cancel();
799
+ this.#sleepers.delete(wake);
800
+ resolve();
801
+ };
802
+ const cancel = schedule(systemClock, { run: wake, delayMs: milliseconds });
803
+ this.#sleepers.add(wake);
804
+ });
805
+ }
806
+ wakeAll() {
807
+ for (const wake of this.#sleepers) {
808
+ wake();
809
+ }
810
+ }
811
+ /**
812
+ * Sleeps `milliseconds` in slices of at most `sliceMs`, running `between` after each, until done
813
+ * or `stopped`: a long backoff then never outlives the session timeout.
814
+ */
815
+ async sleepInSlices(milliseconds, {
816
+ sliceMs,
817
+ between,
818
+ stopped
819
+ }) {
820
+ let remaining = milliseconds;
821
+ do {
822
+ const slice = Math.min(remaining, sliceMs);
823
+ await this.sleep(slice);
824
+ await between();
825
+ remaining -= slice;
826
+ } while (remaining > 0 && !stopped());
827
+ }
828
+ };
829
+
830
+ // src/kafka-transport.ts
831
+ var KafkaTransport = class extends SubscribingTransport {
512
832
  name;
513
833
  system = "kafka";
514
- #kafka;
515
- #groupId;
516
- #fromBeginning;
517
- #maxInFlightRequests;
518
- #producerConfig;
519
- #consumerConfig;
520
- #invalidPolicy;
521
- #codec;
522
- #logger;
523
834
  #settings;
524
- #sleepers = /* @__PURE__ */ new Set();
525
- #state = "idle";
526
835
  #producer;
836
+ #groupJoin;
837
+ #sleeps = new WakeableSleeps();
838
+ #state = "idle";
527
839
  #consumer;
528
840
  #groupJoined = false;
529
841
  #consuming = false;
530
- #producerConnected = false;
531
842
  #restartLoop;
532
843
  #stalledPartitions = /* @__PURE__ */ new Set();
533
844
  #startConsuming;
534
- #joinWaiter;
535
845
  constructor(options) {
536
- this.name = options.name ?? DEFAULTS.name;
537
- this.#kafka = options.kafka;
538
- this.#groupId = options.groupId;
539
- this.#fromBeginning = options.fromBeginning ?? true;
540
- this.#maxInFlightRequests = assertMaxInFlightRequests(
541
- options.maxInFlightRequests ?? DEFAULTS.maxInFlightRequests
542
- );
543
- this.#producerConfig = options.producerConfig ?? {};
544
- this.#consumerConfig = options.consumerConfig ?? {};
545
- this.#invalidPolicy = options.onInvalidMessage ?? "dead-letter";
546
- this.#codec = resolveCodec(options);
547
- this.#logger = options.logger ?? console;
548
- this.#settings = resolveSettings(options);
846
+ super();
847
+ this.#settings = resolveKafkaSettings(options);
848
+ this.name = this.#settings.name;
849
+ this.#producer = new KafkaProducerSession(this.#settings, () => this.#state === "closed");
850
+ this.#groupJoin = new GroupJoinWatch(this.#settings);
549
851
  }
550
852
  /**
551
853
  * True while the consumer is joined to its group, not between a crash or
@@ -562,21 +864,21 @@ var KafkaTransport = class {
562
864
  * dead consumer is being restarted.
563
865
  */
564
866
  isReady() {
565
- if (this.#state === "closed" || !this.#producerConnected) {
867
+ if (this.#state === "closed" || !this.#producer.isConnected) {
566
868
  return false;
567
869
  }
568
870
  return this.#state === "idle" || this.isConsuming();
569
871
  }
570
872
  /** Connects the producer. Called by the bus on start; `publish` also connects on demand. */
571
873
  async connect() {
572
- await this.#connectedProducer();
874
+ await this.#producer.connected();
573
875
  }
574
876
  async publish(envelope, destination) {
575
- const producer = await this.#connectedProducer();
576
- await this.#send(producer, {
877
+ const producer = await this.#producer.connected();
878
+ await this.#producer.send(producer, {
577
879
  topic: destination,
578
880
  acks: -1,
579
- messages: [this.#codec.encode(envelope)]
881
+ messages: [this.#settings.codec.encode(envelope)]
580
882
  });
581
883
  }
582
884
  /**
@@ -586,14 +888,21 @@ var KafkaTransport = class {
586
888
  * @throws ConfigurationError when `groupId` is missing, `destinations` is empty
587
889
  * or the transport already subscribed (create one transport per subscription).
588
890
  */
589
- // eslint-disable-next-line relaybus/max-two-params -- the Transport.subscribe signature
590
- async subscribe(destinations, onMessage, hooks) {
591
- this.#assertCanSubscribe(destinations);
891
+ openSubscription(subscription) {
892
+ return this.#subscribe(subscription);
893
+ }
894
+ async #subscribe({ destinations, onMessage, hooks }) {
895
+ assertCanSubscribe(this.#state, { settings: this.#settings, destinations });
592
896
  this.#state = "subscribed";
593
- const consumer = this.#kafka.consumer({ ...this.#consumerConfig, groupId: this.#groupId });
897
+ const { kafka, consumerConfig, groupId } = this.#settings;
898
+ const consumer = kafka.consumer({ ...consumerConfig, groupId });
594
899
  this.#consumer = consumer;
595
900
  this.#observeConsumer(consumer);
596
- const start = () => this.#startConsumer(consumer, { destinations: [...destinations], onMessage, hooks });
901
+ const start = () => this.#groupJoin.start(consumer, {
902
+ topics: [...destinations],
903
+ fromBeginning: this.#settings.fromBeginning,
904
+ eachMessage: (payload) => this.#handleMessage(payload, { onMessage, hooks })
905
+ });
597
906
  this.#startConsuming = start;
598
907
  try {
599
908
  await start();
@@ -605,40 +914,21 @@ var KafkaTransport = class {
605
914
  throw error;
606
915
  }
607
916
  this.#consuming = true;
608
- this.#logger.log(
609
- `[KafkaTransport](subscribe) Consuming | transport=${this.name} group=${this.#groupId} topics=${destinations.join(",")}`
917
+ this.#settings.logger.log(
918
+ `[KafkaTransport](subscribe) Consuming | transport=${this.name} group=${groupId} topics=${destinations.join(",")}`
610
919
  );
611
920
  }
612
- async #startConsumer(consumer, {
613
- destinations,
614
- onMessage,
615
- hooks
616
- }) {
617
- await consumer.connect();
618
- await consumer.subscribe({ topics: destinations, fromBeginning: this.#fromBeginning });
619
- const joined = this.#waitForGroupJoin(consumer);
620
- try {
621
- await consumer.run({
622
- eachMessage: (payload) => this.#handleMessage(payload, { onMessage, hooks })
623
- });
624
- await joined.promise;
625
- } finally {
626
- joined.dispose();
627
- }
628
- }
629
921
  /** Stops consuming and disconnects the consumer and the producer. Safe to call twice. */
630
922
  async close() {
631
923
  if (this.#state === "closed") {
632
924
  return;
633
925
  }
634
926
  this.#state = "closed";
635
- const pendingProducer = this.#producer;
927
+ const pendingProducer = this.#producer.pending;
636
928
  this.#groupJoined = false;
637
- this.#producerConnected = false;
638
- for (const wake of this.#sleepers) {
639
- wake();
640
- }
641
- this.#joinWaiter?.reject(new TransportClosedError(`Transport '${this.name}' is closed`));
929
+ this.#producer.markClosed();
930
+ this.#sleeps.wakeAll();
931
+ this.#groupJoin.closed();
642
932
  await this.#restartLoop;
643
933
  const producer = await pendingProducer?.catch(() => void 0);
644
934
  const results = await Promise.allSettled([
@@ -650,99 +940,15 @@ var KafkaTransport = class {
650
940
  throw failure.reason;
651
941
  }
652
942
  }
653
- #assertCanSubscribe(destinations) {
654
- if (this.#state === "closed") {
655
- throw new TransportClosedError(`Transport '${this.name}' is closed`);
656
- }
657
- if (this.#state === "subscribed") {
658
- throw new ConfigurationError(
659
- `Transport '${this.name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
660
- );
661
- }
662
- if (this.#groupId === void 0 || this.#groupId.length === 0) {
663
- throw new ConfigurationError(`Transport '${this.name}' needs a groupId to subscribe`);
664
- }
665
- if (destinations.length === 0) {
666
- throw new ConfigurationError(`Transport '${this.name}' subscribed to no topics`);
667
- }
668
- for (const topic of destinations) {
669
- const deadLetterTopic = this.#deadLetterTopicFor(topic);
670
- if (deadLetterTopic !== void 0 && destinations.includes(deadLetterTopic)) {
671
- throw new ConfigurationError(
672
- `Dead letter topic '${deadLetterTopic}' is also consumed, which would loop`
673
- );
674
- }
675
- }
676
- }
677
- /** The dead-letter topic for records of `sourceTopic`, or undefined when the policy does not use one. */
678
- #deadLetterTopicFor(sourceTopic) {
679
- const policy = this.#invalidPolicy;
680
- if (policy === "dead-letter") {
681
- return `${sourceTopic}.dead`;
682
- }
683
- if (typeof policy === "object") {
684
- const { deadLetterTopic } = policy;
685
- return typeof deadLetterTopic === "function" ? deadLetterTopic(sourceTopic) : deadLetterTopic;
686
- }
687
- return void 0;
688
- }
689
- /** Sends with the publish timeout; the late outcome of a timed-out send is ignored. */
690
- async #send(producer, record) {
691
- let cancel;
692
- const timeout = new Promise((_resolve, reject) => {
693
- cancel = schedule(systemClock, {
694
- run: () => {
695
- reject(
696
- new PublishTimeoutError(
697
- `Transport '${this.name}' got no broker ack for topic '${record.topic}' within ${this.#settings.publishTimeoutMs}ms`
698
- )
699
- );
700
- },
701
- delayMs: this.#settings.publishTimeoutMs
702
- });
703
- });
704
- const sending = producer.send(record);
705
- sending.catch(() => void 0);
706
- try {
707
- await Promise.race([sending, timeout]);
708
- } finally {
709
- cancel?.();
710
- }
711
- }
712
- #connectedProducer() {
713
- if (this.#state === "closed") {
714
- return Promise.reject(new TransportClosedError(`Transport '${this.name}' is closed`));
715
- }
716
- this.#producer ??= this.#openProducer();
717
- return this.#producer;
718
- }
719
- async #openProducer() {
720
- const producer = this.#kafka.producer({
721
- ...this.#producerConfig,
722
- idempotent: true,
723
- maxInFlightRequests: this.#maxInFlightRequests
724
- });
725
- try {
726
- await producer.connect();
727
- } catch (error) {
728
- this.#producer = void 0;
729
- throw error;
730
- }
731
- this.#producerConnected = this.#state !== "closed";
732
- return producer;
733
- }
734
- async #handleMessage(payload, {
735
- onMessage,
736
- hooks
737
- }) {
943
+ async #handleMessage(payload, route) {
738
944
  let envelope;
739
945
  try {
740
- envelope = this.#decodeRecord(payload);
946
+ envelope = decodeRecord(this.#settings.codec, payload);
741
947
  } catch (error) {
742
- if (this.#codec === cloudEventsBinary && !(error instanceof InvalidKafkaMessageError)) {
948
+ if (this.#settings.codec === cloudEventsBinary && !(error instanceof InvalidKafkaMessageError)) {
743
949
  throw toHandlerFailure(error);
744
950
  }
745
- hooks?.onInvalidMessage({ destination: payload.topic, error });
951
+ route.hooks?.onInvalidMessage({ destination: payload.topic, error });
746
952
  await this.#withRetries(payload, {
747
953
  method: "handleInvalid",
748
954
  operation: () => this.#handleInvalid(payload, error)
@@ -753,7 +959,7 @@ var KafkaTransport = class {
753
959
  method: "deliver",
754
960
  operation: async () => {
755
961
  try {
756
- await onMessage(envelope, { destination: payload.topic });
962
+ await route.onMessage(envelope, { destination: payload.topic });
757
963
  } catch (error) {
758
964
  if (!(error instanceof NonRetryableError)) {
759
965
  throw error;
@@ -763,30 +969,15 @@ var KafkaTransport = class {
763
969
  }
764
970
  });
765
971
  }
766
- #decodeRecord({ topic, partition, message }) {
767
- if (this.#codec === cloudEventsBinary) {
768
- return decodeEnvelope(message);
769
- }
770
- const record = {
771
- topic,
772
- partition,
773
- offset: message.offset,
774
- key: message.key,
775
- value: message.value,
776
- headers: message.headers,
777
- timestamp: message.timestamp
778
- };
779
- return completeDecodedEvent(this.#codec.decode(record), record);
780
- }
781
972
  async #handleInvalid(payload, error) {
782
973
  const { topic, partition, message } = payload;
783
974
  const { message: reason, name, code } = toErrorInfo(error);
784
975
  const location = `topic=${topic} partition=${partition} offset=${message.offset}`;
785
976
  const errorFields = `errorName=${name}${code === void 0 ? "" : ` code=${code}`}`;
786
- const policy = this.#invalidPolicy;
787
- const deadLetterTopic = this.#deadLetterTopicFor(topic);
977
+ const policy = this.#settings.invalidPolicy;
978
+ const deadLetterTopic = deadLetterTopicFor(policy, topic);
788
979
  if (policy === "skip") {
789
- this.#logger.error(
980
+ this.#settings.logger.error(
790
981
  `[KafkaTransport](handleInvalid) Skipping malformed message | ${location} reason=${reason} ${errorFields}`
791
982
  );
792
983
  } else if (typeof policy === "function") {
@@ -799,32 +990,15 @@ var KafkaTransport = class {
799
990
  value: message.value
800
991
  });
801
992
  } else {
802
- const headers = {
803
- ...toOutgoingHeaders(message.headers),
804
- "relaybus-invalid-reason": reason,
805
- "relaybus-source-topic": topic,
806
- "relaybus-source-partition": String(partition),
807
- "relaybus-source-offset": message.offset,
808
- "relaybus-error-name": name
809
- };
810
- if (code === void 0) {
811
- delete headers["relaybus-error-code"];
812
- } else {
813
- headers["relaybus-error-code"] = code;
814
- }
815
- const producer = await this.#connectedProducer();
816
- await this.#send(producer, {
817
- topic: deadLetterTopic,
818
- acks: -1,
819
- messages: [
820
- {
821
- key: message.key === null ? null : Buffer.from(message.key),
822
- value: message.value === null ? null : Buffer.from(message.value),
823
- headers
824
- }
825
- ]
993
+ const record = deadLetterRecord(payload, {
994
+ reason,
995
+ name,
996
+ code,
997
+ deadLetterTopic
826
998
  });
827
- this.#logger.error(
999
+ const producer = await this.#producer.connected();
1000
+ await this.#producer.send(producer, record);
1001
+ this.#settings.logger.error(
828
1002
  `[KafkaTransport](handleInvalid) Dead-lettered malformed message | ${location} deadLetterTopic=${deadLetterTopic} reason=${reason} ${errorFields}`
829
1003
  );
830
1004
  }
@@ -835,7 +1009,8 @@ var KafkaTransport = class {
835
1009
  * redelivers the record; heartbeats between attempts keep the session alive.
836
1010
  */
837
1011
  async #withRetries(payload, { method, operation }) {
838
- const { maxAttempts, initialBackoffMs, maxBackoffMs } = this.#settings;
1012
+ const { maxAttempts, processingBackoff } = this.#settings.timings;
1013
+ const { logger } = this.#settings;
839
1014
  const location = `topic=${payload.topic} partition=${payload.partition} offset=${payload.message.offset}`;
840
1015
  const partitionKey = `${payload.topic}:${payload.partition}`;
841
1016
  for (let attempt = 1; ; attempt += 1) {
@@ -847,51 +1022,28 @@ var KafkaTransport = class {
847
1022
  const reason = toErrorInfo(error).message;
848
1023
  if (attempt >= maxAttempts || this.#state === "closed") {
849
1024
  this.#stalledPartitions.add(partitionKey);
850
- this.#logger.error(
1025
+ logger.error(
851
1026
  `[KafkaTransport](withRetries) Giving up for now, the record stays uncommitted and will be redelivered | operation=${method} ${location} attempts=${attempt} error=${reason}`
852
1027
  );
853
1028
  throw toHandlerFailure(error);
854
1029
  }
855
- this.#logger.warn(
1030
+ logger.warn(
856
1031
  `[KafkaTransport](withRetries) Attempt failed, retrying | operation=${method} ${location} attempt=${attempt} error=${reason}`
857
1032
  );
858
- await this.#sleepWithHeartbeats(
859
- Math.min(initialBackoffMs * 2 ** (attempt - 1), maxBackoffMs),
860
- payload
861
- );
1033
+ await this.#sleeps.sleepInSlices(processingBackoff.delayFor(attempt), {
1034
+ sliceMs: this.#settings.timings.heartbeatIntervalMs,
1035
+ between: () => payload.heartbeat(),
1036
+ stopped: () => this.#state === "closed"
1037
+ });
862
1038
  }
863
1039
  }
864
1040
  }
865
- /** Sleeps in slices and heartbeats after each, so a long backoff never outlives the session timeout. */
866
- async #sleepWithHeartbeats(milliseconds, payload) {
867
- let remaining = milliseconds;
868
- do {
869
- const slice = Math.min(remaining, this.#settings.heartbeatIntervalMs);
870
- await this.#sleep(slice);
871
- await payload.heartbeat();
872
- remaining -= slice;
873
- } while (remaining > 0 && this.#state !== "closed");
874
- }
875
- #sleep(milliseconds) {
876
- return new Promise((resolve) => {
877
- const wake = () => {
878
- cancel();
879
- this.#sleepers.delete(wake);
880
- resolve();
881
- };
882
- const cancel = schedule(systemClock, { run: wake, delayMs: milliseconds });
883
- this.#sleepers.add(wake);
884
- });
885
- }
886
- /** Whether the client exposes the instrumentation needed to follow the group membership. */
887
- #tracksMembership(consumer) {
888
- return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
889
- }
890
1041
  #observeConsumer(consumer) {
891
- if (!this.#tracksMembership(consumer)) {
1042
+ if (!tracksMembership(consumer)) {
892
1043
  this.#groupJoined = true;
893
1044
  return;
894
1045
  }
1046
+ const { logger, groupId } = this.#settings;
895
1047
  const names = consumer.events;
896
1048
  const listen = (name, listener) => {
897
1049
  if (name !== void 0) {
@@ -900,29 +1052,28 @@ var KafkaTransport = class {
900
1052
  };
901
1053
  listen(names.GROUP_JOIN, () => {
902
1054
  this.#groupJoined = true;
903
- this.#joinWaiter?.resolve();
904
- this.#logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${this.#groupId}`);
1055
+ this.#groupJoin.joined();
1056
+ logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
905
1057
  });
906
1058
  listen(names.REBALANCING, () => {
907
1059
  this.#groupJoined = false;
908
1060
  this.#stalledPartitions.clear();
909
- this.#logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${this.#groupId}`);
1061
+ logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
910
1062
  });
911
1063
  listen(names.CRASH, (event) => this.#handleCrash(event));
912
- listen(names.STOP, () => {
913
- this.#groupJoined = false;
914
- });
915
- listen(names.DISCONNECT, () => {
1064
+ const leftGroup = () => {
916
1065
  this.#groupJoined = false;
917
- });
1066
+ };
1067
+ listen(names.STOP, leftGroup);
1068
+ listen(names.DISCONNECT, leftGroup);
918
1069
  }
919
1070
  #handleCrash(event) {
920
1071
  const { error, restart } = event.payload ?? {};
921
1072
  this.#groupJoined = false;
922
- this.#logger.error(
923
- `[KafkaTransport](handleCrash) Consumer crashed | group=${this.#groupId} restart=${String(restart)} error=${toErrorInfo(error).message}`
1073
+ this.#settings.logger.error(
1074
+ `[KafkaTransport](handleCrash) Consumer crashed | group=${this.#settings.groupId} restart=${String(restart)} error=${toErrorInfo(error).message}`
924
1075
  );
925
- this.#joinWaiter?.reject(error);
1076
+ this.#groupJoin.failed(error);
926
1077
  if (restart === false && this.#consuming && this.#restartLoop === void 0) {
927
1078
  this.#restartLoop = this.#restartConsumer().finally(() => {
928
1079
  this.#restartLoop = void 0;
@@ -934,125 +1085,28 @@ var KafkaTransport = class {
934
1085
  * again, with bounded backoff, until it joined or the transport closed.
935
1086
  */
936
1087
  async #restartConsumer() {
937
- const { restartInitialBackoffMs, restartMaxBackoffMs } = this.#settings;
1088
+ const { logger, groupId, timings } = this.#settings;
938
1089
  const consumer = this.#consumer;
939
1090
  for (let attempt = 1; this.#state === "subscribed"; attempt += 1) {
940
- await this.#sleep(
941
- Math.min(restartInitialBackoffMs * 2 ** (attempt - 1), restartMaxBackoffMs)
942
- );
1091
+ await this.#sleeps.sleep(timings.restartBackoff.delayFor(attempt));
943
1092
  if (this.#state !== "subscribed") {
944
1093
  return;
945
1094
  }
946
1095
  await consumer.disconnect().catch(() => void 0);
947
1096
  try {
948
1097
  await this.#startConsuming();
949
- this.#logger.log(
950
- `[KafkaTransport](restartConsumer) Consumer restarted | group=${this.#groupId} attempt=${attempt}`
1098
+ logger.log(
1099
+ `[KafkaTransport](restartConsumer) Consumer restarted | group=${groupId} attempt=${attempt}`
951
1100
  );
952
1101
  return;
953
1102
  } catch (error) {
954
- this.#logger.error(
955
- `[KafkaTransport](restartConsumer) Restart failed, will retry | group=${this.#groupId} attempt=${attempt} error=${toErrorInfo(error).message}`
1103
+ logger.error(
1104
+ `[KafkaTransport](restartConsumer) Restart failed, will retry | group=${groupId} attempt=${attempt} error=${toErrorInfo(error).message}`
956
1105
  );
957
1106
  }
958
1107
  }
959
1108
  }
960
- #waitForGroupJoin(consumer) {
961
- if (!this.#tracksMembership(consumer)) {
962
- return { promise: Promise.resolve(), dispose: () => void 0 };
963
- }
964
- let cancel;
965
- const promise = new Promise((resolve, reject) => {
966
- this.#joinWaiter = { resolve, reject };
967
- cancel = schedule(systemClock, {
968
- run: () => {
969
- reject(
970
- new GroupJoinTimeoutError(
971
- `Consumer of transport '${this.name}' did not join group '${this.#groupId}' within ${this.#settings.startupTimeoutMs}ms`
972
- )
973
- );
974
- },
975
- delayMs: this.#settings.startupTimeoutMs
976
- });
977
- });
978
- promise.catch(() => void 0);
979
- return {
980
- promise,
981
- dispose: () => {
982
- cancel?.();
983
- this.#joinWaiter = void 0;
984
- }
985
- };
986
- }
987
1109
  };
988
- function resolveCodec({ codec, decode }) {
989
- if (decode === void 0) {
990
- return codec ?? cloudEventsBinary;
991
- }
992
- if (codec !== void 0) {
993
- throw new ConfigurationError("Give 'codec' or the deprecated 'decode', not both");
994
- }
995
- return { encode: encodeEnvelope, decode };
996
- }
997
- function resolveSettings(options) {
998
- const processing = options.processing ?? {};
999
- const maxAttempts = assertPositiveInteger(
1000
- processing.maxAttempts ?? DEFAULTS.maxAttempts,
1001
- "processing.maxAttempts"
1002
- );
1003
- return {
1004
- maxAttempts,
1005
- initialBackoffMs: toMilliseconds(
1006
- processing.initialBackoff ?? DEFAULTS.initialBackoff,
1007
- "processing.initialBackoff"
1008
- ),
1009
- maxBackoffMs: toMilliseconds(
1010
- processing.maxBackoff ?? DEFAULTS.maxBackoff,
1011
- "processing.maxBackoff"
1012
- ),
1013
- startupTimeoutMs: toMilliseconds(
1014
- options.startupTimeout ?? DEFAULTS.startupTimeout,
1015
- "startupTimeout"
1016
- ),
1017
- publishTimeoutMs: toMilliseconds(
1018
- options.publishTimeout ?? DEFAULTS.publishTimeout,
1019
- "publishTimeout"
1020
- ),
1021
- heartbeatIntervalMs: Math.max(
1022
- 1,
1023
- toMilliseconds(
1024
- processing.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1025
- "processing.heartbeatInterval"
1026
- )
1027
- ),
1028
- ...resolveRestartSettings(options.restart ?? {})
1029
- };
1030
- }
1031
- function resolveRestartSettings(restart) {
1032
- return {
1033
- restartInitialBackoffMs: toMilliseconds(
1034
- restart.initialBackoff ?? DEFAULTS.restartInitialBackoff,
1035
- "restart.initialBackoff"
1036
- ),
1037
- restartMaxBackoffMs: toMilliseconds(
1038
- restart.maxBackoff ?? DEFAULTS.restartMaxBackoff,
1039
- "restart.maxBackoff"
1040
- )
1041
- };
1042
- }
1043
- var MAXIMUM_IN_FLIGHT_REQUESTS = 5;
1044
- function assertMaxInFlightRequests(value) {
1045
- if (!Number.isInteger(value) || value < 1 || value > MAXIMUM_IN_FLIGHT_REQUESTS) {
1046
- throw new ConfigurationError(
1047
- `maxInFlightRequests must be an integer from 1 to 5, received ${value}`
1048
- );
1049
- }
1050
- return value;
1051
- }
1052
- function toHandlerFailure(error) {
1053
- const { name, message } = toErrorInfo(error);
1054
- return new HandlerFailedError(`${name}: ${message}`);
1055
- }
1056
1110
 
1057
1111
  export { GroupJoinTimeoutError, HandlerFailedError, InvalidKafkaMessageError, KafkaTransport, PublishTimeoutError, TransportClosedError, UnencodableEventError, cloudEventsBinary, cloudEventsStructured, decodeCloudEvent, decodeEnvelope, encodeEnvelope, jsonEnvelope, plainJsonDecoder };
1058
1112
  //# sourceMappingURL=index.js.map