@relaybus/kafka 0.1.0 → 0.2.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/README.md +126 -19
- package/dist/index.cjs +277 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +163 -2
- package/dist/index.d.ts +163 -2
- package/dist/index.js +274 -14
- package/dist/index.js.map +1 -1
- package/package.json +22 -9
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @relaybus/kafka
|
|
2
2
|
|
|
3
|
+
@relaybus/kafka is the Apache Kafka transport of relaybus, a reliable, storage-agnostic event bus for NestJS and Node.js: it forwards committed outbox events to Kafka topics and stores consumed events once in an idempotent inbox, so events are not lost between your database and Kafka.
|
|
4
|
+
|
|
3
5
|
Apache Kafka transport for [relaybus](../../README.md). It forwards committed
|
|
4
6
|
outbox events to Kafka topics and feeds events consumed from Kafka into the
|
|
5
7
|
bus inbox, with at-least-once delivery and idempotent storage.
|
|
@@ -51,25 +53,26 @@ const consuming = transport.isConsuming();
|
|
|
51
53
|
|
|
52
54
|
## Options
|
|
53
55
|
|
|
54
|
-
| Option | Default
|
|
55
|
-
| ------------------------------ |
|
|
56
|
-
| `kafka` | required
|
|
57
|
-
| `name` | `'kafka'`
|
|
58
|
-
| `groupId` | none
|
|
59
|
-
| `fromBeginning` | `true`
|
|
60
|
-
| `maxInFlightRequests` | `1`
|
|
61
|
-
| `producerConfig` | `{}`
|
|
62
|
-
| `consumerConfig` | `{}`
|
|
63
|
-
| `processing.maxAttempts` | `5`
|
|
64
|
-
| `processing.initialBackoff` | `'200ms'`
|
|
65
|
-
| `processing.maxBackoff` | `'5s'`
|
|
66
|
-
| `processing.heartbeatInterval` | `'1s'`
|
|
67
|
-
| `startupTimeout` | `'30s'`
|
|
68
|
-
| `publishTimeout` | `'30s'`
|
|
69
|
-
| `restart.initialBackoff` | `'1s'`
|
|
70
|
-
| `restart.maxBackoff` | `'30s'`
|
|
71
|
-
| `onInvalidMessage` | `'dead-letter'`
|
|
72
|
-
| `
|
|
56
|
+
| Option | Default | Meaning |
|
|
57
|
+
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
58
|
+
| `kafka` | required | A `kafkajs` `Kafka` or Confluent `KafkaJS.Kafka` instance. It is the producer/consumer factory. |
|
|
59
|
+
| `name` | `'kafka'` | Transport name used by routes and subscriptions. |
|
|
60
|
+
| `groupId` | none | Consumer group. Required as soon as `subscribe` is used; publish-only services can omit it. |
|
|
61
|
+
| `fromBeginning` | `true` | Where a group without committed offsets starts. `true` so events published before the first start are not skipped. |
|
|
62
|
+
| `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
|
+
| `processing.maxAttempts` | `5` | In-process attempts per delivery of a record. |
|
|
66
|
+
| `processing.initialBackoff` | `'200ms'` | Delay before attempt 2, doubling per attempt. |
|
|
67
|
+
| `processing.maxBackoff` | `'5s'` | Upper bound of the delay between attempts. |
|
|
68
|
+
| `processing.heartbeatInterval` | `'1s'` | Heartbeat interval while sleeping between attempts, so a long backoff never exceeds `sessionTimeout`. |
|
|
69
|
+
| `startupTimeout` | `'30s'` | How long `subscribe` waits for the group join. A failed start-up disconnects the consumer it started. |
|
|
70
|
+
| `publishTimeout` | `'30s'` | How long `publish` waits for the broker ack before rejecting with `PublishTimeoutError`. |
|
|
71
|
+
| `restart.initialBackoff` | `'1s'` | First delay of the consumer restart loop after an unrecoverable crash; doubles per failure. |
|
|
72
|
+
| `restart.maxBackoff` | `'30s'` | Upper bound of the restart delay. |
|
|
73
|
+
| `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). |
|
|
74
|
+
| `decode` | strict binary mode | How consumed records become events. `decodeCloudEvent` (binary and structured mode), `plainJsonDecoder({...})` (plain JSON from producers without relaybus) or your own function. See [Interoperating with services that do not use relaybus](#interoperating-with-services-that-do-not-use-relaybus). |
|
|
75
|
+
| `logger` | `console` | `log`, `warn`, `error`. Messages start with `[KafkaTransport](method)`. |
|
|
73
76
|
|
|
74
77
|
## Wire format
|
|
75
78
|
|
|
@@ -102,6 +105,85 @@ RFC 3339), are rejected on publish with `UnencodableEventError`, a `NonRetryable
|
|
|
102
105
|
retrying cannot help. `Date` payload values become ISO strings. A header named `__proto__` is kept
|
|
103
106
|
as ordinary data by the codec (kafkajs itself drops it when it builds the consumer's header object).
|
|
104
107
|
|
|
108
|
+
## Interoperating with services that do not use relaybus
|
|
109
|
+
|
|
110
|
+
**relaybus producer, other consumer.** Any Kafka client reads the record as is: the value is the
|
|
111
|
+
JSON payload (UTF-8), the key is the event key, and the `ce_*` headers above carry the attributes.
|
|
112
|
+
A plain consumer only needs `JSON.parse(value)` and the `ce_type` / `ce_id` headers. CloudEvents
|
|
113
|
+
SDKs decode the record natively, because it is CloudEvents binary mode of the Kafka protocol
|
|
114
|
+
binding: Java `io.cloudevents:cloudevents-kafka` (`CloudEventDeserializer`), Go `sdk-go`
|
|
115
|
+
(`protocol/kafka_sarama`), and Spring Cloud Function / Spring Cloud Stream, which read the
|
|
116
|
+
`ce_`-prefixed headers. Use `ce_id` to deduplicate: an outbox delivers at least once.
|
|
117
|
+
|
|
118
|
+
**Other producer, relaybus consumer.** By default the transport accepts only binary-mode
|
|
119
|
+
CloudEvents; anything else is malformed and dead-lettered, so a misconfigured producer cannot
|
|
120
|
+
slip events in. Opt in per transport (one transport is one subscription) with `decode`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import {
|
|
124
|
+
KafkaTransport,
|
|
125
|
+
decodeCloudEvent,
|
|
126
|
+
fromHeader,
|
|
127
|
+
fromPayloadField,
|
|
128
|
+
plainJsonDecoder,
|
|
129
|
+
type KafkaMessageDecoder,
|
|
130
|
+
} from '@relaybus/kafka';
|
|
131
|
+
|
|
132
|
+
// A Spring / Go / plain kafkajs producer writing JSON values with an `event-type` header.
|
|
133
|
+
const transport = new KafkaTransport({
|
|
134
|
+
kafka,
|
|
135
|
+
groupId: 'billing',
|
|
136
|
+
decode: plainJsonDecoder({ type: fromHeader('event-type'), id: fromHeader('message-id') }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A topic that carries one kind of event; the ordering key from the payload.
|
|
140
|
+
const oneEventType = plainJsonDecoder({
|
|
141
|
+
type: 'order.placed.v1',
|
|
142
|
+
key: fromPayloadField('orderId'),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// A CloudEvents SDK producer in structured mode (`content-type: application/cloudevents+json`).
|
|
146
|
+
const cloudEvents: KafkaMessageDecoder = decodeCloudEvent;
|
|
147
|
+
|
|
148
|
+
// Different formats per topic: switch on the record.
|
|
149
|
+
const perTopic: KafkaMessageDecoder = (record) =>
|
|
150
|
+
record.topic === 'legacy.orders' ? oneEventType(record) : decodeCloudEvent(record);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
| Decoder | Accepts |
|
|
154
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| default (`decodeEnvelope`) | Binary-mode CloudEvents only, strictly as described above. Unchanged. |
|
|
156
|
+
| `decodeCloudEvent` | Binary mode as the default, plus structured mode: a JSON value with `specversion` `1.0`, `id`, `type`, `source`, optional `time` (else the record timestamp), `subject` (else the record key), `data` (the payload; `data_base64` is refused). |
|
|
157
|
+
| `plainJsonDecoder(opts)` | Any JSON value. A record that is a CloudEvent anyway (a `ce_specversion` header or a structured content type) goes through `decodeCloudEvent`, so relaybus and foreign producers can share a topic. |
|
|
158
|
+
| your function | `(record) => ({ id, type, payload, ...optional })`. Missing fields are filled: `source` = topic, `key` = record key, `occurredAt` = record timestamp, `headers` = `{}`. |
|
|
159
|
+
|
|
160
|
+
`plainJsonDecoder` options (each extractor gets `{ topic, partition, offset, key, headers, payload, timestamp }`;
|
|
161
|
+
an extractor returning `undefined` or `''` selects the default):
|
|
162
|
+
|
|
163
|
+
| Option | Default | Meaning |
|
|
164
|
+
| ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
165
|
+
| `type` | required | A fixed event type, or an extractor such as `fromHeader('event-type')` or `fromPayloadField('type')`. |
|
|
166
|
+
| `id` | `kafka:<topic>:<partition>:<offset>` | Inbox dedup id. The default is stable across redeliveries of the record. Prefer a producer id when it may send an event twice. |
|
|
167
|
+
| `key` | the record key | Ordering key. A key that is not UTF-8 (Java `LongSerializer`) is base64-encoded. |
|
|
168
|
+
| `source` | the topic | Fixed text or an extractor. |
|
|
169
|
+
| `occurredAt` | the record timestamp | Extractor returning a `Date`. |
|
|
170
|
+
|
|
171
|
+
Every record header becomes an event header (the last value of a repeated header; a value that is
|
|
172
|
+
not valid UTF-8 is left out, so binary ids can never collide).
|
|
173
|
+
|
|
174
|
+
**Guarantees stay the same.** Decoding runs again on every redelivery and must be deterministic;
|
|
175
|
+
the inbox deduplicates on the id, so a redelivered record is stored and handled once. A record that
|
|
176
|
+
cannot be decoded (not JSON, a tombstone, no type, an invalid type, an extractor returning a
|
|
177
|
+
non-string) and anything a custom decoder throws go to `onInvalidMessage` (dead-letter by default)
|
|
178
|
+
with the reason in `relaybus-invalid-reason`; the consumer keeps running. Per-key order is the
|
|
179
|
+
Kafka partition order, as for relaybus producers.
|
|
180
|
+
|
|
181
|
+
**Limits.** The derived id assumes offsets are never reused: deleting and recreating a topic
|
|
182
|
+
restarts its offsets, so records after a recreation could collide with ids already in the inbox
|
|
183
|
+
(within its retention). Two sends of one event by a producer are two offsets; only an id set by the
|
|
184
|
+
producer (`id: fromHeader(...)`) dedupes them. Payloads must be JSON (Avro or Protobuf need your own
|
|
185
|
+
decoder function, which must stay synchronous).
|
|
186
|
+
|
|
105
187
|
## Partitioning and ordering
|
|
106
188
|
|
|
107
189
|
The key decides the partition. Events with the same key are consumed in publish order; events
|
|
@@ -214,3 +296,28 @@ kafkajs as the supported client.
|
|
|
214
296
|
transport recovered it. Repeated `Restart failed` logs mean the broker or credentials are the problem.
|
|
215
297
|
- `PublishTimeoutError`: no broker ack within `publishTimeout`; the outbox retries.
|
|
216
298
|
- Events appear twice in logs but handlers run once: expected, the inbox deduplicates on the id.
|
|
299
|
+
|
|
300
|
+
## When to use @relaybus/kafka, and when not to
|
|
301
|
+
|
|
302
|
+
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.
|
|
303
|
+
|
|
304
|
+
Documentation: <https://berkcansavur.github.io/relaybus/packages/kafka>
|
|
305
|
+
|
|
306
|
+
### relaybus vs alternatives
|
|
307
|
+
|
|
308
|
+
<!-- relaybus:snippet:comparison -->
|
|
309
|
+
|
|
310
|
+
| | What it is | Survives a restart | Written in your database transaction | Choose it when |
|
|
311
|
+
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
312
|
+
| **relaybus** | Event bus built on the transactional outbox: PostgreSQL and MongoDB stores, Kafka and RabbitMQ transports, per-handler deliveries | Yes, events live in your database | Yes | Events must never be lost or phantom, and handlers need retries, dead letters and ordering keys |
|
|
313
|
+
| [`@nestjs/event-emitter`](https://docs.nestjs.com/techniques/events) | In-process event emitter built on `eventemitter2` | No persistence is documented | No | Simple in-process notifications where losing an event on restart is acceptable |
|
|
314
|
+
| [`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs) | CommandBus, QueryBus, EventBus and sagas; the docs say sagas and event handlers run in memory | No built-in persistence | No | You want CQRS structure; it can sit on top of an outbox |
|
|
315
|
+
| [`@nestjs/outbox`](https://github.com/nestjs/outbox) | Transactional outbox module from the NestJS organisation: PostgreSQL, MySQL and in-memory stores, relay with leases and retries, dead-letter table, inbox | Yes with the database stores | Yes | You need MySQL, or a NestJS-organisation module; compare features with relaybus before choosing |
|
|
316
|
+
| [BullMQ](https://docs.bullmq.io/) | Job queue on Redis with retries, delayed jobs and flows; at-least-once in the worst case | Yes, in Redis | No, jobs are stored in Redis and cannot join a SQL or MongoDB transaction | Your stack is Redis-based and background jobs matter more than event atomicity |
|
|
317
|
+
| [pg-boss](https://github.com/timgit/pg-boss) | Job queue on PostgreSQL using `SKIP LOCKED`, with retries, dead letter queues, scheduling and pub/sub; jobs can be created in your own transaction | Yes, in PostgreSQL | Yes, through its database adapters | You want a PostgreSQL job queue with scheduling; relaybus is an event bus with per-handler deliveries and transports |
|
|
318
|
+
| Kafka client directly | A distributed log; the idempotent producer and transactions give exactly-once within Kafka | Yes, in Kafka | No, producing to Kafka is a second write | You consume and produce only inside Kafka; add an outbox when a database write must be announced |
|
|
319
|
+
|
|
320
|
+
This table summarises the vendors' own documentation as of October 2026; check their docs for current details.
|
|
321
|
+
Full comparison and sources: <https://berkcansavur.github.io/relaybus/comparison>.
|
|
322
|
+
|
|
323
|
+
<!-- relaybus:end -->
|
package/dist/index.cjs
CHANGED
|
@@ -149,17 +149,20 @@ function resolveTime(text, recordTimestamp) {
|
|
|
149
149
|
if (text !== void 0 && text.length > 0) {
|
|
150
150
|
return parseTime(text);
|
|
151
151
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
if (recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime())) {
|
|
152
|
+
const time = readRecordTimestamp(recordTimestamp);
|
|
153
|
+
if (time === void 0) {
|
|
155
154
|
throw invalid(`${HEADER.time} is missing and the record has no usable timestamp`);
|
|
156
155
|
}
|
|
157
156
|
return time;
|
|
158
157
|
}
|
|
159
|
-
function
|
|
158
|
+
function readRecordTimestamp(recordTimestamp) {
|
|
159
|
+
const time = new Date(Number(recordTimestamp));
|
|
160
|
+
return recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime()) ? void 0 : time;
|
|
161
|
+
}
|
|
162
|
+
function parseTime(text, label = HEADER.time) {
|
|
160
163
|
const time = new Date(text);
|
|
161
164
|
if (!RFC3339_PATTERN.test(text) || Number.isNaN(time.getTime())) {
|
|
162
|
-
throw invalid(`${
|
|
165
|
+
throw invalid(`${label} is not an RFC 3339 timestamp: '${text}'`);
|
|
163
166
|
}
|
|
164
167
|
return time;
|
|
165
168
|
}
|
|
@@ -175,12 +178,15 @@ function parsePayload(value) {
|
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
function resolveKey(subject, recordKey) {
|
|
178
|
-
const keyText =
|
|
181
|
+
const keyText = readRecordKey(recordKey);
|
|
179
182
|
if (subject !== void 0 && keyText !== null && subject !== keyText) {
|
|
180
183
|
throw invalid(`${HEADER.subject} does not match the message key`);
|
|
181
184
|
}
|
|
182
185
|
return subject ?? keyText;
|
|
183
186
|
}
|
|
187
|
+
function readRecordKey(recordKey) {
|
|
188
|
+
return recordKey === null ? null : decodeText(recordKey, true, "Message key");
|
|
189
|
+
}
|
|
184
190
|
function collectUserHeaders(headers) {
|
|
185
191
|
const entries = [];
|
|
186
192
|
for (const [name, value] of headers) {
|
|
@@ -208,6 +214,219 @@ function toOutgoingHeaders(incoming) {
|
|
|
208
214
|
function toBufferOrString(value) {
|
|
209
215
|
return typeof value === "string" ? value : Buffer.from(value);
|
|
210
216
|
}
|
|
217
|
+
var STRUCTURED_MEDIA_TYPE = "application/cloudevents+json";
|
|
218
|
+
var exactUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
219
|
+
var SPEC_VERSION2 = "1.0";
|
|
220
|
+
var SPEC_VERSION_HEADER = "ce_specversion";
|
|
221
|
+
var STRUCTURED_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
222
|
+
"specversion",
|
|
223
|
+
"id",
|
|
224
|
+
"type",
|
|
225
|
+
"source",
|
|
226
|
+
"time",
|
|
227
|
+
"subject",
|
|
228
|
+
"datacontenttype",
|
|
229
|
+
"dataschema",
|
|
230
|
+
"data",
|
|
231
|
+
"data_base64",
|
|
232
|
+
"correlationid",
|
|
233
|
+
"causationid"
|
|
234
|
+
]);
|
|
235
|
+
function decodeCloudEvent(record) {
|
|
236
|
+
const headers = readHeaders(record.headers);
|
|
237
|
+
if (mediaTypeOf(headers.get(CONTENT_TYPE)) !== STRUCTURED_MEDIA_TYPE) {
|
|
238
|
+
return decodeEnvelope(record);
|
|
239
|
+
}
|
|
240
|
+
const body = parsePayload(record.value);
|
|
241
|
+
if (!isJsonObject(body)) {
|
|
242
|
+
throw invalid("A structured CloudEvent must be a JSON object");
|
|
243
|
+
}
|
|
244
|
+
if (body["specversion"] !== SPEC_VERSION2) {
|
|
245
|
+
throw invalid(`specversion must be '${SPEC_VERSION2}'`);
|
|
246
|
+
}
|
|
247
|
+
const type = requiredAttribute(body, "type");
|
|
248
|
+
assertValidType(type, "type");
|
|
249
|
+
assertJsonDataContentType(optionalAttribute(body, "datacontenttype"));
|
|
250
|
+
if (Object.hasOwn(body, "data_base64")) {
|
|
251
|
+
throw invalid("data_base64 is not supported: relaybus payloads are JSON, send them as data");
|
|
252
|
+
}
|
|
253
|
+
const time = optionalAttribute(body, "time");
|
|
254
|
+
return {
|
|
255
|
+
id: requiredAttribute(body, "id"),
|
|
256
|
+
type,
|
|
257
|
+
source: requiredAttribute(body, "source"),
|
|
258
|
+
key: optionalAttribute(body, "subject") ?? readRecordKey(record.key),
|
|
259
|
+
payload: Object.hasOwn(body, "data") ? body["data"] : null,
|
|
260
|
+
headers: { ...collectUserHeaders(headers), ...extensionAttributes(body) },
|
|
261
|
+
correlationId: optionalAttribute(body, "correlationid") ?? null,
|
|
262
|
+
causationId: optionalAttribute(body, "causationid") ?? null,
|
|
263
|
+
occurredAt: time === void 0 ? requireRecordTimestamp(record) : parseTime(time, "time")
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function plainJsonDecoder(options) {
|
|
267
|
+
if (typeof options.type === "string") {
|
|
268
|
+
core.assertEventType(options.type);
|
|
269
|
+
}
|
|
270
|
+
return (record) => {
|
|
271
|
+
const headers = readHeadersLeniently(record.headers);
|
|
272
|
+
if (record.headers?.[SPEC_VERSION_HEADER] !== void 0 || mediaTypeOf(headers[CONTENT_TYPE]) === STRUCTURED_MEDIA_TYPE) {
|
|
273
|
+
return decodeCloudEvent(record);
|
|
274
|
+
}
|
|
275
|
+
const plain = {
|
|
276
|
+
topic: record.topic,
|
|
277
|
+
partition: record.partition,
|
|
278
|
+
offset: record.offset,
|
|
279
|
+
key: readKeyLeniently(record.key),
|
|
280
|
+
headers,
|
|
281
|
+
payload: parsePayload(record.value),
|
|
282
|
+
timestamp: record.timestamp
|
|
283
|
+
};
|
|
284
|
+
const type = resolveText(options.type, plain, "type");
|
|
285
|
+
if (type === void 0) {
|
|
286
|
+
throw invalid("Record has no event type: the type option found none");
|
|
287
|
+
}
|
|
288
|
+
assertValidType(type, "The event type");
|
|
289
|
+
return {
|
|
290
|
+
id: resolveText(options.id, plain, "id") ?? `kafka:${record.topic}:${record.partition}:${record.offset}`,
|
|
291
|
+
type,
|
|
292
|
+
source: resolveText(options.source, plain, "source") ?? record.topic,
|
|
293
|
+
key: options.key === void 0 ? plain.key : resolveText(options.key, plain, "key") ?? null,
|
|
294
|
+
payload: plain.payload,
|
|
295
|
+
headers,
|
|
296
|
+
correlationId: null,
|
|
297
|
+
causationId: null,
|
|
298
|
+
occurredAt: resolveOccurredAt(options.occurredAt, plain, record)
|
|
299
|
+
};
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function completeDecodedEvent(decoded, record) {
|
|
303
|
+
if (typeof decoded !== "object" || decoded === null) {
|
|
304
|
+
throw invalid("The decoder must return an event object synchronously");
|
|
305
|
+
}
|
|
306
|
+
if ("then" in decoded) {
|
|
307
|
+
observeRejection(decoded);
|
|
308
|
+
throw invalid("The decoder must return an event object synchronously");
|
|
309
|
+
}
|
|
310
|
+
const event = decoded;
|
|
311
|
+
return {
|
|
312
|
+
id: event.id,
|
|
313
|
+
type: event.type,
|
|
314
|
+
source: event.source ?? record.topic,
|
|
315
|
+
key: event.key === void 0 ? readRecordKey(record.key) : event.key,
|
|
316
|
+
payload: event.payload,
|
|
317
|
+
headers: event.headers ?? {},
|
|
318
|
+
correlationId: event.correlationId ?? null,
|
|
319
|
+
causationId: event.causationId ?? null,
|
|
320
|
+
occurredAt: event.occurredAt ?? requireRecordTimestamp(record)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function mediaTypeOf(contentType) {
|
|
324
|
+
return contentType?.split(";")[0]?.trim().toLowerCase();
|
|
325
|
+
}
|
|
326
|
+
function isJsonObject(value) {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
329
|
+
function optionalAttribute(body, name) {
|
|
330
|
+
const value = body[name];
|
|
331
|
+
if (value !== void 0 && typeof value !== "string") {
|
|
332
|
+
throw invalid(`Attribute '${name}' must be a string`);
|
|
333
|
+
}
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
function requiredAttribute(body, name) {
|
|
337
|
+
const value = optionalAttribute(body, name);
|
|
338
|
+
if (value === void 0 || value.length === 0) {
|
|
339
|
+
throw invalid(`Attribute '${name}' is missing or empty`);
|
|
340
|
+
}
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
function assertValidType(type, label) {
|
|
344
|
+
try {
|
|
345
|
+
core.assertEventType(type);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
throw invalid(`${label} is not a valid event type: ${error.message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function assertJsonDataContentType(dataContentType) {
|
|
351
|
+
const mediaType = mediaTypeOf(dataContentType);
|
|
352
|
+
if (mediaType !== void 0 && mediaType !== "application/json" && mediaType !== "text/json" && !mediaType.endsWith("+json")) {
|
|
353
|
+
throw invalid(`datacontenttype must be JSON, received '${dataContentType}'`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function extensionAttributes(body) {
|
|
357
|
+
return Object.fromEntries(
|
|
358
|
+
Object.entries(body).filter(
|
|
359
|
+
(entry) => !STRUCTURED_ATTRIBUTES.has(entry[0]) && typeof entry[1] === "string"
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
function requireRecordTimestamp(record) {
|
|
364
|
+
const time = readRecordTimestamp(record.timestamp);
|
|
365
|
+
if (time === void 0) {
|
|
366
|
+
throw invalid("The event has no time and the record has no usable timestamp");
|
|
367
|
+
}
|
|
368
|
+
return time;
|
|
369
|
+
}
|
|
370
|
+
function readHeadersLeniently(incoming) {
|
|
371
|
+
const entries = [];
|
|
372
|
+
for (const [name, raw] of Object.entries(incoming ?? {})) {
|
|
373
|
+
const value = Array.isArray(raw) ? raw.at(-1) : raw;
|
|
374
|
+
const text = value === void 0 ? void 0 : decodeUtf8OrUndefined(value);
|
|
375
|
+
if (text !== void 0) {
|
|
376
|
+
entries.push([name, text]);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return Object.fromEntries(entries);
|
|
380
|
+
}
|
|
381
|
+
function decodeUtf8OrUndefined(value) {
|
|
382
|
+
if (typeof value === "string") {
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
return exactUtf8.decode(value);
|
|
387
|
+
} catch {
|
|
388
|
+
return void 0;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function readKeyLeniently(key) {
|
|
392
|
+
if (key === null) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
return readRecordKey(key);
|
|
397
|
+
} catch {
|
|
398
|
+
return Buffer.from(key).toString("base64");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function resolveText(option, record, name) {
|
|
402
|
+
const value = typeof option === "function" ? option(record) : option;
|
|
403
|
+
if (value === void 0 || value === null) {
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
if (typeof value !== "string") {
|
|
407
|
+
observeRejection(value);
|
|
408
|
+
throw invalid(`The ${name} option returned ${typeof value}, expected a string`);
|
|
409
|
+
}
|
|
410
|
+
return value.length === 0 ? void 0 : value;
|
|
411
|
+
}
|
|
412
|
+
function resolveOccurredAt(option, plain, record) {
|
|
413
|
+
const value = option?.(plain);
|
|
414
|
+
if (value === void 0) {
|
|
415
|
+
return requireRecordTimestamp(record);
|
|
416
|
+
}
|
|
417
|
+
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
|
418
|
+
observeRejection(value);
|
|
419
|
+
throw invalid("The occurredAt option returned no valid Date");
|
|
420
|
+
}
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
function observeRejection(value) {
|
|
424
|
+
if (typeof value === "object" && value !== null && "then" in value) {
|
|
425
|
+
Promise.resolve(value).catch(() => void 0);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/kafka-transport.ts
|
|
211
430
|
var PublishTimeoutError = class extends core.RelayBusError {
|
|
212
431
|
name = "PublishTimeoutError";
|
|
213
432
|
};
|
|
@@ -235,6 +454,7 @@ var KafkaTransport = class {
|
|
|
235
454
|
#producerConfig;
|
|
236
455
|
#consumerConfig;
|
|
237
456
|
#invalidPolicy;
|
|
457
|
+
#decode;
|
|
238
458
|
#logger;
|
|
239
459
|
#settings;
|
|
240
460
|
#sleepers = /* @__PURE__ */ new Set();
|
|
@@ -262,6 +482,7 @@ var KafkaTransport = class {
|
|
|
262
482
|
this.#producerConfig = options.producerConfig ?? {};
|
|
263
483
|
this.#consumerConfig = options.consumerConfig ?? {};
|
|
264
484
|
this.#invalidPolicy = options.onInvalidMessage ?? "dead-letter";
|
|
485
|
+
this.#decode = options.decode;
|
|
265
486
|
this.#logger = options.logger ?? console;
|
|
266
487
|
this.#settings = resolveSettings(options);
|
|
267
488
|
}
|
|
@@ -443,16 +664,13 @@ var KafkaTransport = class {
|
|
|
443
664
|
async #handleMessage(payload, onMessage) {
|
|
444
665
|
let envelope;
|
|
445
666
|
try {
|
|
446
|
-
envelope =
|
|
667
|
+
envelope = this.#decodeRecord(payload);
|
|
447
668
|
} catch (error) {
|
|
448
|
-
if (!(error instanceof InvalidKafkaMessageError)) {
|
|
669
|
+
if (this.#decode === void 0 && !(error instanceof InvalidKafkaMessageError)) {
|
|
449
670
|
throw toHandlerFailure(error);
|
|
450
671
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
"handleInvalid",
|
|
454
|
-
() => this.#handleInvalid(payload, error.message)
|
|
455
|
-
);
|
|
672
|
+
const reason = core.toErrorInfo(error).message;
|
|
673
|
+
await this.#withRetries(payload, "handleInvalid", () => this.#handleInvalid(payload, reason));
|
|
456
674
|
return;
|
|
457
675
|
}
|
|
458
676
|
await this.#withRetries(payload, "deliver", async () => {
|
|
@@ -466,6 +684,21 @@ var KafkaTransport = class {
|
|
|
466
684
|
}
|
|
467
685
|
});
|
|
468
686
|
}
|
|
687
|
+
#decodeRecord({ topic, partition, message }) {
|
|
688
|
+
if (this.#decode === void 0) {
|
|
689
|
+
return decodeEnvelope(message);
|
|
690
|
+
}
|
|
691
|
+
const record = {
|
|
692
|
+
topic,
|
|
693
|
+
partition,
|
|
694
|
+
offset: message.offset,
|
|
695
|
+
key: message.key,
|
|
696
|
+
value: message.value,
|
|
697
|
+
headers: message.headers,
|
|
698
|
+
timestamp: message.timestamp
|
|
699
|
+
};
|
|
700
|
+
return completeDecodedEvent(this.#decode(record), record);
|
|
701
|
+
}
|
|
469
702
|
async #handleInvalid(payload, reason) {
|
|
470
703
|
const { topic, partition, message } = payload;
|
|
471
704
|
const location = `topic=${topic} partition=${partition} offset=${message.offset}`;
|
|
@@ -707,13 +940,44 @@ function toHandlerFailure(error) {
|
|
|
707
940
|
const { name, message } = core.toErrorInfo(error);
|
|
708
941
|
return new HandlerFailedError(`${name}: ${message}`);
|
|
709
942
|
}
|
|
943
|
+
function fromHeader(name) {
|
|
944
|
+
if (name.length === 0) {
|
|
945
|
+
throw new core.ConfigurationError("fromHeader needs a header name");
|
|
946
|
+
}
|
|
947
|
+
return (message) => Object.hasOwn(message.headers, name) ? message.headers[name] : void 0;
|
|
948
|
+
}
|
|
949
|
+
function fromPayloadField(path) {
|
|
950
|
+
const segments = path.split(".");
|
|
951
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
952
|
+
throw new core.ConfigurationError(
|
|
953
|
+
`fromPayloadField needs a dotted path of non-empty names, received '${path}'`
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
return (message) => {
|
|
957
|
+
let value = message.payload;
|
|
958
|
+
for (const segment of segments) {
|
|
959
|
+
if (typeof value !== "object" || value === null || !Object.hasOwn(value, segment)) {
|
|
960
|
+
return void 0;
|
|
961
|
+
}
|
|
962
|
+
value = value[segment];
|
|
963
|
+
}
|
|
964
|
+
if (typeof value === "string") {
|
|
965
|
+
return value;
|
|
966
|
+
}
|
|
967
|
+
return Number.isSafeInteger(value) ? String(value) : void 0;
|
|
968
|
+
};
|
|
969
|
+
}
|
|
710
970
|
|
|
711
971
|
exports.HandlerFailedError = HandlerFailedError;
|
|
712
972
|
exports.InvalidKafkaMessageError = InvalidKafkaMessageError;
|
|
713
973
|
exports.KafkaTransport = KafkaTransport;
|
|
714
974
|
exports.PublishTimeoutError = PublishTimeoutError;
|
|
715
975
|
exports.UnencodableEventError = UnencodableEventError;
|
|
976
|
+
exports.decodeCloudEvent = decodeCloudEvent;
|
|
716
977
|
exports.decodeEnvelope = decodeEnvelope;
|
|
717
978
|
exports.encodeEnvelope = encodeEnvelope;
|
|
979
|
+
exports.fromHeader = fromHeader;
|
|
980
|
+
exports.fromPayloadField = fromPayloadField;
|
|
981
|
+
exports.plainJsonDecoder = plainJsonDecoder;
|
|
718
982
|
//# sourceMappingURL=index.cjs.map
|
|
719
983
|
//# sourceMappingURL=index.cjs.map
|