@invisiblecities/sanity-edge-fetcher
Version:
Lightweight, Edge Runtime-compatible Sanity client for Next.js and Vercel Edge Functions
335 lines (294 loc) • 8.18 kB
text/typescript
/**
* @file cache.ts
* @description Multi-layer caching for Sanity Edge Fetcher
* @author Invisible Cities Agency
* @license MIT
*/
import { edgeSanityFetch, type EdgeSanityFetchOptions, type QueryParams } from './core';
import { Redis } from '@upstash/redis';
// Check if Upstash Redis is configured
const REDIS_URL = process.env.KV_REST_API_URL || process.env.UPSTASH_REDIS_REST_URL;
const REDIS_TOKEN = process.env.KV_REST_API_TOKEN || process.env.UPSTASH_REDIS_REST_TOKEN;
const REDIS_READ_ONLY_TOKEN = process.env.KV_REST_API_READ_ONLY_TOKEN;
const isRedisConfigured = !!(REDIS_URL && (REDIS_TOKEN || REDIS_READ_ONLY_TOKEN));
// Initialize Redis client if configured
let redis: Redis | null = null;
let redisWriter: Redis | null = null;
if (isRedisConfigured && REDIS_URL && (REDIS_TOKEN || REDIS_READ_ONLY_TOKEN)) {
try {
redis = new Redis({
url: REDIS_URL,
token: (REDIS_READ_ONLY_TOKEN || REDIS_TOKEN) as string,
automaticDeserialization: true,
});
// Separate writer client if write token available
if (REDIS_TOKEN) {
redisWriter = new Redis({
url: REDIS_URL,
token: REDIS_TOKEN,
automaticDeserialization: true,
});
} else {
redisWriter = redis;
}
} catch {
// Failed to initialize Redis client
redis = null;
redisWriter = null;
}
}
interface CacheEntry<T> {
value: T;
timestamp: number;
validUntil: number;
}
interface CachedFetchOptions extends EdgeSanityFetchOptions {
/** Cache configuration */
cache?: {
/** Time to live in seconds */
ttl?: number;
/** Cache key prefix */
prefix?: string;
/** Force cache refresh */
force?: boolean;
/** Enable Redis caching if available */
useRedis?: boolean;
/** Enable Next.js cache */
useNextCache?: boolean;
};
}
/**
* Generate cache key from query and params
*/
function generateCacheKey(
dataset: string,
query: string,
params?: QueryParams
): string {
const baseKey = `sanity:${dataset}:${query}`;
if (!params || Object.keys(params).length === 0) {
return baseKey;
}
// Sort params for consistent key generation
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${JSON.stringify(params[key])}`)
.join('&');
return `${baseKey}:${sortedParams}`;
}
/**
* In-memory LRU cache for edge runtime
*/
class MemoryCache {
private cache = new Map<string, CacheEntry<unknown>>();
private maxSize = 100;
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.validUntil) {
this.cache.delete(key);
return null;
}
// Move to end (LRU)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value as T;
}
set<T>(key: string, value: T, ttl: number): void {
// Evict oldest if at capacity
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
value,
timestamp: Date.now(),
validUntil: Date.now() + (ttl * 1000)
});
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
size(): number {
return this.cache.size;
}
}
// Global memory cache instance
const memoryCache = new MemoryCache();
/**
* Fetches data from Sanity with multi-layer caching
*
* Cache layers (in order):
* 1. In-memory LRU cache (fastest, ~1ms)
* 2. Upstash Redis (if configured, ~10-30ms)
* 3. Origin fetch with Next.js cache
*/
export async function cachedSanityFetch<T>(
options: CachedFetchOptions
): Promise<T> {
const {
dataset,
query,
params,
cache = {}
} = options;
const {
ttl = 60, // 1 minute default
prefix = '',
force = false,
useRedis = true
} = cache;
const cacheKey = prefix + generateCacheKey(dataset, query, params);
// Layer 1: Memory cache (unless force refresh)
if (!force) {
const memoryResult = memoryCache.get<T>(cacheKey);
if (memoryResult !== null) {
// Cache hit from memory
return memoryResult;
}
}
// Layer 2: Redis cache (if configured and enabled)
if (!force && useRedis && redis) {
try {
const redisEntry = await redis.get<CacheEntry<T>>(cacheKey);
if (redisEntry && Date.now() <= redisEntry.validUntil) {
// Cache hit from Redis
// Populate memory cache
memoryCache.set(cacheKey, redisEntry.value, ttl);
return redisEntry.value;
}
} catch {
// Redis cache read error, continue to fetch from origin
// Continue to fetch from origin
}
}
// Layer 3: Fetch from origin
// Cache miss, fetching from origin
// Build safe options for edge fetch (remove cache, avoid undefined params)
const { cache: _cache, params: _params, ...rest } = options;
const edgeOptions = (params !== undefined)
? { ...rest, params }
: { ...rest };
// Fetch from origin (Next.js cache removed for edge compatibility)
const result = await edgeSanityFetch<T>(edgeOptions as EdgeSanityFetchOptions);
// Populate caches
memoryCache.set(cacheKey, result, ttl);
if (useRedis && redisWriter) {
try {
const entry: CacheEntry<T> = {
value: result,
timestamp: Date.now(),
validUntil: Date.now() + (ttl * 1000)
};
await redisWriter.set(cacheKey, entry, { ex: ttl });
} catch {
// Redis cache write error, continue without caching
// Continue without caching
}
}
return result;
}
/**
* Create a cached fetcher with default options
*/
export function createCachedFetcher(
dataset: string,
defaultCacheOptions?: CachedFetchOptions['cache']
) {
return <T>(
query: string,
params?: QueryParams,
cacheOverrides?: CachedFetchOptions['cache']
) => {
return cachedSanityFetch<T>({
dataset,
query,
...(params !== undefined ? { params } : {}),
cache: { ...(defaultCacheOptions || {}), ...(cacheOverrides || {}) }
});
};
}
/**
* Clear caches for a specific dataset or pattern
*/
export async function clearSanityCache(options?: {
dataset?: string;
pattern?: string;
clearMemory?: boolean;
clearRedis?: boolean;
}): Promise<void> {
const {
dataset,
pattern,
clearMemory = true,
clearRedis = true
} = options || {};
// Clear memory cache
if (clearMemory) {
if (!dataset && !pattern) {
memoryCache.clear();
} else {
// Note: Memory cache doesn't support pattern matching
// Would need to iterate all keys for pattern support
// Pattern-based memory cache clearing not implemented
}
}
// Clear Redis cache
if (clearRedis && redisWriter && redis) {
try {
const keyPattern = pattern || (dataset ? `sanity:${dataset}:*` : 'sanity:*');
const keys = await redis.keys(keyPattern);
if (keys.length > 0) {
await redisWriter.del(...keys);
}
} catch {
// Failed to clear Redis cache
}
}
}
/**
* Warm cache by pre-fetching common queries
*/
export async function warmSanityCache(
queries: Array<{
dataset: string;
query: string;
params?: QueryParams;
ttl?: number;
}>
): Promise<void> {
await Promise.all(
queries.map(({ dataset, query, params, ttl }) =>
cachedSanityFetch({
dataset,
query,
...(params !== undefined ? { params } : {}),
cache: { ...(ttl !== undefined ? { ttl } : {}) }
}).catch(() => {
// Failed to warm cache for query
})
)
);
}
// Export cache status utility
export function getCacheStatus() {
return {
memory: {
available: true,
size: memoryCache.size()
},
redis: {
available: isRedisConfigured && redis !== null,
configured: isRedisConfigured,
url: REDIS_URL ? new URL(REDIS_URL).hostname : null
},
nextCache: {
available: typeof window === 'undefined'
}
};
}