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:
172
ts/cache/cache.headers.ts
vendored
Normal file
172
ts/cache/cache.headers.ts
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user