376 lines
9.4 KiB
TypeScript
376 lines
9.4 KiB
TypeScript
/**
|
|
* 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<T = any> {
|
|
data: T;
|
|
headers: Record<string, string>;
|
|
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<string, CachedResponse> = 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<string, string | boolean> = {};
|
|
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<T = any>(config: AxiosRequestConfig): AxiosResponse<T> | 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<T> = {
|
|
data: cached.data as T,
|
|
status: cached.status,
|
|
statusText: cached.statusText,
|
|
headers: cached.headers as any,
|
|
config: config as InternalAxiosRequestConfig<any>,
|
|
request: {},
|
|
};
|
|
|
|
logger.debug(`[ResponseCache] Cache hit: ${config.url}`, {
|
|
key,
|
|
age: Date.now() - cached.timestamp,
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Store response in cache
|
|
*/
|
|
set<T = any>(config: AxiosRequestConfig, response: AxiosResponse<T>): 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<string, string>,
|
|
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);
|
|
}
|