259 lines
5.9 KiB
JavaScript
259 lines
5.9 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|