- 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.
		
			
				
	
	
		
			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();
 | |
|   }
 | |
| }
 |