fetch-extras
Advanced tools
+1
-1
| { | ||
| "name": "fetch-extras", | ||
| "version": "3.0.0", | ||
| "version": "3.0.1", | ||
| "description": "Useful utilities for working with Fetch", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
| /** | ||
| Custom error class for HTTP errors that should be thrown when the response has a non-2xx status code. | ||
| The error message includes the response URL, but embedded URL credentials are redacted from that message. The original `response.url` is still available on `error.response`. | ||
| @example | ||
@@ -5,0 +7,0 @@ ``` |
+17
-1
| import {copyFetchMetadata} from './utilities.js'; | ||
| function formatErrorUrl(url) { | ||
| if (!url) { | ||
| return url; | ||
| } | ||
| try { | ||
| const parsedUrl = new URL(url); | ||
| parsedUrl.username = ''; | ||
| parsedUrl.password = ''; | ||
| return parsedUrl.href; | ||
| } catch { | ||
| return url; | ||
| } | ||
| } | ||
| export class HttpError extends Error { | ||
@@ -7,4 +22,5 @@ constructor(response) { | ||
| const reason = status ? `status code ${status}` : 'an unknown error'; | ||
| const errorUrl = formatErrorUrl(response.url); | ||
| super(`Request failed with ${reason}: ${response.url}`); | ||
| super(`Request failed with ${reason}: ${errorUrl}`); | ||
| Error.captureStackTrace?.(this, this.constructor); | ||
@@ -11,0 +27,0 @@ |
@@ -1,2 +0,2 @@ | ||
| // Inlined from https://github.com/sindresorhus/is-network-error v1.3.1 | ||
| // Inlined from https://github.com/sindresorhus/is-network-error v1.3.2 | ||
@@ -31,3 +31,3 @@ const objectToString = Object.prototype.toString; | ||
| // Safari 17+ has generic message but no stack for network errors | ||
| if (message === 'Load failed') { | ||
| if (message === 'Load failed' || (message.startsWith('Load failed (') && message.endsWith(')'))) { | ||
| return stack === undefined | ||
@@ -34,0 +34,0 @@ // Sentry adds its own stack trace to the fetch error, so also check for that |
@@ -38,3 +38,3 @@ /** | ||
| **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. | ||
| **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, and automatic default headers from wrappers such as `withHeaders()` stay blocked unless you return them explicitly 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. | ||
@@ -242,3 +242,3 @@ @param data - Context object with response, current URL, and items. | ||
| **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`. | ||
| **Important**: When pagination crosses to a different origin, inherited request headers are cleared before the next request is built, and automatic default headers from wrappers such as `withHeaders()` are blocked from being re-applied automatically on later pages. If you intentionally need headers on the new origin, return them explicitly from `pagination.paginate`. | ||
@@ -245,0 +245,0 @@ **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. |
+5
-25
@@ -11,2 +11,3 @@ import parseLinkHeader from './parse-link-header.js'; | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| } from './utilities.js'; | ||
@@ -214,3 +215,3 @@ | ||
| /* | ||
| 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. | ||
| 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. Research note: most general HTTP clients leave pagination policy to user code, while Got strips inherited standard credential headers on cross-origin pagination and preserves explicit replacements. Since fetch-extras does not have a pagination-specific redirect hook for app-defined auth policies, we use the smaller contract here: clear carried request header state and block all automatic default headers from later cross-origin pages. Callers can still opt back in explicitly through `pagination.paginate`. | ||
| */ | ||
@@ -224,2 +225,3 @@ const clearCrossOriginHeaderState = (requestTemplate, fetchOptions) => { | ||
| : {...fetchOptions}; | ||
| const blockedFetchOptions = markToBlockDefaultHeaders(nextFetchOptions, ['*']); | ||
@@ -231,3 +233,3 @@ return { | ||
| }), | ||
| currentFetchOptions: hasExplicitBody(fetchOptions) ? normalizeBodylessFetchOptions(nextFetchOptions) : nextFetchOptions, | ||
| currentFetchOptions: hasExplicitBody(fetchOptions) ? normalizeBodylessFetchOptions(blockedFetchOptions) : blockedFetchOptions, | ||
| inheritedStateOrigin: undefined, | ||
@@ -240,24 +242,2 @@ }; | ||
| /** | ||
| Paginate through API responses using async iteration. | ||
| By default, it automatically follows RFC 5988 Link headers with rel="next". | ||
| Note: 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`. | ||
| @param {RequestInfo | URL} input - The URL to fetch. | ||
| @param {RequestInit & {pagination?: PaginationOptions, fetchFunction?: Function}} options - Fetch options plus pagination options. | ||
| @yields {*} Items from each page. | ||
| @returns {AsyncIterableIterator} An async iterator that yields items from each page. | ||
| @example | ||
| ``` | ||
| import {paginate} from 'fetch-extras'; | ||
| // Basic usage with Link headers | ||
| for await (const item of paginate('https://api.example.com/items')) { | ||
| console.log(item); | ||
| } | ||
| ``` | ||
| */ | ||
| // eslint-disable-next-line complexity | ||
@@ -354,3 +334,3 @@ export async function * paginate(input, options = {}) { | ||
| let currentUrl = requestTemplate ? new URL(requestTemplate.url) : input; | ||
| let inheritedStateOrigin = absoluteUrl(requestTemplate ? requestTemplate.url : input); | ||
| let inheritedStateOrigin = absoluteUrl(resolveRequestUrl(fetchFunction, requestTemplate ?? input)); | ||
@@ -357,0 +337,0 @@ while (numberOfRequests < paginationOptions.requestLimit && countLimit > 0) { |
@@ -65,9 +65,2 @@ const tokenPattern = /^[!#$%&'*+.^`|~\w-]+$/; | ||
| /** | ||
| Parses an RFC 5988 Link header into an array of link objects. | ||
| @param {string} linkHeader - The Link header value. | ||
| @returns {Array<{url: string, parameters: Object}>} Parsed links with normalized parameter values. | ||
| @throws {Error} If the Link header format is invalid. | ||
| */ | ||
| export default function parseLinkHeader(linkHeader) { | ||
@@ -86,3 +79,3 @@ const links = []; | ||
| const url = trimmedUrlReference.slice(1, -1); | ||
| const parameters = {}; | ||
| const parameters = Object.create(null); | ||
@@ -89,0 +82,0 @@ for (const parameter of rawLinkParameters) { |
@@ -1,8 +0,1 @@ | ||
| /** | ||
| Pipes a value through a series of functions, left to right. | ||
| @param {unknown} value - The initial value. | ||
| @param {...Function} functions - Functions to apply in order. | ||
| @returns {unknown} The result of applying all functions. | ||
| */ | ||
| export function pipeline(value, ...functions) { | ||
@@ -9,0 +2,0 @@ for (const function_ of functions) { |
+7
-15
@@ -12,2 +12,3 @@ export const blockedDefaultHeaderNamesSymbol = Symbol('blockedDefaultHeaderNames'); | ||
| export const defersFetchStartSymbol = Symbol('defersFetchStart'); | ||
| const rootFetchFunctions = new WeakMap(); | ||
| export const requestBodyHeaderNames = [ | ||
@@ -187,9 +188,2 @@ 'content-encoding', | ||
| /** | ||
| Creates a promise that resolves after the specified delay. | ||
| @param {number} milliseconds - The delay duration in milliseconds. | ||
| @param {{signal?: AbortSignal}} [options] - Options for the delay. | ||
| @returns {Promise<void>} A promise that resolves after the delay. | ||
| */ | ||
| export function delay(milliseconds, {signal} = {}) { | ||
@@ -303,2 +297,6 @@ return new Promise((resolve, reject) => { | ||
| export function getRootFetchFunction(fetchFunction) { | ||
| return rootFetchFunctions.get(fetchFunction) ?? fetchFunction; | ||
| } | ||
| export function deleteHeaders(headers, headerNames) { | ||
@@ -378,10 +376,2 @@ for (const headerName of headerNames) { | ||
| /** | ||
| 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 = {}) { | ||
@@ -486,3 +476,5 @@ return new Headers(await resolveRequestHeaders(fetchFunction, urlOrRequest, options)); | ||
| rootFetchFunctions.set(targetFetch, getRootFetchFunction(sourceFetch)); | ||
| return targetFetch; | ||
| } |
| /** | ||
| Wraps a fetch function to resolve relative URLs against a base URL. Useful for API clients with a consistent base URL. | ||
| Only string-based relative URLs are resolved against the base URL. Absolute URLs and URL objects are passed through unchanged. 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. | ||
| 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 ambiguity about 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. | ||
| `withBaseUrl()` only resolves URLs. Absolute inputs still go through other wrappers. | ||
| `withBaseUrl()` is intended for normal relative paths and explicit absolute URLs. Ambiguous `http:` / `https:` shorthand string inputs without `//` are intentionally unsupported. | ||
| Can be combined with other `with*` functions. | ||
@@ -33,2 +37,11 @@ | ||
| ``` | ||
| const fetchWithBaseUrl = withBaseUrl('https://api.example.com')(fetch); | ||
| await fetchWithBaseUrl('https://other.example.com/users'); | ||
| await fetchWithBaseUrl(new URL('https://other.example.com/users')); | ||
| await fetchWithBaseUrl(new Request('https://other.example.com/users')); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withBaseUrl, withHttpError, withTimeout} from 'fetch-extras'; | ||
@@ -35,0 +48,0 @@ |
+53
-11
| import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to resolve relative URLs against a base URL. Only string-based relative URLs are resolved; absolute URLs and URL objects are passed through unchanged. Protocol-relative URLs are rejected. | ||
| const schemePattern = /^(?<scheme>[a-z][a-z\d+\-.]*:)/i; | ||
| const blockedSpecialSchemeShorthandSchemes = new Set(['http', 'https']); | ||
| @param {URL | string} baseUrl - The base URL to resolve relative URLs against. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} | ||
| */ | ||
| export function withBaseUrl(baseUrl) { | ||
@@ -27,8 +24,47 @@ const baseUrlString = baseUrl instanceof URL ? baseUrl.href : baseUrl; | ||
| const shouldBypassBaseUrl = input => { | ||
| const match = schemePattern.exec(input); | ||
| if (!match) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }; | ||
| const isUnsupportedSpecialSchemeShorthand = input => { | ||
| const match = schemePattern.exec(input); | ||
| if (!match) { | ||
| return false; | ||
| } | ||
| const scheme = match.groups.scheme.slice(0, -1).toLowerCase(); | ||
| return blockedSpecialSchemeShorthandSchemes.has(scheme) && !input.startsWith(`${match[0]}//`); | ||
| }; | ||
| const resolveRequestUrl = urlOrRequest => { | ||
| if (urlOrRequest instanceof Request) { | ||
| return urlOrRequest.url; | ||
| } | ||
| if (urlOrRequest instanceof URL) { | ||
| return urlOrRequest.href; | ||
| } | ||
| if (typeof urlOrRequest !== 'string') { | ||
| return urlOrRequest instanceof Request ? urlOrRequest.url : String(urlOrRequest); | ||
| urlOrRequest = String(urlOrRequest); | ||
| } | ||
| if (/^[a-z][a-z\d+\-.]*:/i.test(urlOrRequest)) { | ||
| if (isUnsupportedSpecialSchemeShorthand(urlOrRequest)) { | ||
| /* | ||
| Boundary: although fetch accepts `http:foo` / `https:foo` and normalizes them as absolute URLs, this wrapper intentionally does not support those shorthand forms. | ||
| In a base-URL helper they are too easy to misread as ordinary explicit web URLs, so we keep the contract smaller and require `http://` / `https://` for pass-through absolute web inputs. | ||
| */ | ||
| throw new TypeError('Special-scheme URLs without `//` are unsupported.'); | ||
| } | ||
| if (shouldBypassBaseUrl(urlOrRequest)) { | ||
| /* | ||
| Explicit absolute string inputs intentionally bypass base-URL joining. | ||
| Do not "fix" this by rejecting cross-origin absolute URLs. | ||
| */ | ||
| return urlOrRequest; | ||
@@ -60,7 +96,13 @@ } | ||
| const fetchWithBaseUrl = async (urlOrRequest, options = {}) => fetchFunction( | ||
| typeof urlOrRequest === 'string' ? resolveRequestUrl(urlOrRequest) : urlOrRequest, | ||
| options, | ||
| ); | ||
| const fetchWithBaseUrl = async (urlOrRequest, options = {}) => { | ||
| const resolvedRequestUrl = resolveRequestUrl(urlOrRequest); | ||
| return fetchFunction( | ||
| urlOrRequest instanceof Request || urlOrRequest instanceof URL | ||
| ? urlOrRequest | ||
| : resolvedRequestUrl, | ||
| options, | ||
| ); | ||
| }; | ||
| fetchWithBaseUrl[resolveRequestUrlSymbol] = resolveRequestUrl; | ||
@@ -67,0 +109,0 @@ |
@@ -10,4 +10,6 @@ /** | ||
| 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. | ||
| 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. Responses that include `Set-Cookie`, any `Vary` header, or `Cache-Control: no-store` / `no-cache` are also treated as uncacheable so a URL-only wrapper does not replay responses whose representation depends on hidden request state or should be revalidated before reuse. With `cache: 'only-if-cached'`, that still means a cache miss and the wrapper returns its synthetic `504` response. | ||
| This wrapper cannot see ambient runtime credentials such as browser-managed same-origin cookies or other hidden auth state that is attached outside explicit request headers. If a response may vary on that kind of ambient state, treat `withCache()` as unsupported for that route and rely on server-side cache headers or a cache keyed with your own explicit auth signal instead. | ||
| 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. | ||
@@ -14,0 +16,0 @@ |
+50
-2
@@ -150,2 +150,29 @@ import { | ||
| function hasSetCookieHeader(response) { | ||
| return response.headers.getSetCookie?.().length > 0 || response.headers.has('set-cookie'); | ||
| } | ||
| function hasVaryHeader(response) { | ||
| return response.headers.has('vary'); | ||
| } | ||
| function hasResponseCacheControlDirective(response, directive) { | ||
| const cacheControl = response.headers.get('cache-control'); | ||
| if (!cacheControl) { | ||
| return false; | ||
| } | ||
| return cacheControl | ||
| .split(',') | ||
| .some(value => value.trim().split('=', 1)[0].toLowerCase() === directive); | ||
| } | ||
| function isUncacheableResponse(response) { | ||
| return hasVaryHeader(response) | ||
| || hasResponseCacheControlDirective(response, 'no-cache') | ||
| || hasResponseCacheControlDirective(response, 'no-store') | ||
| || hasSetCookieHeader(response); | ||
| } | ||
| export function withCache({ttl}) { | ||
@@ -211,2 +238,7 @@ if (typeof ttl !== 'number' || ttl <= 0 || !Number.isFinite(ttl)) { | ||
| const currentTime = evictExpiredEntries(cache, state, retainStaleEntry ? url : undefined); | ||
| /* | ||
| Cacheability stays tied to explicit request state only. | ||
| Ambient runtime state like `globalThis.location` is not a reliable signal for whether credentials will be attached, so it must not change caching by itself. | ||
| If a caller needs cookie-aware caching, that needs an explicit request signal or a dedicated wrapper contract. | ||
| */ | ||
| const isCacheableRequest = !isRangedRequest && !hasRequestHeaders && !hasNonDefaultRequestState; | ||
@@ -233,7 +265,22 @@ | ||
| return trackPending(resources, url, 'pendingGetCount', async () => { | ||
| return trackPending(resources, url, 'pendingGetCount', async urlState => { | ||
| const response = await fetchFunction(urlOrRequest, fetchOptionsWithHeaders); | ||
| const isPartialResponse = response.status === 206; | ||
| const shouldInvalidateCachedResponse = response.ok | ||
| && isCacheableRequest | ||
| && !isPartialResponse | ||
| && isUncacheableResponse(response) | ||
| && cacheMode !== 'only-if-cached' | ||
| && cacheMode !== 'no-store' | ||
| && generation === getGeneration(cache, state, url); | ||
| // Only cache successful responses. | ||
| if (shouldInvalidateCachedResponse) { | ||
| cache.delete(url); | ||
| urlState.generation = generation + 1; | ||
| } | ||
| /* | ||
| URL-only memoization cannot safely replay responses whose representation varies on request headers. | ||
| Those responses need a real HTTP cache key, so this wrapper treats any `Vary` response as uncacheable. | ||
| */ | ||
| if ( | ||
@@ -243,2 +290,3 @@ response.ok | ||
| && !isPartialResponse | ||
| && !isUncacheableResponse(response) | ||
| && cacheMode !== 'no-store' | ||
@@ -245,0 +293,0 @@ && generation === getGeneration(cache, state, url) |
| /** | ||
| Wraps a fetch function with in-flight request deduplication for plain GET URL requests. | ||
| If multiple callers request the same URL concurrently without passing a `Request` object or any non-empty `RequestInit`, only one network request is made. Each caller receives an independent clone of the response. Once the request completes, the result is immediately forgotten, so a later request starts a fresh fetch. | ||
| If multiple callers request the same URL concurrently without passing a `Request` object or any non-empty `RequestInit`, only one network request is made, as long as the effective request headers stay empty after inner request-building wrappers run. Each caller receives an independent clone of the response. Once the request completes, the result is immediately forgotten, so a later request starts a fresh fetch. | ||
| Non-GET requests, `Request` objects, calls with non-empty `RequestInit`, and fetch functions already wrapped with `withTimeout` pass through unchanged. | ||
| Non-GET requests, `Request` objects, calls with non-empty `RequestInit`, requests with effective headers, and fetch functions already wrapped with `withTimeout` pass through unchanged. | ||
| Deduplication only applies when you call the wrapper with a URL and no non-empty `RequestInit`. An empty `{}` is treated the same as omitting the second argument so transparent outer wrappers like `withHttpError` still compose correctly. | ||
| `withDeduplication()` skips requests once inner wrappers resolve any headers. This avoids collapsing concurrent requests that differ by auth or other header-driven context. | ||
| `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. | ||
@@ -11,0 +13,0 @@ |
@@ -1,2 +0,10 @@ | ||
| import {copyFetchMetadata, resolveRequestUrl, timeoutDurationSymbol} from './utilities.js'; | ||
| import { | ||
| copyFetchMetadata, | ||
| getResolvedRequestHeaders, | ||
| hasHeaders, | ||
| resolveRequestHeadersSymbol, | ||
| resolveRequestUrl, | ||
| timeoutDurationSymbol, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
@@ -39,2 +47,8 @@ function enqueueWaiter(entry) { | ||
| async function getFetchOptionsWithResolvedHeaders(fetchFunction, urlOrRequest, options) { | ||
| const resolvedHeaders = await getResolvedRequestHeaders(fetchFunction, urlOrRequest, options); | ||
| return withResolvedRequestHeaders(options, resolvedHeaders); | ||
| } | ||
| export function withDeduplication() { | ||
@@ -46,3 +60,3 @@ return fetchFunction => { | ||
| const fetchWithDeduplication = async function (urlOrRequest, options) { | ||
| const requestOptions = options ?? {}; | ||
| let requestOptions = options ?? {}; | ||
@@ -53,2 +67,12 @@ if (!shouldDeduplicate(fetchFunction, urlOrRequest, requestOptions)) { | ||
| if (fetchFunction[resolveRequestHeadersSymbol] !== undefined) { | ||
| const requestOptionsWithResolvedHeaders = await getFetchOptionsWithResolvedHeaders(fetchFunction, urlOrRequest, requestOptions); | ||
| if (hasHeaders(requestOptionsWithResolvedHeaders.headers)) { | ||
| return fetchFunction(urlOrRequest, requestOptionsWithResolvedHeaders); | ||
| } | ||
| requestOptions = requestOptionsWithResolvedHeaders; | ||
| } | ||
| const key = resolveDeduplicationKey(fetchFunction, urlOrRequest); | ||
@@ -55,0 +79,0 @@ const existingEntry = pending.get(key); |
@@ -14,13 +14,2 @@ import { | ||
| /** | ||
| Returns a wrapped fetch function that includes default headers on every request. Per-call headers take priority over the defaults, so you can always override a default on a specific request. | ||
| Can be combined with other `with*` functions. | ||
| @param {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(defaultHeaders) { | ||
@@ -33,3 +22,2 @@ const isFunction = typeof defaultHeaders === 'function'; | ||
| const getMergedHeaders = (urlOrRequest, resolvedDefaults, options = {}) => { | ||
| const merged = new Headers(resolvedDefaults); | ||
| const requestHeaders = urlOrRequest instanceof Request ? new Headers(urlOrRequest.headers) : undefined; | ||
@@ -42,2 +30,4 @@ const callHeaders = options.headers ? new Headers(options.headers) : undefined; | ||
| ]); | ||
| const hasBlockedAllDefaultHeaders = blockedDefaultHeaderNames.has('*'); | ||
| const merged = hasBlockedAllDefaultHeaders ? new Headers() : new Headers(resolvedDefaults); | ||
@@ -49,2 +39,3 @@ deleteHeaders(merged, blockedDefaultHeaderNames); | ||
| return { | ||
| hasBlockedAllDefaultHeaders, | ||
| merged, | ||
@@ -74,2 +65,3 @@ requestHeaders, | ||
| const { | ||
| hasBlockedAllDefaultHeaders, | ||
| resolvedDefaults, | ||
@@ -87,3 +79,3 @@ merged, | ||
| if (shouldTreatRequestBodyHeadersAsInherited) { | ||
| const resolvedDefaultHeaders = new Headers(resolvedDefaults); | ||
| const resolvedDefaultHeaders = hasBlockedAllDefaultHeaders ? new Headers() : new Headers(resolvedDefaults); | ||
| const inheritedHeaderNames = blockedRequestBodyHeaderNames.filter(headerName => | ||
@@ -90,0 +82,0 @@ requestHeaders.has(headerName) |
+0
-10
@@ -22,12 +22,2 @@ import { | ||
| /** | ||
| 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 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 {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 {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns a wrapped fetch function with hooks. | ||
| */ | ||
| export function withHooks({beforeRequest, afterResponse} = {}) { | ||
@@ -34,0 +24,0 @@ return fetchFunction => { |
@@ -83,7 +83,2 @@ import { | ||
| /** | ||
| Returns a wrapped fetch function that automatically stringifies plain-object and array bodies as JSON and sets the `Content-Type: application/json` header. | ||
| @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() { | ||
@@ -90,0 +85,0 @@ return fetchFunction => { |
+0
-20
@@ -35,22 +35,2 @@ import isNetworkError from './is-network-error.js'; | ||
| /** | ||
| 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, and ignores malformed values by falling back to `backoff`. | ||
| When all retries are exhausted, the last response is returned (for HTTP status retries) or the last error is thrown (for network errors). | ||
| Requests with a one-shot body provided via `options.body`, such as a `ReadableStream` or AsyncIterable, are sent as-is and are not retried. Retrying those uploads would require buffering and would change wrapper composition semantics such as upload progress. Requests whose body comes from a bare `Request` object (no `options.body` override) are also not retried. | ||
| `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] | ||
| @param {number} [options.retries=2] - Number of retries after the initial attempt. `retries: 2` means up to 3 total attempts. | ||
| @param {string[]} [options.methods=['GET','HEAD','PUT','DELETE','OPTIONS','TRACE']] - HTTP methods to retry. | ||
| @param {number[]} [options.statusCodes=[408,429,500,502,503,504]] - HTTP status codes that trigger a retry. | ||
| @param {number} [options.maxRetryAfter=60000] - Maximum `Retry-After` duration in milliseconds. If the server requests a longer delay, the response is returned without retrying. | ||
| @param {(attemptNumber: number) => number} [options.backoff] - Function returning the delay in milliseconds before a retry. Receives the 1-based attempt number that just failed. | ||
| @param {(context: {error?: Error, response?: Response, attemptNumber: number, retriesLeft: number}) => boolean | Promise<boolean>} [options.shouldRetry] - Called after built-in checks pass, before retrying. Return `false` to stop retrying. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} A function that accepts a fetch function and returns a wrapped fetch function with automatic retry. | ||
| */ | ||
| export function withRetry(options = {}) { | ||
@@ -57,0 +37,0 @@ const { |
| import {copyFetchMetadata, resolveRequestUrlSymbol} from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to include default search parameters on every request. Per-call parameters in the URL take priority over the defaults. String URLs and `URL` objects are modified. `Request` objects are passed through unchanged. | ||
| @param {Record<string, string> | URLSearchParams | ReadonlyArray<readonly [string, string]>} defaultSearchParameters - Default search parameters to include on every request. | ||
| @returns {(fetchFunction: typeof fetch) => typeof fetch} | ||
| */ | ||
| export function withSearchParameters(defaultSearchParameters) { | ||
@@ -10,0 +4,0 @@ const defaults = new URLSearchParams(defaultSearchParameters); |
| /** | ||
| Wraps a fetch function to automatically refresh the token and retry the request on a `401 Unauthorized` response. | ||
| On a 401 response, calls `refreshToken` to obtain a new token, then retries the request once with `Authorization: Bearer <token>`. If the refresh fails or the retry also returns a non-2xx status, the response is returned as-is. Abort signals are still respected and will reject the call. | ||
| On a 401 response, calls `refreshToken` to obtain a new token, then retries the request once with `Authorization: Bearer <token>`. Requests that already use a non-bearer `Authorization` scheme are returned as-is, because this wrapper only knows how to refresh bearer tokens. Refresh failures or retries that still return a non-2xx status are also returned as-is. Abort signals are still respected and will reject the call. | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call 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. | ||
| Concurrent 401 responses that overlap while a refresh is still pending share a single `refreshToken` call when they have the same resolved request origin and effective `Authorization` header, preventing token invalidation races across requests for the same auth context. Requests on different origins or with different effective `Authorization` headers refresh separately. | ||
| When a request URL cannot be resolved to an absolute origin, deduplication stays conservative and only shares within the same wrapped fetch function. | ||
| > 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. | ||
@@ -25,3 +27,3 @@ | ||
| @param options - Token refresh options. | ||
| @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. | ||
| @returns A function that takes a fetch function and returns a wrapped fetch function that retries once with a refreshed `Authorization: Bearer <token>` header on 401 responses, unless the request already used a different `Authorization` scheme. | ||
@@ -67,3 +69,3 @@ @example | ||
| /** | ||
| Called when a 401 response is received. Should return the new token string. | ||
| Called when a 401 response is received and the request is anonymous or already uses bearer auth. Should return the new token string. | ||
| */ | ||
@@ -70,0 +72,0 @@ refreshToken: () => string | Promise<string>; |
@@ -10,14 +10,36 @@ import { | ||
| resolveRequestHeaders, | ||
| resolveRequestUrl, | ||
| withResolvedRequestHeaders, | ||
| } from './utilities.js'; | ||
| /** | ||
| Wraps a fetch function to automatically refresh the token and retry the request on a `401 Unauthorized` response. | ||
| function withSignal(options, signal) { | ||
| return signal ? {...options, signal} : options; | ||
| } | ||
| 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. | ||
| function isAsyncIterable(value) { | ||
| return value !== undefined | ||
| && !(value instanceof ReadableStream) | ||
| && typeof value[Symbol.asyncIterator] === 'function'; | ||
| } | ||
| @param {object} options | ||
| @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. | ||
| */ | ||
| function isBearerAuthorizationHeader(authorization) { | ||
| return /^bearer[\t ]+/i.test(authorization ?? ''); | ||
| } | ||
| function shouldRetryWithBearerToken(authorization) { | ||
| return authorization === null || isBearerAuthorizationHeader(authorization); | ||
| } | ||
| function getRequestOrigin(requestUrl) { | ||
| try { | ||
| return new URL(requestUrl).origin; | ||
| } catch { | ||
| try { | ||
| return new URL(requestUrl, globalThis.location?.href).origin; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| } | ||
| export function withTokenRefresh({refreshToken}) { | ||
@@ -31,8 +53,26 @@ /* | ||
| const refreshEntries = new Map(); | ||
| const unresolvedFetchFunctionKeys = new WeakMap(); | ||
| let unresolvedFetchFunctionKeyCount = 0; | ||
| const getUnresolvedFetchFunctionKey = fetchFunction => { | ||
| let fetchFunctionKey = unresolvedFetchFunctionKeys.get(fetchFunction); | ||
| if (fetchFunctionKey === undefined) { | ||
| unresolvedFetchFunctionKeyCount++; | ||
| fetchFunctionKey = `unresolved:${unresolvedFetchFunctionKeyCount}`; | ||
| unresolvedFetchFunctionKeys.set(fetchFunction, fetchFunctionKey); | ||
| } | ||
| return fetchFunctionKey; | ||
| }; | ||
| const getRefreshKey = (fetchFunction, urlOrRequest, authorization) => { | ||
| const requestUrl = resolveRequestUrl(fetchFunction, urlOrRequest); | ||
| const requestOrigin = getRequestOrigin(requestUrl); | ||
| return `${requestOrigin ?? getUnresolvedFetchFunctionKey(fetchFunction)}\n${authorization ?? ''}`; | ||
| }; | ||
| return fetchFunction => { | ||
| 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) => { | ||
@@ -48,9 +88,9 @@ await discardBody(response?.body); | ||
| const clearRefreshEntry = (authorization, refreshEntry) => { | ||
| if (refreshEntries.get(authorization) === refreshEntry) { | ||
| refreshEntries.delete(authorization); | ||
| const clearRefreshEntry = (refreshKey, refreshEntry) => { | ||
| if (refreshEntries.get(refreshKey) === refreshEntry) { | ||
| refreshEntries.delete(refreshKey); | ||
| } | ||
| }; | ||
| const createRefreshEntry = authorization => { | ||
| const createRefreshEntry = refreshKey => { | ||
| const refreshEntry = { | ||
@@ -64,11 +104,11 @@ waiterCount: 0, | ||
| } finally { | ||
| clearRefreshEntry(authorization, refreshEntry); | ||
| clearRefreshEntry(refreshKey, refreshEntry); | ||
| } | ||
| })(); | ||
| refreshEntries.set(authorization, refreshEntry); | ||
| refreshEntries.set(refreshKey, refreshEntry); | ||
| return refreshEntry; | ||
| }; | ||
| const getToken = async (authorization, signal) => { | ||
| const getToken = async (refreshKey, signal) => { | ||
| if (signal?.aborted) { | ||
@@ -78,11 +118,12 @@ throw getAbortReason(signal); | ||
| let refreshEntry = refreshEntries.get(authorization); | ||
| let refreshEntry = refreshEntries.get(refreshKey); | ||
| if (!refreshEntry || refreshEntry.waiterCount === 0) { | ||
| /* | ||
| Algorithm: one pending refresh promise per effective Authorization value. | ||
| Algorithm: one pending refresh promise per resolved request-origin and effective Authorization combination. | ||
| 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. | ||
| Requests whose origin cannot be resolved stay conservative and only share within the same wrapped fetch function. | ||
| If every waiter gives up on a still-pending refresh, the next same-context request starts a fresh attempt instead of inheriting an abandoned hung refresh. | ||
| */ | ||
| refreshEntry = createRefreshEntry(authorization); | ||
| refreshEntry = createRefreshEntry(refreshKey); | ||
| } | ||
@@ -181,2 +222,6 @@ | ||
| if (!shouldRetryWithBearerToken(authorization)) { | ||
| return returnResponse(response, retryBody); | ||
| } | ||
| // Boundary: bare Request bodies are not retried because cloning every Request up front would penalize successful uploads too. | ||
@@ -193,3 +238,4 @@ if ( | ||
| try { | ||
| token = await getToken(authorization, signal); | ||
| const refreshKey = getRefreshKey(fetchFunction, urlOrRequest, authorization); | ||
| token = await getToken(refreshKey, signal); | ||
| } catch (error) { | ||
@@ -196,0 +242,0 @@ // Refresh failures fall back to the original 401, but abort-driven failures must still reject. |
3702
2.27%162548
-0.16%