UNPKG

@alwatr/fetch

Version:

`@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.

8 lines (7 loc) 17.8 kB
{ "version": 3, "sources": ["../src/main.ts"], "sourcesContent": ["/**\n * @module @alwatr/fetch\n *\n * An enhanced, lightweight, and dependency-free wrapper for the native `fetch` API.\n * It provides modern features like caching strategies, request retries, timeouts, and\n * duplicate request handling.\n */\n\nimport {delay} from '@alwatr/delay';\nimport {getGlobalThis} from '@alwatr/global-this';\nimport {HttpStatusCodes, MimeTypes} from '@alwatr/http-primer';\nimport {createLogger} from '@alwatr/logger';\nimport {parseDuration} from '@alwatr/parse-duration';\n\nimport type {AlwatrFetchOptions_, FetchOptions} from './type.js';\n\nexport {cacheSupported};\nexport type * from './type.js';\n\nconst logger_ = createLogger('@alwatr/fetch');\nconst globalThis_ = getGlobalThis();\n\n/**\n * A boolean flag indicating whether the browser's Cache API is supported.\n */\nconst cacheSupported = Object.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 * 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<Response>} A promise that resolves to the `Response` object for the request.\n *\n * @example\n * ```typescript\n * async function fetchProducts() {\n * try {\n * const response = await fetch(\"/api/products\", {\n * queryParams: { limit: 10, category: \"electronics\" },\n * timeout: 5_000, // 5 seconds\n * retry: 3,\n * cacheStrategy: \"stale_while_revalidate\",\n * });\n *\n * if (!response.ok) {\n * throw new Error(`HTTP error! status: ${response.status}`);\n * }\n *\n * const data = await response.json();\n * console.log(\"Products:\", data);\n * } catch (error) {\n * console.error(\"Failed to fetch products:\", error);\n * }\n * }\n *\n * fetchProducts();\n * ```\n */\nexport function fetch(url: string, options: FetchOptions): Promise<Response> {\n logger_.logMethodArgs?.('fetch', {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 // Start the fetch lifecycle, beginning with the cache strategy.\n return handleCacheStrategy_(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 */\nasync 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 logger_.accident('_handleCacheStrategy', 'fetch_cache_not_found', {url: request.url});\n throw new Error('fetch_cache_not_found');\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 // Only retry on server errors (5xx). Client errors (4xx) are not retried.\n if (response.status < HttpStatusCodes.Error_Server_500_Internal_Server_Error) {\n return response;\n }\n // else\n\n throw new Error('fetch_server_error');\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 Error('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"], "mappings": "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", "names": [] }