Fetch API wrapper with middleware support, authentication, and request timing.
import { RequestInterceptor } from '@zappzarapp/browser-utils/request';
// Create interceptor with base URL and auth
const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
auth: {
type: 'bearer',
token: () => localStorage.getItem('token') ?? '',
},
});
// Make requests
const response = await api.fetch('/users');
const users = await response.json();
// Cleanup when done
api.destroy();| Feature | Description |
|---|---|
| Fetch Wrapper | Enhanced fetch with middleware support |
| Authentication | Bearer, API Key, Basic, and custom auth types |
| Request Middleware | Transform requests before sending |
| Response Middleware | Transform responses after receiving |
| Error Middleware | Handle errors in middleware chain |
| Request Timing | Track request duration and performance |
| URL Validation | Protocol allowlist and pattern blocking |
| Credential Protection | Prevent credential leakage to different origins |
| Content-Type Check | Validate response MIME type against expected |
| SSRF Protection | Block requests to private/internal IP addresses |
| Abort Signal Merge | Combine multiple AbortSignals into one |
| Download Progress | Track download progress via ReadableStream |
| Upload Progress | Track upload progress for request bodies |
| Type | Description |
|---|---|
RequestInterceptorConfig |
Configuration options for the interceptor |
RequestInterceptorInstance |
Interceptor instance with fetch methods |
RequestMiddleware |
Middleware definition with request/response hooks |
RequestConfig |
Immutable request configuration |
MutableRequestConfig |
Mutable config for middleware modification |
InterceptedResponse |
Response with timing and metadata |
RequestTiming |
Request timing information |
AuthConfig |
Authentication configuration |
HttpMethod |
HTTP method type |
RequestError |
Request-specific error class |
RequestErrorCode |
Error code enum |
combineAbortSignals |
Utility to merge two AbortSignals into one |
ProgressInfo |
Progress data: loaded, total, percentage |
ProgressCallback |
Progress event callback type |
ProgressMiddlewareOptions |
Options for progress middleware factory |
trackDownloadProgress |
Wrap Response body to track download progress |
trackUploadProgress |
Wrap request body to track upload progress |
createProgressMiddleware |
Middleware factory for upload/download progress |
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
'' |
Base URL prepended to all requests |
timeout |
number |
30000 |
Request timeout in milliseconds |
defaultHeaders |
Record<string,string> |
{} |
Headers added to all requests |
auth |
AuthConfig |
null |
Authentication configuration |
throwOnError |
boolean |
false |
Throw on non-2xx responses |
allowedProtocols |
string[] |
['https:'] |
Allowed URL protocols |
blockedPatterns |
RegExp[] |
[] |
URL patterns to block |
validateCredentialOrigin |
boolean |
true |
Prevent credentials to different origins |
blockPrivateIPs |
boolean |
false |
Block requests to private/internal IPs |
expectedContentType |
string | string[] |
undefined |
Validate response Content-Type |
Check if Fetch API is available.
if (RequestInterceptor.isSupported()) {
const api = RequestInterceptor.create({ baseUrl: 'https://api.example.com' });
}Returns: boolean
Create a new request interceptor instance.
const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
timeout: 10000,
defaultHeaders: {
'Content-Type': 'application/json',
},
});Parameters:
| Parameter | Type | Description |
|---|---|---|
config |
RequestInterceptorConfig |
Configuration options |
Returns: RequestInterceptorInstance
Make a fetch request.
const response = await api.fetch('/users', {
method: 'POST',
body: JSON.stringify({ name: 'Alice' }),
});Convenience methods for HTTP verbs.
// GET request
const users = await api.get('/users');
// POST request with body
const newUser = await api.post('/users', JSON.stringify({ name: 'Alice' }));
// PUT request
await api.put('/users/1', JSON.stringify({ name: 'Bob' }));
// PATCH request
await api.patch('/users/1', JSON.stringify({ active: true }));
// DELETE request
await api.delete('/users/1');Add middleware to the request chain.
const cleanup = api.use({
onRequest: (config) => {
config.headers.set('X-Request-ID', crypto.randomUUID());
console.log('Request:', config.method, config.url);
return config;
},
onResponse: (response) => {
console.log('Response:', response.status, response.duration + 'ms');
return response;
},
onError: (error, config) => {
console.error('Error:', error.code, config.url);
},
});
// Later: remove middleware
cleanup();Returns: CleanupFn
Subscribe to request timing events.
const cleanup = api.onTiming((timing) => {
console.log(`${timing.method} ${timing.url}: ${timing.duration}ms`);
if (timing.error) {
console.error('Failed:', timing.error);
}
});
// Later: unsubscribe
cleanup();Returns: CleanupFn
Get current configuration (frozen/immutable).
const config = api.getConfig();
console.log('Base URL:', config.baseUrl);
console.log('Timeout:', config.timeout);Returns: Readonly<RequestInterceptorConfig>
Update authentication configuration.
// Set new auth
api.setAuth({
type: 'bearer',
token: newToken,
});
// Remove auth
api.setAuth(null);Abort all pending requests. The interceptor remains usable for new requests
after calling abortAll().
// Abort all in-flight requests
api.abortAll();
// New requests work normally after abortAll
const response = await api.fetch('/users');Aborted requests will reject with a RequestError with code 'ABORTED'.
Destroy the interceptor and cleanup resources. Also aborts any pending requests.
api.destroy();
// Further requests will throwCheck if a header name is sensitive (should not be logged).
RequestInterceptor.isSensitiveHeader('authorization'); // true
RequestInterceptor.isSensitiveHeader('content-type'); // falseRedact sensitive headers for safe logging.
const headers = new Headers();
headers.set('Authorization', 'Bearer secret');
headers.set('Content-Type', 'application/json');
const safe = RequestInterceptor.redactHeaders(headers);
// { 'authorization': '[REDACTED]', 'content-type': 'application/json' }const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
auth: {
type: 'bearer',
token: 'your-access-token',
// Or use a function for dynamic tokens
// token: () => localStorage.getItem('token') ?? '',
// token: async () => await refreshToken(),
},
});const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
auth: {
type: 'api-key',
apiKey: 'your-api-key',
apiKeyHeader: 'X-API-Key', // default
},
});const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
auth: {
type: 'basic',
username: 'user',
password: 'pass',
},
});const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
auth: {
type: 'custom',
customHeader: {
name: 'X-Custom-Auth',
value: () => computeSignature(),
},
},
});api.use({
onRequest: (config) => {
console.log(`[${new Date().toISOString()}] ${config.method} ${config.url}`);
return config;
},
onResponse: (response) => {
console.log(
`[${response.status}] ${response.url} (${response.duration}ms)`
);
return response;
},
onError: (error) => {
console.error(`[ERROR] ${error.code}: ${error.message}`);
},
});api.use({
onError: async (error, config) => {
if (error.code === 'TIMEOUT' && config.metadata?.retryCount === undefined) {
// Retry logic would need custom implementation
console.log('Request timed out, consider retry');
}
},
});api.use({
onRequest: (config) => {
config.headers.set('X-Request-ID', crypto.randomUUID());
config.headers.set('X-Correlation-ID', getCorrelationId());
return config;
},
});api.use({
onResponse: (response) => {
// Log slow requests
if (response.duration > 1000) {
console.warn(`Slow request: ${response.url} took ${response.duration}ms`);
}
return response;
},
});Validate that responses have the expected MIME type. Fails closed — a missing
Content-Type header with expectedContentType set will throw. Comparison is
case-insensitive; parameters like charset are ignored.
// Single MIME type — applied to all requests
const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
expectedContentType: 'application/json',
});
// Multiple accepted types
const api2 = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
expectedContentType: ['application/json', 'application/ld+json'],
});
// Override per-request
const response = await api.fetch('/report.csv', {
expectedContentType: 'text/csv',
});Merge two AbortSignal instances into one. When either signal fires, the
combined signal aborts and listeners on the other signal are cleaned up to
prevent memory leaks.
import { combineAbortSignals } from '@zappzarapp/browser-utils/request';
const userController = new AbortController();
const timeoutController = new AbortController();
const combined = combineAbortSignals(
userController.signal,
timeoutController.signal
);
// Either signal aborting cancels the request
const response = await api.fetch('/data', { signal: combined });Track download progress by wrapping the response body ReadableStream. Progress
is reported on each chunk with loaded bytes, total (from Content-Length), and
percentage.
import { trackDownloadProgress } from '@zappzarapp/browser-utils/request';
const response = await fetch('https://example.com/large-file.zip');
const tracked = trackDownloadProgress(response, (progress) => {
console.log(`${progress.loaded} / ${progress.total ?? '?'} bytes`);
if (progress.percentage !== null) {
updateProgressBar(progress.percentage);
}
});
// Progress is reported as the body is consumed
const blob = await tracked.blob();import {
RequestInterceptor,
createProgressMiddleware,
} from '@zappzarapp/browser-utils/request';
const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
});
api.use(
createProgressMiddleware({
onDownloadProgress: (progress) => {
console.log(`Downloaded: ${progress.percentage ?? '?'}%`);
},
})
);
const response = await api.get('/files/large.zip');
const blob = await response.blob();| Property | Type | Description |
|---|---|---|
loaded |
number |
Bytes received so far |
total |
number | null |
Total bytes from Content-Length, null if unknown |
percentage |
number | null |
Download percentage (0-100), null if unknown |
Notes:
- If
Content-Lengthis missing,totalandpercentagearenull - Percentage is capped at 100 even if actual bytes exceed
Content-Length - A final progress event is emitted when the stream ends
- For responses with no body (e.g. 204), a single event is emitted with
loaded: 0 - Stream cancellation propagates to the original response body
Track upload progress by wrapping the request body in a ReadableStream that reports progress on each chunk. Total size is determined automatically for Blob, ArrayBuffer, string, and URLSearchParams bodies. For ReadableStream and FormData bodies, total is unknown.
import { trackUploadProgress } from '@zappzarapp/browser-utils/request';
const file = new Blob([largeData]);
const trackedBody = trackUploadProgress(file, (progress) => {
console.log(`${progress.loaded} / ${progress.total ?? '?'} bytes`);
if (progress.percentage !== null) {
updateProgressBar(progress.percentage);
}
});
await fetch('https://example.com/upload', {
method: 'POST',
body: trackedBody,
});import {
RequestInterceptor,
createProgressMiddleware,
} from '@zappzarapp/browser-utils/request';
const api = RequestInterceptor.create({
baseUrl: 'https://api.example.com',
});
api.use(
createProgressMiddleware({
onUploadProgress: (progress) => {
console.log(`Uploaded: ${progress.percentage ?? '?'}%`);
},
})
);
await api.post('/files', { body: new Blob([data]) });api.use(
createProgressMiddleware({
onUploadProgress: (progress) => {
uploadBar.style.width = `${String(progress.percentage ?? 0)}%`;
},
onDownloadProgress: (progress) => {
downloadBar.style.width = `${String(progress.percentage ?? 0)}%`;
},
})
);| Body Type | Total Known | Notes |
|---|---|---|
Blob |
Yes | Uses Blob.size |
ArrayBuffer |
Yes | Uses byteLength |
ArrayBufferView |
Yes | Uses byteLength |
string |
Yes | Uses UTF-8 encoded byte length |
URLSearchParams |
Yes | Uses encoded string byte length |
ReadableStream |
No | Size unknown at start |
FormData |
No | Multipart encoding unknown |
| Code | Description |
|---|---|
FETCH_NOT_SUPPORTED |
Fetch API not available |
INVALID_URL |
URL validation failed |
INVALID_CONFIG |
Invalid configuration |
REQUEST_FAILED |
Network or fetch error |
RESPONSE_ERROR |
Non-2xx response (when throwOnError: true) |
MIDDLEWARE_ERROR |
Error in middleware |
TIMEOUT |
Request timed out |
ABORTED |
Request was aborted |
CREDENTIAL_LEAK |
Attempted credential leak to different origin |
SSRF_BLOCKED |
Request to private/internal IP blocked |
CONTENT_TYPE_MISMATCH |
Response Content-Type does not match expected |
import { RequestError } from '@zappzarapp/browser-utils/request';
try {
const response = await api.fetch('/users');
} catch (error) {
if (error instanceof RequestError) {
switch (error.code) {
case 'TIMEOUT':
console.log('Request timed out');
break;
case 'CREDENTIAL_LEAK':
console.error('Security: credential leak prevented');
break;
case 'CONTENT_TYPE_MISMATCH':
console.error('Unexpected response type:', error.message);
break;
case 'SSRF_BLOCKED':
console.error('Security: private IP blocked');
break;
case 'INVALID_URL':
console.error('Invalid URL:', error.message);
break;
default:
console.error('Request failed:', error.message);
}
}
}-
Protocol Allowlist - Only HTTPS is allowed by default. HTTP and other protocols must be explicitly enabled.
-
Credential Origin Validation - When authentication is configured, the interceptor prevents sending credentials to different origins than the base URL.
-
URL Pattern Blocking - Block requests to specific URL patterns:
const api = RequestInterceptor.create({ baseUrl: 'https://api.example.com', blockedPatterns: [/internal\//, /admin\//], });
-
Sensitive Header Protection - Authorization and similar headers are automatically redacted in logging utilities.
-
No JavaScript/Data URLs - The interceptor blocks
javascript:anddata:URLs as a defense-in-depth measure. -
SSRF Protection - Optionally block requests to private/internal IP addresses (
blockPrivateIPs: true). Covers IPv4 private ranges (10.x, 172.16-31.x, 192.168.x), loopback (127.x, ::1), and link-local. -
Content-Type Validation - Validate response MIME types to prevent type-confusion attacks. Fails closed (missing header throws).
const controller = new AbortController();
// Start request
const responsePromise = api.fetch('/long-running', {
signal: controller.signal,
});
// Abort after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const response = await responsePromise;
} catch (error) {
if (error instanceof RequestError && error.code === 'ABORTED') {
console.log('Request was aborted');
}
}| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Fetch API | 42+ | 39+ | 10.1+ | 14+ |
| AbortController | 66+ | 57+ | 11.1+ | 16+ |
| Headers | 42+ | 39+ | 10.1+ | 14+ |
| ReadableStream | 43+ | 65+ | 10.1+ | 14+ |
| async/await | 55+ | 52+ | 10.1+ | 14+ |