UNPKG

ky

Version:

Tiny and elegant HTTP client based on the Fetch API

395 lines 20.7 kB
import { HTTPError } from '../errors/HTTPError.js'; import { NonError } from '../errors/NonError.js'; import { ForceRetryError } from '../errors/ForceRetryError.js'; import { streamRequest, streamResponse } from '../utils/body.js'; import { mergeHeaders, mergeHooks } from '../utils/merge.js'; import { normalizeRequestMethod, normalizeRetryOptions } from '../utils/normalize.js'; import timeout from '../utils/timeout.js'; import delay from '../utils/delay.js'; import { findUnknownOptions, hasSearchParameters } from '../utils/options.js'; import { isHTTPError, isTimeoutError } from '../utils/type-guards.js'; import { maxSafeTimeout, responseTypes, stop, RetryMarker, supportsAbortController, supportsAbortSignal, supportsFormData, supportsResponseStreams, supportsRequestStreams, } from './constants.js'; export class Ky { static create(input, options) { const ky = new Ky(input, options); const function_ = async () => { if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) { throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`); } // Delay the fetch so that body method shortcuts can set the Accept header await Promise.resolve(); // Before using ky.request, _fetch clones it and saves the clone for future retries to use. // If retry is not needed, close the cloned request's ReadableStream for memory safety. let response = await ky.#fetch(); for (const hook of ky.#options.hooks.afterResponse) { // Clone the response before passing to hook so we can cancel it if needed const clonedResponse = ky.#decorateResponse(response.clone()); let modifiedResponse; try { // eslint-disable-next-line no-await-in-loop modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), clonedResponse, { retryCount: ky.#retryCount }); } catch (error) { // Cancel both responses to prevent memory leaks when hook throws ky.#cancelResponseBody(clonedResponse); ky.#cancelResponseBody(response); throw error; } if (modifiedResponse instanceof RetryMarker) { // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun). // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec. ky.#cancelResponseBody(clonedResponse); ky.#cancelResponseBody(response); throw new ForceRetryError(modifiedResponse.options); } // Determine which response to use going forward const nextResponse = modifiedResponse instanceof globalThis.Response ? modifiedResponse : response; // Cancel any response bodies we won't use to prevent memory leaks. // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation. if (clonedResponse !== nextResponse) { ky.#cancelResponseBody(clonedResponse); } if (response !== nextResponse) { ky.#cancelResponseBody(response); } response = nextResponse; } ky.#decorateResponse(response); if (!response.ok && (typeof ky.#options.throwHttpErrors === 'function' ? ky.#options.throwHttpErrors(response.status) : ky.#options.throwHttpErrors)) { let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions()); for (const hook of ky.#options.hooks.beforeError) { // eslint-disable-next-line no-await-in-loop error = await hook(error, { retryCount: ky.#retryCount }); } throw error; } // If `onDownloadProgress` is passed, it uses the stream API internally if (ky.#options.onDownloadProgress) { if (typeof ky.#options.onDownloadProgress !== 'function') { throw new TypeError('The `onDownloadProgress` option must be a function'); } if (!supportsResponseStreams) { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } const progressResponse = response.clone(); ky.#cancelResponseBody(response); return streamResponse(progressResponse, ky.#options.onDownloadProgress); } return response; }; // Always wrap in #retry to catch forced retries from afterResponse hooks // Method retriability is checked in #calculateRetryDelay for non-forced retries const result = ky.#retry(function_) .finally(() => { const originalRequest = ky.#originalRequest; // Ignore cancellation errors from already-locked or already-consumed streams. ky.#cancelBody(originalRequest?.body ?? undefined); ky.#cancelBody(ky.request.body ?? undefined); }); for (const [type, mimeType] of Object.entries(responseTypes)) { // Only expose `.bytes()` when the environment implements it. if (type === 'bytes' && typeof globalThis.Response?.prototype?.bytes !== 'function') { continue; } result[type] = async () => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType); const response = await result; if (type === 'json') { if (response.status === 204) { return ''; } const text = await response.text(); if (text === '') { return ''; } if (options.parseJson) { return options.parseJson(text); } return JSON.parse(text); } return response[type](); }; } return result; } // eslint-disable-next-line unicorn/prevent-abbreviations static #normalizeSearchParams(searchParams) { // Filter out undefined values from plain objects if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) { return Object.fromEntries(Object.entries(searchParams).filter(([, value]) => value !== undefined)); } return searchParams; } request; #abortController; #retryCount = 0; // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202 #input; #options; #originalRequest; #userProvidedAbortSignal; #cachedNormalizedOptions; // eslint-disable-next-line complexity constructor(input, options = {}) { this.#input = input; this.#options = { ...options, headers: mergeHeaders(this.#input.headers, options.headers), hooks: mergeHooks({ beforeRequest: [], beforeRetry: [], beforeError: [], afterResponse: [], }, options.hooks), method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing prefixUrl: String(options.prefixUrl || ''), retry: normalizeRetryOptions(options.retry), throwHttpErrors: options.throwHttpErrors ?? true, timeout: options.timeout ?? 10_000, fetch: options.fetch ?? globalThis.fetch.bind(globalThis), context: options.context ?? {}, }; if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) { throw new TypeError('`input` must be a string, URL, or Request'); } if (this.#options.prefixUrl && typeof this.#input === 'string') { if (this.#input.startsWith('/')) { throw new Error('`input` must not begin with a slash when using `prefixUrl`'); } if (!this.#options.prefixUrl.endsWith('/')) { this.#options.prefixUrl += '/'; } this.#input = this.#options.prefixUrl + this.#input; } if (supportsAbortController && supportsAbortSignal) { this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal; this.#abortController = new globalThis.AbortController(); this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal; } if (supportsRequestStreams) { // @ts-expect-error - Types are outdated. this.#options.duplex = 'half'; } if (this.#options.json !== undefined) { this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json); this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json'); } // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body // Only delete if user didn't explicitly provide a custom content-type const userProvidedContentType = options.headers && new globalThis.Headers(options.headers).has('content-type'); if (this.#input instanceof globalThis.Request && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams) && !userProvidedContentType) { this.#options.headers.delete('content-type'); } this.request = new globalThis.Request(this.#input, this.#options); if (hasSearchParameters(this.#options.searchParams)) { // eslint-disable-next-line unicorn/prevent-abbreviations const textSearchParams = typeof this.#options.searchParams === 'string' ? this.#options.searchParams.replace(/^\?/, '') : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams)).toString(); // eslint-disable-next-line unicorn/prevent-abbreviations const searchParams = '?' + textSearchParams; const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams); // Recreate request with the updated URL. We already have all options in this.#options, including duplex. this.request = new globalThis.Request(url, this.#options); } // If `onUploadProgress` is passed, it uses the stream API internally if (this.#options.onUploadProgress) { if (typeof this.#options.onUploadProgress !== 'function') { throw new TypeError('The `onUploadProgress` option must be a function'); } if (!supportsRequestStreams) { throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.'); } this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined); } } #calculateDelay() { const retryDelay = this.#options.retry.delay(this.#retryCount); let jitteredDelay = retryDelay; if (this.#options.retry.jitter === true) { jitteredDelay = Math.random() * retryDelay; } else if (typeof this.#options.retry.jitter === 'function') { jitteredDelay = this.#options.retry.jitter(retryDelay); if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) { jitteredDelay = retryDelay; } } // Handle undefined backoffLimit by treating it as no limit (Infinity) const backoffLimit = this.#options.retry.backoffLimit ?? Number.POSITIVE_INFINITY; return Math.min(backoffLimit, jitteredDelay); } async #calculateRetryDelay(error) { this.#retryCount++; if (this.#retryCount > this.#options.retry.limit) { throw error; } // Wrap non-Error throws to ensure consistent error handling const errorObject = error instanceof Error ? error : new NonError(error); // Handle forced retry from afterResponse hook - skip method check and shouldRetry if (errorObject instanceof ForceRetryError) { return errorObject.customDelay ?? this.#calculateDelay(); } // Check if method is retriable for non-forced retries if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) { throw error; } // User-provided shouldRetry function takes precedence over all other checks if (this.#options.retry.shouldRetry !== undefined) { const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount }); // Strict boolean checking - only exact true/false are handled specially if (result === false) { throw error; } if (result === true) { // Force retry - skip all other validation and return delay return this.#calculateDelay(); } // If undefined or any other value, fall through to default behavior } // Default timeout behavior if (isTimeoutError(error) && !this.#options.retry.retryOnTimeout) { throw error; } if (isHTTPError(error)) { if (!this.#options.retry.statusCodes.includes(error.response.status)) { throw error; } const retryAfter = error.response.headers.get('Retry-After') ?? error.response.headers.get('RateLimit-Reset') ?? error.response.headers.get('X-RateLimit-Retry-After') // Symfony-based services ?? error.response.headers.get('X-RateLimit-Reset') // GitHub ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) { let after = Number(retryAfter) * 1000; if (Number.isNaN(after)) { after = Date.parse(retryAfter) - Date.now(); } else if (after >= Date.parse('2024-01-01')) { // A large number is treated as a timestamp (fixed threshold protects against clock skew) after -= Date.now(); } const max = this.#options.retry.maxRetryAfter ?? after; // Don't apply jitter when server provides explicit retry timing return after < max ? after : max; } if (error.response.status === 413) { throw error; } } return this.#calculateDelay(); } #decorateResponse(response) { if (this.#options.parseJson) { response.json = async () => this.#options.parseJson(await response.text()); } return response; } #cancelBody(body) { if (!body) { return; } // Ignore cancellation failures from already-locked or already-consumed streams. void body.cancel().catch(() => undefined); } #cancelResponseBody(response) { // Ignore cancellation failures from already-locked or already-consumed streams. this.#cancelBody(response.body ?? undefined); } async #retry(function_) { try { return await function_(); } catch (error) { const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout); if (this.#retryCount < 1) { throw error; } // Only use user-provided signal for delay, not our internal abortController await delay(ms, this.#userProvidedAbortSignal ? { signal: this.#userProvidedAbortSignal } : {}); // Apply custom request from forced retry before beforeRetry hooks // Ensure the custom request has the correct managed signal for timeouts and user aborts if (error instanceof ForceRetryError && error.customRequest) { const managedRequest = this.#options.signal ? new globalThis.Request(error.customRequest, { signal: this.#options.signal }) : new globalThis.Request(error.customRequest); this.#assignRequest(managedRequest); } for (const hook of this.#options.hooks.beforeRetry) { // eslint-disable-next-line no-await-in-loop const hookResult = await hook({ request: this.request, options: this.#getNormalizedOptions(), error: error, retryCount: this.#retryCount, }); if (hookResult instanceof globalThis.Request) { this.#assignRequest(hookResult); break; } // If a Response is returned, use it and skip the retry if (hookResult instanceof globalThis.Response) { return hookResult; } // If `stop` is returned from the hook, the retry process is stopped if (hookResult === stop) { return; } } return this.#retry(function_); } } async #fetch() { // Reset abortController if it was aborted (happens on timeout retry) if (this.#abortController?.signal.aborted) { this.#abortController = new globalThis.AbortController(); this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal; // Recreate request with new signal this.request = new globalThis.Request(this.request, { signal: this.#options.signal }); } for (const hook of this.#options.hooks.beforeRequest) { // eslint-disable-next-line no-await-in-loop const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this.#retryCount }); if (result instanceof Response) { return result; } if (result instanceof globalThis.Request) { this.#assignRequest(result); break; } } const nonRequestOptions = findUnknownOptions(this.request, this.#options); // Cloning is done here to prepare in advance for retries this.#originalRequest = this.request; this.request = this.#originalRequest.clone(); if (this.#options.timeout === false) { return this.#options.fetch(this.#originalRequest, nonRequestOptions); } return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options); } #getNormalizedOptions() { if (!this.#cachedNormalizedOptions) { const { hooks, ...normalizedOptions } = this.#options; this.#cachedNormalizedOptions = Object.freeze(normalizedOptions); } return this.#cachedNormalizedOptions; } #assignRequest(request) { this.#cachedNormalizedOptions = undefined; this.request = this.#wrapRequestWithUploadProgress(request); } #wrapRequestWithUploadProgress(request, originalBody) { if (!this.#options.onUploadProgress || !request.body) { return request; } return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined); } } //# sourceMappingURL=Ky.js.map