/** * HTTP Cache Header parsing and utilities * Implements RFC 7234 (HTTP Caching) */ import type { ICacheMetadata } from '../webrequest.types.js'; /** * Parse Cache-Control header into metadata */ export function parseCacheControl( cacheControlHeader: string | null, ): Partial { const metadata: Partial = { maxAge: 0, immutable: false, noCache: false, noStore: false, mustRevalidate: false, }; if (!cacheControlHeader) { return metadata; } const directives = cacheControlHeader .toLowerCase() .split(',') .map((d) => d.trim()); for (const directive of directives) { if (directive === 'no-cache') { metadata.noCache = true; } else if (directive === 'no-store') { metadata.noStore = true; } else if (directive === 'immutable') { metadata.immutable = true; } else if (directive === 'must-revalidate') { metadata.mustRevalidate = true; } else if (directive.startsWith('max-age=')) { const maxAge = parseInt(directive.split('=')[1], 10); if (!isNaN(maxAge)) { metadata.maxAge = maxAge * 1000; // Convert to milliseconds } } } return metadata; } /** * Parse Expires header into timestamp */ export function parseExpires(expiresHeader: string | null): number | undefined { if (!expiresHeader) { return undefined; } try { const date = new Date(expiresHeader); return date.getTime(); } catch { return undefined; } } /** * Extract cache metadata from response headers */ export function extractCacheMetadata(headers: Headers): ICacheMetadata { const cacheControl = headers.get('cache-control'); const expires = headers.get('expires'); const etag = headers.get('etag'); const lastModified = headers.get('last-modified'); const metadata = parseCacheControl(cacheControl); // If no max-age from Cache-Control, try Expires header if (metadata.maxAge === 0 && expires) { const expiresTime = parseExpires(expires); if (expiresTime) { metadata.maxAge = Math.max(0, expiresTime - Date.now()); } } return { maxAge: metadata.maxAge || 0, etag: etag || undefined, lastModified: lastModified || undefined, immutable: metadata.immutable || false, noCache: metadata.noCache || false, noStore: metadata.noStore || false, mustRevalidate: metadata.mustRevalidate || false, }; } /** * Check if a cached response is still fresh */ export function isFresh( cacheEntry: { timestamp: number; maxAge?: number }, metadata: ICacheMetadata, ): boolean { // no-store means never cache if (metadata.noStore) { return false; } // If immutable, it's always fresh if (metadata.immutable) { return true; } const age = Date.now() - cacheEntry.timestamp; const maxAge = cacheEntry.maxAge || metadata.maxAge || 0; // If no max-age specified, consider stale if (maxAge === 0) { return false; } return age < maxAge; } /** * Check if revalidation is required */ export function requiresRevalidation(metadata: ICacheMetadata): boolean { return metadata.noCache || metadata.mustRevalidate; } /** * Create conditional request headers for revalidation */ export function createConditionalHeaders(cacheEntry: { etag?: string; lastModified?: string; }): HeadersInit { const headers: Record = {}; if (cacheEntry.etag) { headers['if-none-match'] = cacheEntry.etag; } if (cacheEntry.lastModified) { headers['if-modified-since'] = cacheEntry.lastModified; } return headers; } /** * Convert Headers object to plain object for storage */ export function headersToObject(headers: Headers): Record { const obj: Record = {}; headers.forEach((value, key) => { obj[key] = value; }); return obj; } /** * Convert plain object back to Headers */ export function objectToHeaders(obj: Record): Headers { const headers = new Headers(); Object.entries(obj).forEach(([key, value]) => { headers.set(key, value); }); return headers; }