/** * Response Cache Service * FE-API-017: Response caching for GET requests * * Caches GET request responses to reduce server load and improve performance */ import { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; import { logger } from '@/utils/logger'; /** * Cached response with metadata */ interface CachedResponse { data: T; headers: Record; status: number; statusText: string; timestamp: number; etag?: string; lastModified?: string; maxAge?: number; // Cache max age in seconds } /** * Cache configuration */ export interface CacheConfig { /** Default TTL in milliseconds (default: 5 minutes) */ defaultTTL?: number; /** Maximum cache size (default: 100 entries) */ maxSize?: number; /** Whether to respect Cache-Control headers from server */ respectCacheControl?: boolean; /** Whether to enable ETag support */ enableETag?: boolean; } /** * Response Cache Service * Manages caching of GET request responses */ class ResponseCacheService { private cache: Map = new Map(); private defaultTTL = 5 * 60 * 1000; // 5 minutes private maxSize = 100; private respectCacheControl = true; private enableETag = true; constructor(config: CacheConfig = {}) { this.defaultTTL = config.defaultTTL || this.defaultTTL; this.maxSize = config.maxSize || this.maxSize; this.respectCacheControl = config.respectCacheControl !== false; this.enableETag = config.enableETag !== false; } /** * Generate a unique key for a request */ private generateCacheKey(config: AxiosRequestConfig): string { const method = (config.method || 'GET').toUpperCase(); const url = config.url || ''; const baseURL = config.baseURL || ''; const fullUrl = url.startsWith('http') ? url : `${baseURL}${url}`; // Sort params for consistent key generation const params = config.params ? Object.keys(config.params) .sort() .map((key) => `${key}=${JSON.stringify(config.params![key])}`) .join('&') : ''; // Include Authorization header in key for user-specific cache const authHeader = config.headers?.Authorization || ''; return `${method}:${fullUrl}${params ? `?${params}` : ''}:${authHeader}`; } /** * Parse Cache-Control header */ private parseCacheControl(header: string | undefined): { maxAge?: number; noCache?: boolean; noStore?: boolean; mustRevalidate?: boolean; } { if (!header) { return {}; } const directives: Record = {}; const parts = header.split(',').map((p) => p.trim()); for (const part of parts) { if (part.includes('=')) { const [key, value] = part.split('=').map((p) => p.trim()); directives[key.toLowerCase()] = value; } else { directives[part.toLowerCase()] = true; } } return { maxAge: directives['max-age'] ? parseInt(String(directives['max-age']), 10) : undefined, noCache: directives['no-cache'] === true, noStore: directives['no-store'] === true, mustRevalidate: directives['must-revalidate'] === true, }; } /** * Check if a cached response is still valid */ private isCacheValid( cached: CachedResponse, config: AxiosRequestConfig, ): boolean { const now = Date.now(); const age = now - cached.timestamp; // Check if cache is expired based on maxAge if (cached.maxAge) { const maxAgeMs = cached.maxAge * 1000; if (age > maxAgeMs) { return false; } } else if (age > this.defaultTTL) { return false; } // Check ETag if enabled and request has If-None-Match if (this.enableETag && cached.etag) { const ifNoneMatch = config.headers?.['If-None-Match']; if (ifNoneMatch && ifNoneMatch !== cached.etag) { return false; } } // Check Last-Modified if enabled if (cached.lastModified) { const ifModifiedSince = config.headers?.['If-Modified-Since']; if (ifModifiedSince) { const cachedDate = new Date(cached.lastModified).getTime(); const requestDate = new Date(ifModifiedSince).getTime(); if (cachedDate < requestDate) { return false; } } } return true; } /** * Get cached response if available and valid */ get(config: AxiosRequestConfig): AxiosResponse | null { // Only cache GET requests const method = (config.method || 'GET').toUpperCase(); if (method !== 'GET') { return null; } // Check if caching is disabled for this request if ((config as any)?._disableCache === true) { return null; } const key = this.generateCacheKey(config); const cached = this.cache.get(key); if (!cached) { return null; } // Check if cache is still valid if (!this.isCacheValid(cached, config)) { this.cache.delete(key); logger.debug(`[ResponseCache] Cache expired: ${config.url}`); return null; } // Create AxiosResponse-like object from cache const response: AxiosResponse = { data: cached.data as T, status: cached.status, statusText: cached.statusText, headers: cached.headers as any, config: config as InternalAxiosRequestConfig, request: {}, }; logger.debug(`[ResponseCache] Cache hit: ${config.url}`, { key, age: Date.now() - cached.timestamp, }); return response; } /** * Store response in cache */ set(config: AxiosRequestConfig, response: AxiosResponse): void { // Only cache GET requests const method = (config.method || 'GET').toUpperCase(); if (method !== 'GET') { return; } // Check if caching is disabled for this request if ((config as any)?._disableCache === true) { return; } // Check Cache-Control header const cacheControl = response.headers['cache-control'] || response.headers['Cache-Control']; const directives = this.parseCacheControl(cacheControl); // Don't cache if no-store or no-cache if (directives.noStore || directives.noCache) { logger.debug( `[ResponseCache] Not caching (no-store/no-cache): ${config.url}`, ); return; } // Calculate max age let maxAge: number | undefined; if (this.respectCacheControl && directives.maxAge) { maxAge = directives.maxAge; } else { maxAge = Math.floor(this.defaultTTL / 1000); // Convert to seconds } // Extract ETag and Last-Modified const etag = response.headers['etag'] || response.headers['ETag']; const lastModified = response.headers['last-modified'] || response.headers['Last-Modified']; const key = this.generateCacheKey(config); // Check cache size limit if (this.cache.size >= this.maxSize && !this.cache.has(key)) { // Remove oldest entry (simple FIFO) const firstKey = this.cache.keys().next().value; if (firstKey) { this.cache.delete(firstKey); } } // Store in cache this.cache.set(key, { data: response.data, headers: response.headers as Record, status: response.status, statusText: response.statusText, timestamp: Date.now(), etag, lastModified, maxAge, }); logger.debug(`[ResponseCache] Cached: ${config.url}`, { key, maxAge, etag: etag ? 'present' : 'none', }); } /** * Invalidate cache for a specific URL pattern */ invalidate(pattern: string | RegExp): number { let invalidated = 0; for (const key of this.cache.keys()) { const shouldInvalidate = typeof pattern === 'string' ? key.includes(pattern) : pattern.test(key); if (shouldInvalidate) { this.cache.delete(key); invalidated++; } } if (invalidated > 0) { logger.info( `[ResponseCache] Invalidated ${invalidated} cache entries for pattern: ${pattern}`, ); } return invalidated; } /** * Clear all cache */ clear(): void { const size = this.cache.size; this.cache.clear(); logger.info(`[ResponseCache] Cache cleared (${size} entries)`); } /** * Get cache statistics */ getStats(): { size: number; maxSize: number; entries: Array<{ key: string; age: number; maxAge?: number }>; } { const entries = Array.from(this.cache.entries()).map(([key, cached]) => ({ key, age: Date.now() - cached.timestamp, maxAge: cached.maxAge, })); return { size: this.cache.size, maxSize: this.maxSize, entries, }; } /** * Clean up expired cache entries */ cleanup(): number { const now = Date.now(); let removed = 0; for (const [key, cached] of this.cache.entries()) { const age = now - cached.timestamp; const maxAgeMs = (cached.maxAge || Math.floor(this.defaultTTL / 1000)) * 1000; if (age > maxAgeMs) { this.cache.delete(key); removed++; } } if (removed > 0) { logger.debug( `[ResponseCache] Cleaned up ${removed} expired cache entries`, ); } return removed; } } // Singleton instance export const responseCache = new ResponseCacheService({ defaultTTL: 5 * 60 * 1000, // 5 minutes maxSize: 100, respectCacheControl: true, enableETag: true, }); // Periodic cleanup (every minute) if (typeof window !== 'undefined') { setInterval(() => { responseCache.cleanup(); }, 60 * 1000); }