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:
		
							
								
								
									
										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