UNPKG

@smeijer/ky

Version:

Tiny and elegant HTTP client based on the Fetch API

255 lines 12.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Ky = void 0; const HTTPError_js_1 = require("../errors/HTTPError.js"); const TimeoutError_js_1 = require("../errors/TimeoutError.js"); const body_js_1 = require("../utils/body.js"); const merge_js_1 = require("../utils/merge.js"); const normalize_js_1 = require("../utils/normalize.js"); const timeout_js_1 = __importDefault(require("../utils/timeout.js")); const delay_js_1 = __importDefault(require("../utils/delay.js")); const options_js_1 = require("../utils/options.js"); const constants_js_1 = require("./constants.js"); class Ky { static create(input, options) { const ky = new Ky(input, options); const function_ = async () => { if (typeof ky._options.timeout === 'number' && ky._options.timeout > constants_js_1.maxSafeTimeout) { throw new RangeError(`The \`timeout\` option cannot be greater than ${constants_js_1.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) { // eslint-disable-next-line no-await-in-loop const modifiedResponse = await hook(ky.request, ky._options, ky._decorateResponse(response.clone())); if (modifiedResponse instanceof globalThis.Response) { response = modifiedResponse; } } ky._decorateResponse(response); if (!response.ok && ky._options.throwHttpErrors) { let error = new HTTPError_js_1.HTTPError(response, ky.request, ky._options); for (const hook of ky._options.hooks.beforeError) { // eslint-disable-next-line no-await-in-loop error = await hook(error); } 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 (!constants_js_1.supportsResponseStreams) { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } return (0, body_js_1.streamResponse)(response.clone(), ky._options.onDownloadProgress); } return response; }; const isRetriableMethod = ky._options.retry.methods.includes(ky.request.method.toLowerCase()); const result = (isRetriableMethod ? ky._retry(function_) : function_()) .finally(async () => { // Now that we know a retry is not needed, close the ReadableStream of the cloned request. if (!ky.request.bodyUsed) { await ky.request.body?.cancel(); } }); for (const [type, mimeType] of Object.entries(constants_js_1.responseTypes)) { 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 arrayBuffer = await response.clone().arrayBuffer(); const responseSize = arrayBuffer.byteLength; if (responseSize === 0) { return ''; } if (options.parseJson) { return options.parseJson(await response.text()); } } return response[type](); }; } return result; } request; abortController; _retryCount = 0; _input; _options; // eslint-disable-next-line complexity constructor(input, options = {}) { this._input = input; this._options = { ...options, headers: (0, merge_js_1.mergeHeaders)(this._input.headers, options.headers), hooks: (0, merge_js_1.mergeHooks)({ beforeRequest: [], beforeRetry: [], beforeError: [], afterResponse: [], }, options.hooks), method: (0, normalize_js_1.normalizeRequestMethod)(options.method ?? this._input.method ?? 'GET'), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing prefixUrl: String(options.prefixUrl || ''), retry: (0, normalize_js_1.normalizeRetryOptions)(options.retry), throwHttpErrors: options.throwHttpErrors !== false, timeout: options.timeout ?? 10_000, fetch: options.fetch ?? globalThis.fetch.bind(globalThis), }; 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 (constants_js_1.supportsAbortController && constants_js_1.supportsAbortSignal) { const originalSignal = this._options.signal ?? this._input.signal; this.abortController = new globalThis.AbortController(); this._options.signal = originalSignal ? AbortSignal.any([originalSignal, this.abortController.signal]) : this.abortController.signal; } if (constants_js_1.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'); } this.request = new globalThis.Request(this._input, this._options); if (this._options.searchParams) { // eslint-disable-next-line unicorn/prevent-abbreviations const textSearchParams = typeof this._options.searchParams === 'string' ? this._options.searchParams.replace(/^\?/, '') : new URLSearchParams(this._options.searchParams).toString(); // eslint-disable-next-line unicorn/prevent-abbreviations const searchParams = '?' + textSearchParams; const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams); // To provide correct form boundary, Content-Type header should be deleted each time when new Request instantiated from another one if (((constants_js_1.supportsFormData && this._options.body instanceof globalThis.FormData) || this._options.body instanceof URLSearchParams) && !(this._options.headers && this._options.headers['content-type'])) { this.request.headers.delete('content-type'); } // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws. this.request = new globalThis.Request(new globalThis.Request(url, { ...this.request }), 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 (!constants_js_1.supportsRequestStreams) { throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.'); } const originalBody = this.request.body; if (originalBody) { this.request = (0, body_js_1.streamRequest)(this.request, this._options.onUploadProgress); } } } _calculateRetryDelay(error) { this._retryCount++; if (this._retryCount > this._options.retry.limit || error instanceof TimeoutError_js_1.TimeoutError) { throw error; } if (error instanceof HTTPError_js_1.HTTPError) { 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-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; return after < max ? after : max; } if (error.response.status === 413) { throw error; } } const retryDelay = this._options.retry.delay(this._retryCount); return Math.min(this._options.retry.backoffLimit, retryDelay); } _decorateResponse(response) { if (this._options.parseJson) { response.json = async () => this._options.parseJson(await response.text()); } return response; } async _retry(function_) { try { return await function_(); } catch (error) { const ms = Math.min(this._calculateRetryDelay(error), constants_js_1.maxSafeTimeout); if (this._retryCount < 1) { throw error; } await (0, delay_js_1.default)(ms, { signal: this._options.signal }); 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._options, error: error, retryCount: this._retryCount, }); // If `stop` is returned from the hook, the retry process is stopped if (hookResult === constants_js_1.stop) { return; } } return this._retry(function_); } } async _fetch() { for (const hook of this._options.hooks.beforeRequest) { // eslint-disable-next-line no-await-in-loop const result = await hook(this.request, this._options); if (result instanceof Request) { this.request = result; break; } if (result instanceof Response) { return result; } } const nonRequestOptions = (0, options_js_1.findUnknownOptions)(this.request, this._options); // Cloning is done here to prepare in advance for retries const mainRequest = this.request; this.request = mainRequest.clone(); if (this._options.timeout === false) { return this._options.fetch(mainRequest, nonRequestOptions); } return (0, timeout_js_1.default)(mainRequest, nonRequestOptions, this.abortController, this._options); } } exports.Ky = Ky; //# sourceMappingURL=Ky.js.map