@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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RelayBusError, NonRetryableError, EventEnvelope, Duration, Transport, RelayBusLogger, TransportMessageHandler } from '@relaybus/core';
1
+ import { RelayBusError, NonRetryableError, EventEnvelope, EventHeaders, Duration, Transport, RelayBusLogger, TransportMessageHandler } from '@relaybus/core';
2
2
 
3
3
  /**
4
4
  * Structural views of the Kafka client API used by this package.
@@ -143,6 +143,125 @@ declare function encodeEnvelope(envelope: EventEnvelope): EncodedMessage;
143
143
  */
144
144
  declare function decodeEnvelope(message: DecodableMessage): EventEnvelope;
145
145
 
146
+ /** A consumed record as handed to a {@link KafkaMessageDecoder}. */
147
+ interface KafkaInboundRecord extends DecodableMessage {
148
+ readonly topic: string;
149
+ readonly partition: number;
150
+ readonly offset: string;
151
+ }
152
+ /**
153
+ * The event a decoder extracted from a record. `id`, `type` and `payload` are
154
+ * required; every other field may be left out and is then filled by the
155
+ * transport: `source` with the topic, `key` with the record key (UTF-8),
156
+ * `headers` with none, `correlationId` and `causationId` with null, and
157
+ * `occurredAt` with the record timestamp.
158
+ */
159
+ interface DecodedEvent {
160
+ readonly id: string;
161
+ readonly type: string;
162
+ readonly payload: unknown;
163
+ readonly source?: string;
164
+ readonly key?: string | null;
165
+ readonly headers?: EventHeaders;
166
+ readonly correlationId?: string | null;
167
+ readonly causationId?: string | null;
168
+ readonly occurredAt?: Date;
169
+ }
170
+ /**
171
+ * Turns a consumed record into an event, for topics written by producers that
172
+ * do not use relaybus. Must be synchronous and free of side effects: it runs
173
+ * again on every redelivery, so the same record must always give the same
174
+ * event, above all the same `id`, which is what the inbox deduplicates on.
175
+ *
176
+ * Anything it throws marks the record as malformed: it goes to the transport's
177
+ * `onInvalidMessage` policy (dead-letter by default) and the consumer moves on.
178
+ * An event the bus then rejects (empty id, invalid type or time) is
179
+ * dead-lettered the same way.
180
+ */
181
+ type KafkaMessageDecoder = (record: KafkaInboundRecord) => DecodedEvent;
182
+ /** A plain JSON record, decoded for the extractor options of {@link plainJsonDecoder}. */
183
+ interface PlainJsonRecord {
184
+ readonly topic: string;
185
+ readonly partition: number;
186
+ readonly offset: string;
187
+ /**
188
+ * The record key as UTF-8 text, base64 when its bytes are not valid UTF-8
189
+ * (for example a key written by Java's `LongSerializer`), null when absent.
190
+ */
191
+ readonly key: string | null;
192
+ /**
193
+ * Every record header as text: the last value of a repeated header; a value
194
+ * that is not valid UTF-8 is left out.
195
+ */
196
+ readonly headers: EventHeaders;
197
+ /** The record value parsed as JSON. */
198
+ readonly payload: unknown;
199
+ /** Record timestamp in epoch milliseconds. */
200
+ readonly timestamp: string | undefined;
201
+ }
202
+ /** Options of {@link plainJsonDecoder}. Extractors returning undefined or `''` select the default. */
203
+ interface PlainJsonDecoderOptions {
204
+ /**
205
+ * Event type handlers subscribe to: a fixed name for a topic that carries one
206
+ * kind of event, or an extractor such as `fromHeader('event-type')` or
207
+ * `fromPayloadField('type')`. Required; a record without a type is invalid.
208
+ */
209
+ readonly type: string | ((record: PlainJsonRecord) => string | undefined);
210
+ /**
211
+ * Event id the inbox deduplicates on, for example `fromHeader('message-id')`.
212
+ * Default, and fallback when the extractor finds none:
213
+ * `kafka:<topic>:<partition>:<offset>`, which is stable across redeliveries
214
+ * of the record. Prefer an id set by the producer when it may send the same
215
+ * event twice: two sends are two offsets, so only a producer id dedupes them.
216
+ */
217
+ readonly id?: (record: PlainJsonRecord) => string | undefined;
218
+ /** Ordering key. Default: the record key. When given, its result is final (undefined means no key). */
219
+ readonly key?: (record: PlainJsonRecord) => string | null | undefined;
220
+ /** Logical name of the producer. Default: the topic. */
221
+ readonly source?: string | ((record: PlainJsonRecord) => string | undefined);
222
+ /** When the event happened. Default: the record timestamp. */
223
+ readonly occurredAt?: (record: PlainJsonRecord) => Date | undefined;
224
+ }
225
+ /**
226
+ * Decodes a CloudEvents 1.0 record in either content mode of the Kafka
227
+ * protocol binding:
228
+ * - structured (`content-type: application/cloudevents+json`): the value is
229
+ * a JSON object with `specversion`, `id`, `type`, `source` and optionally
230
+ * `time`, `subject` (the key, else the record key), `data` (the payload,
231
+ * null when absent) and a JSON `datacontenttype`. `data_base64` is refused,
232
+ * because relaybus payloads are JSON. Extension attributes with string
233
+ * values (`traceparent`, ...) become event headers, as do the record
234
+ * headers; `correlationid` and `causationid` fill their envelope fields.
235
+ * - binary (anything else): exactly {@link decodeEnvelope}, the default decoder.
236
+ *
237
+ * @throws InvalidKafkaMessageError for a record that is neither.
238
+ */
239
+ declare function decodeCloudEvent(record: DecodableMessage): EventEnvelope;
240
+ /**
241
+ * Creates a decoder for records whose value is plain JSON, as written by a
242
+ * service that does not use relaybus (a plain kafkajs producer, Spring Kafka's
243
+ * `JsonSerializer`, a Go service): the value becomes the payload, the record
244
+ * headers become event headers, and the options say where the event type and
245
+ * id come from.
246
+ *
247
+ * A record that is a CloudEvent anyway (a `ce_specversion` header, or a
248
+ * structured `application/cloudevents+json` value) is decoded with
249
+ * {@link decodeCloudEvent} instead, so relaybus and foreign producers can share
250
+ * a topic. A missing or non-JSON value, a missing or invalid type, and an
251
+ * extractor returning anything but a string are invalid.
252
+ *
253
+ * ```ts
254
+ * new KafkaTransport({
255
+ * kafka,
256
+ * groupId: 'billing',
257
+ * decode: plainJsonDecoder({ type: fromHeader('event-type'), id: fromHeader('message-id') }),
258
+ * });
259
+ * ```
260
+ *
261
+ * @throws ConfigurationError when a fixed `type` is not a valid event type.
262
+ */
263
+ declare function plainJsonDecoder(options: PlainJsonDecoderOptions): KafkaMessageDecoder;
264
+
146
265
  /** A record the transport refused to hand to the bus because it is malformed. */
