@alwatr/fetch 7.1.6 → 8.0.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/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +5 -0
- package/dist/main.js.map +12 -0
- package/dist/type.d.ts.map +1 -1
- package/package.json +17 -19
- package/src/core.ts +346 -0
- package/src/error.ts +46 -0
- package/src/main.ts +203 -0
- package/src/type.ts +157 -0
- package/CHANGELOG.md +0 -632
- package/dist/main.cjs +0 -3
- package/dist/main.cjs.map +0 -7
- package/dist/main.mjs +0 -3
- package/dist/main.mjs.map +0 -7
package/dist/main.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export type * from './type.js';
|
|
|
53
53
|
* ```
|
|
54
54
|
*/
|
|
55
55
|
export declare function fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
|
|
56
|
+
export declare namespace fetch {
|
|
57
|
+
var version: string;
|
|
58
|
+
}
|
|
56
59
|
/**
|
|
57
60
|
* An enhanced wrapper for the native `fetch` function that automatically parses JSON responses.
|
|
58
61
|
*
|
package/dist/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAkD,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3F,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAE/E,OAAO,EAAE,cAAc,EAAE,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,mBAAmB,WAAW,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAkD3F;yBAlDqB,KAAK;;;AAsD3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAsB,SAAS,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU,EAC/D,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,gBAAqB,GAC7B,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,CA6CzC"}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* 📦 @alwatr/fetch v8.0.0 */
|
|
2
|
+
import{delay as C}from"@alwatr/delay";import{getGlobalThis as J}from"@alwatr/global-this";import{hasOwn as j}from"@alwatr/has-own";import{HttpStatusCodes as D,MimeTypes as O}from"@alwatr/http-primer";import{createLogger as P}from"@alwatr/logger";import{parseDuration as v}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=P("@alwatr/fetch"),W=J(),$=j(W,"caches"),U={},x={method:"GET",headers:{},timeout:8000,retry:3,retryDelay:1000,removeDuplicate:"never",cacheStrategy:"network_only",cacheStorageName:"fetch_cache"};function N(B,X){Z.logMethodArgs?.("_processOptions",{url:B,options:X});let z={...x,...X,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"]=O.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 K(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 L(B);Z.logMethod?.("handleRetryPattern_"),B.retry--;let X=B.signal;try{let z=await L(B);if(!z.ok&&z.status>=D.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 C.by(B.retryDelay),B.signal=X,I(B)}}function L(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")},v(B.timeout));W.fetch(B.url,B).then((H)=>X(H)).catch((H)=>z(H)).finally(()=>{clearTimeout(Y)})})}async function A(B,X={}){Z.logMethodArgs?.("fetch",{url:B,options:X});let z=N(B,X);try{let Q=await K(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]}}A.version="8.0.0";async function R(B,X={}){Z.logMethodArgs?.("fetchJson",{url:B,options:X});let[z,Q]=await A(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,A as fetch,$ as cacheSupported,G as FetchError};
|
|
3
|
+
|
|
4
|
+
//# debugId=4179A1811433C2F264756E2164756E21
|
|
5
|
+
//# sourceMappingURL=main.js.map
|
package/dist/main.js.map
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/core.ts", "../src/error.ts", "../src/main.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import {delay} from '@alwatr/delay';\nimport {getGlobalThis} from '@alwatr/global-this';\nimport {hasOwn} from '@alwatr/has-own';\nimport {HttpStatusCodes, MimeTypes} from '@alwatr/http-primer';\nimport {createLogger} from '@alwatr/logger';\nimport {parseDuration} from '@alwatr/parse-duration';\n\nimport {FetchError} from './error.js';\n\nimport type {AlwatrFetchOptions_, FetchOptions} from './type.js';\n\nexport const logger_ = createLogger('@alwatr/fetch');\n\nconst globalThis_ = getGlobalThis();\n\n/**\n * A boolean flag indicating whether the browser's Cache API is supported.\n */\nexport const cacheSupported = /* #__PURE__ */ hasOwn(globalThis_, 'caches');\n\n/**\n * A simple in-memory storage for tracking and managing duplicate in-flight requests.\n * The key is a unique identifier for the request (e.g., method + URL + body),\n * and the value is the promise of the ongoing fetch operation.\n */\nconst duplicateRequestStorage_: Record<string, Promise<Response>> = {};\n\n/**\n * Default options for all fetch requests. These can be overridden by passing\n * a custom `options` object to the `fetch` function.\n */\nconst defaultFetchOptions: AlwatrFetchOptions_ = {\n method: 'GET',\n headers: {},\n timeout: 8_000,\n retry: 3,\n retryDelay: 1_000,\n removeDuplicate: 'never',\n cacheStrategy: 'network_only',\n cacheStorageName: 'fetch_cache',\n};\n\n/**\n * Internal-only fetch options type, which includes the URL and ensures all\n * optional properties from AlwatrFetchOptions_ are present.\n */\ntype FetchOptions__ = AlwatrFetchOptions_ & Omit<RequestInit, 'headers'> & {url: string};\n\n/**\n * Processes and sanitizes the fetch options.\n *\n * @param {string} url - The URL to fetch.\n * @param {FetchOptions} options - The user-provided options.\n * @returns {FetchOptions__} The processed and complete fetch options.\n * @private\n */\nexport function _processOptions(url: string, options: FetchOptions): FetchOptions__ {\n logger_.logMethodArgs?.('_processOptions', {url, options});\n\n const options_: FetchOptions__ = {\n ...defaultFetchOptions,\n ...options,\n url,\n };\n\n options_.window ??= null;\n\n if (options_.removeDuplicate === 'auto') {\n options_.removeDuplicate = cacheSupported ? 'until_load' : 'always';\n }\n\n // Append query parameters to the URL if they are provided and the URL doesn't already have them.\n if (options_.url.lastIndexOf('?') === -1 && options_.queryParams != null) {\n const queryParams = options_.queryParams;\n // prettier-ignore\n const queryArray = Object\n .keys(queryParams)\n .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(queryParams[key]))}`);\n\n if (queryArray.length > 0) {\n options_.url += '?' + queryArray.join('&');\n }\n }\n\n // If `bodyJson` is provided, stringify it and set the appropriate 'Content-Type' header.\n if (options_.bodyJson !== undefined) {\n options_.body = JSON.stringify(options_.bodyJson);\n options_.headers['content-type'] = MimeTypes.JSON;\n }\n\n // Set the 'Authorization' header for bearer tokens or Alwatr's authentication scheme.\n if (options_.bearerToken !== undefined) {\n options_.headers.authorization = `Bearer ${options_.bearerToken}`;\n }\n else if (options_.alwatrAuth !== undefined) {\n options_.headers.authorization = `Alwatr ${options_.alwatrAuth.userId}:${options_.alwatrAuth.userToken}`;\n }\n\n logger_.logProperty?.('fetch.options', options_);\n\n return options_;\n}\n\n/**\n * Manages caching strategies for the fetch request.\n * If the strategy is `network_only`, it bypasses caching and proceeds to the next step.\n * Otherwise, it interacts with the browser's Cache API based on the selected strategy.\n *\n * @param {FetchOptions__} options - The fully configured fetch options.\n * @returns {Promise<Response>} A promise resolving to a `Response` object, either from the cache or the network.\n * @private\n */\nexport async function handleCacheStrategy_(options: FetchOptions__): Promise<Response> {\n if (options.cacheStrategy === 'network_only') {\n return handleRemoveDuplicate_(options);\n }\n // else\n\n logger_.logMethod?.('handleCacheStrategy_');\n\n if (!cacheSupported) {\n logger_.incident?.('fetch', 'fetch_cache_strategy_unsupported', {\n cacheSupported,\n });\n // Fallback to network_only if Cache API is not available.\n options.cacheStrategy = 'network_only';\n return handleRemoveDuplicate_(options);\n }\n // else\n\n const cacheStorage = await caches.open(options.cacheStorageName);\n\n const request = new Request(options.url, options);\n\n switch (options.cacheStrategy) {\n case 'cache_first': {\n const cachedResponse = await cacheStorage.match(request);\n if (cachedResponse != null) {\n return cachedResponse;\n }\n // else\n\n const response = await handleRemoveDuplicate_(options);\n if (response.ok) {\n cacheStorage.put(request, response.clone());\n }\n return response;\n }\n\n case 'cache_only': {\n const cachedResponse = await cacheStorage.match(request);\n if (cachedResponse == null) {\n throw new FetchError('cache_not_found', 'Resource not found in cache');\n }\n // else\n\n return cachedResponse;\n }\n\n case 'network_first': {\n try {\n const networkResponse = await handleRemoveDuplicate_(options);\n if (networkResponse.ok) {\n cacheStorage.put(request, networkResponse.clone());\n }\n return networkResponse;\n }\n catch (err) {\n const cachedResponse = await cacheStorage.match(request);\n if (cachedResponse != null) {\n return cachedResponse;\n }\n // else\n\n throw err;\n }\n }\n\n case 'update_cache': {\n const networkResponse = await handleRemoveDuplicate_(options);\n if (networkResponse.ok) {\n cacheStorage.put(request, networkResponse.clone());\n }\n return networkResponse;\n }\n\n case 'stale_while_revalidate': {\n const cachedResponse = await cacheStorage.match(request);\n const fetchedResponsePromise = handleRemoveDuplicate_(options).then((networkResponse) => {\n if (networkResponse.ok) {\n cacheStorage.put(request, networkResponse.clone());\n if (typeof options.revalidateCallback === 'function') {\n setTimeout(options.revalidateCallback, 0, networkResponse.clone());\n }\n }\n return networkResponse;\n });\n\n return cachedResponse ?? fetchedResponsePromise;\n }\n\n default: {\n return handleRemoveDuplicate_(options);\n }\n }\n}\n\n/**\n * Handles duplicate request elimination.\n *\n * It creates a unique key based on the request method, URL, and body. If a request with the\n * same key is already in flight, it returns the promise of the existing request instead of\n * creating a new one. This prevents redundant network calls for identical parallel requests.\n *\n * @param {FetchOptions__} options - The fully configured fetch options.\n * @returns {Promise<Response>} A promise resolving to a cloned `Response` object.\n * @private\n */\nasync function handleRemoveDuplicate_(options: FetchOptions__): Promise<Response> {\n if (options.removeDuplicate === 'never') {\n return handleRetryPattern_(options);\n }\n // else\n\n logger_.logMethod?.('handleRemoveDuplicate_');\n\n // Create a unique key for the request. Including the body is crucial to differentiate\n // between requests to the same URL but with different payloads (e.g., POST requests).\n const bodyString = typeof options.body === 'string' ? options.body : '';\n const cacheKey = `${options.method} ${options.url} ${bodyString}`;\n\n // If a request with the same key doesn't exist, create it and store its promise.\n duplicateRequestStorage_[cacheKey] ??= handleRetryPattern_(options);\n\n try {\n // Await the shared promise to get the response.\n const response = await duplicateRequestStorage_[cacheKey];\n\n // Clean up the stored promise based on the removal strategy.\n if (duplicateRequestStorage_[cacheKey] != null) {\n if (response.ok !== true || options.removeDuplicate === 'until_load') {\n // Remove after completion for 'until_load' or if the request failed.\n delete duplicateRequestStorage_[cacheKey];\n }\n }\n\n // Return a clone of the response, so each caller can consume the body independently.\n return response.clone();\n }\n catch (err) {\n // If the request fails, remove it from storage to allow for retries.\n delete duplicateRequestStorage_[cacheKey];\n throw err;\n }\n}\n\n/**\n * Implements a retry mechanism for the fetch request.\n * If the request fails due to a server error (status >= 500) or a timeout,\n * it will be retried up to the specified number of times.\n *\n * @param {FetchOptions__} options - The fully configured fetch options.\n * @returns {Promise<Response>} A promise that resolves to the final `Response` after all retries.\n * @private\n */\nasync function handleRetryPattern_(options: FetchOptions__): Promise<Response> {\n if (!(options.retry > 1)) {\n return handleTimeout_(options);\n }\n // else\n\n logger_.logMethod?.('handleRetryPattern_');\n options.retry--;\n\n const externalAbortSignal = options.signal;\n\n try {\n const response = await handleTimeout_(options);\n\n if (!response.ok && response.status >= HttpStatusCodes.Error_Server_500_Internal_Server_Error) {\n // only retry for server errors (5xx)\n throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);\n }\n\n return response;\n }\n catch (err) {\n logger_.accident('fetch', 'fetch_failed_retry', err);\n\n // Do not retry if the browser is offline.\n if (globalThis_.navigator?.onLine === false) {\n logger_.accident('handleRetryPattern_', 'offline', 'Skip retry because offline');\n throw err;\n }\n\n await delay.by(options.retryDelay);\n\n // Restore the original signal for the next attempt.\n options.signal = externalAbortSignal;\n return handleRetryPattern_(options);\n }\n}\n\n/**\n * Wraps the native fetch call with a timeout mechanism.\n *\n * It uses an `AbortController` to abort the request if it does not complete\n * within the specified `timeout` duration. It also respects external abort signals.\n *\n * @param {FetchOptions__} options - The fully configured fetch options.\n * @returns {Promise<Response>} A promise that resolves with the `Response` or rejects on timeout.\n * @private\n */\nfunction handleTimeout_(options: FetchOptions__): Promise<Response> {\n if (options.timeout === 0) {\n // If timeout is disabled, call fetch directly.\n return globalThis_.fetch(options.url, options);\n }\n\n logger_.logMethod?.('handleTimeout_');\n\n return new Promise((resolved, reject) => {\n const abortController = typeof AbortController === 'function' ? new AbortController() : null;\n const externalAbortSignal = options.signal;\n options.signal = abortController?.signal;\n\n // If an external AbortSignal is provided, listen to it and propagate the abort.\n if (abortController !== null && externalAbortSignal != null) {\n externalAbortSignal.addEventListener('abort', () => abortController.abort(), {once: true});\n }\n\n const timeoutId = setTimeout(() => {\n reject(new FetchError('timeout', 'fetch_timeout'));\n abortController?.abort('fetch_timeout');\n }, parseDuration(options.timeout!));\n\n globalThis_\n .fetch(options.url, options)\n .then((response) => resolved(response))\n .catch((reason) => reject(reason))\n .finally(() => {\n // Clean up the timeout to prevent it from firing after the request has completed.\n clearTimeout(timeoutId);\n });\n });\n}\n",
|
|
6
|
+
"import type { FetchErrorReason } from \"./type.js\";\n\n/**\n * Custom error class for fetch-related failures.\n *\n * This error is thrown when a fetch request fails, either due to a network issue\n * or an HTTP error status (i.e., `response.ok` is `false`). It enriches the\n * standard `Error` object with the `response` and the parsed `data` from the\n * response body, allowing for more detailed error handling.\n *\n * @example\n * ```typescript\n * const [response, error] = await fetch('/api/endpoint');\n * if (error) {\n * console.error(`Request failed with status ${error.response?.status}`);\n * console.error('Server response:', error.data);\n * }\n * ```\n */\nexport class FetchError extends Error {\n /**\n * The original `Response` object.\n * This is useful for accessing headers and other response metadata.\n * It will be `undefined` for non-HTTP errors like network failures or timeouts.\n */\n public response?: Response;\n\n /**\n * The parsed body of the error response, typically a JSON object.\n * It will be `undefined` for non-HTTP errors.\n */\n public data?: JsonObject | string;\n\n /**\n * The specific reason for the fetch failure.\n */\n public reason: FetchErrorReason;\n\n constructor(reason: FetchErrorReason, message: string, response?: Response, data?: JsonObject | string) {\n super(message);\n this.name = 'FetchError';\n this.reason = reason;\n this.response = response;\n this.data = data;\n }\n}\n",
|
|
7
|
+
"/**\n * @module @alwatr/fetch\n *\n * An enhanced, lightweight, and dependency-free wrapper for the native `fetch`\n * API. It provides modern features like caching strategies, request retries,\n * timeouts, and duplicate request handling.\n */\n\nimport { _processOptions, handleCacheStrategy_, logger_, cacheSupported } from './core.js';\nimport { FetchError } from './error.js';\n\nimport type { FetchJsonOptions, FetchOptions, FetchResponse } from './type.js';\n\nexport { cacheSupported };\nexport * from './error.js';\nexport type * from './type.js';\n\n/**\n * An enhanced wrapper for the native `fetch` function.\n *\n * This function extends the standard `fetch` with additional features such as:\n * - **Timeout**: Aborts the request if it takes too long.\n * - **Retry Pattern**: Automatically retries the request on failure (e.g., server errors or network issues).\n * - **Duplicate Request Handling**: Prevents sending multiple identical requests in parallel.\n * - **Cache Strategies**: Provides various caching mechanisms using the browser's Cache API.\n * - **Simplified API**: Offers convenient options for adding query parameters, JSON bodies, and auth tokens.\n *\n * @see {@link FetchOptions} for a detailed list of available options.\n *\n * @param {string} url - The URL to fetch.\n * @param {FetchOptions} options - Optional configuration for the fetch request.\n * @returns {Promise<FetchResponse>} A promise that resolves to a tuple. On\n * success, it returns `[response, null]`. On failure, it returns `[null,\n * FetchError]`.\n *\n * @example\n * ```typescript\n * import {fetch} from '@alwatr/fetch';\n *\n * async function fetchProducts() {\n * const [response, error] = await fetch('/api/products', {\n * queryParams: { limit: 10 },\n * timeout: 5_000,\n * });\n *\n * if (error) {\n * console.error('Request failed:', error.reason);\n * return;\n * }\n *\n * // At this point, response is guaranteed to be valid and ok.\n * const data = await response.json();\n * console.log('Products:', data);\n * }\n *\n * fetchProducts();\n * ```\n */\nexport async function fetch(url: string, options: FetchOptions = {}): Promise<FetchResponse> {\n logger_.logMethodArgs?.('fetch', { url, options });\n\n const options_ = _processOptions(url, options);\n\n try {\n // Start the fetch lifecycle, beginning with the cache strategy.\n const response = await handleCacheStrategy_(options_);\n\n if (!response.ok) {\n throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);\n }\n\n return [response, null];\n }\n catch (err) {\n let error: FetchError;\n\n if (err instanceof FetchError) {\n error = err;\n\n if (error.response !== undefined && error.data === undefined) {\n const bodyText = await error.response.text().catch(() => '');\n\n if (bodyText.trim().length > 0) {\n try {\n // Try to parse as JSON\n error.data = JSON.parse(bodyText);\n }\n catch {\n error.data = bodyText;\n }\n }\n }\n }\n else if (err instanceof Error) {\n if (err.name === 'AbortError') {\n error = new FetchError('aborted', err.message);\n }\n else {\n error = new FetchError('network_error', err.message);\n }\n }\n else {\n error = new FetchError('unknown_error', String(err ?? 'unknown_error'));\n }\n\n logger_.error('fetch', error.reason, { error });\n return [null, error];\n }\n}\n\nfetch.version = __package_version__;\n\n/**\n * An enhanced wrapper for the native `fetch` function that automatically parses JSON responses.\n *\n * This function extends the standard `fetch` with the same features (timeout, retry, caching, etc.)\n * and automatically parses the response body as JSON. It returns a tuple with the parsed data or an error.\n *\n * @template T - The expected type of the JSON response data.\n *\n * @param {string} url - The URL to fetch.\n * @param {FetchOptions} options - Optional configuration for the fetch request.\n * @returns {Promise<[T, null] | [null, FetchError]>} A promise that resolves to a tuple.\n * On success, it returns `[data, null]` where data is the parsed JSON.\n * On failure, it returns `[null, FetchError]`.\n *\n * @example\n * ```typescript\n * import {fetchJson} from '@alwatr/fetch';\n *\n * interface Product {\n * ok: true;\n * id: number;\n * name: string;\n * price: number;\n * }\n *\n * async function getProduct(id: number) {\n * const [data, error] = await fetchJson<Product>(`/api/products/${id}`, {\n * timeout: 5_000,\n * cacheStrategy: 'cache_first',\n * requireResponseJsonWithOkTrue: true,\n * });\n *\n * if (error) {\n * console.error('Failed to fetch product:', error.reason);\n * return;\n * }\n *\n * // data is now typed as Product and guaranteed to be valid\n * console.log('Product name:', data.name);\n * }\n * ```\n */\nexport async function fetchJson<T extends JsonObject = JsonObject>(\n url: string,\n options: FetchJsonOptions = {},\n): Promise<[T, null] | [null, FetchError]> {\n logger_.logMethodArgs?.('fetchJson', { url, options });\n\n const [response, error] = await fetch(url, options);\n\n if (error) {\n return [null, error];\n }\n\n const bodyText = await response.text().catch(() => '');\n if (bodyText.trim().length === 0) {\n const parseError = new FetchError(\n 'json_parse_error',\n 'Response body is empty, cannot parse JSON',\n response,\n bodyText,\n );\n logger_.error('fetchJson', parseError.reason, { error: parseError });\n return [null, parseError];\n }\n\n try {\n const data = JSON.parse(bodyText) as T;\n if (options.requireJsonResponseWithOkTrue && data.ok !== true) {\n const parseError = new FetchError(\n 'json_response_error',\n 'Response JSON \"ok\" property is not true',\n response,\n data,\n );\n logger_.error('fetchJson', parseError.reason, { error: parseError });\n return [null, parseError];\n }\n return [data, null];\n }\n catch (err) {\n const parseError = new FetchError(\n 'json_parse_error',\n err instanceof Error ? err.message : 'Failed to parse JSON response',\n response,\n bodyText,\n );\n logger_.error('fetchJson', parseError.reason, { error: parseError });\n return [null, parseError];\n }\n}\n"
|
|
8
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "4179A1811433C2F264756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
package/dist/type.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type.d.ts","sourceRoot":"","sources":["../src/type.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAC,UAAU,EAAE,kBAAkB,EAAC,MAAM,qBAAqB,CAAC;AACxE,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"type.d.ts","sourceRoot":"","sources":["../src/type.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAC,UAAU,EAAE,kBAAkB,EAAC,MAAM,qBAAqB,CAAC;AACxE,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,wBAAwB,CAAC;AAIrD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,aAAa,CAAC,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAEnE;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,eAAe,GACf,YAAY,GACZ,aAAa,GACb,cAAc,GACd,wBAAwB,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,GAAG,YAAY,GAAG,MAAM,CAAC;AAExE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,EAAE,UAAU,CAAC;IAEnB;;OAEG;IACH,OAAO,EAAE,kBAAkB,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IAEpD;;;;OAIG;IACH,OAAO,EAAE,QAAQ,CAAC;IAElB;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,UAAU,EAAE,QAAQ,CAAC;IAErB;;;;OAIG;IACH,eAAe,EAAE,cAAc,CAAC;IAEhC;;;;OAIG;IACH,aAAa,EAAE,aAAa,CAAC;IAE7B;;OAEG;IACH,kBAAkB,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAElE;;;OAGG;IACH,gBAAgB,EAAE,MAAM,CAAC;IAEzB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,CAAC;IAErB;;OAEG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;IAE1B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;OAEG;IACH,UAAU,CAAC,EAAE;QACX,MAAM,EAAE,MAAM,CAAC;QACf,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,mBAAmB,CAAC,GAAG,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;AAEvF,MAAM,MAAM,gBAAgB,GAAG,YAAY,GAAG;IAAC,6BAA6B,CAAC,EAAE,IAAI,CAAA;CAAC,CAAC;AAErF;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC;AAE3E;;;;;;;;;;GAUG;AACH,MAAM,MAAM,gBAAgB,GACxB,YAAY,GACZ,iBAAiB,GACjB,SAAS,GACT,eAAe,GACf,SAAS,GACT,kBAAkB,GAClB,qBAAqB,GACrB,eAAe,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwatr/fetch",
|
|
3
3
|
"description": "`@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.",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "8.0.0",
|
|
5
5
|
"author": "S. Ali Mihandoost <ali.mihandoost@gmail.com>",
|
|
6
6
|
"bugs": "https://github.com/Alwatr/nanolib/issues",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@alwatr/delay": "
|
|
9
|
-
"@alwatr/global-this": "
|
|
10
|
-
"@alwatr/has-own": "
|
|
11
|
-
"@alwatr/http-primer": "
|
|
12
|
-
"@alwatr/logger": "
|
|
13
|
-
"@alwatr/parse-duration": "
|
|
8
|
+
"@alwatr/delay": "7.0.0",
|
|
9
|
+
"@alwatr/global-this": "6.0.0",
|
|
10
|
+
"@alwatr/has-own": "6.0.0",
|
|
11
|
+
"@alwatr/http-primer": "7.0.0",
|
|
12
|
+
"@alwatr/logger": "7.0.0",
|
|
13
|
+
"@alwatr/parse-duration": "6.0.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@alwatr/nano-build": "
|
|
17
|
-
"@alwatr/prettier-config": "
|
|
18
|
-
"@alwatr/tsconfig-base": "
|
|
19
|
-
"@alwatr/type-helper": "
|
|
16
|
+
"@alwatr/nano-build": "7.0.0",
|
|
17
|
+
"@alwatr/prettier-config": "7.0.0",
|
|
18
|
+
"@alwatr/tsconfig-base": "7.0.0",
|
|
19
|
+
"@alwatr/type-helper": "8.0.0",
|
|
20
20
|
"typescript": "^5.9.3"
|
|
21
21
|
},
|
|
22
22
|
"exports": {
|
|
23
23
|
".": {
|
|
24
24
|
"types": "./dist/main.d.ts",
|
|
25
|
-
"
|
|
26
|
-
"require": "./dist/main.cjs"
|
|
25
|
+
"default": "./dist/main.js"
|
|
27
26
|
}
|
|
28
27
|
},
|
|
29
28
|
"files": [
|
|
30
|
-
"**/*.{js,mjs,cjs,map,d.ts,html,
|
|
29
|
+
"**/*.{js,mjs,cjs,ts,map,d.ts,html,LEGAL.txt}",
|
|
30
|
+
"README.md",
|
|
31
31
|
"LICENSE",
|
|
32
32
|
"!demo/**/*",
|
|
33
33
|
"!**/*.test.js"
|
|
@@ -56,8 +56,6 @@
|
|
|
56
56
|
"utils"
|
|
57
57
|
],
|
|
58
58
|
"license": "MPL-2.0",
|
|
59
|
-
"main": "./dist/main.cjs",
|
|
60
|
-
"module": "./dist/main.mjs",
|
|
61
59
|
"prettier": "@alwatr/prettier-config",
|
|
62
60
|
"publishConfig": {
|
|
63
61
|
"access": "public"
|
|
@@ -70,12 +68,12 @@
|
|
|
70
68
|
"scripts": {
|
|
71
69
|
"b": "bun run build",
|
|
72
70
|
"build": "bun run build:ts && bun run build:es",
|
|
73
|
-
"build:es": "nano-build --preset=module",
|
|
71
|
+
"build:es": "nano-build --preset=module src/main.ts",
|
|
74
72
|
"build:ts": "tsc --build",
|
|
75
73
|
"c": "bun run clean",
|
|
76
74
|
"cb": "bun run clean && bun run build",
|
|
77
75
|
"clean": "rm -rfv dist *.tsbuildinfo",
|
|
78
|
-
"d": "bun run build:es && bun
|
|
76
|
+
"d": "bun run build:es && bun",
|
|
79
77
|
"t": "bun run test",
|
|
80
78
|
"test": "bun test",
|
|
81
79
|
"w": "bun run watch",
|
|
@@ -86,5 +84,5 @@
|
|
|
86
84
|
"sideEffects": false,
|
|
87
85
|
"type": "module",
|
|
88
86
|
"types": "./dist/main.d.ts",
|
|
89
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "056102a1c8a563bbae8a290b6830450f467322a3"
|
|
90
88
|
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
logger_.logMethodArgs?.('_processOptions', {url, options});
|
|
59
|
+
|
|
60
|
+
const options_: FetchOptions__ = {
|
|
61
|
+
...defaultFetchOptions,
|
|
62
|
+
...options,
|
|
63
|
+
url,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
options_.window ??= null;
|
|
67
|
+
|
|
68
|
+
if (options_.removeDuplicate === 'auto') {
|
|
69
|
+
options_.removeDuplicate = cacheSupported ? 'until_load' : 'always';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Append query parameters to the URL if they are provided and the URL doesn't already have them.
|
|
73
|
+
if (options_.url.lastIndexOf('?') === -1 && options_.queryParams != null) {
|
|
74
|
+
const queryParams = options_.queryParams;
|
|
75
|
+
// prettier-ignore
|
|
76
|
+
const queryArray = Object
|
|
77
|
+
.keys(queryParams)
|
|
78
|
+
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(queryParams[key]))}`);
|
|
79
|
+
|
|
80
|
+
if (queryArray.length > 0) {
|
|
81
|
+
options_.url += '?' + queryArray.join('&');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// If `bodyJson` is provided, stringify it and set the appropriate 'Content-Type' header.
|
|
86
|
+
if (options_.bodyJson !== undefined) {
|
|
87
|
+
options_.body = JSON.stringify(options_.bodyJson);
|
|
88
|
+
options_.headers['content-type'] = MimeTypes.JSON;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Set the 'Authorization' header for bearer tokens or Alwatr's authentication scheme.
|
|
92
|
+
if (options_.bearerToken !== undefined) {
|
|
93
|
+
options_.headers.authorization = `Bearer ${options_.bearerToken}`;
|
|
94
|
+
}
|
|
95
|
+
else if (options_.alwatrAuth !== undefined) {
|
|
96
|
+
options_.headers.authorization = `Alwatr ${options_.alwatrAuth.userId}:${options_.alwatrAuth.userToken}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
logger_.logProperty?.('fetch.options', options_);
|
|
100
|
+
|
|
101
|
+
return options_;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Manages caching strategies for the fetch request.
|
|
106
|
+
* If the strategy is `network_only`, it bypasses caching and proceeds to the next step.
|
|
107
|
+
* Otherwise, it interacts with the browser's Cache API based on the selected strategy.
|
|
108
|
+
*
|
|
109
|
+
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
110
|
+
* @returns {Promise<Response>} A promise resolving to a `Response` object, either from the cache or the network.
|
|
111
|
+
* @private
|
|
112
|
+
*/
|
|
113
|
+
export async function handleCacheStrategy_(options: FetchOptions__): Promise<Response> {
|
|
114
|
+
if (options.cacheStrategy === 'network_only') {
|
|
115
|
+
return handleRemoveDuplicate_(options);
|
|
116
|
+
}
|
|
117
|
+
// else
|
|
118
|
+
|
|
119
|
+
logger_.logMethod?.('handleCacheStrategy_');
|
|
120
|
+
|
|
121
|
+
if (!cacheSupported) {
|
|
122
|
+
logger_.incident?.('fetch', 'fetch_cache_strategy_unsupported', {
|
|
123
|
+
cacheSupported,
|
|
124
|
+
});
|
|
125
|
+
// Fallback to network_only if Cache API is not available.
|
|
126
|
+
options.cacheStrategy = 'network_only';
|
|
127
|
+
return handleRemoveDuplicate_(options);
|
|
128
|
+
}
|
|
129
|
+
// else
|
|
130
|
+
|
|
131
|
+
const cacheStorage = await caches.open(options.cacheStorageName);
|
|
132
|
+
|
|
133
|
+
const request = new Request(options.url, options);
|
|
134
|
+
|
|
135
|
+
switch (options.cacheStrategy) {
|
|
136
|
+
case 'cache_first': {
|
|
137
|
+
const cachedResponse = await cacheStorage.match(request);
|
|
138
|
+
if (cachedResponse != null) {
|
|
139
|
+
return cachedResponse;
|
|
140
|
+
}
|
|
141
|
+
// else
|
|
142
|
+
|
|
143
|
+
const response = await handleRemoveDuplicate_(options);
|
|
144
|
+
if (response.ok) {
|
|
145
|
+
cacheStorage.put(request, response.clone());
|
|
146
|
+
}
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'cache_only': {
|
|
151
|
+
const cachedResponse = await cacheStorage.match(request);
|
|
152
|
+
if (cachedResponse == null) {
|
|
153
|
+
throw new FetchError('cache_not_found', 'Resource not found in cache');
|
|
154
|
+
}
|
|
155
|
+
// else
|
|
156
|
+
|
|
157
|
+
return cachedResponse;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'network_first': {
|
|
161
|
+
try {
|
|
162
|
+
const networkResponse = await handleRemoveDuplicate_(options);
|
|
163
|
+
if (networkResponse.ok) {
|
|
164
|
+
cacheStorage.put(request, networkResponse.clone());
|
|
165
|
+
}
|
|
166
|
+
return networkResponse;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const cachedResponse = await cacheStorage.match(request);
|
|
170
|
+
if (cachedResponse != null) {
|
|
171
|
+
return cachedResponse;
|
|
172
|
+
}
|
|
173
|
+
// else
|
|
174
|
+
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'update_cache': {
|
|
180
|
+
const networkResponse = await handleRemoveDuplicate_(options);
|
|
181
|
+
if (networkResponse.ok) {
|
|
182
|
+
cacheStorage.put(request, networkResponse.clone());
|
|
183
|
+
}
|
|
184
|
+
return networkResponse;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'stale_while_revalidate': {
|
|
188
|
+
const cachedResponse = await cacheStorage.match(request);
|
|
189
|
+
const fetchedResponsePromise = handleRemoveDuplicate_(options).then((networkResponse) => {
|
|
190
|
+
if (networkResponse.ok) {
|
|
191
|
+
cacheStorage.put(request, networkResponse.clone());
|
|
192
|
+
if (typeof options.revalidateCallback === 'function') {
|
|
193
|
+
setTimeout(options.revalidateCallback, 0, networkResponse.clone());
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return networkResponse;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return cachedResponse ?? fetchedResponsePromise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
default: {
|
|
203
|
+
return handleRemoveDuplicate_(options);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Handles duplicate request elimination.
|
|
210
|
+
*
|
|
211
|
+
* It creates a unique key based on the request method, URL, and body. If a request with the
|
|
212
|
+
* same key is already in flight, it returns the promise of the existing request instead of
|
|
213
|
+
* creating a new one. This prevents redundant network calls for identical parallel requests.
|
|
214
|
+
*
|
|
215
|
+
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
216
|
+
* @returns {Promise<Response>} A promise resolving to a cloned `Response` object.
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
async function handleRemoveDuplicate_(options: FetchOptions__): Promise<Response> {
|
|
220
|
+
if (options.removeDuplicate === 'never') {
|
|
221
|
+
return handleRetryPattern_(options);
|
|
222
|
+
}
|
|
223
|
+
// else
|
|
224
|
+
|
|
225
|
+
logger_.logMethod?.('handleRemoveDuplicate_');
|
|
226
|
+
|
|
227
|
+
// Create a unique key for the request. Including the body is crucial to differentiate
|
|
228
|
+
// between requests to the same URL but with different payloads (e.g., POST requests).
|
|
229
|
+
const bodyString = typeof options.body === 'string' ? options.body : '';
|
|
230
|
+
const cacheKey = `${options.method} ${options.url} ${bodyString}`;
|
|
231
|
+
|
|
232
|
+
// If a request with the same key doesn't exist, create it and store its promise.
|
|
233
|
+
duplicateRequestStorage_[cacheKey] ??= handleRetryPattern_(options);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
// Await the shared promise to get the response.
|
|
237
|
+
const response = await duplicateRequestStorage_[cacheKey];
|
|
238
|
+
|
|
239
|
+
// Clean up the stored promise based on the removal strategy.
|
|
240
|
+
if (duplicateRequestStorage_[cacheKey] != null) {
|
|
241
|
+
if (response.ok !== true || options.removeDuplicate === 'until_load') {
|
|
242
|
+
// Remove after completion for 'until_load' or if the request failed.
|
|
243
|
+
delete duplicateRequestStorage_[cacheKey];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Return a clone of the response, so each caller can consume the body independently.
|
|
248
|
+
return response.clone();
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
// If the request fails, remove it from storage to allow for retries.
|
|
252
|
+
delete duplicateRequestStorage_[cacheKey];
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Implements a retry mechanism for the fetch request.
|
|
259
|
+
* If the request fails due to a server error (status >= 500) or a timeout,
|
|
260
|
+
* it will be retried up to the specified number of times.
|
|
261
|
+
*
|
|
262
|
+
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
263
|
+
* @returns {Promise<Response>} A promise that resolves to the final `Response` after all retries.
|
|
264
|
+
* @private
|
|
265
|
+
*/
|
|
266
|
+
async function handleRetryPattern_(options: FetchOptions__): Promise<Response> {
|
|
267
|
+
if (!(options.retry > 1)) {
|
|
268
|
+
return handleTimeout_(options);
|
|
269
|
+
}
|
|
270
|
+
// else
|
|
271
|
+
|
|
272
|
+
logger_.logMethod?.('handleRetryPattern_');
|
|
273
|
+
options.retry--;
|
|
274
|
+
|
|
275
|
+
const externalAbortSignal = options.signal;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const response = await handleTimeout_(options);
|
|
279
|
+
|
|
280
|
+
if (!response.ok && response.status >= HttpStatusCodes.Error_Server_500_Internal_Server_Error) {
|
|
281
|
+
// only retry for server errors (5xx)
|
|
282
|
+
throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return response;
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
logger_.accident('fetch', 'fetch_failed_retry', err);
|
|
289
|
+
|
|
290
|
+
// Do not retry if the browser is offline.
|
|
291
|
+
if (globalThis_.navigator?.onLine === false) {
|
|
292
|
+
logger_.accident('handleRetryPattern_', 'offline', 'Skip retry because offline');
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await delay.by(options.retryDelay);
|
|
297
|
+
|
|
298
|
+
// Restore the original signal for the next attempt.
|
|
299
|
+
options.signal = externalAbortSignal;
|
|
300
|
+
return handleRetryPattern_(options);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Wraps the native fetch call with a timeout mechanism.
|
|
306
|
+
*
|
|
307
|
+
* It uses an `AbortController` to abort the request if it does not complete
|
|
308
|
+
* within the specified `timeout` duration. It also respects external abort signals.
|
|
309
|
+
*
|
|
310
|
+
* @param {FetchOptions__} options - The fully configured fetch options.
|
|
311
|
+
* @returns {Promise<Response>} A promise that resolves with the `Response` or rejects on timeout.
|
|
312
|
+
* @private
|
|
313
|
+
*/
|
|
314
|
+
function handleTimeout_(options: FetchOptions__): Promise<Response> {
|
|
315
|
+
if (options.timeout === 0) {
|
|
316
|
+
// If timeout is disabled, call fetch directly.
|
|
317
|
+
return globalThis_.fetch(options.url, options);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
logger_.logMethod?.('handleTimeout_');
|
|
321
|
+
|
|
322
|
+
return new Promise((resolved, reject) => {
|
|
323
|
+
const abortController = typeof AbortController === 'function' ? new AbortController() : null;
|
|
324
|
+
const externalAbortSignal = options.signal;
|
|
325
|
+
options.signal = abortController?.signal;
|
|
326
|
+
|
|
327
|
+
// If an external AbortSignal is provided, listen to it and propagate the abort.
|
|
328
|
+
if (abortController !== null && externalAbortSignal != null) {
|
|
329
|
+
externalAbortSignal.addEventListener('abort', () => abortController.abort(), {once: true});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const timeoutId = setTimeout(() => {
|
|
333
|
+
reject(new FetchError('timeout', 'fetch_timeout'));
|
|
334
|
+
abortController?.abort('fetch_timeout');
|
|
335
|
+
}, parseDuration(options.timeout!));
|
|
336
|
+
|
|
337
|
+
globalThis_
|
|
338
|
+
.fetch(options.url, options)
|
|
339
|
+
.then((response) => resolved(response))
|
|
340
|
+
.catch((reason) => reject(reason))
|
|
341
|
+
.finally(() => {
|
|
342
|
+
// Clean up the timeout to prevent it from firing after the request has completed.
|
|
343
|
+
clearTimeout(timeoutId);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FetchErrorReason } from "./type.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom error class for fetch-related failures.
|
|
5
|
+
*
|
|
6
|
+
* This error is thrown when a fetch request fails, either due to a network issue
|
|
7
|
+
* or an HTTP error status (i.e., `response.ok` is `false`). It enriches the
|
|
8
|
+
* standard `Error` object with the `response` and the parsed `data` from the
|
|
9
|
+
* response body, allowing for more detailed error handling.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const [response, error] = await fetch('/api/endpoint');
|
|
14
|
+
* if (error) {
|
|
15
|
+
* console.error(`Request failed with status ${error.response?.status}`);
|
|
16
|
+
* console.error('Server response:', error.data);
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class FetchError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* The original `Response` object.
|
|
23
|
+
* This is useful for accessing headers and other response metadata.
|
|
24
|
+
* It will be `undefined` for non-HTTP errors like network failures or timeouts.
|
|
25
|
+
*/
|
|
26
|
+
public response?: Response;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The parsed body of the error response, typically a JSON object.
|
|
30
|
+
* It will be `undefined` for non-HTTP errors.
|
|
31
|
+
*/
|
|
32
|
+
public data?: JsonObject | string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The specific reason for the fetch failure.
|
|
36
|
+
*/
|
|
37
|
+
public reason: FetchErrorReason;
|
|
38
|
+
|
|
39
|
+
constructor(reason: FetchErrorReason, message: string, response?: Response, data?: JsonObject | string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'FetchError';
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
this.response = response;
|
|
44
|
+
this.data = data;
|
|
45
|
+
}
|
|
46
|
+
}
|