@babelqueue/redis 1.0.0 → 1.1.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
@@ -73,6 +73,26 @@ the envelope with `attempts + 1` (at-least-once) up to `maxTries`, then dead-let
73
73
  The envelope is unchanged (`schema_version` stays `1`). The `ioredis` client is replaced with a
74
74
  fake in the unit suite — no Redis, no network.
75
75
 
76
+ ## OpenTelemetry tracing (ADR-0028)
77
+
78
+ Cross-hop **span** linkage rides on the out-of-band `HeaderCarrier` from
79
+ [`@babelqueue/core@^1.4.0`](https://www.npmjs.com/package/@babelqueue/core). Pass the carrier produced by
80
+ `@babelqueue/core/otel`'s `publish` to this adapter's `publish({ headers })`; it is carried on
81
+ a transport-owned `__bq_frame` JSON frame (Redis lists have no native metadata channel; the `LREM` ack handle *is* the stored value). On consume, the consumer surfaces a delivered message's headers to the handler's third argument (and a `headersOf(...)` extractor reads them back),
82
+ so the core's `otel` `wrapHandler` starts the consumer span as a true **child** of the producer span.
83
+
84
+ A bare (pre-0028 / cross-version) value consumes with empty headers, and a header-less publish stays byte-identical.
85
+
86
+ ```ts
87
+ import { trace } from "@opentelemetry/api";
88
+ import { publish as tracedPublish } from "@babelqueue/core/otel";
89
+ import type { HeaderCarrier } from "@babelqueue/core";
90
+
91
+ const headers: HeaderCarrier = {};
92
+ await tracedPublish(trace.getTracer("orders"), urn, data,
93
+ () => adapterPublish(urn, data, { headers }), { headers });
94
+ ```
95
+
76
96
  ## License
77
97
 
78
98
  MIT
package/dist/index.cjs CHANGED
@@ -20,15 +20,60 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ FRAME_KEY: () => FRAME_KEY,
23
24
  RedisConsumer: () => RedisConsumer,
24
25
  RedisPublisher: () => RedisPublisher,
25
- redisValue: () => redisValue
26
+ frameValue: () => frameValue,
27
+ headersOf: () => headersOf,
28
+ redisValue: () => redisValue,
29
+ unframe: () => unframe
26
30
  });
27
31
  module.exports = __toCommonJS(index_exports);
28
32
  var import_core = require("@babelqueue/core");
