@babelqueue/kafka 1.1.2 → 1.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.cjs +83 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -1
- package/dist/index.d.ts +55 -1
- package/dist/index.js +82 -23
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
KafkaConsumer: () => KafkaConsumer,
|
|
24
24
|
KafkaPublisher: () => KafkaPublisher,
|
|
25
|
+
KafkaRedriveIO: () => KafkaRedriveIO,
|
|
25
26
|
RetryTopics: () => RetryTopics,
|
|
26
27
|
headersOf: () => headersOf,
|
|
27
28
|
kafkaHeaders: () => kafkaHeaders
|
|
@@ -86,6 +87,9 @@ function headerInt(headers, key, fallback) {
|
|
|
86
87
|
const n = Number.parseInt(s.trim(), 10);
|
|
87
88
|
return Number.isNaN(n) ? fallback : n;
|
|
88
89
|
}
|
|
90
|
+
function isThenable(value) {
|
|
91
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
|
|
92
|
+
}
|
|
89
93
|
var BLANK_ASCII = /^[ \t\n\v\f\r]*$/;
|
|
90
94
|
function unsupportedSchemaVersion(message) {
|
|
91
95
|
const value = headerString(message.headers, "bq-schema-version");
|
|
@@ -176,6 +180,33 @@ var KafkaPublisher = class _KafkaPublisher {
|
|
|
176
180
|
});
|
|
177
181
|
}
|
|
178
182
|
};
|
|
183
|
+
var KafkaRedriveIO = class {
|
|
184
|
+
constructor(options) {
|
|
185
|
+
this.options = options;
|
|
186
|
+
}
|
|
187
|
+
options;
|
|
188
|
+
pop(queue) {
|
|
189
|
+
return this.options.pop(queue);
|
|
190
|
+
}
|
|
191
|
+
/** Append an already-encoded body to `queue` as a keyless record. */
|
|
192
|
+
async publish(queue, body) {
|
|
193
|
+
await this.send(queue, body, null);
|
|
194
|
+
}
|
|
195
|
+
/** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
|
|
196
|
+
async publishFrom(queue, body, from) {
|
|
197
|
+
await this.send(queue, body, from.key ?? null);
|
|
198
|
+
}
|
|
199
|
+
async send(queue, body, key) {
|
|
200
|
+
const record = { value: body };
|
|
201
|
+
const decoded = import_core.EnvelopeCodec.decode(body);
|
|
202
|
+
if (import_core.EnvelopeCodec.accepts(decoded)) {
|
|
203
|
+
record.headers = kafkaHeaders(decoded);
|
|
204
|
+
record.timestamp = String(decoded.meta.created_at);
|
|
205
|
+
}
|
|
206
|
+
if (key !== null) record.key = Buffer.from(key);
|
|
207
|
+
await this.options.producer.send({ topic: queue, messages: [record] });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
179
210
|
var KafkaConsumer = class {
|
|
180
211
|
constructor(consumer, handlers, options = {}) {
|
|
181
212
|
this.consumer = consumer;
|
|
@@ -198,13 +229,15 @@ var KafkaConsumer = class {
|
|
|
198
229
|
const { message } = payload;
|
|
199
230
|
const unsupported = unsupportedSchemaVersion(message);
|
|
200
231
|
if (unsupported !== void 0) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
null,
|
|
204
|
-
payload
|
|
232
|
+
const rejected = new import_core.BabelQueueError(
|
|
233
|
+
`Rejected a record from Kafka with unsupported bq-schema-version "${unsupported}" (supported: ${import_core.SCHEMA_VERSION}).`
|
|
205
234
|
);
|
|
206
|
-
|
|
207
|
-
|
|
235
|
+
try {
|
|
236
|
+
await this.deadLetterRaw(payload);
|
|
237
|
+
await this.commit(payload);
|
|
238
|
+
} finally {
|
|
239
|
+
this.report(rejected, null, payload);
|
|
240
|
+
}
|
|
208
241
|
return;
|
|
209
242
|
}
|
|
210
243
|
const raw = message.value ? message.value.toString("utf8") : "";
|
|
@@ -212,15 +245,22 @@ var KafkaConsumer = class {
|
|
|
212
245
|
try {
|
|
213
246
|
envelope = this.reconcile(import_core.EnvelopeCodec.decode(raw), message);
|
|
214
247
|
} catch (decodeError) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
248
|
+
try {
|
|
249
|
+
await this.deadLetterRaw(payload);
|
|
250
|
+
await this.commit(payload);
|
|
251
|
+
} finally {
|
|
252
|
+
this.report(decodeError, null, payload);
|
|
253
|
+
}
|
|
218
254
|
return;
|
|
219
255
|
}
|
|
220
256
|
if (!import_core.EnvelopeCodec.accepts(envelope)) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
257
|
+
const rejected = new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Kafka.");
|
|
258
|
+
try {
|
|
259
|
+
await this.deadLetterRaw(payload);
|
|
260
|
+
await this.commit(payload);
|
|
261
|
+
} finally {
|
|
262
|
+
this.report(rejected, envelope, payload);
|
|
263
|
+
}
|
|
224
264
|
return;
|
|
225
265
|
}
|
|
226
266
|
const urn = headerString(message.headers, "bq-job") ?? import_core.EnvelopeCodec.urn(envelope);
|
|
@@ -233,9 +273,12 @@ var KafkaConsumer = class {
|
|
|
233
273
|
await handler(envelope, message, headersOf(message));
|
|
234
274
|
await this.commit(payload);
|
|
235
275
|
} catch (error) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
276
|
+
try {
|
|
277
|
+
await this.retryOrDeadLetter(payload, envelope, error);
|
|
278
|
+
await this.commit(payload);
|
|
279
|
+
} finally {
|
|
280
|
+
this.report(error, envelope, payload);
|
|
281
|
+
}
|
|
239
282
|
}
|
|
240
283
|
}
|
|
241
284
|
reconcile(envelope, message) {
|
|
@@ -285,7 +328,7 @@ var KafkaConsumer = class {
|
|
|
285
328
|
const headers = kafkaHeaders(bumped);
|
|
286
329
|
headers["bq-delay"] = String(tier.delayMs);
|
|
287
330
|
headers["bq-original-topic"] = this.originalTopic(payload);
|
|
288
|
-
await this.produce(tier.topic, bumped, headers);
|
|
331
|
+
await this.produce(tier.topic, bumped, headers, payload.message.key);
|
|
289
332
|
}
|
|
290
333
|
async deadLetter(envelope, payload, reason, error) {
|
|
291
334
|
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
@@ -298,7 +341,7 @@ var KafkaConsumer = class {
|
|
|
298
341
|
});
|
|
299
342
|
const headers = kafkaHeaders(annotated);
|
|
300
343
|
headers["bq-original-topic"] = original;
|
|
301
|
-
await this.produce(dlq, annotated, headers);
|
|
344
|
+
await this.produce(dlq, annotated, headers, payload.message.key);
|
|
302
345
|
}
|
|
303
346
|
async deadLetterRaw(payload) {
|
|
304
347
|
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
@@ -314,12 +357,16 @@ var KafkaConsumer = class {
|
|
|
314
357
|
if (message.key !== void 0 && message.key !== null) forwarded.key = Buffer.from(message.key);
|
|
315
358
|
await this.options.producer.send({ topic: dlq, messages: [forwarded] });
|
|
316
359
|
}
|
|
317
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays
|
|
362
|
+
* empty); a null/absent key is left off the record, never synthesised. Partition equality is not
|
|
363
|
+
* guaranteed.
|
|
364
|
+
*/
|
|
365
|
+
async produce(topic, envelope, headers, key) {
|
|
318
366
|
this.requireProducer();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
});
|
|
367
|
+
const record = { value: import_core.EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };
|
|
368
|
+
if (key !== void 0 && key !== null) record.key = Buffer.from(key);
|
|
369
|
+
await this.options.producer.send({ topic, messages: [record] });
|
|
323
370
|
}
|
|
324
371
|
async commit(payload) {
|
|
325
372
|
await this.consumer.commitOffsets([
|
|
@@ -334,14 +381,27 @@ var KafkaConsumer = class {
|
|
|
334
381
|
throw new import_core.BabelQueueError("This Kafka consumer needs a producer to republish (retry/DLQ).");
|
|
335
382
|
}
|
|
336
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been
|
|
386
|
+
* settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a
|
|
387
|
+
* promise that later rejects (an `async` function is assignable to the `void` callback type), is
|
|
388
|
+
* swallowed here so user code can never bypass settlement or stop the consumer.
|
|
389
|
+
*/
|
|
337
390
|
report(error, envelope, payload) {
|
|
338
|
-
|
|
391
|
+
try {
|
|
392
|
+
const returned = this.options.onError?.(error, envelope, payload);
|
|
393
|
+
if (isThenable(returned)) {
|
|
394
|
+
returned.then(void 0, () => void 0);
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
}
|
|
339
398
|
}
|
|
340
399
|
};
|
|
341
400
|
// Annotate the CommonJS export names for ESM import in node:
|
|
342
401
|
0 && (module.exports = {
|
|
343
402
|
KafkaConsumer,
|
|
344
403
|
KafkaPublisher,
|
|
404
|
+
KafkaRedriveIO,
|
|
345
405
|
RetryTopics,
|
|
346
406
|
headersOf,
|
|
347
407
|
kafkaHeaders
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Apache Kafka adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed, **process-then-commit** consumer over\n * Apache Kafka (KafkaJS), so a Kafka-based Node service speaks the same contract (envelope\n * shape, URN identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * Kafka has **no native** per-message ack, delayed delivery, dead-letter queue, or delivery\n * counter — this adapter absorbs all four in the binding layer (the envelope stays\n * `schema_version: 1`): the envelope JSON is the record **value**; the contract fields are\n * mirrored onto `bq-` headers (route on `bq-job` without decoding the body) and the record\n * timestamp mirrors `meta.created_at`; **`bq-attempts` is the authoritative retry counter**;\n * consume is process-then-commit (manual commit, at-least-once); retry/delay use SDK-owned\n * tiered retry topics `<topic>.retry.<n>`; terminal failures go to an opt-in `<topic>.dlq`.\n *\n * This implements §6 of the broker-bindings contract. `kafkajs` is an optional peer — you\n * provide the producer/consumer (a KafkaJS `Producer`/`Consumer` satisfies the adapter\n * structurally).\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional Kafka **record headers** beside the contract `bq-*` headers —\n * the contract headers win a key collision (merge-not-clobber), and blanks are dropped. On consume\n * the inbound record headers are surfaced as a `Record<string,string>` so the core's `otel` extract\n * sees the `traceparent` and links the consumer span as a child. A header-less publish is\n * byte-identical. GR-1: the wire envelope (the record value) is never touched.\n *\n * **Version gate (§6.7).** Before decoding the record value the consumer reads the `bq-schema-version`\n * header. A missing or blank value (empty or only ASCII whitespace) follows the normal decode path;\n * any other value is compared, untrimmed, to the supported `\"1\"`, and a record that does not match is\n * rejected undecoded down the poison path — reported to `onError`, the raw record (value, key and\n * headers) forwarded to the DLQ (when configured) and the offset committed.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { mergeInto } from \"./headers.js\";\n\n// --- Minimal Kafka shapes (a structural subset of kafkajs) ---------------------\n\n/** Incoming record headers (KafkaJS decodes header values to Buffers). */\nexport type IncomingHeaders = { [key: string]: Buffer | string | (Buffer | string)[] | null | undefined };\n\n/** A record to produce (structural subset of a KafkaJS message). */\nexport interface KafkaProducerMessage {\n key?: Buffer | string | null;\n value: Buffer | string;\n /**\n * Header values are strings for records the adapter builds. Raw DLQ forwarding (gate rejection,\n * decode error, non-conformant envelope) instead passes the inbound values through as\n * `Buffer | Buffer[]` (KafkaJS `IHeaders`); a custom producer wrapper must tolerate both.\n */\n headers?: { [key: string]: string };\n timestamp?: string;\n}\n\n/** The subset of the KafkaJS producer this adapter calls. */\nexport interface KafkaProducerClient {\n send(record: { topic: string; messages: KafkaProducerMessage[] }): Promise<unknown>;\n}\n\n/** A received record (structural subset of a KafkaJS message). */\nexport interface KafkaIncomingMessage {\n value: Buffer | null;\n headers?: IncomingHeaders;\n offset: string;\n timestamp?: string;\n key?: Buffer | null;\n}\n\n/** The payload KafkaJS passes to `eachMessage`. */\nexport interface EachMessagePayload {\n topic: string;\n partition: number;\n message: KafkaIncomingMessage;\n}\n\n/** The subset of the KafkaJS consumer this adapter calls (manual commit — `autoCommit: false`). */\nexport interface KafkaConsumerClient {\n run(config: { autoCommit?: boolean; eachMessage: (payload: EachMessagePayload) => Promise<void> }): Promise<void>;\n commitOffsets(offsets: { topic: string; partition: number; offset: string }[]): Promise<void>;\n}\n\n// --- Header projection (contract §6.3) -----------------------------------------\n\n/** Project the envelope's contract fields onto Kafka record headers (UTF-8 string values). */\nexport function kafkaHeaders(envelope: Envelope): { [key: string]: string } {\n const headers: { [key: string]: string } = {};\n if (envelope.job) headers[\"bq-job\"] = envelope.job;\n if (envelope.trace_id) headers[\"bq-trace-id\"] = envelope.trace_id;\n if (envelope.meta.id) headers[\"bq-message-id\"] = envelope.meta.id;\n if (envelope.meta.schema_version != null) headers[\"bq-schema-version\"] = String(envelope.meta.schema_version);\n if (envelope.meta.lang) headers[\"bq-source-lang\"] = envelope.meta.lang;\n headers[\"bq-attempts\"] = String(envelope.attempts ?? 0);\n return headers;\n}\n\n/**\n * Copy inbound record headers for a raw DLQ forward: every value becomes a `Buffer` copy (a string is\n * UTF-8 encoded), array-valued headers keep all their entries, and `undefined` / `null` values are dropped.\n */\nfunction copyHeaders(headers: IncomingHeaders | undefined): { [key: string]: Buffer | Buffer[] } {\n const out: { [key: string]: Buffer | Buffer[] } = {};\n if (!headers) return out;\n const copy = (v: Buffer | string): Buffer => (Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from(v, \"utf8\"));\n for (const [name, value] of Object.entries(headers)) {\n if (value === undefined || value === null) continue;\n if (Array.isArray(value)) {\n out[name] = value.filter((v) => v !== undefined && v !== null).map(copy);\n } else {\n out[name] = copy(value);\n }\n }\n return out;\n}\n\nfunction headerString(headers: IncomingHeaders | undefined, key: string): string | undefined {\n const value = headers?.[key];\n // A null header value is \"no header\" (broker-bindings.md §6.7): never stringified to \"null\".\n if (value === undefined || value === null) return undefined;\n const v = Array.isArray(value) ? value[0] : value;\n if (v === undefined || v === null) return undefined;\n return Buffer.isBuffer(v) ? v.toString(\"utf8\") : String(v);\n}\n\nfunction headerInt(headers: IncomingHeaders | undefined, key: string, fallback: number): number {\n const s = headerString(headers, key);\n if (s === undefined) return fallback;\n const n = Number.parseInt(s.trim(), 10);\n return Number.isNaN(n) ? fallback : n;\n}\n\n/** Blank = empty or only ASCII whitespace (GR-5: the same set in every SDK, never `String.trim()`). */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The inbound `bq-schema-version` header when it is present, non-blank and not exactly the supported\n * version (compared untrimmed), else `undefined` (the record may be decoded).\n */\nfunction unsupportedSchemaVersion(message: KafkaIncomingMessage): string | undefined {\n const value = headerString(message.headers, \"bq-schema-version\");\n if (value === undefined || BLANK_ASCII.test(value)) return undefined;\n return value === String(SCHEMA_VERSION) ? undefined : value;\n}\n\n/**\n * Map an inbound record's headers onto a flat {@link HeaderCarrier}, stringifying KafkaJS's Buffer\n * values. Returns an empty object when there are none. Both the contract `bq-*` headers and any\n * out-of-band rider (e.g. `traceparent`) surface — the core's `otel` extract reads only the keys it\n * knows.\n */\nexport function headersOf(message: KafkaIncomingMessage): HeaderCarrier {\n const headers = message.headers;\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headerString(headers, key);\n if (value !== undefined && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n// --- Retry / delay topology (contract §6.4–§6.5) -------------------------------\n\n/** A single delay tier: the `<topic>.retry.<n>` topic and the delay (ms) it holds for. */\nexport interface Tier {\n topic: string;\n delayMs: number;\n}\n\n/**\n * The SDK-owned retry/delay topology for one work topic: tiered delay topics\n * `<topic>.retry.<n>` (ascending) plus an opt-in `<topic>.dlq`. A delay or release with no\n * tiers configured raises rather than silently dropping.\n */\nexport class RetryTopics {\n readonly tiers: Tier[];\n readonly dlqTopic: string | null;\n\n constructor(\n readonly workTopic: string,\n delaysMs: number[] = [],\n dlqTopic: string | null | undefined = undefined,\n ) {\n this.tiers = [...delaysMs]\n .sort((a, b) => a - b)\n .map((delayMs, i) => ({ topic: `${workTopic}.retry.${i + 1}`, delayMs }));\n this.dlqTopic = dlqTopic === undefined ? `${workTopic}.dlq` : dlqTopic;\n }\n\n hasTiers(): boolean {\n return this.tiers.length > 0;\n }\n\n /** The smallest tier whose delay ≥ `delayMs`; raises if none configured or the delay is too large. */\n tierForDelay(delayMs: number): Tier {\n this.requireTiers();\n for (const tier of this.tiers) {\n if (tier.delayMs >= delayMs) return tier;\n }\n throw new BabelQueueError(\n `Requested Kafka delay ${delayMs}ms exceeds the largest retry tier (${this.tiers[this.tiers.length - 1]!.delayMs}ms).`,\n );\n }\n\n /** The tier for a retry at `attempt` (0-based), clamped to the largest; raises if none configured. */\n tierForAttempt(attempt: number): Tier {\n this.requireTiers();\n return this.tiers[Math.min(Math.max(attempt, 0), this.tiers.length - 1)]!;\n }\n\n private requireTiers(): void {\n if (!this.tiers.length) {\n throw new BabelQueueError(`Kafka retry/delay requires retry topics; none are configured for '${this.workTopic}'.`);\n }\n }\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link KafkaPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /** Schedule via a retry tier (requires a {@link RetryTopics}); raises on a plain publisher. */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as Kafka record headers beside the contract `bq-*`\n * headers (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract headers win a key collision; an empty/omitted carrier leaves the record unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Kafka work topic with the §6 projection. */\nexport class KafkaPublisher {\n private constructor(\n private readonly producer: KafkaProducerClient,\n private readonly workTopic: string,\n private readonly retryTopics?: RetryTopics,\n ) {}\n\n /** A publisher onto `topic` with no retry topics (a delay raises). */\n static create(producer: KafkaProducerClient, topic: string): KafkaPublisher {\n return new KafkaPublisher(producer, topic);\n }\n\n /** A publisher onto the topology's work topic, with delay routed via its retry tiers. */\n static withRetryTopics(producer: KafkaProducerClient, retryTopics: RetryTopics): KafkaPublisher {\n return new KafkaPublisher(producer, retryTopics.workTopic, retryTopics);\n }\n\n /** Build + send the canonical envelope; returns the message id (`meta.id`). */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.workTopic, traceId: options.traceId });\n if (options.delayMs != null && options.delayMs > 0) {\n if (!this.retryTopics) {\n throw new BabelQueueError(\"Kafka has no native delayed delivery; a delay requires retry topics (none configured).\");\n }\n const tier = this.retryTopics.tierForDelay(options.delayMs);\n await this.sendRecord(tier.topic, envelope, options.headers, options.delayMs, this.workTopic);\n } else {\n await this.sendRecord(this.workTopic, envelope, options.headers);\n }\n return envelope.meta.id;\n }\n\n private async sendRecord(\n topic: string,\n envelope: Envelope,\n extra?: HeaderCarrier | null,\n delayMs?: number,\n originalTopic?: string,\n ): Promise<void> {\n // The contract bq-* headers are seeded first, then the out-of-band riders are merged beside\n // them without clobbering (the contract wins a collision).\n const headers = mergeInto(kafkaHeaders(envelope), extra);\n if (delayMs != null) headers[\"bq-delay\"] = String(delayMs);\n if (originalTopic != null) headers[\"bq-original-topic\"] = originalTopic;\n await this.producer.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(envelope.meta.created_at) }],\n });\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band\n * {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer span\n * (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: KafkaIncomingMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link KafkaConsumer}. */\nexport interface KafkaConsumerOptions {\n /** The producer used to republish retry/DLQ records (required for retry/DLQ). */\n producer?: KafkaProducerClient;\n /** The retry/DLQ topology; enables per-record retry, delay, and dead-lettering. */\n retryTopics?: RetryTopics;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a poison record, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload) => void;\n}\n\n/**\n * Consumes a Kafka work topic in process-then-commit mode: each record is decoded, validated,\n * routed to the handler for its URN (read from `bq-job`), and its offset committed only after\n * the handler returns. A throwing handler republishes to a `<topic>.retry.<n>` tier with\n * `bq-attempts + 1`, then commits; terminal failures go to `<topic>.dlq` with a `dead_letter`\n * block. The `bq-attempts` header is the authoritative counter (the body is the fallback).\n */\nexport class KafkaConsumer {\n private readonly maxTries: number;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly consumer: KafkaConsumerClient,\n private readonly handlers: BabelHandlers,\n private readonly options: KafkaConsumerOptions = {},\n ) {\n this.maxTries = options.maxTries ?? 3;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Start the manual-commit consume loop (`autoCommit: false`); resolves when the consumer stops. */\n async run(): Promise<void> {\n await this.consumer.run({ autoCommit: false, eachMessage: (payload) => this.handle(payload) });\n }\n\n /** Route + settle one record (process-then-commit). Exposed for testing. */\n async handle(payload: EachMessagePayload): Promise<void> {\n const { message } = payload;\n\n // Version gate (§6.7): decided on the header alone, before the value is decoded.\n const unsupported = unsupportedSchemaVersion(message);\n if (unsupported !== undefined) {\n this.report(\n new BabelQueueError(`Rejected a record from Kafka with unsupported bq-schema-version \"${unsupported}\" (supported: ${SCHEMA_VERSION}).`),\n null,\n payload,\n );\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n const raw = message.value ? message.value.toString(\"utf8\") : \"\";\n\n let envelope: IncomingEnvelope;\n try {\n envelope = this.reconcile(EnvelopeCodec.decode(raw), message);\n } catch (decodeError) {\n this.report(decodeError, null, payload);\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n // A non-conformant / poison envelope may be missing the fields needed to annotate, so\n // forward the raw record to the DLQ rather than building a dead_letter block.\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Kafka.\"), envelope, payload);\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n const urn = headerString(message.headers, \"bq-job\") ?? EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(payload, envelope, urn);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.commit(payload);\n } catch (error) {\n this.report(error, envelope, payload);\n await this.retryOrDeadLetter(payload, envelope as Envelope, error);\n await this.commit(payload);\n }\n }\n\n private reconcile(envelope: IncomingEnvelope, message: KafkaIncomingMessage): IncomingEnvelope {\n const body = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n return { ...envelope, attempts: headerInt(message.headers, \"bq-attempts\", body) };\n }\n\n private async onUnknownUrn(payload: EachMessagePayload, envelope: IncomingEnvelope, urn: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope as Envelope, payload, \"unknown_urn\");\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.republishRetry(payload, envelope as Envelope);\n await this.commit(payload);\n return;\n default: {\n // FAIL: surface and do NOT commit — the record redelivers on the next poll.\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, payload);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(payload: EachMessagePayload, envelope: Envelope, error: unknown): Promise<void> {\n const hasTiers = this.options.retryTopics?.hasTiers() ?? false;\n const hasDlq = (this.options.retryTopics?.dlqTopic ?? null) !== null;\n if (!hasTiers && !hasDlq) {\n void error; // reported via onError before this; Kafka has nowhere to put the failure\n throw new BabelQueueError(\"Kafka per-record retry requires retry topics and/or a DLQ; neither is configured.\");\n }\n if (hasTiers && (envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.republishRetry(payload, envelope);\n } else {\n await this.deadLetter(envelope, payload, \"failed\", error);\n }\n }\n\n private async republishRetry(payload: EachMessagePayload, envelope: Envelope): Promise<void> {\n const topics = this.options.retryTopics;\n if (!topics || !topics.hasTiers()) {\n throw new BabelQueueError(\"Kafka retry/release requires retry topics; none are configured.\");\n }\n const tier = topics.tierForAttempt(envelope.attempts ?? 0);\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n const headers = kafkaHeaders(bumped);\n headers[\"bq-delay\"] = String(tier.delayMs);\n headers[\"bq-original-topic\"] = this.originalTopic(payload);\n await this.produce(tier.topic, bumped, headers);\n }\n\n private async deadLetter(envelope: Envelope, payload: EachMessagePayload, reason: string, error?: unknown): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return; // dead-lettering disabled → degrade to commit-and-drop\n const original = this.originalTopic(payload);\n const annotated = annotate(envelope, reason, original, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n const headers = kafkaHeaders(annotated);\n headers[\"bq-original-topic\"] = original;\n await this.produce(dlq, annotated, headers);\n }\n\n private async deadLetterRaw(payload: EachMessagePayload): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return;\n this.requireProducer();\n const { message } = payload;\n // Forward the original record verbatim (value, key, headers) so the DLQ copy keeps its provenance\n // (Java reference; the .NET consumer forwards value + headers). Only the timestamp is re-stamped.\n const forwarded: KafkaProducerMessage = {\n value: message.value ?? Buffer.alloc(0),\n // KafkaJS accepts Buffer / array header values; the published structural type stays string-only.\n headers: copyHeaders(message.headers) as unknown as KafkaProducerMessage[\"headers\"],\n timestamp: String(Date.now()),\n };\n if (message.key !== undefined && message.key !== null) forwarded.key = Buffer.from(message.key);\n await this.options.producer!.send({ topic: dlq, messages: [forwarded] });\n }\n\n private async produce(topic: string, envelope: Envelope, headers: { [key: string]: string }): Promise<void> {\n this.requireProducer();\n await this.options.producer!.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) }],\n });\n }\n\n private async commit(payload: EachMessagePayload): Promise<void> {\n await this.consumer.commitOffsets([\n { topic: payload.topic, partition: payload.partition, offset: (Number(payload.message.offset) + 1).toString() },\n ]);\n }\n\n private originalTopic(payload: EachMessagePayload): string {\n return headerString(payload.message.headers, \"bq-original-topic\") ?? payload.topic;\n }\n\n private requireProducer(): void {\n if (!this.options.producer) {\n throw new BabelQueueError(\"This Kafka consumer needs a producer to republish (retry/DLQ).\");\n }\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload): void {\n this.options.onError?.(error, envelope, payload);\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n\n/**\n * Merge out-of-band `extra` headers onto an already-built `base` header map **without clobbering**\n * the contract headers in `base` (they win a key collision) and dropping blanks. The result is\n * `base`, mutated in place.\n */\nexport function mergeInto(base: { [key: string]: string }, extra: HeaderCarrier | null | undefined): { [key: string]: string } {\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) {\n if (key in base) continue; // the contract header already there wins\n base[key] = value;\n }\n return base;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Apache Kafka adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed, **process-then-commit** consumer over\n * Apache Kafka (KafkaJS), so a Kafka-based Node service speaks the same contract (envelope\n * shape, URN identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * Kafka has **no native** per-message ack, delayed delivery, dead-letter queue, or delivery\n * counter — this adapter absorbs all four in the binding layer (the envelope stays\n * `schema_version: 1`): the envelope JSON is the record **value**; the contract fields are\n * mirrored onto `bq-` headers (route on `bq-job` without decoding the body) and the record\n * timestamp mirrors `meta.created_at`; **`bq-attempts` is the authoritative retry counter**;\n * consume is process-then-commit (manual commit, at-least-once); retry/delay use SDK-owned\n * tiered retry topics `<topic>.retry.<n>`; terminal failures go to an opt-in `<topic>.dlq`.\n *\n * This implements §6 of the broker-bindings contract. `kafkajs` is an optional peer — you\n * provide the producer/consumer (a KafkaJS `Producer`/`Consumer` satisfies the adapter\n * structurally).\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional Kafka **record headers** beside the contract `bq-*` headers —\n * the contract headers win a key collision (merge-not-clobber), and blanks are dropped. On consume\n * the inbound record headers are surfaced as a `Record<string,string>` so the core's `otel` extract\n * sees the `traceparent` and links the consumer span as a child. A header-less publish is\n * byte-identical. GR-1: the wire envelope (the record value) is never touched.\n *\n * **Version gate (§6.7).** Before decoding the record value the consumer reads the `bq-schema-version`\n * header. A missing or blank value (empty or only ASCII whitespace) follows the normal decode path;\n * any other value is compared, untrimmed, to the supported `\"1\"`, and a record that does not match is\n * rejected undecoded down the poison path — reported to `onError`, the raw record (value, key and\n * headers) forwarded to the DLQ (when configured) and the offset committed.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { mergeInto } from \"./headers.js\";\n\n// --- Minimal Kafka shapes (a structural subset of kafkajs) ---------------------\n\n/** Incoming record headers (KafkaJS decodes header values to Buffers). */\nexport type IncomingHeaders = { [key: string]: Buffer | string | (Buffer | string)[] | null | undefined };\n\n/** A record to produce (structural subset of a KafkaJS message). */\nexport interface KafkaProducerMessage {\n key?: Buffer | string | null;\n value: Buffer | string;\n /**\n * Header values are strings for records the adapter builds. Raw DLQ forwarding (gate rejection,\n * decode error, non-conformant envelope) instead passes the inbound values through as\n * `Buffer | Buffer[]` (KafkaJS `IHeaders`); a custom producer wrapper must tolerate both.\n */\n headers?: { [key: string]: string };\n timestamp?: string;\n}\n\n/** The subset of the KafkaJS producer this adapter calls. */\nexport interface KafkaProducerClient {\n send(record: { topic: string; messages: KafkaProducerMessage[] }): Promise<unknown>;\n}\n\n/** A received record (structural subset of a KafkaJS message). */\nexport interface KafkaIncomingMessage {\n value: Buffer | null;\n headers?: IncomingHeaders;\n offset: string;\n timestamp?: string;\n key?: Buffer | null;\n}\n\n/** The payload KafkaJS passes to `eachMessage`. */\nexport interface EachMessagePayload {\n topic: string;\n partition: number;\n message: KafkaIncomingMessage;\n}\n\n/** The subset of the KafkaJS consumer this adapter calls (manual commit — `autoCommit: false`). */\nexport interface KafkaConsumerClient {\n run(config: { autoCommit?: boolean; eachMessage: (payload: EachMessagePayload) => Promise<void> }): Promise<void>;\n commitOffsets(offsets: { topic: string; partition: number; offset: string }[]): Promise<void>;\n}\n\n// --- Header projection (contract §6.3) -----------------------------------------\n\n/** Project the envelope's contract fields onto Kafka record headers (UTF-8 string values). */\nexport function kafkaHeaders(envelope: Envelope): { [key: string]: string } {\n const headers: { [key: string]: string } = {};\n if (envelope.job) headers[\"bq-job\"] = envelope.job;\n if (envelope.trace_id) headers[\"bq-trace-id\"] = envelope.trace_id;\n if (envelope.meta.id) headers[\"bq-message-id\"] = envelope.meta.id;\n if (envelope.meta.schema_version != null) headers[\"bq-schema-version\"] = String(envelope.meta.schema_version);\n if (envelope.meta.lang) headers[\"bq-source-lang\"] = envelope.meta.lang;\n headers[\"bq-attempts\"] = String(envelope.attempts ?? 0);\n return headers;\n}\n\n/**\n * Copy inbound record headers for a raw DLQ forward: every value becomes a `Buffer` copy (a string is\n * UTF-8 encoded), array-valued headers keep all their entries, and `undefined` / `null` values are dropped.\n */\nfunction copyHeaders(headers: IncomingHeaders | undefined): { [key: string]: Buffer | Buffer[] } {\n const out: { [key: string]: Buffer | Buffer[] } = {};\n if (!headers) return out;\n const copy = (v: Buffer | string): Buffer => (Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from(v, \"utf8\"));\n for (const [name, value] of Object.entries(headers)) {\n if (value === undefined || value === null) continue;\n if (Array.isArray(value)) {\n out[name] = value.filter((v) => v !== undefined && v !== null).map(copy);\n } else {\n out[name] = copy(value);\n }\n }\n return out;\n}\n\nfunction headerString(headers: IncomingHeaders | undefined, key: string): string | undefined {\n const value = headers?.[key];\n // A null header value is \"no header\" (broker-bindings.md §6.7): never stringified to \"null\".\n if (value === undefined || value === null) return undefined;\n const v = Array.isArray(value) ? value[0] : value;\n if (v === undefined || v === null) return undefined;\n return Buffer.isBuffer(v) ? v.toString(\"utf8\") : String(v);\n}\n\nfunction headerInt(headers: IncomingHeaders | undefined, key: string, fallback: number): number {\n const s = headerString(headers, key);\n if (s === undefined) return fallback;\n const n = Number.parseInt(s.trim(), 10);\n return Number.isNaN(n) ? fallback : n;\n}\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === \"object\" || typeof value === \"function\") &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/** Blank = empty or only ASCII whitespace (GR-5: the same set in every SDK, never `String.trim()`). */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The inbound `bq-schema-version` header when it is present, non-blank and not exactly the supported\n * version (compared untrimmed), else `undefined` (the record may be decoded).\n */\nfunction unsupportedSchemaVersion(message: KafkaIncomingMessage): string | undefined {\n const value = headerString(message.headers, \"bq-schema-version\");\n if (value === undefined || BLANK_ASCII.test(value)) return undefined;\n return value === String(SCHEMA_VERSION) ? undefined : value;\n}\n\n/**\n * Map an inbound record's headers onto a flat {@link HeaderCarrier}, stringifying KafkaJS's Buffer\n * values. Returns an empty object when there are none. Both the contract `bq-*` headers and any\n * out-of-band rider (e.g. `traceparent`) surface — the core's `otel` extract reads only the keys it\n * knows.\n */\nexport function headersOf(message: KafkaIncomingMessage): HeaderCarrier {\n const headers = message.headers;\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headerString(headers, key);\n if (value !== undefined && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n// --- Retry / delay topology (contract §6.4–§6.5) -------------------------------\n\n/** A single delay tier: the `<topic>.retry.<n>` topic and the delay (ms) it holds for. */\nexport interface Tier {\n topic: string;\n delayMs: number;\n}\n\n/**\n * The SDK-owned retry/delay topology for one work topic: tiered delay topics\n * `<topic>.retry.<n>` (ascending) plus an opt-in `<topic>.dlq`. A delay or release with no\n * tiers configured raises rather than silently dropping.\n */\nexport class RetryTopics {\n readonly tiers: Tier[];\n readonly dlqTopic: string | null;\n\n constructor(\n readonly workTopic: string,\n delaysMs: number[] = [],\n dlqTopic: string | null | undefined = undefined,\n ) {\n this.tiers = [...delaysMs]\n .sort((a, b) => a - b)\n .map((delayMs, i) => ({ topic: `${workTopic}.retry.${i + 1}`, delayMs }));\n this.dlqTopic = dlqTopic === undefined ? `${workTopic}.dlq` : dlqTopic;\n }\n\n hasTiers(): boolean {\n return this.tiers.length > 0;\n }\n\n /** The smallest tier whose delay ≥ `delayMs`; raises if none configured or the delay is too large. */\n tierForDelay(delayMs: number): Tier {\n this.requireTiers();\n for (const tier of this.tiers) {\n if (tier.delayMs >= delayMs) return tier;\n }\n throw new BabelQueueError(\n `Requested Kafka delay ${delayMs}ms exceeds the largest retry tier (${this.tiers[this.tiers.length - 1]!.delayMs}ms).`,\n );\n }\n\n /** The tier for a retry at `attempt` (0-based), clamped to the largest; raises if none configured. */\n tierForAttempt(attempt: number): Tier {\n this.requireTiers();\n return this.tiers[Math.min(Math.max(attempt, 0), this.tiers.length - 1)]!;\n }\n\n private requireTiers(): void {\n if (!this.tiers.length) {\n throw new BabelQueueError(`Kafka retry/delay requires retry topics; none are configured for '${this.workTopic}'.`);\n }\n }\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link KafkaPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /** Schedule via a retry tier (requires a {@link RetryTopics}); raises on a plain publisher. */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as Kafka record headers beside the contract `bq-*`\n * headers (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract headers win a key collision; an empty/omitted carrier leaves the record unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Kafka work topic with the §6 projection. */\nexport class KafkaPublisher {\n private constructor(\n private readonly producer: KafkaProducerClient,\n private readonly workTopic: string,\n private readonly retryTopics?: RetryTopics,\n ) {}\n\n /** A publisher onto `topic` with no retry topics (a delay raises). */\n static create(producer: KafkaProducerClient, topic: string): KafkaPublisher {\n return new KafkaPublisher(producer, topic);\n }\n\n /** A publisher onto the topology's work topic, with delay routed via its retry tiers. */\n static withRetryTopics(producer: KafkaProducerClient, retryTopics: RetryTopics): KafkaPublisher {\n return new KafkaPublisher(producer, retryTopics.workTopic, retryTopics);\n }\n\n /** Build + send the canonical envelope; returns the message id (`meta.id`). */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.workTopic, traceId: options.traceId });\n if (options.delayMs != null && options.delayMs > 0) {\n if (!this.retryTopics) {\n throw new BabelQueueError(\"Kafka has no native delayed delivery; a delay requires retry topics (none configured).\");\n }\n const tier = this.retryTopics.tierForDelay(options.delayMs);\n await this.sendRecord(tier.topic, envelope, options.headers, options.delayMs, this.workTopic);\n } else {\n await this.sendRecord(this.workTopic, envelope, options.headers);\n }\n return envelope.meta.id;\n }\n\n private async sendRecord(\n topic: string,\n envelope: Envelope,\n extra?: HeaderCarrier | null,\n delayMs?: number,\n originalTopic?: string,\n ): Promise<void> {\n // The contract bq-* headers are seeded first, then the out-of-band riders are merged beside\n // them without clobbering (the contract wins a collision).\n const headers = mergeInto(kafkaHeaders(envelope), extra);\n if (delayMs != null) headers[\"bq-delay\"] = String(delayMs);\n if (originalTopic != null) headers[\"bq-original-topic\"] = originalTopic;\n await this.producer.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(envelope.meta.created_at) }],\n });\n }\n}\n\n// --- DLQ redrive IO (ADR-0026, §6.5 \"Record key on republish\") -------------------\n\n/**\n * A DLQ record reserved for {@link KafkaRedriveIO}: the structural shape `@babelqueue/core`'s\n * `RedriveMessage` expects (`body` + `ack`), plus the consumed record's `key` — `null`/absent for a\n * keyless record, a (possibly empty) `Buffer` otherwise.\n */\nexport interface KafkaRedriveMessage {\n body: string;\n key?: Buffer | null;\n ack(): Promise<void>;\n}\n\n/** Options for {@link KafkaRedriveIO}. */\nexport interface KafkaRedriveIOOptions {\n /** The producer used for every write the redrive makes (redrive write + restore to the DLQ). */\n producer: KafkaProducerClient;\n /**\n * Reserve the next record from `queue` (the DLQ topic), or `null` when it is empty. Kafka consumption\n * is push-based in KafkaJS, so the caller supplies the pull (a bounded fetch with manual commit) and\n * its `ack` commits the offset. Return the record's `key` so the redrive can carry it.\n */\n pop(queue: string): Promise<KafkaRedriveMessage | null>;\n}\n\n/**\n * A `@babelqueue/core` `RedriveIO` over Kafka that carries the DLQ record key byte-for-byte through the\n * redrive (contract §6.5): the redrive write back to the work topic and the restore write back to the\n * DLQ both go through {@link KafkaRedriveIO.publishFrom}, which copies the reserved record's key — a null\n * key stays null (no key on the record), an empty key stays empty — and never synthesises one. Partition\n * equality is not guaranteed (client partitioners differ). The §6.3 header projection is rebuilt from\n * the body being written (`bq-attempts` therefore reads 0 for a reset envelope); a body that is not a\n * canonical envelope (an undecodable record being restored) is written verbatim with no `bq-*` headers.\n *\n * Plain {@link KafkaRedriveIO.publish} writes a keyless record. `publishWithHeaders` is not offered, so\n * `bypass` is a no-op on this transport (as in the Go reference's keyed path).\n */\nexport class KafkaRedriveIO {\n constructor(private readonly options: KafkaRedriveIOOptions) {}\n\n pop(queue: string): Promise<KafkaRedriveMessage | null> {\n return this.options.pop(queue);\n }\n\n /** Append an already-encoded body to `queue` as a keyless record. */\n async publish(queue: string, body: string): Promise<void> {\n await this.send(queue, body, null);\n }\n\n /** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */\n async publishFrom(queue: string, body: string, from: KafkaRedriveMessage): Promise<void> {\n await this.send(queue, body, from.key ?? null);\n }\n\n private async send(queue: string, body: string, key: Buffer | null): Promise<void> {\n const record: KafkaProducerMessage = { value: body };\n const decoded = EnvelopeCodec.decode(body);\n if (EnvelopeCodec.accepts(decoded)) {\n record.headers = kafkaHeaders(decoded);\n record.timestamp = String(decoded.meta.created_at);\n }\n if (key !== null) record.key = Buffer.from(key);\n await this.options.producer.send({ topic: queue, messages: [record] });\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band\n * {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer span\n * (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: KafkaIncomingMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link KafkaConsumer}. */\nexport interface KafkaConsumerOptions {\n /** The producer used to republish retry/DLQ records (required for retry/DLQ). */\n producer?: KafkaProducerClient;\n /** The retry/DLQ topology; enables per-record retry, delay, and dead-lettering. */\n retryTopics?: RetryTopics;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a poison record, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload) => void;\n}\n\n/**\n * Consumes a Kafka work topic in process-then-commit mode: each record is decoded, validated,\n * routed to the handler for its URN (read from `bq-job`), and its offset committed only after\n * the handler returns. A throwing handler republishes to a `<topic>.retry.<n>` tier with\n * `bq-attempts + 1`, then commits; terminal failures go to `<topic>.dlq` with a `dead_letter`\n * block. The `bq-attempts` header is the authoritative counter (the body is the fallback).\n */\nexport class KafkaConsumer {\n private readonly maxTries: number;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly consumer: KafkaConsumerClient,\n private readonly handlers: BabelHandlers,\n private readonly options: KafkaConsumerOptions = {},\n ) {\n this.maxTries = options.maxTries ?? 3;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Start the manual-commit consume loop (`autoCommit: false`); resolves when the consumer stops. */\n async run(): Promise<void> {\n await this.consumer.run({ autoCommit: false, eachMessage: (payload) => this.handle(payload) });\n }\n\n /** Route + settle one record (process-then-commit). Exposed for testing. */\n async handle(payload: EachMessagePayload): Promise<void> {\n const { message } = payload;\n\n // Version gate (§6.7): decided on the header alone, before the value is decoded.\n const unsupported = unsupportedSchemaVersion(message);\n if (unsupported !== undefined) {\n const rejected = new BabelQueueError(\n `Rejected a record from Kafka with unsupported bq-schema-version \"${unsupported}\" (supported: ${SCHEMA_VERSION}).`,\n );\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(rejected, null, payload);\n }\n return;\n }\n\n const raw = message.value ? message.value.toString(\"utf8\") : \"\";\n\n let envelope: IncomingEnvelope;\n try {\n envelope = this.reconcile(EnvelopeCodec.decode(raw), message);\n } catch (decodeError) {\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(decodeError, null, payload);\n }\n return;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n // A non-conformant / poison envelope may be missing the fields needed to annotate, so\n // forward the raw record to the DLQ rather than building a dead_letter block.\n const rejected = new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Kafka.\");\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(rejected, envelope, payload);\n }\n return;\n }\n\n const urn = headerString(message.headers, \"bq-job\") ?? EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(payload, envelope, urn);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.commit(payload);\n } catch (error) {\n try {\n await this.retryOrDeadLetter(payload, envelope as Envelope, error);\n await this.commit(payload);\n } finally {\n this.report(error, envelope, payload);\n }\n }\n }\n\n private reconcile(envelope: IncomingEnvelope, message: KafkaIncomingMessage): IncomingEnvelope {\n const body = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n return { ...envelope, attempts: headerInt(message.headers, \"bq-attempts\", body) };\n }\n\n private async onUnknownUrn(payload: EachMessagePayload, envelope: IncomingEnvelope, urn: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope as Envelope, payload, \"unknown_urn\");\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.republishRetry(payload, envelope as Envelope);\n await this.commit(payload);\n return;\n default: {\n // FAIL: surface and do NOT commit — the record redelivers on the next poll.\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, payload);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(payload: EachMessagePayload, envelope: Envelope, error: unknown): Promise<void> {\n const hasTiers = this.options.retryTopics?.hasTiers() ?? false;\n const hasDlq = (this.options.retryTopics?.dlqTopic ?? null) !== null;\n if (!hasTiers && !hasDlq) {\n void error; // reported via onError afterwards; Kafka has nowhere to put the failure\n throw new BabelQueueError(\"Kafka per-record retry requires retry topics and/or a DLQ; neither is configured.\");\n }\n if (hasTiers && (envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.republishRetry(payload, envelope);\n } else {\n await this.deadLetter(envelope, payload, \"failed\", error);\n }\n }\n\n private async republishRetry(payload: EachMessagePayload, envelope: Envelope): Promise<void> {\n const topics = this.options.retryTopics;\n if (!topics || !topics.hasTiers()) {\n throw new BabelQueueError(\"Kafka retry/release requires retry topics; none are configured.\");\n }\n const tier = topics.tierForAttempt(envelope.attempts ?? 0);\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n const headers = kafkaHeaders(bumped);\n headers[\"bq-delay\"] = String(tier.delayMs);\n headers[\"bq-original-topic\"] = this.originalTopic(payload);\n await this.produce(tier.topic, bumped, headers, payload.message.key);\n }\n\n private async deadLetter(envelope: Envelope, payload: EachMessagePayload, reason: string, error?: unknown): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return; // dead-lettering disabled → degrade to commit-and-drop\n const original = this.originalTopic(payload);\n const annotated = annotate(envelope, reason, original, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n const headers = kafkaHeaders(annotated);\n headers[\"bq-original-topic\"] = original;\n await this.produce(dlq, annotated, headers, payload.message.key);\n }\n\n private async deadLetterRaw(payload: EachMessagePayload): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return;\n this.requireProducer();\n const { message } = payload;\n // Forward the original record verbatim (value, key, headers) so the DLQ copy keeps its provenance\n // (Java reference). Only the timestamp is re-stamped.\n const forwarded: KafkaProducerMessage = {\n value: message.value ?? Buffer.alloc(0),\n // KafkaJS accepts Buffer / array header values; the published structural type stays string-only.\n headers: copyHeaders(message.headers) as unknown as KafkaProducerMessage[\"headers\"],\n timestamp: String(Date.now()),\n };\n if (message.key !== undefined && message.key !== null) forwarded.key = Buffer.from(message.key);\n await this.options.producer!.send({ topic: dlq, messages: [forwarded] });\n }\n\n /**\n * Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays\n * empty); a null/absent key is left off the record, never synthesised. Partition equality is not\n * guaranteed.\n */\n private async produce(\n topic: string,\n envelope: Envelope,\n headers: { [key: string]: string },\n key?: Buffer | null,\n ): Promise<void> {\n this.requireProducer();\n const record: KafkaProducerMessage = { value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };\n if (key !== undefined && key !== null) record.key = Buffer.from(key);\n await this.options.producer!.send({ topic, messages: [record] });\n }\n\n private async commit(payload: EachMessagePayload): Promise<void> {\n await this.consumer.commitOffsets([\n { topic: payload.topic, partition: payload.partition, offset: (Number(payload.message.offset) + 1).toString() },\n ]);\n }\n\n private originalTopic(payload: EachMessagePayload): string {\n return headerString(payload.message.headers, \"bq-original-topic\") ?? payload.topic;\n }\n\n private requireProducer(): void {\n if (!this.options.producer) {\n throw new BabelQueueError(\"This Kafka consumer needs a producer to republish (retry/DLQ).\");\n }\n }\n\n /**\n * Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been\n * settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a\n * promise that later rejects (an `async` function is assignable to the `void` callback type), is\n * swallowed here so user code can never bypass settlement or stop the consumer.\n */\n private report(error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload): void {\n try {\n const returned: unknown = this.options.onError?.(error, envelope, payload);\n if (isThenable(returned)) {\n // An async callback's rejection would otherwise surface as an unhandled rejection.\n returned.then(undefined, () => undefined);\n }\n } catch {\n // user callback failure must not escape the consume loop\n }\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n\n/**\n * Merge out-of-band `extra` headers onto an already-built `base` header map **without clobbering**\n * the contract headers in `base` (they win a key collision) and dropping blanks. The result is\n * `base`, mutated in place.\n */\nexport function mergeInto(base: { [key: string]: string }, extra: HeaderCarrier | null | undefined): { [key: string]: string } {\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) {\n if (key in base) continue; // the contract header already there wins\n base[key] = value;\n }\n return base;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -143,6 +143,49 @@ declare class KafkaPublisher {
|
|
|
143
143
|
publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
|
|
144
144
|
private sendRecord;
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* A DLQ record reserved for {@link KafkaRedriveIO}: the structural shape `@babelqueue/core`'s
|
|
148
|
+
* `RedriveMessage` expects (`body` + `ack`), plus the consumed record's `key` — `null`/absent for a
|
|
149
|
+
* keyless record, a (possibly empty) `Buffer` otherwise.
|
|
150
|
+
*/
|
|
151
|
+
interface KafkaRedriveMessage {
|
|
152
|
+
body: string;
|
|
153
|
+
key?: Buffer | null;
|
|
154
|
+
ack(): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
/** Options for {@link KafkaRedriveIO}. */
|
|
157
|
+
interface KafkaRedriveIOOptions {
|
|
158
|
+
/** The producer used for every write the redrive makes (redrive write + restore to the DLQ). */
|
|
159
|
+
producer: KafkaProducerClient;
|
|
160
|
+
/**
|
|
161
|
+
* Reserve the next record from `queue` (the DLQ topic), or `null` when it is empty. Kafka consumption
|
|
162
|
+
* is push-based in KafkaJS, so the caller supplies the pull (a bounded fetch with manual commit) and
|
|
163
|
+
* its `ack` commits the offset. Return the record's `key` so the redrive can carry it.
|
|
164
|
+
*/
|
|
165
|
+
pop(queue: string): Promise<KafkaRedriveMessage | null>;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A `@babelqueue/core` `RedriveIO` over Kafka that carries the DLQ record key byte-for-byte through the
|
|
169
|
+
* redrive (contract §6.5): the redrive write back to the work topic and the restore write back to the
|
|
170
|
+
* DLQ both go through {@link KafkaRedriveIO.publishFrom}, which copies the reserved record's key — a null
|
|
171
|
+
* key stays null (no key on the record), an empty key stays empty — and never synthesises one. Partition
|
|
172
|
+
* equality is not guaranteed (client partitioners differ). The §6.3 header projection is rebuilt from
|
|
173
|
+
* the body being written (`bq-attempts` therefore reads 0 for a reset envelope); a body that is not a
|
|
174
|
+
* canonical envelope (an undecodable record being restored) is written verbatim with no `bq-*` headers.
|
|
175
|
+
*
|
|
176
|
+
* Plain {@link KafkaRedriveIO.publish} writes a keyless record. `publishWithHeaders` is not offered, so
|
|
177
|
+
* `bypass` is a no-op on this transport (as in the Go reference's keyed path).
|
|
178
|
+
*/
|
|
179
|
+
declare class KafkaRedriveIO {
|
|
180
|
+
private readonly options;
|
|
181
|
+
constructor(options: KafkaRedriveIOOptions);
|
|
182
|
+
pop(queue: string): Promise<KafkaRedriveMessage | null>;
|
|
183
|
+
/** Append an already-encoded body to `queue` as a keyless record. */
|
|
184
|
+
publish(queue: string, body: string): Promise<void>;
|
|
185
|
+
/** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
|
|
186
|
+
publishFrom(queue: string, body: string, from: KafkaRedriveMessage): Promise<void>;
|
|
187
|
+
private send;
|
|
188
|
+
}
|
|
146
189
|
/**
|
|
147
190
|
* A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band
|
|
148
191
|
* {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to
|
|
@@ -189,11 +232,22 @@ declare class KafkaConsumer {
|
|
|
189
232
|
private republishRetry;
|
|
190
233
|
private deadLetter;
|
|
191
234
|
private deadLetterRaw;
|
|
235
|
+
/**
|
|
236
|
+
* Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays
|
|
237
|
+
* empty); a null/absent key is left off the record, never synthesised. Partition equality is not
|
|
238
|
+
* guaranteed.
|
|
239
|
+
*/
|
|
192
240
|
private produce;
|
|
193
241
|
private commit;
|
|
194
242
|
private originalTopic;
|
|
195
243
|
private requireProducer;
|
|
244
|
+
/**
|
|
245
|
+
* Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been
|
|
246
|
+
* settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a
|
|
247
|
+
* promise that later rejects (an `async` function is assignable to the `void` callback type), is
|
|
248
|
+
* swallowed here so user code can never bypass settlement or stop the consumer.
|
|
249
|
+
*/
|
|
196
250
|
private report;
|
|
197
251
|
}
|
|
198
252
|
|
|
199
|
-
export { type BabelHandler, type BabelHandlers, type EachMessagePayload, type IncomingHeaders, KafkaConsumer, type KafkaConsumerClient, type KafkaConsumerOptions, type KafkaIncomingMessage, type KafkaProducerClient, type KafkaProducerMessage, KafkaPublisher, type PublishOptions, RetryTopics, type Tier, headersOf, kafkaHeaders };
|
|
253
|
+
export { type BabelHandler, type BabelHandlers, type EachMessagePayload, type IncomingHeaders, KafkaConsumer, type KafkaConsumerClient, type KafkaConsumerOptions, type KafkaIncomingMessage, type KafkaProducerClient, type KafkaProducerMessage, KafkaPublisher, KafkaRedriveIO, type KafkaRedriveIOOptions, type KafkaRedriveMessage, type PublishOptions, RetryTopics, type Tier, headersOf, kafkaHeaders };
|
package/dist/index.d.ts
CHANGED
|
@@ -143,6 +143,49 @@ declare class KafkaPublisher {
|
|
|
143
143
|
publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
|
|
144
144
|
private sendRecord;
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* A DLQ record reserved for {@link KafkaRedriveIO}: the structural shape `@babelqueue/core`'s
|
|
148
|
+
* `RedriveMessage` expects (`body` + `ack`), plus the consumed record's `key` — `null`/absent for a
|
|
149
|
+
* keyless record, a (possibly empty) `Buffer` otherwise.
|
|
150
|
+
*/
|
|
151
|
+
interface KafkaRedriveMessage {
|
|
152
|
+
body: string;
|
|
153
|
+
key?: Buffer | null;
|
|
154
|
+
ack(): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
/** Options for {@link KafkaRedriveIO}. */
|
|
157
|
+
interface KafkaRedriveIOOptions {
|
|
158
|
+
/** The producer used for every write the redrive makes (redrive write + restore to the DLQ). */
|
|
159
|
+
producer: KafkaProducerClient;
|
|
160
|
+
/**
|
|
161
|
+
* Reserve the next record from `queue` (the DLQ topic), or `null` when it is empty. Kafka consumption
|
|
162
|
+
* is push-based in KafkaJS, so the caller supplies the pull (a bounded fetch with manual commit) and
|
|
163
|
+
* its `ack` commits the offset. Return the record's `key` so the redrive can carry it.
|
|
164
|
+
*/
|
|
165
|
+
pop(queue: string): Promise<KafkaRedriveMessage | null>;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A `@babelqueue/core` `RedriveIO` over Kafka that carries the DLQ record key byte-for-byte through the
|
|
169
|
+
* redrive (contract §6.5): the redrive write back to the work topic and the restore write back to the
|
|
170
|
+
* DLQ both go through {@link KafkaRedriveIO.publishFrom}, which copies the reserved record's key — a null
|
|
171
|
+
* key stays null (no key on the record), an empty key stays empty — and never synthesises one. Partition
|
|
172
|
+
* equality is not guaranteed (client partitioners differ). The §6.3 header projection is rebuilt from
|
|
173
|
+
* the body being written (`bq-attempts` therefore reads 0 for a reset envelope); a body that is not a
|
|
174
|
+
* canonical envelope (an undecodable record being restored) is written verbatim with no `bq-*` headers.
|
|
175
|
+
*
|
|
176
|
+
* Plain {@link KafkaRedriveIO.publish} writes a keyless record. `publishWithHeaders` is not offered, so
|
|
177
|
+
* `bypass` is a no-op on this transport (as in the Go reference's keyed path).
|
|
178
|
+
*/
|
|
179
|
+
declare class KafkaRedriveIO {
|
|
180
|
+
private readonly options;
|
|
181
|
+
constructor(options: KafkaRedriveIOOptions);
|
|
182
|
+
pop(queue: string): Promise<KafkaRedriveMessage | null>;
|
|
183
|
+
/** Append an already-encoded body to `queue` as a keyless record. */
|
|
184
|
+
publish(queue: string, body: string): Promise<void>;
|
|
185
|
+
/** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
|
|
186
|
+
publishFrom(queue: string, body: string, from: KafkaRedriveMessage): Promise<void>;
|
|
187
|
+
private send;
|
|
188
|
+
}
|
|
146
189
|
/**
|
|
147
190
|
* A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band
|
|
148
191
|
* {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to
|
|
@@ -189,11 +232,22 @@ declare class KafkaConsumer {
|
|
|
189
232
|
private republishRetry;
|
|
190
233
|
private deadLetter;
|
|
191
234
|
private deadLetterRaw;
|
|
235
|
+
/**
|
|
236
|
+
* Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays
|
|
237
|
+
* empty); a null/absent key is left off the record, never synthesised. Partition equality is not
|
|
238
|
+
* guaranteed.
|
|
239
|
+
*/
|
|
192
240
|
private produce;
|
|
193
241
|
private commit;
|
|
194
242
|
private originalTopic;
|
|
195
243
|
private requireProducer;
|
|
244
|
+
/**
|
|
245
|
+
* Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been
|
|
246
|
+
* settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a
|
|
247
|
+
* promise that later rejects (an `async` function is assignable to the `void` callback type), is
|
|
248
|
+
* swallowed here so user code can never bypass settlement or stop the consumer.
|
|
249
|
+
*/
|
|
196
250
|
private report;
|
|
197
251
|
}
|
|
198
252
|
|
|
199
|
-
export { type BabelHandler, type BabelHandlers, type EachMessagePayload, type IncomingHeaders, KafkaConsumer, type KafkaConsumerClient, type KafkaConsumerOptions, type KafkaIncomingMessage, type KafkaProducerClient, type KafkaProducerMessage, KafkaPublisher, type PublishOptions, RetryTopics, type Tier, headersOf, kafkaHeaders };
|
|
253
|
+
export { type BabelHandler, type BabelHandlers, type EachMessagePayload, type IncomingHeaders, KafkaConsumer, type KafkaConsumerClient, type KafkaConsumerOptions, type KafkaIncomingMessage, type KafkaProducerClient, type KafkaProducerMessage, KafkaPublisher, KafkaRedriveIO, type KafkaRedriveIOOptions, type KafkaRedriveMessage, type PublishOptions, RetryTopics, type Tier, headersOf, kafkaHeaders };
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,9 @@ function headerInt(headers, key, fallback) {
|
|
|
58
58
|
const n = Number.parseInt(s.trim(), 10);
|
|
59
59
|
return Number.isNaN(n) ? fallback : n;
|
|
60
60
|
}
|
|
61
|
+
function isThenable(value) {
|
|
62
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
|
|
63
|
+
}
|
|
61
64
|
var BLANK_ASCII = /^[ \t\n\v\f\r]*$/;
|
|
62
65
|
function unsupportedSchemaVersion(message) {
|
|
63
66
|
const value = headerString(message.headers, "bq-schema-version");
|
|
@@ -148,6 +151,33 @@ var KafkaPublisher = class _KafkaPublisher {
|
|
|
148
151
|
});
|
|
149
152
|
}
|
|
150
153
|
};
|
|
154
|
+
var KafkaRedriveIO = class {
|
|
155
|
+
constructor(options) {
|
|
156
|
+
this.options = options;
|
|
157
|
+
}
|
|
158
|
+
options;
|
|
159
|
+
pop(queue) {
|
|
160
|
+
return this.options.pop(queue);
|
|
161
|
+
}
|
|
162
|
+
/** Append an already-encoded body to `queue` as a keyless record. */
|
|
163
|
+
async publish(queue, body) {
|
|
164
|
+
await this.send(queue, body, null);
|
|
165
|
+
}
|
|
166
|
+
/** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
|
|
167
|
+
async publishFrom(queue, body, from) {
|
|
168
|
+
await this.send(queue, body, from.key ?? null);
|
|
169
|
+
}
|
|
170
|
+
async send(queue, body, key) {
|
|
171
|
+
const record = { value: body };
|
|
172
|
+
const decoded = EnvelopeCodec.decode(body);
|
|
173
|
+
if (EnvelopeCodec.accepts(decoded)) {
|
|
174
|
+
record.headers = kafkaHeaders(decoded);
|
|
175
|
+
record.timestamp = String(decoded.meta.created_at);
|
|
176
|
+
}
|
|
177
|
+
if (key !== null) record.key = Buffer.from(key);
|
|
178
|
+
await this.options.producer.send({ topic: queue, messages: [record] });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
151
181
|
var KafkaConsumer = class {
|
|
152
182
|
constructor(consumer, handlers, options = {}) {
|
|
153
183
|
this.consumer = consumer;
|
|
@@ -170,13 +200,15 @@ var KafkaConsumer = class {
|
|
|
170
200
|
const { message } = payload;
|
|
171
201
|
const unsupported = unsupportedSchemaVersion(message);
|
|
172
202
|
if (unsupported !== void 0) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
null,
|
|
176
|
-
payload
|
|
203
|
+
const rejected = new BabelQueueError(
|
|
204
|
+
`Rejected a record from Kafka with unsupported bq-schema-version "${unsupported}" (supported: ${SCHEMA_VERSION}).`
|
|
177
205
|
);
|
|
178
|
-
|
|
179
|
-
|
|
206
|
+
try {
|
|
207
|
+
await this.deadLetterRaw(payload);
|
|
208
|
+
await this.commit(payload);
|
|
209
|
+
} finally {
|
|
210
|
+
this.report(rejected, null, payload);
|
|
211
|
+
}
|
|
180
212
|
return;
|
|
181
213
|
}
|
|
182
214
|
const raw = message.value ? message.value.toString("utf8") : "";
|
|
@@ -184,15 +216,22 @@ var KafkaConsumer = class {
|
|
|
184
216
|
try {
|
|
185
217
|
envelope = this.reconcile(EnvelopeCodec.decode(raw), message);
|
|
186
218
|
} catch (decodeError) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
219
|
+
try {
|
|
220
|
+
await this.deadLetterRaw(payload);
|
|
221
|
+
await this.commit(payload);
|
|
222
|
+
} finally {
|
|
223
|
+
this.report(decodeError, null, payload);
|
|
224
|
+
}
|
|
190
225
|
return;
|
|
191
226
|
}
|
|
192
227
|
if (!EnvelopeCodec.accepts(envelope)) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
228
|
+
const rejected = new BabelQueueError("Rejected a non-conformant BabelQueue envelope from Kafka.");
|
|
229
|
+
try {
|
|
230
|
+
await this.deadLetterRaw(payload);
|
|
231
|
+
await this.commit(payload);
|
|
232
|
+
} finally {
|
|
233
|
+
this.report(rejected, envelope, payload);
|
|
234
|
+
}
|
|
196
235
|
return;
|
|
197
236
|
}
|
|
198
237
|
const urn = headerString(message.headers, "bq-job") ?? EnvelopeCodec.urn(envelope);
|
|
@@ -205,9 +244,12 @@ var KafkaConsumer = class {
|
|
|
205
244
|
await handler(envelope, message, headersOf(message));
|
|
206
245
|
await this.commit(payload);
|
|
207
246
|
} catch (error) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
247
|
+
try {
|
|
248
|
+
await this.retryOrDeadLetter(payload, envelope, error);
|
|
249
|
+
await this.commit(payload);
|
|
250
|
+
} finally {
|
|
251
|
+
this.report(error, envelope, payload);
|
|
252
|
+
}
|
|
211
253
|
}
|
|
212
254
|
}
|
|
213
255
|
reconcile(envelope, message) {
|
|
@@ -257,7 +299,7 @@ var KafkaConsumer = class {
|
|
|
257
299
|
const headers = kafkaHeaders(bumped);
|
|
258
300
|
headers["bq-delay"] = String(tier.delayMs);
|
|
259
301
|
headers["bq-original-topic"] = this.originalTopic(payload);
|
|
260
|
-
await this.produce(tier.topic, bumped, headers);
|
|
302
|
+
await this.produce(tier.topic, bumped, headers, payload.message.key);
|
|
261
303
|
}
|
|
262
304
|
async deadLetter(envelope, payload, reason, error) {
|
|
263
305
|
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
@@ -270,7 +312,7 @@ var KafkaConsumer = class {
|
|
|
270
312
|
});
|
|
271
313
|
const headers = kafkaHeaders(annotated);
|
|
272
314
|
headers["bq-original-topic"] = original;
|
|
273
|
-
await this.produce(dlq, annotated, headers);
|
|
315
|
+
await this.produce(dlq, annotated, headers, payload.message.key);
|
|
274
316
|
}
|
|
275
317
|
async deadLetterRaw(payload) {
|
|
276
318
|
const dlq = this.options.retryTopics?.dlqTopic ?? null;
|
|
@@ -286,12 +328,16 @@ var KafkaConsumer = class {
|
|
|
286
328
|
if (message.key !== void 0 && message.key !== null) forwarded.key = Buffer.from(message.key);
|
|
287
329
|
await this.options.producer.send({ topic: dlq, messages: [forwarded] });
|
|
288
330
|
}
|
|
289
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays
|
|
333
|
+
* empty); a null/absent key is left off the record, never synthesised. Partition equality is not
|
|
334
|
+
* guaranteed.
|
|
335
|
+
*/
|
|
336
|
+
async produce(topic, envelope, headers, key) {
|
|
290
337
|
this.requireProducer();
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
});
|
|
338
|
+
const record = { value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };
|
|
339
|
+
if (key !== void 0 && key !== null) record.key = Buffer.from(key);
|
|
340
|
+
await this.options.producer.send({ topic, messages: [record] });
|
|
295
341
|
}
|
|
296
342
|
async commit(payload) {
|
|
297
343
|
await this.consumer.commitOffsets([
|
|
@@ -306,13 +352,26 @@ var KafkaConsumer = class {
|
|
|
306
352
|
throw new BabelQueueError("This Kafka consumer needs a producer to republish (retry/DLQ).");
|
|
307
353
|
}
|
|
308
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been
|
|
357
|
+
* settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a
|
|
358
|
+
* promise that later rejects (an `async` function is assignable to the `void` callback type), is
|
|
359
|
+
* swallowed here so user code can never bypass settlement or stop the consumer.
|
|
360
|
+
*/
|
|
309
361
|
report(error, envelope, payload) {
|
|
310
|
-
|
|
362
|
+
try {
|
|
363
|
+
const returned = this.options.onError?.(error, envelope, payload);
|
|
364
|
+
if (isThenable(returned)) {
|
|
365
|
+
returned.then(void 0, () => void 0);
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
}
|
|
311
369
|
}
|
|
312
370
|
};
|
|
313
371
|
export {
|
|
314
372
|
KafkaConsumer,
|
|
315
373
|
KafkaPublisher,
|
|
374
|
+
KafkaRedriveIO,
|
|
316
375
|
RetryTopics,
|
|
317
376
|
headersOf,
|
|
318
377
|
kafkaHeaders
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Apache Kafka adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed, **process-then-commit** consumer over\n * Apache Kafka (KafkaJS), so a Kafka-based Node service speaks the same contract (envelope\n * shape, URN identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * Kafka has **no native** per-message ack, delayed delivery, dead-letter queue, or delivery\n * counter — this adapter absorbs all four in the binding layer (the envelope stays\n * `schema_version: 1`): the envelope JSON is the record **value**; the contract fields are\n * mirrored onto `bq-` headers (route on `bq-job` without decoding the body) and the record\n * timestamp mirrors `meta.created_at`; **`bq-attempts` is the authoritative retry counter**;\n * consume is process-then-commit (manual commit, at-least-once); retry/delay use SDK-owned\n * tiered retry topics `<topic>.retry.<n>`; terminal failures go to an opt-in `<topic>.dlq`.\n *\n * This implements §6 of the broker-bindings contract. `kafkajs` is an optional peer — you\n * provide the producer/consumer (a KafkaJS `Producer`/`Consumer` satisfies the adapter\n * structurally).\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional Kafka **record headers** beside the contract `bq-*` headers —\n * the contract headers win a key collision (merge-not-clobber), and blanks are dropped. On consume\n * the inbound record headers are surfaced as a `Record<string,string>` so the core's `otel` extract\n * sees the `traceparent` and links the consumer span as a child. A header-less publish is\n * byte-identical. GR-1: the wire envelope (the record value) is never touched.\n *\n * **Version gate (§6.7).** Before decoding the record value the consumer reads the `bq-schema-version`\n * header. A missing or blank value (empty or only ASCII whitespace) follows the normal decode path;\n * any other value is compared, untrimmed, to the supported `\"1\"`, and a record that does not match is\n * rejected undecoded down the poison path — reported to `onError`, the raw record (value, key and\n * headers) forwarded to the DLQ (when configured) and the offset committed.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { mergeInto } from \"./headers.js\";\n\n// --- Minimal Kafka shapes (a structural subset of kafkajs) ---------------------\n\n/** Incoming record headers (KafkaJS decodes header values to Buffers). */\nexport type IncomingHeaders = { [key: string]: Buffer | string | (Buffer | string)[] | null | undefined };\n\n/** A record to produce (structural subset of a KafkaJS message). */\nexport interface KafkaProducerMessage {\n key?: Buffer | string | null;\n value: Buffer | string;\n /**\n * Header values are strings for records the adapter builds. Raw DLQ forwarding (gate rejection,\n * decode error, non-conformant envelope) instead passes the inbound values through as\n * `Buffer | Buffer[]` (KafkaJS `IHeaders`); a custom producer wrapper must tolerate both.\n */\n headers?: { [key: string]: string };\n timestamp?: string;\n}\n\n/** The subset of the KafkaJS producer this adapter calls. */\nexport interface KafkaProducerClient {\n send(record: { topic: string; messages: KafkaProducerMessage[] }): Promise<unknown>;\n}\n\n/** A received record (structural subset of a KafkaJS message). */\nexport interface KafkaIncomingMessage {\n value: Buffer | null;\n headers?: IncomingHeaders;\n offset: string;\n timestamp?: string;\n key?: Buffer | null;\n}\n\n/** The payload KafkaJS passes to `eachMessage`. */\nexport interface EachMessagePayload {\n topic: string;\n partition: number;\n message: KafkaIncomingMessage;\n}\n\n/** The subset of the KafkaJS consumer this adapter calls (manual commit — `autoCommit: false`). */\nexport interface KafkaConsumerClient {\n run(config: { autoCommit?: boolean; eachMessage: (payload: EachMessagePayload) => Promise<void> }): Promise<void>;\n commitOffsets(offsets: { topic: string; partition: number; offset: string }[]): Promise<void>;\n}\n\n// --- Header projection (contract §6.3) -----------------------------------------\n\n/** Project the envelope's contract fields onto Kafka record headers (UTF-8 string values). */\nexport function kafkaHeaders(envelope: Envelope): { [key: string]: string } {\n const headers: { [key: string]: string } = {};\n if (envelope.job) headers[\"bq-job\"] = envelope.job;\n if (envelope.trace_id) headers[\"bq-trace-id\"] = envelope.trace_id;\n if (envelope.meta.id) headers[\"bq-message-id\"] = envelope.meta.id;\n if (envelope.meta.schema_version != null) headers[\"bq-schema-version\"] = String(envelope.meta.schema_version);\n if (envelope.meta.lang) headers[\"bq-source-lang\"] = envelope.meta.lang;\n headers[\"bq-attempts\"] = String(envelope.attempts ?? 0);\n return headers;\n}\n\n/**\n * Copy inbound record headers for a raw DLQ forward: every value becomes a `Buffer` copy (a string is\n * UTF-8 encoded), array-valued headers keep all their entries, and `undefined` / `null` values are dropped.\n */\nfunction copyHeaders(headers: IncomingHeaders | undefined): { [key: string]: Buffer | Buffer[] } {\n const out: { [key: string]: Buffer | Buffer[] } = {};\n if (!headers) return out;\n const copy = (v: Buffer | string): Buffer => (Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from(v, \"utf8\"));\n for (const [name, value] of Object.entries(headers)) {\n if (value === undefined || value === null) continue;\n if (Array.isArray(value)) {\n out[name] = value.filter((v) => v !== undefined && v !== null).map(copy);\n } else {\n out[name] = copy(value);\n }\n }\n return out;\n}\n\nfunction headerString(headers: IncomingHeaders | undefined, key: string): string | undefined {\n const value = headers?.[key];\n // A null header value is \"no header\" (broker-bindings.md §6.7): never stringified to \"null\".\n if (value === undefined || value === null) return undefined;\n const v = Array.isArray(value) ? value[0] : value;\n if (v === undefined || v === null) return undefined;\n return Buffer.isBuffer(v) ? v.toString(\"utf8\") : String(v);\n}\n\nfunction headerInt(headers: IncomingHeaders | undefined, key: string, fallback: number): number {\n const s = headerString(headers, key);\n if (s === undefined) return fallback;\n const n = Number.parseInt(s.trim(), 10);\n return Number.isNaN(n) ? fallback : n;\n}\n\n/** Blank = empty or only ASCII whitespace (GR-5: the same set in every SDK, never `String.trim()`). */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The inbound `bq-schema-version` header when it is present, non-blank and not exactly the supported\n * version (compared untrimmed), else `undefined` (the record may be decoded).\n */\nfunction unsupportedSchemaVersion(message: KafkaIncomingMessage): string | undefined {\n const value = headerString(message.headers, \"bq-schema-version\");\n if (value === undefined || BLANK_ASCII.test(value)) return undefined;\n return value === String(SCHEMA_VERSION) ? undefined : value;\n}\n\n/**\n * Map an inbound record's headers onto a flat {@link HeaderCarrier}, stringifying KafkaJS's Buffer\n * values. Returns an empty object when there are none. Both the contract `bq-*` headers and any\n * out-of-band rider (e.g. `traceparent`) surface — the core's `otel` extract reads only the keys it\n * knows.\n */\nexport function headersOf(message: KafkaIncomingMessage): HeaderCarrier {\n const headers = message.headers;\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headerString(headers, key);\n if (value !== undefined && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n// --- Retry / delay topology (contract §6.4–§6.5) -------------------------------\n\n/** A single delay tier: the `<topic>.retry.<n>` topic and the delay (ms) it holds for. */\nexport interface Tier {\n topic: string;\n delayMs: number;\n}\n\n/**\n * The SDK-owned retry/delay topology for one work topic: tiered delay topics\n * `<topic>.retry.<n>` (ascending) plus an opt-in `<topic>.dlq`. A delay or release with no\n * tiers configured raises rather than silently dropping.\n */\nexport class RetryTopics {\n readonly tiers: Tier[];\n readonly dlqTopic: string | null;\n\n constructor(\n readonly workTopic: string,\n delaysMs: number[] = [],\n dlqTopic: string | null | undefined = undefined,\n ) {\n this.tiers = [...delaysMs]\n .sort((a, b) => a - b)\n .map((delayMs, i) => ({ topic: `${workTopic}.retry.${i + 1}`, delayMs }));\n this.dlqTopic = dlqTopic === undefined ? `${workTopic}.dlq` : dlqTopic;\n }\n\n hasTiers(): boolean {\n return this.tiers.length > 0;\n }\n\n /** The smallest tier whose delay ≥ `delayMs`; raises if none configured or the delay is too large. */\n tierForDelay(delayMs: number): Tier {\n this.requireTiers();\n for (const tier of this.tiers) {\n if (tier.delayMs >= delayMs) return tier;\n }\n throw new BabelQueueError(\n `Requested Kafka delay ${delayMs}ms exceeds the largest retry tier (${this.tiers[this.tiers.length - 1]!.delayMs}ms).`,\n );\n }\n\n /** The tier for a retry at `attempt` (0-based), clamped to the largest; raises if none configured. */\n tierForAttempt(attempt: number): Tier {\n this.requireTiers();\n return this.tiers[Math.min(Math.max(attempt, 0), this.tiers.length - 1)]!;\n }\n\n private requireTiers(): void {\n if (!this.tiers.length) {\n throw new BabelQueueError(`Kafka retry/delay requires retry topics; none are configured for '${this.workTopic}'.`);\n }\n }\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link KafkaPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /** Schedule via a retry tier (requires a {@link RetryTopics}); raises on a plain publisher. */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as Kafka record headers beside the contract `bq-*`\n * headers (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract headers win a key collision; an empty/omitted carrier leaves the record unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Kafka work topic with the §6 projection. */\nexport class KafkaPublisher {\n private constructor(\n private readonly producer: KafkaProducerClient,\n private readonly workTopic: string,\n private readonly retryTopics?: RetryTopics,\n ) {}\n\n /** A publisher onto `topic` with no retry topics (a delay raises). */\n static create(producer: KafkaProducerClient, topic: string): KafkaPublisher {\n return new KafkaPublisher(producer, topic);\n }\n\n /** A publisher onto the topology's work topic, with delay routed via its retry tiers. */\n static withRetryTopics(producer: KafkaProducerClient, retryTopics: RetryTopics): KafkaPublisher {\n return new KafkaPublisher(producer, retryTopics.workTopic, retryTopics);\n }\n\n /** Build + send the canonical envelope; returns the message id (`meta.id`). */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.workTopic, traceId: options.traceId });\n if (options.delayMs != null && options.delayMs > 0) {\n if (!this.retryTopics) {\n throw new BabelQueueError(\"Kafka has no native delayed delivery; a delay requires retry topics (none configured).\");\n }\n const tier = this.retryTopics.tierForDelay(options.delayMs);\n await this.sendRecord(tier.topic, envelope, options.headers, options.delayMs, this.workTopic);\n } else {\n await this.sendRecord(this.workTopic, envelope, options.headers);\n }\n return envelope.meta.id;\n }\n\n private async sendRecord(\n topic: string,\n envelope: Envelope,\n extra?: HeaderCarrier | null,\n delayMs?: number,\n originalTopic?: string,\n ): Promise<void> {\n // The contract bq-* headers are seeded first, then the out-of-band riders are merged beside\n // them without clobbering (the contract wins a collision).\n const headers = mergeInto(kafkaHeaders(envelope), extra);\n if (delayMs != null) headers[\"bq-delay\"] = String(delayMs);\n if (originalTopic != null) headers[\"bq-original-topic\"] = originalTopic;\n await this.producer.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(envelope.meta.created_at) }],\n });\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band\n * {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer span\n * (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: KafkaIncomingMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link KafkaConsumer}. */\nexport interface KafkaConsumerOptions {\n /** The producer used to republish retry/DLQ records (required for retry/DLQ). */\n producer?: KafkaProducerClient;\n /** The retry/DLQ topology; enables per-record retry, delay, and dead-lettering. */\n retryTopics?: RetryTopics;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a poison record, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload) => void;\n}\n\n/**\n * Consumes a Kafka work topic in process-then-commit mode: each record is decoded, validated,\n * routed to the handler for its URN (read from `bq-job`), and its offset committed only after\n * the handler returns. A throwing handler republishes to a `<topic>.retry.<n>` tier with\n * `bq-attempts + 1`, then commits; terminal failures go to `<topic>.dlq` with a `dead_letter`\n * block. The `bq-attempts` header is the authoritative counter (the body is the fallback).\n */\nexport class KafkaConsumer {\n private readonly maxTries: number;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly consumer: KafkaConsumerClient,\n private readonly handlers: BabelHandlers,\n private readonly options: KafkaConsumerOptions = {},\n ) {\n this.maxTries = options.maxTries ?? 3;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Start the manual-commit consume loop (`autoCommit: false`); resolves when the consumer stops. */\n async run(): Promise<void> {\n await this.consumer.run({ autoCommit: false, eachMessage: (payload) => this.handle(payload) });\n }\n\n /** Route + settle one record (process-then-commit). Exposed for testing. */\n async handle(payload: EachMessagePayload): Promise<void> {\n const { message } = payload;\n\n // Version gate (§6.7): decided on the header alone, before the value is decoded.\n const unsupported = unsupportedSchemaVersion(message);\n if (unsupported !== undefined) {\n this.report(\n new BabelQueueError(`Rejected a record from Kafka with unsupported bq-schema-version \"${unsupported}\" (supported: ${SCHEMA_VERSION}).`),\n null,\n payload,\n );\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n const raw = message.value ? message.value.toString(\"utf8\") : \"\";\n\n let envelope: IncomingEnvelope;\n try {\n envelope = this.reconcile(EnvelopeCodec.decode(raw), message);\n } catch (decodeError) {\n this.report(decodeError, null, payload);\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n // A non-conformant / poison envelope may be missing the fields needed to annotate, so\n // forward the raw record to the DLQ rather than building a dead_letter block.\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Kafka.\"), envelope, payload);\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n return;\n }\n\n const urn = headerString(message.headers, \"bq-job\") ?? EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(payload, envelope, urn);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.commit(payload);\n } catch (error) {\n this.report(error, envelope, payload);\n await this.retryOrDeadLetter(payload, envelope as Envelope, error);\n await this.commit(payload);\n }\n }\n\n private reconcile(envelope: IncomingEnvelope, message: KafkaIncomingMessage): IncomingEnvelope {\n const body = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n return { ...envelope, attempts: headerInt(message.headers, \"bq-attempts\", body) };\n }\n\n private async onUnknownUrn(payload: EachMessagePayload, envelope: IncomingEnvelope, urn: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope as Envelope, payload, \"unknown_urn\");\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.republishRetry(payload, envelope as Envelope);\n await this.commit(payload);\n return;\n default: {\n // FAIL: surface and do NOT commit — the record redelivers on the next poll.\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, payload);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(payload: EachMessagePayload, envelope: Envelope, error: unknown): Promise<void> {\n const hasTiers = this.options.retryTopics?.hasTiers() ?? false;\n const hasDlq = (this.options.retryTopics?.dlqTopic ?? null) !== null;\n if (!hasTiers && !hasDlq) {\n void error; // reported via onError before this; Kafka has nowhere to put the failure\n throw new BabelQueueError(\"Kafka per-record retry requires retry topics and/or a DLQ; neither is configured.\");\n }\n if (hasTiers && (envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.republishRetry(payload, envelope);\n } else {\n await this.deadLetter(envelope, payload, \"failed\", error);\n }\n }\n\n private async republishRetry(payload: EachMessagePayload, envelope: Envelope): Promise<void> {\n const topics = this.options.retryTopics;\n if (!topics || !topics.hasTiers()) {\n throw new BabelQueueError(\"Kafka retry/release requires retry topics; none are configured.\");\n }\n const tier = topics.tierForAttempt(envelope.attempts ?? 0);\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n const headers = kafkaHeaders(bumped);\n headers[\"bq-delay\"] = String(tier.delayMs);\n headers[\"bq-original-topic\"] = this.originalTopic(payload);\n await this.produce(tier.topic, bumped, headers);\n }\n\n private async deadLetter(envelope: Envelope, payload: EachMessagePayload, reason: string, error?: unknown): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return; // dead-lettering disabled → degrade to commit-and-drop\n const original = this.originalTopic(payload);\n const annotated = annotate(envelope, reason, original, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n const headers = kafkaHeaders(annotated);\n headers[\"bq-original-topic\"] = original;\n await this.produce(dlq, annotated, headers);\n }\n\n private async deadLetterRaw(payload: EachMessagePayload): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return;\n this.requireProducer();\n const { message } = payload;\n // Forward the original record verbatim (value, key, headers) so the DLQ copy keeps its provenance\n // (Java reference; the .NET consumer forwards value + headers). Only the timestamp is re-stamped.\n const forwarded: KafkaProducerMessage = {\n value: message.value ?? Buffer.alloc(0),\n // KafkaJS accepts Buffer / array header values; the published structural type stays string-only.\n headers: copyHeaders(message.headers) as unknown as KafkaProducerMessage[\"headers\"],\n timestamp: String(Date.now()),\n };\n if (message.key !== undefined && message.key !== null) forwarded.key = Buffer.from(message.key);\n await this.options.producer!.send({ topic: dlq, messages: [forwarded] });\n }\n\n private async produce(topic: string, envelope: Envelope, headers: { [key: string]: string }): Promise<void> {\n this.requireProducer();\n await this.options.producer!.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) }],\n });\n }\n\n private async commit(payload: EachMessagePayload): Promise<void> {\n await this.consumer.commitOffsets([\n { topic: payload.topic, partition: payload.partition, offset: (Number(payload.message.offset) + 1).toString() },\n ]);\n }\n\n private originalTopic(payload: EachMessagePayload): string {\n return headerString(payload.message.headers, \"bq-original-topic\") ?? payload.topic;\n }\n\n private requireProducer(): void {\n if (!this.options.producer) {\n throw new BabelQueueError(\"This Kafka consumer needs a producer to republish (retry/DLQ).\");\n }\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload): void {\n this.options.onError?.(error, envelope, payload);\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n\n/**\n * Merge out-of-band `extra` headers onto an already-built `base` header map **without clobbering**\n * the contract headers in `base` (they win a key collision) and dropping blanks. The result is\n * `base`, mutated in place.\n */\nexport function mergeInto(base: { [key: string]: string }, extra: HeaderCarrier | null | undefined): { [key: string]: string } {\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) {\n if (key in base) continue; // the contract header already there wins\n base[key] = value;\n }\n return base;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Apache Kafka adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed, **process-then-commit** consumer over\n * Apache Kafka (KafkaJS), so a Kafka-based Node service speaks the same contract (envelope\n * shape, URN identity, trace propagation) as the .NET, Java, Python and Go SDKs.\n *\n * Kafka has **no native** per-message ack, delayed delivery, dead-letter queue, or delivery\n * counter — this adapter absorbs all four in the binding layer (the envelope stays\n * `schema_version: 1`): the envelope JSON is the record **value**; the contract fields are\n * mirrored onto `bq-` headers (route on `bq-job` without decoding the body) and the record\n * timestamp mirrors `meta.created_at`; **`bq-attempts` is the authoritative retry counter**;\n * consume is process-then-commit (manual commit, at-least-once); retry/delay use SDK-owned\n * tiered retry topics `<topic>.retry.<n>`; terminal failures go to an opt-in `<topic>.dlq`.\n *\n * This implements §6 of the broker-bindings contract. `kafkajs` is an optional peer — you\n * provide the producer/consumer (a KafkaJS `Producer`/`Consumer` satisfies the adapter\n * structurally).\n *\n * **Out-of-band headers (ADR-0028).** A `headers` carrier (e.g. a W3C `traceparent` for cross-hop\n * span linkage) rides as additional Kafka **record headers** beside the contract `bq-*` headers —\n * the contract headers win a key collision (merge-not-clobber), and blanks are dropped. On consume\n * the inbound record headers are surfaced as a `Record<string,string>` so the core's `otel` extract\n * sees the `traceparent` and links the consumer span as a child. A header-less publish is\n * byte-identical. GR-1: the wire envelope (the record value) is never touched.\n *\n * **Version gate (§6.7).** Before decoding the record value the consumer reads the `bq-schema-version`\n * header. A missing or blank value (empty or only ASCII whitespace) follows the normal decode path;\n * any other value is compared, untrimmed, to the supported `\"1\"`, and a record that does not match is\n * rejected undecoded down the poison path — reported to `onError`, the raw record (value, key and\n * headers) forwarded to the DLQ (when configured) and the offset committed.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { mergeInto } from \"./headers.js\";\n\n// --- Minimal Kafka shapes (a structural subset of kafkajs) ---------------------\n\n/** Incoming record headers (KafkaJS decodes header values to Buffers). */\nexport type IncomingHeaders = { [key: string]: Buffer | string | (Buffer | string)[] | null | undefined };\n\n/** A record to produce (structural subset of a KafkaJS message). */\nexport interface KafkaProducerMessage {\n key?: Buffer | string | null;\n value: Buffer | string;\n /**\n * Header values are strings for records the adapter builds. Raw DLQ forwarding (gate rejection,\n * decode error, non-conformant envelope) instead passes the inbound values through as\n * `Buffer | Buffer[]` (KafkaJS `IHeaders`); a custom producer wrapper must tolerate both.\n */\n headers?: { [key: string]: string };\n timestamp?: string;\n}\n\n/** The subset of the KafkaJS producer this adapter calls. */\nexport interface KafkaProducerClient {\n send(record: { topic: string; messages: KafkaProducerMessage[] }): Promise<unknown>;\n}\n\n/** A received record (structural subset of a KafkaJS message). */\nexport interface KafkaIncomingMessage {\n value: Buffer | null;\n headers?: IncomingHeaders;\n offset: string;\n timestamp?: string;\n key?: Buffer | null;\n}\n\n/** The payload KafkaJS passes to `eachMessage`. */\nexport interface EachMessagePayload {\n topic: string;\n partition: number;\n message: KafkaIncomingMessage;\n}\n\n/** The subset of the KafkaJS consumer this adapter calls (manual commit — `autoCommit: false`). */\nexport interface KafkaConsumerClient {\n run(config: { autoCommit?: boolean; eachMessage: (payload: EachMessagePayload) => Promise<void> }): Promise<void>;\n commitOffsets(offsets: { topic: string; partition: number; offset: string }[]): Promise<void>;\n}\n\n// --- Header projection (contract §6.3) -----------------------------------------\n\n/** Project the envelope's contract fields onto Kafka record headers (UTF-8 string values). */\nexport function kafkaHeaders(envelope: Envelope): { [key: string]: string } {\n const headers: { [key: string]: string } = {};\n if (envelope.job) headers[\"bq-job\"] = envelope.job;\n if (envelope.trace_id) headers[\"bq-trace-id\"] = envelope.trace_id;\n if (envelope.meta.id) headers[\"bq-message-id\"] = envelope.meta.id;\n if (envelope.meta.schema_version != null) headers[\"bq-schema-version\"] = String(envelope.meta.schema_version);\n if (envelope.meta.lang) headers[\"bq-source-lang\"] = envelope.meta.lang;\n headers[\"bq-attempts\"] = String(envelope.attempts ?? 0);\n return headers;\n}\n\n/**\n * Copy inbound record headers for a raw DLQ forward: every value becomes a `Buffer` copy (a string is\n * UTF-8 encoded), array-valued headers keep all their entries, and `undefined` / `null` values are dropped.\n */\nfunction copyHeaders(headers: IncomingHeaders | undefined): { [key: string]: Buffer | Buffer[] } {\n const out: { [key: string]: Buffer | Buffer[] } = {};\n if (!headers) return out;\n const copy = (v: Buffer | string): Buffer => (Buffer.isBuffer(v) ? Buffer.from(v) : Buffer.from(v, \"utf8\"));\n for (const [name, value] of Object.entries(headers)) {\n if (value === undefined || value === null) continue;\n if (Array.isArray(value)) {\n out[name] = value.filter((v) => v !== undefined && v !== null).map(copy);\n } else {\n out[name] = copy(value);\n }\n }\n return out;\n}\n\nfunction headerString(headers: IncomingHeaders | undefined, key: string): string | undefined {\n const value = headers?.[key];\n // A null header value is \"no header\" (broker-bindings.md §6.7): never stringified to \"null\".\n if (value === undefined || value === null) return undefined;\n const v = Array.isArray(value) ? value[0] : value;\n if (v === undefined || v === null) return undefined;\n return Buffer.isBuffer(v) ? v.toString(\"utf8\") : String(v);\n}\n\nfunction headerInt(headers: IncomingHeaders | undefined, key: string, fallback: number): number {\n const s = headerString(headers, key);\n if (s === undefined) return fallback;\n const n = Number.parseInt(s.trim(), 10);\n return Number.isNaN(n) ? fallback : n;\n}\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === \"object\" || typeof value === \"function\") &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/** Blank = empty or only ASCII whitespace (GR-5: the same set in every SDK, never `String.trim()`). */\nconst BLANK_ASCII = /^[ \\t\\n\\v\\f\\r]*$/;\n\n/**\n * The inbound `bq-schema-version` header when it is present, non-blank and not exactly the supported\n * version (compared untrimmed), else `undefined` (the record may be decoded).\n */\nfunction unsupportedSchemaVersion(message: KafkaIncomingMessage): string | undefined {\n const value = headerString(message.headers, \"bq-schema-version\");\n if (value === undefined || BLANK_ASCII.test(value)) return undefined;\n return value === String(SCHEMA_VERSION) ? undefined : value;\n}\n\n/**\n * Map an inbound record's headers onto a flat {@link HeaderCarrier}, stringifying KafkaJS's Buffer\n * values. Returns an empty object when there are none. Both the contract `bq-*` headers and any\n * out-of-band rider (e.g. `traceparent`) surface — the core's `otel` extract reads only the keys it\n * knows.\n */\nexport function headersOf(message: KafkaIncomingMessage): HeaderCarrier {\n const headers = message.headers;\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headerString(headers, key);\n if (value !== undefined && value !== \"\") out[key] = value;\n }\n return out;\n}\n\n// --- Retry / delay topology (contract §6.4–§6.5) -------------------------------\n\n/** A single delay tier: the `<topic>.retry.<n>` topic and the delay (ms) it holds for. */\nexport interface Tier {\n topic: string;\n delayMs: number;\n}\n\n/**\n * The SDK-owned retry/delay topology for one work topic: tiered delay topics\n * `<topic>.retry.<n>` (ascending) plus an opt-in `<topic>.dlq`. A delay or release with no\n * tiers configured raises rather than silently dropping.\n */\nexport class RetryTopics {\n readonly tiers: Tier[];\n readonly dlqTopic: string | null;\n\n constructor(\n readonly workTopic: string,\n delaysMs: number[] = [],\n dlqTopic: string | null | undefined = undefined,\n ) {\n this.tiers = [...delaysMs]\n .sort((a, b) => a - b)\n .map((delayMs, i) => ({ topic: `${workTopic}.retry.${i + 1}`, delayMs }));\n this.dlqTopic = dlqTopic === undefined ? `${workTopic}.dlq` : dlqTopic;\n }\n\n hasTiers(): boolean {\n return this.tiers.length > 0;\n }\n\n /** The smallest tier whose delay ≥ `delayMs`; raises if none configured or the delay is too large. */\n tierForDelay(delayMs: number): Tier {\n this.requireTiers();\n for (const tier of this.tiers) {\n if (tier.delayMs >= delayMs) return tier;\n }\n throw new BabelQueueError(\n `Requested Kafka delay ${delayMs}ms exceeds the largest retry tier (${this.tiers[this.tiers.length - 1]!.delayMs}ms).`,\n );\n }\n\n /** The tier for a retry at `attempt` (0-based), clamped to the largest; raises if none configured. */\n tierForAttempt(attempt: number): Tier {\n this.requireTiers();\n return this.tiers[Math.min(Math.max(attempt, 0), this.tiers.length - 1)]!;\n }\n\n private requireTiers(): void {\n if (!this.tiers.length) {\n throw new BabelQueueError(`Kafka retry/delay requires retry topics; none are configured for '${this.workTopic}'.`);\n }\n }\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link KafkaPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /** Schedule via a retry tier (requires a {@link RetryTopics}); raises on a plain publisher. */\n delayMs?: number;\n /**\n * Out-of-band transport headers carried as Kafka record headers beside the contract `bq-*`\n * headers (ADR-0028) — e.g. a W3C `traceparent` written by `@babelqueue/core/otel`'s `publish`.\n * The contract headers win a key collision; an empty/omitted carrier leaves the record unchanged.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Kafka work topic with the §6 projection. */\nexport class KafkaPublisher {\n private constructor(\n private readonly producer: KafkaProducerClient,\n private readonly workTopic: string,\n private readonly retryTopics?: RetryTopics,\n ) {}\n\n /** A publisher onto `topic` with no retry topics (a delay raises). */\n static create(producer: KafkaProducerClient, topic: string): KafkaPublisher {\n return new KafkaPublisher(producer, topic);\n }\n\n /** A publisher onto the topology's work topic, with delay routed via its retry tiers. */\n static withRetryTopics(producer: KafkaProducerClient, retryTopics: RetryTopics): KafkaPublisher {\n return new KafkaPublisher(producer, retryTopics.workTopic, retryTopics);\n }\n\n /** Build + send the canonical envelope; returns the message id (`meta.id`). */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.workTopic, traceId: options.traceId });\n if (options.delayMs != null && options.delayMs > 0) {\n if (!this.retryTopics) {\n throw new BabelQueueError(\"Kafka has no native delayed delivery; a delay requires retry topics (none configured).\");\n }\n const tier = this.retryTopics.tierForDelay(options.delayMs);\n await this.sendRecord(tier.topic, envelope, options.headers, options.delayMs, this.workTopic);\n } else {\n await this.sendRecord(this.workTopic, envelope, options.headers);\n }\n return envelope.meta.id;\n }\n\n private async sendRecord(\n topic: string,\n envelope: Envelope,\n extra?: HeaderCarrier | null,\n delayMs?: number,\n originalTopic?: string,\n ): Promise<void> {\n // The contract bq-* headers are seeded first, then the out-of-band riders are merged beside\n // them without clobbering (the contract wins a collision).\n const headers = mergeInto(kafkaHeaders(envelope), extra);\n if (delayMs != null) headers[\"bq-delay\"] = String(delayMs);\n if (originalTopic != null) headers[\"bq-original-topic\"] = originalTopic;\n await this.producer.send({\n topic,\n messages: [{ value: EnvelopeCodec.encode(envelope), headers, timestamp: String(envelope.meta.created_at) }],\n });\n }\n}\n\n// --- DLQ redrive IO (ADR-0026, §6.5 \"Record key on republish\") -------------------\n\n/**\n * A DLQ record reserved for {@link KafkaRedriveIO}: the structural shape `@babelqueue/core`'s\n * `RedriveMessage` expects (`body` + `ack`), plus the consumed record's `key` — `null`/absent for a\n * keyless record, a (possibly empty) `Buffer` otherwise.\n */\nexport interface KafkaRedriveMessage {\n body: string;\n key?: Buffer | null;\n ack(): Promise<void>;\n}\n\n/** Options for {@link KafkaRedriveIO}. */\nexport interface KafkaRedriveIOOptions {\n /** The producer used for every write the redrive makes (redrive write + restore to the DLQ). */\n producer: KafkaProducerClient;\n /**\n * Reserve the next record from `queue` (the DLQ topic), or `null` when it is empty. Kafka consumption\n * is push-based in KafkaJS, so the caller supplies the pull (a bounded fetch with manual commit) and\n * its `ack` commits the offset. Return the record's `key` so the redrive can carry it.\n */\n pop(queue: string): Promise<KafkaRedriveMessage | null>;\n}\n\n/**\n * A `@babelqueue/core` `RedriveIO` over Kafka that carries the DLQ record key byte-for-byte through the\n * redrive (contract §6.5): the redrive write back to the work topic and the restore write back to the\n * DLQ both go through {@link KafkaRedriveIO.publishFrom}, which copies the reserved record's key — a null\n * key stays null (no key on the record), an empty key stays empty — and never synthesises one. Partition\n * equality is not guaranteed (client partitioners differ). The §6.3 header projection is rebuilt from\n * the body being written (`bq-attempts` therefore reads 0 for a reset envelope); a body that is not a\n * canonical envelope (an undecodable record being restored) is written verbatim with no `bq-*` headers.\n *\n * Plain {@link KafkaRedriveIO.publish} writes a keyless record. `publishWithHeaders` is not offered, so\n * `bypass` is a no-op on this transport (as in the Go reference's keyed path).\n */\nexport class KafkaRedriveIO {\n constructor(private readonly options: KafkaRedriveIOOptions) {}\n\n pop(queue: string): Promise<KafkaRedriveMessage | null> {\n return this.options.pop(queue);\n }\n\n /** Append an already-encoded body to `queue` as a keyless record. */\n async publish(queue: string, body: string): Promise<void> {\n await this.send(queue, body, null);\n }\n\n /** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */\n async publishFrom(queue: string, body: string, from: KafkaRedriveMessage): Promise<void> {\n await this.send(queue, body, from.key ?? null);\n }\n\n private async send(queue: string, body: string, key: Buffer | null): Promise<void> {\n const record: KafkaProducerMessage = { value: body };\n const decoded = EnvelopeCodec.decode(body);\n if (EnvelopeCodec.accepts(decoded)) {\n record.headers = kafkaHeaders(decoded);\n record.timestamp = String(decoded.meta.created_at);\n }\n if (key !== null) record.key = Buffer.from(key);\n await this.options.producer.send({ topic: queue, messages: [record] });\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw Kafka message, and the out-of-band\n * {@link HeaderCarrier} read from the record headers (empty when there are none). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer span\n * (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, message: KafkaIncomingMessage, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link KafkaConsumer}. */\nexport interface KafkaConsumerOptions {\n /** The producer used to republish retry/DLQ records (required for retry/DLQ). */\n producer?: KafkaProducerClient;\n /** The retry/DLQ topology; enables per-record retry, delay, and dead-lettering. */\n retryTopics?: RetryTopics;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a poison record, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload) => void;\n}\n\n/**\n * Consumes a Kafka work topic in process-then-commit mode: each record is decoded, validated,\n * routed to the handler for its URN (read from `bq-job`), and its offset committed only after\n * the handler returns. A throwing handler republishes to a `<topic>.retry.<n>` tier with\n * `bq-attempts + 1`, then commits; terminal failures go to `<topic>.dlq` with a `dead_letter`\n * block. The `bq-attempts` header is the authoritative counter (the body is the fallback).\n */\nexport class KafkaConsumer {\n private readonly maxTries: number;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly consumer: KafkaConsumerClient,\n private readonly handlers: BabelHandlers,\n private readonly options: KafkaConsumerOptions = {},\n ) {\n this.maxTries = options.maxTries ?? 3;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Start the manual-commit consume loop (`autoCommit: false`); resolves when the consumer stops. */\n async run(): Promise<void> {\n await this.consumer.run({ autoCommit: false, eachMessage: (payload) => this.handle(payload) });\n }\n\n /** Route + settle one record (process-then-commit). Exposed for testing. */\n async handle(payload: EachMessagePayload): Promise<void> {\n const { message } = payload;\n\n // Version gate (§6.7): decided on the header alone, before the value is decoded.\n const unsupported = unsupportedSchemaVersion(message);\n if (unsupported !== undefined) {\n const rejected = new BabelQueueError(\n `Rejected a record from Kafka with unsupported bq-schema-version \"${unsupported}\" (supported: ${SCHEMA_VERSION}).`,\n );\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(rejected, null, payload);\n }\n return;\n }\n\n const raw = message.value ? message.value.toString(\"utf8\") : \"\";\n\n let envelope: IncomingEnvelope;\n try {\n envelope = this.reconcile(EnvelopeCodec.decode(raw), message);\n } catch (decodeError) {\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(decodeError, null, payload);\n }\n return;\n }\n\n if (!EnvelopeCodec.accepts(envelope)) {\n // A non-conformant / poison envelope may be missing the fields needed to annotate, so\n // forward the raw record to the DLQ rather than building a dead_letter block.\n const rejected = new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Kafka.\");\n try {\n await this.deadLetterRaw(payload);\n await this.commit(payload);\n } finally {\n this.report(rejected, envelope, payload);\n }\n return;\n }\n\n const urn = headerString(message.headers, \"bq-job\") ?? EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(payload, envelope, urn);\n return;\n }\n\n try {\n await handler(envelope, message, headersOf(message));\n await this.commit(payload);\n } catch (error) {\n try {\n await this.retryOrDeadLetter(payload, envelope as Envelope, error);\n await this.commit(payload);\n } finally {\n this.report(error, envelope, payload);\n }\n }\n }\n\n private reconcile(envelope: IncomingEnvelope, message: KafkaIncomingMessage): IncomingEnvelope {\n const body = typeof envelope.attempts === \"number\" ? envelope.attempts : 0;\n return { ...envelope, attempts: headerInt(message.headers, \"bq-attempts\", body) };\n }\n\n private async onUnknownUrn(payload: EachMessagePayload, envelope: IncomingEnvelope, urn: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope as Envelope, payload, \"unknown_urn\");\n await this.commit(payload);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.republishRetry(payload, envelope as Envelope);\n await this.commit(payload);\n return;\n default: {\n // FAIL: surface and do NOT commit — the record redelivers on the next poll.\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, payload);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(payload: EachMessagePayload, envelope: Envelope, error: unknown): Promise<void> {\n const hasTiers = this.options.retryTopics?.hasTiers() ?? false;\n const hasDlq = (this.options.retryTopics?.dlqTopic ?? null) !== null;\n if (!hasTiers && !hasDlq) {\n void error; // reported via onError afterwards; Kafka has nowhere to put the failure\n throw new BabelQueueError(\"Kafka per-record retry requires retry topics and/or a DLQ; neither is configured.\");\n }\n if (hasTiers && (envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.republishRetry(payload, envelope);\n } else {\n await this.deadLetter(envelope, payload, \"failed\", error);\n }\n }\n\n private async republishRetry(payload: EachMessagePayload, envelope: Envelope): Promise<void> {\n const topics = this.options.retryTopics;\n if (!topics || !topics.hasTiers()) {\n throw new BabelQueueError(\"Kafka retry/release requires retry topics; none are configured.\");\n }\n const tier = topics.tierForAttempt(envelope.attempts ?? 0);\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n const headers = kafkaHeaders(bumped);\n headers[\"bq-delay\"] = String(tier.delayMs);\n headers[\"bq-original-topic\"] = this.originalTopic(payload);\n await this.produce(tier.topic, bumped, headers, payload.message.key);\n }\n\n private async deadLetter(envelope: Envelope, payload: EachMessagePayload, reason: string, error?: unknown): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return; // dead-lettering disabled → degrade to commit-and-drop\n const original = this.originalTopic(payload);\n const annotated = annotate(envelope, reason, original, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n const headers = kafkaHeaders(annotated);\n headers[\"bq-original-topic\"] = original;\n await this.produce(dlq, annotated, headers, payload.message.key);\n }\n\n private async deadLetterRaw(payload: EachMessagePayload): Promise<void> {\n const dlq = this.options.retryTopics?.dlqTopic ?? null;\n if (!dlq) return;\n this.requireProducer();\n const { message } = payload;\n // Forward the original record verbatim (value, key, headers) so the DLQ copy keeps its provenance\n // (Java reference). Only the timestamp is re-stamped.\n const forwarded: KafkaProducerMessage = {\n value: message.value ?? Buffer.alloc(0),\n // KafkaJS accepts Buffer / array header values; the published structural type stays string-only.\n headers: copyHeaders(message.headers) as unknown as KafkaProducerMessage[\"headers\"],\n timestamp: String(Date.now()),\n };\n if (message.key !== undefined && message.key !== null) forwarded.key = Buffer.from(message.key);\n await this.options.producer!.send({ topic: dlq, messages: [forwarded] });\n }\n\n /**\n * Republish an envelope. The consumed record's key is carried over byte-for-byte (an empty key stays\n * empty); a null/absent key is left off the record, never synthesised. Partition equality is not\n * guaranteed.\n */\n private async produce(\n topic: string,\n envelope: Envelope,\n headers: { [key: string]: string },\n key?: Buffer | null,\n ): Promise<void> {\n this.requireProducer();\n const record: KafkaProducerMessage = { value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };\n if (key !== undefined && key !== null) record.key = Buffer.from(key);\n await this.options.producer!.send({ topic, messages: [record] });\n }\n\n private async commit(payload: EachMessagePayload): Promise<void> {\n await this.consumer.commitOffsets([\n { topic: payload.topic, partition: payload.partition, offset: (Number(payload.message.offset) + 1).toString() },\n ]);\n }\n\n private originalTopic(payload: EachMessagePayload): string {\n return headerString(payload.message.headers, \"bq-original-topic\") ?? payload.topic;\n }\n\n private requireProducer(): void {\n if (!this.options.producer) {\n throw new BabelQueueError(\"This Kafka consumer needs a producer to republish (retry/DLQ).\");\n }\n }\n\n /**\n * Hand a failure to the user's `onError` callback. Callers invoke this only AFTER the record has been\n * settled (dead-lettered / retried and committed). A callback that throws synchronously, or returns a\n * promise that later rejects (an `async` function is assignable to the `void` callback type), is\n * swallowed here so user code can never bypass settlement or stop the consumer.\n */\n private report(error: unknown, envelope: IncomingEnvelope | null, payload: EachMessagePayload): void {\n try {\n const returned: unknown = this.options.onError?.(error, envelope, payload);\n if (isThenable(returned)) {\n // An async callback's rejection would otherwise surface as an unhandled rejection.\n returned.then(undefined, () => undefined);\n }\n } catch {\n // user callback failure must not escape the consume loop\n }\n }\n}\n","/**\n * Pure, dependency-free helpers for the out-of-band transport-header seam (ADR-0028) — the Node\n * adapter mirror of Go's `sanitizeHeaders` and PHP's `Support\\Headers`. They fold an injected\n * {@link HeaderCarrier} (e.g. a W3C `traceparent`) onto a transport's metadata channel **without\n * clobbering** the contract headers it already carries.\n *\n * Headers ride beside the frozen wire envelope (GR-1), never inside it.\n */\n\nimport type { HeaderCarrier } from \"@babelqueue/core\";\n\n/**\n * Copy `headers`, dropping blank keys and blank values. Returns an empty object when nothing\n * survives. A nullish input yields an empty object.\n */\nexport function sanitizeHeaders(headers: HeaderCarrier | null | undefined): HeaderCarrier {\n const out: HeaderCarrier = {};\n if (!headers) return out;\n for (const key of Object.keys(headers)) {\n const value = headers[key];\n if (key === \"\" || value == null || value === \"\") continue;\n out[key] = value;\n }\n return out;\n}\n\n/**\n * Merge out-of-band `extra` headers onto an already-built `base` header map **without clobbering**\n * the contract headers in `base` (they win a key collision) and dropping blanks. The result is\n * `base`, mutated in place.\n */\nexport function mergeInto(base: { [key: string]: string }, extra: HeaderCarrier | null | undefined): { [key: string]: string } {\n for (const [key, value] of Object.entries(sanitizeHeaders(extra))) {\n if (key in base) continue; // the contract header already there wins\n base[key] = value;\n }\n return base;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@babelqueue/kafka",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Apache Kafka adapter for BabelQueue — canonical-envelope publisher and a URN-routed, process-then-commit consumer (SDK-owned retry topics + DLQ).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"babelqueue",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"test": "node --import tsx --test test/*.test.ts"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@babelqueue/core": "^1.
|
|
54
|
+
"@babelqueue/core": "^1.9.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"kafkajs": "^2.0.0"
|