fetch-extras
Advanced tools
| /** | ||
| Custom error class for HTTP errors that should be thrown when the response has a non-2xx status code. | ||
| @example | ||
| ``` | ||
| import {HttpError, throwIfHttpError} from 'fetch-extras'; | ||
| try { | ||
| await throwIfHttpError(fetch('/api')); | ||
| } catch (error) { | ||
| if (error instanceof HttpError) { | ||
| console.log(error.response.status); // 404 | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export class HttpError extends Error { | ||
| readonly name: 'HttpError'; | ||
| readonly code: 'ERR_HTTP_RESPONSE_NOT_OK'; | ||
| response: Response; | ||
| /** | ||
| Constructs a new `HttpError` instance. | ||
| @param response - The `Response` object that caused the error. | ||
| */ | ||
| constructor(response: Response); | ||
| } | ||
| /** | ||
| Throws an `HttpError` if the response is not ok (non-2xx status code). | ||
| @param response - The `Response` object to check. | ||
| @returns The same `Response` object if it is ok. | ||
| @throws {HttpError} If the response is not ok. | ||
| @example | ||
| ``` | ||
| import {throwIfHttpError} from 'fetch-extras'; | ||
| const response = await fetch('/api'); | ||
| throwIfHttpError(response); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function throwIfHttpError(response: Response): Response; | ||
| /** | ||
| Throws an `HttpError` if the response is not ok (non-2xx status code). | ||
| @param responsePromise - A promise that resolves to a `Response` object to check. | ||
| @returns A promise that resolves to the same `Response` object if it is ok. | ||
| @throws {HttpError} If the response is not ok. | ||
| @example | ||
| ``` | ||
| import {throwIfHttpError} from 'fetch-extras'; | ||
| const response = await throwIfHttpError(fetch('/api')); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function throwIfHttpError(responsePromise: Promise<Response>): Promise<Response>; | ||
| /** | ||
| Wraps a fetch function to automatically throw `HttpError` for non-2xx responses. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that will throw HttpError for non-2xx responses. | ||
| @example | ||
| ``` | ||
| import {withHttpError} from 'fetch-extras'; | ||
| const fetchWithError = withHttpError(fetch); | ||
| const response = await fetchWithError('/api'); // Throws HttpError for non-2xx responses | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function withHttpError<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction | ||
| ): (...arguments_: Parameters<FetchFunction>) => ReturnType<FetchFunction>; |
| import {copyFetchMetadata} from './utilities.js'; | ||
| export class HttpError extends Error { | ||
| constructor(response) { | ||
| const status = `${response.status} ${response.statusText}`.trim(); | ||
| const reason = status ? `status code ${status}` : 'an unknown error'; | ||
| super(`Request failed with ${reason}: ${response.url}`); | ||
| Error.captureStackTrace?.(this, this.constructor); | ||
| this.name = 'HttpError'; | ||
| this.code = 'ERR_HTTP_RESPONSE_NOT_OK'; | ||
| this.response = response; | ||
| } | ||
| } | ||
| export async function throwIfHttpError(responseOrPromise) { | ||
| if (!(responseOrPromise instanceof Response)) { | ||
| responseOrPromise = await responseOrPromise; | ||
| } | ||
| if (!responseOrPromise.ok) { | ||
| throw new HttpError(responseOrPromise); | ||
| } | ||
| return responseOrPromise; | ||
| } | ||
| export function withHttpError(fetchFunction) { | ||
| const fetchWithHttpError = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| return throwIfHttpError(response); | ||
| }; | ||
| return copyFetchMetadata(fetchWithHttpError, fetchFunction); | ||
| } |
| /** | ||
| Pagination options for customizing how pagination works. | ||
| */ | ||
| export type PaginationOptions<ItemType = unknown> = { | ||
| /** | ||
| Transform the response into an array of items. | ||
| By default, it calls `response.json()` and expects an array. | ||
| @param response - The Response object from the fetch request. | ||
| @returns An array of items to yield, or a Promise that resolves to an array. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| const items = await paginate.all('https://api.example.com/users', { | ||
| pagination: { | ||
| transform: async response => { | ||
| const data = await response.json(); | ||
| return data.users; // Extract items from nested property | ||
| } | ||
| } | ||
| }); | ||
| ``` | ||
| */ | ||
| transform?: (response: Response) => ItemType[] | Promise<ItemType[]>; | ||
| /** | ||
| Determine the next page to fetch. | ||
| Return an object with fetch options for the next request, or `false` to stop pagination. | ||
| By default, it parses the `Link` header and follows the `rel="next"` link. | ||
| **Important**: The response body has already been consumed by the `transform` function. Do NOT call `response.json()` or other body methods here. Instead, extract pagination info from headers, the URL, or share data from the transform function through closure. | ||
| **Note**: Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request. | ||
| @param data - Context object with response, current URL, and items. | ||
| @returns Options for the next fetch request, or `false` to stop pagination. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Cursor-based pagination using headers (recommended) | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| paginate: ({response}) => { | ||
| const nextCursor = response.headers.get('X-Next-Cursor'); | ||
| if (!nextCursor) return false; | ||
| return { | ||
| url: new URL(`https://api.example.com/items?cursor=${nextCursor}`) | ||
| }; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Sharing data between transform and paginate via closure | ||
| let nextCursor; | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async (response) => { | ||
| const data = await response.json(); | ||
| // Store pagination info in closure | ||
| nextCursor = data.nextCursor; | ||
| return data.items; | ||
| }, | ||
| paginate: () => { | ||
| if (!nextCursor) return false; | ||
| return { | ||
| url: new URL(`https://api.example.com/items?cursor=${nextCursor}`) | ||
| }; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| */ | ||
| paginate?: (data: { | ||
| response: Response; | ||
| currentUrl: URL | string; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => PaginationNextPage | false | Promise<PaginationNextPage | false>; | ||
| /** | ||
| Filter items before yielding them. | ||
| @param data - Context object with the current item and item arrays. | ||
| @returns `true` to yield the item, `false` to skip it. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Only get active users | ||
| for await (const user of paginate('https://api.example.com/users', { | ||
| pagination: { | ||
| filter: ({item}) => item.status === 'active' | ||
| } | ||
| })) { | ||
| console.log(user); | ||
| } | ||
| ``` | ||
| */ | ||
| filter?: (data: { | ||
| item: ItemType; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => boolean; | ||
| /** | ||
| Check if pagination should continue after yielding an item. | ||
| This is called after `filter` returns `true`. Useful for stopping pagination based on item values. | ||
| @param data - Context object with the current item and item arrays. | ||
| @returns `true` to continue pagination, `false` to stop. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Stop when we reach items older than one week | ||
| const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); | ||
| for await (const commit of paginate('https://api.github.com/repos/user/repo/commits', { | ||
| pagination: { | ||
| shouldContinue: ({item}) => new Date(item.date).getTime() >= oneWeekAgo | ||
| } | ||
| })) { | ||
| console.log(commit); | ||
| } | ||
| ``` | ||
| */ | ||
| shouldContinue?: (data: { | ||
| item: ItemType; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => boolean; | ||
| /** | ||
| Maximum number of items to yield. | ||
| @default Infinity | ||
| */ | ||
| countLimit?: number; | ||
| /** | ||
| Delay in milliseconds between requests. | ||
| Useful for rate limiting. | ||
| @default 0 | ||
| */ | ||
| backoff?: number; | ||
| /** | ||
| Maximum number of requests to make. | ||
| This prevents infinite loops if your `paginate` function has bugs. Ensure your `paginate` function eventually returns `false` or the iteration will continue until this limit is reached. | ||
| @default 10000 | ||
| */ | ||
| requestLimit?: number; | ||
| /** | ||
| Whether to keep all yielded items in memory. | ||
| When `true`, the `allItems` array passed to callbacks will contain all previously yielded items. When `false` (default), `allItems` will always be empty to save memory. | ||
| @default false | ||
| */ | ||
| stackAllItems?: boolean; | ||
| }; | ||
| /** | ||
| Options for the next page request. | ||
| */ | ||
| export type PaginationNextPage = { | ||
| /** | ||
| URL for the next page. | ||
| Must be a URL instance, not a string. | ||
| */ | ||
| url?: URL; | ||
| } & RequestInit; | ||
| /** | ||
| A function with the same signature as the global `fetch`. | ||
| This allows you to use a custom fetch implementation, such as [`ky`](https://github.com/sindresorhus/ky). | ||
| */ | ||
| export type FetchFunction = (input: RequestInfo | URL, init?: any) => Promise<Response>; | ||
| /** | ||
| Options for the `paginate` function. | ||
| */ | ||
| export type PaginateOptions<ItemType = unknown> = RequestInit & { | ||
| /** | ||
| Pagination-specific options. | ||
| */ | ||
| pagination?: PaginationOptions<ItemType>; | ||
| /** | ||
| Custom fetch function to use for requests. | ||
| This allows you to use a custom fetch implementation, such as [`ky`](https://github.com/sindresorhus/ky), or a fetch function wrapped with `withHttpError` or `withTimeout`. | ||
| @default globalThis.fetch | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| import ky from 'ky'; | ||
| const url = 'https://api.github.com/repos/sindresorhus/ky/commits'; | ||
| for await (const commit of paginate(url, {fetchFunction: ky})) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| */ | ||
| fetchFunction?: FetchFunction; | ||
| }; | ||
| /** | ||
| Paginate through API responses using async iteration. | ||
| By default, it automatically follows RFC 5988 `Link` headers with `rel="next"`. | ||
| **Note**: This function does not check response status codes. If you need error handling for non-2xx responses, wrap fetch with `withHttpError()` or handle errors in your `transform` function. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
| @param options - Fetch options plus pagination options. | ||
| @returns An async iterator that yields items from each page. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Basic usage with Link headers (GitHub API) | ||
| for await (const commit of paginate('https://api.github.com/repos/sindresorhus/ky/commits')) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // With error handling for non-2xx responses | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async (response) => { | ||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
| return response.json(); | ||
| }, | ||
| countLimit: 100, | ||
| backoff: 1000 | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Cursor-based pagination using headers | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async response => { | ||
| const data = await response.json(); | ||
| return data.items; | ||
| }, | ||
| paginate: ({response}) => { | ||
| const cursor = response.headers.get('X-Next-Cursor'); | ||
| return cursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${cursor}`)} | ||
| : false; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| */ | ||
| export function paginate<ItemType = unknown>( | ||
| input: RequestInfo | URL, | ||
| options?: PaginateOptions<ItemType> | ||
| ): AsyncIterableIterator<ItemType>; | ||
| export namespace paginate { | ||
| /** | ||
| Get all paginated items as an array. | ||
| This is a convenience method that collects all items into memory. For large datasets, prefer using the async iterator directly. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
| @param options - Fetch options plus pagination options. | ||
| @returns A promise that resolves to an array of all items. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| const commits = await paginate.all('https://api.github.com/repos/sindresorhus/ky/commits', { | ||
| pagination: { | ||
| countLimit: 50 | ||
| } | ||
| }); | ||
| console.log(`Fetched ${commits.length} commits`); | ||
| ``` | ||
| */ | ||
| export function all<ItemType = unknown>( | ||
| input: RequestInfo | URL, | ||
| options?: PaginateOptions<ItemType> | ||
| ): Promise<ItemType[]>; | ||
| } |
| /** | ||
| Pipes a value through a series of functions, left to right. | ||
| This is a convenience for composing `with*` functions without deep nesting. Without `pipeline()`, the same composition would need nested `with*` calls. | ||
| You can write: | ||
| ``` | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| ); | ||
| ``` | ||
| Functions are applied left to right: the first function receives the initial value, the second receives the result of the first, and so on. | ||
| @param value - The initial value to pipe through. | ||
| @param functions - Functions to apply in order. Each function receives the previous function's return value and may return a different type. | ||
| @returns The result of applying all functions. | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withTimeout, withBaseUrl, withHeaders} from 'fetch-extras'; | ||
| // Create a tiny reusable API client that: | ||
| // - Sends auth headers on every request | ||
| // - Uses a base URL so you only write paths | ||
| // - Throws errors for non-2xx responses | ||
| // - Times out after 5 seconds | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| // These overloads exist to preserve contextual typing for each stage. | ||
| // A shorter variadic generic signature would be nicer, but in practice it does not reliably | ||
| // infer callback parameter types for the documented pipeline(fetch, f => ..., withHttpError) pattern. | ||
| export function pipeline<Value>(value: Value): Value; | ||
| export function pipeline<Value, Result1>(value: Value, function1: (value: Value) => Result1): Result1; | ||
| export function pipeline<Value, Result1, Result2>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2): Result2; | ||
| export function pipeline<Value, Result1, Result2, Result3>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3): Result3; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4): Result4; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5): Result5; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6): Result6; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7): Result7; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8): Result8; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9): Result9; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10): Result10; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11): Result11; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12): Result12; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13): Result13; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14): Result14; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15): Result15; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15, Result16>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15, function16: (value: Result15) => Result16): Result16; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15, Result16, Result17>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15, function16: (value: Result15) => Result16, function17: (value: Result16) => Result17): Result17; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15, Result16, Result17, Result18>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15, function16: (value: Result15) => Result16, function17: (value: Result16) => Result17, function18: (value: Result17) => Result18): Result18; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15, Result16, Result17, Result18, Result19>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15, function16: (value: Result15) => Result16, function17: (value: Result16) => Result17, function18: (value: Result17) => Result18, function19: (value: Result18) => Result19): Result19; | ||
| export function pipeline<Value, Result1, Result2, Result3, Result4, Result5, Result6, Result7, Result8, Result9, Result10, Result11, Result12, Result13, Result14, Result15, Result16, Result17, Result18, Result19, Result20>(value: Value, function1: (value: Value) => Result1, function2: (value: Result1) => Result2, function3: (value: Result2) => Result3, function4: (value: Result3) => Result4, function5: (value: Result4) => Result5, function6: (value: Result5) => Result6, function7: (value: Result6) => Result7, function8: (value: Result7) => Result8, function9: (value: Result8) => Result9, function10: (value: Result9) => Result10, function11: (value: Result10) => Result11, function12: (value: Result11) => Result12, function13: (value: Result12) => Result13, function14: (value: Result13) => Result14, function15: (value: Result14) => Result15, function16: (value: Result15) => Result16, function17: (value: Result16) => Result17, function18: (value: Result17) => Result18, function19: (value: Result18) => Result19, function20: (value: Result19) => Result20): Result20; | ||
| // Longer pipelines still work at runtime, so keep a permissive fallback instead of rejecting them. | ||
| // The tradeoff is that very long chains lose precise inference rather than failing to type-check. | ||
| export function pipeline(value: unknown, ...functions: Array<(value: any) => any>): any; |
| /** | ||
| Pipes a value through a series of functions, left to right. | ||
| @param {unknown} value - The initial value. | ||
| @param {...Function} functions - Functions to apply in order. | ||
| @returns {unknown} The result of applying all functions. | ||
| */ | ||
| export function pipeline(value, ...functions) { | ||
| for (const function_ of functions) { | ||
| value = function_(value); | ||
| } | ||
| return value; | ||
| } |
| /** | ||
| Returns a wrapped fetch function that resolves relative URLs against a base URL. Useful for API clients with a consistent base URL. | ||
| Only string-based relative URLs are resolved against the base URL. Absolute URLs and URL objects are passed through unchanged. Relative paths are resolved against the base URL's pathname, while query-only and fragment-only inputs keep normal URL semantics. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param baseUrl - The base URL to resolve relative URLs against. Can be a string or URL object. | ||
| @returns A wrapped fetch function that resolves relative URLs against the base URL. | ||
| @example | ||
| ``` | ||
| import {withBaseUrl} from 'fetch-extras'; | ||
| const fetchWithBaseUrl = withBaseUrl(fetch, 'https://api.example.com'); | ||
| const response = await fetchWithBaseUrl('/users'); // Requests https://api.example.com/users | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| // Both of these work the same way | ||
| const fetch1 = withBaseUrl(fetch, 'https://api.example.com/v1'); | ||
| const fetch2 = withBaseUrl(fetch, 'https://api.example.com/v1/'); | ||
| await fetch1('users'); // https://api.example.com/v1/users | ||
| await fetch2('users'); // https://api.example.com/v1/users | ||
| await fetch1('?page=2'); // https://api.example.com/v1?page=2 | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withBaseUrl, withHttpError, withTimeout} from 'fetch-extras'; | ||
| const fetchWithAll = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withHttpError, | ||
| ); | ||
| const response = await fetchWithAll('/users'); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate, withBaseUrl} from 'fetch-extras'; | ||
| const fetchWithBaseUrl = withBaseUrl(fetch, 'https://api.github.com'); | ||
| for await (const commit of paginate('/repos/sindresorhus/ky/commits', {fetchFunction: fetchWithBaseUrl})) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| */ | ||
| export function withBaseUrl( | ||
| fetchFunction: typeof fetch, | ||
| baseUrl: URL | string | ||
| ): typeof fetch; |
| import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to resolve relative URLs against a base URL. Only string-based relative URLs are resolved; absolute URLs and URL objects are passed through unchanged. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {URL | string} baseUrl - The base URL to resolve relative URLs against. | ||
| @returns {typeof fetch} A wrapped fetch function that resolves relative URLs against the base URL. | ||
| */ | ||
| export function withBaseUrl(fetchFunction, baseUrl) { | ||
| const baseUrlString = baseUrl instanceof URL ? baseUrl.href : baseUrl; | ||
| let baseUrlObject; | ||
| const getBaseUrlObject = () => { | ||
| if (!baseUrlObject) { | ||
| try { | ||
| baseUrlObject = new URL(baseUrlString); | ||
| } catch (error) { | ||
| throw new TypeError(`Invalid base URL: ${error.message}`); | ||
| } | ||
| } | ||
| return baseUrlObject; | ||
| }; | ||
| const resolveRequestUrl = urlOrRequest => { | ||
| if (typeof urlOrRequest !== 'string') { | ||
| return urlOrRequest instanceof Request ? urlOrRequest.url : String(urlOrRequest); | ||
| } | ||
| if (/^[a-z][a-z\d+\-.]*:/i.test(urlOrRequest)) { | ||
| return urlOrRequest; | ||
| } | ||
| if (urlOrRequest === '') { | ||
| return baseUrlString; | ||
| } | ||
| if (/^\/\/[^/]/.test(urlOrRequest) || /^[?#]/.test(urlOrRequest)) { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| const baseUrlForPath = new URL(getBaseUrlObject()); | ||
| baseUrlForPath.search = ''; | ||
| baseUrlForPath.hash = ''; | ||
| if (!baseUrlForPath.pathname.endsWith('/')) { | ||
| baseUrlForPath.pathname = `${baseUrlForPath.pathname}/`; | ||
| } | ||
| return new URL(urlOrRequest.replace(/^\/+/, ''), baseUrlForPath).href; | ||
| }; | ||
| const fetchWithBaseUrl = async (urlOrRequest, options = {}) => fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrl(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| fetchWithBaseUrl[resolveRequestUrlSymbol] = resolveRequestUrl; | ||
| return copyFetchMetadata(fetchWithBaseUrl, fetchFunction); | ||
| } |
| /** | ||
| Wraps a fetch function with in-memory caching for GET requests. | ||
| Non-GET requests pass through unchanged. Unsafe methods (POST, PUT, PATCH, DELETE, etc.) also invalidate any cached response for the same URL. Only successful (2xx) responses are cached. Each cache hit returns a fresh clone, so the body can be consumed independently on every call. | ||
| The cache lives in memory for the lifetime of the returned function. To clear it, create a new `withCache` wrapper. | ||
| The cache key is the URL only. Requests with different headers but the same URL share the same cache entry. | ||
| Place `withCache` after `withBaseUrl` in a pipeline so the cache key is the resolved absolute URL, and before `withHttpError` so cached responses still get error-checked. | ||
| If you need to limit the number of cached entries, you can use a [`quick-lru`](https://github.com/sindresorhus/quick-lru) instance as the basis for a custom wrapper, since it implements the `Map` interface with a `maxSize` option. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Cache configuration. | ||
| @returns A wrapped fetch function that caches GET responses. | ||
| @example | ||
| ``` | ||
| import {withCache} from 'fetch-extras'; | ||
| const cachedFetch = withCache(fetch, {ttl: 60_000}); | ||
| const response = await cachedFetch('https://api.example.com/data'); | ||
| const data = await response.json(); | ||
| // Second call within 60 seconds returns the cached response | ||
| const response2 = await cachedFetch('https://api.example.com/data'); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withCache, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withCache(f, {ttl: 30_000}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withCache( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
| /** | ||
| Time-to-live in milliseconds. Cached responses older than this are discarded and re-fetched. | ||
| */ | ||
| ttl: number; | ||
| }, | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| defersFetchStartSymbol, | ||
| notifyFetchStartSymbol, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| } from './utilities.js'; | ||
| const nonInvalidatingMethods = new Set(['HEAD', 'OPTIONS', 'TRACE']); | ||
| function getHeaders(urlOrRequest, options) { | ||
| const resolvedHeaders = this?.[resolveRequestHeadersSymbol]?.(urlOrRequest, options); | ||
| if (resolvedHeaders) { | ||
| return new Headers(resolvedHeaders); | ||
| } | ||
| return new Headers(options.headers ?? (urlOrRequest instanceof Request ? urlOrRequest.headers : undefined)); | ||
| } | ||
| function getRequestContext(fetchFunction, urlOrRequest, options) { | ||
| return { | ||
| method: (options.method ?? (urlOrRequest instanceof Request ? urlOrRequest.method : 'GET')).toUpperCase(), | ||
| url: resolveRequestUrl(fetchFunction, urlOrRequest), | ||
| cacheMode: options.cache ?? (urlOrRequest instanceof Request ? urlOrRequest.cache : undefined), | ||
| signal: options.signal ?? (urlOrRequest instanceof Request ? urlOrRequest.signal : undefined), | ||
| isRangedRequest: getHeaders.call(fetchFunction, urlOrRequest, options).has('range'), | ||
| }; | ||
| } | ||
| function getGeneration(cache, state, url) { | ||
| return cache.get(url)?.generation ?? state.get(url)?.generation ?? 0; | ||
| } | ||
| function getState(state, url) { | ||
| let urlState = state.get(url); | ||
| if (!urlState) { | ||
| urlState = { | ||
| generation: 0, | ||
| pendingGetCount: 0, | ||
| pendingInvalidationCount: 0, | ||
| }; | ||
| state.set(url, urlState); | ||
| } | ||
| return urlState; | ||
| } | ||
| function cleanupState(cache, state, url) { | ||
| const urlState = state.get(url); | ||
| if ( | ||
| urlState | ||
| && !cache.has(url) | ||
| && urlState.pendingGetCount === 0 | ||
| && urlState.pendingInvalidationCount === 0 | ||
| ) { | ||
| state.delete(url); | ||
| } | ||
| } | ||
| async function trackPending(resources, url, counterName, callback) { | ||
| const urlState = getState(resources.state, url); | ||
| urlState[counterName]++; | ||
| try { | ||
| return await callback(urlState); | ||
| } finally { | ||
| urlState[counterName]--; | ||
| cleanupState(resources.cache, resources.state, url); | ||
| } | ||
| } | ||
| function evictExpiredEntries(cache, state, retainKey) { | ||
| const currentTime = performance.now(); | ||
| for (const [key, entry] of cache) { | ||
| if (entry.expiry <= currentTime && (key !== retainKey || !entry.response)) { | ||
| cache.delete(key); | ||
| cleanupState(cache, state, key); | ||
| } | ||
| } | ||
| return currentTime; | ||
| } | ||
| function getCachedResponse(entry, cacheMode, currentTime, isRangedRequest) { | ||
| if (!entry?.response || isRangedRequest || cacheMode === 'no-store') { | ||
| return; | ||
| } | ||
| if (cacheMode === 'only-if-cached' || cacheMode === 'force-cache') { | ||
| return entry.response.clone(); | ||
| } | ||
| if (cacheMode !== 'reload' && cacheMode !== 'no-cache' && currentTime < entry.expiry) { | ||
| return entry.response.clone(); | ||
| } | ||
| } | ||
| export function withCache(fetchFunction, {ttl}) { | ||
| if (typeof ttl !== 'number' || ttl <= 0 || !Number.isFinite(ttl)) { | ||
| throw new TypeError('`ttl` must be a positive finite number.'); | ||
| } | ||
| const cache = new Map(); | ||
| const state = new Map(); | ||
| const resources = {cache, state}; | ||
| const fetchWithCache = async (urlOrRequest, options = {}) => { | ||
| const {method, url, cacheMode, signal, isRangedRequest} = getRequestContext(fetchFunction, urlOrRequest, options); | ||
| const retainStaleEntry = cacheMode === 'force-cache' || cacheMode === 'only-if-cached'; | ||
| const currentTime = evictExpiredEntries(cache, state, retainStaleEntry ? url : undefined); | ||
| // Non-GET requests pass through; unsafe methods also invalidate cache | ||
| if (method !== 'GET') { | ||
| if (nonInvalidatingMethods.has(method)) { | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const generation = getGeneration(cache, state, url); | ||
| return trackPending(resources, url, 'pendingInvalidationCount', async urlState => { | ||
| let didInvalidate = false; | ||
| const invalidate = () => { | ||
| if (didInvalidate) { | ||
| return; | ||
| } | ||
| didInvalidate = true; | ||
| cache.delete(url); | ||
| urlState.generation = generation + 1; | ||
| }; | ||
| if (!fetchFunction[defersFetchStartSymbol]) { | ||
| invalidate(); | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| return fetchFunction(urlOrRequest, {...options, [notifyFetchStartSymbol]: invalidate}); | ||
| }); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const entry = cache.get(url); | ||
| const cachedResponse = getCachedResponse(entry, cacheMode, currentTime, isRangedRequest); | ||
| if (cachedResponse) { | ||
| return cachedResponse; | ||
| } | ||
| if (cacheMode === 'only-if-cached') { | ||
| return new Response(undefined, {status: 504, statusText: 'Gateway Timeout'}); | ||
| } | ||
| const generation = getGeneration(cache, state, url); | ||
| return trackPending(resources, url, 'pendingGetCount', async () => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| const isPartialResponse = response.status === 206; | ||
| // Only cache successful responses. | ||
| if ( | ||
| response.ok | ||
| && !isRangedRequest | ||
| && !isPartialResponse | ||
| && cacheMode !== 'no-store' | ||
| && generation === getGeneration(cache, state, url) | ||
| ) { | ||
| cache.set(url, { | ||
| generation, | ||
| response: response.clone(), | ||
| expiry: performance.now() + ttl, | ||
| }); | ||
| } | ||
| return response; | ||
| }); | ||
| }; | ||
| return copyFetchMetadata(fetchWithCache, fetchFunction); | ||
| } |
| /** | ||
| Wraps a fetch function with a concurrency limit. When the maximum number of requests are already running until `fetch()` resolves, additional calls are queued and executed as earlier ones complete. | ||
| The concurrency state is shared across all calls to the returned function. To limit different hosts independently, create separate `withConcurrency` wrappers. | ||
| Abort signals are respected while waiting: if the signal fires before a slot opens, the call rejects with the signal's abort reason without consuming a concurrency slot. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Concurrency configuration. | ||
| @returns A wrapped fetch function that enforces the concurrency limit. | ||
| @example | ||
| ``` | ||
| import {withConcurrency} from 'fetch-extras'; | ||
| // Allow at most 5 fetch() calls to run at the same time | ||
| const concurrentFetch = withConcurrency(fetch, { | ||
| maxConcurrentRequests: 5, | ||
| }); | ||
| const response = await concurrentFetch('/api/data'); | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withConcurrency, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withConcurrency(f, {maxConcurrentRequests: 5}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withConcurrency( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
| /** | ||
| Maximum number of requests allowed to run at the same time until `fetch()` resolves. | ||
| */ | ||
| maxConcurrentRequests: number; | ||
| }, | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| defersConcurrencySlotSymbol, | ||
| defersFetchStartSymbol, | ||
| enqueueAbortable, | ||
| getFetchSignal, | ||
| getRequestSignal, | ||
| notifyFetchStart, | ||
| waitForConcurrencySlotSymbol, | ||
| } from './utilities.js'; | ||
| export function withConcurrency(fetchFunction, {maxConcurrentRequests}) { | ||
| if (!Number.isInteger(maxConcurrentRequests) || maxConcurrentRequests < 1) { | ||
| throw new TypeError('`maxConcurrentRequests` must be a positive integer.'); | ||
| } | ||
| let activeCount = 0; | ||
| const queue = []; | ||
| const tryNext = () => { | ||
| while (queue.length > 0 && activeCount < maxConcurrentRequests) { | ||
| const entry = queue.shift(); | ||
| if (entry.signal?.aborted) { | ||
| entry.reject(entry.signal.reason); | ||
| continue; | ||
| } | ||
| activeCount++; | ||
| entry.resolve(); | ||
| } | ||
| }; | ||
| const waitForSlot = async signal => { | ||
| signal?.throwIfAborted(); | ||
| if (activeCount < maxConcurrentRequests) { | ||
| activeCount++; | ||
| return; | ||
| } | ||
| await enqueueAbortable(queue, {signal}); | ||
| }; | ||
| const createReleaseSlot = () => { | ||
| let didReleaseSlot = false; | ||
| return () => { | ||
| if (didReleaseSlot) { | ||
| return; | ||
| } | ||
| didReleaseSlot = true; | ||
| activeCount--; | ||
| tryNext(); | ||
| }; | ||
| }; | ||
| const fetchWithConcurrency = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| let releaseSlot; | ||
| const acquireSlot = async () => { | ||
| if (releaseSlot) { | ||
| return; | ||
| } | ||
| await waitForSlot(signal); | ||
| releaseSlot = createReleaseSlot(); | ||
| }; | ||
| signal?.throwIfAborted(); | ||
| const resolvedOptions = fetchFunction[defersConcurrencySlotSymbol] | ||
| ? {...options, [waitForConcurrencySlotSymbol]: acquireSlot} | ||
| : options; | ||
| try { | ||
| if (!fetchFunction[defersConcurrencySlotSymbol]) { | ||
| await acquireSlot(); | ||
| } | ||
| const fetchOptions = signal ? {...resolvedOptions, signal} : resolvedOptions; | ||
| notifyFetchStart(fetchFunction, fetchOptions); | ||
| return await fetchFunction(urlOrRequest, fetchOptions); | ||
| } finally { | ||
| releaseSlot?.(); | ||
| } | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithConcurrency, fetchFunction); | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
| /** | ||
| Wraps a fetch function with in-flight request deduplication for plain GET URL requests. | ||
| If multiple callers request the same URL concurrently without passing a `Request` object or any non-empty `RequestInit`, only one network request is made. Each caller receives an independent clone of the response. Once the request completes, the result is immediately forgotten, so a later request starts a fresh fetch. | ||
| Non-GET requests, `Request` objects, calls with non-empty `RequestInit`, and fetch functions already wrapped with `withTimeout` pass through unchanged. | ||
| Deduplication only applies when you call the wrapper with a URL and no non-empty `RequestInit`. An empty `{}` is treated the same as omitting the second argument so transparent outer wrappers like `withHttpError` still compose correctly. | ||
| `withDeduplication` does not deduplicate fetch functions already wrapped with `withTimeout`. Place `withTimeout` outside `withDeduplication` if you need per-call timeout behavior. | ||
| Place `withDeduplication` after `withBaseUrl` in a pipeline so the deduplication key is the resolved absolute URL. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that deduplicates concurrent plain GET URL requests. | ||
| @example | ||
| ``` | ||
| import {withDeduplication} from 'fetch-extras'; | ||
| const deduplicatedFetch = withDeduplication(fetch); | ||
| // These two concurrent requests result in only one network call | ||
| const [response1, response2] = await Promise.all([ | ||
| deduplicatedFetch('https://api.example.com/data'), | ||
| deduplicatedFetch('https://api.example.com/data'), | ||
| ]); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withDeduplication, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withDeduplication, | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withDeduplication( | ||
| fetchFunction: typeof fetch, | ||
| ): typeof fetch; |
| import {copyFetchMetadata, resolveRequestUrl, timeoutDurationSymbol} from './utilities.js'; | ||
| function enqueueWaiter(entry) { | ||
| return new Promise((resolve, reject) => { | ||
| entry.waiters.push({resolve, reject}); | ||
| }); | ||
| } | ||
| function normalizeDeduplicationKey(key) { | ||
| try { | ||
| return new URL(key).href; | ||
| } catch { | ||
| return key; | ||
| } | ||
| } | ||
| function resolveDeduplicationKey(fetchFunction, urlOrRequest) { | ||
| return normalizeDeduplicationKey(resolveRequestUrl(fetchFunction, urlOrRequest)); | ||
| } | ||
| function shouldDeduplicate(fetchFunction, urlOrRequest, options) { | ||
| const method = (options.method ?? (urlOrRequest instanceof Request ? urlOrRequest.method : 'GET')).toUpperCase(); | ||
| if (method !== 'GET') { | ||
| return false; | ||
| } | ||
| if (urlOrRequest instanceof Request) { | ||
| return false; | ||
| } | ||
| if (fetchFunction[timeoutDurationSymbol] !== undefined) { | ||
| return false; | ||
| } | ||
| return Object.keys(options).length === 0; | ||
| } | ||
| export function withDeduplication(fetchFunction) { | ||
| const pending = new Map(); | ||
| const fetchWithDeduplication = async function (urlOrRequest, options) { | ||
| const requestOptions = options ?? {}; | ||
| if (!shouldDeduplicate(fetchFunction, urlOrRequest, requestOptions)) { | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| const key = resolveDeduplicationKey(fetchFunction, urlOrRequest); | ||
| const existingEntry = pending.get(key); | ||
| if (existingEntry) { | ||
| return enqueueWaiter(existingEntry); | ||
| } | ||
| const entry = {waiters: []}; | ||
| pending.set(key, entry); | ||
| const responsePromise = enqueueWaiter(entry); | ||
| try { | ||
| const response = await fetchFunction(urlOrRequest, requestOptions); | ||
| const [firstWaiter, ...otherWaiters] = entry.waiters; | ||
| firstWaiter.resolve(response); | ||
| for (const waiter of otherWaiters) { | ||
| waiter.resolve(response.clone()); | ||
| } | ||
| } catch (error) { | ||
| for (const waiter of entry.waiters) { | ||
| waiter.reject(error); | ||
| } | ||
| } finally { | ||
| pending.delete(key); | ||
| } | ||
| return responsePromise; | ||
| }; | ||
| return copyFetchMetadata(fetchWithDeduplication, fetchFunction); | ||
| } |
| /** | ||
| Progress information for a request or response. | ||
| */ | ||
| export type Progress = { | ||
| /** | ||
| A number between 0 and 1 representing the transfer completion. | ||
| */ | ||
| percent: number; | ||
| /** | ||
| The number of bytes transferred so far. | ||
| */ | ||
| transferredBytes: number; | ||
| /** | ||
| The total number of bytes expected. When the size is known upfront (e.g. a `content-length` header is present), this reflects the full expected size. When the size is unknown, this adapts to reflect the running total of bytes seen so far, reaching the true total at completion. | ||
| */ | ||
| totalBytes: number; | ||
| }; | ||
| /** | ||
| Wraps a fetch function with download progress tracking. | ||
| The original response metadata such as `url`, `type`, and `redirected` is preserved. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Download progress callback options. | ||
| @returns A wrapped fetch function that reports download progress. | ||
| @example | ||
| ``` | ||
| import {withDownloadProgress} from 'fetch-extras'; | ||
| const fetchWithDownloadProgress = withDownloadProgress(fetch, { | ||
| onProgress(progress) { | ||
| console.log(`Download: ${Math.round(progress.percent * 100)}%`); | ||
| }, | ||
| }); | ||
| const response = await fetchWithDownloadProgress('https://example.com/large-file'); | ||
| const data = await response.arrayBuffer(); | ||
| ``` | ||
| */ | ||
| export function withDownloadProgress( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
| onProgress?: (progress: Progress) => void; | ||
| } | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| isByteStream, | ||
| isImmutableHeaders, | ||
| trackByteProgress, | ||
| trackProgress, | ||
| withResponseMetadata, | ||
| } from './utilities.js'; | ||
| function responseTotalBytes(response, immutableHeaders) { | ||
| if (immutableHeaders && response.url && response.headers.has('content-encoding')) { | ||
| return 0; | ||
| } | ||
| return Math.max(0, Number(response.headers.get('content-length')) || 0); | ||
| } | ||
| export function withDownloadProgress(fetchFunction, {onProgress} = {}) { | ||
| const fetchWithDownloadProgress = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| if (onProgress && response.body) { | ||
| const immutableHeaders = isImmutableHeaders(response.headers); | ||
| const totalBytes = responseTotalBytes(response, immutableHeaders); | ||
| const trackedBody = isByteStream(response.body) ? trackByteProgress(response.body, totalBytes, onProgress) : trackProgress(response.body, totalBytes, onProgress); | ||
| return withResponseMetadata(response, trackedBody); | ||
| } | ||
| return response; | ||
| }; | ||
| return copyFetchMetadata(fetchWithDownloadProgress, fetchFunction); | ||
| } |
| /** | ||
| Returns a wrapped fetch function that includes default headers on every request. Per-call headers take priority over the defaults, so you can always override a default on a specific request. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param defaultHeaders - Default headers to include on every request. Accepts a plain object, a `Headers` instance, or an array of `[name, value]` tuples. | ||
| @returns A wrapped fetch function that merges the default headers into every request. | ||
| @example | ||
| ``` | ||
| import {withHeaders} from 'fetch-extras'; | ||
| const fetchWithAuth = withHeaders(fetch, { | ||
| Authorization: 'Bearer my-token', | ||
| 'Content-Type': 'application/json', | ||
| }); | ||
| const response = await fetchWithAuth('/api/users'); | ||
| const data = await response.json(); | ||
| // Per-call headers override defaults | ||
| const response2 = await fetchWithAuth('/api/upload', { | ||
| headers: {'Content-Type': 'multipart/form-data'}, | ||
| }); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHeaders, withBaseUrl, withTimeout} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer my-token'}), | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withHeaders( | ||
| fetchFunction: typeof fetch, | ||
| defaultHeaders: HeadersInit | ||
| ): typeof fetch; |
| import { | ||
| blockedRequestBodyHeaderNames, | ||
| blockedDefaultHeaderNamesSymbol, | ||
| copyFetchMetadata, | ||
| deleteHeaders, | ||
| inheritedRequestBodyHeaderNamesSymbol, | ||
| resolveAuthorizationHeaderSymbol, | ||
| resolveRequestHeadersSymbol, | ||
| setHeaders, | ||
| } from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to include default headers on every request. Per-call headers take priority over the defaults. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {HeadersInit} defaultHeaders - Default headers to include on every request. | ||
| @returns {typeof fetch} A wrapped fetch function that merges the default headers into every request. | ||
| */ | ||
| export function withHeaders(fetchFunction, defaultHeaders) { | ||
| const defaultHeadersObject = new Headers(defaultHeaders); | ||
| const getMergedHeaders = (urlOrRequest, options = {}) => { | ||
| const merged = new Headers(defaultHeaders); | ||
| const requestHeaders = urlOrRequest instanceof Request ? new Headers(urlOrRequest.headers) : undefined; | ||
| const callHeaders = options.headers ? new Headers(options.headers) : undefined; | ||
| const blockedDefaultHeaderNames = new Set([ | ||
| ...(urlOrRequest?.[blockedDefaultHeaderNamesSymbol] ?? []), | ||
| ...(options[blockedDefaultHeaderNamesSymbol] ?? []), | ||
| ...(options.headers?.[blockedDefaultHeaderNamesSymbol] ?? []), | ||
| ]); | ||
| deleteHeaders(merged, blockedDefaultHeaderNames); | ||
| setHeaders(merged, requestHeaders); | ||
| setHeaders(merged, callHeaders); | ||
| return { | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| }; | ||
| }; | ||
| const fetchWithHeaders = async (urlOrRequest, options = {}) => { | ||
| const { | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| } = getMergedHeaders(urlOrRequest, options); | ||
| const shouldTreatRequestBodyHeadersAsInherited = requestHeaders | ||
| && options.body !== undefined | ||
| && !blockedRequestBodyHeaderNames.some(headerName => blockedDefaultHeaderNames.has(headerName)); | ||
| if (shouldTreatRequestBodyHeadersAsInherited) { | ||
| const inheritedHeaderNames = blockedRequestBodyHeaderNames.filter(headerName => | ||
| requestHeaders.has(headerName) | ||
| && !callHeaders?.has(headerName) | ||
| && defaultHeadersObject.get(headerName) !== requestHeaders.get(headerName), | ||
| ); | ||
| if (inheritedHeaderNames.length > 0) { | ||
| options = { | ||
| ...options, | ||
| [inheritedRequestBodyHeaderNamesSymbol]: inheritedHeaderNames, | ||
| }; | ||
| } | ||
| } | ||
| return fetchFunction(urlOrRequest, {...options, headers: merged}); | ||
| }; | ||
| fetchWithHeaders[resolveAuthorizationHeaderSymbol] = function (urlOrRequest, options = {}) { | ||
| const mergedHeaders = getMergedHeaders(urlOrRequest, options).merged; | ||
| const authorization = mergedHeaders.get('Authorization'); | ||
| if (authorization !== null) { | ||
| return authorization; | ||
| } | ||
| return fetchFunction[resolveAuthorizationHeaderSymbol]?.(urlOrRequest, { | ||
| ...options, | ||
| headers: mergedHeaders, | ||
| }); | ||
| }; | ||
| fetchWithHeaders[resolveRequestHeadersSymbol] = function (urlOrRequest, options = {}) { | ||
| const mergedHeaders = getMergedHeaders(urlOrRequest, options).merged; | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, { | ||
| ...options, | ||
| headers: mergedHeaders, | ||
| }) ?? mergedHeaders; | ||
| }; | ||
| return copyFetchMetadata(fetchWithHeaders, fetchFunction); | ||
| } |
| type HookRequestInit<Arguments extends unknown[]> = Arguments extends [unknown] | ||
| ? RequestInit | ||
| : Arguments extends [unknown, ...infer Rest] | ||
| ? Rest extends [infer RequestInit_] | ||
| ? Exclude<RequestInit_, undefined> | ||
| : Rest extends [(infer RequestInit_)?] | ||
| ? Exclude<RequestInit_, undefined> | ||
| : RequestInit | ||
| : RequestInit; | ||
| type HookResponse<FetchFunction extends typeof fetch> = Awaited<ReturnType<FetchFunction>>; | ||
| /** | ||
| Wraps a fetch function with hooks that run before each request and after each response. | ||
| This is the recommended way to add custom logic (logging, metrics, dynamic headers, response transformation) in the documented pipeline position after request-building wrappers, `withRetry()`, and `withTokenRefresh()`, but before `withHttpError()`. When combined with `withTokenRefresh()`, hooks observe the public call and the final response returned to the caller. The internal refresh retry is not re-hooked. | ||
| Can be combined with other `with*` functions: | ||
| ``` | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| f => withRetry(f, {retries: 2}), | ||
| f => withTokenRefresh(f, { | ||
| async refreshToken() { | ||
| return 'new-token'; | ||
| }, | ||
| }), | ||
| f => withHooks(f, { | ||
| beforeRequest({url, options}) { | ||
| console.log('→', options.method ?? 'GET', url); | ||
| }, | ||
| afterResponse({url, response}) { | ||
| console.log('←', response.status, url); | ||
| }, | ||
| }), | ||
| withHttpError, | ||
| ); | ||
| ``` | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Hook options. | ||
| @returns A wrapped fetch function with hooks. | ||
| @example | ||
| ``` | ||
| import {withHooks} from 'fetch-extras'; | ||
| const fetchWithLogging = withHooks(fetch, { | ||
| beforeRequest({url, options}) { | ||
| console.log('→', options.method ?? 'GET', url); | ||
| }, | ||
| afterResponse({url, response}) { | ||
| console.log('←', response.status, url); | ||
| }, | ||
| }); | ||
| const response = await fetchWithLogging('https://api.example.com/users'); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {withHooks} from 'fetch-extras'; | ||
| // Add a dynamic request ID header to every request | ||
| const fetchWithRequestId = withHooks(fetch, { | ||
| beforeRequest({options}) { | ||
| return { | ||
| ...options, | ||
| headers: { | ||
| ...options.headers, | ||
| 'X-Request-ID': crypto.randomUUID(), | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
| ``` | ||
| */ | ||
| export function withHooks<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction, | ||
| options?: { | ||
| /** | ||
| Called before each request. Receives the resolved URL and the request options. | ||
| Return a replacement `RequestInit` to modify the request options, return a `Response` to short-circuit the request entirely (skipping the fetch call and `afterResponse`), or return `undefined` to leave them unchanged. | ||
| */ | ||
| readonly beforeRequest?: (context: { | ||
| readonly url: string; | ||
| readonly options: HookRequestInit<Parameters<FetchFunction>>; | ||
| }) => HookRequestInit<Parameters<FetchFunction>> | HookResponse<FetchFunction> | void | Promise<HookRequestInit<Parameters<FetchFunction>> | HookResponse<FetchFunction> | void>; | ||
| /** | ||
| Called after each response. Receives the response, resolved URL, and the request options. | ||
| Return a replacement `Response` to modify the response, or return `undefined` to leave it unchanged. | ||
| */ | ||
| readonly afterResponse?: (context: { | ||
| readonly url: string; | ||
| readonly options: HookRequestInit<Parameters<FetchFunction>>; | ||
| readonly response: HookResponse<FetchFunction>; | ||
| }) => HookResponse<FetchFunction> | void | Promise<HookResponse<FetchFunction> | void>; | ||
| }, | ||
| ): (...arguments_: Parameters<FetchFunction>) => ReturnType<FetchFunction>; |
| import { | ||
| copyFetchMetadata, | ||
| getFetchSignal, | ||
| getRequestOptions, | ||
| getRequestSignal, | ||
| resolveRequestBody, | ||
| resolveRequestBodySymbol, | ||
| resolveRequestHeaders, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| } from './utilities.js'; | ||
| const inheritedHookBodies = new WeakSet(); | ||
| /* | ||
| Design note: `withHooks` is one function rather than separate `withBeforeRequest`/`withAfterResponse` functions (even though the split would make call-site syntax simpler) because the shared call frame lets `afterResponse` naturally see the options as modified by `beforeRequest`. Two separate wrappers could share this via a WeakMap on the Response, but that trades one coupling for a worse one: hidden global state. | ||
| */ | ||
| /** | ||
| Wraps a fetch function with hooks that run before each request and after each response. | ||
| This is the recommended way to add custom logic (logging, metrics, dynamic headers, response transformation) in the documented pipeline position after request-building wrappers, `withRetry()`, and `withTokenRefresh()`, but before `withHttpError()`. When combined with `withTokenRefresh()`, hooks observe the public call and the final response returned to the caller. The internal refresh retry is not re-hooked. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {object} [options] | ||
| @param {(context: {url: string, options: RequestInit}) => RequestInit | Response | void | Promise<RequestInit | Response | void>} [options.beforeRequest] - Called before each request. Receives the resolved URL and the request options. Return a replacement `RequestInit` to modify the request options, return a `Response` to short-circuit the request entirely (skipping the fetch call and `afterResponse`), or return `undefined` to leave them unchanged. | ||
| @param {(context: {url: string, options: RequestInit, response: Response}) => Response | void | Promise<Response | void>} [options.afterResponse] - Called after each response. Receives the response, resolved URL, and the request options. Return a replacement `Response` to modify the response, or return `undefined` to leave it unchanged. | ||
| @returns {typeof fetch} A wrapped fetch function with hooks. | ||
| */ | ||
| export function withHooks(fetchFunction, {beforeRequest, afterResponse} = {}) { | ||
| const setInheritedHookBody = (options, body) => { | ||
| // Hooks should be able to inspect an inherited Request body without turning it into an explicit override when the same object is returned unchanged. | ||
| inheritedHookBodies.add(options); | ||
| Object.setPrototypeOf(options, { | ||
| body, | ||
| }); | ||
| }; | ||
| const getFetchOptions = (urlOrRequest, options, signal) => { | ||
| // Strip the prototype-only inherited body marker before calling the inner fetch so no-op hooks do not change downstream wrapper semantics. | ||
| const fetchOptions = inheritedHookBodies.has(options) ? {...options} : options; | ||
| const requestSignal = getRequestSignal(urlOrRequest, fetchOptions); | ||
| return requestSignal === signal | ||
| ? fetchOptions | ||
| : {...fetchOptions, signal}; | ||
| }; | ||
| const shouldClearInheritedBody = (request, hookOptions, options) => { | ||
| if (!(request instanceof Request) || hookOptions.body === undefined || Object.hasOwn(options, 'body')) { | ||
| return false; | ||
| } | ||
| return ['GET', 'HEAD'].includes(options.method?.toUpperCase()); | ||
| }; | ||
| const waitForHook = async (callback, signal) => { | ||
| signal?.throwIfAborted(); | ||
| if (!signal) { | ||
| return callback(); | ||
| } | ||
| let abort; | ||
| const abortPromise = new Promise((_resolve, reject) => { | ||
| abort = () => { | ||
| reject(signal.reason); | ||
| }; | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| }); | ||
| try { | ||
| return await Promise.race([ | ||
| callback(), | ||
| abortPromise, | ||
| ]); | ||
| } finally { | ||
| signal.removeEventListener('abort', abort); | ||
| } | ||
| }; | ||
| const fetchWithHooks = async (urlOrRequest, options = {}) => { | ||
| const getLifecycleSignal = options_ => getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options_)); | ||
| const getHookOptions = (options_ = {}) => { | ||
| const lifecycleSignal = getLifecycleSignal(options_); | ||
| const effectiveOptions = getRequestOptions(urlOrRequest, options_); | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestHeadersSymbol] !== undefined) { | ||
| effectiveOptions.headers = Object.fromEntries(resolveRequestHeaders(fetchFunction, urlOrRequest, options_)); | ||
| } | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestBodySymbol] !== undefined) { | ||
| const resolvedBody = resolveRequestBody(fetchFunction, urlOrRequest, options_); | ||
| if (resolvedBody !== undefined) { | ||
| effectiveOptions.body = resolvedBody; | ||
| } else if (urlOrRequest instanceof Request && urlOrRequest.body !== null) { | ||
| setInheritedHookBody(effectiveOptions, urlOrRequest.body); | ||
| } | ||
| } | ||
| if (lifecycleSignal !== undefined) { | ||
| effectiveOptions.signal = lifecycleSignal; | ||
| } | ||
| return effectiveOptions; | ||
| }; | ||
| const url = resolveRequestUrl(fetchFunction, urlOrRequest); | ||
| let hookOptions = getHookOptions(options); | ||
| if (beforeRequest) { | ||
| let result = await waitForHook(() => beforeRequest({url, options: hookOptions}), hookOptions.signal); | ||
| if (result instanceof Response) { | ||
| return result; | ||
| } | ||
| if (result !== undefined) { | ||
| if (shouldClearInheritedBody(urlOrRequest, hookOptions, result)) { | ||
| result = {...result, body: undefined}; | ||
| } | ||
| options = result; | ||
| hookOptions = getHookOptions(options); | ||
| } | ||
| } | ||
| const finalFetchOptions = getFetchOptions(urlOrRequest, options, hookOptions.signal); | ||
| const fetchInput = urlOrRequest instanceof Request && Object.hasOwn(finalFetchOptions, 'body') && finalFetchOptions.body === undefined | ||
| ? url | ||
| : urlOrRequest; | ||
| const response = await fetchFunction(fetchInput, finalFetchOptions); | ||
| if (afterResponse) { | ||
| const modifiedResponse = await waitForHook(() => afterResponse({url, options: hookOptions, response}), hookOptions.signal); | ||
| if (modifiedResponse !== undefined) { | ||
| return modifiedResponse; | ||
| } | ||
| } | ||
| return response; | ||
| }; | ||
| return copyFetchMetadata(fetchWithHooks, fetchFunction); | ||
| } |
| /** | ||
| `RequestInit` with `body` extended to also accept plain objects and arrays that will be auto-serialized as JSON. | ||
| */ | ||
| export type JsonBodyRequestInit = Omit<RequestInit, 'body'> & { | ||
| readonly body?: RequestInit['body'] | Record<string, unknown> | readonly unknown[]; | ||
| }; | ||
| type JsonBodyWrappedDefinedRequestInit<RequestInit> = RequestInit extends {readonly body?: never} | ||
| ? RequestInit | ||
| : RequestInit extends {body?: infer Body} | ||
| ? {[Key in keyof RequestInit]: Key extends 'body' ? Body | JsonBodyRequestInit['body'] : RequestInit[Key]} | ||
| : RequestInit; | ||
| type JsonBodyWrappedRequestInit<RequestInit> = undefined extends RequestInit | ||
| ? JsonBodyWrappedDefinedRequestInit<Exclude<RequestInit, undefined>> | undefined | ||
| : JsonBodyWrappedDefinedRequestInit<RequestInit>; | ||
| type JsonBodyWrappedArguments<Arguments extends unknown[]> = Arguments extends [infer Input] | ||
| ? [input: Input] | ||
| : Arguments extends [infer Input, ...infer Rest] | ||
| ? Rest extends [infer RequestInit_] | ||
| ? [input: Input, init: JsonBodyWrappedRequestInit<RequestInit_>] | ||
| : Rest extends [(infer RequestInit_)?] | ||
| ? [input: Input, init?: JsonBodyWrappedRequestInit<RequestInit_>] | ||
| : Arguments | ||
| : Arguments; | ||
| type JsonBodyWrappedFetch<FetchFunction extends typeof fetch> = (...arguments_: JsonBodyWrappedArguments<Parameters<FetchFunction>>) => ReturnType<FetchFunction>; | ||
| /** | ||
| Returns a wrapped fetch function that automatically stringifies plain-object and array bodies as JSON and sets the `Content-Type: application/json` header. | ||
| Only plain objects (`{}`) and arrays are auto-serialized. Other body types like strings, `FormData`, `Blob`, and `ReadableStream` are passed through unchanged. | ||
| If you need a custom `Content-Type` (e.g., `application/vnd.api+json`), set it explicitly in the request headers and it will not be overridden. | ||
| When replacing the body of an existing `Request`, the original request `Content-Type` is not preserved. Set the replacement `Content-Type` in `init.headers` or with `withHeaders()` if you need one. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that auto-serializes JSON bodies. | ||
| @example | ||
| ``` | ||
| import {withJsonBody} from 'fetch-extras'; | ||
| const fetchWithJson = withJsonBody(fetch); | ||
| const response = await fetchWithJson('/api/users', { | ||
| method: 'POST', | ||
| body: {name: 'Alice', age: 30}, | ||
| }); | ||
| // Sends JSON.stringify({name: 'Alice', age: 30}) with Content-Type: application/json | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withJsonBody, withBaseUrl, withHttpError} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withJsonBody, | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users', { | ||
| method: 'POST', | ||
| body: {name: 'Alice'}, | ||
| }); | ||
| ``` | ||
| */ | ||
| export function withJsonBody<FetchFunction extends (input: RequestInfo | URL, ...arguments_: any[]) => Promise<Response>>( | ||
| fetchFunction: FetchFunction | ||
| ): JsonBodyWrappedFetch<FetchFunction>; |
| import { | ||
| blockedDefaultHeaderNamesSymbol, | ||
| blockedRequestBodyHeaderNames, | ||
| copyFetchMetadata, | ||
| deleteHeaders, | ||
| inheritedRequestBodyHeaderNamesSymbol, | ||
| requestSnapshot, | ||
| resolveRequestBodySymbol, | ||
| resolveRequestHeadersSymbol, | ||
| setHeaders, | ||
| } from './utilities.js'; | ||
| function isPlainObject(value) { | ||
| if (typeof value !== 'object' || value === null) { | ||
| return false; | ||
| } | ||
| const prototype = Object.getPrototypeOf(value); | ||
| return prototype === Object.prototype || prototype === null; | ||
| } | ||
| function getRequestForHeaders(urlOrRequest) { | ||
| if (!(urlOrRequest instanceof Request)) { | ||
| return urlOrRequest; | ||
| } | ||
| const request = new Request(urlOrRequest.url, { | ||
| ...requestSnapshot(urlOrRequest), | ||
| headers: deleteHeaders(new Headers(urlOrRequest.headers), blockedRequestBodyHeaderNames), | ||
| }); | ||
| request[blockedDefaultHeaderNamesSymbol] = urlOrRequest[blockedDefaultHeaderNamesSymbol]; | ||
| return request; | ||
| } | ||
| const blockedJsonDefaultHeaderNames = blockedRequestBodyHeaderNames.filter(headerName => headerName !== 'content-type'); | ||
| function getJsonHeaders(fetchFunction, urlOrRequest, options) { | ||
| const inheritedHeaderNames = options[inheritedRequestBodyHeaderNamesSymbol] ?? []; | ||
| const requestForHeaders = getRequestForHeaders(urlOrRequest); | ||
| const headers = new Headers(fetchFunction[resolveRequestHeadersSymbol]?.(requestForHeaders, options) ?? (requestForHeaders instanceof Request ? requestForHeaders.headers : undefined)); | ||
| const callHeaders = new Headers(options.headers); | ||
| const contentType = callHeaders.get('content-type') | ||
| ?? headers.get('content-type'); | ||
| deleteHeaders(headers, blockedRequestBodyHeaderNames); | ||
| deleteHeaders(callHeaders, blockedRequestBodyHeaderNames); | ||
| deleteHeaders(callHeaders, inheritedHeaderNames); | ||
| setHeaders(headers, callHeaders); | ||
| if (contentType) { | ||
| headers.set('content-type', contentType); | ||
| } else { | ||
| headers.set('content-type', 'application/json'); | ||
| } | ||
| return headers; | ||
| } | ||
| function getBlockedJsonDefaultHeaderNames(options) { | ||
| return [ | ||
| ...(options[blockedDefaultHeaderNamesSymbol] ?? []), | ||
| ...blockedJsonDefaultHeaderNames, | ||
| ]; | ||
| } | ||
| function getJsonBody(options) { | ||
| return JSON.stringify(options.body); | ||
| } | ||
| function getJsonRequestOptions(fetchFunction, urlOrRequest, options) { | ||
| return { | ||
| ...options, | ||
| [blockedDefaultHeaderNamesSymbol]: getBlockedJsonDefaultHeaderNames(options), | ||
| body: getJsonBody(options), | ||
| headers: getJsonHeaders(fetchFunction, urlOrRequest, options), | ||
| }; | ||
| } | ||
| function shouldJsonifyBody(body) { | ||
| return isPlainObject(body) || Array.isArray(body); | ||
| } | ||
| /** | ||
| Returns a wrapped fetch function that automatically stringifies plain-object and array bodies as JSON and sets the `Content-Type: application/json` header. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns {typeof fetch} A wrapped fetch function that auto-serializes JSON bodies. | ||
| */ | ||
| export function withJsonBody(fetchFunction) { | ||
| const fetchWithJsonBody = async (urlOrRequest, options = {}) => { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| options = getJsonRequestOptions(fetchFunction, urlOrRequest, options); | ||
| } | ||
| return fetchFunction(urlOrRequest, options); | ||
| }; | ||
| fetchWithJsonBody[resolveRequestHeadersSymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonHeaders(fetchFunction, urlOrRequest, options); | ||
| } | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, options); | ||
| }; | ||
| fetchWithJsonBody[resolveRequestBodySymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonBody(options); | ||
| } | ||
| return fetchFunction[resolveRequestBodySymbol]?.(urlOrRequest, options) ?? options.body; | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonBody, fetchFunction); | ||
| } |
| /** | ||
| Wraps a fetch function with client-side rate limiting. Requests that would exceed the limit are queued and delayed until a slot becomes available in the sliding window. | ||
| The rate limit state is shared across all calls to the returned function. To rate-limit different hosts independently, create separate `withRateLimit` wrappers. | ||
| Abort signals are respected while waiting: if the signal fires before a slot opens, the call rejects with the signal's abort reason and does not consume a rate limit slot. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Rate limit configuration. | ||
| @returns A wrapped fetch function that enforces the rate limit. | ||
| @example | ||
| ``` | ||
| import {withRateLimit} from 'fetch-extras'; | ||
| // Allow at most 10 requests per second | ||
| const rateLimitedFetch = withRateLimit(fetch, { | ||
| requestsPerInterval: 10, | ||
| interval: 1000, | ||
| }); | ||
| const response = await rateLimitedFetch('/api/data'); | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withRateLimit, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withRateLimit(f, {requestsPerInterval: 10, interval: 1000}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withRateLimit( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
| /** | ||
| Maximum number of requests allowed within the interval. | ||
| */ | ||
| requestsPerInterval: number; | ||
| /** | ||
| The sliding window duration in milliseconds. | ||
| */ | ||
| interval: number; | ||
| }, | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| defersConcurrencySlotSymbol, | ||
| defersFetchStartSymbol, | ||
| enqueueAbortable, | ||
| getFetchSignal, | ||
| getRequestSignal, | ||
| notifyFetchStart, | ||
| waitForConcurrencySlotSymbol, | ||
| } from './utilities.js'; | ||
| export function withRateLimit(fetchFunction, {requestsPerInterval, interval}) { | ||
| if (!Number.isInteger(requestsPerInterval) || requestsPerInterval < 1) { | ||
| throw new TypeError('`requestsPerInterval` must be a positive integer.'); | ||
| } | ||
| if (typeof interval !== 'number' || interval <= 0 || !Number.isFinite(interval)) { | ||
| throw new TypeError('`interval` must be a positive finite number.'); | ||
| } | ||
| const reservations = []; | ||
| const queue = []; | ||
| let nextSlotTimeout; | ||
| const now = () => performance.now(); | ||
| const prune = currentTime => { | ||
| const cutoff = currentTime - interval; | ||
| for (let index = reservations.length - 1; index >= 0; index--) { | ||
| const reservation = reservations[index]; | ||
| if (reservation.timestamp !== undefined && reservation.timestamp <= cutoff) { | ||
| reservations.splice(index, 1); | ||
| } | ||
| } | ||
| }; | ||
| const clearNextSlotTimeout = () => { | ||
| if (nextSlotTimeout === undefined) { | ||
| return; | ||
| } | ||
| clearTimeout(nextSlotTimeout); | ||
| nextSlotTimeout = undefined; | ||
| }; | ||
| const schedule = ({force = false} = {}) => { | ||
| if (nextSlotTimeout !== undefined && !force) { | ||
| return; | ||
| } | ||
| clearNextSlotTimeout(); | ||
| const currentTime = now(); | ||
| prune(currentTime); | ||
| while (queue.length > 0) { | ||
| const entry = queue[0]; | ||
| if (entry.signal?.aborted) { | ||
| queue.shift(); | ||
| entry.reject(entry.signal.reason); | ||
| continue; | ||
| } | ||
| if (reservations.length >= requestsPerInterval) { | ||
| const oldestStartedReservation = reservations.find(reservation => reservation.timestamp !== undefined); | ||
| if (!oldestStartedReservation) { | ||
| return; | ||
| } | ||
| const waitTime = Math.max(oldestStartedReservation.timestamp + interval - currentTime, 0); | ||
| nextSlotTimeout = setTimeout(() => { | ||
| nextSlotTimeout = undefined; | ||
| schedule(); | ||
| }, waitTime); | ||
| return; | ||
| } | ||
| const reservation = { | ||
| timestamp: undefined, | ||
| }; | ||
| reservations.push(reservation); | ||
| queue.shift(); | ||
| entry.resolve(reservation); | ||
| } | ||
| }; | ||
| const releaseReservation = reservation => { | ||
| const index = reservations.indexOf(reservation); | ||
| if (index === -1) { | ||
| return; | ||
| } | ||
| reservations.splice(index, 1); | ||
| schedule({force: true}); | ||
| }; | ||
| const fetchWithRateLimit = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| signal?.throwIfAborted(); | ||
| const reservation = await enqueueAbortable(queue, { | ||
| signal, | ||
| onAbort() { | ||
| schedule({force: true}); | ||
| }, | ||
| onEnqueue() { | ||
| schedule(); | ||
| }, | ||
| }); | ||
| const resolvedOptions = signal ? {...options, signal} : options; | ||
| try { | ||
| await resolvedOptions[waitForConcurrencySlotSymbol]?.(); | ||
| signal?.throwIfAborted(); | ||
| } catch (error) { | ||
| releaseReservation(reservation); | ||
| throw error; | ||
| } | ||
| reservation.timestamp = now(); | ||
| schedule({force: true}); | ||
| notifyFetchStart(fetchFunction, resolvedOptions); | ||
| return fetchFunction(urlOrRequest, resolvedOptions); | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithRateLimit, fetchFunction); | ||
| wrappedFetch[defersConcurrencySlotSymbol] = true; | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
| /** | ||
| Wraps a fetch function to automatically retry failed requests. | ||
| Retries on [network errors](https://github.com/sindresorhus/is-network-error) and configurable HTTP status codes. Only retries idempotent methods by default (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). Uses exponential backoff with jitter by default. Respects the `Retry-After` response header when present. | ||
| POST and PATCH are not retried by default because they are not idempotent. Add them to `methods` if your endpoints are safe to retry. | ||
| When all retries are exhausted, the last response is returned (for HTTP status retries) or the last error is thrown (for network errors). | ||
| Place `withRetry` before `withHttpError` in a pipeline so it sees raw responses and can check status codes. | ||
| Do not consume the `response` body inside `shouldRetry`. If you need to inspect the body, clone the response first. | ||
| Requests with a one-shot body provided via `options.body`, such as a `ReadableStream` or AsyncIterable, are sent as-is and are not retried. Retrying those uploads would require buffering and would change wrapper composition semantics such as upload progress. Requests whose body comes from a bare `Request` object (no `options.body` override) are also not retried. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Retry configuration. | ||
| @returns A wrapped fetch function with automatic retry. | ||
| @example | ||
| ``` | ||
| import {withRetry} from 'fetch-extras'; | ||
| const fetchWithRetry = withRetry(fetch, {retries: 3}); | ||
| const response = await fetchWithRetry('https://api.example.com/data'); | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withRetry, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withRetry(f, {retries: 2}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withRetry( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
| /** | ||
| Number of retries after the initial attempt. `retries: 2` means up to 3 total attempts. | ||
| @default 2 | ||
| */ | ||
| readonly retries?: number; | ||
| /** | ||
| HTTP methods to retry. Non-matching methods pass through without retry. | ||
| @default ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'] | ||
| */ | ||
| readonly methods?: string[]; | ||
| /** | ||
| HTTP status codes that trigger a retry. | ||
| @default [408, 429, 500, 502, 503, 504] | ||
| */ | ||
| readonly statusCodes?: number[]; | ||
| /** | ||
| Maximum `Retry-After` duration in milliseconds. If the server requests a longer delay, the response is returned without retrying. | ||
| @default 60_000 | ||
| */ | ||
| readonly maxRetryAfter?: number; | ||
| /** | ||
| Function returning the delay in milliseconds before a retry. Receives the 1-based attempt number that just failed. | ||
| @default Exponential backoff with jitter: `min(1000 * 2^(attempt-1), 30000) * random(0.5, 1.0)` | ||
| */ | ||
| readonly backoff?: (attemptNumber: number) => number; | ||
| /** | ||
| Called after built-in checks pass, before retrying. Return `false` to stop retrying. | ||
| For network errors, `error` is set. For HTTP status retries, `response` is set. | ||
| Do not consume the `response` body. If you need to inspect the body, clone the response first. | ||
| */ | ||
| readonly shouldRetry?: (context: { | ||
| readonly error?: Error; | ||
| readonly response?: Response; | ||
| readonly attemptNumber: number; | ||
| readonly retriesLeft: number; | ||
| }) => boolean | Promise<boolean>; | ||
| }, | ||
| ): typeof fetch; |
| import isNetworkError from 'is-network-error'; | ||
| import { | ||
| copyFetchMetadata, | ||
| delay, | ||
| discardBody, | ||
| getFetchSignal, | ||
| getRequestSignal, | ||
| hasHeaders, | ||
| requestSnapshot, | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeaders, | ||
| resolveRequestUrl, | ||
| } from './utilities.js'; | ||
| const defaultRetriableMethods = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE']); | ||
| const defaultRetriableStatusCodes = new Set([408, 429, 500, 502, 503, 504]); | ||
| const defaultBackoff = attemptNumber => | ||
| Math.min(1000 * (2 ** (attemptNumber - 1)), 30_000) * (0.5 + (Math.random() * 0.5)); | ||
| function parseRetryAfter(response) { | ||
| const header = response.headers.get('retry-after'); | ||
| if (!header) { | ||
| return; | ||
| } | ||
| const seconds = Number(header); | ||
| if (Number.isFinite(seconds)) { | ||
| return seconds * 1000; | ||
| } | ||
| const date = Date.parse(header); | ||
| if (!Number.isNaN(date)) { | ||
| return date - Date.now(); | ||
| } | ||
| } | ||
| /** | ||
| Wraps a fetch function to automatically retry failed requests. | ||
| Retries on [network errors](https://github.com/sindresorhus/is-network-error) and configurable HTTP status codes. Only retries idempotent methods by default (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). Uses exponential backoff with jitter by default. Respects the `Retry-After` response header when present. | ||
| When all retries are exhausted, the last response is returned (for HTTP status retries) or the last error is thrown (for network errors). | ||
| Requests with a one-shot body provided via `options.body`, such as a `ReadableStream` or AsyncIterable, are sent as-is and are not retried. Retrying those uploads would require buffering and would change wrapper composition semantics such as upload progress. Requests whose body comes from a bare `Request` object (no `options.body` override) are also not retried. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {object} [options] | ||
| @param {number} [options.retries=2] - Number of retries after the initial attempt. `retries: 2` means up to 3 total attempts. | ||
| @param {string[]} [options.methods=['GET','HEAD','PUT','DELETE','OPTIONS','TRACE']] - HTTP methods to retry. | ||
| @param {number[]} [options.statusCodes=[408,429,500,502,503,504]] - HTTP status codes that trigger a retry. | ||
| @param {number} [options.maxRetryAfter=60000] - Maximum `Retry-After` duration in milliseconds. If the server requests a longer delay, the response is returned without retrying. | ||
| @param {(attemptNumber: number) => number} [options.backoff] - Function returning the delay in milliseconds before a retry. Receives the 1-based attempt number that just failed. | ||
| @param {(context: {error?: Error, response?: Response, attemptNumber: number, retriesLeft: number}) => boolean | Promise<boolean>} [options.shouldRetry] - Called after built-in checks pass, before retrying. Return `false` to stop retrying. | ||
| @returns {typeof fetch} A wrapped fetch function with automatic retry. | ||
| */ | ||
| export function withRetry(fetchFunction, options = {}) { | ||
| const { | ||
| retries = 2, | ||
| methods = [...defaultRetriableMethods], | ||
| statusCodes = [...defaultRetriableStatusCodes], | ||
| maxRetryAfter = 60_000, | ||
| backoff = defaultBackoff, | ||
| shouldRetry = () => true, | ||
| } = options; | ||
| if (!Number.isInteger(retries) || retries < 0) { | ||
| throw new TypeError('`retries` must be a non-negative integer.'); | ||
| } | ||
| const retriableMethods = new Set(methods.map(method => method.toUpperCase())); | ||
| const retriableStatusCodes = new Set(statusCodes); | ||
| function isNonReplayableBody(body) { | ||
| return body instanceof ReadableStream | ||
| || typeof body?.[Symbol.asyncIterator] === 'function'; | ||
| } | ||
| function createRetryInput({request, fetchOptions, retryBaseOptions, urlOrRequest}) { | ||
| if (!(request && fetchOptions.body !== undefined)) { | ||
| return urlOrRequest; | ||
| } | ||
| return new Request(resolveRequestUrl(fetchFunction, request), { | ||
| ...requestSnapshot(request), | ||
| headers: new Headers(retryBaseOptions.headers), | ||
| }); | ||
| } | ||
| const getRetryDelay = (response, attemptNumber) => { | ||
| const retryAfter = parseRetryAfter(response); | ||
| if (retryAfter !== undefined && retryAfter >= 0) { | ||
| if (retryAfter > maxRetryAfter) { | ||
| return; // Signal: don't retry | ||
| } | ||
| return retryAfter; | ||
| } | ||
| return backoff(attemptNumber); | ||
| }; | ||
| const fetchWithRetry = async (urlOrRequest, fetchOptions = {}) => { | ||
| const request = urlOrRequest instanceof Request ? urlOrRequest : undefined; | ||
| const method = (fetchOptions.method ?? request?.method ?? 'GET').toUpperCase(); | ||
| if (!retriableMethods.has(method)) { | ||
| return fetchFunction(urlOrRequest, fetchOptions); | ||
| } | ||
| const bodyResolvedFetchOptions = resolveRequestBodyOptions(fetchFunction, urlOrRequest, fetchOptions); | ||
| const canRetryBody = !(request?.body && fetchOptions.body === undefined) && !isNonReplayableBody(bodyResolvedFetchOptions.body); | ||
| const maximumAttempts = canRetryBody ? retries : 0; | ||
| const hasResolvedBody = bodyResolvedFetchOptions.body !== undefined; | ||
| const requestHeaders = hasResolvedBody | ||
| ? resolveRequestHeaders(fetchFunction, urlOrRequest, fetchOptions) | ||
| : undefined; | ||
| const resolvedHeaders = requestHeaders && (request || fetchOptions.headers !== undefined || hasHeaders(requestHeaders)) | ||
| ? requestHeaders | ||
| : undefined; | ||
| const attemptSignal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, fetchOptions)); | ||
| const currentOptionsBase = resolvedHeaders | ||
| ? {...bodyResolvedFetchOptions, headers: resolvedHeaders} | ||
| : bodyResolvedFetchOptions; | ||
| const currentOptions = attemptSignal | ||
| ? {...currentOptionsBase, signal: attemptSignal} | ||
| : currentOptionsBase; | ||
| const retryBaseOptions = resolvedHeaders | ||
| ? currentOptionsBase | ||
| : currentOptions; | ||
| const retryRequestInput = createRetryInput({ | ||
| request, | ||
| fetchOptions, | ||
| retryBaseOptions, | ||
| urlOrRequest, | ||
| }); | ||
| let retryOptions = retryBaseOptions; | ||
| if (attemptSignal) { | ||
| retryOptions = retryBaseOptions === fetchOptions | ||
| ? currentOptions | ||
| : {...retryBaseOptions, signal: attemptSignal}; | ||
| } | ||
| /* eslint-disable no-await-in-loop */ | ||
| for (let attempt = 0; attempt <= maximumAttempts; attempt++) { | ||
| const attemptOptions = attempt === 0 ? currentOptions : retryOptions; | ||
| const isLastAttempt = attempt >= maximumAttempts; | ||
| let response; | ||
| try { | ||
| response = await fetchFunction( | ||
| attempt === 0 ? urlOrRequest : retryRequestInput, | ||
| attemptOptions, | ||
| ); | ||
| } catch (error) { | ||
| if (isLastAttempt || !isNetworkError(error)) { | ||
| throw error; | ||
| } | ||
| if (!await shouldRetry({error, attemptNumber: attempt + 1, retriesLeft: retries - attempt})) { | ||
| throw error; | ||
| } | ||
| await delay(backoff(attempt + 1), {signal: attemptSignal}); | ||
| continue; | ||
| } | ||
| if (!retriableStatusCodes.has(response.status) || isLastAttempt) { | ||
| return response; | ||
| } | ||
| const retryDelay = getRetryDelay(response, attempt + 1); | ||
| if (retryDelay === undefined) { | ||
| return response; | ||
| } | ||
| let shouldRetryResponse; | ||
| try { | ||
| shouldRetryResponse = await shouldRetry({response, attemptNumber: attempt + 1, retriesLeft: retries - attempt}); | ||
| } catch (error) { | ||
| await discardBody(response.body); | ||
| throw error; | ||
| } | ||
| if (!shouldRetryResponse) { | ||
| return response; | ||
| } | ||
| await discardBody(response.body); | ||
| await delay(retryDelay, {signal: attemptSignal}); | ||
| } | ||
| /* eslint-enable no-await-in-loop */ | ||
| }; | ||
| return copyFetchMetadata(fetchWithRetry, fetchFunction); | ||
| } |
| /** | ||
| Returns a wrapped fetch function that includes default search parameters on every request. Per-call parameters in the URL take priority over the defaults, so you can always override a default on a specific request. | ||
| String URLs and `URL` objects are modified. `Request` objects are passed through unchanged. | ||
| Place `withSearchParameters` after `withBaseUrl` in a pipeline so the parameters are appended to the resolved absolute URL. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param defaultSearchParameters - Default search parameters to include on every request. Accepts a plain object, a `URLSearchParams` instance, or an array of `[name, value]` tuples. | ||
| @returns A wrapped fetch function that merges the default search parameters into every request URL. | ||
| @example | ||
| ``` | ||
| import {withSearchParameters} from 'fetch-extras'; | ||
| const fetchWithParameters = withSearchParameters(fetch, {apiKey: 'my-key', format: 'json'}); | ||
| const response = await fetchWithParameters('/users'); | ||
| // Requests /users?apiKey=my-key&format=json | ||
| const data = await response.json(); | ||
| // Per-call parameters override defaults | ||
| const response2 = await fetchWithParameters('/users?format=xml'); | ||
| // Requests /users?apiKey=my-key&format=xml | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withSearchParameters, withBaseUrl, withHttpError} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withSearchParameters(f, {apiKey: 'my-key'}), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withSearchParameters( | ||
| fetchFunction: typeof fetch, | ||
| defaultSearchParameters: Record<string, string> | URLSearchParams | ReadonlyArray<readonly [string, string]> | ||
| ): typeof fetch; |
| import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to include default search parameters on every request. Per-call parameters in the URL take priority over the defaults. String URLs and `URL` objects are modified. `Request` objects are passed through unchanged. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {Record<string, string> | URLSearchParams | ReadonlyArray<readonly [string, string]>} defaultSearchParameters - Default search parameters to include on every request. | ||
| @returns {typeof fetch} A wrapped fetch function that merges the default search parameters into every request URL. | ||
| */ | ||
| export function withSearchParameters(fetchFunction, defaultSearchParameters) { | ||
| const defaults = new URLSearchParams(defaultSearchParameters); | ||
| const mergeSearchParameters = existingParameters => { | ||
| const merged = new URLSearchParams(defaults); | ||
| // Per-call URL parameters override defaults (like withHeaders) | ||
| for (const key of existingParameters.keys()) { | ||
| merged.delete(key); | ||
| } | ||
| for (const [key, value] of existingParameters) { | ||
| merged.append(key, value); | ||
| } | ||
| return merged; | ||
| }; | ||
| const applyToUrl = url => { | ||
| const merged = mergeSearchParameters(url.searchParams); | ||
| const newUrl = new URL(url); | ||
| newUrl.search = merged.toString(); | ||
| return newUrl; | ||
| }; | ||
| const resolveRequestUrlWithSearchParameters = urlOrRequest => { | ||
| if (urlOrRequest instanceof URL) { | ||
| return applyToUrl(urlOrRequest).href; | ||
| } | ||
| if (typeof urlOrRequest !== 'string') { | ||
| return urlOrRequest instanceof Request ? urlOrRequest.url : String(urlOrRequest); | ||
| } | ||
| const resolvedRequestUrl = fetchFunction[resolveRequestUrlSymbol]?.(urlOrRequest) ?? urlOrRequest; | ||
| const resolvedUrl = resolvedRequestUrl instanceof Request ? resolvedRequestUrl.url : String(resolvedRequestUrl); | ||
| if (/^[a-z][a-z\d+\-.]*:/i.test(resolvedUrl)) { | ||
| return applyToUrl(new URL(resolvedUrl)).href; | ||
| } | ||
| const hashIndex = resolvedUrl.indexOf('#'); | ||
| const fragment = hashIndex === -1 ? '' : resolvedUrl.slice(hashIndex); | ||
| const urlWithoutFragment = hashIndex === -1 ? resolvedUrl : resolvedUrl.slice(0, hashIndex); | ||
| const questionIndex = urlWithoutFragment.indexOf('?'); | ||
| const urlBase = questionIndex === -1 ? urlWithoutFragment : urlWithoutFragment.slice(0, questionIndex); | ||
| const existingSearch = questionIndex === -1 ? '' : urlWithoutFragment.slice(questionIndex + 1); | ||
| const merged = mergeSearchParameters(new URLSearchParams(existingSearch)); | ||
| const search = merged.toString(); | ||
| return urlBase + (search ? `?${search}` : '') + fragment; | ||
| }; | ||
| const fetchWithSearchParameters = async (urlOrRequest, options = {}) => { | ||
| if (urlOrRequest instanceof URL) { | ||
| return fetchFunction(applyToUrl(urlOrRequest), options); | ||
| } | ||
| return fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrlWithSearchParameters(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| }; | ||
| fetchWithSearchParameters[resolveRequestUrlSymbol] = resolveRequestUrlWithSearchParameters; | ||
| return copyFetchMetadata(fetchWithSearchParameters, fetchFunction); | ||
| } |
| /** | ||
| Wraps a fetch function with timeout functionality. | ||
| Can be combined with other `with*` functions. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param timeout - Timeout in milliseconds. | ||
| @returns A wrapped fetch function that will abort if the request takes longer than the specified timeout. | ||
| @example | ||
| ``` | ||
| import {withTimeout} from 'fetch-extras'; | ||
| const fetchWithTimeout = withTimeout(fetch, 5000); | ||
| const response = await fetchWithTimeout('/api'); | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withTimeout} from 'fetch-extras'; | ||
| const fetchWithAll = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| ); | ||
| const response = await fetchWithAll('/api'); | ||
| ``` | ||
| */ | ||
| export function withTimeout( | ||
| fetchFunction: typeof fetch, | ||
| timeout: number | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| getRequestSignal, | ||
| getTimeoutSignal, | ||
| timeoutDurationSymbol, | ||
| } from './utilities.js'; | ||
| export function withTimeout(fetchFunction, timeout) { | ||
| const fetchWithTimeout = async (urlOrRequest, options = {}) => { | ||
| const signal = getTimeoutSignal(timeout, getRequestSignal(urlOrRequest, options)); | ||
| return fetchFunction(urlOrRequest, {...options, signal}); | ||
| }; | ||
| fetchWithTimeout[timeoutDurationSymbol] = timeout; | ||
| return copyFetchMetadata(fetchWithTimeout, fetchFunction); | ||
| } |
| /** | ||
| Wraps a fetch function to automatically refresh the token and retry the request on a `401 Unauthorized` response. | ||
| On a 401 response, calls `refreshToken` to obtain a new token, then retries the request once with `Authorization: Bearer <token>`. If the refresh fails or the retry also returns a non-2xx status, the response is returned as-is. Abort signals are still respected and will reject the call. | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call to prevent token invalidation races. | ||
| > Important: Deduplication only applies while the refresh promise is still pending. Once it settles, it is forgotten immediately. A later `401` starts a new refresh on purpose instead of reusing a settled token result. | ||
| > Note: Retrying an `options.body` `ReadableStream` requires buffering it with `ReadableStream#tee()`. This keeps streamed uploads replayable for a single retry, but it also means the streamed body is buffered in memory while the retry is possible. Request bodies are not pre-buffered, so a `Request` with its own body is only retried when you provide a replacement `options.body`. | ||
| > Note: Wrappers outside `withTokenRefresh()` only observe the initial call, not the internal retry. For example, if you want upload progress for both the first send and the retry, compose `withUploadProgress()` inside `withTokenRefresh()`. | ||
| Can be combined with other `with*` functions. Should be composed inside `withHttpError` so it can see the raw 401 response: | ||
| ``` | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withTokenRefresh(f, {refreshToken: ...}), | ||
| withHttpError, | ||
| ); | ||
| ``` | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Token refresh options. | ||
| @returns A wrapped fetch function that retries once with a refreshed `Authorization: Bearer <token>` header on 401 responses. | ||
| @example | ||
| ``` | ||
| import {withTokenRefresh} from 'fetch-extras'; | ||
| const apiFetch = withTokenRefresh(fetch, { | ||
| refreshToken: async () => { | ||
| const response = await fetch('/auth/refresh', {method: 'POST'}); | ||
| const {accessToken} = await response.json(); | ||
| return accessToken; | ||
| }, | ||
| }); | ||
| const response = await apiFetch('/api/users'); | ||
| const data = await response.json(); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withTokenRefresh, withBaseUrl} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withTokenRefresh(f, { | ||
| refreshToken: async () => { | ||
| const response = await fetch('/auth/refresh', {method: 'POST'}); | ||
| const {accessToken} = await response.json(); | ||
| return accessToken; | ||
| }, | ||
| }), | ||
| withHttpError, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| ``` | ||
| */ | ||
| export function withTokenRefresh( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
| /** | ||
| Called when a 401 response is received. Should return the new token string. | ||
| */ | ||
| refreshToken: () => Promise<string>; | ||
| } | ||
| ): typeof fetch; |
| import { | ||
| copyFetchMetadata, | ||
| discardBody, | ||
| getFetchSignal, | ||
| getRequestSignal, | ||
| hasHeaders, | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeaders, | ||
| resolveAuthorizationHeaderSymbol, | ||
| } from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to automatically refresh the token and retry the request on a `401 Unauthorized` response. | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param {object} options | ||
| @param {() => Promise<string>} options.refreshToken - Called when a 401 response is received. Should return the new token string. | ||
| @returns {typeof fetch} A wrapped fetch function that retries once with a refreshed `Authorization: Bearer <token>` header on 401 responses. | ||
| */ | ||
| export function withTokenRefresh(fetchFunction, {refreshToken}) { | ||
| /* | ||
| Boundary: this wrapper only deduplicates overlapping refreshes. | ||
| It does not try to remember a "current" token or reuse a settled refresh result for late 401s. | ||
| If a request reaches the 401 path after the shared refresh has already settled, it starts a new refresh. | ||
| This smaller contract is intentional because it keeps the state model predictable and avoids stale-token reuse. | ||
| */ | ||
| const refreshEntries = new Map(); | ||
| const getAbortReason = signal => signal?.reason ?? new DOMException('This operation was aborted', 'AbortError'); | ||
| const withSignal = (options, signal) => signal ? {...options, signal} : options; | ||
| const isAsyncIterable = value => value !== undefined && !(value instanceof ReadableStream) && typeof value[Symbol.asyncIterator] === 'function'; | ||
| const discardHiddenBodies = async (response, retryBody) => { | ||
| await discardBody(response?.body); | ||
| await discardBody(retryBody); | ||
| }; | ||
| const returnResponse = async (response, retryBody) => { | ||
| await discardBody(retryBody); | ||
| return response; | ||
| }; | ||
| const clearRefreshEntry = (authorization, refreshEntry) => { | ||
| if (refreshEntries.get(authorization) === refreshEntry) { | ||
| refreshEntries.delete(authorization); | ||
| } | ||
| }; | ||
| const createRefreshEntry = authorization => { | ||
| const refreshEntry = { | ||
| waiterCount: 0, | ||
| }; | ||
| refreshEntry.promise = (async () => { | ||
| try { | ||
| return await refreshToken(); | ||
| } finally { | ||
| clearRefreshEntry(authorization, refreshEntry); | ||
| } | ||
| })(); | ||
| refreshEntries.set(authorization, refreshEntry); | ||
| return refreshEntry; | ||
| }; | ||
| const getToken = async (authorization, signal) => { | ||
| if (signal?.aborted) { | ||
| throw getAbortReason(signal); | ||
| } | ||
| let refreshEntry = refreshEntries.get(authorization); | ||
| if (!refreshEntry || refreshEntry.waiterCount === 0) { | ||
| /* | ||
| Algorithm: one pending refresh promise per effective Authorization value. | ||
| Requests that hit 401 while this promise is pending reuse it. | ||
| If every waiter gives up on a still-pending refresh, the next same-auth request starts a fresh attempt instead of inheriting an abandoned hung refresh. | ||
| */ | ||
| refreshEntry = createRefreshEntry(authorization); | ||
| } | ||
| refreshEntry.waiterCount++; | ||
| if (!signal) { | ||
| try { | ||
| return await refreshEntry.promise; | ||
| } finally { | ||
| refreshEntry.waiterCount--; | ||
| } | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| let didFinish = false; | ||
| const finish = () => { | ||
| if (didFinish) { | ||
| return; | ||
| } | ||
| didFinish = true; | ||
| refreshEntry.waiterCount--; | ||
| signal.removeEventListener('abort', abort); | ||
| }; | ||
| const abort = () => { | ||
| finish(); | ||
| // Preserve the caller's original abort reason so this still matches fetch cancellation semantics. | ||
| reject(getAbortReason(signal)); | ||
| }; | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| (async () => { | ||
| try { | ||
| resolve(await refreshEntry.promise); | ||
| } catch (error) { | ||
| reject(error); | ||
| } finally { | ||
| finish(); | ||
| } | ||
| })(); | ||
| }); | ||
| }; | ||
| const fetchWithTokenRefresh = async (urlOrRequest, options = {}) => { | ||
| const request = urlOrRequest instanceof Request | ||
| ? urlOrRequest | ||
| : undefined; | ||
| const bodyResolvedOptions = resolveRequestBodyOptions(fetchFunction, urlOrRequest, options); | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| const requestHeaders = resolveRequestHeaders(fetchFunction, urlOrRequest, options); | ||
| const hasInitialHeaders = request || options.headers !== undefined || hasHeaders(requestHeaders); | ||
| let initialOptions = hasInitialHeaders | ||
| ? {...bodyResolvedOptions, headers: requestHeaders} | ||
| : bodyResolvedOptions; | ||
| let retryBody = bodyResolvedOptions.body; | ||
| let hasRetryBody = false; | ||
| if (bodyResolvedOptions.body instanceof ReadableStream) { | ||
| /* | ||
| `options.body` is an explicit override, so replaying it once is worth the memory cost for the single retry path. | ||
| Boundary: outer wrappers only see the initial call into withTokenRefresh, not the internal retry. | ||
| If callers need per-attempt upload progress, withUploadProgress must be composed inside withTokenRefresh so both sends go through it. | ||
| */ | ||
| const [initialBody, clonedBody] = bodyResolvedOptions.body.tee(); | ||
| initialOptions = {...initialOptions, body: initialBody}; | ||
| retryBody = clonedBody; | ||
| hasRetryBody = true; | ||
| } | ||
| initialOptions = withSignal(initialOptions, signal); | ||
| const authorization = fetchFunction[resolveAuthorizationHeaderSymbol]?.(urlOrRequest, initialOptions) ?? requestHeaders.get('Authorization'); | ||
| let response; | ||
| try { | ||
| response = await fetchFunction(urlOrRequest, initialOptions); | ||
| } catch (error) { | ||
| await discardBody(retryBody); | ||
| throw error; | ||
| } | ||
| if (response.status !== 401) { | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| // Boundary: bare Request bodies are not retried because cloning every Request up front would penalize successful uploads too. | ||
| if ( | ||
| (request?.body && options.body === undefined) | ||
| || isAsyncIterable(bodyResolvedOptions.body) | ||
| ) { | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| let token; | ||
| try { | ||
| token = await getToken(authorization, signal); | ||
| } catch (error) { | ||
| // Refresh failures fall back to the original 401, but abort-driven failures must still reject. | ||
| if (signal?.aborted) { | ||
| // The 401 response is also hidden from the caller on this path, so abort cleanup must release both unread bodies. | ||
| await discardHiddenBodies(response, retryBody); | ||
| throw error; | ||
| } | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| const headers = new Headers(requestHeaders); | ||
| // The original 401 response is never exposed once we retry, so release its body before issuing the second request. | ||
| await discardHiddenBodies(response); | ||
| // Retry state stays minimal: replace only Authorization and rerun the same request shape once. | ||
| headers.set('Authorization', `Bearer ${token}`); | ||
| const retryOptions = hasRetryBody | ||
| ? {...bodyResolvedOptions, body: retryBody, headers} | ||
| : {...bodyResolvedOptions, headers}; | ||
| return fetchFunction(urlOrRequest, withSignal(retryOptions, signal)); | ||
| }; | ||
| return copyFetchMetadata(fetchWithTokenRefresh, fetchFunction); | ||
| } |
| import type {Progress} from './with-download-progress.js'; | ||
| /** | ||
| Wraps a fetch function with upload progress tracking. | ||
| Upload progress is best-effort and only supported when the effective request body is an explicit `ReadableStream` provided in `init.body`. Other body types are passed through unchanged so `fetch` keeps its native body handling and content-type inference. Streaming request bodies still require support for `duplex: 'half'` in the runtime. | ||
| When composing with `withTokenRefresh()`, place `withUploadProgress()` inside `withTokenRefresh()` if you want progress for both the initial send and the retry. If `withUploadProgress()` wraps `withTokenRefresh()`, it only observes the first call into `withTokenRefresh()`. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Upload progress callback options. | ||
| @returns A wrapped fetch function that reports upload progress for explicit streamed bodies. | ||
| @example | ||
| ``` | ||
| import {withUploadProgress} from 'fetch-extras'; | ||
| const fetchWithUploadProgress = withUploadProgress(fetch, { | ||
| onProgress(progress) { | ||
| console.log(`Upload: ${Math.round(progress.percent * 100)}%`); | ||
| }, | ||
| }); | ||
| await fetchWithUploadProgress('https://example.com/upload', { | ||
| method: 'POST', | ||
| body: largeBlob.stream(), | ||
| duplex: 'half', | ||
| }); | ||
| ``` | ||
| */ | ||
| export function withUploadProgress( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
| onProgress?: (progress: Progress) => void; | ||
| } | ||
| ): typeof fetch; |
| import { | ||
| blockedRequestBodyHeaderNames, | ||
| blockedDefaultHeaderNamesSymbol, | ||
| copyFetchMetadata, | ||
| deleteHeaders, | ||
| inheritedRequestBodyHeaderNamesSymbol, | ||
| isByteStream, | ||
| trackByteProgress, | ||
| trackProgress, | ||
| } from './utilities.js'; | ||
| function stripContentLength(headers) { | ||
| const cleanedHeaders = new Headers(headers); | ||
| cleanedHeaders.delete('content-length'); | ||
| return cleanedHeaders; | ||
| } | ||
| function mergeMissingRequestHeaders(headers, requestHeaders) { | ||
| const mergedHeaders = new Headers(headers); | ||
| for (const [headerName, headerValue] of requestHeaders) { | ||
| if (headerName === 'content-length' || mergedHeaders.has(headerName)) { | ||
| continue; | ||
| } | ||
| mergedHeaders.set(headerName, headerValue); | ||
| } | ||
| return mergedHeaders; | ||
| } | ||
| function requestSnapshot(request) { | ||
| const snapshot = { | ||
| method: request.method, | ||
| referrer: request.referrer, | ||
| referrerPolicy: request.referrerPolicy, | ||
| mode: request.mode, | ||
| credentials: request.credentials, | ||
| cache: request.cache, | ||
| keepalive: request.keepalive, | ||
| redirect: request.redirect, | ||
| integrity: request.integrity, | ||
| signal: request.signal, | ||
| headers: stripContentLength(request.headers), | ||
| }; | ||
| if ('priority' in request) { | ||
| snapshot.priority = request.priority; | ||
| } | ||
| return snapshot; | ||
| } | ||
| function markBlockedDefaultHeaders(object, headerNames) { | ||
| object[blockedDefaultHeaderNamesSymbol] = [ | ||
| ...(object[blockedDefaultHeaderNamesSymbol] ?? []), | ||
| ...headerNames, | ||
| ]; | ||
| return object; | ||
| } | ||
| export function withUploadProgress(fetchFunction, {onProgress} = {}) { | ||
| const fetchWithUploadProgress = async (urlOrRequest, options = {}) => { | ||
| if (onProgress) { | ||
| const {body} = options; | ||
| if (body instanceof ReadableStream) { | ||
| const trackedStream = isByteStream(body) ? trackByteProgress(body, 0, onProgress) : trackProgress(body, 0, onProgress); | ||
| if (urlOrRequest instanceof Request) { | ||
| if (options[inheritedRequestBodyHeaderNamesSymbol]) { | ||
| options = { | ||
| ...options, | ||
| headers: deleteHeaders(new Headers(options.headers), options[inheritedRequestBodyHeaderNamesSymbol]), | ||
| }; | ||
| } else { | ||
| options = { | ||
| ...options, | ||
| headers: mergeMissingRequestHeaders(options.headers, urlOrRequest.headers), | ||
| }; | ||
| } | ||
| const rebuiltRequest = new Request(urlOrRequest.url, requestSnapshot(urlOrRequest)); | ||
| rebuiltRequest[blockedDefaultHeaderNamesSymbol] = urlOrRequest[blockedDefaultHeaderNamesSymbol]; | ||
| urlOrRequest = rebuiltRequest; | ||
| } | ||
| options = markBlockedDefaultHeaders({...options, body: trackedStream, duplex: 'half'}, blockedRequestBodyHeaderNames); | ||
| } | ||
| } | ||
| return fetchFunction(urlOrRequest, options); | ||
| }; | ||
| return copyFetchMetadata(fetchWithUploadProgress, fetchFunction); | ||
| } |
+8
-5
| { | ||
| "name": "fetch-extras", | ||
| "version": "1.1.0", | ||
| "version": "2.0.0", | ||
| "description": "Useful utilities for working with Fetch", | ||
@@ -20,6 +20,6 @@ "license": "MIT", | ||
| "engines": { | ||
| "node": ">=18.18" | ||
| "node": ">=22" | ||
| }, | ||
| "scripts": { | ||
| "test": "xo && ava && tsc source/index.d.ts" | ||
| "test": "xo && ava && tsc source/index.d.ts && tsc --noEmit --module node16 --moduleResolution node16 --target es2024 test-d.ts" | ||
| }, | ||
@@ -44,5 +44,8 @@ "files": [ | ||
| ], | ||
| "dependencies": { | ||
| "is-network-error": "^1.3.1" | ||
| }, | ||
| "devDependencies": { | ||
| "ava": "^6.2.0", | ||
| "typescript": "^5.7.3", | ||
| "ava": "^7.0.0", | ||
| "typescript": "^6.0.2", | ||
| "xo": "^0.60.0" | ||
@@ -49,0 +52,0 @@ }, |
+42
-266
@@ -7,4 +7,6 @@ <h1 align="center" title="fetch-extras"> | ||
| *For more features and conveniences on top of Fetch, check out my [`ky`](https://github.com/sindresorhus/ky) package.* | ||
| Great for creating tiny custom HTTP clients without a heavy dependency. Fully tree-shakeable. | ||
| *For a full-featured HTTP client on top of Fetch, check out my [`ky`](https://github.com/sindresorhus/ky) package.* | ||
| ## Install | ||
@@ -19,10 +21,24 @@ | ||
| ```js | ||
| import {withHttpError, withTimeout} from 'fetch-extras'; | ||
| import { | ||
| pipeline, | ||
| withTimeout, | ||
| withBaseUrl, | ||
| withHeaders, | ||
| withHttpError | ||
| } from 'fetch-extras'; | ||
| // Create an enhanced reusable fetch function that: | ||
| // Create a tiny reusable API client that: | ||
| // - Times out after 5 seconds | ||
| // - Uses a base URL so you only write paths | ||
| // - Sends auth headers on every request | ||
| // - Throws errors for non-2xx responses | ||
| // - Times out after 5 seconds | ||
| const enhancedFetch = withHttpError(withTimeout(fetch, 5000)); | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| ); | ||
| const response = await enhancedFetch('/api'); | ||
| const response = await apiFetch('/users'); | ||
| const data = await response.json(); | ||
@@ -33,264 +49,24 @@ ``` | ||
| ### HttpError | ||
| The `with*` functions are listed in the recommended wrapping order for use with [`pipeline`](docs/pipeline.md). | ||
| Error class thrown when a response has a non-2xx status code. | ||
| - [`withTimeout`](docs/with-timeout.md) | ||
| - [`withBaseUrl`](docs/with-base-url.md) | ||
| - [`withSearchParameters`](docs/with-search-parameters.md) | ||
| - [`withHeaders`](docs/with-headers.md) | ||
| - [`withJsonBody`](docs/with-json-body.md) | ||
| - [`withRateLimit`](docs/with-rate-limit.md) | ||
| - [`withConcurrency`](docs/with-concurrency.md) | ||
| - [`withDeduplication`](docs/with-deduplication.md) | ||
| - [`withCache`](docs/with-cache.md) | ||
| - [`withDownloadProgress`](docs/with-download-progress.md) | ||
| - [`withUploadProgress`](docs/with-upload-progress.md) | ||
| - [`withRetry`](docs/with-retry.md) | ||
| - [`withTokenRefresh`](docs/with-token-refresh.md) | ||
| - [`withHooks`](docs/with-hooks.md) | ||
| - [`withHttpError`](docs/http-error.md#withhttperrorfetchfunction) | ||
| - [`HttpError`](docs/http-error.md#httperror) | ||
| - [`throwIfHttpError`](docs/http-error.md#throwifhttperrorresponse) | ||
| - [`paginate`](docs/paginate.md) | ||
| - [`pipeline`](docs/pipeline.md) | ||
| ```js | ||
| import {HttpError, throwIfHttpError} from 'fetch-extras'; | ||
| try { | ||
| await throwIfHttpError(fetch('/api')); | ||
| } catch (error) { | ||
| if (error instanceof HttpError) { | ||
| console.log(error.response.status); // 404 | ||
| } | ||
| } | ||
| ``` | ||
| ### throwIfHttpError(response) | ||
| Throws an `HttpError` if the response is not ok. Can also accept a promise that resolves to a response. | ||
| ```js | ||
| import {throwIfHttpError} from 'fetch-extras'; | ||
| const response = await throwIfHttpError(fetch('/api')); | ||
| const data = await response.json(); | ||
| ``` | ||
| ### withHttpError(fetchFunction) | ||
| Returns a wrapped fetch function that automatically throws `HttpError` for non-2xx responses. | ||
| ```js | ||
| import {withHttpError} from 'fetch-extras'; | ||
| const fetchWithError = withHttpError(fetch); | ||
| const response = await fetchWithError('/api'); | ||
| ``` | ||
| ### withTimeout(fetchFunction, timeout) | ||
| Returns a wrapped fetch function with timeout functionality. | ||
| ```js | ||
| import {withTimeout} from 'fetch-extras'; | ||
| const fetchWithTimeout = withTimeout(fetch, 5000); | ||
| const response = await fetchWithTimeout('/api'); | ||
| ``` | ||
| ### paginate(input, options?) | ||
| Paginate through API responses using async iteration. By default, it automatically follows RFC 5988 `Link` headers with `rel="next"`. | ||
| Returns an async iterator that yields items from each page. | ||
| ```js | ||
| import {paginate} from 'fetch-extras'; | ||
| // Basic usage with Link headers (GitHub API) | ||
| for await (const commit of paginate('https://api.github.com/repos/sindresorhus/ky/commits')) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| #### options | ||
| Type: `object` | ||
| ##### pagination | ||
| Type: `object` | ||
| ###### transform | ||
| Type: `(response: Response) => Promise<unknown[]>`\ | ||
| Default: `response => response.json()` | ||
| Transform the response into an array of items. | ||
| ```js | ||
| for await (const user of paginate('https://api.example.com/users', { | ||
| pagination: { | ||
| transform: async response => { | ||
| const data = await response.json(); | ||
| return data.users; // Extract from nested property | ||
| } | ||
| } | ||
| })) { | ||
| console.log(user); | ||
| } | ||
| ``` | ||
| ###### paginate | ||
| Type: `(data: {response, currentUrl, currentItems, allItems}) => Promise<PaginationNextPage | false>`\ | ||
| Default: Parses RFC 5988 `Link` header | ||
| Determine the next page to fetch. Return an object with fetch options for the next request, or `false` to stop pagination. | ||
| > [!IMPORTANT] | ||
| > The response body has already been consumed by the `transform` function. Do NOT call `response.json()` or other body methods here. Extract pagination info from headers, the URL, or share data from the transform function through closure. | ||
| > [!NOTE] | ||
| > Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. If you need to add headers while keeping existing ones, read them from the response and include them in the returned object. | ||
| > Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request, consistent with HTTP semantics for bodyless requests. | ||
| ```js | ||
| // Cursor-based pagination using headers (recommended) | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| paginate: ({response}) => { | ||
| const cursor = response.headers.get('X-Next-Cursor'); | ||
| return cursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${cursor}`)} | ||
| : false; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| ```js | ||
| // Sharing data between transform and paginate via closure | ||
| let nextCursor; | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async (response) => { | ||
| const data = await response.json(); | ||
| nextCursor = data.nextCursor; | ||
| return data.items; | ||
| }, | ||
| paginate: () => { | ||
| return nextCursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${nextCursor}`)} | ||
| : false; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| ###### filter | ||
| Type: `(data: {item, currentItems, allItems}) => boolean`\ | ||
| Default: `() => true` | ||
| Filter items before yielding them. | ||
| ```js | ||
| // Only get active users | ||
| for await (const user of paginate('https://api.example.com/users', { | ||
| pagination: { | ||
| filter: ({item}) => item.status === 'active' | ||
| } | ||
| })) { | ||
| console.log(user); | ||
| } | ||
| ``` | ||
| ###### shouldContinue | ||
| Type: `(data: {item, currentItems, allItems}) => boolean`\ | ||
| Default: `() => true` | ||
| Check if pagination should continue after yielding an item. This is called after `filter` returns `true`. Useful for stopping pagination based on item values. | ||
| ```js | ||
| // Stop when we reach items older than one week | ||
| const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); | ||
| for await (const commit of paginate('https://api.github.com/repos/user/repo/commits', { | ||
| pagination: { | ||
| shouldContinue: ({item}) => new Date(item.date).getTime() >= oneWeekAgo | ||
| } | ||
| })) { | ||
| console.log(commit); | ||
| } | ||
| ``` | ||
| ###### countLimit | ||
| Type: `number`\ | ||
| Default: `Infinity` | ||
| Maximum number of items to yield. | ||
| ```js | ||
| const items = await paginate.all('https://api.example.com/items', { | ||
| pagination: { | ||
| countLimit: 100 // Stop after 100 items | ||
| } | ||
| }); | ||
| ``` | ||
| ###### requestLimit | ||
| Type: `number`\ | ||
| Default: `10000` | ||
| Maximum number of requests to make. This prevents infinite loops if your `paginate` function has bugs. Ensure your `paginate` function eventually returns `false` or the iteration will continue until this limit is reached. | ||
| ###### backoff | ||
| Type: `number`\ | ||
| Default: `0` | ||
| Delay in milliseconds between requests. Useful for rate limiting. | ||
| ```js | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| backoff: 1000 // Wait 1 second between requests | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| ###### stackAllItems | ||
| Type: `boolean`\ | ||
| Default: `false` | ||
| Whether to keep all yielded items in memory. When `true`, the `allItems` array passed to callbacks will contain all previously yielded items. When `false`, `allItems` will always be empty to save memory. | ||
| ##### fetchFunction | ||
| Type: `(input: RequestInfo | URL, init?: any) => Promise<Response>`\ | ||
| Default: `globalThis.fetch` | ||
| Custom fetch function to use for requests. This allows you to use a custom fetch implementation, such as [`ky`](https://github.com/sindresorhus/ky), or a fetch function wrapped with `withHttpError` or `withTimeout`. | ||
| ```js | ||
| import {paginate} from 'fetch-extras'; | ||
| import ky from 'ky'; | ||
| const url = 'https://api.github.com/repos/sindresorhus/ky/commits'; | ||
| for await (const commit of paginate(url, {fetchFunction: ky})) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| ### paginate.all(input, options?) | ||
| Get all paginated items as an array. This is a convenience method that collects all items into memory. For large datasets, prefer using the async iterator directly. | ||
| ```js | ||
| import {paginate} from 'fetch-extras'; | ||
| const commits = await paginate.all('https://api.github.com/repos/sindresorhus/ky/commits', { | ||
| pagination: { | ||
| countLimit: 50 | ||
| } | ||
| }); | ||
| console.log(`Fetched ${commits.length} commits`); | ||
| ``` | ||
| ## Related | ||
@@ -297,0 +73,0 @@ |
+23
-428
@@ -1,428 +0,23 @@ | ||
| /** | ||
| Custom error class for HTTP errors that should be thrown when the response has a non-2xx status code. | ||
| */ | ||
| export class HttpError extends Error { | ||
| readonly name: 'HttpError'; | ||
| readonly code: 'ERR_HTTP_RESPONSE_NOT_OK'; | ||
| response: Response; | ||
| /** | ||
| Constructs a new `HttpError` instance. | ||
| @param response - The `Response` object that caused the error. | ||
| */ | ||
| constructor(response: Response); | ||
| } | ||
| /** | ||
| Throws an `HttpError` if the response is not ok (non-2xx status code). | ||
| @param response - The `Response` object to check. | ||
| @returns The same `Response` object if it is ok. | ||
| @throws {HttpError} If the response is not ok. | ||
| @example | ||
| ``` | ||
| import {throwIfHttpError} from 'fetch-extras'; | ||
| const response = await fetch('/api'); | ||
| throwIfHttpError(response); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function throwIfHttpError(response: Response): Response; | ||
| /** | ||
| Throws an `HttpError` if the response is not ok (non-2xx status code). | ||
| @param responsePromise - A promise that resolves to a `Response` object to check. | ||
| @returns A promise that resolves to the same `Response` object if it is ok. | ||
| @throws {HttpError} If the response is not ok. | ||
| @example | ||
| ``` | ||
| import {throwIfHttpError} from 'fetch-extras'; | ||
| const response = await throwIfHttpError(fetch('/api')); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function throwIfHttpError(responsePromise: Promise<Response>): Promise<Response>; | ||
| /** | ||
| Wraps a fetch function to automatically throw `HttpError` for non-2xx responses. | ||
| Can be combined with other `with*` methods. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that will throw HttpError for non-2xx responses. | ||
| @example | ||
| ``` | ||
| import {withHttpError} from 'fetch-extras'; | ||
| const fetchWithError = withHttpError(fetch); | ||
| const response = await fetchWithError('/api'); // Throws HttpError for non-2xx responses | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function withHttpError( | ||
| fetchFunction: typeof fetch | ||
| ): typeof fetch; | ||
| /** | ||
| Wraps a fetch function with timeout functionality. | ||
| Can be combined with other `with*` methods. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param timeout - Timeout in milliseconds. | ||
| @returns A wrapped fetch function that will abort if the request takes longer than the specified timeout. | ||
| @example | ||
| ``` | ||
| import {withTimeout} from 'fetch-extras'; | ||
| const fetchWithTimeout = withTimeout(fetch, 5000); | ||
| const response = await fetchWithTimeout('/api'); | ||
| const data = await response.json(); | ||
| ``` | ||
| */ | ||
| export function withTimeout( | ||
| fetchFunction: typeof fetch, | ||
| timeout: number | ||
| ): typeof fetch; | ||
| /** | ||
| Pagination options for customizing how pagination works. | ||
| */ | ||
| export type PaginationOptions<ItemType = unknown> = { | ||
| /** | ||
| Transform the response into an array of items. | ||
| By default, it calls `response.json()` and expects an array. | ||
| @param response - The Response object from the fetch request. | ||
| @returns An array of items to yield, or a Promise that resolves to an array. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| const items = await paginate.all('https://api.example.com/users', { | ||
| pagination: { | ||
| transform: async response => { | ||
| const data = await response.json(); | ||
| return data.users; // Extract items from nested property | ||
| } | ||
| } | ||
| }); | ||
| ``` | ||
| */ | ||
| transform?: (response: Response) => ItemType[] | Promise<ItemType[]>; | ||
| /** | ||
| Determine the next page to fetch. | ||
| Return an object with fetch options for the next request, or `false` to stop pagination. | ||
| By default, it parses the `Link` header and follows the `rel="next"` link. | ||
| **Important**: The response body has already been consumed by the `transform` function. Do NOT call `response.json()` or other body methods here. Instead, extract pagination info from headers, the URL, or share data from the transform function through closure. | ||
| **Note**: Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request. | ||
| @param data - Context object with response, current URL, and items. | ||
| @returns Options for the next fetch request, or `false` to stop pagination. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Cursor-based pagination using headers (recommended) | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| paginate: ({response}) => { | ||
| const nextCursor = response.headers.get('X-Next-Cursor'); | ||
| if (!nextCursor) return false; | ||
| return { | ||
| url: new URL(`https://api.example.com/items?cursor=${nextCursor}`) | ||
| }; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Sharing data between transform and paginate via closure | ||
| let nextCursor; | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async (response) => { | ||
| const data = await response.json(); | ||
| // Store pagination info in closure | ||
| nextCursor = data.nextCursor; | ||
| return data.items; | ||
| }, | ||
| paginate: () => { | ||
| if (!nextCursor) return false; | ||
| return { | ||
| url: new URL(`https://api.example.com/items?cursor=${nextCursor}`) | ||
| }; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| */ | ||
| paginate?: (data: { | ||
| response: Response; | ||
| currentUrl: URL | string; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => PaginationNextPage | false | Promise<PaginationNextPage | false>; | ||
| /** | ||
| Filter items before yielding them. | ||
| @param data - Context object with the current item and item arrays. | ||
| @returns `true` to yield the item, `false` to skip it. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Only get active users | ||
| for await (const user of paginate('https://api.example.com/users', { | ||
| pagination: { | ||
| filter: ({item}) => item.status === 'active' | ||
| } | ||
| })) { | ||
| console.log(user); | ||
| } | ||
| ``` | ||
| */ | ||
| filter?: (data: { | ||
| item: ItemType; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => boolean; | ||
| /** | ||
| Check if pagination should continue after yielding an item. | ||
| This is called after `filter` returns `true`. Useful for stopping pagination based on item values. | ||
| @param data - Context object with the current item and item arrays. | ||
| @returns `true` to continue pagination, `false` to stop. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Stop when we reach items older than one week | ||
| const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); | ||
| for await (const commit of paginate('https://api.github.com/repos/user/repo/commits', { | ||
| pagination: { | ||
| shouldContinue: ({item}) => new Date(item.date).getTime() >= oneWeekAgo | ||
| } | ||
| })) { | ||
| console.log(commit); | ||
| } | ||
| ``` | ||
| */ | ||
| shouldContinue?: (data: { | ||
| item: ItemType; | ||
| currentItems: ItemType[]; | ||
| allItems: ItemType[]; | ||
| }) => boolean; | ||
| /** | ||
| Maximum number of items to yield. | ||
| @default Infinity | ||
| */ | ||
| countLimit?: number; | ||
| /** | ||
| Delay in milliseconds between requests. | ||
| Useful for rate limiting. | ||
| @default 0 | ||
| */ | ||
| backoff?: number; | ||
| /** | ||
| Maximum number of requests to make. | ||
| This prevents infinite loops if your `paginate` function has bugs. Ensure your `paginate` function eventually returns `false` or the iteration will continue until this limit is reached. | ||
| @default 10000 | ||
| */ | ||
| requestLimit?: number; | ||
| /** | ||
| Whether to keep all yielded items in memory. | ||
| When `true`, the `allItems` array passed to callbacks will contain all previously yielded items. When `false` (default), `allItems` will always be empty to save memory. | ||
| @default false | ||
| */ | ||
| stackAllItems?: boolean; | ||
| }; | ||
| /** | ||
| Options for the next page request. | ||
| */ | ||
| export type PaginationNextPage = { | ||
| /** | ||
| URL for the next page. | ||
| Must be a URL instance, not a string. | ||
| */ | ||
| url?: URL; | ||
| } & RequestInit; | ||
| /** | ||
| A function with the same signature as the global `fetch`. | ||
| This allows you to use a custom fetch implementation, such as [`ky`](https://github.com/sindresorhus/ky). | ||
| */ | ||
| export type FetchFunction = (input: RequestInfo | URL, init?: any) => Promise<Response>; | ||
| /** | ||
| Options for the `paginate` function. | ||
| */ | ||
| export type PaginateOptions<ItemType = unknown> = RequestInit & { | ||
| /** | ||
| Pagination-specific options. | ||
| */ | ||
| pagination?: PaginationOptions<ItemType>; | ||
| /** | ||
| Custom fetch function to use for requests. | ||
| This allows you to use a custom fetch implementation, such as [`ky`](https://github.com/sindresorhus/ky), or a fetch function wrapped with `withHttpError` or `withTimeout`. | ||
| @default globalThis.fetch | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| import ky from 'ky'; | ||
| const url = 'https://api.github.com/repos/sindresorhus/ky/commits'; | ||
| for await (const commit of paginate(url, {fetchFunction: ky})) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| */ | ||
| fetchFunction?: FetchFunction; | ||
| }; | ||
| /** | ||
| Paginate through API responses using async iteration. | ||
| By default, it automatically follows RFC 5988 `Link` headers with `rel="next"`. | ||
| **Note**: This function does not check response status codes. If you need error handling for non-2xx responses, wrap fetch with `withHttpError()` or handle errors in your `transform` function. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
| @param options - Fetch options plus pagination options. | ||
| @returns An async iterator that yields items from each page. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Basic usage with Link headers (GitHub API) | ||
| for await (const commit of paginate('https://api.github.com/repos/sindresorhus/ky/commits')) { | ||
| console.log(commit.sha); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // With error handling for non-2xx responses | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async (response) => { | ||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
| return response.json(); | ||
| }, | ||
| countLimit: 100, | ||
| backoff: 1000 | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Cursor-based pagination using headers | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
| pagination: { | ||
| transform: async response => { | ||
| const data = await response.json(); | ||
| return data.items; | ||
| }, | ||
| paginate: ({response}) => { | ||
| const cursor = response.headers.get('X-Next-Cursor'); | ||
| return cursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${cursor}`)} | ||
| : false; | ||
| } | ||
| } | ||
| })) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| */ | ||
| export function paginate<ItemType = unknown>( | ||
| input: RequestInfo | URL, | ||
| options?: PaginateOptions<ItemType> | ||
| ): AsyncIterableIterator<ItemType>; | ||
| export namespace paginate { | ||
| /** | ||
| Get all paginated items as an array. | ||
| This is a convenience method that collects all items into memory. For large datasets, prefer using the async iterator directly. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
| @param options - Fetch options plus pagination options. | ||
| @returns A promise that resolves to an array of all items. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| const commits = await paginate.all('https://api.github.com/repos/sindresorhus/ky/commits', { | ||
| pagination: { | ||
| countLimit: 50 | ||
| } | ||
| }); | ||
| console.log(`Fetched ${commits.length} commits`); | ||
| ``` | ||
| */ | ||
| export function all<ItemType = unknown>( | ||
| input: RequestInfo | URL, | ||
| options?: PaginateOptions<ItemType> | ||
| ): Promise<ItemType[]>; | ||
| } | ||
| export {HttpError, throwIfHttpError, withHttpError} from './http-error.js'; | ||
| export {withTimeout} from './with-timeout.js'; | ||
| export {withBaseUrl} from './with-base-url.js'; | ||
| export {withSearchParameters} from './with-search-parameters.js'; | ||
| export {withHeaders} from './with-headers.js'; | ||
| export {withJsonBody, type JsonBodyRequestInit} from './with-json-body.js'; | ||
| export {withDownloadProgress, type Progress} from './with-download-progress.js'; | ||
| export {withUploadProgress} from './with-upload-progress.js'; | ||
| export {withTokenRefresh} from './with-token-refresh.js'; | ||
| export {withConcurrency} from './with-concurrency.js'; | ||
| export {withRateLimit} from './with-rate-limit.js'; | ||
| export {withCache} from './with-cache.js'; | ||
| export {withDeduplication} from './with-deduplication.js'; | ||
| export {withRetry} from './with-retry.js'; | ||
| export {withHooks} from './with-hooks.js'; | ||
| export { | ||
| paginate, | ||
| type PaginationOptions, | ||
| type PaginationNextPage, | ||
| type FetchFunction, | ||
| type PaginateOptions, | ||
| } from './paginate.js'; | ||
| export {pipeline} from './pipeline.js'; |
+16
-42
@@ -0,43 +1,17 @@ | ||
| export {HttpError, throwIfHttpError, withHttpError} from './http-error.js'; | ||
| export {withTimeout} from './with-timeout.js'; | ||
| export {withBaseUrl} from './with-base-url.js'; | ||
| export {withSearchParameters} from './with-search-parameters.js'; | ||
| export {withHeaders} from './with-headers.js'; | ||
| export {withJsonBody} from './with-json-body.js'; | ||
| export {withDownloadProgress} from './with-download-progress.js'; | ||
| export {withUploadProgress} from './with-upload-progress.js'; | ||
| export {withTokenRefresh} from './with-token-refresh.js'; | ||
| export {withConcurrency} from './with-concurrency.js'; | ||
| export {withRateLimit} from './with-rate-limit.js'; | ||
| export {withCache} from './with-cache.js'; | ||
| export {withDeduplication} from './with-deduplication.js'; | ||
| export {withRetry} from './with-retry.js'; | ||
| export {withHooks} from './with-hooks.js'; | ||
| export {paginate} from './paginate.js'; | ||
| export class HttpError extends Error { | ||
| constructor(response) { | ||
| const status = `${response.status} ${response.statusText}`.trim(); | ||
| const reason = status ? `status code ${status}` : 'an unknown error'; | ||
| super(`Request failed with ${reason}: ${response.url}`); | ||
| Error.captureStackTrace?.(this, this.constructor); | ||
| this.name = 'HttpError'; | ||
| this.code = 'ERR_HTTP_RESPONSE_NOT_OK'; | ||
| this.response = response; | ||
| } | ||
| } | ||
| export async function throwIfHttpError(responseOrPromise) { | ||
| if (!(responseOrPromise instanceof Response)) { | ||
| responseOrPromise = await responseOrPromise; | ||
| } | ||
| if (!responseOrPromise.ok) { | ||
| throw new HttpError(responseOrPromise); | ||
| } | ||
| return responseOrPromise; | ||
| } | ||
| export function withHttpError(fetchFunction) { | ||
| return async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| return throwIfHttpError(response); | ||
| }; | ||
| } | ||
| export function withTimeout(fetchFunction, timeout) { | ||
| return async (urlOrRequest, options = {}) => { | ||
| const providedSignal = options.signal ?? (urlOrRequest instanceof Request && urlOrRequest.signal); | ||
| const timeoutSignal = AbortSignal.timeout(timeout); | ||
| const signal = providedSignal ? AbortSignal.any([providedSignal, timeoutSignal]) : timeoutSignal; | ||
| return fetchFunction(urlOrRequest, {...options, signal}); | ||
| }; | ||
| } | ||
| export {pipeline} from './pipeline.js'; |
+62
-38
| import parseLinkHeader from './parse-link-header.js'; | ||
| import {delay} from './utilities.js'; | ||
| import { | ||
| blockedDefaultHeaderNamesSymbol, | ||
| delay, | ||
| requestBodyHeaderNames, | ||
| requestSnapshot, | ||
| resolveRequestHeaders, | ||
| resolveRequestBodyOptions, | ||
| } from './utilities.js'; | ||
@@ -32,8 +39,2 @@ const defaultPaginationOptions = { | ||
| const requestBodyHeaderNames = [ | ||
| 'content-encoding', | ||
| 'content-language', | ||
| 'content-location', | ||
| 'content-type', | ||
| ]; | ||
| const sensitiveHeaderNames = [ | ||
@@ -66,19 +67,9 @@ 'authorization', | ||
| const markToBlockDefaultHeaders = (object, headerNames) => { | ||
| object[blockedDefaultHeaderNamesSymbol] = [...headerNames]; | ||
| return object; | ||
| }; | ||
| const methodCanHaveBody = method => method === undefined || !['get', 'head'].includes(method.toLowerCase()); | ||
| const requestSnapshot = request => ({ | ||
| method: request.method, | ||
| referrer: request.referrer, | ||
| referrerPolicy: request.referrerPolicy, | ||
| mode: request.mode, | ||
| credentials: request.credentials, | ||
| cache: request.cache, | ||
| redirect: request.redirect, | ||
| integrity: request.integrity, | ||
| keepalive: request.keepalive, | ||
| signal: request.signal, | ||
| duplex: request.duplex, | ||
| priority: request.priority, | ||
| }); | ||
| const requestWithoutBody = request => ({ | ||
@@ -105,13 +96,2 @@ ...requestSnapshot(request), | ||
| const stripSensitiveHeadersFromFetchOptions = fetchOptions => { | ||
| if (!('headers' in fetchOptions)) { | ||
| return fetchOptions; | ||
| } | ||
| return { | ||
| ...fetchOptions, | ||
| headers: stripSensitiveHeaders(fetchOptions.headers), | ||
| }; | ||
| }; | ||
| const normalizeBodylessFetchOptions = fetchOptions => { | ||
@@ -193,2 +173,17 @@ const {body: _body, ...restFetchOptions} = fetchOptions; | ||
| const createResolvedRequestTemplateState = (fetchFunction, input, fetchOptions) => { | ||
| const resolvedFetchOptions = resolveRequestBodyOptions(fetchFunction, input, fetchOptions); | ||
| const resolvedHeaders = resolveRequestHeaders(fetchFunction, input, resolvedFetchOptions); | ||
| const templateFetchOptions = resolvedHeaders === undefined | ||
| ? resolvedFetchOptions | ||
| : {...resolvedFetchOptions, headers: resolvedHeaders}; | ||
| return { | ||
| requestTemplate: createRequestTemplate(input, templateFetchOptions), | ||
| fetchOptions: createTemplateFetchOptions(templateFetchOptions), | ||
| }; | ||
| }; | ||
| const shouldUseRequestTemplateOnFirstRequest = (input, fetchOptions) => input instanceof Request || (absoluteUrl(input) && fetchOptions.body instanceof ReadableStream); | ||
| const currentSignal = (requestTemplate, fetchOptions) => fetchOptions.signal ?? requestTemplate?.signal; | ||
@@ -201,6 +196,17 @@ const requestWithoutSensitiveHeaders = request => ({ | ||
| const stripInheritedSensitiveState = (requestTemplate, fetchOptions) => ({ | ||
| requestTemplate: requestTemplate && new Request(requestTemplate.url, requestTemplate.body === null ? requestWithoutSensitiveHeaders(requestTemplate) : requestWithoutSensitiveState(requestTemplate)), | ||
| fetchOptions: Object.hasOwn(fetchOptions, 'body') && fetchOptions.body !== undefined ? normalizeBodylessFetchOptions(stripSensitiveHeadersFromFetchOptions(fetchOptions)) : stripSensitiveHeadersFromFetchOptions(fetchOptions), | ||
| requestTemplate: requestTemplate && markToBlockDefaultHeaders(new Request(requestTemplate.url, requestTemplate.body === null ? requestWithoutSensitiveHeaders(requestTemplate) : requestWithoutSensitiveState(requestTemplate)), sensitiveHeaderNames), | ||
| fetchOptions: Object.hasOwn(fetchOptions, 'body') && fetchOptions.body !== undefined ? normalizeBodylessFetchOptions(markSensitiveHeadersAsFinal(fetchOptions)) : markSensitiveHeadersAsFinal(fetchOptions), | ||
| }); | ||
| const markSensitiveHeadersAsFinal = fetchOptions => { | ||
| if (!('headers' in fetchOptions)) { | ||
| return markToBlockDefaultHeaders({...fetchOptions}, sensitiveHeaderNames); | ||
| } | ||
| return markToBlockDefaultHeaders({ | ||
| ...fetchOptions, | ||
| headers: markToBlockDefaultHeaders(stripSensitiveHeaders(fetchOptions.headers), sensitiveHeaderNames), | ||
| }, sensitiveHeaderNames); | ||
| }; | ||
| const hasSensitiveHeaders = headers => { | ||
@@ -246,2 +252,6 @@ const normalizedHeaders = new Headers(headers); | ||
| if (!methodCanHaveBody(fetchOptions.method) && Object.hasOwn(fetchOptions, 'body') && fetchOptions.body !== undefined) { | ||
| throw new TypeError('Request with GET/HEAD method cannot have body.'); | ||
| } | ||
| if (typeof paginationOptions.transform !== 'function') { | ||
@@ -318,4 +328,3 @@ throw new TypeError('pagination.transform must be a function'); | ||
| let numberOfRequests = 0; | ||
| const absoluteInputUrl = input instanceof Request ? undefined : absoluteUrl(input); | ||
| let requestTemplate = input instanceof Request || ('body' in fetchOptions && absoluteInputUrl) ? createRequestTemplate(input instanceof Request ? input : absoluteInputUrl, fetchOptions) : undefined; | ||
| let requestTemplate = shouldUseRequestTemplateOnFirstRequest(input, fetchOptions) ? createRequestTemplate(input, fetchOptions) : undefined; | ||
| let currentUrl = requestTemplate ? new URL(requestTemplate.url) : input; | ||
@@ -331,4 +340,12 @@ let currentFetchOptions = requestTemplate ? createTemplateFetchOptions(fetchOptions) : normalizeFetchOptions(fetchOptions); | ||
| const currentInput = requestTemplate ? new Request(currentUrl, requestTemplate.clone()) : currentUrl; | ||
| let currentInput = currentUrl; | ||
| if (requestTemplate) { | ||
| currentInput = new Request(currentUrl, requestTemplate.clone()); | ||
| if (requestTemplate[blockedDefaultHeaderNamesSymbol]) { | ||
| markToBlockDefaultHeaders(currentInput, requestTemplate[blockedDefaultHeaderNamesSymbol]); | ||
| } | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
@@ -403,2 +420,9 @@ const response = await fetchFunction(currentInput, currentFetchOptions); | ||
| if (!requestTemplate && nextRequestUrl && Object.hasOwn(currentFetchOptions, 'body') && currentFetchOptions.body !== undefined) { | ||
| const requestTemplateState = createResolvedRequestTemplateState(fetchFunction, currentUrl, currentFetchOptions); | ||
| requestTemplate = requestTemplateState.requestTemplate; | ||
| currentFetchOptions = requestTemplateState.fetchOptions; | ||
| currentUrl = new URL(requestTemplate.url); | ||
| } | ||
| if (shouldStripInheritedSensitiveState(inheritedStateOrigin, nextRequestUrl)) { | ||
@@ -405,0 +429,0 @@ const strippedState = stripInheritedSensitiveState(requestTemplate, currentFetchOptions); |
+391
-9
@@ -0,1 +1,186 @@ | ||
| export const blockedDefaultHeaderNamesSymbol = Symbol('blockedDefaultHeaderNames'); | ||
| export const inheritedRequestBodyHeaderNamesSymbol = Symbol('inheritedRequestBodyHeaderNames'); | ||
| export const timeoutDurationSymbol = Symbol('timeoutDuration'); | ||
| export const resolveRequestUrlSymbol = Symbol('resolveRequestUrl'); | ||
| export const resolveAuthorizationHeaderSymbol = Symbol('resolveAuthorizationHeader'); | ||
| export const resolveRequestHeadersSymbol = Symbol('resolveRequestHeaders'); | ||
| export const resolveRequestBodySymbol = Symbol('resolveRequestBody'); | ||
| export const waitForConcurrencySlotSymbol = Symbol('waitForConcurrencySlot'); | ||
| export const defersConcurrencySlotSymbol = Symbol('defersConcurrencySlot'); | ||
| export const notifyFetchStartSymbol = Symbol('notifyFetchStart'); | ||
| export const defersFetchStartSymbol = Symbol('defersFetchStart'); | ||
| export const requestBodyHeaderNames = [ | ||
| 'content-encoding', | ||
| 'content-language', | ||
| 'content-location', | ||
| 'content-type', | ||
| ]; | ||
| export const blockedRequestBodyHeaderNames = ['content-length', ...requestBodyHeaderNames]; | ||
| const textEncoder = new TextEncoder(); | ||
| function chunkLength(chunk) { | ||
| if (typeof chunk === 'string') { | ||
| return textEncoder.encode(chunk).byteLength; | ||
| } | ||
| return chunk.byteLength; | ||
| } | ||
| function reportProgress(transferredBytes, totalBytes, onProgress) { | ||
| const effectiveTotalBytes = Math.max(totalBytes, transferredBytes); | ||
| const percent = totalBytes === 0 | ||
| ? 0 | ||
| : Math.min(transferredBytes / (effectiveTotalBytes + 1), 1 - Number.EPSILON); | ||
| onProgress({percent, transferredBytes, totalBytes: effectiveTotalBytes}); | ||
| } | ||
| function reportCompletion(transferredBytes, totalBytes, onProgress) { | ||
| onProgress({percent: 1, transferredBytes, totalBytes: Math.max(totalBytes, transferredBytes)}); | ||
| } | ||
| export function isByteStream(stream) { | ||
| try { | ||
| const reader = stream.getReader({mode: 'byob'}); | ||
| reader.releaseLock(); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| export function isImmutableHeaders(headers) { | ||
| try { | ||
| headers.set('x-fetch-extras-immutability-check', '1'); | ||
| headers.delete('x-fetch-extras-immutability-check'); | ||
| return false; | ||
| } catch { | ||
| return true; | ||
| } | ||
| } | ||
| function freezeResponseHeaders(headers) { | ||
| Object.defineProperties(headers, { | ||
| append: { | ||
| value() { | ||
| throw new TypeError('immutable'); | ||
| }, | ||
| }, | ||
| delete: { | ||
| value() { | ||
| throw new TypeError('immutable'); | ||
| }, | ||
| }, | ||
| set: { | ||
| value() { | ||
| throw new TypeError('immutable'); | ||
| }, | ||
| }, | ||
| }); | ||
| return headers; | ||
| } | ||
| function copyResponseMetadata(targetResponse, sourceResponse, immutableHeaders) { | ||
| const properties = { | ||
| url: { | ||
| value: sourceResponse.url, | ||
| }, | ||
| type: { | ||
| value: sourceResponse.type, | ||
| }, | ||
| redirected: { | ||
| value: sourceResponse.redirected, | ||
| }, | ||
| clone: { | ||
| value() { | ||
| const clonedResponse = Response.prototype.clone.call(this); | ||
| copyResponseMetadata(clonedResponse, this, immutableHeaders); | ||
| return clonedResponse; | ||
| }, | ||
| }, | ||
| }; | ||
| if (immutableHeaders) { | ||
| freezeResponseHeaders(targetResponse.headers); | ||
| } | ||
| Object.defineProperties(targetResponse, properties); | ||
| } | ||
| export function withResponseMetadata(response, body) { | ||
| const immutableHeaders = isImmutableHeaders(response.headers); | ||
| const trackedResponse = new Response(body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: response.headers, | ||
| }); | ||
| copyResponseMetadata(trackedResponse, response, immutableHeaders); | ||
| return trackedResponse; | ||
| } | ||
| export function trackProgress(stream, totalBytes, onProgress) { | ||
| let transferredBytes = 0; | ||
| return stream.pipeThrough(new TransformStream({ | ||
| transform(chunk, controller) { | ||
| controller.enqueue(chunk); | ||
| transferredBytes += chunkLength(chunk); | ||
| reportProgress(transferredBytes, totalBytes, onProgress); | ||
| }, | ||
| flush() { | ||
| reportCompletion(transferredBytes, totalBytes, onProgress); | ||
| }, | ||
| })); | ||
| } | ||
| export function trackByteProgress(stream, totalBytes, onProgress) { | ||
| let transferredBytes = 0; | ||
| let isCanceled = false; | ||
| let didReportCompletion = false; | ||
| const reader = stream.getReader(); | ||
| const emitCompletion = () => { | ||
| if (isCanceled || didReportCompletion) { | ||
| return; | ||
| } | ||
| didReportCompletion = true; | ||
| reportCompletion(transferredBytes, totalBytes, onProgress); | ||
| }; | ||
| const watchForClose = async () => { | ||
| try { | ||
| await reader.closed; | ||
| await Promise.resolve(); | ||
| emitCompletion(); | ||
| } catch {} | ||
| }; | ||
| watchForClose(); | ||
| return new ReadableStream({ | ||
| type: 'bytes', | ||
| async pull(controller) { | ||
| const {done, value} = await reader.read(); | ||
| if (done) { | ||
| controller.close(); | ||
| return; | ||
| } | ||
| transferredBytes += chunkLength(value); | ||
| reportProgress(transferredBytes, totalBytes, onProgress); | ||
| controller.enqueue(value); | ||
| }, | ||
| cancel(reason) { | ||
| isCanceled = true; | ||
| return reader.cancel(reason); | ||
| }, | ||
| }); | ||
| } | ||
| /** | ||
@@ -12,10 +197,2 @@ Creates a promise that resolves after the specified delay. | ||
| const rejectWithAbortReason = () => { | ||
| try { | ||
| signal.throwIfAborted(); | ||
| } catch (error) { | ||
| reject(error); | ||
| } | ||
| }; | ||
| const timeout = setTimeout(() => { | ||
@@ -33,3 +210,3 @@ cleanup(); | ||
| cleanup(); | ||
| rejectWithAbortReason(); | ||
| reject(signal.reason); | ||
| }; | ||
@@ -40,1 +217,206 @@ | ||
| } | ||
| export function enqueueAbortable(queue, {signal, onAbort, onEnqueue} = {}) { | ||
| return new Promise((resolve, reject) => { | ||
| let isSettled = false; | ||
| const cleanup = () => { | ||
| signal?.removeEventListener('abort', abort); | ||
| }; | ||
| const settle = callback => { | ||
| if (isSettled) { | ||
| return; | ||
| } | ||
| isSettled = true; | ||
| cleanup(); | ||
| callback(); | ||
| }; | ||
| const abort = () => { | ||
| const index = queue.indexOf(entry); | ||
| if (index !== -1) { | ||
| queue.splice(index, 1); | ||
| } | ||
| onAbort?.(); | ||
| settle(() => { | ||
| reject(signal.reason); | ||
| }); | ||
| }; | ||
| const entry = { | ||
| signal, | ||
| resolve(value) { | ||
| settle(() => { | ||
| resolve(value); | ||
| }); | ||
| }, | ||
| reject(error) { | ||
| settle(() => { | ||
| reject(error); | ||
| }); | ||
| }, | ||
| }; | ||
| signal?.addEventListener('abort', abort, {once: true}); | ||
| queue.push(entry); | ||
| onEnqueue?.(); | ||
| }); | ||
| } | ||
| export function resolveRequestUrl(fetchFunction, urlOrRequest) { | ||
| const resolvedUrl = fetchFunction[resolveRequestUrlSymbol]?.(urlOrRequest) ?? urlOrRequest; | ||
| const url = resolvedUrl instanceof Request | ||
| ? resolvedUrl.url | ||
| : String(resolvedUrl); | ||
| return url.split('#', 1)[0]; | ||
| } | ||
| export function deleteHeaders(headers, headerNames) { | ||
| for (const headerName of headerNames) { | ||
| headers.delete(headerName); | ||
| } | ||
| return headers; | ||
| } | ||
| export function setHeaders(headers, sourceHeaders) { | ||
| if (!sourceHeaders) { | ||
| return headers; | ||
| } | ||
| for (const [key, value] of new Headers(sourceHeaders)) { | ||
| headers.set(key, value); | ||
| } | ||
| return headers; | ||
| } | ||
| export function getRequestReplayHeaders(urlOrRequest, options = {}) { | ||
| const request = urlOrRequest instanceof Request ? urlOrRequest : undefined; | ||
| const requestHeaders = new Headers(request?.headers); | ||
| const callHeaders = new Headers(options.headers); | ||
| if (request && options.body !== undefined) { | ||
| deleteHeaders(callHeaders, options[inheritedRequestBodyHeaderNamesSymbol] ?? []); | ||
| } | ||
| return setHeaders(requestHeaders, callHeaders); | ||
| } | ||
| export function resolveRequestHeaders(fetchFunction, urlOrRequest, options = {}) { | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, options) ?? getRequestReplayHeaders(urlOrRequest, options); | ||
| } | ||
| export function resolveRequestBody(fetchFunction, urlOrRequest, options = {}) { | ||
| return fetchFunction[resolveRequestBodySymbol]?.(urlOrRequest, options) ?? options.body; | ||
| } | ||
| export function getRequestOptions(urlOrRequest, options = {}) { | ||
| return urlOrRequest instanceof Request | ||
| ? {...requestSnapshot(urlOrRequest), ...options} | ||
| : {...options}; | ||
| } | ||
| export function resolveRequestBodyOptions(fetchFunction, urlOrRequest, options = {}) { | ||
| const body = resolveRequestBody(fetchFunction, urlOrRequest, options); | ||
| return body === options.body | ||
| ? options | ||
| : {...options, body}; | ||
| } | ||
| export function hasHeaders(headers) { | ||
| return !headers.keys().next().done; | ||
| } | ||
| export function getRequestSignal(urlOrRequest, options = {}) { | ||
| return options.signal ?? (urlOrRequest instanceof Request ? urlOrRequest.signal : undefined); | ||
| } | ||
| export function getTimeoutSignal(timeout, providedSignal) { | ||
| const timeoutSignal = AbortSignal.timeout(timeout); | ||
| if (providedSignal) { | ||
| return AbortSignal.any([providedSignal, timeoutSignal]); | ||
| } | ||
| return timeoutSignal; | ||
| } | ||
| export function getFetchSignal(fetchFunction, providedSignal) { | ||
| const timeoutDuration = fetchFunction[timeoutDurationSymbol]; | ||
| if (timeoutDuration === undefined) { | ||
| return providedSignal; | ||
| } | ||
| return getTimeoutSignal(timeoutDuration, providedSignal); | ||
| } | ||
| export function notifyFetchStart(fetchFunction, options) { | ||
| if (fetchFunction[defersFetchStartSymbol]) { | ||
| return; | ||
| } | ||
| options[notifyFetchStartSymbol]?.(); | ||
| } | ||
| export async function discardBody(body) { | ||
| try { | ||
| await body?.cancel?.(); | ||
| } catch {} | ||
| } | ||
| export function requestSnapshot(request) { | ||
| return { | ||
| method: request.method, | ||
| referrer: request.referrer, | ||
| referrerPolicy: request.referrerPolicy, | ||
| mode: request.mode, | ||
| credentials: request.credentials, | ||
| cache: request.cache, | ||
| redirect: request.redirect, | ||
| integrity: request.integrity, | ||
| keepalive: request.keepalive, | ||
| signal: request.signal, | ||
| duplex: request.duplex, | ||
| priority: request.priority, | ||
| }; | ||
| } | ||
| export function copyFetchMetadata(targetFetch, sourceFetch) { | ||
| /* | ||
| Boundary: this only forwards metadata that outer wrappers need to preserve their documented behavior. | ||
| Right now that is timeoutDurationSymbol, resolveRequestUrlSymbol, resolveAuthorizationHeaderSymbol, resolveRequestHeadersSymbol, and resolveRequestBodySymbol so wrappers can preserve timeout behavior, URL-based composition semantics, Authorization-scoped refresh deduplication, effective request-header inspection, and replayable transformed request bodies through simple wrapper chains. | ||
| Nested withTimeout wrappers are not a supported contract. Keep timeout forwarding simple and let the outermost documented withTimeout define the budget. | ||
| Do not expand this into a generic wrapper-introspection channel. | ||
| */ | ||
| if (sourceFetch[timeoutDurationSymbol] !== undefined) { | ||
| targetFetch[timeoutDurationSymbol] = sourceFetch[timeoutDurationSymbol]; | ||
| } | ||
| if (targetFetch[resolveRequestUrlSymbol] === undefined && sourceFetch[resolveRequestUrlSymbol] !== undefined) { | ||
| targetFetch[resolveRequestUrlSymbol] = sourceFetch[resolveRequestUrlSymbol]; | ||
| } | ||
| if (targetFetch[resolveAuthorizationHeaderSymbol] === undefined && sourceFetch[resolveAuthorizationHeaderSymbol] !== undefined) { | ||
| targetFetch[resolveAuthorizationHeaderSymbol] = sourceFetch[resolveAuthorizationHeaderSymbol]; | ||
| } | ||
| if (targetFetch[resolveRequestHeadersSymbol] === undefined && sourceFetch[resolveRequestHeadersSymbol] !== undefined) { | ||
| targetFetch[resolveRequestHeadersSymbol] = sourceFetch[resolveRequestHeadersSymbol]; | ||
| } | ||
| if (targetFetch[resolveRequestBodySymbol] === undefined && sourceFetch[resolveRequestBodySymbol] !== undefined) { | ||
| targetFetch[resolveRequestBodySymbol] = sourceFetch[resolveRequestBodySymbol]; | ||
| } | ||
| if (targetFetch[defersFetchStartSymbol] === undefined && sourceFetch[defersFetchStartSymbol] !== undefined) { | ||
| targetFetch[defersFetchStartSymbol] = sourceFetch[defersFetchStartSymbol]; | ||
| } | ||
| return targetFetch; | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
140287
248.54%41
412.5%3220
283.79%1
Infinity%74
-75.17%30
400%+ Added
+ Added