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
49 changes: 29 additions & 20 deletions src/backends/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ let connectionPromise: Promise<Redis> | null = null;

/**
* Create a default Redis backend, reusing a global client if available.
*
*
* @param options - Options for creating the Redis backend
* @returns A Redis cache backend instance
*/
export function createDefaultBackend<T = unknown>(
options: { prefix?: string; redisClient?: Redis } = {}
options: { prefix?: string; redisClient?: Redis } = {},
): CacheBackend<T> {
const { prefix = 'next-cachex', redisClient } = options;

// Use provided client or create/reuse global client
const client = redisClient || getGlobalRedisClient();

return new RedisCacheBackend<T>(client, prefix);
}

Expand All @@ -46,32 +46,41 @@ function getGlobalRedisClient(): Redis {
globalRedisClient.on('error', (error) => {
// Only log connection errors, don't throw unhandled rejections
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.warn('Redis connection error:', error.message);
}
});

globalRedisClient.on('connect', () => {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.log('Redis connected successfully');
}
});

// Capture the client reference to avoid non-null assertion in the closures below
const currentClient = globalRedisClient;

// Handle connection promise
connectionPromise = globalRedisClient.connect().then(() => globalRedisClient!).catch((error) => {
// Reset the promise on error so it can be retried
connectionPromise = null;
const connectionError = new CacheConnectionError(
`Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`
);

// In test environment, don't throw unhandled rejections
if (process.env.NODE_ENV === 'test') {
console.warn('Redis connection failed in test environment:', connectionError.message);
return globalRedisClient!;
}

throw connectionError;
});
connectionPromise = currentClient
.connect()
.then(() => currentClient)
.catch((error) => {
// Reset the promise on error so it can be retried
connectionPromise = null;
const connectionError = new CacheConnectionError(
`Failed to connect to Redis: ${error instanceof Error ? error.message : String(error)}`,
);

// In test environment, don't throw unhandled rejections
if (process.env.NODE_ENV === 'test') {
// eslint-disable-next-line no-console
console.warn('Redis connection failed in test environment:', connectionError.message);
return currentClient;
}

throw connectionError;
});
}
return globalRedisClient;
}
Expand All @@ -96,4 +105,4 @@ export function closeGlobalRedisClient(): void {
}

export { RedisCacheBackend } from './redis';
export { MemoryCacheBackend } from './memory';
export { MemoryCacheBackend } from './memory';
18 changes: 9 additions & 9 deletions src/backends/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ export class MemoryCacheBackend<T = unknown> implements CacheBackend<T> {
async get(key: string): Promise<T | undefined> {
const item = this.store.get(key);
if (!item) return undefined;

// Check if item has expired
if (item.expiresAt && Date.now() > item.expiresAt) {
this.store.delete(key);
return undefined;
}

return item.value;
}

Expand All @@ -32,7 +32,7 @@ export class MemoryCacheBackend<T = unknown> implements CacheBackend<T> {
* @param options - Optional TTL in seconds
*/
async set(key: string, value: T, options?: { ttl?: number }): Promise<void> {
const expiresAt = options?.ttl ? Date.now() + (options.ttl * 1000) : undefined;
const expiresAt = options?.ttl ? Date.now() + options.ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}

Expand All @@ -52,14 +52,14 @@ export class MemoryCacheBackend<T = unknown> implements CacheBackend<T> {
*/
async lock(key: string, ttl: number): Promise<boolean> {
const now = Date.now();
const expiresAt = now + (ttl * 1000);
const expiresAt = now + ttl * 1000;

// Check if lock exists and is still valid
const existingLock = this.locks.get(key);
if (existingLock && existingLock.expiresAt > now) {
return false;
}

// Acquire the lock
this.locks.set(key, { expiresAt });
return true;
Expand All @@ -86,19 +86,19 @@ export class MemoryCacheBackend<T = unknown> implements CacheBackend<T> {
*/
cleanup(): void {
const now = Date.now();

// Clean up expired cache entries
for (const [key, item] of this.store.entries()) {
if (item.expiresAt && item.expiresAt <= now) {
this.store.delete(key);
}
}

// Clean up expired locks
for (const [key, lock] of this.locks.entries()) {
if (lock.expiresAt <= now) {
this.locks.delete(key);
}
}
}
}
}
35 changes: 20 additions & 15 deletions src/backends/redis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { CacheBackend, CacheSerializationError, CacheBackendError, CacheConfigError } from '../types';
import {
CacheBackend,
CacheSerializationError,
CacheBackendError,
CacheConfigError,
} from '../types';
import type Redis from 'ioredis';

/**
Expand All @@ -23,24 +28,24 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
try {
const value = await this.client.get(fullKey);
if (value === null) return undefined;

// Fast path for simple values
if (value === 'null') return null as T;
if (value === 'undefined') return undefined;
if (value === 'true') return true as T;
if (value === 'false') return false as T;

// Try to parse as number first (common case)
const num = Number(value);
if (!isNaN(num) && value.trim() === num.toString()) {
return num as T;
}

try {
return JSON.parse(value) as T;
} catch (error) {
throw new CacheSerializationError(
`Failed to parse cached value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`
`Failed to parse cached value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
);
}
} catch (error) {
Expand All @@ -49,7 +54,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
}
throw new CacheBackendError(
`Redis get operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand All @@ -63,7 +68,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
async set(key: string, value: T, options?: { ttl?: number }): Promise<void> {
const fullKey = this.prefix ? `${this.prefix}:${key}` : key;
let str: string;

// Fast path for simple values
if (value === null) str = 'null';
else if (value === undefined) str = 'undefined';
Expand All @@ -75,11 +80,11 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
str = JSON.stringify(value);
} catch (error) {
throw new CacheSerializationError(
`Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`
`Failed to stringify value for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}

try {
if (options?.ttl) {
await this.client.set(fullKey, str, 'EX', options.ttl);
Expand All @@ -89,7 +94,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
} catch (error) {
throw new CacheBackendError(
`Redis set operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand All @@ -105,7 +110,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
} catch (error) {
throw new CacheBackendError(
`Redis delete operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand All @@ -125,7 +130,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
} catch (error) {
throw new CacheBackendError(
`Redis lock operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand All @@ -141,7 +146,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
} catch (error) {
throw new CacheBackendError(
`Redis unlock operation failed for key "${fullKey}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand All @@ -154,7 +159,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
if (!this.prefix) {
throw new CacheConfigError('Refusing to clear all keys: prefix is required for safety.');
}

const pattern = `${this.prefix}:*`;
let cursor = '0';
try {
Expand All @@ -168,7 +173,7 @@ export class RedisCacheBackend<T = unknown> implements CacheBackend<T> {
} catch (error) {
throw new CacheBackendError(
`Redis clear operation failed for pattern "${pattern}": ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined
error instanceof Error ? error : undefined,
);
}
}
Expand Down
Loading
Loading