const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); /** * Cache busting utility for preventing browser caching of static assets */ class CacheBusting { constructor() { // Generate a unique version identifier on server start this.version = this.generateVersion(); this.fileHashes = new Map(); } /** * Generate a version identifier based on server start time and random data * @returns {string} Version string */ generateVersion() { const timestamp = Date.now(); const random = crypto.randomBytes(4).toString('hex'); return `${timestamp}-${random}`; } /** * Get cache busting version for the application * @returns {string} Version string */ getVersion() { return this.version; } /** * Generate a file hash for specific file-based cache busting * @param {string} filePath - Path to the file * @returns {string} File hash or version fallback */ getFileHash(filePath) { try { if (this.fileHashes.has(filePath)) { return this.fileHashes.get(filePath); } const fullPath = path.join(__dirname, '..', 'public', filePath); if (fs.existsSync(fullPath)) { const content = fs.readFileSync(fullPath); const hash = crypto.createHash('md5').update(content).digest('hex').substring(0, 8); this.fileHashes.set(filePath, hash); return hash; } } catch (error) { console.warn(`Cache busting: Could not hash file ${filePath}:`, error.message); } // Fallback to version return this.version; } /** * Add cache busting parameter to a URL * @param {string} url - Original URL * @param {boolean} useFileHash - Whether to use file-specific hash * @returns {string} URL with cache busting parameter */ bustCache(url, useFileHash = false) { if (!url) return url; const separator = url.includes('?') ? '&' : '?'; const version = useFileHash ? this.getFileHash(url) : this.version; return `${url}${separator}v=${version}`; } /** * Get cache headers for static assets * @param {boolean} longTerm - Whether to use long-term caching * @returns {object} Cache headers */ getCacheHeaders(longTerm = false) { if (longTerm) { // Long-term caching for versioned assets return { 'Cache-Control': 'public, max-age=31536000, immutable', // 1 year 'ETag': this.version }; } else { // Short-term caching for frequently changing content return { 'Cache-Control': 'public, max-age=300', // 5 minutes 'ETag': this.version }; } } /** * Get no-cache headers for dynamic content * @returns {object} No-cache headers */ getNoCacheHeaders() { return { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }; } /** * Middleware to add cache busting to HTML responses * @returns {function} Express middleware */ htmlMiddleware() { return (req, res, next) => { // Store original send method const originalSend = res.send; // Override send method to modify HTML content res.send = (body) => { if (typeof body === 'string' && res.get('Content-Type')?.includes('text/html')) { // Add cache busting to CSS and JS files body = body.replace( /(href|src)=["']([^"']+\.(css|js))["']/g, (match, attr, url, ext) => { // Skip external URLs if (url.startsWith('http') || url.startsWith('//')) { return match; } const bustedUrl = this.bustCache(url, true); return `${attr}="${bustedUrl}"`; } ); // Add version info to HTML for debugging body = body.replace( '', ` \n` ); } // Call original send method originalSend.call(res, body); }; next(); }; } /** * Middleware to set cache headers for static files * @returns {function} Express middleware */ staticCacheMiddleware() { return (req, res, next) => { const ext = path.extname(req.path).toLowerCase(); const isVersioned = req.query.v !== undefined; // Set cache headers based on file type and versioning if (['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg'].includes(ext)) { const headers = isVersioned ? this.getCacheHeaders(true) : this.getCacheHeaders(false); Object.keys(headers).forEach(key => { res.set(key, headers[key]); }); } next(); }; } } // Create singleton instance const cacheBusting = new CacheBusting(); module.exports = { CacheBusting, cacheBusting };