/** * Response Cache Middleware for Express * * Provides in-memory caching for API responses to reduce database load * and improve response times. Especially useful for frequently accessed * data like songs, lists, and stats. * * Features: * - Configurable TTL per route * - Cache invalidation on mutations * - ETag support for conditional requests * - Memory-efficient with automatic cleanup */ const crypto = require("crypto"); // Cache storage const cache = new Map(); // Default TTL values (in seconds) const DEFAULT_TTL = { "/api/songs": 300, // 5 minutes for song list "/api/lists": 120, // 2 minutes for worship lists "/api/profiles": 300, // 5 minutes for profiles "/api/stats": 60, // 1 minute for stats default: 60, // 1 minute default }; // Cache entry structure: { data, etag, timestamp, ttl } /** * Generate a cache key from request */ function generateCacheKey(req) { const baseKey = `${req.method}:${req.originalUrl}`; // Include query parameters in key return baseKey; } /** * Generate ETag from response data */ function generateETag(data) { return crypto.createHash("md5").update(JSON.stringify(data)).digest("hex"); } /** * Check if cache entry is still valid */ function isCacheValid(entry) { if (!entry) return false; const now = Date.now(); return now - entry.timestamp < entry.ttl * 1000; } /** * Get TTL for a specific route */ function getTTL(path) { // Strip query params for TTL lookup const basePath = path.split("?")[0]; // Check for exact match if (DEFAULT_TTL[basePath]) { return DEFAULT_TTL[basePath]; } // Check for prefix match for (const [key, ttl] of Object.entries(DEFAULT_TTL)) { if (basePath.startsWith(key)) { return ttl; } } return DEFAULT_TTL.default; } /** * Cache middleware - only caches GET requests */ function cacheMiddleware(options = {}) { return (req, res, next) => { // Only cache GET requests if (req.method !== "GET") { return next(); } // Skip caching for authenticated user-specific data const skipPaths = ["/api/auth/me", "/api/admin/"]; if (skipPaths.some((path) => req.originalUrl.startsWith(path))) { return next(); } const cacheKey = generateCacheKey(req); const cachedEntry = cache.get(cacheKey); // Check if we have valid cached data if (isCacheValid(cachedEntry)) { // Check If-None-Match header for conditional request const clientETag = req.headers["if-none-match"]; if (clientETag && clientETag === cachedEntry.etag) { return res.status(304).end(); // Not Modified } // Return cached response res.set("X-Cache", "HIT"); res.set("ETag", cachedEntry.etag); res.set( "Cache-Control", `private, max-age=${Math.floor((cachedEntry.ttl * 1000 - (Date.now() - cachedEntry.timestamp)) / 1000)}`, ); return res.json(cachedEntry.data); } // Cache miss - capture the response const originalJson = res.json.bind(res); res.json = (data) => { // Only cache successful responses if (res.statusCode >= 200 && res.statusCode < 300) { const ttl = options.ttl || getTTL(req.originalUrl); const etag = generateETag(data); cache.set(cacheKey, { data, etag, timestamp: Date.now(), ttl, }); res.set("X-Cache", "MISS"); res.set("ETag", etag); res.set("Cache-Control", `private, max-age=${ttl}`); } return originalJson(data); }; next(); }; } /** * Invalidate cache entries matching a pattern */ function invalidateCache(pattern) { if (typeof pattern === "string") { // Invalidate specific key cache.delete(pattern); // Also invalidate any keys that start with pattern for (const key of cache.keys()) { if (key.includes(pattern)) { cache.delete(key); } } } else if (pattern instanceof RegExp) { // Invalidate matching pattern for (const key of cache.keys()) { if (pattern.test(key)) { cache.delete(key); } } } } /** * Invalidation middleware for mutations (POST, PUT, DELETE) */ function invalidationMiddleware(req, res, next) { // Skip for GET requests if (req.method === "GET") { return next(); } const originalJson = res.json.bind(res); res.json = (data) => { // Invalidate related caches on successful mutations if (res.statusCode >= 200 && res.statusCode < 300) { const basePath = req.baseUrl || ""; // Invalidate caches based on route if (basePath.includes("/songs") || req.originalUrl.includes("/songs")) { invalidateCache("/api/songs"); invalidateCache("/api/stats"); } if (basePath.includes("/lists") || req.originalUrl.includes("/lists")) { invalidateCache("/api/lists"); invalidateCache("/api/stats"); } if ( basePath.includes("/profiles") || req.originalUrl.includes("/profiles") ) { invalidateCache("/api/profiles"); invalidateCache("/api/stats"); } } return originalJson(data); }; next(); } /** * Clear all cache entries */ function clearCache() { cache.clear(); } /** * Get cache statistics */ function getCacheStats() { let validEntries = 0; let expiredEntries = 0; for (const entry of cache.values()) { if (isCacheValid(entry)) { validEntries++; } else { expiredEntries++; } } return { totalEntries: cache.size, validEntries, expiredEntries, keys: Array.from(cache.keys()), }; } /** * Periodic cache cleanup (remove expired entries) */ function startCacheCleanup(intervalMs = 60000) { setInterval(() => { const now = Date.now(); for (const [key, entry] of cache.entries()) { if (!isCacheValid(entry)) { cache.delete(key); } } }, intervalMs); } module.exports = { cacheMiddleware, invalidationMiddleware, invalidateCache, clearCache, getCacheStats, startCacheCleanup, };