feat: Implement comprehensive web request handling with caching, retry, and interceptors
- Added cache strategies: NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly. - Introduced InterceptorManager for managing request, response, and error interceptors. - Developed RetryManager for handling request retries with customizable backoff strategies. - Implemented RequestDeduplicator to prevent simultaneous identical requests. - Created timeout utilities for handling request timeouts. - Enhanced WebrequestClient to support global interceptors, caching, and retry logic. - Added convenience methods for common HTTP methods (GET, POST, PUT, DELETE) with JSON handling. - Established a fetch-compatible webrequest function for seamless integration. - Defined core type structures for caching, retry options, interceptors, and web request configurations.
This commit is contained in:
377
ts/cache/cache.strategies.ts
vendored
Normal file
377
ts/cache/cache.strategies.ts
vendored
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Cache strategy implementations
|
||||
*/
|
||||
|
||||
import type {
|
||||
ICacheEntry,
|
||||
ICacheMetadata,
|
||||
TCacheStrategy,
|
||||
} from '../webrequest.types.js';
|
||||
import { CacheStore } from './cache.store.js';
|
||||
import {
|
||||
extractCacheMetadata,
|
||||
isFresh,
|
||||
requiresRevalidation,
|
||||
createConditionalHeaders,
|
||||
headersToObject,
|
||||
} from './cache.headers.js';
|
||||
|
||||
export interface IStrategyContext {
|
||||
request: Request;
|
||||
cacheKey: string;
|
||||
cacheStore: CacheStore;
|
||||
fetchFn: (request: Request) => Promise<Response>;
|
||||
logging?: boolean;
|
||||
}
|
||||
|
||||
export interface IStrategyResult {
|
||||
response: Response;
|
||||
fromCache: boolean;
|
||||
revalidated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base strategy handler interface
|
||||
*/
|
||||
export interface ICacheStrategyHandler {
|
||||
execute(context: IStrategyContext): Promise<IStrategyResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-First Strategy
|
||||
* Try network first, fallback to cache on failure
|
||||
*/
|
||||
export class NetworkFirstStrategy implements ICacheStrategyHandler {
|
||||
async execute(context: IStrategyContext): Promise<IStrategyResult> {
|
||||
try {
|
||||
// Try network first
|
||||
const response = await context.fetchFn(context.request);
|
||||
|
||||
// If successful, cache it
|
||||
if (response.ok) {
|
||||
await this.cacheResponse(context, response);
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// Network failed, try cache
|
||||
if (context.logging) {
|
||||
console.log('[webrequest] Network failed, trying cache:', error);
|
||||
}
|
||||
|
||||
const cachedEntry = await context.cacheStore.get(context.cacheKey);
|
||||
if (cachedEntry) {
|
||||
return {
|
||||
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
|
||||
fromCache: true,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache available, re-throw error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheResponse(
|
||||
context: IStrategyContext,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const metadata = extractCacheMetadata(response.headers);
|
||||
|
||||
// Don't cache if no-store
|
||||
if (metadata.noStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await context.cacheStore.cacheEntryFromResponse(
|
||||
context.request.url,
|
||||
response,
|
||||
metadata,
|
||||
);
|
||||
await context.cacheStore.set(context.cacheKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-First Strategy
|
||||
* Check cache first, fetch if miss or stale
|
||||
*/
|
||||
export class CacheFirstStrategy implements ICacheStrategyHandler {
|
||||
async execute(context: IStrategyContext): Promise<IStrategyResult> {
|
||||
// Check cache first
|
||||
const cachedEntry = await context.cacheStore.get(context.cacheKey);
|
||||
|
||||
if (cachedEntry) {
|
||||
const metadata = extractCacheMetadata(new Headers(cachedEntry.headers));
|
||||
|
||||
// Check if cache is fresh
|
||||
if (isFresh(cachedEntry, metadata)) {
|
||||
if (context.logging) {
|
||||
console.log('[webrequest] Cache hit (fresh):', context.request.url);
|
||||
}
|
||||
|
||||
return {
|
||||
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
|
||||
fromCache: true,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// If requires revalidation, check with server
|
||||
if (
|
||||
requiresRevalidation(metadata) &&
|
||||
(cachedEntry.etag || cachedEntry.lastModified)
|
||||
) {
|
||||
return await this.revalidate(context, cachedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or stale, fetch from network
|
||||
if (context.logging) {
|
||||
console.log('[webrequest] Cache miss, fetching:', context.request.url);
|
||||
}
|
||||
|
||||
const response = await context.fetchFn(context.request);
|
||||
|
||||
// Cache the response
|
||||
const metadata = extractCacheMetadata(response.headers);
|
||||
if (!metadata.noStore) {
|
||||
const entry = await context.cacheStore.cacheEntryFromResponse(
|
||||
context.request.url,
|
||||
response,
|
||||
metadata,
|
||||
);
|
||||
await context.cacheStore.set(context.cacheKey, entry);
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async revalidate(
|
||||
context: IStrategyContext,
|
||||
cachedEntry: ICacheEntry,
|
||||
): Promise<IStrategyResult> {
|
||||
const conditionalHeaders = createConditionalHeaders(cachedEntry);
|
||||
|
||||
// Create a new request with conditional headers
|
||||
const revalidateRequest = new Request(context.request.url, {
|
||||
method: context.request.method,
|
||||
headers: {
|
||||
...headersToObject(context.request.headers),
|
||||
...conditionalHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await context.fetchFn(revalidateRequest);
|
||||
|
||||
// 304 Not Modified - cache is still valid
|
||||
if (response.status === 304) {
|
||||
if (context.logging) {
|
||||
console.log(
|
||||
'[webrequest] Cache revalidated (304):',
|
||||
context.request.url,
|
||||
);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
cachedEntry.timestamp = Date.now();
|
||||
await context.cacheStore.set(context.cacheKey, cachedEntry);
|
||||
|
||||
return {
|
||||
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
|
||||
fromCache: true,
|
||||
revalidated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Response changed, cache the new one
|
||||
if (response.ok) {
|
||||
const metadata = extractCacheMetadata(response.headers);
|
||||
if (!metadata.noStore) {
|
||||
const entry = await context.cacheStore.cacheEntryFromResponse(
|
||||
context.request.url,
|
||||
response,
|
||||
metadata,
|
||||
);
|
||||
await context.cacheStore.set(context.cacheKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: true,
|
||||
};
|
||||
} catch (error) {
|
||||
// Revalidation failed, use cached response
|
||||
if (context.logging) {
|
||||
console.log('[webrequest] Revalidation failed, using cache:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
|
||||
fromCache: true,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-While-Revalidate Strategy
|
||||
* Return cache immediately, update in background
|
||||
*/
|
||||
export class StaleWhileRevalidateStrategy implements ICacheStrategyHandler {
|
||||
async execute(context: IStrategyContext): Promise<IStrategyResult> {
|
||||
const cachedEntry = await context.cacheStore.get(context.cacheKey);
|
||||
|
||||
if (cachedEntry) {
|
||||
// Return cached response immediately
|
||||
const cachedResponse =
|
||||
context.cacheStore.responseFromCacheEntry(cachedEntry);
|
||||
|
||||
// Revalidate in background
|
||||
this.revalidateInBackground(context, cachedEntry).catch((error) => {
|
||||
if (context.logging) {
|
||||
console.warn('[webrequest] Background revalidation failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
response: cachedResponse,
|
||||
fromCache: true,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache, fetch from network
|
||||
const response = await context.fetchFn(context.request);
|
||||
|
||||
// Cache the response
|
||||
const metadata = extractCacheMetadata(response.headers);
|
||||
if (!metadata.noStore && response.ok) {
|
||||
const entry = await context.cacheStore.cacheEntryFromResponse(
|
||||
context.request.url,
|
||||
response,
|
||||
metadata,
|
||||
);
|
||||
await context.cacheStore.set(context.cacheKey, entry);
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
private async revalidateInBackground(
|
||||
context: IStrategyContext,
|
||||
cachedEntry: ICacheEntry,
|
||||
): Promise<void> {
|
||||
const metadata = extractCacheMetadata(new Headers(cachedEntry.headers));
|
||||
|
||||
// Check if revalidation is needed
|
||||
if (isFresh(cachedEntry, metadata) && !requiresRevalidation(metadata)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await context.fetchFn(context.request);
|
||||
|
||||
if (response.ok) {
|
||||
const newMetadata = extractCacheMetadata(response.headers);
|
||||
if (!newMetadata.noStore) {
|
||||
const entry = await context.cacheStore.cacheEntryFromResponse(
|
||||
context.request.url,
|
||||
response,
|
||||
newMetadata,
|
||||
);
|
||||
await context.cacheStore.set(context.cacheKey, entry);
|
||||
|
||||
if (context.logging) {
|
||||
console.log(
|
||||
'[webrequest] Background revalidation complete:',
|
||||
context.request.url,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Background revalidation failed, keep existing cache
|
||||
if (context.logging) {
|
||||
console.warn('[webrequest] Background revalidation failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-Only Strategy
|
||||
* Never use cache
|
||||
*/
|
||||
export class NetworkOnlyStrategy implements ICacheStrategyHandler {
|
||||
async execute(context: IStrategyContext): Promise<IStrategyResult> {
|
||||
const response = await context.fetchFn(context.request);
|
||||
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-Only Strategy
|
||||
* Only use cache, fail if miss
|
||||
*/
|
||||
export class CacheOnlyStrategy implements ICacheStrategyHandler {
|
||||
async execute(context: IStrategyContext): Promise<IStrategyResult> {
|
||||
const cachedEntry = await context.cacheStore.get(context.cacheKey);
|
||||
|
||||
if (!cachedEntry) {
|
||||
throw new Error(
|
||||
`Cache miss for ${context.request.url} (cache-only mode)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
response: context.cacheStore.responseFromCacheEntry(cachedEntry),
|
||||
fromCache: true,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strategy handler for a given strategy type
|
||||
*/
|
||||
export function getStrategyHandler(
|
||||
strategy: TCacheStrategy,
|
||||
): ICacheStrategyHandler {
|
||||
switch (strategy) {
|
||||
case 'network-first':
|
||||
return new NetworkFirstStrategy();
|
||||
case 'cache-first':
|
||||
return new CacheFirstStrategy();
|
||||
case 'stale-while-revalidate':
|
||||
return new StaleWhileRevalidateStrategy();
|
||||
case 'network-only':
|
||||
return new NetworkOnlyStrategy();
|
||||
case 'cache-only':
|
||||
return new CacheOnlyStrategy();
|
||||
default:
|
||||
return new NetworkFirstStrategy();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user