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:
		| @@ -4,5 +4,6 @@ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/webrequest', | ||||
|   version: '3.0.37', | ||||
|   description: 'A module for making secure web requests from browsers with support for caching and fault tolerance.' | ||||
| } | ||||
|   description: | ||||
|     'A module for making secure web requests from browsers with support for caching and fault tolerance.', | ||||
| }; | ||||
|   | ||||
							
								
								
									
										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(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										253
									
								
								ts/index.ts
									
									
									
									
									
								
							
							
						
						
									
										253
									
								
								ts/index.ts
									
									
									
									
									
								
							| @@ -1,220 +1,47 @@ | ||||
| import * as plugins from './webrequest.plugins.js'; | ||||
|  | ||||
| export interface IWebrequestContructorOptions { | ||||
|   logging?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * web request | ||||
|  * @push.rocks/webrequest v4 | ||||
|  * Modern, fetch-compatible web request library with intelligent caching | ||||
|  */ | ||||
| export class WebRequest { | ||||
|  | ||||
|   public cacheStore = new plugins.webstore.WebStore({ | ||||
|     dbName: 'webrequest', | ||||
|     storeName: 'webrequest', | ||||
|   }); | ||||
| // Main exports | ||||
| export { webrequest } from './webrequest.function.js'; | ||||
| export { WebrequestClient } from './webrequest.client.js'; | ||||
|  | ||||
|   public options: IWebrequestContructorOptions; | ||||
| // Type exports | ||||
| export type { | ||||
|   IWebrequestOptions, | ||||
|   ICacheOptions, | ||||
|   IRetryOptions, | ||||
|   IInterceptors, | ||||
|   TCacheStrategy, | ||||
|   TStandardCacheMode, | ||||
|   TBackoffStrategy, | ||||
|   TWebrequestResult, | ||||
|   IWebrequestSuccess, | ||||
|   IWebrequestError, | ||||
|   ICacheEntry, | ||||
|   ICacheMetadata, | ||||
| } from './webrequest.types.js'; | ||||
|  | ||||
|   constructor(public optionsArg: IWebrequestContructorOptions = {}) { | ||||
|     this.options = { | ||||
|       logging: true, | ||||
|       ...optionsArg, | ||||
|     }; | ||||
|   } | ||||
| export type { | ||||
|   TRequestInterceptor, | ||||
|   TResponseInterceptor, | ||||
|   TErrorInterceptor, | ||||
| } from './interceptors/interceptor.types.js'; | ||||
|  | ||||
|   public async getJson(urlArg: string, useCacheArg: boolean = false) { | ||||
|     const response: Response = await this.request(urlArg, { | ||||
|       method: 'GET', | ||||
|       useCache: useCacheArg, | ||||
|     }); | ||||
|     const responseText = await response.text(); | ||||
|     const responseResult = plugins.smartjson.parse(responseText); | ||||
|     return responseResult; | ||||
|   } | ||||
| // Advanced exports for custom implementations | ||||
| export { CacheManager } from './cache/cache.manager.js'; | ||||
| export { CacheStore } from './cache/cache.store.js'; | ||||
| export { RetryManager } from './retry/retry.manager.js'; | ||||
| export { InterceptorManager } from './interceptors/interceptor.manager.js'; | ||||
| export { RequestDeduplicator } from './utils/deduplicator.js'; | ||||
|  | ||||
|   /** | ||||
|    * postJson | ||||
|    */ | ||||
|   public async postJson(urlArg: string, requestBody?: any, useCacheArg: boolean = false) { | ||||
|     const response: Response = await this.request(urlArg, { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       body: plugins.smartjson.stringify(requestBody), | ||||
|       useCache: useCacheArg, | ||||
|     }); | ||||
|     const responseText = await response.text(); | ||||
|     const responseResult = plugins.smartjson.parse(responseText); | ||||
|     return responseResult; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * put js | ||||
|    */ | ||||
|   public async putJson(urlArg: string, requestBody?: any, useStoreAsFallback: boolean = false) { | ||||
|     const response: Response = await this.request(urlArg, { | ||||
|       method: 'PUT', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       body: plugins.smartjson.stringify(requestBody), | ||||
|     }); | ||||
|     const responseText = await response.text(); | ||||
|     const responseResult = plugins.smartjson.parse(responseText); | ||||
|     return responseResult; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * put js | ||||
|    */ | ||||
|   public async deleteJson(urlArg: string, useStoreAsFallback: boolean = false) { | ||||
|     const response: Response = await this.request(urlArg, { | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       method: 'GET', | ||||
|     }); | ||||
|     const responseText = await response.text(); | ||||
|     const responseResult = plugins.smartjson.parse(responseText); | ||||
|     return responseResult; | ||||
|   } | ||||
|  | ||||
|   public async request( | ||||
|     urlArg: string, | ||||
|     optionsArg: { | ||||
|       method: 'GET' | 'POST' | 'PUT' | 'DELETE'; | ||||
|       body?: any; | ||||
|       headers?: HeadersInit; | ||||
|       useCache?: boolean; | ||||
|       timeoutMs?: number; | ||||
|     } | ||||
|   ) { | ||||
|     optionsArg = { | ||||
|       timeoutMs: 60000, | ||||
|       useCache: false, | ||||
|       ...optionsArg, | ||||
|     }; | ||||
|  | ||||
|     let controller = new AbortController(); | ||||
|     plugins.smartdelay.delayFor(optionsArg.timeoutMs).then(() => { | ||||
|       controller.abort(); | ||||
|     }); | ||||
|     let cachedResponseDeferred = plugins.smartpromise.defer<Response>(); | ||||
|     let cacheUsed = false; | ||||
|     if (optionsArg.useCache && (await this.cacheStore.check(urlArg))) { | ||||
|       const responseBuffer: ArrayBuffer = await this.cacheStore.get(urlArg); | ||||
|       cachedResponseDeferred.resolve(new Response(responseBuffer, {})); | ||||
|     } else { | ||||
|       cachedResponseDeferred.resolve(null); | ||||
|     } | ||||
|     let response: Response = await fetch(urlArg, { | ||||
|         signal: controller.signal, | ||||
|         method: optionsArg.method, | ||||
|         headers: { | ||||
|           ...(optionsArg.headers || {}), | ||||
|         }, | ||||
|         body: optionsArg.body, | ||||
|       }) | ||||
|       .catch(async (err) => { | ||||
|         if (optionsArg.useCache && (await cachedResponseDeferred.promise)) { | ||||
|           cacheUsed = true; | ||||
|           const cachedResponse = cachedResponseDeferred.promise; | ||||
|           return cachedResponse; | ||||
|         } else { | ||||
|           return err; | ||||
|         } | ||||
|       }); | ||||
|     if (optionsArg.useCache && (await cachedResponseDeferred.promise) && response.status === 500) { | ||||
|       cacheUsed = true; | ||||
|       response = await cachedResponseDeferred.promise; | ||||
|     } | ||||
|     if (!cacheUsed && optionsArg.useCache && response.status < 300) { | ||||
|       const buffer = await response.clone().arrayBuffer(); | ||||
|       await this.cacheStore.set(urlArg, buffer); | ||||
|     } | ||||
|     this.log(`${urlArg} answers with status: ${response.status}`); | ||||
|     return response; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * a multi endpoint, fault tolerant request function | ||||
|    */ | ||||
|   public async requestMultiEndpoint( | ||||
|     urlArg: string | string[], | ||||
|     optionsArg: { | ||||
|       method: 'GET' | 'POST' | 'PUT' | 'DELETE'; | ||||
|       body?: any; | ||||
|       headers?: HeadersInit; | ||||
|     } | ||||
|   ): Promise<Response> { | ||||
|      | ||||
|     let allUrls: string[]; | ||||
|     let usedUrlIndex = 0; | ||||
|  | ||||
|     // determine what we got | ||||
|     if (Array.isArray(urlArg)) { | ||||
|       allUrls = urlArg; | ||||
|     } else { | ||||
|       allUrls = [urlArg]; | ||||
|     } | ||||
|  | ||||
|     const requestHistory: string[] = []; // keep track of the request history | ||||
|  | ||||
|     const doHistoryCheck = async ( | ||||
|       // check history for a | ||||
|       historyEntryTypeArg: string | ||||
|     ) => { | ||||
|       requestHistory.push(historyEntryTypeArg); | ||||
|       if (historyEntryTypeArg === '429') { | ||||
|         console.log('got 429, so waiting a little bit.'); | ||||
|         await plugins.smartdelay.delayFor(Math.floor(Math.random() * (2000 - 1000 + 1)) + 1000); // wait between 1 and 10 seconds | ||||
|       } | ||||
|  | ||||
|       let numOfHistoryType = 0; | ||||
|       for (const entry of requestHistory) { | ||||
|         if (entry === historyEntryTypeArg) numOfHistoryType++; | ||||
|       } | ||||
|       if (numOfHistoryType > 2 * allUrls.length * usedUrlIndex) { | ||||
|         usedUrlIndex++; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // lets go recursive | ||||
|     const doRequest = async (urlToUse: string): Promise<any> => { | ||||
|       if (!urlToUse) { | ||||
|         throw new Error('request failed permanently'); | ||||
|       } | ||||
|       this.log(`Getting ${urlToUse} with method ${optionsArg.method}`); | ||||
|       const response = await fetch(urlToUse, { | ||||
|         method: optionsArg.method, | ||||
|         headers: { | ||||
|           'Content-Type': 'application/json', | ||||
|           ...(optionsArg.headers || {}), | ||||
|         }, | ||||
|         body: optionsArg.body, | ||||
|       }); | ||||
|       this.log(`${urlToUse} answers with status: ${response.status}`); | ||||
|  | ||||
|       if (response.status >= 200 && response.status < 300) { | ||||
|         return response; | ||||
|       } else { | ||||
|         // lets perform a history check to determine failed urls | ||||
|         await doHistoryCheck(response.status.toString()); | ||||
|         // lets fire the request | ||||
|         const result = await doRequest(allUrls[usedUrlIndex]); | ||||
|         return result; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const finalResponse: Response = await doRequest(allUrls[usedUrlIndex]); | ||||
|     return finalResponse; | ||||
|   } | ||||
|  | ||||
|   public log(logArg: string) { | ||||
|     if (this.options.logging) { | ||||
|       console.log(logArg); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| // Cache utilities | ||||
| export { | ||||
|   extractCacheMetadata, | ||||
|   isFresh, | ||||
|   requiresRevalidation, | ||||
|   createConditionalHeaders, | ||||
|   headersToObject, | ||||
|   objectToHeaders, | ||||
| } from './cache/cache.headers.js'; | ||||
|   | ||||
							
								
								
									
										149
									
								
								ts/interceptors/interceptor.manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								ts/interceptors/interceptor.manager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| /** | ||||
|  * Interceptor manager for request/response transformation | ||||
|  */ | ||||
|  | ||||
| import type { | ||||
|   TRequestInterceptor, | ||||
|   TResponseInterceptor, | ||||
|   TErrorInterceptor, | ||||
| } from './interceptor.types.js'; | ||||
|  | ||||
| export class InterceptorManager { | ||||
|   private requestInterceptors: TRequestInterceptor[] = []; | ||||
|   private responseInterceptors: TResponseInterceptor[] = []; | ||||
|   private errorInterceptors: TErrorInterceptor[] = []; | ||||
|  | ||||
|   /** | ||||
|    * Add a request interceptor | ||||
|    */ | ||||
|   public addRequestInterceptor(interceptor: TRequestInterceptor): void { | ||||
|     this.requestInterceptors.push(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a response interceptor | ||||
|    */ | ||||
|   public addResponseInterceptor(interceptor: TResponseInterceptor): void { | ||||
|     this.responseInterceptors.push(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add an error interceptor | ||||
|    */ | ||||
|   public addErrorInterceptor(interceptor: TErrorInterceptor): void { | ||||
|     this.errorInterceptors.push(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove a request interceptor | ||||
|    */ | ||||
|   public removeRequestInterceptor(interceptor: TRequestInterceptor): void { | ||||
|     const index = this.requestInterceptors.indexOf(interceptor); | ||||
|     if (index > -1) { | ||||
|       this.requestInterceptors.splice(index, 1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove a response interceptor | ||||
|    */ | ||||
|   public removeResponseInterceptor(interceptor: TResponseInterceptor): void { | ||||
|     const index = this.responseInterceptors.indexOf(interceptor); | ||||
|     if (index > -1) { | ||||
|       this.responseInterceptors.splice(index, 1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove an error interceptor | ||||
|    */ | ||||
|   public removeErrorInterceptor(interceptor: TErrorInterceptor): void { | ||||
|     const index = this.errorInterceptors.indexOf(interceptor); | ||||
|     if (index > -1) { | ||||
|       this.errorInterceptors.splice(index, 1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear all interceptors | ||||
|    */ | ||||
|   public clearAll(): void { | ||||
|     this.requestInterceptors = []; | ||||
|     this.responseInterceptors = []; | ||||
|     this.errorInterceptors = []; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process request through all request interceptors | ||||
|    */ | ||||
|   public async processRequest(request: Request): Promise<Request> { | ||||
|     let processedRequest = request; | ||||
|  | ||||
|     for (const interceptor of this.requestInterceptors) { | ||||
|       try { | ||||
|         processedRequest = await interceptor(processedRequest); | ||||
|       } catch (error) { | ||||
|         // If interceptor throws, process through error interceptors | ||||
|         throw await this.processError( | ||||
|           error instanceof Error ? error : new Error(String(error)), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return processedRequest; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process response through all response interceptors | ||||
|    */ | ||||
|   public async processResponse(response: Response): Promise<Response> { | ||||
|     let processedResponse = response; | ||||
|  | ||||
|     for (const interceptor of this.responseInterceptors) { | ||||
|       try { | ||||
|         processedResponse = await interceptor(processedResponse); | ||||
|       } catch (error) { | ||||
|         // If interceptor throws, process through error interceptors | ||||
|         throw await this.processError( | ||||
|           error instanceof Error ? error : new Error(String(error)), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return processedResponse; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process error through all error interceptors | ||||
|    */ | ||||
|   public async processError(error: Error): Promise<Error> { | ||||
|     let processedError = error; | ||||
|  | ||||
|     for (const interceptor of this.errorInterceptors) { | ||||
|       try { | ||||
|         processedError = await interceptor(processedError); | ||||
|       } catch (newError) { | ||||
|         // If error interceptor throws, use the new error | ||||
|         processedError = | ||||
|           newError instanceof Error ? newError : new Error(String(newError)); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return processedError; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get count of registered interceptors | ||||
|    */ | ||||
|   public getInterceptorCounts(): { | ||||
|     request: number; | ||||
|     response: number; | ||||
|     error: number; | ||||
|   } { | ||||
|     return { | ||||
|       request: this.requestInterceptors.length, | ||||
|       response: this.responseInterceptors.length, | ||||
|       error: this.errorInterceptors.length, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										31
									
								
								ts/interceptors/interceptor.types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								ts/interceptors/interceptor.types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| /** | ||||
|  * Interceptor type definitions | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Request interceptor | ||||
|  * Transforms the request before it's sent | ||||
|  */ | ||||
| export type TRequestInterceptor = ( | ||||
|   request: Request, | ||||
| ) => Request | Promise<Request>; | ||||
|  | ||||
| /** | ||||
|  * Response interceptor | ||||
|  * Transforms the response after it's received | ||||
|  */ | ||||
| export type TResponseInterceptor = ( | ||||
|   response: Response, | ||||
| ) => Response | Promise<Response>; | ||||
|  | ||||
| /** | ||||
|  * Error interceptor | ||||
|  * Handles errors during request/response processing | ||||
|  */ | ||||
| export type TErrorInterceptor = (error: Error) => Error | Promise<Error>; | ||||
|  | ||||
| export interface IInterceptors { | ||||
|   request?: TRequestInterceptor[]; | ||||
|   response?: TResponseInterceptor[]; | ||||
|   error?: TErrorInterceptor[]; | ||||
| } | ||||
							
								
								
									
										199
									
								
								ts/retry/retry.manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								ts/retry/retry.manager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,199 @@ | ||||
| /** | ||||
|  * Retry manager for handling request retries | ||||
|  */ | ||||
|  | ||||
| import * as plugins from '../webrequest.plugins.js'; | ||||
| import type { IRetryOptions } from '../webrequest.types.js'; | ||||
| import { getBackoffCalculator, addJitter } from './retry.strategies.js'; | ||||
|  | ||||
| export class RetryManager { | ||||
|   private options: Required<IRetryOptions>; | ||||
|  | ||||
|   constructor(options: IRetryOptions = {}) { | ||||
|     this.options = { | ||||
|       maxAttempts: options.maxAttempts ?? 3, | ||||
|       backoff: options.backoff ?? 'exponential', | ||||
|       initialDelay: options.initialDelay ?? 1000, | ||||
|       maxDelay: options.maxDelay ?? 30000, | ||||
|       retryOn: options.retryOn ?? [408, 429, 500, 502, 503, 504], | ||||
|       onRetry: options.onRetry ?? (() => {}), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a request with retry logic | ||||
|    */ | ||||
|   public async execute<T>( | ||||
|     executeFn: () => Promise<T>, | ||||
|     shouldRetryFn?: (error: any, attempt: number) => boolean, | ||||
|   ): Promise<T> { | ||||
|     let lastError: Error; | ||||
|     let lastResponse: Response | undefined; | ||||
|  | ||||
|     for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) { | ||||
|       try { | ||||
|         const result = await executeFn(); | ||||
|  | ||||
|         // Check if result is a Response and if we should retry based on status | ||||
|         if (result instanceof Response) { | ||||
|           if (this.shouldRetryResponse(result)) { | ||||
|             lastResponse = result; | ||||
|  | ||||
|             // If this is the last attempt, return the failed response | ||||
|             if (attempt === this.options.maxAttempts) { | ||||
|               return result; | ||||
|             } | ||||
|  | ||||
|             // Calculate delay and retry | ||||
|             const delay = this.calculateDelay(attempt); | ||||
|             this.options.onRetry( | ||||
|               attempt, | ||||
|               new Error(`HTTP ${result.status}`), | ||||
|               delay, | ||||
|             ); | ||||
|  | ||||
|             await this.delay(delay); | ||||
|             continue; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // Success | ||||
|         return result; | ||||
|       } catch (error) { | ||||
|         lastError = error instanceof Error ? error : new Error(String(error)); | ||||
|  | ||||
|         // Check if we should retry | ||||
|         const shouldRetry = shouldRetryFn | ||||
|           ? shouldRetryFn(error, attempt) | ||||
|           : this.shouldRetryError(error); | ||||
|  | ||||
|         // If this is the last attempt or we shouldn't retry, throw | ||||
|         if (attempt === this.options.maxAttempts || !shouldRetry) { | ||||
|           throw lastError; | ||||
|         } | ||||
|  | ||||
|         // Calculate delay and retry | ||||
|         const delay = this.calculateDelay(attempt); | ||||
|         this.options.onRetry(attempt, lastError, delay); | ||||
|  | ||||
|         await this.delay(delay); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // This should never be reached, but TypeScript needs it | ||||
|     throw lastError! || new Error('Max retry attempts reached'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute with multiple fallback URLs | ||||
|    */ | ||||
|   public async executeWithFallbacks( | ||||
|     urls: string[], | ||||
|     requestInit: RequestInit, | ||||
|     fetchFn: (url: string, init: RequestInit) => Promise<Response>, | ||||
|   ): Promise<Response> { | ||||
|     if (urls.length === 0) { | ||||
|       throw new Error('No URLs provided for fallback execution'); | ||||
|     } | ||||
|  | ||||
|     let lastError: Error | undefined; | ||||
|     const failedUrls: string[] = []; | ||||
|  | ||||
|     for (const url of urls) { | ||||
|       try { | ||||
|         // Try the URL with retry logic | ||||
|         const response = await this.execute(async () => { | ||||
|           return await fetchFn(url, requestInit); | ||||
|         }); | ||||
|  | ||||
|         // If successful (status < 400), return | ||||
|         if (response.status < 400) { | ||||
|           return response; | ||||
|         } | ||||
|  | ||||
|         // If 4xx client error (except 408 timeout), don't try other URLs | ||||
|         if ( | ||||
|           response.status >= 400 && | ||||
|           response.status < 500 && | ||||
|           response.status !== 408 | ||||
|         ) { | ||||
|           return response; | ||||
|         } | ||||
|  | ||||
|         // Server error or timeout, try next URL | ||||
|         failedUrls.push(url); | ||||
|         lastError = new Error(`Request failed with status ${response.status}`); | ||||
|       } catch (error) { | ||||
|         failedUrls.push(url); | ||||
|         lastError = error instanceof Error ? error : new Error(String(error)); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // All URLs failed | ||||
|     throw new Error( | ||||
|       `All URLs failed: ${failedUrls.join(', ')}. Last error: ${lastError?.message || 'Unknown error'}`, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if we should retry based on response status | ||||
|    */ | ||||
|   private shouldRetryResponse(response: Response): boolean { | ||||
|     const retryOn = this.options.retryOn; | ||||
|  | ||||
|     if (typeof retryOn === 'function') { | ||||
|       return retryOn(response); | ||||
|     } | ||||
|  | ||||
|     if (Array.isArray(retryOn)) { | ||||
|       return retryOn.includes(response.status); | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if we should retry based on error | ||||
|    */ | ||||
|   private shouldRetryError(error: any): boolean { | ||||
|     // Network errors should be retried | ||||
|     if (error instanceof TypeError && error.message.includes('fetch')) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // Timeout errors should be retried | ||||
|     if (error.name === 'AbortError' || error.message.includes('timeout')) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // If retryOn is a function, use it | ||||
|     const retryOn = this.options.retryOn; | ||||
|     if (typeof retryOn === 'function') { | ||||
|       return retryOn(undefined as any, error); | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Calculate delay for next retry | ||||
|    */ | ||||
|   private calculateDelay(attempt: number): number { | ||||
|     const calculator = getBackoffCalculator(this.options.backoff); | ||||
|     const baseDelay = calculator.calculate( | ||||
|       attempt, | ||||
|       this.options.initialDelay, | ||||
|       this.options.maxDelay, | ||||
|     ); | ||||
|  | ||||
|     // Add jitter to prevent thundering herd | ||||
|     return addJitter(baseDelay); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Delay execution | ||||
|    */ | ||||
|   private async delay(ms: number): Promise<void> { | ||||
|     await plugins.smartdelay.delayFor(ms); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										67
									
								
								ts/retry/retry.strategies.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								ts/retry/retry.strategies.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| /** | ||||
|  * Retry backoff strategies | ||||
|  */ | ||||
|  | ||||
| import type { TBackoffStrategy } from '../webrequest.types.js'; | ||||
|  | ||||
| export interface IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Exponential backoff strategy | ||||
|  * Delay increases exponentially: initialDelay * 2^attempt | ||||
|  */ | ||||
| export class ExponentialBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     const delay = initialDelay * Math.pow(2, attempt - 1); | ||||
|     return Math.min(delay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Linear backoff strategy | ||||
|  * Delay increases linearly: initialDelay * attempt | ||||
|  */ | ||||
| export class LinearBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     const delay = initialDelay * attempt; | ||||
|     return Math.min(delay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Constant backoff strategy | ||||
|  * Delay stays constant: initialDelay | ||||
|  */ | ||||
| export class ConstantBackoff implements IBackoffCalculator { | ||||
|   calculate(attempt: number, initialDelay: number, maxDelay: number): number { | ||||
|     return Math.min(initialDelay, maxDelay); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get backoff calculator for a given strategy | ||||
|  */ | ||||
| export function getBackoffCalculator( | ||||
|   strategy: TBackoffStrategy, | ||||
| ): IBackoffCalculator { | ||||
|   switch (strategy) { | ||||
|     case 'exponential': | ||||
|       return new ExponentialBackoff(); | ||||
|     case 'linear': | ||||
|       return new LinearBackoff(); | ||||
|     case 'constant': | ||||
|       return new ConstantBackoff(); | ||||
|     default: | ||||
|       return new ExponentialBackoff(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Add jitter to delay to prevent thundering herd | ||||
|  */ | ||||
| export function addJitter(delay: number, jitterFactor: number = 0.1): number { | ||||
|   const jitter = delay * jitterFactor * Math.random(); | ||||
|   return delay + jitter; | ||||
| } | ||||
							
								
								
									
										105
									
								
								ts/utils/deduplicator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								ts/utils/deduplicator.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| /** | ||||
|  * Request deduplication system | ||||
|  * Prevents multiple simultaneous identical requests | ||||
|  */ | ||||
|  | ||||
| import * as plugins from '../webrequest.plugins.js'; | ||||
|  | ||||
| export class RequestDeduplicator { | ||||
|   private inFlightRequests: Map< | ||||
|     string, | ||||
|     plugins.smartpromise.Deferred<Response> | ||||
|   > = new Map(); | ||||
|  | ||||
|   /** | ||||
|    * Generate a deduplication key from a request | ||||
|    */ | ||||
|   public generateKey(request: Request): string { | ||||
|     // Use URL + method as the base key | ||||
|     const url = request.url; | ||||
|     const method = request.method; | ||||
|  | ||||
|     // For GET/HEAD requests, just use URL + method | ||||
|     if (method === 'GET' || method === 'HEAD') { | ||||
|       return `${method}:${url}`; | ||||
|     } | ||||
|  | ||||
|     // For other methods, we can't deduplicate as easily | ||||
|     // (body might be different) | ||||
|     // Use a timestamp to make it unique | ||||
|     return `${method}:${url}:${Date.now()}`; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a request with deduplication | ||||
|    */ | ||||
|   public async execute( | ||||
|     key: string, | ||||
|     executeFn: () => Promise<Response>, | ||||
|   ): Promise<{ response: Response; wasDeduplicated: boolean }> { | ||||
|     // Check if request is already in flight | ||||
|     const existingDeferred = this.inFlightRequests.get(key); | ||||
|  | ||||
|     if (existingDeferred) { | ||||
|       // Wait for the existing request to complete | ||||
|       const response = await existingDeferred.promise; | ||||
|  | ||||
|       // Clone the response so it can be used multiple times | ||||
|       return { | ||||
|         response: response.clone(), | ||||
|         wasDeduplicated: true, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Create a new deferred for this request | ||||
|     const deferred = plugins.smartpromise.defer<Response>(); | ||||
|     this.inFlightRequests.set(key, deferred); | ||||
|  | ||||
|     try { | ||||
|       // Execute the request | ||||
|       const response = await executeFn(); | ||||
|  | ||||
|       // Resolve the deferred | ||||
|       deferred.resolve(response); | ||||
|  | ||||
|       // Clean up | ||||
|       this.inFlightRequests.delete(key); | ||||
|  | ||||
|       // Return the original response | ||||
|       return { | ||||
|         response, | ||||
|         wasDeduplicated: false, | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       // Reject the deferred | ||||
|       deferred.reject(error); | ||||
|  | ||||
|       // Clean up | ||||
|       this.inFlightRequests.delete(key); | ||||
|  | ||||
|       // Re-throw the error | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a request is currently in flight | ||||
|    */ | ||||
|   public isInFlight(key: string): boolean { | ||||
|     return this.inFlightRequests.has(key); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get the number of in-flight requests | ||||
|    */ | ||||
|   public getInFlightCount(): number { | ||||
|     return this.inFlightRequests.size; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear all in-flight requests | ||||
|    */ | ||||
|   public clear(): void { | ||||
|     this.inFlightRequests.clear(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										66
									
								
								ts/utils/timeout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								ts/utils/timeout.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| /** | ||||
|  * Timeout handling utilities | ||||
|  */ | ||||
|  | ||||
| import * as plugins from '../webrequest.plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Create an AbortController with timeout | ||||
|  */ | ||||
| export function createTimeoutController(timeoutMs: number): { | ||||
|   controller: AbortController; | ||||
|   cleanup: () => void; | ||||
| } { | ||||
|   const controller = new AbortController(); | ||||
|   let timeoutId: any; | ||||
|  | ||||
|   // Set up timeout | ||||
|   plugins.smartdelay | ||||
|     .delayFor(timeoutMs) | ||||
|     .then(() => { | ||||
|       controller.abort(); | ||||
|     }) | ||||
|     .then((result) => { | ||||
|       timeoutId = result; | ||||
|     }); | ||||
|  | ||||
|   // Cleanup function to clear timeout | ||||
|   const cleanup = () => { | ||||
|     if (timeoutId !== undefined) { | ||||
|       // smartdelay doesn't expose a cancel method, so we just ensure | ||||
|       // the controller won't abort if already completed | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return { controller, cleanup }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Execute a fetch with timeout | ||||
|  */ | ||||
| export async function fetchWithTimeout( | ||||
|   url: string, | ||||
|   init: RequestInit, | ||||
|   timeoutMs: number, | ||||
| ): Promise<Response> { | ||||
|   const { controller, cleanup } = createTimeoutController(timeoutMs); | ||||
|  | ||||
|   try { | ||||
|     const response = await fetch(url, { | ||||
|       ...init, | ||||
|       signal: controller.signal, | ||||
|     }); | ||||
|  | ||||
|     cleanup(); | ||||
|     return response; | ||||
|   } catch (error) { | ||||
|     cleanup(); | ||||
|  | ||||
|     // Re-throw with more informative error if it's a timeout | ||||
|     if (error instanceof Error && error.name === 'AbortError') { | ||||
|       throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`); | ||||
|     } | ||||
|  | ||||
|     throw error; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										326
									
								
								ts/webrequest.client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										326
									
								
								ts/webrequest.client.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,326 @@ | ||||
| /** | ||||
|  * WebrequestClient - Advanced configuration and global interceptors | ||||
|  */ | ||||
|  | ||||
| import type { IWebrequestOptions } from './webrequest.types.js'; | ||||
| import type { | ||||
|   TRequestInterceptor, | ||||
|   TResponseInterceptor, | ||||
|   TErrorInterceptor, | ||||
| } from './interceptors/interceptor.types.js'; | ||||
| import { InterceptorManager } from './interceptors/interceptor.manager.js'; | ||||
| import { CacheManager } from './cache/cache.manager.js'; | ||||
| import { RetryManager } from './retry/retry.manager.js'; | ||||
| import { RequestDeduplicator } from './utils/deduplicator.js'; | ||||
| import { fetchWithTimeout } from './utils/timeout.js'; | ||||
|  | ||||
| export class WebrequestClient { | ||||
|   private interceptorManager: InterceptorManager; | ||||
|   private cacheManager: CacheManager; | ||||
|   private deduplicator: RequestDeduplicator; | ||||
|   private defaultOptions: Partial<IWebrequestOptions>; | ||||
|  | ||||
|   constructor(options: Partial<IWebrequestOptions> = {}) { | ||||
|     this.defaultOptions = options; | ||||
|     this.interceptorManager = new InterceptorManager(); | ||||
|     this.cacheManager = new CacheManager(); | ||||
|     this.deduplicator = new RequestDeduplicator(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a global request interceptor | ||||
|    */ | ||||
|   public addRequestInterceptor(interceptor: TRequestInterceptor): void { | ||||
|     this.interceptorManager.addRequestInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a global response interceptor | ||||
|    */ | ||||
|   public addResponseInterceptor(interceptor: TResponseInterceptor): void { | ||||
|     this.interceptorManager.addResponseInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a global error interceptor | ||||
|    */ | ||||
|   public addErrorInterceptor(interceptor: TErrorInterceptor): void { | ||||
|     this.interceptorManager.addErrorInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove a request interceptor | ||||
|    */ | ||||
|   public removeRequestInterceptor(interceptor: TRequestInterceptor): void { | ||||
|     this.interceptorManager.removeRequestInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove a response interceptor | ||||
|    */ | ||||
|   public removeResponseInterceptor(interceptor: TResponseInterceptor): void { | ||||
|     this.interceptorManager.removeResponseInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Remove an error interceptor | ||||
|    */ | ||||
|   public removeErrorInterceptor(interceptor: TErrorInterceptor): void { | ||||
|     this.interceptorManager.removeErrorInterceptor(interceptor); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear all interceptors | ||||
|    */ | ||||
|   public clearInterceptors(): void { | ||||
|     this.interceptorManager.clearAll(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear the cache | ||||
|    */ | ||||
|   public async clearCache(): Promise<void> { | ||||
|     await this.cacheManager.clear(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Execute a request with all configured features | ||||
|    */ | ||||
|   public async request( | ||||
|     url: string | Request, | ||||
|     options: IWebrequestOptions = {}, | ||||
|   ): Promise<Response> { | ||||
|     // Merge default options with request options | ||||
|     const mergedOptions: IWebrequestOptions = { | ||||
|       ...this.defaultOptions, | ||||
|       ...options, | ||||
|     }; | ||||
|  | ||||
|     // Create Request object | ||||
|     let request: Request; | ||||
|     if (typeof url === 'string') { | ||||
|       request = new Request(url, mergedOptions); | ||||
|     } else { | ||||
|       request = url; | ||||
|     } | ||||
|  | ||||
|     // Process through request interceptors | ||||
|     request = await this.interceptorManager.processRequest(request); | ||||
|  | ||||
|     // Add per-request interceptors if provided | ||||
|     if (mergedOptions.interceptors?.request) { | ||||
|       for (const interceptor of mergedOptions.interceptors.request) { | ||||
|         request = await interceptor(request); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Execute with deduplication if enabled | ||||
|     const deduplicate = mergedOptions.deduplicate ?? false; | ||||
|  | ||||
|     if (deduplicate) { | ||||
|       const dedupeKey = this.deduplicator.generateKey(request); | ||||
|       const result = await this.deduplicator.execute(dedupeKey, async () => { | ||||
|         return await this.executeRequest(request, mergedOptions); | ||||
|       }); | ||||
|       return result.response; | ||||
|     } | ||||
|  | ||||
|     return await this.executeRequest(request, mergedOptions); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Internal request execution with caching and retry | ||||
|    */ | ||||
|   private async executeRequest( | ||||
|     request: Request, | ||||
|     options: IWebrequestOptions, | ||||
|   ): Promise<Response> { | ||||
|     try { | ||||
|       // Determine if retry is enabled | ||||
|       const retryOptions = | ||||
|         typeof options.retry === 'object' | ||||
|           ? options.retry | ||||
|           : options.retry | ||||
|             ? {} | ||||
|             : undefined; | ||||
|  | ||||
|       // Create fetch function for Request objects (used with caching) | ||||
|       const fetchFnForRequest = async (req: Request): Promise<Response> => { | ||||
|         const timeout = options.timeout ?? 60000; | ||||
|         return await fetchWithTimeout( | ||||
|           req.url, | ||||
|           { | ||||
|             method: req.method, | ||||
|             headers: req.headers, | ||||
|             body: req.body, | ||||
|             ...options, | ||||
|           }, | ||||
|           timeout, | ||||
|         ); | ||||
|       }; | ||||
|  | ||||
|       // Create fetch function for fallbacks (url + init) | ||||
|       const fetchFnForFallbacks = async (url: string, init: RequestInit): Promise<Response> => { | ||||
|         const timeout = options.timeout ?? 60000; | ||||
|         return await fetchWithTimeout(url, init, timeout); | ||||
|       }; | ||||
|  | ||||
|       let response: Response; | ||||
|  | ||||
|       // Execute with retry if enabled | ||||
|       if (retryOptions) { | ||||
|         const retryManager = new RetryManager(retryOptions); | ||||
|  | ||||
|         // Handle fallback URLs if provided | ||||
|         if (options.fallbackUrls && options.fallbackUrls.length > 0) { | ||||
|           const allUrls = [request.url, ...options.fallbackUrls]; | ||||
|           response = await retryManager.executeWithFallbacks( | ||||
|             allUrls, | ||||
|             { | ||||
|               method: request.method, | ||||
|               headers: request.headers, | ||||
|               body: request.body, | ||||
|               ...options, | ||||
|             }, | ||||
|             fetchFnForFallbacks, | ||||
|           ); | ||||
|         } else { | ||||
|           response = await retryManager.execute(async () => { | ||||
|             // Execute with caching | ||||
|             const result = await this.cacheManager.execute( | ||||
|               request, | ||||
|               options, | ||||
|               fetchFnForRequest, | ||||
|             ); | ||||
|             return result.response; | ||||
|           }); | ||||
|         } | ||||
|       } else { | ||||
|         // Execute with caching (no retry) | ||||
|         const result = await this.cacheManager.execute( | ||||
|           request, | ||||
|           options, | ||||
|           fetchFnForRequest, | ||||
|         ); | ||||
|         response = result.response; | ||||
|       } | ||||
|  | ||||
|       // Process through response interceptors | ||||
|       response = await this.interceptorManager.processResponse(response); | ||||
|  | ||||
|       // Add per-request response interceptors if provided | ||||
|       if (options.interceptors?.response) { | ||||
|         for (const interceptor of options.interceptors.response) { | ||||
|           response = await interceptor(response); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return response; | ||||
|     } catch (error) { | ||||
|       // Process through error interceptors | ||||
|       const processedError = await this.interceptorManager.processError( | ||||
|         error instanceof Error ? error : new Error(String(error)), | ||||
|       ); | ||||
|  | ||||
|       throw processedError; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convenience method: GET request returning JSON | ||||
|    */ | ||||
|   public async getJson<T = any>( | ||||
|     url: string, | ||||
|     options: IWebrequestOptions = {}, | ||||
|   ): Promise<T> { | ||||
|     const response = await this.request(url, { | ||||
|       ...options, | ||||
|       method: 'GET', | ||||
|       headers: { | ||||
|         Accept: 'application/json', | ||||
|         ...((options.headers as any) || {}), | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     return await response.json(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convenience method: POST request with JSON body | ||||
|    */ | ||||
|   public async postJson<T = any>( | ||||
|     url: string, | ||||
|     data: any, | ||||
|     options: IWebrequestOptions = {}, | ||||
|   ): Promise<T> { | ||||
|     const response = await this.request(url, { | ||||
|       ...options, | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         Accept: 'application/json', | ||||
|         ...((options.headers as any) || {}), | ||||
|       }, | ||||
|       body: JSON.stringify(data), | ||||
|     }); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     return await response.json(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convenience method: PUT request with JSON body | ||||
|    */ | ||||
|   public async putJson<T = any>( | ||||
|     url: string, | ||||
|     data: any, | ||||
|     options: IWebrequestOptions = {}, | ||||
|   ): Promise<T> { | ||||
|     const response = await this.request(url, { | ||||
|       ...options, | ||||
|       method: 'PUT', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         Accept: 'application/json', | ||||
|         ...((options.headers as any) || {}), | ||||
|       }, | ||||
|       body: JSON.stringify(data), | ||||
|     }); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     return await response.json(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Convenience method: DELETE request | ||||
|    */ | ||||
|   public async deleteJson<T = any>( | ||||
|     url: string, | ||||
|     options: IWebrequestOptions = {}, | ||||
|   ): Promise<T> { | ||||
|     const response = await this.request(url, { | ||||
|       ...options, | ||||
|       method: 'DELETE', | ||||
|       headers: { | ||||
|         Accept: 'application/json', | ||||
|         ...((options.headers as any) || {}), | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     return await response.json(); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										147
									
								
								ts/webrequest.function.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								ts/webrequest.function.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| /** | ||||
|  * Main webrequest function - fetch-compatible API | ||||
|  */ | ||||
|  | ||||
| import type { IWebrequestOptions } from './webrequest.types.js'; | ||||
| import { WebrequestClient } from './webrequest.client.js'; | ||||
|  | ||||
| // Global default client | ||||
| const defaultClient = new WebrequestClient(); | ||||
|  | ||||
| /** | ||||
|  * Fetch-compatible webrequest function | ||||
|  * Drop-in replacement for fetch() with caching, retry, and fault tolerance | ||||
|  * | ||||
|  * @param input - URL or Request object | ||||
|  * @param init - Request options (standard RequestInit + webrequest extensions) | ||||
|  * @returns Promise<Response> | ||||
|  * | ||||
|  * @example | ||||
|  * ```typescript | ||||
|  * // Simple GET request | ||||
|  * const response = await webrequest('https://api.example.com/data'); | ||||
|  * const data = await response.json(); | ||||
|  * | ||||
|  * // With caching | ||||
|  * const response = await webrequest('https://api.example.com/data', { | ||||
|  *   cacheStrategy: 'cache-first', | ||||
|  *   cacheMaxAge: 60000 | ||||
|  * }); | ||||
|  * | ||||
|  * // With retry | ||||
|  * const response = await webrequest('https://api.example.com/data', { | ||||
|  *   retry: { | ||||
|  *     maxAttempts: 3, | ||||
|  *     backoff: 'exponential' | ||||
|  *   } | ||||
|  * }); | ||||
|  * | ||||
|  * // With fallback URLs | ||||
|  * const response = await webrequest('https://api.example.com/data', { | ||||
|  *   fallbackUrls: ['https://backup.example.com/data'], | ||||
|  *   retry: true | ||||
|  * }); | ||||
|  * ``` | ||||
|  */ | ||||
| export async function webrequest( | ||||
|   input: string | Request | URL, | ||||
|   init?: IWebrequestOptions, | ||||
| ): Promise<Response> { | ||||
|   const url = input instanceof Request ? input.url : String(input); | ||||
|   const request = input instanceof Request ? input : new Request(url, init); | ||||
|  | ||||
|   return await defaultClient.request(request, init); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Convenience method: GET request returning JSON | ||||
|  */ | ||||
| webrequest.getJson = async function <T = any>( | ||||
|   url: string, | ||||
|   options?: IWebrequestOptions, | ||||
| ): Promise<T> { | ||||
|   return await defaultClient.getJson<T>(url, options); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Convenience method: POST request with JSON body | ||||
|  */ | ||||
| webrequest.postJson = async function <T = any>( | ||||
|   url: string, | ||||
|   data: any, | ||||
|   options?: IWebrequestOptions, | ||||
| ): Promise<T> { | ||||
|   return await defaultClient.postJson<T>(url, data, options); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Convenience method: PUT request with JSON body | ||||
|  */ | ||||
| webrequest.putJson = async function <T = any>( | ||||
|   url: string, | ||||
|   data: any, | ||||
|   options?: IWebrequestOptions, | ||||
| ): Promise<T> { | ||||
|   return await defaultClient.putJson<T>(url, data, options); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Convenience method: DELETE request | ||||
|  */ | ||||
| webrequest.deleteJson = async function <T = any>( | ||||
|   url: string, | ||||
|   options?: IWebrequestOptions, | ||||
| ): Promise<T> { | ||||
|   return await defaultClient.deleteJson<T>(url, options); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Add a global request interceptor | ||||
|  */ | ||||
| webrequest.addRequestInterceptor = function (interceptor) { | ||||
|   defaultClient.addRequestInterceptor(interceptor); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Add a global response interceptor | ||||
|  */ | ||||
| webrequest.addResponseInterceptor = function (interceptor) { | ||||
|   defaultClient.addResponseInterceptor(interceptor); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Add a global error interceptor | ||||
|  */ | ||||
| webrequest.addErrorInterceptor = function (interceptor) { | ||||
|   defaultClient.addErrorInterceptor(interceptor); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Clear all global interceptors | ||||
|  */ | ||||
| webrequest.clearInterceptors = function () { | ||||
|   defaultClient.clearInterceptors(); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Clear the cache | ||||
|  */ | ||||
| webrequest.clearCache = async function () { | ||||
|   await defaultClient.clearCache(); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Create a new WebrequestClient with custom configuration | ||||
|  */ | ||||
| webrequest.createClient = function ( | ||||
|   options?: Partial<IWebrequestOptions>, | ||||
| ): WebrequestClient { | ||||
|   return new WebrequestClient(options); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Get the default client | ||||
|  */ | ||||
| webrequest.getDefaultClient = function (): WebrequestClient { | ||||
|   return defaultClient; | ||||
| }; | ||||
							
								
								
									
										143
									
								
								ts/webrequest.types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								ts/webrequest.types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | ||||
| /** | ||||
|  * Core type definitions for @push.rocks/webrequest v4 | ||||
|  */ | ||||
|  | ||||
| // ================== | ||||
| // Cache Types | ||||
| // ================== | ||||
|  | ||||
| export type TCacheStrategy = | ||||
|   | 'network-first' | ||||
|   | 'cache-first' | ||||
|   | 'stale-while-revalidate' | ||||
|   | 'network-only' | ||||
|   | 'cache-only'; | ||||
|  | ||||
| export type TStandardCacheMode = | ||||
|   | 'default' | ||||
|   | 'no-store' | ||||
|   | 'reload' | ||||
|   | 'no-cache' | ||||
|   | 'force-cache' | ||||
|   | 'only-if-cached'; | ||||
|  | ||||
| export interface ICacheEntry { | ||||
|   response: ArrayBuffer; | ||||
|   headers: Record<string, string>; | ||||
|   timestamp: number; | ||||
|   etag?: string; | ||||
|   lastModified?: string; | ||||
|   maxAge?: number; | ||||
|   url: string; | ||||
|   status: number; | ||||
|   statusText: string; | ||||
| } | ||||
|  | ||||
| export interface ICacheOptions { | ||||
|   /** Standard cache mode (fetch API compatible) */ | ||||
|   cache?: TStandardCacheMode; | ||||
|   /** Advanced cache strategy */ | ||||
|   cacheStrategy?: TCacheStrategy; | ||||
|   /** Maximum age in milliseconds */ | ||||
|   cacheMaxAge?: number; | ||||
|   /** Custom cache key generator */ | ||||
|   cacheKey?: string | ((request: Request) => string); | ||||
|   /** Force revalidation even if cached */ | ||||
|   revalidate?: boolean; | ||||
| } | ||||
|  | ||||
| // ================== | ||||
| // Retry Types | ||||
| // ================== | ||||
|  | ||||
| export type TBackoffStrategy = 'exponential' | 'linear' | 'constant'; | ||||
|  | ||||
| export interface IRetryOptions { | ||||
|   /** Maximum number of retry attempts (default: 3) */ | ||||
|   maxAttempts?: number; | ||||
|   /** Backoff strategy (default: 'exponential') */ | ||||
|   backoff?: TBackoffStrategy; | ||||
|   /** Initial delay in milliseconds (default: 1000) */ | ||||
|   initialDelay?: number; | ||||
|   /** Maximum delay in milliseconds (default: 30000) */ | ||||
|   maxDelay?: number; | ||||
|   /** Status codes or function to determine if retry should occur */ | ||||
|   retryOn?: number[] | ((response: Response, error?: Error) => boolean); | ||||
|   /** Callback on each retry attempt */ | ||||
|   onRetry?: (attempt: number, error: Error, nextDelay: number) => void; | ||||
| } | ||||
|  | ||||
| // ================== | ||||
| // Interceptor Types | ||||
| // ================== | ||||
|  | ||||
| export type TRequestInterceptor = ( | ||||
|   request: Request, | ||||
| ) => Request | Promise<Request>; | ||||
| export type TResponseInterceptor = ( | ||||
|   response: Response, | ||||
| ) => Response | Promise<Response>; | ||||
|  | ||||
| export interface IInterceptors { | ||||
|   request?: TRequestInterceptor[]; | ||||
|   response?: TResponseInterceptor[]; | ||||
| } | ||||
|  | ||||
| // ================== | ||||
| // Main Options | ||||
| // ================== | ||||
|  | ||||
| export interface IWebrequestOptions extends Omit<RequestInit, 'cache'> { | ||||
|   // Caching | ||||
|   cache?: TStandardCacheMode; | ||||
|   cacheStrategy?: TCacheStrategy; | ||||
|   cacheMaxAge?: number; | ||||
|   cacheKey?: string | ((request: Request) => string); | ||||
|   revalidate?: boolean; | ||||
|  | ||||
|   // Retry & Fault Tolerance | ||||
|   retry?: boolean | IRetryOptions; | ||||
|   fallbackUrls?: string[]; | ||||
|   timeout?: number; | ||||
|  | ||||
|   // Interceptors | ||||
|   interceptors?: IInterceptors; | ||||
|  | ||||
|   // Deduplication | ||||
|   deduplicate?: boolean; | ||||
|  | ||||
|   // Logging | ||||
|   logging?: boolean; | ||||
| } | ||||
|  | ||||
| // ================== | ||||
| // Result Types | ||||
| // ================== | ||||
|  | ||||
| export interface IWebrequestSuccess<T> { | ||||
|   ok: true; | ||||
|   data: T; | ||||
|   response: Response; | ||||
| } | ||||
|  | ||||
| export interface IWebrequestError { | ||||
|   ok: false; | ||||
|   error: Error; | ||||
|   response?: Response; | ||||
| } | ||||
|  | ||||
| export type TWebrequestResult<T> = IWebrequestSuccess<T> | IWebrequestError; | ||||
|  | ||||
| // ================== | ||||
| // Internal Types | ||||
| // ================== | ||||
|  | ||||
| export interface ICacheMetadata { | ||||
|   maxAge: number; | ||||
|   etag?: string; | ||||
|   lastModified?: string; | ||||
|   immutable: boolean; | ||||
|   noCache: boolean; | ||||
|   noStore: boolean; | ||||
|   mustRevalidate: boolean; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user