/** * production-ready In-Memory Caching and Rate-Limiting Subsystem * Designed to protect the Supabase DB and Express server from spikes in traffic */ import dotenv from 'dotenv'; dotenv.config(); // Configuration flags const IS_CACHE_ENABLED = process.env.CACHE_ENABLED !== 'false'; const DEFAULT_TTL_MS = (parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 15) * 1000; const CACHE_MAX_KEYS = parseInt(process.env.CACHE_MAX_KEYS, 10) || 1000; const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 60; const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000; // Metrics tracker const metrics = { hits: 0, misses: 0, setCount: 0, blockedRequests: 0, }; // ========================================================================= // CACHE MANAGER CLASS // ========================================================================= class CacheManager { constructor() { this.store = new Map(); // Background garbage collection sweep every 30 seconds this.gcTimer = setInterval(() => { this.evictExpired(); }, 30000).unref(); // .unref() allows Node process to exit cleanly if running tests } set(key, value, ttlMs = DEFAULT_TTL_MS) { if (!IS_CACHE_ENABLED) return; const now = Date.now(); const expiresAt = now + ttlMs; if (this.store.has(key)) { // Delete existing to update its insertion order position (LRU) this.store.delete(key); } else if (this.store.size >= CACHE_MAX_KEYS) { // Evict the least recently used (oldest) key const oldestKey = this.store.keys().next().value; if (oldestKey !== undefined) { this.store.delete(oldestKey); console.log(`[CacheManager LRU] Evicted oldest key: ${oldestKey}`); } } this.store.set(key, { value, expiresAt, }); metrics.setCount++; } get(key) { if (!IS_CACHE_ENABLED) return null; const entry = this.store.get(key); if (!entry) return null; // Refresh insertion order position for LRU this.store.delete(key); this.store.set(key, entry); // Return the entry even if expired. Freshness logic is handled by middleware return entry; } del(key) { return this.store.delete(key); } flush() { this.store.clear(); console.log('[CacheManager] Cache flushed successfully.'); } evictExpired() { const now = Date.now(); let evictedCount = 0; for (const [key, entry] of this.store.entries()) { // Keep stale data in memory for up to 24 hours so it can be served during heavy compute spikes if (now > entry.expiresAt + 24 * 60 * 60 * 1000) { this.store.delete(key); evictedCount++; } } if (evictedCount > 0) { console.log(`[CacheManager GC] Swept and evicted ${evictedCount} expired cache keys.`); } } getStats() { const totalKeys = this.store.size; const totalRequests = metrics.hits + metrics.misses; const hitRatio = totalRequests > 0 ? ((metrics.hits / totalRequests) * 100).toFixed(1) + '%' : '0%'; return { enabled: IS_CACHE_ENABLED, totalKeys, hits: metrics.hits, misses: metrics.misses, hitRatio, setCount: metrics.setCount, blockedRequests: metrics.blockedRequests, }; } } export const cache = new CacheManager(); // ========================================================================= // RATE LIMITER CLASS // ========================================================================= class RateLimiter { constructor() { this.clients = new Map(); // Background sweeper to clear expired rate-limit buckets every 30 seconds this.gcTimer = setInterval(() => { this.evictExpired(); }, 30000).unref(); } /** * Check if client IP is within limits * @returns {object} { allowed: boolean, remaining: number, limit: number, resetTime: number } */ check(ip) { const now = Date.now(); let client = this.clients.get(ip); if (!client || now > client.resetTime) { // Create new window const resetTime = now + RATE_LIMIT_WINDOW_MS; client = { hits: 1, resetTime, }; this.clients.set(ip, client); return { allowed: true, remaining: RATE_LIMIT_MAX - 1, limit: RATE_LIMIT_MAX, resetTime, }; } client.hits++; const remaining = Math.max(0, RATE_LIMIT_MAX - client.hits); const allowed = client.hits <= RATE_LIMIT_MAX; return { allowed, remaining, limit: RATE_LIMIT_MAX, resetTime: client.resetTime, }; } evictExpired() { const now = Date.now(); let count = 0; for (const [ip, data] of this.clients.entries()) { if (now > data.resetTime) { this.clients.delete(ip); count++; } } if (count > 0) { console.log(`[RateLimiter GC] Evicted ${count} expired rate-limit records.`); } } } const rateLimiter = new RateLimiter(); // ========================================================================= // EXPRESS MIDDLEWARE // ========================================================================= const pendingRequests = new Map(); /** * Express Middleware protecting route with Rate-Limiting, Coalescing, and Stale-While-Revalidate Cache * @param {number} ttlSeconds Custom TTL override in seconds */ export function cacheMiddleware(ttlSeconds) { const ttlMs = ttlSeconds ? ttlSeconds * 1000 : DEFAULT_TTL_MS; return async (req, res, next) => { // 1. Bypass check if cache is disabled globally if (!IS_CACHE_ENABLED) { return next(); } // Extract real client IP (supports Express trust proxy) const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress; // 2. Perform Rate Limiting const limitStatus = rateLimiter.check(ip); // Set standard rate limit headers res.setHeader('X-RateLimit-Limit', limitStatus.limit); res.setHeader('X-RateLimit-Remaining', limitStatus.remaining); res.setHeader('X-RateLimit-Reset', Math.ceil(limitStatus.resetTime / 1000)); if (!limitStatus.allowed) { metrics.blockedRequests++; const retryAfter = Math.ceil((limitStatus.resetTime - Date.now()) / 1000); res.setHeader('Retry-After', retryAfter); console.warn(`[RateLimiter] Blocked IP: ${ip} for too many requests on path: ${req.path}`); return res.status(429).json({ error: 'Too Many Requests', message: 'Превышен лимит запросов к серверу. Пожалуйста, подождите.', retryAfter, }); } // 3. Perform Cache Lookup const sortedQueries = Object.keys(req.query) .sort() .map(k => `${k}=${req.query[k]}`) .join('&'); const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path; const entry = cache.get(cacheKey); const now = Date.now(); // CASE A: Cache is FRESH if (entry && now <= entry.expiresAt) { metrics.hits++; const remainingTtlSecs = Math.max(0, Math.ceil((entry.expiresAt - now) / 1000)); res.setHeader('X-Cache', 'HIT'); res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs); return res.json(entry.value); } // CASE B: Cache is STALE or MISS, and another request is already computing it if (pendingRequests.has(cacheKey)) { if (entry) { // Stale-While-Revalidate: Return old data immediately to avoid waiting metrics.hits++; res.setHeader('X-Cache', 'STALE'); return res.json(entry.value); } else { // Request Coalescing: No old data exists, so wait for the pending request to finish metrics.misses++; res.setHeader('X-Cache', 'COALESCED'); try { const value = await pendingRequests.get(cacheKey); return res.json(value); } catch (e) { return res.status(500).json({ error: 'Internal Server Error computing cache' }); } } } // CASE C: Cache is STALE or MISS, and WE are the first request to trigger compute metrics.misses++; res.setHeader('X-Cache', 'MISS'); // Create a pending Promise that others can wait on let resolvePending; let rejectPending; const pendingPromise = new Promise((resolve, reject) => { resolvePending = resolve; rejectPending = reject; }); pendingRequests.set(cacheKey, pendingPromise); // Timeout safety net to prevent frozen promises if next() hangs forever const safetyTimeout = setTimeout(() => { if (pendingRequests.get(cacheKey) === pendingPromise) { pendingRequests.delete(cacheKey); rejectPending(new Error('Cache compute timeout')); } }, 5 * 60 * 1000); // Intercept res.json to store the result const originalJson = res.json; res.json = function (body) { clearTimeout(safetyTimeout); res.json = originalJson; // Restore // Save fresh data only on successful 2xx responses and check array structure for streams if (res.statusCode >= 200 && res.statusCode < 300) { let shouldCache = true; // If this is the streams list endpoint, ensure the body is a valid array if (req.path === '/api/streams' && !Array.isArray(body)) { shouldCache = false; } if (shouldCache) { cache.set(cacheKey, body, ttlMs); } } // Resolve the promise to unblock anyone who was coalesced (waiting) resolvePending(body); if (pendingRequests.get(cacheKey) === pendingPromise) { pendingRequests.delete(cacheKey); } return res.json(body); }; next(); }; }