veza/apps/web/src/services/responseCache.ts

377 lines
9.4 KiB
TypeScript
Raw Normal View History

/**
* 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);
}