diff --git a/README.md b/README.md index dca4df6..2406397 100644 --- a/README.md +++ b/README.md @@ -514,28 +514,32 @@ Check Examples section below for more information. ## Comparison with another libraries -| Feature | fetchff | ofetch | wretch | axios | native fetch() | -| --------------------------------------- | ----------- | ------------ | ------------ | ------------ | -------------- | -| **Unified API Client** | ✅ | -- | -- | -- | -- | -| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- | -| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- | -| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- | -| **Custom Retry logic** | ✅ | ✅ | ✅ | -- | -- | -| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- | -| **Easy Cancellation** | ✅ | -- | -- | -- | -- | -| **Default Responses** | ✅ | -- | -- | -- | -- | -| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- | -| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- | -| **Interceptors** | ✅ | ✅ | ✅ | ✅ | -- | -| **Request and Response Transformation** | ✅ | ✅ | ✅ | ✅ | -- | -| **Integration with Libraries** | ✅ | ✅ | ✅ | ✅ | -- | -| **Request Queuing** | ✅ | -- | -- | -- | -- | -| **Multiple Fetching Strategies** | ✅ | -- | -- | -- | -- | -| **Dynamic URLs** | ✅ | -- | ✅ | -- | -- | -| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- | -| **Server-Side Rendering (SSR) Support** | ✅ | ✅ | -- | -- | -- | -| **Minimal Installation Size** | 🟢 (2.9 KB) | 🟡 (6.41 KB) | 🟢 (2.21 KB) | 🔴 (13.7 KB) | 🟢 (0 KB) | +| Feature | fetchff | ofetch | wretch | axios | native fetch() | +| -------------------------------------------------- | ----------- | ------------ | ------------ | ------------ | -------------- | +| **Unified API Client** | ✅ | -- | -- | -- | -- | +| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- | +| **Built-in Error Handling** | ✅ | -- | ✅ | -- | -- | +| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- | +| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- | +| **Advanced Query Params handling** | ✅ | -- | -- | -- | -- | +| **Custom Retry logic** | ✅ | ✅ | ✅ | -- | -- | +| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- | +| **Polling Functionality** | ✅ | -- | -- | -- | -- | +| **Easy Cancellation of stale (previous) requests** | ✅ | -- | -- | -- | -- | +| **Default Responses** | ✅ | -- | -- | -- | -- | +| **Custom adapters (fetchers)** | ✅ | -- | -- | ✅ | -- | +| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- | +| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- | +| **Request Interceptors** | ✅ | ✅ | ✅ | ✅ | -- | +| **Request and Response Transformation** | ✅ | ✅ | ✅ | ✅ | -- | +| **Integration with Libraries** | ✅ | ✅ | ✅ | ✅ | -- | +| **Request Queuing** | ✅ | -- | -- | -- | -- | +| **Multiple Fetching Strategies** | ✅ | -- | -- | -- | -- | +| **Dynamic URLs** | ✅ | -- | ✅ | -- | -- | +| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- | +| **Server-Side Rendering (SSR) Support** | ✅ | ✅ | -- | -- | -- | +| **Minimal Installation Size** | 🟢 (2.9 KB) | 🟡 (6.41 KB) | 🟢 (2.21 KB) | 🔴 (13.7 KB) | 🟢 (0 KB) | Please mind that this table is for informational purposes only. All of these solutions differ. For example `swr` and `react-query` are more focused on React, re-rendering, query caching and keeping data in sync, while fetch wrappers like `fetchff` or `ofetch` aim to extend functionalities of native `fetch` so to reduce complexity of having to maintain various wrappers. @@ -768,6 +772,46 @@ try { } ``` +### Polling Mechanism + +Standard polling - re-fetch every n seconds. + +```typescript +fetchff('https://api.example.com/books/all', null, { + pollingInterval: 5000, // Re-fetch the data every 5 seconds + shouldStopPolling(response, error, attempt) { + // Add some custom conditions + return attempt < 3; // Retry up to 3 times + }, + onResponse(response) { + console.log('New response:', response); + + return response; + }, + onError(error) { + console.error('Request ultimately failed:', error); + }, +}); +``` + +Status Polling - until you get a certain data from an API. Let's say you have an API that returns the progress of a process, and you want to call that API until the process is finished. + +```typescript +try { + const { data } = fetchff('https://api.example.com/books/all', null, { + pollingInterval: 5000, // Poll every 5 seconds + shouldStopPolling(response, error, attempt) { + // Add some custom conditions + return attempt < 3; // Retry up to 3 times + }, + }); + + console.log('Request finally succeeded:', data); +} catch (error) { + console.error('Request ultimately failed:', error); +} +``` + ### ✔️ Advanced Usage with TypeScript and custom headers ```typescript diff --git a/src/const.ts b/src/const.ts index 036dad0..f4e8f9f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,7 @@ export const APPLICATION_JSON = 'application/json'; export const CONTENT_TYPE = 'Content-Type'; export const UNDEFINED = 'undefined'; +export const OBJECT = 'object'; export const ABORT_ERROR = 'AbortError'; export const TIMEOUT_ERROR = 'TimeoutError'; export const CANCELLED_ERROR = 'CanceledError'; diff --git a/src/request-handler.ts b/src/request-handler.ts index 10b1ad4..f32e4f2 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -8,6 +8,7 @@ import type { ResponseError, RequestHandlerReturnType, CreatedCustomFetcherInstance, + PollingFunction, } from './types/request-handler'; import type { APIResponse, @@ -43,8 +44,8 @@ const defaultConfig: RequestHandlerConfig = { method: GET, strategy: 'reject', timeout: 30000, - rejectCancelled: false, dedupeTime: 1000, + rejectCancelled: false, withCredentials: false, flattenResponse: false, defaultResponse: null, @@ -320,6 +321,11 @@ function createRequestHandler( 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 { retries, @@ -339,6 +345,7 @@ function createRequestHandler( ) as Required; let attempt = 0; + let pollingAttempt = 0; let waitTime: number = delay; while (attempt <= retries) { @@ -403,6 +410,22 @@ function createRequestHandler( removeRequest(fetcherConfig); + // Polling logic + if ( + pollingInterval && + (!shouldStopPolling || !shouldStopPolling(response, pollingAttempt)) + ) { + // Restart the main retry loop + pollingAttempt++; + + logger(`Polling attempt ${pollingAttempt}...`); + + await delayInvocation(pollingInterval); + + continue; + } + + // If polling is not required, or polling attempts are exhausted return outputResponse(response, requestConfig) as ResponseData & FetchResponse; } catch (err) { @@ -421,7 +444,7 @@ function createRequestHandler( return outputErrorResponse(error, response, fetcherConfig); } - logger(`Attempt ${attempt + 1} failed. Retrying in ${waitTime}ms...`); + logger(`Attempt ${attempt + 1} failed. Retry in ${waitTime}ms.`); await delayInvocation(waitTime); diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index b5eb68e..dc511e9 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -132,6 +132,12 @@ export interface RetryOptions { ) => Promise; } +export type PollingFunction = ( + response: FetchResponse, + attempt: number, + error?: ResponseError, +) => boolean; + /** * ExtendedRequestConfig * @@ -253,6 +259,20 @@ interface ExtendedRequestConfig extends Omit { * @default 1000 (1 second) */ dedupeTime?: number; + + /** + * Interval in milliseconds between polling attempts. + * Set to < 1 to disable polling. + * @default 0 (disabled) + */ + pollingInterval?: number; + + /** + * Function to determine if polling should stop based on the response. + * @param response - The response data. + * @returns `true` to stop polling, `false` to continue. + */ + shouldStopPolling?: PollingFunction; } interface BaseRequestHandlerConfig extends RequestConfig { diff --git a/src/utils.ts b/src/utils.ts index b7647c1..ec6fab5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { UNDEFINED } from './const'; +import { OBJECT, UNDEFINED } from './const'; import type { HeadersObject, QueryParams, UrlPathParams } from './types'; 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; +} + /** * Appends query parameters to a given URL. * @@ -22,11 +30,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { if (isSearchParams(params)) { const encodedQueryString = params.toString(); - return url.includes('?') - ? `${url}&${encodedQueryString}` - : encodedQueryString - ? `${url}?${encodedQueryString}` - : url; + return makeUrl(url, encodedQueryString); } // This is exact copy of what JQ used to do. It works much better than URLSearchParams @@ -45,14 +49,11 @@ export function appendQueryParams(url: string, params: QueryParams): string { if (Array.isArray(obj)) { for (i = 0, len = obj.length; i < len; i++) { buildParams( - prefix + - '[' + - (typeof obj[i] === 'object' && obj[i] ? i : '') + - ']', + prefix + '[' + (typeof obj[i] === OBJECT && obj[i] ? i : '') + ']', obj[i], ); } - } else if (typeof obj === 'object' && obj !== null) { + } else if (typeof obj === OBJECT && obj !== null) { for (key in obj) { buildParams(prefix + '[' + key + ']', obj[key]); } @@ -76,11 +77,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 url.includes('?') - ? `${url}&${encodedQueryString}` - : encodedQueryString - ? `${url}?${encodedQueryString}` - : url; + return makeUrl(url, encodedQueryString); } /** @@ -142,7 +139,7 @@ export function isJSONSerializable(value: any): boolean { return false; } - if (t === 'object') { + if (t === OBJECT) { const proto = Object.getPrototypeOf(value); // Check if the prototype is `Object.prototype` or `null` (plain object) @@ -179,7 +176,7 @@ export async function delayInvocation(ms: number): Promise { export function flattenData(data: any): any { if ( data && - typeof data === 'object' && + typeof data === OBJECT && typeof data.data !== UNDEFINED && Object.keys(data).length === 1 ) { @@ -213,7 +210,7 @@ export function processHeaders( headers.forEach((value, key) => { headersObject[key] = value; }); - } else if (typeof headers === 'object' && headers !== null) { + } else if (typeof headers === OBJECT && headers !== null) { // Handle plain object for (const [key, value] of Object.entries(headers)) { // Normalize keys to lowercase as per RFC 2616 4.2 diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 543c00b..727e930 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -399,6 +399,210 @@ describe('Request Handler', () => { }); }); + describe('request() Polling Mechanism', () => { + const baseURL = 'https://api.example.com'; + const mockLogger = { warn: jest.fn() }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + globalThis.fetch = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should poll the specified number of times until shouldStopPolling returns true', async () => { + // Setup polling configuration + const pollingConfig = { + pollingInterval: 100, + shouldStopPolling: jest.fn((_response, pollingAttempt) => { + // Stop polling after 3 attempts + return pollingAttempt >= 3; + }), + }; + + // Initialize RequestHandler with polling configuration + const requestHandler = createRequestHandler({ + baseURL, + retry: { + retries: 0, // No retries for this test + }, + ...pollingConfig, + logger: mockLogger, + }); + + // Mock fetch to return a successful response every time + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + clone: jest.fn().mockReturnValue({}), + json: jest.fn().mockResolvedValue({}), + }); + + const mockDelayInvocation = delayInvocation as jest.MockedFunction< + typeof delayInvocation + >; + + mockDelayInvocation.mockResolvedValue(true); + + // Make the request + await requestHandler.request('/endpoint'); + + // Advance timers to cover the polling interval + jest.advanceTimersByTime(300); // pollingInterval * 3 + + // Ensure polling stopped after 3 attempts + expect(pollingConfig.shouldStopPolling).toHaveBeenCalledTimes(4); + expect(globalThis.fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 polls + + // Ensure delay function was called for each polling attempt + expect(mockDelayInvocation).toHaveBeenCalledTimes(3); + expect(mockDelayInvocation).toHaveBeenCalledWith( + pollingConfig.pollingInterval, + ); + }); + + it('should not poll if pollingInterval is not provided', async () => { + // Setup without polling configuration + const requestHandler = createRequestHandler({ + baseURL, + retry: { + retries: 0, // No retries for this test + }, + pollingInterval: 0, // No polling + logger: mockLogger, + }); + + // Mock fetch to return a successful response + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + clone: jest.fn().mockReturnValue({}), + json: jest.fn().mockResolvedValue({}), + }); + + await requestHandler.request('/endpoint'); + + // Ensure fetch was only called once + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + // Ensure polling was not attempted + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should stop polling on error and not proceed with polling attempts', async () => { + // Setup polling configuration + const pollingConfig = { + pollingInterval: 100, + shouldStopPolling: jest.fn(() => false), // Always continue polling if no errors + }; + + const requestHandler = createRequestHandler({ + baseURL, + retry: { + retries: 0, // No retries for this test + }, + ...pollingConfig, + logger: mockLogger, + }); + + // Mock fetch to fail + (globalThis.fetch as any).mockRejectedValue({ + status: 500, + json: jest.fn().mockResolvedValue({}), + }); + + const mockDelayInvocation = delayInvocation as jest.MockedFunction< + typeof delayInvocation + >; + + mockDelayInvocation.mockResolvedValue(true); + + await expect(requestHandler.request('/endpoint')).rejects.toEqual({ + status: 500, + json: expect.any(Function), + }); + + // Ensure fetch was called once (no polling due to error) + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + // Ensure polling was not attempted after failure + expect(mockDelayInvocation).toHaveBeenCalledTimes(0); + + // Ensure we process the error + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('should log polling attempts and delays', async () => { + // Setup polling configuration + const pollingConfig = { + pollingInterval: 100, + shouldStopPolling: jest.fn((_response, pollingAttempt) => { + // Stop polling after 3 attempts + return pollingAttempt >= 3; + }), + }; + + const requestHandler = createRequestHandler({ + baseURL, + retry: { + retries: 0, // No retries for this test + }, + ...pollingConfig, + logger: mockLogger, + }); + + // Mock fetch to return a successful response + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + clone: jest.fn().mockReturnValue({}), + json: jest.fn().mockResolvedValue({}), + }); + + await requestHandler.request('/endpoint'); + + // Advance timers to cover polling interval + jest.advanceTimersByTime(300); // pollingInterval * 3 + + // Check if polling was logged properly + expect(mockLogger.warn).toHaveBeenCalledWith('Polling attempt 1...'); + expect(mockLogger.warn).toHaveBeenCalledWith('Polling attempt 2...'); + expect(mockLogger.warn).toHaveBeenCalledWith('Polling attempt 3...'); + }); + + it('should not poll if shouldStopPolling returns true immediately', async () => { + // Setup polling configuration + const pollingConfig = { + pollingInterval: 100, + shouldStopPolling: jest.fn(() => true), // Stop immediately + }; + + const requestHandler = createRequestHandler({ + baseURL, + retry: { + retries: 0, // No retries for this test + }, + ...pollingConfig, + logger: mockLogger, + }); + + // Mock fetch to return a successful response + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + clone: jest.fn().mockReturnValue({}), + json: jest.fn().mockResolvedValue({}), + }); + + await requestHandler.request('/endpoint'); + + // Ensure fetch was only called once + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + // Ensure polling was skipped + expect(pollingConfig.shouldStopPolling).toHaveBeenCalledTimes(1); + }); + }); + describe('request() Retry Mechanism', () => { const baseURL = 'https://api.example.com'; const mockLogger = { warn: jest.fn() }; @@ -521,13 +725,13 @@ describe('Request Handler', () => { // Check delay between retries expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 1 failed. Retrying in 100ms...', + 'Attempt 1 failed. Retry in 100ms.', ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 2 failed. Retrying in 150ms...', + 'Attempt 2 failed. Retry in 150ms.', ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 3 failed. Retrying in 225ms...', + 'Attempt 3 failed. Retry in 225ms.', ); });