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:
@@ -4,5 +4,6 @@
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/webrequest',
|
||||
version: '3.0.37',
|
||||
description: 'A module for making secure web requests from browsers with support for caching and fault tolerance.'
|
||||
}
|
||||
description:
|
||||
'A module for making secure web requests from browsers with support for caching and fault tolerance.',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
156
ts/cache/cache.manager.ts
vendored
Normal file
156
ts/cache/cache.manager.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Cache manager - orchestrates caching logic
|
||||
*/
|
||||
|
||||
import type {
|
||||
ICacheOptions,
|
||||
TCacheStrategy,
|
||||
TStandardCacheMode,
|
||||
} from '../webrequest.types.js';
|
||||
import { CacheStore } from './cache.store.js';
|
||||
import {
|
||||
getStrategyHandler,
|
||||
type IStrategyContext,
|
||||
type IStrategyResult,
|
||||
} from './cache.strategies.js';
|
||||
import { extractCacheMetadata } from './cache.headers.js';
|
||||
|
||||
export class CacheManager {
|
||||
private cacheStore: CacheStore;
|
||||
|
||||
constructor(dbName?: string, storeName?: string) {
|
||||
this.cacheStore = new CacheStore(dbName, storeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with caching
|
||||
*/
|
||||
public async execute(
|
||||
request: Request,
|
||||
options: ICacheOptions & { logging?: boolean },
|
||||
fetchFn: (request: Request) => Promise<Response>,
|
||||
): Promise<IStrategyResult> {
|
||||
// Determine the cache strategy
|
||||
const strategy = this.determineStrategy(request, options);
|
||||
|
||||
// If no caching (no-store or network-only), bypass cache
|
||||
if (strategy === 'network-only') {
|
||||
const response = await fetchFn(request);
|
||||
return {
|
||||
response,
|
||||
fromCache: false,
|
||||
revalidated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = this.generateCacheKey(request, options);
|
||||
|
||||
// Get strategy handler
|
||||
const handler = getStrategyHandler(strategy);
|
||||
|
||||
// Execute strategy
|
||||
const context: IStrategyContext = {
|
||||
request,
|
||||
cacheKey,
|
||||
cacheStore: this.cacheStore,
|
||||
fetchFn,
|
||||
logging: options.logging,
|
||||
};
|
||||
|
||||
return await handler.execute(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the caching strategy based on options and request
|
||||
*/
|
||||
private determineStrategy(
|
||||
request: Request,
|
||||
options: ICacheOptions,
|
||||
): TCacheStrategy {
|
||||
// If explicit strategy provided, use it
|
||||
if (options.cacheStrategy) {
|
||||
return options.cacheStrategy;
|
||||
}
|
||||
|
||||
// Map standard cache modes to strategies
|
||||
if (options.cache) {
|
||||
return this.mapCacheModeToStrategy(options.cache);
|
||||
}
|
||||
|
||||
// Check request cache mode
|
||||
if (request.cache) {
|
||||
return this.mapCacheModeToStrategy(request.cache as TStandardCacheMode);
|
||||
}
|
||||
|
||||
// Default strategy
|
||||
return 'network-first';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map standard fetch cache modes to our strategies
|
||||
*/
|
||||
private mapCacheModeToStrategy(
|
||||
cacheMode: TStandardCacheMode,
|
||||
): TCacheStrategy {
|
||||
switch (cacheMode) {
|
||||
case 'default':
|
||||
return 'network-first';
|
||||
case 'no-store':
|
||||
case 'reload':
|
||||
return 'network-only';
|
||||
case 'no-cache':
|
||||
return 'network-first'; // Will use revalidation
|
||||
case 'force-cache':
|
||||
return 'cache-first';
|
||||
case 'only-if-cached':
|
||||
return 'cache-only';
|
||||
default:
|
||||
return 'network-first';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key
|
||||
*/
|
||||
private generateCacheKey(request: Request, options: ICacheOptions): string {
|
||||
// If custom cache key provided
|
||||
if (options.cacheKey) {
|
||||
if (typeof options.cacheKey === 'function') {
|
||||
return options.cacheKey(request);
|
||||
}
|
||||
return options.cacheKey;
|
||||
}
|
||||
|
||||
// Default cache key generation
|
||||
return this.cacheStore.generateCacheKey(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache
|
||||
*/
|
||||
public async clear(): Promise<void> {
|
||||
await this.cacheStore.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific cache entry
|
||||
*/
|
||||
public async delete(cacheKey: string): Promise<void> {
|
||||
await this.cacheStore.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache entry exists
|
||||
*/
|
||||
public async has(cacheKey: string): Promise<boolean> {
|
||||
return await this.cacheStore.has(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying cache store
|
||||
*/
|
||||
public getStore(): CacheStore {
|
||||
return this.cacheStore;
|
||||
}
|
||||
}
|
||||
154
ts/cache/cache.store.ts
vendored
Normal file
154
ts/cache/cache.store.ts
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Cache storage layer using IndexedDB via @push.rocks/webstore
|
||||
*/
|
||||
|
||||
import * as plugins from '../webrequest.plugins.js';
|
||||
import type { ICacheEntry } from '../webrequest.types.js';
|
||||
|
||||
export class CacheStore {
|
||||
private webstore: plugins.webstore.WebStore;
|
||||
private initPromise: Promise<void>;
|
||||
|
||||
constructor(dbName: string = 'webrequest-v4', storeName: string = 'cache') {
|
||||
this.webstore = new plugins.webstore.WebStore({
|
||||
dbName,
|
||||
storeName,
|
||||
});
|
||||
|
||||
// Initialize the store
|
||||
this.initPromise = this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store
|
||||
*/
|
||||
private async init(): Promise<void> {
|
||||
// WebStore handles initialization internally
|
||||
// This method exists for future extension if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from a request
|
||||
*/
|
||||
public generateCacheKey(request: Request): string {
|
||||
// Use URL + method as the base key
|
||||
const url = request.url;
|
||||
const method = request.method;
|
||||
|
||||
// For GET requests, just use the URL
|
||||
if (method === 'GET') {
|
||||
return url;
|
||||
}
|
||||
|
||||
// For other methods, include the method
|
||||
return `${method}:${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a response in the cache
|
||||
*/
|
||||
public async set(cacheKey: string, entry: ICacheEntry): Promise<void> {
|
||||
await this.initPromise;
|
||||
await this.webstore.set(cacheKey, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a cached response
|
||||
*/
|
||||
public async get(cacheKey: string): Promise<ICacheEntry | null> {
|
||||
await this.initPromise;
|
||||
|
||||
try {
|
||||
const entry = (await this.webstore.get(cacheKey)) as ICacheEntry;
|
||||
return entry || null;
|
||||
} catch (error) {
|
||||
// If entry doesn't exist or is corrupted, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache entry exists
|
||||
*/
|
||||
public async has(cacheKey: string): Promise<boolean> {
|
||||
await this.initPromise;
|
||||
return await this.webstore.check(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cache entry
|
||||
*/
|
||||
public async delete(cacheKey: string): Promise<void> {
|
||||
await this.initPromise;
|
||||
await this.webstore.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
public async clear(): Promise<void> {
|
||||
await this.initPromise;
|
||||
await this.webstore.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Response object from a cache entry
|
||||
*/
|
||||
public responseFromCacheEntry(entry: ICacheEntry): Response {
|
||||
const headers = new Headers(entry.headers);
|
||||
|
||||
return new Response(entry.response, {
|
||||
status: entry.status,
|
||||
statusText: entry.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache entry from a Response object
|
||||
*/
|
||||
public async cacheEntryFromResponse(
|
||||
url: string,
|
||||
response: Response,
|
||||
metadata?: { maxAge?: number; etag?: string; lastModified?: string },
|
||||
): Promise<ICacheEntry> {
|
||||
// Clone the response so we can read it multiple times
|
||||
const clonedResponse = response.clone();
|
||||
const buffer = await clonedResponse.arrayBuffer();
|
||||
|
||||
// Extract headers
|
||||
const headers: Record<string, string> = {};
|
||||
clonedResponse.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
response: buffer,
|
||||
headers,
|
||||
timestamp: Date.now(),
|
||||
etag: metadata?.etag || clonedResponse.headers.get('etag') || undefined,
|
||||
lastModified:
|
||||
metadata?.lastModified ||
|
||||
clonedResponse.headers.get('last-modified') ||
|
||||
undefined,
|
||||
maxAge: metadata?.maxAge,
|
||||
url,
|
||||
status: clonedResponse.status,
|
||||
statusText: clonedResponse.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune expired entries (garbage collection)
|
||||
* Returns the number of entries deleted
|
||||
*/
|
||||
public async pruneExpired(): Promise<number> {
|
||||
await this.initPromise;
|
||||
|
||||
// Note: WebStore doesn't provide a way to list all keys
|
||||
// This would need to be implemented if we want automatic cleanup
|
||||
// For now, we rely on individual entry checks
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
253
ts/index.ts
253
ts/index.ts
@@ -1,220 +1,47 @@
|
||||
import * as plugins from './webrequest.plugins.js';
|
||||
|
||||
export interface IWebrequestContructorOptions {
|
||||
logging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* web request
|
||||
* @push.rocks/webrequest v4
|
||||
* Modern, fetch-compatible web request library with intelligent caching
|
||||
*/
|
||||
export class WebRequest {
|
||||
|
||||
public cacheStore = new plugins.webstore.WebStore({
|
||||
dbName: 'webrequest',
|
||||
storeName: 'webrequest',
|
||||
});
|
||||
// Main exports
|
||||
export { webrequest } from './webrequest.function.js';
|
||||
export { WebrequestClient } from './webrequest.client.js';
|
||||
|
||||
public options: IWebrequestContructorOptions;
|
||||
// Type exports
|
||||
export type {
|
||||
IWebrequestOptions,
|
||||
ICacheOptions,
|
||||
IRetryOptions,
|
||||
IInterceptors,
|
||||
TCacheStrategy,
|
||||
TStandardCacheMode,
|
||||
TBackoffStrategy,
|
||||
TWebrequestResult,
|
||||
IWebrequestSuccess,
|
||||
IWebrequestError,
|
||||
ICacheEntry,
|
||||
ICacheMetadata,
|
||||
} from './webrequest.types.js';
|
||||
|
||||
constructor(public optionsArg: IWebrequestContructorOptions = {}) {
|
||||
this.options = {
|
||||
logging: true,
|
||||
...optionsArg,
|
||||
};
|
||||
}
|
||||
export type {
|
||||
TRequestInterceptor,
|
||||
TResponseInterceptor,
|
||||
TErrorInterceptor,
|
||||
} from './interceptors/interceptor.types.js';
|
||||
|
||||
public async getJson(urlArg: string, useCacheArg: boolean = false) {
|
||||
const response: Response = await this.request(urlArg, {
|
||||
method: 'GET',
|
||||
useCache: useCacheArg,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const responseResult = plugins.smartjson.parse(responseText);
|
||||
return responseResult;
|
||||
}
|
||||
// Advanced exports for custom implementations
|
||||
export { CacheManager } from './cache/cache.manager.js';
|
||||
export { CacheStore } from './cache/cache.store.js';
|
||||
export { RetryManager } from './retry/retry.manager.js';
|
||||
export { InterceptorManager } from './interceptors/interceptor.manager.js';
|
||||
export { RequestDeduplicator } from './utils/deduplicator.js';
|
||||
|
||||
/**
|
||||
* postJson
|
||||
*/
|
||||
public async postJson(urlArg: string, requestBody?: any, useCacheArg: boolean = false) {
|
||||
const response: Response = await this.request(urlArg, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: plugins.smartjson.stringify(requestBody),
|
||||
useCache: useCacheArg,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const responseResult = plugins.smartjson.parse(responseText);
|
||||
return responseResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* put js
|
||||
*/
|
||||
public async putJson(urlArg: string, requestBody?: any, useStoreAsFallback: boolean = false) {
|
||||
const response: Response = await this.request(urlArg, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: plugins.smartjson.stringify(requestBody),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const responseResult = plugins.smartjson.parse(responseText);
|
||||
return responseResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* put js
|
||||
*/
|
||||
public async deleteJson(urlArg: string, useStoreAsFallback: boolean = false) {
|
||||
const response: Response = await this.request(urlArg, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const responseResult = plugins.smartjson.parse(responseText);
|
||||
return responseResult;
|
||||
}
|
||||
|
||||
public async request(
|
||||
urlArg: string,
|
||||
optionsArg: {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: any;
|
||||
headers?: HeadersInit;
|
||||
useCache?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
) {
|
||||
optionsArg = {
|
||||
timeoutMs: 60000,
|
||||
useCache: false,
|
||||
...optionsArg,
|
||||
};
|
||||
|
||||
let controller = new AbortController();
|
||||
plugins.smartdelay.delayFor(optionsArg.timeoutMs).then(() => {
|
||||
controller.abort();
|
||||
});
|
||||
let cachedResponseDeferred = plugins.smartpromise.defer<Response>();
|
||||
let cacheUsed = false;
|
||||
if (optionsArg.useCache && (await this.cacheStore.check(urlArg))) {
|
||||
const responseBuffer: ArrayBuffer = await this.cacheStore.get(urlArg);
|
||||
cachedResponseDeferred.resolve(new Response(responseBuffer, {}));
|
||||
} else {
|
||||
cachedResponseDeferred.resolve(null);
|
||||
}
|
||||
let response: Response = await fetch(urlArg, {
|
||||
signal: controller.signal,
|
||||
method: optionsArg.method,
|
||||
headers: {
|
||||
...(optionsArg.headers || {}),
|
||||
},
|
||||
body: optionsArg.body,
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (optionsArg.useCache && (await cachedResponseDeferred.promise)) {
|
||||
cacheUsed = true;
|
||||
const cachedResponse = cachedResponseDeferred.promise;
|
||||
return cachedResponse;
|
||||
} else {
|
||||
return err;
|
||||
}
|
||||
});
|
||||
if (optionsArg.useCache && (await cachedResponseDeferred.promise) && response.status === 500) {
|
||||
cacheUsed = true;
|
||||
response = await cachedResponseDeferred.promise;
|
||||
}
|
||||
if (!cacheUsed && optionsArg.useCache && response.status < 300) {
|
||||
const buffer = await response.clone().arrayBuffer();
|
||||
await this.cacheStore.set(urlArg, buffer);
|
||||
}
|
||||
this.log(`${urlArg} answers with status: ${response.status}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* a multi endpoint, fault tolerant request function
|
||||
*/
|
||||
public async requestMultiEndpoint(
|
||||
urlArg: string | string[],
|
||||
optionsArg: {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: any;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
): Promise<Response> {
|
||||
|
||||
let allUrls: string[];
|
||||
let usedUrlIndex = 0;
|
||||
|
||||
// determine what we got
|
||||
if (Array.isArray(urlArg)) {
|
||||
allUrls = urlArg;
|
||||
} else {
|
||||
allUrls = [urlArg];
|
||||
}
|
||||
|
||||
const requestHistory: string[] = []; // keep track of the request history
|
||||
|
||||
const doHistoryCheck = async (
|
||||
// check history for a
|
||||
historyEntryTypeArg: string
|
||||
) => {
|
||||
requestHistory.push(historyEntryTypeArg);
|
||||
if (historyEntryTypeArg === '429') {
|
||||
console.log('got 429, so waiting a little bit.');
|
||||
await plugins.smartdelay.delayFor(Math.floor(Math.random() * (2000 - 1000 + 1)) + 1000); // wait between 1 and 10 seconds
|
||||
}
|
||||
|
||||
let numOfHistoryType = 0;
|
||||
for (const entry of requestHistory) {
|
||||
if (entry === historyEntryTypeArg) numOfHistoryType++;
|
||||
}
|
||||
if (numOfHistoryType > 2 * allUrls.length * usedUrlIndex) {
|
||||
usedUrlIndex++;
|
||||
}
|
||||
};
|
||||
|
||||
// lets go recursive
|
||||
const doRequest = async (urlToUse: string): Promise<any> => {
|
||||
if (!urlToUse) {
|
||||
throw new Error('request failed permanently');
|
||||
}
|
||||
this.log(`Getting ${urlToUse} with method ${optionsArg.method}`);
|
||||
const response = await fetch(urlToUse, {
|
||||
method: optionsArg.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(optionsArg.headers || {}),
|
||||
},
|
||||
body: optionsArg.body,
|
||||
});
|
||||
this.log(`${urlToUse} answers with status: ${response.status}`);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response;
|
||||
} else {
|
||||
// lets perform a history check to determine failed urls
|
||||
await doHistoryCheck(response.status.toString());
|
||||
// lets fire the request
|
||||
const result = await doRequest(allUrls[usedUrlIndex]);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const finalResponse: Response = await doRequest(allUrls[usedUrlIndex]);
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
public log(logArg: string) {
|
||||
if (this.options.logging) {
|
||||
console.log(logArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cache utilities
|
||||
export {
|
||||
extractCacheMetadata,
|
||||
isFresh,
|
||||
requiresRevalidation,
|
||||
createConditionalHeaders,
|
||||
headersToObject,
|
||||
objectToHeaders,
|
||||
} from './cache/cache.headers.js';
|
||||
|
||||
149
ts/interceptors/interceptor.manager.ts
Normal file
149
ts/interceptors/interceptor.manager.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Interceptor manager for request/response transformation
|
||||
*/
|
||||
|
||||
import type {
|
||||
TRequestInterceptor,
|
||||
TResponseInterceptor,
|
||||
TErrorInterceptor,
|
||||
} from './interceptor.types.js';
|
||||
|
||||
export class InterceptorManager {
|
||||
private requestInterceptors: TRequestInterceptor[] = [];
|
||||
private responseInterceptors: TResponseInterceptor[] = [];
|
||||
private errorInterceptors: TErrorInterceptor[] = [];
|
||||
|
||||
/**
|
||||
* Add a request interceptor
|
||||
*/
|
||||
public addRequestInterceptor(interceptor: TRequestInterceptor): void {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a response interceptor
|
||||
*/
|
||||
public addResponseInterceptor(interceptor: TResponseInterceptor): void {
|
||||
this.responseInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an error interceptor
|
||||
*/
|
||||
public addErrorInterceptor(interceptor: TErrorInterceptor): void {
|
||||
this.errorInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a request interceptor
|
||||
*/
|
||||
public removeRequestInterceptor(interceptor: TRequestInterceptor): void {
|
||||
const index = this.requestInterceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
this.requestInterceptors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a response interceptor
|
||||
*/
|
||||
public removeResponseInterceptor(interceptor: TResponseInterceptor): void {
|
||||
const index = this.responseInterceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
this.responseInterceptors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an error interceptor
|
||||
*/
|
||||
public removeErrorInterceptor(interceptor: TErrorInterceptor): void {
|
||||
const index = this.errorInterceptors.indexOf(interceptor);
|
||||
if (index > -1) {
|
||||
this.errorInterceptors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all interceptors
|
||||
*/
|
||||
public clearAll(): void {
|
||||
this.requestInterceptors = [];
|
||||
this.responseInterceptors = [];
|
||||
this.errorInterceptors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request through all request interceptors
|
||||
*/
|
||||
public async processRequest(request: Request): Promise<Request> {
|
||||
let processedRequest = request;
|
||||
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
try {
|
||||
processedRequest = await interceptor(processedRequest);
|
||||
} catch (error) {
|
||||
// If interceptor throws, process through error interceptors
|
||||
throw await this.processError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return processedRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process response through all response interceptors
|
||||
*/
|
||||
public async processResponse(response: Response): Promise<Response> {
|
||||
let processedResponse = response;
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
try {
|
||||
processedResponse = await interceptor(processedResponse);
|
||||
} catch (error) {
|
||||
// If interceptor throws, process through error interceptors
|
||||
throw await this.processError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return processedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process error through all error interceptors
|
||||
*/
|
||||
public async processError(error: Error): Promise<Error> {
|
||||
let processedError = error;
|
||||
|
||||
for (const interceptor of this.errorInterceptors) {
|
||||
try {
|
||||
processedError = await interceptor(processedError);
|
||||
} catch (newError) {
|
||||
// If error interceptor throws, use the new error
|
||||
processedError =
|
||||
newError instanceof Error ? newError : new Error(String(newError));
|
||||
}
|
||||
}
|
||||
|
||||
return processedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered interceptors
|
||||
*/
|
||||
public getInterceptorCounts(): {
|
||||
request: number;
|
||||
response: number;
|
||||
error: number;
|
||||
} {
|
||||
return {
|
||||
request: this.requestInterceptors.length,
|
||||
response: this.responseInterceptors.length,
|
||||
error: this.errorInterceptors.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
31
ts/interceptors/interceptor.types.ts
Normal file
31
ts/interceptors/interceptor.types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Interceptor type definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request interceptor
|
||||
* Transforms the request before it's sent
|
||||
*/
|
||||
export type TRequestInterceptor = (
|
||||
request: Request,
|
||||
) => Request | Promise<Request>;
|
||||
|
||||
/**
|
||||
* Response interceptor
|
||||
* Transforms the response after it's received
|
||||
*/
|
||||
export type TResponseInterceptor = (
|
||||
response: Response,
|
||||
) => Response | Promise<Response>;
|
||||
|
||||
/**
|
||||
* Error interceptor
|
||||
* Handles errors during request/response processing
|
||||
*/
|
||||
export type TErrorInterceptor = (error: Error) => Error | Promise<Error>;
|
||||
|
||||
export interface IInterceptors {
|
||||
request?: TRequestInterceptor[];
|
||||
response?: TResponseInterceptor[];
|
||||
error?: TErrorInterceptor[];
|
||||
}
|
||||
199
ts/retry/retry.manager.ts
Normal file
199
ts/retry/retry.manager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Retry manager for handling request retries
|
||||
*/
|
||||
|
||||
import * as plugins from '../webrequest.plugins.js';
|
||||
import type { IRetryOptions } from '../webrequest.types.js';
|
||||
import { getBackoffCalculator, addJitter } from './retry.strategies.js';
|
||||
|
||||
export class RetryManager {
|
||||
private options: Required<IRetryOptions>;
|
||||
|
||||
constructor(options: IRetryOptions = {}) {
|
||||
this.options = {
|
||||
maxAttempts: options.maxAttempts ?? 3,
|
||||
backoff: options.backoff ?? 'exponential',
|
||||
initialDelay: options.initialDelay ?? 1000,
|
||||
maxDelay: options.maxDelay ?? 30000,
|
||||
retryOn: options.retryOn ?? [408, 429, 500, 502, 503, 504],
|
||||
onRetry: options.onRetry ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with retry logic
|
||||
*/
|
||||
public async execute<T>(
|
||||
executeFn: () => Promise<T>,
|
||||
shouldRetryFn?: (error: any, attempt: number) => boolean,
|
||||
): Promise<T> {
|
||||
let lastError: Error;
|
||||
let lastResponse: Response | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await executeFn();
|
||||
|
||||
// Check if result is a Response and if we should retry based on status
|
||||
if (result instanceof Response) {
|
||||
if (this.shouldRetryResponse(result)) {
|
||||
lastResponse = result;
|
||||
|
||||
// If this is the last attempt, return the failed response
|
||||
if (attempt === this.options.maxAttempts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate delay and retry
|
||||
const delay = this.calculateDelay(attempt);
|
||||
this.options.onRetry(
|
||||
attempt,
|
||||
new Error(`HTTP ${result.status}`),
|
||||
delay,
|
||||
);
|
||||
|
||||
await this.delay(delay);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Check if we should retry
|
||||
const shouldRetry = shouldRetryFn
|
||||
? shouldRetryFn(error, attempt)
|
||||
: this.shouldRetryError(error);
|
||||
|
||||
// If this is the last attempt or we shouldn't retry, throw
|
||||
if (attempt === this.options.maxAttempts || !shouldRetry) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Calculate delay and retry
|
||||
const delay = this.calculateDelay(attempt);
|
||||
this.options.onRetry(attempt, lastError, delay);
|
||||
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// This should never be reached, but TypeScript needs it
|
||||
throw lastError! || new Error('Max retry attempts reached');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute with multiple fallback URLs
|
||||
*/
|
||||
public async executeWithFallbacks(
|
||||
urls: string[],
|
||||
requestInit: RequestInit,
|
||||
fetchFn: (url: string, init: RequestInit) => Promise<Response>,
|
||||
): Promise<Response> {
|
||||
if (urls.length === 0) {
|
||||
throw new Error('No URLs provided for fallback execution');
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
const failedUrls: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
// Try the URL with retry logic
|
||||
const response = await this.execute(async () => {
|
||||
return await fetchFn(url, requestInit);
|
||||
});
|
||||
|
||||
// If successful (status < 400), return
|
||||
if (response.status < 400) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// If 4xx client error (except 408 timeout), don't try other URLs
|
||||
if (
|
||||
response.status >= 400 &&
|
||||
response.status < 500 &&
|
||||
response.status !== 408
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Server error or timeout, try next URL
|
||||
failedUrls.push(url);
|
||||
lastError = new Error(`Request failed with status ${response.status}`);
|
||||
} catch (error) {
|
||||
failedUrls.push(url);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// All URLs failed
|
||||
throw new Error(
|
||||
`All URLs failed: ${failedUrls.join(', ')}. Last error: ${lastError?.message || 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should retry based on response status
|
||||
*/
|
||||
private shouldRetryResponse(response: Response): boolean {
|
||||
const retryOn = this.options.retryOn;
|
||||
|
||||
if (typeof retryOn === 'function') {
|
||||
return retryOn(response);
|
||||
}
|
||||
|
||||
if (Array.isArray(retryOn)) {
|
||||
return retryOn.includes(response.status);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should retry based on error
|
||||
*/
|
||||
private shouldRetryError(error: any): boolean {
|
||||
// Network errors should be retried
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timeout errors should be retried
|
||||
if (error.name === 'AbortError' || error.message.includes('timeout')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If retryOn is a function, use it
|
||||
const retryOn = this.options.retryOn;
|
||||
if (typeof retryOn === 'function') {
|
||||
return retryOn(undefined as any, error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay for next retry
|
||||
*/
|
||||
private calculateDelay(attempt: number): number {
|
||||
const calculator = getBackoffCalculator(this.options.backoff);
|
||||
const baseDelay = calculator.calculate(
|
||||
attempt,
|
||||
this.options.initialDelay,
|
||||
this.options.maxDelay,
|
||||
);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
return addJitter(baseDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay execution
|
||||
*/
|
||||
private async delay(ms: number): Promise<void> {
|
||||
await plugins.smartdelay.delayFor(ms);
|
||||
}
|
||||
}
|
||||
67
ts/retry/retry.strategies.ts
Normal file
67
ts/retry/retry.strategies.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Retry backoff strategies
|
||||
*/
|
||||
|
||||
import type { TBackoffStrategy } from '../webrequest.types.js';
|
||||
|
||||
export interface IBackoffCalculator {
|
||||
calculate(attempt: number, initialDelay: number, maxDelay: number): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff strategy
|
||||
* Delay increases exponentially: initialDelay * 2^attempt
|
||||
*/
|
||||
export class ExponentialBackoff implements IBackoffCalculator {
|
||||
calculate(attempt: number, initialDelay: number, maxDelay: number): number {
|
||||
const delay = initialDelay * Math.pow(2, attempt - 1);
|
||||
return Math.min(delay, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear backoff strategy
|
||||
* Delay increases linearly: initialDelay * attempt
|
||||
*/
|
||||
export class LinearBackoff implements IBackoffCalculator {
|
||||
calculate(attempt: number, initialDelay: number, maxDelay: number): number {
|
||||
const delay = initialDelay * attempt;
|
||||
return Math.min(delay, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant backoff strategy
|
||||
* Delay stays constant: initialDelay
|
||||
*/
|
||||
export class ConstantBackoff implements IBackoffCalculator {
|
||||
calculate(attempt: number, initialDelay: number, maxDelay: number): number {
|
||||
return Math.min(initialDelay, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backoff calculator for a given strategy
|
||||
*/
|
||||
export function getBackoffCalculator(
|
||||
strategy: TBackoffStrategy,
|
||||
): IBackoffCalculator {
|
||||
switch (strategy) {
|
||||
case 'exponential':
|
||||
return new ExponentialBackoff();
|
||||
case 'linear':
|
||||
return new LinearBackoff();
|
||||
case 'constant':
|
||||
return new ConstantBackoff();
|
||||
default:
|
||||
return new ExponentialBackoff();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add jitter to delay to prevent thundering herd
|
||||
*/
|
||||
export function addJitter(delay: number, jitterFactor: number = 0.1): number {
|
||||
const jitter = delay * jitterFactor * Math.random();
|
||||
return delay + jitter;
|
||||
}
|
||||
105
ts/utils/deduplicator.ts
Normal file
105
ts/utils/deduplicator.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Request deduplication system
|
||||
* Prevents multiple simultaneous identical requests
|
||||
*/
|
||||
|
||||
import * as plugins from '../webrequest.plugins.js';
|
||||
|
||||
export class RequestDeduplicator {
|
||||
private inFlightRequests: Map<
|
||||
string,
|
||||
plugins.smartpromise.Deferred<Response>
|
||||
> = new Map();
|
||||
|
||||
/**
|
||||
* Generate a deduplication key from a request
|
||||
*/
|
||||
public generateKey(request: Request): string {
|
||||
// Use URL + method as the base key
|
||||
const url = request.url;
|
||||
const method = request.method;
|
||||
|
||||
// For GET/HEAD requests, just use URL + method
|
||||
if (method === 'GET' || method === 'HEAD') {
|
||||
return `${method}:${url}`;
|
||||
}
|
||||
|
||||
// For other methods, we can't deduplicate as easily
|
||||
// (body might be different)
|
||||
// Use a timestamp to make it unique
|
||||
return `${method}:${url}:${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with deduplication
|
||||
*/
|
||||
public async execute(
|
||||
key: string,
|
||||
executeFn: () => Promise<Response>,
|
||||
): Promise<{ response: Response; wasDeduplicated: boolean }> {
|
||||
// Check if request is already in flight
|
||||
const existingDeferred = this.inFlightRequests.get(key);
|
||||
|
||||
if (existingDeferred) {
|
||||
// Wait for the existing request to complete
|
||||
const response = await existingDeferred.promise;
|
||||
|
||||
// Clone the response so it can be used multiple times
|
||||
return {
|
||||
response: response.clone(),
|
||||
wasDeduplicated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new deferred for this request
|
||||
const deferred = plugins.smartpromise.defer<Response>();
|
||||
this.inFlightRequests.set(key, deferred);
|
||||
|
||||
try {
|
||||
// Execute the request
|
||||
const response = await executeFn();
|
||||
|
||||
// Resolve the deferred
|
||||
deferred.resolve(response);
|
||||
|
||||
// Clean up
|
||||
this.inFlightRequests.delete(key);
|
||||
|
||||
// Return the original response
|
||||
return {
|
||||
response,
|
||||
wasDeduplicated: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// Reject the deferred
|
||||
deferred.reject(error);
|
||||
|
||||
// Clean up
|
||||
this.inFlightRequests.delete(key);
|
||||
|
||||
// Re-throw the error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is currently in flight
|
||||
*/
|
||||
public isInFlight(key: string): boolean {
|
||||
return this.inFlightRequests.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of in-flight requests
|
||||
*/
|
||||
public getInFlightCount(): number {
|
||||
return this.inFlightRequests.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-flight requests
|
||||
*/
|
||||
public clear(): void {
|
||||
this.inFlightRequests.clear();
|
||||
}
|
||||
}
|
||||
66
ts/utils/timeout.ts
Normal file
66
ts/utils/timeout.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Timeout handling utilities
|
||||
*/
|
||||
|
||||
import * as plugins from '../webrequest.plugins.js';
|
||||
|
||||
/**
|
||||
* Create an AbortController with timeout
|
||||
*/
|
||||
export function createTimeoutController(timeoutMs: number): {
|
||||
controller: AbortController;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
let timeoutId: any;
|
||||
|
||||
// Set up timeout
|
||||
plugins.smartdelay
|
||||
.delayFor(timeoutMs)
|
||||
.then(() => {
|
||||
controller.abort();
|
||||
})
|
||||
.then((result) => {
|
||||
timeoutId = result;
|
||||
});
|
||||
|
||||
// Cleanup function to clear timeout
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
// smartdelay doesn't expose a cancel method, so we just ensure
|
||||
// the controller won't abort if already completed
|
||||
}
|
||||
};
|
||||
|
||||
return { controller, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a fetch with timeout
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const { controller, cleanup } = createTimeoutController(timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
return response;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
|
||||
// Re-throw with more informative error if it's a timeout
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
326
ts/webrequest.client.ts
Normal file
326
ts/webrequest.client.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* WebrequestClient - Advanced configuration and global interceptors
|
||||
*/
|
||||
|
||||
import type { IWebrequestOptions } from './webrequest.types.js';
|
||||
import type {
|
||||
TRequestInterceptor,
|
||||
TResponseInterceptor,
|
||||
TErrorInterceptor,
|
||||
} from './interceptors/interceptor.types.js';
|
||||
import { InterceptorManager } from './interceptors/interceptor.manager.js';
|
||||
import { CacheManager } from './cache/cache.manager.js';
|
||||
import { RetryManager } from './retry/retry.manager.js';
|
||||
import { RequestDeduplicator } from './utils/deduplicator.js';
|
||||
import { fetchWithTimeout } from './utils/timeout.js';
|
||||
|
||||
export class WebrequestClient {
|
||||
private interceptorManager: InterceptorManager;
|
||||
private cacheManager: CacheManager;
|
||||
private deduplicator: RequestDeduplicator;
|
||||
private defaultOptions: Partial<IWebrequestOptions>;
|
||||
|
||||
constructor(options: Partial<IWebrequestOptions> = {}) {
|
||||
this.defaultOptions = options;
|
||||
this.interceptorManager = new InterceptorManager();
|
||||
this.cacheManager = new CacheManager();
|
||||
this.deduplicator = new RequestDeduplicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a global request interceptor
|
||||
*/
|
||||
public addRequestInterceptor(interceptor: TRequestInterceptor): void {
|
||||
this.interceptorManager.addRequestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a global response interceptor
|
||||
*/
|
||||
public addResponseInterceptor(interceptor: TResponseInterceptor): void {
|
||||
this.interceptorManager.addResponseInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a global error interceptor
|
||||
*/
|
||||
public addErrorInterceptor(interceptor: TErrorInterceptor): void {
|
||||
this.interceptorManager.addErrorInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a request interceptor
|
||||
*/
|
||||
public removeRequestInterceptor(interceptor: TRequestInterceptor): void {
|
||||
this.interceptorManager.removeRequestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a response interceptor
|
||||
*/
|
||||
public removeResponseInterceptor(interceptor: TResponseInterceptor): void {
|
||||
this.interceptorManager.removeResponseInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an error interceptor
|
||||
*/
|
||||
public removeErrorInterceptor(interceptor: TErrorInterceptor): void {
|
||||
this.interceptorManager.removeErrorInterceptor(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all interceptors
|
||||
*/
|
||||
public clearInterceptors(): void {
|
||||
this.interceptorManager.clearAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache
|
||||
*/
|
||||
public async clearCache(): Promise<void> {
|
||||
await this.cacheManager.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request with all configured features
|
||||
*/
|
||||
public async request(
|
||||
url: string | Request,
|
||||
options: IWebrequestOptions = {},
|
||||
): Promise<Response> {
|
||||
// Merge default options with request options
|
||||
const mergedOptions: IWebrequestOptions = {
|
||||
...this.defaultOptions,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Create Request object
|
||||
let request: Request;
|
||||
if (typeof url === 'string') {
|
||||
request = new Request(url, mergedOptions);
|
||||
} else {
|
||||
request = url;
|
||||
}
|
||||
|
||||
// Process through request interceptors
|
||||
request = await this.interceptorManager.processRequest(request);
|
||||
|
||||
// Add per-request interceptors if provided
|
||||
if (mergedOptions.interceptors?.request) {
|
||||
for (const interceptor of mergedOptions.interceptors.request) {
|
||||
request = await interceptor(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute with deduplication if enabled
|
||||
const deduplicate = mergedOptions.deduplicate ?? false;
|
||||
|
||||
if (deduplicate) {
|
||||
const dedupeKey = this.deduplicator.generateKey(request);
|
||||
const result = await this.deduplicator.execute(dedupeKey, async () => {
|
||||
return await this.executeRequest(request, mergedOptions);
|
||||
});
|
||||
return result.response;
|
||||
}
|
||||
|
||||
return await this.executeRequest(request, mergedOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal request execution with caching and retry
|
||||
*/
|
||||
private async executeRequest(
|
||||
request: Request,
|
||||
options: IWebrequestOptions,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Determine if retry is enabled
|
||||
const retryOptions =
|
||||
typeof options.retry === 'object'
|
||||
? options.retry
|
||||
: options.retry
|
||||
? {}
|
||||
: undefined;
|
||||
|
||||
// Create fetch function for Request objects (used with caching)
|
||||
const fetchFnForRequest = async (req: Request): Promise<Response> => {
|
||||
const timeout = options.timeout ?? 60000;
|
||||
return await fetchWithTimeout(
|
||||
req.url,
|
||||
{
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
...options,
|
||||
},
|
||||
timeout,
|
||||
);
|
||||
};
|
||||
|
||||
// Create fetch function for fallbacks (url + init)
|
||||
const fetchFnForFallbacks = async (url: string, init: RequestInit): Promise<Response> => {
|
||||
const timeout = options.timeout ?? 60000;
|
||||
return await fetchWithTimeout(url, init, timeout);
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
|
||||
// Execute with retry if enabled
|
||||
if (retryOptions) {
|
||||
const retryManager = new RetryManager(retryOptions);
|
||||
|
||||
// Handle fallback URLs if provided
|
||||
if (options.fallbackUrls && options.fallbackUrls.length > 0) {
|
||||
const allUrls = [request.url, ...options.fallbackUrls];
|
||||
response = await retryManager.executeWithFallbacks(
|
||||
allUrls,
|
||||
{
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
...options,
|
||||
},
|
||||
fetchFnForFallbacks,
|
||||
);
|
||||
} else {
|
||||
response = await retryManager.execute(async () => {
|
||||
// Execute with caching
|
||||
const result = await this.cacheManager.execute(
|
||||
request,
|
||||
options,
|
||||
fetchFnForRequest,
|
||||
);
|
||||
return result.response;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Execute with caching (no retry)
|
||||
const result = await this.cacheManager.execute(
|
||||
request,
|
||||
options,
|
||||
fetchFnForRequest,
|
||||
);
|
||||
response = result.response;
|
||||
}
|
||||
|
||||
// Process through response interceptors
|
||||
response = await this.interceptorManager.processResponse(response);
|
||||
|
||||
// Add per-request response interceptors if provided
|
||||
if (options.interceptors?.response) {
|
||||
for (const interceptor of options.interceptors.response) {
|
||||
response = await interceptor(response);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Process through error interceptors
|
||||
const processedError = await this.interceptorManager.processError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
|
||||
throw processedError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: GET request returning JSON
|
||||
*/
|
||||
public async getJson<T = any>(
|
||||
url: string,
|
||||
options: IWebrequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(url, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...((options.headers as any) || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: POST request with JSON body
|
||||
*/
|
||||
public async postJson<T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options: IWebrequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...((options.headers as any) || {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: PUT request with JSON body
|
||||
*/
|
||||
public async putJson<T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options: IWebrequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...((options.headers as any) || {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: DELETE request
|
||||
*/
|
||||
public async deleteJson<T = any>(
|
||||
url: string,
|
||||
options: IWebrequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await this.request(url, {
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...((options.headers as any) || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
147
ts/webrequest.function.ts
Normal file
147
ts/webrequest.function.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Main webrequest function - fetch-compatible API
|
||||
*/
|
||||
|
||||
import type { IWebrequestOptions } from './webrequest.types.js';
|
||||
import { WebrequestClient } from './webrequest.client.js';
|
||||
|
||||
// Global default client
|
||||
const defaultClient = new WebrequestClient();
|
||||
|
||||
/**
|
||||
* Fetch-compatible webrequest function
|
||||
* Drop-in replacement for fetch() with caching, retry, and fault tolerance
|
||||
*
|
||||
* @param input - URL or Request object
|
||||
* @param init - Request options (standard RequestInit + webrequest extensions)
|
||||
* @returns Promise<Response>
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple GET request
|
||||
* const response = await webrequest('https://api.example.com/data');
|
||||
* const data = await response.json();
|
||||
*
|
||||
* // With caching
|
||||
* const response = await webrequest('https://api.example.com/data', {
|
||||
* cacheStrategy: 'cache-first',
|
||||
* cacheMaxAge: 60000
|
||||
* });
|
||||
*
|
||||
* // With retry
|
||||
* const response = await webrequest('https://api.example.com/data', {
|
||||
* retry: {
|
||||
* maxAttempts: 3,
|
||||
* backoff: 'exponential'
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // With fallback URLs
|
||||
* const response = await webrequest('https://api.example.com/data', {
|
||||
* fallbackUrls: ['https://backup.example.com/data'],
|
||||
* retry: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function webrequest(
|
||||
input: string | Request | URL,
|
||||
init?: IWebrequestOptions,
|
||||
): Promise<Response> {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
const request = input instanceof Request ? input : new Request(url, init);
|
||||
|
||||
return await defaultClient.request(request, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method: GET request returning JSON
|
||||
*/
|
||||
webrequest.getJson = async function <T = any>(
|
||||
url: string,
|
||||
options?: IWebrequestOptions,
|
||||
): Promise<T> {
|
||||
return await defaultClient.getJson<T>(url, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience method: POST request with JSON body
|
||||
*/
|
||||
webrequest.postJson = async function <T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options?: IWebrequestOptions,
|
||||
): Promise<T> {
|
||||
return await defaultClient.postJson<T>(url, data, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience method: PUT request with JSON body
|
||||
*/
|
||||
webrequest.putJson = async function <T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options?: IWebrequestOptions,
|
||||
): Promise<T> {
|
||||
return await defaultClient.putJson<T>(url, data, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience method: DELETE request
|
||||
*/
|
||||
webrequest.deleteJson = async function <T = any>(
|
||||
url: string,
|
||||
options?: IWebrequestOptions,
|
||||
): Promise<T> {
|
||||
return await defaultClient.deleteJson<T>(url, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a global request interceptor
|
||||
*/
|
||||
webrequest.addRequestInterceptor = function (interceptor) {
|
||||
defaultClient.addRequestInterceptor(interceptor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a global response interceptor
|
||||
*/
|
||||
webrequest.addResponseInterceptor = function (interceptor) {
|
||||
defaultClient.addResponseInterceptor(interceptor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a global error interceptor
|
||||
*/
|
||||
webrequest.addErrorInterceptor = function (interceptor) {
|
||||
defaultClient.addErrorInterceptor(interceptor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all global interceptors
|
||||
*/
|
||||
webrequest.clearInterceptors = function () {
|
||||
defaultClient.clearInterceptors();
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the cache
|
||||
*/
|
||||
webrequest.clearCache = async function () {
|
||||
await defaultClient.clearCache();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new WebrequestClient with custom configuration
|
||||
*/
|
||||
webrequest.createClient = function (
|
||||
options?: Partial<IWebrequestOptions>,
|
||||
): WebrequestClient {
|
||||
return new WebrequestClient(options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default client
|
||||
*/
|
||||
webrequest.getDefaultClient = function (): WebrequestClient {
|
||||
return defaultClient;
|
||||
};
|
||||
143
ts/webrequest.types.ts
Normal file
143
ts/webrequest.types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Core type definitions for @push.rocks/webrequest v4
|
||||
*/
|
||||
|
||||
// ==================
|
||||
// Cache Types
|
||||
// ==================
|
||||
|
||||
export type TCacheStrategy =
|
||||
| 'network-first'
|
||||
| 'cache-first'
|
||||
| 'stale-while-revalidate'
|
||||
| 'network-only'
|
||||
| 'cache-only';
|
||||
|
||||
export type TStandardCacheMode =
|
||||
| 'default'
|
||||
| 'no-store'
|
||||
| 'reload'
|
||||
| 'no-cache'
|
||||
| 'force-cache'
|
||||
| 'only-if-cached';
|
||||
|
||||
export interface ICacheEntry {
|
||||
response: ArrayBuffer;
|
||||
headers: Record<string, string>;
|
||||
timestamp: number;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
maxAge?: number;
|
||||
url: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface ICacheOptions {
|
||||
/** Standard cache mode (fetch API compatible) */
|
||||
cache?: TStandardCacheMode;
|
||||
/** Advanced cache strategy */
|
||||
cacheStrategy?: TCacheStrategy;
|
||||
/** Maximum age in milliseconds */
|
||||
cacheMaxAge?: number;
|
||||
/** Custom cache key generator */
|
||||
cacheKey?: string | ((request: Request) => string);
|
||||
/** Force revalidation even if cached */
|
||||
revalidate?: boolean;
|
||||
}
|
||||
|
||||
// ==================
|
||||
// Retry Types
|
||||
// ==================
|
||||
|
||||
export type TBackoffStrategy = 'exponential' | 'linear' | 'constant';
|
||||
|
||||
export interface IRetryOptions {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxAttempts?: number;
|
||||
/** Backoff strategy (default: 'exponential') */
|
||||
backoff?: TBackoffStrategy;
|
||||
/** Initial delay in milliseconds (default: 1000) */
|
||||
initialDelay?: number;
|
||||
/** Maximum delay in milliseconds (default: 30000) */
|
||||
maxDelay?: number;
|
||||
/** Status codes or function to determine if retry should occur */
|
||||
retryOn?: number[] | ((response: Response, error?: Error) => boolean);
|
||||
/** Callback on each retry attempt */
|
||||
onRetry?: (attempt: number, error: Error, nextDelay: number) => void;
|
||||
}
|
||||
|
||||
// ==================
|
||||
// Interceptor Types
|
||||
// ==================
|
||||
|
||||
export type TRequestInterceptor = (
|
||||
request: Request,
|
||||
) => Request | Promise<Request>;
|
||||
export type TResponseInterceptor = (
|
||||
response: Response,
|
||||
) => Response | Promise<Response>;
|
||||
|
||||
export interface IInterceptors {
|
||||
request?: TRequestInterceptor[];
|
||||
response?: TResponseInterceptor[];
|
||||
}
|
||||
|
||||
// ==================
|
||||
// Main Options
|
||||
// ==================
|
||||
|
||||
export interface IWebrequestOptions extends Omit<RequestInit, 'cache'> {
|
||||
// Caching
|
||||
cache?: TStandardCacheMode;
|
||||
cacheStrategy?: TCacheStrategy;
|
||||
cacheMaxAge?: number;
|
||||
cacheKey?: string | ((request: Request) => string);
|
||||
revalidate?: boolean;
|
||||
|
||||
// Retry & Fault Tolerance
|
||||
retry?: boolean | IRetryOptions;
|
||||
fallbackUrls?: string[];
|
||||
timeout?: number;
|
||||
|
||||
// Interceptors
|
||||
interceptors?: IInterceptors;
|
||||
|
||||
// Deduplication
|
||||
deduplicate?: boolean;
|
||||
|
||||
// Logging
|
||||
logging?: boolean;
|
||||
}
|
||||
|
||||
// ==================
|
||||
// Result Types
|
||||
// ==================
|
||||
|
||||
export interface IWebrequestSuccess<T> {
|
||||
ok: true;
|
||||
data: T;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
export interface IWebrequestError {
|
||||
ok: false;
|
||||
error: Error;
|
||||
response?: Response;
|
||||
}
|
||||
|
||||
export type TWebrequestResult<T> = IWebrequestSuccess<T> | IWebrequestError;
|
||||
|
||||
// ==================
|
||||
// Internal Types
|
||||
// ==================
|
||||
|
||||
export interface ICacheMetadata {
|
||||
maxAge: number;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
immutable: boolean;
|
||||
noCache: boolean;
|
||||
noStore: boolean;
|
||||
mustRevalidate: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user