Files
webrequest/ts/cache/cache.strategies.ts
Juergen Kunz 54afcc46e2 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.
2025-10-20 09:59:24 +00:00

378 lines
9.7 KiB
TypeScript

/**
* Cache strategy implementations
*/
import type {
ICacheEntry,
ICacheMetadata,
TCacheStrategy,
} from '../webrequest.types.js';
import { CacheStore } from './cache.store.js';
import {
extractCacheMetadata,
isFresh,
requiresRevalidation,
createConditionalHeaders,
headersToObject,
} from './cache.headers.js';
export interface IStrategyContext {
request: Request;
cacheKey: string;
cacheStore: CacheStore;
fetchFn: (request: Request) => Promise<Response>;
logging?: boolean;
}
export interface IStrategyResult {
response: Response;
fromCache: boolean;
revalidated: boolean;
}
/**
* Base strategy handler interface
*/
export interface ICacheStrategyHandler {
execute(context: IStrategyContext): Promise<IStrategyResult>;
}
/**
* Network-First Strategy
* Try network first, fallback to cache on failure
*/
export class NetworkFirstStrategy implements ICacheStrategyHandler {
async execute(context: IStrategyContext): Promise<IStrategyResult> {
try {
// Try network first
const response = await context.fetchFn(context.request);
// If successful, cache it
if (response.ok) {
await this.cacheResponse(context, response);
}
return {
response,
fromCache: false,
revalidated: false,
};
} catch (error) {
// Network failed, try cache
if (context.logging) {
console.log('[webrequest] Network failed, trying cache:', error);
}
const cachedEntry = await context.cacheStore.get(context.cacheKey);
if (cachedEntry) {
return {
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
fromCache: true,
revalidated: false,
};
}
// No cache available, re-throw error
throw error;
}
}
private async cacheResponse(
context: IStrategyContext,
response: Response,
): Promise<void> {
const metadata = extractCacheMetadata(response.headers);
// Don't cache if no-store
if (metadata.noStore) {
return;
}
const entry = await context.cacheStore.cacheEntryFromResponse(
context.request.url,
response,
metadata,
);
await context.cacheStore.set(context.cacheKey, entry);
}
}
/**
* Cache-First Strategy
* Check cache first, fetch if miss or stale
*/
export class CacheFirstStrategy implements ICacheStrategyHandler {
async execute(context: IStrategyContext): Promise<IStrategyResult> {
// Check cache first
const cachedEntry = await context.cacheStore.get(context.cacheKey);
if (cachedEntry) {
const metadata = extractCacheMetadata(new Headers(cachedEntry.headers));
// Check if cache is fresh
if (isFresh(cachedEntry, metadata)) {
if (context.logging) {
console.log('[webrequest] Cache hit (fresh):', context.request.url);
}
return {
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
fromCache: true,
revalidated: false,
};
}
// If requires revalidation, check with server
if (
requiresRevalidation(metadata) &&
(cachedEntry.etag || cachedEntry.lastModified)
) {
return await this.revalidate(context, cachedEntry);
}
}
// Cache miss or stale, fetch from network
if (context.logging) {
console.log('[webrequest] Cache miss, fetching:', context.request.url);
}
const response = await context.fetchFn(context.request);
// Cache the response
const metadata = extractCacheMetadata(response.headers);
if (!metadata.noStore) {
const entry = await context.cacheStore.cacheEntryFromResponse(
context.request.url,
response,
metadata,
);
await context.cacheStore.set(context.cacheKey, entry);
}
return {
response,
fromCache: false,
revalidated: false,
};
}
private async revalidate(
context: IStrategyContext,
cachedEntry: ICacheEntry,
): Promise<IStrategyResult> {
const conditionalHeaders = createConditionalHeaders(cachedEntry);
// Create a new request with conditional headers
const revalidateRequest = new Request(context.request.url, {
method: context.request.method,
headers: {
...headersToObject(context.request.headers),
...conditionalHeaders,
},
});
try {
const response = await context.fetchFn(revalidateRequest);
// 304 Not Modified - cache is still valid
if (response.status === 304) {
if (context.logging) {
console.log(
'[webrequest] Cache revalidated (304):',
context.request.url,
);
}
// Update timestamp
cachedEntry.timestamp = Date.now();
await context.cacheStore.set(context.cacheKey, cachedEntry);
return {
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
fromCache: true,
revalidated: true,
};
}
// Response changed, cache the new one
if (response.ok) {
const metadata = extractCacheMetadata(response.headers);
if (!metadata.noStore) {
const entry = await context.cacheStore.cacheEntryFromResponse(
context.request.url,
response,
metadata,
);
await context.cacheStore.set(context.cacheKey, entry);
}
}
return {
response,
fromCache: false,
revalidated: true,
};
} catch (error) {
// Revalidation failed, use cached response
if (context.logging) {
console.log('[webrequest] Revalidation failed, using cache:', error);
}
return {
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
fromCache: true,
revalidated: false,
};
}
}
}
/**
* Stale-While-Revalidate Strategy
* Return cache immediately, update in background
*/
export class StaleWhileRevalidateStrategy implements ICacheStrategyHandler {
async execute(context: IStrategyContext): Promise<IStrategyResult> {
const cachedEntry = await context.cacheStore.get(context.cacheKey);
if (cachedEntry) {
// Return cached response immediately
const cachedResponse =
context.cacheStore.responseFromCacheEntry(cachedEntry);
// Revalidate in background
this.revalidateInBackground(context, cachedEntry).catch((error) => {
if (context.logging) {
console.warn('[webrequest] Background revalidation failed:', error);
}
});
return {
response: cachedResponse,
fromCache: true,
revalidated: false,
};
}
// No cache, fetch from network
const response = await context.fetchFn(context.request);
// Cache the response
const metadata = extractCacheMetadata(response.headers);
if (!metadata.noStore && response.ok) {
const entry = await context.cacheStore.cacheEntryFromResponse(
context.request.url,
response,
metadata,
);
await context.cacheStore.set(context.cacheKey, entry);
}
return {
response,
fromCache: false,
revalidated: false,
};
}
private async revalidateInBackground(
context: IStrategyContext,
cachedEntry: ICacheEntry,
): Promise<void> {
const metadata = extractCacheMetadata(new Headers(cachedEntry.headers));
// Check if revalidation is needed
if (isFresh(cachedEntry, metadata) && !requiresRevalidation(metadata)) {
return;
}
try {
const response = await context.fetchFn(context.request);
if (response.ok) {
const newMetadata = extractCacheMetadata(response.headers);
if (!newMetadata.noStore) {
const entry = await context.cacheStore.cacheEntryFromResponse(
context.request.url,
response,
newMetadata,
);
await context.cacheStore.set(context.cacheKey, entry);
if (context.logging) {
console.log(
'[webrequest] Background revalidation complete:',
context.request.url,
);
}
}
}
} catch (error) {
// Background revalidation failed, keep existing cache
if (context.logging) {
console.warn('[webrequest] Background revalidation failed:', error);
}
}
}
}
/**
* Network-Only Strategy
* Never use cache
*/
export class NetworkOnlyStrategy implements ICacheStrategyHandler {
async execute(context: IStrategyContext): Promise<IStrategyResult> {
const response = await context.fetchFn(context.request);
return {
response,
fromCache: false,
revalidated: false,
};
}
}
/**
* Cache-Only Strategy
* Only use cache, fail if miss
*/
export class CacheOnlyStrategy implements ICacheStrategyHandler {
async execute(context: IStrategyContext): Promise<IStrategyResult> {
const cachedEntry = await context.cacheStore.get(context.cacheKey);
if (!cachedEntry) {
throw new Error(
`Cache miss for ${context.request.url} (cache-only mode)`,
);
}
return {
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
fromCache: true,
revalidated: false,
};
}
}
/**
* Get strategy handler for a given strategy type
*/
export function getStrategyHandler(
strategy: TCacheStrategy,
): ICacheStrategyHandler {
switch (strategy) {
case 'network-first':
return new NetworkFirstStrategy();
case 'cache-first':
return new CacheFirstStrategy();
case 'stale-while-revalidate':
return new StaleWhileRevalidateStrategy();
case 'network-only':
return new NetworkOnlyStrategy();
case 'cache-only':
return new CacheOnlyStrategy();
default:
return new NetworkFirstStrategy();
}
}