@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 +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 -351
package/src/core.ts
DELETED
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
import {delay} from '@alwatr/delay';
|
|
2
|
-
import {getGlobalThis} from '@alwatr/global-this';
|
|
3
|
-
import {hasOwn} from '@alwatr/has-own';
|
|
4
|
-
import {HttpStatusCodes, MimeTypes} from '@alwatr/http-primer';
|
|
5
|
-
import {createLogger} from '@alwatr/logger';
|
|
6
|
-
import {parseDuration} from '@alwatr/parse-duration';
|
|
7
|
-
|
|
8
|
-
import {FetchError} from './error.js';
|
|
9
|
-
|
|
10
|
-
import type {AlwatrFetchOptions_, FetchOptions} from './type.js';
|
|
11
|
-
|
|
12
|
-
export const logger_ = createLogger('@alwatr/fetch');
|
|
13
|
-
|
|
14
|
-
const globalThis_ = getGlobalThis();
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A boolean flag indicating whether the browser's Cache API is supported.
|
|
18
|
-
*/
|
|
19
|
-
export const cacheSupported = /* #__PURE__ */ hasOwn(globalThis_, 'caches');
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A simple in-memory storage for tracking and managing duplicate in-flight requests.
|
|
23
|
-
* The key is a unique identifier for the request (e.g., method + URL + body),
|
|
24
|
-
* and the value is the promise of the ongoing fetch operation.
|
|
25
|
-
*/
|
|
26
|
-
const duplicateRequestStorage_: Record<string, Promise<Response>> = {};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Default options for all fetch requests. These can be overridden by passing
|
|
30
|
-
* a custom `options` object to the `fetch` function.
|
|
31
|
-
*/
|
|
32
|
-
const defaultFetchOptions: AlwatrFetchOptions_ = {
|
|
33
|
-
method: 'GET',
|
|
34
|
-
headers: {},
|
|
35
|
-
timeout: 8_000,
|
|
36
|
-
retry: 3,
|
|
37
|
-
retryDelay: 1_000,
|
|
38
|
-
removeDuplicate: 'never',
|
|
39
|
-
cacheStrategy: 'network_only',
|
|
40
|
-
cacheStorageName: 'fetch_cache',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Internal-only fetch options type, which includes the URL and ensures all
|
|
45
|
-
* optional properties from AlwatrFetchOptions_ are present.
|
|
46
|
-
*/
|
|
47
|
-
type FetchOptions__ = AlwatrFetchOptions_ & Omit<RequestInit, 'headers'> & {url: string};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Processes and sanitizes the fetch options.
|
|
51
|
-
*
|
|
52
|
-
* @param {string} url - The URL to fetch.
|
|
53
|
-
* @param {FetchOptions} options - The user-provided options.
|
|
54
|
-
* @returns {FetchOptions__} The processed and complete fetch options.
|
|
55
|
-
* @private
|
|
56
|
-
*/
|
|
57
|
-
export function _processOptions(url: string, options: FetchOptions): FetchOptions__ {
|
|
58
|
-
DEV_MODE && logger_.logMethodArgs?.('_processOptions', {url, options});
|
|
59
|
-
|
|
60
|
-
const options_: FetchOptions__ = {
|
|
61
|
-
...defaultFetchOptions,
|
|
62
|
-
...options,
|
|
63
|
-
// Headers must be private per request: the object is mutated below
|
|
64
|
-
// (content-type, authorization), and both the module-level default and a
|
|
65
|
-
// caller-supplied object would otherwise accumulate headers across calls
|
|
66
|
-
// — leaking one request's credential onto every later one.
|
|
67
|
-
headers: {
|
|
68
|
-
...defaultFetchOptions.headers,
|
|
69
|
-
...options.headers,
|
|
70
|
-
},
|
|
71
|
-
url,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
options_.window ??= null;
|
|
75
|
-
|
|
76
|
-
if (options_.removeDuplicate === 'auto') {
|
|
77
|
-
options_.removeDuplicate = cacheSupported ? 'until_load' : 'always';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Append query parameters to the URL if they are provided and the URL doesn't already have them.
|
|
81
|
-
if (options_.url.lastIndexOf('?') === -1 && options_.queryParams != null) {
|
|
82
|
-
const queryParams = options_.queryParams;
|
|
83
|
-
// prettier-ignore
|
|
84
|
-
const queryArray = Object
|
|
85
|
-
.keys(queryParams)
|
|
86
|
-
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(queryParams[key]))}`);
|
|
87
|
-
|
|
88
|
-
if (queryArray.length > 0) {
|
|
89
|
-
options_.url += '?' + queryArray.join('&');
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// If `bodyJson` is provided, stringify it and set the appropriate 'Content-Type' header.
|
|
94
|
-
if (options_.bodyJson !== undefined) {
|
|
95
|
-
options_.body = JSON.stringify(options_.bodyJson);
|
|
96
|
-
options_.headers['content-type'] = MimeTypes.JSON;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Set the 'Authorization' header for bearer tokens or Alwatr's authentication scheme.
|
|
100
|
-
if (options_.bearerToken !== undefined) {
|
|
101
|
-
options_.headers.authorization = `Bearer ${options_.bearerToken}`;
|
|
102
|
-
} else if (options_.alwatrAuth !== undefined) {
|
|
103
|
-
options_.headers.authorization = `Alwatr ${options_.alwatrAuth.userId}:${options_.alwatrAuth.userToken}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
DEV_MODE && logger_.logProperty?.('fetch.options', options_);
|
|
107
|
-
|
|
108
|
-
return options_;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Manages caching strategies for the fetch request.
|
|
113
|
-
* If the strategy is `network_only`, it bypasses caching and proceeds to the next step.
|
|
114
|
-
* Otherwise, it interacts with the browser's Cache API based on the selected strategy.
|
|
115
|
-
*
|
|
116
|
-
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
117
|
-
* @returns {Promise<Response>} A promise resolving to a `Response` object, either from the cache or the network.
|
|
118
|
-
* @private
|
|
119
|
-
*/
|
|
120
|
-
export async function handleCacheStrategy_(options: FetchOptions__): Promise<Response> {
|
|
121
|
-
if (options.cacheStrategy === 'network_only') {
|
|
122
|
-
return handleRemoveDuplicate_(options);
|
|
123
|
-
}
|
|
124
|
-
// else
|
|
125
|
-
|
|
126
|
-
DEV_MODE && logger_.logMethod?.('handleCacheStrategy_');
|
|
127
|
-
|
|
128
|
-
if (!cacheSupported) {
|
|
129
|
-
DEV_MODE
|
|
130
|
-
&& logger_.incident?.('fetch', 'fetch_cache_strategy_unsupported', {
|
|
131
|
-
cacheSupported,
|
|
132
|
-
});
|
|
133
|
-
// Fallback to network_only if Cache API is not available.
|
|
134
|
-
options.cacheStrategy = 'network_only';
|
|
135
|
-
return handleRemoveDuplicate_(options);
|
|
136
|
-
}
|
|
137
|
-
// else
|
|
138
|
-
|
|
139
|
-
const cacheStorage = await caches.open(options.cacheStorageName);
|
|
140
|
-
|
|
141
|
-
const request = new Request(options.url, options);
|
|
142
|
-
|
|
143
|
-
switch (options.cacheStrategy) {
|
|
144
|
-
case 'cache_first': {
|
|
145
|
-
const cachedResponse = await cacheStorage.match(request);
|
|
146
|
-
if (cachedResponse != null) {
|
|
147
|
-
return cachedResponse;
|
|
148
|
-
}
|
|
149
|
-
// else
|
|
150
|
-
|
|
151
|
-
const response = await handleRemoveDuplicate_(options);
|
|
152
|
-
if (response.ok) {
|
|
153
|
-
cacheStorage.put(request, response.clone());
|
|
154
|
-
}
|
|
155
|
-
return response;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
case 'cache_only': {
|
|
159
|
-
const cachedResponse = await cacheStorage.match(request);
|
|
160
|
-
if (cachedResponse == null) {
|
|
161
|
-
throw new FetchError('cache_not_found', 'Resource not found in cache');
|
|
162
|
-
}
|
|
163
|
-
// else
|
|
164
|
-
|
|
165
|
-
return cachedResponse;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
case 'network_first': {
|
|
169
|
-
try {
|
|
170
|
-
const networkResponse = await handleRemoveDuplicate_(options);
|
|
171
|
-
if (networkResponse.ok) {
|
|
172
|
-
cacheStorage.put(request, networkResponse.clone());
|
|
173
|
-
}
|
|
174
|
-
return networkResponse;
|
|
175
|
-
} catch (err) {
|
|
176
|
-
const cachedResponse = await cacheStorage.match(request);
|
|
177
|
-
if (cachedResponse != null) {
|
|
178
|
-
return cachedResponse;
|
|
179
|
-
}
|
|
180
|
-
// else
|
|
181
|
-
|
|
182
|
-
throw err;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
case 'update_cache': {
|
|
187
|
-
const networkResponse = await handleRemoveDuplicate_(options);
|
|
188
|
-
if (networkResponse.ok) {
|
|
189
|
-
cacheStorage.put(request, networkResponse.clone());
|
|
190
|
-
}
|
|
191
|
-
return networkResponse;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
case 'stale_while_revalidate': {
|
|
195
|
-
const cachedResponse = await cacheStorage.match(request);
|
|
196
|
-
const fetchedResponsePromise = handleRemoveDuplicate_(options).then((networkResponse) => {
|
|
197
|
-
if (networkResponse.ok) {
|
|
198
|
-
cacheStorage.put(request, networkResponse.clone());
|
|
199
|
-
if (typeof options.revalidateCallback === 'function') {
|
|
200
|
-
setTimeout(options.revalidateCallback, 0, networkResponse.clone());
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return networkResponse;
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
return cachedResponse ?? fetchedResponsePromise;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
default: {
|
|
210
|
-
return handleRemoveDuplicate_(options);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Handles duplicate request elimination.
|
|
217
|
-
*
|
|
218
|
-
* It creates a unique key based on the request method, URL, and body. If a request with the
|
|
219
|
-
* same key is already in flight, it returns the promise of the existing request instead of
|
|
220
|
-
* creating a new one. This prevents redundant network calls for identical parallel requests.
|
|
221
|
-
*
|
|
222
|
-
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
223
|
-
* @returns {Promise<Response>} A promise resolving to a cloned `Response` object.
|
|
224
|
-
* @private
|
|
225
|
-
*/
|
|
226
|
-
async function handleRemoveDuplicate_(options: FetchOptions__): Promise<Response> {
|
|
227
|
-
if (options.removeDuplicate === 'never') {
|
|
228
|
-
return handleRetryPattern_(options);
|
|
229
|
-
}
|
|
230
|
-
// else
|
|
231
|
-
|
|
232
|
-
DEV_MODE && logger_.logMethod?.('handleRemoveDuplicate_');
|
|
233
|
-
|
|
234
|
-
// Create a unique key for the request. Including the body is crucial to differentiate
|
|
235
|
-
// between requests to the same URL but with different payloads (e.g., POST requests).
|
|
236
|
-
const bodyString = typeof options.body === 'string' ? options.body : '';
|
|
237
|
-
const cacheKey = `${options.method} ${options.url} ${bodyString}`;
|
|
238
|
-
|
|
239
|
-
// If a request with the same key doesn't exist, create it and store its promise.
|
|
240
|
-
duplicateRequestStorage_[cacheKey] ??= handleRetryPattern_(options);
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
// Await the shared promise to get the response.
|
|
244
|
-
const response = await duplicateRequestStorage_[cacheKey];
|
|
245
|
-
|
|
246
|
-
// Clean up the stored promise based on the removal strategy.
|
|
247
|
-
if (duplicateRequestStorage_[cacheKey] != null) {
|
|
248
|
-
if (response.ok !== true || options.removeDuplicate === 'until_load') {
|
|
249
|
-
// Remove after completion for 'until_load' or if the request failed.
|
|
250
|
-
delete duplicateRequestStorage_[cacheKey];
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Return a clone of the response, so each caller can consume the body independently.
|
|
255
|
-
return response.clone();
|
|
256
|
-
} catch (err) {
|
|
257
|
-
// If the request fails, remove it from storage to allow for retries.
|
|
258
|
-
delete duplicateRequestStorage_[cacheKey];
|
|
259
|
-
throw err;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Implements a retry mechanism for the fetch request.
|
|
265
|
-
* If the request fails due to a server error (status >= 500) or a timeout,
|
|
266
|
-
* it will be retried up to the specified number of times.
|
|
267
|
-
*
|
|
268
|
-
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
269
|
-
* @returns {Promise<Response>} A promise that resolves to the final `Response` after all retries.
|
|
270
|
-
* @private
|
|
271
|
-
*/
|
|
272
|
-
async function handleRetryPattern_(options: FetchOptions__): Promise<Response> {
|
|
273
|
-
if (!(options.retry > 1)) {
|
|
274
|
-
return handleTimeout_(options);
|
|
275
|
-
}
|
|
276
|
-
// else
|
|
277
|
-
|
|
278
|
-
DEV_MODE && logger_.logMethod?.('handleRetryPattern_');
|
|
279
|
-
options.retry--;
|
|
280
|
-
|
|
281
|
-
const externalAbortSignal = options.signal;
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
const response = await handleTimeout_(options);
|
|
285
|
-
|
|
286
|
-
if (!response.ok && response.status >= HttpStatusCodes.Error_Server_500_Internal_Server_Error) {
|
|
287
|
-
// only retry for server errors (5xx)
|
|
288
|
-
throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return response;
|
|
292
|
-
} catch (err) {
|
|
293
|
-
DEV_MODE && logger_.accident('fetch', 'fetch_failed_retry', err);
|
|
294
|
-
|
|
295
|
-
// Do not retry if the browser is offline.
|
|
296
|
-
if (globalThis_.navigator?.onLine === false) {
|
|
297
|
-
DEV_MODE && logger_.accident('handleRetryPattern_', 'offline', 'Skip retry because offline');
|
|
298
|
-
throw err;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
await delay.by(options.retryDelay);
|
|
302
|
-
|
|
303
|
-
// Restore the original signal for the next attempt.
|
|
304
|
-
options.signal = externalAbortSignal;
|
|
305
|
-
return handleRetryPattern_(options);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Wraps the native fetch call with a timeout mechanism.
|
|
311
|
-
*
|
|
312
|
-
* It uses an `AbortController` to abort the request if it does not complete
|
|
313
|
-
* within the specified `timeout` duration. It also respects external abort signals.
|
|
314
|
-
*
|
|
315
|
-
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
316
|
-
* @returns {Promise<Response>} A promise that resolves with the `Response` or rejects on timeout.
|
|
317
|
-
* @private
|
|
318
|
-
*/
|
|
319
|
-
function handleTimeout_(options: FetchOptions__): Promise<Response> {
|
|
320
|
-
if (options.timeout === 0) {
|
|
321
|
-
// If timeout is disabled, call fetch directly.
|
|
322
|
-
return globalThis_.fetch(options.url, options);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
DEV_MODE && logger_.logMethod?.('handleTimeout_');
|
|
326
|
-
|
|
327
|
-
return new Promise((resolved, reject) => {
|
|
328
|
-
const abortController = typeof AbortController === 'function' ? new AbortController() : null;
|
|
329
|
-
const externalAbortSignal = options.signal;
|
|
330
|
-
options.signal = abortController?.signal;
|
|
331
|
-
|
|
332
|
-
// If an external AbortSignal is provided, listen to it and propagate the abort.
|
|
333
|
-
if (abortController !== null && externalAbortSignal != null) {
|
|
334
|
-
externalAbortSignal.addEventListener('abort', () => abortController.abort(), {once: true});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const timeoutId = setTimeout(() => {
|
|
338
|
-
reject(new FetchError('timeout', 'fetch_timeout'));
|
|
339
|
-
abortController?.abort('fetch_timeout');
|
|
340
|
-
}, parseDuration(options.timeout!));
|
|
341
|
-
|
|
342
|
-
globalThis_
|
|
343
|
-
.fetch(options.url, options)
|
|
344
|
-
.then((response) => resolved(response))
|
|
345
|
-
.catch((reason) => reject(reason))
|
|
346
|
-
.finally(() => {
|
|
347
|
-
// Clean up the timeout to prevent it from firing after the request has completed.
|
|
348
|
-
clearTimeout(timeoutId);
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
}
|