@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/dist/index.js CHANGED
@@ -1,6 +1,266 @@
1
1
  // src/index.ts
2
2
  import { annotate, BabelQueueError, EnvelopeCodec, SCHEMA_VERSION, UnknownUrnError, UnknownUrnStrategy } from "@babelqueue/core";
3
3
 
4
+ // src/envelope-shape.ts
5
+ var MAX_DEPTH = 1e4;
6
+ var INT64_MIN = -(2n ** 63n);
7
+ var INT64_MAX = 2n ** 63n - 1n;
8
+ var META = {
9
+ id: "string",
10
+ queue: "string",
11
+ lang: "string",
12
+ schema_version: "integer",
13
+ created_at: "integer"
14
+ };
15
+ var DEAD_LETTER = {
16
+ reason: "string",
17
+ error: "string",
18
+ exception: "string",
19
+ original_queue: "string",
20
+ lang: "string",
21
+ failed_at: "integer",
22
+ attempts: "integer"
23
+ };
24
+ var TOP = {
25
+ job: "string",
26
+ trace_id: "string",
27
+ attempts: "integer",
28
+ data: "data",
29
+ meta: "object",
30
+ dead_letter: "object"
31
+ };
32
+ var Reject = class extends Error {
33
+ };
34
+ function reject(message) {
35
+ throw new Reject(message);
36
+ }
37
+ var isWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
38
+ var isDigit = (c) => c !== void 0 && c >= "0" && c <= "9";
39
+ var Scanner = class {
40
+ constructor(s) {
41
+ this.s = s;
42
+ }
43
+ s;
44
+ i = 0;
45
+ /** Containers currently open around the cursor (Go counts nesting over the whole document, not per subtree). */
46
+ depth = 0;
47
+ /** Why the body is undecodable, or `null` when Go's typed decode accepts it. */
48
+ check() {
49
+ try {
50
+ this.ws();
51
+ if (this.s.startsWith("null", this.i)) {
52
+ this.i += 4;
53
+ } else if (this.s[this.i] === "{") {
54
+ this.members(TOP, "");
55
+ } else {
56
+ reject("expected a JSON object.");
57
+ }
58
+ this.ws();
59
+ if (this.i < this.s.length) reject("unexpected trailing data after the JSON value.");
60
+ return null;
61
+ } catch (error) {
62
+ if (error instanceof Reject) return error.message;
63
+ throw error;
64
+ }
65
+ }
66
+ ws() {
67
+ while (isWs(this.s[this.i])) this.i++;
68
+ }
69
+ /** Reads an object whose members are checked against `kinds`; the cursor sits on its `{`. */
70
+ members(kinds, path) {
71
+ this.i++;
72
+ this.depth++;
73
+ this.ws();
74
+ if (this.s[this.i] === "}") {
75
+ this.i++;
76
+ this.depth--;
77
+ return;
78
+ }
79
+ for (; ; ) {
80
+ this.ws();
81
+ if (this.s[this.i] !== '"') reject("expected an object key.");
82
+ const key = this.string();
83
+ this.ws();
84
+ if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
85
+ this.i++;
86
+ this.ws();
87
+ const kind = Object.prototype.hasOwnProperty.call(kinds, key) ? kinds[key] : void 0;
88
+ if (kind === void 0) {
89
+ this.skip(false);
90
+ } else {
91
+ this.typed(kind, path + key, key);
92
+ }
93
+ this.ws();
94
+ const c = this.s[this.i++];
95
+ if (c === "}") {
96
+ this.depth--;
97
+ return;
98
+ }
99
+ if (c !== ",") reject("expected ',' or '}' in an object.");
100
+ }
101
+ }
102
+ typed(kind, name, lower) {
103
+ const c = this.s[this.i];
104
+ if (this.s.startsWith("null", this.i)) {
105
+ this.i += 4;
106
+ return;
107
+ }
108
+ switch (kind) {
109
+ case "string":
110
+ if (c !== '"') reject(`'${name}' must be a string.`);
111
+ this.string();
112
+ return;
113
+ case "integer": {
114
+ if (c !== "-" && !isDigit(c)) reject(`'${name}' must be an integer.`);
115
+ const token = this.number();
116
+ if (!/^-?[0-9]+$/.test(token)) reject(`'${name}' must be an integer.`);
117
+ const n = BigInt(token);
118
+ if (n < INT64_MIN || n > INT64_MAX) reject(`'${name}' does not fit an int64.`);
119
+ return;
120
+ }
121
+ case "data":
122
+ if (c !== "{") reject(`'${name}' must be a JSON object.`);
123
+ this.skip(true);
124
+ return;
125
+ case "object":
126
+ if (c !== "{") reject(`'${name}' must be a JSON object.`);
127
+ this.members(lower === "meta" ? META : DEAD_LETTER, name + ".");
128
+ return;
129
+ }
130
+ }
131
+ /** Reads a string literal and returns its decoded value; the cursor sits on the opening quote. */
132
+ string() {
133
+ const start = this.i++;
134
+ for (; ; ) {
135
+ const c = this.s[this.i];
136
+ if (c === void 0) reject("unterminated string.");
137
+ if (c === '"') break;
138
+ if (c < " ") reject("control character in a string.");
139
+ if (c === "\\") {
140
+ const e = this.s[this.i + 1];
141
+ if (e === "u") {
142
+ if (!/^[0-9a-fA-F]{4}$/.test(this.s.slice(this.i + 2, this.i + 6))) reject("invalid \\u escape.");
143
+ this.i += 6;
144
+ continue;
145
+ }
146
+ if (e === void 0 || !'"\\/bfnrt'.includes(e)) reject("invalid escape in a string.");
147
+ this.i += 2;
148
+ continue;
149
+ }
150
+ this.i++;
151
+ }
152
+ this.i++;
153
+ return JSON.parse(this.s.slice(start, this.i));
154
+ }
155
+ /** Reads a number literal per the JSON grammar and returns its text. */
156
+ number() {
157
+ const start = this.i;
158
+ if (this.s[this.i] === "-") this.i++;
159
+ if (this.s[this.i] === "0") {
160
+ this.i++;
161
+ } else if (isDigit(this.s[this.i])) {
162
+ while (isDigit(this.s[this.i])) this.i++;
163
+ } else {
164
+ reject("invalid number.");
165
+ }
166
+ if (this.s[this.i] === ".") {
167
+ this.i++;
168
+ if (!isDigit(this.s[this.i])) reject("invalid number.");
169
+ while (isDigit(this.s[this.i])) this.i++;
170
+ }
171
+ if (this.s[this.i] === "e" || this.s[this.i] === "E") {
172
+ this.i++;
173
+ if (this.s[this.i] === "+" || this.s[this.i] === "-") this.i++;
174
+ if (!isDigit(this.s[this.i])) reject("invalid number.");
175
+ while (isDigit(this.s[this.i])) this.i++;
176
+ }
177
+ return this.s.slice(start, this.i);
178
+ }
179
+ /** Skips one value iteratively (no recursion, so depth cannot overflow the stack); optionally requires finite numbers. */
180
+ skip(finite) {
181
+ const stack = [];
182
+ let expectValue = true;
183
+ for (; ; ) {
184
+ this.ws();
185
+ const c = this.s[this.i];
186
+ if (expectValue) {
187
+ if (c === "{" || c === "[") {
188
+ if (this.depth + stack.length >= MAX_DEPTH) reject("exceeded the maximum nesting depth.");
189
+ stack.push(c);
190
+ this.i++;
191
+ this.ws();
192
+ if (this.s[this.i] === (c === "{" ? "}" : "]")) {
193
+ this.i++;
194
+ stack.pop();
195
+ expectValue = false;
196
+ } else if (c === "{") {
197
+ this.key();
198
+ }
199
+ continue;
200
+ }
201
+ if (c === '"') {
202
+ this.string();
203
+ } else if (c === "-" || isDigit(c)) {
204
+ const token = this.number();
205
+ if (finite && !Number.isFinite(Number(token))) reject("a number in 'data' is not a finite double.");
206
+ } else if (this.s.startsWith("true", this.i)) {
207
+ this.i += 4;
208
+ } else if (this.s.startsWith("false", this.i)) {
209
+ this.i += 5;
210
+ } else if (this.s.startsWith("null", this.i)) {
211
+ this.i += 4;
212
+ } else {
213
+ reject("invalid JSON value.");
214
+ }
215
+ expectValue = false;
216
+ }
217
+ if (stack.length === 0) return;
218
+ const top = stack[stack.length - 1];
219
+ this.ws();
220
+ const d = this.s[this.i++];
221
+ if (d === ",") {
222
+ if (top === "{") this.key();
223
+ expectValue = true;
224
+ } else if (d === (top === "{" ? "}" : "]")) {
225
+ stack.pop();
226
+ if (stack.length === 0) return;
227
+ } else {
228
+ reject("expected ',' or the end of a container.");
229
+ }
230
+ }
231
+ }
232
+ /** Reads `"key" :` inside a skipped object. */
233
+ key() {
234
+ this.ws();
235
+ if (this.s[this.i] !== '"') reject("expected an object key.");
236
+ this.string();
237
+ this.ws();
238
+ if (this.s[this.i] !== ":") reject("expected ':' after an object key.");
239
+ this.i++;
240
+ }
241
+ };
242
+ function envelopeViolation(raw) {
243
+ return new Scanner(raw).check();
244
+ }
245
+ function normalizedDecimal(token) {
246
+ const m = /^(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(token);
247
+ if (!m) return token;
248
+ const fraction = m[3] ?? "";
249
+ let digits = (m[2] + fraction).replace(/^0+/, "");
250
+ if (digits === "") return "0";
251
+ let exponent = Number(m[4] ?? "0") - fraction.length;
252
+ const trimmed = digits.replace(/0+$/, "");
253
+ exponent += digits.length - trimmed.length;
254
+ digits = trimmed;
255
+ return `${m[1]}${digits}e${exponent}`;
256
+ }
257
+ function isLossyNumberToken(token) {
258
+ const n = Number(token);
259
+ if (!Number.isFinite(n)) return true;
260
+ if (/^-?[0-9]+$/.test(token) && Number.isSafeInteger(n)) return false;
261
+ return normalizedDecimal(token) !== normalizedDecimal(String(n));
262
+ }
263
+
4
264
  // src/headers.ts
5
265
  function sanitizeHeaders(headers) {
6
266
  const out = {};
@@ -154,19 +414,106 @@ var KafkaPublisher = class _KafkaPublisher {
154
414
  var KafkaRedriveIO = class {
155
415
  constructor(options) {
156
416
  this.options = options;
417
+ const consumer = options.consumer;
418
+ if (consumer) {
419
+ this.rewind = async (from) => {
420
+ if (from.topic === void 0 || from.partition === void 0 || from.offset === void 0) {
421
+ throw new Error("kafka redrive: cannot rewind a record without topic, partition and offset");
422
+ }
423
+ const pending = new Set(this.inFlight);
424
+ if (!this.acknowledged.has(from)) pending.add(from);
425
+ const first = /* @__PURE__ */ new Map();
426
+ for (const m of pending) {
427
+ if (m.topic === void 0 || m.partition === void 0 || m.offset === void 0) continue;
428
+ const key = `${m.topic}\0${m.partition}`;
429
+ const offset = BigInt(m.offset);
430
+ const seen = first.get(key);
431
+ if (!seen || offset < seen.offset) first.set(key, { topic: m.topic, partition: m.partition, offset });
432
+ }
433
+ this.inFlight.clear();
434
+ const failures = [];
435
+ for (const { topic, partition, offset } of first.values()) {
436
+ try {
437
+ await consumer.seek({ topic, partition, offset: offset.toString() });
438
+ } catch (err) {
439
+ failures.push(err);
440
+ }
441
+ }
442
+ if (failures.length === 1) throw failures[0];
443
+ if (failures.length > 1) throw new AggregateError(failures, "kafka redrive: seeking several partitions back failed");
444
+ };
445
+ }
157
446
  }
158
447
  options;
159
- pop(queue) {
160
- return this.options.pop(queue);
448
+ /** Seek the consumer back to every unacknowledged record (`from` included unless it was acknowledged already); defined only when a `consumer` was supplied. */
449
+ rewind;
450
+ /** Records handed out by {@link KafkaRedriveIO.pop} that carry a position and are not acknowledged yet. */
451
+ inFlight = /* @__PURE__ */ new Set();
452
+ /** Records handed out by {@link KafkaRedriveIO.pop} that were acknowledged since; a rewind never re-reads them. */
453
+ acknowledged = /* @__PURE__ */ new WeakSet();
454
+ async pop(queue) {
455
+ const message = await this.options.pop(queue);
456
+ if (message && this.rewind && message.topic !== void 0 && message.partition !== void 0 && message.offset !== void 0) {
457
+ const tracked = {
458
+ body: message.body,
459
+ key: message.key,
460
+ value: message.value,
461
+ headers: message.headers,
462
+ topic: message.topic,
463
+ partition: message.partition,
464
+ offset: message.offset,
465
+ ack: async () => {
466
+ await message.ack();
467
+ this.inFlight.delete(tracked);
468
+ this.acknowledged.add(tracked);
469
+ }
470
+ };
471
+ this.inFlight.add(tracked);
472
+ return tracked;
473
+ }
474
+ return message;
161
475
  }
162
476
  /** Append an already-encoded body to `queue` as a keyless record. */
163
477
  async publish(queue, body) {
164
478
  await this.send(queue, body, null);
165
479
  }
166
- /** Append `body` to `queue`, carrying the key of the reserved record `from` byte-for-byte. */
480
+ /**
481
+ * Append `body` to `queue` on behalf of the reserved record `from`. Two modes (Go/Java/.NET parity):
482
+ *
483
+ * - **Restore** — `queue` is the topic `from` was consumed from and `body` is the body `pop` returned for
484
+ * it (redrive writing an unchanged DLQ record back). The original record is re-written verbatim: its
485
+ * value bytes (`from.value`), headers (`from.headers`) and key, with a fresh timestamp so the restored
486
+ * copy does not age out of the DLQ early (never `meta.created_at`). A message that carries no `value`
487
+ * re-writes `body`; one that carries no `headers` falls back to the `bq-*` projection of a decodable body.
488
+ * Needs `from.topic` to be set; without it a restore is indistinguishable from a republish.
489
+ * Header values the consumer decoded as `null` (a header with a -1 length) are not re-written: the
490
+ * restore copy goes through `copyHeaders`, which drops them (a deliberate bound; the Go reference
491
+ * writes them as they are).
492
+ * - **Republish** — any other queue/body (a redrive to the work topic): the `bq-*` header projection and
493
+ * the `meta.created_at` timestamp, plus the key of `from` byte-for-byte (§6.5).
494
+ */
167
495
  async publishFrom(queue, body, from) {
496
+ if (from.topic !== void 0 && queue === from.topic && body === from.body) {
497
+ await this.restoreVerbatim(queue, body, from);
498
+ return;
499
+ }
168
500
  await this.send(queue, body, from.key ?? null);
169
501
  }
502
+ async restoreVerbatim(queue, body, from) {
503
+ const record = {
504
+ // A tombstone stays a tombstone; KafkaJS accepts a null value although the structural type does not.
505
+ value: from.value === void 0 ? body : from.value,
506
+ timestamp: String(Date.now())
507
+ };
508
+ if (from.headers !== void 0) {
509
+ record.headers = copyHeaders(from.headers);
510
+ } else {
511
+ const decoded = EnvelopeCodec.decode(body);
512
+ if (EnvelopeCodec.accepts(decoded)) record.headers = kafkaHeaders(decoded);
513
+ }
514
+ if (from.key !== void 0 && from.key !== null) record.key = Buffer.from(from.key);
515
+ await this.options.producer.send({ topic: queue, messages: [record] });
516
+ }
170
517
  async send(queue, body, key) {
171
518
  const record = { value: body };
172
519
  const decoded = EnvelopeCodec.decode(body);
@@ -178,6 +525,66 @@ var KafkaRedriveIO = class {
178
525
  await this.options.producer.send({ topic: queue, messages: [record] });
179
526
  }
180
527
  };
528
+ function decodeFailure(raw) {
529
+ const reason = envelopeViolation(raw);
530
+ return reason === null ? null : new BabelQueueError(`Undecodable BabelQueue body: ${reason}`);
531
+ }
532
+ function hasLossyNumber(raw) {
533
+ let i = 0;
534
+ while (i < raw.length) {
535
+ const c = raw[i];
536
+ if (c === '"') {
537
+ i++;
538
+ while (i < raw.length && raw[i] !== '"') i += raw[i] === "\\" ? 2 : 1;
539
+ i++;
540
+ } else if (c === "-" || c >= "0" && c <= "9") {
541
+ let j = i + 1;
542
+ while (j < raw.length && /[0-9.eE+-]/.test(raw[j])) j++;
543
+ if (isLossyNumberToken(raw.slice(i, j))) return true;
544
+ i = j;
545
+ } else {
546
+ i++;
547
+ }
548
+ }
549
+ return false;
550
+ }
551
+ function poisonRecord(raw, cause, originalQueue, attempts, keepMembers = true) {
552
+ let record = { raw };
553
+ try {
554
+ if (!keepMembers) throw new Error("carry the body verbatim");
555
+ const parsed = JSON.parse(raw);
556
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && !hasLossyNumber(raw)) {
557
+ record = parsed;
558
+ }
559
+ } catch {
560
+ }
561
+ delete record.dead_letter;
562
+ record.dead_letter = annotate({ attempts }, "poison", originalQueue, {
563
+ attempts,
564
+ error: cause instanceof Error ? cause.message : null,
565
+ exception: cause instanceof Error ? cause.name : null
566
+ }).dead_letter;
567
+ return record;
568
+ }
569
+ function poisonHeaders(record, originalQueue, attempts) {
570
+ const headers = {};
571
+ const str = (v) => typeof v === "string" && v !== "" ? v : void 0;
572
+ const meta = record.meta !== null && typeof record.meta === "object" ? record.meta : {};
573
+ const job = str(record.job) ?? str(record.urn);
574
+ if (job) headers["bq-job"] = job;
575
+ const trace = str(record.trace_id);
576
+ if (trace) headers["bq-trace-id"] = trace;
577
+ const id = str(meta.id);
578
+ if (id) headers["bq-message-id"] = id;
579
+ if (meta.schema_version != null && typeof meta.schema_version !== "object") {
580
+ headers["bq-schema-version"] = String(meta.schema_version);
581
+ }
582
+ const lang = str(meta.lang);
583
+ if (lang) headers["bq-source-lang"] = lang;
584
+ headers["bq-attempts"] = String(attempts);
585
+ headers["bq-original-topic"] = originalQueue;
586
+ return headers;
587
+ }
181
588
  var KafkaConsumer = class {
182
589
  constructor(consumer, handlers, options = {}) {
183
590
  this.consumer = consumer;
@@ -212,26 +619,16 @@ var KafkaConsumer = class {
212
619
  return;
213
620
  }
214
621
  const raw = message.value ? message.value.toString("utf8") : "";
622
+ const undecodable = decodeFailure(raw);
623
+ if (undecodable) {
624
+ await this.settlePoison(payload, raw, undecodable, null);
625
+ return;
626
+ }
215
627
  let envelope;
216
628
  try {
217
629
  envelope = this.reconcile(EnvelopeCodec.decode(raw), message);
218
630
  } catch (decodeError) {
219
- try {
220
- await this.deadLetterRaw(payload);
221
- await this.commit(payload);
222
- } finally {
223
- this.report(decodeError, null, payload);
224
- }
225
- return;
226
- }
227
- if (!EnvelopeCodec.accepts(envelope)) {
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
- }
631
+ await this.settlePoison(payload, raw, decodeError, null);
235
632
  return;
236
633
  }
237
634
  const urn = headerString(message.headers, "bq-job") ?? EnvelopeCodec.urn(envelope);
@@ -314,6 +711,32 @@ var KafkaConsumer = class {
314
711
  headers["bq-original-topic"] = original;
315
712
  await this.produce(dlq, annotated, headers, payload.message.key);
316
713
  }
714
+ /** Dead-letter a poison record (wrapped), commit it, then report: settle first so `onError` cannot bypass it. */
715
+ async settlePoison(payload, raw, cause, envelope) {
716
+ try {
717
+ await this.deadLetterPoison(payload, raw, cause);
718
+ await this.commit(payload);
719
+ } finally {
720
+ this.report(cause, envelope, payload);
721
+ }
722
+ }
723
+ async deadLetterPoison(payload, raw, cause) {
724
+ const dlq = this.options.retryTopics?.dlqTopic ?? null;
725
+ if (!dlq) return;
726
+ this.requireProducer();
727
+ const original = this.originalTopic(payload);
728
+ const attempts = Math.max(headerInt(payload.message.headers, "bq-attempts", 0), 0);
729
+ let record = poisonRecord(raw, cause, original, attempts);
730
+ let json;
731
+ try {
732
+ json = JSON.stringify(record);
733
+ } catch {
734
+ record = poisonRecord(raw, cause, original, attempts, false);
735
+ json = JSON.stringify(record);
736
+ }
737
+ const headers = poisonHeaders(record, original, attempts);
738
+ await this.produceRaw(dlq, json, headers, payload.message.key);
739
+ }
317
740
  async deadLetterRaw(payload) {
318
741
  const dlq = this.options.retryTopics?.dlqTopic ?? null;
319
742
  if (!dlq) return;
@@ -334,8 +757,11 @@ var KafkaConsumer = class {
334
757
  * guaranteed.
335
758
  */
336
759
  async produce(topic, envelope, headers, key) {
760
+ await this.produceRaw(topic, EnvelopeCodec.encode(envelope), headers, key);
761
+ }
762
+ async produceRaw(topic, value, headers, key) {
337
763
  this.requireProducer();
338
- const record = { value: EnvelopeCodec.encode(envelope), headers, timestamp: String(Date.now()) };
764
+ const record = { value, headers, timestamp: String(Date.now()) };
339
765
  if (key !== void 0 && key !== null) record.key = Buffer.from(key);
340
766
  await this.options.producer.send({ topic, messages: [record] });
341
767
  }