@babelqueue/redis 1.0.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 +78 -0
- package/dist/index.cjs +168 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @babelqueue/redis
|
|
2
|
+
|
|
3
|
+
Redis adapter for [BabelQueue](https://babelqueue.com) — a canonical-envelope **publisher** and a
|
|
4
|
+
URN-routed **consumer** over the §1 reliable-queue pattern, so a Redis-based Node service speaks
|
|
5
|
+
the same wire contract as the PHP, Python and Go SDKs. Implements
|
|
6
|
+
[§1 of the broker-bindings contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis).
|
|
7
|
+
|
|
8
|
+
Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,
|
|
9
|
+
byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job
|
|
10
|
+
layout). Produce is `RPUSH`; consume reserves the head into a `<queue>:processing` list
|
|
11
|
+
(`BRPOPLPUSH`, so an in-flight message survives a crash), routes by URN, and `LREM`s it on success.
|
|
12
|
+
|
|
13
|
+
> This is a Node-owned reliable queue. Full parity with Laravel's reserved-sorted-set reservation
|
|
14
|
+
> on a *shared* Redis queue is a separate task — for a mixed PHP+Node fleet, prefer a queue this
|
|
15
|
+
> consumer owns end-to-end.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @babelqueue/redis ioredis
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`ioredis` is an optional peer — you provide the client; an `ioredis` instance satisfies the
|
|
24
|
+
adapter structurally.
|
|
25
|
+
|
|
26
|
+
## Produce
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import Redis from "ioredis";
|
|
30
|
+
import { RedisPublisher } from "@babelqueue/redis";
|
|
31
|
+
|
|
32
|
+
const client = new Redis("redis://localhost:6379/0");
|
|
33
|
+
const id = await RedisPublisher.create(client, "orders").publish("urn:babel:orders:created", { order_id: 1042 });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`publish(urn, data, { traceId? })` returns the message `meta.id`.
|
|
37
|
+
|
|
38
|
+
## Consume
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { RedisConsumer, type BabelHandlers } from "@babelqueue/redis";
|
|
42
|
+
|
|
43
|
+
const handlers: BabelHandlers = {
|
|
44
|
+
"urn:babel:orders:created": (envelope, raw) => {
|
|
45
|
+
// envelope.data, envelope.trace_id, envelope.attempts ...
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const consumer = new RedisConsumer(client, "orders", handlers, {
|
|
50
|
+
maxTries: 3, // requeue with attempts+1, then <queue>.dlq
|
|
51
|
+
onError: (err) => console.error(err),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await consumer.run(() => true); // reserve → process → LREM, until you stop it
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A successful handler `LREM`s the element from `<queue>:processing`. A throwing handler requeues
|
|
58
|
+
the envelope with `attempts + 1` (at-least-once) up to `maxTries`, then dead-letters to
|
|
59
|
+
`<queue>.dlq` with a `dead_letter` block. Unknown-URN strategy is one of
|
|
60
|
+
`fail` / `delete` / `release` / `dead_letter`. `poll()` and `handle(raw)` are exposed for testing.
|
|
61
|
+
|
|
62
|
+
## Contract mapping (§1)
|
|
63
|
+
|
|
64
|
+
| Envelope | Redis |
|
|
65
|
+
| :--- | :--- |
|
|
66
|
+
| body | the list element — the canonical envelope JSON, **verbatim** (no wrapping) |
|
|
67
|
+
| produce | `RPUSH <queue> <envelope>` |
|
|
68
|
+
| reserve | `BRPOPLPUSH <queue> <queue>:processing` (crash-safe) |
|
|
69
|
+
| ack | `LREM <queue>:processing 1 <element>` |
|
|
70
|
+
| retry | requeue with `attempts + 1` (the body owns the count) |
|
|
71
|
+
| dead-letter | `RPUSH <queue>.dlq` + `dead_letter` block |
|
|
72
|
+
|
|
73
|
+
The envelope is unchanged (`schema_version` stays `1`). The `ioredis` client is replaced with a
|
|
74
|
+
fake in the unit suite — no Redis, no network.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
RedisConsumer: () => RedisConsumer,
|
|
24
|
+
RedisPublisher: () => RedisPublisher,
|
|
25
|
+
redisValue: () => redisValue
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_core = require("@babelqueue/core");
|
|
29
|
+
function redisValue(envelope) {
|
|
30
|
+
return import_core.EnvelopeCodec.encode(envelope);
|
|
31
|
+
}
|
|
32
|
+
var RedisPublisher = class _RedisPublisher {
|
|
33
|
+
constructor(client, queue) {
|
|
34
|
+
this.client = client;
|
|
35
|
+
this.queue = queue;
|
|
36
|
+
}
|
|
37
|
+
client;
|
|
38
|
+
queue;
|
|
39
|
+
/** A publisher over `client`, producing to the list named `queue`. */
|
|
40
|
+
static create(client, queue) {
|
|
41
|
+
return new _RedisPublisher(client, queue);
|
|
42
|
+
}
|
|
43
|
+
/** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
|
|
44
|
+
async publish(urn, data, options = {}) {
|
|
45
|
+
const envelope = import_core.EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
|
|
46
|
+
await this.client.rpush(this.queue, redisValue(envelope));
|
|
47
|
+
return envelope.meta.id;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var RedisConsumer = class {
|
|
51
|
+
constructor(client, queue, handlers, options = {}) {
|
|
52
|
+
this.client = client;
|
|
53
|
+
this.queue = queue;
|
|
54
|
+
this.handlers = handlers;
|
|
55
|
+
this.options = options;
|
|
56
|
+
this.processing = queue + (options.processingSuffix ?? ":processing");
|
|
57
|
+
this.blockTimeout = options.blockTimeout ?? 5;
|
|
58
|
+
this.maxTries = options.maxTries ?? 3;
|
|
59
|
+
this.dlq = options.deadLetterQueue === void 0 ? `${queue}.dlq` : options.deadLetterQueue;
|
|
60
|
+
this.unknownUrn = options.unknownUrn ?? import_core.UnknownUrnStrategy.FAIL;
|
|
61
|
+
}
|
|
62
|
+
client;
|
|
63
|
+
queue;
|
|
64
|
+
handlers;
|
|
65
|
+
options;
|
|
66
|
+
processing;
|
|
67
|
+
blockTimeout;
|
|
68
|
+
maxTries;
|
|
69
|
+
dlq;
|
|
70
|
+
unknownUrn;
|
|
71
|
+
/** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
|
|
72
|
+
async poll() {
|
|
73
|
+
const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);
|
|
74
|
+
if (raw == null) return false;
|
|
75
|
+
await this.handle(raw);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
/** Poll while `shouldContinue` returns true. */
|
|
79
|
+
async run(shouldContinue) {
|
|
80
|
+
while (shouldContinue()) {
|
|
81
|
+
await this.poll();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Route + settle one reserved element. Exposed for testing. */
|
|
85
|
+
async handle(raw) {
|
|
86
|
+
const decoded = import_core.EnvelopeCodec.decode(raw);
|
|
87
|
+
if (!import_core.EnvelopeCodec.accepts(decoded)) {
|
|
88
|
+
this.report(new import_core.BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis."), decoded, raw);
|
|
89
|
+
await this.deadLetterRaw(raw);
|
|
90
|
+
await this.settle(raw);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const envelope = decoded;
|
|
94
|
+
const urn = import_core.EnvelopeCodec.urn(envelope);
|
|
95
|
+
const handler = this.handlers[urn];
|
|
96
|
+
if (!handler) {
|
|
97
|
+
await this.onUnknownUrn(envelope, urn, raw);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await handler(envelope, raw);
|
|
102
|
+
await this.settle(raw);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.report(error, envelope, raw);
|
|
105
|
+
await this.retryOrDeadLetter(envelope, raw, error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async onUnknownUrn(envelope, urn, raw) {
|
|
109
|
+
switch (this.unknownUrn) {
|
|
110
|
+
case import_core.UnknownUrnStrategy.DELETE:
|
|
111
|
+
await this.settle(raw);
|
|
112
|
+
return;
|
|
113
|
+
case import_core.UnknownUrnStrategy.DEAD_LETTER:
|
|
114
|
+
await this.deadLetter(envelope, "unknown_urn");
|
|
115
|
+
await this.settle(raw);
|
|
116
|
+
return;
|
|
117
|
+
case import_core.UnknownUrnStrategy.RELEASE:
|
|
118
|
+
await this.requeue(envelope);
|
|
119
|
+
await this.settle(raw);
|
|
120
|
+
return;
|
|
121
|
+
default: {
|
|
122
|
+
const error = new import_core.UnknownUrnError(urn);
|
|
123
|
+
this.report(error, envelope, raw);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async retryOrDeadLetter(envelope, raw, error) {
|
|
129
|
+
if ((envelope.attempts ?? 0) + 1 < this.maxTries) {
|
|
130
|
+
await this.requeue(envelope);
|
|
131
|
+
} else {
|
|
132
|
+
await this.deadLetter(envelope, "failed", error);
|
|
133
|
+
}
|
|
134
|
+
await this.settle(raw);
|
|
135
|
+
}
|
|
136
|
+
/** Republish with `attempts + 1` onto the work list (at-least-once retry). */
|
|
137
|
+
async requeue(envelope) {
|
|
138
|
+
const bumped = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };
|
|
139
|
+
await this.client.rpush(this.queue, redisValue(bumped));
|
|
140
|
+
}
|
|
141
|
+
async deadLetter(envelope, reason, error) {
|
|
142
|
+
if (!this.dlq) return;
|
|
143
|
+
const annotated = (0, import_core.annotate)(envelope, reason, envelope.meta?.queue ?? this.queue, {
|
|
144
|
+
attempts: envelope.attempts ?? 0,
|
|
145
|
+
error: error instanceof Error ? error.message : null,
|
|
146
|
+
exception: error instanceof Error ? error.name : null
|
|
147
|
+
});
|
|
148
|
+
await this.client.rpush(this.dlq, redisValue(annotated));
|
|
149
|
+
}
|
|
150
|
+
async deadLetterRaw(raw) {
|
|
151
|
+
if (!this.dlq) return;
|
|
152
|
+
await this.client.rpush(this.dlq, raw);
|
|
153
|
+
}
|
|
154
|
+
/** Remove the element from the reservation list (acknowledge). */
|
|
155
|
+
async settle(raw) {
|
|
156
|
+
await this.client.lrem(this.processing, 1, raw);
|
|
157
|
+
}
|
|
158
|
+
report(error, envelope, raw) {
|
|
159
|
+
this.options.onError?.(error, envelope, raw);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
163
|
+
0 && (module.exports = {
|
|
164
|
+
RedisConsumer,
|
|
165
|
+
RedisPublisher,
|
|
166
|
+
redisValue
|
|
167
|
+
});
|
|
168
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Envelope, IncomingEnvelope } from '@babelqueue/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redis adapter for BabelQueue.
|
|
5
|
+
*
|
|
6
|
+
* A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue
|
|
7
|
+
* pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so
|
|
8
|
+
* an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service
|
|
9
|
+
* speaks the same wire contract as the PHP, Python and Go SDKs.
|
|
10
|
+
*
|
|
11
|
+
* Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,
|
|
12
|
+
* byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job
|
|
13
|
+
* layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable
|
|
14
|
+
* queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a
|
|
15
|
+
* separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns
|
|
16
|
+
* end-to-end.
|
|
17
|
+
*
|
|
18
|
+
* This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide
|
|
19
|
+
* the client (an `ioredis` instance satisfies the adapter structurally).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The subset of an ioredis client this adapter calls. */
|
|
23
|
+
interface RedisClient {
|
|
24
|
+
/** Append a value to the tail of a list; returns the new length. */
|
|
25
|
+
rpush(key: string, value: string): Promise<number>;
|
|
26
|
+
/** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up
|
|
27
|
+
* to `timeout` seconds; resolves to the value, or `null` on timeout. */
|
|
28
|
+
brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;
|
|
29
|
+
/** Remove `count` occurrences of `value` from a list; returns how many were removed. */
|
|
30
|
+
lrem(key: string, count: number, value: string): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
|
|
33
|
+
declare function redisValue(envelope: Envelope): string;
|
|
34
|
+
/** Options for {@link RedisPublisher.publish}. */
|
|
35
|
+
interface PublishOptions {
|
|
36
|
+
traceId?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
|
|
39
|
+
declare class RedisPublisher {
|
|
40
|
+
private readonly client;
|
|
41
|
+
private readonly queue;
|
|
42
|
+
private constructor();
|
|
43
|
+
/** A publisher over `client`, producing to the list named `queue`. */
|
|
44
|
+
static create(client: RedisClient, queue: string): RedisPublisher;
|
|
45
|
+
/** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
|
|
46
|
+
publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
/** A URN handler. Receives the validated envelope and the raw list element. */
|
|
49
|
+
type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;
|
|
50
|
+
/** A map of URN → handler. */
|
|
51
|
+
type BabelHandlers = Record<string, BabelHandler>;
|
|
52
|
+
/** Options for {@link RedisConsumer}. */
|
|
53
|
+
interface RedisConsumerOptions {
|
|
54
|
+
/** Suffix for the per-queue reservation list (default `":processing"`). */
|
|
55
|
+
processingSuffix?: string;
|
|
56
|
+
/** Block timeout (seconds) for a single reserve (default 5). */
|
|
57
|
+
blockTimeout?: number;
|
|
58
|
+
/** Attempts before terminal dead-lettering (default 3). */
|
|
59
|
+
maxTries?: number;
|
|
60
|
+
/** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */
|
|
61
|
+
deadLetterQueue?: string | null;
|
|
62
|
+
/** Strategy for a URN with no handler (default `fail`). */
|
|
63
|
+
unknownUrn?: string;
|
|
64
|
+
/** Called for a non-conformant message, an unmapped URN, or a throwing handler. */
|
|
65
|
+
onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +
|
|
69
|
+
* validate, route to the handler for its URN, then `LREM` it from the reservation list on success.
|
|
70
|
+
* A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,
|
|
71
|
+
* then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in
|
|
72
|
+
* the body and the runtime owns retry.
|
|
73
|
+
*/
|
|
74
|
+
declare class RedisConsumer {
|
|
75
|
+
private readonly client;
|
|
76
|
+
private readonly queue;
|
|
77
|
+
private readonly handlers;
|
|
78
|
+
private readonly options;
|
|
79
|
+
private readonly processing;
|
|
80
|
+
private readonly blockTimeout;
|
|
81
|
+
private readonly maxTries;
|
|
82
|
+
private readonly dlq;
|
|
83
|
+
private readonly unknownUrn;
|
|
84
|
+
constructor(client: RedisClient, queue: string, handlers: BabelHandlers, options?: RedisConsumerOptions);
|
|
85
|
+
/** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
|
|
86
|
+
poll(): Promise<boolean>;
|
|
87
|
+
/** Poll while `shouldContinue` returns true. */
|
|
88
|
+
run(shouldContinue: () => boolean): Promise<void>;
|
|
89
|
+
/** Route + settle one reserved element. Exposed for testing. */
|
|
90
|
+
handle(raw: string): Promise<void>;
|
|
91
|
+
private onUnknownUrn;
|
|
92
|
+
private retryOrDeadLetter;
|
|
93
|
+
/** Republish with `attempts + 1` onto the work list (at-least-once retry). */
|
|
94
|
+
private requeue;
|
|
95
|
+
private deadLetter;
|
|
96
|
+
private deadLetterRaw;
|
|
97
|
+
/** Remove the element from the reservation list (acknowledge). */
|
|
98
|
+
private settle;
|
|
99
|
+
private report;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { type BabelHandler, type BabelHandlers, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, redisValue };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Envelope, IncomingEnvelope } from '@babelqueue/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Redis adapter for BabelQueue.
|
|
5
|
+
*
|
|
6
|
+
* A canonical-envelope **publisher** and a URN-routed **consumer** over the §1 reliable-queue
|
|
7
|
+
* pattern (`RPUSH` to produce, `BRPOPLPUSH` the head to a `<queue>:processing` list to reserve so
|
|
8
|
+
* an in-flight message survives a crash, `LREM` to acknowledge), so a Redis-based Node service
|
|
9
|
+
* speaks the same wire contract as the PHP, Python and Go SDKs.
|
|
10
|
+
*
|
|
11
|
+
* Redis lists carry **no native metadata** — the list element **is** the canonical envelope JSON,
|
|
12
|
+
* byte-for-byte, with **no wrapping** (unlike `@babelqueue/bullmq`, which uses BullMQ's own job
|
|
13
|
+
* layout). Routing and tracing read the body's `job` / `trace_id`. This is a Node-owned reliable
|
|
14
|
+
* queue; full parity with Laravel's reserved-sorted-set reservation on a *shared* Redis queue is a
|
|
15
|
+
* separate task — for a mixed PHP+Node fleet on one queue, prefer a queue this consumer owns
|
|
16
|
+
* end-to-end.
|
|
17
|
+
*
|
|
18
|
+
* This implements §1 of the broker-bindings contract. `ioredis` is an optional peer — you provide
|
|
19
|
+
* the client (an `ioredis` instance satisfies the adapter structurally).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The subset of an ioredis client this adapter calls. */
|
|
23
|
+
interface RedisClient {
|
|
24
|
+
/** Append a value to the tail of a list; returns the new length. */
|
|
25
|
+
rpush(key: string, value: string): Promise<number>;
|
|
26
|
+
/** Atomically pop the head of `source` and push it to the tail of `destination`, blocking up
|
|
27
|
+
* to `timeout` seconds; resolves to the value, or `null` on timeout. */
|
|
28
|
+
brpoplpush(source: string, destination: string, timeout: number): Promise<string | null>;
|
|
29
|
+
/** Remove `count` occurrences of `value` from a list; returns how many were removed. */
|
|
30
|
+
lrem(key: string, count: number, value: string): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
/** The list element for an envelope: the canonical JSON, verbatim — no wrapping (§1). */
|
|
33
|
+
declare function redisValue(envelope: Envelope): string;
|
|
34
|
+
/** Options for {@link RedisPublisher.publish}. */
|
|
35
|
+
interface PublishOptions {
|
|
36
|
+
traceId?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Sends canonical-envelope messages to one Redis list (§1 reliable-queue). */
|
|
39
|
+
declare class RedisPublisher {
|
|
40
|
+
private readonly client;
|
|
41
|
+
private readonly queue;
|
|
42
|
+
private constructor();
|
|
43
|
+
/** A publisher over `client`, producing to the list named `queue`. */
|
|
44
|
+
static create(client: RedisClient, queue: string): RedisPublisher;
|
|
45
|
+
/** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
|
|
46
|
+
publish(urn: string, data: Record<string, unknown>, options?: PublishOptions): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
/** A URN handler. Receives the validated envelope and the raw list element. */
|
|
49
|
+
type BabelHandler = (envelope: Envelope, raw: string) => unknown | Promise<unknown>;
|
|
50
|
+
/** A map of URN → handler. */
|
|
51
|
+
type BabelHandlers = Record<string, BabelHandler>;
|
|
52
|
+
/** Options for {@link RedisConsumer}. */
|
|
53
|
+
interface RedisConsumerOptions {
|
|
54
|
+
/** Suffix for the per-queue reservation list (default `":processing"`). */
|
|
55
|
+
processingSuffix?: string;
|
|
56
|
+
/** Block timeout (seconds) for a single reserve (default 5). */
|
|
57
|
+
blockTimeout?: number;
|
|
58
|
+
/** Attempts before terminal dead-lettering (default 3). */
|
|
59
|
+
maxTries?: number;
|
|
60
|
+
/** The dead-letter list (default `<queue>.dlq`); set to `null` to drop on terminal failure. */
|
|
61
|
+
deadLetterQueue?: string | null;
|
|
62
|
+
/** Strategy for a URN with no handler (default `fail`). */
|
|
63
|
+
unknownUrn?: string;
|
|
64
|
+
/** Called for a non-conformant message, an unmapped URN, or a throwing handler. */
|
|
65
|
+
onError?: (error: unknown, envelope: IncomingEnvelope | null, raw: string) => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Consumes a Redis list: reserve the head into `<queue>:processing` (`BRPOPLPUSH`), decode +
|
|
69
|
+
* validate, route to the handler for its URN, then `LREM` it from the reservation list on success.
|
|
70
|
+
* A throwing handler requeues the envelope with `attempts + 1` (at-least-once) up to `maxTries`,
|
|
71
|
+
* then dead-letters to `<queue>.dlq`. Redis has no native delivery count, so `attempts` lives in
|
|
72
|
+
* the body and the runtime owns retry.
|
|
73
|
+
*/
|
|
74
|
+
declare class RedisConsumer {
|
|
75
|
+
private readonly client;
|
|
76
|
+
private readonly queue;
|
|
77
|
+
private readonly handlers;
|
|
78
|
+
private readonly options;
|
|
79
|
+
private readonly processing;
|
|
80
|
+
private readonly blockTimeout;
|
|
81
|
+
private readonly maxTries;
|
|
82
|
+
private readonly dlq;
|
|
83
|
+
private readonly unknownUrn;
|
|
84
|
+
constructor(client: RedisClient, queue: string, handlers: BabelHandlers, options?: RedisConsumerOptions);
|
|
85
|
+
/** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
|
|
86
|
+
poll(): Promise<boolean>;
|
|
87
|
+
/** Poll while `shouldContinue` returns true. */
|
|
88
|
+
run(shouldContinue: () => boolean): Promise<void>;
|
|
89
|
+
/** Route + settle one reserved element. Exposed for testing. */
|
|
90
|
+
handle(raw: string): Promise<void>;
|
|
91
|
+
private onUnknownUrn;
|
|
92
|
+
private retryOrDeadLetter;
|
|
93
|
+
/** Republish with `attempts + 1` onto the work list (at-least-once retry). */
|
|
94
|
+
private requeue;
|
|
95
|
+
private deadLetter;
|
|
96
|
+
private deadLetterRaw;
|
|
97
|
+
/** Remove the element from the reservation list (acknowledge). */
|
|
98
|
+
private settle;
|
|
99
|
+
private report;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { type BabelHandler, type BabelHandlers, type PublishOptions, type RedisClient, RedisConsumer, type RedisConsumerOptions, RedisPublisher, redisValue };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { annotate, BabelQueueError, EnvelopeCodec, UnknownUrnError, UnknownUrnStrategy } from "@babelqueue/core";
|
|
3
|
+
function redisValue(envelope) {
|
|
4
|
+
return EnvelopeCodec.encode(envelope);
|
|
5
|
+
}
|
|
6
|
+
var RedisPublisher = class _RedisPublisher {
|
|
7
|
+
constructor(client, queue) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
this.queue = queue;
|
|
10
|
+
}
|
|
11
|
+
client;
|
|
12
|
+
queue;
|
|
13
|
+
/** A publisher over `client`, producing to the list named `queue`. */
|
|
14
|
+
static create(client, queue) {
|
|
15
|
+
return new _RedisPublisher(client, queue);
|
|
16
|
+
}
|
|
17
|
+
/** Build + `RPUSH` the canonical envelope; returns the message id (`meta.id`). */
|
|
18
|
+
async publish(urn, data, options = {}) {
|
|
19
|
+
const envelope = EnvelopeCodec.make(urn, data, { queue: this.queue, traceId: options.traceId });
|
|
20
|
+
await this.client.rpush(this.queue, redisValue(envelope));
|
|
21
|
+
return envelope.meta.id;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var RedisConsumer = class {
|
|
25
|
+
constructor(client, queue, handlers, options = {}) {
|
|
26
|
+
this.client = client;
|
|
27
|
+
this.queue = queue;
|
|
28
|
+
this.handlers = handlers;
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.processing = queue + (options.processingSuffix ?? ":processing");
|
|
31
|
+
this.blockTimeout = options.blockTimeout ?? 5;
|
|
32
|
+
this.maxTries = options.maxTries ?? 3;
|
|
33
|
+
this.dlq = options.deadLetterQueue === void 0 ? `${queue}.dlq` : options.deadLetterQueue;
|
|
34
|
+
this.unknownUrn = options.unknownUrn ?? UnknownUrnStrategy.FAIL;
|
|
35
|
+
}
|
|
36
|
+
client;
|
|
37
|
+
queue;
|
|
38
|
+
handlers;
|
|
39
|
+
options;
|
|
40
|
+
processing;
|
|
41
|
+
blockTimeout;
|
|
42
|
+
maxTries;
|
|
43
|
+
dlq;
|
|
44
|
+
unknownUrn;
|
|
45
|
+
/** Reserve + route + settle one message. Returns true if one was handled, false on timeout. */
|
|
46
|
+
async poll() {
|
|
47
|
+
const raw = await this.client.brpoplpush(this.queue, this.processing, this.blockTimeout);
|
|
48
|
+
if (raw == null) return false;
|
|
49
|
+
await this.handle(raw);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/** Poll while `shouldContinue` returns true. */
|
|
53
|
+
async run(shouldContinue) {
|
|
54
|
+
while (shouldContinue()) {
|
|
55
|
+
await this.poll();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Route + settle one reserved element. Exposed for testing. */
|
|
59
|
+
async handle(raw) {
|
|
60
|
+
const decoded = EnvelopeCodec.decode(raw);
|
|
61
|
+
if (!EnvelopeCodec.accepts(decoded)) {
|
|
62
|
+
this.report(new BabelQueueError("Rejected a non-conformant BabelQueue envelope from Redis."), decoded, raw);
|
|
63
|
+
await this.deadLetterRaw(raw);
|
|
64
|
+
await this.settle(raw);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const envelope = decoded;
|
|
68
|
+
const urn = EnvelopeCodec.urn(envelope);
|
|
69
|
+
const handler = this.handlers[urn];
|
|
70
|
+
if (!handler) {
|
|
71
|
+
await this.onUnknownUrn(envelope, urn, raw);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await handler(envelope, raw);
|
|
76
|
+
await this.settle(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.report(error, envelope, raw);
|
|
79
|
+
await this.retryOrDeadLetter(envelope, raw, error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async onUnknownUrn(envelope, urn, raw) {
|
|
83
|
+
switch (this.unknownUrn) {
|
|
84
|
+
case UnknownUrnStrategy.DELETE:
|
|
85
|
+
await this.settle(raw);
|
|
86
|
+
return;
|
|
87
|
+
case UnknownUrnStrategy.DEAD_LETTER:
|
|
88
|
+
await this.deadLetter(envelope, "unknown_urn");
|
|
89
|
+
await this.settle(raw);
|
|
90
|
+
return;
|
|
91
|
+
case UnknownUrnStrategy.RELEASE:
|
|
92
|
+
await this.requeue(envelope);
|
|
93
|
+
await this.settle(raw);
|
|
94
|
+
return;
|
|
95
|
+
default: {
|
|
96
|
+
const error = new UnknownUrnError(urn);
|
|
97
|
+
this.report(error, envelope, raw);
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async retryOrDeadLetter(envelope, raw, error) {
|
|
103
|
+
if ((envelope.attempts ?? 0) + 1 < this.maxTries) {
|
|
104
|
+
await this.requeue(envelope);
|
|
105
|
+
} else {
|
|
106
|
+
await this.deadLetter(envelope, "failed", error);
|
|
107
|
+
}
|
|
108
|
+
await this.settle(raw);
|
|
109
|
+
}
|
|
110
|
+
/** Republish with `attempts + 1` onto the work list (at-least-once retry). */
|
|
111
|
+
async requeue(envelope) {
|
|
112
|
+
const bumped = { ...envelope, attempts: (envelope.attempts ?? 0) + 1 };
|
|
113
|
+
await this.client.rpush(this.queue, redisValue(bumped));
|
|
114
|
+
}
|
|
115
|
+
async deadLetter(envelope, reason, error) {
|
|
116
|
+
if (!this.dlq) return;
|
|
117
|
+
const annotated = annotate(envelope, reason, envelope.meta?.queue ?? this.queue, {
|
|
118
|
+
attempts: envelope.attempts ?? 0,
|
|
119
|
+
error: error instanceof Error ? error.message : null,
|
|
120
|
+
exception: error instanceof Error ? error.name : null
|
|
121
|
+
});
|
|
122
|
+
await this.client.rpush(this.dlq, redisValue(annotated));
|
|
123
|
+
}
|
|
124
|
+
async deadLetterRaw(raw) {
|
|
125
|
+
if (!this.dlq) return;
|
|
126
|
+
await this.client.rpush(this.dlq, raw);
|
|
127
|
+
}
|
|
128
|
+
/** Remove the element from the reservation list (acknowledge). */
|
|
129
|
+
async settle(raw) {
|
|
130
|
+
await this.client.lrem(this.processing, 1, raw);
|
|
131
|
+
}
|
|
132
|
+
report(error, envelope, raw) {
|
|
133
|
+
this.options.onError?.(error, envelope, raw);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
export {
|
|
137
|
+
RedisConsumer,
|
|
138
|
+
RedisPublisher,
|
|
139
|
+
redisValue
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":";AAmBA,SAAS,UAAU,iBAAiB,eAAe,iBAAiB,0BAA0B;AAmBvF,SAAS,WAAW,UAA4B;AACrD,SAAO,cAAc,OAAO,QAAQ;AACtC;AAUO,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,EAGA,MAAM,QAAQ,KAAa,MAA+B,UAA0B,CAAC,GAAoB;AACvG,UAAM,WAAW,cAAc,KAAK,KAAK,MAAM,EAAE,OAAO,KAAK,OAAO,SAAS,QAAQ,QAAQ,CAAC;AAC9F,UAAM,KAAK,OAAO,MAAM,KAAK,OAAO,WAAW,QAAQ,CAAC;AACxD,WAAO,SAAS,KAAK;AAAA,EACvB;AACF;AAiCO,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,mBAAmB;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,EAGA,MAAM,OAAO,KAA4B;AACvC,UAAM,UAAU,cAAc,OAAO,GAAG;AAExC,QAAI,CAAC,cAAc,QAAQ,OAAO,GAAG;AACnC,WAAK,OAAO,IAAI,gBAAgB,2DAA2D,GAAG,SAAS,GAAG;AAC1G,YAAM,KAAK,cAAc,GAAG;AAC5B,YAAM,KAAK,OAAO,GAAG;AACrB;AAAA,IACF;AAEA,UAAM,WAAW;AACjB,UAAM,MAAM,cAAc,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,GAAG;AAC3B,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,mBAAmB;AACtB,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,KAAK,mBAAmB;AACtB,cAAM,KAAK,WAAW,UAAU,aAAa;AAC7C,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,KAAK,mBAAmB;AACtB,cAAM,KAAK,QAAQ,QAAQ;AAC3B,cAAM,KAAK,OAAO,GAAG;AACrB;AAAA,MACF,SAAS;AAEP,cAAM,QAAQ,IAAI,gBAAgB,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,YAAY,SAAS,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/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@babelqueue/redis",
|
|
3
|
+
"version": "1.0.0",
|
|
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"],
|
|
6
|
+
"homepage": "https://babelqueue.com",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/BabelQueue/babelqueue-node-adapters/issues"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/BabelQueue/babelqueue-node-adapters.git",
|
|
13
|
+
"directory": "packages/redis"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Muhammet Şafak <info@muhammetsafak.com.tr>",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": ["dist"],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "node --import tsx --test test/*.test.ts"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@babelqueue/core": "^1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"ioredis": "^5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"ioredis": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|