Skip to content
Open
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
66 changes: 66 additions & 0 deletions 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 @@ -404,4 +411,63 @@ describe("RedisCacheHandler", () => {
expect(rawData).toBeNull();
});
});

describe("existing Redis client support", () => {
test("should accept an existing Redis client instance", async () => {
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 () => {
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);
});
});
});
67 changes: 56 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:
* - A 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,26 @@ 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,
* // ... other options
* });
*
* export default class NextCacheHandler extends RedisCacheHandler {
* constructor(options) {
* super({
* ...options,
* redis: redisClient, // Pass existing client
* keyPrefix: "nextjs:cache:",
* defaultTTL: 3600
* });
* }
* }
* ```
*/
export class RedisCacheHandler implements CacheHandler {
Expand All @@ -78,24 +102,39 @@ export class RedisCacheHandler implements CacheHandler {
private readonly tagPrefix: string;
private readonly defaultTTL?: number;
private readonly debug: boolean;
private didCreateClient = false;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🟡 Nit: should be readonly.

This flag is set once in the constructor and never mutated after. Mark it readonly for clarity and to let the type system enforce that:

private readonly didCreateClient: boolean;

Then assign it explicitly in each constructor branch (e.g. this.didCreateClient = true / false).


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;
} 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);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

🟡 Foot-gun: if the consumer also forgot to add an error listener, Node.js will crash.

Multiple 'error' listeners on the same EventEmitter are fine — they're called in order. A safer approach: always attach one, but check first so we don't add a duplicate logger:

if (this.redis.listenerCount('error') === 0) {
  this.redis.on('error', (err) => {
    console.error('[RedisCacheHandler] Redis connection error:', err);
  });
}

At minimum, the JSDoc for the shared-client option should document that the caller is responsible for error handling.

});
}

if (this.debug) {
console.log("[RedisCacheHandler] Initialized", {
Expand Down Expand Up @@ -289,11 +328,17 @@ 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");
// Only close if we created the client
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