Files
webrequest/ts/webrequest.client.ts
Juergen Kunz 54afcc46e2 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.
2025-10-20 09:59:24 +00:00

327 lines
8.7 KiB
TypeScript

/**
* 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();
}
}