winx_prinx-api / server /cache.js
Sasha
feat: implement caching, rate limiter, and security headers (CSP) for decoupled deploy
f0fe495
Raw
History Blame
7.24 kB
/**
* 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 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;
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;
const now = Date.now();
if (now > entry.expiresAt) {
this.store.delete(key);
return null;
}
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()) {
if (now > entry.expiresAt) {
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
// =========================================================================
/**
* Express Middleware protecting route with Rate-Limiting and serving/saving 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
// Generate normalized cache key based on route path and alphabetically sorted query parameters
const sortedQueries = Object.keys(req.query)
.sort()
.map(k => `${k}=${req.query[k]}`)
.join('&');
const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path;
const cachedEntry = cache.get(cacheKey);
if (cachedEntry) {
// Cache Hit
metrics.hits++;
const now = Date.now();
const remainingTtlSecs = Math.max(0, Math.ceil((cachedEntry.expiresAt - now) / 1000));
res.setHeader('X-Cache', 'HIT');
res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs);
// Serve cached JSON payload
return res.json(cachedEntry.value);
}
// Cache Miss
metrics.misses++;
res.setHeader('X-Cache', 'MISS');
res.setHeader('X-Cache-TTL-Remaining', Math.ceil(ttlMs / 1000));
// Intercept res.json to store the result in cache
const originalJson = res.json;
res.json = function (body) {
// Restore original res.json first to avoid recursive loops
res.json = originalJson;
// Store in CacheManager
cache.set(cacheKey, body, ttlMs);
// Call original response
return res.json(body);
};
next();
};
}