@relaybus/kafka 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Berkcan Şavur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # @relaybus/kafka
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Apache Kafka transport for [relaybus](../../README.md). It forwards committed
4
+ outbox events to Kafka topics and feeds events consumed from Kafka into the
5
+ bus inbox, with at-least-once delivery and idempotent storage.
6
+
7
+ - 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.
8
+ - 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.
9
+ - Never loses an event: the offset is committed only after the bus stored the event; a failing store blocks the partition instead of skipping.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ pnpm add @relaybus/core @relaybus/kafka kafkajs
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { Kafka, Partitioners } from 'kafkajs';
21
+ import { RelayBus } from '@relaybus/core';
22
+ import { KafkaTransport } from '@relaybus/kafka';
23
+
24
+ const kafka = new Kafka({ clientId: 'orders', brokers: ['kafka:9092'] });
25
+
26
+ const transport = new KafkaTransport({
27
+ kafka,
28
+ groupId: 'billing', // required to subscribe
29
+ producerConfig: { createPartitioner: Partitioners.DefaultPartitioner },
30
+ });
31
+
32
+ const bus = new RelayBus({
33
+ store,
34
+ source: 'orders',
35
+ transports: [transport],
36
+ routes: [{ event: OrderPlaced, transport: 'kafka', destination: 'orders.events' }],
37
+ subscriptions: [{ transport: 'kafka', destinations: ['payments.events'] }],
38
+ });
39
+ await bus.start(); // throws if the consumer cannot join its group
40
+ ```
41
+
42
+ Readiness for Nest or Kubernetes:
43
+
44
+ ```ts
45
+ // true only when the producer is connected and, if subscribed, the consumer is consuming
46
+ const ready = transport.isReady(); // used by the bus health check
47
+
48
+ // false while rebalancing, after a crash, after close, or while any partition is stuck failing
49
+ const consuming = transport.isConsuming();
50
+ ```
51
+
52
+ ## Options
53
+
54
+ | Option | Default | Meaning |
55
+ | ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
56
+ | `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance. It is the producer/consumer factory. |
57
+ | `name` | `'kafka'` | Transport name used by routes and subscriptions. |
58
+ | `groupId` | none | Consumer group. Required as soon as `subscribe` is used; publish-only services can omit it. |
59
+ | `fromBeginning` | `true` | Where a group without committed offsets starts. `true` so events published before the first start are not skipped. |
60
+ | `maxInFlightRequests` | `1` | Producer in-flight requests, 1 to 5. `1` keeps retries from reordering events of one key. |
61
+ | `producerConfig` | `{}` | Extra producer settings (partitioner, compression, ...). `idempotent` and `maxInFlightRequests` always win. |
62
+ | `consumerConfig` | `{}` | Extra consumer settings (`sessionTimeout`, `retry`, `heartbeatInterval`, ...). `groupId` always wins. |
63
+ | `processing.maxAttempts` | `5` | In-process attempts per delivery of a record. |
64
+ | `processing.initialBackoff` | `'200ms'` | Delay before attempt 2, doubling per attempt. |
65
+ | `processing.maxBackoff` | `'5s'` | Upper bound of the delay between attempts. |
66
+ | `processing.heartbeatInterval` | `'1s'` | Heartbeat interval while sleeping between attempts, so a long backoff never exceeds `sessionTimeout`. |
67
+ | `startupTimeout` | `'30s'` | How long `subscribe` waits for the group join. A failed start-up disconnects the consumer it started. |
68
+ | `publishTimeout` | `'30s'` | How long `publish` waits for the broker ack before rejecting with `PublishTimeoutError`. |
69
+ | `restart.initialBackoff` | `'1s'` | First delay of the consumer restart loop after an unrecoverable crash; doubles per failure. |
70
+ | `restart.maxBackoff` | `'30s'` | Upper bound of the restart delay. |
71
+ | `onInvalidMessage` | `'dead-letter'` | `'dead-letter'` (to `<topic>.dead`), `{ deadLetterTopic: string \| (sourceTopic) => string }`, a callback `(message) => void \| Promise<void>`, or `'skip'` (drops the record; explicit opt-in). |
72
+ | `logger` | `console` | `log`, `warn`, `error`. Messages start with `[KafkaTransport](method)`. |
73
+
74
+ ## Wire format
75
+
76
+ Value: the JSON payload. Key: the envelope key (`null` means no key).
77
+
78
+ | Header | Content |
79
+ | ------------------------------------ | --------------------------------------------------------- |
80
+ | `ce_specversion` | `1.0` |
81
+ | `ce_id`, `ce_type`, `ce_source` | envelope `id`, `type`, `source` |
82
+ | `ce_time` | `occurredAt` as RFC 3339 UTC (`2026-01-01T00:00:00.000Z`) |
83
+ | `ce_subject` | envelope key, only when present |
84
+ | `content-type` | `application/json` |
85
+ | `ce_correlationid`, `ce_causationid` | relaybus extensions, only when present |
86
+ | anything else | envelope headers, passed through |
87
+
88
+ An envelope header whose name collides with the protocol (`ce_*`, `content-type`,
89
+ `x-relaybus-*`, any case) is stored as `x-relaybus-h-<name>` and restored on read,
90
+ so user headers can never overwrite event attributes and round-trip unchanged.
91
+ Decoding is strict: wrong spec version, missing or empty `ce_id`/`ce_type`/`ce_source`,
92
+ an invalid event type, a non-RFC 3339 `ce_time`, a non-JSON content type, a missing or non-JSON/UTF-8
93
+ value, repeated protocol headers, or a `ce_subject` that disagrees with the record key all make
94
+ the record malformed.
95
+
96
+ `ce_time` is optional in CloudEvents: when a producer omits it, the Kafka record timestamp becomes
97
+ `occurredAt`. relaybus itself always writes it.
98
+
99
+ Payloads are JSON. An `undefined`, function, symbol, `BigInt` or circular payload, and an
100
+ `occurredAt` that is an invalid date or lies outside the years 0 to 9999 (not representable as
101
+ RFC 3339), are rejected on publish with `UnencodableEventError`, a `NonRetryableError`, because
102
+ retrying cannot help. `Date` payload values become ISO strings. A header named `__proto__` is kept
103
+ as ordinary data by the codec (kafkajs itself drops it when it builds the consumer's header object).
104
+
105
+ ## Partitioning and ordering
106
+
107
+ The key decides the partition. Events with the same key are consumed in publish order; events
108
+ without a key (`key: null`) are spread over partitions and have no ordering. Use the aggregate id
109
+ as the key where order matters. Use the same partitioner in every producer of a topic, otherwise
110
+ one key can map to different partitions depending on who wrote it.
111
+
112
+ ## Guarantees
113
+
114
+ - **Publish**: idempotent producer, `acks=-1`; `publish` resolves after the broker ack and rejects
115
+ with `PublishTimeoutError` after `publishTimeout` (kafkajs would otherwise retry an idempotent
116
+ send for as long as the broker is unreachable). A rejected publish makes the outbox retry; a
117
+ timed-out send may still land, so a message can be duplicated, and the idempotent producer plus
118
+ the consumer's dedup on the event id absorb it. Pass `producerConfig: { retry: { retries } }` to
119
+ bound client-side retries further.
120
+ - **Consume**: `eachMessage` awaits the bus (`RelayBus.receive`, one idempotent insert) and the
121
+ client commits the offset only afterwards. A duplicate message is stored once.
122
+ - **Handler failure**: retried in-process `processing.maxAttempts` times with backoff and
123
+ heartbeats (also during the backoff sleeps). If it still fails, the error is rethrown to the client
124
+ wrapped in a `HandlerFailedError` that keeps the original name and message but has no `cause`;
125
+ kafkajs classifies a raw `TypeError`, `RangeError`, `ReferenceError` or `SyntaxError` as
126
+ unrecoverable and would stop the consumer for good. The client does not commit and re-fetches the
127
+ same record. The record is never skipped, so a persistent outage blocks the partition until the
128
+ store recovers. That is deliberate: `receive` is a cheap insert, and skipping would lose the
129
+ event. `isConsuming()` is `false` while any partition is stuck (tracked per partition).
130
+ - **Dead consumer**: the transport does not rely on the client to restart itself. When the client
131
+ reports a crash with `restart=false`, the transport logs it and reconnects, re-subscribes and
132
+ re-runs the consumer with bounded backoff (`restart.*`) until it joined again or the transport
133
+ closed. `isReady()` is `false` meanwhile.
134
+ We deliberately do not use `pause()` plus `seek()` plus return: the client resolves the offset of
135
+ a returned `eachMessage` before applying the seek, which can commit past the record if the
136
+ process dies in that window.
137
+ - **Malformed or rejected record**: a record that cannot be decoded, or that the bus rejects with
138
+ any `NonRetryableError` (such as `InvalidEnvelopeError` or `EventValidationError`), is handled by
139
+ `onInvalidMessage`. The default copies the untouched record to `<topic>.dead` with its original
140
+ headers plus `relaybus-invalid-reason`, `relaybus-source-topic`, `relaybus-source-partition` and
141
+ `relaybus-source-offset`, using the same idempotent producer, and commits only after the ack.
142
+ A failing callback or dead-letter send is retried like a handler failure, so a record is dropped
143
+ only when you chose `'skip'` or your callback returned. A subscription that consumes its own
144
+ dead-letter topic is rejected as a loop.
145
+ - **Start-up**: `subscribe` resolves only after the consumer joined its group. A connect, subscribe
146
+ or run error, a crash before the first join, or `startupTimeout` rejects it, so `bus.start()`
147
+ fails instead of reporting a healthy service that consumes nothing.
148
+
149
+ ## Client API used
150
+
151
+ Only these members are called (`KafkaClientLike` and friends in `src/kafka-client.ts`):
152
+
153
+ | Client | Members |
154
+ | -------------------------- | -------------------------------------------------------------------------------------------------------- |
155
+ | `kafka` | `producer(config)`, `consumer(config)` |
156
+ | producer | `connect()`, `send({ topic, acks, messages: [{ key, value, headers }] })`, `disconnect()` |
157
+ | consumer | `connect()`, `subscribe({ topics, fromBeginning })`, `run({ eachMessage })`, `disconnect()` |
158
+ | `eachMessage` payload | `topic`, `partition`, `message.{ key, value, headers, offset }`, `heartbeat()` |
159
+ | consumer events (optional) | `consumer.on(name, listener)` and `consumer.events.{ GROUP_JOIN, CRASH, REBALANCING, STOP, DISCONNECT }` |
160
+
161
+ `on`/`events` are optional: without `GROUP_JOIN` the transport cannot wait for the join or observe
162
+ crashes, so `subscribe` resolves when `run` returns and `isConsuming()` only reflects processing
163
+ stalls and `close()`. kafkajs 2.2.4 is verified by the integration tests.
164
+
165
+ ## Confluent (experimental, untested)
166
+
167
+ `@confluentinc/kafka-javascript` exposes a KafkaJS-compatible API, and the structural client types
168
+ here are satisfied by it, but nothing in this repository runs against it. Its documented
169
+ differences from kafkajs mean the options below do **not** work as written for Confluent:
170
+
171
+ - `idempotent` and `maxInFlightRequests` (producer) and `groupId` (consumer) must be nested in the
172
+ client's `kafkaJS: { ... }` config block. The transport passes them at the top level, so with
173
+ Confluent the producer is not guaranteed to be idempotent and the group id may be ignored.
174
+ - `fromBeginning` is not supported per `subscribe()`; it is a consumer-level `kafkaJS` setting.
175
+ The transport's `fromBeginning` option is therefore not applied.
176
+ - `heartbeat()` is a no-op there and the consumer `events` (`GROUP_JOIN`, `CRASH`, ...) are not
177
+ documented, so start-up falls back to "resolved when `run` returns" and crash recovery cannot
178
+ trigger.
179
+ - Failed `eachMessage` handling (re-seek) and the error classification of a rethrown error differ.
180
+
181
+ Use it only after verifying these against your version; until a Confluent path is tested, treat
182
+ kafkajs as the supported client.
183
+
184
+ ## Operations
185
+
186
+ - **Topics**: create them up front with the partitions and retention you want. Relying on
187
+ auto-creation gives broker defaults (and kafkajs subscribes to a missing topic only after
188
+ auto-creating it). Create the dead letter topics (`<topic>.dead` by default) too, or the broker
189
+ must auto-create them; otherwise the dead-letter send fails and the record keeps blocking its
190
+ partition (nothing is lost). Monitor the dead-letter topics.
191
+ - **One subscription per transport**: `subscribe` can be called once; list every topic in that
192
+ subscription. Create a second `KafkaTransport` (with its own `name` and `groupId`) for a second
193
+ consumer group.
194
+ - **Session timeout**: the retry loop heartbeats between attempts. Keep
195
+ `maxAttempts * maxBackoff` well below the consumer `sessionTimeout` (30 s default).
196
+ - **Concurrency**: one record at a time per partition (kafkajs default); scale with partitions and
197
+ service replicas in the same group. With `partitionsConsumedConcurrently > 1` in `consumerConfig`
198
+ the stall state is tracked per partition.
199
+ - **Retention vs. inbox**: Kafka may redeliver old records after rebalances or offset resets. The
200
+ bus deduplicates on the event id for as long as it keeps completed deliveries
201
+ (`retention.completed`, default 7 days), so keep that longer than your redelivery window.
202
+ - **Partitioner warning** (kafkajs 2.x): pass `createPartitioner: Partitioners.DefaultPartitioner`
203
+ in `producerConfig` to silence the legacy partitioner warning.
204
+
205
+ ## Troubleshooting
206
+
207
+ - `did not join group ... within 30000ms` at start: broker unreachable, wrong credentials, or the
208
+ group is rebalancing for longer than `startupTimeout`. Check the kafkajs connection settings.
209
+ - `isConsuming()` stays `false` with `Giving up for now` logs: `RelayBus.receive` keeps failing
210
+ (database down?). Fix the store; consumption resumes by itself.
211
+ - `Dead-lettered malformed message` logs: a producer on that topic is not writing CloudEvents binary
212
+ mode with `content-type: application/json`. Inspect `<topic>.dead` and its `relaybus-invalid-reason`.
213
+ - `Consumer crashed ... restart=false` followed by `Consumer restarted`: the client gave up and the
214
+ transport recovered it. Repeated `Restart failed` logs mean the broker or credentials are the problem.
215
+ - `PublishTimeoutError`: no broker ack within `publishTimeout`; the outbox retries.
216
+ - Events appear twice in logs but handlers run once: expected, the inbox deduplicates on the id.