fix(webrequest): complete rewrite to v4: migrate API to fetch-compatible function and new WebrequestClient, update tests and docs, and bump dependencies
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-02 - 4.0.4 - fix(webrequest)
|
||||
complete rewrite to v4: migrate API to fetch-compatible function and new WebrequestClient, update tests and docs, and bump dependencies
|
||||
|
||||
- Breaking API change: v3 class-based API removed; use webrequest() function or new WebrequestClient
|
||||
- Tests updated to use TypedServer and handler functions that return Response objects (new typedserver API)
|
||||
- Dependencies bumped ( @api.global/typedserver, @git.zone/tsbuild, @git.zone/tsbundle, @git.zone/tstest, @types/node, @push.rocks/smartenv, @push.rocks/smartjson )
|
||||
- README updated with migration guide, cross-runtime notes, and example changes
|
||||
- Build script simplified (removed --web flag)
|
||||
|
||||
## 2026-02-15 - 4.0.3 - fix(webrequest)
|
||||
no changes in this commit
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -10,20 +10,20 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web --allowimplicitany && tsbundle npm)",
|
||||
"build": "(tsbuild && tsbundle npm)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@api.global/typedserver": "^3.0.27",
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@git.zone/tsbundle": "^2.0.15",
|
||||
"@git.zone/tstest": "^2.6.2",
|
||||
"@types/node": "^20.12.7"
|
||||
"@api.global/typedserver": "^8.4.0",
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.9.0",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@types/node": "^25.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartenv": "^5.0.12",
|
||||
"@push.rocks/smartjson": "^5.2.0",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
"@push.rocks/smartjson": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/webstore": "^2.0.13"
|
||||
},
|
||||
|
||||
5191
pnpm-lock.yaml
generated
5191
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
364
readme.md
364
readme.md
@@ -1,18 +1,23 @@
|
||||
# @push.rocks/webrequest
|
||||
|
||||
Modern, fetch-compatible web request library with intelligent HTTP caching, retry strategies, and advanced fault tolerance.
|
||||
Modern, fetch-compatible web request library with intelligent HTTP caching, retry strategies, and advanced fault tolerance. Works seamlessly in browsers, Node.js, Deno, and Bun.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Features
|
||||
|
||||
- 🌐 **Fetch-Compatible API** - Drop-in replacement for native `fetch()` with enhanced features
|
||||
- 💾 **Intelligent HTTP Caching** - Respects `Cache-Control`, `ETag`, `Last-Modified`, and `Expires` headers (RFC 7234)
|
||||
- 🔄 **Multiple Cache Strategies** - network-first, cache-first, stale-while-revalidate, network-only, cache-only
|
||||
- 🔁 **Advanced Retry System** - Configurable retry with exponential/linear/constant backoff
|
||||
- 🎯 **Request/Response Interceptors** - Middleware pattern for transforming requests and responses
|
||||
- 🚫 **Request Deduplication** - Automatically deduplicate simultaneous identical requests
|
||||
- 📘 **TypeScript Generics** - Type-safe response parsing with `webrequest.getJson<T>()`
|
||||
- 🛡️ **Better Fault Tolerance** - Multi-endpoint fallback with retry strategies
|
||||
- ⏱️ **Timeout Support** - Configurable request timeouts with AbortController
|
||||
- 🌐 **Fetch-Compatible API** — Drop-in replacement for native `fetch()` with enhanced features
|
||||
- 💾 **Intelligent HTTP Caching** — Respects `Cache-Control`, `ETag`, `Last-Modified`, and `Expires` headers (RFC 7234)
|
||||
- 🔄 **Multiple Cache Strategies** — network-first, cache-first, stale-while-revalidate, network-only, cache-only
|
||||
- 🔁 **Advanced Retry System** — Configurable retry with exponential, linear, or constant backoff
|
||||
- 🎯 **Request/Response Interceptors** — Middleware pattern for transforming requests and responses
|
||||
- 🚫 **Request Deduplication** — Automatically deduplicate simultaneous identical requests
|
||||
- 📘 **TypeScript Generics** — Type-safe response parsing with `webrequest.getJson<T>()`
|
||||
- 🛡️ **Multi-Endpoint Fallback** — Fault tolerance via fallback URLs with retry strategies
|
||||
- ⏱️ **Timeout Support** — Configurable request timeouts with AbortController
|
||||
- 🌍 **Cross-Runtime** — Works in browsers, Node.js, Deno, and Bun
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -41,7 +46,7 @@ const response = await webrequest('https://api.example.com/data', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'value' }),
|
||||
timeout: 30000,
|
||||
retry: true
|
||||
retry: true,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -63,12 +68,11 @@ const user = await webrequest.getJson<User>('https://api.example.com/user/1');
|
||||
// POST JSON
|
||||
const result = await webrequest.postJson('https://api.example.com/users', {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
email: 'john@example.com',
|
||||
});
|
||||
|
||||
// Other convenience methods
|
||||
// PUT and DELETE
|
||||
await webrequest.putJson(url, data);
|
||||
await webrequest.patchJson(url, data);
|
||||
await webrequest.deleteJson(url);
|
||||
```
|
||||
|
||||
@@ -80,7 +84,7 @@ Always fetch from network, fall back to cache on failure. Respects HTTP caching
|
||||
|
||||
```typescript
|
||||
const data = await webrequest.getJson('https://api.example.com/data', {
|
||||
cacheStrategy: 'network-first'
|
||||
cacheStrategy: 'network-first',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -91,7 +95,7 @@ Check cache first, only fetch from network if not cached or stale.
|
||||
```typescript
|
||||
const data = await webrequest.getJson('https://api.example.com/data', {
|
||||
cacheStrategy: 'cache-first',
|
||||
cacheMaxAge: 60000 // 60 seconds
|
||||
cacheMaxAge: 60000, // 60 seconds
|
||||
});
|
||||
```
|
||||
|
||||
@@ -101,7 +105,7 @@ Return cached data immediately, update in background.
|
||||
|
||||
```typescript
|
||||
const data = await webrequest.getJson('https://api.example.com/data', {
|
||||
cacheStrategy: 'stale-while-revalidate'
|
||||
cacheStrategy: 'stale-while-revalidate',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -109,13 +113,13 @@ const data = await webrequest.getJson('https://api.example.com/data', {
|
||||
|
||||
```typescript
|
||||
// Always fetch from network, never cache
|
||||
const data = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'network-only'
|
||||
const fresh = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'network-only',
|
||||
});
|
||||
|
||||
// Only use cache, never fetch from network
|
||||
const data = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'cache-only'
|
||||
const cached = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'cache-only',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -126,12 +130,12 @@ The library automatically respects HTTP caching headers:
|
||||
```typescript
|
||||
// Server returns: Cache-Control: max-age=3600, ETag: "abc123"
|
||||
const response = await webrequest('https://api.example.com/data', {
|
||||
cacheStrategy: 'network-first'
|
||||
cacheStrategy: 'network-first',
|
||||
});
|
||||
|
||||
// Subsequent requests automatically send:
|
||||
// If-None-Match: "abc123"
|
||||
// Server returns 304 Not Modified - cache is used
|
||||
// Server returns 304 Not Modified — cache is used
|
||||
```
|
||||
|
||||
### Custom Cache Keys
|
||||
@@ -142,7 +146,7 @@ const response = await webrequest('https://api.example.com/search?q=test', {
|
||||
cacheKey: (request) => {
|
||||
const url = new URL(request.url);
|
||||
return `search:${url.searchParams.get('q')}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -152,7 +156,7 @@ const response = await webrequest('https://api.example.com/search?q=test', {
|
||||
|
||||
```typescript
|
||||
const response = await webrequest('https://api.example.com/data', {
|
||||
retry: true // Uses defaults: 3 attempts, exponential backoff
|
||||
retry: true, // Uses defaults: 3 attempts, exponential backoff
|
||||
});
|
||||
```
|
||||
|
||||
@@ -165,29 +169,33 @@ const response = await webrequest('https://api.example.com/data', {
|
||||
backoff: 'exponential', // or 'linear', 'constant'
|
||||
initialDelay: 1000, // 1 second
|
||||
maxDelay: 30000, // 30 seconds
|
||||
retryOn: [408, 429, 500, 502, 503, 504], // Status codes to retry
|
||||
retryOn: [408, 429, 500, 502, 503, 504],
|
||||
onRetry: (attempt, error, nextDelay) => {
|
||||
console.log(`Retry attempt ${attempt}, waiting ${nextDelay}ms`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Endpoint Fallback
|
||||
|
||||
When the primary endpoint fails, automatically try fallback URLs:
|
||||
|
||||
```typescript
|
||||
const response = await webrequest('https://api1.example.com/data', {
|
||||
fallbackUrls: [
|
||||
'https://api2.example.com/data',
|
||||
'https://api3.example.com/data'
|
||||
'https://api3.example.com/data',
|
||||
],
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoff: 'exponential'
|
||||
}
|
||||
backoff: 'exponential',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Each URL is tried with the configured retry strategy. If all attempts for a URL fail with server errors, the next fallback URL is tried.
|
||||
|
||||
## Request/Response Interceptors
|
||||
|
||||
### Global Interceptors
|
||||
@@ -211,8 +219,11 @@ webrequest.addResponseInterceptor((response) => {
|
||||
// Handle errors globally
|
||||
webrequest.addErrorInterceptor((error) => {
|
||||
console.error('Request failed:', error);
|
||||
throw error;
|
||||
return error;
|
||||
});
|
||||
|
||||
// Clear all interceptors when needed
|
||||
webrequest.clearInterceptors();
|
||||
```
|
||||
|
||||
### Per-Request Interceptors
|
||||
@@ -228,11 +239,7 @@ const response = await webrequest('https://api.example.com/data', {
|
||||
console.log('Received:', res.status);
|
||||
return res;
|
||||
}],
|
||||
error: [(err) => {
|
||||
console.error('Error:', err);
|
||||
throw err;
|
||||
}]
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -248,12 +255,14 @@ const [res1, res2, res3] = await Promise.all([
|
||||
webrequest('https://api.example.com/data', { deduplicate: true }),
|
||||
]);
|
||||
|
||||
// All three get the same response
|
||||
// All three get the same response (cloned)
|
||||
```
|
||||
|
||||
## Client API with Default Options
|
||||
Deduplication works for GET and HEAD requests by matching URL + method. Non-GET/HEAD requests are not deduplicated since they may have different bodies.
|
||||
|
||||
For more control, use `WebrequestClient` to set default options:
|
||||
## WebrequestClient
|
||||
|
||||
For more control, use `WebrequestClient` to set default options that apply to all requests made through that client:
|
||||
|
||||
```typescript
|
||||
import { WebrequestClient } from '@push.rocks/webrequest';
|
||||
@@ -264,22 +273,25 @@ const apiClient = new WebrequestClient({
|
||||
cacheStrategy: 'network-first',
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoff: 'exponential'
|
||||
}
|
||||
backoff: 'exponential',
|
||||
},
|
||||
});
|
||||
|
||||
// Add global interceptors to this client
|
||||
apiClient.addRequestInterceptor((request) => {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('X-API-Key', process.env.API_KEY);
|
||||
headers.set('X-API-Key', 'my-key');
|
||||
return new Request(request, { headers });
|
||||
});
|
||||
|
||||
// All requests through this client use the configured defaults
|
||||
const data = await apiClient.getJson('https://api.example.com/data');
|
||||
|
||||
// Standard fetch-compatible API also available
|
||||
// Standard fetch-compatible API
|
||||
const response = await apiClient.request('https://api.example.com/data');
|
||||
|
||||
// Create a client from the webrequest function
|
||||
const client = webrequest.createClient({ timeout: 5000 });
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
@@ -288,18 +300,7 @@ const response = await apiClient.request('https://api.example.com/data');
|
||||
|
||||
```typescript
|
||||
const response = await webrequest('https://api.example.com/data', {
|
||||
timeout: 5000 // 5 seconds
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Headers
|
||||
|
||||
```typescript
|
||||
const response = await webrequest('https://api.example.com/data', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer token123',
|
||||
'X-Custom-Header': 'value'
|
||||
}
|
||||
timeout: 5000, // 5 seconds — throws Error on timeout
|
||||
});
|
||||
```
|
||||
|
||||
@@ -308,34 +309,6 @@ const response = await webrequest('https://api.example.com/data', {
|
||||
```typescript
|
||||
// Clear all cached responses
|
||||
await webrequest.clearCache();
|
||||
|
||||
// Clear specific cache entry
|
||||
await webrequest.clearCache('https://api.example.com/data');
|
||||
```
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript support with generics for type-safe responses:
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Fully typed response
|
||||
const response = await webrequest.getJson<ApiResponse<User>>(
|
||||
'https://api.example.com/user/1'
|
||||
);
|
||||
|
||||
// response.data.id, response.data.name, response.data.email are all typed
|
||||
```
|
||||
|
||||
## API Reference
|
||||
@@ -352,7 +325,6 @@ webrequest(input: string | Request | URL, options?: IWebrequestOptions): Promise
|
||||
webrequest.getJson<T>(url: string, options?: IWebrequestOptions): Promise<T>
|
||||
webrequest.postJson<T>(url: string, body: any, options?: IWebrequestOptions): Promise<T>
|
||||
webrequest.putJson<T>(url: string, body: any, options?: IWebrequestOptions): Promise<T>
|
||||
webrequest.patchJson<T>(url: string, body: any, options?: IWebrequestOptions): Promise<T>
|
||||
webrequest.deleteJson<T>(url: string, options?: IWebrequestOptions): Promise<T>
|
||||
```
|
||||
|
||||
@@ -362,7 +334,10 @@ webrequest.deleteJson<T>(url: string, options?: IWebrequestOptions): Promise<T>
|
||||
webrequest.addRequestInterceptor(interceptor: TRequestInterceptor): void
|
||||
webrequest.addResponseInterceptor(interceptor: TResponseInterceptor): void
|
||||
webrequest.addErrorInterceptor(interceptor: TErrorInterceptor): void
|
||||
webrequest.clearCache(url?: string): Promise<void>
|
||||
webrequest.clearInterceptors(): void
|
||||
webrequest.clearCache(): Promise<void>
|
||||
webrequest.createClient(options?: Partial<IWebrequestOptions>): WebrequestClient
|
||||
webrequest.getDefaultClient(): WebrequestClient
|
||||
```
|
||||
|
||||
### Options Interface
|
||||
@@ -374,140 +349,43 @@ interface IWebrequestOptions extends Omit<RequestInit, 'cache'> {
|
||||
headers?: HeadersInit;
|
||||
body?: BodyInit;
|
||||
|
||||
// Enhanced options
|
||||
// Caching
|
||||
cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache' | 'only-if-cached';
|
||||
cacheStrategy?: 'network-first' | 'cache-first' | 'stale-while-revalidate' | 'network-only' | 'cache-only';
|
||||
cacheMaxAge?: number; // milliseconds
|
||||
cacheKey?: (request: Request) => string;
|
||||
cacheMaxAge?: number;
|
||||
cacheKey?: string | ((request: Request) => string);
|
||||
revalidate?: boolean;
|
||||
|
||||
// Retry & Fault Tolerance
|
||||
retry?: boolean | IRetryOptions;
|
||||
fallbackUrls?: string[];
|
||||
timeout?: number; // milliseconds
|
||||
timeout?: number;
|
||||
|
||||
// Interceptors
|
||||
interceptors?: {
|
||||
request?: TRequestInterceptor[];
|
||||
response?: TResponseInterceptor[];
|
||||
error?: TErrorInterceptor[];
|
||||
};
|
||||
|
||||
// Deduplication
|
||||
deduplicate?: boolean;
|
||||
|
||||
// Logging
|
||||
logging?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from v3
|
||||
|
||||
Version 4.0 is a **complete rewrite** of `@push.rocks/webrequest` with breaking changes. **The v3 API has been completely removed** - all v3 code must be migrated to v4.
|
||||
|
||||
### What's New in v4
|
||||
|
||||
- **Fetch-Compatible API**: Drop-in replacement for native `fetch()` with enhanced features
|
||||
- **Intelligent HTTP Caching**: Respects `Cache-Control`, `ETag`, `Last-Modified`, and `Expires` headers (RFC 7234)
|
||||
- **Multiple Cache Strategies**: network-first, cache-first, stale-while-revalidate, network-only, cache-only
|
||||
- **Advanced Retry System**: Configurable retry with exponential/linear/constant backoff
|
||||
- **Request/Response Interceptors**: Middleware pattern for transforming requests and responses
|
||||
- **Request Deduplication**: Automatically deduplicate simultaneous identical requests
|
||||
- **TypeScript Generics**: Type-safe response parsing with `webrequest.getJson<T>()`
|
||||
- **Better Fault Tolerance**: Enhanced multi-endpoint fallback with retry strategies
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
1. **Removed `WebRequest` class** - Use `webrequest` function or `WebrequestClient` class instead
|
||||
2. **Cache API changed** - Boolean `useCache` replaced with explicit `cacheStrategy` options
|
||||
3. **Multi-endpoint API changed** - `requestMultiEndpoint()` replaced with `fallbackUrls` option
|
||||
|
||||
### Migration Examples
|
||||
|
||||
#### Basic Fetch-Compatible Usage
|
||||
### Retry Options
|
||||
|
||||
```typescript
|
||||
// v3
|
||||
import { WebRequest } from '@push.rocks/webrequest';
|
||||
const client = new WebRequest();
|
||||
const response = await client.request('https://api.example.com/data', {
|
||||
method: 'GET'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// v4 - Fetch-compatible
|
||||
import { webrequest } from '@push.rocks/webrequest';
|
||||
const response = await webrequest('https://api.example.com/data');
|
||||
const data = await response.json();
|
||||
```
|
||||
|
||||
#### JSON Convenience Methods
|
||||
|
||||
```typescript
|
||||
// v3
|
||||
const client = new WebRequest();
|
||||
const data = await client.getJson('https://api.example.com/data', true);
|
||||
|
||||
// v4 - Function API
|
||||
import { webrequest } from '@push.rocks/webrequest';
|
||||
const data = await webrequest.getJson('https://api.example.com/data', {
|
||||
cacheStrategy: 'cache-first'
|
||||
});
|
||||
|
||||
// v4 - Client API (similar to v3)
|
||||
import { WebrequestClient } from '@push.rocks/webrequest';
|
||||
const client = new WebrequestClient({ logging: true });
|
||||
const data = await client.getJson('https://api.example.com/data', {
|
||||
cacheStrategy: 'cache-first'
|
||||
});
|
||||
```
|
||||
|
||||
#### Caching
|
||||
|
||||
```typescript
|
||||
// v3 - Boolean flag
|
||||
const data = await client.getJson(url, true); // useCache = true
|
||||
|
||||
// v4 - Explicit strategies
|
||||
const data = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'cache-first',
|
||||
cacheMaxAge: 60000 // 60 seconds
|
||||
});
|
||||
|
||||
// v4 - HTTP header-based caching (automatic)
|
||||
const data = await webrequest.getJson(url, {
|
||||
cacheStrategy: 'network-first' // Respects Cache-Control headers
|
||||
});
|
||||
```
|
||||
|
||||
#### Multi-Endpoint Fallback
|
||||
|
||||
```typescript
|
||||
// v3
|
||||
const client = new WebRequest();
|
||||
const response = await client.requestMultiEndpoint(
|
||||
['https://api1.example.com/data', 'https://api2.example.com/data'],
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
// v4
|
||||
import { webrequest } from '@push.rocks/webrequest';
|
||||
const response = await webrequest('https://api1.example.com/data', {
|
||||
fallbackUrls: ['https://api2.example.com/data'],
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoff: 'exponential'
|
||||
interface IRetryOptions {
|
||||
maxAttempts?: number; // Default: 3
|
||||
backoff?: 'exponential' | 'linear' | 'constant'; // Default: 'exponential'
|
||||
initialDelay?: number; // Default: 1000 (ms)
|
||||
maxDelay?: number; // Default: 30000 (ms)
|
||||
retryOn?: number[] | ((response: Response, error?: Error) => boolean);
|
||||
onRetry?: (attempt: number, error: Error, nextDelay: number) => void;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Timeout
|
||||
|
||||
```typescript
|
||||
// v3
|
||||
const response = await client.request(url, {
|
||||
method: 'GET',
|
||||
timeoutMs: 30000
|
||||
});
|
||||
|
||||
// v4
|
||||
const response = await webrequest(url, {
|
||||
timeout: 30000 // milliseconds
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
@@ -527,35 +405,24 @@ async function fetchUserData(userId: string) {
|
||||
const user = await webrequest.getJson<User>(
|
||||
`https://api.example.com/users/${userId}`,
|
||||
{
|
||||
// Caching
|
||||
cacheStrategy: 'stale-while-revalidate',
|
||||
cacheMaxAge: 300000, // 5 minutes
|
||||
|
||||
// Retry
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoff: 'exponential',
|
||||
retryOn: [500, 502, 503, 504]
|
||||
retryOn: [500, 502, 503, 504],
|
||||
},
|
||||
|
||||
// Fallback
|
||||
fallbackUrls: [
|
||||
'https://api-backup.example.com/users/${userId}'
|
||||
`https://api-backup.example.com/users/${userId}`,
|
||||
],
|
||||
|
||||
// Timeout
|
||||
timeout: 10000, // 10 seconds
|
||||
|
||||
// Deduplication
|
||||
timeout: 10000,
|
||||
deduplicate: true,
|
||||
|
||||
// Per-request interceptor
|
||||
interceptors: {
|
||||
request: [(req) => {
|
||||
console.log(`Fetching user ${userId}`);
|
||||
return req;
|
||||
}]
|
||||
}
|
||||
}],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -568,6 +435,17 @@ async function fetchUserData(userId: string) {
|
||||
```typescript
|
||||
import { WebrequestClient } from '@push.rocks/webrequest';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface CreateUserData {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: WebrequestClient;
|
||||
|
||||
@@ -577,11 +455,10 @@ class ApiClient {
|
||||
cacheStrategy: 'network-first',
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoff: 'exponential'
|
||||
}
|
||||
backoff: 'exponential',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth interceptor
|
||||
this.client.addRequestInterceptor((request) => {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('Authorization', `Bearer ${this.apiKey}`);
|
||||
@@ -598,7 +475,7 @@ class ApiClient {
|
||||
return this.client.postJson<User>(`${this.baseUrl}/users`, data);
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: UpdateUserData): Promise<User> {
|
||||
async updateUser(id: string, data: Partial<User>): Promise<User> {
|
||||
return this.client.putJson<User>(`${this.baseUrl}/users/${id}`, data);
|
||||
}
|
||||
|
||||
@@ -607,26 +484,59 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const api = new ApiClient('https://api.example.com', process.env.API_KEY);
|
||||
const api = new ApiClient('https://api.example.com', 'my-api-key');
|
||||
const user = await api.getUser('123');
|
||||
```
|
||||
|
||||
## Migration from v3
|
||||
|
||||
Version 4.0 is a **complete rewrite** of `@push.rocks/webrequest`. The v3 API has been removed entirely.
|
||||
|
||||
### Key Changes
|
||||
|
||||
| v3 | v4 |
|
||||
|---|---|
|
||||
| `new WebRequest()` | `webrequest()` function or `new WebrequestClient()` |
|
||||
| `client.getJson(url, true)` | `webrequest.getJson(url, { cacheStrategy: 'cache-first' })` |
|
||||
| `client.requestMultiEndpoint([...urls])` | `webrequest(url, { fallbackUrls: [...] })` |
|
||||
| `request(url, { timeoutMs: 30000 })` | `webrequest(url, { timeout: 30000 })` |
|
||||
|
||||
### Migration Examples
|
||||
|
||||
```typescript
|
||||
// v3 — Class-based
|
||||
import { WebRequest } from '@push.rocks/webrequest';
|
||||
const client = new WebRequest();
|
||||
const response = await client.request('https://api.example.com/data', { method: 'GET' });
|
||||
|
||||
// v4 — Function-based (fetch-compatible)
|
||||
import { webrequest } from '@push.rocks/webrequest';
|
||||
const response = await webrequest('https://api.example.com/data');
|
||||
const data = await response.json();
|
||||
|
||||
// v4 — Client-based (when you need defaults)
|
||||
import { WebrequestClient } from '@push.rocks/webrequest';
|
||||
const client = new WebrequestClient({ timeout: 30000 });
|
||||
const data = await client.getJson('https://api.example.com/data');
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
43
test/test.ts
43
test/test.ts
@@ -2,42 +2,31 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { webrequest } from '../ts/index.js';
|
||||
|
||||
// test dependencies
|
||||
import * as typedserver from '@api.global/typedserver';
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
let testServer: typedserver.servertools.Server;
|
||||
let testServer: TypedServer;
|
||||
|
||||
tap.test('setup test server', async () => {
|
||||
testServer = new typedserver.servertools.Server({
|
||||
testServer = new TypedServer({
|
||||
cors: false,
|
||||
forceSsl: false,
|
||||
port: 2345,
|
||||
});
|
||||
|
||||
testServer.addRoute(
|
||||
'/apiroute1',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.status(500);
|
||||
res.end();
|
||||
}),
|
||||
);
|
||||
|
||||
testServer.addRoute(
|
||||
'/apiroute2',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.status(500);
|
||||
res.end();
|
||||
}),
|
||||
);
|
||||
|
||||
testServer.addRoute(
|
||||
'/apiroute3',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.status(200);
|
||||
res.send({
|
||||
hithere: 'hi',
|
||||
testServer.addRoute('/apiroute1', 'GET', async (ctx) => {
|
||||
return new Response(null, { status: 500 });
|
||||
});
|
||||
|
||||
testServer.addRoute('/apiroute2', 'GET', async (ctx) => {
|
||||
return new Response(null, { status: 500 });
|
||||
});
|
||||
|
||||
testServer.addRoute('/apiroute3', 'GET', async (ctx) => {
|
||||
return new Response(JSON.stringify({ hithere: 'hi' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await testServer.start();
|
||||
});
|
||||
|
||||
@@ -1,96 +1,88 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { webrequest, WebrequestClient } from '../ts/index.js';
|
||||
import * as typedserver from '@api.global/typedserver';
|
||||
import { TypedServer } from '@api.global/typedserver';
|
||||
|
||||
let testServer: typedserver.servertools.Server;
|
||||
let testServer: TypedServer;
|
||||
|
||||
// Setup test server
|
||||
tap.test('setup test server for v4 tests', async () => {
|
||||
testServer = new typedserver.servertools.Server({
|
||||
testServer = new TypedServer({
|
||||
cors: false,
|
||||
forceSsl: false,
|
||||
port: 2346,
|
||||
});
|
||||
|
||||
// Route that returns JSON with cache headers
|
||||
testServer.addRoute(
|
||||
'/cached',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'max-age=60');
|
||||
res.setHeader('ETag', '"12345"');
|
||||
res.status(200);
|
||||
res.send({ data: 'cached response', timestamp: Date.now() });
|
||||
}),
|
||||
);
|
||||
testServer.addRoute('/cached', 'GET', async (ctx) => {
|
||||
return new Response(JSON.stringify({ data: 'cached response', timestamp: Date.now() }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'max-age=60',
|
||||
'ETag': '"12345"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Route that returns different data each time
|
||||
testServer.addRoute(
|
||||
'/dynamic',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.status(200);
|
||||
res.send({ data: 'dynamic response', timestamp: Date.now() });
|
||||
}),
|
||||
);
|
||||
testServer.addRoute('/dynamic', 'GET', async (ctx) => {
|
||||
return new Response(JSON.stringify({ data: 'dynamic response', timestamp: Date.now() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Route that sometimes fails (for retry testing)
|
||||
let requestCount = 0;
|
||||
testServer.addRoute(
|
||||
'/flaky',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
testServer.addRoute('/flaky', 'GET', async (ctx) => {
|
||||
requestCount++;
|
||||
if (requestCount < 3) {
|
||||
res.status(500);
|
||||
res.end();
|
||||
return new Response(null, { status: 500 });
|
||||
} else {
|
||||
res.status(200);
|
||||
res.send({ success: true, attempts: requestCount });
|
||||
return new Response(JSON.stringify({ success: true, attempts: requestCount }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Route that always fails with 500 (for fallback testing)
|
||||
testServer.addRoute(
|
||||
'/always-fails',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
res.status(500);
|
||||
res.end();
|
||||
}),
|
||||
);
|
||||
testServer.addRoute('/always-fails', 'GET', async (ctx) => {
|
||||
return new Response(null, { status: 500 });
|
||||
});
|
||||
|
||||
// Route that takes a long time to respond (for timeout testing)
|
||||
testServer.addRoute(
|
||||
'/slow',
|
||||
new typedserver.servertools.Handler('GET', async (req, res) => {
|
||||
testServer.addRoute('/slow', 'GET', async (ctx) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
res.status(200);
|
||||
res.send({ data: 'slow response' });
|
||||
}),
|
||||
);
|
||||
return new Response(JSON.stringify({ data: 'slow response' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
// Route that returns 304 when ETag matches
|
||||
testServer.addRoute(
|
||||
'/conditional',
|
||||
new typedserver.servertools.Handler('GET', (req, res) => {
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
testServer.addRoute('/conditional', 'GET', async (ctx) => {
|
||||
const ifNoneMatch = ctx.request.headers.get('if-none-match');
|
||||
if (ifNoneMatch === '"67890"') {
|
||||
res.status(304);
|
||||
res.end();
|
||||
return new Response(null, { status: 304 });
|
||||
} else {
|
||||
res.setHeader('ETag', '"67890"');
|
||||
res.status(200);
|
||||
res.send({ data: 'conditional response' });
|
||||
return new Response(JSON.stringify({ data: 'conditional response' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'ETag': '"67890"',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// POST route for testing
|
||||
testServer.addRoute(
|
||||
'/post',
|
||||
new typedserver.servertools.Handler('POST', (req, res) => {
|
||||
res.status(200);
|
||||
res.send({ received: true });
|
||||
}),
|
||||
);
|
||||
testServer.addRoute('/post', 'POST', async (ctx) => {
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await testServer.start();
|
||||
});
|
||||
@@ -247,8 +239,6 @@ tap.test('should support global interceptors', async () => {
|
||||
|
||||
// Test 11: Request deduplication
|
||||
tap.test('should deduplicate simultaneous requests', async () => {
|
||||
const start = Date.now();
|
||||
|
||||
// Make 3 identical requests simultaneously
|
||||
const [res1, res2, res3] = await Promise.all([
|
||||
webrequest('http://localhost:2346/dynamic', { deduplicate: true }),
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/webrequest',
|
||||
version: '4.0.3',
|
||||
version: '4.0.4',
|
||||
description: 'Modern, fetch-compatible web request library with intelligent HTTP caching, retry strategies, and fault tolerance.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user