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