33
+
34
+ // src/headers.ts
35
+ function sanitizeHeaders(headers) {
36
+ const out = {};
37
+ if (!headers) return out;
38
+ for (const key of Object.keys(headers)) {
39
+ const value = headers[key];
40
+ if (key === "" || value == null || value === "") continue;
41
+ out[key] = value;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // src/index.ts
29
47
  function redisValue(envelope) {
30
48
  return import_core.EnvelopeCodec.encode(envelope);
31
49
  }
50
+ var FRAME_KEY = "__bq_frame";
51
+ var FRAME_VERSION = 1;
52
+ function frameValue(body, headers) {
53
+ const clean = sanitizeHeaders(headers);
54
+ if (Object.keys(clean).length === 0) return body;
55
+ const frame = { [FRAME_KEY]: FRAME_VERSION, headers: clean, body };
56
+ return JSON.stringify(frame);
57
+ }
58
+ function unframe(value) {
59
+ if (value === "" || value[0] !== "{" || !value.includes(`"${FRAME_KEY}"`)) {
60
+ return [value, {}];
61
+ }
62
+ let decoded;
63
+ try {
64
+ decoded = JSON.parse(value);
65
+ } catch {
66
+ return [value, {}];
67
+ }
68
+ if (decoded == null || typeof decoded !== "object" || !(FRAME_KEY in decoded) || !decoded[FRAME_KEY] || typeof decoded.body !== "string") {
69
+ return [value, {}];
70
+ }
71
+ const frame = decoded;
72
+ return [frame.body, sanitizeHeaders(frame.headers)];
73
+ }
74
+ function headersOf(value) {
75
+ return unframe(value)[1];
76
+ }
32
77
  var RedisPublisher = class _RedisPublisher {
33
78
  constructor(client, queue) {
34
79
  this.client = client;
@@ -40,10 +85,15 @@ var RedisPublisher = class _RedisPublisher {
40
85
  static create(client, queue) {
41
86
  return new _RedisPublisher(client, queue);
42
87
  }
43
- /** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
88
+ /**
89
+ * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
90
+ * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
91
+ * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
92
+ * pushed byte-for-byte (GR-1, no regression).
93
+ */
44
94
  async publish(urn, data, options = {}) {
45
95
  const envelope = import_core.EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
46
- await this.client.rpush(this.queue, redisValue(envelope));
96
+ await this.client.rpush(this.queue, frameValue(redisValue(envelope), options.headers));
47
97
  return envelope.meta.id;
48
98
  }
49
99
  };
@@ -81,9 +131,17 @@ var RedisConsumer = class {
81
131
  await this.poll();
82
132
  }
83
133
  }
84
- /** Route + settle one reserved element. Exposed for testing. */
134
+ /**
135
+ * Route + settle one reserved element. Exposed for testing.
136
+ *
137
+ * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
138
+ * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
139
+ * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
140
+ * to the handler. Settling/dead-lettering still operate on the original stored value.
141
+ */
85
142
  async handle(raw) {
86
- const decoded = import_core.EnvelopeCodec.decode(raw);
143
+ const [body, headers] = unframe(raw);
144
+ const decoded = import_core.EnvelopeCodec.decode(body);
87
145
  if (!import_core.EnvelopeCodec.accepts(decoded)) {
88
146
  this.report(new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis."), decoded, raw);
89
147
  await this.deadLetterRaw(raw);
@@ -98,7 +156,7 @@ var RedisConsumer = class {
98
156
  return;
99
157
  }
100
158
  try {
101
- await handler(envelope, raw);
159
+ await handler(envelope, raw, headers);
102
160
  await this.settle(raw);
103
161
  } catch (error) {
104
162
  this.report(error, envelope, raw);
@@ -161,8 +219,12 @@ var RedisConsumer = class {
161
219
  };
162
220
  // Annotate the CommonJS export names for ESM import in node:
163
221
  0 && (module.exports = {
222
+ FRAME_KEY,
164
223
  RedisConsumer,
165
224
  RedisPublisher,
166
- redisValue
225
+ frameValue,
226
+ headersOf,
227
+ redisValue,
228
+ unframe
167
229
  });
168
230
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Redis adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue\n * pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so\n * an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service\n * speaks the same wire contract as the PHP, Python and Go SDKs.\n *\n * Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,\n * byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job\n * layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable\n * queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a\n * separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns\n * end-to-end.\n *\n * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide\n * the client (an `ioredis` instance satisfies the adapter structurally).\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, IncomingEnvelope } from \"@babelqueue/core\";\n\n// --- Minimal Redis shape (a structural subset of ioredis) ----------------------\n\n/** The subset of an ioredis client this adapter calls. */\nexport interface RedisClient {\n /** Append a value to the tail of a list; returns the new length. */\n rpush(key: string, value: string): Promise<number>;\n /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up\n * to `timeout` seconds; resolves to the value, or `null` on timeout. */\n brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;\n /** Remove `count` occurrences of `value` from a list; returns how many were removed. */\n lrem(key: string, count: number, value: string): Promise<number>;\n}\n\n// --- Payload (contract §1) -----------------------------------------------------\n\n/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */\nexport function redisValue(envelope: Envelope): string {\n return EnvelopeCodec.encode(envelope);\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link RedisPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n}\n\n/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */\nexport class RedisPublisher {\n private constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n ) {}\n\n /** A publisher over `client`, producing to the list named `queue`. */\n static create(client: RedisClient, queue: string): RedisPublisher {\n return new RedisPublisher(client, queue);\n }\n\n /** Build + `RPUSH` 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.queue, traceId: options.traceId });\n await this.client.rpush(this.queue, redisValue(envelope));\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/** A URN handler. Receives the validated envelope and the raw list element. */\nexport type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link RedisConsumer}. */\nexport interface RedisConsumerOptions {\n /** Suffix for the per-queue reservation list (default `\":processing\"`). */\n processingSuffix?: string;\n /** Block timeout (seconds) for a single reserve (default 5). */\n blockTimeout?: number;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */\n deadLetterQueue?: string | null;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;\n}\n\n/**\n * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +\n * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.\n * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,\n * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in\n * the body and the runtime owns retry.\n */\nexport class RedisConsumer {\n private readonly processing: string;\n private readonly blockTimeout: number;\n private readonly maxTries: number;\n private readonly dlq: string | null;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n private readonly handlers: BabelHandlers,\n private readonly options: RedisConsumerOptions = {},\n ) {\n this.processing = queue + (options.processingSuffix ?? \":processing\");\n this.blockTimeout = options.blockTimeout ?? 5;\n this.maxTries = options.maxTries ?? 3;\n this.dlq = options.deadLetterQueue === undefined ? `${queue}.dlq` : options.deadLetterQueue;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */\n async poll(): Promise<boolean> {\n const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);\n if (raw == null) return false;\n await this.handle(raw);\n return true;\n }\n\n /** Poll while `shouldContinue` returns true. */\n async run(shouldContinue: () => boolean): Promise<void> {\n while (shouldContinue()) {\n await this.poll();\n }\n }\n\n /** Route + settle one reserved element. Exposed for testing. */\n async handle(raw: string): Promise<void> {\n const decoded = EnvelopeCodec.decode(raw);\n\n if (!EnvelopeCodec.accepts(decoded)) {\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Redis.\"), decoded, raw);\n await this.deadLetterRaw(raw);\n await this.settle(raw);\n return;\n }\n\n const envelope = decoded as Envelope;\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(envelope, urn, raw);\n return;\n }\n\n try {\n await handler(envelope, raw);\n await this.settle(raw);\n } catch (error) {\n this.report(error, envelope, raw);\n await this.retryOrDeadLetter(envelope, raw, error);\n }\n }\n\n private async onUnknownUrn(envelope: Envelope, urn: string, raw: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.requeue(envelope);\n await this.settle(raw);\n return;\n default: {\n // FAIL: surface and leave the element in the reservation list (recoverable).\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, raw);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(envelope: Envelope, raw: string, error: unknown): Promise<void> {\n if ((envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.requeue(envelope);\n } else {\n await this.deadLetter(envelope, \"failed\", error);\n }\n await this.settle(raw);\n }\n\n /** Republish with `attempts + 1` onto the work list (at-least-once retry). */\n private async requeue(envelope: Envelope): Promise<void> {\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n await this.client.rpush(this.queue, redisValue(bumped));\n }\n\n private async deadLetter(envelope: Envelope, reason: string, error?: unknown): Promise<void> {\n if (!this.dlq) return; // dead-lettering disabled → drop\n const annotated = annotate(envelope, reason, envelope.meta?.queue ?? this.queue, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n await this.client.rpush(this.dlq, redisValue(annotated));\n }\n\n private async deadLetterRaw(raw: string): Promise<void> {\n if (!this.dlq) return;\n await this.client.rpush(this.dlq, raw);\n }\n\n /** Remove the element from the reservation list (acknowledge). */\n private async settle(raw: string): Promise<void> {\n await this.client.lrem(this.processing, 1, raw);\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, raw: string): void {\n this.options.onError?.(error, envelope, raw);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Redis adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue\n * pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so\n * an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service\n * speaks the same wire contract as the PHP, Python and Go SDKs.\n *\n * Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,\n * byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job\n * layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable\n * queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a\n * separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns\n * end-to-end.\n *\n * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide\n * the client (an `ioredis` instance satisfies the adapter structurally).\n *\n * **Out-of-band headers (ADR-0028).** A Redis list element *is* the canonical envelope JSON (the\n * `LREM` ack handle is that exact value), so — unlike AMQP headers or SQS `MessageAttributes` —\n * there is no native per-message metadata channel. To carry a {@link HeaderCarrier} (e.g. a W3C\n * `traceparent` for cross-hop span linkage) the adapter owns a tiny JSON *frame* distinct from the\n * wire envelope:\n *\n * {\"__bq_frame\":1,\"headers\":{\"traceparent\":\"00-…\"},\"body\":\"<raw wire envelope>\"}\n *\n * `RPUSH` stores the frame, so the `LREM` ack handle stays byte-for-byte what was pushed and the\n * reliable-queue semantics (`RPUSH`/`BRPOPLPUSH`/`LREM`) are untouched. Framing is **opt-in and\n * backward compatible**: only `publish` with a non-empty `headers` carrier writes a frame; a plain\n * publish stores the **bare** envelope byte-for-byte, exactly as before. The consumer detects\n * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it), so a\n * bare value consumes with empty headers and cross-version queues interoperate. This mirrors the\n * Go `/redis` and PHP `RedisTransport` framing byte-for-byte. GR-1: the wire envelope is never\n * touched.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Redis shape (a structural subset of ioredis) ----------------------\n\n/** The subset of an ioredis client this adapter calls. */\nexport interface RedisClient {\n /** Append a value to the tail of a list; returns the new length. */\n rpush(key: string, value: string): Promise<number>;\n /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up\n * to `timeout` seconds; resolves to the value, or `null` on timeout. */\n brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;\n /** Remove `count` occurrences of `value` from a list; returns how many were removed. */\n lrem(key: string, count: number, value: string): Promise<number>;\n}\n\n// --- Payload (contract §1) -----------------------------------------------------\n\n/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */\nexport function redisValue(envelope: Envelope): string {\n return EnvelopeCodec.encode(envelope);\n}\n\n// --- Header frame (ADR-0028) ---------------------------------------------------\n\n/** The reserved discriminator key + current version of the transport-owned header frame. */\nexport const FRAME_KEY = \"__bq_frame\";\nconst FRAME_VERSION = 1;\n\n/** The transport-owned frame the list value carries when out-of-band headers accompany a message. */\ninterface HeaderFrame {\n __bq_frame: number;\n headers?: HeaderCarrier;\n body: string;\n}\n\n/**\n * The pure produce-side decision: the exact string to `RPUSH` for `body` + `headers`. With no\n * usable headers it returns `body` verbatim (the **bare** form, so a plain publish and a\n * header-less publish store byte-identical values); otherwise it returns the transport-owned frame\n * JSON. Kept pure so the framing decision is unit-testable without a broker.\n */\nexport function frameValue(body: string, headers: HeaderCarrier | null | undefined): string {\n const clean = sanitizeHeaders(headers);\n if (Object.keys(clean).length === 0) return body;\n const frame: HeaderFrame = { [FRAME_KEY]: FRAME_VERSION, headers: clean, body };\n return JSON.stringify(frame);\n}\n\n/**\n * Interpret a stored Redis list value: `[wire-envelope-body, headers]`. A value is a header frame\n * iff it is a JSON object carrying the reserved `__bq_frame` sentinel (a frozen wire envelope never\n * has it); then it yields the unframed body plus the carried headers. Any other value — a bare\n * envelope, non-JSON, or JSON without the sentinel — is returned verbatim as the body with empty\n * headers, so older / cross-version queue values consume exactly as before.\n */\nexport function unframe(value: string): [string, HeaderCarrier] {\n // Cheap reject: a frame is always a JSON object, and the sentinel substring must appear. This\n // avoids a full parse for the overwhelmingly common bare-envelope case (only short-circuits\n // negatives).\n if (value === \"\" || value[0] !== \"{\" || !value.includes(`\"${FRAME_KEY}\"`)) {\n return [value, {}];\n }\n let decoded: unknown;\n try {\n decoded = JSON.parse(value);\n } catch {\n return [value, {}];\n }\n if (\n decoded == null ||\n typeof decoded !== \"object\" ||\n !(FRAME_KEY in decoded) ||\n !(decoded as HeaderFrame)[FRAME_KEY] ||\n typeof (decoded as HeaderFrame).body !== \"string\"\n ) {\n return [value, {}];\n }\n const frame = decoded as HeaderFrame;\n return [frame.body, sanitizeHeaders(frame.headers)];\n}\n\n/** The out-of-band headers carried by a stored list value (empty for a bare value). */\nexport function headersOf(value: string): HeaderCarrier {\n return unframe(value)[1];\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link RedisPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /**\n * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C\n * `traceparent` written by `@babelqueue/core/otel`'s `publish`. A non-empty carrier RPUSHes a\n * transport-owned frame; an empty/omitted carrier stays a byte-identical bare publish.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */\nexport class RedisPublisher {\n private constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n ) {}\n\n /** A publisher over `client`, producing to the list named `queue`. */\n static create(client: RedisClient, queue: string): RedisPublisher {\n return new RedisPublisher(client, queue);\n }\n\n /**\n * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When\n * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value\n * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is\n * pushed byte-for-byte (GR-1, no regression).\n */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });\n await this.client.rpush(this.queue, frameValue(redisValue(envelope), options.headers));\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band\n * {@link HeaderCarrier} carried beside it (empty for a bare value). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer\n * span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link RedisConsumer}. */\nexport interface RedisConsumerOptions {\n /** Suffix for the per-queue reservation list (default `\":processing\"`). */\n processingSuffix?: string;\n /** Block timeout (seconds) for a single reserve (default 5). */\n blockTimeout?: number;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */\n deadLetterQueue?: string | null;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;\n}\n\n/**\n * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +\n * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.\n * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,\n * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in\n * the body and the runtime owns retry.\n */\nexport class RedisConsumer {\n private readonly processing: string;\n private readonly blockTimeout: number;\n private readonly maxTries: number;\n private readonly dlq: string | null;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n private readonly handlers: BabelHandlers,\n private readonly options: RedisConsumerOptions = {},\n ) {\n this.processing = queue + (options.processingSuffix ?? \":processing\");\n this.blockTimeout = options.blockTimeout ?? 5;\n this.maxTries = options.maxTries ?? 3;\n this.dlq = options.deadLetterQueue === undefined ? `${queue}.dlq` : options.deadLetterQueue;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */\n async poll(): Promise<boolean> {\n const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);\n if (raw == null) return false;\n await this.handle(raw);\n return true;\n }\n\n /** Poll while `shouldContinue` returns true. */\n async run(shouldContinue: () => boolean): Promise<void> {\n while (shouldContinue()) {\n await this.poll();\n }\n }\n\n /**\n * Route + settle one reserved element. Exposed for testing.\n *\n * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header\n * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim\n * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced\n * to the handler. Settling/dead-lettering still operate on the original stored value.\n */\n async handle(raw: string): Promise<void> {\n const [body, headers] = unframe(raw);\n const decoded = EnvelopeCodec.decode(body);\n\n if (!EnvelopeCodec.accepts(decoded)) {\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Redis.\"), decoded, raw);\n await this.deadLetterRaw(raw);\n await this.settle(raw);\n return;\n }\n\n const envelope = decoded as Envelope;\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(envelope, urn, raw);\n return;\n }\n\n try {\n await handler(envelope, raw, headers);\n await this.settle(raw);\n } catch (error) {\n this.report(error, envelope, raw);\n await this.retryOrDeadLetter(envelope, raw, error);\n }\n }\n\n private async onUnknownUrn(envelope: Envelope, urn: string, raw: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.requeue(envelope);\n await this.settle(raw);\n return;\n default: {\n // FAIL: surface and leave the element in the reservation list (recoverable).\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, raw);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(envelope: Envelope, raw: string, error: unknown): Promise<void> {\n if ((envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.requeue(envelope);\n } else {\n await this.deadLetter(envelope, \"failed\", error);\n }\n await this.settle(raw);\n }\n\n /** Republish with `attempts + 1` onto the work list (at-least-once retry). */\n private async requeue(envelope: Envelope): Promise<void> {\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n await this.client.rpush(this.queue, redisValue(bumped));\n }\n\n private async deadLetter(envelope: Envelope, reason: string, error?: unknown): Promise<void> {\n if (!this.dlq) return; // dead-lettering disabled → drop\n const annotated = annotate(envelope, reason, envelope.meta?.queue ?? this.queue, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n await this.client.rpush(this.dlq, redisValue(annotated));\n }\n\n private async deadLetterRaw(raw: string): Promise<void> {\n if (!this.dlq) return;\n await this.client.rpush(this.dlq, raw);\n }\n\n /** Remove the element from the reservation list (acknowledge). */\n private async settle(raw: string): Promise<void> {\n await this.client.lrem(this.processing, 1, raw);\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, raw: string): void {\n this.options.onError?.(error, envelope, raw);\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, so a caller can treat the result as \"no headers\" via `Object.keys(...).length === 0`.\n * 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"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoCA,kBAA8F;;;ACpBvF,SAAS,gBAAgB,SAA0D;AACxF,QAAM,MAAqB,CAAC;AAC5B,MAAI,CAAC,QAAS,QAAO;AACrB,aAAW,OAAO,OAAO,KAAK,OAAO,GAAG;AACtC,UAAM,QAAQ,QAAQ,GAAG;AACzB,QAAI,QAAQ,MAAM,SAAS,QAAQ,UAAU,GAAI;AACjD,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;;;ADgCO,SAAS,WAAW,UAA4B;AACrD,SAAO,0BAAc,OAAO,QAAQ;AACtC;AAKO,IAAM,YAAY;AACzB,IAAM,gBAAgB;AAef,SAAS,WAAW,MAAc,SAAmD;AAC1F,QAAM,QAAQ,gBAAgB,OAAO;AACrC,MAAI,OAAO,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO;AAC5C,QAAM,QAAqB,EAAE,CAAC,SAAS,GAAG,eAAe,SAAS,OAAO,KAAK;AAC9E,SAAO,KAAK,UAAU,KAAK;AAC7B;AASO,SAAS,QAAQ,OAAwC;AAI9D,MAAI,UAAU,MAAM,MAAM,CAAC,MAAM,OAAO,CAAC,MAAM,SAAS,IAAI,SAAS,GAAG,GAAG;AACzE,WAAO,CAAC,OAAO,CAAC,CAAC;AAAA,EACnB;AACA,MAAI;AACJ,MAAI;AACF,cAAU,KAAK,MAAM,KAAK;AAAA,EAC5B,QAAQ;AACN,WAAO,CAAC,OAAO,CAAC,CAAC;AAAA,EACnB;AACA,MACE,WAAW,QACX,OAAO,YAAY,YACnB,EAAE,aAAa,YACf,CAAE,QAAwB,SAAS,KACnC,OAAQ,QAAwB,SAAS,UACzC;AACA,WAAO,CAAC,OAAO,CAAC,CAAC;AAAA,EACnB;AACA,QAAM,QAAQ;AACd,SAAO,CAAC,MAAM,MAAM,gBAAgB,MAAM,OAAO,CAAC;AACpD;AAGO,SAAS,UAAU,OAA8B;AACtD,SAAO,QAAQ,KAAK,EAAE,CAAC;AACzB;AAgBO,IAAM,iBAAN,MAAM,gBAAe;AAAA,EAClB,YACW,QACA,OACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAFgB;AAAA,EACA;AAAA;AAAA,EAInB,OAAO,OAAO,QAAqB,OAA+B;AAChE,WAAO,IAAI,gBAAe,QAAQ,KAAK;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,QAAQ,KAAa,MAA+B,UAA0B,CAAC,GAAoB;AACvG,UAAM,WAAW,0BAAc,KAAK,KAAK,MAAM,EAAE,OAAO,KAAK,OAAO,SAAS,QAAQ,QAAQ,CAAC;AAC9F,UAAM,KAAK,OAAO,MAAM,KAAK,OAAO,WAAW,WAAW,QAAQ,GAAG,QAAQ,OAAO,CAAC;AACrF,WAAO,SAAS,KAAK;AAAA,EACvB;AACF;AAsCO,IAAM,gBAAN,MAAoB;AAAA,EAOzB,YACmB,QACA,OACA,UACA,UAAgC,CAAC,GAClD;AAJiB;AACA;AACA;AACA;AAEjB,SAAK,aAAa,SAAS,QAAQ,oBAAoB;AACvD,SAAK,eAAe,QAAQ,gBAAgB;AAC5C,SAAK,WAAW,QAAQ,YAAY;AACpC,SAAK,MAAM,QAAQ,oBAAoB,SAAY,GAAG,KAAK,SAAS,QAAQ;AAC5E,SAAK,aAAa,QAAQ,cAAc,+BAAmB;AAAA,EAC7D;AAAA,EAVmB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAVF;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAgBjB,MAAM,OAAyB;AAC7B,UAAM,MAAM,MAAM,KAAK,OAAO,WAAW,KAAK,OAAO,KAAK,YAAY,KAAK,YAAY;AACvF,QAAI,OAAO,KAAM,QAAO;AACxB,UAAM,KAAK,OAAO,GAAG;AACrB,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,MAAM,IAAI,gBAA8C;AACtD,WAAO,eAAe,GAAG;AACvB,YAAM,KAAK,KAAK;AAAA,IAClB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,OAAO,KAA4B;AACvC,UAAM,CAAC,MAAM,OAAO,IAAI,QAAQ,GAAG;AACnC,UAAM,UAAU,0BAAc,OAAO,IAAI;AAEzC,QAAI,CAAC,0BAAc,QAAQ,OAAO,GAAG;AACnC,WAAK,OAAO,IAAI,4BAAgB,2DAA2D,GAAG,SAAS,GAAG;AAC1G,YAAM,KAAK,cAAc,GAAG;AAC5B,YAAM,KAAK,OAAO,GAAG;AACrB;AAAA,IACF;AAEA,UAAM,WAAW;AACjB,UAAM,MAAM,0BAAc,IAAI,QAAQ;AACtC,UAAM,UAAU,KAAK,SAAS,GAAG;AACjC,QAAI,CAAC,SAAS;AACZ,YAAM,KAAK,aAAa,UAAU,KAAK,GAAG;AAC1C;AAAA,IACF;AAEA,QAAI;AACF,YAAM,QAAQ,UAAU,KAAK,OAAO;AACpC,YAAM,KAAK,OAAO,GAAG;AAAA,IACvB,SAAS,OAAO;AACd,WAAK,OAAO,OAAO,UAAU,GAAG;AAChC,YAAM,KAAK,kBAAkB,UAAU,KAAK,KAAK;AAAA,IACnD;AAAA,EACF;AAAA,EAEA,MAAc,aAAa,UAAoB,KAAa,KAA4B;AACtF,YAAQ,KAAK,YAAY;AAAA,MACvB,KAAK,+BAAmB;AACtB,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,KAAK,+BAAmB;AACtB,cAAM,KAAK,WAAW,UAAU,aAAa;AAC7C,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,KAAK,+BAAmB;AACtB,cAAM,KAAK,QAAQ,QAAQ;AAC3B,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,SAAS;AAEP,cAAM,QAAQ,IAAI,4BAAgB,GAAG;AACrC,aAAK,OAAO,OAAO,UAAU,GAAG;AAChC,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,kBAAkB,UAAoB,KAAa,OAA+B;AAC9F,SAAK,SAAS,YAAY,KAAK,IAAI,KAAK,UAAU;AAChD,YAAM,KAAK,QAAQ,QAAQ;AAAA,IAC7B,OAAO;AACL,YAAM,KAAK,WAAW,UAAU,UAAU,KAAK;AAAA,IACjD;AACA,UAAM,KAAK,OAAO,GAAG;AAAA,EACvB;AAAA;AAAA,EAGA,MAAc,QAAQ,UAAmC;AACvD,UAAM,SAAmB,EAAE,GAAG,UAAU,WAAW,SAAS,YAAY,KAAK,EAAE;AAC/E,UAAM,KAAK,OAAO,MAAM,KAAK,OAAO,WAAW,MAAM,CAAC;AAAA,EACxD;AAAA,EAEA,MAAc,WAAW,UAAoB,QAAgB,OAAgC;AAC3F,QAAI,CAAC,KAAK,IAAK;AACf,UAAM,gBAAY,sBAAS,UAAU,QAAQ,SAAS,MAAM,SAAS,KAAK,OAAO;AAAA,MAC/E,UAAU,SAAS,YAAY;AAAA,MAC/B,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MAChD,WAAW,iBAAiB,QAAQ,MAAM,OAAO;AAAA,IACnD,CAAC;AACD,UAAM,KAAK,OAAO,MAAM,KAAK,KAAK,WAAW,SAAS,CAAC;AAAA,EACzD;AAAA,EAEA,MAAc,cAAc,KAA4B;AACtD,QAAI,CAAC,KAAK,IAAK;AACf,UAAM,KAAK,OAAO,MAAM,KAAK,KAAK,GAAG;AAAA,EACvC;AAAA;AAAA,EAGA,MAAc,OAAO,KAA4B;AAC/C,UAAM,KAAK,OAAO,KAAK,KAAK,YAAY,GAAG,GAAG;AAAA,EAChD;AAAA,EAEQ,OAAO,OAAgB,UAAmC,KAAmB;AACnF,SAAK,QAAQ,UAAU,OAAO,UAAU,GAAG;AAAA,EAC7C;AACF;","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Envelope, IncomingEnvelope } from '@babelqueue/core';
1
+ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
2
2
 
3
3
  /**
4
4
  * Redis adapter for BabelQueue.
@@ -17,6 +17,23 @@ import { Envelope, IncomingEnvelope } from '@babelqueue/core';
17
17
  *
18
18
  * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide
19
19
  * the client (an `ioredis` instance satisfies the adapter structurally).
20
+ *
21
+ * **Out-of-band headers (ADR-0028).** A Redis list element *is* the canonical envelope JSON (the
22
+ * `LREM` ack handle is that exact value), so — unlike AMQP headers or SQS `MessageAttributes` —
23
+ * there is no native per-message metadata channel. To carry a {@link HeaderCarrier} (e.g. a W3C
24
+ * `traceparent` for cross-hop span linkage) the adapter owns a tiny JSON *frame* distinct from the
25
+ * wire envelope:
26
+ *
27
+ * {"__bq_frame":1,"headers":{"traceparent":"00-…"},"body":"<raw wire envelope>"}
28
+ *
29
+ * `RPUSH` stores the frame, so the `LREM` ack handle stays byte-for-byte what was pushed and the
30
+ * reliable-queue semantics (`RPUSH`/`BRPOPLPUSH`/`LREM`) are untouched. Framing is **opt-in and
31
+ * backward compatible**: only `publish` with a non-empty `headers` carrier writes a frame; a plain
32
+ * publish stores the **bare** envelope byte-for-byte, exactly as before. The consumer detects
33
+ * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it), so a
34
+ * bare value consumes with empty headers and cross-version queues interoperate. This mirrors the
35
+ * Go `/redis` and PHP `RedisTransport` framing byte-for-byte. GR-1: the wire envelope is never
36
+ * touched.
20
37
  */
21
38
 
22
39
  /** The subset of an ioredis client this adapter calls. */
@@ -31,9 +48,34 @@ interface RedisClient {
31
48
  }
32
49
  /** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
33
50
  declare function redisValue(envelope: Envelope): string;
51
+ /** The reserved discriminator key + current version of the transport-owned header frame. */
52
+ declare const FRAME_KEY = "__bq_frame";
53
+ /**
54
+ * The pure produce-side decision: the exact string to `RPUSH` for `body` + `headers`. With no
55
+ * usable headers it returns `body` verbatim (the **bare** form, so a plain publish and a
56
+ * header-less publish store byte-identical values); otherwise it returns the transport-owned frame
57
+ * JSON. Kept pure so the framing decision is unit-testable without a broker.
58
+ */
59
+ declare function frameValue(body: string, headers: HeaderCarrier | null | undefined): string;
60
+ /**
61
+ * Interpret a stored Redis list value: `[wire-envelope-body, headers]`. A value is a header frame
62
+ * iff it is a JSON object carrying the reserved `__bq_frame` sentinel (a frozen wire envelope never
63
+ * has it); then it yields the unframed body plus the carried headers. Any other value — a bare
64
+ * envelope, non-JSON, or JSON without the sentinel — is returned verbatim as the body with empty
65
+ * headers, so older / cross-version queue values consume exactly as before.
66
+ */
67
+ declare function unframe(value: string): [string, HeaderCarrier];
68
+ /** The out-of-band headers carried by a stored list value (empty for a bare value). */
69
+ declare function headersOf(value: string): HeaderCarrier;
34
70
  /** Options for {@link RedisPublisher.publish}. */
35
71
  interface PublishOptions {
36
72
  traceId?: string;
73
+ /**
74
+ * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C
75
+ * `traceparent` written by `@babelqueue/core/otel`'s `publish`. A non-empty carrier RPUSHes a
76
+ * transport-owned frame; an empty/omitted carrier stays a byte-identical bare publish.
77
+ */
78
+ headers?: HeaderCarrier;
37
79
  }
38
80
  /** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
39
81
  declare class RedisPublisher {
@@ -42,11 +84,21 @@ declare class RedisPublisher {
42
84
  private constructor();
43
85
  /** A publisher over `client`, producing to the list named `queue`. */
44
86
  static create(client: RedisClient, queue: string): RedisPublisher;
45
- /** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
87
+ /**
88
+ * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
89
+ * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
90
+ * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
91
+ * pushed byte-for-byte (GR-1, no regression).
92
+ */
46
93
  publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
47
94
  }
48
- /** A URN handler. Receives the validated envelope and the raw list element. */
49
- type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;
95
+ /**
96
+ * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band
97
+ * {@link HeaderCarrier} carried beside it (empty for a bare value). Pass `headers` to
98
+ * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer
99
+ * span (ADR-0028).
100
+ */
101
+ type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;
50
102
  /** A map of URN → handler. */
51
103
  type BabelHandlers = Record<string, BabelHandler>;
52
104
  /** Options for {@link RedisConsumer}. */
@@ -86,7 +138,14 @@ declare class RedisConsumer {
86
138
  poll(): Promise<boolean>;
87
139
  /** Poll while `shouldContinue` returns true. */
88
140
  run(shouldContinue: () => boolean): Promise<void>;
89
- /** Route + settle one reserved element. Exposed for testing. */
141
+ /**
142
+ * Route + settle one reserved element. Exposed for testing.
143
+ *
144
+ * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
145
+ * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
146
+ * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
147
+ * to the handler. Settling/dead-lettering still operate on the original stored value.
148
+ */
90
149
  handle(raw: string): Promise<void>;
91
150
  private onUnknownUrn;
92
151
  private retryOrDeadLetter;
@@ -99,4 +158,4 @@ declare class RedisConsumer {
99
158
  private report;
100
159
  }
101
160
 
102
- export { type BabelHandler, type BabelHandlers, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, redisValue };
161
+ export { type BabelHandler, type BabelHandlers, FRAME_KEY, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, frameValue, headersOf, redisValue, unframe };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Envelope, IncomingEnvelope } from '@babelqueue/core';
1
+ import { Envelope, HeaderCarrier, IncomingEnvelope } from '@babelqueue/core';
2
2
 
3
3
  /**
4
4
  * Redis adapter for BabelQueue.
@@ -17,6 +17,23 @@ import { Envelope, IncomingEnvelope } from '@babelqueue/core';
17
17
  *
18
18
  * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide
19
19
  * the client (an `ioredis` instance satisfies the adapter structurally).
20
+ *
21
+ * **Out-of-band headers (ADR-0028).** A Redis list element *is* the canonical envelope JSON (the
22
+ * `LREM` ack handle is that exact value), so — unlike AMQP headers or SQS `MessageAttributes` —
23
+ * there is no native per-message metadata channel. To carry a {@link HeaderCarrier} (e.g. a W3C
24
+ * `traceparent` for cross-hop span linkage) the adapter owns a tiny JSON *frame* distinct from the
25
+ * wire envelope:
26
+ *
27
+ * {"__bq_frame":1,"headers":{"traceparent":"00-…"},"body":"<raw wire envelope>"}
28
+ *
29
+ * `RPUSH` stores the frame, so the `LREM` ack handle stays byte-for-byte what was pushed and the
30
+ * reliable-queue semantics (`RPUSH`/`BRPOPLPUSH`/`LREM`) are untouched. Framing is **opt-in and
31
+ * backward compatible**: only `publish` with a non-empty `headers` carrier writes a frame; a plain
32
+ * publish stores the **bare** envelope byte-for-byte, exactly as before. The consumer detects
33
+ * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it), so a
34
+ * bare value consumes with empty headers and cross-version queues interoperate. This mirrors the
35
+ * Go `/redis` and PHP `RedisTransport` framing byte-for-byte. GR-1: the wire envelope is never
36
+ * touched.
20
37
  */
21
38
 
22
39
  /** The subset of an ioredis client this adapter calls. */
@@ -31,9 +48,34 @@ interface RedisClient {
31
48
  }
32
49
  /** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
33
50
  declare function redisValue(envelope: Envelope): string;
51
+ /** The reserved discriminator key + current version of the transport-owned header frame. */
52
+ declare const FRAME_KEY = "__bq_frame";
53
+ /**
54
+ * The pure produce-side decision: the exact string to `RPUSH` for `body` + `headers`. With no
55
+ * usable headers it returns `body` verbatim (the **bare** form, so a plain publish and a
56
+ * header-less publish store byte-identical values); otherwise it returns the transport-owned frame
57
+ * JSON. Kept pure so the framing decision is unit-testable without a broker.
58
+ */
59
+ declare function frameValue(body: string, headers: HeaderCarrier | null | undefined): string;
60
+ /**
61
+ * Interpret a stored Redis list value: `[wire-envelope-body, headers]`. A value is a header frame
62
+ * iff it is a JSON object carrying the reserved `__bq_frame` sentinel (a frozen wire envelope never
63
+ * has it); then it yields the unframed body plus the carried headers. Any other value — a bare
64
+ * envelope, non-JSON, or JSON without the sentinel — is returned verbatim as the body with empty
65
+ * headers, so older / cross-version queue values consume exactly as before.
66
+ */
67
+ declare function unframe(value: string): [string, HeaderCarrier];
68
+ /** The out-of-band headers carried by a stored list value (empty for a bare value). */
69
+ declare function headersOf(value: string): HeaderCarrier;
34
70
  /** Options for {@link RedisPublisher.publish}. */
35
71
  interface PublishOptions {
36
72
  traceId?: string;
73
+ /**
74
+ * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C
75
+ * `traceparent` written by `@babelqueue/core/otel`'s `publish`. A non-empty carrier RPUSHes a
76
+ * transport-owned frame; an empty/omitted carrier stays a byte-identical bare publish.
77
+ */
78
+ headers?: HeaderCarrier;
37
79
  }
38
80
  /** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
39
81
  declare class RedisPublisher {
@@ -42,11 +84,21 @@ declare class RedisPublisher {
42
84
  private constructor();
43
85
  /** A publisher over `client`, producing to the list named `queue`. */
44
86
  static create(client: RedisClient, queue: string): RedisPublisher;
45
- /** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
87
+ /**
88
+ * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
89
+ * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
90
+ * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
91
+ * pushed byte-for-byte (GR-1, no regression).
92
+ */
46
93
  publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
47
94
  }
48
- /** A URN handler. Receives the validated envelope and the raw list element. */
49
- type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;
95
+ /**
96
+ * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band
97
+ * {@link HeaderCarrier} carried beside it (empty for a bare value). Pass `headers` to
98
+ * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer
99
+ * span (ADR-0028).
100
+ */
101
+ type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;
50
102
  /** A map of URN → handler. */
51
103
  type BabelHandlers = Record<string, BabelHandler>;
52
104
  /** Options for {@link RedisConsumer}. */
@@ -86,7 +138,14 @@ declare class RedisConsumer {
86
138
  poll(): Promise<boolean>;
87
139
  /** Poll while `shouldContinue` returns true. */
88
140
  run(shouldContinue: () => boolean): Promise<void>;
89
- /** Route + settle one reserved element. Exposed for testing. */
141
+ /**
142
+ * Route + settle one reserved element. Exposed for testing.
143
+ *
144
+ * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
145
+ * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
146
+ * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
147
+ * to the handler. Settling/dead-lettering still operate on the original stored value.
148
+ */
90
149
  handle(raw: string): Promise<void>;
91
150
  private onUnknownUrn;
92
151
  private retryOrDeadLetter;
@@ -99,4 +158,4 @@ declare class RedisConsumer {
99
158
  private report;
100
159
  }
101
160
 
102
- export { type BabelHandler, type BabelHandlers, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, redisValue };
161
+ export { type BabelHandler, type BabelHandlers, FRAME_KEY, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, frameValue, headersOf, redisValue, unframe };
package/dist/index.js CHANGED
@@ -1,8 +1,49 @@
1
1
  // src/index.ts
2
2
  import { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from "@babelqueue/core";
3
+
4
+ // src/headers.ts
5
+ function sanitizeHeaders(headers) {
6
+ const out = {};
7
+ if (!headers) return out;
8
+ for (const key of Object.keys(headers)) {
9
+ const value = headers[key];
10
+ if (key === "" || value == null || value === "") continue;
11
+ out[key] = value;
12
+ }
13
+ return out;
14
+ }
15
+
16
+ // src/index.ts
3
17
  function redisValue(envelope) {
4
18
  return EnvelopeCodec.encode(envelope);
5
19
  }
20
+ var FRAME_KEY = "__bq_frame";
21
+ var FRAME_VERSION = 1;
22
+ function frameValue(body, headers) {
23
+ const clean = sanitizeHeaders(headers);
24
+ if (Object.keys(clean).length === 0) return body;
25
+ const frame = { [FRAME_KEY]: FRAME_VERSION, headers: clean, body };
26
+ return JSON.stringify(frame);
27
+ }
28
+ function unframe(value) {
29
+ if (value === "" || value[0] !== "{" || !value.includes(`"${FRAME_KEY}"`)) {
30
+ return [value, {}];
31
+ }
32
+ let decoded;
33
+ try {
34
+ decoded = JSON.parse(value);
35
+ } catch {
36
+ return [value, {}];
37
+ }
38
+ if (decoded == null || typeof decoded !== "object" || !(FRAME_KEY in decoded) || !decoded[FRAME_KEY] || typeof decoded.body !== "string") {
39
+ return [value, {}];
40
+ }
41
+ const frame = decoded;
42
+ return [frame.body, sanitizeHeaders(frame.headers)];
43
+ }
44
+ function headersOf(value) {
45
+ return unframe(value)[1];
46
+ }
6
47
  var RedisPublisher = class _RedisPublisher {
7
48
  constructor(client, queue) {
8
49
  this.client = client;
@@ -14,10 +55,15 @@ var RedisPublisher = class _RedisPublisher {
14
55
  static create(client, queue) {
15
56
  return new _RedisPublisher(client, queue);
16
57
  }
17
- /** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
58
+ /**
59
+ * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When
60
+ * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value
61
+ * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is
62
+ * pushed byte-for-byte (GR-1, no regression).
63
+ */
18
64
  async publish(urn, data, options = {}) {
19
65
  const envelope = EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
20
- await this.client.rpush(this.queue, redisValue(envelope));
66
+ await this.client.rpush(this.queue, frameValue(redisValue(envelope), options.headers));
21
67
  return envelope.meta.id;
22
68
  }
23
69
  };
@@ -55,9 +101,17 @@ var RedisConsumer = class {
55
101
  await this.poll();
56
102
  }
57
103
  }
58
- /** Route + settle one reserved element. Exposed for testing. */
104
+ /**
105
+ * Route + settle one reserved element. Exposed for testing.
106
+ *
107
+ * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header
108
+ * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim
109
+ * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced
110
+ * to the handler. Settling/dead-lettering still operate on the original stored value.
111
+ */
59
112
  async handle(raw) {
60
- const decoded = EnvelopeCodec.decode(raw);
113
+ const [body, headers] = unframe(raw);
114
+ const decoded = EnvelopeCodec.decode(body);
61
115
  if (!EnvelopeCodec.accepts(decoded)) {
62
116
  this.report(new BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis."), decoded, raw);
63
117
  await this.deadLetterRaw(raw);
@@ -72,7 +126,7 @@ var RedisConsumer = class {
72
126
  return;
73
127
  }
74
128
  try {
75
- await handler(envelope, raw);
129
+ await handler(envelope, raw, headers);
76
130
  await this.settle(raw);
77
131
  } catch (error) {
78
132
  this.report(error, envelope, raw);
@@ -134,8 +188,12 @@ var RedisConsumer = class {
134
188
  }
135
189
  };
136
190
  export {
191
+ FRAME_KEY,
137
192
  RedisConsumer,
138
193
  RedisPublisher,
139
- redisValue
194
+ frameValue,
195
+ headersOf,
196
+ redisValue,
197
+ unframe
140
198
  };
141
199
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Redis adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue\n * pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so\n * an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service\n * speaks the same wire contract as the PHP, Python and Go SDKs.\n *\n * Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,\n * byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job\n * layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable\n * queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a\n * separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns\n * end-to-end.\n *\n * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide\n * the client (an `ioredis` instance satisfies the adapter structurally).\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, IncomingEnvelope } from \"@babelqueue/core\";\n\n// --- Minimal Redis shape (a structural subset of ioredis) ----------------------\n\n/** The subset of an ioredis client this adapter calls. */\nexport interface RedisClient {\n /** Append a value to the tail of a list; returns the new length. */\n rpush(key: string, value: string): Promise<number>;\n /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up\n * to `timeout` seconds; resolves to the value, or `null` on timeout. */\n brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;\n /** Remove `count` occurrences of `value` from a list; returns how many were removed. */\n lrem(key: string, count: number, value: string): Promise<number>;\n}\n\n// --- Payload (contract §1) -----------------------------------------------------\n\n/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */\nexport function redisValue(envelope: Envelope): string {\n return EnvelopeCodec.encode(envelope);\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link RedisPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n}\n\n/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */\nexport class RedisPublisher {\n private constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n ) {}\n\n /** A publisher over `client`, producing to the list named `queue`. */\n static create(client: RedisClient, queue: string): RedisPublisher {\n return new RedisPublisher(client, queue);\n }\n\n /** Build + `RPUSH` 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.queue, traceId: options.traceId });\n await this.client.rpush(this.queue, redisValue(envelope));\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/** A URN handler. Receives the validated envelope and the raw list element. */\nexport type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link RedisConsumer}. */\nexport interface RedisConsumerOptions {\n /** Suffix for the per-queue reservation list (default `\":processing\"`). */\n processingSuffix?: string;\n /** Block timeout (seconds) for a single reserve (default 5). */\n blockTimeout?: number;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */\n deadLetterQueue?: string | null;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;\n}\n\n/**\n * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +\n * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.\n * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,\n * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in\n * the body and the runtime owns retry.\n */\nexport class RedisConsumer {\n private readonly processing: string;\n private readonly blockTimeout: number;\n private readonly maxTries: number;\n private readonly dlq: string | null;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n private readonly handlers: BabelHandlers,\n private readonly options: RedisConsumerOptions = {},\n ) {\n this.processing = queue + (options.processingSuffix ?? \":processing\");\n this.blockTimeout = options.blockTimeout ?? 5;\n this.maxTries = options.maxTries ?? 3;\n this.dlq = options.deadLetterQueue === undefined ? `${queue}.dlq` : options.deadLetterQueue;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */\n async poll(): Promise<boolean> {\n const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);\n if (raw == null) return false;\n await this.handle(raw);\n return true;\n }\n\n /** Poll while `shouldContinue` returns true. */\n async run(shouldContinue: () => boolean): Promise<void> {\n while (shouldContinue()) {\n await this.poll();\n }\n }\n\n /** Route + settle one reserved element. Exposed for testing. */\n async handle(raw: string): Promise<void> {\n const decoded = EnvelopeCodec.decode(raw);\n\n if (!EnvelopeCodec.accepts(decoded)) {\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Redis.\"), decoded, raw);\n await this.deadLetterRaw(raw);\n await this.settle(raw);\n return;\n }\n\n const envelope = decoded as Envelope;\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(envelope, urn, raw);\n return;\n }\n\n try {\n await handler(envelope, raw);\n await this.settle(raw);\n } catch (error) {\n this.report(error, envelope, raw);\n await this.retryOrDeadLetter(envelope, raw, error);\n }\n }\n\n private async onUnknownUrn(envelope: Envelope, urn: string, raw: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.requeue(envelope);\n await this.settle(raw);\n return;\n default: {\n // FAIL: surface and leave the element in the reservation list (recoverable).\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, raw);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(envelope: Envelope, raw: string, error: unknown): Promise<void> {\n if ((envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.requeue(envelope);\n } else {\n await this.deadLetter(envelope, \"failed\", error);\n }\n await this.settle(raw);\n }\n\n /** Republish with `attempts + 1` onto the work list (at-least-once retry). */\n private async requeue(envelope: Envelope): Promise<void> {\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n await this.client.rpush(this.queue, redisValue(bumped));\n }\n\n private async deadLetter(envelope: Envelope, reason: string, error?: unknown): Promise<void> {\n if (!this.dlq) return; // dead-lettering disabled → drop\n const annotated = annotate(envelope, reason, envelope.meta?.queue ?? this.queue, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n await this.client.rpush(this.dlq, redisValue(annotated));\n }\n\n private async deadLetterRaw(raw: string): Promise<void> {\n if (!this.dlq) return;\n await this.client.rpush(this.dlq, raw);\n }\n\n /** Remove the element from the reservation list (acknowledge). */\n private async settle(raw: string): Promise<void> {\n await this.client.lrem(this.processing, 1, raw);\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, raw: string): void {\n this.options.onError?.(error, envelope, raw);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/headers.ts"],"sourcesContent":["/**\n * Redis adapter for BabelQueue.\n *\n * A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue\n * pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so\n * an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service\n * speaks the same wire contract as the PHP, Python and Go SDKs.\n *\n * Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,\n * byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job\n * layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable\n * queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a\n * separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns\n * end-to-end.\n *\n * This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide\n * the client (an `ioredis` instance satisfies the adapter structurally).\n *\n * **Out-of-band headers (ADR-0028).** A Redis list element *is* the canonical envelope JSON (the\n * `LREM` ack handle is that exact value), so — unlike AMQP headers or SQS `MessageAttributes` —\n * there is no native per-message metadata channel. To carry a {@link HeaderCarrier} (e.g. a W3C\n * `traceparent` for cross-hop span linkage) the adapter owns a tiny JSON *frame* distinct from the\n * wire envelope:\n *\n * {\"__bq_frame\":1,\"headers\":{\"traceparent\":\"00-…\"},\"body\":\"<raw wire envelope>\"}\n *\n * `RPUSH` stores the frame, so the `LREM` ack handle stays byte-for-byte what was pushed and the\n * reliable-queue semantics (`RPUSH`/`BRPOPLPUSH`/`LREM`) are untouched. Framing is **opt-in and\n * backward compatible**: only `publish` with a non-empty `headers` carrier writes a frame; a plain\n * publish stores the **bare** envelope byte-for-byte, exactly as before. The consumer detects\n * frame-vs-bare by the reserved `__bq_frame` sentinel (a frozen envelope can never carry it), so a\n * bare value consumes with empty headers and cross-version queues interoperate. This mirrors the\n * Go `/redis` and PHP `RedisTransport` framing byte-for-byte. GR-1: the wire envelope is never\n * touched.\n */\n\nimport { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from \"@babelqueue/core\";\nimport type { Envelope, HeaderCarrier, IncomingEnvelope } from \"@babelqueue/core\";\n\nimport { sanitizeHeaders } from \"./headers.js\";\n\n// --- Minimal Redis shape (a structural subset of ioredis) ----------------------\n\n/** The subset of an ioredis client this adapter calls. */\nexport interface RedisClient {\n /** Append a value to the tail of a list; returns the new length. */\n rpush(key: string, value: string): Promise<number>;\n /** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up\n * to `timeout` seconds; resolves to the value, or `null` on timeout. */\n brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;\n /** Remove `count` occurrences of `value` from a list; returns how many were removed. */\n lrem(key: string, count: number, value: string): Promise<number>;\n}\n\n// --- Payload (contract §1) -----------------------------------------------------\n\n/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */\nexport function redisValue(envelope: Envelope): string {\n return EnvelopeCodec.encode(envelope);\n}\n\n// --- Header frame (ADR-0028) ---------------------------------------------------\n\n/** The reserved discriminator key + current version of the transport-owned header frame. */\nexport const FRAME_KEY = \"__bq_frame\";\nconst FRAME_VERSION = 1;\n\n/** The transport-owned frame the list value carries when out-of-band headers accompany a message. */\ninterface HeaderFrame {\n __bq_frame: number;\n headers?: HeaderCarrier;\n body: string;\n}\n\n/**\n * The pure produce-side decision: the exact string to `RPUSH` for `body` + `headers`. With no\n * usable headers it returns `body` verbatim (the **bare** form, so a plain publish and a\n * header-less publish store byte-identical values); otherwise it returns the transport-owned frame\n * JSON. Kept pure so the framing decision is unit-testable without a broker.\n */\nexport function frameValue(body: string, headers: HeaderCarrier | null | undefined): string {\n const clean = sanitizeHeaders(headers);\n if (Object.keys(clean).length === 0) return body;\n const frame: HeaderFrame = { [FRAME_KEY]: FRAME_VERSION, headers: clean, body };\n return JSON.stringify(frame);\n}\n\n/**\n * Interpret a stored Redis list value: `[wire-envelope-body, headers]`. A value is a header frame\n * iff it is a JSON object carrying the reserved `__bq_frame` sentinel (a frozen wire envelope never\n * has it); then it yields the unframed body plus the carried headers. Any other value — a bare\n * envelope, non-JSON, or JSON without the sentinel — is returned verbatim as the body with empty\n * headers, so older / cross-version queue values consume exactly as before.\n */\nexport function unframe(value: string): [string, HeaderCarrier] {\n // Cheap reject: a frame is always a JSON object, and the sentinel substring must appear. This\n // avoids a full parse for the overwhelmingly common bare-envelope case (only short-circuits\n // negatives).\n if (value === \"\" || value[0] !== \"{\" || !value.includes(`\"${FRAME_KEY}\"`)) {\n return [value, {}];\n }\n let decoded: unknown;\n try {\n decoded = JSON.parse(value);\n } catch {\n return [value, {}];\n }\n if (\n decoded == null ||\n typeof decoded !== \"object\" ||\n !(FRAME_KEY in decoded) ||\n !(decoded as HeaderFrame)[FRAME_KEY] ||\n typeof (decoded as HeaderFrame).body !== \"string\"\n ) {\n return [value, {}];\n }\n const frame = decoded as HeaderFrame;\n return [frame.body, sanitizeHeaders(frame.headers)];\n}\n\n/** The out-of-band headers carried by a stored list value (empty for a bare value). */\nexport function headersOf(value: string): HeaderCarrier {\n return unframe(value)[1];\n}\n\n// --- Publisher -----------------------------------------------------------------\n\n/** Options for {@link RedisPublisher.publish}. */\nexport interface PublishOptions {\n traceId?: string;\n /**\n * Out-of-band transport headers to carry beside the frozen envelope (ADR-0028) — e.g. a W3C\n * `traceparent` written by `@babelqueue/core/otel`'s `publish`. A non-empty carrier RPUSHes a\n * transport-owned frame; an empty/omitted carrier stays a byte-identical bare publish.\n */\n headers?: HeaderCarrier;\n}\n\n/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */\nexport class RedisPublisher {\n private constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n ) {}\n\n /** A publisher over `client`, producing to the list named `queue`. */\n static create(client: RedisClient, queue: string): RedisPublisher {\n return new RedisPublisher(client, queue);\n }\n\n /**\n * Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). When\n * `options.headers` carries any out-of-band header (e.g. a `traceparent`, ADR-0028) the value\n * pushed is a transport-owned frame that wraps the envelope; otherwise the bare envelope is\n * pushed byte-for-byte (GR-1, no regression).\n */\n async publish(urn: string, data: Record<string, unknown>, options: PublishOptions = {}): Promise<string> {\n const envelope = EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });\n await this.client.rpush(this.queue, frameValue(redisValue(envelope), options.headers));\n return envelope.meta.id;\n }\n}\n\n// --- Consumer ------------------------------------------------------------------\n\n/**\n * A URN handler. Receives the validated envelope, the raw stored list element, and the out-of-band\n * {@link HeaderCarrier} carried beside it (empty for a bare value). Pass `headers` to\n * `@babelqueue/core/otel`'s `wrapHandler` to link the consumer span as a child of the producer\n * span (ADR-0028).\n */\nexport type BabelHandler = (envelope: Envelope, raw: string, headers: HeaderCarrier) => unknown | Promise<unknown>;\n\n/** A map of URN → handler. */\nexport type BabelHandlers = Record<string, BabelHandler>;\n\n/** Options for {@link RedisConsumer}. */\nexport interface RedisConsumerOptions {\n /** Suffix for the per-queue reservation list (default `\":processing\"`). */\n processingSuffix?: string;\n /** Block timeout (seconds) for a single reserve (default 5). */\n blockTimeout?: number;\n /** Attempts before terminal dead-lettering (default 3). */\n maxTries?: number;\n /** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */\n deadLetterQueue?: string | null;\n /** Strategy for a URN with no handler (default `fail`). */\n unknownUrn?: string;\n /** Called for a non-conformant message, an unmapped URN, or a throwing handler. */\n onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;\n}\n\n/**\n * Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +\n * validate, route to the handler for its URN, then `LREM` it from the reservation list on success.\n * A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,\n * then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in\n * the body and the runtime owns retry.\n */\nexport class RedisConsumer {\n private readonly processing: string;\n private readonly blockTimeout: number;\n private readonly maxTries: number;\n private readonly dlq: string | null;\n private readonly unknownUrn: string;\n\n constructor(\n private readonly client: RedisClient,\n private readonly queue: string,\n private readonly handlers: BabelHandlers,\n private readonly options: RedisConsumerOptions = {},\n ) {\n this.processing = queue + (options.processingSuffix ?? \":processing\");\n this.blockTimeout = options.blockTimeout ?? 5;\n this.maxTries = options.maxTries ?? 3;\n this.dlq = options.deadLetterQueue === undefined ? `${queue}.dlq` : options.deadLetterQueue;\n this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;\n }\n\n /** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */\n async poll(): Promise<boolean> {\n const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);\n if (raw == null) return false;\n await this.handle(raw);\n return true;\n }\n\n /** Poll while `shouldContinue` returns true. */\n async run(shouldContinue: () => boolean): Promise<void> {\n while (shouldContinue()) {\n await this.poll();\n }\n }\n\n /**\n * Route + settle one reserved element. Exposed for testing.\n *\n * `raw` is the **stored** list value (the `LREM` ack handle); it may be a transport-owned header\n * frame or a bare envelope. It is unframed first so the envelope is decoded from the verbatim\n * wire body while the carried out-of-band headers (e.g. a `traceparent`, ADR-0028) are surfaced\n * to the handler. Settling/dead-lettering still operate on the original stored value.\n */\n async handle(raw: string): Promise<void> {\n const [body, headers] = unframe(raw);\n const decoded = EnvelopeCodec.decode(body);\n\n if (!EnvelopeCodec.accepts(decoded)) {\n this.report(new BabelQueueError(\"Rejected a non-conformant BabelQueue envelope from Redis.\"), decoded, raw);\n await this.deadLetterRaw(raw);\n await this.settle(raw);\n return;\n }\n\n const envelope = decoded as Envelope;\n const urn = EnvelopeCodec.urn(envelope);\n const handler = this.handlers[urn];\n if (!handler) {\n await this.onUnknownUrn(envelope, urn, raw);\n return;\n }\n\n try {\n await handler(envelope, raw, headers);\n await this.settle(raw);\n } catch (error) {\n this.report(error, envelope, raw);\n await this.retryOrDeadLetter(envelope, raw, error);\n }\n }\n\n private async onUnknownUrn(envelope: Envelope, urn: string, raw: string): Promise<void> {\n switch (this.unknownUrn) {\n case UnknownUrnStrategy.DELETE:\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.DEAD_LETTER:\n await this.deadLetter(envelope, \"unknown_urn\");\n await this.settle(raw);\n return;\n case UnknownUrnStrategy.RELEASE:\n await this.requeue(envelope);\n await this.settle(raw);\n return;\n default: {\n // FAIL: surface and leave the element in the reservation list (recoverable).\n const error = new UnknownUrnError(urn);\n this.report(error, envelope, raw);\n throw error;\n }\n }\n }\n\n private async retryOrDeadLetter(envelope: Envelope, raw: string, error: unknown): Promise<void> {\n if ((envelope.attempts ?? 0) + 1 < this.maxTries) {\n await this.requeue(envelope);\n } else {\n await this.deadLetter(envelope, \"failed\", error);\n }\n await this.settle(raw);\n }\n\n /** Republish with `attempts + 1` onto the work list (at-least-once retry). */\n private async requeue(envelope: Envelope): Promise<void> {\n const bumped: Envelope = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };\n await this.client.rpush(this.queue, redisValue(bumped));\n }\n\n private async deadLetter(envelope: Envelope, reason: string, error?: unknown): Promise<void> {\n if (!this.dlq) return; // dead-lettering disabled → drop\n const annotated = annotate(envelope, reason, envelope.meta?.queue ?? this.queue, {\n attempts: envelope.attempts ?? 0,\n error: error instanceof Error ? error.message : null,\n exception: error instanceof Error ? error.name : null,\n });\n await this.client.rpush(this.dlq, redisValue(annotated));\n }\n\n private async deadLetterRaw(raw: string): Promise<void> {\n if (!this.dlq) return;\n await this.client.rpush(this.dlq, raw);\n }\n\n /** Remove the element from the reservation list (acknowledge). */\n private async settle(raw: string): Promise<void> {\n await this.client.lrem(this.processing, 1, raw);\n }\n\n private report(error: unknown, envelope: IncomingEnvelope | null, raw: string): void {\n this.options.onError?.(error, envelope, raw);\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, so a caller can treat the result as \"no headers\" via `Object.keys(...).length === 0`.\n * 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"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@babelqueue/redis",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Redis adapter for BabelQueue — a canonical-envelope publisher and a URN-routed consumer over the §1 reliable-queue pattern (RPUSH / BRPOPLPUSH-reserve / LREM-ack) on ioredis.",
5
- "keywords": ["babelqueue", "redis", "ioredis", "queue", "polyglot", "messaging"],
5
+ "keywords": [
6
+ "babelqueue",
7
+ "redis",
8
+ "ioredis",
9
+ "queue",
10
+ "polyglot",
11
+ "messaging"
12
+ ],
6
13
  "homepage": "https://babelqueue.com",
7
14
  "bugs": {
8
15
  "url": "https://github.com/BabelQueue/babelqueue-node-adapters/issues"
@@ -30,7 +37,9 @@
30
37
  "main": "./dist/index.cjs",
31
38
  "module": "./dist/index.js",
32
39
  "types": "./dist/index.d.ts",
33
- "files": ["dist"],
40
+ "files": [
41
+ "dist"
42
+ ],
34
43
  "sideEffects": false,
35
44
  "engines": {
36
45
  "node": ">=18"
@@ -41,7 +50,7 @@
41
50
  "test": "node --import tsx --test test/*.test.ts"
42
51
  },
43
52
  "dependencies": {
44
- "@babelqueue/core": "^1.0.0"
53
+ "@babelqueue/core": "^1.4.0"
45
54
  },
46
55
  "peerDependencies": {
47
56
  "ioredis": "^5.0.0"