@veryfront/ext-redis 0.1.1185

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.
@@ -0,0 +1,356 @@
1
+ import { logger as baseLogger } from "veryfront/utils/logger";
2
+ import { requireRedisUrl } from "./connection-config.js";
3
+ const logger = baseLogger.component("redis-event-publisher");
4
+ const CONFIG_KEYS = new Set(["url", "channelPrefix", "debug"]);
5
+ const MAX_CHANNEL_PREFIX_CODE_UNITS = 1_024;
6
+ function readOwnDataProperty(value, key) {
7
+ let descriptor;
8
+ try {
9
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
10
+ }
11
+ catch (cause) {
12
+ throw new TypeError(`Redis event publisher config ${key} could not be inspected`, { cause });
13
+ }
14
+ if (!descriptor)
15
+ return undefined;
16
+ if (!("value" in descriptor)) {
17
+ throw new TypeError(`Redis event publisher config ${key} must be a data property`);
18
+ }
19
+ return descriptor.value;
20
+ }
21
+ function captureConfig(value) {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
23
+ throw new TypeError("Redis event publisher config must be an object");
24
+ }
25
+ let prototype;
26
+ let keys;
27
+ try {
28
+ prototype = Object.getPrototypeOf(value);
29
+ keys = Reflect.ownKeys(value);
30
+ }
31
+ catch (cause) {
32
+ throw new TypeError("Redis event publisher config could not be inspected", { cause });
33
+ }
34
+ if (prototype !== Object.prototype && prototype !== null) {
35
+ throw new TypeError("Redis event publisher config must be a plain object");
36
+ }
37
+ if (keys.some((key) => typeof key !== "string" || !CONFIG_KEYS.has(key))) {
38
+ throw new TypeError("Redis event publisher config contains an unknown option");
39
+ }
40
+ const url = requireRedisUrl(readOwnDataProperty(value, "url"));
41
+ const channelPrefixValue = readOwnDataProperty(value, "channelPrefix");
42
+ const channelPrefix = channelPrefixValue ?? "claude-code";
43
+ if (typeof channelPrefix !== "string" || channelPrefix.length === 0 ||
44
+ channelPrefix.length > MAX_CHANNEL_PREFIX_CODE_UNITS ||
45
+ channelPrefix.trim() !== channelPrefix || /\p{Cc}/u.test(channelPrefix)) {
46
+ throw new TypeError("Redis event publisher channelPrefix must be a bounded canonical string");
47
+ }
48
+ const debugValue = readOwnDataProperty(value, "debug");
49
+ if (debugValue !== undefined && typeof debugValue !== "boolean") {
50
+ throw new TypeError("Redis event publisher debug must be a boolean");
51
+ }
52
+ return Object.freeze({ url, channelPrefix, debug: debugValue ?? false });
53
+ }
54
+ function safeLogInfo(target, message, ...args) {
55
+ try {
56
+ target.info(message, ...args);
57
+ }
58
+ catch {
59
+ // Diagnostics must not own transport lifecycle.
60
+ }
61
+ }
62
+ function safeLogError(target, message, ...args) {
63
+ try {
64
+ target.error(message, ...args);
65
+ }
66
+ catch {
67
+ // Diagnostics must not own transport lifecycle.
68
+ }
69
+ }
70
+ function publisherClosedError(state) {
71
+ return new Error(`Redis event publisher is ${state}`);
72
+ }
73
+ function raceWithAbort(promise, signal) {
74
+ if (signal.aborted)
75
+ return Promise.reject(signal.reason);
76
+ return new Promise((resolve, reject) => {
77
+ const onAbort = () => reject(signal.reason);
78
+ signal.addEventListener("abort", onAbort, { once: true });
79
+ promise.then((value) => {
80
+ signal.removeEventListener("abort", onAbort);
81
+ resolve(value);
82
+ }, (error) => {
83
+ signal.removeEventListener("abort", onAbort);
84
+ reject(error);
85
+ });
86
+ });
87
+ }
88
+ class InternalRedisEventPublisher {
89
+ config;
90
+ createClient;
91
+ diagnosticLogger;
92
+ resources = new Set();
93
+ closeAttempts = new Map();
94
+ retirements = new Set();
95
+ clients = null;
96
+ initialization = null;
97
+ initializationAbortController = null;
98
+ closePromise = null;
99
+ state = "open";
100
+ constructor(config, createClient, diagnosticLogger) {
101
+ this.config = config;
102
+ this.createClient = createClient;
103
+ this.diagnosticLogger = diagnosticLogger;
104
+ }
105
+ registerErrorListener(client, role) {
106
+ client.on("error", (error) => {
107
+ safeLogError(this.diagnosticLogger, "Redis event publisher client error", error, { role });
108
+ });
109
+ }
110
+ createClientPair() {
111
+ const publishClient = this.createClient(this.config.url);
112
+ this.resources.add(publishClient);
113
+ this.registerErrorListener(publishClient, "publish");
114
+ const subscribeClient = this.createClient(this.config.url);
115
+ if (subscribeClient === publishClient) {
116
+ throw new TypeError("Redis event publisher requires distinct publish and subscribe clients");
117
+ }
118
+ this.resources.add(subscribeClient);
119
+ this.registerErrorListener(subscribeClient, "subscribe");
120
+ return Object.freeze({ publish: publishClient, subscribe: subscribeClient });
121
+ }
122
+ ensureInitialized() {
123
+ if (this.state !== "open")
124
+ return Promise.reject(publisherClosedError(this.state));
125
+ if (this.clients)
126
+ return Promise.resolve(this.clients);
127
+ if (this.initialization)
128
+ return this.initialization;
129
+ const abortController = new AbortController();
130
+ this.initializationAbortController = abortController;
131
+ const initialization = (async () => {
132
+ let clients;
133
+ let connectAttempts = [];
134
+ try {
135
+ // A failed prior setup must be fully retired before replacement.
136
+ await raceWithAbort(this.waitForRetirements(), abortController.signal);
137
+ await raceWithAbort(this.closeClients([...this.resources]), abortController.signal);
138
+ if (this.state !== "open")
139
+ throw publisherClosedError(this.state);
140
+ const nextClients = this.createClientPair();
141
+ clients = nextClients;
142
+ connectAttempts = [
143
+ this.startConnect(nextClients.publish),
144
+ this.startConnect(nextClients.subscribe),
145
+ ];
146
+ await raceWithAbort(Promise.all(connectAttempts.map(({ promise }) => promise)).then(() => undefined), abortController.signal);
147
+ if (this.state !== "open")
148
+ throw publisherClosedError(this.state);
149
+ }
150
+ catch (error) {
151
+ const cleanupTargets = clients ? [clients.publish, clients.subscribe] : [...this.resources];
152
+ const immediateCleanup = this.closeClients(cleanupTargets);
153
+ for (const attempt of connectAttempts) {
154
+ if (!attempt.settled)
155
+ this.scheduleFinalClose(attempt, immediateCleanup);
156
+ }
157
+ try {
158
+ await immediateCleanup;
159
+ }
160
+ catch (closeError) {
161
+ throw new AggregateError([error, closeError], "Redis event publisher setup and cleanup failed");
162
+ }
163
+ throw error;
164
+ }
165
+ if (!clients)
166
+ throw new Error("Redis event publisher clients were not initialized");
167
+ this.clients = clients;
168
+ return clients;
169
+ })().finally(() => {
170
+ if (this.initialization === initialization)
171
+ this.initialization = null;
172
+ if (this.initializationAbortController === abortController) {
173
+ this.initializationAbortController = null;
174
+ }
175
+ });
176
+ this.initialization = initialization;
177
+ return initialization;
178
+ }
179
+ startConnect(client) {
180
+ const attempt = {
181
+ client,
182
+ promise: Promise.resolve().then(() => client.connect()),
183
+ settled: false,
184
+ };
185
+ void attempt.promise.then(() => {
186
+ attempt.settled = true;
187
+ }, () => {
188
+ attempt.settled = true;
189
+ });
190
+ return attempt;
191
+ }
192
+ waitForRetirements() {
193
+ return Promise.all([...this.retirements]).then(() => undefined);
194
+ }
195
+ scheduleFinalClose(attempt, immediateCleanup) {
196
+ const connectionSettled = attempt.promise.then(() => undefined, () => undefined);
197
+ const retirement = connectionSettled
198
+ .then(() => immediateCleanup.catch(() => undefined))
199
+ .then(() => this.closeClient(attempt.client, true));
200
+ const tracked = retirement.finally(() => {
201
+ this.retirements.delete(tracked);
202
+ });
203
+ this.retirements.add(tracked);
204
+ void tracked.catch((error) => {
205
+ safeLogError(this.diagnosticLogger, "Redis event publisher late-connect cleanup failed", error);
206
+ });
207
+ }
208
+ closeClient(client, force = false) {
209
+ if (force)
210
+ this.resources.add(client);
211
+ else if (!this.resources.has(client))
212
+ return Promise.resolve();
213
+ const existing = this.closeAttempts.get(client);
214
+ if (existing) {
215
+ if (!force)
216
+ return existing;
217
+ return existing.catch(() => undefined).then(() => this.closeClient(client, true));
218
+ }
219
+ const attempt = Promise.resolve()
220
+ .then(() => client.isOpen === false ? undefined : client.close())
221
+ .then(() => {
222
+ this.resources.delete(client);
223
+ if (this.clients?.publish === client || this.clients?.subscribe === client) {
224
+ this.clients = null;
225
+ }
226
+ this.closeAttempts.delete(client);
227
+ }, (error) => {
228
+ this.closeAttempts.delete(client);
229
+ throw error;
230
+ });
231
+ this.closeAttempts.set(client, attempt);
232
+ return attempt;
233
+ }
234
+ closeClients(clients) {
235
+ return Promise.all([...new Set(clients)].map((client) => this.closeClient(client))).then(() => undefined);
236
+ }
237
+ getChannel(runId) {
238
+ return `${this.config.channelPrefix}:events:${runId}`;
239
+ }
240
+ async publish(event) {
241
+ const clients = await this.ensureInitialized();
242
+ if (this.state !== "open")
243
+ throw publisherClosedError(this.state);
244
+ const channel = event.runId
245
+ ? this.getChannel(event.runId)
246
+ : `${this.config.channelPrefix}:events:global`;
247
+ await clients.publish.publish(channel, JSON.stringify(event));
248
+ if (this.config.debug) {
249
+ safeLogInfo(this.diagnosticLogger, "Published event", {
250
+ channel,
251
+ eventType: event.type,
252
+ });
253
+ }
254
+ }
255
+ async subscribe(runId, handler) {
256
+ const clients = await this.ensureInitialized();
257
+ if (this.state !== "open")
258
+ throw publisherClosedError(this.state);
259
+ const channel = this.getChannel(runId);
260
+ let deliveryActive = true;
261
+ const listener = (message) => {
262
+ if (!deliveryActive || this.state !== "open")
263
+ return;
264
+ let parsed;
265
+ try {
266
+ parsed = JSON.parse(message);
267
+ }
268
+ catch (error) {
269
+ safeLogError(this.diagnosticLogger, "Failed to parse Redis event", error);
270
+ return;
271
+ }
272
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
273
+ return;
274
+ try {
275
+ const handled = handler(parsed);
276
+ void Promise.resolve(handled).catch((error) => {
277
+ safeLogError(this.diagnosticLogger, "Redis event handler failed", error);
278
+ });
279
+ }
280
+ catch (error) {
281
+ safeLogError(this.diagnosticLogger, "Redis event handler failed", error);
282
+ }
283
+ };
284
+ await clients.subscribe.subscribe(channel, listener);
285
+ if (this.state !== "open") {
286
+ this.startBackgroundUnsubscribe(clients.subscribe, channel, listener);
287
+ throw publisherClosedError(this.state);
288
+ }
289
+ if (this.config.debug) {
290
+ safeLogInfo(this.diagnosticLogger, "Subscribed to channel", { channel });
291
+ }
292
+ let disposed = false;
293
+ let disposal = null;
294
+ return () => {
295
+ deliveryActive = false;
296
+ if (disposed || disposal || this.state === "closed")
297
+ return;
298
+ const attempt = Promise.resolve()
299
+ .then(async () => {
300
+ if (this.state === "closing" && this.closePromise)
301
+ return false;
302
+ await clients.subscribe.unsubscribe(channel, listener);
303
+ return true;
304
+ })
305
+ .then((unsubscribed) => {
306
+ if (unsubscribed)
307
+ disposed = true;
308
+ else if (disposal === attempt)
309
+ disposal = null;
310
+ }, (error) => {
311
+ if (disposal === attempt)
312
+ disposal = null;
313
+ safeLogError(this.diagnosticLogger, "Failed to unsubscribe Redis event listener", error, { channel });
314
+ });
315
+ disposal = attempt;
316
+ };
317
+ }
318
+ startBackgroundUnsubscribe(client, channel, listener) {
319
+ void Promise.resolve()
320
+ .then(() => client.unsubscribe(channel, listener))
321
+ .catch((error) => {
322
+ safeLogError(this.diagnosticLogger, "Failed to unsubscribe Redis event listener", error, { channel });
323
+ });
324
+ }
325
+ close() {
326
+ if (this.closePromise)
327
+ return this.closePromise;
328
+ if (this.state === "closed" && this.resources.size === 0)
329
+ return Promise.resolve();
330
+ this.state = "closing";
331
+ this.initializationAbortController?.abort(publisherClosedError("closing"));
332
+ const closePromise = this.closeClients([...this.resources]).then(() => {
333
+ this.state = "closed";
334
+ }, (error) => {
335
+ throw error;
336
+ }).finally(() => {
337
+ if (this.closePromise === closePromise)
338
+ this.closePromise = null;
339
+ });
340
+ this.closePromise = closePromise;
341
+ return closePromise;
342
+ }
343
+ }
344
+ /** Construct a validated Redis Pub/Sub implementation. */
345
+ export function createRedisEventPublisher(config, dependencies) {
346
+ if (typeof dependencies.createClient !== "function") {
347
+ throw new TypeError("Redis event publisher client factory must be a function");
348
+ }
349
+ const diagnosticLogger = dependencies.logger ?? logger;
350
+ if (!diagnosticLogger || typeof diagnosticLogger !== "object" ||
351
+ typeof diagnosticLogger.info !== "function" ||
352
+ typeof diagnosticLogger.error !== "function") {
353
+ throw new TypeError("Redis event publisher logger is invalid");
354
+ }
355
+ return new InternalRedisEventPublisher(captureConfig(config), dependencies.createClient, diagnosticLogger);
356
+ }
package/esm/index.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ext-redis: third-party Redis runtime implementation for Veryfront.
3
+ *
4
+ * @module extensions/ext-redis
5
+ */
6
+ import type { ExtensionFactory } from "veryfront/extensions/types";
7
+ declare const extRedis: ExtensionFactory;
8
+ export default extRedis;
9
+ export { createRedisRuntimeProvider } from "./redis-runtime-provider.js";
10
+ export type { RedisClient, RedisClientOptions, RedisEventPublisherConfig, RedisEventPublisherImplementation, RedisRuntimeProvider, } from "veryfront/extensions/distributed";
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAOnE,QAAA,MAAM,QAAQ,EAAE,gBAqCf,CAAC;AAEF,eAAe,QAAQ,CAAC;AACxB,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAC;AACzE,YAAY,EACV,WAAW,EACX,kBAAkB,EAClB,yBAAyB,EACzB,iCAAiC,EACjC,oBAAoB,GACrB,MAAM,kCAAkC,CAAC"}
package/esm/index.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ext-redis: third-party Redis runtime implementation for Veryfront.
3
+ *
4
+ * @module extensions/ext-redis
5
+ */
6
+ import { RedisRuntimeProviderName, } from "veryfront/extensions/distributed";
7
+ import { createRedisRuntimeProvider } from "./redis-runtime-provider.js";
8
+ const extRedis = () => {
9
+ let provider;
10
+ return {
11
+ name: "ext-redis",
12
+ version: "0.1.0",
13
+ contracts: { provides: [RedisRuntimeProviderName] },
14
+ capabilities: [
15
+ { type: "net:outbound", hosts: ["*"] },
16
+ {
17
+ type: "env:read",
18
+ keys: ["NODE_ENV", "REDIS_PASSWORD", "REDIS_URL", "REDIS_USERNAME"],
19
+ },
20
+ ],
21
+ async setup(ctx) {
22
+ if (provider)
23
+ throw new Error("ext-redis is already active");
24
+ const nextProvider = createRedisRuntimeProvider();
25
+ try {
26
+ ctx.provide(RedisRuntimeProviderName, nextProvider);
27
+ }
28
+ catch (error) {
29
+ await nextProvider.close();
30
+ throw error;
31
+ }
32
+ provider = nextProvider;
33
+ try {
34
+ ctx.logger.info(`[ext-redis] ${RedisRuntimeProviderName} registered`);
35
+ }
36
+ catch {
37
+ // Diagnostics must not invalidate a successfully registered provider.
38
+ }
39
+ },
40
+ async teardown() {
41
+ if (!provider)
42
+ return;
43
+ const retiring = provider;
44
+ await retiring.close();
45
+ if (provider === retiring)
46
+ provider = undefined;
47
+ },
48
+ };
49
+ };
50
+ export default extRedis;
51
+ export { createRedisRuntimeProvider } from "./redis-runtime-provider.js";
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,33 @@
1
+ import type { RedisClient, RedisClientOptions } from "veryfront/extensions/distributed";
2
+ /** Minimal `@redis/client` factory options used by this extension. */
3
+ export interface RedisClientFactoryOptions {
4
+ url?: string;
5
+ socket?: {
6
+ tls?: boolean;
7
+ connectTimeout?: number;
8
+ reconnectStrategy?: false;
9
+ };
10
+ password?: string;
11
+ username?: string;
12
+ }
13
+ export type RedisClientFactory = (options: RedisClientFactoryOptions) => RedisClient;
14
+ export interface RedisClientManagerDependencies {
15
+ getEnv?: (key: string) => string | undefined;
16
+ loadFactory?: () => Promise<RedisClientFactory>;
17
+ now?: () => number;
18
+ }
19
+ export interface RedisClientOpenLifecycle {
20
+ signal?: AbortSignal;
21
+ onClientCreated?(client: RedisClient): (() => Promise<void>) | void;
22
+ }
23
+ export interface RedisClientManager {
24
+ getClient(options?: RedisClientOptions): Promise<RedisClient>;
25
+ disconnect(): Promise<void>;
26
+ }
27
+ /** Internal handoff of a provisional client that still requires cleanup. */
28
+ export declare function takeRedisClientSetupCleanupClient(error: unknown): RedisClient | undefined;
29
+ /** Open one independently owned Redis connection. */
30
+ export declare function openRedisClient(options?: RedisClientOptions, dependencies?: RedisClientManagerDependencies, lifecycle?: RedisClientOpenLifecycle): Promise<RedisClient>;
31
+ /** Create an isolated, concurrency-safe Redis client manager. */
32
+ export declare function createRedisClientManager(dependencies?: RedisClientManagerDependencies): RedisClientManager;
33
+ //# sourceMappingURL=redis-client-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-client-manager.d.ts","sourceRoot":"","sources":["../src/redis-client-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAyBxF,sEAAsE;AACtE,MAAM,WAAW,yBAAyB;IACxC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE;QACP,GAAG,CAAC,EAAE,OAAO,CAAC;QACd,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,iBAAiB,CAAC,EAAE,KAAK,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,EAAE,yBAAyB,KAAK,WAAW,CAAC;AAErF,MAAM,WAAW,8BAA8B;IAC7C,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAChD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,eAAe,CAAC,CAAC,MAAM,EAAE,WAAW,GAAG,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;CACrE;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9D,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAeD,4EAA4E;AAC5E,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,GAAG,SAAS,CAKzF;AAuOD,qDAAqD;AACrD,wBAAsB,eAAe,CACnC,OAAO,GAAE,kBAAuB,EAChC,YAAY,GAAE,8BAAmC,EACjD,SAAS,GAAE,wBAA6B,GACvC,OAAO,CAAC,WAAW,CAAC,CA4DtB;AAOD,iEAAiE;AACjE,wBAAgB,wBAAwB,CACtC,YAAY,GAAE,8BAAmC,GAChD,kBAAkB,CA6QpB"}