@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.
347 lines (291 loc) • 11.6 kB
text/typescript
import {delay} from '@alwatr/delay';
import {getGlobalThis} from '@alwatr/global-this';
import {hasOwn} from '@alwatr/has-own';
import {HttpStatusCodes, MimeTypes} from '@alwatr/http-primer';
import {createLogger} from '@alwatr/logger';
import {parseDuration} from '@alwatr/parse-duration';
import {FetchError} from './error.js';
import type {AlwatrFetchOptions_, FetchOptions} from './type.js';
export const logger_ = createLogger('@alwatr/fetch');
const globalThis_ = getGlobalThis();
/**
* A boolean flag indicating whether the browser's Cache API is supported.
*/
export const cacheSupported = /* #__PURE__ */ hasOwn(globalThis_, 'caches');
/**
* A simple in-memory storage for tracking and managing duplicate in-flight requests.
* The key is a unique identifier for the request (e.g., method + URL + body),
* and the value is the promise of the ongoing fetch operation.
*/
const duplicateRequestStorage_: Record<string, Promise<Response>> = {};
/**
* Default options for all fetch requests. These can be overridden by passing
* a custom `options` object to the `fetch` function.
*/
const defaultFetchOptions: AlwatrFetchOptions_ = {
method: 'GET',
headers: {},
timeout: 8_000,
retry: 3,
retryDelay: 1_000,
removeDuplicate: 'never',
cacheStrategy: 'network_only',
cacheStorageName: 'fetch_cache',
};
/**
* Internal-only fetch options type, which includes the URL and ensures all
* optional properties from AlwatrFetchOptions_ are present.
*/
type FetchOptions__ = AlwatrFetchOptions_ & Omit<RequestInit, 'headers'> & {url: string};
/**
* Processes and sanitizes the fetch options.
*
* @param {string} url - The URL to fetch.
* @param {FetchOptions} options - The user-provided options.
* @returns {FetchOptions__} The processed and complete fetch options.
* @private
*/
export function _processOptions(url: string, options: FetchOptions): FetchOptions__ {
logger_.logMethodArgs?.('_processOptions', {url, options});
const options_: FetchOptions__ = {
...defaultFetchOptions,
...options,
url,
};
options_.window ??= null;
if (options_.removeDuplicate === 'auto') {
options_.removeDuplicate = cacheSupported ? 'until_load' : 'always';
}
// Append query parameters to the URL if they are provided and the URL doesn't already have them.
if (options_.url.lastIndexOf('?') === -1 && options_.queryParams != null) {
const queryParams = options_.queryParams;
// prettier-ignore
const queryArray = Object
.keys(queryParams)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(String(queryParams[key]))}`);
if (queryArray.length > 0) {
options_.url += '?' + queryArray.join('&');
}
}
// If `bodyJson` is provided, stringify it and set the appropriate 'Content-Type' header.
if (options_.bodyJson !== undefined) {
options_.body = JSON.stringify(options_.bodyJson);
options_.headers['content-type'] = MimeTypes.JSON;
}
// Set the 'Authorization' header for bearer tokens or Alwatr's authentication scheme.
if (options_.bearerToken !== undefined) {
options_.headers.authorization = `Bearer ${options_.bearerToken}`;
}
else if (options_.alwatrAuth !== undefined) {
options_.headers.authorization = `Alwatr ${options_.alwatrAuth.userId}:${options_.alwatrAuth.userToken}`;
}
logger_.logProperty?.('fetch.options', options_);
return options_;
}
/**
* Manages caching strategies for the fetch request.
* If the strategy is `network_only`, it bypasses caching and proceeds to the next step.
* Otherwise, it interacts with the browser's Cache API based on the selected strategy.
*
* @param {FetchOptions__} options - The fully configured fetch options.
* @returns {Promise<Response>} A promise resolving to a `Response` object, either from the cache or the network.
* @private
*/
export async function handleCacheStrategy_(options: FetchOptions__): Promise<Response> {
if (options.cacheStrategy === 'network_only') {
return handleRemoveDuplicate_(options);
}
// else
logger_.logMethod?.('handleCacheStrategy_');
if (!cacheSupported) {
logger_.incident?.('fetch', 'fetch_cache_strategy_unsupported', {
cacheSupported,
});
// Fallback to network_only if Cache API is not available.
options.cacheStrategy = 'network_only';
return handleRemoveDuplicate_(options);
}
// else
const cacheStorage = await caches.open(options.cacheStorageName);
const request = new Request(options.url, options);
switch (options.cacheStrategy) {
case 'cache_first': {
const cachedResponse = await cacheStorage.match(request);
if (cachedResponse != null) {
return cachedResponse;
}
// else
const response = await handleRemoveDuplicate_(options);
if (response.ok) {
cacheStorage.put(request, response.clone());
}
return response;
}
case 'cache_only': {
const cachedResponse = await cacheStorage.match(request);
if (cachedResponse == null) {
throw new FetchError('cache_not_found', 'Resource not found in cache');
}
// else
return cachedResponse;
}
case 'network_first': {
try {
const networkResponse = await handleRemoveDuplicate_(options);
if (networkResponse.ok) {
cacheStorage.put(request, networkResponse.clone());
}
return networkResponse;
}
catch (err) {
const cachedResponse = await cacheStorage.match(request);
if (cachedResponse != null) {
return cachedResponse;
}
// else
throw err;
}
}
case 'update_cache': {
const networkResponse = await handleRemoveDuplicate_(options);
if (networkResponse.ok) {
cacheStorage.put(request, networkResponse.clone());
}
return networkResponse;
}
case 'stale_while_revalidate': {
const cachedResponse = await cacheStorage.match(request);
const fetchedResponsePromise = handleRemoveDuplicate_(options).then((networkResponse) => {
if (networkResponse.ok) {
cacheStorage.put(request, networkResponse.clone());
if (typeof options.revalidateCallback === 'function') {
setTimeout(options.revalidateCallback, 0, networkResponse.clone());
}
}
return networkResponse;
});
return cachedResponse ?? fetchedResponsePromise;
}
default: {
return handleRemoveDuplicate_(options);
}
}
}
/**
* Handles duplicate request elimination.
*
* It creates a unique key based on the request method, URL, and body. If a request with the
* same key is already in flight, it returns the promise of the existing request instead of
* creating a new one. This prevents redundant network calls for identical parallel requests.
*
* @param {FetchOptions__} options - The fully configured fetch options.
* @returns {Promise<Response>} A promise resolving to a cloned `Response` object.
* @private
*/
async function handleRemoveDuplicate_(options: FetchOptions__): Promise<Response> {
if (options.removeDuplicate === 'never') {
return handleRetryPattern_(options);
}
// else
logger_.logMethod?.('handleRemoveDuplicate_');
// Create a unique key for the request. Including the body is crucial to differentiate
// between requests to the same URL but with different payloads (e.g., POST requests).
const bodyString = typeof options.body === 'string' ? options.body : '';
const cacheKey = `${options.method} ${options.url} ${bodyString}`;
// If a request with the same key doesn't exist, create it and store its promise.
duplicateRequestStorage_[cacheKey] ??= handleRetryPattern_(options);
try {
// Await the shared promise to get the response.
const response = await duplicateRequestStorage_[cacheKey];
// Clean up the stored promise based on the removal strategy.
if (duplicateRequestStorage_[cacheKey] != null) {
if (response.ok !== true || options.removeDuplicate === 'until_load') {
// Remove after completion for 'until_load' or if the request failed.
delete duplicateRequestStorage_[cacheKey];
}
}
// Return a clone of the response, so each caller can consume the body independently.
return response.clone();
}
catch (err) {
// If the request fails, remove it from storage to allow for retries.
delete duplicateRequestStorage_[cacheKey];
throw err;
}
}
/**
* Implements a retry mechanism for the fetch request.
* If the request fails due to a server error (status >= 500) or a timeout,
* it will be retried up to the specified number of times.
*
* @param {FetchOptions__} options - The fully configured fetch options.
* @returns {Promise<Response>} A promise that resolves to the final `Response` after all retries.
* @private
*/
async function handleRetryPattern_(options: FetchOptions__): Promise<Response> {
if (!(options.retry > 1)) {
return handleTimeout_(options);
}
// else
logger_.logMethod?.('handleRetryPattern_');
options.retry--;
const externalAbortSignal = options.signal;
try {
const response = await handleTimeout_(options);
if (!response.ok && response.status >= HttpStatusCodes.Error_Server_500_Internal_Server_Error) {
// only retry for server errors (5xx)
throw new FetchError('http_error', `HTTP error! status: ${response.status} ${response.statusText}`, response);
}
return response;
}
catch (err) {
logger_.accident('fetch', 'fetch_failed_retry', err);
// Do not retry if the browser is offline.
if (globalThis_.navigator?.onLine === false) {
logger_.accident('handleRetryPattern_', 'offline', 'Skip retry because offline');
throw err;
}
await delay.by(options.retryDelay);
// Restore the original signal for the next attempt.
options.signal = externalAbortSignal;
return handleRetryPattern_(options);
}
}
/**
* Wraps the native fetch call with a timeout mechanism.
*
* It uses an `AbortController` to abort the request if it does not complete
* within the specified `timeout` duration. It also respects external abort signals.
*
* @param {FetchOptions__} options - The fully configured fetch options.
* @returns {Promise<Response>} A promise that resolves with the `Response` or rejects on timeout.
* @private
*/
function handleTimeout_(options: FetchOptions__): Promise<Response> {
if (options.timeout === 0) {
// If timeout is disabled, call fetch directly.
return globalThis_.fetch(options.url, options);
}
logger_.logMethod?.('handleTimeout_');
return new Promise((resolved, reject) => {
const abortController = typeof AbortController === 'function' ? new AbortController() : null;
const externalAbortSignal = options.signal;
options.signal = abortController?.signal;
// If an external AbortSignal is provided, listen to it and propagate the abort.
if (abortController !== null && externalAbortSignal != null) {
externalAbortSignal.addEventListener('abort', () => abortController.abort(), {once: true});
}
const timeoutId = setTimeout(() => {
reject(new FetchError('timeout', 'fetch_timeout'));
abortController?.abort('fetch_timeout');
}, parseDuration(options.timeout!));
globalThis_
.fetch(options.url, options)
.then((response) => resolved(response))
.catch((reason) => reject(reason))
.finally(() => {
// Clean up the timeout to prevent it from firing after the request has completed.
clearTimeout(timeoutId);
});
});
}