feat: Implement comprehensive web request handling with caching, retry, and interceptors
- Added cache strategies: NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly. - Introduced InterceptorManager for managing request, response, and error interceptors. - Developed RetryManager for handling request retries with customizable backoff strategies. - Implemented RequestDeduplicator to prevent simultaneous identical requests. - Created timeout utilities for handling request timeouts. - Enhanced WebrequestClient to support global interceptors, caching, and retry logic. - Added convenience methods for common HTTP methods (GET, POST, PUT, DELETE) with JSON handling. - Established a fetch-compatible webrequest function for seamless integration. - Defined core type structures for caching, retry options, interceptors, and web request configurations.
This commit is contained in:
		
							
								
								
									
										172
									
								
								ts/cache/cache.headers.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								ts/cache/cache.headers.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| /** | ||||
|  * 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; | ||||
| } | ||||
							
								
								
									
										156
									
								
								ts/cache/cache.manager.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								ts/cache/cache.manager.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | ||||
| /** | ||||
|  * Cache manager - orchestrates caching logic | ||||
|  */ | ||||
|  | ||||
| import type { | ||||
|   ICacheOptions, | ||||
|   TCacheStrategy, | ||||
|   TStandardCacheMode, | ||||
| } from '../webrequest.types.js'; | ||||
| import { CacheStore } from './cache.store.js'; | ||||
| import { | ||||
|   getStrategyHandler, | ||||
|   type IStrategyContext, | ||||
|   type IStrategyResult, | ||||
| } from './cache.strategies.js'; | ||||
| import { extractCacheMetadata } from './cache.headers.js'; | ||||
|  | ||||
| export class CacheManager { | ||||
|   private cacheStore: CacheStore; | ||||
|  | ||||
|   constructor(dbName?: string, storeName?: string) { | ||||
|     this.cacheStore = new CacheStore(dbName, storeName); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a request with caching | ||||
|    */ | ||||
|   public async execute( | ||||
|     request: Request, | ||||
|     options: ICacheOptions & { logging?: boolean }, | ||||
|     fetchFn: (request: Request) => Promise<Response>, | ||||
|   ): Promise<IStrategyResult> { | ||||
|     // Determine the cache strategy | ||||
|     const strategy = this.determineStrategy(request, options); | ||||
|  | ||||
|     // If no caching (no-store or network-only), bypass cache | ||||
|     if (strategy === 'network-only') { | ||||
|       const response = await fetchFn(request); | ||||
|       return { | ||||
|         response, | ||||
|         fromCache: false, | ||||
|         revalidated: false, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Generate cache key | ||||
|     const cacheKey = this.generateCacheKey(request, options); | ||||
|  | ||||
|     // Get strategy handler | ||||
|     const handler = getStrategyHandler(strategy); | ||||
|  | ||||
|     // Execute strategy | ||||
|     const context: IStrategyContext = { | ||||
|       request, | ||||
|       cacheKey, | ||||
|       cacheStore: this.cacheStore, | ||||
|       fetchFn, | ||||
|       logging: options.logging, | ||||
|     }; | ||||
|  | ||||
|     return await handler.execute(context); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Determine the caching strategy based on options and request | ||||
|    */ | ||||
|   private determineStrategy( | ||||
|     request: Request, | ||||
|     options: ICacheOptions, | ||||
|   ): TCacheStrategy { | ||||
|     // If explicit strategy provided, use it | ||||
|     if (options.cacheStrategy) { | ||||
|       return options.cacheStrategy; | ||||
|     } | ||||
|  | ||||
|     // Map standard cache modes to strategies | ||||
|     if (options.cache) { | ||||
|       return this.mapCacheModeToStrategy(options.cache); | ||||
|     } | ||||
|  | ||||
|     // Check request cache mode | ||||
|     if (request.cache) { | ||||
|       return this.mapCacheModeToStrategy(request.cache as TStandardCacheMode); | ||||
|     } | ||||
|  | ||||
|     // Default strategy | ||||
|     return 'network-first'; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Map standard fetch cache modes to our strategies | ||||
|    */ | ||||
|   private mapCacheModeToStrategy( | ||||
|     cacheMode: TStandardCacheMode, | ||||
|   ): TCacheStrategy { | ||||
|     switch (cacheMode) { | ||||
|       case 'default': | ||||
|         return 'network-first'; | ||||
|       case 'no-store': | ||||
|       case 'reload': | ||||
|         return 'network-only'; | ||||
|       case 'no-cache': | ||||
|         return 'network-first'; // Will use revalidation | ||||
|       case 'force-cache': | ||||
|         return 'cache-first'; | ||||
|       case 'only-if-cached': | ||||
|         return 'cache-only'; | ||||
|       default: | ||||
|         return 'network-first'; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate cache key | ||||
|    */ | ||||
|   private generateCacheKey(request: Request, options: ICacheOptions): string { | ||||
|     // If custom cache key provided | ||||
|     if (options.cacheKey) { | ||||
|       if (typeof options.cacheKey === 'function') { | ||||
|         return options.cacheKey(request); | ||||
|       } | ||||
|       return options.cacheKey; | ||||
|     } | ||||
|  | ||||
|     // Default cache key generation | ||||
|     return this.cacheStore.generateCacheKey(request); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear the cache | ||||
|    */ | ||||
|   public async clear(): Promise<void> { | ||||
|     await this.cacheStore.clear(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete a specific cache entry | ||||
|    */ | ||||
|   public async delete(cacheKey: string): Promise<void> { | ||||
|     await this.cacheStore.delete(cacheKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a cache entry exists | ||||
|    */ | ||||
|   public async has(cacheKey: string): Promise<boolean> { | ||||
|     return await this.cacheStore.has(cacheKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the underlying cache store | ||||
|    */ | ||||
|   public getStore(): CacheStore { | ||||
|     return this.cacheStore; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										154
									
								
								ts/cache/cache.store.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								ts/cache/cache.store.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| /** | ||||
|  * Cache storage layer using IndexedDB via @push.rocks/webstore | ||||
|  */ | ||||
|  | ||||
| import * as plugins from '../webrequest.plugins.js'; | ||||
| import type { ICacheEntry } from '../webrequest.types.js'; | ||||
|  | ||||
| export class CacheStore { | ||||
|   private webstore: plugins.webstore.WebStore; | ||||
|   private initPromise: Promise<void>; | ||||
|  | ||||
|   constructor(dbName: string = 'webrequest-v4', storeName: string = 'cache') { | ||||
|     this.webstore = new plugins.webstore.WebStore({ | ||||
|       dbName, | ||||
|       storeName, | ||||
|     }); | ||||
|  | ||||
|     // Initialize the store | ||||
|     this.initPromise = this.init(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initialize the store | ||||
|    */ | ||||
|   private async init(): Promise<void> { | ||||
|     // WebStore handles initialization internally | ||||
|     // This method exists for future extension if needed | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate a cache key from a request | ||||
|    */ | ||||
|   public generateCacheKey(request: Request): string { | ||||
|     // Use URL + method as the base key | ||||
|     const url = request.url; | ||||
|     const method = request.method; | ||||
|  | ||||
|     // For GET requests, just use the URL | ||||
|     if (method === 'GET') { | ||||
|       return url; | ||||
|     } | ||||
|  | ||||
|     // For other methods, include the method | ||||
|     return `${method}:${url}`; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Store a response in the cache | ||||
|    */ | ||||
|   public async set(cacheKey: string, entry: ICacheEntry): Promise<void> { | ||||
|     await this.initPromise; | ||||
|     await this.webstore.set(cacheKey, entry); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Retrieve a cached response | ||||
|    */ | ||||
|   public async get(cacheKey: string): Promise<ICacheEntry | null> { | ||||
|     await this.initPromise; | ||||
|  | ||||
|     try { | ||||
|       const entry = (await this.webstore.get(cacheKey)) as ICacheEntry; | ||||
|       return entry || null; | ||||
|     } catch (error) { | ||||
|       // If entry doesn't exist or is corrupted, return null | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a cache entry exists | ||||
|    */ | ||||
|   public async has(cacheKey: string): Promise<boolean> { | ||||
|     await this.initPromise; | ||||
|     return await this.webstore.check(cacheKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delete a cache entry | ||||
|    */ | ||||
|   public async delete(cacheKey: string): Promise<void> { | ||||
|     await this.initPromise; | ||||
|     await this.webstore.delete(cacheKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear all cache entries | ||||
|    */ | ||||
|   public async clear(): Promise<void> { | ||||
|     await this.initPromise; | ||||
|     await this.webstore.clear(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create a Response object from a cache entry | ||||
|    */ | ||||
|   public responseFromCacheEntry(entry: ICacheEntry): Response { | ||||
|     const headers = new Headers(entry.headers); | ||||
|  | ||||
|     return new Response(entry.response, { | ||||
|       status: entry.status, | ||||
|       statusText: entry.statusText, | ||||
|       headers, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Create a cache entry from a Response object | ||||
|    */ | ||||
|   public async cacheEntryFromResponse( | ||||
|     url: string, | ||||
|     response: Response, | ||||
|     metadata?: { maxAge?: number; etag?: string; lastModified?: string }, | ||||
|   ): Promise<ICacheEntry> { | ||||
|     // Clone the response so we can read it multiple times | ||||
|     const clonedResponse = response.clone(); | ||||
|     const buffer = await clonedResponse.arrayBuffer(); | ||||
|  | ||||
|     // Extract headers | ||||
|     const headers: Record<string, string> = {}; | ||||
|     clonedResponse.headers.forEach((value, key) => { | ||||
|       headers[key] = value; | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|       response: buffer, | ||||
|       headers, | ||||
|       timestamp: Date.now(), | ||||
|       etag: metadata?.etag || clonedResponse.headers.get('etag') || undefined, | ||||
|       lastModified: | ||||
|         metadata?.lastModified || | ||||
|         clonedResponse.headers.get('last-modified') || | ||||
|         undefined, | ||||
|       maxAge: metadata?.maxAge, | ||||
|       url, | ||||
|       status: clonedResponse.status, | ||||
|       statusText: clonedResponse.statusText, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Prune expired entries (garbage collection) | ||||
|    * Returns the number of entries deleted | ||||
|    */ | ||||
|   public async pruneExpired(): Promise<number> { | ||||
|     await this.initPromise; | ||||
|  | ||||
|     // Note: WebStore doesn't provide a way to list all keys | ||||
|     // This would need to be implemented if we want automatic cleanup | ||||
|     // For now, we rely on individual entry checks | ||||
|  | ||||
|     return 0; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										377
									
								
								ts/cache/cache.strategies.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										377
									
								
								ts/cache/cache.strategies.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,377 @@ | ||||
| /** | ||||
|  * Cache strategy implementations | ||||
|  */ | ||||
|  | ||||
| import type { | ||||
|   ICacheEntry, | ||||
|   ICacheMetadata, | ||||
|   TCacheStrategy, | ||||
| } from '../webrequest.types.js'; | ||||
| import { CacheStore } from './cache.store.js'; | ||||
| import { | ||||
|   extractCacheMetadata, | ||||
|   isFresh, | ||||
|   requiresRevalidation, | ||||
|   createConditionalHeaders, | ||||
|   headersToObject, | ||||
| } from './cache.headers.js'; | ||||
|  | ||||
| export interface IStrategyContext { | ||||
|   request: Request; | ||||
|   cacheKey: string; | ||||
|   cacheStore: CacheStore; | ||||
|   fetchFn: (request: Request) => Promise<Response>; | ||||
|   logging?: boolean; | ||||
| } | ||||
|  | ||||
| export interface IStrategyResult { | ||||
|   response: Response; | ||||
|   fromCache: boolean; | ||||
|   revalidated: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Base strategy handler interface | ||||
|  */ | ||||
| export interface ICacheStrategyHandler { | ||||
|   execute(context: IStrategyContext): Promise<IStrategyResult>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Network-First Strategy | ||||
|  * Try network first, fallback to cache on failure | ||||
|  */ | ||||
| export class NetworkFirstStrategy implements ICacheStrategyHandler { | ||||
|   async execute(context: IStrategyContext): Promise<IStrategyResult> { | ||||
|     try { | ||||
|       // Try network first | ||||
|       const response = await context.fetchFn(context.request); | ||||
|  | ||||
|       // If successful, cache it | ||||
|       if (response.ok) { | ||||
|         await this.cacheResponse(context, response); | ||||
|       } | ||||
|  | ||||
|       return { | ||||
|         response, | ||||
|         fromCache: false, | ||||
|         revalidated: false, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       // Network failed, try cache | ||||
|       if (context.logging) { | ||||
|         console.log('[webrequest] Network failed, trying cache:', error); | ||||
|       } | ||||
|  | ||||
|       const cachedEntry = await context.cacheStore.get(context.cacheKey); | ||||
|       if (cachedEntry) { | ||||
|         return { | ||||
|           response: context.cacheStore.responseFromCacheEntry(cachedEntry), | ||||
|           fromCache: true, | ||||
|           revalidated: false, | ||||
|         }; | ||||
|       } | ||||
|  | ||||
|       // No cache available, re-throw error | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async cacheResponse( | ||||
|     context: IStrategyContext, | ||||
|     response: Response, | ||||
|   ): Promise<void> { | ||||
|     const metadata = extractCacheMetadata(response.headers); | ||||
|  | ||||
|     // Don't cache if no-store | ||||
|     if (metadata.noStore) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const entry = await context.cacheStore.cacheEntryFromResponse( | ||||
|       context.request.url, | ||||
|       response, | ||||
|       metadata, | ||||
|     ); | ||||
|     await context.cacheStore.set(context.cacheKey, entry); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Cache-First Strategy | ||||
|  * Check cache first, fetch if miss or stale | ||||
|  */ | ||||
| export class CacheFirstStrategy implements ICacheStrategyHandler { | ||||
|   async execute(context: IStrategyContext): Promise<IStrategyResult> { | ||||
|     // Check cache first | ||||
|     const cachedEntry = await context.cacheStore.get(context.cacheKey); | ||||
|  | ||||
|     if (cachedEntry) { | ||||
|       const metadata = extractCacheMetadata(new Headers(cachedEntry.headers)); | ||||
|  | ||||
|       // Check if cache is fresh | ||||
|       if (isFresh(cachedEntry, metadata)) { | ||||
|         if (context.logging) { | ||||
|           console.log('[webrequest] Cache hit (fresh):', context.request.url); | ||||
|         } | ||||
|  | ||||
|         return { | ||||
|           response: context.cacheStore.responseFromCacheEntry(cachedEntry), | ||||
|           fromCache: true, | ||||
|           revalidated: false, | ||||
|         }; | ||||
|       } | ||||
|  | ||||
|       // If requires revalidation, check with server | ||||
|       if ( | ||||
|         requiresRevalidation(metadata) && | ||||
|         (cachedEntry.etag || cachedEntry.lastModified) | ||||
|       ) { | ||||
|         return await this.revalidate(context, cachedEntry); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Cache miss or stale, fetch from network | ||||
|     if (context.logging) { | ||||
|       console.log('[webrequest] Cache miss, fetching:', context.request.url); | ||||
|     } | ||||
|  | ||||
|     const response = await context.fetchFn(context.request); | ||||
|  | ||||
|     // Cache the response | ||||
|     const metadata = extractCacheMetadata(response.headers); | ||||
|     if (!metadata.noStore) { | ||||
|       const entry = await context.cacheStore.cacheEntryFromResponse( | ||||
|         context.request.url, | ||||
|         response, | ||||
|         metadata, | ||||
|       ); | ||||
|       await context.cacheStore.set(context.cacheKey, entry); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       response, | ||||
|       fromCache: false, | ||||
|       revalidated: false, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   private async revalidate( | ||||
|     context: IStrategyContext, | ||||
|     cachedEntry: ICacheEntry, | ||||
|   ): Promise<IStrategyResult> { | ||||
|     const conditionalHeaders = createConditionalHeaders(cachedEntry); | ||||
|  | ||||
|     // Create a new request with conditional headers | ||||
|     const revalidateRequest = new Request(context.request.url, { | ||||
|       method: context.request.method, | ||||
|       headers: { | ||||
|         ...headersToObject(context.request.headers), | ||||
|         ...conditionalHeaders, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     try { | ||||
|       const response = await context.fetchFn(revalidateRequest); | ||||
|  | ||||
|       // 304 Not Modified - cache is still valid | ||||
|       if (response.status === 304) { | ||||
|         if (context.logging) { | ||||
|           console.log( | ||||
|             '[webrequest] Cache revalidated (304):', | ||||
|             context.request.url, | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         // Update timestamp | ||||
|         cachedEntry.timestamp = Date.now(); | ||||
|         await context.cacheStore.set(context.cacheKey, cachedEntry); | ||||
|  | ||||
|         return { | ||||
|           response: context.cacheStore.responseFromCacheEntry(cachedEntry), | ||||
|           fromCache: true, | ||||
|           revalidated: true, | ||||
|         }; | ||||
|       } | ||||
|  | ||||
|       // Response changed, cache the new one | ||||
|       if (response.ok) { | ||||
|         const metadata = extractCacheMetadata(response.headers); | ||||
|         if (!metadata.noStore) { | ||||
|           const entry = await context.cacheStore.cacheEntryFromResponse( | ||||
|             context.request.url, | ||||
|             response, | ||||
|             metadata, | ||||
|           ); | ||||
|           await context.cacheStore.set(context.cacheKey, entry); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return { | ||||
|         response, | ||||
|         fromCache: false, | ||||
|         revalidated: true, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       // Revalidation failed, use cached response | ||||
|       if (context.logging) { | ||||
|         console.log('[webrequest] Revalidation failed, using cache:', error); | ||||
|       } | ||||
|  | ||||
|       return { | ||||
|         response: context.cacheStore.responseFromCacheEntry(cachedEntry), | ||||
|         fromCache: true, | ||||
|         revalidated: false, | ||||
|       }; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Stale-While-Revalidate Strategy | ||||
|  * Return cache immediately, update in background | ||||
|  */ | ||||
| export class StaleWhileRevalidateStrategy implements ICacheStrategyHandler { | ||||
|   async execute(context: IStrategyContext): Promise<IStrategyResult> { | ||||
|     const cachedEntry = await context.cacheStore.get(context.cacheKey); | ||||
|  | ||||
|     if (cachedEntry) { | ||||
|       // Return cached response immediately | ||||
|       const cachedResponse = | ||||
|         context.cacheStore.responseFromCacheEntry(cachedEntry); | ||||
|  | ||||
|       // Revalidate in background | ||||
|       this.revalidateInBackground(context, cachedEntry).catch((error) => { | ||||
|         if (context.logging) { | ||||
|           console.warn('[webrequest] Background revalidation failed:', error); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       return { | ||||
|         response: cachedResponse, | ||||
|         fromCache: true, | ||||
|         revalidated: false, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // No cache, fetch from network | ||||
|     const response = await context.fetchFn(context.request); | ||||
|  | ||||
|     // Cache the response | ||||
|     const metadata = extractCacheMetadata(response.headers); | ||||
|     if (!metadata.noStore && response.ok) { | ||||
|       const entry = await context.cacheStore.cacheEntryFromResponse( | ||||
|         context.request.url, | ||||
|         response, | ||||
|         metadata, | ||||
|       ); | ||||
|       await context.cacheStore.set(context.cacheKey, entry); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       response, | ||||
|       fromCache: false, | ||||
|       revalidated: false, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   private async revalidateInBackground( | ||||
|     context: IStrategyContext, | ||||
|     cachedEntry: ICacheEntry, | ||||
|   ): Promise<void> { | ||||
|     const metadata = extractCacheMetadata(new Headers(cachedEntry.headers)); | ||||
|  | ||||
|     // Check if revalidation is needed | ||||
|     if (isFresh(cachedEntry, metadata) && !requiresRevalidation(metadata)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const response = await context.fetchFn(context.request); | ||||
|  | ||||
|       if (response.ok) { | ||||
|         const newMetadata = extractCacheMetadata(response.headers); | ||||
|         if (!newMetadata.noStore) { | ||||
|           const entry = await context.cacheStore.cacheEntryFromResponse( | ||||
|             context.request.url, | ||||
|             response, | ||||
|             newMetadata, | ||||
|           ); | ||||
|           await context.cacheStore.set(context.cacheKey, entry); | ||||
|  | ||||
|           if (context.logging) { | ||||
|             console.log( | ||||
|               '[webrequest] Background revalidation complete:', | ||||
|               context.request.url, | ||||
|             ); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Background revalidation failed, keep existing cache | ||||
|       if (context.logging) { | ||||
|         console.warn('[webrequest] Background revalidation failed:', error); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Network-Only Strategy | ||||
|  * Never use cache | ||||
|  */ | ||||
| export class NetworkOnlyStrategy implements ICacheStrategyHandler { | ||||
|   async execute(context: IStrategyContext): Promise<IStrategyResult> { | ||||
|     const response = await context.fetchFn(context.request); | ||||
|  | ||||
|     return { | ||||
|       response, | ||||
|       fromCache: false, | ||||
|       revalidated: false, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Cache-Only Strategy | ||||
|  * Only use cache, fail if miss | ||||
|  */ | ||||
| export class CacheOnlyStrategy implements ICacheStrategyHandler { | ||||
|   async execute(context: IStrategyContext): Promise<IStrategyResult> { | ||||
|     const cachedEntry = await context.cacheStore.get(context.cacheKey); | ||||
|  | ||||
|     if (!cachedEntry) { | ||||
|       throw new Error( | ||||
|         `Cache miss for ${context.request.url} (cache-only mode)`, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       response: context.cacheStore.responseFromCacheEntry(cachedEntry), | ||||
|       fromCache: true, | ||||
|       revalidated: false, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get strategy handler for a given strategy type | ||||
|  */ | ||||
| export function getStrategyHandler( | ||||
|   strategy: TCacheStrategy, | ||||
| ): ICacheStrategyHandler { | ||||
|   switch (strategy) { | ||||
|     case 'network-first': | ||||
|       return new NetworkFirstStrategy(); | ||||
|     case 'cache-first': | ||||
|       return new CacheFirstStrategy(); | ||||
|     case 'stale-while-revalidate': | ||||
|       return new StaleWhileRevalidateStrategy(); | ||||
|     case 'network-only': | ||||
|       return new NetworkOnlyStrategy(); | ||||
|     case 'cache-only': | ||||
|       return new CacheOnlyStrategy(); | ||||
|     default: | ||||
|       return new NetworkFirstStrategy(); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user