From 0639783927d8e964069407d66bbe1a7e564ac1f0 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 02:23:33 +0200 Subject: [PATCH 01/14] docs: Fix fetchf() calls --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2406397..2977235 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [npm-url]: https://npmjs.org/package/fetchff [npm-image]: http://img.shields.io/npm/v/fetchff.svg -[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://badgen.now.sh/badge/coverage/97.68%/blue)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?style=flat-square)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) +[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://img.shields.io/badge/coverage-97.39-green)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?color=lightblue)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) ## Why? @@ -68,8 +68,7 @@ yarn add fetchff import { fetchf } from 'fetchff'; const { data, error, status } = await fetchf( - 'https://example.com/api/v1/getBooks', - { bookId: 1 }, + 'https://example.com/api/v1/books', { timeout: 2000, // Specify some other settings here... @@ -691,14 +690,10 @@ try { ```typescript import { fetchf } from 'fetchff'; -const books = await fetchf( - 'https://example.com/api/v1/getBooks', - { bookId: 1 }, - { - timeout: 2000, - // Specify some other settings here... - }, -); +const books = await fetchf('https://example.com/api/v1/books', { + timeout: 2000, + // Specify some other settings here... +}); ``` ### Multiple APIs Handler from different API sources From 0adcf05b524477d8893a211587f87564f6ea9074 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 03:00:11 +0200 Subject: [PATCH 02/14] feat: Introduce cache manager with faster key generation --- src/cache-manager.ts | 217 +++++++++++++++++++++++++++++++++++++ src/hash.ts | 68 ++---------- src/types/cache-manager.ts | 5 + src/utils.ts | 95 ++++++++++++++-- test/hash.spec.ts | 134 +---------------------- 5 files changed, 320 insertions(+), 199 deletions(-) create mode 100644 src/cache-manager.ts create mode 100644 src/types/cache-manager.ts diff --git a/src/cache-manager.ts b/src/cache-manager.ts new file mode 100644 index 0000000..74399b3 --- /dev/null +++ b/src/cache-manager.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { hash } from './hash'; +import { fetchf } from './index'; +import type { RequestConfig } from './types/request-handler'; +import type { CacheEntry } from './types/cache-manager'; +import { OBJECT } from './const'; +import { formDataToString, shallowSerialize, sortObject } from './utils'; + +const cache = new Map>(); + +/** + * Generates a cache key for a given URL and fetch options, ensuring that key factors + * like method, headers, body, and other options are included in the cache key. + * Headers and other objects are sorted by key to ensure consistent cache keys. + * + * @param options - The fetch options that may affect the request. The most important are: + * @property {string} [method="GET"] - The HTTP method (GET, POST, etc.). + * @property {HeadersInit} [headers={}] - The request headers. + * @property {BodyInit | null} [body=""] - The body of the request (only for methods like POST, PUT). + * @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, same-origin). + * @property {RequestCredentials} [credentials="same-origin"] - Whether to include credentials like cookies. + * @property {RequestCache} [cache="default"] - The cache mode (e.g., default, no-store, reload). + * @property {RequestRedirect} [redirect="follow"] - How to handle redirects (e.g., follow, error, manual). + * @property {string} [referrer=""] - The referrer URL to send with the request. + * @property {string} [integrity=""] - Subresource integrity value (a cryptographic hash for resource validation). + * @returns {string|null} - A unique cache key based on the URL and request options. Null if cache is to be burst. + * + * @example + * const cacheKey = generateCacheKey({ + * url: 'https://api.example.com/data', + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify({ name: 'Alice' }), + * mode: 'cors', + * credentials: 'include', + * }); + * console.log(cacheKey); + */ +export function generateCacheKey(options: RequestConfig = {}): string | null { + const { + url = '', + method = 'GET', + headers = {}, + body = '', + mode = 'cors', + credentials = 'same-origin', + cache = 'default', + redirect = 'follow', + referrer = '', + integrity = '', + } = options; + + // Bail early if cache should be burst + if (cache === 'reload') { + return null; + } + + // Sort headers and body + convert sorted to strings for hashing purposes + // Native serializer is on avg. 3.5x faster than a Fast Hash or FNV-1a + const headersString = shallowSerialize(sortObject(headers)); + + let bodyString = ''; + + // In majority of cases we do not cache body + if (body !== null) { + if (typeof body === 'string') { + bodyString = body; + } else if (typeof body === OBJECT) { + if (body instanceof FormData) { + bodyString = formDataToString(body); + } else { + bodyString = JSON.stringify(sortObject(body)); + } + } else if (body instanceof Blob || body instanceof File) { + bodyString = `Blob/File:${body.size}:${body.type}`; + } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + bodyString = `ArrBuffer:${body.byteLength}`; + } + + // Hash it for smaller output + if (bodyString) { + bodyString = hash(bodyString); + } + } + + // Concatenate all key parts into a cache key string + // Template literals are apparently slower + return ( + method + + ':' + + url + + ':' + + headersString + + ':' + + bodyString + + ':' + + mode + + ':' + + credentials + + ':' + + cache + + ':' + + redirect + + ':' + + referrer + + ':' + + integrity + ); +} + +/** + * Checks if the cache entry is expired based on its timestamp and the maximum stale time. + * + * @param {number} timestamp - The timestamp of the cache entry. + * @param {number} maxStaleTime - The maximum stale time in seconds. + * @returns {boolean} - Returns true if the cache entry is expired, false otherwise. + */ +function isCacheExpired(timestamp: number, maxStaleTime: number): boolean { + return maxStaleTime && Date.now() - timestamp > maxStaleTime * 1000; +} + +/** + * Retrieves a cache entry if it exists and is not expired. + * + * @param {string} key Cache key to utilize + * @param {RequestConfig} config - The request configuration object. + * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. + */ +export function getCache( + key: string, + config: RequestConfig, +): CacheEntry | null { + const entry = cache.get(key); + + if (entry) { + if (!isCacheExpired(entry.timestamp, config.cacheTime)) { + return entry; + } + + cache.delete(key); + } + + return null; +} + +/** + * Sets a new cache entry or updates an existing one. + * + * @param {string} key Cache key to utilize + * @param {T} data - The data to be cached. + * @param {boolean} isLoading - Indicates if the data is currently being fetched. + */ +export function setCache( + key: string, + data: T, + isLoading: boolean = false, +): void { + cache.set(key, { + data, + isLoading, + timestamp: Date.now(), + }); +} + +/** + * Revalidates a cache entry by fetching fresh data and updating the cache. + * + * @param {string} key Cache key to utilize + * @param {RequestConfig} config - The request configuration object. + * @returns {Promise} - A promise that resolves when the revalidation is complete. + */ +export async function revalidate( + key: string, + config: RequestConfig = null, +): Promise { + try { + // Fetch fresh data + const newData = await fetchf(config.url, { + ...config, + cache: 'reload', + }); + + setCache(key, newData); + } catch (error) { + console.error(`Error revalidating ${config.url}:`, error); + } +} + +/** + * Invalidates (deletes) a cache entry. + * + * @param {string} key Cache key to utilize + */ +export function deleteCache(key: string): void { + cache.delete(key); +} + +/** + * Mutates a cache entry with new data and optionally revalidates it. + * + * @param {string} key Cache key to utilize + * @param {RequestConfig} config - The request configuration object. + * @param {T} newData - The new data to be cached. + * @param {boolean} revalidateAfter - If true, triggers revalidation after mutation. + */ +export function mutate( + key: string, + config: RequestConfig = null, + newData: T, + revalidateAfter: boolean = false, +): void { + setCache(key, newData); + + if (revalidateAfter) { + revalidate(key, config); + } +} diff --git a/src/hash.ts b/src/hash.ts index 0719759..5d288f8 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,68 +1,20 @@ -import { UNDEFINED } from './const'; -import { RequestConfig } from './types'; - -// Garbage collected hash cache table -// Since we use hashtable, it is really fast -const hashCache = new WeakMap(); +const PRIME_MULTIPLIER = 31; /** - * FNV-1a Hash Function Implementation + * Computes a hash value for a given string using the djb2 hash function. * It's non-crypto and very fast - * We avoid BigInt here due to compatibility issues (ES2020), and the bundle size - * @url https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function + * @author Daniel J. Bernstein * - * @param input Input string to hash + * @param str Input string to hash * @returns {string} Hash */ -export function hash(input: string): string { - // FNV-1a 32-bit offset basis - let hash = 2166136261; - - // FNV-1a 32-bit prime - const FNV_32_PRIME = 16777619; - - for (const char of input) { - hash ^= char.charCodeAt(0); - hash *= FNV_32_PRIME; - // Ensure the hash stays within 32-bit unsigned integer range - hash = hash >>> 0; - } - - // Convert hash to hexadecimal string, pad to ensure it's always 8 chars long - return hash.toString(16).padStart(8, '0'); -} - -/** - * Computes and retrieves a hash for a given `RequestConfig` object. - * - * This function first checks if the hash for the provided `requestConfig` object is - * already cached. If it is not found in the cache, it serializes the `requestConfig` - * object into a JSON string, computes a hash for that string using the FNV-1a algorithm, - * and stores the resulting hash in the cache for future use. The function utilizes a - * `WeakMap` to manage the cache, ensuring automatic garbage collection of cache entries - * when the associated `requestConfig` objects are no longer in use. - * - * @param {RequestConfig} requestConfig - The configuration object to hash. This object - * should have the following properties: - * - `method` {string} - The HTTP method (e.g., 'GET', 'POST'). - * - `baseURL` {string} - The base URL of the API. - * - `url` {string} - The specific URL path. - * - `params` {object} - The query parameters as key-value pairs. - * - `data` {object} - The request body data as key-value pairs. - * - * @returns {string} The computed hash for the `requestConfig` object. The hash is - * represented as a hexadecimal string of 8 characters, ensuring a fixed length for - * consistency and easier comparison. - */ -export function hashFromConfig(requestConfig: RequestConfig): string { - let key = hashCache.get(requestConfig); - - if (typeof key === UNDEFINED) { - const keyString = JSON.stringify(requestConfig); +export function hash(str: string): string { + let hash = 0; - key = hash(keyString); - hashCache.set(requestConfig, key); + for (let i = 0, len = str.length; i < len; i++) { + const char = str.charCodeAt(i); + hash = (hash * PRIME_MULTIPLIER + char) | 0; } - return key; + return String(hash); } diff --git a/src/types/cache-manager.ts b/src/types/cache-manager.ts new file mode 100644 index 0000000..9ebb191 --- /dev/null +++ b/src/types/cache-manager.ts @@ -0,0 +1,5 @@ +export interface CacheEntry { + data: T; + timestamp: number; + isLoading: boolean; +} diff --git a/src/utils.ts b/src/utils.ts index ec6fab5..5e749f2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,12 +6,91 @@ export function isSearchParams(data: unknown): boolean { return data instanceof URLSearchParams; } -function makeUrl(url: string, encodedQueryString: string) { - return url.includes('?') - ? `${url}&${encodedQueryString}` - : encodedQueryString - ? `${url}?${encodedQueryString}` - : url; +/** + * Determines if a value is a non-null object. + * + * @param {any} value - The value to check. + * @returns {boolean} - True if the value is a non-null object. + */ +export function isObject(value: any): value is Record { + return value !== null && typeof value === OBJECT; +} + +/** + * Converts a FormData object to a string representation. + * + * @param {FormData} formData - The FormData object to convert. + * @returns {string} - A string representation of the FormData object. + */ +export function formDataToString(formData: FormData): string { + let result = ''; + + formData.forEach((value, key) => { + // Append key=value and '&' directly to the result + result += key + '=' + value + '&'; + }); + + // Remove trailing '&' if there are any key-value pairs + return result ? result.slice(0, -1) : result; +} + +/** + * Shallowly serializes an object by converting its key-value pairs into a string representation. + * This function does not recursively serialize nested objects. + * + * @param obj - The object to serialize. + * @returns A string representation of the object's top-level properties. + */ +export function shallowSerialize(obj: Record): string { + let result = ''; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + result += key + ':' + obj[key]; + } + } + + return result; +} + +/** + * Sorts the keys of an object and returns a new object with sorted keys. + * + * This function is optimized for performance by minimizing the number of object operations + * and using a single pass to create the sorted object. + * + * @param {Object} obj - The object to be sorted by keys. + * @returns {Object} - A new object with keys sorted in ascending order. + */ +export function sortObject(obj: Record): object { + const sortedObj = {}; + const keys = Object.keys(obj); + + keys.sort(); + + for (let i = 0, len = keys.length; i < len; i++) { + const key = keys[i]; + sortedObj[key] = obj[key]; + } + + return sortedObj; +} + +/** + * Appends a query string to a URL, ensuring proper handling of existing query parameters. + * + * @param baseUrl - The base URL to which the query string will be appended. + * @param queryString - The encoded query string to append. + * @returns The URL with the appended query string, or the original URL if no query string is provided. + */ +function appendQueryStringToUrl(baseUrl: string, queryString: string): string { + if (!queryString) { + return baseUrl; + } + + return baseUrl.includes('?') + ? `${baseUrl}&${queryString}` + : `${baseUrl}?${queryString}`; } /** @@ -30,7 +109,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { if (isSearchParams(params)) { const encodedQueryString = params.toString(); - return makeUrl(url, encodedQueryString); + return appendQueryStringToUrl(url, encodedQueryString); } // This is exact copy of what JQ used to do. It works much better than URLSearchParams @@ -77,7 +156,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { // Encode special characters as per RFC 3986, https://datatracker.ietf.org/doc/html/rfc3986 const encodedQueryString = queryStringParts.replace(/%5B%5D/g, '[]'); // Keep '[]' for arrays - return makeUrl(url, encodedQueryString); + return appendQueryStringToUrl(url, encodedQueryString); } /** diff --git a/test/hash.spec.ts b/test/hash.spec.ts index 3d5f613..440a458 100644 --- a/test/hash.spec.ts +++ b/test/hash.spec.ts @@ -1,5 +1,4 @@ -import { hash, hashFromConfig } from '../src/hash'; -import type { RequestConfig } from '../src/types/request-handler'; +import { hash } from '../src/hash'; describe('hash function', () => { it('should return a consistent hash for the same input', () => { @@ -26,134 +25,3 @@ describe('hash function', () => { expect(hash(input).length).toBeLessThan(500); }); }); - -describe('hashFromConfig function', () => { - it('should return a hash for a given RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - expect(hashFromConfig(requestConfig)).toBeTruthy(); - expect(hashFromConfig(requestConfig)).toEqual( - hashFromConfig(requestConfig), - ); - }); - - it('should cache results to avoid rehashing', () => { - const requestConfig: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/another-endpoint', - params: { query: 'anotherValue' }, - data: { anotherKey: 'anotherValue' }, - }; - - const firstHash = hashFromConfig(requestConfig); - const secondHash = hashFromConfig(requestConfig); - - expect(firstHash).toBe(secondHash); - }); - - it('should produce different hashes for different RequestConfig objects', () => { - const config1: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint1', - params: { query: 'value1' }, - data: { key: 'value1' }, - }; - - const config2: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/endpoint2', - params: { query: 'value2' }, - data: { key: 'value2' }, - }; - - expect(hashFromConfig(config1)).not.toBe(hashFromConfig(config2)); - }); -}); - -describe('hashFromConfig with cache', () => { - let hashCacheGetSpy: unknown; - let hashCacheSetSpy: unknown; - - beforeEach(() => { - // Reset spies before each test - hashCacheGetSpy = jest.spyOn(WeakMap.prototype, 'get'); - hashCacheSetSpy = jest.spyOn(WeakMap.prototype, 'set'); - }); - - afterEach(() => { - // Restore the original implementation after each test - jest.restoreAllMocks(); - }); - - it('should compute and cache the hash for a new RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - const firstHash = hashFromConfig(requestConfig); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - }); - - it('should return the cached hash for the same RequestConfig', () => { - const requestConfig: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value' }, - data: { key: 'value' }, - }; - - const firstHash = hashFromConfig(requestConfig); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - - const secondHash = hashFromConfig(requestConfig); - expect(secondHash).toBe(firstHash); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(2); // Verify cache get was called again - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); // Verify cache set was not called again - }); - - it('should compute and cache the hash for a different RequestConfig', () => { - const requestConfig1: RequestConfig = { - method: 'GET', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value1' }, - data: { key: 'value1' }, - }; - - const requestConfig2: RequestConfig = { - method: 'POST', - baseURL: 'https://api.example.com', - url: '/endpoint', - params: { query: 'value2' }, - data: { key: 'value2' }, - }; - - const firstHash = hashFromConfig(requestConfig1); - expect(firstHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(1); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(1); - - const secondHash = hashFromConfig(requestConfig2); - expect(secondHash).toBeDefined(); - expect(hashCacheGetSpy).toHaveBeenCalledTimes(2); - expect(hashCacheSetSpy).toHaveBeenCalledTimes(2); - }); -}); From 54b751858069c2023f82c9fa18fbb9d5e888fa43 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 03:00:51 +0200 Subject: [PATCH 03/14] perf: Faster shallow clone of headers --- src/request-handler.ts | 18 +++++++++++------- src/types/request-handler.ts | 10 +++++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index f32e4f2..db2003c 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -52,6 +52,11 @@ const defaultConfig: RequestHandlerConfig = { logger: null, fetcher: null, baseURL: '', + headers: { + Accept: APPLICATION_JSON + ', text/plain, */*', + 'Accept-Encoding': 'gzip, deflate, br', + [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', + }, retry: { retries: 0, delay: 1000, @@ -219,13 +224,12 @@ function createRequestHandler( url: baseURL + urlPath, // Add sensible defaults - headers: { - Accept: APPLICATION_JSON + ', text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', - ...(handlerConfig.headers || {}), - ...(reqConfig.headers || {}), - }, + headers: reqConfig.headers + ? { + ...handlerConfig.headers, + ...reqConfig.headers, + } + : handlerConfig.headers, }; }; diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index dc511e9..3d2ee52 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -213,11 +213,6 @@ interface ExtendedRequestConfig extends Omit { */ params?: QueryParams; - /** - * The maximum time (in milliseconds) the request can take before automatically being aborted. - */ - timeout?: number; - /** * Indicates whether credentials (such as cookies) should be included with the request. */ @@ -254,6 +249,11 @@ interface ExtendedRequestConfig extends Omit { */ onError?: ErrorHandlerInterceptor; + /** + * The maximum time (in milliseconds) the request can take before automatically being aborted. + */ + timeout?: number; + /** * Time window, in miliseconds, during which identical requests are deduplicated (treated as single request). * @default 1000 (1 second) From 1fcee59f5c3e3e89686cef8cc99fb7145b351e92 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 03:01:46 +0200 Subject: [PATCH 04/14] feat: Cache getter dependent only on cache time --- src/cache-manager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cache-manager.ts b/src/cache-manager.ts index 74399b3..be826c5 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -123,17 +123,17 @@ function isCacheExpired(timestamp: number, maxStaleTime: number): boolean { * Retrieves a cache entry if it exists and is not expired. * * @param {string} key Cache key to utilize - * @param {RequestConfig} config - The request configuration object. + * @param {RequestConfig} cacheTime - Maximum time to cache entry. * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. */ export function getCache( key: string, - config: RequestConfig, + cacheTime: number, ): CacheEntry | null { const entry = cache.get(key); if (entry) { - if (!isCacheExpired(entry.timestamp, config.cacheTime)) { + if (!isCacheExpired(entry.timestamp, cacheTime)) { return entry; } From 70d84eb06f0153f6880e988fa6c02a6aad03bec4 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 23:00:19 +0200 Subject: [PATCH 05/14] feat: Introduce caching to request handler --- src/cache-manager.ts | 53 +++++++++---------- src/const.ts | 4 ++ src/queue-manager.ts | 2 +- src/request-handler.ts | 99 +++++++++++++++++++++++------------- src/types/request-handler.ts | 57 ++++++++++++++++++++- src/utils.ts | 6 +-- test/hash.spec.ts | 2 +- 7 files changed, 154 insertions(+), 69 deletions(-) diff --git a/src/cache-manager.ts b/src/cache-manager.ts index be826c5..728dc71 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { hash } from './hash'; import { fetchf } from './index'; -import type { RequestConfig } from './types/request-handler'; +import type { FetcherConfig } from './types/request-handler'; import type { CacheEntry } from './types/cache-manager'; -import { OBJECT } from './const'; +import { GET, OBJECT } from './const'; import { formDataToString, shallowSerialize, sortObject } from './utils'; const cache = new Map>(); @@ -17,13 +17,13 @@ const cache = new Map>(); * @property {string} [method="GET"] - The HTTP method (GET, POST, etc.). * @property {HeadersInit} [headers={}] - The request headers. * @property {BodyInit | null} [body=""] - The body of the request (only for methods like POST, PUT). - * @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, same-origin). - * @property {RequestCredentials} [credentials="same-origin"] - Whether to include credentials like cookies. + * @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, include). + * @property {RequestCredentials} [credentials="include"] - Whether to include credentials like cookies. * @property {RequestCache} [cache="default"] - The cache mode (e.g., default, no-store, reload). * @property {RequestRedirect} [redirect="follow"] - How to handle redirects (e.g., follow, error, manual). * @property {string} [referrer=""] - The referrer URL to send with the request. * @property {string} [integrity=""] - Subresource integrity value (a cryptographic hash for resource validation). - * @returns {string|null} - A unique cache key based on the URL and request options. Null if cache is to be burst. + * @returns {string} - A unique cache key based on the URL and request options. Empty if cache is to be burst. * * @example * const cacheKey = generateCacheKey({ @@ -36,14 +36,14 @@ const cache = new Map>(); * }); * console.log(cacheKey); */ -export function generateCacheKey(options: RequestConfig = {}): string | null { +export function generateCacheKey(options: FetcherConfig): string { const { url = '', - method = 'GET', + method = GET, headers = {}, body = '', mode = 'cors', - credentials = 'same-origin', + credentials = 'include', cache = 'default', redirect = 'follow', referrer = '', @@ -52,7 +52,7 @@ export function generateCacheKey(options: RequestConfig = {}): string | null { // Bail early if cache should be burst if (cache === 'reload') { - return null; + return ''; } // Sort headers and body + convert sorted to strings for hashing purposes @@ -72,9 +72,9 @@ export function generateCacheKey(options: RequestConfig = {}): string | null { bodyString = JSON.stringify(sortObject(body)); } } else if (body instanceof Blob || body instanceof File) { - bodyString = `Blob/File:${body.size}:${body.type}`; + bodyString = `BF${body.size}${body.type}`; } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - bodyString = `ArrBuffer:${body.byteLength}`; + bodyString = `AB${body.byteLength}`; } // Hash it for smaller output @@ -87,24 +87,15 @@ export function generateCacheKey(options: RequestConfig = {}): string | null { // Template literals are apparently slower return ( method + - ':' + url + - ':' + - headersString + - ':' + - bodyString + - ':' + mode + - ':' + credentials + - ':' + cache + - ':' + redirect + - ':' + referrer + - ':' + - integrity + integrity + + headersString + + bodyString ); } @@ -116,14 +107,18 @@ export function generateCacheKey(options: RequestConfig = {}): string | null { * @returns {boolean} - Returns true if the cache entry is expired, false otherwise. */ function isCacheExpired(timestamp: number, maxStaleTime: number): boolean { - return maxStaleTime && Date.now() - timestamp > maxStaleTime * 1000; + if (!maxStaleTime) { + return false; + } + + return Date.now() - timestamp > maxStaleTime * 1000; } /** * Retrieves a cache entry if it exists and is not expired. * * @param {string} key Cache key to utilize - * @param {RequestConfig} cacheTime - Maximum time to cache entry. + * @param {FetcherConfig} cacheTime - Maximum time to cache entry. * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. */ export function getCache( @@ -166,12 +161,12 @@ export function setCache( * Revalidates a cache entry by fetching fresh data and updating the cache. * * @param {string} key Cache key to utilize - * @param {RequestConfig} config - The request configuration object. + * @param {FetcherConfig} config - The request configuration object. * @returns {Promise} - A promise that resolves when the revalidation is complete. */ export async function revalidate( key: string, - config: RequestConfig = null, + config: FetcherConfig, ): Promise { try { // Fetch fresh data @@ -199,13 +194,13 @@ export function deleteCache(key: string): void { * Mutates a cache entry with new data and optionally revalidates it. * * @param {string} key Cache key to utilize - * @param {RequestConfig} config - The request configuration object. + * @param {FetcherConfig} config - The request configuration object. * @param {T} newData - The new data to be cached. * @param {boolean} revalidateAfter - If true, triggers revalidation after mutation. */ export function mutate( key: string, - config: RequestConfig = null, + config: FetcherConfig, newData: T, revalidateAfter: boolean = false, ): void { diff --git a/src/const.ts b/src/const.ts index f4e8f9f..41913df 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,9 +1,13 @@ export const APPLICATION_JSON = 'application/json'; export const CONTENT_TYPE = 'Content-Type'; + export const UNDEFINED = 'undefined'; export const OBJECT = 'object'; +export const STRING = 'string'; + export const ABORT_ERROR = 'AbortError'; export const TIMEOUT_ERROR = 'TimeoutError'; export const CANCELLED_ERROR = 'CanceledError'; + export const GET = 'GET'; export const HEAD = 'HEAD'; diff --git a/src/queue-manager.ts b/src/queue-manager.ts index c428802..f7cf15e 100644 --- a/src/queue-manager.ts +++ b/src/queue-manager.ts @@ -24,7 +24,7 @@ const queue: RequestsQueue = new Map(); */ export async function addRequest( config: RequestConfig, - timeout: number, + timeout: number | undefined, dedupeTime: number = 0, isCancellable: boolean = false, isTimeoutEnabled: boolean = true, diff --git a/src/request-handler.ts b/src/request-handler.ts index db2003c..0dccd28 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -8,7 +8,9 @@ import type { ResponseError, RequestHandlerReturnType, CreatedCustomFetcherInstance, - PollingFunction, + FetcherConfig, + CacheBusterFunction, + CacheSkipFunction, } from './types/request-handler'; import type { APIResponse, @@ -36,9 +38,12 @@ import { CONTENT_TYPE, GET, HEAD, + OBJECT, + STRING, UNDEFINED, } from './const'; import { parseResponseData } from './response-parser'; +import { generateCacheKey, getCache, setCache } from './cache-manager'; const defaultConfig: RequestHandlerConfig = { method: GET, @@ -78,6 +83,8 @@ const defaultConfig: RequestHandlerConfig = { shouldRetry: async () => true, }, + cacheBuster: () => false, + skipCache: () => false, }; /** @@ -155,7 +162,7 @@ function createRequestHandler( url: string, data: QueryParamsOrBody, reqConfig: RequestConfig, - ): RequestConfig => { + ): FetcherConfig => { const method = getConfig( reqConfig, 'method', @@ -208,7 +215,7 @@ function createRequestHandler( // Automatically stringify request body, if possible and when not dealing with strings if ( body && - typeof body !== 'string' && + typeof body !== STRING && !isSearchParams(body) && isJSONSerializable(body) ) { @@ -222,14 +229,6 @@ function createRequestHandler( method, url: baseURL + urlPath, - - // Add sensible defaults - headers: reqConfig.headers - ? { - ...handlerConfig.headers, - ...reqConfig.headers, - } - : handlerConfig.headers, }; }; @@ -318,18 +317,47 @@ function createRequestHandler( data: QueryParamsOrBody = null, reqConfig: RequestConfig | null = null, ): Promise> => { + const mergedConfig = { + ...handlerConfig, + ...(reqConfig || {}), + } as RequestConfig; + let response: FetchResponse | null = null; - const _reqConfig = reqConfig || {}; - const fetcherConfig = buildConfig(url, data, _reqConfig); - - const timeout = getConfig(fetcherConfig, 'timeout'); - const isCancellable = getConfig(fetcherConfig, 'cancellable'); - const dedupeTime = getConfig(fetcherConfig, 'dedupeTime'); - const pollingInterval = getConfig(fetcherConfig, 'pollingInterval'); - const shouldStopPolling = getConfig( - fetcherConfig, - 'shouldStopPolling', - ); + const fetcherConfig = buildConfig(url, data, mergedConfig); + + const { + timeout, + cancellable, + dedupeTime, + pollingInterval, + shouldStopPolling, + cacheTime, + cacheKey, + } = mergedConfig; + + // Prevent performance overhead of cache access + let _cacheKey: string; + + if (cacheKey) { + _cacheKey = cacheKey(fetcherConfig); + } else { + _cacheKey = generateCacheKey(fetcherConfig); + } + + if (cacheTime && _cacheKey) { + const cacheBuster = mergedConfig.cacheBuster as CacheBusterFunction; + + if (!cacheBuster(fetcherConfig)) { + const cachedEntry = getCache< + ResponseData & FetchResponse + >(_cacheKey, cacheTime); + + if (cachedEntry) { + // Serve stale data from cache + return cachedEntry.data; + } + } + } const { retries, @@ -339,14 +367,7 @@ function createRequestHandler( shouldRetry, maxDelay, resetTimeout, - } = ( - fetcherConfig.retry - ? { - ...handlerConfig.retry, - ...fetcherConfig.retry, - } - : handlerConfig.retry - ) as Required; + } = mergedConfig.retry as Required; let attempt = 0; let pollingAttempt = 0; @@ -359,9 +380,9 @@ function createRequestHandler( fetcherConfig, timeout, dedupeTime, - isCancellable, + cancellable, // Reset timeouts by default or when retries are ON - timeout > 0 && (!retries || resetTimeout), + !!(timeout && (!retries || resetTimeout)), ); // Shallow copy to ensure basic idempotency @@ -430,8 +451,18 @@ function createRequestHandler( } // If polling is not required, or polling attempts are exhausted - return outputResponse(response, requestConfig) as ResponseData & + const output = outputResponse(response, requestConfig) as ResponseData & FetchResponse; + + if (cacheTime && _cacheKey) { + const skipCache = mergedConfig.skipCache as CacheSkipFunction; + + if (!skipCache(output, requestConfig)) { + setCache(_cacheKey, output); + } + } + + return output; } catch (err) { const error = err as ResponseErr; const status = error?.response?.status || error?.status || 0; @@ -503,7 +534,7 @@ function createRequestHandler( if ( data === undefined || data === null || - (typeof data === 'object' && Object.keys(data).length === 0) + (typeof data === OBJECT && Object.keys(data).length === 0) ) { data = defaultResponse; } diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 3d2ee52..8dcb10c 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -110,6 +110,7 @@ export interface RetryOptions { /** * Retry only on specific status codes. + * @url https://developer.mozilla.org/en-US/docs/Web/HTTP/Status * @default [ * 408, // Request Timeout * 409, // Conflict @@ -138,13 +139,63 @@ export type PollingFunction = ( error?: ResponseError, ) => boolean; +export type CacheKeyFunction = (config: FetcherConfig) => string; + +export type CacheBusterFunction = (config: FetcherConfig) => boolean; + +export type CacheSkipFunction = ( + data: ResponseData, + config: RequestConfig, +) => boolean; + +/** + * Configuration object for cache related options + */ +export interface CacheOptions { + /** + * Maximum time, in seconds, a cache entry is considered fresh (valid). + * After this time, the entry may be considered stale (expired). + * + * @default 0 (no cache) + */ + cacheTime?: number; + + /** + * Cache key + * It provides a way to customize caching behavior dynamically according to different criteria. + * @param config - Request configuration. + * @default null By default it generates a unique cache key for HTTP requests based on: + * URL with Query Params, headers, body, mode, credentials, cache mode, redirection, referrer, integrity + */ + cacheKey?: CacheKeyFunction; + + /** + * Cache Buster Function + * It is called when a cache entry exists and you want to invalidate or refresh the cache under certain conditions + * @param config - Request configuration. + * @default (config)=>false Busting cache is disabled by default. Return true to change that + */ + cacheBuster?: CacheBusterFunction; + + /** + * Skip Cache Function + * Determines whether to set or skip setting caching for a request based on the response. + * @param response - Parsed Response. + * @param config - Request configuration. + * @default (response,config)=>false Bypassing cache is disabled by default. Return true to skip cache + */ + skipCache?: CacheSkipFunction; +} + /** * ExtendedRequestConfig * * This interface extends the standard `RequestInit` from the Fetch API, providing additional options * for handling requests, including custom error handling strategies, request interception, and more. */ -interface ExtendedRequestConfig extends Omit { +interface ExtendedRequestConfig + extends Omit, + CacheOptions { /** * Custom error handling strategy for the request. * - `'reject'`: Rejects the promise with an error. @@ -283,6 +334,10 @@ interface BaseRequestHandlerConfig extends RequestConfig { export type RequestConfig = ExtendedRequestConfig; +export type FetcherConfig = Omit & { + url: string; +}; + export type RequestHandlerConfig = BaseRequestHandlerConfig; export interface RequestHandlerReturnType { diff --git a/src/utils.ts b/src/utils.ts index 5e749f2..74197c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { OBJECT, UNDEFINED } from './const'; +import { OBJECT, STRING, UNDEFINED } from './const'; import type { HeadersObject, QueryParams, UrlPathParams } from './types'; export function isSearchParams(data: unknown): boolean { @@ -63,7 +63,7 @@ export function shallowSerialize(obj: Record): string { * @returns {Object} - A new object with keys sorted in ascending order. */ export function sortObject(obj: Record): object { - const sortedObj = {}; + const sortedObj = {} as Record; const keys = Object.keys(obj); keys.sort(); @@ -202,7 +202,7 @@ export function isJSONSerializable(value: any): boolean { return false; } - if (t === 'string' || t === 'number' || t === 'boolean') { + if (t === STRING || t === 'number' || t === 'boolean') { return true; } diff --git a/test/hash.spec.ts b/test/hash.spec.ts index 440a458..61b8f5d 100644 --- a/test/hash.spec.ts +++ b/test/hash.spec.ts @@ -15,7 +15,7 @@ describe('hash function', () => { it('should handle an empty string', () => { const input = ''; - const expectedHash = '811c9dc5'; // Update with the correct expected hash value + const expectedHash = '0'; expect(hash(input)).toBe(expectedHash); }); From 744629e77d341d38fb5afee9748ac3842ecd6077 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Sep 2024 23:23:25 +0200 Subject: [PATCH 06/14] feat: urlPathParams can now accept numbers and underscores --- src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 74197c1..150b491 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -115,7 +115,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { // This is exact copy of what JQ used to do. It works much better than URLSearchParams const s: string[] = []; const encode = encodeURIComponent; - const add = function (k: string, v: any) { + const add = (k: string, v: any) => { v = typeof v === 'function' ? v() : v; v = v === null ? '' : v === undefined ? '' : v; s[s.length] = encode(k) + '=' + encode(v); @@ -176,7 +176,7 @@ export function replaceUrlPathParams( return url; } - return url.replace(/:[a-zA-Z]+/gi, (str): string => { + return url.replace(/:\w+/g, (str): string => { const word = str.substring(1); return String(urlPathParams[word] ? urlPathParams[word] : str); From 93d0dc2442487c81f9eeb2e4930da01129e6e41f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 01:07:29 +0200 Subject: [PATCH 07/14] perf: Properly hash arrays and Array Buffers --- src/cache-manager.ts | 25 ++-- test/cache-manager.spec.ts | 291 +++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 test/cache-manager.spec.ts diff --git a/src/cache-manager.ts b/src/cache-manager.ts index 728dc71..a88b5c5 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -64,22 +64,16 @@ export function generateCacheKey(options: FetcherConfig): string { // In majority of cases we do not cache body if (body !== null) { if (typeof body === 'string') { - bodyString = body; - } else if (typeof body === OBJECT) { - if (body instanceof FormData) { - bodyString = formDataToString(body); - } else { - bodyString = JSON.stringify(sortObject(body)); - } + bodyString = hash(body); + } else if (body instanceof FormData) { + bodyString = hash(formDataToString(body)); } else if (body instanceof Blob || body instanceof File) { - bodyString = `BF${body.size}${body.type}`; + bodyString = 'BF' + body.size + body.type; } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - bodyString = `AB${body.byteLength}`; - } - - // Hash it for smaller output - if (bodyString) { - bodyString = hash(bodyString); + bodyString = 'AB' + body.byteLength; + } else { + const o = typeof body === OBJECT ? sortObject(body) : String(body); + bodyString = hash(JSON.stringify(o)); } } @@ -178,6 +172,9 @@ export async function revalidate( setCache(key, newData); } catch (error) { console.error(`Error revalidating ${config.url}:`, error); + + // Rethrow the error to forward it + throw error; } } diff --git a/test/cache-manager.spec.ts b/test/cache-manager.spec.ts new file mode 100644 index 0000000..09ab6da --- /dev/null +++ b/test/cache-manager.spec.ts @@ -0,0 +1,291 @@ +import { + generateCacheKey, + getCache, + setCache, + revalidate, + deleteCache, + mutate, +} from '../src/cache-manager'; +import { fetchf } from '../src/index'; +import * as hashM from '../src/hash'; +import * as utils from '../src/utils'; + +jest.mock('../src/index'); + +describe('Cache Manager', () => { + beforeAll(() => { + global.console = { + ...global.console, + error: jest.fn(), + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateCacheKey', () => { + const url = 'https://api.example.com/data'; + + it('should generate a cache key for basic GET request', () => { + const key = generateCacheKey({ + url, + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + expect(key).toContain('GEThttps://api.example.com/datacorsinclude'); + }); + + it('should generate a cache key for basic GET request with empty url', () => { + const key = generateCacheKey({ + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } as never); + expect(key).not.toContain('http'); + }); + + it('should return an empty string if cache is reload', () => { + const key = generateCacheKey({ + url, + cache: 'reload', + }); + expect(key).toBe(''); + }); + + it('should generate a cache key with sorted headers', () => { + const shallowSerialize = jest.spyOn(utils, 'shallowSerialize'); + + const key = generateCacheKey({ + url, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(key).toContain('POSThttps://api.example.com/datacorsinclude'); + expect(shallowSerialize).toHaveBeenCalledWith({ + 'Content-Type': 'application/json', + }); + }); + + it('should hash the body if provided', () => { + // (hash as jest.Mock).mockReturnValue('hashedBody'); + const spy = jest.spyOn(hashM, 'hash'); + + const key = generateCacheKey({ + url, + method: 'POST', + body: JSON.stringify({ name: 'Alice' }), + }); + expect(spy).toHaveBeenCalled(); + expect(key).toContain('1008044925'); + }); + + it('should convert FormData body to string', () => { + const formDataToString = jest.spyOn(utils, 'formDataToString'); + const formData = new FormData(); + formData.set('something', '1'); + + const key = generateCacheKey({ + url, + method: 'POST', + body: formData, + }); + expect(formDataToString).toHaveBeenCalledWith(formData); + expect(key).toContain('1688970866'); + }); + + it('should handle Blob body', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const key = generateCacheKey({ + url, + method: 'POST', + body: blob, + }); + expect(key).toContain('BF4text/plain'); + }); + + it('should handle ArrayBuffer body', () => { + const buffer = new ArrayBuffer(8); + const key = generateCacheKey({ + url, + method: 'POST', + body: buffer, + }); + expect(key).toContain('AB8'); + }); + + it('should handle numbers', () => { + const key = generateCacheKey({ + url, + method: 'POST', + // @ts-expect-error Number + body: 10, + }); + expect(key).toContain('1061505'); + }); + + it('should handle Array body', () => { + const arrayBody = [1, 2, 3]; + const key = generateCacheKey({ + url, + method: 'POST', + body: arrayBody, + }); + expect(key).toContain('1004020241'); + }); + + it('should handle Object body and sort properties', () => { + const objectBody = { b: 2, a: 1 }; + const key = generateCacheKey({ + url, + method: 'POST', + body: objectBody, + }); + + expect(key).toContain('1268505936'); + }); + }); + + describe('getCache', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should return cache entry if not expired', () => { + setCache('key', { data: 'test' }, false); + const result = getCache('key', 0); + expect(result).not.toBeNull(); + expect(result?.data).toEqual({ data: 'test' }); + }); + + it('should return null and delete cache if expired', () => { + setCache('key', { data: 'test' }, false); + const result = getCache('key', -1); + expect(result).toBeNull(); + }); + + it('should return null if no cache entry exists', () => { + const result = getCache('nonExistentKey', 60); + expect(result).toBeNull(); + }); + + it('should delete expired cache entry', () => { + setCache('key', { data: 'test' }, false); + deleteCache('key'); + expect(getCache('key', 60)).toBe(null); + }); + }); + + describe('setCache', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should set cache with proper data', () => { + const data = { foo: 'bar' }; + setCache('key', data); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(data); + expect(entry?.isLoading).toBe(false); + }); + + it('should handle isLoading state', () => { + setCache('key', { foo: 'bar' }, true); + const entry = getCache('key', 60); + expect(entry?.isLoading).toBe(true); + }); + + it('should set timestamp when caching data', () => { + const timestampBefore = Date.now(); + setCache('key', { foo: 'bar' }); + const entry = getCache('key', 60); + expect(entry?.timestamp).toBeGreaterThanOrEqual(timestampBefore); + }); + }); + + describe('revalidate', () => { + afterEach(() => { + deleteCache('key'); + }); + + it('should fetch fresh data and update cache', async () => { + const mockResponse = { data: 'newData' }; + (fetchf as jest.Mock).mockResolvedValue(mockResponse); + + await revalidate('key', { url: 'https://api.example.com' }); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(mockResponse); + }); + + it('should handle fetch errors during revalidation', async () => { + const errorMessage = 'Fetch failed'; + (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect( + revalidate('key', { url: 'https://api.example.com' }), + ).rejects.toThrow(errorMessage); + const entry = getCache('key', 60); + expect(entry?.data).toBeUndefined(); + }); + + it('should not update cache if revalidation fails', async () => { + const errorMessage = 'Fetch failed'; + const oldData = { data: 'oldData' }; + + (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); + setCache('key', oldData); + + await expect( + revalidate('key', { url: 'https://api.example.com' }), + ).rejects.toThrow(errorMessage); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(oldData); + }); + }); + + describe('deleteCache', () => { + it('should delete cache entry', () => { + setCache('key', { data: 'test' }); + deleteCache('key'); + expect(getCache('key', 60)).toBe(null); + }); + + it('should do nothing if cache key does not exist', () => { + deleteCache('nonExistentKey'); + expect(getCache('nonExistentKey', 60)).toBe(null); + }); + }); + + describe('mutate', () => { + it('should mutate cache entry with new data', () => { + setCache('key', { data: 'oldData' }); + mutate('key', { url: 'https://api.example.com' }, { data: 'newData' }); + const entry = getCache('key', 60); + expect(entry?.data).toEqual({ data: 'newData' }); + }); + + it('should revalidate after mutation if revalidateAfter is true', async () => { + const mockResponse = { data: 'newData' }; + (fetchf as jest.Mock).mockResolvedValue(mockResponse); + + await mutate( + 'key', + { url: 'https://api.example.com' }, + { data: 'mutatedData' }, + true, + ); + const entry = getCache('key', 60); + expect(entry?.data).toEqual(mockResponse); + }); + + it('should not revalidate after mutation if revalidateAfter is false', async () => { + setCache('key', { data: 'oldData' }); + mutate( + 'key', + { url: 'https://api.example.com' }, + { data: 'newData' }, + false, + ); + expect(fetchf).not.toHaveBeenCalled(); + }); + }); +}); From 115051a48073a4fbbd9e8a630d36722b9158d23e Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 01:08:24 +0200 Subject: [PATCH 08/14] feat: apiUrl alias should in per-request configs --- src/request-handler.ts | 27 +++++------ src/types/request-handler.ts | 6 ++- test/request-handler.spec.ts | 87 ++++++++++-------------------------- 3 files changed, 38 insertions(+), 82 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 0dccd28..8d16adb 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -9,8 +9,6 @@ import type { RequestHandlerReturnType, CreatedCustomFetcherInstance, FetcherConfig, - CacheBusterFunction, - CacheSkipFunction, } from './types/request-handler'; import type { APIResponse, @@ -54,9 +52,6 @@ const defaultConfig: RequestHandlerConfig = { withCredentials: false, flattenResponse: false, defaultResponse: null, - logger: null, - fetcher: null, - baseURL: '', headers: { Accept: APPLICATION_JSON + ', text/plain, */*', 'Accept-Encoding': 'gzip, deflate, br', @@ -80,11 +75,7 @@ const defaultConfig: RequestHandlerConfig = { 503, // Service Unavailable 504, // Gateway Timeout ], - - shouldRetry: async () => true, }, - cacheBuster: () => false, - skipCache: () => false, }; /** @@ -98,7 +89,6 @@ function createRequestHandler( ): RequestHandlerReturnType { const handlerConfig: RequestHandlerConfig = { ...defaultConfig, - baseURL: config.apiUrl || '', ...config, }; @@ -110,7 +100,7 @@ function createRequestHandler( const requestInstance = customFetcher?.create({ ...config, - baseURL: handlerConfig.baseURL, + baseURL: handlerConfig.baseURL || handlerConfig.apiUrl, timeout: handlerConfig.timeout, }) || null; @@ -210,7 +200,10 @@ function createRequestHandler( ? appendQueryParams(dynamicUrl, explicitParams || (data as QueryParams)) : dynamicUrl; const isFullUrl = urlPath.includes('://'); - const baseURL = isFullUrl ? '' : getConfig(reqConfig, 'baseURL'); + const baseURL = isFullUrl + ? '' + : getConfig(reqConfig, 'baseURL') || + getConfig(reqConfig, 'apiUrl'); // Automatically stringify request body, if possible and when not dealing with strings if ( @@ -345,9 +338,9 @@ function createRequestHandler( } if (cacheTime && _cacheKey) { - const cacheBuster = mergedConfig.cacheBuster as CacheBusterFunction; + const cacheBuster = mergedConfig.cacheBuster; - if (!cacheBuster(fetcherConfig)) { + if (!cacheBuster || !cacheBuster(fetcherConfig)) { const cachedEntry = getCache< ResponseData & FetchResponse >(_cacheKey, cacheTime); @@ -455,9 +448,9 @@ function createRequestHandler( FetchResponse; if (cacheTime && _cacheKey) { - const skipCache = mergedConfig.skipCache as CacheSkipFunction; + const skipCache = requestConfig.skipCache; - if (!skipCache(output, requestConfig)) { + if (!skipCache || !skipCache(output, requestConfig)) { setCache(_cacheKey, output); } } @@ -469,7 +462,7 @@ function createRequestHandler( if ( attempt === retries || - !(await shouldRetry(error, attempt)) || + !(!shouldRetry || (await shouldRetry(error, attempt))) || !retryOn?.includes(status) ) { processError(error, fetcherConfig); diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 8dcb10c..13166a8 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -254,6 +254,11 @@ interface ExtendedRequestConfig */ baseURL?: string; + /** + * Alias for base URL. + */ + apiUrl?: string; + /** * An object representing the headers to include with the request. */ @@ -328,7 +333,6 @@ interface ExtendedRequestConfig interface BaseRequestHandlerConfig extends RequestConfig { fetcher?: FetcherInstance | null; - apiUrl?: string; logger?: any; } diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 727e930..9eb42a8 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -56,6 +56,11 @@ describe('Request Handler', () => { describe('buildConfig() with native fetch()', () => { let requestHandler: RequestHandlerReturnType | null = null; + const headers = { + Accept: 'application/json, text/plain, */*', + 'Accept-Encoding': 'gzip, deflate, br', + 'Content-Type': 'application/json;charset=utf-8', + }; beforeAll(() => { requestHandler = createRequestHandler({}); @@ -90,17 +95,15 @@ describe('Request Handler', () => { 'GET', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'GET', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, }); }); @@ -109,17 +112,15 @@ describe('Request Handler', () => { 'POST', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -129,17 +130,15 @@ describe('Request Handler', () => { 'PUT', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'PUT', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -149,17 +148,15 @@ describe('Request Handler', () => { 'DELETE', 'https://example.com/api', { foo: 'bar' }, - {}, + { + headers, + }, ); expect(result).toEqual({ url: 'https://example.com/api', method: 'DELETE', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, + headers, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -170,7 +167,7 @@ describe('Request Handler', () => { 'https://example.com/api', { foo: 'bar' }, { - headers: { Authorization: 'Bearer token' }, + headers: { 'X-CustomHeader': 'Some token' }, data: { additional: 'info' }, }, ); @@ -179,10 +176,7 @@ describe('Request Handler', () => { url: 'https://example.com/api?foo=bar', method: 'POST', headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - Authorization: 'Bearer token', + 'X-CustomHeader': 'Some token', }, body: JSON.stringify({ additional: 'info' }), }); @@ -194,11 +188,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: null, }); }); @@ -214,11 +203,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: 'rawData', }); }); @@ -234,11 +218,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo[]=1&foo[]=2', method: 'HEAD', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, }); }); @@ -253,11 +232,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: JSON.stringify({ additional: 'info' }), }); }); @@ -270,11 +244,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, credentials: 'include', body: null, }); @@ -293,11 +262,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api', method: 'POST', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, body: JSON.stringify({ foo: 'bar' }), }); }); @@ -313,11 +277,6 @@ describe('Request Handler', () => { expect(result).toEqual({ url: 'https://example.com/api?foo=bar', method: 'GET', - headers: { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json;charset=utf-8', - }, }); }); }); From fd1bf04e67564e89e4caeb3d97503249ddb65bdf Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 01:42:35 +0200 Subject: [PATCH 09/14] fix: global interceptors for requests and responses were called twice --- src/interceptor-manager.ts | 71 +++++++++++--------------------- src/request-handler.ts | 15 +++---- test/interceptor-manager.spec.ts | 8 ++-- test/request-handler.spec.ts | 66 +++++++++-------------------- test/utils.spec.ts | 5 --- 5 files changed, 54 insertions(+), 111 deletions(-) diff --git a/src/interceptor-manager.ts b/src/interceptor-manager.ts index 230ae93..1220b34 100644 --- a/src/interceptor-manager.ts +++ b/src/interceptor-manager.ts @@ -1,59 +1,34 @@ -import type { RequestHandlerConfig, FetchResponse } from './types'; -import type { - RequestInterceptor, - ResponseInterceptor, -} from './types/interceptor-manager'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +type InterceptorFunction = (object: T) => Promise; /** - * Applies a series of request interceptors to the provided configuration. - * @param {RequestHandlerConfig} config - The initial request configuration. - * @param {RequestInterceptor | RequestInterceptor[]} interceptors - The request interceptor function(s) to apply. - * @returns {Promise} - The modified request configuration. + * Applies interceptors to the object. Interceptors can be a single function or an array of functions. + * + * @template T - Type of the object. + * @template I - Type of interceptors. + * + * @param {T} object - The object to process. + * @param {InterceptorFunction | InterceptorFunction[]} [interceptors] - Interceptor function(s). + * + * @returns {Promise} - The modified object. */ -export async function interceptRequest( - config: RequestHandlerConfig, - interceptors?: RequestInterceptor | RequestInterceptor[], -): Promise { +export async function applyInterceptor< + T = any, + I = InterceptorFunction | InterceptorFunction[], +>(object: T, interceptors?: I): Promise { if (!interceptors) { - return config; + return object; } - const interceptorList = Array.isArray(interceptors) - ? interceptors - : [interceptors]; - - let interceptedConfig = { ...config }; - - for (const interceptor of interceptorList) { - interceptedConfig = await interceptor(interceptedConfig); + if (typeof interceptors === 'function') { + return await interceptors(object); } - return interceptedConfig; -} - -/** - * Applies a series of response interceptors to the provided response. - * @param {FetchResponse} response - The initial response object. - * @param {ResponseInterceptor | ResponseInterceptor[]} interceptors - The response interceptor function(s) to apply. - * @returns {Promise>} - The modified response object. - */ -export async function interceptResponse( - response: FetchResponse, - interceptors?: ResponseInterceptor | ResponseInterceptor[], -): Promise> { - if (!interceptors) { - return response; - } - - const interceptorList = Array.isArray(interceptors) - ? interceptors - : [interceptors]; - - let interceptedResponse = response; - - for (const interceptor of interceptorList) { - interceptedResponse = await interceptor(interceptedResponse); + if (Array.isArray(interceptors)) { + for (const interceptor of interceptors) { + object = await interceptor(object); + } } - return interceptedResponse; + return object; } diff --git a/src/request-handler.ts b/src/request-handler.ts index 8d16adb..58107a1 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -16,7 +16,7 @@ import type { QueryParams, QueryParamsOrBody, } from './types/api-handler'; -import { interceptRequest, interceptResponse } from './interceptor-manager'; +import { applyInterceptor } from './interceptor-manager'; import { ResponseErr } from './response-error'; import { appendQueryParams, @@ -310,9 +310,10 @@ function createRequestHandler( data: QueryParamsOrBody = null, reqConfig: RequestConfig | null = null, ): Promise> => { + const _reqConfig = reqConfig || {}; const mergedConfig = { ...handlerConfig, - ...(reqConfig || {}), + ..._reqConfig, } as RequestConfig; let response: FetchResponse | null = null; @@ -385,13 +386,13 @@ function createRequestHandler( }; // Local interceptors - requestConfig = await interceptRequest( + requestConfig = await applyInterceptor( requestConfig, - requestConfig?.onRequest, + _reqConfig?.onRequest, ); // Global interceptors - requestConfig = await interceptRequest( + requestConfig = await applyInterceptor( requestConfig, handlerConfig?.onRequest, ); @@ -421,10 +422,10 @@ function createRequestHandler( } // Local interceptors - response = await interceptResponse(response, requestConfig?.onResponse); + response = await applyInterceptor(response, _reqConfig?.onResponse); // Global interceptors - response = await interceptResponse(response, handlerConfig?.onResponse); + response = await applyInterceptor(response, handlerConfig?.onResponse); removeRequest(fetcherConfig); diff --git a/test/interceptor-manager.spec.ts b/test/interceptor-manager.spec.ts index 5c3ad8a..fae783c 100644 --- a/test/interceptor-manager.spec.ts +++ b/test/interceptor-manager.spec.ts @@ -3,10 +3,7 @@ import type { FetchResponse, RequestHandlerConfig, } from '../src/types/request-handler'; -import { - interceptRequest, - interceptResponse, -} from '../src/interceptor-manager'; +import { applyInterceptor } from '../src/interceptor-manager'; import type { RequestInterceptor, ResponseInterceptor, @@ -16,6 +13,8 @@ import type { type ResponseData = any; describe('Interceptor Functions', () => { + const interceptResponse = applyInterceptor; + const interceptRequest = applyInterceptor; let requestInterceptors: RequestInterceptor[] = []; let responseInterceptors: ResponseInterceptor[] = []; @@ -135,7 +134,6 @@ describe('Interceptor Functions', () => { status: 500, }) as ExtendedResponse; - // Test interceptResponse handling of errors await expect( interceptResponse(errorResponse, responseInterceptors), ).rejects.toThrow('Response Error'); diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 9eb42a8..1d25909 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -1,18 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { createRequestHandler } from '../src/request-handler'; import fetchMock from 'fetch-mock'; -import { - interceptRequest, - interceptResponse, -} from '../src/interceptor-manager'; +import { applyInterceptor } from '../src/interceptor-manager'; import { delayInvocation } from '../src/utils'; import type { RequestHandlerReturnType } from '../src/types/request-handler'; import { fetchf } from '../src'; import { ABORT_ERROR } from '../src/const'; jest.mock('../src/interceptor-manager', () => ({ - interceptRequest: jest.fn().mockImplementation(async (config) => config), - interceptResponse: jest.fn().mockImplementation(async (response) => response), + applyInterceptor: jest.fn().mockImplementation(async (response) => response), })); jest.mock('../src/utils', () => { @@ -800,6 +796,10 @@ describe('Request Handler', () => { let requestHandler: RequestHandlerReturnType; beforeEach(() => { + (applyInterceptor as jest.Mock).mockReset(); + (applyInterceptor as jest.Mock).mockImplementation( + async (response) => response, + ); requestHandler = createRequestHandler({ baseURL: 'https://api.example.com', timeout: 5000, @@ -817,22 +817,11 @@ describe('Request Handler', () => { afterEach(() => { jest.useRealTimers(); - (interceptRequest as jest.Mock).mockReset(); - (interceptResponse as jest.Mock).mockReset(); - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( - async (response) => response, - ); }); it('should apply interceptors correctly', async () => { // Set up mock implementations if needed - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -848,19 +837,15 @@ describe('Request Handler', () => { // Call the request method await requestHandler.request(url, data, config); - // Verify that interceptRequest and interceptResponse were called - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + // Verify that interceptRequest and applyInterceptor were called + expect(applyInterceptor).toHaveBeenCalledTimes(4); }); it('should handle modified config in interceptRequest', async () => { - (interceptRequest as jest.Mock).mockImplementation(async (config) => ({ + (applyInterceptor as jest.Mock).mockImplementation(async (config) => ({ ...config, headers: { 'Modified-Header': 'ModifiedValue' }, })); - (interceptResponse as jest.Mock).mockImplementation( - async (response) => response, - ); fetchMock.mock('https://api.example.com/test-endpoint?key=value', { status: 200, @@ -873,19 +858,15 @@ describe('Request Handler', () => { await requestHandler.request(url, data, config); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + expect(applyInterceptor).toHaveBeenCalledTimes(4); // Verify that fetch was called with modified headers expect(fetchMock.lastOptions()).toMatchObject({ headers: { 'Modified-Header': 'ModifiedValue' }, }); }); - it('should handle modified response in interceptResponse', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation(async (response) => ({ + it('should handle modified response in applyInterceptor', async () => { + (applyInterceptor as jest.Mock).mockImplementation(async (response) => ({ ...response, data: { username: 'modified response' }, })); @@ -901,16 +882,12 @@ describe('Request Handler', () => { const response = await requestHandler.request(url, data, config); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).toHaveBeenCalled(); + expect(applyInterceptor).toHaveBeenCalledTimes(4); expect(response).toMatchObject({ username: 'modified response' }); }); it('should handle request failure with interceptors', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -927,15 +904,12 @@ describe('Request Handler', () => { 'https://api.example.com/test-endpoint?key=value failed! Status: 500', ); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).not.toHaveBeenCalled(); + // Only request interceptors are called (2 because 1 local and 1 global) + expect(applyInterceptor).toHaveBeenCalledTimes(2); }); it('should handle request with different response status', async () => { - (interceptRequest as jest.Mock).mockImplementation( - async (config) => config, - ); - (interceptResponse as jest.Mock).mockImplementation( + (applyInterceptor as jest.Mock).mockImplementation( async (response) => response, ); @@ -952,8 +926,8 @@ describe('Request Handler', () => { 'https://api.example.com/test-endpoint?key=value failed! Status: 404', ); - expect(interceptRequest).toHaveBeenCalled(); - expect(interceptResponse).not.toHaveBeenCalled(); + // Only request interceptors are called (2 because 1 local and 1 global) + expect(applyInterceptor).toHaveBeenCalledTimes(2); }); }); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 648b570..db32bff 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -6,11 +6,6 @@ import { processHeaders, } from '../src/utils'; -jest.mock('../src/interceptor-manager', () => ({ - interceptRequest: jest.fn().mockImplementation(async (config) => config), - interceptResponse: jest.fn().mockImplementation(async (response) => response), -})); - describe('Utils', () => { console.warn = jest.fn(); From 61e1388c52a7065fd37908b38961a26b2040a41d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 01:55:50 +0200 Subject: [PATCH 10/14] docs: Mark baseURL (apiUrl) as safe to use on per-request basis --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2977235..be60d39 100644 --- a/README.md +++ b/README.md @@ -436,36 +436,36 @@ You can also use all native `fetch()` settings. Settings that are global only are marked with star `*`. -| | Type | Default | Description | -| ----------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| baseURL \*
(alias: apiUrl) | string | | Your API base url. | -| endpoints \* | object | | List of your endpoints. Each endpoint accepts all these settings. They can be set globally or per-endpoint when they are called. | -| fetcher \* | Function | fetch | The native `fetch()` is used by default. A custom instance that exposes create() and request() can be used otherwise. | -| url | string | | URL path e.g. /user-details/get | -| method | string | get | Default request method e.g. GET, POST, DELETE, PUT etc. | -| params | object
URLSearchParams | {} | A key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures. If you use `createApiFetcher()` then it is the first argument of your api.endpoint() function. You can still pass configuration in 3rd argument if necessary. | -| body
(alias: data) | object
string
FormData
URLSearchParams
Blob
ArrayBuffer
ReadableStream | {} | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | -| urlPathParams | object | {} | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | -| strategy | string | reject | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | -| cancellable | boolean | false | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | -| rejectCancelled | boolean | false | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | -| flattenResponse | boolean | false | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | -| defaultResponse | any | null | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | -| withCredentials | boolean | false | Indicates whether credentials (such as cookies) should be included with the request. | -| timeout | int | 30000 | You can set a request timeout for all requests or particular in milliseconds. | -| dedupeTime | int | 1000 | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | -| onRequest | function(config) | | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. | -| onResponse | function(response) | | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | -| onError | function(error) | | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | -| logger | object | null | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | -| retry | object | | The object with retry settings available below. | -| retry.retries | number | 0 | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | -| retry.delay | number | 1000 | The initial delay (in milliseconds) between retry attempts. | -| retry.backoff | number | 1.5 | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | -| retry.maxDelay | number | 30000 | The maximum delay (in milliseconds) between retry attempts. | -| retry.resetTimeout | boolean | true | Reset timeout when retrying requests. | -| retry.retryOn | array | [408, 409, 425, 429, 500, 502, 503, 504] | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | -| retry.shouldRetry | async function | | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` (the error object) and `attempts` (the number of attempts made so far). | +| | Type | Default | Description | +| -------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoints \* | object | | List of your endpoints. Each endpoint accepts all the settings below. They can be set globally, per-endpoint or per-request. | +| fetcher \* | FetcherInstance | | A custom adapter (an instance / object) that exposes `create()` function so to create instance of API Fetcher. The `create()` should return `request()` function that would be used when making the requests. The native `fetch()` is used if the fetcher is not provided. | +| baseURL
(alias: apiUrl) | string | | Your API base url. | +| url | string | | URL path e.g. /user-details/get | +| method | string | get | Default request method e.g. GET, POST, DELETE, PUT etc. | +| params | object
URLSearchParams | {} | A key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures. If you use `createApiFetcher()` then it is the first argument of your api.endpoint() function. You can still pass configuration in 3rd argument if necessary. | +| body
(alias: data) | object
string
FormData
URLSearchParams
Blob
ArrayBuffer
ReadableStream | {} | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | +| urlPathParams | object | {} | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | +| strategy | string | reject | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | +| cancellable | boolean | false | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | +| rejectCancelled | boolean | false | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | +| flattenResponse | boolean | false | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | +| defaultResponse | any | null | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | +| withCredentials | boolean | false | Indicates whether credentials (such as cookies) should be included with the request. | +| timeout | int | 30000 | You can set a request timeout for all requests or particular in milliseconds. | +| dedupeTime | int | 1000 | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | +| onRequest | function(config) | | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. You should always return the config from this function. | +| onResponse | function(response) | | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | +| onError | function(error) | | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | +| logger | object | null | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | +| retry | object | | The object with retry settings available below. | +| retry.retries | number | 0 | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | +| retry.delay | number | 1000 | The initial delay (in milliseconds) between retry attempts. | +| retry.backoff | number | 1.5 | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | +| retry.maxDelay | number | 30000 | The maximum delay (in milliseconds) between retry attempts. | +| retry.resetTimeout | boolean | true | Reset timeout when retrying requests. | +| retry.retryOn | array | [408, 409, 425, 429, 500, 502, 503, 504] | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | +| retry.shouldRetry | async function | | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` (the error object) and `attempts` (the number of attempts made so far). | ## ✔️ Retry Mechanism From 03ff02b143a4f9aec5f6ab703ba6bd849ca9c9d4 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 02:35:51 +0200 Subject: [PATCH 11/14] docs: Improve documentation of all existing settings --- README.md | 73 ++++++++++++++++++++---------------- src/hash.ts | 6 +-- src/types/request-handler.ts | 46 ++++++++++++----------- 3 files changed, 67 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index be60d39..a15bcaa 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Managing multiple API endpoints can be complex and time-consuming. `fetchff` sim ## Features - **100% Performance-Oriented**: Optimized for speed and efficiency, ensuring fast and reliable API interactions. -- **Fully TypeScript Compatible**: Enjoy full TypeScript support for better development experience and type safety. -- **Smart Error Retry**: Features exponential backoff for intelligent error handling and retry mechanisms. +- **Smart Retry Mechanism**: Features exponential backoff for intelligent error handling and retry mechanisms. - **Automatic Request Deduplication**: Set the time during which requests are deduplicated (treated as same request). +- **Smart Cache Management**: Dynamically manage cache with configurable expiration, custom keys, and selective invalidation. - **Dynamic URLs Support**: Easily manage routes with dynamic parameters, such as `/user/:userId`. - **Native `fetch()` Support**: Uses the modern `fetch()` API by default, eliminating the need for libraries like Axios. - **Global and Per Request Error Handling**: Flexible error management at both global and individual request levels. @@ -35,7 +35,8 @@ Managing multiple API endpoints can be complex and time-consuming. `fetchff` sim - **Supports All Axios Options**: Fully compatible with all Axios configuration options for seamless integration. - **Lightweight**: Minimal footprint, only a few KBs when gzipped, ensuring quick load times. - **Framework Independent**: Pure JavaScript solution, compatible with any framework or library. -- **Browser and Node 18+ Compatible**: Works flawlessly in both modern browsers and Node.js environments. +- **Browser and Node.js 18+ Compatible**: Works flawlessly in both modern browsers and Node.js environments. +- **Fully TypeScript Compatible**: Enjoy full TypeScript support for better development experience and type safety. - **Custom Interceptors**: Includes `onRequest`, `onResponse`, and `onError` interceptors for flexible request and response handling. Please open an issue for future requests. @@ -436,36 +437,42 @@ You can also use all native `fetch()` settings. Settings that are global only are marked with star `*`. -| | Type | Default | Description | -| -------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| endpoints \* | object | | List of your endpoints. Each endpoint accepts all the settings below. They can be set globally, per-endpoint or per-request. | -| fetcher \* | FetcherInstance | | A custom adapter (an instance / object) that exposes `create()` function so to create instance of API Fetcher. The `create()` should return `request()` function that would be used when making the requests. The native `fetch()` is used if the fetcher is not provided. | -| baseURL
(alias: apiUrl) | string | | Your API base url. | -| url | string | | URL path e.g. /user-details/get | -| method | string | get | Default request method e.g. GET, POST, DELETE, PUT etc. | -| params | object
URLSearchParams | {} | A key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures. If you use `createApiFetcher()` then it is the first argument of your api.endpoint() function. You can still pass configuration in 3rd argument if necessary. | -| body
(alias: data) | object
string
FormData
URLSearchParams
Blob
ArrayBuffer
ReadableStream | {} | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | -| urlPathParams | object | {} | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | -| strategy | string | reject | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | -| cancellable | boolean | false | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | -| rejectCancelled | boolean | false | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | -| flattenResponse | boolean | false | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | -| defaultResponse | any | null | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | -| withCredentials | boolean | false | Indicates whether credentials (such as cookies) should be included with the request. | -| timeout | int | 30000 | You can set a request timeout for all requests or particular in milliseconds. | -| dedupeTime | int | 1000 | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | -| onRequest | function(config) | | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. You should always return the config from this function. | -| onResponse | function(response) | | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | -| onError | function(error) | | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | -| logger | object | null | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | -| retry | object | | The object with retry settings available below. | -| retry.retries | number | 0 | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | -| retry.delay | number | 1000 | The initial delay (in milliseconds) between retry attempts. | -| retry.backoff | number | 1.5 | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | -| retry.maxDelay | number | 30000 | The maximum delay (in milliseconds) between retry attempts. | -| retry.resetTimeout | boolean | true | Reset timeout when retrying requests. | -| retry.retryOn | array | [408, 409, 425, 429, 500, 502, 503, 504] | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | -| retry.shouldRetry | async function | | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` (the error object) and `attempts` (the number of attempts made so far). | +| | Type | Default | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoints \* | `object` | | List of your endpoints. Each endpoint accepts all the settings below. They can be set globally, per-endpoint or per-request. | +| fetcher \* | `FetcherInstance` | | A custom adapter (an instance / object) that exposes `create()` function so to create instance of API Fetcher. The `create()` should return `request()` function that would be used when making the requests. The native `fetch()` is used if the fetcher is not provided. | +| baseURL
(alias: apiUrl) | `string` | | Your API base url. | +| url | `string` | | URL path e.g. /user-details/get | +| method | `string` | `GET` | Default request method e.g. GET, POST, DELETE, PUT etc. All methods are supported. | +| params | `object`
`URLSearchParams`
`NameValuePair[]` | `{}` | Query Parameters - a key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures similarly to what `jQuery` used to do in the past. If you use `createApiFetcher()` then it is the first argument of your `api.myEndpoint()` function. You can still pass configuration in 3rd argument if want to. | +| body
(alias: data) | `object`
`string`
`FormData`
`URLSearchParams`
`Blob`
`ArrayBuffer`
`ReadableStream` | `{}` | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | +| urlPathParams | `object` | `{}` | An object with URL path parameters so to dynamically replace placeholders in the URL path. For example, if URL contains a placeholder like `/users/:userId`, you can provide an object with the `userId` key to replace that placeholder with an actual value. The keys in the `urlPathParams` object should match the placeholders in the URL. This allows for dynamic URL construction based on runtime values. | +| strategy | `string` | `reject` | Error handling strategies - basically what to return when an error occurs. It can be a default data, promise can be hanged (nothing would be returned) or rejected so to use try/catch.

Available: `reject`, `softFail`, `defaultResponse`, `silent`.

`reject` - Promises are rejected, and global error handling is triggered. Requires try/catch for handling.

`softFail` - returns a response object with additional properties such as `data`, `error`, `config`, `request`, and `headers` when an error occurs. This approach avoids throwing errors, allowing you to handle error information directly within the response object without the need for try/catch blocks.

`defaultResponse` - returns default response specified in case of an error. Promise will not be rejected. It could be used in conjuction with `flattenResponse` and as `defaultResponse: {}` so to provide a sensible defaults.

`silent` - hangs the promise silently on error, useful for fire-and-forget requests without the need for try/catch. In case of an error, the promise will never be resolved or rejected, and any code after will never be executed. The requests could be dispatched within an asynchronous wrapper functions that do not need to be awaited. If used properly, it prevents excessive usage of try/catch or additional response data checks everywhere. You can use it in combination with `onError` to handle errors separately. | +| cancellable | `boolean` | `false` | If `true`, any ongoing previous requests to same API endpoint will be cancelled, if a subsequent request is made meanwhile. This helps you avoid unnecessary requests to the backend. | +| rejectCancelled | `boolean` | `false` | If `true` and request is set to `cancellable`, a cancelled requests' promise will be rejected. By default, instead of rejecting the promise, `defaultResponse` is returned. | +| flattenResponse | `boolean` | `false` | Flatten nested response data, so you can avoid writing `response.data.data` and obtain response directly. Response is flattened when there is a "data" within response "data", and no other object properties set. | +| defaultResponse | `any` | `null` | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | +| withCredentials | `boolean` | `false` | Indicates whether credentials (such as cookies) should be included with the request. | +| timeout | `number` | `30000` | You can set a request timeout for all requests or particular in milliseconds. | +| dedupeTime | `number` | `1000` | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). | +| pollingInterval | `number` | `0` | Interval in milliseconds between polling attempts. Setting `0` disables polling. | +| shouldStopPolling | `PollingFunction` | `(response, error, attempt) => false` | Function to determine if polling should stop based on the response. Returns `true` to stop polling, `false` to continue. | +| cacheTime | `number` | `0` | Maximum time, in seconds, a cache entry is considered fresh. After this time, the entry may be considered stale. | +| cacheKey | `CacheKeyFunction` | | Function to customize the cache key. If not provided, it automatically generates a unique cache key based on `Method`, `URL`, `Query Params`, `Dynamic Path Params`, `mode`, `credentials`, `cache`, `redirect`, `referrer`, `integrity`, `headers` and `body`, selectively using a very fast hashing variant of djb2 authored by Daniel J. Bernstein. | +| cacheBuster | `CacheBusterFunction` | `(config) => false` | Function to invalidate or refresh cache under certain conditions. Defaults to no cache busting. | +| skipCache | `CacheSkipFunction` | `(response, config) => false` | Function to determine whether to set or skip setting caching based on the response. Defaults to no skipping. | +| onRequest | `RequestInterceptor`
`RequestInterceptor[]` | `(config) => config` | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. You should always return the config from this function. | +| onResponse | `ResponseInterceptor`
`ResponseInterceptor[]` | `(response) => response` | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | +| onError | `ErrorInterceptor` | `(error) => void` | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | +| logger | `object` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | +| retry | `object` | | The object with retry settings available below. | +| retry.retries | `number` | `0` | The number of times to retry the request in case of failure. If set to `0` (default), no retries will be attempted. | +| retry.delay | `number` | `1000` | The initial delay (in milliseconds) between retry attempts. | +| retry.backoff | `number` | `1.5` | The backoff factor to apply to the delay between retries. For example, if the delay is 100ms and the backoff is 1.5, the next delay will be 150ms, then 225ms, and so on. | +| retry.maxDelay | `number` | `30000` | The maximum delay (in milliseconds) between retry attempts. Default is equal to timeout. | +| retry.resetTimeout | `boolean` | `true` | Reset timeout when retrying requests. | +| retry.retryOn | `number[]` | `[408, 409, 425, 429, 500, 502, 503, 504]` | An array of HTTP status codes on which to retry the request. Default values include: 408 (Request Timeout), 409 (Conflict), 425 (Too Early), 429 (Too Many Requests), 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout). | +| retry.shouldRetry | `RetryFunction` | `(error, attempt) => true` | A custom asynchronous function to determine whether to retry the request. It receives two arguments: `error` - the error object, and `attempts` - the current attempts made, starting from `0`. | ## ✔️ Retry Mechanism diff --git a/src/hash.ts b/src/hash.ts index 5d288f8..a79c9ad 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,9 +1,9 @@ const PRIME_MULTIPLIER = 31; /** - * Computes a hash value for a given string using the djb2 hash function. - * It's non-crypto and very fast - * @author Daniel J. Bernstein + * Computes a hash value for a given string using the variant of djb2 hash function. + * This hash function is non-cryptographic and designed for speed. + * @author Daniel J. Bernstein (of djb2) * * @param str Input string to hash * @returns {string} Hash diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 13166a8..beef901 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -52,7 +52,7 @@ export type ErrorHandlingStrategy = | 'defaultResponse' | 'softFail'; -type ErrorHandlerInterceptor = (error: ResponseError) => unknown; +type ErrorInterceptor = (error: ResponseError) => unknown; export interface HeadersObject { [key: string]: string; @@ -77,6 +77,26 @@ export interface ResponseError extends Error { response?: FetchResponse; } +export type RetryFunction = ( + error: ResponseError, + attempts: number, +) => Promise; + +export type PollingFunction = ( + response: FetchResponse, + attempts: number, + error?: ResponseError, +) => boolean; + +export type CacheKeyFunction = (config: FetcherConfig) => string; + +export type CacheBusterFunction = (config: FetcherConfig) => boolean; + +export type CacheSkipFunction = ( + data: ResponseData, + config: RequestConfig, +) => boolean; + export interface RetryOptions { /** * Maximum number of retry attempts. @@ -127,27 +147,9 @@ export interface RetryOptions { /** * A function to determine whether to retry based on the error and attempt number. */ - shouldRetry?: ( - error: ResponseError, - attempt: number, - ) => Promise; + shouldRetry?: RetryFunction; } -export type PollingFunction = ( - response: FetchResponse, - attempt: number, - error?: ResponseError, -) => boolean; - -export type CacheKeyFunction = (config: FetcherConfig) => string; - -export type CacheBusterFunction = (config: FetcherConfig) => boolean; - -export type CacheSkipFunction = ( - data: ResponseData, - config: RequestConfig, -) => boolean; - /** * Configuration object for cache related options */ @@ -165,7 +167,7 @@ export interface CacheOptions { * It provides a way to customize caching behavior dynamically according to different criteria. * @param config - Request configuration. * @default null By default it generates a unique cache key for HTTP requests based on: - * URL with Query Params, headers, body, mode, credentials, cache mode, redirection, referrer, integrity + * Method, URL, Query Params, Dynamic Path Params, mode, credentials, cache, redirect, referrer, integrity, headers and body */ cacheKey?: CacheKeyFunction; @@ -303,7 +305,7 @@ interface ExtendedRequestConfig /** * A function to handle errors that occur during the request or response processing. */ - onError?: ErrorHandlerInterceptor; + onError?: ErrorInterceptor; /** * The maximum time (in milliseconds) the request can take before automatically being aborted. From c2c70a20df54ed0d895d9bb9ea6ddb9a35443d8b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 04:51:00 +0200 Subject: [PATCH 12/14] fix: Blob & File checks in Node.js env --- src/cache-manager.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cache-manager.ts b/src/cache-manager.ts index a88b5c5..48416c7 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -3,7 +3,7 @@ import { hash } from './hash'; import { fetchf } from './index'; import type { FetcherConfig } from './types/request-handler'; import type { CacheEntry } from './types/cache-manager'; -import { GET, OBJECT } from './const'; +import { GET, OBJECT, UNDEFINED } from './const'; import { formDataToString, shallowSerialize, sortObject } from './utils'; const cache = new Map>(); @@ -67,7 +67,10 @@ export function generateCacheKey(options: FetcherConfig): string { bodyString = hash(body); } else if (body instanceof FormData) { bodyString = hash(formDataToString(body)); - } else if (body instanceof Blob || body instanceof File) { + } else if ( + (typeof Blob !== UNDEFINED && body instanceof Blob) || + (typeof File !== UNDEFINED && body instanceof File) + ) { bodyString = 'BF' + body.size + body.type; } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { bodyString = 'AB' + body.byteLength; From 31cf03b76a276d771e970f689095d9a6d4173a68 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 16:02:14 +0200 Subject: [PATCH 13/14] perf: Speed up form data checks --- README.md | 4 ++-- src/cache-manager.ts | 8 ++++++-- src/utils.ts | 18 ------------------ test/cache-manager.spec.ts | 4 +--- 4 files changed, 9 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a15bcaa..747655f 100644 --- a/README.md +++ b/README.md @@ -461,8 +461,8 @@ Settings that are global only are marked with star `*`. | cacheKey | `CacheKeyFunction` | | Function to customize the cache key. If not provided, it automatically generates a unique cache key based on `Method`, `URL`, `Query Params`, `Dynamic Path Params`, `mode`, `credentials`, `cache`, `redirect`, `referrer`, `integrity`, `headers` and `body`, selectively using a very fast hashing variant of djb2 authored by Daniel J. Bernstein. | | cacheBuster | `CacheBusterFunction` | `(config) => false` | Function to invalidate or refresh cache under certain conditions. Defaults to no cache busting. | | skipCache | `CacheSkipFunction` | `(response, config) => false` | Function to determine whether to set or skip setting caching based on the response. Defaults to no skipping. | -| onRequest | `RequestInterceptor`
`RequestInterceptor[]` | `(config) => config` | You can specify a function that will be triggered before the request is sent. The request configuration object will be sent as the first argument of the function. This is useful for modifying request parameters, headers, etc. You should always return the config from this function. | -| onResponse | `ResponseInterceptor`
`ResponseInterceptor[]` | `(response) => response` | You can specify a function that will be triggered when the endpoint successfully responds. The full Response Object is sent as the first argument of the function. This is useful for handling the response data, parsing, and error handling based on status codes. | +| onRequest | `RequestInterceptor`
`RequestInterceptor[]` | `(config) => config` | A function or an array of functions that are invoked before sending a request. Each function receives the request configuration object as its argument, allowing you to modify request parameters, headers, or other settings. The function should return the updated configuration. | +| onResponse | `ResponseInterceptor`
`ResponseInterceptor[]` | `(response) => response` | A function or an array of functions that are invoked when a response is received. Each function receives the full response object, enabling you to process the response, handle status codes, or parse data as needed. The function should return the processed response. | | onError | `ErrorInterceptor` | `(error) => void` | You can specify a function or class that will be triggered when endpoint fails. If it's a class it should expose a `process` method. When using native fetch(), the full Response Object is sent as a first argument of the function. | | logger | `object` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | | retry | `object` | | The object with retry settings available below. | diff --git a/src/cache-manager.ts b/src/cache-manager.ts index 48416c7..a6e0436 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -4,7 +4,7 @@ import { fetchf } from './index'; import type { FetcherConfig } from './types/request-handler'; import type { CacheEntry } from './types/cache-manager'; import { GET, OBJECT, UNDEFINED } from './const'; -import { formDataToString, shallowSerialize, sortObject } from './utils'; +import { shallowSerialize, sortObject } from './utils'; const cache = new Map>(); @@ -66,7 +66,11 @@ export function generateCacheKey(options: FetcherConfig): string { if (typeof body === 'string') { bodyString = hash(body); } else if (body instanceof FormData) { - bodyString = hash(formDataToString(body)); + body.forEach((value, key) => { + // Append key=value and '&' directly to the result + bodyString += key + '=' + value + '&'; + }); + bodyString = hash(bodyString); } else if ( (typeof Blob !== UNDEFINED && body instanceof Blob) || (typeof File !== UNDEFINED && body instanceof File) diff --git a/src/utils.ts b/src/utils.ts index 150b491..11d21b0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,24 +16,6 @@ export function isObject(value: any): value is Record { return value !== null && typeof value === OBJECT; } -/** - * Converts a FormData object to a string representation. - * - * @param {FormData} formData - The FormData object to convert. - * @returns {string} - A string representation of the FormData object. - */ -export function formDataToString(formData: FormData): string { - let result = ''; - - formData.forEach((value, key) => { - // Append key=value and '&' directly to the result - result += key + '=' + value + '&'; - }); - - // Remove trailing '&' if there are any key-value pairs - return result ? result.slice(0, -1) : result; -} - /** * Shallowly serializes an object by converting its key-value pairs into a string representation. * This function does not recursively serialize nested objects. diff --git a/test/cache-manager.spec.ts b/test/cache-manager.spec.ts index 09ab6da..efdb41f 100644 --- a/test/cache-manager.spec.ts +++ b/test/cache-manager.spec.ts @@ -80,7 +80,6 @@ describe('Cache Manager', () => { }); it('should convert FormData body to string', () => { - const formDataToString = jest.spyOn(utils, 'formDataToString'); const formData = new FormData(); formData.set('something', '1'); @@ -89,8 +88,7 @@ describe('Cache Manager', () => { method: 'POST', body: formData, }); - expect(formDataToString).toHaveBeenCalledWith(formData); - expect(key).toContain('1688970866'); + expect(key).toContain('-818489256'); }); it('should handle Blob body', () => { From 1c9ec9ae5da3ed79e049778fbc00734ca4632dd0 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Sep 2024 17:20:16 +0200 Subject: [PATCH 14/14] perf: Optimize bundle size of request initializer --- src/request-handler.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/request-handler.ts b/src/request-handler.ts index 58107a1..4d35950 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -48,9 +48,6 @@ const defaultConfig: RequestHandlerConfig = { strategy: 'reject', timeout: 30000, dedupeTime: 1000, - rejectCancelled: false, - withCredentials: false, - flattenResponse: false, defaultResponse: null, headers: { Accept: APPLICATION_JSON + ', text/plain, */*', @@ -58,7 +55,6 @@ const defaultConfig: RequestHandlerConfig = { [CONTENT_TYPE]: APPLICATION_JSON + ';charset=utf-8', }, retry: { - retries: 0, delay: 1000, maxDelay: 30000, resetTimeout: true, @@ -79,12 +75,12 @@ const defaultConfig: RequestHandlerConfig = { }; /** - * Create a Request Handler + * Create Request Handler * * @param {RequestHandlerConfig} config - Configuration object for the request handler * @returns {Object} An object with methods for handling requests */ -function createRequestHandler( +export function createRequestHandler( config: RequestHandlerConfig, ): RequestHandlerReturnType { const handlerConfig: RequestHandlerConfig = { @@ -96,13 +92,7 @@ function createRequestHandler( * Immediately create instance of custom fetcher if it is defined */ const customFetcher = handlerConfig.fetcher; - - const requestInstance = - customFetcher?.create({ - ...config, - baseURL: handlerConfig.baseURL || handlerConfig.apiUrl, - timeout: handlerConfig.timeout, - }) || null; + const requestInstance = customFetcher?.create(handlerConfig) || null; /** * Get Provider Instance @@ -354,7 +344,7 @@ function createRequestHandler( } const { - retries, + retries = 0, delay, backoff, retryOn, @@ -576,5 +566,3 @@ function createRequestHandler( request, }; } - -export { createRequestHandler };