@babelqueue/kafka 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,14 @@ normal decode path; any other value is compared as-is (never trimmed) to `"1"`.
67
67
  not match is rejected undecoded as poison: reported to `onError`, forwarded raw to `<topic>.dlq`
68
68
  (when configured) and its offset committed.
69
69
 
70
+ **Poison records (§6.5).** A body that is not a decodable BabelQueue envelope - the bodies the Go
71
+ SDK's typed `Decode` rejects, except that member names are matched case-sensitively as the contract spells them (invalid JSON, a non-object root other than `null`, a wrongly typed member such as
72
+ `meta.created_at: "2026"`, `meta.id: 5`, a fractional or string `attempts`, or `meta.schema_version: "1"`) - is reported to `onError`, written to
73
+ `<topic>.dlq` wrapped like the Go SDK does - a body that is one JSON object keeps its keys, anything else
74
+ becomes `{"raw": "<body>"}`, plus a `dead_letter` block with `reason: "poison"` - with the record key
75
+ preserved byte for byte, and its offset committed. A well-typed envelope that is merely non-conformant (for
76
+ example an empty `trace_id`, or a non-string `urn` alias) is not poison: like Go, it is routed by its URN.
77
+
70
78
  ## Contract mapping (§6)
71
79
 
72
80
  | Envelope | Apache Kafka |
