@alwatr/fetch 10.0.3 → 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 CHANGED
@@ -2,45 +2,51 @@
2
2
 
3
3
  ![@alwatr/fetch](./demo/alwatr-fetch.webp)
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, and intelligent duplicate request handling, all in a compact package.
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's designed to be a drop-in replacement for the standard `fetch` to instantly upgrade your application's network layer.
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—no exceptions thrown.
12
- - **Retry Pattern**: Automatically retries failed requests on timeouts or server errors (5xx).
13
- - **Request Timeout**: Aborts requests that take too long to complete.
14
- - **Duplicate Handling**: Prevents sending identical parallel requests, returning a single response for all callers.
15
- - **Caching Strategies**: Leverages the browser's Cache API with strategies like `stale_while_revalidate`.
16
- - **Simplified API**: Send JSON and URL parameters with ease using `bodyJson` and `queryParams`.
17
- - **TypeScript First**: Written entirely in TypeScript for a great developer experience.
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
- Import the `fetch` function and use it with tuple destructuring for elegant error handling. The function returns `[Response, null]` on success or `[null, FetchError]` on failure—no exceptions are thrown.
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
- console.error('Failed to fetch products:', error.message);
52
- console.error('Error reason:', error.reason);
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
- // At this point, response is guaranteed to be valid and ok
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
- ## Error Handling
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
- ### Return Type
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
- type FetchResponse = Promise<[Response, null] | [null, FetchError]>;
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
- All errors are returned as `FetchError` instances, which provide rich context about the failure:
80
-
81
- ```typescript
82
- class FetchError extends Error {
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
- ### Error Handling Example
101
-
102
- ```typescript
103
- const [response, error] = await fetch('/api/user/profile', {
104
- bearerToken: 'jwt-token',
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
- switch (error.reason) {
109
- case 'http_error':
110
- console.error(`HTTP ${error.response?.status}:`, error.data);
111
- break;
112
- case 'timeout':
113
- console.error('Request timed out. Please try again.');
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
- // Safe to use response here
128
- const userData = await response.json();
101
+ // user is typed as UserProfile
102
+ console.log('User Name:', user.name);
103
+ }
129
104
  ```
130
105
 
131
- ## API and Options
106
+ ---
132
107
 
133
- The `fetch` function takes a `url` string and an `options` object. The options object extends the standard `RequestInit` and adds several custom options for enhanced control.
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
- | Option | Type | Default | Description |
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
- ... and all other standard `RequestInit` properties like `signal`, `credentials`, etc.
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 in Detail
175
+ ## Detailed Features
156
176
 
157
- ### Query Parameters
177
+ ### 1. Header Isolation & Multi-Tenant Security
158
178
 
159
- The `queryParams` option simplifies adding search parameters to your request URL.
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
- // This will make a GET request to: /api/users?page=2&sort=asc
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
- ### JSON Body
176
-
177
- Use `bodyJson` to send a JavaScript object as a JSON payload. The `Content-Type` header is automatically set to `application/json`.
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
- if (error) {
190
- console.error('Failed to create order:', error.message);
191
- return;
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
- if (error) {
208
- if (error.reason === 'timeout') {
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 Pattern
199
+ ### 2. Intelligent Retry & `Retry-After` Parsing
216
200
 
217
- The fetch operation will automatically retry on server errors (5xx status codes) or timeouts.
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
- // Retry up to 5 times, with a 2-second delay between each attempt
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
- ### Duplicate Request Handling
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
- - `'never'` (default): Does nothing.
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
- // Both calls will result in only ONE network request.
245
- // The second call will receive the response from the first.
246
- const results = await Promise.all([
247
- fetch('/api/data', {removeDuplicate: 'until_load'}),
248
- fetch('/api/data', {removeDuplicate: 'until_load'}),
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
- ### Authentication
224
+ ### 4. Duplicate Request Coalescing (`removeDuplicate`)
286
225
 
287
- Easily add authentication headers with `bearerToken` or the `alwatrAuth` scheme.
226
+ Prevents duplicate parallel requests from creating redundant network traffic:
288
227
 
289
228
  ```typescript
290
- // Using a Bearer Token
291
- const [response, error] = await fetch('/api/secure/data', {
292
- bearerToken: 'your-jwt-token-here',
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 Nanolib's ongoing maintenance and development. Become a Sponsor to get your logo on our README and website.
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\! Please read our [contribution guidelines](https://github.com/Alwatr/.github/blob/next/CONTRIBUTING.md) before submitting a pull request.
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)
@@ -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"}
@@ -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.0.3 */
2
- import{delay as J}from"@alwatr/delay";import{getGlobalThis as j}from"@alwatr/global-this";import{hasOwn as D}from"@alwatr/has-own";import{HttpStatusCodes as P,MimeTypes as v}from"@alwatr/http-primer";import{createLogger as x}from"@alwatr/logger";import{parseDuration as O}from"@alwatr/parse-duration";class G extends Error{response;data;reason;constructor(B,X,z,Q){super(X);this.name="FetchError",this.reason=B,this.response=z,this.data=Q}}var Z=x("@alwatr/fetch"),W=j(),$=D(W,"caches"),U={},L={method:"GET",headers:{},timeout:8000,retry:3,retryDelay:1000,removeDuplicate:"never",cacheStrategy:"network_only",cacheStorageName:"fetch_cache"};function K(B,X){Z.logMethodArgs?.("_processOptions",{url:B,options:X});let z={...L,...X,headers:{...L.headers,...X.headers},url:B};if(z.window??=null,z.removeDuplicate==="auto")z.removeDuplicate=$?"until_load":"always";if(z.url.lastIndexOf("?")===-1&&z.queryParams!=null){let Q=z.queryParams,V=Object.keys(Q).map((Y)=>`${encodeURIComponent(Y)}=${encodeURIComponent(String(Q[Y]))}`);if(V.length>0)z.url+="?"+V.join("&")}if(z.bodyJson!==void 0)z.body=JSON.stringify(z.bodyJson),z.headers["content-type"]=v.JSON;if(z.bearerToken!==void 0)z.headers.authorization=`Bearer ${z.bearerToken}`;else if(z.alwatrAuth!==void 0)z.headers.authorization=`Alwatr ${z.alwatrAuth.userId}:${z.alwatrAuth.userToken}`;return Z.logProperty?.("fetch.options",z),z}async function A(B){if(B.cacheStrategy==="network_only")return M(B);if(Z.logMethod?.("handleCacheStrategy_"),!$)return Z.incident?.("fetch","fetch_cache_strategy_unsupported",{cacheSupported:$}),B.cacheStrategy="network_only",M(B);let X=await caches.open(B.cacheStorageName),z=new Request(B.url,B);switch(B.cacheStrategy){case"cache_first":{let Q=await X.match(z);if(Q!=null)return Q;let V=await M(B);if(V.ok)X.put(z,V.clone());return V}case"cache_only":{let Q=await X.match(z);if(Q==null)throw new G("cache_not_found","Resource not found in cache");return Q}case"network_first":try{let Q=await M(B);if(Q.ok)X.put(z,Q.clone());return Q}catch(Q){let V=await X.match(z);if(V!=null)return V;throw Q}case"update_cache":{let Q=await M(B);if(Q.ok)X.put(z,Q.clone());return Q}case"stale_while_revalidate":{let Q=await X.match(z),V=M(B).then((Y)=>{if(Y.ok){if(X.put(z,Y.clone()),typeof B.revalidateCallback==="function")setTimeout(B.revalidateCallback,0,Y.clone())}return Y});return Q??V}default:return M(B)}}async function M(B){if(B.removeDuplicate==="never")return I(B);Z.logMethod?.("handleRemoveDuplicate_");let X=typeof B.body==="string"?B.body:"",z=`${B.method} ${B.url} ${X}`;U[z]??=I(B);try{let Q=await U[z];if(U[z]!=null){if(Q.ok!==!0||B.removeDuplicate==="until_load")delete U[z]}return Q.clone()}catch(Q){throw delete U[z],Q}}async function I(B){if(!(B.retry>1))return N(B);Z.logMethod?.("handleRetryPattern_"),B.retry--;let X=B.signal;try{let z=await N(B);if(!z.ok&&z.status>=P.Error_Server_500_Internal_Server_Error)throw new G("http_error",`HTTP error! status: ${z.status} ${z.statusText}`,z);return z}catch(z){if(Z.accident("fetch","fetch_failed_retry",z),W.navigator?.onLine===!1)throw Z.accident("handleRetryPattern_","offline","Skip retry because offline"),z;return await J.by(B.retryDelay),B.signal=X,I(B)}}function N(B){if(B.timeout===0)return W.fetch(B.url,B);return Z.logMethod?.("handleTimeout_"),new Promise((X,z)=>{let Q=typeof AbortController==="function"?new AbortController:null,V=B.signal;if(B.signal=Q?.signal,Q!==null&&V!=null)V.addEventListener("abort",()=>Q.abort(),{once:!0});let Y=setTimeout(()=>{z(new G("timeout","fetch_timeout")),Q?.abort("fetch_timeout")},O(B.timeout));W.fetch(B.url,B).then((H)=>X(H)).catch((H)=>z(H)).finally(()=>{clearTimeout(Y)})})}async function C(B,X={}){Z.logMethodArgs?.("fetch",{url:B,options:X});let z=K(B,X);try{let Q=await A(z);if(!Q.ok)throw new G("http_error",`HTTP error! status: ${Q.status} ${Q.statusText}`,Q);return[Q,null]}catch(Q){let V;if(Q instanceof G){if(V=Q,V.response!==void 0&&V.data===void 0){let Y=await V.response.text().catch(()=>"");if(Y.trim().length>0)try{V.data=JSON.parse(Y)}catch{V.data=Y}}}else if(Q instanceof Error)if(Q.name==="AbortError")V=new G("aborted",Q.message);else V=new G("network_error",Q.message);else V=new G("unknown_error",String(Q??"unknown_error"));return Z.error("fetch",V.reason,{error:V}),[null,V]}}C.version="10.0.3";async function R(B,X={}){Z.logMethodArgs?.("fetchJson",{url:B,options:X});let[z,Q]=await C(B,X);if(Q)return[null,Q];let V=await z.text().catch(()=>"");if(V.trim().length===0){let Y=new G("json_parse_error","Response body is empty, cannot parse JSON",z,V);return Z.error("fetchJson",Y.reason,{error:Y}),[null,Y]}try{let Y=JSON.parse(V);if(X.requireJsonResponseWithOkTrue&&Y.ok!==!0){let H=new G("json_response_error",'Response JSON "ok" property is not true',z,Y);return Z.error("fetchJson",H.reason,{error:H}),[null,H]}return[Y,null]}catch(Y){let H=new G("json_parse_error",Y instanceof Error?Y.message:"Failed to parse JSON response",z,V);return Z.error("fetchJson",H.reason,{error:H}),[null,H]}}export{R as fetchJson,C as fetch,$ as cacheSupported,G as FetchError};
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=583B21699A4267AD64756E2164756E21
4
+ //# debugId=ED8587057A108B4C64756E2164756E21
5
5
  //# sourceMappingURL=main.js.map