378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			378 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * 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(); | ||
|  |   } | ||
|  | } |