/*! MIT License © Sindre Sorhus */ const globals = {}; const getGlobal = property => { /* istanbul ignore next */ if (typeof self !== 'undefined' && self && property in self) { return self; } /* istanbul ignore next */ if (typeof window !== 'undefined' && window && property in window) { return window; } if (typeof global !== 'undefined' && global && property in global) { return global; } /* istanbul ignore next */ if (typeof globalThis !== 'undefined' && globalThis) { return globalThis; } }; const globalProperties = [ 'Headers', 'Request', 'Response', 'ReadableStream', 'fetch', 'AbortController', 'FormData' ]; for (const property of globalProperties) { Object.defineProperty(globals, property, { get() { const globalObject = getGlobal(property); const value = globalObject && globalObject[property]; return typeof value === 'function' ? value.bind(globalObject) : value; } }); } const isObject = value => value !== null && typeof value === 'object'; const supportsAbortController = typeof globals.AbortController === 'function'; const supportsStreams = typeof globals.ReadableStream === 'function'; const supportsFormData = typeof globals.FormData === 'function'; const mergeHeaders = (source1, source2) => { const result = new globals.Headers(source1 || {}); const isHeadersInstance = source2 instanceof globals.Headers; const source = new globals.Headers(source2 || {}); for (const [key, value] of source) { if ((isHeadersInstance && value === 'undefined') || value === undefined) { result.delete(key); } else { result.set(key, value); } } return result; }; const deepMerge = (...sources) => { let returnValue = {}; let headers = {}; for (const source of sources) { if (Array.isArray(source)) { if (!(Array.isArray(returnValue))) { returnValue = []; } returnValue = [...returnValue, ...source]; } else if (isObject(source)) { for (let [key, value] of Object.entries(source)) { if (isObject(value) && (key in returnValue)) { value = deepMerge(returnValue[key], value); } returnValue = {...returnValue, [key]: value}; } if (isObject(source.headers)) { headers = mergeHeaders(headers, source.headers); } } returnValue.headers = headers; } return returnValue; }; const requestMethods = [ 'get', 'post', 'put', 'patch', 'head', 'delete' ]; const responseTypes = { json: 'application/json', text: 'text/*', formData: 'multipart/form-data', arrayBuffer: '*/*', blob: '*/*' }; const retryMethods = [ 'get', 'put', 'head', 'delete', 'options', 'trace' ]; const retryStatusCodes = [ 408, 413, 429, 500, 502, 503, 504 ]; const retryAfterStatusCodes = [ 413, 429, 503 ]; const stop = Symbol('stop'); class HTTPError extends Error { constructor(response) { // Set the message to the status text, such as Unauthorized, // with some fallbacks. This message should never be undefined. super( response.statusText || String( (response.status === 0 || response.status) ? response.status : 'Unknown response error' ) ); this.name = 'HTTPError'; this.response = response; } } class TimeoutError extends Error { constructor(request) { super('Request timed out'); this.name = 'TimeoutError'; this.request = request; } } const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); // `Promise.race()` workaround (#91) const timeout = (request, abortController, options) => new Promise((resolve, reject) => { const timeoutID = setTimeout(() => { if (abortController) { abortController.abort(); } reject(new TimeoutError(request)); }, options.timeout); /* eslint-disable promise/prefer-await-to-then */ options.fetch(request) .then(resolve) .catch(reject) .then(() => { clearTimeout(timeoutID); }); /* eslint-enable promise/prefer-await-to-then */ }); const normalizeRequestMethod = input => requestMethods.includes(input) ? input.toUpperCase() : input; const defaultRetryOptions = { limit: 2, methods: retryMethods, statusCodes: retryStatusCodes, afterStatusCodes: retryAfterStatusCodes }; const normalizeRetryOptions = (retry = {}) => { if (typeof retry === 'number') { return { ...defaultRetryOptions, limit: retry }; } if (retry.methods && !Array.isArray(retry.methods)) { throw new Error('retry.methods must be an array'); } if (retry.statusCodes && !Array.isArray(retry.statusCodes)) { throw new Error('retry.statusCodes must be an array'); } return { ...defaultRetryOptions, ...retry, afterStatusCodes: retryAfterStatusCodes }; }; // The maximum value of a 32bit int (see issue #117) const maxSafeTimeout = 2147483647; class Ky { constructor(input, options = {}) { this._retryCount = 0; this._input = input; this._options = { // TODO: credentials can be removed when the spec change is implemented in all browsers. Context: https://www.chromestatus.com/feature/4539473312350208 credentials: this._input.credentials || 'same-origin', ...options, headers: mergeHeaders(this._input.headers, options.headers), hooks: deepMerge({ beforeRequest: [], beforeRetry: [], afterResponse: [] }, options.hooks), method: normalizeRequestMethod(options.method || this._input.method), prefixUrl: String(options.prefixUrl || ''), retry: normalizeRetryOptions(options.retry), throwHttpErrors: options.throwHttpErrors !== false, timeout: typeof options.timeout === 'undefined' ? 10000 : options.timeout, fetch: options.fetch || globals.fetch }; if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globals.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) { this.abortController = new globals.AbortController(); if (this._options.signal) { this._options.signal.addEventListener('abort', () => { this.abortController.abort(); }); } this._options.signal = this.abortController.signal; } this.request = new globals.Request(this._input, this._options); if (this._options.searchParams) { const searchParams = '?' + new URLSearchParams(this._options.searchParams).toString(); 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 (((supportsFormData && this._options.body instanceof globals.FormData) || this._options.body instanceof URLSearchParams) && !(this._options.headers && this._options.headers['content-type'])) { this.request.headers.delete('content-type'); } this.request = new globals.Request(new globals.Request(url, this.request), this._options); } if (this._options.json !== undefined) { this._options.body = JSON.stringify(this._options.json); this.request.headers.set('content-type', 'application/json'); this.request = new globals.Request(this.request, {body: this._options.body}); } const fn = async () => { if (this._options.timeout > maxSafeTimeout) { throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`); } await delay(1); let response = await this._fetch(); for (const hook of this._options.hooks.afterResponse) { // eslint-disable-next-line no-await-in-loop const modifiedResponse = await hook( this.request, this._options, this._decorateResponse(response.clone()) ); if (modifiedResponse instanceof globals.Response) { response = modifiedResponse; } } this._decorateResponse(response); if (!response.ok && this._options.throwHttpErrors) { throw new HTTPError(response); } // If `onDownloadProgress` is passed, it uses the stream API internally /* istanbul ignore next */ if (this._options.onDownloadProgress) { if (typeof this._options.onDownloadProgress !== 'function') { throw new TypeError('The `onDownloadProgress` option must be a function'); } if (!supportsStreams) { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } return this._stream(response.clone(), this._options.onDownloadProgress); } return response; }; const isRetriableMethod = this._options.retry.methods.includes(this.request.method.toLowerCase()); const result = isRetriableMethod ? this._retry(fn) : fn(); for (const [type, mimeType] of Object.entries(responseTypes)) { result[type] = async () => { this.request.headers.set('accept', this.request.headers.get('accept') || mimeType); const response = (await result).clone(); if (type === 'json') { if (response.status === 204) { return ''; } if (options.parseJson) { return options.parseJson(await response.text()); } } return response[type](); }; } return result; } _calculateRetryDelay(error) { this._retryCount++; if (this._retryCount < this._options.retry.limit && !(error instanceof TimeoutError)) { if (error instanceof HTTPError) { if (!this._options.retry.statusCodes.includes(error.response.status)) { return 0; } const retryAfter = error.response.headers.get('Retry-After'); if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) { let after = Number(retryAfter); if (Number.isNaN(after)) { after = Date.parse(retryAfter) - Date.now(); } else { after *= 1000; } if (typeof this._options.retry.maxRetryAfter !== 'undefined' && after > this._options.retry.maxRetryAfter) { return 0; } return after; } if (error.response.status === 413) { return 0; } } const BACKOFF_FACTOR = 0.3; return BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000; } return 0; } _decorateResponse(response) { if (this._options.parseJson) { response.json = async () => { return this._options.parseJson(await response.text()); }; } return response; } async _retry(fn) { try { return await fn(); } catch (error) { const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout); if (ms !== 0 && this._retryCount > 0) { await delay(ms); 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, retryCount: this._retryCount }); // If `stop` is returned from the hook, the retry process is stopped if (hookResult === stop) { return; } } return this._retry(fn); } if (this._options.throwHttpErrors) { throw error; } } } 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; } } if (this._options.timeout === false) { return this._options.fetch(this.request.clone()); } return timeout(this.request.clone(), this.abortController, this._options); } /* istanbul ignore next */ _stream(response, onDownloadProgress) { const totalBytes = Number(response.headers.get('content-length')) || 0; let transferredBytes = 0; return new globals.Response( new globals.ReadableStream({ start(controller) { const reader = response.body.getReader(); if (onDownloadProgress) { onDownloadProgress({percent: 0, transferredBytes: 0, totalBytes}, new Uint8Array()); } async function read() { const {done, value} = await reader.read(); if (done) { controller.close(); return; } if (onDownloadProgress) { transferredBytes += value.byteLength; const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; onDownloadProgress({percent, transferredBytes, totalBytes}, value); } controller.enqueue(value); read(); } read(); } }) ); } } const validateAndMerge = (...sources) => { for (const source of sources) { if ((!isObject(source) || Array.isArray(source)) && typeof source !== 'undefined') { throw new TypeError('The `options` argument must be an object'); } } return deepMerge({}, ...sources); }; const createInstance = defaults => { const ky = (input, options) => new Ky(input, validateAndMerge(defaults, options)); for (const method of requestMethods) { ky[method] = (input, options) => new Ky(input, validateAndMerge(defaults, options, {method})); } ky.HTTPError = HTTPError; ky.TimeoutError = TimeoutError; ky.create = newDefaults => createInstance(validateAndMerge(newDefaults)); ky.extend = newDefaults => createInstance(validateAndMerge(defaults, newDefaults)); ky.stop = stop; return ky; }; export default createInstance();