@saga-bus/transport-redis 0.1.2

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 ADDED
@@ -0,0 +1,177 @@
1
+ # @saga-bus/transport-redis
2
+
3
+ Redis Streams transport for saga-bus using ioredis.
4
+
5
+ ## Features
6
+
7
+ - **Redis Streams** - Uses XADD/XREADGROUP for reliable message delivery
8
+ - **Consumer Groups** - Competing consumers with automatic load balancing
9
+ - **Message Acknowledgment** - Manual XACK after successful processing
10
+ - **Delayed Messages** - Sorted set-based delayed delivery (ZADD/ZRANGEBYSCORE)
11
+ - **Pending Recovery** - Automatic claiming of unacknowledged messages (XCLAIM)
12
+ - **Stream Trimming** - Configurable MAXLEN for memory management
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @saga-bus/transport-redis ioredis
18
+ # or
19
+ pnpm add @saga-bus/transport-redis ioredis
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Basic Setup
25
+
26
+ ```typescript
27
+ import Redis from "ioredis";
28
+ import { RedisTransport } from "@saga-bus/transport-redis";
29
+
30
+ const redis = new Redis({
31
+ host: "localhost",
32
+ port: 6379,
33
+ });
34
+
35
+ const transport = new RedisTransport({
36
+ redis,
37
+ consumerGroup: "order-processor",
38
+ });
39
+
40
+ await transport.start();
41
+ ```
42
+
43
+ ### With Connection Options
44
+
45
+ ```typescript
46
+ import { RedisTransport } from "@saga-bus/transport-redis";
47
+
48
+ const transport = new RedisTransport({
49
+ connection: {
50
+ host: "localhost",
51
+ port: 6379,
52
+ password: "secret",
53
+ db: 0,
54
+ },
55
+ consumerGroup: "order-processor",
56
+ });
57
+ ```
58
+
59
+ ### Publishing Messages
60
+
61
+ ```typescript
62
+ interface OrderCreated {
63
+ type: "OrderCreated";
64
+ orderId: string;
65
+ amount: number;
66
+ }
67
+
68
+ // Immediate delivery
69
+ await transport.publish<OrderCreated>(
70
+ { type: "OrderCreated", orderId: "123", amount: 99.99 },
71
+ { endpoint: "orders" }
72
+ );
73
+
74
+ // With partition key (for ordering)
75
+ await transport.publish<OrderCreated>(
76
+ { type: "OrderCreated", orderId: "123", amount: 99.99 },
77
+ { endpoint: "orders", key: "customer-456" }
78
+ );
79
+
80
+ // Delayed delivery (5 minutes)
81
+ await transport.publish<OrderCreated>(
82
+ { type: "OrderCreated", orderId: "123", amount: 99.99 },
83
+ { endpoint: "orders", delayMs: 5 * 60 * 1000 }
84
+ );
85
+ ```
86
+
87
+ ### Subscribing to Messages
88
+
89
+ ```typescript
90
+ await transport.subscribe(
91
+ { endpoint: "orders", concurrency: 5 },
92
+ async (envelope) => {
93
+ console.log("Received:", envelope.type, envelope.payload);
94
+ // Message is automatically acknowledged after successful processing
95
+ }
96
+ );
97
+
98
+ await transport.start();
99
+ ```
100
+
101
+ ### With saga-bus
102
+
103
+ ```typescript
104
+ import { createBus } from "@saga-bus/core";
105
+ import { RedisTransport } from "@saga-bus/transport-redis";
106
+ import Redis from "ioredis";
107
+
108
+ const bus = createBus({
109
+ transport: new RedisTransport({
110
+ redis: new Redis(),
111
+ consumerGroup: "my-app",
112
+ }),
113
+ // ... other config
114
+ });
115
+ ```
116
+
117
+ ## Configuration Options
118
+
119
+ | Option | Type | Default | Description |
120
+ |--------|------|---------|-------------|
121
+ | `redis` | `Redis` | - | ioredis client instance |
122
+ | `connection` | `RedisOptions` | - | Connection options (alternative to `redis`) |
123
+ | `keyPrefix` | `string` | `"saga-bus:"` | Prefix for all Redis keys |
124
+ | `consumerGroup` | `string` | - | Consumer group name (required for subscribing) |
125
+ | `consumerName` | `string` | Auto UUID | Consumer name within the group |
126
+ | `autoCreateGroup` | `boolean` | `true` | Create consumer groups automatically |
127
+ | `batchSize` | `number` | `10` | Messages to fetch per read |
128
+ | `blockTimeoutMs` | `number` | `5000` | Block timeout for XREADGROUP |
129
+ | `maxStreamLength` | `number` | `0` | Max stream length (0 = unlimited) |
130
+ | `approximateMaxLen` | `boolean` | `true` | Use approximate MAXLEN (~) |
131
+ | `delayedPollIntervalMs` | `number` | `1000` | How often to check delayed messages |
132
+ | `delayedSetKey` | `string` | `"saga-bus:delayed"` | Key for delayed messages sorted set |
133
+ | `pendingClaimIntervalMs` | `number` | `30000` | How often to claim pending messages |
134
+ | `minIdleTimeMs` | `number` | `60000` | Min idle time before claiming |
135
+
136
+ ## Redis Data Structures
137
+
138
+ ### Streams
139
+
140
+ Messages are stored in Redis Streams with key pattern:
141
+ ```
142
+ {keyPrefix}stream:{endpoint}
143
+ ```
144
+
145
+ Example: `saga-bus:stream:orders`
146
+
147
+ Each message contains:
148
+ ```
149
+ data: <JSON envelope>
150
+ ```
151
+
152
+ ### Delayed Messages
153
+
154
+ Delayed messages use a sorted set:
155
+ ```
156
+ {delayedSetKey}
157
+ ```
158
+
159
+ Score: Unix timestamp (ms) when message should be delivered
160
+ Value: JSON with `{ streamKey, envelope, deliverAt }`
161
+
162
+ ## Error Handling
163
+
164
+ - Failed messages are NOT acknowledged, allowing retry via pending recovery
165
+ - Pending messages older than `minIdleTimeMs` are claimed by active consumers
166
+ - Consumer group creation ignores "BUSYGROUP" errors (already exists)
167
+
168
+ ## Performance Tips
169
+
170
+ 1. **Batch Size**: Increase `batchSize` for high-throughput scenarios
171
+ 2. **Stream Trimming**: Set `maxStreamLength` to prevent unbounded growth
172
+ 3. **Approximate MAXLEN**: Keep `approximateMaxLen: true` for better performance
173
+ 4. **Connection Pooling**: Pass a shared Redis client for connection reuse
174
+
175
+ ## License
176
+
177
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,339 @@
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
+ RedisTransport: () => RedisTransport
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/RedisTransport.ts
28
+ var import_ioredis = require("ioredis");
29
+ var import_crypto = require("crypto");
30
+ var RedisTransport = class {
31
+ redis = null;
32
+ subscriberRedis = null;
33
+ options;
34
+ subscriptions = [];
35
+ started = false;
36
+ stopping = false;
37
+ readLoopPromise = null;
38
+ delayedPollInterval = null;
39
+ pendingClaimInterval = null;
40
+ constructor(options) {
41
+ if (!options.redis && !options.connection) {
42
+ throw new Error("Either redis client or connection options must be provided");
43
+ }
44
+ this.options = {
45
+ keyPrefix: "saga-bus:",
46
+ consumerGroup: "",
47
+ consumerName: `consumer-${(0, import_crypto.randomUUID)()}`,
48
+ autoCreateGroup: true,
49
+ batchSize: 10,
50
+ blockTimeoutMs: 5e3,
51
+ maxStreamLength: 0,
52
+ approximateMaxLen: true,
53
+ delayedPollIntervalMs: 1e3,
54
+ delayedSetKey: "saga-bus:delayed",
55
+ pendingClaimIntervalMs: 3e4,
56
+ minIdleTimeMs: 6e4,
57
+ ...options
58
+ };
59
+ }
60
+ async start() {
61
+ if (this.started) return;
62
+ if (this.options.redis) {
63
+ this.redis = this.options.redis;
64
+ this.subscriberRedis = this.options.redis.duplicate();
65
+ } else if (this.options.connection) {
66
+ this.redis = new import_ioredis.Redis(this.options.connection);
67
+ this.subscriberRedis = new import_ioredis.Redis(this.options.connection);
68
+ } else {
69
+ throw new Error("Invalid configuration");
70
+ }
71
+ if (this.options.autoCreateGroup && this.options.consumerGroup) {
72
+ for (const sub of this.subscriptions) {
73
+ await this.ensureConsumerGroup(sub.streamKey);
74
+ }
75
+ }
76
+ this.started = true;
77
+ if (this.subscriptions.length > 0) {
78
+ this.readLoopPromise = this.readLoop();
79
+ }
80
+ if (this.options.delayedPollIntervalMs > 0) {
81
+ this.delayedPollInterval = setInterval(
82
+ () => void this.processDelayedMessages(),
83
+ this.options.delayedPollIntervalMs
84
+ );
85
+ }
86
+ if (this.options.pendingClaimIntervalMs > 0) {
87
+ this.pendingClaimInterval = setInterval(
88
+ () => void this.claimPendingMessages(),
89
+ this.options.pendingClaimIntervalMs
90
+ );
91
+ }
92
+ }
93
+ async stop() {
94
+ if (!this.started || this.stopping) return;
95
+ this.stopping = true;
96
+ if (this.delayedPollInterval) {
97
+ clearInterval(this.delayedPollInterval);
98
+ this.delayedPollInterval = null;
99
+ }
100
+ if (this.pendingClaimInterval) {
101
+ clearInterval(this.pendingClaimInterval);
102
+ this.pendingClaimInterval = null;
103
+ }
104
+ if (this.readLoopPromise) {
105
+ await this.readLoopPromise;
106
+ this.readLoopPromise = null;
107
+ }
108
+ if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {
109
+ await this.subscriberRedis.quit();
110
+ }
111
+ this.subscriberRedis = null;
112
+ if (this.redis && !this.options.redis) {
113
+ await this.redis.quit();
114
+ }
115
+ this.redis = null;
116
+ this.started = false;
117
+ this.stopping = false;
118
+ }
119
+ async subscribe(options, handler) {
120
+ const { endpoint, concurrency = 1 } = options;
121
+ const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
122
+ const subscription = {
123
+ streamKey,
124
+ handler,
125
+ concurrency
126
+ };
127
+ this.subscriptions.push(subscription);
128
+ if (this.started && this.redis) {
129
+ await this.ensureConsumerGroup(streamKey);
130
+ if (!this.readLoopPromise) {
131
+ this.readLoopPromise = this.readLoop();
132
+ }
133
+ }
134
+ }
135
+ async publish(message, options) {
136
+ if (!this.redis) {
137
+ throw new Error("Transport not started");
138
+ }
139
+ const { endpoint, key, headers = {}, delayMs } = options;
140
+ const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
141
+ const envelope = {
142
+ id: (0, import_crypto.randomUUID)(),
143
+ type: message.type,
144
+ payload: message,
145
+ headers,
146
+ timestamp: /* @__PURE__ */ new Date(),
147
+ partitionKey: key
148
+ };
149
+ const envelopeJson = JSON.stringify(envelope);
150
+ if (delayMs && delayMs > 0) {
151
+ const deliverAt = Date.now() + delayMs;
152
+ const delayedEntry = {
153
+ streamKey,
154
+ envelope: envelopeJson,
155
+ deliverAt
156
+ };
157
+ await this.redis.zadd(
158
+ this.options.delayedSetKey,
159
+ deliverAt,
160
+ JSON.stringify(delayedEntry)
161
+ );
162
+ return;
163
+ }
164
+ await this.addToStream(streamKey, envelopeJson);
165
+ }
166
+ async addToStream(streamKey, envelopeJson) {
167
+ if (!this.redis) return;
168
+ const args = [streamKey];
169
+ if (this.options.maxStreamLength > 0) {
170
+ if (this.options.approximateMaxLen) {
171
+ args.push("MAXLEN", "~", this.options.maxStreamLength);
172
+ } else {
173
+ args.push("MAXLEN", this.options.maxStreamLength);
174
+ }
175
+ }
176
+ args.push("*", "data", envelopeJson);
177
+ await this.redis.xadd(...args);
178
+ }
179
+ async ensureConsumerGroup(streamKey) {
180
+ if (!this.redis || !this.options.consumerGroup) return;
181
+ try {
182
+ await this.redis.xgroup(
183
+ "CREATE",
184
+ streamKey,
185
+ this.options.consumerGroup,
186
+ "0",
187
+ "MKSTREAM"
188
+ );
189
+ } catch (error) {
190
+ if (error instanceof Error && !error.message.includes("BUSYGROUP")) {
191
+ throw error;
192
+ }
193
+ }
194
+ }
195
+ async readLoop() {
196
+ if (!this.subscriberRedis || !this.options.consumerGroup) return;
197
+ const streams = this.subscriptions.map((s) => s.streamKey);
198
+ const ids = streams.map(() => ">");
199
+ while (this.started && !this.stopping) {
200
+ try {
201
+ const results = await this.subscriberRedis.xreadgroup(
202
+ "GROUP",
203
+ this.options.consumerGroup,
204
+ this.options.consumerName,
205
+ "COUNT",
206
+ this.options.batchSize,
207
+ "BLOCK",
208
+ this.options.blockTimeoutMs,
209
+ "STREAMS",
210
+ ...streams,
211
+ ...ids
212
+ );
213
+ if (!results) continue;
214
+ const typedResults = results;
215
+ for (const [streamKey, messages] of typedResults) {
216
+ const subscription = this.subscriptions.find(
217
+ (s) => s.streamKey === streamKey
218
+ );
219
+ if (!subscription) continue;
220
+ for (const [messageId, fields] of messages) {
221
+ await this.processMessage(
222
+ streamKey,
223
+ messageId,
224
+ fields,
225
+ subscription
226
+ );
227
+ }
228
+ }
229
+ } catch (error) {
230
+ if (!this.stopping) {
231
+ console.error("[RedisTransport] Read loop error:", error);
232
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
233
+ }
234
+ }
235
+ }
236
+ }
237
+ async processMessage(streamKey, messageId, fields, subscription) {
238
+ if (!this.redis) return;
239
+ try {
240
+ const data = {};
241
+ for (let i = 0; i < fields.length; i += 2) {
242
+ data[fields[i]] = fields[i + 1];
243
+ }
244
+ if (!data.data) {
245
+ console.error("[RedisTransport] Message missing data field:", messageId);
246
+ await this.acknowledgeMessage(streamKey, messageId);
247
+ return;
248
+ }
249
+ const rawEnvelope = JSON.parse(data.data);
250
+ const envelope = {
251
+ id: rawEnvelope.id,
252
+ type: rawEnvelope.type,
253
+ payload: rawEnvelope.payload,
254
+ headers: rawEnvelope.headers,
255
+ timestamp: new Date(rawEnvelope.timestamp),
256
+ partitionKey: rawEnvelope.partitionKey
257
+ };
258
+ await subscription.handler(envelope);
259
+ await this.acknowledgeMessage(streamKey, messageId);
260
+ } catch (error) {
261
+ console.error("[RedisTransport] Message processing error:", error);
262
+ }
263
+ }
264
+ async acknowledgeMessage(streamKey, messageId) {
265
+ if (!this.redis || !this.options.consumerGroup) return;
266
+ await this.redis.xack(streamKey, this.options.consumerGroup, messageId);
267
+ }
268
+ async processDelayedMessages() {
269
+ if (!this.redis || this.stopping) return;
270
+ try {
271
+ const now = Date.now();
272
+ const entries = await this.redis.zrangebyscore(
273
+ this.options.delayedSetKey,
274
+ "-inf",
275
+ now
276
+ );
277
+ if (entries.length === 0) return;
278
+ for (const entryJson of entries) {
279
+ try {
280
+ const entry = JSON.parse(entryJson);
281
+ await this.addToStream(entry.streamKey, entry.envelope);
282
+ await this.redis.zrem(this.options.delayedSetKey, entryJson);
283
+ } catch (error) {
284
+ console.error("[RedisTransport] Error processing delayed message:", error);
285
+ }
286
+ }
287
+ } catch (error) {
288
+ console.error("[RedisTransport] Error in delayed message poll:", error);
289
+ }
290
+ }
291
+ async claimPendingMessages() {
292
+ if (!this.redis || !this.options.consumerGroup || this.stopping) return;
293
+ for (const subscription of this.subscriptions) {
294
+ try {
295
+ const pending = await this.redis.xpending(
296
+ subscription.streamKey,
297
+ this.options.consumerGroup,
298
+ "-",
299
+ "+",
300
+ 10
301
+ // Max entries to check
302
+ );
303
+ if (!Array.isArray(pending) || pending.length === 0) continue;
304
+ for (const entry of pending) {
305
+ if (!Array.isArray(entry) || entry.length < 4) continue;
306
+ const [messageId, , idleTime] = entry;
307
+ if (idleTime < this.options.minIdleTimeMs) continue;
308
+ try {
309
+ const claimed = await this.redis.xclaim(
310
+ subscription.streamKey,
311
+ this.options.consumerGroup,
312
+ this.options.consumerName,
313
+ this.options.minIdleTimeMs,
314
+ messageId
315
+ );
316
+ if (claimed && claimed.length > 0) {
317
+ const [claimedId, fields] = claimed[0];
318
+ await this.processMessage(
319
+ subscription.streamKey,
320
+ claimedId,
321
+ fields,
322
+ subscription
323
+ );
324
+ }
325
+ } catch (error) {
326
+ console.error("[RedisTransport] Error claiming message:", error);
327
+ }
328
+ }
329
+ } catch (error) {
330
+ console.error("[RedisTransport] Error in pending claim:", error);
331
+ }
332
+ }
333
+ }
334
+ };
335
+ // Annotate the CommonJS export names for ESM import in node:
336
+ 0 && (module.exports = {
337
+ RedisTransport
338
+ });
339
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/RedisTransport.ts"],"sourcesContent":["export { RedisTransport } from \"./RedisTransport.js\";\nexport type { RedisTransportOptions } from \"./types.js\";\n","import { Redis } from \"ioredis\";\nimport { randomUUID } from \"crypto\";\nimport {\n type Transport,\n type TransportSubscribeOptions,\n type TransportPublishOptions,\n type MessageEnvelope,\n type BaseMessage,\n} from \"@saga-bus/core\";\nimport type {\n RedisTransportOptions,\n StreamSubscription,\n DelayedMessageEntry,\n} from \"./types.js\";\n\n/**\n * Redis Streams transport implementation for saga-bus.\n *\n * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:\n * - Consumer groups for competing consumers\n * - Message acknowledgment (XACK)\n * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)\n * - Pending message claiming (XCLAIM) for recovery\n *\n * @example\n * ```typescript\n * import Redis from \"ioredis\";\n * import { RedisTransport } from \"@saga-bus/transport-redis\";\n *\n * const transport = new RedisTransport({\n * redis: new Redis(),\n * consumerGroup: \"order-processor\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class RedisTransport implements Transport {\n private redis: Redis | null = null;\n private subscriberRedis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisTransportOptions,\n | \"keyPrefix\"\n | \"consumerGroup\"\n | \"consumerName\"\n | \"autoCreateGroup\"\n | \"batchSize\"\n | \"blockTimeoutMs\"\n | \"maxStreamLength\"\n | \"approximateMaxLen\"\n | \"delayedPollIntervalMs\"\n | \"delayedSetKey\"\n | \"pendingClaimIntervalMs\"\n | \"minIdleTimeMs\"\n >\n > &\n RedisTransportOptions;\n\n private readonly subscriptions: StreamSubscription[] = [];\n private started = false;\n private stopping = false;\n private readLoopPromise: Promise<void> | null = null;\n private delayedPollInterval: ReturnType<typeof setInterval> | null = null;\n private pendingClaimInterval: ReturnType<typeof setInterval> | null = null;\n\n constructor(options: RedisTransportOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis client or connection options must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n consumerGroup: \"\",\n consumerName: `consumer-${randomUUID()}`,\n autoCreateGroup: true,\n batchSize: 10,\n blockTimeoutMs: 5000,\n maxStreamLength: 0,\n approximateMaxLen: true,\n delayedPollIntervalMs: 1000,\n delayedSetKey: \"saga-bus:delayed\",\n pendingClaimIntervalMs: 30000,\n minIdleTimeMs: 60000,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Create Redis clients\n if (this.options.redis) {\n this.redis = this.options.redis;\n // Create a separate connection for blocking reads\n this.subscriberRedis = this.options.redis.duplicate();\n } else if (this.options.connection) {\n this.redis = new Redis(this.options.connection);\n this.subscriberRedis = new Redis(this.options.connection);\n } else {\n throw new Error(\"Invalid configuration\");\n }\n\n // Create consumer groups for all subscribed streams\n if (this.options.autoCreateGroup && this.options.consumerGroup) {\n for (const sub of this.subscriptions) {\n await this.ensureConsumerGroup(sub.streamKey);\n }\n }\n\n this.started = true;\n\n // Start the read loop if we have subscriptions\n if (this.subscriptions.length > 0) {\n this.readLoopPromise = this.readLoop();\n }\n\n // Start delayed message polling\n if (this.options.delayedPollIntervalMs > 0) {\n this.delayedPollInterval = setInterval(\n () => void this.processDelayedMessages(),\n this.options.delayedPollIntervalMs\n );\n }\n\n // Start pending message claiming\n if (this.options.pendingClaimIntervalMs > 0) {\n this.pendingClaimInterval = setInterval(\n () => void this.claimPendingMessages(),\n this.options.pendingClaimIntervalMs\n );\n }\n }\n\n async stop(): Promise<void> {\n if (!this.started || this.stopping) return;\n this.stopping = true;\n\n // Stop polling intervals\n if (this.delayedPollInterval) {\n clearInterval(this.delayedPollInterval);\n this.delayedPollInterval = null;\n }\n\n if (this.pendingClaimInterval) {\n clearInterval(this.pendingClaimInterval);\n this.pendingClaimInterval = null;\n }\n\n // Wait for read loop to finish\n if (this.readLoopPromise) {\n await this.readLoopPromise;\n this.readLoopPromise = null;\n }\n\n // Close subscriber connection\n if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {\n await this.subscriberRedis.quit();\n }\n this.subscriberRedis = null;\n\n // Close main connection if we created it\n if (this.redis && !this.options.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n\n this.started = false;\n this.stopping = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n const { endpoint, concurrency = 1 } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n const subscription: StreamSubscription = {\n streamKey,\n handler: handler as (envelope: unknown) => Promise<void>,\n concurrency,\n };\n\n this.subscriptions.push(subscription);\n\n // If already started, create consumer group and restart read loop\n if (this.started && this.redis) {\n await this.ensureConsumerGroup(streamKey);\n\n // Restart read loop with new subscription\n if (!this.readLoopPromise) {\n this.readLoopPromise = this.readLoop();\n }\n }\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.redis) {\n throw new Error(\"Transport not started\");\n }\n\n const { endpoint, key, headers = {}, delayMs } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n // Create message envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: headers as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n const envelopeJson = JSON.stringify(envelope);\n\n // Handle delayed delivery\n if (delayMs && delayMs > 0) {\n const deliverAt = Date.now() + delayMs;\n const delayedEntry: DelayedMessageEntry = {\n streamKey,\n envelope: envelopeJson,\n deliverAt,\n };\n\n // Store in sorted set with score = delivery timestamp\n await this.redis.zadd(\n this.options.delayedSetKey,\n deliverAt,\n JSON.stringify(delayedEntry)\n );\n return;\n }\n\n // Immediate delivery via stream\n await this.addToStream(streamKey, envelopeJson);\n }\n\n private async addToStream(streamKey: string, envelopeJson: string): Promise<void> {\n if (!this.redis) return;\n\n const args: (string | number)[] = [streamKey];\n\n // Add MAXLEN if configured\n if (this.options.maxStreamLength > 0) {\n if (this.options.approximateMaxLen) {\n args.push(\"MAXLEN\", \"~\", this.options.maxStreamLength);\n } else {\n args.push(\"MAXLEN\", this.options.maxStreamLength);\n }\n }\n\n args.push(\"*\", \"data\", envelopeJson);\n\n await this.redis.xadd(...(args as [string, ...Array<string | number>]));\n }\n\n private async ensureConsumerGroup(streamKey: string): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n try {\n // Create stream with empty entry if it doesn't exist, then create group\n await this.redis.xgroup(\n \"CREATE\",\n streamKey,\n this.options.consumerGroup,\n \"0\",\n \"MKSTREAM\"\n );\n } catch (error) {\n // Ignore \"BUSYGROUP Consumer Group name already exists\" error\n if (\n error instanceof Error &&\n !error.message.includes(\"BUSYGROUP\")\n ) {\n throw error;\n }\n }\n }\n\n private async readLoop(): Promise<void> {\n if (!this.subscriberRedis || !this.options.consumerGroup) return;\n\n const streams = this.subscriptions.map((s) => s.streamKey);\n const ids = streams.map(() => \">\"); // Only new messages\n\n while (this.started && !this.stopping) {\n try {\n const results = await this.subscriberRedis.xreadgroup(\n \"GROUP\",\n this.options.consumerGroup,\n this.options.consumerName,\n \"COUNT\",\n this.options.batchSize,\n \"BLOCK\",\n this.options.blockTimeoutMs,\n \"STREAMS\",\n ...streams,\n ...ids\n );\n\n if (!results) continue;\n\n // Process messages - results is Array<[streamKey, messages]>\n const typedResults = results as Array<\n [string, Array<[string, string[]]>]\n >;\n for (const [streamKey, messages] of typedResults) {\n const subscription = this.subscriptions.find(\n (s) => s.streamKey === streamKey\n );\n if (!subscription) continue;\n\n for (const [messageId, fields] of messages) {\n await this.processMessage(\n streamKey,\n messageId,\n fields,\n subscription\n );\n }\n }\n } catch (error) {\n if (!this.stopping) {\n console.error(\"[RedisTransport] Read loop error:\", error);\n // Brief pause before retrying\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n }\n }\n }\n\n private async processMessage(\n streamKey: string,\n messageId: string,\n fields: string[],\n subscription: StreamSubscription\n ): Promise<void> {\n if (!this.redis) return;\n\n try {\n // Parse fields array into object\n const data: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) {\n data[fields[i]!] = fields[i + 1]!;\n }\n\n if (!data.data) {\n console.error(\"[RedisTransport] Message missing data field:\", messageId);\n await this.acknowledgeMessage(streamKey, messageId);\n return;\n }\n\n const rawEnvelope = JSON.parse(data.data) as {\n id: string;\n type: string;\n payload: unknown;\n headers: Record<string, string>;\n timestamp: string | Date;\n partitionKey?: string;\n };\n\n // Reconstruct Date objects into proper envelope\n const envelope: MessageEnvelope = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as BaseMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n\n await subscription.handler(envelope);\n\n // Acknowledge successful processing\n await this.acknowledgeMessage(streamKey, messageId);\n } catch (error) {\n console.error(\"[RedisTransport] Message processing error:\", error);\n // Don't acknowledge - message will be claimed by pending recovery\n }\n }\n\n private async acknowledgeMessage(\n streamKey: string,\n messageId: string\n ): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n await this.redis.xack(streamKey, this.options.consumerGroup, messageId);\n }\n\n private async processDelayedMessages(): Promise<void> {\n if (!this.redis || this.stopping) return;\n\n try {\n const now = Date.now();\n\n // Get all messages due for delivery\n const entries = await this.redis.zrangebyscore(\n this.options.delayedSetKey,\n \"-inf\",\n now\n );\n\n if (entries.length === 0) return;\n\n // Process each delayed message\n for (const entryJson of entries) {\n try {\n const entry = JSON.parse(entryJson) as DelayedMessageEntry;\n\n // Add to the target stream\n await this.addToStream(entry.streamKey, entry.envelope);\n\n // Remove from delayed set\n await this.redis.zrem(this.options.delayedSetKey, entryJson);\n } catch (error) {\n console.error(\"[RedisTransport] Error processing delayed message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in delayed message poll:\", error);\n }\n }\n\n private async claimPendingMessages(): Promise<void> {\n if (!this.redis || !this.options.consumerGroup || this.stopping) return;\n\n for (const subscription of this.subscriptions) {\n try {\n // Get pending messages for this stream\n const pending = await this.redis.xpending(\n subscription.streamKey,\n this.options.consumerGroup,\n \"-\",\n \"+\",\n 10 // Max entries to check\n );\n\n if (!Array.isArray(pending) || pending.length === 0) continue;\n\n for (const entry of pending) {\n if (!Array.isArray(entry) || entry.length < 4) continue;\n\n const [messageId, , idleTime] = entry as [string, string, number, number];\n\n // Only claim if idle time exceeds threshold\n if (idleTime < this.options.minIdleTimeMs) continue;\n\n try {\n // Claim the message\n const claimed = await this.redis.xclaim(\n subscription.streamKey,\n this.options.consumerGroup,\n this.options.consumerName,\n this.options.minIdleTimeMs,\n messageId\n );\n\n if (claimed && claimed.length > 0) {\n // Process the claimed message\n const [claimedId, fields] = claimed[0] as [string, string[]];\n await this.processMessage(\n subscription.streamKey,\n claimedId,\n fields,\n subscription\n );\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error claiming message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in pending claim:\", error);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,129 @@
1
+ import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
2
+ import { Redis, RedisOptions } from 'ioredis';
3
+
4
+ /**
5
+ * Configuration options for Redis Streams transport.
6
+ */
7
+ interface RedisTransportOptions {
8
+ /**
9
+ * Redis client instance.
10
+ * Either redis or connection must be provided.
11
+ */
12
+ redis?: Redis;
13
+ /**
14
+ * Redis connection options.
15
+ * Used to create a new Redis client if redis is not provided.
16
+ */
17
+ connection?: RedisOptions;
18
+ /**
19
+ * Prefix for all stream keys.
20
+ * @default "saga-bus:"
21
+ */
22
+ keyPrefix?: string;
23
+ /**
24
+ * Consumer group name.
25
+ * Required for subscribing.
26
+ */
27
+ consumerGroup?: string;
28
+ /**
29
+ * Consumer name within the group.
30
+ * @default Auto-generated UUID
31
+ */
32
+ consumerName?: string;
33
+ /**
34
+ * Whether to create consumer groups automatically.
35
+ * @default true
36
+ */
37
+ autoCreateGroup?: boolean;
38
+ /**
39
+ * Maximum number of messages to fetch per read.
40
+ * @default 10
41
+ */
42
+ batchSize?: number;
43
+ /**
44
+ * Block timeout in milliseconds when waiting for messages.
45
+ * @default 5000
46
+ */
47
+ blockTimeoutMs?: number;
48
+ /**
49
+ * Maximum stream length (MAXLEN for XADD).
50
+ * Set to 0 for unlimited.
51
+ * @default 0
52
+ */
53
+ maxStreamLength?: number;
54
+ /**
55
+ * Whether to use approximate MAXLEN (~).
56
+ * More efficient but less precise.
57
+ * @default true
58
+ */
59
+ approximateMaxLen?: boolean;
60
+ /**
61
+ * How often to check for delayed messages in milliseconds.
62
+ * @default 1000
63
+ */
64
+ delayedPollIntervalMs?: number;
65
+ /**
66
+ * Key for the delayed messages sorted set.
67
+ * @default "saga-bus:delayed"
68
+ */
69
+ delayedSetKey?: string;
70
+ /**
71
+ * How often to claim pending messages in milliseconds.
72
+ * Set to 0 to disable.
73
+ * @default 30000
74
+ */
75
+ pendingClaimIntervalMs?: number;
76
+ /**
77
+ * Minimum idle time before claiming a pending message in milliseconds.
78
+ * @default 60000
79
+ */
80
+ minIdleTimeMs?: number;
81
+ }
82
+
83
+ /**
84
+ * Redis Streams transport implementation for saga-bus.
85
+ *
86
+ * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:
87
+ * - Consumer groups for competing consumers
88
+ * - Message acknowledgment (XACK)
89
+ * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)
90
+ * - Pending message claiming (XCLAIM) for recovery
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * import Redis from "ioredis";
95
+ * import { RedisTransport } from "@saga-bus/transport-redis";
96
+ *
97
+ * const transport = new RedisTransport({
98
+ * redis: new Redis(),
99
+ * consumerGroup: "order-processor",
100
+ * });
101
+ *
102
+ * await transport.start();
103
+ * ```
104
+ */
105
+ declare class RedisTransport implements Transport {
106
+ private redis;
107
+ private subscriberRedis;
108
+ private readonly options;
109
+ private readonly subscriptions;
110
+ private started;
111
+ private stopping;
112
+ private readLoopPromise;
113
+ private delayedPollInterval;
114
+ private pendingClaimInterval;
115
+ constructor(options: RedisTransportOptions);
116
+ start(): Promise<void>;
117
+ stop(): Promise<void>;
118
+ subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
119
+ publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
120
+ private addToStream;
121
+ private ensureConsumerGroup;
122
+ private readLoop;
123
+ private processMessage;
124
+ private acknowledgeMessage;
125
+ private processDelayedMessages;
126
+ private claimPendingMessages;
127
+ }
128
+
129
+ export { RedisTransport, type RedisTransportOptions };
@@ -0,0 +1,129 @@
1
+ import { Transport, BaseMessage, TransportSubscribeOptions, MessageEnvelope, TransportPublishOptions } from '@saga-bus/core';
2
+ import { Redis, RedisOptions } from 'ioredis';
3
+
4
+ /**
5
+ * Configuration options for Redis Streams transport.
6
+ */
7
+ interface RedisTransportOptions {
8
+ /**
9
+ * Redis client instance.
10
+ * Either redis or connection must be provided.
11
+ */
12
+ redis?: Redis;
13
+ /**
14
+ * Redis connection options.
15
+ * Used to create a new Redis client if redis is not provided.
16
+ */
17
+ connection?: RedisOptions;
18
+ /**
19
+ * Prefix for all stream keys.
20
+ * @default "saga-bus:"
21
+ */
22
+ keyPrefix?: string;
23
+ /**
24
+ * Consumer group name.
25
+ * Required for subscribing.
26
+ */
27
+ consumerGroup?: string;
28
+ /**
29
+ * Consumer name within the group.
30
+ * @default Auto-generated UUID
31
+ */
32
+ consumerName?: string;
33
+ /**
34
+ * Whether to create consumer groups automatically.
35
+ * @default true
36
+ */
37
+ autoCreateGroup?: boolean;
38
+ /**
39
+ * Maximum number of messages to fetch per read.
40
+ * @default 10
41
+ */
42
+ batchSize?: number;
43
+ /**
44
+ * Block timeout in milliseconds when waiting for messages.
45
+ * @default 5000
46
+ */
47
+ blockTimeoutMs?: number;
48
+ /**
49
+ * Maximum stream length (MAXLEN for XADD).
50
+ * Set to 0 for unlimited.
51
+ * @default 0
52
+ */
53
+ maxStreamLength?: number;
54
+ /**
55
+ * Whether to use approximate MAXLEN (~).
56
+ * More efficient but less precise.
57
+ * @default true
58
+ */
59
+ approximateMaxLen?: boolean;
60
+ /**
61
+ * How often to check for delayed messages in milliseconds.
62
+ * @default 1000
63
+ */
64
+ delayedPollIntervalMs?: number;
65
+ /**
66
+ * Key for the delayed messages sorted set.
67
+ * @default "saga-bus:delayed"
68
+ */
69
+ delayedSetKey?: string;
70
+ /**
71
+ * How often to claim pending messages in milliseconds.
72
+ * Set to 0 to disable.
73
+ * @default 30000
74
+ */
75
+ pendingClaimIntervalMs?: number;
76
+ /**
77
+ * Minimum idle time before claiming a pending message in milliseconds.
78
+ * @default 60000
79
+ */
80
+ minIdleTimeMs?: number;
81
+ }
82
+
83
+ /**
84
+ * Redis Streams transport implementation for saga-bus.
85
+ *
86
+ * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:
87
+ * - Consumer groups for competing consumers
88
+ * - Message acknowledgment (XACK)
89
+ * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)
90
+ * - Pending message claiming (XCLAIM) for recovery
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * import Redis from "ioredis";
95
+ * import { RedisTransport } from "@saga-bus/transport-redis";
96
+ *
97
+ * const transport = new RedisTransport({
98
+ * redis: new Redis(),
99
+ * consumerGroup: "order-processor",
100
+ * });
101
+ *
102
+ * await transport.start();
103
+ * ```
104
+ */
105
+ declare class RedisTransport implements Transport {
106
+ private redis;
107
+ private subscriberRedis;
108
+ private readonly options;
109
+ private readonly subscriptions;
110
+ private started;
111
+ private stopping;
112
+ private readLoopPromise;
113
+ private delayedPollInterval;
114
+ private pendingClaimInterval;
115
+ constructor(options: RedisTransportOptions);
116
+ start(): Promise<void>;
117
+ stop(): Promise<void>;
118
+ subscribe<TMessage extends BaseMessage>(options: TransportSubscribeOptions, handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>): Promise<void>;
119
+ publish<TMessage extends BaseMessage>(message: TMessage, options: TransportPublishOptions): Promise<void>;
120
+ private addToStream;
121
+ private ensureConsumerGroup;
122
+ private readLoop;
123
+ private processMessage;
124
+ private acknowledgeMessage;
125
+ private processDelayedMessages;
126
+ private claimPendingMessages;
127
+ }
128
+
129
+ export { RedisTransport, type RedisTransportOptions };
package/dist/index.js ADDED
@@ -0,0 +1,312 @@
1
+ // src/RedisTransport.ts
2
+ import { Redis } from "ioredis";
3
+ import { randomUUID } from "crypto";
4
+ var RedisTransport = class {
5
+ redis = null;
6
+ subscriberRedis = null;
7
+ options;
8
+ subscriptions = [];
9
+ started = false;
10
+ stopping = false;
11
+ readLoopPromise = null;
12
+ delayedPollInterval = null;
13
+ pendingClaimInterval = null;
14
+ constructor(options) {
15
+ if (!options.redis && !options.connection) {
16
+ throw new Error("Either redis client or connection options must be provided");
17
+ }
18
+ this.options = {
19
+ keyPrefix: "saga-bus:",
20
+ consumerGroup: "",
21
+ consumerName: `consumer-${randomUUID()}`,
22
+ autoCreateGroup: true,
23
+ batchSize: 10,
24
+ blockTimeoutMs: 5e3,
25
+ maxStreamLength: 0,
26
+ approximateMaxLen: true,
27
+ delayedPollIntervalMs: 1e3,
28
+ delayedSetKey: "saga-bus:delayed",
29
+ pendingClaimIntervalMs: 3e4,
30
+ minIdleTimeMs: 6e4,
31
+ ...options
32
+ };
33
+ }
34
+ async start() {
35
+ if (this.started) return;
36
+ if (this.options.redis) {
37
+ this.redis = this.options.redis;
38
+ this.subscriberRedis = this.options.redis.duplicate();
39
+ } else if (this.options.connection) {
40
+ this.redis = new Redis(this.options.connection);
41
+ this.subscriberRedis = new Redis(this.options.connection);
42
+ } else {
43
+ throw new Error("Invalid configuration");
44
+ }
45
+ if (this.options.autoCreateGroup && this.options.consumerGroup) {
46
+ for (const sub of this.subscriptions) {
47
+ await this.ensureConsumerGroup(sub.streamKey);
48
+ }
49
+ }
50
+ this.started = true;
51
+ if (this.subscriptions.length > 0) {
52
+ this.readLoopPromise = this.readLoop();
53
+ }
54
+ if (this.options.delayedPollIntervalMs > 0) {
55
+ this.delayedPollInterval = setInterval(
56
+ () => void this.processDelayedMessages(),
57
+ this.options.delayedPollIntervalMs
58
+ );
59
+ }
60
+ if (this.options.pendingClaimIntervalMs > 0) {
61
+ this.pendingClaimInterval = setInterval(
62
+ () => void this.claimPendingMessages(),
63
+ this.options.pendingClaimIntervalMs
64
+ );
65
+ }
66
+ }
67
+ async stop() {
68
+ if (!this.started || this.stopping) return;
69
+ this.stopping = true;
70
+ if (this.delayedPollInterval) {
71
+ clearInterval(this.delayedPollInterval);
72
+ this.delayedPollInterval = null;
73
+ }
74
+ if (this.pendingClaimInterval) {
75
+ clearInterval(this.pendingClaimInterval);
76
+ this.pendingClaimInterval = null;
77
+ }
78
+ if (this.readLoopPromise) {
79
+ await this.readLoopPromise;
80
+ this.readLoopPromise = null;
81
+ }
82
+ if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {
83
+ await this.subscriberRedis.quit();
84
+ }
85
+ this.subscriberRedis = null;
86
+ if (this.redis && !this.options.redis) {
87
+ await this.redis.quit();
88
+ }
89
+ this.redis = null;
90
+ this.started = false;
91
+ this.stopping = false;
92
+ }
93
+ async subscribe(options, handler) {
94
+ const { endpoint, concurrency = 1 } = options;
95
+ const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
96
+ const subscription = {
97
+ streamKey,
98
+ handler,
99
+ concurrency
100
+ };
101
+ this.subscriptions.push(subscription);
102
+ if (this.started && this.redis) {
103
+ await this.ensureConsumerGroup(streamKey);
104
+ if (!this.readLoopPromise) {
105
+ this.readLoopPromise = this.readLoop();
106
+ }
107
+ }
108
+ }
109
+ async publish(message, options) {
110
+ if (!this.redis) {
111
+ throw new Error("Transport not started");
112
+ }
113
+ const { endpoint, key, headers = {}, delayMs } = options;
114
+ const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;
115
+ const envelope = {
116
+ id: randomUUID(),
117
+ type: message.type,
118
+ payload: message,
119
+ headers,
120
+ timestamp: /* @__PURE__ */ new Date(),
121
+ partitionKey: key
122
+ };
123
+ const envelopeJson = JSON.stringify(envelope);
124
+ if (delayMs && delayMs > 0) {
125
+ const deliverAt = Date.now() + delayMs;
126
+ const delayedEntry = {
127
+ streamKey,
128
+ envelope: envelopeJson,
129
+ deliverAt
130
+ };
131
+ await this.redis.zadd(
132
+ this.options.delayedSetKey,
133
+ deliverAt,
134
+ JSON.stringify(delayedEntry)
135
+ );
136
+ return;
137
+ }
138
+ await this.addToStream(streamKey, envelopeJson);
139
+ }
140
+ async addToStream(streamKey, envelopeJson) {
141
+ if (!this.redis) return;
142
+ const args = [streamKey];
143
+ if (this.options.maxStreamLength > 0) {
144
+ if (this.options.approximateMaxLen) {
145
+ args.push("MAXLEN", "~", this.options.maxStreamLength);
146
+ } else {
147
+ args.push("MAXLEN", this.options.maxStreamLength);
148
+ }
149
+ }
150
+ args.push("*", "data", envelopeJson);
151
+ await this.redis.xadd(...args);
152
+ }
153
+ async ensureConsumerGroup(streamKey) {
154
+ if (!this.redis || !this.options.consumerGroup) return;
155
+ try {
156
+ await this.redis.xgroup(
157
+ "CREATE",
158
+ streamKey,
159
+ this.options.consumerGroup,
160
+ "0",
161
+ "MKSTREAM"
162
+ );
163
+ } catch (error) {
164
+ if (error instanceof Error && !error.message.includes("BUSYGROUP")) {
165
+ throw error;
166
+ }
167
+ }
168
+ }
169
+ async readLoop() {
170
+ if (!this.subscriberRedis || !this.options.consumerGroup) return;
171
+ const streams = this.subscriptions.map((s) => s.streamKey);
172
+ const ids = streams.map(() => ">");
173
+ while (this.started && !this.stopping) {
174
+ try {
175
+ const results = await this.subscriberRedis.xreadgroup(
176
+ "GROUP",
177
+ this.options.consumerGroup,
178
+ this.options.consumerName,
179
+ "COUNT",
180
+ this.options.batchSize,
181
+ "BLOCK",
182
+ this.options.blockTimeoutMs,
183
+ "STREAMS",
184
+ ...streams,
185
+ ...ids
186
+ );
187
+ if (!results) continue;
188
+ const typedResults = results;
189
+ for (const [streamKey, messages] of typedResults) {
190
+ const subscription = this.subscriptions.find(
191
+ (s) => s.streamKey === streamKey
192
+ );
193
+ if (!subscription) continue;
194
+ for (const [messageId, fields] of messages) {
195
+ await this.processMessage(
196
+ streamKey,
197
+ messageId,
198
+ fields,
199
+ subscription
200
+ );
201
+ }
202
+ }
203
+ } catch (error) {
204
+ if (!this.stopping) {
205
+ console.error("[RedisTransport] Read loop error:", error);
206
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
207
+ }
208
+ }
209
+ }
210
+ }
211
+ async processMessage(streamKey, messageId, fields, subscription) {
212
+ if (!this.redis) return;
213
+ try {
214
+ const data = {};
215
+ for (let i = 0; i < fields.length; i += 2) {
216
+ data[fields[i]] = fields[i + 1];
217
+ }
218
+ if (!data.data) {
219
+ console.error("[RedisTransport] Message missing data field:", messageId);
220
+ await this.acknowledgeMessage(streamKey, messageId);
221
+ return;
222
+ }
223
+ const rawEnvelope = JSON.parse(data.data);
224
+ const envelope = {
225
+ id: rawEnvelope.id,
226
+ type: rawEnvelope.type,
227
+ payload: rawEnvelope.payload,
228
+ headers: rawEnvelope.headers,
229
+ timestamp: new Date(rawEnvelope.timestamp),
230
+ partitionKey: rawEnvelope.partitionKey
231
+ };
232
+ await subscription.handler(envelope);
233
+ await this.acknowledgeMessage(streamKey, messageId);
234
+ } catch (error) {
235
+ console.error("[RedisTransport] Message processing error:", error);
236
+ }
237
+ }
238
+ async acknowledgeMessage(streamKey, messageId) {
239
+ if (!this.redis || !this.options.consumerGroup) return;
240
+ await this.redis.xack(streamKey, this.options.consumerGroup, messageId);
241
+ }
242
+ async processDelayedMessages() {
243
+ if (!this.redis || this.stopping) return;
244
+ try {
245
+ const now = Date.now();
246
+ const entries = await this.redis.zrangebyscore(
247
+ this.options.delayedSetKey,
248
+ "-inf",
249
+ now
250
+ );
251
+ if (entries.length === 0) return;
252
+ for (const entryJson of entries) {
253
+ try {
254
+ const entry = JSON.parse(entryJson);
255
+ await this.addToStream(entry.streamKey, entry.envelope);
256
+ await this.redis.zrem(this.options.delayedSetKey, entryJson);
257
+ } catch (error) {
258
+ console.error("[RedisTransport] Error processing delayed message:", error);
259
+ }
260
+ }
261
+ } catch (error) {
262
+ console.error("[RedisTransport] Error in delayed message poll:", error);
263
+ }
264
+ }
265
+ async claimPendingMessages() {
266
+ if (!this.redis || !this.options.consumerGroup || this.stopping) return;
267
+ for (const subscription of this.subscriptions) {
268
+ try {
269
+ const pending = await this.redis.xpending(
270
+ subscription.streamKey,
271
+ this.options.consumerGroup,
272
+ "-",
273
+ "+",
274
+ 10
275
+ // Max entries to check
276
+ );
277
+ if (!Array.isArray(pending) || pending.length === 0) continue;
278
+ for (const entry of pending) {
279
+ if (!Array.isArray(entry) || entry.length < 4) continue;
280
+ const [messageId, , idleTime] = entry;
281
+ if (idleTime < this.options.minIdleTimeMs) continue;
282
+ try {
283
+ const claimed = await this.redis.xclaim(
284
+ subscription.streamKey,
285
+ this.options.consumerGroup,
286
+ this.options.consumerName,
287
+ this.options.minIdleTimeMs,
288
+ messageId
289
+ );
290
+ if (claimed && claimed.length > 0) {
291
+ const [claimedId, fields] = claimed[0];
292
+ await this.processMessage(
293
+ subscription.streamKey,
294
+ claimedId,
295
+ fields,
296
+ subscription
297
+ );
298
+ }
299
+ } catch (error) {
300
+ console.error("[RedisTransport] Error claiming message:", error);
301
+ }
302
+ }
303
+ } catch (error) {
304
+ console.error("[RedisTransport] Error in pending claim:", error);
305
+ }
306
+ }
307
+ }
308
+ };
309
+ export {
310
+ RedisTransport
311
+ };
312
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/RedisTransport.ts"],"sourcesContent":["import { Redis } from \"ioredis\";\nimport { randomUUID } from \"crypto\";\nimport {\n type Transport,\n type TransportSubscribeOptions,\n type TransportPublishOptions,\n type MessageEnvelope,\n type BaseMessage,\n} from \"@saga-bus/core\";\nimport type {\n RedisTransportOptions,\n StreamSubscription,\n DelayedMessageEntry,\n} from \"./types.js\";\n\n/**\n * Redis Streams transport implementation for saga-bus.\n *\n * Uses Redis Streams (XADD/XREADGROUP) for message delivery with:\n * - Consumer groups for competing consumers\n * - Message acknowledgment (XACK)\n * - Delayed messages via sorted sets (ZADD/ZRANGEBYSCORE)\n * - Pending message claiming (XCLAIM) for recovery\n *\n * @example\n * ```typescript\n * import Redis from \"ioredis\";\n * import { RedisTransport } from \"@saga-bus/transport-redis\";\n *\n * const transport = new RedisTransport({\n * redis: new Redis(),\n * consumerGroup: \"order-processor\",\n * });\n *\n * await transport.start();\n * ```\n */\nexport class RedisTransport implements Transport {\n private redis: Redis | null = null;\n private subscriberRedis: Redis | null = null;\n private readonly options: Required<\n Pick<\n RedisTransportOptions,\n | \"keyPrefix\"\n | \"consumerGroup\"\n | \"consumerName\"\n | \"autoCreateGroup\"\n | \"batchSize\"\n | \"blockTimeoutMs\"\n | \"maxStreamLength\"\n | \"approximateMaxLen\"\n | \"delayedPollIntervalMs\"\n | \"delayedSetKey\"\n | \"pendingClaimIntervalMs\"\n | \"minIdleTimeMs\"\n >\n > &\n RedisTransportOptions;\n\n private readonly subscriptions: StreamSubscription[] = [];\n private started = false;\n private stopping = false;\n private readLoopPromise: Promise<void> | null = null;\n private delayedPollInterval: ReturnType<typeof setInterval> | null = null;\n private pendingClaimInterval: ReturnType<typeof setInterval> | null = null;\n\n constructor(options: RedisTransportOptions) {\n if (!options.redis && !options.connection) {\n throw new Error(\"Either redis client or connection options must be provided\");\n }\n\n this.options = {\n keyPrefix: \"saga-bus:\",\n consumerGroup: \"\",\n consumerName: `consumer-${randomUUID()}`,\n autoCreateGroup: true,\n batchSize: 10,\n blockTimeoutMs: 5000,\n maxStreamLength: 0,\n approximateMaxLen: true,\n delayedPollIntervalMs: 1000,\n delayedSetKey: \"saga-bus:delayed\",\n pendingClaimIntervalMs: 30000,\n minIdleTimeMs: 60000,\n ...options,\n };\n }\n\n async start(): Promise<void> {\n if (this.started) return;\n\n // Create Redis clients\n if (this.options.redis) {\n this.redis = this.options.redis;\n // Create a separate connection for blocking reads\n this.subscriberRedis = this.options.redis.duplicate();\n } else if (this.options.connection) {\n this.redis = new Redis(this.options.connection);\n this.subscriberRedis = new Redis(this.options.connection);\n } else {\n throw new Error(\"Invalid configuration\");\n }\n\n // Create consumer groups for all subscribed streams\n if (this.options.autoCreateGroup && this.options.consumerGroup) {\n for (const sub of this.subscriptions) {\n await this.ensureConsumerGroup(sub.streamKey);\n }\n }\n\n this.started = true;\n\n // Start the read loop if we have subscriptions\n if (this.subscriptions.length > 0) {\n this.readLoopPromise = this.readLoop();\n }\n\n // Start delayed message polling\n if (this.options.delayedPollIntervalMs > 0) {\n this.delayedPollInterval = setInterval(\n () => void this.processDelayedMessages(),\n this.options.delayedPollIntervalMs\n );\n }\n\n // Start pending message claiming\n if (this.options.pendingClaimIntervalMs > 0) {\n this.pendingClaimInterval = setInterval(\n () => void this.claimPendingMessages(),\n this.options.pendingClaimIntervalMs\n );\n }\n }\n\n async stop(): Promise<void> {\n if (!this.started || this.stopping) return;\n this.stopping = true;\n\n // Stop polling intervals\n if (this.delayedPollInterval) {\n clearInterval(this.delayedPollInterval);\n this.delayedPollInterval = null;\n }\n\n if (this.pendingClaimInterval) {\n clearInterval(this.pendingClaimInterval);\n this.pendingClaimInterval = null;\n }\n\n // Wait for read loop to finish\n if (this.readLoopPromise) {\n await this.readLoopPromise;\n this.readLoopPromise = null;\n }\n\n // Close subscriber connection\n if (this.subscriberRedis && this.subscriberRedis !== this.options.redis) {\n await this.subscriberRedis.quit();\n }\n this.subscriberRedis = null;\n\n // Close main connection if we created it\n if (this.redis && !this.options.redis) {\n await this.redis.quit();\n }\n this.redis = null;\n\n this.started = false;\n this.stopping = false;\n }\n\n async subscribe<TMessage extends BaseMessage>(\n options: TransportSubscribeOptions,\n handler: (envelope: MessageEnvelope<TMessage>) => Promise<void>\n ): Promise<void> {\n const { endpoint, concurrency = 1 } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n const subscription: StreamSubscription = {\n streamKey,\n handler: handler as (envelope: unknown) => Promise<void>,\n concurrency,\n };\n\n this.subscriptions.push(subscription);\n\n // If already started, create consumer group and restart read loop\n if (this.started && this.redis) {\n await this.ensureConsumerGroup(streamKey);\n\n // Restart read loop with new subscription\n if (!this.readLoopPromise) {\n this.readLoopPromise = this.readLoop();\n }\n }\n }\n\n async publish<TMessage extends BaseMessage>(\n message: TMessage,\n options: TransportPublishOptions\n ): Promise<void> {\n if (!this.redis) {\n throw new Error(\"Transport not started\");\n }\n\n const { endpoint, key, headers = {}, delayMs } = options;\n const streamKey = `${this.options.keyPrefix}stream:${endpoint}`;\n\n // Create message envelope\n const envelope: MessageEnvelope<TMessage> = {\n id: randomUUID(),\n type: message.type,\n payload: message,\n headers: headers as Record<string, string>,\n timestamp: new Date(),\n partitionKey: key,\n };\n\n const envelopeJson = JSON.stringify(envelope);\n\n // Handle delayed delivery\n if (delayMs && delayMs > 0) {\n const deliverAt = Date.now() + delayMs;\n const delayedEntry: DelayedMessageEntry = {\n streamKey,\n envelope: envelopeJson,\n deliverAt,\n };\n\n // Store in sorted set with score = delivery timestamp\n await this.redis.zadd(\n this.options.delayedSetKey,\n deliverAt,\n JSON.stringify(delayedEntry)\n );\n return;\n }\n\n // Immediate delivery via stream\n await this.addToStream(streamKey, envelopeJson);\n }\n\n private async addToStream(streamKey: string, envelopeJson: string): Promise<void> {\n if (!this.redis) return;\n\n const args: (string | number)[] = [streamKey];\n\n // Add MAXLEN if configured\n if (this.options.maxStreamLength > 0) {\n if (this.options.approximateMaxLen) {\n args.push(\"MAXLEN\", \"~\", this.options.maxStreamLength);\n } else {\n args.push(\"MAXLEN\", this.options.maxStreamLength);\n }\n }\n\n args.push(\"*\", \"data\", envelopeJson);\n\n await this.redis.xadd(...(args as [string, ...Array<string | number>]));\n }\n\n private async ensureConsumerGroup(streamKey: string): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n try {\n // Create stream with empty entry if it doesn't exist, then create group\n await this.redis.xgroup(\n \"CREATE\",\n streamKey,\n this.options.consumerGroup,\n \"0\",\n \"MKSTREAM\"\n );\n } catch (error) {\n // Ignore \"BUSYGROUP Consumer Group name already exists\" error\n if (\n error instanceof Error &&\n !error.message.includes(\"BUSYGROUP\")\n ) {\n throw error;\n }\n }\n }\n\n private async readLoop(): Promise<void> {\n if (!this.subscriberRedis || !this.options.consumerGroup) return;\n\n const streams = this.subscriptions.map((s) => s.streamKey);\n const ids = streams.map(() => \">\"); // Only new messages\n\n while (this.started && !this.stopping) {\n try {\n const results = await this.subscriberRedis.xreadgroup(\n \"GROUP\",\n this.options.consumerGroup,\n this.options.consumerName,\n \"COUNT\",\n this.options.batchSize,\n \"BLOCK\",\n this.options.blockTimeoutMs,\n \"STREAMS\",\n ...streams,\n ...ids\n );\n\n if (!results) continue;\n\n // Process messages - results is Array<[streamKey, messages]>\n const typedResults = results as Array<\n [string, Array<[string, string[]]>]\n >;\n for (const [streamKey, messages] of typedResults) {\n const subscription = this.subscriptions.find(\n (s) => s.streamKey === streamKey\n );\n if (!subscription) continue;\n\n for (const [messageId, fields] of messages) {\n await this.processMessage(\n streamKey,\n messageId,\n fields,\n subscription\n );\n }\n }\n } catch (error) {\n if (!this.stopping) {\n console.error(\"[RedisTransport] Read loop error:\", error);\n // Brief pause before retrying\n await new Promise((resolve) => setTimeout(resolve, 1000));\n }\n }\n }\n }\n\n private async processMessage(\n streamKey: string,\n messageId: string,\n fields: string[],\n subscription: StreamSubscription\n ): Promise<void> {\n if (!this.redis) return;\n\n try {\n // Parse fields array into object\n const data: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) {\n data[fields[i]!] = fields[i + 1]!;\n }\n\n if (!data.data) {\n console.error(\"[RedisTransport] Message missing data field:\", messageId);\n await this.acknowledgeMessage(streamKey, messageId);\n return;\n }\n\n const rawEnvelope = JSON.parse(data.data) as {\n id: string;\n type: string;\n payload: unknown;\n headers: Record<string, string>;\n timestamp: string | Date;\n partitionKey?: string;\n };\n\n // Reconstruct Date objects into proper envelope\n const envelope: MessageEnvelope = {\n id: rawEnvelope.id,\n type: rawEnvelope.type,\n payload: rawEnvelope.payload as BaseMessage,\n headers: rawEnvelope.headers,\n timestamp: new Date(rawEnvelope.timestamp),\n partitionKey: rawEnvelope.partitionKey,\n };\n\n await subscription.handler(envelope);\n\n // Acknowledge successful processing\n await this.acknowledgeMessage(streamKey, messageId);\n } catch (error) {\n console.error(\"[RedisTransport] Message processing error:\", error);\n // Don't acknowledge - message will be claimed by pending recovery\n }\n }\n\n private async acknowledgeMessage(\n streamKey: string,\n messageId: string\n ): Promise<void> {\n if (!this.redis || !this.options.consumerGroup) return;\n\n await this.redis.xack(streamKey, this.options.consumerGroup, messageId);\n }\n\n private async processDelayedMessages(): Promise<void> {\n if (!this.redis || this.stopping) return;\n\n try {\n const now = Date.now();\n\n // Get all messages due for delivery\n const entries = await this.redis.zrangebyscore(\n this.options.delayedSetKey,\n \"-inf\",\n now\n );\n\n if (entries.length === 0) return;\n\n // Process each delayed message\n for (const entryJson of entries) {\n try {\n const entry = JSON.parse(entryJson) as DelayedMessageEntry;\n\n // Add to the target stream\n await this.addToStream(entry.streamKey, entry.envelope);\n\n // Remove from delayed set\n await this.redis.zrem(this.options.delayedSetKey, entryJson);\n } catch (error) {\n console.error(\"[RedisTransport] Error processing delayed message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in delayed message poll:\", error);\n }\n }\n\n private async claimPendingMessages(): Promise<void> {\n if (!this.redis || !this.options.consumerGroup || this.stopping) return;\n\n for (const subscription of this.subscriptions) {\n try {\n // Get pending messages for this stream\n const pending = await this.redis.xpending(\n subscription.streamKey,\n this.options.consumerGroup,\n \"-\",\n \"+\",\n 10 // Max entries to check\n );\n\n if (!Array.isArray(pending) || pending.length === 0) continue;\n\n for (const entry of pending) {\n if (!Array.isArray(entry) || entry.length < 4) continue;\n\n const [messageId, , idleTime] = entry as [string, string, number, number];\n\n // Only claim if idle time exceeds threshold\n if (idleTime < this.options.minIdleTimeMs) continue;\n\n try {\n // Claim the message\n const claimed = await this.redis.xclaim(\n subscription.streamKey,\n this.options.consumerGroup,\n this.options.consumerName,\n this.options.minIdleTimeMs,\n messageId\n );\n\n if (claimed && claimed.length > 0) {\n // Process the claimed message\n const [claimedId, fields] = claimed[0] as [string, string[]];\n await this.processMessage(\n subscription.streamKey,\n claimedId,\n fields,\n subscription\n );\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error claiming message:\", error);\n }\n }\n } catch (error) {\n console.error(\"[RedisTransport] Error in pending claim:\", error);\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@saga-bus/transport-redis",
3
+ "version": "0.1.2",
4
+ "description": "Redis Streams transport for saga-bus",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/d-e-a-n-f/saga-bus.git",
26
+ "directory": "packages/transport-redis"
27
+ },
28
+ "keywords": [
29
+ "saga",
30
+ "message-bus",
31
+ "transport",
32
+ "redis",
33
+ "redis-streams"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch",
38
+ "lint": "eslint src/",
39
+ "check-types": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ },
43
+ "dependencies": {
44
+ "@saga-bus/core": "workspace:*"
45
+ },
46
+ "peerDependencies": {
47
+ "ioredis": ">=5.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@repo/eslint-config": "workspace:*",
51
+ "@repo/typescript-config": "workspace:*",
52
+ "ioredis": "^5.4.2",
53
+ "tsup": "^8.0.0",
54
+ "typescript": "^5.9.2",
55
+ "vitest": "^3.0.0"
56
+ }
57
+ }