@ix-xs/node-comfort 1.1.31 → 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -748
  4. package/index.js +84 -13
  5. package/package.json +201 -21
  6. package/src/Arr.js +901 -0
  7. package/src/Async.js +585 -0
  8. package/src/Cache.js +329 -0
  9. package/src/Checker.js +979 -188
  10. package/src/Cli.js +1034 -0
  11. package/src/Color.js +415 -0
  12. package/src/Crypto.js +707 -0
  13. package/src/Emitter.js +289 -0
  14. package/src/Env.js +555 -0
  15. package/src/FS.js +1483 -768
  16. package/src/Func.js +692 -0
  17. package/src/Http.js +538 -0
  18. package/src/Id.js +418 -0
  19. package/src/Logger.js +739 -307
  20. package/src/Num.js +849 -0
  21. package/src/Obj.js +904 -0
  22. package/src/SQLite.js +1308 -994
  23. package/src/Schema.js +1586 -0
  24. package/src/Str.js +999 -0
  25. package/src/Sys.js +511 -0
  26. package/src/Time.js +1323 -0
  27. package/src/Utils.js +321 -325
  28. package/src/errors.js +292 -0
  29. package/types/index.d.ts +693 -120
  30. package/types/src/Arr.d.ts +562 -0
  31. package/types/src/Arr.d.ts.map +1 -0
  32. package/types/src/Async.d.ts +321 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +195 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +609 -81
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +582 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +294 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +534 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +170 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +382 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +709 -201
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +455 -0
  51. package/types/src/Func.d.ts.map +1 -0
  52. package/types/src/Http.d.ts +374 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +200 -0
  55. package/types/src/Id.d.ts.map +1 -0
  56. package/types/src/Logger.d.ts +374 -24
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +578 -0
  59. package/types/src/Num.d.ts.map +1 -0
  60. package/types/src/Obj.d.ts +475 -0
  61. package/types/src/Obj.d.ts.map +1 -0
  62. package/types/src/SQLite.d.ts +647 -305
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +940 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +696 -0
  67. package/types/src/Str.d.ts.map +1 -0
  68. package/types/src/Sys.d.ts +269 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +664 -0
  71. package/types/src/Time.d.ts.map +1 -0
  72. package/types/src/Utils.d.ts +154 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +292 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/tsconfig.json +0 -18
  77. 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
+ };