@relaybus/kafka 0.2.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,14 +1,36 @@
1
- import { RelayBusError, NonRetryableError, assertEventType, ConfigurationError, toErrorInfo, toMilliseconds } from '@relaybus/core';
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
+ export { fromHeader, fromPayloadField } from '@relaybus/core/internal';
2
4
 
3
- // src/cloudevents-codec.ts
5
+ // src/errors.ts
4
6
  var InvalidKafkaMessageError = class extends RelayBusError {
7
+ name = "InvalidKafkaMessageError";
8
+ code = "RELAYBUS_KAFKA_INVALID_MESSAGE";
5
9
  };
6
10
  var UnencodableEventError = class extends NonRetryableError {
7
11
  name = "UnencodableEventError";
12
+ code = "RELAYBUS_KAFKA_UNENCODABLE_EVENT";
13
+ };
14
+ var PublishTimeoutError = class extends RelayBusError {
15
+ name = "PublishTimeoutError";
16
+ code = "RELAYBUS_KAFKA_PUBLISH_TIMEOUT";
17
+ };
18
+ var TransportClosedError = class extends RelayBusError {
19
+ name = "TransportClosedError";
20
+ code = "RELAYBUS_KAFKA_TRANSPORT_CLOSED";
21
+ };
22
+ var GroupJoinTimeoutError = class extends RelayBusError {
23
+ name = "GroupJoinTimeoutError";
24
+ code = "RELAYBUS_KAFKA_GROUP_JOIN_TIMEOUT";
25
+ };
26
+ var HandlerFailedError = class extends RelayBusError {
27
+ name = "HandlerFailedError";
28
+ code = "RELAYBUS_KAFKA_HANDLER_FAILED";
8
29
  };
9
30
  var SPEC_VERSION = "1.0";
10
31
  var CONTENT_TYPE = "content-type";
11
32
  var JSON_MEDIA_TYPE = "application/json";
33
+ var STRUCTURED_MEDIA_TYPE = "application/cloudevents+json";
12
34
  var CLOUDEVENTS_PREFIX = "ce_";
13
35
  var ESCAPE_PREFIX = "x-relaybus-h-";
14
36
  var RESERVED_PREFIXES = [CLOUDEVENTS_PREFIX, "x-relaybus-"];
@@ -23,6 +45,7 @@ var HEADER = {
23
45
  causationId: "ce_causationid"
24
46
  };
25
47
  var PROTOCOL_HEADERS = /* @__PURE__ */ new Set([...Object.values(HEADER), CONTENT_TYPE]);
48
+ var MAXIMUM_RFC3339_YEAR = 9999;
26
49
  var RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i;
27
50
  var strictUtf8 = new TextDecoder("utf-8", { fatal: true });
28
51
  var lenientUtf8 = new TextDecoder("utf-8");
@@ -50,25 +73,39 @@ function encodeEnvelope(envelope) {
50
73
  }
51
74
  return { key: envelope.key, value, headers: Object.fromEntries(entries) };
52
75
  }
