fetch-extras
Advanced tools
| // Inlined from https://github.com/sindresorhus/is-network-error v1.3.1 | ||
| const objectToString = Object.prototype.toString; | ||
| const isError = value => objectToString.call(value) === '[object Error]'; | ||
| const errorMessages = new Set([ | ||
| 'network error', // Chrome | ||
| 'NetworkError when attempting to fetch resource.', // Firefox | ||
| 'The Internet connection appears to be offline.', // Safari 16 | ||
| 'Network request failed', // `cross-fetch` | ||
| 'fetch failed', // Undici (Node.js) | ||
| 'terminated', // Undici (Node.js) | ||
| ' A network error occurred.', // Bun (WebKit) - leading space is intentional | ||
| 'Network connection lost', // Cloudflare Workers (fetch) | ||
| ]); | ||
| export default function isRawNetworkError(error) { | ||
| const isValid = error | ||
| && isError(error) | ||
| && error.name === 'TypeError' | ||
| && typeof error.message === 'string'; | ||
| if (!isValid) { | ||
| return false; | ||
| } | ||
| const {message, stack} = error; | ||
| // Safari 17+ has generic message but no stack for network errors | ||
| if (message === 'Load failed') { | ||
| return stack === undefined | ||
| // Sentry adds its own stack trace to the fetch error, so also check for that | ||
| || '__sentry_captured__' in error; | ||
| } | ||
| // Deno network errors start with specific text | ||
| if (message.startsWith('error sending request for url')) { | ||
| return true; | ||
| } | ||
| // Chrome: exact "Failed to fetch" or with hostname: "Failed to fetch (example.com)" | ||
| if (message === 'Failed to fetch' || (message.startsWith('Failed to fetch (') && message.endsWith(')'))) { | ||
| return true; | ||
| } | ||
| // Standard network error messages | ||
| return errorMessages.has(message); | ||
| } |
| // Standard Schema types (inlined from https://standardschema.dev) | ||
| export type StandardSchemaV1Issue = { | ||
| readonly message: string; | ||
| readonly path?: ReadonlyArray<PropertyKey | {readonly key: PropertyKey}> | undefined; | ||
| }; | ||
| type StandardSchemaV1SuccessResult<OutputType> = { | ||
| readonly value: OutputType; | ||
| readonly issues?: undefined; | ||
| }; | ||
| type StandardSchemaV1FailureResult = { | ||
| readonly issues: readonly StandardSchemaV1Issue[]; | ||
| readonly value?: undefined; | ||
| }; | ||
| type StandardSchemaV1Result<OutputType> = StandardSchemaV1SuccessResult<OutputType> | StandardSchemaV1FailureResult; | ||
| type StandardSchemaV1Types<InputType, OutputType> = { | ||
| readonly input: InputType; | ||
| readonly output: OutputType; | ||
| }; | ||
| type StandardSchemaV1Options = { | ||
| readonly libraryOptions?: Readonly<Record<string, unknown>> | undefined; | ||
| }; | ||
| export type StandardSchemaV1<InputType = unknown, OutputType = InputType> = { | ||
| readonly '~standard': { | ||
| readonly version: 1; | ||
| readonly vendor: string; | ||
| readonly validate: ( | ||
| value: unknown, | ||
| options?: StandardSchemaV1Options, | ||
| ) => StandardSchemaV1Result<OutputType> | Promise<StandardSchemaV1Result<OutputType>>; | ||
| readonly types?: StandardSchemaV1Types<InputType, OutputType> | undefined; | ||
| }; | ||
| }; | ||
| /* eslint-disable @typescript-eslint/indent */ | ||
| export type StandardSchemaV1InferOutput<Schema extends StandardSchemaV1> = Schema['~standard'] extends { | ||
| readonly types: StandardSchemaV1Types<unknown, infer OutputType>; | ||
| } | ||
| ? OutputType | ||
| : Extract< | ||
| Awaited<ReturnType<Schema['~standard']['validate']>>, | ||
| StandardSchemaV1SuccessResult<unknown> | ||
| > extends StandardSchemaV1SuccessResult<infer OutputType> | ||
| ? OutputType | ||
| : unknown; | ||
| /* eslint-enable @typescript-eslint/indent */ | ||
| /** | ||
| Custom error class thrown when [Standard Schema](https://standardschema.dev) validation fails in {@link withJsonResponse}. It has an `issues` property with the validation issues from the schema and a `response` property with the original `Response` object. | ||
| This error represents a schema rejection, not an HTTP failure. The request succeeded, but the response data did not match the expected schema. | ||
| @example | ||
| ``` | ||
| import {withJsonResponse, SchemaValidationError} from 'fetch-extras'; | ||
| import {z} from 'zod'; | ||
| const userSchema = z.object({name: z.string()}); | ||
| const fetchUser = withJsonResponse(fetch, {schema: userSchema}); | ||
| try { | ||
| const user = await fetchUser('/api/user'); | ||
| console.log(user.name); | ||
| } catch (error) { | ||
| if (error instanceof SchemaValidationError) { | ||
| console.error(error.issues); | ||
| console.log(error.response.status); | ||
| } | ||
| } | ||
| ``` | ||
| */ | ||
| export class SchemaValidationError extends Error { | ||
| readonly name: 'SchemaValidationError'; | ||
| readonly code: 'ERR_SCHEMA_VALIDATION'; | ||
| /** | ||
| The validation issues from the Standard Schema validator. | ||
| */ | ||
| readonly issues: readonly StandardSchemaV1Issue[]; | ||
| /** | ||
| The original `Response` object. Note that the body has already been consumed for JSON parsing. | ||
| */ | ||
| readonly response: Response; | ||
| constructor(issues: readonly StandardSchemaV1Issue[], response: Response); | ||
| } | ||
| /** | ||
| Wraps a fetch function to automatically parse response bodies as JSON. Optionally validates the parsed JSON against a [Standard Schema](https://standardschema.dev). | ||
| Unlike other wrappers, this one returns parsed data instead of a `Response`, so it should be placed last in a [`pipeline`](pipeline.md). | ||
| 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()`. | ||
| @param fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| @returns A wrapped fetch function that returns the parsed JSON data. | ||
| @throws {SyntaxError} When the response body is empty or is not valid JSON. | ||
| @example | ||
| ``` | ||
| import {withJsonResponse} from 'fetch-extras'; | ||
| const fetchJson = withJsonResponse(fetch); | ||
| const data = await fetchJson('/api/user/1'); | ||
| console.log(data.name); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withTimeout, withJsonResponse} from 'fetch-extras'; | ||
| const fetchJson = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| withJsonResponse, | ||
| ); | ||
| const data = await fetchJson('/api/user/1'); | ||
| ``` | ||
| */ | ||
| export function withJsonResponse<FetchFunction extends typeof fetch>( | ||
| fetchFunction: FetchFunction, | ||
| ): (...arguments_: Parameters<FetchFunction>) => Promise<unknown>; | ||
| /** | ||
| Wraps a fetch function to automatically parse response bodies as JSON and validate against a [Standard Schema](https://standardschema.dev). | ||
| Use a Standard Schema compatible validator such as [Zod](https://zod.dev) (v3.24+), [Valibot](https://valibot.dev), or [ArkType](https://arktype.io). | ||
| Unlike other wrappers, this one returns validated data instead of a `Response`, so it should be placed last in a [`pipeline`](pipeline.md). | ||
| 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()`. | ||
| @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. | ||
| @throws {SyntaxError} When the response body is empty or is not valid JSON. | ||
| @throws {SchemaValidationError} When the response JSON does not match the schema. | ||
| @example | ||
| ``` | ||
| import {withJsonResponse} from 'fetch-extras'; | ||
| import {z} from 'zod'; | ||
| const userSchema = z.object({name: z.string(), age: z.number()}); | ||
| const fetchUser = withJsonResponse(fetch, {schema: userSchema}); | ||
| const user = await fetchUser('/api/user/1'); | ||
| console.log(user.name); | ||
| ``` | ||
| @example | ||
| ``` | ||
| import {pipeline, withHttpError, withTimeout, withJsonResponse} from 'fetch-extras'; | ||
| import {z} from 'zod'; | ||
| const userSchema = z.object({name: z.string()}); | ||
| const fetchUser = pipeline( | ||
| fetch, | ||
| f => withTimeout(f, 5000), | ||
| withHttpError, | ||
| f => withJsonResponse(f, {schema: userSchema}), | ||
| ); | ||
| const user = await fetchUser('/api/user/1'); | ||
| ``` | ||
| */ | ||
| 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>; |
| import {copyFetchMetadata} from './utilities.js'; | ||
| export class SchemaValidationError extends Error { | ||
| constructor(issues, response) { | ||
| super('Response JSON validation failed'); | ||
| Error.captureStackTrace?.(this, this.constructor); | ||
| this.name = 'SchemaValidationError'; | ||
| this.code = 'ERR_SCHEMA_VALIDATION'; | ||
| this.issues = issues; | ||
| this.response = response; | ||
| } | ||
| } | ||
| export function withJsonResponse(fetchFunction, {schema} = {}) { | ||
| if (schema !== undefined && typeof schema?.['~standard']?.validate !== 'function') { | ||
| 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); | ||
| // 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; | ||
| } | ||
| const result = await schema['~standard'].validate(jsonValue); | ||
| if (result.issues) { | ||
| throw new SchemaValidationError(result.issues, response); | ||
| } | ||
| return result.value; | ||
| }; | ||
| return copyFetchMetadata(fetchWithJsonResponse, fetchFunction); | ||
| } |
+1
-4
| { | ||
| "name": "fetch-extras", | ||
| "version": "2.0.0", | ||
| "version": "2.1.0", | ||
| "description": "Useful utilities for working with Fetch", | ||
@@ -43,5 +43,2 @@ "license": "MIT", | ||
| ], | ||
| "dependencies": { | ||
| "is-network-error": "^1.3.1" | ||
| }, | ||
| "devDependencies": { | ||
@@ -48,0 +45,0 @@ "ava": "^7.0.0", |
+56
-24
@@ -7,4 +7,14 @@ <h1 align="center" title="fetch-extras"> | ||
| Great for creating tiny custom HTTP clients without a heavy dependency. Fully tree-shakeable. | ||
| Build tiny, focused HTTP clients by composing only the features you need on top of the standard `fetch` API. No wrapper objects, no new interface to learn, no lock-in. | ||
| ## Highlights | ||
| - **Composable** — Each `with*` function adds a single capability. Stack them to build exactly the client you need. | ||
| - **Works everywhere** — Browsers, Node.js, Deno, Bun, Cloudflare Workers, etc. | ||
| - **Zero dependencies** | ||
| - **Standard `fetch`** — The input and output are always a plain `fetch` function. Your code stays portable and familiar. | ||
| - **Tree-shakeable** — Only the utilities you import end up in your bundle. | ||
| - **TypeScript** — Full type definitions with strong generics. | ||
| - **Schema validation** — Validate responses against [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, etc.). | ||
| *For a full-featured HTTP client on top of Fetch, check out my [`ky`](https://github.com/sindresorhus/ky) package.* | ||
@@ -26,3 +36,4 @@ | ||
| withHeaders, | ||
| withHttpError | ||
| withHttpError, | ||
| withJsonResponse, | ||
| } from 'fetch-extras'; | ||
@@ -35,2 +46,3 @@ | ||
| // - Throws errors for non-2xx responses | ||
| // - Parses JSON responses automatically | ||
| const apiFetch = pipeline( | ||
@@ -42,6 +54,6 @@ fetch, | ||
| withHttpError, | ||
| withJsonResponse, | ||
| ); | ||
| const response = await apiFetch('/users'); | ||
| const data = await response.json(); | ||
| const data = await apiFetch('/users'); | ||
| ``` | ||
@@ -51,24 +63,44 @@ | ||
| The `with*` functions are listed in the recommended wrapping order for use with [`pipeline`](docs/pipeline.md). | ||
| ### Wrappers | ||
| - [`withTimeout`](docs/with-timeout.md) | ||
| - [`withBaseUrl`](docs/with-base-url.md) | ||
| - [`withSearchParameters`](docs/with-search-parameters.md) | ||
| - [`withHeaders`](docs/with-headers.md) | ||
| - [`withJsonBody`](docs/with-json-body.md) | ||
| - [`withRateLimit`](docs/with-rate-limit.md) | ||
| - [`withConcurrency`](docs/with-concurrency.md) | ||
| - [`withDeduplication`](docs/with-deduplication.md) | ||
| - [`withCache`](docs/with-cache.md) | ||
| - [`withDownloadProgress`](docs/with-download-progress.md) | ||
| - [`withUploadProgress`](docs/with-upload-progress.md) | ||
| - [`withRetry`](docs/with-retry.md) | ||
| - [`withTokenRefresh`](docs/with-token-refresh.md) | ||
| - [`withHooks`](docs/with-hooks.md) | ||
| - [`withHttpError`](docs/http-error.md#withhttperrorfetchfunction) | ||
| - [`HttpError`](docs/http-error.md#httperror) | ||
| - [`throwIfHttpError`](docs/http-error.md#throwifhttperrorresponse) | ||
| - [`paginate`](docs/paginate.md) | ||
| - [`pipeline`](docs/pipeline.md) | ||
| Listed in the recommended wrapping order for use with [`pipeline`](docs/pipeline.md). | ||
| - [`withTimeout`](docs/with-timeout.md) - Abort requests that take too long | ||
| - [`withBaseUrl`](docs/with-base-url.md) - Resolve relative URLs against a base URL | ||
| - [`withSearchParameters`](docs/with-search-parameters.md) - Attach default query parameters to every request | ||
| - [`withHeaders`](docs/with-headers.md) - Attach default headers to every request | ||
| - [`withJsonBody`](docs/with-json-body.md) - Auto-stringify plain objects as JSON | ||
| - [`withRateLimit`](docs/with-rate-limit.md) - Enforce client-side rate limiting with a sliding window | ||
| - [`withConcurrency`](docs/with-concurrency.md) - Cap how many requests can run simultaneously | ||
| - [`withDeduplication`](docs/with-deduplication.md) - Collapse concurrent identical GET requests into a single call | ||
| - [`withCache`](docs/with-cache.md) - In-memory caching for plain unconditional GET responses with a TTL | ||
| - [`withDownloadProgress`](docs/with-download-progress.md) - Track download progress | ||
| - [`withUploadProgress`](docs/with-upload-progress.md) - Track upload progress | ||
| - [`withRetry`](docs/with-retry.md) - Retry failed requests with exponential backoff | ||
| - [`withTokenRefresh`](docs/with-token-refresh.md) - Auto-refresh auth tokens on 401 and retry | ||
| - [`withHooks`](docs/with-hooks.md) - `beforeRequest` and `afterResponse` hooks | ||
| - [`withHttpError`](docs/http-error.md#withhttperrorfetchfunction) - Throw on non-2xx responses | ||
| - [`withJsonResponse`](docs/with-json-response.md) - Parse response as JSON, with optional [Standard Schema](https://standardschema.dev) validation *(place last in pipeline)* | ||
| ### Utilities | ||
| - [`pipeline`](docs/pipeline.md) - Compose `with*` wrappers without deep nesting | ||
| - [`paginate`](docs/paginate.md) - Async-iterate over paginated API endpoints | ||
| - [`throwIfHttpError`](docs/http-error.md#throwifhttperrorresponse) - Throw if a response is non-2xx | ||
| ### Errors | ||
| - [`HttpError`](docs/http-error.md#httperror) - Error class for non-2xx responses | ||
| - [`SchemaValidationError`](docs/schema-validation-error.md) - Error class for schema validation failures | ||
| ## FAQ | ||
| ### How is this different from Ky? | ||
| [Ky](https://github.com/sindresorhus/ky) is a full-featured HTTP client with its own API (`ky.get()`, `.json()`, etc.). This package instead gives you individual utilities that wrap the standard `fetch` function. You pick only what you need and compose them together. If you want a batteries-included client, use Ky. If you want to stay close to the `fetch` API while adding specific capabilities, use this. | ||
| ### How do I use a proxy? | ||
| This package wraps the standard `fetch` API, so proxy support comes from the runtime. In Node.js, use the [`--use-env-proxy`](https://nodejs.org/learn/http/enterprise-network-configuration) flag. | ||
| ## Related | ||
@@ -75,0 +107,0 @@ |
@@ -23,2 +23,9 @@ export {HttpError, throwIfHttpError, withHttpError} from './http-error.js'; | ||
| } from './paginate.js'; | ||
| export { | ||
| SchemaValidationError, | ||
| withJsonResponse, | ||
| type StandardSchemaV1, | ||
| type StandardSchemaV1InferOutput, | ||
| type StandardSchemaV1Issue, | ||
| } from './with-json-response.js'; | ||
| export {pipeline} from './pipeline.js'; |
+1
-0
@@ -17,2 +17,3 @@ export {HttpError, throwIfHttpError, withHttpError} from './http-error.js'; | ||
| export {paginate} from './paginate.js'; | ||
| export {SchemaValidationError, withJsonResponse} from './with-json-response.js'; | ||
| export {pipeline} from './pipeline.js'; |
@@ -38,3 +38,3 @@ /** | ||
| **Note**: Returning `headers` replaces all inherited headers, consistent with standard Fetch API behavior. Setting `body` to `undefined` will strip body-related headers (`Content-Type`, `Content-Length`, etc.) from the request. | ||
| **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. | ||
@@ -245,2 +245,4 @@ @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. If you intentionally need headers on the new origin, return them explicitly from `pagination.paginate`. | ||
| @param input - The URL to fetch. Can be a string or URL instance. | ||
@@ -247,0 +249,0 @@ @param options - Fetch options plus pagination options. |
+43
-62
@@ -39,8 +39,2 @@ import parseLinkHeader from './parse-link-header.js'; | ||
| const sensitiveHeaderNames = [ | ||
| 'authorization', | ||
| 'cookie', | ||
| 'proxy-authorization', | ||
| ]; | ||
| const stripBodyHeaders = headers => { | ||
@@ -57,12 +51,2 @@ const cleanedHeaders = new Headers(headers); | ||
| const stripSensitiveHeaders = headers => { | ||
| const cleanedHeaders = new Headers(headers); | ||
| for (const headerName of sensitiveHeaderNames) { | ||
| cleanedHeaders.delete(headerName); | ||
| } | ||
| return cleanedHeaders; | ||
| }; | ||
| const markToBlockDefaultHeaders = (object, headerNames) => { | ||
@@ -80,7 +64,2 @@ object[blockedDefaultHeaderNamesSymbol] = [...headerNames]; | ||
| const requestWithoutSensitiveState = request => ({ | ||
| ...requestWithoutBody(request), | ||
| headers: stripSensitiveHeaders(stripBodyHeaders(request.headers)), | ||
| }); | ||
| const stripBodyHeadersFromFetchOptions = fetchOptions => { | ||
@@ -115,6 +94,7 @@ if (!('headers' in fetchOptions)) { | ||
| const shouldResetBodyHeaders = fetchOptions => shouldStripBodyHeaders(fetchOptions) || Object.hasOwn(fetchOptions, 'body'); | ||
| const hasExplicitBody = fetchOptions => Object.hasOwn(fetchOptions, 'body') && fetchOptions.body !== undefined; | ||
| const integerOrInfinity = value => value === Number.POSITIVE_INFINITY || Number.isInteger(value); | ||
| const isPaginationFetchOptions = value => typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof URL); | ||
| const isCrossOrigin = (currentUrl, nextUrl) => currentUrl.origin !== nextUrl.origin; | ||
| const shouldStripInheritedSensitiveState = (inheritedStateOrigin, requestUrl) => inheritedStateOrigin && requestUrl instanceof URL && isCrossOrigin(inheritedStateOrigin, requestUrl); | ||
| const shouldStripInheritedHeaderState = (inheritedStateOrigin, requestUrl) => inheritedStateOrigin && requestUrl instanceof URL && isCrossOrigin(inheritedStateOrigin, requestUrl); | ||
@@ -190,36 +170,31 @@ const toUrl = (input, baseUrl = globalThis.location?.href) => { | ||
| const currentSignal = (requestTemplate, fetchOptions) => fetchOptions.signal ?? requestTemplate?.signal; | ||
| const requestWithoutSensitiveHeaders = request => ({ | ||
| ...requestSnapshot(request), | ||
| headers: stripSensitiveHeaders(request.headers), | ||
| }); | ||
| const stripInheritedSensitiveState = (requestTemplate, fetchOptions) => ({ | ||
| requestTemplate: requestTemplate && markToBlockDefaultHeaders(new Request(requestTemplate.url, requestTemplate.body === null ? requestWithoutSensitiveHeaders(requestTemplate) : requestWithoutSensitiveState(requestTemplate)), sensitiveHeaderNames), | ||
| fetchOptions: Object.hasOwn(fetchOptions, 'body') && fetchOptions.body !== undefined ? normalizeBodylessFetchOptions(markSensitiveHeadersAsFinal(fetchOptions)) : markSensitiveHeadersAsFinal(fetchOptions), | ||
| }); | ||
| const getHeaderNames = headers => [...new Headers(headers).keys()]; | ||
| const markSensitiveHeadersAsFinal = fetchOptions => { | ||
| if (!('headers' in fetchOptions)) { | ||
| return markToBlockDefaultHeaders({...fetchOptions}, sensitiveHeaderNames); | ||
| } | ||
| /* | ||
| 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. | ||
| */ | ||
| 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, | ||
| ); | ||
| return markToBlockDefaultHeaders({ | ||
| ...fetchOptions, | ||
| headers: markToBlockDefaultHeaders(stripSensitiveHeaders(fetchOptions.headers), sensitiveHeaderNames), | ||
| }, sensitiveHeaderNames); | ||
| return { | ||
| requestTemplate: requestTemplate && markToBlockDefaultHeaders(new Request(requestTemplate.url, { | ||
| ...requestSnapshot(requestTemplate), | ||
| headers: new Headers(), | ||
| }), blockedHeaderNames), | ||
| currentFetchOptions: hasExplicitBody(fetchOptions) ? normalizeBodylessFetchOptions(nextFetchOptions) : nextFetchOptions, | ||
| inheritedStateOrigin: undefined, | ||
| }; | ||
| }; | ||
| const hasSensitiveHeaders = headers => { | ||
| const normalizedHeaders = new Headers(headers); | ||
| for (const headerName of sensitiveHeaderNames) { | ||
| if (normalizedHeaders.has(headerName)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| }; | ||
| const hasExplicitSensitiveState = fetchOptions => ('body' in fetchOptions && fetchOptions.body !== undefined) || ('headers' in fetchOptions && hasSensitiveHeaders(fetchOptions.headers)); | ||
| const hasExplicitHeaderState = fetchOptions => hasExplicitBody(fetchOptions) || ('headers' in fetchOptions && getHeaderNames(fetchOptions.headers).length > 0); | ||
| const shouldClearInheritedBody = (fetchOptions, nextPageOptions) => !methodCanHaveBody(fetchOptions.method) && !Object.hasOwn(nextPageOptions, 'body') && Object.hasOwn(fetchOptions, 'body'); | ||
@@ -232,2 +207,4 @@ | ||
| 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. | ||
@@ -355,7 +332,9 @@ @param {RequestInit & {pagination?: PaginationOptions, fetchFunction?: Function}} options - Fetch options plus pagination options. | ||
| if (shouldStripInheritedSensitiveState(inheritedStateOrigin, currentResponseUrl)) { | ||
| const strippedState = stripInheritedSensitiveState(requestTemplate, currentFetchOptions); | ||
| requestTemplate = strippedState.requestTemplate; | ||
| currentFetchOptions = strippedState.fetchOptions; | ||
| inheritedStateOrigin = undefined; | ||
| if (shouldStripInheritedHeaderState(inheritedStateOrigin, currentResponseUrl)) { | ||
| ({requestTemplate, currentFetchOptions, inheritedStateOrigin} = clearCrossOriginHeaderState( | ||
| fetchFunction, | ||
| requestTemplate ?? currentUrl, | ||
| requestTemplate, | ||
| currentFetchOptions, | ||
| )); | ||
| } | ||
@@ -426,7 +405,9 @@ | ||
| if (shouldStripInheritedSensitiveState(inheritedStateOrigin, nextRequestUrl)) { | ||
| const strippedState = stripInheritedSensitiveState(requestTemplate, currentFetchOptions); | ||
| requestTemplate = strippedState.requestTemplate; | ||
| currentFetchOptions = strippedState.fetchOptions; | ||
| inheritedStateOrigin = undefined; | ||
| if (shouldStripInheritedHeaderState(inheritedStateOrigin, nextRequestUrl)) { | ||
| ({requestTemplate, currentFetchOptions, inheritedStateOrigin} = clearCrossOriginHeaderState( | ||
| fetchFunction, | ||
| requestTemplate ?? currentUrl, | ||
| requestTemplate, | ||
| currentFetchOptions, | ||
| )); | ||
| } | ||
@@ -446,3 +427,3 @@ | ||
| if (hasExplicitSensitiveState(restNextPageOptions)) { | ||
| if (hasExplicitHeaderState(restNextPageOptions)) { | ||
| inheritedStateOrigin = nextRequestUrl ?? inheritedStateOrigin; | ||
@@ -449,0 +430,0 @@ } |
+26
-0
@@ -216,2 +216,28 @@ export const blockedDefaultHeaderNamesSymbol = Symbol('blockedDefaultHeaderNames'); | ||
| export async function waitForAbortable(callback, signal) { | ||
| signal?.throwIfAborted(); | ||
| if (!signal) { | ||
| return callback(); | ||
| } | ||
| let abort; | ||
| const abortPromise = new Promise((_resolve, reject) => { | ||
| abort = () => { | ||
| reject(signal.reason); | ||
| }; | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| }); | ||
| try { | ||
| return await Promise.race([ | ||
| callback(), | ||
| abortPromise, | ||
| ]); | ||
| } finally { | ||
| signal.removeEventListener('abort', abort); | ||
| } | ||
| } | ||
| export function enqueueAbortable(queue, {signal, onAbort, onEnqueue} = {}) { | ||
@@ -218,0 +244,0 @@ return new Promise((resolve, reject) => { |
| /** | ||
| Returns a wrapped fetch function that resolves relative URLs against a base URL. Useful for API clients with a consistent base URL. | ||
| Only string-based relative URLs are resolved against the base URL. Absolute URLs and URL objects are passed through unchanged. Relative paths are resolved against the base URL's pathname, while query-only and fragment-only inputs keep normal URL semantics. | ||
| 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. | ||
@@ -6,0 +6,0 @@ Can be combined with other `with*` functions. |
| 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. | ||
| 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. | ||
@@ -36,6 +36,10 @@ @param {typeof fetch} fetchFunction - The fetch function to wrap (usually the global `fetch`). | ||
| if (urlOrRequest === '') { | ||
| return baseUrlString; | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
| } | ||
| if (/^\/\/[^/]/.test(urlOrRequest) || /^[?#]/.test(urlOrRequest)) { | ||
| if (/^\/\/[^/]/.test(urlOrRequest)) { | ||
| throw new TypeError('Protocol-relative URLs are unsupported.'); | ||
| } | ||
| if (/^[?#]/.test(urlOrRequest)) { | ||
| return new URL(urlOrRequest, getBaseUrlObject()).href; | ||
@@ -42,0 +46,0 @@ } |
| /** | ||
| Wraps a fetch function with in-memory caching for GET requests. | ||
| Wraps a fetch function with in-memory caching for plain unconditional GET requests. | ||
@@ -8,4 +8,6 @@ Non-GET requests pass through unchanged. Unsafe methods (POST, PUT, PATCH, DELETE, etc.) also invalidate any cached response for the same URL. Only successful (2xx) responses are cached. Each cache hit returns a fresh clone, so the body can be consumed independently on every call. | ||
| The cache key is the URL only. Requests with different headers but the same URL share the same cache entry. | ||
| This is a small in-memory cache, not a full HTTP cache. It memoizes plain unconditional GET responses for a fixed TTL. | ||
| 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. | ||
| 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. | ||
@@ -12,0 +14,0 @@ |
+24
-5
| import { | ||
| copyFetchMetadata, | ||
| defersFetchStartSymbol, | ||
| hasHeaders, | ||
| notifyFetchStartSymbol, | ||
@@ -22,2 +23,4 @@ resolveRequestHeadersSymbol, | ||
| function getRequestContext(fetchFunction, urlOrRequest, options) { | ||
| const headers = getHeaders.call(fetchFunction, urlOrRequest, options); | ||
| return { | ||
@@ -28,3 +31,4 @@ method: (options.method ?? (urlOrRequest instanceof Request ? urlOrRequest.method : 'GET')).toUpperCase(), | ||
| signal: options.signal ?? (urlOrRequest instanceof Request ? urlOrRequest.signal : undefined), | ||
| isRangedRequest: getHeaders.call(fetchFunction, urlOrRequest, options).has('range'), | ||
| isRangedRequest: headers.has('range'), | ||
| hasRequestHeaders: hasHeaders(headers), | ||
| }; | ||
@@ -90,3 +94,3 @@ } | ||
| function getCachedResponse(entry, cacheMode, currentTime, isRangedRequest) { | ||
| function getCachedResponse({entry, cacheMode, currentTime, isRangedRequest}) { | ||
| if (!entry?.response || isRangedRequest || cacheMode === 'no-store') { | ||
@@ -115,5 +119,13 @@ return; | ||
| const fetchWithCache = async (urlOrRequest, options = {}) => { | ||
| const {method, url, cacheMode, signal, isRangedRequest} = getRequestContext(fetchFunction, 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; | ||
@@ -153,3 +165,10 @@ // Non-GET requests pass through; unsafe methods also invalidate cache | ||
| const entry = cache.get(url); | ||
| const cachedResponse = getCachedResponse(entry, cacheMode, currentTime, isRangedRequest); | ||
| const cachedResponse = isCacheableRequest | ||
| ? getCachedResponse({ | ||
| entry, | ||
| cacheMode, | ||
| currentTime, | ||
| isRangedRequest, | ||
| }) | ||
| : undefined; | ||
| if (cachedResponse) { | ||
@@ -171,3 +190,3 @@ return cachedResponse; | ||
| response.ok | ||
| && !isRangedRequest | ||
| && isCacheableRequest | ||
| && !isPartialResponse | ||
@@ -174,0 +193,0 @@ && cacheMode !== 'no-store' |
+10
-30
| import { | ||
| copyFetchMetadata, | ||
| defersFetchStartSymbol, | ||
| getFetchSignal, | ||
| getRequestOptions, | ||
| getRequestSignal, | ||
| notifyFetchStart, | ||
| resolveRequestBody, | ||
@@ -11,2 +13,3 @@ resolveRequestBodySymbol, | ||
| resolveRequestUrl, | ||
| waitForAbortable, | ||
| } from './utilities.js'; | ||
@@ -28,3 +31,3 @@ | ||
| @param {(context: {url: string, options: RequestInit}) => RequestInit | Response | void | Promise<RequestInit | Response | void>} [options.beforeRequest] - Called before each request. Receives the resolved URL and the request options. Return a replacement `RequestInit` to modify the request options, return a `Response` to short-circuit the request entirely (skipping the fetch call and `afterResponse`), or return `undefined` to leave them unchanged. | ||
| @param {(context: {url: string, options: RequestInit, response: Response}) => Response | void | Promise<Response | void>} [options.afterResponse] - Called after each response. Receives the response, resolved URL, and the request options. Return a replacement `Response` to modify the response, or return `undefined` to leave it unchanged. | ||
| @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. | ||
@@ -58,28 +61,2 @@ */ | ||
| const waitForHook = async (callback, signal) => { | ||
| signal?.throwIfAborted(); | ||
| if (!signal) { | ||
| return callback(); | ||
| } | ||
| let abort; | ||
| const abortPromise = new Promise((_resolve, reject) => { | ||
| abort = () => { | ||
| reject(signal.reason); | ||
| }; | ||
| signal.addEventListener('abort', abort, {once: true}); | ||
| }); | ||
| try { | ||
| return await Promise.race([ | ||
| callback(), | ||
| abortPromise, | ||
| ]); | ||
| } finally { | ||
| signal.removeEventListener('abort', abort); | ||
| } | ||
| }; | ||
| const fetchWithHooks = async (urlOrRequest, options = {}) => { | ||
@@ -117,3 +94,3 @@ const getLifecycleSignal = options_ => getFetchSignal(fetchFunction, getRequestSignal(urlOrRequest, options_)); | ||
| if (beforeRequest) { | ||
| let result = await waitForHook(() => beforeRequest({url, options: hookOptions}), hookOptions.signal); | ||
| let result = await waitForAbortable(() => beforeRequest({url, options: hookOptions}), hookOptions.signal); | ||
| if (result instanceof Response) { | ||
@@ -138,6 +115,7 @@ return result; | ||
| notifyFetchStart(fetchFunction, finalFetchOptions); | ||
| const response = await fetchFunction(fetchInput, finalFetchOptions); | ||
| if (afterResponse) { | ||
| const modifiedResponse = await waitForHook(() => afterResponse({url, options: hookOptions, response}), hookOptions.signal); | ||
| const modifiedResponse = await waitForAbortable(() => afterResponse({url, options: hookOptions, response}), hookOptions.signal); | ||
| if (modifiedResponse !== undefined) { | ||
@@ -151,3 +129,5 @@ return modifiedResponse; | ||
| return copyFetchMetadata(fetchWithHooks, fetchFunction); | ||
| const wrappedFetch = copyFetchMetadata(fetchWithHooks, fetchFunction); | ||
| wrappedFetch[defersFetchStartSymbol] = true; | ||
| return wrappedFetch; | ||
| } |
| /** | ||
| Wraps a fetch function to automatically retry failed requests. | ||
| Retries on [network errors](https://github.com/sindresorhus/is-network-error) and configurable HTTP status codes. Only retries idempotent methods by default (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). Uses exponential backoff with jitter by default. Respects the `Retry-After` response header when present. | ||
| 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. | ||
@@ -6,0 +6,0 @@ POST and PATCH are not retried by default because they are not idempotent. Add them to `methods` if your endpoints are safe to retry. |
+11
-4
@@ -1,2 +0,2 @@ | ||
| import isNetworkError from 'is-network-error'; | ||
| import isNetworkError from './is-network-error.js'; | ||
| import { | ||
@@ -13,2 +13,3 @@ copyFetchMetadata, | ||
| resolveRequestUrl, | ||
| waitForAbortable, | ||
| } from './utilities.js'; | ||
@@ -42,3 +43,3 @@ | ||
| Retries on [network errors](https://github.com/sindresorhus/is-network-error) and configurable HTTP status codes. Only retries idempotent methods by default (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). Uses exponential backoff with jitter by default. Respects the `Retry-After` response header when present. | ||
| 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. | ||
@@ -163,3 +164,6 @@ When all retries are exhausted, the last response is returned (for HTTP status retries) or the last error is thrown (for network errors). | ||
| if (!await shouldRetry({error, attemptNumber: attempt + 1, retriesLeft: retries - attempt})) { | ||
| if (!await waitForAbortable( | ||
| () => shouldRetry({error, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| )) { | ||
| throw error; | ||
@@ -183,3 +187,6 @@ } | ||
| try { | ||
| shouldRetryResponse = await shouldRetry({response, attemptNumber: attempt + 1, retriesLeft: retries - attempt}); | ||
| shouldRetryResponse = await waitForAbortable( | ||
| () => shouldRetry({response, attemptNumber: attempt + 1, retriesLeft: retries - attempt}), | ||
| attemptSignal, | ||
| ); | ||
| } catch (error) { | ||
@@ -186,0 +193,0 @@ await discardBody(response.body); |
@@ -70,4 +70,4 @@ /** | ||
| */ | ||
| refreshToken: () => Promise<string>; | ||
| refreshToken: () => string | Promise<string>; | ||
| } | ||
| ): typeof fetch; |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
153798
9.63%0
-100%44
7.32%3472
7.83%106
43.24%2
100%31
3.33%- Removed
- Removed