@relaybus/kafka 0.0.0-stage → 0.0.0

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.cjs ADDED
@@ -0,0 +1,719 @@
1
+ 'use strict';
2
+
3
+ var core = require('@relaybus/core');
4
+
5
+ // src/cloudevents-codec.ts
6
+ var InvalidKafkaMessageError = class extends core.RelayBusError {
7
+ };
8
+ var UnencodableEventError = class extends core.NonRetryableError {
9
+ name = "UnencodableEventError";
10
+ };
11
+ var SPEC_VERSION = "1.0";
12
+ var CONTENT_TYPE = "content-type";
13
+ var JSON_MEDIA_TYPE = "application/json";
14
+ var CLOUDEVENTS_PREFIX = "ce_";
15
+ var ESCAPE_PREFIX = "x-relaybus-h-";
16
+ var RESERVED_PREFIXES = [CLOUDEVENTS_PREFIX, "x-relaybus-"];
17
+ var HEADER = {
18
+ specVersion: "ce_specversion",
19
+ id: "ce_id",
20
+ type: "ce_type",
21
+ source: "ce_source",
22
+ time: "ce_time",
23
+ subject: "ce_subject",
24
+ correlationId: "ce_correlationid",
25
+ causationId: "ce_causationid"
26
+ };
27
+ var PROTOCOL_HEADERS = /* @__PURE__ */ new Set([...Object.values(HEADER), CONTENT_TYPE]);
28
+ var RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i;
29
+ var strictUtf8 = new TextDecoder("utf-8", { fatal: true });
30
+ var lenientUtf8 = new TextDecoder("utf-8");
31
+ function encodeEnvelope(envelope) {
32
+ const value = serializePayload(envelope);
33
+ const entries = Object.entries(envelope.headers).map(
34
+ ([name, headerValue]) => [escapeUserHeaderName(name), headerValue]
35
+ );
36
+ entries.push(
37
+ [HEADER.specVersion, SPEC_VERSION],
38
+ [HEADER.id, envelope.id],
39
+ [HEADER.type, envelope.type],
40
+ [HEADER.source, envelope.source],
41
+ [HEADER.time, formatTime(envelope)],
42
+ [CONTENT_TYPE, JSON_MEDIA_TYPE]
43
+ );
44
+ if (envelope.key !== null) {
45
+ entries.push([HEADER.subject, envelope.key]);
46
+ }
47
+ if (envelope.correlationId !== null) {
48
+ entries.push([HEADER.correlationId, envelope.correlationId]);
49
+ }
50
+ if (envelope.causationId !== null) {
51
+ entries.push([HEADER.causationId, envelope.causationId]);
52
+ }
53
+ return { key: envelope.key, value, headers: Object.fromEntries(entries) };
54
+ }
55
+ function serializePayload(envelope) {
56
+ let value;
57
+ try {
58
+ value = JSON.stringify(envelope.payload);
59
+ } catch (error) {
60
+ throw new UnencodableEventError(
61
+ `Event ${envelope.id} has a payload that JSON cannot represent: ${error.message}`
62
+ );
63
+ }
64
+ if (value === void 0) {
65
+ throw new UnencodableEventError(
66
+ `Event ${envelope.id} has a payload that JSON cannot represent (undefined, a function or a symbol)`
67
+ );
68
+ }
69
+ return value;
70
+ }
71
+ function formatTime(envelope) {
72
+ const year = envelope.occurredAt.getUTCFullYear();
73
+ if (Number.isNaN(year) || year < 0 || year > 9999) {
74
+ throw new UnencodableEventError(
75
+ `Event ${envelope.id} has an occurredAt that is not an RFC 3339 instant (invalid date or year outside 0..9999)`
76
+ );
77
+ }
78
+ return envelope.occurredAt.toISOString();
79
+ }
80
+ function decodeEnvelope(message) {
81
+ const headers = readHeaders(message.headers);
82
+ if (headers.get(HEADER.specVersion) !== SPEC_VERSION) {
83
+ throw invalid(`${HEADER.specVersion} must be '${SPEC_VERSION}'`);
84
+ }
85
+ const type = requireHeader(headers, HEADER.type);
86
+ try {
87
+ core.assertEventType(type);
88
+ } catch (error) {
89
+ throw invalid(`${HEADER.type} is not a valid event type: ${error.message}`);
90
+ }
91
+ assertJsonContentType(headers.get(CONTENT_TYPE));
92
+ return {
93
+ id: requireHeader(headers, HEADER.id),
94
+ type,
95
+ source: requireHeader(headers, HEADER.source),
96
+ key: resolveKey(headers.get(HEADER.subject), message.key),
97
+ payload: parsePayload(message.value),
98
+ headers: collectUserHeaders(headers),
99
+ correlationId: headers.get(HEADER.correlationId) ?? null,
100
+ causationId: headers.get(HEADER.causationId) ?? null,
101
+ occurredAt: resolveTime(headers.get(HEADER.time), message.timestamp)
102
+ };
103
+ }
104
+ function invalid(reason) {
105
+ return new InvalidKafkaMessageError(reason);
106
+ }
107
+ function escapeUserHeaderName(name) {
108
+ const lowered = name.toLowerCase();
109
+ const reserved = lowered === CONTENT_TYPE || RESERVED_PREFIXES.some((p) => lowered.startsWith(p));
110
+ return reserved ? `${ESCAPE_PREFIX}${name}` : name;
111
+ }
112
+ function readHeaders(incoming) {
113
+ const headers = /* @__PURE__ */ new Map();
114
+ for (const [name, raw] of Object.entries(incoming ?? {})) {
115
+ if (raw === void 0) {
116
+ continue;
117
+ }
118
+ if (typeof raw !== "string" && !(raw instanceof Uint8Array)) {
119
+ throw invalid(`Header '${name}' is repeated`);
120
+ }
121
+ headers.set(name, decodeText(raw, PROTOCOL_HEADERS.has(name), `Header '${name}'`));
122
+ }
123
+ return headers;
124
+ }
125
+ function decodeText(raw, strict, label) {
126
+ if (typeof raw === "string") {
127
+ return raw;
128
+ }
129
+ try {
130
+ return (strict ? strictUtf8 : lenientUtf8).decode(raw);
131
+ } catch {
132
+ throw invalid(`${label} is not valid UTF-8`);
133
+ }
134
+ }
135
+ function requireHeader(headers, name) {
136
+ const value = headers.get(name);
137
+ if (value === void 0 || value.length === 0) {
138
+ throw invalid(`Header '${name}' is missing or empty`);
139
+ }
140
+ return value;
141
+ }
142
+ function assertJsonContentType(contentType) {
143
+ const mediaType = contentType?.split(";")[0]?.trim().toLowerCase();
144
+ if (mediaType !== JSON_MEDIA_TYPE) {
145
+ throw invalid(`${CONTENT_TYPE} must be '${JSON_MEDIA_TYPE}', received '${contentType ?? ""}'`);
146
+ }
147
+ }
148
+ function resolveTime(text, recordTimestamp) {
149
+ if (text !== void 0 && text.length > 0) {
150
+ return parseTime(text);
151
+ }
152
+ const milliseconds = Number(recordTimestamp);
153
+ const time = new Date(milliseconds);
154
+ if (recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime())) {
155
+ throw invalid(`${HEADER.time} is missing and the record has no usable timestamp`);
156
+ }
157
+ return time;
158
+ }
159
+ function parseTime(text) {
160
+ const time = new Date(text);
161
+ if (!RFC3339_PATTERN.test(text) || Number.isNaN(time.getTime())) {
162
+ throw invalid(`${HEADER.time} is not an RFC 3339 timestamp: '${text}'`);
163
+ }
164
+ return time;
165
+ }
166
+ function parsePayload(value) {
167
+ if (value === null) {
168
+ throw invalid("Message has no value");
169
+ }
170
+ const text = decodeText(value, true, "Message value");
171
+ try {
172
+ return JSON.parse(text);
173
+ } catch {
174
+ throw invalid("Message value is not valid JSON");
175
+ }
176
+ }
177
+ function resolveKey(subject, recordKey) {
178
+ const keyText = recordKey === null ? null : decodeText(recordKey, true, "Message key");
179
+ if (subject !== void 0 && keyText !== null && subject !== keyText) {
180
+ throw invalid(`${HEADER.subject} does not match the message key`);
181
+ }
182
+ return subject ?? keyText;
183
+ }
184
+ function collectUserHeaders(headers) {
185
+ const entries = [];
186
+ for (const [name, value] of headers) {
187
+ if (!PROTOCOL_HEADERS.has(name)) {
188
+ entries.push([
189
+ name.startsWith(ESCAPE_PREFIX) ? name.slice(ESCAPE_PREFIX.length) : name,
190
+ value
191
+ ]);
192
+ }
193
+ }
194
+ return Object.fromEntries(entries);
195
+ }
196
+ function toOutgoingHeaders(incoming) {
197
+ const entries = [];
198
+ for (const [name, raw] of Object.entries(incoming ?? {})) {
199
+ if (raw !== void 0) {
200
+ entries.push([
201
+ name,
202
+ Array.isArray(raw) ? raw.map(toBufferOrString) : toBufferOrString(raw)
203
+ ]);
204
+ }
205
+ }
206
+ return Object.fromEntries(entries);
207
+ }
208
+ function toBufferOrString(value) {
209
+ return typeof value === "string" ? value : Buffer.from(value);
210
+ }
211
+ var PublishTimeoutError = class extends core.RelayBusError {
212
+ name = "PublishTimeoutError";
213
+ };
214
+ var HandlerFailedError = class extends core.RelayBusError {
215
+ name = "HandlerFailedError";
216
+ };
217
+ var DEFAULTS = {
218
+ name: "kafka",
219
+ maxInFlightRequests: 1,
220
+ maxAttempts: 5,
221
+ initialBackoff: "200ms",
222
+ maxBackoff: "5s",
223
+ startupTimeout: "30s",
224
+ publishTimeout: "30s",
225
+ heartbeatInterval: "1s",
226
+ restartInitialBackoff: "1s",
227
+ restartMaxBackoff: "30s"
228
+ };
229
+ var KafkaTransport = class {
230
+ name;
231
+ #kafka;
232
+ #groupId;
233
+ #fromBeginning;
234
+ #maxInFlightRequests;
235
+ #producerConfig;
236
+ #consumerConfig;
237
+ #invalidPolicy;
238
+ #logger;
239
+ #settings;
240
+ #sleepers = /* @__PURE__ */ new Set();
241
+ #state = "idle";
242
+ #producer;
243
+ #consumer;
244
+ #groupJoined = false;
245
+ #consuming = false;
246
+ #producerConnected = false;
247
+ #restartLoop;
248
+ #stalledPartitions = /* @__PURE__ */ new Set();
249
+ #startConsuming;
250
+ #joinWaiter;
251
+ constructor(options) {
252
+ this.name = options.name ?? DEFAULTS.name;
253
+ this.#kafka = options.kafka;
254
+ this.#groupId = options.groupId;
255
+ this.#fromBeginning = options.fromBeginning ?? true;
256
+ this.#maxInFlightRequests = options.maxInFlightRequests ?? DEFAULTS.maxInFlightRequests;
257
+ if (!Number.isInteger(this.#maxInFlightRequests) || this.#maxInFlightRequests < 1 || this.#maxInFlightRequests > 5) {
258
+ throw new core.ConfigurationError(
259
+ `maxInFlightRequests must be an integer from 1 to 5, received ${this.#maxInFlightRequests}`
260
+ );
261
+ }
262
+ this.#producerConfig = options.producerConfig ?? {};
263
+ this.#consumerConfig = options.consumerConfig ?? {};
264
+ this.#invalidPolicy = options.onInvalidMessage ?? "dead-letter";
265
+ this.#logger = options.logger ?? console;
266
+ this.#settings = resolveSettings(options);
267
+ }
268
+ /**
269
+ * True while the consumer is joined to its group, not between a crash or
270
+ * rebalance and the next join, and no partition is stalled on a record whose
271
+ * handler keeps failing. Use it for readiness so a consumer that silently
272
+ * stopped is noticed.
273
+ */
274
+ isConsuming() {
275
+ return this.#state === "subscribed" && this.#groupJoined && this.#stalledPartitions.size === 0;
276
+ }
277
+ /**
278
+ * True once the producer is connected and, if the transport subscribed, the
279
+ * consumer is consuming. False before `connect`, after `close` and while a
280
+ * dead consumer is being restarted.
281
+ */
282
+ isReady() {
283
+ if (this.#state === "closed" || !this.#producerConnected) {
284
+ return false;
285
+ }
286
+ return this.#state === "idle" || this.isConsuming();
287
+ }
288
+ /** Connects the producer. Called by the bus on start; `publish` also connects on demand. */
289
+ async connect() {
290
+ await this.#connectedProducer();
291
+ }
292
+ async publish(envelope, destination) {
293
+ const producer = await this.#connectedProducer();
294
+ const { key, value, headers } = encodeEnvelope(envelope);
295
+ await this.#send(producer, {
296
+ topic: destination,
297
+ acks: -1,
298
+ messages: [{ key, value, headers }]
299
+ });
300
+ }
301
+ /**
302
+ * Starts the consumer and resolves once it joined its group, so a start-up
303
+ * that cannot consume fails loudly instead of looking healthy.
304
+ *
305
+ * @throws ConfigurationError when `groupId` is missing, `destinations` is empty
306
+ * or the transport already subscribed (create one transport per subscription).
307
+ */
308
+ async subscribe(destinations, onMessage) {
309
+ this.#assertCanSubscribe(destinations);
310
+ this.#state = "subscribed";
311
+ const consumer = this.#kafka.consumer({ ...this.#consumerConfig, groupId: this.#groupId });
312
+ this.#consumer = consumer;
313
+ this.#observeConsumer(consumer);
314
+ const start = () => this.#startConsumer(consumer, [...destinations], onMessage);
315
+ this.#startConsuming = start;
316
+ try {
317
+ await start();
318
+ } catch (error) {
319
+ await consumer.disconnect().catch(() => void 0);
320
+ this.#groupJoined = false;
321
+ this.#consumer = void 0;
322
+ this.#state = "idle";
323
+ throw error;
324
+ }
325
+ this.#consuming = true;
326
+ this.#logger.log(
327
+ `[KafkaTransport](subscribe) Consuming | transport=${this.name} group=${this.#groupId} topics=${destinations.join(",")}`
328
+ );
329
+ }
330
+ async #startConsumer(consumer, destinations, onMessage) {
331
+ await consumer.connect();
332
+ await consumer.subscribe({ topics: destinations, fromBeginning: this.#fromBeginning });
333
+ const joined = this.#waitForGroupJoin(consumer);
334
+ try {
335
+ await consumer.run({ eachMessage: (payload) => this.#handleMessage(payload, onMessage) });
336
+ await joined.promise;
337
+ } finally {
338
+ joined.dispose();
339
+ }
340
+ }
341
+ /** Stops consuming and disconnects the consumer and the producer. Safe to call twice. */
342
+ async close() {
343
+ if (this.#state === "closed") {
344
+ return;
345
+ }
346
+ this.#state = "closed";
347
+ const pendingProducer = this.#producer;
348
+ this.#groupJoined = false;
349
+ this.#producerConnected = false;
350
+ for (const wake of this.#sleepers) {
351
+ wake();
352
+ }
353
+ this.#joinWaiter?.reject(new core.RelayBusError(`Transport '${this.name}' is closed`));
354
+ await this.#restartLoop;
355
+ const producer = await pendingProducer?.catch(() => void 0);
356
+ const results = await Promise.allSettled([
357
+ this.#consumer?.disconnect(),
358
+ producer?.disconnect()
359
+ ]);
360
+ const failure = results.find((result) => result.status === "rejected");
361
+ if (failure !== void 0) {
362
+ throw failure.reason;
363
+ }
364
+ }
365
+ #assertCanSubscribe(destinations) {
366
+ if (this.#state === "closed") {
367
+ throw new core.RelayBusError(`Transport '${this.name}' is closed`);
368
+ }
369
+ if (this.#state === "subscribed") {
370
+ throw new core.ConfigurationError(
371
+ `Transport '${this.name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
372
+ );
373
+ }
374
+ if (this.#groupId === void 0 || this.#groupId.length === 0) {
375
+ throw new core.ConfigurationError(`Transport '${this.name}' needs a groupId to subscribe`);
376
+ }
377
+ if (destinations.length === 0) {
378
+ throw new core.ConfigurationError(`Transport '${this.name}' subscribed to no topics`);
379
+ }
380
+ for (const topic of destinations) {
381
+ const deadLetterTopic = this.#deadLetterTopicFor(topic);
382
+ if (deadLetterTopic !== void 0 && destinations.includes(deadLetterTopic)) {
383
+ throw new core.ConfigurationError(
384
+ `Dead letter topic '${deadLetterTopic}' is also consumed, which would loop`
385
+ );
386
+ }
387
+ }
388
+ }
389
+ /** The dead-letter topic for records of `sourceTopic`, or undefined when the policy does not use one. */
390
+ #deadLetterTopicFor(sourceTopic) {
391
+ const policy = this.#invalidPolicy;
392
+ if (policy === "dead-letter") {
393
+ return `${sourceTopic}.dead`;
394
+ }
395
+ if (typeof policy === "object") {
396
+ const { deadLetterTopic } = policy;
397
+ return typeof deadLetterTopic === "function" ? deadLetterTopic(sourceTopic) : deadLetterTopic;
398
+ }
399
+ return void 0;
400
+ }
401
+ /** Sends with the publish timeout; the late outcome of a timed-out send is ignored. */
402
+ async #send(producer, record) {
403
+ let timer;
404
+ const timeout = new Promise((_resolve, reject) => {
405
+ timer = setTimeout(() => {
406
+ reject(
407
+ new PublishTimeoutError(
408
+ `Transport '${this.name}' got no broker ack for topic '${record.topic}' within ${this.#settings.publishTimeoutMs}ms`
409
+ )
410
+ );
411
+ }, this.#settings.publishTimeoutMs);
412
+ });
413
+ const sending = producer.send(record);
414
+ sending.catch(() => void 0);
415
+ try {
416
+ await Promise.race([sending, timeout]);
417
+ } finally {
418
+ clearTimeout(timer);
419
+ }
420
+ }
421
+ #connectedProducer() {
422
+ if (this.#state === "closed") {
423
+ return Promise.reject(new core.RelayBusError(`Transport '${this.name}' is closed`));
424
+ }
425
+ this.#producer ??= this.#openProducer();
426
+ return this.#producer;
427
+ }
428
+ async #openProducer() {
429
+ const producer = this.#kafka.producer({
430
+ ...this.#producerConfig,
431
+ idempotent: true,
432
+ maxInFlightRequests: this.#maxInFlightRequests
433
+ });
434
+ try {
435
+ await producer.connect();
436
+ } catch (error) {
437
+ this.#producer = void 0;
438
+ throw error;
439
+ }
440
+ this.#producerConnected = this.#state !== "closed";
441
+ return producer;
442
+ }
443
+ async #handleMessage(payload, onMessage) {
444
+ let envelope;
445
+ try {
446
+ envelope = decodeEnvelope(payload.message);
447
+ } catch (error) {
448
+ if (!(error instanceof InvalidKafkaMessageError)) {
449
+ throw toHandlerFailure(error);
450
+ }
451
+ await this.#withRetries(
452
+ payload,
453
+ "handleInvalid",
454
+ () => this.#handleInvalid(payload, error.message)
455
+ );
456
+ return;
457
+ }
458
+ await this.#withRetries(payload, "deliver", async () => {
459
+ try {
460
+ await onMessage(envelope);
461
+ } catch (error) {
462
+ if (!(error instanceof core.NonRetryableError)) {
463
+ throw error;
464
+ }
465
+ await this.#handleInvalid(payload, error.message);
466
+ }
467
+ });
468
+ }
469
+ async #handleInvalid(payload, reason) {
470
+ const { topic, partition, message } = payload;
471
+ const location = `topic=${topic} partition=${partition} offset=${message.offset}`;
472
+ const policy = this.#invalidPolicy;
473
+ const deadLetterTopic = this.#deadLetterTopicFor(topic);
474
+ if (policy === "skip") {
475
+ this.#logger.error(
476
+ `[KafkaTransport](handleInvalid) Skipping malformed message | ${location} reason=${reason}`
477
+ );
478
+ } else if (typeof policy === "function") {
479
+ await policy({
480
+ topic,
481
+ partition,
482
+ offset: message.offset,
483
+ reason,
484
+ key: message.key,
485
+ value: message.value
486
+ });
487
+ } else {
488
+ const producer = await this.#connectedProducer();
489
+ await this.#send(producer, {
490
+ topic: deadLetterTopic,
491
+ acks: -1,
492
+ messages: [
493
+ {
494
+ key: message.key === null ? null : Buffer.from(message.key),
495
+ value: message.value === null ? null : Buffer.from(message.value),
496
+ headers: {
497
+ ...toOutgoingHeaders(message.headers),
498
+ "relaybus-invalid-reason": reason,
499
+ "relaybus-source-topic": topic,
500
+ "relaybus-source-partition": String(partition),
501
+ "relaybus-source-offset": message.offset
502
+ }
503
+ }
504
+ ]
505
+ });
506
+ this.#logger.error(
507
+ `[KafkaTransport](handleInvalid) Dead-lettered malformed message | ${location} deadLetterTopic=${deadLetterTopic} reason=${reason}`
508
+ );
509
+ }
510
+ }
511
+ /**
512
+ * Runs `operation` up to `maxAttempts` times with exponential backoff. The
513
+ * last error is rethrown so the client leaves the offset uncommitted and
514
+ * redelivers the record; heartbeats between attempts keep the session alive.
515
+ */
516
+ async #withRetries(payload, method, operation) {
517
+ const { maxAttempts, initialBackoffMs, maxBackoffMs } = this.#settings;
518
+ const location = `topic=${payload.topic} partition=${payload.partition} offset=${payload.message.offset}`;
519
+ const partitionKey = `${payload.topic}:${payload.partition}`;
520
+ for (let attempt = 1; ; attempt += 1) {
521
+ try {
522
+ await operation();
523
+ this.#stalledPartitions.delete(partitionKey);
524
+ return;
525
+ } catch (error) {
526
+ const reason = core.toErrorInfo(error).message;
527
+ if (attempt >= maxAttempts || this.#state === "closed") {
528
+ this.#stalledPartitions.add(partitionKey);
529
+ this.#logger.error(
530
+ `[KafkaTransport](${method}) Giving up for now, the record stays uncommitted and will be redelivered | ${location} attempts=${attempt} error=${reason}`
531
+ );
532
+ throw toHandlerFailure(error);
533
+ }
534
+ this.#logger.warn(
535
+ `[KafkaTransport](${method}) Attempt failed, retrying | ${location} attempt=${attempt} error=${reason}`
536
+ );
537
+ await this.#sleepWithHeartbeats(
538
+ Math.min(initialBackoffMs * 2 ** (attempt - 1), maxBackoffMs),
539
+ payload
540
+ );
541
+ }
542
+ }
543
+ }
544
+ /** Sleeps in slices and heartbeats after each, so a long backoff never outlives the session timeout. */
545
+ async #sleepWithHeartbeats(milliseconds, payload) {
546
+ let remaining = milliseconds;
547
+ do {
548
+ const slice = Math.min(remaining, this.#settings.heartbeatIntervalMs);
549
+ await this.#sleep(slice);
550
+ await payload.heartbeat();
551
+ remaining -= slice;
552
+ } while (remaining > 0 && this.#state !== "closed");
553
+ }
554
+ #sleep(milliseconds) {
555
+ return new Promise((resolve) => {
556
+ const wake = () => {
557
+ clearTimeout(timer);
558
+ this.#sleepers.delete(wake);
559
+ resolve();
560
+ };
561
+ const timer = setTimeout(wake, milliseconds);
562
+ this.#sleepers.add(wake);
563
+ });
564
+ }
565
+ /** Whether the client exposes the instrumentation needed to follow the group membership. */
566
+ #tracksMembership(consumer) {
567
+ return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
568
+ }
569
+ #observeConsumer(consumer) {
570
+ if (!this.#tracksMembership(consumer)) {
571
+ this.#groupJoined = true;
572
+ return;
573
+ }
574
+ const names = consumer.events;
575
+ const listen = (name, listener) => {
576
+ if (name !== void 0) {
577
+ consumer.on(name, listener);
578
+ }
579
+ };
580
+ listen(names.GROUP_JOIN, () => {
581
+ this.#groupJoined = true;
582
+ this.#joinWaiter?.resolve();
583
+ this.#logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${this.#groupId}`);
584
+ });
585
+ listen(names.REBALANCING, () => {
586
+ this.#groupJoined = false;
587
+ this.#stalledPartitions.clear();
588
+ this.#logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${this.#groupId}`);
589
+ });
590
+ listen(names.CRASH, (event) => this.#handleCrash(event));
591
+ listen(names.STOP, () => {
592
+ this.#groupJoined = false;
593
+ });
594
+ listen(names.DISCONNECT, () => {
595
+ this.#groupJoined = false;
596
+ });
597
+ }
598
+ #handleCrash(event) {
599
+ const { error, restart } = event.payload ?? {};
600
+ this.#groupJoined = false;
601
+ this.#logger.error(
602
+ `[KafkaTransport](handleCrash) Consumer crashed | group=${this.#groupId} restart=${String(restart)} error=${core.toErrorInfo(error).message}`
603
+ );
604
+ this.#joinWaiter?.reject(error);
605
+ if (restart === false && this.#consuming && this.#restartLoop === void 0) {
606
+ this.#restartLoop = this.#restartConsumer().finally(() => {
607
+ this.#restartLoop = void 0;
608
+ });
609
+ }
610
+ }
611
+ /**
612
+ * The client gave up on the consumer (`restart=false`): reconnect it and run
613
+ * again, with bounded backoff, until it joined or the transport closed.
614
+ */
615
+ async #restartConsumer() {
616
+ const { restartInitialBackoffMs, restartMaxBackoffMs } = this.#settings;
617
+ const consumer = this.#consumer;
618
+ for (let attempt = 1; this.#state === "subscribed"; attempt += 1) {
619
+ await this.#sleep(
620
+ Math.min(restartInitialBackoffMs * 2 ** (attempt - 1), restartMaxBackoffMs)
621
+ );
622
+ if (this.#state !== "subscribed") {
623
+ return;
624
+ }
625
+ await consumer.disconnect().catch(() => void 0);
626
+ try {
627
+ await this.#startConsuming();
628
+ this.#logger.log(
629
+ `[KafkaTransport](restartConsumer) Consumer restarted | group=${this.#groupId} attempt=${attempt}`
630
+ );
631
+ return;
632
+ } catch (error) {
633
+ this.#logger.error(
634
+ `[KafkaTransport](restartConsumer) Restart failed, will retry | group=${this.#groupId} attempt=${attempt} error=${core.toErrorInfo(error).message}`
635
+ );
636
+ }
637
+ }
638
+ }
639
+ #waitForGroupJoin(consumer) {
640
+ if (!this.#tracksMembership(consumer)) {
641
+ return { promise: Promise.resolve(), dispose: () => void 0 };
642
+ }
643
+ let timer;
644
+ const promise = new Promise((resolve, reject) => {
645
+ this.#joinWaiter = { resolve, reject };
646
+ timer = setTimeout(() => {
647
+ reject(
648
+ new core.RelayBusError(
649
+ `Consumer of transport '${this.name}' did not join group '${this.#groupId}' within ${this.#settings.startupTimeoutMs}ms`
650
+ )
651
+ );
652
+ }, this.#settings.startupTimeoutMs);
653
+ });
654
+ promise.catch(() => void 0);
655
+ return {
656
+ promise,
657
+ dispose: () => {
658
+ clearTimeout(timer);
659
+ this.#joinWaiter = void 0;
660
+ }
661
+ };
662
+ }
663
+ };
664
+ function resolveSettings(options) {
665
+ const maxAttempts = options.processing?.maxAttempts ?? DEFAULTS.maxAttempts;
666
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
667
+ throw new core.ConfigurationError(
668
+ `processing.maxAttempts must be a positive integer, received ${maxAttempts}`
669
+ );
670
+ }
671
+ return {
672
+ maxAttempts,
673
+ initialBackoffMs: core.toMilliseconds(
674
+ options.processing?.initialBackoff ?? DEFAULTS.initialBackoff,
675
+ "processing.initialBackoff"
676
+ ),
677
+ maxBackoffMs: core.toMilliseconds(
678
+ options.processing?.maxBackoff ?? DEFAULTS.maxBackoff,
679
+ "processing.maxBackoff"
680
+ ),
681
+ startupTimeoutMs: core.toMilliseconds(
682
+ options.startupTimeout ?? DEFAULTS.startupTimeout,
683
+ "startupTimeout"
684
+ ),
685
+ publishTimeoutMs: core.toMilliseconds(
686
+ options.publishTimeout ?? DEFAULTS.publishTimeout,
687
+ "publishTimeout"
688
+ ),
689
+ heartbeatIntervalMs: Math.max(
690
+ 1,
691
+ core.toMilliseconds(
692
+ options.processing?.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
693
+ "processing.heartbeatInterval"
694
+ )
695
+ ),
696
+ restartInitialBackoffMs: core.toMilliseconds(
697
+ options.restart?.initialBackoff ?? DEFAULTS.restartInitialBackoff,
698
+ "restart.initialBackoff"
699
+ ),
700
+ restartMaxBackoffMs: core.toMilliseconds(
701
+ options.restart?.maxBackoff ?? DEFAULTS.restartMaxBackoff,
702
+ "restart.maxBackoff"
703
+ )
704
+ };
705
+ }
706
+ function toHandlerFailure(error) {
707
+ const { name, message } = core.toErrorInfo(error);
708
+ return new HandlerFailedError(`${name}: ${message}`);
709
+ }
710
+
711
+ exports.HandlerFailedError = HandlerFailedError;
712
+ exports.InvalidKafkaMessageError = InvalidKafkaMessageError;
713
+ exports.KafkaTransport = KafkaTransport;
714
+ exports.PublishTimeoutError = PublishTimeoutError;
715
+ exports.UnencodableEventError = UnencodableEventError;
716
+ exports.decodeEnvelope = decodeEnvelope;
717
+ exports.encodeEnvelope = encodeEnvelope;
718
+ //# sourceMappingURL=index.cjs.map
719
+ //# sourceMappingURL=index.cjs.map