76
+ function encodeStructuredEnvelope(envelope) {
77
+ const value = writeStructuredCloudEvent(envelope, {
78
+ time: formatTime(envelope),
79
+ data: serializePayload(envelope)
80
+ });
81
+ const entries = Object.entries(envelope.headers).map(
82
+ ([name, headerValue]) => [escapeUserHeaderName(name), headerValue]
83
+ );
84
+ entries.push([CONTENT_TYPE, STRUCTURED_MEDIA_TYPE]);
85
+ return { key: envelope.key, value, headers: Object.fromEntries(entries) };
86
+ }
53
87
  function serializePayload(envelope) {
54
- let value;
88
+ return serializeJson(envelope, envelope.payload);
89
+ }
90
+ function serializeJson(envelope, value) {
91
+ let text;
55
92
  try {
56
- value = JSON.stringify(envelope.payload);
93
+ text = JSON.stringify(value);
57
94
  } catch (error) {
58
95
  throw new UnencodableEventError(
59
96
  `Event ${envelope.id} has a payload that JSON cannot represent: ${error.message}`
60
97
  );
61
98
  }
62
- if (value === void 0) {
99
+ if (text === void 0) {
63
100
  throw new UnencodableEventError(
64
101
  `Event ${envelope.id} has a payload that JSON cannot represent (undefined, a function or a symbol)`
65
102
  );
66
103
  }
67
- return value;
104
+ return text;
68
105
  }
69
106
  function formatTime(envelope) {
70
107
  const year = envelope.occurredAt.getUTCFullYear();
71
- if (Number.isNaN(year) || year < 0 || year > 9999) {
108
+ if (Number.isNaN(year) || year < 0 || year > MAXIMUM_RFC3339_YEAR) {
72
109
  throw new UnencodableEventError(
73
110
  `Event ${envelope.id} has an occurredAt that is not an RFC 3339 instant (invalid date or year outside 0..9999)`
74
111
  );
@@ -116,11 +153,14 @@ function readHeaders(incoming) {
116
153
  if (typeof raw !== "string" && !(raw instanceof Uint8Array)) {
117
154
  throw invalid(`Header '${name}' is repeated`);
118
155
  }
119
- headers.set(name, decodeText(raw, PROTOCOL_HEADERS.has(name), `Header '${name}'`));
156
+ headers.set(
157
+ name,
158
+ decodeText(raw, { strict: PROTOCOL_HEADERS.has(name), label: `Header '${name}'` })
159
+ );
120
160
  }
121
161
  return headers;
122
162
  }
123
- function decodeText(raw, strict, label) {
163
+ function decodeText(raw, { strict, label }) {
124
164
  if (typeof raw === "string") {
125
165
  return raw;
126
166
  }
@@ -143,19 +183,19 @@ function assertJsonContentType(contentType) {
143
183
  throw invalid(`${CONTENT_TYPE} must be '${JSON_MEDIA_TYPE}', received '${contentType ?? ""}'`);
144
184
  }
145
185
  }
146
- function resolveTime(text, recordTimestamp) {
186
+ function resolveTime(text, recordTimestamp2) {
147
187
  if (text !== void 0 && text.length > 0) {
148
188
  return parseTime(text);
149
189
  }
150
- const time = readRecordTimestamp(recordTimestamp);
190
+ const time = readRecordTimestamp(recordTimestamp2);
151
191
  if (time === void 0) {
152
192
  throw invalid(`${HEADER.time} is missing and the record has no usable timestamp`);
153
193
  }
154
194
  return time;
155
195
  }
156
- function readRecordTimestamp(recordTimestamp) {
157
- const time = new Date(Number(recordTimestamp));
158
- return recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime()) ? void 0 : time;
196
+ function readRecordTimestamp(recordTimestamp2) {
197
+ const time = new Date(Number(recordTimestamp2));
198
+ return recordTimestamp2 === void 0 || recordTimestamp2 === "" || Number.isNaN(time.getTime()) ? void 0 : time;
159
199
  }
160
200
  function parseTime(text, label = HEADER.time) {
161
201
  const time = new Date(text);
@@ -168,7 +208,7 @@ function parsePayload(value) {
168
208
  if (value === null) {
169
209
  throw invalid("Message has no value");
170
210
  }
171
- const text = decodeText(value, true, "Message value");
211
+ const text = decodeText(value, { strict: true, label: "Message value" });
172
212
  try {
173
213
  return JSON.parse(text);
174
214
  } catch {
@@ -183,7 +223,7 @@ function resolveKey(subject, recordKey) {
183
223
  return subject ?? keyText;
184
224
  }
185
225
  function readRecordKey(recordKey) {
186
- return recordKey === null ? null : decodeText(recordKey, true, "Message key");
226
+ return recordKey === null ? null : decodeText(recordKey, { strict: true, label: "Message key" });
187
227
  }
188
228
  function collectUserHeaders(headers) {
189
229
  const entries = [];
@@ -212,7 +252,8 @@ function toOutgoingHeaders(incoming) {
212
252
  function toBufferOrString(value) {
213
253
  return typeof value === "string" ? value : Buffer.from(value);
214
254
  }
215
- var STRUCTURED_MEDIA_TYPE = "application/cloudevents+json";
255
+ var { optionalAttribute, requiredAttribute, assertValidType, assertJsonDataContentType } = createCloudEventReaders(invalid);
256
+ var STRUCTURED_MEDIA_TYPE2 = "application/cloudevents+json";
216
257
  var exactUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
217
258
  var SPEC_VERSION2 = "1.0";
218
259
  var SPEC_VERSION_HEADER = "ce_specversion";
@@ -232,7 +273,7 @@ var STRUCTURED_ATTRIBUTES = /* @__PURE__ */ new Set([
232
273
  ]);
233
274
  function decodeCloudEvent(record) {
234
275
  const headers = readHeaders(record.headers);
235
- if (mediaTypeOf(headers.get(CONTENT_TYPE)) !== STRUCTURED_MEDIA_TYPE) {
276
+ if (mediaTypeOf(headers.get(CONTENT_TYPE)) !== STRUCTURED_MEDIA_TYPE2) {
236
277
  return decodeEnvelope(record);
237
278
  }
238
279
  const body = parsePayload(record.value);
@@ -267,89 +308,59 @@ function plainJsonDecoder(options) {
267
308
  }
268
309
  return (record) => {
269
310
  const headers = readHeadersLeniently(record.headers);
270
- if (record.headers?.[SPEC_VERSION_HEADER] !== void 0 || mediaTypeOf(headers[CONTENT_TYPE]) === STRUCTURED_MEDIA_TYPE) {
311
+ if (record.headers?.[SPEC_VERSION_HEADER] !== void 0 || mediaTypeOf(headers[CONTENT_TYPE]) === STRUCTURED_MEDIA_TYPE2) {
271
312
  return decodeCloudEvent(record);
272
313
  }
273
- const plain = {
274
- topic: record.topic,
275
- partition: record.partition,
276
- offset: record.offset,
277
- key: readKeyLeniently(record.key),
278
- headers,
279
- payload: parsePayload(record.value),
280
- timestamp: record.timestamp
281
- };
282
- const type = resolveText(options.type, plain, "type");
314
+ const plain = plainRecordOf(record, headers);
315
+ const type = KAFKA_EVENTS.text(options.type, { plain, name: "type" });
283
316
  if (type === void 0) {
284
317
  throw invalid("Record has no event type: the type option found none");
285
318
  }
286
319
  assertValidType(type, "The event type");
287
320
  return {
288
- id: resolveText(options.id, plain, "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}`,
289
322
  type,
290
- source: resolveText(options.source, plain, "source") ?? record.topic,
291
- key: options.key === void 0 ? plain.key : resolveText(options.key, plain, "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,
292
325
  payload: plain.payload,
293
326
  headers,
294
327
  correlationId: null,
295
328
  causationId: null,
296
- occurredAt: resolveOccurredAt(options.occurredAt, plain, record)
329
+ occurredAt: resolveOccurredAt(options.occurredAt, { plain, record })
297
330
  };
298
331
  };
299
332
  }
300
- function completeDecodedEvent(decoded, record) {
301
- if (typeof decoded !== "object" || decoded === null) {
302
- throw invalid("The decoder must return an event object synchronously");
303
- }
304
- if ("then" in decoded) {
305
- observeRejection(decoded);
306
- throw invalid("The decoder must return an event object synchronously");
307
- }
308
- const event = decoded;
333
+ function plainRecordOf(record, headers) {
309
334
  return {
310
- id: event.id,
311
- type: event.type,
312
- source: event.source ?? record.topic,
313
- key: event.key === void 0 ? readRecordKey(record.key) : event.key,
314
- payload: event.payload,
315
- headers: event.headers ?? {},
316
- correlationId: event.correlationId ?? null,
317
- causationId: event.causationId ?? null,
318
- 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
319
342
  };
320
343
  }
321
- function mediaTypeOf(contentType) {
322
- return contentType?.split(";")[0]?.trim().toLowerCase();
323
- }
324
- function isJsonObject(value) {
325
- return typeof value === "object" && value !== null && !Array.isArray(value);
326
- }
327
- function optionalAttribute(body, name) {
328
- const value = body[name];
329
- if (value !== void 0 && typeof value !== "string") {
330
- throw invalid(`Attribute '${name}' must be a string`);
344
+ var KafkaDecodedEvents = class extends DecodedEventReader {
345
+ invalid(reason) {
346
+ return invalid(reason);
331
347
  }
332
- return value;
333
- }
334
- function requiredAttribute(body, name) {
335
- const value = optionalAttribute(body, name);
336
- if (value === void 0 || value.length === 0) {
337
- throw invalid(`Attribute '${name}' is missing or empty`);
348
+ /** The topic. */
349
+ defaultSource(record) {
350
+ return record.topic;
338
351
  }
339
- return value;
340
- }
341
- function assertValidType(type, label) {
342
- try {
343
- assertEventType(type);
344
- } catch (error) {
345
- throw invalid(`${label} is not a valid event type: ${error.message}`);
352
+ /** The record key as UTF-8 text. */
353
+ defaultKey(record) {
354
+ return readRecordKey(record.key);
346
355
  }
347
- }
348
- function assertJsonDataContentType(dataContentType) {
349
- const mediaType = mediaTypeOf(dataContentType);
350
- if (mediaType !== void 0 && mediaType !== "application/json" && mediaType !== "text/json" && !mediaType.endsWith("+json")) {
351
- throw invalid(`datacontenttype must be JSON, received '${dataContentType}'`);
356
+ /** The record timestamp; a record without a usable one is malformed. */
357
+ defaultTime(record) {
358
+ return requireRecordTimestamp(record);
352
359
  }
360
+ };
361
+ var KAFKA_EVENTS = new KafkaDecodedEvents();
362
+ function completeDecodedEvent(decoded, record) {
363
+ return KAFKA_EVENTS.complete(decoded, record);
353
364
  }
354
365
  function extensionAttributes(body) {
355
366
  return Object.fromEntries(
@@ -396,18 +407,7 @@ function readKeyLeniently(key) {
396
407
  return Buffer.from(key).toString("base64");
397
408
  }
398
409
  }
399
- function resolveText(option, record, name) {
400
- const value = typeof option === "function" ? option(record) : option;
401
- if (value === void 0 || value === null) {
402
- return void 0;
403
- }
404
- if (typeof value !== "string") {
405
- observeRejection(value);
406
- throw invalid(`The ${name} option returned ${typeof value}, expected a string`);
407
- }
408
- return value.length === 0 ? void 0 : value;
409
- }
410
- function resolveOccurredAt(option, plain, record) {
410
+ function resolveOccurredAt(option, { plain, record }) {
411
411
  const value = option?.(plain);
412
412
  if (value === void 0) {
413
413
  return requireRecordTimestamp(record);
@@ -418,18 +418,205 @@ function resolveOccurredAt(option, plain, record) {
418
418
  }
419
419
  return value;
420
420
  }
421
- function observeRejection(value) {
422
- if (typeof value === "object" && value !== null && "then" in value) {
423
- Promise.resolve(value).catch(() => void 0);
421
+
422
+ // src/codecs.ts
423
+ var cloudEventsBinary = Object.freeze({
424
+ encode: encodeEnvelope,
425
+ decode: decodeEnvelope
426
+ });
427
+ var cloudEventsStructured = Object.freeze({
428
+ encode: encodeStructuredEnvelope,
429
+ decode: decodeCloudEvent
430
+ });
431
+ var RECORD_POSITION = "record-position";
432
+ var EVENT_ID_HEADER = "relaybus-event-id";
433
+ var DEFAULT_CONTENT_TYPE = "application/json";
434
+ function jsonEnvelope(mapping) {
435
+ const format = createJsonEnvelopeFormat(mapping, {
436
+ transportIds: [RECORD_POSITION],
437
+ failures: { invalid, unencodable: (reason) => new UnencodableEventError(reason) }
438
+ });
439
+ const contentType = mapping.contentType ?? DEFAULT_CONTENT_TYPE;
440
+ const stampsTime = mapping.occurredAt === void 0;
441
+ const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? { [CONTENT_TYPE]: contentType, [EVENT_ID_HEADER]: envelope.id } : { [CONTENT_TYPE]: contentType };
442
+ return {
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
+ );
424
455
  }
456
+ return {
457
+ key: envelope.key,
458
+ value: serializeJson(envelope, body),
459
+ headers: { ...headers, ...ownHeaders(envelope) },
460
+ ...stampsTime ? { timestamp: recordTimestamp(envelope) } : {}
461
+ };
425
462
  }
426
-
427
- // src/kafka-transport.ts
428
- var PublishTimeoutError = class extends RelayBusError {
429
- name = "PublishTimeoutError";
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)
471
+ };
472
+ }
473
+ function withoutContentType(headers) {
474
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => name !== CONTENT_TYPE));
475
+ }
476
+ function assertRepresentable(envelope) {
477
+ const kind = typeof envelope.payload;
478
+ if (kind === "undefined" || kind === "function" || kind === "symbol") {
479
+ throw new UnencodableEventError(
480
+ `Event ${envelope.id} has a payload that JSON cannot represent (undefined, a function or a symbol)`
481
+ );
482
+ }
483
+ }
484
+ function recordTimestamp(envelope) {
485
+ formatTime(envelope);
486
+ return String(envelope.occurredAt.getTime());
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
+ }
430
553
  };
431
- var HandlerFailedError = class extends RelayBusError {
432
- name = "HandlerFailedError";
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
+ }
433
620
  };
434
621
  var DEFAULTS = {
435
622
  name: "kafka",
@@ -443,46 +630,224 @@ var DEFAULTS = {
443
630
  restartInitialBackoff: "1s",
444
631
  restartMaxBackoff: "30s"
445
632
  };
446
- 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 {
447
832
  name;
448
- #kafka;
449
- #groupId;
450
- #fromBeginning;
451
- #maxInFlightRequests;
452
- #producerConfig;
453
- #consumerConfig;
454
- #invalidPolicy;
455
- #decode;
456
- #logger;
833
+ system = "kafka";
457
834
  #settings;
458
- #sleepers = /* @__PURE__ */ new Set();
459
- #state = "idle";
460
835
  #producer;
836
+ #groupJoin;
837
+ #sleeps = new WakeableSleeps();
838
+ #state = "idle";
461
839
  #consumer;
462
840
  #groupJoined = false;
463
841
  #consuming = false;
464
- #producerConnected = false;
465
842
  #restartLoop;
466
843
  #stalledPartitions = /* @__PURE__ */ new Set();
467
844
  #startConsuming;
468
- #joinWaiter;
469
845
  constructor(options) {
470
- this.name = options.name ?? DEFAULTS.name;
471
- this.#kafka = options.kafka;
472
- this.#groupId = options.groupId;
473
- this.#fromBeginning = options.fromBeginning ?? true;
474
- this.#maxInFlightRequests = options.maxInFlightRequests ?? DEFAULTS.maxInFlightRequests;
475
- if (!Number.isInteger(this.#maxInFlightRequests) || this.#maxInFlightRequests < 1 || this.#maxInFlightRequests > 5) {
476
- throw new ConfigurationError(
477
- `maxInFlightRequests must be an integer from 1 to 5, received ${this.#maxInFlightRequests}`
478
- );
479
- }
480
- this.#producerConfig = options.producerConfig ?? {};
481
- this.#consumerConfig = options.consumerConfig ?? {};
482
- this.#invalidPolicy = options.onInvalidMessage ?? "dead-letter";
483
- this.#decode = options.decode;
484
- this.#logger = options.logger ?? console;
485
- 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);
486
851
  }
487
852
  /**
488
853
  * True while the consumer is joined to its group, not between a crash or
@@ -499,22 +864,21 @@ var KafkaTransport = class {
499
864
  * dead consumer is being restarted.
500
865
  */
501
866
  isReady() {
502
- if (this.#state === "closed" || !this.#producerConnected) {
867
+ if (this.#state === "closed" || !this.#producer.isConnected) {
503
868
  return false;
504
869
  }
505
870
  return this.#state === "idle" || this.isConsuming();
506
871
  }
507
872
  /** Connects the producer. Called by the bus on start; `publish` also connects on demand. */
508
873
  async connect() {
509
- await this.#connectedProducer();
874
+ await this.#producer.connected();
510
875
  }
511
876
  async publish(envelope, destination) {
512
- const producer = await this.#connectedProducer();
513
- const { key, value, headers } = encodeEnvelope(envelope);
514
- await this.#send(producer, {
877
+ const producer = await this.#producer.connected();
878
+ await this.#producer.send(producer, {
515
879
  topic: destination,
516
880
  acks: -1,
517
- messages: [{ key, value, headers }]
881
+ messages: [this.#settings.codec.encode(envelope)]
518
882
  });
519
883
  }
520
884
  /**
@@ -524,13 +888,21 @@ var KafkaTransport = class {
524
888
  * @throws ConfigurationError when `groupId` is missing, `destinations` is empty
525
889
  * or the transport already subscribed (create one transport per subscription).
526
890
  */
527
- async subscribe(destinations, onMessage) {
528
- 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 });
529
896
  this.#state = "subscribed";
530
- const consumer = this.#kafka.consumer({ ...this.#consumerConfig, groupId: this.#groupId });
897
+ const { kafka, consumerConfig, groupId } = this.#settings;
898
+ const consumer = kafka.consumer({ ...consumerConfig, groupId });
531
899
  this.#consumer = consumer;
532
900
  this.#observeConsumer(consumer);
533
- const start = () => this.#startConsumer(consumer, [...destinations], onMessage);
901
+ const start = () => this.#groupJoin.start(consumer, {
902
+ topics: [...destinations],
903
+ fromBeginning: this.#settings.fromBeginning,
904
+ eachMessage: (payload) => this.#handleMessage(payload, { onMessage, hooks })
905
+ });
534
906
  this.#startConsuming = start;
535
907
  try {
536
908
  await start();
@@ -542,34 +914,21 @@ var KafkaTransport = class {
542
914
  throw error;
543
915
  }
544
916
  this.#consuming = true;
545
- this.#logger.log(
546
- `[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(",")}`
547
919
  );
548
920
  }
549
- async #startConsumer(consumer, destinations, onMessage) {
550
- await consumer.connect();
551
- await consumer.subscribe({ topics: destinations, fromBeginning: this.#fromBeginning });
552
- const joined = this.#waitForGroupJoin(consumer);
553
- try {
554
- await consumer.run({ eachMessage: (payload) => this.#handleMessage(payload, onMessage) });
555
- await joined.promise;
556
- } finally {
557
- joined.dispose();
558
- }
559
- }
560
921
  /** Stops consuming and disconnects the consumer and the producer. Safe to call twice. */
561
922
  async close() {
562
923
  if (this.#state === "closed") {
563
924
  return;
564
925
  }
565
926
  this.#state = "closed";
566
- const pendingProducer = this.#producer;
927
+ const pendingProducer = this.#producer.pending;
567
928
  this.#groupJoined = false;
568
- this.#producerConnected = false;
569
- for (const wake of this.#sleepers) {
570
- wake();
571
- }
572
- this.#joinWaiter?.reject(new RelayBusError(`Transport '${this.name}' is closed`));
929
+ this.#producer.markClosed();
930
+ this.#sleeps.wakeAll();
931
+ this.#groupJoin.closed();
573
932
  await this.#restartLoop;
574
933
  const producer = await pendingProducer?.catch(() => void 0);
575
934
  const results = await Promise.allSettled([
@@ -581,130 +940,45 @@ var KafkaTransport = class {
581
940
  throw failure.reason;
582
941
  }
583
942
  }
584
- #assertCanSubscribe(destinations) {
585
- if (this.#state === "closed") {
586
- throw new RelayBusError(`Transport '${this.name}' is closed`);
587
- }
588
- if (this.#state === "subscribed") {
589
- throw new ConfigurationError(
590
- `Transport '${this.name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
591
- );
592
- }
593
- if (this.#groupId === void 0 || this.#groupId.length === 0) {
594
- throw new ConfigurationError(`Transport '${this.name}' needs a groupId to subscribe`);
595
- }
596
- if (destinations.length === 0) {
597
- throw new ConfigurationError(`Transport '${this.name}' subscribed to no topics`);
598
- }
599
- for (const topic of destinations) {
600
- const deadLetterTopic = this.#deadLetterTopicFor(topic);
601
- if (deadLetterTopic !== void 0 && destinations.includes(deadLetterTopic)) {
602
- throw new ConfigurationError(
603
- `Dead letter topic '${deadLetterTopic}' is also consumed, which would loop`
604
- );
605
- }
606
- }
607
- }
608
- /** The dead-letter topic for records of `sourceTopic`, or undefined when the policy does not use one. */
609
- #deadLetterTopicFor(sourceTopic) {
610
- const policy = this.#invalidPolicy;
611
- if (policy === "dead-letter") {
612
- return `${sourceTopic}.dead`;
613
- }
614
- if (typeof policy === "object") {
615
- const { deadLetterTopic } = policy;
616
- return typeof deadLetterTopic === "function" ? deadLetterTopic(sourceTopic) : deadLetterTopic;
617
- }
618
- return void 0;
619
- }
620
- /** Sends with the publish timeout; the late outcome of a timed-out send is ignored. */
621
- async #send(producer, record) {
622
- let timer;
623
- const timeout = new Promise((_resolve, reject) => {
624
- timer = setTimeout(() => {
625
- reject(
626
- new PublishTimeoutError(
627
- `Transport '${this.name}' got no broker ack for topic '${record.topic}' within ${this.#settings.publishTimeoutMs}ms`
628
- )
629
- );
630
- }, this.#settings.publishTimeoutMs);
631
- });
632
- const sending = producer.send(record);
633
- sending.catch(() => void 0);
634
- try {
635
- await Promise.race([sending, timeout]);
636
- } finally {
637
- clearTimeout(timer);
638
- }
639
- }
640
- #connectedProducer() {
641
- if (this.#state === "closed") {
642
- return Promise.reject(new RelayBusError(`Transport '${this.name}' is closed`));
643
- }
644
- this.#producer ??= this.#openProducer();
645
- return this.#producer;
646
- }
647
- async #openProducer() {
648
- const producer = this.#kafka.producer({
649
- ...this.#producerConfig,
650
- idempotent: true,
651
- maxInFlightRequests: this.#maxInFlightRequests
652
- });
653
- try {
654
- await producer.connect();
655
- } catch (error) {
656
- this.#producer = void 0;
657
- throw error;
658
- }
659
- this.#producerConnected = this.#state !== "closed";
660
- return producer;
661
- }
662
- async #handleMessage(payload, onMessage) {
943
+ async #handleMessage(payload, route) {
663
944
  let envelope;
664
945
  try {
665
- envelope = this.#decodeRecord(payload);
946
+ envelope = decodeRecord(this.#settings.codec, payload);
666
947
  } catch (error) {
667
- if (this.#decode === void 0 && !(error instanceof InvalidKafkaMessageError)) {
948
+ if (this.#settings.codec === cloudEventsBinary && !(error instanceof InvalidKafkaMessageError)) {
668
949
  throw toHandlerFailure(error);
669
950
  }
670
- const reason = toErrorInfo(error).message;
671
- await this.#withRetries(payload, "handleInvalid", () => this.#handleInvalid(payload, reason));
951
+ route.hooks?.onInvalidMessage({ destination: payload.topic, error });
952
+ await this.#withRetries(payload, {
953
+ method: "handleInvalid",
954
+ operation: () => this.#handleInvalid(payload, error)
955
+ });
672
956
  return;
673
957
  }
674
- await this.#withRetries(payload, "deliver", async () => {
675
- try {
676
- await onMessage(envelope);
677
- } catch (error) {
678
- if (!(error instanceof NonRetryableError)) {
679
- throw error;
958
+ await this.#withRetries(payload, {
959
+ method: "deliver",
960
+ operation: async () => {
961
+ try {
962
+ await route.onMessage(envelope, { destination: payload.topic });
963
+ } catch (error) {
964
+ if (!(error instanceof NonRetryableError)) {
965
+ throw error;
966
+ }
967
+ await this.#handleInvalid(payload, error);
680
968
  }
681
- await this.#handleInvalid(payload, error.message);
682
969
  }
683
970
  });
684
971
  }
685
- #decodeRecord({ topic, partition, message }) {
686
- if (this.#decode === void 0) {
687
- return decodeEnvelope(message);
688
- }
689
- const record = {
690
- topic,
691
- partition,
692
- offset: message.offset,
693
- key: message.key,
694
- value: message.value,
695
- headers: message.headers,
696
- timestamp: message.timestamp
697
- };
698
- return completeDecodedEvent(this.#decode(record), record);
699
- }
700
- async #handleInvalid(payload, reason) {
972
+ async #handleInvalid(payload, error) {
701
973
  const { topic, partition, message } = payload;
974
+ const { message: reason, name, code } = toErrorInfo(error);
702
975
  const location = `topic=${topic} partition=${partition} offset=${message.offset}`;
703
- const policy = this.#invalidPolicy;
704
- const deadLetterTopic = this.#deadLetterTopicFor(topic);
976
+ const errorFields = `errorName=${name}${code === void 0 ? "" : ` code=${code}`}`;
977
+ const policy = this.#settings.invalidPolicy;
978
+ const deadLetterTopic = deadLetterTopicFor(policy, topic);
705
979
  if (policy === "skip") {
706
- this.#logger.error(
707
- `[KafkaTransport](handleInvalid) Skipping malformed message | ${location} reason=${reason}`
980
+ this.#settings.logger.error(
981
+ `[KafkaTransport](handleInvalid) Skipping malformed message | ${location} reason=${reason} ${errorFields}`
708
982
  );
709
983
  } else if (typeof policy === "function") {
710
984
  await policy({
@@ -716,26 +990,16 @@ var KafkaTransport = class {
716
990
  value: message.value
717
991
  });
718
992
  } else {
719
- const producer = await this.#connectedProducer();
720
- await this.#send(producer, {
721
- topic: deadLetterTopic,
722
- acks: -1,
723
- messages: [
724
- {
725
- key: message.key === null ? null : Buffer.from(message.key),
726
- value: message.value === null ? null : Buffer.from(message.value),
727
- headers: {
728
- ...toOutgoingHeaders(message.headers),
729
- "relaybus-invalid-reason": reason,
730
- "relaybus-source-topic": topic,
731
- "relaybus-source-partition": String(partition),
732
- "relaybus-source-offset": message.offset
733
- }
734
- }
735
- ]
993
+ const record = deadLetterRecord(payload, {
994
+ reason,
995
+ name,
996
+ code,
997
+ deadLetterTopic
736
998
  });
737
- this.#logger.error(
738
- `[KafkaTransport](handleInvalid) Dead-lettered malformed message | ${location} deadLetterTopic=${deadLetterTopic} reason=${reason}`
999
+ const producer = await this.#producer.connected();
1000
+ await this.#producer.send(producer, record);
1001
+ this.#settings.logger.error(
1002
+ `[KafkaTransport](handleInvalid) Dead-lettered malformed message | ${location} deadLetterTopic=${deadLetterTopic} reason=${reason} ${errorFields}`
739
1003
  );
740
1004
  }
741
1005
  }
@@ -744,8 +1008,9 @@ var KafkaTransport = class {
744
1008
  * last error is rethrown so the client leaves the offset uncommitted and
745
1009
  * redelivers the record; heartbeats between attempts keep the session alive.
746
1010
  */
747
- async #withRetries(payload, method, operation) {
748
- const { maxAttempts, initialBackoffMs, maxBackoffMs } = this.#settings;
1011
+ async #withRetries(payload, { method, operation }) {
1012
+ const { maxAttempts, processingBackoff } = this.#settings.timings;
1013
+ const { logger } = this.#settings;
749
1014
  const location = `topic=${payload.topic} partition=${payload.partition} offset=${payload.message.offset}`;
750
1015
  const partitionKey = `${payload.topic}:${payload.partition}`;
751
1016
  for (let attempt = 1; ; attempt += 1) {
@@ -757,51 +1022,28 @@ var KafkaTransport = class {
757
1022
  const reason = toErrorInfo(error).message;
758
1023
  if (attempt >= maxAttempts || this.#state === "closed") {
759
1024
  this.#stalledPartitions.add(partitionKey);
760
- this.#logger.error(
761
- `[KafkaTransport](${method}) Giving up for now, the record stays uncommitted and will be redelivered | ${location} attempts=${attempt} error=${reason}`
1025
+ logger.error(
1026
+ `[KafkaTransport](withRetries) Giving up for now, the record stays uncommitted and will be redelivered | operation=${method} ${location} attempts=${attempt} error=${reason}`
762
1027
  );
763
1028
  throw toHandlerFailure(error);
764
1029
  }
765
- this.#logger.warn(
766
- `[KafkaTransport](${method}) Attempt failed, retrying | ${location} attempt=${attempt} error=${reason}`
767
- );
768
- await this.#sleepWithHeartbeats(
769
- Math.min(initialBackoffMs * 2 ** (attempt - 1), maxBackoffMs),
770
- payload
1030
+ logger.warn(
1031
+ `[KafkaTransport](withRetries) Attempt failed, retrying | operation=${method} ${location} attempt=${attempt} error=${reason}`
771
1032
  );
1033
+ await this.#sleeps.sleepInSlices(processingBackoff.delayFor(attempt), {
1034
+ sliceMs: this.#settings.timings.heartbeatIntervalMs,
1035
+ between: () => payload.heartbeat(),
1036
+ stopped: () => this.#state === "closed"
1037
+ });
772
1038
  }
773
1039
  }
774
1040
  }
775
- /** Sleeps in slices and heartbeats after each, so a long backoff never outlives the session timeout. */
776
- async #sleepWithHeartbeats(milliseconds, payload) {
777
- let remaining = milliseconds;
778
- do {
779
- const slice = Math.min(remaining, this.#settings.heartbeatIntervalMs);
780
- await this.#sleep(slice);
781
- await payload.heartbeat();
782
- remaining -= slice;
783
- } while (remaining > 0 && this.#state !== "closed");
784
- }
785
- #sleep(milliseconds) {
786
- return new Promise((resolve) => {
787
- const wake = () => {
788
- clearTimeout(timer);
789
- this.#sleepers.delete(wake);
790
- resolve();
791
- };
792
- const timer = setTimeout(wake, milliseconds);
793
- this.#sleepers.add(wake);
794
- });
795
- }
796
- /** Whether the client exposes the instrumentation needed to follow the group membership. */
797
- #tracksMembership(consumer) {
798
- return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
799
- }
800
1041
  #observeConsumer(consumer) {
801
- if (!this.#tracksMembership(consumer)) {
1042
+ if (!tracksMembership(consumer)) {
802
1043
  this.#groupJoined = true;
803
1044
  return;
804
1045
  }
1046
+ const { logger, groupId } = this.#settings;
805
1047
  const names = consumer.events;
806
1048
  const listen = (name, listener) => {
807
1049
  if (name !== void 0) {
@@ -810,29 +1052,28 @@ var KafkaTransport = class {
810
1052
  };
811
1053
  listen(names.GROUP_JOIN, () => {
812
1054
  this.#groupJoined = true;
813
- this.#joinWaiter?.resolve();
814
- this.#logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${this.#groupId}`);
1055
+ this.#groupJoin.joined();
1056
+ logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
815
1057
  });
816
1058
  listen(names.REBALANCING, () => {
817
1059
  this.#groupJoined = false;
818
1060
  this.#stalledPartitions.clear();
819
- this.#logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${this.#groupId}`);
1061
+ logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
820
1062
  });
821
1063
  listen(names.CRASH, (event) => this.#handleCrash(event));
822
- listen(names.STOP, () => {
1064
+ const leftGroup = () => {
823
1065
  this.#groupJoined = false;
824
- });
825
- listen(names.DISCONNECT, () => {
826
- this.#groupJoined = false;
827
- });
1066
+ };
1067
+ listen(names.STOP, leftGroup);
1068
+ listen(names.DISCONNECT, leftGroup);
828
1069
  }
829
1070
  #handleCrash(event) {
830
1071
  const { error, restart } = event.payload ?? {};
831
1072
  this.#groupJoined = false;
832
- this.#logger.error(
833
- `[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}`
834
1075
  );
835
- this.#joinWaiter?.reject(error);
1076
+ this.#groupJoin.failed(error);
836
1077
  if (restart === false && this.#consuming && this.#restartLoop === void 0) {
837
1078
  this.#restartLoop = this.#restartConsumer().finally(() => {
838
1079
  this.#restartLoop = void 0;
@@ -844,128 +1085,29 @@ var KafkaTransport = class {
844
1085
  * again, with bounded backoff, until it joined or the transport closed.
845
1086
  */
846
1087
  async #restartConsumer() {
847
- const { restartInitialBackoffMs, restartMaxBackoffMs } = this.#settings;
1088
+ const { logger, groupId, timings } = this.#settings;
848
1089
  const consumer = this.#consumer;
849
1090
  for (let attempt = 1; this.#state === "subscribed"; attempt += 1) {
850
- await this.#sleep(
851
- Math.min(restartInitialBackoffMs * 2 ** (attempt - 1), restartMaxBackoffMs)
852
- );
1091
+ await this.#sleeps.sleep(timings.restartBackoff.delayFor(attempt));
853
1092
  if (this.#state !== "subscribed") {
854
1093
  return;
855
1094
  }
856
1095
  await consumer.disconnect().catch(() => void 0);
857
1096
  try {
858
1097
  await this.#startConsuming();
859
- this.#logger.log(
860
- `[KafkaTransport](restartConsumer) Consumer restarted | group=${this.#groupId} attempt=${attempt}`
1098
+ logger.log(
1099
+ `[KafkaTransport](restartConsumer) Consumer restarted | group=${groupId} attempt=${attempt}`
861
1100
  );
862
1101
  return;
863
1102
  } catch (error) {
864
- this.#logger.error(
865
- `[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}`
866
1105
  );
867
1106
  }
868
1107
  }
869
1108
  }
870
- #waitForGroupJoin(consumer) {
871
- if (!this.#tracksMembership(consumer)) {
872
- return { promise: Promise.resolve(), dispose: () => void 0 };
873
- }
874
- let timer;
875
- const promise = new Promise((resolve, reject) => {
876
- this.#joinWaiter = { resolve, reject };
877
- timer = setTimeout(() => {
878
- reject(
879
- new RelayBusError(
880
- `Consumer of transport '${this.name}' did not join group '${this.#groupId}' within ${this.#settings.startupTimeoutMs}ms`
881
- )
882
- );
883
- }, this.#settings.startupTimeoutMs);
884
- });
885
- promise.catch(() => void 0);
886
- return {
887
- promise,
888
- dispose: () => {
889
- clearTimeout(timer);
890
- this.#joinWaiter = void 0;
891
- }
892
- };
893
- }
894
1109
  };
895
- function resolveSettings(options) {
896
- const maxAttempts = options.processing?.maxAttempts ?? DEFAULTS.maxAttempts;
897
- if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
898
- throw new ConfigurationError(
899
- `processing.maxAttempts must be a positive integer, received ${maxAttempts}`
900
- );
901
- }
902
- return {
903
- maxAttempts,
904
- initialBackoffMs: toMilliseconds(
905
- options.processing?.initialBackoff ?? DEFAULTS.initialBackoff,
906
- "processing.initialBackoff"
907
- ),
908
- maxBackoffMs: toMilliseconds(
909
- options.processing?.maxBackoff ?? DEFAULTS.maxBackoff,
910
- "processing.maxBackoff"
911
- ),
912
- startupTimeoutMs: toMilliseconds(
913
- options.startupTimeout ?? DEFAULTS.startupTimeout,
914
- "startupTimeout"
915
- ),
916
- publishTimeoutMs: toMilliseconds(
917
- options.publishTimeout ?? DEFAULTS.publishTimeout,
918
- "publishTimeout"
919
- ),
920
- heartbeatIntervalMs: Math.max(
921
- 1,
922
- toMilliseconds(
923
- options.processing?.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
924
- "processing.heartbeatInterval"
925
- )
926
- ),
927
- restartInitialBackoffMs: toMilliseconds(
928
- options.restart?.initialBackoff ?? DEFAULTS.restartInitialBackoff,
929
- "restart.initialBackoff"
930
- ),
931
- restartMaxBackoffMs: toMilliseconds(
932
- options.restart?.maxBackoff ?? DEFAULTS.restartMaxBackoff,
933
- "restart.maxBackoff"
934
- )
935
- };
936
- }
937
- function toHandlerFailure(error) {
938
- const { name, message } = toErrorInfo(error);
939
- return new HandlerFailedError(`${name}: ${message}`);
940
- }
941
- function fromHeader(name) {
942
- if (name.length === 0) {
943
- throw new ConfigurationError("fromHeader needs a header name");
944
- }
945
- return (message) => Object.hasOwn(message.headers, name) ? message.headers[name] : void 0;
946
- }
947
- function fromPayloadField(path) {
948
- const segments = path.split(".");
949
- if (segments.some((segment) => segment.length === 0)) {
950
- throw new ConfigurationError(
951
- `fromPayloadField needs a dotted path of non-empty names, received '${path}'`
952
- );
953
- }
954
- return (message) => {
955
- let value = message.payload;
956
- for (const segment of segments) {
957
- if (typeof value !== "object" || value === null || !Object.hasOwn(value, segment)) {
958
- return void 0;
959
- }
960
- value = value[segment];
961
- }
962
- if (typeof value === "string") {
963
- return value;
964
- }
965
- return Number.isSafeInteger(value) ? String(value) : void 0;
966
- };
967
- }
968
1110
 
969
- export { HandlerFailedError, InvalidKafkaMessageError, KafkaTransport, PublishTimeoutError, UnencodableEventError, decodeCloudEvent, decodeEnvelope, encodeEnvelope, fromHeader, fromPayloadField, plainJsonDecoder };
1111
+ export { GroupJoinTimeoutError, HandlerFailedError, InvalidKafkaMessageError, KafkaTransport, PublishTimeoutError, TransportClosedError, UnencodableEventError, cloudEventsBinary, cloudEventsStructured, decodeCloudEvent, decodeEnvelope, encodeEnvelope, jsonEnvelope, plainJsonDecoder };
970
1112
  //# sourceMappingURL=index.js.map
971
1113
  //# sourceMappingURL=index.js.map