fetch-extras
Advanced tools
+1
-1
| { | ||
| "name": "fetch-extras", | ||
| "version": "2.1.1", | ||
| "version": "3.0.0", | ||
| "description": "Useful utilities for working with Fetch", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+8
-6
@@ -47,7 +47,7 @@ <h1 align="center" title="fetch-extras"> | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| withJsonResponse, | ||
| withTimeout(5000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withHeaders({Authorization: 'Bearer token'}), | ||
| withHttpError(), | ||
| withJsonResponse(), | ||
| ); | ||
@@ -58,2 +58,4 @@ | ||
| `pipeline()` order is the documented order throughout this package. Runtime wrapper nesting is the inverse, so `pipeline(fetch, withTimeout(5000), withHeaders(headers))` becomes `withHeaders(headers)(withTimeout(5000)(fetch))`. | ||
| ## API | ||
@@ -63,3 +65,3 @@ | ||
| Listed in the recommended wrapping order for use with [`pipeline`](docs/pipeline.md). | ||
| Listed in the recommended [`pipeline`](docs/pipeline.md) order. Read the list top to bottom as the order you pass wrappers to `pipeline()`. | ||
@@ -66,0 +68,0 @@ - [`withTimeout`](docs/with-timeout.md) - Abort requests that take too long |
@@ -20,3 +20,3 @@ /** | ||
| readonly code: 'ERR_HTTP_RESPONSE_NOT_OK'; | ||
| response: Response; | ||
| readonly response: Response; | ||
@@ -71,4 +71,3 @@ /** | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that will throw HttpError for non-2xx responses. | ||
| @returns A function that accepts a fetch function and returns a wrapped fetch function that throws `HttpError` for non-2xx responses. | ||
@@ -79,3 +78,3 @@ @example | ||
| const fetchWithError = withHttpError(fetch); | ||
| const fetchWithError = withHttpError()(fetch); | ||
| const response = await fetchWithError('/api'); // Throws HttpError for non-2xx responses | ||
@@ -85,4 +84,2 @@ const data = await response.json(); | ||
| */ | ||
| export function withHttpError<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction | ||
| ): (...arguments_: Parameters<FetchFunction>) => ReturnType<FetchFunction>; | ||
| export function withHttpError(): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -31,9 +31,11 @@ import {copyFetchMetadata} from './utilities.js'; | ||
| export function withHttpError(fetchFunction) { | ||
| const fetchWithHttpError = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| return throwIfHttpError(response); | ||
| export function withHttpError() { | ||
| return fetchFunction => { | ||
| const fetchWithHttpError = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| return throwIfHttpError(response); | ||
| }; | ||
| return copyFetchMetadata(fetchWithHttpError, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithHttpError, fetchFunction); | ||
| } |
+14
-14
@@ -8,3 +8,3 @@ /** | ||
| By default, it calls `response.json()` and expects an array. | ||
| Default: `response => response.json()` | ||
@@ -39,3 +39,3 @@ @param response - The Response object from the fetch request. | ||
| **Note**: Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. When the next page crosses to a different origin, inherited request headers are already cleared before your returned headers are applied. Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request. | ||
| **Note**: Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. When the next page crosses to a different origin, inherited request headers are already cleared before your returned headers are applied. Then wrappers such as `withHeaders()` run again for that next page. Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request. Function-based `withHeaders()` defaults are still resolved per page after that merge. | ||
@@ -53,8 +53,6 @@ @param data - Context object with response, current URL, and items. | ||
| 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}`) | ||
| }; | ||
| const cursor = response.headers.get('X-Next-Cursor'); | ||
| return cursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${cursor}`)} | ||
| : false; | ||
| } | ||
@@ -73,2 +71,3 @@ } | ||
| let nextCursor; | ||
| for await (const item of paginate('https://api.example.com/items', { | ||
@@ -78,3 +77,2 @@ pagination: { | ||
| const data = await response.json(); | ||
| // Store pagination info in closure | ||
| nextCursor = data.nextCursor; | ||
@@ -84,6 +82,5 @@ return data.items; | ||
| paginate: () => { | ||
| if (!nextCursor) return false; | ||
| return { | ||
| url: new URL(`https://api.example.com/items?cursor=${nextCursor}`) | ||
| }; | ||
| return nextCursor | ||
| ? {url: new URL(`https://api.example.com/items?cursor=${nextCursor}`)} | ||
| : false; | ||
| } | ||
@@ -250,4 +247,6 @@ } | ||
| **Important**: When pagination crosses to a different origin, inherited request headers are cleared before the next request is built. If you intentionally need headers on the new origin, return them explicitly from `pagination.paginate`. | ||
| **Important**: When pagination crosses to a different origin, inherited request headers are cleared before the next request is built. Then wrappers like `withHeaders()` run again for that next page. If you intentionally need extra per-page headers on the new origin, return them explicitly from `pagination.paginate`. | ||
| **Note**: Later pages are new requests. If your `fetchFunction` uses `withHeaders()`, its defaults still apply on each page. Function-based defaults are re-resolved for each page instead of being frozen from the first page. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
@@ -335,2 +334,3 @@ @param options - Fetch options plus pagination options. | ||
| }); | ||
| console.log(`Fetched ${commits.length} commits`); | ||
@@ -337,0 +337,0 @@ ``` |
+98
-46
@@ -5,6 +5,8 @@ import parseLinkHeader from './parse-link-header.js'; | ||
| delay, | ||
| markResolvedRequestHeaders, | ||
| requestBodyHeaderNames, | ||
| requestSnapshot, | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeaders, | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeadersSymbol, | ||
| } from './utilities.js'; | ||
@@ -130,2 +132,10 @@ | ||
| if (!shouldStripBodyHeaders(fetchOptions)) { | ||
| if ('headers' in fetchOptions) { | ||
| return new Request(input.url, { | ||
| ...requestSnapshot(input), | ||
| ...fetchOptions, | ||
| headers: new Headers(fetchOptions.headers), | ||
| }); | ||
| } | ||
| return new Request(input, fetchOptions); | ||
@@ -152,15 +162,48 @@ } | ||
| 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}; | ||
| const cloneRequest = (url, request, headers = request.headers) => new Request(url, { | ||
| ...requestSnapshot(request), | ||
| headers: new Headers(headers), | ||
| body: request.body ?? undefined, | ||
| }); | ||
| return { | ||
| requestTemplate: createRequestTemplate(input, templateFetchOptions), | ||
| fetchOptions: createTemplateFetchOptions(templateFetchOptions), | ||
| }; | ||
| const createResolvedRequestTemplate = async (fetchFunction, input, fetchOptions) => { | ||
| const bodyResolvedFetchOptions = resolveRequestBodyOptions(fetchFunction, input, fetchOptions); | ||
| // The template bakes in the resolved body; callers strip body/headers via createTemplateFetchOptions, so original fetchOptions is correct here. | ||
| return [ | ||
| createRequestTemplate(input, bodyResolvedFetchOptions), | ||
| fetchOptions, | ||
| ]; | ||
| }; | ||
| const createCurrentInput = async (fetchFunction, currentUrl, requestTemplate, fetchOptions) => { | ||
| const clonedTemplate = requestTemplate.clone(); | ||
| const currentInput = cloneRequest(currentUrl, clonedTemplate); | ||
| const blockedDefaultHeaderNames = requestTemplate[blockedDefaultHeaderNamesSymbol]; | ||
| if (blockedDefaultHeaderNames) { | ||
| markToBlockDefaultHeaders(currentInput, blockedDefaultHeaderNames); | ||
| } | ||
| if (fetchFunction[resolveRequestHeadersSymbol] === undefined) { | ||
| return currentInput; | ||
| } | ||
| /* | ||
| Boundary: later pages are new requests, not retries, so dynamic defaults from withHeaders() must be re-resolved here instead of being baked into the stored template. | ||
| The template only preserves replayable request state like the body and non-header RequestInit fields across pages. | ||
| */ | ||
| const resolvedHeaders = await resolveRequestHeaders(fetchFunction, currentInput, { | ||
| ...fetchOptions, | ||
| signal: currentSignal(requestTemplate, fetchOptions), | ||
| }); | ||
| const resolvedInput = cloneRequest(currentUrl, currentInput, resolvedHeaders); | ||
| if (blockedDefaultHeaderNames) { | ||
| markToBlockDefaultHeaders(resolvedInput, blockedDefaultHeaderNames); | ||
| } | ||
| return markResolvedRequestHeaders(resolvedInput); | ||
| }; | ||
| const shouldUseRequestTemplateOnFirstRequest = (input, fetchOptions) => input instanceof Request || (absoluteUrl(input) && fetchOptions.body instanceof ReadableStream); | ||
@@ -173,21 +216,17 @@ | ||
| /* | ||
| Boundary: following a Link header is not an HTTP redirect. We are constructing a new request for a new URL, often in environments like Node where callers can set arbitrary credential headers. The standards only special-case a few headers for browser-controlled redirects, which is not enough here because secrets often live in custom headers such as x-api-key. So when pagination crosses origins, inherited headers are cleared and only headers explicitly returned from pagination.paginate are kept for the new origin. | ||
| Boundary: following a Link header is not an HTTP redirect. We are constructing a new request for a new URL, often in environments like Node where callers can set arbitrary credential headers. So when pagination crosses origins, only the carried request header state is cleared. Then inner wrappers such as withHeaders() run again for the next page. | ||
| */ | ||
| const clearCrossOriginHeaderState = (fetchFunction, input, requestTemplate, fetchOptions) => { | ||
| const blockedHeaderNames = getHeaderNames(resolveRequestHeaders(fetchFunction, input, fetchOptions)); | ||
| const nextFetchOptions = markToBlockDefaultHeaders( | ||
| 'headers' in fetchOptions | ||
| ? { | ||
| ...fetchOptions, | ||
| headers: markToBlockDefaultHeaders(new Headers(), blockedHeaderNames), | ||
| } | ||
| : {...fetchOptions}, | ||
| blockedHeaderNames, | ||
| ); | ||
| const clearCrossOriginHeaderState = (requestTemplate, fetchOptions) => { | ||
| const nextFetchOptions = 'headers' in fetchOptions | ||
| ? { | ||
| ...fetchOptions, | ||
| headers: new Headers(), | ||
| } | ||
| : {...fetchOptions}; | ||
| return { | ||
| requestTemplate: requestTemplate && markToBlockDefaultHeaders(new Request(requestTemplate.url, { | ||
| requestTemplate: requestTemplate && new Request(requestTemplate.url, { | ||
| ...requestSnapshot(requestTemplate), | ||
| headers: new Headers(), | ||
| }), blockedHeaderNames), | ||
| }), | ||
| currentFetchOptions: hasExplicitBody(fetchOptions) ? normalizeBodylessFetchOptions(nextFetchOptions) : nextFetchOptions, | ||
@@ -303,5 +342,13 @@ inheritedStateOrigin: undefined, | ||
| let numberOfRequests = 0; | ||
| let requestTemplate = shouldUseRequestTemplateOnFirstRequest(input, fetchOptions) ? createRequestTemplate(input, fetchOptions) : undefined; | ||
| const shouldUseRequestTemplate = shouldUseRequestTemplateOnFirstRequest(input, fetchOptions); | ||
| let requestTemplate; | ||
| let currentFetchOptions; | ||
| if (shouldUseRequestTemplate) { | ||
| [requestTemplate, currentFetchOptions] = await createResolvedRequestTemplate(fetchFunction, input, fetchOptions); | ||
| } else { | ||
| currentFetchOptions = normalizeFetchOptions(fetchOptions); | ||
| } | ||
| let currentUrl = requestTemplate ? new URL(requestTemplate.url) : input; | ||
| let currentFetchOptions = requestTemplate ? createTemplateFetchOptions(fetchOptions) : normalizeFetchOptions(fetchOptions); | ||
| let inheritedStateOrigin = absoluteUrl(requestTemplate ? requestTemplate.url : input); | ||
@@ -318,13 +365,21 @@ | ||
| if (requestTemplate) { | ||
| currentInput = new Request(currentUrl, requestTemplate.clone()); | ||
| if (requestTemplate[blockedDefaultHeaderNamesSymbol]) { | ||
| markToBlockDefaultHeaders(currentInput, requestTemplate[blockedDefaultHeaderNamesSymbol]); | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| currentInput = await createCurrentInput(fetchFunction, currentUrl, requestTemplate, currentFetchOptions); | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| const response = await fetchFunction(currentInput, currentFetchOptions); | ||
| const response = await fetchFunction( | ||
| currentInput, | ||
| requestTemplate ? createTemplateFetchOptions(currentFetchOptions) : currentFetchOptions, | ||
| ); | ||
| const currentResponseUrl = responseUrl(response, currentUrl); | ||
| const shouldClearLaterStreamBody = !requestTemplate && currentFetchOptions.body instanceof ReadableStream; | ||
| if (shouldClearLaterStreamBody) { | ||
| currentFetchOptions = normalizeFetchOptions({ | ||
| ...currentFetchOptions, | ||
| body: undefined, | ||
| }); | ||
| } | ||
| currentUrl = currentResponseUrl; | ||
@@ -334,4 +389,2 @@ | ||
| ({requestTemplate, currentFetchOptions, inheritedStateOrigin} = clearCrossOriginHeaderState( | ||
| fetchFunction, | ||
| requestTemplate ?? currentUrl, | ||
| requestTemplate, | ||
@@ -398,13 +451,4 @@ 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 (shouldStripInheritedHeaderState(inheritedStateOrigin, nextRequestUrl)) { | ||
| ({requestTemplate, currentFetchOptions, inheritedStateOrigin} = clearCrossOriginHeaderState( | ||
| fetchFunction, | ||
| requestTemplate ?? currentUrl, | ||
| requestTemplate, | ||
@@ -418,7 +462,15 @@ currentFetchOptions, | ||
| const {url: _, ...restNextPageOptions} = nextPageOptions; | ||
| const nextFetchOptions = {...currentFetchOptions, ...restNextPageOptions}; | ||
| const nextFetchOptions = { | ||
| ...(!requestTemplate && currentFetchOptions.body instanceof ReadableStream | ||
| ? { | ||
| ...currentFetchOptions, | ||
| body: undefined, | ||
| } | ||
| : currentFetchOptions), | ||
| ...restNextPageOptions, | ||
| }; | ||
| if (requestTemplate) { | ||
| requestTemplate = createRequestTemplate(requestTemplate, nextFetchOptions); | ||
| currentFetchOptions = createTemplateFetchOptions(nextFetchOptions); | ||
| currentFetchOptions = nextFetchOptions; | ||
| } else { | ||
@@ -425,0 +477,0 @@ currentFetchOptions = normalizeFetchOptions(shouldClearInheritedBody(nextFetchOptions, restNextPageOptions) ? {...nextFetchOptions, body: undefined} : nextFetchOptions); |
@@ -0,1 +1,3 @@ | ||
| const tokenPattern = /^[!#$%&'*+.^`|~\w-]+$/; | ||
| function splitHeaderValue(value, separator) { | ||
@@ -54,2 +56,6 @@ const parts = []; | ||
| if (insideUrl || insideQuotes || isEscaped) { | ||
| throw new Error('Invalid Link header format'); | ||
| } | ||
| parts.push(value.slice(startIndex)); | ||
@@ -101,4 +107,10 @@ | ||
| if (!tokenPattern.test(name)) { | ||
| throw new Error(`Invalid Link header format: ${trimmedParameter}`); | ||
| } | ||
| if (value.startsWith('"') && value.endsWith('"')) { | ||
| value = value.slice(1, -1).replaceAll(/\\(.)/g, '$1'); | ||
| } else if (value !== '' && !tokenPattern.test(value)) { | ||
| throw new Error(`Invalid Link header format: ${trimmedParameter}`); | ||
| } | ||
@@ -105,0 +117,0 @@ |
+23
-9
@@ -6,2 +6,4 @@ /** | ||
| For `with*` wrappers, that left-to-right `pipeline()` order is the canonical documented order throughout this package. The resulting runtime wrapper nesting is the inverse of that order. | ||
| You can write: | ||
@@ -12,6 +14,6 @@ | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| withTimeout(5000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withHeaders({Authorization: 'Bearer token'}), | ||
| withHttpError(), | ||
| ); | ||
@@ -22,2 +24,14 @@ ``` | ||
| Equivalent nested form: | ||
| ``` | ||
| const apiFetch = withHttpError()( | ||
| withHeaders({Authorization: 'Bearer token'})( | ||
| withBaseUrl('https://api.example.com')( | ||
| withTimeout(5000)(fetch), | ||
| ), | ||
| ), | ||
| ); | ||
| ``` | ||
| @param value - The initial value to pipe through. | ||
@@ -38,6 +52,6 @@ @param functions - Functions to apply in order. Each function receives the previous function's return value and may return a different type. | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| withHttpError, | ||
| withTimeout(5000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withHeaders({Authorization: 'Bearer token'}), | ||
| withHttpError(), | ||
| ); | ||
@@ -51,3 +65,3 @@ | ||
| // 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. | ||
| // infer callback parameter types for the documented pipeline(fetch, withTimeout(5000), withHttpError()) pattern. | ||
| export function pipeline<Value>(value: Value): Value; | ||
@@ -54,0 +68,0 @@ export function pipeline<Value, Result1>(value: Value, function1: (value: Value) => Result1): Result1; |
+45
-7
| export const blockedDefaultHeaderNamesSymbol = Symbol('blockedDefaultHeaderNames'); | ||
| export const inheritedRequestBodyHeaderNamesSymbol = Symbol('inheritedRequestBodyHeaderNames'); | ||
| export const resolvedRequestHeadersOptionSymbol = Symbol('resolvedRequestHeadersOption'); | ||
| export const timeoutDurationSymbol = Symbol('timeoutDuration'); | ||
| export const resolveRequestUrlSymbol = Symbol('resolveRequestUrl'); | ||
| export const resolveAuthorizationHeaderSymbol = Symbol('resolveAuthorizationHeader'); | ||
| export const resolveRequestHeadersSymbol = Symbol('resolveRequestHeaders'); | ||
@@ -334,3 +334,8 @@ export const resolveRequestBodySymbol = Symbol('resolveRequestBody'); | ||
| export function resolveRequestHeaders(fetchFunction, urlOrRequest, options = {}) { | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, options) ?? getRequestReplayHeaders(urlOrRequest, options); | ||
| const resolvedOptions = withFetchSignal(fetchFunction, urlOrRequest, options); | ||
| if (hasResolvedRequestHeaders(urlOrRequest, resolvedOptions)) { | ||
| return getRequestReplayHeaders(urlOrRequest, resolvedOptions); | ||
| } | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, resolvedOptions) ?? getRequestReplayHeaders(urlOrRequest, resolvedOptions); | ||
| } | ||
@@ -359,2 +364,31 @@ | ||
| export function hasResolvedRequestHeaders(urlOrRequest, options = {}) { | ||
| return Boolean(urlOrRequest?.[resolvedRequestHeadersOptionSymbol] || options[resolvedRequestHeadersOptionSymbol]); | ||
| } | ||
| export function withResolvedRequestHeaders(options, headers) { | ||
| return { | ||
| ...options, | ||
| headers, | ||
| [resolvedRequestHeadersOptionSymbol]: true, | ||
| }; | ||
| } | ||
| /** | ||
| Resolves effective request headers and, when needed, marks them as already resolved for downstream wrappers. | ||
| @param {typeof fetch} fetchFunction - The fetch function whose header resolver metadata should be honored. | ||
| @param {RequestInfo | URL} urlOrRequest - The current request input. | ||
| @param {RequestInit} [options] - Request options associated with the input. | ||
| @returns {Promise<Headers>} The resolved headers. | ||
| */ | ||
| export async function getResolvedRequestHeaders(fetchFunction, urlOrRequest, options = {}) { | ||
| return new Headers(await resolveRequestHeaders(fetchFunction, urlOrRequest, options)); | ||
| } | ||
| export function markResolvedRequestHeaders(input) { | ||
| input[resolvedRequestHeadersOptionSymbol] = true; | ||
| return input; | ||
| } | ||
| export function getRequestSignal(urlOrRequest, options = {}) { | ||
@@ -384,2 +418,10 @@ return options.signal ?? (urlOrRequest instanceof Request ? urlOrRequest.signal : undefined); | ||
| export function withFetchSignal(fetchFunction, urlOrRequest, options = {}) { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| return signal | ||
| ? {...options, signal} | ||
| : options; | ||
| } | ||
| export function notifyFetchStart(fetchFunction, options) { | ||
@@ -419,3 +461,3 @@ if (fetchFunction[defersFetchStartSymbol]) { | ||
| 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. | ||
| Right now that is timeoutDurationSymbol, resolveRequestUrlSymbol, resolveRequestHeadersSymbol, and resolveRequestBodySymbol so wrappers can preserve timeout behavior, URL-based composition semantics, 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. | ||
@@ -432,6 +474,2 @@ Do not expand this into a generic wrapper-introspection channel. | ||
| if (targetFetch[resolveAuthorizationHeaderSymbol] === undefined && sourceFetch[resolveAuthorizationHeaderSymbol] !== undefined) { | ||
| targetFetch[resolveAuthorizationHeaderSymbol] = sourceFetch[resolveAuthorizationHeaderSymbol]; | ||
| } | ||
| if (targetFetch[resolveRequestHeadersSymbol] === undefined && sourceFetch[resolveRequestHeadersSymbol] !== undefined) { | ||
@@ -438,0 +476,0 @@ targetFetch[resolveRequestHeadersSymbol] = sourceFetch[resolveRequestHeadersSymbol]; |
| /** | ||
| Returns a wrapped fetch function that resolves relative URLs against a base URL. Useful for API clients with a consistent base URL. | ||
| Wraps a fetch function to resolve relative URLs against a base URL. Useful for API clients with a consistent base URL. | ||
@@ -8,5 +8,4 @@ Only string-based relative URLs are resolved against the base URL. Absolute URLs and URL objects are passed through unchanged. Protocol-relative inputs like `//cdn.example.com/file.js` are rejected to avoid escaping the configured origin. Relative paths are resolved against the base URL's pathname, while query-only and fragment-only inputs keep normal URL semantics. | ||
| @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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that resolves relative URLs against the base URL. | ||
@@ -17,3 +16,3 @@ @example | ||
| const fetchWithBaseUrl = withBaseUrl(fetch, 'https://api.example.com'); | ||
| const fetchWithBaseUrl = withBaseUrl('https://api.example.com')(fetch); | ||
| const response = await fetchWithBaseUrl('/users'); // Requests https://api.example.com/users | ||
@@ -26,4 +25,4 @@ const data = await response.json(); | ||
| // 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/'); | ||
| const fetch1 = withBaseUrl('https://api.example.com/v1')(fetch); | ||
| const fetch2 = withBaseUrl('https://api.example.com/v1/')(fetch); | ||
@@ -41,5 +40,5 @@ await fetch1('users'); // https://api.example.com/v1/users | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withHttpError, | ||
| withTimeout(5000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withHttpError(), | ||
| ); | ||
@@ -54,3 +53,3 @@ | ||
| const fetchWithBaseUrl = withBaseUrl(fetch, 'https://api.github.com'); | ||
| const fetchWithBaseUrl = withBaseUrl('https://api.github.com')(fetch); | ||
@@ -63,4 +62,3 @@ for await (const commit of paginate('/repos/sindresorhus/ky/commits', {fetchFunction: fetchWithBaseUrl})) { | ||
| export function withBaseUrl( | ||
| fetchFunction: typeof fetch, | ||
| baseUrl: URL | string | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+45
-43
@@ -6,62 +6,64 @@ import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| @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. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} | ||
| */ | ||
| export function withBaseUrl(fetchFunction, baseUrl) { | ||
| export function withBaseUrl(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 fetchFunction => { | ||
| let baseUrlObject; | ||
| const getBaseUrlObject = () => { | ||
| if (!baseUrlObject) { | ||
| try { | ||
| baseUrlObject = new URL(baseUrlString); | ||
| } catch (error) { | ||
| throw new TypeError(`Invalid base URL: ${error.message}`); | ||
| } | ||
| } | ||
| } | ||
| return baseUrlObject; | ||
| }; | ||
| return baseUrlObject; | ||
| }; | ||
| const resolveRequestUrl = urlOrRequest => { | ||
| if (typeof urlOrRequest !== 'string') { | ||
| return urlOrRequest instanceof Request ? urlOrRequest.url : String(urlOrRequest); | ||
| } | ||
| 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 (/^[a-z][a-z\d+\-.]*:/i.test(urlOrRequest)) { | ||
| return urlOrRequest; | ||
| } | ||
| if (urlOrRequest === '') { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| if (urlOrRequest === '') { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| if (/^\/\/[^/]/.test(urlOrRequest)) { | ||
| throw new TypeError('Protocol-relative URLs are unsupported.'); | ||
| } | ||
| if (/^\/\/[^/]/.test(urlOrRequest)) { | ||
| throw new TypeError('Protocol-relative URLs are unsupported.'); | ||
| } | ||
| if (/^[?#]/.test(urlOrRequest)) { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| if (/^[?#]/.test(urlOrRequest)) { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| const baseUrlForPath = new URL(getBaseUrlObject()); | ||
| baseUrlForPath.search = ''; | ||
| baseUrlForPath.hash = ''; | ||
| const baseUrlForPath = new URL(getBaseUrlObject()); | ||
| baseUrlForPath.search = ''; | ||
| baseUrlForPath.hash = ''; | ||
| if (!baseUrlForPath.pathname.endsWith('/')) { | ||
| baseUrlForPath.pathname = `${baseUrlForPath.pathname}/`; | ||
| } | ||
| if (!baseUrlForPath.pathname.endsWith('/')) { | ||
| baseUrlForPath.pathname = `${baseUrlForPath.pathname}/`; | ||
| } | ||
| return new URL(urlOrRequest.replace(/^\/+/, ''), baseUrlForPath).href; | ||
| }; | ||
| return new URL(urlOrRequest.replace(/^\/+/, ''), baseUrlForPath).href; | ||
| }; | ||
| const fetchWithBaseUrl = async (urlOrRequest, options = {}) => fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrl(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| const fetchWithBaseUrl = async (urlOrRequest, options = {}) => fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrl(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| fetchWithBaseUrl[resolveRequestUrlSymbol] = resolveRequestUrl; | ||
| fetchWithBaseUrl[resolveRequestUrlSymbol] = resolveRequestUrl; | ||
| return copyFetchMetadata(fetchWithBaseUrl, fetchFunction); | ||
| return copyFetchMetadata(fetchWithBaseUrl, fetchFunction); | ||
| }; | ||
| } |
@@ -10,11 +10,10 @@ /** | ||
| The cache key is the URL only, so `withCache()` only caches plain unconditional GET requests without request headers. If a GET request carries any explicit or inherited headers, including auth, cookies, or validators like `If-None-Match`, it is treated as uncacheable and bypasses this wrapper's in-memory cache. With `cache: 'only-if-cached'`, that still means a cache miss and the wrapper returns its synthetic `504` response. | ||
| The cache key is the URL only, so `withCache()` only caches plain unconditional GET requests in the default fetch mode. If a GET request carries any explicit or inherited headers, including auth, cookies, or validators like `If-None-Match`, or it changes request metadata like `credentials`, `integrity`, `mode`, `redirect`, `referrer`, or `referrerPolicy`, it is treated as uncacheable and bypasses this wrapper's in-memory cache. With `cache: 'only-if-cached'`, that still means a cache miss and the wrapper returns its synthetic `504` response. | ||
| 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. | ||
| In documented `pipeline()` order, place `withCache` after `withBaseUrl` 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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that caches GET responses. | ||
@@ -25,3 +24,3 @@ @example | ||
| const cachedFetch = withCache(fetch, {ttl: 60_000}); | ||
| const cachedFetch = withCache({ttl: 60_000})(fetch); | ||
@@ -41,5 +40,5 @@ const response = await cachedFetch('https://api.example.com/data'); | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withCache(f, {ttl: 30_000}), | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withCache({ttl: 30_000}), | ||
| withHttpError(), | ||
| ); | ||
@@ -51,3 +50,2 @@ | ||
| export function withCache( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
@@ -59,2 +57,2 @@ /** | ||
| }, | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+137
-83
| import { | ||
| copyFetchMetadata, | ||
| defersFetchStartSymbol, | ||
| getRequestReplayHeaders, | ||
| hasHeaders, | ||
| hasResolvedRequestHeaders, | ||
| notifyFetchStartSymbol, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| withFetchSignal, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
| const nonInvalidatingMethods = new Set(['HEAD', 'OPTIONS', 'TRACE']); | ||
| const defaultCacheableRequestState = (() => { | ||
| const request = new Request('https://example.com'); | ||
| function getHeaders(urlOrRequest, options) { | ||
| const resolvedHeaders = this?.[resolveRequestHeadersSymbol]?.(urlOrRequest, options); | ||
| return { | ||
| credentials: request.credentials, | ||
| integrity: request.integrity, | ||
| mode: request.mode, | ||
| redirect: request.redirect, | ||
| referrer: request.referrer, | ||
| referrerPolicy: request.referrerPolicy, | ||
| }; | ||
| })(); | ||
| if (resolvedHeaders) { | ||
| return new Headers(resolvedHeaders); | ||
| function hasNonDefaultRequestState(urlOrRequest, options) { | ||
| const request = urlOrRequest instanceof Request ? urlOrRequest : undefined; | ||
| for (const [key, defaultValue] of Object.entries(defaultCacheableRequestState)) { | ||
| if ((options[key] ?? request?.[key] ?? defaultValue) !== defaultValue) { | ||
| return true; | ||
| } | ||
| } | ||
| return new Headers(options.headers ?? (urlOrRequest instanceof Request ? urlOrRequest.headers : undefined)); | ||
| return false; | ||
| } | ||
| async function getRequestHeaders(fetchFunction, urlOrRequest, fetchOptions) { | ||
| const requestReplayHeaders = getRequestReplayHeaders(urlOrRequest, fetchOptions); | ||
| if (hasResolvedRequestHeaders(urlOrRequest, fetchOptions)) { | ||
| return requestReplayHeaders; | ||
| } | ||
| const requestHeaders = await fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, fetchOptions); | ||
| return requestHeaders ?? requestReplayHeaders; | ||
| } | ||
| function getRequestContext(fetchFunction, urlOrRequest, options) { | ||
| const headers = getHeaders.call(fetchFunction, urlOrRequest, options); | ||
| const fetchOptions = withFetchSignal(fetchFunction, urlOrRequest, options); | ||
@@ -29,5 +58,20 @@ return { | ||
| cacheMode: options.cache ?? (urlOrRequest instanceof Request ? urlOrRequest.cache : undefined), | ||
| signal: options.signal ?? (urlOrRequest instanceof Request ? urlOrRequest.signal : undefined), | ||
| signal: fetchOptions.signal, | ||
| fetchOptions, | ||
| }; | ||
| } | ||
| async function getGetRequestContext(fetchFunction, urlOrRequest, options, requestContext) { | ||
| const hasRequestHeaderResolver = fetchFunction[resolveRequestHeadersSymbol] !== undefined; | ||
| const headers = new Headers(await getRequestHeaders(fetchFunction, urlOrRequest, requestContext.fetchOptions)); | ||
| const fetchOptions = hasRequestHeaderResolver | ||
| ? withResolvedRequestHeaders(requestContext.fetchOptions, headers) | ||
| : requestContext.fetchOptions; | ||
| return { | ||
| ...requestContext, | ||
| isRangedRequest: headers.has('range'), | ||
| hasRequestHeaders: hasHeaders(headers), | ||
| hasNonDefaultRequestState: hasNonDefaultRequestState(urlOrRequest, options), | ||
| fetchOptions, | ||
| }; | ||
@@ -107,3 +151,3 @@ } | ||
| export function withCache(fetchFunction, {ttl}) { | ||
| export function withCache({ttl}) { | ||
| if (typeof ttl !== 'number' || ttl <= 0 || !Number.isFinite(ttl)) { | ||
@@ -113,93 +157,103 @@ throw new TypeError('`ttl` must be a positive finite number.'); | ||
| const cache = new Map(); | ||
| const state = new Map(); | ||
| const resources = {cache, state}; | ||
| return fetchFunction => { | ||
| // Cache state is per wrapped fetch function, not per curried wrapper factory. | ||
| const cache = new Map(); | ||
| const state = new Map(); | ||
| const resources = {cache, state}; | ||
| const fetchWithCache = async (urlOrRequest, options = {}) => { | ||
| const { | ||
| method, | ||
| url, | ||
| cacheMode, | ||
| signal, | ||
| isRangedRequest, | ||
| hasRequestHeaders, | ||
| } = getRequestContext(fetchFunction, urlOrRequest, options); | ||
| const retainStaleEntry = cacheMode === 'force-cache' || cacheMode === 'only-if-cached'; | ||
| const currentTime = evictExpiredEntries(cache, state, retainStaleEntry ? url : undefined); | ||
| const isCacheableRequest = !isRangedRequest && !hasRequestHeaders; | ||
| const fetchWithCache = async (urlOrRequest, options = {}) => { | ||
| const {method, url, cacheMode, signal, fetchOptions} = getRequestContext(fetchFunction, urlOrRequest, options); | ||
| // Non-GET requests pass through; unsafe methods also invalidate cache | ||
| if (method !== 'GET') { | ||
| if (nonInvalidatingMethods.has(method)) { | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| // Non-GET requests pass through; unsafe methods also invalidate cache | ||
| if (method !== 'GET') { | ||
| if (nonInvalidatingMethods.has(method)) { | ||
| return fetchFunction(urlOrRequest, fetchOptions); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| signal?.throwIfAborted(); | ||
| const generation = getGeneration(cache, state, url); | ||
| return trackPending(resources, url, 'pendingInvalidationCount', async urlState => { | ||
| let didInvalidate = false; | ||
| const invalidate = () => { | ||
| if (didInvalidate) { | ||
| return; | ||
| } | ||
| 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; | ||
| }; | ||
| didInvalidate = true; | ||
| cache.delete(url); | ||
| urlState.generation = generation + 1; | ||
| }; | ||
| if (!fetchFunction[defersFetchStartSymbol]) { | ||
| invalidate(); | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| if (!fetchFunction[defersFetchStartSymbol]) { | ||
| invalidate(); | ||
| return fetchFunction(urlOrRequest, fetchOptions); | ||
| } | ||
| return fetchFunction(urlOrRequest, {...options, [notifyFetchStartSymbol]: invalidate}); | ||
| }); | ||
| } | ||
| return fetchFunction(urlOrRequest, {...fetchOptions, [notifyFetchStartSymbol]: invalidate}); | ||
| }); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const entry = cache.get(url); | ||
| const cachedResponse = isCacheableRequest | ||
| ? getCachedResponse({ | ||
| entry, | ||
| const requestContext = { | ||
| method, | ||
| url, | ||
| cacheMode, | ||
| currentTime, | ||
| signal, | ||
| fetchOptions, | ||
| }; | ||
| const generation = getGeneration(cache, state, url); | ||
| const { | ||
| isRangedRequest, | ||
| }) | ||
| : undefined; | ||
| if (cachedResponse) { | ||
| return cachedResponse; | ||
| } | ||
| hasRequestHeaders, | ||
| hasNonDefaultRequestState, | ||
| fetchOptions: fetchOptionsWithHeaders, | ||
| } = await getGetRequestContext(fetchFunction, urlOrRequest, options, requestContext); | ||
| const retainStaleEntry = cacheMode === 'force-cache' || cacheMode === 'only-if-cached'; | ||
| const currentTime = evictExpiredEntries(cache, state, retainStaleEntry ? url : undefined); | ||
| const isCacheableRequest = !isRangedRequest && !hasRequestHeaders && !hasNonDefaultRequestState; | ||
| if (cacheMode === 'only-if-cached') { | ||
| return new Response(undefined, {status: 504, statusText: 'Gateway Timeout'}); | ||
| } | ||
| signal?.throwIfAborted(); | ||
| const generation = getGeneration(cache, state, url); | ||
| return trackPending(resources, url, 'pendingGetCount', async () => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| const isPartialResponse = response.status === 206; | ||
| const entry = cache.get(url); | ||
| const cachedResponse = isCacheableRequest | ||
| ? getCachedResponse({ | ||
| entry, | ||
| cacheMode, | ||
| currentTime, | ||
| isRangedRequest, | ||
| }) | ||
| : undefined; | ||
| if (cachedResponse) { | ||
| return cachedResponse; | ||
| } | ||
| // Only cache successful responses. | ||
| if ( | ||
| response.ok | ||
| && isCacheableRequest | ||
| && !isPartialResponse | ||
| && cacheMode !== 'no-store' | ||
| && generation === getGeneration(cache, state, url) | ||
| ) { | ||
| cache.set(url, { | ||
| generation, | ||
| response: response.clone(), | ||
| expiry: performance.now() + ttl, | ||
| }); | ||
| if (cacheMode === 'only-if-cached') { | ||
| return new Response(undefined, {status: 504, statusText: 'Gateway Timeout'}); | ||
| } | ||
| return response; | ||
| }); | ||
| return trackPending(resources, url, 'pendingGetCount', async () => { | ||
| const response = await fetchFunction(urlOrRequest, fetchOptionsWithHeaders); | ||
| const isPartialResponse = response.status === 206; | ||
| // Only cache successful responses. | ||
| if ( | ||
| response.ok | ||
| && isCacheableRequest | ||
| && !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); | ||
| }; | ||
| 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. | ||
| 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. | ||
| This is different from {@link withRateLimit}, which caps how many requests can start within a time window. `withConcurrency` caps how many `fetch()` calls can be running at the same time until they resolve. | ||
| The concurrency state is shared across all calls to the returned function. To limit different hosts independently, create separate `withConcurrency` wrappers. | ||
@@ -8,7 +10,8 @@ | ||
| Streaming or otherwise slow response bodies may continue after their `fetch()` call has resolved, so response-body downloads can overlap. | ||
| 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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that enforces the concurrency limit. | ||
@@ -20,5 +23,5 @@ @example | ||
| // Allow at most 5 fetch() calls to run at the same time | ||
| const concurrentFetch = withConcurrency(fetch, { | ||
| const concurrentFetch = withConcurrency({ | ||
| maxConcurrentRequests: 5, | ||
| }); | ||
| })(fetch); | ||
@@ -35,5 +38,5 @@ const response = await concurrentFetch('/api/data'); | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withConcurrency(f, {maxConcurrentRequests: 5}), | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withConcurrency({maxConcurrentRequests: 5}), | ||
| withHttpError(), | ||
| ); | ||
@@ -45,3 +48,2 @@ | ||
| export function withConcurrency( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
@@ -53,2 +55,2 @@ /** | ||
| }, | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -12,3 +12,3 @@ import { | ||
| export function withConcurrency(fetchFunction, {maxConcurrentRequests}) { | ||
| export function withConcurrency({maxConcurrentRequests}) { | ||
| if (!Number.isInteger(maxConcurrentRequests) || maxConcurrentRequests < 1) { | ||
@@ -21,75 +21,77 @@ throw new TypeError('`maxConcurrentRequests` must be a positive integer.'); | ||
| const tryNext = () => { | ||
| while (queue.length > 0 && activeCount < maxConcurrentRequests) { | ||
| const entry = queue.shift(); | ||
| return fetchFunction => { | ||
| const tryNext = () => { | ||
| while (queue.length > 0 && activeCount < maxConcurrentRequests) { | ||
| const entry = queue.shift(); | ||
| if (entry.signal?.aborted) { | ||
| entry.reject(entry.signal.reason); | ||
| continue; | ||
| if (entry.signal?.aborted) { | ||
| entry.reject(entry.signal.reason); | ||
| continue; | ||
| } | ||
| activeCount++; | ||
| entry.resolve(); | ||
| } | ||
| }; | ||
| activeCount++; | ||
| entry.resolve(); | ||
| } | ||
| }; | ||
| const waitForSlot = async signal => { | ||
| signal?.throwIfAborted(); | ||
| const waitForSlot = async signal => { | ||
| signal?.throwIfAborted(); | ||
| if (activeCount < maxConcurrentRequests) { | ||
| activeCount++; | ||
| return; | ||
| } | ||
| await enqueueAbortable(queue, {signal}); | ||
| }; | ||
| const createReleaseSlot = () => { | ||
| let didReleaseSlot = false; | ||
| return () => { | ||
| if (didReleaseSlot) { | ||
| if (activeCount < maxConcurrentRequests) { | ||
| activeCount++; | ||
| return; | ||
| } | ||
| didReleaseSlot = true; | ||
| activeCount--; | ||
| tryNext(); | ||
| await enqueueAbortable(queue, {signal}); | ||
| }; | ||
| }; | ||
| const fetchWithConcurrency = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| let releaseSlot; | ||
| const createReleaseSlot = () => { | ||
| let didReleaseSlot = false; | ||
| const acquireSlot = async () => { | ||
| if (releaseSlot) { | ||
| return; | ||
| } | ||
| return () => { | ||
| if (didReleaseSlot) { | ||
| return; | ||
| } | ||
| await waitForSlot(signal); | ||
| releaseSlot = createReleaseSlot(); | ||
| didReleaseSlot = true; | ||
| activeCount--; | ||
| tryNext(); | ||
| }; | ||
| }; | ||
| signal?.throwIfAborted(); | ||
| const resolvedOptions = fetchFunction[defersConcurrencySlotSymbol] | ||
| ? {...options, [waitForConcurrencySlotSymbol]: acquireSlot} | ||
| : options; | ||
| const fetchWithConcurrency = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| let releaseSlot; | ||
| try { | ||
| if (!fetchFunction[defersConcurrencySlotSymbol]) { | ||
| await acquireSlot(); | ||
| 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 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; | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithConcurrency, fetchFunction); | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
@@ -10,8 +10,7 @@ /** | ||
| `withDeduplication` does not deduplicate fetch functions already wrapped with `withTimeout`. Place `withTimeout` outside `withDeduplication` if you need per-call timeout behavior. | ||
| `withDeduplication` does not deduplicate fetch functions already wrapped with `withTimeout`. In documented `pipeline()` order, place `withTimeout` before `withDeduplication` if you need per-call timeout behavior. | ||
| Place `withDeduplication` after `withBaseUrl` in a pipeline so the deduplication key is the resolved absolute URL. | ||
| In documented `pipeline()` order, place `withDeduplication` after `withBaseUrl` 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. | ||
| @returns A function that accepts a fetch function and returns a wrapped fetch function that deduplicates concurrent plain GET URL requests. | ||
@@ -22,3 +21,3 @@ @example | ||
| const deduplicatedFetch = withDeduplication(fetch); | ||
| const deduplicatedFetch = withDeduplication()(fetch); | ||
@@ -38,5 +37,5 @@ // These two concurrent requests result in only one network call | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withDeduplication, | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withDeduplication(), | ||
| withHttpError(), | ||
| ); | ||
@@ -47,4 +46,2 @@ | ||
| */ | ||
| export function withDeduplication( | ||
| fetchFunction: typeof fetch, | ||
| ): typeof fetch; | ||
| export function withDeduplication(): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -39,43 +39,46 @@ import {copyFetchMetadata, resolveRequestUrl, timeoutDurationSymbol} from './utilities.js'; | ||
| export function withDeduplication(fetchFunction) { | ||
| const pending = new Map(); | ||
| export function withDeduplication() { | ||
| return fetchFunction => { | ||
| // In-flight deduplication is per wrapped fetch function, not per curried wrapper factory. | ||
| const pending = new Map(); | ||
| const fetchWithDeduplication = async function (urlOrRequest, options) { | ||
| const requestOptions = options ?? {}; | ||
| const fetchWithDeduplication = async function (urlOrRequest, options) { | ||
| const requestOptions = options ?? {}; | ||
| if (!shouldDeduplicate(fetchFunction, urlOrRequest, requestOptions)) { | ||
| return fetchFunction(urlOrRequest, 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 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); | ||
| const entry = {waiters: []}; | ||
| pending.set(key, entry); | ||
| const responsePromise = enqueueWaiter(entry); | ||
| try { | ||
| const response = await fetchFunction(urlOrRequest, requestOptions); | ||
| const [firstWaiter, ...otherWaiters] = entry.waiters; | ||
| try { | ||
| const response = await fetchFunction(urlOrRequest, requestOptions); | ||
| const [firstWaiter, ...otherWaiters] = entry.waiters; | ||
| firstWaiter.resolve(response); | ||
| firstWaiter.resolve(response); | ||
| for (const waiter of otherWaiters) { | ||
| waiter.resolve(response.clone()); | ||
| for (const waiter of otherWaiters) { | ||
| waiter.resolve(response.clone()); | ||
| } | ||
| } catch (error) { | ||
| for (const waiter of entry.waiters) { | ||
| waiter.reject(error); | ||
| } | ||
| } finally { | ||
| pending.delete(key); | ||
| } | ||
| } catch (error) { | ||
| for (const waiter of entry.waiters) { | ||
| waiter.reject(error); | ||
| } | ||
| } finally { | ||
| pending.delete(key); | ||
| } | ||
| return responsePromise; | ||
| return responsePromise; | ||
| }; | ||
| return copyFetchMetadata(fetchWithDeduplication, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithDeduplication, fetchFunction); | ||
| } |
@@ -26,5 +26,4 @@ /** | ||
| @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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that reports download progress. | ||
@@ -35,7 +34,7 @@ @example | ||
| const fetchWithDownloadProgress = withDownloadProgress(fetch, { | ||
| const fetchWithDownloadProgress = withDownloadProgress({ | ||
| onProgress(progress) { | ||
| console.log(`Download: ${Math.round(progress.percent * 100)}%`); | ||
| }, | ||
| }); | ||
| })(fetch); | ||
@@ -47,6 +46,5 @@ const response = await fetchWithDownloadProgress('https://example.com/large-file'); | ||
| export function withDownloadProgress( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
| onProgress?: (progress: Progress) => void; | ||
| } | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -18,17 +18,19 @@ import { | ||
| export function withDownloadProgress(fetchFunction, {onProgress} = {}) { | ||
| const fetchWithDownloadProgress = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| export function withDownloadProgress({onProgress} = {}) { | ||
| return fetchFunction => { | ||
| 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); | ||
| } | ||
| 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 response; | ||
| }; | ||
| return copyFetchMetadata(fetchWithDownloadProgress, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithDownloadProgress, fetchFunction); | ||
| } |
+26
-12
| /** | ||
| 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. | ||
| Wraps a fetch function to include 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. | ||
| @param defaultHeaders - Default headers to include on every request. Accepts a plain object, a `Headers` instance, an array of `[name, value]` tuples, or a function that returns any of these (sync or async). When a function is given, it is called on every request, which is useful for headers that need to be resolved at request time (for example, reading an auth token from storage). | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that merges the default headers into every request. | ||
@@ -14,6 +13,6 @@ @example | ||
| const fetchWithAuth = withHeaders(fetch, { | ||
| const fetchWithAuth = withHeaders({ | ||
| Authorization: 'Bearer my-token', | ||
| 'Content-Type': 'application/json', | ||
| }); | ||
| })(fetch); | ||
@@ -31,2 +30,18 @@ const response = await fetchWithAuth('/api/users'); | ||
| ``` | ||
| import {withHeaders} from 'fetch-extras'; | ||
| // Dynamic headers resolved on every request | ||
| const fetchWithAuth = withHeaders(async () => ({ | ||
| Authorization: `Bearer ${await getTokenFromStorage()}`, | ||
| }))(fetch); | ||
| const response = await fetchWithAuth('/api/users'); | ||
| ``` | ||
| The header function does not receive the request URL or options. If you need headers that vary per request, use {@link withHooks} with a `beforeRequest` hook instead. | ||
| Function-based defaults are request-scoped, not sequence-scoped. Each new wrapped fetch call resolves them again. Wrappers such as `withRetry()` and `paginate()` call into `withHeaders()` separately for each attempt or page, so the header function can run again for each one. | ||
| @example | ||
| ``` | ||
| import {pipeline, withHeaders, withBaseUrl, withTimeout} from 'fetch-extras'; | ||
@@ -36,5 +51,5 @@ | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer my-token'}), | ||
| withTimeout(5000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withHeaders({Authorization: 'Bearer my-token'}), | ||
| ); | ||
@@ -46,4 +61,3 @@ | ||
| export function withHeaders( | ||
| fetchFunction: typeof fetch, | ||
| defaultHeaders: HeadersInit | ||
| ): typeof fetch; | ||
| defaultHeaders: HeadersInit | (() => HeadersInit | Promise<HeadersInit>) | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+82
-69
@@ -6,94 +6,107 @@ import { | ||
| deleteHeaders, | ||
| getRequestSignal, | ||
| hasResolvedRequestHeaders, | ||
| inheritedRequestBodyHeaderNamesSymbol, | ||
| resolveAuthorizationHeaderSymbol, | ||
| resolveRequestHeadersSymbol, | ||
| setHeaders, | ||
| waitForAbortable, | ||
| } from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to include default headers on every request. Per-call headers take priority over the defaults. | ||
| 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. | ||
| @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. | ||
| Can be combined with other `with*` functions. | ||
| @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} defaultHeaders - Default headers to include on every request. Accepts a plain object, a `Headers` instance, an array of `[name, value]` tuples, or a function that returns any of these (sync or async). When a function is given, it is called on every request, which is useful for headers that need to be resolved at request time (for example, reading an auth token from storage). | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} | ||
| The header function does not receive the request URL or options. If you need headers that vary per request, use `withHooks` with a `beforeRequest` hook instead. | ||
| Function-based defaults are request-scoped, not sequence-scoped. Each new wrapped fetch call resolves them again. Wrappers such as `withRetry()` and `paginate()` call into `withHeaders()` separately for each attempt or page, so the header function can run again for each one. | ||
| */ | ||
| export function withHeaders(fetchFunction, defaultHeaders) { | ||
| const defaultHeadersObject = new Headers(defaultHeaders); | ||
| export function withHeaders(defaultHeaders) { | ||
| const isFunction = typeof defaultHeaders === 'function'; | ||
| const staticDefaultHeaders = isFunction ? undefined : new Headers(defaultHeaders); | ||
| const resolveDefaultHeaders = isFunction ? defaultHeaders : () => staticDefaultHeaders; | ||
| 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] ?? []), | ||
| ]); | ||
| return fetchFunction => { | ||
| const getMergedHeaders = (urlOrRequest, resolvedDefaults, options = {}) => { | ||
| const merged = new Headers(resolvedDefaults); | ||
| 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); | ||
| deleteHeaders(merged, blockedDefaultHeaderNames); | ||
| setHeaders(merged, requestHeaders); | ||
| setHeaders(merged, callHeaders); | ||
| return { | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| return { | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| }; | ||
| }; | ||
| }; | ||
| const fetchWithHeaders = async (urlOrRequest, options = {}) => { | ||
| const { | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| } = getMergedHeaders(urlOrRequest, options); | ||
| const resolveMergedHeaderState = async (urlOrRequest, options = {}) => { | ||
| const resolvedDefaults = isFunction | ||
| ? await waitForAbortable(resolveDefaultHeaders, getRequestSignal(urlOrRequest, options)) | ||
| : staticDefaultHeaders; | ||
| const shouldTreatRequestBodyHeadersAsInherited = requestHeaders | ||
| && options.body !== undefined | ||
| && !blockedRequestBodyHeaderNames.some(headerName => blockedDefaultHeaderNames.has(headerName)); | ||
| return { | ||
| resolvedDefaults, | ||
| ...getMergedHeaders(urlOrRequest, resolvedDefaults, options), | ||
| }; | ||
| }; | ||
| 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, | ||
| }; | ||
| const fetchWithHeaders = async (urlOrRequest, options = {}) => { | ||
| if (hasResolvedRequestHeaders(urlOrRequest, options)) { | ||
| return fetchFunction(urlOrRequest, options); | ||
| } | ||
| } | ||
| return fetchFunction(urlOrRequest, {...options, headers: merged}); | ||
| }; | ||
| const { | ||
| resolvedDefaults, | ||
| merged, | ||
| requestHeaders, | ||
| callHeaders, | ||
| blockedDefaultHeaderNames, | ||
| } = await resolveMergedHeaderState(urlOrRequest, options); | ||
| fetchWithHeaders[resolveAuthorizationHeaderSymbol] = function (urlOrRequest, options = {}) { | ||
| const mergedHeaders = getMergedHeaders(urlOrRequest, options).merged; | ||
| const authorization = mergedHeaders.get('Authorization'); | ||
| const shouldTreatRequestBodyHeadersAsInherited = requestHeaders | ||
| && options.body !== undefined | ||
| && !blockedRequestBodyHeaderNames.some(headerName => blockedDefaultHeaderNames.has(headerName)); | ||
| if (authorization !== null) { | ||
| return authorization; | ||
| } | ||
| if (shouldTreatRequestBodyHeadersAsInherited) { | ||
| const resolvedDefaultHeaders = new Headers(resolvedDefaults); | ||
| const inheritedHeaderNames = blockedRequestBodyHeaderNames.filter(headerName => | ||
| requestHeaders.has(headerName) | ||
| && !callHeaders?.has(headerName) | ||
| && resolvedDefaultHeaders.get(headerName) !== requestHeaders.get(headerName), | ||
| ); | ||
| return fetchFunction[resolveAuthorizationHeaderSymbol]?.(urlOrRequest, { | ||
| ...options, | ||
| headers: mergedHeaders, | ||
| }); | ||
| }; | ||
| if (inheritedHeaderNames.length > 0) { | ||
| options = { | ||
| ...options, | ||
| [inheritedRequestBodyHeaderNamesSymbol]: inheritedHeaderNames, | ||
| }; | ||
| } | ||
| } | ||
| fetchWithHeaders[resolveRequestHeadersSymbol] = function (urlOrRequest, options = {}) { | ||
| const mergedHeaders = getMergedHeaders(urlOrRequest, options).merged; | ||
| return fetchFunction(urlOrRequest, {...options, headers: merged}); | ||
| }; | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, { | ||
| ...options, | ||
| headers: mergedHeaders, | ||
| }) ?? mergedHeaders; | ||
| fetchWithHeaders[resolveRequestHeadersSymbol] = async function (urlOrRequest, options = {}) { | ||
| const {merged: mergedHeaders} = await resolveMergedHeaderState(urlOrRequest, options); | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, { | ||
| ...options, | ||
| headers: mergedHeaders, | ||
| }) ?? mergedHeaders; | ||
| }; | ||
| return copyFetchMetadata(fetchWithHeaders, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithHeaders, fetchFunction); | ||
| } |
+21
-35
@@ -1,17 +0,5 @@ | ||
| 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. | ||
| This is the recommended way to add custom logic (logging, metrics, dynamic headers, response transformation) in documented `pipeline()` order after request-building wrappers, `withRetry()`, and `withTokenRefresh()`, but before `withHttpError()`. Hooks receive the effective request state for their stage, including URL, headers, and replayable body transformations already prepared by upstream wrappers in documented `pipeline()` order. 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. | ||
@@ -23,6 +11,6 @@ Can be combined with other `with*` functions: | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withHeaders(f, {Authorization: 'Bearer token'}), | ||
| f => withRetry(f, {retries: 2}), | ||
| f => withTokenRefresh(f, { | ||
| withBaseUrl('https://api.example.com'), | ||
| withHeaders({Authorization: 'Bearer token'}), | ||
| withRetry({retries: 2}), | ||
| withTokenRefresh({ | ||
| async refreshToken() { | ||
@@ -32,3 +20,3 @@ return 'new-token'; | ||
| }), | ||
| f => withHooks(f, { | ||
| withHooks({ | ||
| beforeRequest({url, options}) { | ||
@@ -41,9 +29,8 @@ console.log('→', options.method ?? 'GET', url); | ||
| }), | ||
| withHttpError, | ||
| withHttpError(), | ||
| ); | ||
| ``` | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Hook options. | ||
| @returns A wrapped fetch function with hooks. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function with hooks. | ||
@@ -54,3 +41,3 @@ @example | ||
| const fetchWithLogging = withHooks(fetch, { | ||
| const fetchWithLogging = withHooks({ | ||
| beforeRequest({url, options}) { | ||
@@ -62,3 +49,3 @@ console.log('→', options.method ?? 'GET', url); | ||
| }, | ||
| }); | ||
| })(fetch); | ||
@@ -73,3 +60,3 @@ const response = await fetchWithLogging('https://api.example.com/users'); | ||
| // Add a dynamic request ID header to every request | ||
| const fetchWithRequestId = withHooks(fetch, { | ||
| const fetchWithRequestId = withHooks({ | ||
| beforeRequest({options}) { | ||
@@ -84,10 +71,9 @@ return { | ||
| }, | ||
| }); | ||
| })(fetch); | ||
| ``` | ||
| */ | ||
| export function withHooks<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction, | ||
| export function withHooks( | ||
| options?: { | ||
| /** | ||
| Called before each request. Receives the resolved URL and the request options. | ||
| Called before each request. Receives the resolved URL and the effective request options for that stage. | ||
@@ -98,7 +84,7 @@ 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 url: string; | ||
| readonly options: HookRequestInit<Parameters<FetchFunction>>; | ||
| }) => HookRequestInit<Parameters<FetchFunction>> | HookResponse<FetchFunction> | void | Promise<HookRequestInit<Parameters<FetchFunction>> | HookResponse<FetchFunction> | void>; | ||
| readonly options: RequestInit; | ||
| }) => RequestInit | Response | void | Promise<RequestInit | Response | void>; | ||
| /** | ||
| Called after each response. Receives the response, resolved URL, and the request options. | ||
| Called after each response. Receives the response, resolved URL, and the same effective request options used for that hooked request. | ||
@@ -109,6 +95,6 @@ Return a replacement `Response` to modify the response, or return `undefined` to leave it unchanged. | ||
| readonly url: string; | ||
| readonly options: HookRequestInit<Parameters<FetchFunction>>; | ||
| readonly response: HookResponse<FetchFunction>; | ||
| }) => HookResponse<FetchFunction> | void | Promise<HookResponse<FetchFunction> | void>; | ||
| readonly options: RequestInit; | ||
| readonly response: Response; | ||
| }) => Response | void | Promise<Response | void>; | ||
| }, | ||
| ): (...arguments_: Parameters<FetchFunction>) => ReturnType<FetchFunction>; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+83
-77
| import { | ||
| copyFetchMetadata, | ||
| defersFetchStartSymbol, | ||
| getFetchSignal, | ||
| getResolvedRequestHeaders, | ||
| getRequestOptions, | ||
| getRequestSignal, | ||
| notifyFetchStart, | ||
| resolveRequestBody, | ||
| resolveRequestBodySymbol, | ||
| resolveRequestHeaders, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| waitForAbortable, | ||
| withFetchSignal, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
@@ -25,104 +25,110 @@ | ||
| 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. | ||
| This is the recommended way to add custom logic (logging, metrics, dynamic headers, response transformation) in documented `pipeline()` order after request-building wrappers, `withRetry()`, and `withTokenRefresh()`, but before `withHttpError()`. Hooks receive the effective request state for their stage, including URL, headers, and replayable body transformations already prepared by upstream wrappers in documented `pipeline()` order. 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 resolved URL, the request options, and the response. Return a replacement `Response` to modify the response, or return `undefined` to leave it unchanged. | ||
| @returns {typeof fetch} A wrapped fetch function with hooks. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns 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, | ||
| }); | ||
| }; | ||
| export function withHooks({beforeRequest, afterResponse} = {}) { | ||
| return fetchFunction => { | ||
| 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 fetchWithHooks = async (urlOrRequest, options = {}) => { | ||
| const getHookOptions = async (options_ = {}) => { | ||
| const effectiveOptions = getRequestOptions(urlOrRequest, options_); | ||
| const shouldClearInheritedBody = (request, hookOptions, options) => { | ||
| if (!(request instanceof Request) || hookOptions.body === undefined || Object.hasOwn(options, 'body')) { | ||
| return false; | ||
| } | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestHeadersSymbol] !== undefined) { | ||
| effectiveOptions.headers = Object.fromEntries(await getResolvedRequestHeaders(fetchFunction, urlOrRequest, options_)); | ||
| } | ||
| return ['GET', 'HEAD'].includes(options.method?.toUpperCase()); | ||
| }; | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestBodySymbol] !== undefined) { | ||
| const resolvedBody = resolveRequestBody(fetchFunction, urlOrRequest, options_); | ||
| const fetchWithHooks = async (urlOrRequest, options = {}) => { | ||
| const getLifecycleSignal = options_ => getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options_)); | ||
| if (resolvedBody !== undefined) { | ||
| effectiveOptions.body = resolvedBody; | ||
| } else if (urlOrRequest instanceof Request && urlOrRequest.body !== null) { | ||
| setInheritedHookBody(effectiveOptions, urlOrRequest.body); | ||
| } | ||
| } | ||
| const getHookOptions = (options_ = {}) => { | ||
| const lifecycleSignal = getLifecycleSignal(options_); | ||
| const effectiveOptions = getRequestOptions(urlOrRequest, options_); | ||
| const lifecycleSignal = withFetchSignal(fetchFunction, urlOrRequest, options_).signal; | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestHeadersSymbol] !== undefined) { | ||
| effectiveOptions.headers = Object.fromEntries(resolveRequestHeaders(fetchFunction, urlOrRequest, options_)); | ||
| } | ||
| if (lifecycleSignal !== undefined) { | ||
| effectiveOptions.signal = lifecycleSignal; | ||
| } | ||
| if (urlOrRequest instanceof Request || fetchFunction[resolveRequestBodySymbol] !== undefined) { | ||
| const resolvedBody = resolveRequestBody(fetchFunction, urlOrRequest, options_); | ||
| return effectiveOptions; | ||
| }; | ||
| if (resolvedBody !== undefined) { | ||
| effectiveOptions.body = resolvedBody; | ||
| } else if (urlOrRequest instanceof Request && urlOrRequest.body !== null) { | ||
| setInheritedHookBody(effectiveOptions, urlOrRequest.body); | ||
| const getFetchOptions = (options_, headers, signal) => { | ||
| // Strip the prototype-only inherited body marker before calling the inner fetch so no-op hooks do not change downstream wrapper semantics. | ||
| let fetchOptions = inheritedHookBodies.has(options_) ? {...options_} : options_; | ||
| if (headers !== undefined) { | ||
| fetchOptions = withResolvedRequestHeaders(fetchOptions, headers); | ||
| } | ||
| } | ||
| if (lifecycleSignal !== undefined) { | ||
| effectiveOptions.signal = lifecycleSignal; | ||
| } | ||
| return signal === undefined | ||
| ? fetchOptions | ||
| : {...fetchOptions, signal}; | ||
| }; | ||
| return effectiveOptions; | ||
| }; | ||
| const shouldClearInheritedBody = (request, hookOptions, options_) => { | ||
| if (!(request instanceof Request) || hookOptions.body === undefined || Object.hasOwn(options_, 'body')) { | ||
| return false; | ||
| } | ||
| const url = resolveRequestUrl(fetchFunction, urlOrRequest); | ||
| let hookOptions = getHookOptions(options); | ||
| return ['GET', 'HEAD'].includes(options_.method?.toUpperCase()); | ||
| }; | ||
| if (beforeRequest) { | ||
| let result = await waitForAbortable(() => beforeRequest({url, options: hookOptions}), hookOptions.signal); | ||
| if (result instanceof Response) { | ||
| return result; | ||
| } | ||
| const url = resolveRequestUrl(fetchFunction, urlOrRequest); | ||
| let hookOptions = await getHookOptions(options); | ||
| if (result !== undefined) { | ||
| if (shouldClearInheritedBody(urlOrRequest, hookOptions, result)) { | ||
| result = {...result, body: undefined}; | ||
| if (beforeRequest) { | ||
| const result = await waitForAbortable(() => beforeRequest({url, options: hookOptions}), hookOptions.signal); | ||
| if (result instanceof Response) { | ||
| return result; | ||
| } | ||
| options = result; | ||
| hookOptions = getHookOptions(options); | ||
| if (result !== undefined) { | ||
| let nextOptions = result; | ||
| if (shouldClearInheritedBody(urlOrRequest, hookOptions, nextOptions)) { | ||
| nextOptions = {...nextOptions, body: undefined}; | ||
| } | ||
| options = nextOptions; | ||
| hookOptions = await getHookOptions(options); | ||
| } | ||
| } | ||
| } | ||
| const finalFetchOptions = getFetchOptions(urlOrRequest, options, hookOptions.signal); | ||
| const fetchInput = urlOrRequest instanceof Request && Object.hasOwn(finalFetchOptions, 'body') && finalFetchOptions.body === undefined | ||
| ? url | ||
| : urlOrRequest; | ||
| const finalFetchOptions = getFetchOptions(options, hookOptions.headers, hookOptions.signal); | ||
| const fetchInput = urlOrRequest instanceof Request && Object.hasOwn(finalFetchOptions, 'body') && finalFetchOptions.body === undefined | ||
| ? url | ||
| : urlOrRequest; | ||
| notifyFetchStart(fetchFunction, finalFetchOptions); | ||
| const response = await fetchFunction(fetchInput, finalFetchOptions); | ||
| notifyFetchStart(fetchFunction, finalFetchOptions); | ||
| const response = await fetchFunction(fetchInput, finalFetchOptions); | ||
| if (afterResponse) { | ||
| const modifiedResponse = await waitForAbortable(() => afterResponse({url, options: hookOptions, response}), hookOptions.signal); | ||
| if (modifiedResponse !== undefined) { | ||
| return modifiedResponse; | ||
| if (afterResponse) { | ||
| const modifiedResponse = await waitForAbortable(() => afterResponse({url, options: hookOptions, response}), hookOptions.signal); | ||
| if (modifiedResponse !== undefined) { | ||
| return modifiedResponse; | ||
| } | ||
| } | ||
| } | ||
| return response; | ||
| return response; | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithHooks, fetchFunction); | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithHooks, fetchFunction); | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
@@ -8,26 +8,4 @@ /** | ||
| 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. | ||
| Wraps a fetch function to automatically stringify plain-object and array bodies as JSON and set the `Content-Type: application/json` header. | ||
@@ -42,4 +20,3 @@ Only plain objects (`{}`) and arrays are auto-serialized. Other body types like strings, `FormData`, `Blob`, and `ReadableStream` are passed through unchanged. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that auto-serializes JSON bodies. | ||
| @returns A function that accepts a fetch function and returns a wrapped fetch function that auto-serializes JSON bodies. | ||
@@ -50,3 +27,3 @@ @example | ||
| const fetchWithJson = withJsonBody(fetch); | ||
| const fetchWithJson = withJsonBody()(fetch); | ||
@@ -66,5 +43,5 @@ const response = await fetchWithJson('/api/users', { | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| withJsonBody, | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withJsonBody(), | ||
| withHttpError(), | ||
| ); | ||
@@ -78,4 +55,4 @@ | ||
| */ | ||
| export function withJsonBody<FetchFunction extends (input: RequestInfo | URL, ...arguments_: any[]) => Promise<Response>>( | ||
| fetchFunction: FetchFunction | ||
| ): JsonBodyWrappedFetch<FetchFunction>; | ||
| export function withJsonBody(): ( | ||
| fetchFunction: typeof fetch | ||
| ) => (input: RequestInfo | URL, init?: JsonBodyRequestInit) => Promise<Response>; |
+27
-26
@@ -37,6 +37,6 @@ import { | ||
| function getJsonHeaders(fetchFunction, urlOrRequest, options) { | ||
| async 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 headers = new Headers(await fetchFunction[resolveRequestHeadersSymbol]?.(requestForHeaders, options) ?? (requestForHeaders instanceof Request ? requestForHeaders.headers : undefined)); | ||
| const callHeaders = new Headers(options.headers); | ||
@@ -71,3 +71,3 @@ const contentType = callHeaders.get('content-type') | ||
| function getJsonRequestOptions(fetchFunction, urlOrRequest, options) { | ||
| async function getJsonRequestOptions(fetchFunction, urlOrRequest, options) { | ||
| return { | ||
@@ -77,3 +77,3 @@ ...options, | ||
| body: getJsonBody(options), | ||
| headers: getJsonHeaders(fetchFunction, urlOrRequest, options), | ||
| headers: await getJsonHeaders(fetchFunction, urlOrRequest, options), | ||
| }; | ||
@@ -89,31 +89,32 @@ } | ||
| @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. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns 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); | ||
| } | ||
| export function withJsonBody() { | ||
| return fetchFunction => { | ||
| const fetchWithJsonBody = async (urlOrRequest, options = {}) => { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| options = await getJsonRequestOptions(fetchFunction, urlOrRequest, options); | ||
| } | ||
| return fetchFunction(urlOrRequest, options); | ||
| }; | ||
| return fetchFunction(urlOrRequest, options); | ||
| }; | ||
| fetchWithJsonBody[resolveRequestHeadersSymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonHeaders(fetchFunction, urlOrRequest, options); | ||
| } | ||
| fetchWithJsonBody[resolveRequestHeadersSymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonHeaders(fetchFunction, urlOrRequest, options); | ||
| } | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, options); | ||
| }; | ||
| return fetchFunction[resolveRequestHeadersSymbol]?.(urlOrRequest, options); | ||
| }; | ||
| fetchWithJsonBody[resolveRequestBodySymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonBody(options); | ||
| } | ||
| fetchWithJsonBody[resolveRequestBodySymbol] = function (urlOrRequest, options = {}) { | ||
| if (shouldJsonifyBody(options.body)) { | ||
| return getJsonBody(options); | ||
| } | ||
| return fetchFunction[resolveRequestBodySymbol]?.(urlOrRequest, options) ?? options.body; | ||
| return fetchFunction[resolveRequestBodySymbol]?.(urlOrRequest, options) ?? options.body; | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonBody, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonBody, fetchFunction); | ||
| } |
@@ -65,3 +65,3 @@ // Standard Schema types (inlined from https://standardschema.dev) | ||
| const userSchema = z.object({name: z.string()}); | ||
| const fetchUser = withJsonResponse(fetch, {schema: userSchema}); | ||
| const fetchUser = withJsonResponse({schema: userSchema})(fetch); | ||
@@ -101,6 +101,5 @@ try { | ||
| Empty responses are not special-cased. If the response body is empty, including `204`, `205`, or `HEAD` responses, this wrapper throws the same `SyntaxError` as `Response.json()`. | ||
| Empty responses are not special-cased. If the response body is empty, including `204`, `205`, or `HEAD` responses, this wrapper throws the same `SyntaxError` as `Response.json()`. This is intentional: returning `null` would widen every call site's return type to `T | null`, forcing unnecessary null-checks. If your endpoint can return empty responses, handle that before this wrapper in the pipeline. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that returns the parsed JSON data. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that returns the parsed JSON data. | ||
| @throws {SyntaxError} When the response body is empty or is not valid JSON. | ||
@@ -112,3 +111,3 @@ | ||
| const fetchJson = withJsonResponse(fetch); | ||
| const fetchJson = withJsonResponse()(fetch); | ||
| const data = await fetchJson('/api/user/1'); | ||
@@ -125,5 +124,5 @@ | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| withJsonResponse, | ||
| withTimeout(5000), | ||
| withHttpError(), | ||
| withJsonResponse(), | ||
| ); | ||
@@ -134,5 +133,5 @@ | ||
| */ | ||
| export function withJsonResponse<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction, | ||
| ): (...arguments_: Parameters<FetchFunction>) => Promise<unknown>; | ||
| export function withJsonResponse(): ( | ||
| fetchFunction: typeof fetch, | ||
| ) => (...arguments_: Parameters<typeof fetch>) => Promise<unknown>; | ||
@@ -146,8 +145,7 @@ /** | ||
| Empty responses are not special-cased. If the response body is empty, including `204`, `205`, or `HEAD` responses, this wrapper throws the same `SyntaxError` as `Response.json()`. | ||
| Empty responses are not special-cased. If the response body is empty, including `204`, `205`, or `HEAD` responses, this wrapper throws the same `SyntaxError` as `Response.json()`. This is intentional: returning `null` would widen every call site's return type to `T | null`, forcing unnecessary null-checks. If your endpoint can return empty responses, handle that before this wrapper in the pipeline. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @param options - Options object. | ||
| @param options.schema - A Standard Schema object to validate response JSON against. | ||
| @returns A wrapped fetch function that returns the validated data. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that returns the validated data. | ||
| @throws {SyntaxError} When the response body is empty or is not valid JSON. | ||
@@ -163,3 +161,3 @@ @throws {SchemaValidationError} When the response JSON does not match the schema. | ||
| const fetchUser = withJsonResponse(fetch, {schema: userSchema}); | ||
| const fetchUser = withJsonResponse({schema: userSchema})(fetch); | ||
| const user = await fetchUser('/api/user/1'); | ||
@@ -179,5 +177,5 @@ | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| f => withJsonResponse(f, {schema: userSchema}), | ||
| withTimeout(5000), | ||
| withHttpError(), | ||
| withJsonResponse({schema: userSchema}), | ||
| ); | ||
@@ -189,7 +187,7 @@ | ||
| export function withJsonResponse< | ||
| FetchFunction extends typeof fetch, | ||
| Schema extends StandardSchemaV1 | undefined = undefined, | ||
| >( | ||
| fetchFunction: FetchFunction, | ||
| options?: {schema?: Schema}, | ||
| ): (...arguments_: Parameters<FetchFunction>) => Promise<Schema extends StandardSchemaV1 ? StandardSchemaV1InferOutput<Schema> : unknown>; | ||
| ): ( | ||
| fetchFunction: typeof fetch, | ||
| ) => (...arguments_: Parameters<typeof fetch>) => Promise<Schema extends StandardSchemaV1 ? StandardSchemaV1InferOutput<Schema> : unknown>; |
@@ -15,3 +15,3 @@ import {copyFetchMetadata} from './utilities.js'; | ||
| export function withJsonResponse(fetchFunction, {schema} = {}) { | ||
| export function withJsonResponse({schema} = {}) { | ||
| if (schema !== undefined && typeof schema?.['~standard']?.validate !== 'function') { | ||
@@ -21,24 +21,26 @@ throw new TypeError('The `schema` option must be a Standard Schema object (https://standardschema.dev)'); | ||
| const fetchWithJsonResponse = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| return fetchFunction => { | ||
| const fetchWithJsonResponse = async (urlOrRequest, options = {}) => { | ||
| const response = await fetchFunction(urlOrRequest, options); | ||
| // Keep the contract strict: this wrapper means "parse JSON". | ||
| // Empty 200/204/205/HEAD responses are therefore treated as not JSON and throw, | ||
| // instead of widening every successful call site with a special-case empty value. | ||
| const jsonValue = await response.json(); | ||
| // Keep the contract strict: this wrapper means "parse JSON". | ||
| // Empty 200/204/205/HEAD responses are therefore treated as not JSON and throw, | ||
| // instead of widening every successful call site with a special-case empty value. | ||
| const jsonValue = await response.json(); | ||
| if (!schema) { | ||
| return jsonValue; | ||
| } | ||
| if (!schema) { | ||
| return jsonValue; | ||
| } | ||
| const result = await schema['~standard'].validate(jsonValue); | ||
| const result = await schema['~standard'].validate(jsonValue); | ||
| if (result.issues) { | ||
| throw new SchemaValidationError(result.issues, response); | ||
| } | ||
| if (result.issues) { | ||
| throw new SchemaValidationError(result.issues, response); | ||
| } | ||
| return result.value; | ||
| return result.value; | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonResponse, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonResponse, fetchFunction); | ||
| } |
@@ -10,5 +10,4 @@ /** | ||
| @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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that enforces the rate limit. | ||
@@ -20,6 +19,6 @@ @example | ||
| // Allow at most 10 requests per second | ||
| const rateLimitedFetch = withRateLimit(fetch, { | ||
| const rateLimitedFetch = withRateLimit({ | ||
| requestsPerInterval: 10, | ||
| interval: 1000, | ||
| }); | ||
| })(fetch); | ||
@@ -36,5 +35,5 @@ const response = await rateLimitedFetch('/api/data'); | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withRateLimit(f, {requestsPerInterval: 10, interval: 1000}), | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withRateLimit({requestsPerInterval: 10, interval: 1000}), | ||
| withHttpError(), | ||
| ); | ||
@@ -46,3 +45,2 @@ | ||
| export function withRateLimit( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
@@ -59,2 +57,2 @@ /** | ||
| }, | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -12,3 +12,3 @@ import { | ||
| export function withRateLimit(fetchFunction, {requestsPerInterval, interval}) { | ||
| export function withRateLimit({requestsPerInterval, interval}) { | ||
| if (!Number.isInteger(requestsPerInterval) || requestsPerInterval < 1) { | ||
@@ -27,106 +27,108 @@ throw new TypeError('`requestsPerInterval` must be a positive integer.'); | ||
| 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); | ||
| return fetchFunction => { | ||
| 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; | ||
| } | ||
| const clearNextSlotTimeout = () => { | ||
| if (nextSlotTimeout === undefined) { | ||
| return; | ||
| } | ||
| clearTimeout(nextSlotTimeout); | ||
| nextSlotTimeout = undefined; | ||
| }; | ||
| clearTimeout(nextSlotTimeout); | ||
| nextSlotTimeout = undefined; | ||
| }; | ||
| const schedule = ({force = false} = {}) => { | ||
| if (nextSlotTimeout !== undefined && !force) { | ||
| return; | ||
| } | ||
| const schedule = ({force = false} = {}) => { | ||
| if (nextSlotTimeout !== undefined && !force) { | ||
| return; | ||
| } | ||
| clearNextSlotTimeout(); | ||
| clearNextSlotTimeout(); | ||
| const currentTime = now(); | ||
| prune(currentTime); | ||
| const currentTime = now(); | ||
| prune(currentTime); | ||
| while (queue.length > 0) { | ||
| const entry = queue[0]; | ||
| while (queue.length > 0) { | ||
| const entry = queue[0]; | ||
| if (entry.signal?.aborted) { | ||
| queue.shift(); | ||
| entry.reject(entry.signal.reason); | ||
| continue; | ||
| } | ||
| 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) { | ||
| 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 waitTime = Math.max(oldestStartedReservation.timestamp + interval - currentTime, 0); | ||
| nextSlotTimeout = setTimeout(() => { | ||
| nextSlotTimeout = undefined; | ||
| schedule(); | ||
| }, waitTime); | ||
| const reservation = { | ||
| timestamp: undefined, | ||
| }; | ||
| reservations.push(reservation); | ||
| queue.shift(); | ||
| entry.resolve(reservation); | ||
| } | ||
| }; | ||
| const releaseReservation = reservation => { | ||
| const index = reservations.indexOf(reservation); | ||
| if (index === -1) { | ||
| return; | ||
| } | ||
| const reservation = { | ||
| timestamp: undefined, | ||
| }; | ||
| reservations.push(reservation); | ||
| queue.shift(); | ||
| entry.resolve(reservation); | ||
| } | ||
| }; | ||
| reservations.splice(index, 1); | ||
| schedule({force: true}); | ||
| }; | ||
| const releaseReservation = reservation => { | ||
| const index = reservations.indexOf(reservation); | ||
| if (index === -1) { | ||
| return; | ||
| } | ||
| const fetchWithRateLimit = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| signal?.throwIfAborted(); | ||
| reservations.splice(index, 1); | ||
| schedule({force: true}); | ||
| }; | ||
| const reservation = await enqueueAbortable(queue, { | ||
| signal, | ||
| onAbort() { | ||
| schedule({force: true}); | ||
| }, | ||
| onEnqueue() { | ||
| schedule(); | ||
| }, | ||
| }); | ||
| const fetchWithRateLimit = async (urlOrRequest, options = {}) => { | ||
| const signal = getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options)); | ||
| signal?.throwIfAborted(); | ||
| const resolvedOptions = signal ? {...options, signal} : options; | ||
| try { | ||
| await resolvedOptions[waitForConcurrencySlotSymbol]?.(); | ||
| signal?.throwIfAborted(); | ||
| } catch (error) { | ||
| releaseReservation(reservation); | ||
| throw error; | ||
| } | ||
| const reservation = await enqueueAbortable(queue, { | ||
| signal, | ||
| onAbort() { | ||
| schedule({force: true}); | ||
| }, | ||
| onEnqueue() { | ||
| schedule(); | ||
| }, | ||
| }); | ||
| reservation.timestamp = now(); | ||
| schedule({force: true}); | ||
| notifyFetchStart(fetchFunction, resolvedOptions); | ||
| return fetchFunction(urlOrRequest, resolvedOptions); | ||
| }; | ||
| 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; | ||
| }; | ||
| const wrappedFetch = copyFetchMetadata(fetchWithRateLimit, fetchFunction); | ||
| wrappedFetch[defersConcurrencySlotSymbol] = true; | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
+31
-13
| /** | ||
| Wraps a fetch function to automatically retry failed requests. | ||
| Retries on network errors 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. | ||
| Retries on network errors 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, and ignores malformed values by falling back to `backoff`. | ||
@@ -10,3 +10,3 @@ POST and PATCH are not retried by default because they are not idempotent. Add them to `methods` if your endpoints are safe to retry. | ||
| Place `withRetry` before `withHttpError` in a pipeline so it sees raw responses and can check status codes. | ||
| In documented `pipeline()` order, place `withRetry` before `withHttpError` so it sees raw responses and can check status codes. | ||
@@ -17,5 +17,8 @@ Do not consume the `response` body inside `shouldRetry`. If you need to inspect the body, clone the response first. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| `withRetry()` resolves replayable request bodies once before the first attempt so later retries can reuse them. When a wrapper couples body resolution to header resolution, `withRetry()` preserves those resolved headers with the prepared body for every attempt. Other request-scoped header wrappers may still run again on each retry. | ||
| If you need retry-time auth behavior, use a wrapper with explicit retry semantics such as `withTokenRefresh()`. | ||
| @param options - Retry configuration. | ||
| @returns A wrapped fetch function with automatic retry. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function with automatic retry. | ||
@@ -26,3 +29,3 @@ @example | ||
| const fetchWithRetry = withRetry(fetch, {retries: 3}); | ||
| const fetchWithRetry = withRetry({retries: 3})(fetch); | ||
@@ -35,9 +38,25 @@ const response = await fetchWithRetry('https://api.example.com/data'); | ||
| ``` | ||
| import {pipeline, withHttpError, withRetry, withBaseUrl} from 'fetch-extras'; | ||
| import {withRetry} from 'fetch-extras'; | ||
| // With a custom backoff and conditional retry | ||
| const fetchWithRetry = withRetry({ | ||
| retries: 5, | ||
| backoff: attemptNumber => attemptNumber * 1000, // Linear: 1s, 2s, 3s, ... | ||
| shouldRetry({response}) { | ||
| // Don't retry if the server says the resource is gone | ||
| return response?.status !== 410; | ||
| }, | ||
| })(fetch); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withRetry, withBaseUrl, withTimeout} from 'fetch-extras'; | ||
| const apiFetch = pipeline( | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withRetry(f, {retries: 2}), | ||
| withHttpError, | ||
| withTimeout(10_000), | ||
| withBaseUrl('https://api.example.com'), | ||
| withRetry({retries: 2}), | ||
| withHttpError(), | ||
| ); | ||
@@ -49,3 +68,2 @@ | ||
| export function withRetry( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
@@ -64,3 +82,3 @@ /** | ||
| */ | ||
| readonly methods?: string[]; | ||
| readonly methods?: readonly string[]; | ||
@@ -72,3 +90,3 @@ /** | ||
| */ | ||
| readonly statusCodes?: number[]; | ||
| readonly statusCodes?: readonly number[]; | ||
@@ -103,2 +121,2 @@ /** | ||
| }, | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+96
-109
@@ -6,10 +6,7 @@ import isNetworkError from './is-network-error.js'; | ||
| discardBody, | ||
| getFetchSignal, | ||
| getRequestSignal, | ||
| hasHeaders, | ||
| requestSnapshot, | ||
| getResolvedRequestHeaders, | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeaders, | ||
| resolveRequestUrl, | ||
| waitForAbortable, | ||
| withFetchSignal, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
@@ -24,3 +21,3 @@ | ||
| function parseRetryAfter(response) { | ||
| const header = response.headers.get('retry-after'); | ||
| const header = response.headers.get('retry-after')?.trim(); | ||
| if (!header) { | ||
@@ -30,5 +27,4 @@ return; | ||
| const seconds = Number(header); | ||
| if (Number.isFinite(seconds)) { | ||
| return seconds * 1000; | ||
| if (/^\d+$/.test(header)) { | ||
| return Number(header) * 1000; | ||
| } | ||
@@ -45,3 +41,3 @@ | ||
| Retries on network errors 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. | ||
| Retries on network errors 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, and ignores malformed values by falling back to `backoff`. | ||
@@ -52,3 +48,4 @@ When all retries are exhausted, the last response is returned (for HTTP status retries) or the last error is thrown (for network errors). | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| `withRetry()` resolves replayable request bodies once before the first attempt so later retries can reuse them. When a wrapper couples body resolution to header resolution, `withRetry()` preserves those resolved headers with the prepared body for every attempt. Other request-scoped header wrappers may still run again on each retry. If you need retry-time auth behavior, use a wrapper with explicit retry semantics such as `withTokenRefresh()`. | ||
| @param {object} [options] | ||
@@ -61,5 +58,5 @@ @param {number} [options.retries=2] - Number of retries after the initial attempt. `retries: 2` means up to 3 total attempts. | ||
| @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. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns a wrapped fetch function with automatic retry. | ||
| */ | ||
| export function withRetry(fetchFunction, options = {}) { | ||
| export function withRetry(options = {}) { | ||
| const { | ||
@@ -86,13 +83,2 @@ retries = 2, | ||
| 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) => { | ||
@@ -112,101 +98,102 @@ const retryAfter = parseRetryAfter(response); | ||
| const fetchWithRetry = async (urlOrRequest, fetchOptions = {}) => { | ||
| const request = urlOrRequest instanceof Request ? urlOrRequest : undefined; | ||
| const method = (fetchOptions.method ?? request?.method ?? 'GET').toUpperCase(); | ||
| return fetchFunction => { | ||
| const shouldResolveHeadersForRetry = (request, fetchOptions, bodyResolvedFetchOptions) => bodyResolvedFetchOptions !== fetchOptions | ||
| || (request && fetchOptions.body !== undefined && fetchOptions.headers !== undefined); | ||
| if (!retriableMethods.has(method)) { | ||
| return fetchFunction(urlOrRequest, fetchOptions); | ||
| } | ||
| const getAttemptState = async ({urlOrRequest, fetchOptions, request, bodyResolvedFetchOptions}) => { | ||
| let requestOptions = bodyResolvedFetchOptions; | ||
| let attemptOptions = withFetchSignal(fetchFunction, urlOrRequest, requestOptions); | ||
| 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 (shouldResolveHeadersForRetry(request, fetchOptions, bodyResolvedFetchOptions)) { | ||
| const attemptSignal = attemptOptions.signal; | ||
| requestOptions = withResolvedRequestHeaders( | ||
| bodyResolvedFetchOptions, | ||
| await getResolvedRequestHeaders(fetchFunction, urlOrRequest, {...fetchOptions, signal: attemptSignal}), | ||
| ); | ||
| attemptOptions = attemptSignal === undefined | ||
| ? requestOptions | ||
| : {...requestOptions, signal: attemptSignal}; | ||
| } | ||
| if (attemptSignal) { | ||
| retryOptions = retryBaseOptions === fetchOptions | ||
| ? currentOptions | ||
| : {...retryBaseOptions, signal: attemptSignal}; | ||
| } | ||
| return { | ||
| attemptOptions, | ||
| attemptSignal: attemptOptions.signal, | ||
| }; | ||
| }; | ||
| /* 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; | ||
| 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 { | ||
| attemptOptions, | ||
| attemptSignal, | ||
| } = await getAttemptState({ | ||
| urlOrRequest, | ||
| fetchOptions, | ||
| request, | ||
| bodyResolvedFetchOptions, | ||
| }); | ||
| /* eslint-disable no-await-in-loop */ | ||
| for (let attempt = 0; attempt <= maximumAttempts; attempt++) { | ||
| const isLastAttempt = attempt >= maximumAttempts; | ||
| let response; | ||
| try { | ||
| response = await fetchFunction(urlOrRequest, attemptOptions); | ||
| } catch (error) { | ||
| if (isLastAttempt || !isNetworkError(error)) { | ||
| throw error; | ||
| } | ||
| if (!await waitForAbortable( | ||
| () => shouldRetry({error, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| )) { | ||
| throw error; | ||
| } | ||
| await delay(backoff(attempt + 1), {signal: attemptSignal}); | ||
| continue; | ||
| } | ||
| if (!await waitForAbortable( | ||
| () => shouldRetry({error, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| )) { | ||
| throw error; | ||
| if (!retriableStatusCodes.has(response.status) || isLastAttempt) { | ||
| return response; | ||
| } | ||
| await delay(backoff(attempt + 1), {signal: attemptSignal}); | ||
| continue; | ||
| } | ||
| const retryDelay = getRetryDelay(response, attempt + 1); | ||
| if (retryDelay === undefined) { | ||
| return response; | ||
| } | ||
| if (!retriableStatusCodes.has(response.status) || isLastAttempt) { | ||
| return response; | ||
| } | ||
| let shouldRetryResponse; | ||
| try { | ||
| shouldRetryResponse = await waitForAbortable( | ||
| () => shouldRetry({response, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| ); | ||
| } catch (error) { | ||
| await discardBody(response.body); | ||
| throw error; | ||
| } | ||
| const retryDelay = getRetryDelay(response, attempt + 1); | ||
| if (retryDelay === undefined) { | ||
| return response; | ||
| } | ||
| if (!shouldRetryResponse) { | ||
| return response; | ||
| } | ||
| let shouldRetryResponse; | ||
| try { | ||
| shouldRetryResponse = await waitForAbortable( | ||
| () => shouldRetry({response, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| ); | ||
| } catch (error) { | ||
| await discardBody(response.body); | ||
| throw error; | ||
| await delay(retryDelay, {signal: attemptSignal}); | ||
| } | ||
| /* eslint-enable no-await-in-loop */ | ||
| }; | ||
| if (!shouldRetryResponse) { | ||
| return response; | ||
| } | ||
| await discardBody(response.body); | ||
| await delay(retryDelay, {signal: attemptSignal}); | ||
| } | ||
| /* eslint-enable no-await-in-loop */ | ||
| return copyFetchMetadata(fetchWithRetry, fetchFunction); | ||
| }; | ||
| 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. | ||
| Wraps a fetch function to include 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. | ||
| In documented `pipeline()` order, place `withSearchParameters` after `withBaseUrl` 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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that merges the default search parameters into every request URL. | ||
@@ -18,3 +17,3 @@ @example | ||
| const fetchWithParameters = withSearchParameters(fetch, {apiKey: 'my-key', format: 'json'}); | ||
| const fetchWithParameters = withSearchParameters({apiKey: 'my-key', format: 'json'})(fetch); | ||
@@ -37,5 +36,5 @@ const response = await fetchWithParameters('/users'); | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withSearchParameters(f, {apiKey: 'my-key'}), | ||
| withHttpError, | ||
| withBaseUrl('https://api.example.com'), | ||
| withSearchParameters({apiKey: 'my-key'}), | ||
| withHttpError(), | ||
| ); | ||
@@ -47,4 +46,3 @@ | ||
| export function withSearchParameters( | ||
| fetchFunction: typeof fetch, | ||
| defaultSearchParameters: Record<string, string> | URLSearchParams | ReadonlyArray<readonly [string, string]> | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -6,74 +6,75 @@ import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| @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. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} | ||
| */ | ||
| export function withSearchParameters(fetchFunction, defaultSearchParameters) { | ||
| export function withSearchParameters(defaultSearchParameters) { | ||
| const defaults = new URLSearchParams(defaultSearchParameters); | ||
| const mergeSearchParameters = existingParameters => { | ||
| const merged = new URLSearchParams(defaults); | ||
| return fetchFunction => { | ||
| 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); | ||
| } | ||
| // 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); | ||
| } | ||
| for (const [key, value] of existingParameters) { | ||
| merged.append(key, value); | ||
| } | ||
| return merged; | ||
| }; | ||
| return merged; | ||
| }; | ||
| const applyToUrl = url => { | ||
| const merged = mergeSearchParameters(url.searchParams); | ||
| const newUrl = new URL(url); | ||
| newUrl.search = merged.toString(); | ||
| return newUrl; | ||
| }; | ||
| 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; | ||
| } | ||
| const resolveRequestUrlWithSearchParameters = urlOrRequest => { | ||
| if (urlOrRequest instanceof URL) { | ||
| return applyToUrl(urlOrRequest).href; | ||
| } | ||
| if (typeof urlOrRequest !== 'string') { | ||
| return urlOrRequest instanceof Request ? urlOrRequest.url : String(urlOrRequest); | ||
| } | ||
| 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); | ||
| 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; | ||
| } | ||
| 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 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 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 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); | ||
| } | ||
| const fetchWithSearchParameters = async (urlOrRequest, options = {}) => { | ||
| if (urlOrRequest instanceof URL) { | ||
| return fetchFunction(applyToUrl(urlOrRequest), options); | ||
| } | ||
| return fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrlWithSearchParameters(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| }; | ||
| return fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrlWithSearchParameters(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| }; | ||
| fetchWithSearchParameters[resolveRequestUrlSymbol] = resolveRequestUrlWithSearchParameters; | ||
| fetchWithSearchParameters[resolveRequestUrlSymbol] = resolveRequestUrlWithSearchParameters; | ||
| return copyFetchMetadata(fetchWithSearchParameters, fetchFunction); | ||
| return copyFetchMetadata(fetchWithSearchParameters, fetchFunction); | ||
| }; | ||
| } |
@@ -6,5 +6,4 @@ /** | ||
| @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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that will abort if the request takes longer than the specified timeout. | ||
@@ -15,3 +14,3 @@ @example | ||
| const fetchWithTimeout = withTimeout(fetch, 5000); | ||
| const fetchWithTimeout = withTimeout(5000)(fetch); | ||
| const response = await fetchWithTimeout('/api'); | ||
@@ -27,4 +26,4 @@ const data = await response.json(); | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| withTimeout(5000), | ||
| withHttpError(), | ||
| ); | ||
@@ -36,4 +35,3 @@ | ||
| export function withTimeout( | ||
| fetchFunction: typeof fetch, | ||
| timeout: number | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -8,11 +8,13 @@ import { | ||
| export function withTimeout(fetchFunction, timeout) { | ||
| const fetchWithTimeout = async (urlOrRequest, options = {}) => { | ||
| const signal = getTimeoutSignal(timeout, getRequestSignal(urlOrRequest, options)); | ||
| return fetchFunction(urlOrRequest, {...options, signal}); | ||
| }; | ||
| export function withTimeout(timeout) { | ||
| return fetchFunction => { | ||
| const fetchWithTimeout = async (urlOrRequest, options = {}) => { | ||
| const signal = getTimeoutSignal(timeout, getRequestSignal(urlOrRequest, options)); | ||
| return fetchFunction(urlOrRequest, {...options, signal}); | ||
| }; | ||
| fetchWithTimeout[timeoutDurationSymbol] = timeout; | ||
| fetchWithTimeout[timeoutDurationSymbol] = timeout; | ||
| return copyFetchMetadata(fetchWithTimeout, fetchFunction); | ||
| return copyFetchMetadata(fetchWithTimeout, fetchFunction); | ||
| }; | ||
| } |
@@ -6,3 +6,3 @@ /** | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call to prevent token invalidation races. | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call when they have the same effective `Authorization` header, preventing token invalidation races across requests for the same auth context. Requests with different effective `Authorization` headers refresh separately. | ||
@@ -15,3 +15,3 @@ > 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. | ||
| Can be combined with other `with*` functions. Should be composed inside `withHttpError` so it can see the raw 401 response: | ||
| Can be combined with other `with*` functions. In documented `pipeline()` order, place `withTokenRefresh` before `withHttpError` so it can see the raw 401 response: | ||
@@ -21,10 +21,9 @@ ``` | ||
| fetch, | ||
| f => withTokenRefresh(f, {refreshToken: ...}), | ||
| withHttpError, | ||
| withTokenRefresh({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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that retries once with a refreshed `Authorization: Bearer <token>` header on 401 responses. | ||
@@ -35,3 +34,3 @@ @example | ||
| const apiFetch = withTokenRefresh(fetch, { | ||
| const apiFetch = withTokenRefresh({ | ||
| refreshToken: async () => { | ||
@@ -42,3 +41,3 @@ const response = await fetch('/auth/refresh', {method: 'POST'}); | ||
| }, | ||
| }); | ||
| })(fetch); | ||
@@ -55,4 +54,4 @@ const response = await apiFetch('/api/users'); | ||
| fetch, | ||
| f => withBaseUrl(f, 'https://api.example.com'), | ||
| f => withTokenRefresh(f, { | ||
| withBaseUrl('https://api.example.com'), | ||
| withTokenRefresh({ | ||
| refreshToken: async () => { | ||
@@ -64,3 +63,3 @@ const response = await fetch('/auth/refresh', {method: 'POST'}); | ||
| }), | ||
| withHttpError, | ||
| withHttpError(), | ||
| ); | ||
@@ -72,3 +71,2 @@ | ||
| export function withTokenRefresh( | ||
| fetchFunction: typeof fetch, | ||
| options: { | ||
@@ -80,2 +78,2 @@ /** | ||
| } | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
+156
-144
@@ -8,4 +8,5 @@ import { | ||
| resolveRequestBodyOptions, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestHeaders, | ||
| resolveAuthorizationHeaderSymbol, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
@@ -16,8 +17,9 @@ | ||
| @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call when they have the same effective `Authorization` header, preventing token invalidation races across requests for the same auth context. Requests with different effective `Authorization` headers refresh separately. | ||
| @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. | ||
| @param {() => string | Promise<string>} options.refreshToken - Called when a 401 response is received. Should return the new token string. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns a wrapped fetch function that retries once with a refreshed `Authorization: Bearer <token>` header on 401 responses. | ||
| */ | ||
| export function withTokenRefresh(fetchFunction, {refreshToken}) { | ||
| export function withTokenRefresh({refreshToken}) { | ||
| /* | ||
@@ -30,176 +32,186 @@ Boundary: this wrapper only deduplicates overlapping refreshes. | ||
| 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); | ||
| }; | ||
| return fetchFunction => { | ||
| const getAbortReason = signal => signal?.reason ?? new DOMException('This operation was aborted', 'AbortError'); | ||
| const returnResponse = async (response, retryBody) => { | ||
| await discardBody(retryBody); | ||
| return response; | ||
| }; | ||
| 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 clearRefreshEntry = (authorization, refreshEntry) => { | ||
| if (refreshEntries.get(authorization) === refreshEntry) { | ||
| refreshEntries.delete(authorization); | ||
| } | ||
| }; | ||
| const createRefreshEntry = authorization => { | ||
| const refreshEntry = { | ||
| waiterCount: 0, | ||
| const returnResponse = async (response, retryBody) => { | ||
| await discardBody(retryBody); | ||
| return response; | ||
| }; | ||
| refreshEntry.promise = (async () => { | ||
| try { | ||
| return await refreshToken(); | ||
| } finally { | ||
| clearRefreshEntry(authorization, refreshEntry); | ||
| const clearRefreshEntry = (authorization, refreshEntry) => { | ||
| if (refreshEntries.get(authorization) === refreshEntry) { | ||
| refreshEntries.delete(authorization); | ||
| } | ||
| })(); | ||
| }; | ||
| refreshEntries.set(authorization, refreshEntry); | ||
| return refreshEntry; | ||
| }; | ||
| const createRefreshEntry = authorization => { | ||
| const refreshEntry = { | ||
| waiterCount: 0, | ||
| }; | ||
| const getToken = async (authorization, signal) => { | ||
| if (signal?.aborted) { | ||
| throw getAbortReason(signal); | ||
| } | ||
| refreshEntry.promise = (async () => { | ||
| try { | ||
| return await refreshToken(); | ||
| } finally { | ||
| clearRefreshEntry(authorization, refreshEntry); | ||
| } | ||
| })(); | ||
| let refreshEntry = refreshEntries.get(authorization); | ||
| refreshEntries.set(authorization, refreshEntry); | ||
| return refreshEntry; | ||
| }; | ||
| 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); | ||
| } | ||
| const getToken = async (authorization, signal) => { | ||
| if (signal?.aborted) { | ||
| throw getAbortReason(signal); | ||
| } | ||
| refreshEntry.waiterCount++; | ||
| let refreshEntry = refreshEntries.get(authorization); | ||
| if (!signal) { | ||
| try { | ||
| return await refreshEntry.promise; | ||
| } finally { | ||
| refreshEntry.waiterCount--; | ||
| 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); | ||
| } | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| let didFinish = false; | ||
| refreshEntry.waiterCount++; | ||
| const finish = () => { | ||
| if (didFinish) { | ||
| return; | ||
| if (!signal) { | ||
| try { | ||
| return await refreshEntry.promise; | ||
| } finally { | ||
| refreshEntry.waiterCount--; | ||
| } | ||
| } | ||
| didFinish = true; | ||
| refreshEntry.waiterCount--; | ||
| signal.removeEventListener('abort', abort); | ||
| }; | ||
| return new Promise((resolve, reject) => { | ||
| let didFinish = false; | ||
| const abort = () => { | ||
| finish(); | ||
| // Preserve the caller's original abort reason so this still matches fetch cancellation semantics. | ||
| reject(getAbortReason(signal)); | ||
| }; | ||
| const finish = () => { | ||
| if (didFinish) { | ||
| return; | ||
| } | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| (async () => { | ||
| try { | ||
| resolve(await refreshEntry.promise); | ||
| } catch (error) { | ||
| reject(error); | ||
| } finally { | ||
| 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)); | ||
| }; | ||
| 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; | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| (async () => { | ||
| try { | ||
| resolve(await refreshEntry.promise); | ||
| } catch (error) { | ||
| reject(error); | ||
| } finally { | ||
| finish(); | ||
| } | ||
| })(); | ||
| }); | ||
| }; | ||
| 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}; | ||
| 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 = new Headers(await resolveRequestHeaders(fetchFunction, urlOrRequest, options)); | ||
| const hasRequestHeaderResolver = fetchFunction[resolveRequestHeadersSymbol] !== undefined; | ||
| const hasInitialHeaders = request | ||
| || options.headers !== undefined | ||
| || hasRequestHeaderResolver | ||
| || hasHeaders(requestHeaders); | ||
| let requestOptions = bodyResolvedOptions; | ||
| retryBody = clonedBody; | ||
| hasRetryBody = true; | ||
| } | ||
| if (hasInitialHeaders) { | ||
| requestOptions = withResolvedRequestHeaders(bodyResolvedOptions, requestHeaders); | ||
| } | ||
| initialOptions = withSignal(initialOptions, signal); | ||
| const authorization = fetchFunction[resolveAuthorizationHeaderSymbol]?.(urlOrRequest, initialOptions) ?? requestHeaders.get('Authorization'); | ||
| let response; | ||
| let retryBody = bodyResolvedOptions.body; | ||
| let hasRetryBody = false; | ||
| try { | ||
| response = await fetchFunction(urlOrRequest, initialOptions); | ||
| } catch (error) { | ||
| await discardBody(retryBody); | ||
| throw error; | ||
| } | ||
| 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(); | ||
| requestOptions = {...requestOptions, body: initialBody}; | ||
| if (response.status !== 401) { | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| retryBody = clonedBody; | ||
| hasRetryBody = true; | ||
| } | ||
| // 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); | ||
| } | ||
| requestOptions = withSignal(requestOptions, signal); | ||
| const authorization = requestHeaders.get('Authorization'); | ||
| let response; | ||
| 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); | ||
| try { | ||
| response = await fetchFunction(urlOrRequest, requestOptions); | ||
| } catch (error) { | ||
| await discardBody(retryBody); | ||
| throw error; | ||
| } | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| if (response.status !== 401) { | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| const headers = new Headers(requestHeaders); | ||
| // 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); | ||
| } | ||
| // The original 401 response is never exposed once we retry, so release its body before issuing the second request. | ||
| await discardHiddenBodies(response); | ||
| let token; | ||
| // 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)); | ||
| 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 | ||
| ? withResolvedRequestHeaders({...bodyResolvedOptions, body: retryBody}, headers) | ||
| : withResolvedRequestHeaders(bodyResolvedOptions, headers); | ||
| return fetchFunction(urlOrRequest, withSignal(retryOptions, signal)); | ||
| }; | ||
| return copyFetchMetadata(fetchWithTokenRefresh, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithTokenRefresh, fetchFunction); | ||
| } |
@@ -10,5 +10,4 @@ import type {Progress} from './with-download-progress.js'; | ||
| @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. | ||
| @returns A wrapper that takes a fetch function and returns a wrapped fetch function that reports upload progress for explicit streamed bodies. | ||
@@ -19,7 +18,7 @@ @example | ||
| const fetchWithUploadProgress = withUploadProgress(fetch, { | ||
| const fetchWithUploadProgress = withUploadProgress({ | ||
| onProgress(progress) { | ||
| console.log(`Upload: ${Math.round(progress.percent * 100)}%`); | ||
| }, | ||
| }); | ||
| })(fetch); | ||
@@ -34,6 +33,5 @@ await fetchWithUploadProgress('https://example.com/upload', { | ||
| export function withUploadProgress( | ||
| fetchFunction: typeof fetch, | ||
| options?: { | ||
| onProgress?: (progress: Progress) => void; | ||
| } | ||
| ): typeof fetch; | ||
| ): (fetchFunction: typeof fetch) => typeof fetch; |
@@ -63,36 +63,38 @@ import { | ||
| export function withUploadProgress(fetchFunction, {onProgress} = {}) { | ||
| const fetchWithUploadProgress = async (urlOrRequest, options = {}) => { | ||
| if (onProgress) { | ||
| const {body} = options; | ||
| export function withUploadProgress({onProgress} = {}) { | ||
| return fetchFunction => { | ||
| 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 (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), | ||
| }; | ||
| 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; | ||
| } | ||
| const rebuiltRequest = new Request(urlOrRequest.url, requestSnapshot(urlOrRequest)); | ||
| rebuiltRequest[blockedDefaultHeaderNamesSymbol] = urlOrRequest[blockedDefaultHeaderNamesSymbol]; | ||
| urlOrRequest = rebuiltRequest; | ||
| options = markBlockedDefaultHeaders({...options, body: trackedStream, duplex: 'half'}, blockedRequestBodyHeaderNames); | ||
| } | ||
| options = markBlockedDefaultHeaders({...options, body: trackedStream, duplex: 'half'}, blockedRequestBodyHeaderNames); | ||
| } | ||
| } | ||
| return fetchFunction(urlOrRequest, options); | ||
| return fetchFunction(urlOrRequest, options); | ||
| }; | ||
| return copyFetchMetadata(fetchWithUploadProgress, fetchFunction); | ||
| }; | ||
| return copyFetchMetadata(fetchWithUploadProgress, fetchFunction); | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
162809
5.79%3620
4.23%108
1.89%34
9.68%