/** * 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; logging?: boolean; } export interface IStrategyResult { response: Response; fromCache: boolean; revalidated: boolean; } /** * Base strategy handler interface */ export interface ICacheStrategyHandler { execute(context: IStrategyContext): Promise; } /** * Network-First Strategy * Try network first, fallback to cache on failure */ export class NetworkFirstStrategy implements ICacheStrategyHandler { async execute(context: IStrategyContext): Promise { 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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(); } }