- 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.
173 lines
4.0 KiB
TypeScript
173 lines
4.0 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|