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