Skip to content

Commit a840116

Browse files
silverbucketclaude
andauthored
Allow passing in custom ioredis Redis or Cluster client (#106)
* Allow passing in external clients * Address PR review feedback - Remove unnecessary type assertions (as Redis) since both Redis and Cluster have status and connect() properties - Reorder if/else in disconnect() for clarity (check positive case first) * fix: use event-based ready detection for external clients * fix: replace test delay with status polling, add comment for close/end check * tests: improve testing for node envs, cleanup clients * chore: remove superfluous code --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 408caba commit a840116

File tree

4 files changed

+297
-12
lines changed

4 files changed

+297
-12
lines changed

README.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,85 @@ new SecureStore(config: SecureStoreConfig)
4141
| ------------------ | ------------------------------- | -------- | ----------------------------------------------------------------- |
4242
| `uid` | string | Yes | Unique prefix for Redis keys (e.g., `"myApp"`, `"myApp:sessions"`) |
4343
| `secret` | string | Yes | 32-character encryption secret. Use `SecretValidator.generate()`. |
44-
| `redis` | RedisOptions \| { url: string } | Yes | Redis connection config |
44+
| `redis` | RedisOptions \| { url: string } \| { client: Redis \| Cluster } | Yes | Redis connection config or existing client |
4545
| `allowWeakSecrets` | boolean | No | Bypass secret strength validation (default: false) |
4646

47+
### Using an Existing Redis Client
48+
49+
You can pass your own ioredis `Redis` or `Cluster` client instead of connection options. This enables connection sharing, pre-configured clients, and integration with existing Redis infrastructure.
50+
51+
```typescript
52+
import { Redis } from "ioredis";
53+
import SecureStore, { SecretValidator } from "secure-store-redis";
54+
55+
const redis = new Redis({ host: "localhost", port: 6379 });
56+
57+
const store = new SecureStore({
58+
uid: "myApp",
59+
secret: SecretValidator.generate(),
60+
redis: { client: redis },
61+
});
62+
63+
await store.connect();
64+
await store.save("key", "value");
65+
await store.disconnect();
66+
67+
// Your client is still connected - you manage its lifecycle
68+
console.log(redis.status); // "ready"
69+
await redis.quit();
70+
```
71+
72+
#### Redis Cluster
73+
74+
```typescript
75+
import { Cluster } from "ioredis";
76+
import SecureStore, { SecretValidator } from "secure-store-redis";
77+
78+
const cluster = new Cluster([
79+
{ host: "node1", port: 6379 },
80+
{ host: "node2", port: 6379 },
81+
]);
82+
83+
const store = new SecureStore({
84+
uid: "myApp",
85+
secret: SecretValidator.generate(),
86+
redis: { client: cluster },
87+
});
88+
89+
await store.connect();
90+
```
91+
92+
#### Sharing Connections
93+
94+
Multiple SecureStore instances can share a single Redis connection:
95+
96+
```typescript
97+
const redis = new Redis();
98+
99+
const sessionsStore = new SecureStore({
100+
uid: "sessions",
101+
secret: sessionSecret,
102+
redis: { client: redis },
103+
});
104+
105+
const cacheStore = new SecureStore({
106+
uid: "cache",
107+
secret: cacheSecret,
108+
redis: { client: redis },
109+
});
110+
111+
await sessionsStore.connect();
112+
await cacheStore.connect();
113+
114+
// Both stores use the same connection
115+
// Disconnecting either store does NOT close the Redis client
116+
```
117+
118+
**Important notes:**
119+
- When using an external client, `disconnect()` will NOT close the Redis connection - you are responsible for calling `redis.quit()` when done
120+
- The client can be in any connectable state (ready, connecting, or lazyConnect); `connect()` will wait for it to be ready
121+
- If the client is already closed, `connect()` will throw a `ConnectionError`
122+
47123
### Methods
48124

49125
#### `connect(): Promise<void>`
@@ -72,7 +148,7 @@ Create a typed namespace for organizing data. See [Namespaces](#namespaces) for
72148

73149
### Properties
74150

75-
- `client: Redis | undefined` - The underlying ioredis client
151+
- `client: RedisClient | undefined` - The underlying ioredis client (Redis or Cluster)
76152
- `isConnected: boolean` - Connection status
77153

78154
## Namespaces
@@ -223,6 +299,7 @@ Full TypeScript support with exported types:
223299
import SecureStore, {
224300
SecureStoreConfig,
225301
TypedNamespace,
302+
RedisClient,
226303
SecretValidator,
227304
SecureStoreError,
228305
ConnectionError,

src/index.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test, describe, afterAll, beforeAll } from "./test-compat.ts";
2+
import { Redis } from "ioredis";
23

34
import SecureStore, { SecretValidator } from "./index.ts";
45

@@ -528,4 +529,135 @@ describe("SecureStore", () => {
528529
expect(result.valid).toEqual(true);
529530
});
530531
});
532+
533+
describe("External Redis Client", () => {
534+
test("accepts pre-connected Redis client", async () => {
535+
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
536+
try {
537+
await redis.ping(); // Ensure connected
538+
539+
const store = new SecureStore({
540+
uid: "external-client-test",
541+
secret: "823HD8DG26JA0LK1239Hgb651TWfs0j1",
542+
redis: { client: redis },
543+
});
544+
545+
await store.connect();
546+
expect(store.isConnected).toEqual(true);
547+
expect(store.client).toBe(redis);
548+
549+
await store.save("extKey", "extValue");
550+
expect(await store.get("extKey")).toEqual("extValue");
551+
552+
await store.disconnect();
553+
// External client should still be connected
554+
expect(redis.status).toEqual("ready");
555+
} finally {
556+
await redis.quit();
557+
}
558+
});
559+
560+
test("accepts Redis client with lazyConnect", async () => {
561+
const redis = new Redis({
562+
host: "127.0.0.1",
563+
port: 6379,
564+
lazyConnect: true,
565+
});
566+
567+
try {
568+
const store = new SecureStore({
569+
uid: "lazy-client-test",
570+
secret: "823HD8DG26JA0LK1239Hgb651TWfs0j1",
571+
redis: { client: redis },
572+
});
573+
574+
// Client is in "wait" state, connect() should connect it
575+
await store.connect();
576+
expect(store.isConnected).toEqual(true);
577+
578+
await store.save("lazyKey", "lazyValue");
579+
expect(await store.get("lazyKey")).toEqual("lazyValue");
580+
581+
await store.disconnect();
582+
expect(redis.status).toEqual("ready");
583+
} finally {
584+
await redis.quit();
585+
}
586+
});
587+
588+
test("multiple stores share one client", async () => {
589+
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
590+
try {
591+
await redis.ping();
592+
593+
const store1 = new SecureStore({
594+
uid: "shared-client-1",
595+
secret: "823HD8DG26JA0LK1239Hgb651TWfs0j1",
596+
redis: { client: redis },
597+
});
598+
599+
const store2 = new SecureStore({
600+
uid: "shared-client-2",
601+
secret: "923HD8DG26JA0LK1239Hgb651TWfs0j2",
602+
redis: { client: redis },
603+
});
604+
605+
await store1.connect();
606+
await store2.connect();
607+
608+
await store1.save("key", "value1");
609+
await store2.save("key", "value2");
610+
611+
expect(await store1.get("key")).toEqual("value1");
612+
expect(await store2.get("key")).toEqual("value2");
613+
614+
await store1.disconnect();
615+
// Redis should still be connected after store1 disconnect
616+
expect(redis.status).toEqual("ready");
617+
expect(store2.isConnected).toEqual(true);
618+
619+
await store2.disconnect();
620+
expect(redis.status).toEqual("ready");
621+
} finally {
622+
await redis.quit();
623+
}
624+
});
625+
626+
test("throws error if external client is closed", async () => {
627+
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
628+
await redis.ping();
629+
await redis.quit();
630+
// Poll until status changes to "end" (avoid arbitrary delay)
631+
while (redis.status !== "end") {
632+
await new Promise((resolve) => setTimeout(resolve, 10));
633+
}
634+
635+
const store = new SecureStore({
636+
uid: "closed-client-test",
637+
secret: "823HD8DG26JA0LK1239Hgb651TWfs0j1",
638+
redis: { client: redis },
639+
});
640+
641+
await expect(store.connect()).rejects.toThrow(
642+
"External Redis client is closed",
643+
);
644+
});
645+
646+
test("exposes external client via client property", async () => {
647+
const redis = new Redis({ host: "127.0.0.1", port: 6379 });
648+
try {
649+
await redis.ping();
650+
651+
const store = new SecureStore({
652+
uid: "client-property-test",
653+
secret: "823HD8DG26JA0LK1239Hgb651TWfs0j1",
654+
redis: { client: redis },
655+
});
656+
657+
expect(store.client).toBe(redis);
658+
} finally {
659+
await redis.quit();
660+
}
661+
});
662+
});
531663
});

