@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 +20 -0
- package/dist/index.cjs +69 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -6
- package/dist/index.d.ts +65 -6
- package/dist/index.js +64 -6
- package/dist/index.js.map +1 -1
- package/package.json +13 -4
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
225
|
+
frameValue,
|
|
226
|
+
headersOf,
|
|
227
|
+
redisValue,
|
|
228
|
+
unframe
|
|
167
229
|
});
|
|
168
230
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.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/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
|
-
/**
|
|
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
|
-
/**
|
|
49
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
49
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
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": [
|
|
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.
|
|
53
|
+
"@babelqueue/core": "^1.4.0"
|
|
45
54
|
},
|
|
46
55
|
"peerDependencies": {
|
|
47
56
|
"ioredis": "^5.0.0"
|