@relaybus/kafka 0.3.1 → 0.4.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/README.md CHANGED
@@ -7,28 +7,28 @@ outbox events to Kafka topics and feeds events consumed from Kafka into the
7
7
  bus inbox, with at-least-once delivery and idempotent storage.
8
8
 
9
9
  - Wire format: [CloudEvents Kafka protocol binding](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/kafka-protocol-binding.md), binary content mode, so any CloudEvents-aware consumer or tool can read the topic.
10
- - Built for and tested against [`kafkajs`](https://kafka.js.org) 2.2.x on a real broker. The KafkaJS-compatible API of `@confluentinc/kafka-javascript` is **experimental and untested**, see [Confluent](#confluent-experimental-untested). Neither client is a dependency; install the one you use.
10
+ - Tested on a real broker with both clients: [`@confluentinc/kafka-javascript`](https://github.com/confluentinc/confluent-kafka-javascript) 1.11.0 (its KafkaJS-compatible API, **recommended**: maintained, librdkafka based) and [`kafkajs`](https://kafka.js.org) 2.2.4 (unmaintained). See [Choosing a client](#choosing-a-client). Neither client is a dependency; install the one you use.
11
11
  - Never loses an event: the offset is committed only after the bus stored the event; a failing store blocks the partition instead of skipping.
12
12
 
13
13
  ## Install
14
14
 
15
15
  ```sh
16
- pnpm add @relaybus/core @relaybus/kafka kafkajs
16
+ pnpm add @relaybus/core @relaybus/kafka @confluentinc/kafka-javascript
17
17
  ```
18
18
 
19
19
  ## Usage
20
20
 
21
21
  ```ts
22
- import { Kafka, Partitioners } from 'kafkajs';
22
+ import { KafkaJS } from '@confluentinc/kafka-javascript';
23
23
  import { RelayBus } from '@relaybus/core';
24
24
  import { KafkaTransport } from '@relaybus/kafka';
25
25
 
26
- const kafka = new Kafka({ clientId: 'orders', brokers: ['kafka:9092'] });
26
+ const kafka = new KafkaJS.Kafka({ kafkaJS: { clientId: 'orders', brokers: ['kafka:9092'] } });
27
27
 
28
28
  const transport = new KafkaTransport({
29
29
  kafka,
30
+ client: 'confluent', // the default is 'kafkajs'
30
31
  groupId: 'billing', // required to subscribe
31
- producerConfig: { createPartitioner: Partitioners.DefaultPartitioner },
32
32
  });
33
33
 
34
34
  const bus = new RelayBus({
@@ -55,13 +55,15 @@ const consuming = transport.isConsuming();
55
55
 
56
56
  | Option | Default | Meaning |
57
57
  | ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance. It is the producer/consumer factory. |
58
+ | `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance: the factory of the transport's consumer and producer. Optional with `producer`, but then the transport cannot subscribe. |
59
+ | `producer` | none | An already-connected producer the application owns (kafkajs `Producer`, Confluent `KafkaJS.Producer`), used to publish and dead-letter instead of one of `kafka`. Never connected or disconnected by the transport. See [Reusing the application's producer](#reusing-the-applications-producer). |
60
+ | `client` | `'kafkajs'` | Which client `kafka` is: `'kafkajs'` or `'confluent'`. Set `'confluent'` for a Confluent `KafkaJS.Kafka`; see [Choosing a client](#choosing-a-client). |
59
61
  | `name` | `'kafka'` | Transport name used by routes and subscriptions. |
60
62
  | `groupId` | none | Consumer group. Required as soon as `subscribe` is used; publish-only services can omit it. |
61
63
  | `fromBeginning` | `true` | Where a group without committed offsets starts. `true` so events published before the first start are not skipped. |
62
64
  | `maxInFlightRequests` | `1` | Producer in-flight requests, 1 to 5. `1` keeps retries from reordering events of one key. |
63
- | `producerConfig` | `{}` | Extra producer settings (partitioner, compression, ...). `idempotent` and `maxInFlightRequests` always win. |
64
- | `consumerConfig` | `{}` | Extra consumer settings (`sessionTimeout`, `retry`, `heartbeatInterval`, ...). `groupId` always wins. |
65
+ | `producerConfig` | `{}` | Extra producer settings (partitioner, compression, ...). `idempotent` and `maxInFlightRequests` always win. Confluent: KafkaJS-style settings in a `kafkaJS` block, librdkafka properties at the top level; `acks: -1` also wins, as do `enable.idempotence`, `max.in.flight` and `acks` there. |
66
+ | `consumerConfig` | `{}` | Extra consumer settings (`sessionTimeout`, `retry`, `heartbeatInterval`, ...). `groupId` always wins. Confluent: KafkaJS-style settings in a `kafkaJS` block, librdkafka properties at the top level; `fromBeginning` also wins, as do `group.id` and `auto.offset.reset` there. Your `rebalance_cb` is still called. |
65
67
  | `processing.maxAttempts` | `5` | In-process attempts per delivery of a record. |
66
68
  | `processing.initialBackoff` | `'200ms'` | Delay before attempt 2, doubling per attempt. |
67
69
  | `processing.maxBackoff` | `'5s'` | Upper bound of the delay between attempts. |
@@ -77,7 +79,8 @@ const consuming = transport.isConsuming();
77
79
 
78
80
  ## Wire format
79
81
 
80
- Value: the JSON payload. Key: the envelope key (`null` means no key).
82
+ Value: the JSON payload. Key: the event key (`null` means no key); `jsonEnvelope` can write
83
+ another key or none (`recordKey`).
81
84
 
82
85
  | Header | Content |
83
86
  | ------------------------------------ | --------------------------------------------------------- |
@@ -171,8 +174,10 @@ indexes an array) or a header (`{ header: 'event-type' }`) for `type`, `id`, `pa
171
174
  `'whole-body'`), `key`, `source`, `occurredAt`, `correlationId`, `causationId` and `headers`, plus a
172
175
  `contentType`. `type`, `id` and `payload` are required by the types; `id: 'record-position'` uses the
173
176
  record position (records relaybus writes carry the event id in a `relaybus-event-id` header, which
174
- wins on read, so outbox retries stay deduplicated). The record key is always the event key, and when `occurredAt` is not mapped the
175
- record timestamp carries it. Event headers that are record headers keep their names, so a name the
177
+ wins on read, so outbox retries stay deduplicated); `{ body: 'id', whenMissing: 'record-position' }`
178
+ uses the body id when a record has one. The record key is the event key unless `recordKey` says
179
+ otherwise (`'none'` or `(event) => string | null`), `contentTypeHeader: false` writes no
180
+ `content-type` header, and when `occurredAt` is not mapped the record timestamp carries it. Event headers that are record headers keep their names, so a name the
176
181
  mapping uses, `content-type` or `relaybus-event-id`, is refused with `UnencodableEventError`. The
177
182
  [interoperability recipe](https://berkcansavur.github.io/relaybus/recipes/interoperating-with-other-services)
178
183
  has a full table and a MassTransit example.
@@ -225,14 +230,23 @@ without a key (`key: null`) are spread over partitions and have no ordering. Use
225
230
  as the key where order matters. Use the same partitioner in every producer of a topic, otherwise
226
231
  one key can map to different partitions depending on who wrote it.
227
232
 
233
+ The record key is the event key by default. A `jsonEnvelope` codec with `recordKey: 'none'`
234
+ writes records without a key, as many existing producers do, and `recordKey: (event) => ...`
235
+ derives another one. The event key, and the outbox's per-key order, stay as they are: relaybus
236
+ still publishes events of one key one after the other. But a record without a key is placed on a
237
+ partition by the producer (round-robin or sticky), so events of one key can land on different
238
+ partitions and consumers may see them out of order. Keep the event key on the wire wherever
239
+ consumers rely on per-key order.
240
+
228
241
  ## Guarantees
229
242
 
230
243
  - **Publish**: idempotent producer, `acks=-1`; `publish` resolves after the broker ack and rejects
231
244
  with `PublishTimeoutError` after `publishTimeout` (kafkajs would otherwise retry an idempotent
232
245
  send for as long as the broker is unreachable). A rejected publish makes the outbox retry; a
233
246
  timed-out send may still land, so a message can be duplicated, and the idempotent producer plus
234
- the consumer's dedup on the event id absorb it. Pass `producerConfig: { retry: { retries } }` to
235
- bound client-side retries further.
247
+ the consumer's dedup on the event id absorb it. Pass `producerConfig: { retry: { retries } }`
248
+ (Confluent: `producerConfig: { kafkaJS: { retry: { retries } } }`) to bound client-side retries
249
+ further.
236
250
  - **Consume**: `eachMessage` awaits the bus (`RelayBus.receive`, one idempotent insert) and the
237
251
  client commits the offset only afterwards. A duplicate message is stored once.
238
252
  - **Handler failure**: retried in-process `processing.maxAttempts` times with backoff and
@@ -243,7 +257,8 @@ one key can map to different partitions depending on who wrote it.
243
257
  same record. The record is never skipped, so a persistent outage blocks the partition until the
244
258
  store recovers. That is deliberate: `receive` is a cheap insert, and skipping would lose the
245
259
  event. `isConsuming()` is `false` while any partition is stuck (tracked per partition).
246
- - **Dead consumer**: the transport does not rely on the client to restart itself. When the client
260
+ - **Dead consumer** (kafkajs; Confluent never gives up on a consumer): the transport does not rely
261
+ on the client to restart itself. When the client
247
262
  reports a crash with `restart=false`, the transport logs it and reconnects, re-subscribes and
248
263
  re-runs the consumer with bounded backoff (`restart.*`) until it joined again or the transport
249
264
  closed. `isReady()` is `false` meanwhile.
@@ -275,31 +290,70 @@ Only these members are called (`KafkaClientLike` and friends in `src/kafka-clien
275
290
  | `kafka` | `producer(config)`, `consumer(config)` |
276
291
  | producer | `connect()`, `send({ topic, acks, messages: [{ key, value, headers }] })`, `disconnect()` |
277
292
  | consumer | `connect()`, `subscribe({ topics, fromBeginning })`, `run({ eachMessage })`, `disconnect()` |
293
+ | Confluent consumer config | `rebalance_cb(error, assignment)` reports joins (`-175`, assigned) and rebalances (`-174`, revoked) |
278
294
  | `eachMessage` payload | `topic`, `partition`, `message.{ key, value, headers, offset }`, `heartbeat()` |
279
295
  | consumer events (optional) | `consumer.on(name, listener)` and `consumer.events.{ GROUP_JOIN, CRASH, REBALANCING, STOP, DISCONNECT }` |
280
296
 
281
- `on`/`events` are optional: without `GROUP_JOIN` the transport cannot wait for the join or observe
282
- crashes, so `subscribe` resolves when `run` returns and `isConsuming()` only reflects processing
283
- stalls and `close()`. kafkajs 2.2.4 is verified by the integration tests.
297
+ With Confluent, `send` gets no `acks` and `subscribe` no `fromBeginning` (it rejects both; they are
298
+ set on the producer and consumer instead), and `on`/`events` are not used.
299
+
300
+ `on`/`events` are optional for other kafkajs-like clients: without `GROUP_JOIN` the transport cannot
301
+ wait for the join or observe crashes, so `subscribe` resolves when `run` returns and
302
+ `isConsuming()` only reflects processing stalls and `close()`. kafkajs 2.2.4 and
303
+ `@confluentinc/kafka-javascript` 1.11.0 are verified by the integration tests.
284
304
 
285
- ## Confluent (experimental, untested)
305
+ ## Reusing the application's producer
286
306
 
287
- `@confluentinc/kafka-javascript` exposes a KafkaJS-compatible API, and the structural client types
288
- here are satisfied by it, but nothing in this repository runs against it. Its documented
289
- differences from kafkajs mean the options below do **not** work as written for Confluent:
307
+ An application that already has a connected producer, with its SSL and SASL settings, can hand it
308
+ to the transport instead of letting it open a second connection:
290
309
 
291
- - `idempotent` and `maxInFlightRequests` (producer) and `groupId` (consumer) must be nested in the
292
- client's `kafkaJS: { ... }` config block. The transport passes them at the top level, so with
293
- Confluent the producer is not guaranteed to be idempotent and the group id may be ignored.
294
- - `fromBeginning` is not supported per `subscribe()`; it is a consumer-level `kafkaJS` setting.
295
- The transport's `fromBeginning` option is therefore not applied.
296
- - `heartbeat()` is a no-op there and the consumer `events` (`GROUP_JOIN`, `CRASH`, ...) are not
297
- documented, so start-up falls back to "resolved when `run` returns" and crash recovery cannot
298
- trigger.
299
- - Failed `eachMessage` handling (re-seek) and the error classification of a rethrown error differ.
310
+ ```ts
311
+ const transport = new KafkaTransport({
312
+ producer: ordersService.producer, // already connected, owned by the application
313
+ client: 'confluent', // when it is a Confluent producer
314
+ });
315
+ ```
300
316
 
301
- Use it only after verifying these against your version; until a Confluent path is tested, treat
302
- kafkajs as the supported client.
317
+ Without `kafka` the transport only publishes; give `kafka` and `groupId` as well to subscribe (the
318
+ consumer stays the transport's own). The transport calls only `send` on the injected producer: it
319
+ never connects or disconnects it, so it is usable before `bus.start()` and stays open after
320
+ `bus.stop()`; closing it is the application's job, after the bus stopped. The guarantees hold
321
+ only if that producer is configured like the transport's own: idempotent, `acks` -1 (all in-sync
322
+ replicas) and at most 5 in-flight requests (1 keeps retries from reordering a key). For kafkajs
323
+ that is `kafka.producer({ idempotent: true, maxInFlightRequests: 1 })`, for Confluent
324
+ `kafka.producer({ kafkaJS: { idempotent: true, acks: -1, maxInFlightRequests: 1 } })`. Because the
325
+ transport cannot apply `producerConfig` or `maxInFlightRequests` to it, giving them together with
326
+ `producer` is a configuration error.
327
+
328
+ ## Choosing a client
329
+
330
+ **Use `@confluentinc/kafka-javascript`** (`client: 'confluent'`): it is maintained and built on
331
+ librdkafka, while `kafkajs` is no longer maintained. Every integration scenario runs against a real
332
+ broker with both clients (publish and subscribe, retries and redelivery, dead letters, codecs,
333
+ per-key order across partitions, start-up failure, close, interoperability and two services
334
+ through the bus); Confluent is tested with 1.11.0, the lowest version the peer range allows.
335
+
336
+ What the transport does differently for Confluent, because its KafkaJS-compatible API differs:
337
+
338
+ - `idempotent`, `maxInFlightRequests` and `acks: -1` go in the producer's `kafkaJS` block, and
339
+ `groupId` and `fromBeginning` in the consumer's: Confluent rejects them at the top level, in
340
+ `send` and in `subscribe`. They are also set as librdkafka properties (`enable.idempotence`,
341
+ `max.in.flight`, `acks`, `group.id`, `auto.offset.reset`), which Confluent applies after the
342
+ block. Your own `kafkaJS` block and properties are merged below them.
343
+ - The group join and rebalances are followed through `rebalance_cb` (Confluent has no
344
+ instrumentation events), so `subscribe` still waits for the join and `isConsuming()` still turns
345
+ `false` during a rebalance.
346
+ - Confluent never gives up on a consumer: a failed `eachMessage` makes it seek back and redeliver
347
+ the record, and it reports no crash. The restart options therefore never apply.
348
+ - `heartbeat()` is a no-op there; librdkafka heartbeats in the background. Keep the time a record
349
+ can spend in retries (`maxAttempts * maxBackoff`) well below `max.poll.interval.ms` (5 minutes by
350
+ default) instead of `sessionTimeout`.
351
+ - Its producer partitions keys with librdkafka's `murmur2_random`, which librdkafka documents as
352
+ Java compatible, like kafkajs' `DefaultPartitioner`. `createPartitioner` does not exist there.
353
+
354
+ Moving from kafkajs: create the client with `new KafkaJS.Kafka({ kafkaJS: { brokers, clientId } })`,
355
+ add `client: 'confluent'`, and move KafkaJS-style settings in `producerConfig` and `consumerConfig`
356
+ into a `kafkaJS` block (drop `createPartitioner`). The wire format is unchanged.
303
357
 
304
358
  ## Operations
305
359
 
@@ -313,31 +367,31 @@ kafkajs as the supported client.
313
367
  consumer group.
314
368
  - **Session timeout**: the retry loop heartbeats between attempts. Keep
315
369
  `maxAttempts * maxBackoff` well below the consumer `sessionTimeout` (30 s default).
316
- - **Concurrency**: one record at a time per partition (kafkajs default); scale with partitions and
370
+ - **Concurrency**: one record at a time per partition (the default of both clients); scale with partitions and
317
371
  service replicas in the same group. With `partitionsConsumedConcurrently > 1` in `consumerConfig`
318
372
  the stall state is tracked per partition.
319
373
  - **Retention vs. inbox**: Kafka may redeliver old records after rebalances or offset resets. The
320
374
  bus deduplicates on the event id for as long as it keeps completed deliveries
321
375
  (`retention.completed`, default 7 days), so keep that longer than your redelivery window.
322
- - **Partitioner warning** (kafkajs 2.x): pass `createPartitioner: Partitioners.DefaultPartitioner`
376
+ - **Partitioner warning** (kafkajs 2.x only): pass `createPartitioner: Partitioners.DefaultPartitioner`
323
377
  in `producerConfig` to silence the legacy partitioner warning.
324
378
 
325
379
  ## Troubleshooting
326
380
 
327
381
  - `did not join group ... within 30000ms` at start: broker unreachable, wrong credentials, or the
328
- group is rebalancing for longer than `startupTimeout`. Check the kafkajs connection settings.
382
+ group is rebalancing for longer than `startupTimeout`. Check the client's connection settings.
329
383
  - `isConsuming()` stays `false` with `Giving up for now` logs: `RelayBus.receive` keeps failing
330
384
  (database down?). Fix the store; consumption resumes by itself.
331
385
  - `Dead-lettered malformed message` logs: a producer on that topic is not writing CloudEvents binary
332
386
  mode with `content-type: application/json`. Inspect `<topic>.dead` and its `relaybus-invalid-reason`.
333
387
  - `Consumer crashed ... restart=false` followed by `Consumer restarted`: the client gave up and the
334
- transport recovered it. Repeated `Restart failed` logs mean the broker or credentials are the problem.
388
+ transport recovered it (kafkajs only). Repeated `Restart failed` logs mean the broker or credentials are the problem.
335
389
  - `PublishTimeoutError`: no broker ack within `publishTimeout`; the outbox retries.
336
390
  - Events appear twice in logs but handlers run once: expected, the inbox deduplicates on the id.
337
391
 
338
392
  ## When to use @relaybus/kafka, and when not to
339
393
 
340
- Use it to send events between services through Kafka without a dual write between your database and the broker. It is a transport, not a store: it needs `@relaybus/core` and a store, and `@confluentinc/kafka-javascript` support is experimental and untested.
394
+ Use it to send events between services through Kafka without a dual write between your database and the broker. It is a transport, not a store: it needs `@relaybus/core`, a store and one of the two clients, `@confluentinc/kafka-javascript` (recommended) or `kafkajs`.
341
395
 
342
396
  Documentation: <https://berkcansavur.github.io/relaybus/packages/kafka>
343
397
 
package/dist/index.cjs CHANGED
@@ -437,15 +437,30 @@ function jsonEnvelope(mapping) {
437
437
  transportIds: [RECORD_POSITION],
438
438
  failures: { invalid, unencodable: (reason) => new UnencodableEventError(reason) }
439
439
  });
440
- const contentType = mapping.contentType ?? DEFAULT_CONTENT_TYPE;
440
+ const contentType = mapping.contentTypeHeader === false ? {} : { [CONTENT_TYPE]: mapping.contentType ?? DEFAULT_CONTENT_TYPE };
441
441
  const stampsTime = mapping.occurredAt === void 0;
442
- const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? { [CONTENT_TYPE]: contentType, [EVENT_ID_HEADER]: envelope.id } : { [CONTENT_TYPE]: contentType };
442
+ const ownHeaders = (envelope) => mapping.id === RECORD_POSITION ? { ...contentType, [EVENT_ID_HEADER]: envelope.id } : contentType;
443
+ const recordKey = resolveRecordKey(mapping.recordKey);
443
444
  return {
444
- encode: (envelope) => encodeJson(envelope, { format, stampsTime, ownHeaders }),
445
+ encode: (envelope) => encodeJson(envelope, { format, stampsTime, ownHeaders, recordKey }),
445
446
  decode: (record) => decodeJson(record, format)
446
447
  };
447
448
  }
448
- function encodeJson(envelope, { format, stampsTime, ownHeaders }) {
449
+ function resolveRecordKey(recordKey) {
450
+ if (recordKey === void 0 || recordKey === "event-key") {
451
+ return (envelope) => envelope.key;
452
+ }
453
+ if (recordKey === "none") {
454
+ return () => null;
455
+ }
456
+ if (typeof recordKey === "function") {
457
+ return recordKey;
458
+ }
459
+ throw new core.ConfigurationError(
460
+ `jsonEnvelope recordKey must be 'event-key', 'none' or a function, received '${String(recordKey)}'`
461
+ );
462
+ }
463
+ function encodeJson(envelope, { format, stampsTime, ownHeaders, recordKey }) {
449
464
  assertRepresentable(envelope);
450
465
  const { body, headers } = format.write(envelope);
451
466
  const taken = [CONTENT_TYPE, EVENT_ID_HEADER].find((name) => Object.hasOwn(headers, name));
@@ -455,7 +470,7 @@ function encodeJson(envelope, { format, stampsTime, ownHeaders }) {
455
470
  );
456
471
  }
457
472
  return {
458
- key: envelope.key,
473
+ key: recordKey(envelope),
459
474
  value: serializeJson(envelope, body),
460
475
  headers: { ...headers, ...ownHeaders(envelope) },
461
476
  ...stampsTime ? { timestamp: recordTimestamp(envelope) } : {}
@@ -486,9 +501,6 @@ function recordTimestamp(envelope) {
486
501
  formatTime(envelope);
487
502
  return String(envelope.occurredAt.getTime());
488
503
  }
489
- function tracksMembership(consumer) {
490
- return consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0;
491
- }
492
504
  var GroupJoinWatch = class {
493
505
  #settings;
494
506
  #waiter;
@@ -514,7 +526,7 @@ var GroupJoinWatch = class {
514
526
  eachMessage
515
527
  }) {
516
528
  await consumer.connect();
517
- await consumer.subscribe({ topics, fromBeginning });
529
+ await consumer.subscribe(this.#settings.dialect.subscription(topics, fromBeginning));
518
530
  const joined = this.#wait(consumer);
519
531
  try {
520
532
  await consumer.run({ eachMessage });
@@ -524,7 +536,7 @@ var GroupJoinWatch = class {
524
536
  }
525
537
  }
526
538
  #wait(consumer) {
527
- if (!tracksMembership(consumer)) {
539
+ if (!this.#settings.dialect.tracksMembership(consumer)) {
528
540
  return { promise: Promise.resolve(), dispose: () => void 0 };
529
541
  }
530
542
  const { name, groupId, timings } = this.#settings;
@@ -598,7 +610,7 @@ var KafkaProducerSession = class {
598
610
  delayMs: timings.publishTimeoutMs
599
611
  });
600
612
  });
601
- const sending = producer.send(record);
613
+ const sending = producer.send(this.#settings.dialect.record(record));
602
614
  sending.catch(() => void 0);
603
615
  try {
604
616
  await Promise.race([sending, timeout]);
@@ -607,8 +619,12 @@ var KafkaProducerSession = class {
607
619
  }
608
620
  }
609
621
  async #open() {
610
- const { kafka, producerConfig, maxInFlightRequests } = this.#settings;
611
- const producer = kafka.producer({ ...producerConfig, idempotent: true, maxInFlightRequests });
622
+ const { kafka, dialect, injectedProducer } = this.#settings;
623
+ if (injectedProducer !== void 0) {
624
+ this.#connected = true;
625
+ return withoutLifecycle(injectedProducer);
626
+ }
627
+ const producer = kafka.producer(dialect.producerConfig(this.#settings));
612
628
  try {
613
629
  await producer.connect();
614
630
  } catch (error) {
@@ -619,6 +635,88 @@ var KafkaProducerSession = class {
619
635
  return producer;
620
636
  }
621
637
  };
638
+ function withoutLifecycle(producer) {
639
+ return {
640
+ send: (record) => producer.send(record),
641
+ disconnect: () => Promise.resolve()
642
+ };
643
+ }
644
+ var KAFKAJS_DIALECT = {
645
+ producerConfig: ({ producerConfig, maxInFlightRequests }) => ({
646
+ ...producerConfig,
647
+ idempotent: true,
648
+ maxInFlightRequests
649
+ }),
650
+ consumerConfig: ({ consumerConfig, groupId }) => ({ ...consumerConfig, groupId }),
651
+ subscription: (topics, fromBeginning) => ({ topics, fromBeginning }),
652
+ record: (record) => record,
653
+ tracksMembership: (consumer) => consumer.on !== void 0 && consumer.events?.GROUP_JOIN !== void 0,
654
+ observe: (consumer, membership) => {
655
+ if (!KAFKAJS_DIALECT.tracksMembership(consumer)) {
656
+ return;
657
+ }
658
+ const names = consumer.events;
659
+ const listen = (name, listener) => {
660
+ if (name !== void 0) {
661
+ consumer.on(name, listener);
662
+ }
663
+ };
664
+ listen(names.GROUP_JOIN, () => membership.joined());
665
+ listen(names.REBALANCING, () => membership.rebalancing());
666
+ listen(names.CRASH, (event) => membership.crashed(event));
667
+ listen(names.STOP, () => membership.left());
668
+ listen(names.DISCONNECT, () => membership.left());
669
+ }
670
+ };
671
+ var ASSIGN_PARTITIONS = -175;
672
+ var REVOKE_PARTITIONS = -174;
673
+ var CONFLUENT_DIALECT = {
674
+ producerConfig: ({ producerConfig, maxInFlightRequests }) => {
675
+ const user = producerConfig;
676
+ return {
677
+ ...user,
678
+ kafkaJS: { ...user.kafkaJS, idempotent: true, maxInFlightRequests, acks: -1 },
679
+ "enable.idempotence": true,
680
+ "max.in.flight": maxInFlightRequests,
681
+ acks: -1
682
+ };
683
+ },
684
+ consumerConfig: ({ consumerConfig, groupId, fromBeginning }, membership) => {
685
+ const user = consumerConfig;
686
+ const userCallback = typeof user.rebalance_cb === "function" ? user.rebalance_cb : void 0;
687
+ return {
688
+ ...user,
689
+ kafkaJS: { ...user.kafkaJS, groupId, fromBeginning },
690
+ "group.id": groupId,
691
+ "auto.offset.reset": fromBeginning ? "earliest" : "latest",
692
+ rebalance_cb: (...args) => {
693
+ if (args[0].code === ASSIGN_PARTITIONS) {
694
+ membership.joined();
695
+ } else if (args[0].code === REVOKE_PARTITIONS) {
696
+ membership.rebalancing();
697
+ }
698
+ return userCallback?.(...args);
699
+ }
700
+ };
701
+ },
702
+ subscription: (topics) => ({ topics }),
703
+ record: ({ acks: _acks, ...record }) => record,
704
+ tracksMembership: () => true,
705
+ observe: () => void 0
706
+ };
707
+ function clientDialect(client) {
708
+ if (client === void 0 || client === "kafkajs") {
709
+ return KAFKAJS_DIALECT;
710
+ }
711
+ if (client === "confluent") {
712
+ return CONFLUENT_DIALECT;
713
+ }
714
+ throw new core.ConfigurationError(
715
+ `Unknown Kafka client '${String(client)}'; use 'kafkajs' or 'confluent'`
716
+ );
717
+ }
718
+
719
+ // src/kafka-settings.ts
622
720
  var DEFAULTS = {
623
721
  name: "kafka",
624
722
  maxInFlightRequests: 1,
@@ -636,6 +734,8 @@ function resolveKafkaSettings(options) {
636
734
  return {
637
735
  name: options.name ?? DEFAULTS.name,
638
736
  kafka: options.kafka,
737
+ injectedProducer: resolveInjectedProducer(options),
738
+ dialect: clientDialect(options.client),
639
739
  groupId: options.groupId,
640
740
  fromBeginning: options.fromBeginning ?? true,
641
741
  maxInFlightRequests: internal.BoundedInteger.of(
@@ -650,6 +750,23 @@ function resolveKafkaSettings(options) {
650
750
  timings: resolveTimings(options)
651
751
  };
652
752
  }
753
+ function resolveInjectedProducer(options) {
754
+ const { producer, kafka, producerConfig, maxInFlightRequests } = options;
755
+ if (producer === void 0) {
756
+ if (kafka === void 0) {
757
+ throw new core.ConfigurationError(
758
+ "Give 'kafka', or an already-connected 'producer' to publish with"
759
+ );
760
+ }
761
+ return void 0;
762
+ }
763
+ if (producerConfig !== void 0 || maxInFlightRequests !== void 0) {
764
+ throw new core.ConfigurationError(
765
+ "Give 'producerConfig' and 'maxInFlightRequests' to a producer you inject yourself"
766
+ );
767
+ }
768
+ return producer;
769
+ }
653
770
  function resolveCodec({ codec, decode }) {
654
771
  if (decode === void 0) {
655
772
  return codec ?? cloudEventsBinary;
@@ -719,7 +836,7 @@ function assertCanSubscribe(state, {
719
836
  settings,
720
837
  destinations
721
838
  }) {
722
- const { name, groupId, invalidPolicy } = settings;
839
+ const { name, groupId, invalidPolicy, kafka } = settings;
723
840
  if (state === "closed") {
724
841
  throw new TransportClosedError(`Transport '${name}' is closed`);
725
842
  }
@@ -728,6 +845,9 @@ function assertCanSubscribe(state, {
728
845
  `Transport '${name}' already subscribed; list every topic in one subscription or use one KafkaTransport per consumer group`
729
846
  );
730
847
  }
848
+ if (kafka === void 0) {
849
+ throw new core.ConfigurationError(`Transport '${name}' needs 'kafka' to create its consumer`);
850
+ }
731
851
  if (groupId === void 0 || groupId.length === 0) {
732
852
  throw new core.ConfigurationError(`Transport '${name}' needs a groupId to subscribe`);
733
853
  }
@@ -746,10 +866,7 @@ function assertCanSubscribe(state, {
746
866
 
747
867
  // src/kafka-records.ts
748
868
  function decodeRecord(codec, { topic, partition, message }) {
749
- if (codec === cloudEventsBinary) {
750
- return decodeEnvelope(message);
751
- }
752
- const record = {
869
+ return decodeKafkaRecord(codec, {
753
870
  topic,
754
871
  partition,
755
872
  offset: message.offset,
@@ -757,7 +874,12 @@ function decodeRecord(codec, { topic, partition, message }) {
757
874
  value: message.value,
758
875
  headers: message.headers,
759
876
  timestamp: message.timestamp
760
- };
877
+ });
878
+ }
879
+ function decodeKafkaRecord(codec, record) {
880
+ if (codec === cloudEventsBinary) {
881
+ return decodeEnvelope(record);
882
+ }
761
883
  return completeDecodedEvent(codec.decode(record), record);
762
884
  }
763
885
  function deadLetterRecord({ topic, partition, message }, {
@@ -895,10 +1017,12 @@ var KafkaTransport = class extends internal.SubscribingTransport {
895
1017
  async #subscribe({ destinations, onMessage, hooks }) {
896
1018
  assertCanSubscribe(this.#state, { settings: this.#settings, destinations });
897
1019
  this.#state = "subscribed";
898
- const { kafka, consumerConfig, groupId } = this.#settings;
899
- const consumer = kafka.consumer({ ...consumerConfig, groupId });
1020
+ const { kafka, dialect, groupId } = this.#settings;
1021
+ const membership = this.#observeConsumer();
1022
+ const consumer = kafka.consumer(dialect.consumerConfig(this.#settings, membership));
900
1023
  this.#consumer = consumer;
901
- this.#observeConsumer(consumer);
1024
+ this.#groupJoined = !dialect.tracksMembership(consumer);
1025
+ dialect.observe(consumer, membership);
902
1026
  const start = () => this.#groupJoin.start(consumer, {
903
1027
  topics: [...destinations],
904
1028
  fromBeginning: this.#settings.fromBeginning,
@@ -1039,34 +1163,25 @@ var KafkaTransport = class extends internal.SubscribingTransport {
1039
1163
  }
1040
1164
  }
1041
1165
  }
1042
- #observeConsumer(consumer) {
1043
- if (!tracksMembership(consumer)) {
1044
- this.#groupJoined = true;
1045
- return;
1046
- }
1166
+ /** What the transport does when the consumer joins, rebalances, crashes or leaves its group. */
1167
+ #observeConsumer() {
1047
1168
  const { logger, groupId } = this.#settings;
1048
- const names = consumer.events;
1049
- const listen = (name, listener) => {
1050
- if (name !== void 0) {
1051
- consumer.on(name, listener);
1169
+ return {
1170
+ joined: () => {
1171
+ this.#groupJoined = true;
1172
+ this.#groupJoin.joined();
1173
+ logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
1174
+ },
1175
+ rebalancing: () => {
1176
+ this.#groupJoined = false;
1177
+ this.#stalledPartitions.clear();
1178
+ logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
1179
+ },
1180
+ crashed: (event) => this.#handleCrash(event),
1181
+ left: () => {
1182
+ this.#groupJoined = false;
1052
1183
  }
1053
1184
  };
1054
- listen(names.GROUP_JOIN, () => {
1055
- this.#groupJoined = true;
1056
- this.#groupJoin.joined();
1057
- logger.log(`[KafkaTransport](observeConsumer) Joined group | group=${groupId}`);
1058
- });
1059
- listen(names.REBALANCING, () => {
1060
- this.#groupJoined = false;
1061
- this.#stalledPartitions.clear();
1062
- logger.warn(`[KafkaTransport](observeConsumer) Rebalancing | group=${groupId}`);
1063
- });
1064
- listen(names.CRASH, (event) => this.#handleCrash(event));
1065
- const leftGroup = () => {
1066
- this.#groupJoined = false;
1067
- };
1068
- listen(names.STOP, leftGroup);
1069
- listen(names.DISCONNECT, leftGroup);
1070
1185
  }
1071
1186
  #handleCrash(event) {
1072
1187
  const { error, restart } = event.payload ?? {};
@@ -1128,6 +1243,7 @@ exports.cloudEventsBinary = cloudEventsBinary;
1128
1243
  exports.cloudEventsStructured = cloudEventsStructured;
1129
1244
  exports.decodeCloudEvent = decodeCloudEvent;
1130
1245
  exports.decodeEnvelope = decodeEnvelope;
1246
+ exports.decodeKafkaRecord = decodeKafkaRecord;
1131
1247
  exports.encodeEnvelope = encodeEnvelope;
1132
1248
  exports.jsonEnvelope = jsonEnvelope;
1133
1249
  exports.plainJsonDecoder = plainJsonDecoder;