173 lines
		
	
	
		
			4.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			173 lines
		
	
	
		
			4.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * 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<ICacheMetadata> { | ||
|  |   const metadata: Partial<ICacheMetadata> = { | ||
|  |     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<string, string> = {}; | ||
|  | 
 | ||
|  |   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<string, string> { | ||
|  |   const obj: Record<string, string> = {}; | ||
|  |   headers.forEach((value, key) => { | ||
|  |     obj[key] = value; | ||
|  |   }); | ||
|  |   return obj; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Convert plain object back to Headers | ||
|  |  */ | ||
|  | export function objectToHeaders(obj: Record<string, string>): Headers { | ||
|  |   const headers = new Headers(); | ||
|  |   Object.entries(obj).forEach(([key, value]) => { | ||
|  |     headers.set(key, value); | ||
|  |   }); | ||
|  |   return headers; | ||
|  | } |