@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 +21 -0
- package/README.md +215 -2
- package/dist/index.cjs +719 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +283 -0
- package/dist/index.d.ts +283 -0
- package/dist/index.js +711 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -3
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
|
-
#
|
|
1
|
+
# @relaybus/kafka
|
|
2
2
|
|
|
3
|
-
|
|
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.
|