@alwatr/fetch 10.0.0 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -222
- package/dist/cache.d.ts +15 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/dedupe.d.ts +21 -0
- package/dist/dedupe.d.ts.map +1 -0
- package/dist/dev/main.js +3 -3
- package/dist/dev/main.js.map +10 -6
- package/dist/error.d.ts +35 -15
- package/dist/error.d.ts.map +1 -1
- package/dist/fetch.d.ts +73 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/main.d.ts +1 -93
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +10 -6
- package/dist/options.d.ts +40 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/retry.d.ts +26 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/timeout.d.ts +13 -0
- package/dist/timeout.d.ts.map +1 -0
- package/dist/type.d.ts +62 -23
- package/dist/type.d.ts.map +1 -1
- package/package.json +5 -6
- package/src/cache.ts +146 -0
- package/src/dedupe.ts +64 -0
- package/src/error.ts +67 -15
- package/src/fetch.ts +169 -0
- package/src/main.ts +1 -188
- package/src/options.ts +191 -0
- package/src/retry.ts +106 -0
- package/src/timeout.ts +85 -0
- package/src/type.ts +82 -25
- package/dist/core.d.ts +0 -34
- package/dist/core.d.ts.map +0 -1
- package/src/core.ts +0 -343
package/README.md
CHANGED
|
@@ -2,45 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
`@alwatr/fetch` is an enhanced, lightweight, and dependency-free wrapper for the native `fetch` API. It provides modern features like caching strategies, request retries, timeouts,
|
|
5
|
+
`@alwatr/fetch` is an enhanced, lightweight, and dependency-free wrapper for the native `fetch` API. It provides modern features like granular semantic error reporting, Go-style tuple returns, caching strategies, intelligent request retries with `Retry-After` support, timeouts, multi-tenant duplicate request coalescing, and header isolation—all in a compact package.
|
|
6
6
|
|
|
7
|
-
It
|
|
7
|
+
It is designed to be a production-ready replacement for standard `fetch` to instantly upgrade your application's network layer.
|
|
8
|
+
|
|
9
|
+
---
|
|
8
10
|
|
|
9
11
|
## Key Features
|
|
10
12
|
|
|
11
|
-
- **Go-Style Error Handling**: Returns a tuple `[Response, null]` on success or `[null, FetchError]` on failure—
|
|
12
|
-
- **
|
|
13
|
-
-
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
13
|
+
- **Go-Style Error Handling**: Returns a tuple `[Response, null]` (or `[T, null]` for `fetchJson`) on success or `[null, FetchError]` on failure—never throws exceptions.
|
|
14
|
+
- **Granular Semantic Error Reasons**: Maps HTTP status codes directly to semantic error reasons (e.g. `unauthorized`, `forbidden`, `not_found`, `rate_limited`, `server_error`).
|
|
15
|
+
- **`fetchJson<T>` Helper**: Directly parses JSON payloads with unconstrained generic typing (`T`), optional `ok: true` verification (`requireJsonResponseWithOkTrue`), and detailed parsing error feedback.
|
|
16
|
+
- **Smart Retry Pattern**: Automatically retries failed requests on transient server errors (5xx), `429 Too Many Requests`, `408 Request Timeout`, or network failures, automatically respecting `Retry-After` headers.
|
|
17
|
+
- **Header Isolation & Security**: Guarantees zero header pollution across requests, protects caller-supplied header objects from mutation, and isolates multi-tenant request deduplication by authorization token.
|
|
18
|
+
- **Request Timeout**: Aborts requests exceeding the specified timeout duration with automatic resource and listener cleanup.
|
|
19
|
+
- **Duplicate Coalescing**: Prevents redundant in-flight network round-trips for identical parallel requests (`removeDuplicate`).
|
|
20
|
+
- **Caching Strategies**: Leverages the browser's Cache API with strategies like `stale_while_revalidate`, `network_first`, `cache_first`, and `update_cache`.
|
|
21
|
+
- **Option Normalization Boundary**: Pre-processes and sanitizes all options (duration strings, header casing, non-cacheable HTTP methods, offline detection) at the ingestion boundary (`processOptions_`).
|
|
22
|
+
|
|
23
|
+
---
|
|
18
24
|
|
|
19
25
|
## Installation
|
|
20
26
|
|
|
21
27
|
Install the package using your preferred package manager:
|
|
22
28
|
|
|
23
29
|
```bash
|
|
30
|
+
# bun
|
|
31
|
+
bun add @alwatr/fetch
|
|
32
|
+
|
|
24
33
|
# npm
|
|
25
34
|
npm i @alwatr/fetch
|
|
26
35
|
|
|
27
|
-
# yarn
|
|
28
|
-
yarn add @alwatr/fetch
|
|
29
|
-
|
|
30
36
|
# pnpm
|
|
31
37
|
pnpm add @alwatr/fetch
|
|
32
38
|
```
|
|
33
39
|
|
|
40
|
+
---
|
|
41
|
+
|
|
34
42
|
## Quick Start
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
### 1. Standard Response Fetch (`fetch`)
|
|
37
45
|
|
|
38
46
|
```typescript
|
|
39
47
|
import {fetch} from '@alwatr/fetch';
|
|
40
48
|
|
|
41
49
|
async function fetchProducts() {
|
|
42
|
-
console.log('Fetching product list...');
|
|
43
|
-
|
|
44
50
|
const [response, error] = await fetch('/api/products', {
|
|
45
51
|
queryParams: {limit: 10, category: 'electronics'},
|
|
46
52
|
cacheStrategy: 'stale_while_revalidate',
|
|
@@ -48,12 +54,15 @@ async function fetchProducts() {
|
|
|
48
54
|
});
|
|
49
55
|
|
|
50
56
|
if (error) {
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
if (error.reason === 'not_found') {
|
|
58
|
+
console.warn('Products category not found');
|
|
59
|
+
} else {
|
|
60
|
+
console.error('Failed to fetch products:', error.message, error.reason);
|
|
61
|
+
}
|
|
53
62
|
return;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
//
|
|
65
|
+
// response is guaranteed to be valid and ok (2xx)
|
|
57
66
|
const data = await response.json();
|
|
58
67
|
console.log('Products:', data);
|
|
59
68
|
}
|
|
@@ -61,259 +70,179 @@ async function fetchProducts() {
|
|
|
61
70
|
fetchProducts();
|
|
62
71
|
```
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`@alwatr/fetch` uses a **Go-style tuple return pattern** instead of throwing exceptions. This provides explicit, type-safe error handling.
|
|
73
|
+
### 2. Typed JSON Fetch (`fetchJson`)
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
Use `fetchJson<T>` to fetch and parse JSON responses in a single step with full generic type safety:
|
|
69
76
|
|
|
70
77
|
```typescript
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
- **Success**: `[Response, null]` - The response is guaranteed to have `response.ok === true`
|
|
75
|
-
- **Failure**: `[null, FetchError]` - Contains detailed information about what went wrong
|
|
76
|
-
|
|
77
|
-
### FetchError Class
|
|
78
|
+
import {fetchJson} from '@alwatr/fetch';
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
reason: FetchErrorReason; // Specific error reason
|
|
84
|
-
response?: Response; // The HTTP response (if available)
|
|
85
|
-
data?: unknown; // Parsed response body (if available)
|
|
80
|
+
interface UserProfile {
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
email: string;
|
|
86
84
|
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
### Error Reasons
|
|
90
|
-
|
|
91
|
-
The `reason` property indicates why the request failed:
|
|
92
|
-
|
|
93
|
-
- `'http_error'`: HTTP error status (e.g., 404, 500)
|
|
94
|
-
- `'timeout'`: Request exceeded the timeout duration
|
|
95
|
-
- `'cache_not_found'`: Resource not found in cache (when using `cache_only`)
|
|
96
|
-
- `'network_error'`: Network-level error (e.g., DNS failure, connection refused)
|
|
97
|
-
- `'aborted'`: Request was aborted via AbortSignal
|
|
98
|
-
- `'unknown_error'`: Unspecified error
|
|
99
85
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
86
|
+
async function loadProfile(userId: string) {
|
|
87
|
+
const [user, error] = await fetchJson<UserProfile>(`/api/users/${userId}`, {
|
|
88
|
+
bearerToken: 'my-jwt-token',
|
|
89
|
+
timeout: '3s',
|
|
90
|
+
});
|
|
106
91
|
|
|
107
|
-
if (error) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
break;
|
|
115
|
-
case 'network_error':
|
|
116
|
-
console.error('Network error. Check your connection.');
|
|
117
|
-
break;
|
|
118
|
-
case 'cache_not_found':
|
|
119
|
-
console.error('Data not available offline.');
|
|
120
|
-
break;
|
|
121
|
-
default:
|
|
122
|
-
console.error('Request failed:', error.message);
|
|
92
|
+
if (error) {
|
|
93
|
+
if (error.reason === 'unauthorized') {
|
|
94
|
+
console.error('Session expired, please log in again.');
|
|
95
|
+
} else if (error.reason === 'server_error') {
|
|
96
|
+
console.error('Server error, please try again later.');
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
123
99
|
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
100
|
|
|
127
|
-
//
|
|
128
|
-
|
|
101
|
+
// user is typed as UserProfile
|
|
102
|
+
console.log('User Name:', user.name);
|
|
103
|
+
}
|
|
129
104
|
```
|
|
130
105
|
|
|
131
|
-
|
|
106
|
+
---
|
|
132
107
|
|
|
133
|
-
|
|
108
|
+
## Error Handling & Semantic Reasons
|
|
109
|
+
|
|
110
|
+
`@alwatr/fetch` uses a **Go-style tuple return pattern** instead of throwing runtime exceptions.
|
|
111
|
+
|
|
112
|
+
### Return Tuples
|
|
113
|
+
|
|
114
|
+
- **`fetch(url, options)`**: `Promise<[Response, null] | [null, FetchError]>`
|
|
115
|
+
- **`fetchJson<T>(url, options)`**: `Promise<[T, null] | [null, FetchError]>`
|
|
116
|
+
|
|
117
|
+
### `FetchError` Class Properties
|
|
118
|
+
|
|
119
|
+
| Property | Type | Description |
|
|
120
|
+
| :--- | :--- | :--- |
|
|
121
|
+
| `reason` | `FetchErrorReason` | Granular semantic error reason identifier. |
|
|
122
|
+
| `response` | `Response \| undefined` | The underlying HTTP `Response` object (if available). |
|
|
123
|
+
| `status` | `number \| undefined` | Getter returning `response.status` (e.g. `401`, `404`, `500`). |
|
|
124
|
+
| `data` | `unknown` | Auto-parsed error payload (JSON or plain text) from the server. |
|
|
125
|
+
| `ok` | `boolean` | Always `false` on `FetchError`. |
|
|
126
|
+
|
|
127
|
+
### Semantic `FetchErrorReason` Values
|
|
128
|
+
|
|
129
|
+
| Reason | HTTP / Trigger | Description |
|
|
130
|
+
| :--- | :--- | :--- |
|
|
131
|
+
| `'bad_request'` | HTTP 400 | Bad request / validation error. |
|
|
132
|
+
| `'unauthorized'` | HTTP 401 | Authentication required or token invalid. |
|
|
133
|
+
| `'forbidden'` | HTTP 403 | Insufficient permissions / access denied. |
|
|
134
|
+
| `'not_found'` | HTTP 404 | Endpoint or resource not found. |
|
|
135
|
+
| `'request_timeout'` | HTTP 408 | Server-side request timeout. |
|
|
136
|
+
| `'conflict'` | HTTP 409 | Resource state conflict (e.g. duplicate key). |
|
|
137
|
+
| `'payload_too_large'` | HTTP 413 | Request payload exceeds server limit. |
|
|
138
|
+
| `'unprocessable_content'`| HTTP 422 | Unprocessable entity / domain validation failure. |
|
|
139
|
+
| `'rate_limited'` | HTTP 429 | Too many requests; rate limit exceeded. |
|
|
140
|
+
| `'http_error'` | HTTP 4xx | Other 4xx client status codes. |
|
|
141
|
+
| `'server_error'` | HTTP 5xx | Any 5xx server failure (500, 502, 503, 504). |
|
|
142
|
+
| `'timeout'` | Client Timeout | Request exceeded the configured `timeout` duration. |
|
|
143
|
+
| `'aborted'` | AbortSignal | Request was cancelled by external or pre-aborted `AbortSignal`. |
|
|
144
|
+
| `'network_error'` | Network | Transport failure (DNS failure, connection reset, offline). |
|
|
145
|
+
| `'cache_not_found'` | Cache API | Resource missing when using `cacheStrategy: 'cache_only'`. |
|
|
146
|
+
| `'json_parse_error'` | JSON Parsing | Response body is empty or invalid JSON (in `fetchJson`). |
|
|
147
|
+
| `'json_response_error'`| `requireJsonResponseWithOkTrue` | Response JSON `ok` property was not `true`. |
|
|
148
|
+
| `'unknown_error'` | Exception | Untyped or unexpected exception. |
|
|
134
149
|
|
|
135
|
-
|
|
136
|
-
| :------------------- | :---------------------------------------------- | :--------------- | :--------------------------------------------------------------------------------------------- |
|
|
137
|
-
| `method` | `HttpMethod` | `'GET'` | The HTTP request method. |
|
|
138
|
-
| `headers` | `HttpRequestHeaders` | `{}` | An object representing the request's headers. |
|
|
139
|
-
| `timeout` | `Duration` | `8_000` (8s) | Request timeout in milliseconds or as a duration string (e.g., `'5s'`). Set to `0` to disable. |
|
|
140
|
-
| `retry` | `number` | `3` | Number of retries if the request fails with a server error (5xx) or times out. |
|
|
141
|
-
| `retryDelay` | `Duration` | `1_000` (1s) | Delay between retry attempts in milliseconds or as a duration string. |
|
|
142
|
-
| `removeDuplicate` | `'never' \| 'always' \| 'until_load' \| 'auto'` | `'never'` | Strategy for handling identical parallel requests. `body` is included for uniqueness. |
|
|
143
|
-
| `cacheStrategy` | `'network_only' \| 'network_first' \| ...` | `'network_only'` | Caching strategy using the browser's Cache API. |
|
|
144
|
-
| `cacheStorageName` | `string` | `'fetch_cache'` | Custom name for the `CacheStorage` instance. |
|
|
145
|
-
| `revalidateCallback` | `(response: Response) => void` | `undefined` | Callback executed with the new response when using `stale_while_revalidate` strategy. |
|
|
146
|
-
| `bodyJson` | `Json` | `undefined` | A JavaScript object sent as the request body. Sets `Content-Type` to `application/json`. |
|
|
147
|
-
| `queryParams` | `Dictionary` | `undefined` | An object of query parameters appended to the URL. |
|
|
148
|
-
| `bearerToken` | `string` | `undefined` | A bearer token added to the `Authorization` header. |
|
|
149
|
-
| `alwatrAuth` | `{userId: string; userToken: string}` | `undefined` | Alwatr-specific authentication credentials. |
|
|
150
|
+
---
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
## API Options
|
|
153
|
+
|
|
154
|
+
The `fetch` and `fetchJson` functions accept a URL and an options object extending Web Standard `RequestInit`.
|
|
155
|
+
|
|
156
|
+
| Option | Type | Default | Description |
|
|
157
|
+
| :--- | :--- | :--- | :--- |
|
|
158
|
+
| `method` | `HttpMethod` | `'GET'` | HTTP method (`'GET'`, `'POST'`, `'PUT'`, `'DELETE'`, `'PATCH'`, `'HEAD'`). Normalized to uppercase. |
|
|
159
|
+
| `headers` | `HeadersInit` | `{}` | Headers as plain object, Web Standard `Headers`, or entries array. |
|
|
160
|
+
| `timeout` | `Duration` | `8_000` (8s) | Request timeout in ms or duration string (e.g. `'5s'`). Set `0` to disable. |
|
|
161
|
+
| `retry` | `number` | `3` | Maximum attempts (sanitized to integer $\ge 1$). Retries on 5xx, 429, 408, or network errors. |
|
|
162
|
+
| `retryDelay` | `Duration` | `1_000` (1s) | Base retry delay in ms or duration string. Overridden by `Retry-After` header when present. |
|
|
163
|
+
| `removeDuplicate` | `'never' \| 'always' \| 'until_load' \| 'auto'` | `'never'` | In-flight request coalescing strategy. Deduplication key includes auth credentials. |
|
|
164
|
+
| `cacheStrategy` | `'network_only' \| 'network_first' \| 'cache_first' \| 'cache_only' \| 'update_cache' \| 'stale_while_revalidate'` | `'network_only'` | Browser Cache API strategy. Automatically downgraded to `'network_only'` for non-GET/HEAD methods or environments without `caches`. |
|
|
165
|
+
| `cacheStorageName` | `string` | `'fetch_cache'` | Custom `CacheStorage` bucket name. |
|
|
166
|
+
| `revalidateCallback`| `(response: Response) => void` | `undefined` | Callback invoked with fresh response when using `stale_while_revalidate`. |
|
|
167
|
+
| `bodyJson` | `JsonValue` | `undefined` | JavaScript object serialized to JSON body. Automatically sets `Content-Type: application/json`. |
|
|
168
|
+
| `queryParams` | `QueryParams` | `undefined` | Object or arrays of query parameters appended to the URL (preserves existing `?` and `#` anchors). |
|
|
169
|
+
| `bearerToken` | `string` | `undefined` | Bearer token appended to the `Authorization` header (`Bearer <token>`). |
|
|
170
|
+
| `alwatrAuth` | `{userId: string; userToken: string}` | `undefined` | Alwatr auth credentials appended to the `Authorization` header (`Alwatr <userId>:<userToken>`). |
|
|
171
|
+
| `requireJsonResponseWithOkTrue` | `true` | `undefined` | (for `fetchJson`) Requires response JSON to contain `{ ok: true }`. |
|
|
152
172
|
|
|
153
173
|
---
|
|
154
174
|
|
|
155
|
-
## Features
|
|
175
|
+
## Detailed Features
|
|
156
176
|
|
|
157
|
-
###
|
|
177
|
+
### 1. Header Isolation & Multi-Tenant Security
|
|
158
178
|
|
|
159
|
-
|
|
179
|
+
Caller-supplied header objects are never mutated. `bearerToken` and `alwatrAuth` are cleanly injected into isolated per-request header bags without leaking across subsequent calls or polluting shared option objects:
|
|
160
180
|
|
|
161
181
|
```typescript
|
|
162
|
-
|
|
163
|
-
const [response, error] = await fetch('/api/users', {
|
|
164
|
-
queryParams: {page: 2, sort: 'asc'},
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
if (error) {
|
|
168
|
-
console.error('Failed to fetch users:', error.message);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const users = await response.json();
|
|
173
|
-
```
|
|
182
|
+
const sharedHeaders = {'x-app-id': 'my-app'};
|
|
174
183
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
// This will make a POST request to /api/orders with a JSON body
|
|
181
|
-
const [response, error] = await fetch('/api/orders', {
|
|
182
|
-
method: 'POST',
|
|
183
|
-
bodyJson: {
|
|
184
|
-
productId: 'xyz-123',
|
|
185
|
-
quantity: 2,
|
|
186
|
-
},
|
|
184
|
+
// First request with token
|
|
185
|
+
await fetch('/api/private', {
|
|
186
|
+
headers: sharedHeaders,
|
|
187
|
+
bearerToken: 'SECRET-123',
|
|
187
188
|
});
|
|
188
189
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const order = await response.json();
|
|
195
|
-
console.log('Order created:', order);
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Timeout
|
|
199
|
-
|
|
200
|
-
Set a timeout for your requests. If the request takes longer than the specified duration, it will be aborted and return a `FetchError` with `reason: 'timeout'`.
|
|
201
|
-
|
|
202
|
-
```typescript
|
|
203
|
-
const [response, error] = await fetch('/api/slow-endpoint', {
|
|
204
|
-
timeout: '2.5s', // You can use duration strings
|
|
190
|
+
// Second request reusing sharedHeaders
|
|
191
|
+
await fetch('/api/public', {
|
|
192
|
+
headers: sharedHeaders,
|
|
205
193
|
});
|
|
206
194
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
console.error('Request timed out after 2.5 seconds');
|
|
210
|
-
}
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
195
|
+
// sharedHeaders remains untouched ({'x-app-id': 'my-app'})
|
|
196
|
+
// Second request does NOT leak authorization token!
|
|
213
197
|
```
|
|
214
198
|
|
|
215
|
-
### Retry
|
|
199
|
+
### 2. Intelligent Retry & `Retry-After` Parsing
|
|
216
200
|
|
|
217
|
-
|
|
201
|
+
When encountering transient failures (5xx, 429 Rate Limited, 408 Timeout, or connection drops), retries occur automatically. If the server responds with a `Retry-After` header (seconds or HTTP-date), the retry delay automatically adapts to the server's requested delay:
|
|
218
202
|
|
|
219
203
|
```typescript
|
|
220
|
-
|
|
221
|
-
const [response, error] = await fetch('/api/flaky-service', {
|
|
204
|
+
const [response, error] = await fetch('/api/rate-limited-endpoint', {
|
|
222
205
|
retry: 5,
|
|
223
|
-
retryDelay: '2s',
|
|
206
|
+
retryDelay: '2s', // Used if Retry-After header is absent
|
|
224
207
|
});
|
|
225
|
-
|
|
226
|
-
if (error) {
|
|
227
|
-
console.error('Request failed after 5 retries:', error.message);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const data = await response.json();
|
|
232
208
|
```
|
|
233
209
|
|
|
234
|
-
###
|
|
235
|
-
|
|
236
|
-
The `removeDuplicate` option prevents multiple identical requests from being sent simultaneously. The uniqueness of a request is determined by its method, URL, and body.
|
|
210
|
+
### 3. Query Parameter Formatting
|
|
237
211
|
|
|
238
|
-
|
|
239
|
-
- `'until_load'`: Caches the `Promise` of a request until it resolves. Subsequent identical requests will receive a clone of the first response.
|
|
240
|
-
- `'always'`: Caches the response indefinitely (for the lifetime of the application).
|
|
241
|
-
- `'auto'`: Uses `'until_load'` if the Cache API is available, otherwise `'always'`.
|
|
212
|
+
Query parameters are appended safely, encoding arrays (`tag: ['a', 'b']`), numbers, and booleans while correctly respecting pre-existing `?` query strings and `#` hash fragments:
|
|
242
213
|
|
|
243
214
|
```typescript
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
]);
|
|
250
|
-
|
|
251
|
-
// Both results will have the same response or error
|
|
252
|
-
const [response1, error1] = results[0];
|
|
253
|
-
const [response2, error2] = results[1];
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
### Cache Strategies
|
|
257
|
-
|
|
258
|
-
Leverage the browser's Cache API with `cacheStrategy`.
|
|
259
|
-
|
|
260
|
-
- `'network_only'` (default): Standard fetch behavior; no caching.
|
|
261
|
-
- `'cache_first'`: Serves from cache if available. Otherwise, fetches from the network and caches the result.
|
|
262
|
-
- `'network_first'`: Fetches from the network first. If the network fails, it falls back to the cache.
|
|
263
|
-
- `'cache_only'`: Only serves from cache; returns an error if not found.
|
|
264
|
-
- `'update_cache'`: Fetches from network and updates the cache.
|
|
265
|
-
- `'stale_while_revalidate'`: The fastest strategy. It serves stale content from the cache immediately while sending a network request in the background to update the cache for the next time.
|
|
266
|
-
|
|
267
|
-
```typescript
|
|
268
|
-
// Serve news from cache instantly, but update it in the background for the next visit.
|
|
269
|
-
const [response, error] = await fetch('/api/news', {
|
|
270
|
-
cacheStrategy: 'stale_while_revalidate',
|
|
271
|
-
revalidateCallback: (freshResponse) => {
|
|
272
|
-
console.log('Cache updated with fresh data!');
|
|
273
|
-
// You can use freshResponse to update the UI if needed
|
|
215
|
+
// GET /api/search?category=tech&tag=a&tag=b&active=true#results
|
|
216
|
+
const [response, error] = await fetch('/api/search?category=tech#results', {
|
|
217
|
+
queryParams: {
|
|
218
|
+
tag: ['a', 'b'],
|
|
219
|
+
active: true,
|
|
274
220
|
},
|
|
275
221
|
});
|
|
276
|
-
|
|
277
|
-
if (error) {
|
|
278
|
-
console.error('Failed to load news:', error.message);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const news = await response.json();
|
|
283
222
|
```
|
|
284
223
|
|
|
285
|
-
###
|
|
224
|
+
### 4. Duplicate Request Coalescing (`removeDuplicate`)
|
|
286
225
|
|
|
287
|
-
|
|
226
|
+
Prevents duplicate parallel requests from creating redundant network traffic:
|
|
288
227
|
|
|
289
228
|
```typescript
|
|
290
|
-
//
|
|
291
|
-
const [
|
|
292
|
-
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
if (error) {
|
|
296
|
-
if (error.response?.status === 401) {
|
|
297
|
-
console.error('Authentication failed. Please log in again.');
|
|
298
|
-
}
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const data = await response.json();
|
|
303
|
-
|
|
304
|
-
// Using Alwatr's authentication scheme
|
|
305
|
-
const [response2, error2] = await fetch('/api/secure/data', {
|
|
306
|
-
alwatrAuth: {
|
|
307
|
-
userId: 'user-id',
|
|
308
|
-
userToken: 'user-auth-token',
|
|
309
|
-
},
|
|
310
|
-
});
|
|
229
|
+
// Only ONE network request is sent; both promises receive a cloned Response
|
|
230
|
+
const [res1, res2] = await Promise.all([
|
|
231
|
+
fetch('/api/heavy-data', {removeDuplicate: 'until_load'}),
|
|
232
|
+
fetch('/api/heavy-data', {removeDuplicate: 'until_load'}),
|
|
233
|
+
]);
|
|
311
234
|
```
|
|
312
235
|
|
|
236
|
+
---
|
|
237
|
+
|
|
313
238
|
## Sponsors
|
|
314
239
|
|
|
315
|
-
The following companies, organizations, and individuals support
|
|
240
|
+
The following companies, organizations, and individuals support Alwatr's ongoing maintenance and development.
|
|
316
241
|
|
|
317
242
|
## Contributing
|
|
318
243
|
|
|
319
|
-
Contributions are welcome
|
|
244
|
+
Contributions are welcome! Please read our [contribution guidelines](https://github.com/Alwatr/.github/blob/next/CONTRIBUTING.md) before submitting a pull request.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
[MIT License](./LICENSE)
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InternalFetchOptions_ } from './type.js';
|
|
2
|
+
/**
|
|
3
|
+
* Executes the caching lifecycle according to `cacheStrategy`.
|
|
4
|
+
*
|
|
5
|
+
* Interacts safely with Cache API:
|
|
6
|
+
* - Falls back to network when Cache API is unavailable or throws.
|
|
7
|
+
* - Guards against caching non-GET requests.
|
|
8
|
+
* - Clones responses before storing to keep response bodies consumable.
|
|
9
|
+
*
|
|
10
|
+
* @param options - Processed internal fetch options.
|
|
11
|
+
* @returns A promise resolving to a cached or freshly fetched `Response`.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleCacheStrategy_(options: InternalFetchOptions_): Promise<Response>;
|
|
15
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,WAAW,CAAC;AAErD;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA8H5F"}
|
package/dist/dedupe.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { InternalFetchOptions_ } from './type.js';
|
|
2
|
+
/**
|
|
3
|
+
* Computes a secure cache key for request deduplication.
|
|
4
|
+
* Includes method, full URL, authorization header, and request body.
|
|
5
|
+
*
|
|
6
|
+
* @param options - Processed internal fetch options.
|
|
7
|
+
* @returns Unique string identifier for the request intent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function computeDedupeKey_(options: InternalFetchOptions_): string;
|
|
10
|
+
/**
|
|
11
|
+
* Handles duplicate parallel request coalescing.
|
|
12
|
+
*
|
|
13
|
+
* If an identical request is already in-flight, returns a cloned response of the existing
|
|
14
|
+
* promise to avoid redundant network round-trips.
|
|
15
|
+
*
|
|
16
|
+
* @param options - Processed internal fetch options.
|
|
17
|
+
* @returns A promise resolving to an independent cloned `Response`.
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleRemoveDuplicate_(options: InternalFetchOptions_): Promise<Response>;
|
|
21
|
+
//# sourceMappingURL=dedupe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dedupe.d.ts","sourceRoot":"","sources":["../src/dedupe.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,WAAW,CAAC;AAOrD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,qBAAqB,GAAG,MAAM,CAIxE;AAED;;;;;;;;;GASG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,QAAQ,CAAC,CA8B9F"}
|
package/dist/dev/main.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/* 📦 @alwatr/fetch v10.
|
|
2
|
-
import{
|
|
1
|
+
/* 📦 @alwatr/fetch v10.1.0 */
|
|
2
|
+
import{HttpStatusCodes as l}from"@alwatr/http-primer";function g(t){switch(t){case l.Error_Client_400_Bad_Request:return"bad_request";case l.Error_Client_401_Unauthorized:return"unauthorized";case l.Error_Client_403_Forbidden:return"forbidden";case l.Error_Client_404_Not_Found:return"not_found";case l.Error_Client_408_Request_Timeout:return"request_timeout";case l.Error_Client_409_Conflict:return"conflict";case l.Error_Client_413_Payload_Too_Large:return"payload_too_large";case l.Error_Client_422_Unprocessable_Entity:return"unprocessable_content";case l.Error_Client_429_Too_Many_Requests:return"rate_limited";default:if(t>=500&&t<600)return"server_error";return"http_error"}}class s extends Error{response;data;reason;get status(){return this.response?.status}ok=!1;constructor(t,r,n,e){super(r);this.name="FetchError",this.reason=t,this.response=n,this.data=e}}import{MimeTypes as k}from"@alwatr/http-primer";import{createLogger as F}from"@alwatr/logger";import{getGlobalThis as M}from"@alwatr/global-this";import{parseDuration as E}from"@alwatr/parse-duration";var c=F("@alwatr/fetch"),H=M(),h={method:"GET",timeout:8000,retry:3,retryDelay:1000,removeDuplicate:"never",cacheStrategy:"network_only",cacheStorageName:"fetch_cache"};function C(t,r={}){if(t==null)return r;if(typeof Headers<"u"&&t instanceof Headers)return t.forEach((n,e)=>{r[e.toLowerCase()]=n}),r;if(Array.isArray(t)){for(let[n,e]of t)if(typeof n==="string"&&typeof e==="string")r[n.toLowerCase()]=e;return r}if(typeof t==="object")for(let n of Object.keys(t)){let e=t[n];if(e!=null)r[n.toLowerCase()]=String(e)}return r}function T(t){let r=[];for(let n of Object.keys(t)){let e=t[n];if(e===void 0||e===null)continue;if(Array.isArray(e)){for(let o of e)if(o!==void 0&&o!==null)r.push(`${encodeURIComponent(n)}=${encodeURIComponent(String(o))}`)}else r.push(`${encodeURIComponent(n)}=${encodeURIComponent(String(e))}`)}return r.join("&")}function x(t,r){if(r==null)return t;let n=T(r);if(n.length===0)return t;let e=t,o="",a=t.indexOf("#");if(a!==-1)e=t.slice(0,a),o=t.slice(a);let i=e.includes("?")?"&":"?";return`${e}${i}${n}${o}`}function w(t,r={}){c.logMethod?.("processOptions_");let n=x(t,r.queryParams),e={...h,...r,headers:C(r.headers),url:n,method:r.method?.toUpperCase()??h.method,timeout:E(r.timeout??h.timeout),retryDelay:E(r.retryDelay??h.retryDelay),retry:typeof r.retry==="number"&&Number.isFinite(r.retry)?Math.max(1,Math.floor(r.retry)):h.retry};if(e.window??=null,e.cacheStrategy!=="network_only"&&(typeof caches>"u"||e.method!=="GET"&&e.method!=="HEAD"))c.incident?.("processOptions_","fetch_cache_strategy_unsupported",{method:e.method,cacheStrategy:e.cacheStrategy,hasCaches:typeof caches<"u"}),e.cacheStrategy="network_only";if(e.removeDuplicate==="auto")e.removeDuplicate=typeof caches<"u"?"until_load":"always";if(r.bodyJson!==void 0)e.body=JSON.stringify(r.bodyJson),e.headers["content-type"]=k.JSON;if(r.bearerToken!==void 0)e.headers.authorization=`Bearer ${r.bearerToken}`;else if(r.alwatrAuth!==void 0)e.headers.authorization=`Alwatr ${r.alwatrAuth.userId}:${r.alwatrAuth.userToken}`;return e}import{delay as D}from"@alwatr/delay";import{getGlobalThis as I}from"@alwatr/global-this";import{HttpStatusCodes as m}from"@alwatr/http-primer";import{getGlobalThis as v}from"@alwatr/global-this";var b=v();function y(t){let r=t.signal;if(r?.aborted)return c.incident?.("handleTimeout_","already_aborted",{reason:r.reason}),Promise.reject(new s("aborted","The operation was aborted"));if(t.timeout===0)return b.fetch(t.url,t);return c.logMethod?.("handleTimeout_"),new Promise((n,e)=>{let o=typeof AbortController==="function"?new AbortController:null,a;if(o!==null){if(t.signal=o.signal,r!=null)a=()=>{o.abort(r.reason)},r.addEventListener("abort",a,{once:!0})}let i=!1,p=setTimeout(()=>{i=!0,o?.abort("fetch_timeout"),e(new s("timeout","fetch_timeout"))},t.timeout);b.fetch(t.url,t).then((f)=>{if(!i)n(f)}).catch((f)=>{if(i)return;if(r?.aborted||f instanceof Error&&f.name==="AbortError")e(new s("aborted","The operation was aborted"));else e(f)}).finally(()=>{if(clearTimeout(p),t.signal=r,r!=null&&a!==void 0)r.removeEventListener("abort",a)})})}var O=I();function P(t){return t>=m.Error_Server_500_Internal_Server_Error||t===m.Error_Client_408_Request_Timeout||t===m.Error_Client_429_Too_Many_Requests}function V(t){let r=t?.headers?.get("retry-after");if(!r)return;let n=Number(r);if(!isNaN(n)&&n>0)return n*1000;let e=Date.parse(r);if(!isNaN(e)){let o=e-Date.now();return o>0?o:0}return}async function d(t){if(t.retry<=1)return y(t);c.logMethod?.("handleRetryPattern_"),t.retry--;let r;try{if(r=await y(t),r.ok||!P(r.status))return r}catch(e){if(c.accident("fetch","fetch_failed_retry",e),t.signal?.aborted||e instanceof s&&e.reason==="aborted")throw e;if(O.navigator?.onLine===!1)throw c.accident("handleRetryPattern_","offline","Skip retry because offline"),e;return await D.by(t.retryDelay),d(t)}if(c.accident("fetch","fetch_failed_retry",{status:r.status}),O.navigator?.onLine===!1)return c.accident("handleRetryPattern_","offline","Skip retry because offline"),r;let n=V(r)??t.retryDelay;return await D.by(n),d(t)}var _=new Map;function A(t){let r=typeof t.body==="string"?t.body:"",n=t.headers.authorization??"";return`${t.method} ${t.url} [auth:${n}] [body:${r}]`}async function u(t){if(t.removeDuplicate==="never")return d(t);c.logMethod?.("handleRemoveDuplicate_");let r=A(t),n=_.get(r);if(n==null)n=d(t),_.set(r,n);try{let e=await n;if(!e.ok||t.removeDuplicate==="until_load")_.delete(r);return e.clone()}catch(e){throw _.delete(r),e}}import{delay as J}from"@alwatr/delay";async function R(t){if(t.cacheStrategy==="network_only")return u(t);c.logMethod?.("handleCacheStrategy_");let r;try{r=await caches.open(t.cacheStorageName)}catch(e){return c.accident("handleCacheStrategy_","cache_open_failed",{err:e}),t.cacheStrategy="network_only",u(t)}let n=new Request(t.url,t);switch(t.cacheStrategy){case"cache_first":{try{let o=await r.match(n);if(o!=null)return o}catch(o){c.accident("handleCacheStrategy_","cache_match_failed",{err:o})}let e=await u(t);if(e.ok)try{await r.put(n,e.clone())}catch{}return e}case"cache_only":{let e;try{e=await r.match(n)}catch(o){c.accident("handleCacheStrategy_","cache_only_match_failed",{err:o})}if(e==null)throw new s("cache_not_found","Resource not found in cache");return e}case"network_first":try{let e=await u(t);if(e.ok)try{await r.put(n,e.clone())}catch{}return e}catch(e){try{let o=await r.match(n);if(o!=null)return o}catch{}throw e}case"update_cache":{let e=await u(t);if(e.ok)try{await r.put(n,e.clone())}catch{}return e}case"stale_while_revalidate":{let e;try{e=await r.match(n)}catch{}let o=u(t).then(async(a)=>{if(a.ok){try{await r.put(n,a.clone())}catch{}if(typeof t.revalidateCallback==="function"){let i=t.revalidateCallback,p=a.clone();await J.nextMacrotask();try{await i(p)}catch(f){c.accident("handleCacheStrategy_","revalidate_callback_failed",{err:f})}}}return a});return e??o}default:return u(t)}}async function S(t,r={}){let n=w(t,r);c.logMethodArgs?.("fetch",n);try{let e=await R(n);if(!e.ok){let o=g(e.status);throw new s(o,`HTTP error! status: ${e.status} ${e.statusText}`,e)}return[e,null]}catch(e){let o;if(e instanceof s){if(o=e,o.response!==void 0&&o.data===void 0){let a=await o.response.text().catch(()=>"");if(a.trim().length>0)try{o.data=JSON.parse(a)}catch{o.data=a}}}else if(e instanceof Error)if(e.name==="AbortError")o=new s("aborted",e.message);else o=new s("network_error",e.message);else o=new s("unknown_error",String(e??"unknown_error"));return c.accident("fetch",o.reason,{error:o}),[null,o]}}async function N(t,r={}){c.logMethod?.("fetchJson");let[n,e]=await S(t,r);if(e)return[null,e];let o=await n.text().catch(()=>"");if(o.trim().length===0){let a=new s("json_parse_error","Response body is empty, cannot parse JSON",n,o);return c.accident("fetchJson",a.reason,{error:a}),[null,a]}try{let a=JSON.parse(o);if(r.requireJsonResponseWithOkTrue&&(typeof a!=="object"||a===null||a.ok!==!0)){let i=new s("json_response_error",'Response JSON "ok" property is not true',n,a);return c.accident("fetchJson",i.reason,{error:i}),[null,i]}return[a,null]}catch(a){let i=new s("json_parse_error",a instanceof Error?a.message:"Failed to parse JSON response",n,o);return c.accident("fetchJson",i.reason,{error:i}),[null,i]}}N.version=S.version="10.1.0";export{s as FetchError,S as fetch,N as fetchJson,g as httpStatusToErrorReason};
|
|
3
3
|
|
|
4
|
-
//# debugId=
|
|
4
|
+
//# debugId=ED8587057A108B4C64756E2164756E21
|
|
5
5
|
//# sourceMappingURL=main.js.map
|