529 lines
13 KiB
JavaScript
529 lines
13 KiB
JavaScript
/*! 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();
|