@beignet/provider-event-bus-redis 0.0.33

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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @beignet/provider-event-bus-redis
2
+
3
+ ## 0.0.33
4
+
5
+ ### Patch Changes
6
+
7
+ - 308770a: Add opt-in live Redis integration coverage for cross-instance event delivery, unsubscribe behavior, readiness, instrumentation, and failure callbacks.
8
+ - 92af951: Add a Redis Pub/Sub event bus provider and `event-bus-redis` provider preset for cross-process best-effort event delivery.
9
+
10
+ ## 0.0.32
11
+
12
+ ### Patch Changes
13
+
14
+ - Initial Redis Pub/Sub event bus provider for cross-process best-effort
15
+ `EventBusPort` delivery.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Taylor Bryant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # @beignet/provider-event-bus-redis
2
+
3
+ > [!CAUTION]
4
+ > Beignet is experimental alpha software. The `0.0.x` package line is for early
5
+ > evaluation, and APIs may change between releases while the framework settles.
6
+
7
+ Redis Pub/Sub `EventBusPort` provider for Beignet applications.
8
+
9
+ Use it when a multi-process app needs domain events published by one process to
10
+ reach listeners registered in another process. Delivery is best-effort:
11
+ subscribers must be online, Redis does not persist messages for later replay,
12
+ and the provider does not acknowledge, retry, or dead-letter failed handlers.
13
+ Use Beignet outbox and jobs for durable side effects that need retry,
14
+ idempotency, or recovery after downtime.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun add @beignet/provider-event-bus-redis @beignet/core ioredis
20
+ ```
21
+
22
+ You can also wire it into an existing app with the CLI:
23
+
24
+ ```bash
25
+ beignet providers add event-bus-redis
26
+ ```
27
+
28
+ ## Setup
29
+
30
+ ```typescript
31
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
32
+ import { redisEventBusProvider } from "@beignet/provider-event-bus-redis";
33
+ import { appPorts } from "@/infra/app-ports";
34
+ import { routes } from "@/server/routes";
35
+
36
+ // Set environment variables:
37
+ // REDIS_EVENT_BUS_URL=redis://localhost:6379
38
+ // REDIS_EVENT_BUS_DB=0 (optional)
39
+
40
+ export const getServer = createNextServerLoader(() =>
41
+ createNextServer({
42
+ ports: appPorts,
43
+ providers: [redisEventBusProvider],
44
+ context: ({ ports }) => ({
45
+ ports,
46
+ }),
47
+ routes,
48
+ }),
49
+ );
50
+ ```
51
+
52
+ The provider contributes `ctx.ports.eventBus`, the standard Beignet event bus
53
+ port, and `ctx.ports.redisEventBus` as a Redis-specific escape hatch.
54
+
55
+ ## Configuration
56
+
57
+ The provider reads environment variables with the `REDIS_EVENT_BUS_` prefix:
58
+
59
+ | Variable | Required | Description | Example |
60
+ | --- | --- | --- | --- |
61
+ | `REDIS_EVENT_BUS_URL` | Yes | Redis connection URL. | `redis://localhost:6379` |
62
+ | `REDIS_EVENT_BUS_DB` | No | Redis database number. Defaults to `0`. | `0` |
63
+ | `REDIS_EVENT_BUS_CHANNEL_PREFIX` | No | Prefix for Pub/Sub channels. Defaults to `beignet:event-bus:`. | `my-app:events:` |
64
+ | `REDIS_EVENT_BUS_CONNECT_TIMEOUT_MS` | No | Initial connection timeout in milliseconds. Defaults to `5000`. | `2000` |
65
+ | `REDIS_EVENT_BUS_MAX_RETRIES_PER_REQUEST` | No | Per-command retry limit before a command rejects. Defaults to `2`. | `1` |
66
+ | `REDIS_EVENT_BUS_CONNECT_MAX_ATTEMPTS` | No | Connection attempts before startup fails. Defaults to `3`. | `5` |
67
+
68
+ ### Factory options
69
+
70
+ Use `createRedisEventBusProvider(options)` when the app should own connection
71
+ settings. Defined options override matching `REDIS_EVENT_BUS_*` environment
72
+ variables:
73
+
74
+ ```typescript
75
+ import { createRedisEventBusProvider } from "@beignet/provider-event-bus-redis";
76
+
77
+ const redisEventBusProvider = createRedisEventBusProvider({
78
+ channelPrefix: "my-app:events:",
79
+ connectTimeoutMs: 2000,
80
+ maxRetriesPerRequest: 1,
81
+ db: 1,
82
+ retryStrategy: (times) => Math.min(times * 100, 1000),
83
+ });
84
+ ```
85
+
86
+ The default `redisEventBusProvider` export is
87
+ `createRedisEventBusProvider()` with no options.
88
+
89
+ ### Connection behavior
90
+
91
+ During startup the provider creates separate publisher and subscriber clients,
92
+ connects both eagerly, and fails boot with a redacted connection error when
93
+ Redis is unreachable. The default initial connection retry strategy stops
94
+ after `REDIS_EVENT_BUS_CONNECT_MAX_ATTEMPTS`; after a successful connection,
95
+ ioredis reconnects with capped exponential backoff.
96
+
97
+ ## Direct adapter
98
+
99
+ Use `createRedisEventBus(...)` when your app owns Redis clients:
100
+
101
+ ```typescript
102
+ import Redis from "ioredis";
103
+ import { createRedisEventBus } from "@beignet/provider-event-bus-redis";
104
+
105
+ const eventBus = createRedisEventBus({
106
+ publisher: new Redis(process.env.REDIS_EVENT_BUS_URL),
107
+ subscriber: new Redis(process.env.REDIS_EVENT_BUS_URL),
108
+ channelPrefix: "my-app:events:",
109
+ });
110
+ ```
111
+
112
+ Register listeners during startup before handling traffic. The `EventBusPort`
113
+ subscription API is synchronous, while Redis subscription acknowledgement is
114
+ asynchronous, so messages published immediately after a new subscription may be
115
+ missed during startup races.
116
+
117
+ ## Delivery semantics
118
+
119
+ - `publish(...)` publishes one Redis Pub/Sub message on
120
+ `<channelPrefix><event.name>`.
121
+ - Only subscribers that are online and currently subscribed receive the event.
122
+ - Payloads are JSON encoded and delivered to handlers without persisting the
123
+ message.
124
+ - Handler errors are reported through `onHandlerError` when provided and are
125
+ also recorded as provider instrumentation events; they do not reject the
126
+ original publisher.
127
+ - Invalid messages and subscription errors are reported through
128
+ `onSubscriberError` when provided.
129
+ - The provider does not retry handlers, store attempts, replay missed events,
130
+ or dead-letter failures.
131
+
132
+ When side effects must survive process restarts or dependency outages, record
133
+ the domain event through the Unit of Work outbox and drain it through jobs or
134
+ outbox-backed listeners.
135
+
136
+ ## Escape hatch and health
137
+
138
+ `ctx.ports.redisEventBus` exposes:
139
+
140
+ ```typescript
141
+ const health = await ctx.ports.redisEventBus.checkHealth();
142
+
143
+ ctx.ports.redisEventBus.publisher;
144
+ ctx.ports.redisEventBus.subscriber;
145
+ ctx.ports.redisEventBus.channelPrefix;
146
+ ```
147
+
148
+ `checkHealth()` sends cheap `PING` commands to both Redis clients and returns
149
+ metadata suitable for app-owned readiness endpoints.
150
+
151
+ ## Instrumentation
152
+
153
+ The provider records publish events under the `eventBus` watcher. Received
154
+ messages and handler failures are recorded as custom provider events. Payloads
155
+ are not recorded.
156
+
157
+ ## Testing
158
+
159
+ Use `@beignet/provider-event-bus-memory` for deterministic unit tests and
160
+ single-process local tests. Use this Redis provider in integration tests when
161
+ the behavior under test specifically depends on cross-process delivery or
162
+ Redis connection behavior.
163
+
164
+ This package includes opt-in live Redis integration tests for cross-instance
165
+ delivery, unsubscribe behavior, readiness, instrumentation, and failure
166
+ callbacks. The default `bun run test` command runs the hermetic unit suite only
167
+ so the live tests cannot share process state with the mocked `ioredis` tests.
168
+ Set `REDIS_EVENT_BUS_TEST_URL` or the shared `REDIS_TEST_URL` before running
169
+ the dedicated live test command:
170
+
171
+ ```bash
172
+ REDIS_EVENT_BUS_TEST_URL=redis://localhost:6379 bun run test:live
173
+ ```
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @beignet/provider-event-bus-redis
3
+ *
4
+ * Redis Pub/Sub event bus provider that implements EventBusPort for
5
+ * cross-process, best-effort domain event delivery.
6
+ */
7
+ import type { EventBusPort } from "@beignet/core/ports";
8
+ import { type ProviderInstrumentationTarget } from "@beignet/core/providers";
9
+ import { Redis } from "ioredis";
10
+ import { z } from "zod";
11
+ declare const RedisEventBusConfigSchema: z.ZodObject<{
12
+ URL: z.ZodString;
13
+ DB: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
14
+ CHANNEL_PREFIX: z.ZodDefault<z.ZodString>;
15
+ CONNECT_TIMEOUT_MS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
16
+ MAX_RETRIES_PER_REQUEST: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
17
+ CONNECT_MAX_ATTEMPTS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
18
+ }, z.core.$strip>;
19
+ export type RedisEventBusConfig = z.infer<typeof RedisEventBusConfigSchema>;
20
+ export interface RedisEventBusPublisherClient {
21
+ publish(channel: string, message: string): Promise<number> | number;
22
+ ping?(): Promise<string> | string;
23
+ }
24
+ export interface RedisEventBusSubscriberClient {
25
+ subscribe(...channels: string[]): Promise<unknown> | unknown;
26
+ unsubscribe(...channels: string[]): Promise<unknown> | unknown;
27
+ on(event: "message", handler: (channel: string, message: string) => void): unknown;
28
+ off?(event: "message", handler: (channel: string, message: string) => void): unknown;
29
+ removeListener?(event: "message", handler: (channel: string, message: string) => void): unknown;
30
+ ping?(): Promise<string> | string;
31
+ }
32
+ export interface RedisEventBusHealth {
33
+ ok: boolean;
34
+ message?: string;
35
+ metadata: {
36
+ adapter: "redis-event-bus";
37
+ channelPrefix: string;
38
+ publisherResponse?: string;
39
+ subscriberResponse?: string;
40
+ };
41
+ }
42
+ export interface RedisEventBusEscapeHatch {
43
+ publisher: RedisEventBusPublisherClient;
44
+ subscriber: RedisEventBusSubscriberClient;
45
+ channelPrefix: string;
46
+ checkHealth(): Promise<RedisEventBusHealth>;
47
+ }
48
+ export interface RedisEventBusProviderPorts {
49
+ eventBus: EventBusPort;
50
+ redisEventBus: RedisEventBusEscapeHatch;
51
+ }
52
+ export interface CreateRedisEventBusOptions {
53
+ publisher: RedisEventBusPublisherClient;
54
+ subscriber: RedisEventBusSubscriberClient;
55
+ channelPrefix?: string;
56
+ instrumentation?: ProviderInstrumentationTarget;
57
+ onHandlerError?: (error: unknown, eventName: string, payload: unknown) => void;
58
+ onSubscriberError?: (error: unknown) => void;
59
+ }
60
+ export interface CreateRedisEventBusProviderOptions {
61
+ channelPrefix?: string;
62
+ connectTimeoutMs?: number;
63
+ maxRetriesPerRequest?: number;
64
+ retryStrategy?: (times: number) => number | null;
65
+ db?: number;
66
+ }
67
+ export declare function createRedisEventBus(options: CreateRedisEventBusOptions): EventBusPort;
68
+ export declare function createRedisEventBusProvider(options?: CreateRedisEventBusProviderOptions): import("@beignet/core/providers").ServiceProvider<unknown, z.ZodObject<{
69
+ URL: z.ZodString;
70
+ DB: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
71
+ CHANNEL_PREFIX: z.ZodDefault<z.ZodString>;
72
+ CONNECT_TIMEOUT_MS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
73
+ MAX_RETRIES_PER_REQUEST: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
74
+ CONNECT_MAX_ATTEMPTS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
75
+ }, z.core.$strip>, {
76
+ eventBus: EventBusPort;
77
+ redisEventBus: {
78
+ publisher: Redis;
79
+ subscriber: Redis;
80
+ channelPrefix: string;
81
+ checkHealth: () => Promise<RedisEventBusHealth>;
82
+ };
83
+ }, unknown, void>;
84
+ export declare const redisEventBusProvider: import("@beignet/core/providers").ServiceProvider<unknown, z.ZodObject<{
85
+ URL: z.ZodString;
86
+ DB: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
87
+ CHANNEL_PREFIX: z.ZodDefault<z.ZodString>;
88
+ CONNECT_TIMEOUT_MS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
89
+ MAX_RETRIES_PER_REQUEST: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
90
+ CONNECT_MAX_ATTEMPTS: z.ZodDefault<z.ZodUnion<readonly [z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodNumber]>>;
91
+ }, z.core.$strip>, {
92
+ eventBus: EventBusPort;
93
+ redisEventBus: {
94
+ publisher: Redis;
95
+ subscriber: Redis;
96
+ channelPrefix: string;
97
+ checkHealth: () => Promise<RedisEventBusHealth>;
98
+ };
99
+ }, unknown, void>;
100
+ export {};
101
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * @beignet/provider-event-bus-redis
3
+ *
4
+ * Redis Pub/Sub event bus provider that implements EventBusPort for
5
+ * cross-process, best-effort domain event delivery.
6
+ */
7
+ import { createProvider, createProviderInstrumentation, } from "@beignet/core/providers";
8
+ import { Redis } from "ioredis";
9
+ import { z } from "zod";
10
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
11
+ const DEFAULT_MAX_RETRIES_PER_REQUEST = 2;
12
+ const DEFAULT_CONNECT_MAX_ATTEMPTS = 3;
13
+ const DEFAULT_CHANNEL_PREFIX = "beignet:event-bus:";
14
+ const RECONNECT_BACKOFF_CAP_MS = 2000;
15
+ const NumberString = z.union([
16
+ z
17
+ .string()
18
+ .regex(/^\d+$/, "Expected a non-negative integer string")
19
+ .transform((value) => Number.parseInt(value, 10)),
20
+ z.number().int().nonnegative(),
21
+ ]);
22
+ const RedisEventBusConfigSchema = z.object({
23
+ URL: z.string().url(),
24
+ DB: NumberString.optional(),
25
+ CHANNEL_PREFIX: z.string().default(DEFAULT_CHANNEL_PREFIX),
26
+ CONNECT_TIMEOUT_MS: NumberString.default(DEFAULT_CONNECT_TIMEOUT_MS),
27
+ MAX_RETRIES_PER_REQUEST: NumberString.default(DEFAULT_MAX_RETRIES_PER_REQUEST),
28
+ CONNECT_MAX_ATTEMPTS: NumberString.default(DEFAULT_CONNECT_MAX_ATTEMPTS),
29
+ });
30
+ function errorMessage(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ function redactConnectionUrl(url) {
34
+ try {
35
+ const parsed = new URL(url);
36
+ if (parsed.username)
37
+ parsed.username = "redacted";
38
+ if (parsed.password)
39
+ parsed.password = "redacted";
40
+ if (parsed.search)
41
+ parsed.search = "?redacted";
42
+ parsed.hash = "";
43
+ return parsed.toString();
44
+ }
45
+ catch {
46
+ return "[invalid URL]";
47
+ }
48
+ }
49
+ function reconnectBackoff(attempt) {
50
+ return Math.min(2 ** attempt * 100, RECONNECT_BACKOFF_CAP_MS);
51
+ }
52
+ function createRetryStrategy(connectMaxAttempts) {
53
+ let connectedOnce = false;
54
+ return {
55
+ strategy(attempt) {
56
+ if (!connectedOnce && attempt >= connectMaxAttempts) {
57
+ return null;
58
+ }
59
+ return reconnectBackoff(attempt);
60
+ },
61
+ markConnected() {
62
+ connectedOnce = true;
63
+ },
64
+ };
65
+ }
66
+ function channelForEvent(channelPrefix, eventName) {
67
+ return `${channelPrefix}${eventName}`;
68
+ }
69
+ function eventNameForChannel(channelPrefix, channel) {
70
+ if (channelPrefix === "")
71
+ return channel;
72
+ if (!channel.startsWith(channelPrefix))
73
+ return undefined;
74
+ return channel.slice(channelPrefix.length);
75
+ }
76
+ function safeJsonParse(input) {
77
+ return JSON.parse(input);
78
+ }
79
+ async function checkRedisEventBusHealth(publisher, subscriber, channelPrefix) {
80
+ try {
81
+ const [publisherResponse, subscriberResponse] = await Promise.all([
82
+ publisher.ping ? Promise.resolve(publisher.ping()) : Promise.resolve(""),
83
+ subscriber.ping
84
+ ? Promise.resolve(subscriber.ping())
85
+ : Promise.resolve(""),
86
+ ]);
87
+ return {
88
+ ok: true,
89
+ metadata: {
90
+ adapter: "redis-event-bus",
91
+ channelPrefix,
92
+ ...(publisherResponse ? { publisherResponse } : {}),
93
+ ...(subscriberResponse ? { subscriberResponse } : {}),
94
+ },
95
+ };
96
+ }
97
+ catch (error) {
98
+ return {
99
+ ok: false,
100
+ message: errorMessage(error),
101
+ metadata: {
102
+ adapter: "redis-event-bus",
103
+ channelPrefix,
104
+ },
105
+ };
106
+ }
107
+ }
108
+ export function createRedisEventBus(options) {
109
+ const channelPrefix = options.channelPrefix ?? DEFAULT_CHANNEL_PREFIX;
110
+ const handlers = new Map();
111
+ const subscribedChannels = new Set();
112
+ const instrumentation = createProviderInstrumentation(options.instrumentation, {
113
+ providerName: "event-bus-redis",
114
+ watcher: "eventBus",
115
+ });
116
+ const reportSubscriberError = (error) => {
117
+ try {
118
+ options.onSubscriberError?.(error);
119
+ }
120
+ catch {
121
+ // Error callbacks should not create unhandled rejections.
122
+ }
123
+ };
124
+ const reportHandlerError = (error, eventName, payload) => {
125
+ instrumentation.custom({
126
+ name: "eventBus.handler.failed",
127
+ label: "Event handler failed",
128
+ summary: `Handler failed for "${eventName}"`,
129
+ details: {
130
+ eventName,
131
+ error: errorMessage(error),
132
+ },
133
+ });
134
+ try {
135
+ options.onHandlerError?.(error, eventName, payload);
136
+ }
137
+ catch {
138
+ // Error callbacks should not create unhandled rejections.
139
+ }
140
+ };
141
+ const messageHandler = (channel, message) => {
142
+ const eventName = eventNameForChannel(channelPrefix, channel);
143
+ if (!eventName)
144
+ return;
145
+ const eventHandlers = handlers.get(eventName);
146
+ if (!eventHandlers || eventHandlers.size === 0)
147
+ return;
148
+ let payload;
149
+ try {
150
+ const parsed = safeJsonParse(message);
151
+ if (typeof parsed !== "object" ||
152
+ parsed === null ||
153
+ !("payload" in parsed)) {
154
+ throw new Error("Redis event bus message is missing a payload field.");
155
+ }
156
+ payload = parsed.payload;
157
+ }
158
+ catch (error) {
159
+ reportSubscriberError(error);
160
+ return;
161
+ }
162
+ instrumentation.custom({
163
+ name: "eventBus.received",
164
+ label: "Event received",
165
+ summary: `Received "${eventName}"`,
166
+ details: { eventName },
167
+ });
168
+ for (const handler of [...eventHandlers]) {
169
+ try {
170
+ Promise.resolve(handler(payload)).catch((error) => {
171
+ reportHandlerError(error, eventName, payload);
172
+ });
173
+ }
174
+ catch (error) {
175
+ reportHandlerError(error, eventName, payload);
176
+ }
177
+ }
178
+ };
179
+ options.subscriber.on("message", messageHandler);
180
+ const ensureSubscribed = (eventName) => {
181
+ const channel = channelForEvent(channelPrefix, eventName);
182
+ if (subscribedChannels.has(channel))
183
+ return;
184
+ subscribedChannels.add(channel);
185
+ Promise.resolve(options.subscriber.subscribe(channel)).catch((error) => {
186
+ subscribedChannels.delete(channel);
187
+ reportSubscriberError(error);
188
+ });
189
+ };
190
+ const unsubscribeChannel = (eventName) => {
191
+ const channel = channelForEvent(channelPrefix, eventName);
192
+ if (!subscribedChannels.delete(channel))
193
+ return;
194
+ Promise.resolve(options.subscriber.unsubscribe(channel)).catch(reportSubscriberError);
195
+ };
196
+ return {
197
+ async publish(event, payload) {
198
+ instrumentation.record({
199
+ type: "eventBus",
200
+ eventName: event.name,
201
+ });
202
+ await options.publisher.publish(channelForEvent(channelPrefix, event.name), JSON.stringify({ eventName: event.name, payload }));
203
+ },
204
+ subscribe(event, handler) {
205
+ let eventHandlers = handlers.get(event.name);
206
+ if (!eventHandlers) {
207
+ eventHandlers = new Set();
208
+ handlers.set(event.name, eventHandlers);
209
+ }
210
+ eventHandlers.add(handler);
211
+ ensureSubscribed(event.name);
212
+ return () => {
213
+ const currentHandlers = handlers.get(event.name);
214
+ if (!currentHandlers)
215
+ return;
216
+ currentHandlers.delete(handler);
217
+ if (currentHandlers.size === 0) {
218
+ handlers.delete(event.name);
219
+ unsubscribeChannel(event.name);
220
+ }
221
+ };
222
+ },
223
+ };
224
+ }
225
+ export function createRedisEventBusProvider(options = {}) {
226
+ const providerMetadata = {
227
+ packageName: "@beignet/provider-event-bus-redis",
228
+ ports: ["eventBus", "redisEventBus"],
229
+ env: [
230
+ "REDIS_EVENT_BUS_URL",
231
+ "REDIS_EVENT_BUS_DB",
232
+ "REDIS_EVENT_BUS_CHANNEL_PREFIX",
233
+ "REDIS_EVENT_BUS_CONNECT_TIMEOUT_MS",
234
+ "REDIS_EVENT_BUS_MAX_RETRIES_PER_REQUEST",
235
+ "REDIS_EVENT_BUS_CONNECT_MAX_ATTEMPTS",
236
+ ],
237
+ watchers: ["eventBus"],
238
+ };
239
+ return createProvider({
240
+ name: "event-bus-redis",
241
+ metadata: providerMetadata,
242
+ config: {
243
+ schema: RedisEventBusConfigSchema,
244
+ envPrefix: "REDIS_EVENT_BUS_",
245
+ // Defined options win over env-derived values at config load time.
246
+ overrides: {
247
+ DB: options.db,
248
+ CHANNEL_PREFIX: options.channelPrefix,
249
+ CONNECT_TIMEOUT_MS: options.connectTimeoutMs,
250
+ MAX_RETRIES_PER_REQUEST: options.maxRetriesPerRequest,
251
+ },
252
+ },
253
+ async setup({ config, ports }) {
254
+ if (!config) {
255
+ throw new Error("[redisEventBusProvider] Missing Redis event bus config. " +
256
+ "Please set REDIS_EVENT_BUS_URL.");
257
+ }
258
+ const configuredChannelPrefix = config.CHANNEL_PREFIX ??
259
+ options.channelPrefix ??
260
+ DEFAULT_CHANNEL_PREFIX;
261
+ const connectMaxAttempts = config.CONNECT_MAX_ATTEMPTS ?? DEFAULT_CONNECT_MAX_ATTEMPTS;
262
+ const publisherRetry = createRetryStrategy(connectMaxAttempts);
263
+ const subscriberRetry = createRetryStrategy(connectMaxAttempts);
264
+ const redisOptions = {
265
+ db: config.DB ?? options.db ?? 0,
266
+ lazyConnect: true,
267
+ connectTimeout: config.CONNECT_TIMEOUT_MS ??
268
+ options.connectTimeoutMs ??
269
+ DEFAULT_CONNECT_TIMEOUT_MS,
270
+ maxRetriesPerRequest: config.MAX_RETRIES_PER_REQUEST ??
271
+ options.maxRetriesPerRequest ??
272
+ DEFAULT_MAX_RETRIES_PER_REQUEST,
273
+ };
274
+ const publisher = new Redis(config.URL, {
275
+ ...redisOptions,
276
+ retryStrategy: options.retryStrategy ?? publisherRetry.strategy,
277
+ });
278
+ const subscriber = new Redis(config.URL, {
279
+ ...redisOptions,
280
+ retryStrategy: options.retryStrategy ?? subscriberRetry.strategy,
281
+ });
282
+ try {
283
+ await publisher.connect();
284
+ publisherRetry.markConnected();
285
+ await subscriber.connect();
286
+ subscriberRetry.markConnected();
287
+ }
288
+ catch (error) {
289
+ publisher.disconnect();
290
+ subscriber.disconnect();
291
+ throw new Error(`[redisEventBusProvider] Failed to connect to Redis at ${redactConnectionUrl(config.URL)}: ${errorMessage(error)}`);
292
+ }
293
+ const eventBus = createRedisEventBus({
294
+ publisher,
295
+ subscriber,
296
+ channelPrefix: configuredChannelPrefix,
297
+ instrumentation: ports,
298
+ });
299
+ return {
300
+ ports: {
301
+ eventBus,
302
+ redisEventBus: {
303
+ publisher,
304
+ subscriber,
305
+ channelPrefix: configuredChannelPrefix,
306
+ checkHealth: () => checkRedisEventBusHealth(publisher, subscriber, configuredChannelPrefix),
307
+ },
308
+ },
309
+ async stop() {
310
+ try {
311
+ await subscriber.quit();
312
+ }
313
+ catch {
314
+ subscriber.disconnect();
315
+ }
316
+ try {
317
+ await publisher.quit();
318
+ }
319
+ catch {
320
+ publisher.disconnect();
321
+ }
322
+ },
323
+ };
324
+ },
325
+ });
326
+ }
327
+ export const redisEventBusProvider = createRedisEventBusProvider();
328
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@beignet/provider-event-bus-redis",
3
+ "version": "0.0.33",
4
+ "type": "module",
5
+ "description": "Redis Pub/Sub event bus provider for Beignet - implements EventBusPort for cross-process best-effort delivery",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "!src/**/*.test.tsx",
19
+ "!src/**/*.test-d.ts",
20
+ "README.md",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "prepack": "bun run build",
26
+ "dev": "tsc --watch",
27
+ "clean": "rm -rf dist coverage .turbo",
28
+ "test": "bun test src/index.test.ts",
29
+ "test:live": "bun test src/redis-live.test.ts",
30
+ "test:coverage": "bun test --coverage src/index.test.ts",
31
+ "lint": "biome check ."
32
+ },
33
+ "keywords": [
34
+ "beignet",
35
+ "event-bus",
36
+ "redis",
37
+ "provider",
38
+ "pubsub",
39
+ "ports"
40
+ ],
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/taylorbryant/beignet.git",
45
+ "directory": "packages/provider-event-bus-redis"
46
+ },
47
+ "author": "Taylor Bryant",
48
+ "homepage": "https://github.com/taylorbryant/beignet#readme",
49
+ "bugs": "https://github.com/taylorbryant/beignet/issues",
50
+ "sideEffects": false,
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "engines": {
55
+ "node": ">=18.0.0"
56
+ },
57
+ "peerDependencies": {
58
+ "@beignet/core": ">=0.0.3 <1.0.0",
59
+ "ioredis": "^5.0.0"
60
+ },
61
+ "dependencies": {
62
+ "zod": "^4.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@beignet/devtools": "*",
66
+ "@types/bun": "^1.3.13",
67
+ "@types/node": "^20.10.0",
68
+ "ioredis": "^5.0.0",
69
+ "typescript": "^5.3.0"
70
+ },
71
+ "beignet": {
72
+ "provider": {
73
+ "displayName": "Redis event bus provider",
74
+ "ports": [
75
+ "eventBus",
76
+ "redisEventBus"
77
+ ],
78
+ "appPorts": [
79
+ {
80
+ "name": "eventBus",
81
+ "type": "EventBusPort"
82
+ }
83
+ ],
84
+ "env": [
85
+ "REDIS_EVENT_BUS_URL",
86
+ "REDIS_EVENT_BUS_DB",
87
+ "REDIS_EVENT_BUS_CHANNEL_PREFIX",
88
+ "REDIS_EVENT_BUS_CONNECT_TIMEOUT_MS",
89
+ "REDIS_EVENT_BUS_MAX_RETRIES_PER_REQUEST",
90
+ "REDIS_EVENT_BUS_CONNECT_MAX_ATTEMPTS"
91
+ ],
92
+ "requiredEnv": [
93
+ "REDIS_EVENT_BUS_URL"
94
+ ],
95
+ "watchers": [
96
+ "eventBus"
97
+ ],
98
+ "registration": {
99
+ "required": true,
100
+ "tokens": [
101
+ "redisEventBusProvider",
102
+ "createRedisEventBusProvider"
103
+ ]
104
+ }
105
+ }
106
+ }
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * @beignet/provider-event-bus-redis
3
+ *
4
+ * Redis Pub/Sub event bus provider that implements EventBusPort for
5
+ * cross-process, best-effort domain event delivery.
6
+ */
7
+
8
+ import type {
9
+ DomainEventDef,
10
+ EventBusPort,
11
+ InferEventPayload,
12
+ } from "@beignet/core/ports";
13
+ import {
14
+ createProvider,
15
+ createProviderInstrumentation,
16
+ type ProviderInstrumentationTarget,
17
+ } from "@beignet/core/providers";
18
+ import { Redis } from "ioredis";
19
+ import { z } from "zod";
20
+
21
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
22
+ const DEFAULT_MAX_RETRIES_PER_REQUEST = 2;
23
+ const DEFAULT_CONNECT_MAX_ATTEMPTS = 3;
24
+ const DEFAULT_CHANNEL_PREFIX = "beignet:event-bus:";
25
+ const RECONNECT_BACKOFF_CAP_MS = 2000;
26
+
27
+ const NumberString = z.union([
28
+ z
29
+ .string()
30
+ .regex(/^\d+$/, "Expected a non-negative integer string")
31
+ .transform((value) => Number.parseInt(value, 10)),
32
+ z.number().int().nonnegative(),
33
+ ]);
34
+
35
+ const RedisEventBusConfigSchema = z.object({
36
+ URL: z.string().url(),
37
+ DB: NumberString.optional(),
38
+ CHANNEL_PREFIX: z.string().default(DEFAULT_CHANNEL_PREFIX),
39
+ CONNECT_TIMEOUT_MS: NumberString.default(DEFAULT_CONNECT_TIMEOUT_MS),
40
+ MAX_RETRIES_PER_REQUEST: NumberString.default(
41
+ DEFAULT_MAX_RETRIES_PER_REQUEST,
42
+ ),
43
+ CONNECT_MAX_ATTEMPTS: NumberString.default(DEFAULT_CONNECT_MAX_ATTEMPTS),
44
+ });
45
+
46
+ export type RedisEventBusConfig = z.infer<typeof RedisEventBusConfigSchema>;
47
+
48
+ export interface RedisEventBusPublisherClient {
49
+ publish(channel: string, message: string): Promise<number> | number;
50
+ ping?(): Promise<string> | string;
51
+ }
52
+
53
+ export interface RedisEventBusSubscriberClient {
54
+ subscribe(...channels: string[]): Promise<unknown> | unknown;
55
+ unsubscribe(...channels: string[]): Promise<unknown> | unknown;
56
+ on(
57
+ event: "message",
58
+ handler: (channel: string, message: string) => void,
59
+ ): unknown;
60
+ off?(
61
+ event: "message",
62
+ handler: (channel: string, message: string) => void,
63
+ ): unknown;
64
+ removeListener?(
65
+ event: "message",
66
+ handler: (channel: string, message: string) => void,
67
+ ): unknown;
68
+ ping?(): Promise<string> | string;
69
+ }
70
+
71
+ export interface RedisEventBusHealth {
72
+ ok: boolean;
73
+ message?: string;
74
+ metadata: {
75
+ adapter: "redis-event-bus";
76
+ channelPrefix: string;
77
+ publisherResponse?: string;
78
+ subscriberResponse?: string;
79
+ };
80
+ }
81
+
82
+ export interface RedisEventBusEscapeHatch {
83
+ publisher: RedisEventBusPublisherClient;
84
+ subscriber: RedisEventBusSubscriberClient;
85
+ channelPrefix: string;
86
+ checkHealth(): Promise<RedisEventBusHealth>;
87
+ }
88
+
89
+ export interface RedisEventBusProviderPorts {
90
+ eventBus: EventBusPort;
91
+ redisEventBus: RedisEventBusEscapeHatch;
92
+ }
93
+
94
+ export interface CreateRedisEventBusOptions {
95
+ publisher: RedisEventBusPublisherClient;
96
+ subscriber: RedisEventBusSubscriberClient;
97
+ channelPrefix?: string;
98
+ instrumentation?: ProviderInstrumentationTarget;
99
+ onHandlerError?: (
100
+ error: unknown,
101
+ eventName: string,
102
+ payload: unknown,
103
+ ) => void;
104
+ onSubscriberError?: (error: unknown) => void;
105
+ }
106
+
107
+ export interface CreateRedisEventBusProviderOptions {
108
+ channelPrefix?: string;
109
+ connectTimeoutMs?: number;
110
+ maxRetriesPerRequest?: number;
111
+ retryStrategy?: (times: number) => number | null;
112
+ db?: number;
113
+ }
114
+
115
+ function errorMessage(error: unknown): string {
116
+ return error instanceof Error ? error.message : String(error);
117
+ }
118
+
119
+ function redactConnectionUrl(url: string): string {
120
+ try {
121
+ const parsed = new URL(url);
122
+ if (parsed.username) parsed.username = "redacted";
123
+ if (parsed.password) parsed.password = "redacted";
124
+ if (parsed.search) parsed.search = "?redacted";
125
+ parsed.hash = "";
126
+ return parsed.toString();
127
+ } catch {
128
+ return "[invalid URL]";
129
+ }
130
+ }
131
+
132
+ function reconnectBackoff(attempt: number): number {
133
+ return Math.min(2 ** attempt * 100, RECONNECT_BACKOFF_CAP_MS);
134
+ }
135
+
136
+ function createRetryStrategy(connectMaxAttempts: number): {
137
+ strategy: (attempt: number) => number | null;
138
+ markConnected: () => void;
139
+ } {
140
+ let connectedOnce = false;
141
+
142
+ return {
143
+ strategy(attempt) {
144
+ if (!connectedOnce && attempt >= connectMaxAttempts) {
145
+ return null;
146
+ }
147
+ return reconnectBackoff(attempt);
148
+ },
149
+ markConnected() {
150
+ connectedOnce = true;
151
+ },
152
+ };
153
+ }
154
+
155
+ function channelForEvent(channelPrefix: string, eventName: string): string {
156
+ return `${channelPrefix}${eventName}`;
157
+ }
158
+
159
+ function eventNameForChannel(
160
+ channelPrefix: string,
161
+ channel: string,
162
+ ): string | undefined {
163
+ if (channelPrefix === "") return channel;
164
+ if (!channel.startsWith(channelPrefix)) return undefined;
165
+ return channel.slice(channelPrefix.length);
166
+ }
167
+
168
+ function safeJsonParse(input: string): unknown {
169
+ return JSON.parse(input) as unknown;
170
+ }
171
+
172
+ async function checkRedisEventBusHealth(
173
+ publisher: RedisEventBusPublisherClient,
174
+ subscriber: RedisEventBusSubscriberClient,
175
+ channelPrefix: string,
176
+ ): Promise<RedisEventBusHealth> {
177
+ try {
178
+ const [publisherResponse, subscriberResponse] = await Promise.all([
179
+ publisher.ping ? Promise.resolve(publisher.ping()) : Promise.resolve(""),
180
+ subscriber.ping
181
+ ? Promise.resolve(subscriber.ping())
182
+ : Promise.resolve(""),
183
+ ]);
184
+
185
+ return {
186
+ ok: true,
187
+ metadata: {
188
+ adapter: "redis-event-bus",
189
+ channelPrefix,
190
+ ...(publisherResponse ? { publisherResponse } : {}),
191
+ ...(subscriberResponse ? { subscriberResponse } : {}),
192
+ },
193
+ };
194
+ } catch (error) {
195
+ return {
196
+ ok: false,
197
+ message: errorMessage(error),
198
+ metadata: {
199
+ adapter: "redis-event-bus",
200
+ channelPrefix,
201
+ },
202
+ };
203
+ }
204
+ }
205
+
206
+ export function createRedisEventBus(
207
+ options: CreateRedisEventBusOptions,
208
+ ): EventBusPort {
209
+ const channelPrefix = options.channelPrefix ?? DEFAULT_CHANNEL_PREFIX;
210
+ const handlers = new Map<
211
+ string,
212
+ Set<(payload: unknown) => void | Promise<void>>
213
+ >();
214
+ const subscribedChannels = new Set<string>();
215
+ const instrumentation = createProviderInstrumentation(
216
+ options.instrumentation,
217
+ {
218
+ providerName: "event-bus-redis",
219
+ watcher: "eventBus",
220
+ },
221
+ );
222
+
223
+ const reportSubscriberError = (error: unknown) => {
224
+ try {
225
+ options.onSubscriberError?.(error);
226
+ } catch {
227
+ // Error callbacks should not create unhandled rejections.
228
+ }
229
+ };
230
+
231
+ const reportHandlerError = (
232
+ error: unknown,
233
+ eventName: string,
234
+ payload: unknown,
235
+ ) => {
236
+ instrumentation.custom({
237
+ name: "eventBus.handler.failed",
238
+ label: "Event handler failed",
239
+ summary: `Handler failed for "${eventName}"`,
240
+ details: {
241
+ eventName,
242
+ error: errorMessage(error),
243
+ },
244
+ });
245
+
246
+ try {
247
+ options.onHandlerError?.(error, eventName, payload);
248
+ } catch {
249
+ // Error callbacks should not create unhandled rejections.
250
+ }
251
+ };
252
+
253
+ const messageHandler = (channel: string, message: string) => {
254
+ const eventName = eventNameForChannel(channelPrefix, channel);
255
+ if (!eventName) return;
256
+
257
+ const eventHandlers = handlers.get(eventName);
258
+ if (!eventHandlers || eventHandlers.size === 0) return;
259
+
260
+ let payload: unknown;
261
+ try {
262
+ const parsed = safeJsonParse(message);
263
+ if (
264
+ typeof parsed !== "object" ||
265
+ parsed === null ||
266
+ !("payload" in parsed)
267
+ ) {
268
+ throw new Error("Redis event bus message is missing a payload field.");
269
+ }
270
+ payload = (parsed as { payload: unknown }).payload;
271
+ } catch (error) {
272
+ reportSubscriberError(error);
273
+ return;
274
+ }
275
+
276
+ instrumentation.custom({
277
+ name: "eventBus.received",
278
+ label: "Event received",
279
+ summary: `Received "${eventName}"`,
280
+ details: { eventName },
281
+ });
282
+
283
+ for (const handler of [...eventHandlers]) {
284
+ try {
285
+ Promise.resolve(handler(payload)).catch((error: unknown) => {
286
+ reportHandlerError(error, eventName, payload);
287
+ });
288
+ } catch (error) {
289
+ reportHandlerError(error, eventName, payload);
290
+ }
291
+ }
292
+ };
293
+
294
+ options.subscriber.on("message", messageHandler);
295
+
296
+ const ensureSubscribed = (eventName: string) => {
297
+ const channel = channelForEvent(channelPrefix, eventName);
298
+ if (subscribedChannels.has(channel)) return;
299
+ subscribedChannels.add(channel);
300
+ Promise.resolve(options.subscriber.subscribe(channel)).catch((error) => {
301
+ subscribedChannels.delete(channel);
302
+ reportSubscriberError(error);
303
+ });
304
+ };
305
+
306
+ const unsubscribeChannel = (eventName: string) => {
307
+ const channel = channelForEvent(channelPrefix, eventName);
308
+ if (!subscribedChannels.delete(channel)) return;
309
+ Promise.resolve(options.subscriber.unsubscribe(channel)).catch(
310
+ reportSubscriberError,
311
+ );
312
+ };
313
+
314
+ return {
315
+ async publish(event, payload) {
316
+ instrumentation.record({
317
+ type: "eventBus",
318
+ eventName: event.name,
319
+ });
320
+
321
+ await options.publisher.publish(
322
+ channelForEvent(channelPrefix, event.name),
323
+ JSON.stringify({ eventName: event.name, payload }),
324
+ );
325
+ },
326
+
327
+ subscribe<E extends DomainEventDef>(
328
+ event: E,
329
+ handler: (payload: InferEventPayload<E>) => void | Promise<void>,
330
+ ) {
331
+ let eventHandlers = handlers.get(event.name);
332
+ if (!eventHandlers) {
333
+ eventHandlers = new Set();
334
+ handlers.set(event.name, eventHandlers);
335
+ }
336
+
337
+ eventHandlers.add(handler as (payload: unknown) => void | Promise<void>);
338
+ ensureSubscribed(event.name);
339
+
340
+ return () => {
341
+ const currentHandlers = handlers.get(event.name);
342
+ if (!currentHandlers) return;
343
+ currentHandlers.delete(
344
+ handler as (payload: unknown) => void | Promise<void>,
345
+ );
346
+ if (currentHandlers.size === 0) {
347
+ handlers.delete(event.name);
348
+ unsubscribeChannel(event.name);
349
+ }
350
+ };
351
+ },
352
+ };
353
+ }
354
+
355
+ export function createRedisEventBusProvider(
356
+ options: CreateRedisEventBusProviderOptions = {},
357
+ ) {
358
+ const providerMetadata = {
359
+ packageName: "@beignet/provider-event-bus-redis",
360
+ ports: ["eventBus", "redisEventBus"],
361
+ env: [
362
+ "REDIS_EVENT_BUS_URL",
363
+ "REDIS_EVENT_BUS_DB",
364
+ "REDIS_EVENT_BUS_CHANNEL_PREFIX",
365
+ "REDIS_EVENT_BUS_CONNECT_TIMEOUT_MS",
366
+ "REDIS_EVENT_BUS_MAX_RETRIES_PER_REQUEST",
367
+ "REDIS_EVENT_BUS_CONNECT_MAX_ATTEMPTS",
368
+ ],
369
+ watchers: ["eventBus"],
370
+ } as const;
371
+
372
+ return createProvider({
373
+ name: "event-bus-redis",
374
+ metadata: providerMetadata,
375
+
376
+ config: {
377
+ schema: RedisEventBusConfigSchema,
378
+ envPrefix: "REDIS_EVENT_BUS_",
379
+ // Defined options win over env-derived values at config load time.
380
+ overrides: {
381
+ DB: options.db,
382
+ CHANNEL_PREFIX: options.channelPrefix,
383
+ CONNECT_TIMEOUT_MS: options.connectTimeoutMs,
384
+ MAX_RETRIES_PER_REQUEST: options.maxRetriesPerRequest,
385
+ },
386
+ },
387
+
388
+ async setup({ config, ports }) {
389
+ if (!config) {
390
+ throw new Error(
391
+ "[redisEventBusProvider] Missing Redis event bus config. " +
392
+ "Please set REDIS_EVENT_BUS_URL.",
393
+ );
394
+ }
395
+
396
+ const configuredChannelPrefix =
397
+ config.CHANNEL_PREFIX ??
398
+ options.channelPrefix ??
399
+ DEFAULT_CHANNEL_PREFIX;
400
+ const connectMaxAttempts =
401
+ config.CONNECT_MAX_ATTEMPTS ?? DEFAULT_CONNECT_MAX_ATTEMPTS;
402
+ const publisherRetry = createRetryStrategy(connectMaxAttempts);
403
+ const subscriberRetry = createRetryStrategy(connectMaxAttempts);
404
+ const redisOptions = {
405
+ db: config.DB ?? options.db ?? 0,
406
+ lazyConnect: true,
407
+ connectTimeout:
408
+ config.CONNECT_TIMEOUT_MS ??
409
+ options.connectTimeoutMs ??
410
+ DEFAULT_CONNECT_TIMEOUT_MS,
411
+ maxRetriesPerRequest:
412
+ config.MAX_RETRIES_PER_REQUEST ??
413
+ options.maxRetriesPerRequest ??
414
+ DEFAULT_MAX_RETRIES_PER_REQUEST,
415
+ };
416
+
417
+ const publisher = new Redis(config.URL, {
418
+ ...redisOptions,
419
+ retryStrategy: options.retryStrategy ?? publisherRetry.strategy,
420
+ });
421
+ const subscriber = new Redis(config.URL, {
422
+ ...redisOptions,
423
+ retryStrategy: options.retryStrategy ?? subscriberRetry.strategy,
424
+ });
425
+
426
+ try {
427
+ await publisher.connect();
428
+ publisherRetry.markConnected();
429
+ await subscriber.connect();
430
+ subscriberRetry.markConnected();
431
+ } catch (error) {
432
+ publisher.disconnect();
433
+ subscriber.disconnect();
434
+ throw new Error(
435
+ `[redisEventBusProvider] Failed to connect to Redis at ${redactConnectionUrl(
436
+ config.URL,
437
+ )}: ${errorMessage(error)}`,
438
+ );
439
+ }
440
+
441
+ const eventBus = createRedisEventBus({
442
+ publisher,
443
+ subscriber,
444
+ channelPrefix: configuredChannelPrefix,
445
+ instrumentation: ports,
446
+ });
447
+
448
+ return {
449
+ ports: {
450
+ eventBus,
451
+ redisEventBus: {
452
+ publisher,
453
+ subscriber,
454
+ channelPrefix: configuredChannelPrefix,
455
+ checkHealth: () =>
456
+ checkRedisEventBusHealth(
457
+ publisher,
458
+ subscriber,
459
+ configuredChannelPrefix,
460
+ ),
461
+ },
462
+ } satisfies RedisEventBusProviderPorts,
463
+ async stop() {
464
+ try {
465
+ await subscriber.quit();
466
+ } catch {
467
+ subscriber.disconnect();
468
+ }
469
+ try {
470
+ await publisher.quit();
471
+ } catch {
472
+ publisher.disconnect();
473
+ }
474
+ },
475
+ };
476
+ },
477
+ });
478
+ }
479
+
480
+ export const redisEventBusProvider = createRedisEventBusProvider();