package/dist/index.cjs CHANGED
@@ -30,6 +30,266 @@ __export(index_exports, {
30
30
  module.exports = __toCommonJS(index_exports);
31
31
  var import_core = require("@babelqueue/core");
32
32
 
33
+ // src/envelope-shape.ts
34
+ var MAX_DEPTH = 1e4;
35
+ var INT64_MIN = -(2n ** 63n);
36
+ var INT64_MAX = 2n ** 63n - 1n;
37
+ var META = {
38
+ id: "string",
39
+ queue: "string",
40
+ lang: "string",
41
+ schema_version: "integer",
42
+ created_at: "integer"
43
+ };
44
+ var DEAD_LETTER = {
45
+ reason: "string",
46
+ error: "string",
47
+ exception: "string",
48
+ original_queue: "string",
49
+ lang: "string",
50
+ failed_at: "integer",
51
+ attempts: "integer"
52
+ };
53
+ var TOP = {
54
+ job: "string",
55
+ trace_id: "string",
56
+ attempts: "integer",
57
+ data: "data",
58
+ meta: "object",
59
+ dead_letter: "object"
60
+ };
61
+ var Reject = class extends Error {
62
+ };
63
+ function reject(message) {
64
+ throw new Reject(message);
65
+ }
66
+ var isWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
67
+ var isDigit = (c) => c !== void 0 && c >= "0" && c <= "9";
68
+ var Scanner = class {
69
+ constructor(s) {
70
+ this.s = s;
71
+ }
72
+ s;
73
+ i = 0;
74
+ /** Containers currently open around the cursor (Go counts nesting over the whole document, not per subtree). */
75
+ depth = 0;
76
+ /** Why the body is undecodable, or `null` when Go's typed decode accepts it. */
77
+ check() {
78
+ try {
79
+ this.ws();
80
+ if (this.s.startsWith("null", this.i)) {
81
+ this.i += 4;
82
+ } else if (this.s[this.i] === "{") {
83
+ this.members(TOP, "");
84
+ } else {
85
+ reject("expected a JSON object.");
86
+ }
87
+ this.ws();
88
+ if (this.i < this.s.length) reject("unexpected trailing data after the JSON value.");
89
+ return null;
90
+ } catch (error) {
91
+ if (error instanceof Reject) return error.message;
92
+ throw error;
93
+ }
94
+ }
95
+ ws() {
96
+ while (isWs(this.s[this.i])) this.i++;
97
+ }
98
+ /** Reads an object whose members are checked against `kinds`; the cursor sits on its `{`. */
99
+ members(kinds, path) {
100
+ this.i++;
101
+ this.depth++;
102
+ this.ws();
103
+ if (this.s[this.i] === "}") {
104
+ this.i++;
105
+ this.depth--;
106
+ return;
107
+ }
108
+ for (; ; ) {
109
+ this.ws();
110
+ if (this.s[this.i] !== '"') reject("expected an object key.");
111
+ const key = this.string();
112
+ this.ws();
113
+ if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
114
+ this.i++;
115
+ this.ws();
116
+ const kind = Object.prototype.hasOwnProperty.call(kinds, key) ? kinds[key] : void 0;
117
+ if (kind === void 0) {
118
+ this.skip(false);
119
+ } else {
120
+ this.typed(kind, path + key, key);
121
+ }
122
+ this.ws();
123
+ const c = this.s[this.i++];
124
+ if (c === "}") {
125
+ this.depth--;
126
+ return;
127
+ }
128
+ if (c !== ",") reject("expected ',' or '}' in an object.");
129
+ }
130
+ }
131
+ typed(kind, name, lower) {
132
+ const c = this.s[this.i];
133
+ if (this.s.startsWith("null", this.i)) {
134
+ this.i += 4;
135
+ return;
136
+ }
137
+ switch (kind) {
138
+ case "string":
139
+ if (c !== '"') reject(`'${name}' must be a string.`);
140
+ this.string();
141
+ return;
142
+ case "integer": {
143
+ if (c !== "-" && !isDigit(c)) reject(`'${name}' must be an integer.`);
144
+ const token = this.number();
145
+ if (!/^-?[0-9]+$/.test(token)) reject(`'${name}' must be an integer.`);
146
+ const n = BigInt(token);
147
+ if (n < INT64_MIN || n > INT64_MAX) reject(`'${name}' does not fit an int64.`);
148
+ return;
149
+ }
150
+ case "data":
151
+ if (c !== "{") reject(`'${name}' must be a JSON object.`);
152
+ this.skip(true);
153
+ return;
154
+ case "object":
155
+ if (c !== "{") reject(`'${name}' must be a JSON object.`);
156
+ this.members(lower === "meta" ? META : DEAD_LETTER, name + ".");
157
+ return;
158
+ }
159
+ }
160
+ /** Reads a string literal and returns its decoded value; the cursor sits on the opening quote. */
161
+ string() {
162
+ const start = this.i++;
163
+ for (; ; ) {
164
+ const c = this.s[this.i];
165
+ if (c === void 0) reject("unterminated string.");
166
+ if (c === '"') break;
167
+ if (c < " ") reject("control character in a string.");
168
+ if (c === "\\") {
169
+ const e = this.s[this.i + 1];
170
+ if (e === "u") {
171
+ if (!/^[0-9a-fA-F]{4}$/.test(this.s.slice(this.i + 2, this.i + 6))) reject("invalid \\u escape.");
172
+ this.i += 6;
173
+ continue;
174
+ }
175
+ if (e === void 0 || !'"\\/bfnrt'.includes(e)) reject("invalid escape in a string.");
176
+ this.i += 2;
177
+ continue;
178
+ }
179
+ this.i++;
180
+ }
181
+ this.i++;
182
+ return JSON.parse(this.s.slice(start, this.i));
183
+ }
184
+ /** Reads a number literal per the JSON grammar and returns its text. */
185
+ number() {
186
+ const start = this.i;
187
+ if (this.s[this.i] === "-") this.i++;
188
+ if (this.s[this.i] === "0") {
189
+ this.i++;
190
+ } else if (isDigit(this.s[this.i])) {
191
+ while (isDigit(this.s[this.i])) this.i++;
192
+ } else {
193
+ reject("invalid number.");
194
+ }
195
+ if (this.s[this.i] === ".") {
196
+ this.i++;
197
+ if (!isDigit(this.s[this.i])) reject("invalid number.");
198
+ while (isDigit(this.s[this.i])) this.i++;
199
+ }
200
+ if (this.s[this.i] === "e" || this.s[this.i] === "E") {
201
+ this.i++;
202
+ if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
203
+ if (!isDigit(this.s[this.i])) reject("invalid number.");
204
+ while (isDigit(this.s[this.i])) this.i++;
205
+ }
206
+ return this.s.slice(start, this.i);
207
+ }
208
+ /** Skips one value iteratively (no recursion, so depth cannot overflow the stack); optionally requires finite numbers. */
209
+ skip(finite) {
210
+ const stack = [];
211
+ let expectValue = true;
212
+ for (; ; ) {
213
+ this.ws();
214
+ const c = this.s[this.i];
215
+ if (expectValue) {
216
+ if (c === "{" || c === "[") {
217
+ if (this.depth + stack.length >= MAX_DEPTH) reject("exceeded the maximum nesting depth.");
218
+ stack.push(c);
219
+ this.i++;
220
+ this.ws();
221
+ if (this.s[this.i] === (c === "{" ? "}" : "]")) {
222
+ this.i++;
223
+ stack.pop();
224
+ expectValue = false;
225
+ } else if (c === "{") {
226
+ this.key();
227
+ }
228
+ continue;
229
+ }
230
+ if (c === '"') {
231
+ this.string();
232
+ } else if (c === "-" || isDigit(c)) {
233
+ const token = this.number();
234
+ if (finite && !Number.isFinite(Number(token))) reject("a number in 'data' is not a finite double.");
235
+ } else if (this.s.startsWith("true", this.i)) {
236
+ this.i += 4;
237
+ } else if (this.s.startsWith("false", this.i)) {
238
+ this.i += 5;
239
+ } else if (this.s.startsWith("null", this.i)) {
240
+ this.i += 4;
241
+ } else {
242
+ reject("invalid JSON value.");
243
+ }
244
+ expectValue = false;
245
+ }
246
+ if (stack.length === 0) return;
247
+ const top = stack[stack.length - 1];
248
+ this.ws();
249
+ const d = this.s[this.i++];
250
+ if (d === ",") {
251
+ if (top === "{") this.key();
252
+ expectValue = true;
253
+ } else if (d === (top === "{" ? "}" : "]")) {
254
+ stack.pop();
255
+ if (stack.length === 0) return;
256
+ } else {
257
+ reject("expected ',' or the end of a container.");
258
+ }
259
+ }
260
+ }
261
+ /** Reads `"key" :` inside a skipped object. */
262
+ key() {
263
+ this.ws();
264
+ if (this.s[this.i] !== '"') reject("expected an object key.");
265
+ this.string();
266
+ this.ws();
267
+ if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
268
+ this.i++;
269
+ }
270
+ };
271
+ function envelopeViolation(raw) {
272
+ return new Scanner(raw).check();
273
+ }
274
+ function normalizedDecimal(token) {
275
+ const m = /^(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(token);
276
+ if (!m) return token;
277
+ const fraction = m[3] ?? "";
278
+ let digits = (m[2] + fraction).replace(/^0+/, "");
279
+ if (digits === "") return "0";
280
+ let exponent = Number(m[4] ?? "0") - fraction.length;
281
+ const trimmed = digits.replace(/0+$/, "");
282
+ exponent += digits.length - trimmed.length;
283
+ digits = trimmed;
284
+ return `${m[1]}${digits}e${exponent}`;
285
+ }
286
+ function isLossyNumberToken(token) {
287
+ const n = Number(token);
288
+ if (!Number.isFinite(n)) return true;
289
+ if (/^-?[0-9]+$/.test(token) && Number.isSafeInteger(n)) return false;
290
+ return normalizedDecimal(token) !== normalizedDecimal(String(n));
291
+ }
292
+
33
293
  // src/headers.ts
34
294
  function sanitizeHeaders(headers) {
35
295
  const out = {};
@@ -183,19 +443,106 @@ var KafkaPublisher = class _KafkaPublisher {
183
443
  var KafkaRedriveIO = class {
184
444
  constructor(options) {
185
445
  this.options = options;
446
+ const consumer = options.consumer;
447
+ if (consumer) {
448
+ this.rewind = async (from) => {
449
+ if (from.topic === void 0 || from.partition === void 0 || from.offset === void 0) {
450
+ throw new Error("kafka redrive: cannot rewind a record without topic, partition and offset");
451
+ }
452
+ const pending = new Set(this.inFlight);
453
+ if (!this.acknowledged.has(from)) pending.add(from);
454
+ const first = /* @__PURE__ */ new Map();
455
+ for (const m of pending) {
456
+ if (m.topic === void 0 || m.partition === void 0 || m.offset === void 0) continue;
457
+ const key = `${m.topic}\0${m.partition}`;
458
+ const offset = BigInt(m.offset);
459
+ const seen = first.get(key);
460
+ if (!seen || offset < seen.offset) first.set(key, { topic: m.topic, partition: m.partition, offset });
461
+ }
462
+ this.inFlight.clear();
463
+ const failures = [];
464
+ for (const { topic, partition, offset } of first.values()) {
465
+ try {
466
+ await consumer.seek({ topic, partition, offset: offset.toString() });
467
+ } catch (err) {
468
+ failures.push(err);
469
+ }
470
+ }
471
+ if (failures.length === 1) throw failures[0];
472
+ if (failures.length > 1) throw new AggregateError(failures, "kafka redrive: seeking several partitions back failed");
473
+ };
474
+ }
186
475
  }
187
476
  options;
188
- pop(queue) {
189
- return this.options.pop(queue);
477
+ /** Seek the consumer back to every unacknowledged record (`from` included unless it was acknowledged already); defined only when a `consumer` was supplied. */
478
+ rewind;
479
+ /** Records handed out by {@link KafkaRedriveIO.pop} that carry a position and are not acknowledged yet. */
480
+ inFlight = /* @__PURE__ */ new Set();
481
+ /** Records handed out by {@link KafkaRedriveIO.pop} that were acknowledged since; a rewind never re-reads them. */
482
+ acknowledged = /* @__PURE__ */ new WeakSet();
483
+ async pop(queue) {
484
+ const message = await this.options.pop(queue);
485
+ if (message && this.rewind && message.topic !== void 0 && message.partition !== void 0 && message.offset !== void 0) {
486
+ const tracked = {
487
+ body: message.body,
488
+ key: message.key,
489
+ value: message.value,
490
+ headers: message.headers,
491
+ topic: message.topic,
492
+ partition: message.partition,
493
+ offset: message.offset,
494
+ ack: async () => {
495
+ await message.ack();
496
+ this.inFlight.delete(tracked);
497
+ this.acknowledged.add(tracked);
498
+ }
499
+ };
500
+ this.inFlight.add(tracked);
501
+ return tracked;
502
+ }
503
+ return message;
190
504
  }
191
505
  /** Append an already-encoded body to `queue` as a keyless record. */
192
506
  async publish(queue, body) {
193
507
  await this.send(queue, body, null);
194
508
  }
195
- /** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
509
+ /**
510
+ * Append `body` to `queue` on behalf of the reserved record `from`. Two modes (Go/Java/.NET parity):
511
+ *
512
+ * - **Restore** — `queue` is the topic `from` was consumed from and `body` is the body `pop` returned for
513
+ * it (redrive writing an unchanged DLQ record back). The original record is re-written verbatim: its
514
+ * value bytes (`from.value`), headers (`from.headers`) and key, with a fresh timestamp so the restored
515
+ * copy does not age out of the DLQ early (never `meta.created_at`). A message that carries no `value`
516
+ * re-writes `body`; one that carries no `headers` falls back to the `bq-*` projection of a decodable body.
517
+ * Needs `from.topic` to be set; without it a restore is indistinguishable from a republish.
518
+ * Header values the consumer decoded as `null` (a header with a -1 length) are not re-written: the
519
+ * restore copy goes through `copyHeaders`, which drops them (a deliberate bound; the Go reference
520
+ * writes them as they are).
521
+ * - **Republish** — any other queue/body (a redrive to the work topic): the `bq-*` header projection and
522
+ * the `meta.created_at` timestamp, plus the key of `from` byte-for-byte (§6.5).
523
+ */
196
524
  async publishFrom(queue, body, from) {
525
+ if (from.topic !== void 0 && queue === from.topic && body === from.body) {
526
+ await this.restoreVerbatim(queue, body, from);
527
+ return;
528
+ }
197
529
  await this.send(queue, body, from.key ?? null);
198
530
  }
531
+ async restoreVerbatim(queue, body, from) {
532
+ const record = {
533
+ // A tombstone stays a tombstone; KafkaJS accepts a null value although the structural type does not.
534
+ value: from.value === void 0 ? body : from.value,
535
+ timestamp: String(Date.now())
536
+ };
537
+ if (from.headers !== void 0) {
538
+ record.headers = copyHeaders(from.headers);
539
+ } else {
540
+ const decoded = import_core.EnvelopeCodec.decode(body);
541
+ if (import_core.EnvelopeCodec.accepts(decoded)) record.headers = kafkaHeaders(decoded);
542
+ }
543
+ if (from.key !== void 0 && from.key !== null) record.key = Buffer.from(from.key);
544
+ await this.options.producer.send({ topic: queue, messages: [record] });
545
+ }
199
546
  async send(queue, body, key) {
200
547
  const record = { value: body };
201
548
  const decoded = import_core.EnvelopeCodec.decode(body);
@@ -207,6 +554,66 @@ var KafkaRedriveIO = class {
207
554
  await this.options.producer.send({ topic: queue, messages: [record] });
208
555
  }
209
556
  };
557
+ function decodeFailure(raw) {
558
+ const reason = envelopeViolation(raw);
559
+ return reason === null ? null : new import_core.BabelQueueError(`Undecodable BabelQueue body: ${reason}`);
560
+ }
561
+ function hasLossyNumber(raw) {
562
+ let i = 0;
563
+ while (i < raw.length) {
564
+ const c = raw[i];
565
+ if (c === '"') {
566
+ i++;
567
+ while (i < raw.length && raw[i] !== '"') i += raw[i] === "\\" ? 2 : 1;
568
+ i++;
569
+ } else if (c === "-" || c >= "0" && c <= "9") {
570
+ let j = i + 1;
571
+ while (j < raw.length && /[0-9.eE+-]/.test(raw[j])) j++;
572
+ if (isLossyNumberToken(raw.slice(i, j))) return true;
573
+ i = j;
574
+ } else {
575
+ i++;
576
+ }
577
+ }
578
+ return false;
579
+ }
580
+ function poisonRecord(raw, cause, originalQueue, attempts, keepMembers = true) {
581
+ let record = { raw };
582
+ try {
583
+ if (!keepMembers) throw new Error("carry the body verbatim");
584
+ const parsed = JSON.parse(raw);
585
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && !hasLossyNumber(raw)) {
586
+ record = parsed;
587
+ }
588
+ } catch {
589
+ }
590
+ delete record.dead_letter;
591
+ record.dead_letter = (0, import_core.annotate)({ attempts }, "poison", originalQueue, {
592
+ attempts,
593
+ error: cause instanceof Error ? cause.message : null,
594
+ exception: cause instanceof Error ? cause.name : null
595
+ }).dead_letter;
596
+ return record;
597
+ }
598
+ function poisonHeaders(record, originalQueue, attempts) {
599
+ const headers = {};
600
+ const str = (v) => typeof v === "string" && v !== "" ? v : void 0;
601
+ const meta = record.meta !== null && typeof record.meta === "object" ? record.meta : {};
602
+ const job = str(record.job) ?? str(record.urn);
603
+ if (job) headers["bq-job"] = job;
604
+ const trace = str(record.trace_id);
605
+ if (trace) headers["bq-trace-id"] = trace;
606
+ const id = str(meta.id);
607
+ if (id) headers["bq-message-id"] = id;
608
+ if (meta.schema_version != null && typeof meta.schema_version !== "object") {
609
+ headers["bq-schema-version"] = String(meta.schema_version);
610
+ }
611
+ const lang = str(meta.lang);
612
+ if (lang) headers["bq-source-lang"] = lang;
613
+ headers["bq-attempts"] = String(attempts);
614
+ headers["bq-original-topic"] = originalQueue;
615
+ return headers;
616
+ }
210
617
  var KafkaConsumer = class {
211
618
  constructor(consumer, handlers, options = {}) {
212
619
  this.consumer = consumer;
@@ -241,26 +648,16 @@ var KafkaConsumer = class {
241
648
  return;
242
649
  }
243
650
  const raw = message.value ? message.value.toString("utf8") : "";
651
+ const undecodable = decodeFailure(raw);
652
+ if (undecodable) {
653
+ await this.settlePoison(payload, raw, undecodable, null);
654
+ return;
655
+ }
244
656
  let envelope;
245
657
  try {
246
658
  envelope = this.reconcile(import_core.EnvelopeCodec.decode(raw), message);
247
659
  } catch (decodeError) {
248
- try {
249
- await this.deadLetterRaw(payload);
250
- await this.commit(payload);
251
- } finally {
252
- this.report(decodeError, null, payload);
253
- }
254
- return;
255
- }
256
- if (!import_core.EnvelopeCodec.accepts(envelope)) {
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
- }
660
+ await this.settlePoison(payload, raw, decodeError, null);
264
661
  return;
265
662
  }
266
663
  const urn = headerString(message.headers, "bq-job") ?? import_core.EnvelopeCodec.urn(envelope);
@@ -343,6 +740,32 @@ var KafkaConsumer = class {
343
740
  headers["bq-original-topic"] = original;
344
741
  await this.produce(dlq, annotated, headers, payload.message.key);
345
742
  }
743
+ /** Dead-letter a poison record (wrapped), commit it, then report: settle first so `onError` cannot bypass it. */
744
+ async settlePoison(payload, raw, cause, envelope) {
745
+ try {
746
+ await this.deadLetterPoison(payload, raw, cause);
747
+ await this.commit(payload);
748
+ } finally {
749
+ this.report(cause, envelope, payload);
750
+ }
751
+ }
752
+ async deadLetterPoison(payload, raw, cause) {
753
+ const dlq = this.options.retryTopics?.dlqTopic ?? null;
754
+ if (!dlq) return;
755
+ this.requireProducer();
756
+ const original = this.originalTopic(payload);
757
+ const attempts = Math.max(headerInt(payload.message.headers, "bq-attempts", 0), 0);
758
+ let record = poisonRecord(raw, cause, original, attempts);
759
+ let json;
760
+ try {
761
+ json = JSON.stringify(record);
762
+ } catch {
763
+ record = poisonRecord(raw, cause, original, attempts, false);
764
+ json = JSON.stringify(record);
765
+ }
766
+ const headers = poisonHeaders(record, original, attempts);
767
+ await this.produceRaw(dlq, json, headers, payload.message.key);
768
+ }
346
769
  async deadLetterRaw(payload) {
347
770
  const dlq = this.options.retryTopics?.dlqTopic ?? null;
348
771
  if (!dlq) return;
@@ -363,8 +786,11 @@ var KafkaConsumer = class {
363
786
  * guaranteed.
364
787
  */
365
788
  async produce(topic, envelope, headers, key) {
789
+ await this.produceRaw(topic, import_core.EnvelopeCodec.encode(envelope), headers, key);
790
+ }
791
+ async produceRaw(topic, value, headers, key) {
366
792
  this.requireProducer();
367
- const record = { value: import_core.EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };
793
+ const record = { value, headers, timestamp: String(Date.now()) };
368
794
  if (key !== void 0 && key !== null) record.key = Buffer.from(key);
369
795
  await this.options.producer.send({ topic, messages: [record] });
370
796
  }