Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion packages/cache-handler/src/handlers/redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ class FakeRedis {
handlers.push(handler);
return this;
}

public quitCalled = false;

async quit(): Promise<string> {
this.quitCalled = true;
return "OK";
}
}

// Mock ioredis to return our FakeRedis
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
});
});
});
66 changes: 55 additions & 11 deletions packages/cache-handler/src/handlers/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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 {
Expand All @@ -78,24 +101,40 @@ 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"
Comment on lines +110 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current duck-typing for an existing Redis client is not fully robust. It checks for the existence of get, set, and del properties, but only confirms that get is a function. This could lead to runtime errors if an object is passed where set or del are properties but not functions.

A more robust approach is to verify that all three methods (get, set, del) are indeed functions. This also simplifies the condition by making the in checks redundant.

      options.redis &&
      typeof options.redis === "object" &&
      typeof (options.redis as Redis).get === "function" &&
      typeof (options.redis as Redis).set === "function" &&
      typeof (options.redis as Redis).del === "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:";
this.tagPrefix = options.tagPrefix ?? "nextjs:tags:";
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", {
Expand Down Expand Up @@ -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<void> {
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);
}
Expand Down
Loading