UNPKG

13.6 kBJavaScriptView Raw
1(function (global, factory) {
2 typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
3 typeof define === 'function' && define.amd ? define(factory) :
4 (global = global || self, global.ky = factory());
5}(this, (function () { 'use strict';
6
7 /*! MIT License © Sindre Sorhus */
8
9 const globals = {};
10
11 const getGlobal = property => {
12 /* istanbul ignore next */
13 if (typeof self !== 'undefined' && self && property in self) {
14 return self;
15 }
16
17 /* istanbul ignore next */
18 if (typeof window !== 'undefined' && window && property in window) {
19 return window;
20 }
21
22 if (typeof global !== 'undefined' && global && property in global) {
23 return global;
24 }
25
26 /* istanbul ignore next */
27 if (typeof globalThis !== 'undefined' && globalThis) {
28 return globalThis;
29 }
30 };
31
32 const globalProperties = [
33 'Headers',
34 'Request',
35 'Response',
36 'ReadableStream',
37 'fetch',
38 'AbortController',
39 'FormData'
40 ];
41
42 for (const property of globalProperties) {
43 Object.defineProperty(globals, property, {
44 get() {
45 const globalObject = getGlobal(property);
46 const value = globalObject && globalObject[property];
47 return typeof value === 'function' ? value.bind(globalObject) : value;
48 }
49 });
50 }
51
52 const isObject = value => value !== null && typeof value === 'object';
53 const supportsAbortController = typeof globals.AbortController === 'function';
54 const supportsStreams = typeof globals.ReadableStream === 'function';
55 const supportsFormData = typeof globals.FormData === 'function';
56
57 const mergeHeaders = (source1, source2) => {
58 const result = new globals.Headers(source1);
59 const isHeadersInstance = source2 instanceof globals.Headers;
60 const source = new globals.Headers(source2);
61
62 for (const [key, value] of source) {
63 if ((isHeadersInstance && value === 'undefined') || value === undefined) {
64 result.delete(key);
65 } else {
66 result.set(key, value);
67 }
68 }
69
70 return result;
71 };
72
73 const deepMerge = (...sources) => {
74 let returnValue = {};
75 let headers = {};
76
77 for (const source of sources) {
78 if (Array.isArray(source)) {
79 if (!(Array.isArray(returnValue))) {
80 returnValue = [];
81 }
82
83 returnValue = [...returnValue, ...source];
84 } else if (isObject(source)) {
85 for (let [key, value] of Object.entries(source)) {
86 if (isObject(value) && Reflect.has(returnValue, key)) {
87 value = deepMerge(returnValue[key], value);
88 }
89
90 returnValue = {...returnValue, [key]: value};
91 }
92
93 if (isObject(source.headers)) {
94 headers = mergeHeaders(headers, source.headers);
95 }
96 }
97
98 returnValue.headers = headers;
99 }
100
101 return returnValue;
102 };
103
104 const requestMethods = [
105 'get',
106 'post',
107 'put',
108 'patch',
109 'head',
110 'delete'
111 ];
112
113 const responseTypes = {
114 json: 'application/json',
115 text: 'text/*',
116 formData: 'multipart/form-data',
117 arrayBuffer: '*/*',
118 blob: '*/*'
119 };
120
121 const retryMethods = [
122 'get',
123 'put',
124 'head',
125 'delete',
126 'options',
127 'trace'
128 ];
129
130 const retryStatusCodes = [
131 408,
132 413,
133 429,
134 500,
135 502,
136 503,
137 504
138 ];
139
140 const retryAfterStatusCodes = [
141 413,
142 429,
143 503
144 ];
145
146 const stop = Symbol('stop');
147
148 class HTTPError extends Error {
149 constructor(response) {
150 // Set the message to the status text, such as Unauthorized,
151 // with some fallbacks. This message should never be undefined.
152 super(
153 response.statusText ||
154 String(
155 (response.status === 0 || response.status) ?
156 response.status : 'Unknown response error'
157 )
158 );
159 this.name = 'HTTPError';
160 this.response = response;
161 }
162 }
163
164 class TimeoutError extends Error {
165 constructor() {
166 super('Request timed out');
167 this.name = 'TimeoutError';
168 }
169 }
170
171 const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
172
173 // `Promise.race()` workaround (#91)
174 const timeout = (promise, ms, abortController) =>
175 new Promise((resolve, reject) => {
176 const timeoutID = setTimeout(() => {
177 if (abortController) {
178 abortController.abort();
179 }
180
181 reject(new TimeoutError());
182 }, ms);
183
184 /* eslint-disable promise/prefer-await-to-then */
185 promise
186 .then(resolve)
187 .catch(reject)
188 .then(() => {
189 clearTimeout(timeoutID);
190 });
191 /* eslint-enable promise/prefer-await-to-then */
192 });
193
194 const normalizeRequestMethod = input => requestMethods.includes(input) ? input.toUpperCase() : input;
195
196 const defaultRetryOptions = {
197 limit: 2,
198 methods: retryMethods,
199 statusCodes: retryStatusCodes,
200 afterStatusCodes: retryAfterStatusCodes
201 };
202
203 const normalizeRetryOptions = (retry = {}) => {
204 if (typeof retry === 'number') {
205 return {
206 ...defaultRetryOptions,
207 limit: retry
208 };
209 }
210
211 if (retry.methods && !Array.isArray(retry.methods)) {
212 throw new Error('retry.methods must be an array');
213 }
214
215 if (retry.statusCodes && !Array.isArray(retry.statusCodes)) {
216 throw new Error('retry.statusCodes must be an array');
217 }
218
219 return {
220 ...defaultRetryOptions,
221 ...retry,
222 afterStatusCodes: retryAfterStatusCodes
223 };
224 };
225
226 // The maximum value of a 32bit int (see issue #117)
227 const maxSafeTimeout = 2147483647;
228
229 class Ky {
230 constructor(input, options = {}) {
231 this._retryCount = 0;
232 this._input = input;
233 this._options = {
234 // TODO: credentials can be removed when the spec change is implemented in all browsers. Context: https://www.chromestatus.com/feature/4539473312350208
235 credentials: this._input.credentials || 'same-origin',
236 ...options,
237 headers: mergeHeaders(this._input.headers, options.headers),
238 hooks: deepMerge({
239 beforeRequest: [],
240 beforeRetry: [],
241 afterResponse: []
242 }, options.hooks),
243 method: normalizeRequestMethod(options.method || this._input.method),
244 prefixUrl: String(options.prefixUrl || ''),
245 retry: normalizeRetryOptions(options.retry),
246 throwHttpErrors: options.throwHttpErrors !== false,
247 timeout: typeof options.timeout === 'undefined' ? 10000 : options.timeout
248 };
249
250 if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globals.Request)) {
251 throw new TypeError('`input` must be a string, URL, or Request');
252 }
253
254 if (this._options.prefixUrl && typeof this._input === 'string') {
255 if (this._input.startsWith('/')) {
256 throw new Error('`input` must not begin with a slash when using `prefixUrl`');
257 }
258
259 if (!this._options.prefixUrl.endsWith('/')) {
260 this._options.prefixUrl += '/';
261 }
262
263 this._input = this._options.prefixUrl + this._input;
264 }
265
266 if (supportsAbortController) {
267 this.abortController = new globals.AbortController();
268 if (this._options.signal) {
269 this._options.signal.addEventListener('abort', () => {
270 this.abortController.abort();
271 });
272 }
273
274 this._options.signal = this.abortController.signal;
275 }
276
277 this.request = new globals.Request(this._input, this._options);
278
279 if (this._options.searchParams) {
280 const url = new URL(this.request.url);
281 url.search = new URLSearchParams(this._options.searchParams);
282
283 // To provide correct form boundary, Content-Type header should be deleted each time when new Request instantiated from another one
284 if (((supportsFormData && this._options.body instanceof globals.FormData) || this._options.body instanceof URLSearchParams) && !(this._options.headers && this._options.headers['content-type'])) {
285 this.request.headers.delete('content-type');
286 }
287
288 this.request = new globals.Request(new globals.Request(url, this.request), this._options);
289 }
290
291 if (this._options.json !== undefined) {
292 this._options.body = JSON.stringify(this._options.json);
293 this.request.headers.set('content-type', 'application/json');
294 this.request = new globals.Request(this.request, {body: this._options.body});
295 }
296
297 const fn = async () => {
298 if (this._options.timeout > maxSafeTimeout) {
299 throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
300 }
301
302 await delay(1);
303 let response = await this._fetch();
304
305 for (const hook of this._options.hooks.afterResponse) {
306 // eslint-disable-next-line no-await-in-loop
307 const modifiedResponse = await hook(
308 this.request,
309 this._options,
310 response.clone()
311 );
312
313 if (modifiedResponse instanceof globals.Response) {
314 response = modifiedResponse;
315 }
316 }
317
318 if (!response.ok && this._options.throwHttpErrors) {
319 throw new HTTPError(response);
320 }
321
322 // If `onDownloadProgress` is passed, it uses the stream API internally
323 /* istanbul ignore next */
324 if (this._options.onDownloadProgress) {
325 if (typeof this._options.onDownloadProgress !== 'function') {
326 throw new TypeError('The `onDownloadProgress` option must be a function');
327 }
328
329 if (!supportsStreams) {
330 throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
331 }
332
333 return this._stream(response.clone(), this._options.onDownloadProgress);
334 }
335
336 return response;
337 };
338
339 const isRetriableMethod = this._options.retry.methods.includes(this.request.method.toLowerCase());
340 const result = isRetriableMethod ? this._retry(fn) : fn();
341
342 for (const [type, mimeType] of Object.entries(responseTypes)) {
343 result[type] = async () => {
344 this.request.headers.set('accept', this.request.headers.get('accept') || mimeType);
345 const response = (await result).clone();
346 return (type === 'json' && response.status === 204) ? '' : response[type]();
347 };
348 }
349
350 return result;
351 }
352
353 _calculateRetryDelay(error) {
354 this._retryCount++;
355
356 if (this._retryCount < this._options.retry.limit && !(error instanceof TimeoutError)) {
357 if (error instanceof HTTPError) {
358 if (!this._options.retry.statusCodes.includes(error.response.status)) {
359 return 0;
360 }
361
362 const retryAfter = error.response.headers.get('Retry-After');
363 if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
364 let after = Number(retryAfter);
365 if (Number.isNaN(after)) {
366 after = Date.parse(retryAfter) - Date.now();
367 } else {
368 after *= 1000;
369 }
370
371 if (typeof this._options.retry.maxRetryAfter !== 'undefined' && after > this._options.retry.maxRetryAfter) {
372 return 0;
373 }
374
375 return after;
376 }
377
378 if (error.response.status === 413) {
379 return 0;
380 }
381 }
382
383 const BACKOFF_FACTOR = 0.3;
384 return BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000;
385 }
386
387 return 0;
388 }
389
390 async _retry(fn) {
391 try {
392 return await fn();
393 } catch (error) {
394 const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout);
395 if (ms !== 0 && this._retryCount > 0) {
396 await delay(ms);
397
398 for (const hook of this._options.hooks.beforeRetry) {
399 // eslint-disable-next-line no-await-in-loop
400 const hookResult = await hook({
401 request: this.request,
402 options: this._options,
403 error,
404 response: error.response.clone(),
405 retryCount: this._retryCount
406 });
407
408 // If `stop` is returned from the hook, the retry process is stopped
409 if (hookResult === stop) {
410 return;
411 }
412 }
413
414 return this._retry(fn);
415 }
416
417 if (this._options.throwHttpErrors) {
418 throw error;
419 }
420 }
421 }
422
423 async _fetch() {
424 for (const hook of this._options.hooks.beforeRequest) {
425 // eslint-disable-next-line no-await-in-loop
426 const result = await hook(this.request, this._options);
427
428 if (result instanceof Request) {
429 this.request = result;
430 break;
431 }
432
433 if (result instanceof Response) {
434 return result;
435 }
436 }
437
438 if (this._options.timeout === false) {
439 return globals.fetch(this.request.clone());
440 }
441
442 return timeout(globals.fetch(this.request.clone()), this._options.timeout, this.abortController);
443 }
444
445 /* istanbul ignore next */
446 _stream(response, onDownloadProgress) {
447 const totalBytes = Number(response.headers.get('content-length')) || 0;
448 let transferredBytes = 0;
449
450 return new globals.Response(
451 new globals.ReadableStream({
452 start(controller) {
453 const reader = response.body.getReader();
454
455 if (onDownloadProgress) {
456 onDownloadProgress({percent: 0, transferredBytes: 0, totalBytes}, new Uint8Array());
457 }
458
459 async function read() {
460 const {done, value} = await reader.read();
461 if (done) {
462 controller.close();
463 return;
464 }
465
466 if (onDownloadProgress) {
467 transferredBytes += value.byteLength;
468 const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
469 onDownloadProgress({percent, transferredBytes, totalBytes}, value);
470 }
471
472 controller.enqueue(value);
473 read();
474 }
475
476 read();
477 }
478 })
479 );
480 }
481 }
482
483 const validateAndMerge = (...sources) => {
484 for (const source of sources) {
485 if ((!isObject(source) || Array.isArray(source)) && typeof source !== 'undefined') {
486 throw new TypeError('The `options` argument must be an object');
487 }
488 }
489
490 return deepMerge({}, ...sources);
491 };
492
493 const createInstance = defaults => {
494 const ky = (input, options) => new Ky(input, validateAndMerge(defaults, options));
495
496 for (const method of requestMethods) {
497 ky[method] = (input, options) => new Ky(input, validateAndMerge(defaults, options, {method}));
498 }
499
500 ky.HTTPError = HTTPError;
501 ky.TimeoutError = TimeoutError;
502 ky.create = newDefaults => createInstance(validateAndMerge(newDefaults));
503 ky.extend = newDefaults => createInstance(validateAndMerge(defaults, newDefaults));
504 ky.stop = stop;
505
506 return ky;
507 };
508
509 var index = createInstance();
510
511 return index;
512
513})));