src/index.ts

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import {
66
randomBytes,
77
} from "node:crypto";
88
import debug from "debug";
9-
import { Redis, type RedisOptions } from "ioredis";
9+
import { type Cluster, Redis, type RedisOptions } from "ioredis";
10+
11+
/**
12+
* A Redis-like client that supports the commands SecureStore needs.
13+
* Can be either a standard Redis client or a Redis Cluster client.
14+
*/
15+
export type RedisClient = Redis | Cluster;
1016

1117
const ALGORITHM = "aes-256-gcm";
1218
const IV_LENGTH = 16;
@@ -192,9 +198,17 @@ export interface SecureStoreConfig {
192198
*/
193199
secret: string;
194200
/**
195-
* Redis connect config object
201+
* Redis connection configuration. Accepts one of:
202+
* - `RedisOptions`: ioredis connection options (host, port, etc.)
203+
* - `{ url: string }`: Redis connection URL
204+
* - `{ client: RedisClient }`: An existing Redis or Cluster client instance
205+
*
206+
* When providing an external client:
207+
* - SecureStore will NOT close the client on `disconnect()` - you manage its lifecycle
208+
* - The client should be connected or in a connectable state
209+
* - Both `Redis` and `Cluster` clients are supported
196210
*/
197-
redis: RedisOptions | { url: string };
211+
redis: RedisOptions | { url: string } | { client: RedisClient };
198212
/**
199213
* Allow weak secrets (bypass entropy validation). Not recommended for production.
200214
* @default false
@@ -225,9 +239,10 @@ export default class SecureStore {
225239
/**
226240
* Redis client
227241
*/
228-
client: Redis | undefined;
242+
client: RedisClient | undefined;
229243
private readonly config: Required<SecureStoreConfig>;
230244
private connected = false;
245+
private externalClientProvided = false;
231246

232247
/**
233248
* Creates an instance of SecureStore.
@@ -238,6 +253,11 @@ export default class SecureStore {
238253
if (typeof cfg.redis !== "object") {
239254
cfg.redis = {};
240255
}
256+
// Check for external client before secret validation
257+
if ("client" in cfg.redis && cfg.redis.client) {
258+
this.client = cfg.redis.client;
259+
this.externalClientProvided = true;
260+
}
241261
// Validate secret unless allowWeakSecrets is true
242262
if (!cfg.allowWeakSecrets) {
243263
const validation = SecretValidator.validate(cfg.secret);
@@ -252,12 +272,20 @@ export default class SecureStore {
252272
}
253273

254274
/**
255-
* Disconnects the Redis client
275+
* Disconnects the Redis client.
276+
* If using an external client (passed via `{ client: RedisClient }`),
277+
* this method will NOT close the connection - you manage its lifecycle.
256278
*/
257-
async disconnect(client: Redis | undefined = this.client): Promise<void> {
279+
async disconnect(
280+
client: RedisClient | undefined = this.client,
281+
): Promise<void> {
258282
if (client) {
259-
log("Redis client quit called");
260-
await client.quit();
283+
if (this.externalClientProvided && client === this.client) {
284+
log("Skipping quit for external client");
285+
} else {
286+
log("Redis client quit called");
287+
await client.quit();
288+
}
261289
this.connected = false;
262290
}
263291
}
@@ -270,9 +298,50 @@ export default class SecureStore {
270298
}
271299

272300
/**
273-
* Connects the Redis client to the Redis server
301+
* Connects the Redis client to the Redis server.
302+
* If using an external client, ensures the client is ready.
274303
*/
275304
async connect(): Promise<void> {
305+
// Handle external client
306+
if (this.externalClientProvided && this.client) {
307+
const status = this.client.status;
308+
if (status === "ready") {
309+
this.connected = true;
310+
return;
311+
}
312+
// "end" is the terminal state after quit(). "close" can occur from
313+
// connection errors or manual disconnect. Both indicate unusable client.
314+
if (status === "close" || status === "end") {
315+
throw new ConnectionError(
316+
"External Redis client is closed. Provide a connected client or reconnect before calling connect().",
317+
);
318+
}
319+
// wait/connecting/reconnecting - wait for ready event
320+
return new Promise((resolve, reject) => {
321+
const onReady = () => {
322+
this.client?.off("error", onError);
323+
this.connected = true;
324+
resolve();
325+
};
326+
const onError = (err: Error) => {
327+
this.client?.off("ready", onReady);
328+
reject(
329+
new ConnectionError(
330+
"External client failed to connect",
331+
err,
332+
),
333+
);
334+
};
335+
this.client?.once("ready", onReady);
336+
this.client?.once("error", onError);
337+
// Initiate connection if client is waiting (lazyConnect)
338+
if (status === "wait") {
339+
this.client?.connect().catch(onError);
340+
}
341+
});
342+
}
343+
344+
// Create internal client
276345
if (!this.client) {
277346
return new Promise((resolve, reject) => {
278347
let redisConfig: RedisOptions = {};
@@ -294,7 +363,10 @@ export default class SecureStore {
294363
? Number.parseInt(url.pathname.slice(1), 10)
295364
: 0,
296365
};
297-
} else if (this.config.redis) {
366+
} else if (
367+
this.config.redis &&
368+
!("client" in this.config.redis)
369+
) {
298370
redisConfig = this.config.redis as RedisOptions;
299371
}
300372

0 commit comments

Comments
 (0)