@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,535 @@
1
+ import { INITIALIZATION_ERROR } from "veryfront/errors/general";
2
+ import { DEPENDENCY_MISSING } from "veryfront/errors/module";
3
+ import { getEnv } from "veryfront/platform/env";
4
+ import { logger as baseLogger } from "veryfront/utils/logger";
5
+ import { requireRedisUrl } from "./connection-config.js";
6
+ const logger = baseLogger.component("redis");
7
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
8
+ const RECONNECT_DELAY_MS = 5_000;
9
+ const CLIENT_OPTION_KEYS = new Set([
10
+ "url",
11
+ "connectTimeout",
12
+ "autoReconnect",
13
+ "tls",
14
+ "password",
15
+ "username",
16
+ ]);
17
+ function logCleanupFailure(message, error) {
18
+ try {
19
+ logger.error(message, error);
20
+ }
21
+ catch {
22
+ // Diagnostics must not interrupt transport cleanup.
23
+ }
24
+ }
25
+ const setupCleanupClients = new WeakMap();
26
+ /** Setup failed and the provisional client could not be disposed. */
27
+ class RedisClientSetupCleanupError extends AggregateError {
28
+ constructor(setupError, cleanupError, client) {
29
+ super([setupError, cleanupError], "Redis client setup and cleanup failed");
30
+ setupCleanupClients.set(this, client);
31
+ }
32
+ }
33
+ /** Internal handoff of a provisional client that still requires cleanup. */
34
+ export function takeRedisClientSetupCleanupClient(error) {
35
+ if (!error || typeof error !== "object")
36
+ return undefined;
37
+ const client = setupCleanupClients.get(error);
38
+ if (client)
39
+ setupCleanupClients.delete(error);
40
+ return client;
41
+ }
42
+ function readOwnDataProperty(value, key) {
43
+ let descriptor;
44
+ try {
45
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
46
+ }
47
+ catch (cause) {
48
+ throw new TypeError(`Redis client option ${key} could not be inspected`, { cause });
49
+ }
50
+ if (!descriptor)
51
+ return undefined;
52
+ if (!("value" in descriptor)) {
53
+ throw new TypeError(`Redis client option ${key} must be a data property`);
54
+ }
55
+ return descriptor.value;
56
+ }
57
+ function captureClientOptions(value) {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
59
+ throw new TypeError("Redis client options must be an object");
60
+ }
61
+ let prototype;
62
+ let keys;
63
+ try {
64
+ prototype = Object.getPrototypeOf(value);
65
+ keys = Reflect.ownKeys(value);
66
+ }
67
+ catch (cause) {
68
+ throw new TypeError("Redis client options could not be inspected", { cause });
69
+ }
70
+ if (prototype !== Object.prototype && prototype !== null) {
71
+ throw new TypeError("Redis client options must be a plain object");
72
+ }
73
+ if (keys.some((key) => typeof key !== "string" || !CLIENT_OPTION_KEYS.has(key))) {
74
+ throw new TypeError("Redis client options contain an unknown option");
75
+ }
76
+ const urlValue = readOwnDataProperty(value, "url");
77
+ const connectTimeout = readOwnDataProperty(value, "connectTimeout");
78
+ const autoReconnect = readOwnDataProperty(value, "autoReconnect");
79
+ const tls = readOwnDataProperty(value, "tls");
80
+ const password = readOwnDataProperty(value, "password");
81
+ const username = readOwnDataProperty(value, "username");
82
+ if (urlValue !== undefined)
83
+ requireRedisUrl(urlValue);
84
+ if (autoReconnect !== undefined && typeof autoReconnect !== "boolean") {
85
+ throw new TypeError("Redis client option autoReconnect must be a boolean");
86
+ }
87
+ if (tls !== undefined && typeof tls !== "boolean") {
88
+ throw new TypeError("Redis client option tls must be a boolean");
89
+ }
90
+ if (password !== undefined && typeof password !== "string") {
91
+ throw new TypeError("Redis client option password must be a string");
92
+ }
93
+ if (username !== undefined && typeof username !== "string") {
94
+ throw new TypeError("Redis client option username must be a string");
95
+ }
96
+ return Object.freeze({
97
+ ...(urlValue === undefined ? {} : { url: urlValue }),
98
+ ...(connectTimeout === undefined ? {} : { connectTimeout: connectTimeout }),
99
+ ...(autoReconnect === undefined ? {} : { autoReconnect }),
100
+ ...(tls === undefined ? {} : { tls }),
101
+ ...(password === undefined ? {} : { password }),
102
+ ...(username === undefined ? {} : { username }),
103
+ });
104
+ }
105
+ function connectionCancelledError() {
106
+ return INITIALIZATION_ERROR.create({
107
+ detail: "[Redis] Connection attempt cancelled by disconnect",
108
+ });
109
+ }
110
+ function raceWithAbort(promise, signal) {
111
+ if (!signal)
112
+ return promise;
113
+ if (signal.aborted)
114
+ return Promise.reject(signal.reason ?? connectionCancelledError());
115
+ return new Promise((resolve, reject) => {
116
+ const onAbort = () => reject(signal.reason ?? connectionCancelledError());
117
+ signal.addEventListener("abort", onAbort, { once: true });
118
+ promise.then((value) => {
119
+ signal.removeEventListener("abort", onAbort);
120
+ resolve(value);
121
+ }, (error) => {
122
+ signal.removeEventListener("abort", onAbort);
123
+ reject(error);
124
+ });
125
+ });
126
+ }
127
+ function resolveClientOptions(options, readEnv) {
128
+ if (options.connectTimeout !== undefined &&
129
+ (!Number.isInteger(options.connectTimeout) || options.connectTimeout <= 0 ||
130
+ options.connectTimeout > MAX_TIMER_DELAY_MS)) {
131
+ throw new RangeError(`Redis connectTimeout must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
132
+ }
133
+ const configuredUrl = options.url ?? readEnv("REDIS_URL");
134
+ const url = configuredUrl === undefined || configuredUrl.length === 0
135
+ ? undefined
136
+ : requireRedisUrl(configuredUrl);
137
+ const useTls = options.tls ?? url?.startsWith("rediss://") ?? false;
138
+ if (!useTls && readEnv("NODE_ENV") === "production") {
139
+ logger.warn("Redis connection without TLS in production. Set REDIS_URL to rediss:// or pass tls: true.");
140
+ }
141
+ const password = options.password ?? readEnv("REDIS_PASSWORD");
142
+ const username = options.username ?? readEnv("REDIS_USERNAME");
143
+ const socket = useTls || options.connectTimeout !== undefined || options.autoReconnect === false
144
+ ? {
145
+ ...(useTls ? { tls: true } : {}),
146
+ ...(options.connectTimeout === undefined ? {} : { connectTimeout: options.connectTimeout }),
147
+ ...(options.autoReconnect === false ? { reconnectStrategy: false } : {}),
148
+ }
149
+ : undefined;
150
+ const factoryOptions = {
151
+ ...(url === undefined ? {} : { url }),
152
+ ...(socket === undefined ? {} : { socket }),
153
+ ...(password === undefined ? {} : { password }),
154
+ ...(username === undefined ? {} : { username }),
155
+ };
156
+ const key = JSON.stringify({
157
+ url,
158
+ useTls,
159
+ connectTimeout: options.connectTimeout,
160
+ autoReconnect: options.autoReconnect ?? true,
161
+ password,
162
+ username,
163
+ });
164
+ return { factoryOptions, key, useTls };
165
+ }
166
+ async function loadDefaultFactory() {
167
+ try {
168
+ const module = await import("@redis/client");
169
+ if (typeof module.createClient !== "function") {
170
+ throw new TypeError("@redis/client does not export createClient");
171
+ }
172
+ return module.createClient;
173
+ }
174
+ catch (error) {
175
+ logger.debug("Failed to load @redis/client module", { error });
176
+ throw DEPENDENCY_MISSING.create({
177
+ detail: "[Redis] Failed to load @redis/client from @veryfront/ext-redis. Reinstall the extension package.",
178
+ cause: error instanceof Error ? error : undefined,
179
+ });
180
+ }
181
+ }
182
+ function assertRedisClient(value) {
183
+ if (!value || typeof value !== "object") {
184
+ throw new TypeError("Redis client factory must return an object");
185
+ }
186
+ for (const method of [
187
+ "connect",
188
+ "disconnect",
189
+ "get",
190
+ "mGet",
191
+ "set",
192
+ "del",
193
+ "scan",
194
+ "expire",
195
+ "eval",
196
+ "incr",
197
+ "pExpire",
198
+ "pTTL",
199
+ ]) {
200
+ let owner = value;
201
+ const visited = new Set();
202
+ let found = false;
203
+ while (owner) {
204
+ if (visited.has(owner)) {
205
+ throw new TypeError("Redis client factory result has a cyclic prototype chain");
206
+ }
207
+ visited.add(owner);
208
+ let descriptor;
209
+ try {
210
+ descriptor = Object.getOwnPropertyDescriptor(owner, method);
211
+ }
212
+ catch (cause) {
213
+ throw new TypeError(`Redis client factory result ${method} could not be inspected`, {
214
+ cause,
215
+ });
216
+ }
217
+ if (descriptor) {
218
+ if (!("value" in descriptor) || typeof descriptor.value !== "function") {
219
+ throw new TypeError(`Redis client factory result ${method} must be a data method`);
220
+ }
221
+ found = true;
222
+ break;
223
+ }
224
+ try {
225
+ owner = Object.getPrototypeOf(owner);
226
+ }
227
+ catch (cause) {
228
+ throw new TypeError(`Redis client factory result ${method} prototype could not be inspected`, { cause });
229
+ }
230
+ }
231
+ if (!found) {
232
+ throw new TypeError(`Redis client factory result must expose ${method}`);
233
+ }
234
+ }
235
+ }
236
+ /** Open one independently owned Redis connection. */
237
+ export async function openRedisClient(options = {}, dependencies = {}, lifecycle = {}) {
238
+ const capturedOptions = captureClientOptions(options);
239
+ const resolved = resolveClientOptions(capturedOptions, dependencies.getEnv ?? getEnv);
240
+ const factory = await raceWithAbort((dependencies.loadFactory ?? loadDefaultFactory)(), lifecycle.signal);
241
+ const client = factory(resolved.factoryOptions);
242
+ assertRedisClient(client);
243
+ let cleanup = () => client.disconnect();
244
+ let cleanupTail = Promise.resolve();
245
+ const enqueueCleanup = () => {
246
+ const attempt = cleanupTail.catch(() => undefined).then(() => cleanup());
247
+ cleanupTail = attempt;
248
+ return attempt;
249
+ };
250
+ try {
251
+ if (lifecycle.onClientCreated) {
252
+ const observedCleanup = lifecycle.onClientCreated(client);
253
+ if (observedCleanup)
254
+ cleanup = observedCleanup;
255
+ }
256
+ const connectAttempt = Promise.resolve().then(() => client.connect());
257
+ let connectSettled = false;
258
+ void connectAttempt.then(() => {
259
+ connectSettled = true;
260
+ }, () => {
261
+ connectSettled = true;
262
+ });
263
+ try {
264
+ await raceWithAbort(connectAttempt, lifecycle.signal);
265
+ }
266
+ catch (error) {
267
+ if (!connectSettled) {
268
+ void connectAttempt.then(() => enqueueCleanup(), () => enqueueCleanup()).catch((lateCleanupError) => {
269
+ logCleanupFailure("Redis client late-connect cleanup failed", lateCleanupError);
270
+ });
271
+ }
272
+ throw error;
273
+ }
274
+ return client;
275
+ }
276
+ catch (error) {
277
+ try {
278
+ await enqueueCleanup();
279
+ }
280
+ catch (closeError) {
281
+ if (lifecycle.onClientCreated) {
282
+ throw new AggregateError([error, closeError], "Redis client setup and observed cleanup failed");
283
+ }
284
+ throw new RedisClientSetupCleanupError(error, closeError, client);
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+ async function disconnectClient(client) {
290
+ if (client.isOpen === false)
291
+ return;
292
+ await client.disconnect();
293
+ }
294
+ /** Create an isolated, concurrency-safe Redis client manager. */
295
+ export function createRedisClientManager(dependencies = {}) {
296
+ const readEnv = dependencies.getEnv ?? getEnv;
297
+ const loadFactory = dependencies.loadFactory ?? loadDefaultFactory;
298
+ const now = dependencies.now ?? Date.now;
299
+ const states = new Map();
300
+ const orphanedClients = new Set();
301
+ const clientDisconnections = new WeakMap();
302
+ let disconnectVersion = 0;
303
+ let disconnecting = null;
304
+ function disconnectTrackedClient(client) {
305
+ const existing = clientDisconnections.get(client);
306
+ if (existing)
307
+ return existing;
308
+ const pending = disconnectClient(client).then(() => {
309
+ orphanedClients.delete(client);
310
+ }, (error) => {
311
+ orphanedClients.add(client);
312
+ throw error;
313
+ });
314
+ const tracked = pending.finally(() => {
315
+ if (clientDisconnections.get(client) === tracked)
316
+ clientDisconnections.delete(client);
317
+ });
318
+ clientDisconnections.set(client, tracked);
319
+ return tracked;
320
+ }
321
+ function sweepExpiredFailures() {
322
+ const currentTime = now();
323
+ for (const [key, state] of states) {
324
+ if (!state.client && !state.connecting && state.failedAt !== undefined &&
325
+ currentTime - state.failedAt >= RECONNECT_DELAY_MS) {
326
+ states.delete(key);
327
+ }
328
+ }
329
+ }
330
+ function getClient(options = {}) {
331
+ let capturedOptions;
332
+ try {
333
+ capturedOptions = captureClientOptions(options);
334
+ }
335
+ catch (error) {
336
+ return Promise.reject(error);
337
+ }
338
+ if (disconnecting) {
339
+ return disconnecting.then(() => getClient(capturedOptions));
340
+ }
341
+ if (orphanedClients.size > 0) {
342
+ return disconnect().then(() => getClient(capturedOptions));
343
+ }
344
+ let resolved;
345
+ try {
346
+ resolved = resolveClientOptions(capturedOptions, readEnv);
347
+ }
348
+ catch (error) {
349
+ return Promise.reject(error);
350
+ }
351
+ sweepExpiredFailures();
352
+ let state = states.get(resolved.key);
353
+ if (state?.client && state.client.isOpen !== false && state.failedAt === undefined) {
354
+ return Promise.resolve(state.client);
355
+ }
356
+ if (state?.connecting)
357
+ return state.connecting;
358
+ if (state?.failedAt !== undefined && now() - state.failedAt < RECONNECT_DELAY_MS) {
359
+ return Promise.reject(INITIALIZATION_ERROR.create({
360
+ detail: "[Redis] Connection recently failed, waiting before retry",
361
+ }));
362
+ }
363
+ if (!state) {
364
+ state = { generation: 0 };
365
+ states.set(resolved.key, state);
366
+ }
367
+ const connectionState = state;
368
+ const stale = connectionState.client;
369
+ connectionState.client = undefined;
370
+ const attemptGeneration = ++connectionState.generation;
371
+ const attemptDisconnectVersion = disconnectVersion;
372
+ const cancellationError = connectionCancelledError();
373
+ let cancelled = false;
374
+ let rejectCancellation;
375
+ const cancellation = new Promise((_resolve, reject) => {
376
+ rejectCancellation = reject;
377
+ });
378
+ connectionState.cancelConnecting = () => {
379
+ if (cancelled)
380
+ return;
381
+ cancelled = true;
382
+ rejectCancellation?.(cancellationError);
383
+ };
384
+ const trackedPromise = (async () => {
385
+ if (stale) {
386
+ connectionState.provisionalClient = stale;
387
+ try {
388
+ await Promise.race([disconnectTrackedClient(stale), cancellation]);
389
+ }
390
+ finally {
391
+ if (connectionState.provisionalClient === stale) {
392
+ connectionState.provisionalClient = undefined;
393
+ }
394
+ }
395
+ }
396
+ const factory = await Promise.race([loadFactory(), cancellation]);
397
+ if (states.get(resolved.key) !== connectionState ||
398
+ connectionState.generation !== attemptGeneration ||
399
+ disconnectVersion !== attemptDisconnectVersion) {
400
+ throw connectionCancelledError();
401
+ }
402
+ const client = factory(resolved.factoryOptions);
403
+ connectionState.provisionalClient = client;
404
+ if (typeof client.on === "function") {
405
+ client.on("error", (error) => {
406
+ if (states.get(resolved.key) !== connectionState ||
407
+ connectionState.client !== client ||
408
+ connectionState.generation !== attemptGeneration)
409
+ return;
410
+ logger.error("Client error", error);
411
+ connectionState.failedAt = now();
412
+ });
413
+ client.on("reconnecting", () => {
414
+ if (states.get(resolved.key) === connectionState &&
415
+ connectionState.client === client &&
416
+ connectionState.generation === attemptGeneration)
417
+ logger.info("Reconnecting...");
418
+ });
419
+ client.on("ready", () => {
420
+ if (states.get(resolved.key) !== connectionState ||
421
+ connectionState.client !== client ||
422
+ connectionState.generation !== attemptGeneration)
423
+ return;
424
+ logger.info("Ready");
425
+ connectionState.failedAt = undefined;
426
+ });
427
+ }
428
+ const connectAttempt = Promise.resolve().then(() => client.connect());
429
+ let connectSettled = false;
430
+ void connectAttempt.then(() => {
431
+ connectSettled = true;
432
+ }, () => {
433
+ connectSettled = true;
434
+ });
435
+ try {
436
+ await Promise.race([connectAttempt, cancellation]);
437
+ }
438
+ catch (error) {
439
+ const immediateCleanup = disconnectTrackedClient(client);
440
+ if (!connectSettled) {
441
+ void connectAttempt.then(async () => {
442
+ await immediateCleanup.catch(() => undefined);
443
+ await disconnectTrackedClient(client);
444
+ }, async () => {
445
+ await immediateCleanup.catch(() => undefined);
446
+ await disconnectTrackedClient(client);
447
+ }).catch((lateCleanupError) => {
448
+ logCleanupFailure("Redis manager late-connect cleanup failed", lateCleanupError);
449
+ });
450
+ }
451
+ try {
452
+ await immediateCleanup;
453
+ }
454
+ catch (closeError) {
455
+ throw new AggregateError([error, closeError], "Redis client connection and cleanup failed");
456
+ }
457
+ if (connectionState.provisionalClient === client) {
458
+ connectionState.provisionalClient = undefined;
459
+ }
460
+ throw error;
461
+ }
462
+ if (states.get(resolved.key) !== connectionState ||
463
+ connectionState.generation !== attemptGeneration ||
464
+ disconnectVersion !== attemptDisconnectVersion) {
465
+ try {
466
+ await disconnectTrackedClient(client);
467
+ }
468
+ catch (closeError) {
469
+ throw new AggregateError([connectionCancelledError(), closeError], "Redis client cancellation cleanup failed");
470
+ }
471
+ if (connectionState.provisionalClient === client) {
472
+ connectionState.provisionalClient = undefined;
473
+ }
474
+ throw connectionCancelledError();
475
+ }
476
+ connectionState.provisionalClient = undefined;
477
+ connectionState.client = client;
478
+ connectionState.failedAt = undefined;
479
+ logger.info("Connected successfully");
480
+ return client;
481
+ })()
482
+ .catch((error) => {
483
+ if (states.get(resolved.key) === connectionState &&
484
+ connectionState.generation === attemptGeneration) {
485
+ connectionState.failedAt = now();
486
+ connectionState.client = undefined;
487
+ }
488
+ throw error;
489
+ })
490
+ .finally(() => {
491
+ if (connectionState.connecting === trackedPromise) {
492
+ connectionState.connecting = undefined;
493
+ connectionState.cancelConnecting = undefined;
494
+ }
495
+ });
496
+ connectionState.connecting = trackedPromise;
497
+ return trackedPromise;
498
+ }
499
+ function disconnect() {
500
+ if (disconnecting)
501
+ return disconnecting;
502
+ disconnectVersion++;
503
+ const clients = new Set();
504
+ for (const client of orphanedClients)
505
+ clients.add(client);
506
+ for (const state of states.values()) {
507
+ state.generation++;
508
+ state.cancelConnecting?.();
509
+ if (state.client)
510
+ clients.add(state.client);
511
+ if (state.provisionalClient)
512
+ clients.add(state.provisionalClient);
513
+ state.client = undefined;
514
+ state.provisionalClient = undefined;
515
+ }
516
+ states.clear();
517
+ const pending = Promise.allSettled([...clients].map(disconnectTrackedClient)).then((results) => {
518
+ const failures = results
519
+ .filter((result) => result.status === "rejected")
520
+ .map((result) => result.reason);
521
+ if (failures.length === 1)
522
+ throw failures[0];
523
+ if (failures.length > 1) {
524
+ throw new AggregateError(failures, "Redis client manager disconnect failed");
525
+ }
526
+ });
527
+ const tracked = pending.finally(() => {
528
+ if (disconnecting === tracked)
529
+ disconnecting = null;
530
+ });
531
+ disconnecting = tracked;
532
+ return tracked;
533
+ }
534
+ return { getClient, disconnect };
535
+ }
@@ -0,0 +1,9 @@
1
+ import type { RedisClient, RedisClientOptions, RedisRuntimeProvider } from "veryfront/extensions/distributed";
2
+ import { type RedisClientManagerDependencies, type RedisClientOpenLifecycle } from "./redis-client-manager.js";
3
+ export interface RedisRuntimeProviderDependencies {
4
+ clientManagerDependencies?: RedisClientManagerDependencies;
5
+ openClient?: (options?: RedisClientOptions, lifecycle?: RedisClientOpenLifecycle) => Promise<RedisClient>;
6
+ }
7
+ /** Construct an isolated Redis runtime provider. */
8
+ export declare function createRedisRuntimeProvider(dependencies?: RedisRuntimeProviderDependencies): RedisRuntimeProvider;
9
+ //# sourceMappingURL=redis-runtime-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-runtime-provider.d.ts","sourceRoot":"","sources":["../src/redis-runtime-provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,WAAW,EAEX,kBAAkB,EAGlB,oBAAoB,EACrB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EAGL,KAAK,8BAA8B,EACnC,KAAK,wBAAwB,EAE9B,MAAM,2BAA2B,CAAC;AAoHnC,MAAM,WAAW,gCAAgC;IAC/C,yBAAyB,CAAC,EAAE,8BAA8B,CAAC;IAC3D,UAAU,CAAC,EAAE,CACX,OAAO,CAAC,EAAE,kBAAkB,EAC5B,SAAS,CAAC,EAAE,wBAAwB,KACjC,OAAO,CAAC,WAAW,CAAC,CAAC;CAC3B;AAED,oDAAoD;AACpD,wBAAgB,0BAA0B,CACxC,YAAY,GAAE,gCAAqC,GAClD,oBAAoB,CA2NtB"}