UNPKG

12.7 kBJavaScriptView Raw
1import { HTTPError } from '../errors/HTTPError.js';
2import { TimeoutError } from '../errors/TimeoutError.js';
3import { deepMerge, mergeHeaders } from '../utils/merge.js';
4import { normalizeRequestMethod, normalizeRetryOptions } from '../utils/normalize.js';
5import { delay, timeout } from '../utils/time.js';
6import { maxSafeTimeout, responseTypes, stop, supportsAbortController, supportsFormData, supportsStreams } from './constants.js';
7export class Ky {
8 // eslint-disable-next-line complexity
9 constructor(input, options = {}) {
10 Object.defineProperty(this, "request", {
11 enumerable: true,
12 configurable: true,
13 writable: true,
14 value: void 0
15 });
16 Object.defineProperty(this, "abortController", {
17 enumerable: true,
18 configurable: true,
19 writable: true,
20 value: void 0
21 });
22 Object.defineProperty(this, "_retryCount", {
23 enumerable: true,
24 configurable: true,
25 writable: true,
26 value: 0
27 });
28 Object.defineProperty(this, "_input", {
29 enumerable: true,
30 configurable: true,
31 writable: true,
32 value: void 0
33 });
34 Object.defineProperty(this, "_options", {
35 enumerable: true,
36 configurable: true,
37 writable: true,
38 value: void 0
39 });
40 this._input = input;
41 this._options = {
42 // TODO: credentials can be removed when the spec change is implemented in all browsers. Context: https://www.chromestatus.com/feature/4539473312350208
43 credentials: this._input.credentials || 'same-origin',
44 ...options,
45 headers: mergeHeaders(this._input.headers, options.headers),
46 hooks: deepMerge({
47 beforeRequest: [],
48 beforeRetry: [],
49 beforeError: [],
50 afterResponse: [],
51 }, options.hooks),
52 method: normalizeRequestMethod(options.method ?? this._input.method),
53 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
54 prefixUrl: String(options.prefixUrl || ''),
55 retry: normalizeRetryOptions(options.retry),
56 throwHttpErrors: options.throwHttpErrors !== false,
57 timeout: typeof options.timeout === 'undefined' ? 10000 : options.timeout,
58 fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
59 };
60 if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globalThis.Request)) {
61 throw new TypeError('`input` must be a string, URL, or Request');
62 }
63 if (this._options.prefixUrl && typeof this._input === 'string') {
64 if (this._input.startsWith('/')) {
65 throw new Error('`input` must not begin with a slash when using `prefixUrl`');
66 }
67 if (!this._options.prefixUrl.endsWith('/')) {
68 this._options.prefixUrl += '/';
69 }
70 this._input = this._options.prefixUrl + this._input;
71 }
72 if (supportsAbortController) {
73 this.abortController = new globalThis.AbortController();
74 if (this._options.signal) {
75 this._options.signal.addEventListener('abort', () => {
76 this.abortController.abort();
77 });
78 }
79 this._options.signal = this.abortController.signal;
80 }
81 this.request = new globalThis.Request(this._input, this._options);
82 if (this._options.searchParams) {
83 // eslint-disable-next-line unicorn/prevent-abbreviations
84 const textSearchParams = typeof this._options.searchParams === 'string'
85 ? this._options.searchParams.replace(/^\?/, '')
86 : new URLSearchParams(this._options.searchParams).toString();
87 // eslint-disable-next-line unicorn/prevent-abbreviations
88 const searchParams = '?' + textSearchParams;
89 const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
90 // To provide correct form boundary, Content-Type header should be deleted each time when new Request instantiated from another one
91 if (((supportsFormData && this._options.body instanceof globalThis.FormData)
92 || this._options.body instanceof URLSearchParams) && !(this._options.headers && this._options.headers['content-type'])) {
93 this.request.headers.delete('content-type');
94 }
95 this.request = new globalThis.Request(new globalThis.Request(url, this.request), this._options);
96 }
97 if (this._options.json !== undefined) {
98 this._options.body = JSON.stringify(this._options.json);
99 this.request.headers.set('content-type', this._options.headers.get('content-type') ?? 'application/json');
100 this.request = new globalThis.Request(this.request, { body: this._options.body });
101 }
102 }
103 // eslint-disable-next-line @typescript-eslint/promise-function-async
104 static create(input, options) {
105 const ky = new Ky(input, options);
106 const fn = async () => {
107 if (ky._options.timeout > maxSafeTimeout) {
108 throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
109 }
110 // Delay the fetch so that body method shortcuts can set the Accept header
111 await Promise.resolve();
112 let response = await ky._fetch();
113 for (const hook of ky._options.hooks.afterResponse) {
114 // eslint-disable-next-line no-await-in-loop
115 const modifiedResponse = await hook(ky.request, ky._options, ky._decorateResponse(response.clone()));
116 if (modifiedResponse instanceof globalThis.Response) {
117 response = modifiedResponse;
118 }
119 }
120 ky._decorateResponse(response);
121 if (!response.ok && ky._options.throwHttpErrors) {
122 let error = new HTTPError(response, ky.request, ky._options);
123 for (const hook of ky._options.hooks.beforeError) {
124 // eslint-disable-next-line no-await-in-loop
125 error = await hook(error);
126 }
127 throw error;
128 }
129 // If `onDownloadProgress` is passed, it uses the stream API internally
130 /* istanbul ignore next */
131 if (ky._options.onDownloadProgress) {
132 if (typeof ky._options.onDownloadProgress !== 'function') {
133 throw new TypeError('The `onDownloadProgress` option must be a function');
134 }
135 if (!supportsStreams) {
136 throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
137 }
138 return ky._stream(response.clone(), ky._options.onDownloadProgress);
139 }
140 return response;
141 };
142 const isRetriableMethod = ky._options.retry.methods.includes(ky.request.method.toLowerCase());
143 const result = (isRetriableMethod ? ky._retry(fn) : fn());
144 for (const [type, mimeType] of Object.entries(responseTypes)) {
145 result[type] = async () => {
146 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
147 ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
148 const awaitedResult = await result;
149 const response = awaitedResult.clone();
150 if (type === 'json') {
151 if (response.status === 204) {
152 return '';
153 }
154 if (options.parseJson) {
155 return options.parseJson(await response.text());
156 }
157 }
158 return response[type]();
159 };
160 }
161 return result;
162 }
163 _calculateRetryDelay(error) {
164 this._retryCount++;
165 if (this._retryCount < this._options.retry.limit && !(error instanceof TimeoutError)) {
166 if (error instanceof HTTPError) {
167 if (!this._options.retry.statusCodes.includes(error.response.status)) {
168 return 0;
169 }
170 const retryAfter = error.response.headers.get('Retry-After');
171 if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
172 let after = Number(retryAfter);
173 if (Number.isNaN(after)) {
174 after = Date.parse(retryAfter) - Date.now();
175 }
176 else {
177 after *= 1000;
178 }
179 if (typeof this._options.retry.maxRetryAfter !== 'undefined' && after > this._options.retry.maxRetryAfter) {
180 return 0;
181 }
182 return after;
183 }
184 if (error.response.status === 413) {
185 return 0;
186 }
187 }
188 const BACKOFF_FACTOR = 0.3;
189 return BACKOFF_FACTOR * (2 ** (this._retryCount - 1)) * 1000;
190 }
191 return 0;
192 }
193 _decorateResponse(response) {
194 if (this._options.parseJson) {
195 response.json = async () => this._options.parseJson(await response.text());
196 }
197 return response;
198 }
199 async _retry(fn) {
200 try {
201 return await fn();
202 // eslint-disable-next-line @typescript-eslint/no-implicit-any-catch
203 }
204 catch (error) {
205 const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout);
206 if (ms !== 0 && this._retryCount > 0) {
207 await delay(ms);
208 for (const hook of this._options.hooks.beforeRetry) {
209 // eslint-disable-next-line no-await-in-loop
210 const hookResult = await hook({
211 request: this.request,
212 options: this._options,
213 error: error,
214 retryCount: this._retryCount,
215 });
216 // If `stop` is returned from the hook, the retry process is stopped
217 if (hookResult === stop) {
218 return;
219 }
220 }
221 return this._retry(fn);
222 }
223 throw error;
224 }
225 }
226 async _fetch() {
227 for (const hook of this._options.hooks.beforeRequest) {
228 // eslint-disable-next-line no-await-in-loop
229 const result = await hook(this.request, this._options);
230 if (result instanceof Request) {
231 this.request = result;
232 break;
233 }
234 if (result instanceof Response) {
235 return result;
236 }
237 }
238 if (this._options.timeout === false) {
239 return this._options.fetch(this.request.clone());
240 }
241 return timeout(this.request.clone(), this.abortController, this._options);
242 }
243 /* istanbul ignore next */
244 _stream(response, onDownloadProgress) {
245 const totalBytes = Number(response.headers.get('content-length')) || 0;
246 let transferredBytes = 0;
247 return new globalThis.Response(new globalThis.ReadableStream({
248 async start(controller) {
249 const reader = response.body.getReader();
250 if (onDownloadProgress) {
251 onDownloadProgress({ percent: 0, transferredBytes: 0, totalBytes }, new Uint8Array());
252 }
253 async function read() {
254 const { done, value } = await reader.read();
255 if (done) {
256 controller.close();
257 return;
258 }
259 if (onDownloadProgress) {
260 transferredBytes += value.byteLength;
261 const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
262 onDownloadProgress({ percent, transferredBytes, totalBytes }, value);
263 }
264 controller.enqueue(value);
265 await read();
266 }
267 await read();
268 },
269 }), {
270 status: response.status,
271 statusText: response.statusText,
272 headers: response.headers,
273 });
274 }
275}
276//# sourceMappingURL=Ky.js.map
\No newline at end of file