1 | import { HTTPError } from '../errors/HTTPError.js';
|
2 | import { TimeoutError } from '../errors/TimeoutError.js';
|
3 | import { deepMerge, mergeHeaders } from '../utils/merge.js';
|
4 | import { normalizeRequestMethod, normalizeRetryOptions } from '../utils/normalize.js';
|
5 | import { delay, timeout } from '../utils/time.js';
|
6 | import { maxSafeTimeout, responseTypes, stop, supportsAbortController, supportsFormData, supportsStreams } from './constants.js';
|
7 | export class Ky {
|
8 |
|
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 |
|
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 |
|
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 |
|
84 | const textSearchParams = typeof this._options.searchParams === 'string'
|
85 | ? this._options.searchParams.replace(/^\?/, '')
|
86 | : new URLSearchParams(this._options.searchParams).toString();
|
87 |
|
88 | const searchParams = '?' + textSearchParams;
|
89 | const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
|
90 |
|
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 |
|
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 |
|
111 | await Promise.resolve();
|
112 | let response = await ky._fetch();
|
113 | for (const hook of ky._options.hooks.afterResponse) {
|
114 |
|
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 |
|
125 | error = await hook(error);
|
126 | }
|
127 | throw error;
|
128 | }
|
129 |
|
130 |
|
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 |
|
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 |
|
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 |
|
210 | const hookResult = await hook({
|
211 | request: this.request,
|
212 | options: this._options,
|
213 | error: error,
|
214 | retryCount: this._retryCount,
|
215 | });
|
216 |
|
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 |
|
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 |
|
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 |
|
\ | No newline at end of file |