@ix-xs/node-comfort 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/README.md +136 -549
- package/index.js +80 -35
- package/package.json +160 -33
- package/src/Arr.js +887 -419
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +971 -539
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -894
- package/src/Func.js +670 -353
- package/src/Http.js +538 -0
- package/src/Id.js +384 -201
- package/src/Logger.js +739 -378
- package/src/Num.js +844 -361
- package/src/Obj.js +855 -372
- package/src/SQLite.js +1308 -1005
- package/src/Schema.js +1586 -0
- package/src/Str.js +983 -436
- package/src/Sys.js +511 -0
- package/src/Time.js +1260 -422
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -526
- package/types/src/Arr.d.ts +455 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +501 -235
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +686 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +380 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +149 -68
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +363 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +473 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +390 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +646 -309
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +569 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +663 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/index.d.ts.map +0 -1
package/src/Http.js
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An HTTP client on top of `fetch`: JSON by default, query objects, base
|
|
5
|
+
* URLs, timeouts, safe retries that respect `Retry-After`, typed errors,
|
|
6
|
+
* hooks, reusable clients and downloads with progress.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const { data } = await http.get("https://api.github.com/repos/nodejs/node");
|
|
10
|
+
*
|
|
11
|
+
* const api = http.create({ baseURL: "https://api.example.com/v1/", auth: { bearer: process.env.TOKEN } });
|
|
12
|
+
* const { data: users } = await api.get("users", { query: { page: 2 } });
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
const nodePath = require("node:path");
|
|
17
|
+
const { Readable, Transform } = require("node:stream");
|
|
18
|
+
const { pipeline } = require("node:stream/promises");
|
|
19
|
+
const { HttpError, TimeoutError, AbortError } = require("./errors.js");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A request body.
|
|
23
|
+
* @typedef {string | Buffer | Uint8Array | ArrayBuffer | URLSearchParams | FormData | Blob | ReadableStream | import("node:stream").Readable} HttpBody
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An HTTP method.
|
|
28
|
+
* @typedef {"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | (string & {})} HttpMethod
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Query string values. `null` and `undefined` are skipped, arrays repeat the key.
|
|
33
|
+
* @typedef {Record<string, string | number | boolean | null | undefined | Array<string | number | boolean>>} QueryParams
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Retry settings.
|
|
38
|
+
* @typedef {object} HttpRetryOptions
|
|
39
|
+
* @property {number} [attempts] Total attempts, the first included. Defaults to `3` for methods that are safe to repeat, `1` for the others.
|
|
40
|
+
* @property {number} [delay] Wait before the first retry, in ms. Defaults to `300`.
|
|
41
|
+
* @property {number} [backoff] Multiplies the wait after each attempt. Defaults to `2`.
|
|
42
|
+
* @property {number} [maxDelay] Longest wait, `Retry-After` included. Defaults to 30 seconds.
|
|
43
|
+
* @property {number[]} [statuses] Statuses worth retrying. Defaults to 408, 413, 429, 500, 502, 503 and 504.
|
|
44
|
+
* @property {HttpMethod[]} [methods] Methods allowed to retry. POST and PATCH aren't, by default, so nothing gets created twice.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A request as hooks see it.
|
|
49
|
+
* @typedef {object} HttpRequestConfig
|
|
50
|
+
* @property {string} url The final URL, base URL and query included.
|
|
51
|
+
* @property {HttpMethod} method
|
|
52
|
+
* @property {Record<string, string>} headers Lower-cased names.
|
|
53
|
+
* @property {HttpBody | undefined} body
|
|
54
|
+
* @property {number} attempt 1 for the first try.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hooks to watch or change requests.
|
|
59
|
+
* @typedef {object} HttpHooks
|
|
60
|
+
* @property {Array<(config: HttpRequestConfig) => void | Promise<void>>} [beforeRequest] Runs before each attempt. It can change the URL, headers or body.
|
|
61
|
+
* @property {Array<(response: HttpResponse<any>, config: HttpRequestConfig) => void | HttpResponse<any> | Promise<void | HttpResponse<any>>>} [afterResponse] Runs after each response, before errors are thrown. It can return another response.
|
|
62
|
+
* @property {Array<(error: unknown, attempt: number, delay: number) => void | Promise<void>>} [beforeRetry] Runs before waiting for a retry.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Request options.
|
|
67
|
+
* @typedef {object} HttpOptions
|
|
68
|
+
* @property {HttpMethod} [method] Defaults to `"GET"`.
|
|
69
|
+
* @property {string} [baseURL] Prefix for relative URLs.
|
|
70
|
+
* @property {Record<string, string | undefined>} [headers] Set a header to `undefined` to remove a default one.
|
|
71
|
+
* @property {QueryParams | URLSearchParams} [query] Added to the URL's own query string.
|
|
72
|
+
* @property {unknown} [json] Sent as JSON.
|
|
73
|
+
* @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
|
|
74
|
+
* @property {HttpBody} [body] Sent as is: string, Buffer, `FormData`, stream...
|
|
75
|
+
* @property {{ bearer: string } | { username: string, password: string }} [auth] Sets the `authorization` header.
|
|
76
|
+
* @property {number | string | false} [timeout] Time limit per attempt, like `"10s"`. `false` turns it off. Defaults to 30 seconds.
|
|
77
|
+
* @property {number | false | HttpRetryOptions} [retry] Number of attempts, `false`, or retry settings.
|
|
78
|
+
* @property {"auto" | "json" | "text" | "buffer" | "stream"} [responseType] How to read the body. `"auto"` reads JSON or text based on the content type, a Buffer otherwise.
|
|
79
|
+
* @property {boolean} [throwHttpErrors] Throw an `HttpError` when the status isn't 2xx. Defaults to `true`.
|
|
80
|
+
* @property {AbortSignal} [signal] Cancels the request.
|
|
81
|
+
* @property {HttpHooks} [hooks]
|
|
82
|
+
* @property {(progress: { loaded: number, total: number | undefined, percent: number | undefined }) => void} [onDownloadProgress] Called while the body downloads.
|
|
83
|
+
* @property {"follow" | "error" | "manual"} [redirect] Defaults to `"follow"`.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A response.
|
|
88
|
+
* @template [T=any]
|
|
89
|
+
* @typedef {object} HttpResponse
|
|
90
|
+
* @property {number} status
|
|
91
|
+
* @property {string} statusText
|
|
92
|
+
* @property {boolean} ok `true` for 2xx statuses.
|
|
93
|
+
* @property {Record<string, string>} headers Lower-cased names.
|
|
94
|
+
* @property {T} data The body, already read (see `responseType`).
|
|
95
|
+
* @property {string} url The final URL, after redirects.
|
|
96
|
+
* @property {number} duration In ms, retries included.
|
|
97
|
+
* @property {number} attempts How many attempts it took.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A client with its own defaults, from `create()`.
|
|
102
|
+
* @typedef {object} HttpClient
|
|
103
|
+
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} request Sends a request.
|
|
104
|
+
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} get Sends a GET request.
|
|
105
|
+
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} head Sends a HEAD request.
|
|
106
|
+
* @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} delete Sends a DELETE request.
|
|
107
|
+
* @property {<T = any>(url: string | URL, data?: unknown, options?: HttpOptions) => Promise<HttpResponse<T>>} post Sends a POST request. Objects and arrays go as JSON.
|
|
108
|
+
* @property {<T = any>(url: string | URL, data?: unknown, options?: HttpOptions) => Promise<HttpResponse<T>>} put Sends a PUT request. Objects and arrays go as JSON.
|
|
109
|
+
* @property {<T = any>(url: string | URL, data?: unknown, options?: HttpOptions) => Promise<HttpResponse<T>>} patch Sends a PATCH request. Objects and arrays go as JSON.
|
|
110
|
+
* @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
|
|
111
|
+
* @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
|
|
112
|
+
* @property {Readonly<HttpOptions>} defaults
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
|
|
116
|
+
const RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
117
|
+
|
|
118
|
+
/** @param {number | string | false | undefined} value */
|
|
119
|
+
const _ms = (value) => {
|
|
120
|
+
if (value === false) return undefined;
|
|
121
|
+
if (value === undefined) return 30_000;
|
|
122
|
+
if (typeof value === "number") return value;
|
|
123
|
+
const ms = require("./Time.js").parseDuration(value);
|
|
124
|
+
if (ms === null) throw new TypeError(`Invalid timeout: ${value}`);
|
|
125
|
+
return ms;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {HttpOptions} base
|
|
130
|
+
* @param {HttpOptions} extra
|
|
131
|
+
* @returns {HttpOptions}
|
|
132
|
+
*/
|
|
133
|
+
const _mergeOptions = (base, extra) => ({
|
|
134
|
+
...base,
|
|
135
|
+
...extra,
|
|
136
|
+
headers: { ...base.headers, ...extra.headers },
|
|
137
|
+
hooks: {
|
|
138
|
+
beforeRequest: [...(base.hooks?.beforeRequest ?? []), ...(extra.hooks?.beforeRequest ?? [])],
|
|
139
|
+
afterResponse: [...(base.hooks?.afterResponse ?? []), ...(extra.hooks?.afterResponse ?? [])],
|
|
140
|
+
beforeRetry: [...(base.hooks?.beforeRetry ?? []), ...(extra.hooks?.beforeRetry ?? [])],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string | URL} input
|
|
146
|
+
* @param {HttpOptions} options
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
const _buildURL = (input, options) => {
|
|
150
|
+
let url;
|
|
151
|
+
if (input instanceof URL) url = new URL(input.href);
|
|
152
|
+
else if (/^[a-z][a-z\d+.-]*:/i.test(input)) url = new URL(input);
|
|
153
|
+
else if (options.baseURL) url = new URL(input.replace(/^\/+/, ""), options.baseURL.replace(/\/*$/, "/"));
|
|
154
|
+
else throw new TypeError(`Invalid URL "${input}": use an absolute URL or set baseURL`);
|
|
155
|
+
const query = options.query;
|
|
156
|
+
if (query instanceof URLSearchParams) for (const [k, v] of query) url.searchParams.append(k, v);
|
|
157
|
+
else if (query) {
|
|
158
|
+
for (const [key, value] of Object.entries(query)) {
|
|
159
|
+
if (value === undefined || value === null) continue;
|
|
160
|
+
if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, String(item));
|
|
161
|
+
else url.searchParams.set(key, String(value));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return url.href;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {Response} res
|
|
169
|
+
* @param {HttpOptions} options
|
|
170
|
+
* @returns {Promise<unknown>}
|
|
171
|
+
*/
|
|
172
|
+
const _readBody = async (res, options) => {
|
|
173
|
+
const type = options.responseType ?? "auto";
|
|
174
|
+
if (type === "stream") return res.body ? Readable.fromWeb(/** @type {any} */ (res.body)) : Readable.from([]);
|
|
175
|
+
let buffer;
|
|
176
|
+
if (options.onDownloadProgress && res.body) {
|
|
177
|
+
const total = Number(res.headers.get("content-length")) || undefined;
|
|
178
|
+
/** @type {Buffer[]} */
|
|
179
|
+
const chunks = [];
|
|
180
|
+
let loaded = 0;
|
|
181
|
+
for await (const chunk of /** @type {AsyncIterable<Uint8Array>} */ (/** @type {unknown} */ (res.body))) {
|
|
182
|
+
chunks.push(Buffer.from(chunk));
|
|
183
|
+
loaded += chunk.length;
|
|
184
|
+
options.onDownloadProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
|
|
185
|
+
}
|
|
186
|
+
buffer = Buffer.concat(chunks);
|
|
187
|
+
} else {
|
|
188
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
189
|
+
}
|
|
190
|
+
if (type === "buffer") return buffer;
|
|
191
|
+
const text = buffer.toString("utf8");
|
|
192
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
193
|
+
if (type === "json" || (type === "auto" && /[/+]json\b/i.test(contentType))) {
|
|
194
|
+
if (!text) return undefined;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(text);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (type === "json") throw new HttpError(`Invalid JSON in response from ${res.url}`, { status: res.status, url: res.url, data: text, cause: error });
|
|
199
|
+
return text;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (type === "text" || type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType)) return text;
|
|
203
|
+
return buffer;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {Headers} headers
|
|
208
|
+
* @returns {Record<string, string>}
|
|
209
|
+
*/
|
|
210
|
+
const _headers = (headers) => Object.fromEntries(headers.entries());
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The delay asked for by a `Retry-After` header, in ms.
|
|
214
|
+
* @param {string | null} value
|
|
215
|
+
* @returns {number | undefined}
|
|
216
|
+
*/
|
|
217
|
+
const _retryAfter = (value) => {
|
|
218
|
+
if (!value) return undefined;
|
|
219
|
+
const seconds = Number(value);
|
|
220
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
221
|
+
const date = Date.parse(value);
|
|
222
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The engine behind every method.
|
|
227
|
+
* @param {string | URL} input
|
|
228
|
+
* @param {HttpOptions} options
|
|
229
|
+
* @returns {Promise<HttpResponse<any>>}
|
|
230
|
+
*/
|
|
231
|
+
const _send = async (input, options) => {
|
|
232
|
+
const started = Date.now();
|
|
233
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
234
|
+
const retryOptions = typeof options.retry === "object" ? options.retry : {};
|
|
235
|
+
const retryMethods = (retryOptions.methods ?? RETRY_METHODS).map((m) => m.toUpperCase());
|
|
236
|
+
const attempts = options.retry === false
|
|
237
|
+
? 1
|
|
238
|
+
: typeof options.retry === "number"
|
|
239
|
+
? Math.max(1, options.retry)
|
|
240
|
+
: retryOptions.attempts ?? (retryMethods.includes(method) ? 3 : 1);
|
|
241
|
+
const statuses = retryOptions.statuses ?? RETRY_STATUSES;
|
|
242
|
+
const timeoutMs = _ms(options.timeout);
|
|
243
|
+
|
|
244
|
+
/** @type {Record<string, string>} */
|
|
245
|
+
const headers = { accept: "application/json, text/plain, */*" };
|
|
246
|
+
for (const [key, value] of Object.entries(options.headers ?? {})) {
|
|
247
|
+
if (value === undefined) delete headers[key.toLowerCase()];
|
|
248
|
+
else headers[key.toLowerCase()] = value;
|
|
249
|
+
}
|
|
250
|
+
if (options.auth) {
|
|
251
|
+
headers.authorization = "bearer" in options.auth
|
|
252
|
+
? `Bearer ${options.auth.bearer}`
|
|
253
|
+
: `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64")}`;
|
|
254
|
+
}
|
|
255
|
+
/** @type {HttpBody | undefined} */
|
|
256
|
+
let body = options.body;
|
|
257
|
+
if (options.json !== undefined) {
|
|
258
|
+
body = JSON.stringify(options.json);
|
|
259
|
+
headers["content-type"] ??= "application/json";
|
|
260
|
+
} else if (options.form) {
|
|
261
|
+
body = new URLSearchParams(Object.entries(options.form).map(([k, v]) => /** @type {[string, string]} */ ([k, String(v)])));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let lastError;
|
|
265
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
266
|
+
/** @type {HttpRequestConfig} */
|
|
267
|
+
const config = { url: _buildURL(input, options), method, headers: { ...headers }, body, attempt };
|
|
268
|
+
for (const hook of options.hooks?.beforeRequest ?? []) await hook(config);
|
|
269
|
+
const signals = [options.signal, timeoutMs === undefined ? undefined : AbortSignal.timeout(timeoutMs)].filter((s) => s !== undefined);
|
|
270
|
+
/** @type {HttpResponse<any> | undefined} */
|
|
271
|
+
let result;
|
|
272
|
+
/** @type {number | undefined} */
|
|
273
|
+
let serverDelay;
|
|
274
|
+
try {
|
|
275
|
+
/** @type {any} */
|
|
276
|
+
const init = {
|
|
277
|
+
method,
|
|
278
|
+
headers: config.headers,
|
|
279
|
+
body: method === "GET" || method === "HEAD" ? undefined : config.body,
|
|
280
|
+
signal: signals.length ? AbortSignal.any(/** @type {AbortSignal[]} */ (signals)) : undefined,
|
|
281
|
+
redirect: options.redirect ?? "follow",
|
|
282
|
+
};
|
|
283
|
+
if (init.body instanceof Readable || (init.body && typeof (/** @type {any} */ (init.body)).getReader === "function")) init.duplex = "half";
|
|
284
|
+
const res = await fetch(config.url, init);
|
|
285
|
+
const data = method === "HEAD" ? undefined : await _readBody(res, options);
|
|
286
|
+
result = { status: res.status, statusText: res.statusText, ok: res.ok, headers: _headers(res.headers), data, url: res.url || config.url, duration: Date.now() - started, attempts: attempt };
|
|
287
|
+
for (const hook of options.hooks?.afterResponse ?? []) {
|
|
288
|
+
const replaced = await hook(result, config);
|
|
289
|
+
if (replaced) result = replaced;
|
|
290
|
+
}
|
|
291
|
+
if (result.ok || options.throwHttpErrors === false) return result;
|
|
292
|
+
lastError = new HttpError(`Request failed with status ${result.status}${result.statusText ? ` ${result.statusText}` : ""}: ${method} ${config.url}`, {
|
|
293
|
+
status: result.status, statusText: result.statusText, url: result.url, method, headers: result.headers, data: result.data,
|
|
294
|
+
});
|
|
295
|
+
serverDelay = _retryAfter(res.headers.get("retry-after"));
|
|
296
|
+
if (!statuses.includes(result.status)) throw lastError;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error === lastError && lastError instanceof HttpError && !statuses.includes(lastError.status)) throw error;
|
|
299
|
+
if (options.signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: options.signal.reason });
|
|
300
|
+
if (error instanceof Error && (error.name === "TimeoutError" || (error.name === "AbortError" && !options.signal?.aborted))) {
|
|
301
|
+
lastError = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${config.url}`, { timeout: timeoutMs, cause: error });
|
|
302
|
+
} else if (error !== lastError && !(error instanceof HttpError)) {
|
|
303
|
+
/** @type {any} */
|
|
304
|
+
const failure = error;
|
|
305
|
+
/** @type {string} */
|
|
306
|
+
const reason = failure?.cause?.code ?? failure?.cause?.message ?? failure?.message ?? String(error);
|
|
307
|
+
lastError = new HttpError(`Network error (${reason}): ${method} ${config.url}`, { status: 0, url: config.url, method, code: "ERR_NETWORK", cause: error });
|
|
308
|
+
} else if (error !== lastError) {
|
|
309
|
+
lastError = error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (attempt < attempts) {
|
|
313
|
+
const computed = Math.min((retryOptions.delay ?? 300) * (retryOptions.backoff ?? 2) ** (attempt - 1), retryOptions.maxDelay ?? 30_000);
|
|
314
|
+
const wait = serverDelay !== undefined ? Math.min(serverDelay, retryOptions.maxDelay ?? 30_000) : computed;
|
|
315
|
+
for (const hook of options.hooks?.beforeRetry ?? []) await hook(lastError, attempt, wait);
|
|
316
|
+
await require("./Utils.js").wait(wait, { signal: options.signal });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
throw lastError;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {unknown} data
|
|
324
|
+
* @param {HttpOptions} options
|
|
325
|
+
* @returns {HttpOptions}
|
|
326
|
+
*/
|
|
327
|
+
const _withData = (data, options) => {
|
|
328
|
+
if (data === undefined) return options;
|
|
329
|
+
const plain = data !== null && typeof data === "object" && (Array.isArray(data) || Object.getPrototypeOf(data) === Object.prototype || Object.getPrototypeOf(data) === null);
|
|
330
|
+
return plain || typeof data === "number" || typeof data === "boolean" || data === null ? { ...options, json: data } : { ...options, body: /** @type {HttpBody} */ (data) };
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @param {HttpOptions} defaults
|
|
335
|
+
* @returns {HttpClient}
|
|
336
|
+
*/
|
|
337
|
+
const _client = (defaults) => {
|
|
338
|
+
const merged = (/** @type {HttpOptions} */ options) => _mergeOptions(defaults, options ?? {});
|
|
339
|
+
/** @type {HttpClient} */
|
|
340
|
+
const client = {
|
|
341
|
+
request: (url, options) => _send(url, merged(options ?? {})),
|
|
342
|
+
get: (url, options) => _send(url, { ...merged(options ?? {}), method: "GET" }),
|
|
343
|
+
head: (url, options) => _send(url, { ...merged(options ?? {}), method: "HEAD" }),
|
|
344
|
+
delete: (url, options) => _send(url, { ...merged(options ?? {}), method: "DELETE" }),
|
|
345
|
+
post: (url, data, options) => _send(url, { ..._withData(data, merged(options ?? {})), method: "POST" }),
|
|
346
|
+
put: (url, data, options) => _send(url, { ..._withData(data, merged(options ?? {})), method: "PUT" }),
|
|
347
|
+
patch: (url, data, options) => _send(url, { ..._withData(data, merged(options ?? {})), method: "PATCH" }),
|
|
348
|
+
download: (url, destination, options) => download(url, destination, merged(options ?? {})),
|
|
349
|
+
extend: (more) => _client(_mergeOptions(defaults, more)),
|
|
350
|
+
defaults: Object.freeze({ ...defaults }),
|
|
351
|
+
};
|
|
352
|
+
return client;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Sends a request and resolves with the response, body already read.
|
|
357
|
+
* Throws an `HttpError` for error statuses, a `TimeoutError` when it's too
|
|
358
|
+
* slow and an `AbortError` when cancelled.
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* const res = await http.request("https://api.example.com/items/42", {
|
|
362
|
+
* method: "PUT",
|
|
363
|
+
* json: { name: "Lamp" },
|
|
364
|
+
* timeout: "10s",
|
|
365
|
+
* retry: { attempts: 5, statuses: [429, 503] },
|
|
366
|
+
* });
|
|
367
|
+
* res.data; // parsed JSON
|
|
368
|
+
*
|
|
369
|
+
* @template [T=any]
|
|
370
|
+
* @param {string | URL} url An absolute URL, or a path when `baseURL` is set.
|
|
371
|
+
* @param {HttpOptions} [options]
|
|
372
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
373
|
+
* @throws {HttpError | TimeoutError | AbortError}
|
|
374
|
+
*/
|
|
375
|
+
function request(url, options = {}) {
|
|
376
|
+
return _send(url, options);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Sends a GET request.
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* const { data } = await http.get("https://api.example.com/users", { query: { page: 2, tags: ["a", "b"] } });
|
|
384
|
+
*
|
|
385
|
+
* @template [T=any]
|
|
386
|
+
* @param {string | URL} url
|
|
387
|
+
* @param {HttpOptions} [options]
|
|
388
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
389
|
+
*/
|
|
390
|
+
function get(url, options = {}) {
|
|
391
|
+
return _send(url, { ...options, method: "GET" });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Sends a HEAD request: headers only, no body.
|
|
396
|
+
*
|
|
397
|
+
* @example
|
|
398
|
+
* const { headers } = await http.head("https://example.com/big.zip");
|
|
399
|
+
* headers["content-length"];
|
|
400
|
+
*
|
|
401
|
+
* @template [T=undefined]
|
|
402
|
+
* @param {string | URL} url
|
|
403
|
+
* @param {HttpOptions} [options]
|
|
404
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
405
|
+
*/
|
|
406
|
+
function head(url, options = {}) {
|
|
407
|
+
return _send(url, { ...options, method: "HEAD" });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Sends a POST request. Objects, arrays, numbers and booleans go as JSON;
|
|
412
|
+
* strings, Buffers, `FormData`, Blobs and streams go as they are.
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* const { data: user } = await http.post("https://api.example.com/users", { name: "Ada" });
|
|
416
|
+
* await http.post(uploadUrl, formData);
|
|
417
|
+
*
|
|
418
|
+
* @template [T=any]
|
|
419
|
+
* @param {string | URL} url
|
|
420
|
+
* @param {unknown} [data]
|
|
421
|
+
* @param {HttpOptions} [options]
|
|
422
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
423
|
+
*/
|
|
424
|
+
function post(url, data, options = {}) {
|
|
425
|
+
return _send(url, { ..._withData(data, options), method: "POST" });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Sends a PUT request. The body works like in `post()`.
|
|
430
|
+
*
|
|
431
|
+
* @template [T=any]
|
|
432
|
+
* @param {string | URL} url
|
|
433
|
+
* @param {unknown} [data]
|
|
434
|
+
* @param {HttpOptions} [options]
|
|
435
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
436
|
+
*/
|
|
437
|
+
function put(url, data, options = {}) {
|
|
438
|
+
return _send(url, { ..._withData(data, options), method: "PUT" });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Sends a PATCH request. The body works like in `post()`.
|
|
443
|
+
*
|
|
444
|
+
* @template [T=any]
|
|
445
|
+
* @param {string | URL} url
|
|
446
|
+
* @param {unknown} [data]
|
|
447
|
+
* @param {HttpOptions} [options]
|
|
448
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
449
|
+
*/
|
|
450
|
+
function patch(url, data, options = {}) {
|
|
451
|
+
return _send(url, { ..._withData(data, options), method: "PATCH" });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Sends a DELETE request.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* await http.delete(`https://api.example.com/users/${id}`);
|
|
459
|
+
*
|
|
460
|
+
* @template [T=any]
|
|
461
|
+
* @param {string | URL} url
|
|
462
|
+
* @param {HttpOptions} [options]
|
|
463
|
+
* @returns {Promise<HttpResponse<T>>}
|
|
464
|
+
*/
|
|
465
|
+
function del(url, options = {}) {
|
|
466
|
+
return _send(url, { ...options, method: "DELETE" });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Downloads to a file as a stream, so memory stays flat whatever the size.
|
|
471
|
+
* Folders are created, and a partial file is removed if it fails.
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* await http.download("https://example.com/video.mp4", "./downloads/video.mp4", {
|
|
475
|
+
* onDownloadProgress: ({ percent }) => bar.update(percent ?? 0),
|
|
476
|
+
* });
|
|
477
|
+
*
|
|
478
|
+
* @param {string | URL} url
|
|
479
|
+
* @param {string} destination
|
|
480
|
+
* @param {HttpOptions} [options]
|
|
481
|
+
* @returns {Promise<{ path: string, size: number }>} The absolute path and size in bytes.
|
|
482
|
+
* @throws {HttpError | TimeoutError | AbortError}
|
|
483
|
+
*/
|
|
484
|
+
async function download(url, destination, options = {}) {
|
|
485
|
+
const target = nodePath.resolve(destination);
|
|
486
|
+
fs.mkdirSync(nodePath.dirname(target), { recursive: true });
|
|
487
|
+
const res = await _send(url, { ...options, responseType: "stream", onDownloadProgress: undefined, timeout: options.timeout ?? false });
|
|
488
|
+
const total = Number(res.headers["content-length"]) || undefined;
|
|
489
|
+
let size = 0;
|
|
490
|
+
const counter = new Transform({
|
|
491
|
+
transform(chunk, _encoding, callback) {
|
|
492
|
+
size += chunk.length;
|
|
493
|
+
options.onDownloadProgress?.({ loaded: size, total, percent: total ? Math.min(100, (size / total) * 100) : undefined });
|
|
494
|
+
callback(null, chunk);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
try {
|
|
498
|
+
await pipeline(/** @type {Readable} */ (res.data), counter, fs.createWriteStream(target), { signal: options.signal });
|
|
499
|
+
} catch (error) {
|
|
500
|
+
fs.rmSync(target, { force: true });
|
|
501
|
+
if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
return { path: target, size };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Creates a client with default options. Options passed to each call are
|
|
509
|
+
* merged on top.
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* const github = http.create({
|
|
513
|
+
* baseURL: "https://api.github.com",
|
|
514
|
+
* auth: { bearer: process.env.GITHUB_TOKEN },
|
|
515
|
+
* hooks: { afterResponse: [(res) => nc.debug(res.status, res.url)] },
|
|
516
|
+
* });
|
|
517
|
+
* const { data: repo } = await github.get("/repos/nodejs/node");
|
|
518
|
+
*
|
|
519
|
+
* @param {HttpOptions} [defaults]
|
|
520
|
+
* @returns {HttpClient}
|
|
521
|
+
*/
|
|
522
|
+
function create(defaults = {}) {
|
|
523
|
+
return _client(defaults);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
module.exports = {
|
|
527
|
+
request,
|
|
528
|
+
// not shorthand: Node's ES module export detection stops at a bare `get`
|
|
529
|
+
get: get,
|
|
530
|
+
head,
|
|
531
|
+
post,
|
|
532
|
+
put,
|
|
533
|
+
patch,
|
|
534
|
+
delete: del,
|
|
535
|
+
download,
|
|
536
|
+
create,
|
|
537
|
+
HttpError,
|
|
538
|
+
};
|