diff --git a/packages/cache-handler/src/handlers/redis.test.ts b/packages/cache-handler/src/handlers/redis.test.ts index 722b73e..bce33a6 100644 --- a/packages/cache-handler/src/handlers/redis.test.ts +++ b/packages/cache-handler/src/handlers/redis.test.ts @@ -91,6 +91,13 @@ class FakeRedis { handlers.push(handler); return this; } + + public quitCalled = false; + + async quit(): Promise { + this.quitCalled = true; + return "OK"; + } } // Mock ioredis to return our FakeRedis @@ -375,7 +382,7 @@ describe("RedisCacheHandler", () => { test("should force cache miss for old APP_PAGE entries with plain object segmentData", async () => { const handler = new RedisCacheHandler(); - // Simulate an old entry stored before the Map serialization fix. + // Simulate an entry stored before the Map serialization fix. // JSON.stringify(new Map([...])) produces "{}", so segmentData is // a plain empty object after deserialization. const oldEntry = { @@ -404,4 +411,64 @@ describe("RedisCacheHandler", () => { expect(rawData).toBeNull(); }); }); + + describe("existing Redis client support", () => { + test("should accept an existing Redis client instance", async () => { + // Pass the fake redis directly as an existing client + const handler = new RedisCacheHandler({ + redis: fakeRedis as unknown as import("ioredis").default, + }); + + const value: CacheValue = { + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"shared": true}', + status: 200, + url: "https://example.com", + }, + revalidate: 60, + }; + + await handler.set("shared-key", value, { revalidate: false }); + const result = await handler.get("shared-key"); + + expect(result).not.toBeNull(); + expect(result?.value).toEqual(value); + }); + + test("should not add error listener for existing client", async () => { + const listenersBefore = fakeRedis.listeners.get("error")?.length ?? 0; + + new RedisCacheHandler({ redis: fakeRedis as unknown as import("ioredis").default }); + + const listenersAfter = fakeRedis.listeners.get("error")?.length ?? 0; + expect(listenersAfter).toBe(listenersBefore); + }); + + test("should add error listener for internally created client", async () => { + // When using default options (no redis client), the mock returns fakeRedis + // but the handler thinks it created the client, so it adds an error listener + new RedisCacheHandler(); + + const errorListeners = fakeRedis.listeners.get("error")?.length ?? 0; + expect(errorListeners).toBeGreaterThan(0); + }); + + test("should not close shared client on close()", async () => { + const handler = new RedisCacheHandler({ + redis: fakeRedis as unknown as import("ioredis").default, + }); + + await handler.close(); + expect(fakeRedis.quitCalled).toBe(false); + }); + + test("should close internally created client on close()", async () => { + const handler = new RedisCacheHandler(); + + await handler.close(); + expect(fakeRedis.quitCalled).toBe(true); + }); + }); }); diff --git a/packages/cache-handler/src/handlers/redis.ts b/packages/cache-handler/src/handlers/redis.ts index cb80fc2..51e8416 100644 --- a/packages/cache-handler/src/handlers/redis.ts +++ b/packages/cache-handler/src/handlers/redis.ts @@ -14,9 +14,12 @@ import type { export interface RedisCacheHandlerOptions extends CacheHandlerOptions { /** * Redis connection options (ioredis) - * Can be a URL string or RedisOptions object + * Can be: + * - An existing Redis client instance (ioredis) + * - A URL string + * - A RedisOptions object */ - redis?: string | RedisOptions; + redis?: Redis | string | RedisOptions; /** * Key prefix for all cache entries @@ -58,6 +61,7 @@ export interface RedisCacheHandlerOptions extends CacheHandlerOptions { * // In cache-handler.mjs or data-cache-handler.mjs * import { RedisCacheHandler } from "@mrjasonroy/cache-components-cache-handler/handlers/redis"; * + * // Option 1: Pass a Redis URL or config * export default class NextCacheHandler extends RedisCacheHandler { * constructor(options) { * super({ @@ -68,6 +72,25 @@ export interface RedisCacheHandlerOptions extends CacheHandlerOptions { * }); * } * } + * + * // Option 2: Pass an existing Redis client instance + * import Redis from "ioredis"; + * + * const redisClient = new Redis({ + * host: "localhost", + * port: 6379, + * }); + * + * export default class NextCacheHandler extends RedisCacheHandler { + * constructor(options) { + * super({ + * ...options, + * redis: redisClient, + * keyPrefix: "nextjs:cache:", + * defaultTTL: 3600 + * }); + * } + * } * ``` */ export class RedisCacheHandler implements CacheHandler { @@ -78,13 +101,27 @@ export class RedisCacheHandler implements CacheHandler { private readonly tagPrefix: string; private readonly defaultTTL?: number; private readonly debug: boolean; + private readonly didCreateClient: boolean; constructor(options: RedisCacheHandlerOptions = {}) { - // Initialize Redis connection - if (typeof options.redis === "string") { + // Initialize Redis connection - detect existing client via duck typing + // (instanceof can break across package versions or bundler setups) + if ( + options.redis && + typeof options.redis === "object" && + "get" in options.redis && + "set" in options.redis && + "del" in options.redis && + typeof (options.redis as Redis).get === "function" + ) { + this.redis = options.redis as Redis; + this.didCreateClient = false; + } else if (typeof options.redis === "string") { this.redis = new Redis(options.redis); + this.didCreateClient = true; } else { - this.redis = new Redis(options.redis || {}); + this.redis = new Redis((options.redis as RedisOptions) || {}); + this.didCreateClient = true; } this.keyPrefix = options.keyPrefix ?? "nextjs:cache:"; @@ -92,10 +129,12 @@ export class RedisCacheHandler implements CacheHandler { this.defaultTTL = options.defaultTTL; this.debug = options.debug ?? false; - // Handle Redis connection errors - this.redis.on("error", (err) => { - console.error("[RedisCacheHandler] Redis connection error:", err); - }); + // Only attach error listener for clients we created + if (this.didCreateClient) { + this.redis.on("error", (err) => { + console.error("[RedisCacheHandler] Redis connection error:", err); + }); + } if (this.debug) { console.log("[RedisCacheHandler] Initialized", { @@ -289,11 +328,16 @@ export class RedisCacheHandler implements CacheHandler { /** * Close the Redis connection * Call this when shutting down your application + * Note: Only closes connections created by this handler, not shared clients */ async close(): Promise { try { - await this.redis.quit(); - this.log("Connection closed"); + if (this.didCreateClient) { + await this.redis.quit(); + this.log("Connection closed"); + } else { + this.log("Skipping close (using shared client)"); + } } catch (error) { console.error("[RedisCacheHandler] close error:", error); }