UNPKG

@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
/** * @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' } }; }