147
266
  interface InvalidKafkaMessage {
148
267
  readonly topic: string;
@@ -224,12 +343,25 @@ interface KafkaTransportOptions {
224
343
  readonly restart?: KafkaRestartOptions;
225
344
  /** Handling of records that cannot be processed for good. Default `'dead-letter'`. */
226
345
  readonly onInvalidMessage?: InvalidMessagePolicy;
346
+ /**
347
+ * How consumed records become events. Default: strict CloudEvents binary
348
+ * mode ({@link decodeEnvelope}), which is what relaybus producers write; any
349
+ * other record is malformed and goes to `onInvalidMessage`. Set it to read
350
+ * topics written by services that do not use relaybus:
351
+ * {@link decodeCloudEvent} also accepts structured mode, {@link plainJsonDecoder}
352
+ * maps plain JSON records, and a function of your own can do anything else
353
+ * (switch on `record.topic` to decode topics differently). Whatever a custom
354
+ * decoder throws marks the record as malformed, never crashes the consumer.
355
+ */
356
+ readonly decode?: KafkaMessageDecoder;
227
357
  /** Default `console`. */
228
358
  readonly logger?: RelayBusLogger;
229
359
  }
230
360
  /**
231
361
  * Kafka {@link Transport}. Events travel as CloudEvents binary-mode records
232
362
  * (`ce_*` headers, JSON value), so any CloudEvents-aware consumer can read them.
363
+ * Records from producers that do not use relaybus are read through the
364
+ * `decode` option.
233
365
  *
234
366
  * Publishing: idempotent producer, `acks=-1`, resolves after the broker ack and
235
367
  * rejects with {@link PublishTimeoutError} after `publishTimeout`. A timed-out
@@ -280,4 +412,33 @@ declare class KafkaTransport implements Transport {
280
412
  close(): Promise<void>;
281
413
  }
282
414
 
283
- export { type DecodableMessage, type EncodedMessage, HandlerFailedError, type InvalidKafkaMessage, InvalidKafkaMessageError, type InvalidMessagePolicy, type KafkaBytes, type KafkaClientLike, type KafkaConsumerConfig, type KafkaConsumerEvent, type KafkaConsumerEventNames, type KafkaConsumerLike, type KafkaEachMessagePayload, type KafkaIncomingHeaders, type KafkaIncomingMessage, type KafkaOutgoingHeaders, type KafkaOutgoingMessage, type KafkaProcessingOptions, type KafkaProducerConfig, type KafkaProducerLike, type KafkaProducerRecord, type KafkaRestartOptions, type KafkaRunConfig, KafkaTransport, type KafkaTransportOptions, PublishTimeoutError, UnencodableEventError, decodeEnvelope, encodeEnvelope };
415
+ /**
416
+ * Reads one header of an incoming message, for the `type`, `id`, `key` or
417
+ * `source` option of {@link plainJsonDecoder}. Header names are compared
418
+ * exactly (Kafka header names are case-sensitive). Returns undefined when the
419
+ * header is absent, so the decoder falls back to its default.
420
+ *
421
+ * ```ts
422
+ * plainJsonDecoder({ type: fromHeader('event-type'), id: fromHeader('message-id') })
423
+ * ```
424
+ */
425
+ declare function fromHeader(name: string): (message: {
426
+ readonly headers: EventHeaders;
427
+ }) => string | undefined;
428
+ /**
429
+ * Reads a field of the JSON payload by a dotted path (`'meta.eventType'`), for
430
+ * the `type`, `id`, `key` or `source` option of {@link plainJsonDecoder}. A
431
+ * string is returned as is and a safe integer as its decimal text (numeric ids
432
+ * are common). Anything else returns undefined so the decoder falls back to its
433
+ * default: a missing field, and also a fraction or an integer beyond 2^53, which
434
+ * JSON parsing may already have rounded, so two different ids could collide.
435
+ *
436
+ * ```ts
437
+ * plainJsonDecoder({ type: fromPayloadField('eventType'), key: fromPayloadField('order.id') })
438
+ * ```
439
+ */
440
+ declare function fromPayloadField(path: string): (message: {
441
+ readonly payload: unknown;
442
+ }) => string | undefined;
443
+
444
+ export { type DecodableMessage, type DecodedEvent, type EncodedMessage, HandlerFailedError, type InvalidKafkaMessage, InvalidKafkaMessageError, type InvalidMessagePolicy, type KafkaBytes, type KafkaClientLike, type KafkaConsumerConfig, type KafkaConsumerEvent, type KafkaConsumerEventNames, type KafkaConsumerLike, type KafkaEachMessagePayload, type KafkaInboundRecord, type KafkaIncomingHeaders, type KafkaIncomingMessage, type KafkaMessageDecoder, type KafkaOutgoingHeaders, type KafkaOutgoingMessage, type KafkaProcessingOptions, type KafkaProducerConfig, type KafkaProducerLike, type KafkaProducerRecord, type KafkaRestartOptions, type KafkaRunConfig, KafkaTransport, type KafkaTransportOptions, type PlainJsonDecoderOptions, type PlainJsonRecord, PublishTimeoutError, UnencodableEventError, decodeCloudEvent, decodeEnvelope, encodeEnvelope, fromHeader, fromPayloadField, plainJsonDecoder };
package/dist/index.js CHANGED
@@ -147,17 +147,20 @@ function resolveTime(text, recordTimestamp) {
147
147
  if (text !== void 0 && text.length > 0) {
148
148
  return parseTime(text);
149
149
  }
150
- const milliseconds = Number(recordTimestamp);
151
- const time = new Date(milliseconds);
152
- if (recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime())) {
150
+ const time = readRecordTimestamp(recordTimestamp);
151
+ if (time === void 0) {
153
152
  throw invalid(`${HEADER.time} is missing and the record has no usable timestamp`);
154
153
  }
155
154
  return time;
156
155
  }
157
- function parseTime(text) {
156
+ function readRecordTimestamp(recordTimestamp) {
157
+ const time = new Date(Number(recordTimestamp));
158
+ return recordTimestamp === void 0 || recordTimestamp === "" || Number.isNaN(time.getTime()) ? void 0 : time;
159
+ }
160
+ function parseTime(text, label = HEADER.time) {
158
161
  const time = new Date(text);
159
162
  if (!RFC3339_PATTERN.test(text) || Number.isNaN(time.getTime())) {
160
- throw invalid(`${HEADER.time} is not an RFC 3339 timestamp: '${text}'`);
163
+ throw invalid(`${label} is not an RFC 3339 timestamp: '${text}'`);
161
164
  }
162
165
  return time;
163
166
  }
@@ -173,12 +176,15 @@ function parsePayload(value) {
173
176
  }
174
177
  }
175
178
  function resolveKey(subject, recordKey) {
176
- const keyText = recordKey === null ? null : decodeText(recordKey, true, "Message key");
179
+ const keyText = readRecordKey(recordKey);
177
180
  if (subject !== void 0 && keyText !== null && subject !== keyText) {
178
181
  throw invalid(`${HEADER.subject} does not match the message key`);
179
182
  }
180
183
  return subject ?? keyText;
181
184
  }
185
+ function readRecordKey(recordKey) {
186
+ return recordKey === null ? null : decodeText(recordKey, true, "Message key");
187
+ }
182
188
  function collectUserHeaders(headers) {
183
189
  const entries = [];
184
190
  for (const [name, value] of headers) {
@@ -206,6 +212,219 @@ function toOutgoingHeaders(incoming) {
206
212
  function toBufferOrString(value) {
207
213
  return typeof value === "string" ? value : Buffer.from(value);
208
214
  }
215
+ var STRUCTURED_MEDIA_TYPE = "application/cloudevents+json";
216
+ var exactUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
217
+ var SPEC_VERSION2 = "1.0";
218
+ var SPEC_VERSION_HEADER = "ce_specversion";
219
+ var STRUCTURED_ATTRIBUTES = /* @__PURE__ */ new Set([
220
+ "specversion",
221
+ "id",
222
+ "type",
223
+ "source",
224
+ "time",
225
+ "subject",
226
+ "datacontenttype",
227
+ "dataschema",
228
+ "data",
229
+ "data_base64",
230
+ "correlationid",
231
+ "causationid"
232
+ ]);
233
+ function decodeCloudEvent(record) {
234
+ const headers = readHeaders(record.headers);
235
+ if (mediaTypeOf(headers.get(CONTENT_TYPE)) !== STRUCTURED_MEDIA_TYPE) {
236
+ return decodeEnvelope(record);
237
+ }
238
+ const body = parsePayload(record.value);
239
+ if (!isJsonObject(body)) {
240
+ throw invalid("A structured CloudEvent must be a JSON object");
241
+ }
242
+ if (body["specversion"] !== SPEC_VERSION2) {
243
+ throw invalid(`specversion must be '${SPEC_VERSION2}'`);
244
+ }
245
+ const type = requiredAttribute(body, "type");
246
+ assertValidType(type, "type");
247
+ assertJsonDataContentType(optionalAttribute(body, "datacontenttype"));
248
+ if (Object.hasOwn(body, "data_base64")) {
249
+ throw invalid("data_base64 is not supported: relaybus payloads are JSON, send them as data");
250
+ }
251
+ const time = optionalAttribute(body, "time");
252
+ return {
253
+ id: requiredAttribute(body, "id"),
254
+ type,
255
+ source: requiredAttribute(body, "source"),
256
+ key: optionalAttribute(body, "subject") ?? readRecordKey(record.key),
257
+ payload: Object.hasOwn(body, "data") ? body["data"] : null,
258
+ headers: { ...collectUserHeaders(headers), ...extensionAttributes(body) },
259
+ correlationId: optionalAttribute(body, "correlationid") ?? null,
260
+ causationId: optionalAttribute(body, "causationid") ?? null,
261
+ occurredAt: time === void 0 ? requireRecordTimestamp(record) : parseTime(time, "time")
262
+ };
263
+ }
264
+ function plainJsonDecoder(options) {
265
+ if (typeof options.type === "string") {
266
+ assertEventType(options.type);
267
+ }
268
+ return (record) => {
269
+ const headers = readHeadersLeniently(record.headers);
270
+ if (record.headers?.[SPEC_VERSION_HEADER] !== void 0 || mediaTypeOf(headers[CONTENT_TYPE]) === STRUCTURED_MEDIA_TYPE) {
271
+ return decodeCloudEvent(record);
272
+ }
273
+ const plain = {
274
+ topic: record.topic,
275
+ partition: record.partition,
276
+ offset: record.offset,
277
+ key: readKeyLeniently(record.key),
278
+ headers,
279
+ payload: parsePayload(record.value),
280
+ timestamp: record.timestamp
281
+ };
282
+ const type = resolveText(options.type, plain, "type");
283
+ if (type === void 0) {
284
+ throw invalid("Record has no event type: the type option found none");
285
+ }
286
+ assertValidType(type, "The event type");
287
+ return {
288
+ id: resolveText(options.id, plain, "id") ?? `kafka:${record.topic}:${record.partition}:${record.offset}`,
289
+ type,
290
+ source: resolveText(options.source, plain, "source") ?? record.topic,
291
+ key: options.key === void 0 ? plain.key : resolveText(options.key, plain, "key") ?? null,
292
+ payload: plain.payload,
293
+ headers,
294
+ correlationId: null,
295
+ causationId: null,
296
+ occurredAt: resolveOccurredAt(options.occurredAt, plain, record)
297
+ };
298
+ };
299
+ }
300
+ function completeDecodedEvent(decoded, record) {
301
+ if (typeof decoded !== "object" || decoded === null) {
302
+ throw invalid("The decoder must return an event object synchronously");
303
+ }
304
+ if ("then" in decoded) {
305
+ observeRejection(decoded);
306
+ throw invalid("The decoder must return an event object synchronously");
307
+ }
308
+ const event = decoded;
309
+ return {
310
+ id: event.id,
311
+ type: event.type,
312
+ source: event.source ?? record.topic,
313
+ key: event.key === void 0 ? readRecordKey(record.key) : event.key,
314
+ payload: event.payload,
315
+ headers: event.headers ?? {},
316
+ correlationId: event.correlationId ?? null,
317
+ causationId: event.causationId ?? null,
318
+ occurredAt: event.occurredAt ?? requireRecordTimestamp(record)
319
+ };
320
+ }
321
+ function mediaTypeOf(contentType) {
322
+ return contentType?.split(";")[0]?.trim().toLowerCase();
323
+ }
324
+ function isJsonObject(value) {
325
+ return typeof value === "object" && value !== null && !Array.isArray(value);
326
+ }
327
+ function optionalAttribute(body, name) {
328
+ const value = body[name];
329
+ if (value !== void 0 && typeof value !== "string") {
330
+ throw invalid(`Attribute '${name}' must be a string`);
331
+ }
332
+ return value;
333
+ }
334
+ function requiredAttribute(body, name) {
335
+ const value = optionalAttribute(body, name);
336
+ if (value === void 0 || value.length === 0) {
337
+ throw invalid(`Attribute '${name}' is missing or empty`);
338
+ }
339
+ return value;
340
+ }
341
+ function assertValidType(type, label) {
342
+ try {
343
+ assertEventType(type);
344
+ } catch (error) {
345
+ throw invalid(`${label} is not a valid event type: ${error.message}`);
346
+ }
347
+ }
348
+ function assertJsonDataContentType(dataContentType) {
349
+ const mediaType = mediaTypeOf(dataContentType);
350
+ if (mediaType !== void 0 && mediaType !== "application/json" && mediaType !== "text/json" && !mediaType.endsWith("+json")) {
351
+ throw invalid(`datacontenttype must be JSON, received '${dataContentType}'`);
352
+ }
353
+ }
354
+ function extensionAttributes(body) {
355
+ return Object.fromEntries(
356
+ Object.entries(body).filter(
357
+ (entry) => !STRUCTURED_ATTRIBUTES.has(entry[0]) && typeof entry[1] === "string"
358
+ )
359
+ );
360
+ }
361
+ function requireRecordTimestamp(record) {
362
+ const time = readRecordTimestamp(record.timestamp);
363
+ if (time === void 0) {
364
+ throw invalid("The event has no time and the record has no usable timestamp");
365
+ }
366
+ return time;
367
+ }
368
+ function readHeadersLeniently(incoming) {
369
+ const entries = [];
370
+ for (const [name, raw] of Object.entries(incoming ?? {})) {
371
+ const value = Array.isArray(raw) ? raw.at(-1) : raw;
372
+ const text = value === void 0 ? void 0 : decodeUtf8OrUndefined(value);
373
+ if (text !== void 0) {
374
+ entries.push([name, text]);
375
+ }
376
+ }
377
+ return Object.fromEntries(entries);
378
+ }
379
+ function decodeUtf8OrUndefined(value) {
380
+ if (typeof value === "string") {
381
+ return value;
382
+ }
383
+ try {
384
+ return exactUtf8.decode(value);
385
+ } catch {
386
+ return void 0;
387
+ }
388
+ }
389
+ function readKeyLeniently(key) {
390
+ if (key === null) {
391
+ return null;
392
+ }
393
+ try {
394
+ return readRecordKey(key);
395
+ } catch {
396
+ return Buffer.from(key).toString("base64");
397
+ }
398
+ }
399
+ function resolveText(option, record, name) {
400
+ const value = typeof option === "function" ? option(record) : option;
401
+ if (value === void 0 || value === null) {
402
+ return void 0;
403
+ }
404
+ if (typeof value !== "string") {
405
+ observeRejection(value);
406
+ throw invalid(`The ${name} option returned ${typeof value}, expected a string`);
407
+ }
408
+ return value.length === 0 ? void 0 : value;
409
+ }
410
+ function resolveOccurredAt(option, plain, record) {
411
+ const value = option?.(plain);
412
+ if (value === void 0) {
413
+ return requireRecordTimestamp(record);
414
+ }
415
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
416
+ observeRejection(value);
417
+ throw invalid("The occurredAt option returned no valid Date");
418
+ }
419
+ return value;
420
+ }
421
+ function observeRejection(value) {
422
+ if (typeof value === "object" && value !== null && "then" in value) {
423
+ Promise.resolve(value).catch(() => void 0);
424
+ }
425
+ }
426
+
427
+ // src/kafka-transport.ts
209
428
  var PublishTimeoutError = class extends RelayBusError {
210
429
  name = "PublishTimeoutError";
211
430
  };
@@ -233,6 +452,7 @@ var KafkaTransport = class {
233
452
  #producerConfig;
234
453
  #consumerConfig;
235
454
  #invalidPolicy;
455
+ #decode;
236
456
  #logger;
237
457
  #settings;
238
458
  #sleepers = /* @__PURE__ */ new Set();
@@ -260,6 +480,7 @@ var KafkaTransport = class {
260
480
  this.#producerConfig = options.producerConfig ?? {};
261
481
  this.#consumerConfig = options.consumerConfig ?? {};
262
482
  this.#invalidPolicy = options.onInvalidMessage ?? "dead-letter";
483
+ this.#decode = options.decode;
263
484
  this.#logger = options.logger ?? console;
264
485
  this.#settings = resolveSettings(options);
265
486
  }
@@ -441,16 +662,13 @@ var KafkaTransport = class {
441
662
  async #handleMessage(payload, onMessage) {
442
663
  let envelope;
443
664
  try {
444
- envelope = decodeEnvelope(payload.message);
665
+ envelope = this.#decodeRecord(payload);
445
666
  } catch (error) {
446
- if (!(error instanceof InvalidKafkaMessageError)) {
667
+ if (this.#decode === void 0 && !(error instanceof InvalidKafkaMessageError)) {
447
668
  throw toHandlerFailure(error);
448
669
  }
449
- await this.#withRetries(
450
- payload,
451
- "handleInvalid",
452
- () => this.#handleInvalid(payload, error.message)
453
- );
670
+ const reason = toErrorInfo(error).message;
671
+ await this.#withRetries(payload, "handleInvalid", () => this.#handleInvalid(payload, reason));
454
672
  return;
455
673
  }
456
674
  await this.#withRetries(payload, "deliver", async () => {
@@ -464,6 +682,21 @@ var KafkaTransport = class {
464
682
  }
465
683
  });
466
684
  }
685
+ #decodeRecord({ topic, partition, message }) {
686
+ if (this.#decode === void 0) {
687
+ return decodeEnvelope(message);
688
+ }
689
+ const record = {
690
+ topic,
691
+ partition,
692
+ offset: message.offset,
693
+ key: message.key,
694
+ value: message.value,
695
+ headers: message.headers,
696
+ timestamp: message.timestamp
697
+ };
698
+ return completeDecodedEvent(this.#decode(record), record);
699
+ }
467
700
  async #handleInvalid(payload, reason) {
468
701
  const { topic, partition, message } = payload;
469
702
  const location = `topic=${topic} partition=${partition} offset=${message.offset}`;
@@ -705,7 +938,34 @@ function toHandlerFailure(error) {
705
938
  const { name, message } = toErrorInfo(error);
706
939
  return new HandlerFailedError(`${name}: ${message}`);
707
940
  }
941
+ function fromHeader(name) {
942
+ if (name.length === 0) {
943
+ throw new ConfigurationError("fromHeader needs a header name");
944
+ }
945
+ return (message) => Object.hasOwn(message.headers, name) ? message.headers[name] : void 0;
946
+ }
947
+ function fromPayloadField(path) {
948
+ const segments = path.split(".");
949
+ if (segments.some((segment) => segment.length === 0)) {
950
+ throw new ConfigurationError(
951
+ `fromPayloadField needs a dotted path of non-empty names, received '${path}'`
952
+ );
953
+ }
954
+ return (message) => {
955
+ let value = message.payload;
956
+ for (const segment of segments) {
957
+ if (typeof value !== "object" || value === null || !Object.hasOwn(value, segment)) {
958
+ return void 0;
959
+ }
960
+ value = value[segment];
961
+ }
962
+ if (typeof value === "string") {
963
+ return value;
964
+ }
965
+ return Number.isSafeInteger(value) ? String(value) : void 0;
966
+ };
967
+ }
708
968
 
709
- export { HandlerFailedError, InvalidKafkaMessageError, KafkaTransport, PublishTimeoutError, UnencodableEventError, decodeEnvelope, encodeEnvelope };
969
+ export { HandlerFailedError, InvalidKafkaMessageError, KafkaTransport, PublishTimeoutError, UnencodableEventError, decodeCloudEvent, decodeEnvelope, encodeEnvelope, fromHeader, fromPayloadField, plainJsonDecoder };
710
970
  //# sourceMappingURL=index.js.map
711
971
  //# sourceMappingURL=index.js.map