@relaybus/kafka 0.0.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 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 | 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)`. |
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 milliseconds = Number(recordTimestamp);
153
- const time = new Date(milliseconds);
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 parseTime(text) {
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(`${HEADER.time} is not an RFC 3339 timestamp: '${text}'`);
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 = recordKey === null ? null : decodeText(recordKey, true, "Message key");
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 = decodeEnvelope(payload.message);
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
- await this.#withRetries(
452
- payload,
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