@ix-xs/node-comfort 1.2.0 → 2.1.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 (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. package/types/index.d.ts.map +0 -1
package/src/Http.js ADDED
@@ -0,0 +1,700 @@
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 { types } = require("node:util");
16
+ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
17
+ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
18
+
19
+ /**
20
+ * A request body.
21
+ * @typedef {string | Buffer | Uint8Array | ArrayBuffer | URLSearchParams | FormData | Blob | ReadableStream | import("node:stream").Readable} HttpBody
22
+ */
23
+
24
+ /**
25
+ * An HTTP method.
26
+ * @typedef {"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | (string & {})} HttpMethod
27
+ */
28
+
29
+ /**
30
+ * Query string values. `null` and `undefined` are skipped, arrays repeat the key, dates are sent in ISO format.
31
+ * @typedef {Record<string, string | number | boolean | Date | null | undefined | Array<string | number | boolean | Date>>} QueryParams
32
+ */
33
+
34
+ /**
35
+ * Request headers: an object, a `Headers` instance or a list of pairs.
36
+ * @typedef {Record<string, string | undefined> | Headers | Array<[string, string]>} HttpHeaders
37
+ */
38
+
39
+ /**
40
+ * Retry settings.
41
+ * @typedef {object} HttpRetryOptions
42
+ * @property {number} [attempts] Total attempts, the first included. Defaults to `3` for methods that are safe to repeat, `1` for the others. A number you give applies to every method, POST included.
43
+ * @property {number | string} [delay] Wait before the first retry, in ms or like `"1s"`. Defaults to `300`.
44
+ * @property {number} [backoff] Multiplies the wait after each attempt. Defaults to `2`.
45
+ * @property {number | string} [maxDelay] Longest wait, `Retry-After` included. Defaults to 30 seconds.
46
+ * @property {boolean} [jitter] Shorten each wait by a random amount, up to half of it, so many clients don't all come back at the same instant.
47
+ * @property {number[]} [statuses] Statuses worth retrying. Defaults to 408, 413, 429, 500, 502, 503 and 504.
48
+ * @property {HttpMethod[]} [methods] Methods retried when you give no number of attempts. POST and PATCH aren't, by default, so nothing gets created twice.
49
+ */
50
+
51
+ /**
52
+ * A request as hooks see it.
53
+ * @typedef {object} HttpRequestConfig
54
+ * @property {string} url The final URL, base URL and query included.
55
+ * @property {HttpMethod} method
56
+ * @property {Record<string, string>} headers Lower-cased names.
57
+ * @property {HttpBody | undefined} body
58
+ * @property {number} attempt 1 for the first try.
59
+ */
60
+
61
+ /**
62
+ * Hooks to watch or change requests.
63
+ * @typedef {object} HttpHooks
64
+ * @property {Array<(config: HttpRequestConfig) => void | Promise<void>>} [beforeRequest] Runs before each attempt. It can change the URL, headers or body.
65
+ * @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.
66
+ * @property {Array<(error: unknown, attempt: number, delay: number) => void | Promise<void>>} [beforeRetry] Runs before waiting for a retry.
67
+ */
68
+
69
+ /**
70
+ * Request options.
71
+ * @typedef {object} HttpOptions
72
+ * @property {HttpMethod} [method] Defaults to `"GET"`.
73
+ * @property {string} [baseURL] Prefix for relative URLs.
74
+ * @property {HttpHeaders} [headers] Set a header to `undefined` to remove a default one.
75
+ * @property {Record<string, string | number | boolean>} [params] Values for the `:name` parts of the URL: `"users/:id"` with `{ id: 42 }` gives `users/42`. They're encoded for you.
76
+ * @property {QueryParams | URLSearchParams} [query] Added to the URL's own query string, and to the client's default query.
77
+ * @property {unknown} [json] Sent as JSON.
78
+ * @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
79
+ * @property {HttpBody} [body] Sent as is: string, Buffer, `FormData`, stream...
80
+ * @property {{ bearer: string } | { username: string, password: string }} [auth] Sets the `authorization` header.
81
+ * @property {number | string | false} [timeout] Time limit per attempt, like `"10s"`. `false` turns it off. Defaults to 30 seconds. With `responseType: "stream"`, it only covers the wait for the response headers.
82
+ * @property {number | false | HttpRetryOptions} [retry] Number of attempts, `false`, or retry settings. A request whose body is a stream is never retried.
83
+ * @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.
84
+ * @property {{ parse(data: unknown): unknown }} [schema] Checks the body of a 2xx response, and replaces it with the result: pass a `nc.schema` and bad data from an API stops at the door, with a `ValidationError`. `data` takes the type the schema describes.
85
+ * @property {boolean} [throwHttpErrors] Throw an `HttpError` when the status isn't 2xx. Defaults to `true`.
86
+ * @property {AbortSignal} [signal] Cancels the request.
87
+ * @property {HttpHooks} [hooks]
88
+ * @property {(progress: { loaded: number, total: number | undefined, percent: number | undefined }) => void} [onDownloadProgress] Called while the body downloads.
89
+ * @property {"follow" | "error" | "manual"} [redirect] Defaults to `"follow"`.
90
+ */
91
+
92
+ /**
93
+ * A response.
94
+ * @template [T=any]
95
+ * @typedef {object} HttpResponse
96
+ * @property {number} status
97
+ * @property {string} statusText
98
+ * @property {boolean} ok `true` for 2xx statuses.
99
+ * @property {Record<string, string>} headers Lower-cased names.
100
+ * @property {string[]} cookies The `Set-Cookie` headers, one per cookie. `headers` can only hold one of them.
101
+ * @property {T} data The body, already read (see `responseType`).
102
+ * @property {string} url The final URL, after redirects.
103
+ * @property {number} duration In ms, retries included.
104
+ * @property {number} attempts How many attempts it took.
105
+ */
106
+
107
+ /**
108
+ * What the `schema` option takes: a `nc.schema`, or anything else with a
109
+ * `parse()` method that returns the checked value or throws.
110
+ * @typedef {{ parse(data: unknown): unknown }} ResponseSchema
111
+ */
112
+
113
+ /**
114
+ * Request options, where `schema` gives `response.data` its type.
115
+ * @template {ResponseSchema} S
116
+ * @typedef {Omit<HttpOptions, "schema"> & { schema?: S }} HttpOptionsOf
117
+ */
118
+
119
+ /**
120
+ * A client with its own defaults, from `create()`.
121
+ * @typedef {object} HttpClient
122
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} request Sends a request.
123
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} get Sends a GET request.
124
+ * @property {<T = any>(url: string | URL, options?: HttpOptions) => Promise<HttpResponse<T>>} head Sends a HEAD request.
125
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} delete Sends a DELETE request.
126
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} post Sends a POST request. Objects and arrays go as JSON.
127
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} put Sends a PUT request. Objects and arrays go as JSON.
128
+ * @property {<T = any, S extends ResponseSchema = { parse(data: unknown): T }>(url: string | URL, data?: unknown, options?: HttpOptionsOf<S>) => Promise<HttpResponse<ReturnType<S["parse"]>>>} patch Sends a PATCH request. Objects and arrays go as JSON.
129
+ * @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
130
+ * @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
131
+ * @property {Readonly<HttpOptions>} defaults
132
+ */
133
+
134
+ const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
135
+ const RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
136
+ const DEFAULT_TIMEOUT = 30_000;
137
+ const ABSOLUTE = /^[a-z][a-z\d+.-]*:/i;
138
+ // ":name" right after a slash, which leaves "http://host:3000" alone
139
+ const PARAM = /(?<=\/):([A-Za-z_]\w*)/g;
140
+
141
+ /**
142
+ * @param {number | string | false | undefined} value
143
+ * @returns {number | undefined} `undefined` for no limit.
144
+ */
145
+ const _timeout = (value) => {
146
+ if (value === false) return undefined;
147
+ if (value === undefined) return DEFAULT_TIMEOUT;
148
+ const ms = toMs(value, "timeout");
149
+ return ms === Infinity ? undefined : ms;
150
+ };
151
+
152
+ /**
153
+ * Headers as an object with lower-cased names.
154
+ * @param {HttpHeaders | undefined} input
155
+ * @returns {Record<string, string | undefined>}
156
+ */
157
+ const _headerRecord = (input) => {
158
+ /** @type {Record<string, string | undefined>} */
159
+ const out = {};
160
+ if (!input) return out;
161
+ if (typeof (/** @type {Headers} */ (input).forEach) === "function" && !Array.isArray(input)) {
162
+ /** @type {Headers} */ (input).forEach((value, key) => {
163
+ out[key.toLowerCase()] = value;
164
+ });
165
+ return out;
166
+ }
167
+ for (const [key, value] of Array.isArray(input) ? input : Object.entries(input)) {
168
+ out[String(key).toLowerCase()] = value === undefined || value === null ? undefined : String(value);
169
+ }
170
+ return out;
171
+ };
172
+
173
+ /** @param {unknown} value */
174
+ const _queryValue = (value) => (types.isDate(value) ? value.toISOString() : String(value));
175
+
176
+ /**
177
+ * @param {URLSearchParams} params
178
+ * @param {QueryParams | URLSearchParams | undefined} query
179
+ */
180
+ const _appendQuery = (params, query) => {
181
+ if (!query) return;
182
+ if (typeof (/** @type {URLSearchParams} */ (query).append) === "function") {
183
+ for (const [key, value] of /** @type {URLSearchParams} */ (query)) params.append(key, value);
184
+ return;
185
+ }
186
+ for (const [key, value] of Object.entries(query)) {
187
+ if (value === undefined || value === null) continue;
188
+ if (Array.isArray(value)) {
189
+ params.delete(key);
190
+ for (const item of value) params.append(key, _queryValue(item));
191
+ } else {
192
+ params.set(key, _queryValue(value));
193
+ }
194
+ }
195
+ };
196
+
197
+ /**
198
+ * @template T
199
+ * @param {T[] | undefined} a
200
+ * @param {T[] | undefined} b
201
+ * @returns {T[] | undefined}
202
+ */
203
+ const _concat = (a, b) => (a?.length && b?.length ? [...a, ...b] : b?.length ? b : a);
204
+
205
+ /**
206
+ * @param {HttpOptions} base
207
+ * @param {HttpOptions} extra
208
+ * @returns {HttpOptions}
209
+ */
210
+ const _mergeOptions = (base, extra) => {
211
+ const merged = { ...base, ...extra };
212
+ if (base.headers && extra.headers) merged.headers = { ..._headerRecord(base.headers), ..._headerRecord(extra.headers) };
213
+ if (base.params && extra.params) merged.params = { ...base.params, ...extra.params };
214
+ if (base.query && extra.query) {
215
+ // The client's query stays, the call adds to it or replaces a key.
216
+ const query = new URLSearchParams();
217
+ _appendQuery(query, base.query);
218
+ _appendQuery(query, extra.query);
219
+ merged.query = query;
220
+ }
221
+ if (base.hooks && extra.hooks) {
222
+ merged.hooks = {
223
+ beforeRequest: _concat(base.hooks.beforeRequest, extra.hooks.beforeRequest),
224
+ afterResponse: _concat(base.hooks.afterResponse, extra.hooks.afterResponse),
225
+ beforeRetry: _concat(base.hooks.beforeRetry, extra.hooks.beforeRetry),
226
+ };
227
+ }
228
+ return merged;
229
+ };
230
+
231
+ /**
232
+ * @param {string | URL} input
233
+ * @param {HttpOptions} options
234
+ * @returns {string}
235
+ */
236
+ const _buildURL = (input, options) => {
237
+ let url;
238
+ if (typeof input !== "string") {
239
+ url = new URL(String(input));
240
+ } else {
241
+ const params = options.params;
242
+ const path = !params ? input : input.replace(PARAM, (_, name) => {
243
+ const value = Object.hasOwn(params, name) ? params[name] : undefined;
244
+ if (value === undefined || value === null) throw new TypeError(`Missing URL parameter "${name}" for ${input}`);
245
+ return encodeURIComponent(String(value));
246
+ });
247
+ if (ABSOLUTE.test(path)) url = new URL(path);
248
+ else if (options.baseURL) url = new URL(path.replace(/^\/+/, ""), String(options.baseURL).replace(/\/*$/, "/"));
249
+ else throw new TypeError(`Invalid URL "${input}": use an absolute URL or set baseURL`);
250
+ }
251
+ _appendQuery(url.searchParams, options.query);
252
+ return url.href;
253
+ };
254
+
255
+ /** @param {unknown} body */
256
+ const _isStream = (body) =>
257
+ body !== null && typeof body === "object" && (typeof (/** @type {any} */ (body).pipe) === "function" || typeof (/** @type {any} */ (body).getReader) === "function");
258
+
259
+ /**
260
+ * @param {Response} res
261
+ * @param {HttpOptions["responseType"]} type
262
+ * @param {HttpOptions["onDownloadProgress"]} onProgress
263
+ * @returns {Promise<unknown>}
264
+ */
265
+ const _readBody = async (res, type = "auto", onProgress) => {
266
+ let buffer;
267
+ if (onProgress && res.body) {
268
+ const total = Number(res.headers.get("content-length")) || undefined;
269
+ /** @type {Buffer[]} */
270
+ const chunks = [];
271
+ let loaded = 0;
272
+ for await (const chunk of /** @type {AsyncIterable<Uint8Array>} */ (/** @type {unknown} */ (res.body))) {
273
+ chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
274
+ loaded += chunk.length;
275
+ onProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
276
+ }
277
+ buffer = Buffer.concat(chunks);
278
+ } else {
279
+ buffer = Buffer.from(await res.arrayBuffer());
280
+ }
281
+ if (type === "buffer") return buffer;
282
+ const text = buffer.toString("utf8");
283
+ const contentType = res.headers.get("content-type") ?? "";
284
+ if (type === "json" || (type === "auto" && /[/+]json\b/i.test(contentType))) {
285
+ if (!text) return undefined;
286
+ try {
287
+ return JSON.parse(text);
288
+ } catch (error) {
289
+ if (type === "json") throw new HttpError(`Invalid JSON in response from ${res.url}`, { status: res.status, url: res.url, data: text, cause: error });
290
+ return text;
291
+ }
292
+ }
293
+ if (type === "text" || (type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType))) return text;
294
+ return buffer;
295
+ };
296
+
297
+ /**
298
+ * The delay asked for by a `Retry-After` header, in ms.
299
+ * @param {string | null} value
300
+ * @returns {number | undefined}
301
+ */
302
+ const _retryAfter = (value) => {
303
+ if (!value) return undefined;
304
+ const seconds = Number(value);
305
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
306
+ const date = Date.parse(value);
307
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
308
+ };
309
+
310
+ /**
311
+ * The engine behind every method.
312
+ * @param {string | URL} input
313
+ * @param {HttpOptions} options
314
+ * @returns {Promise<HttpResponse<any>>}
315
+ */
316
+ const _send = async (input, options) => {
317
+ const started = Date.now();
318
+ const method = String(options.method ?? "GET").toUpperCase();
319
+ const signal = options.signal;
320
+ const hooks = options.hooks;
321
+ const retry = typeof options.retry === "object" && options.retry !== null ? options.retry : {};
322
+ const statuses = retry.statuses ?? RETRY_STATUSES;
323
+ const timeoutMs = _timeout(options.timeout);
324
+
325
+ /** @type {Record<string, string>} */
326
+ const headers = { accept: "application/json, text/plain, */*" };
327
+ const given = _headerRecord(options.headers);
328
+ for (const key of Object.keys(given)) {
329
+ const value = given[key];
330
+ if (value === undefined) delete headers[key];
331
+ else headers[key] = value;
332
+ }
333
+ if (options.auth) {
334
+ headers.authorization = "bearer" in options.auth
335
+ ? `Bearer ${options.auth.bearer}`
336
+ : `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64")}`;
337
+ }
338
+ /** @type {HttpBody | undefined} */
339
+ let body = options.body;
340
+ if (options.json !== undefined) {
341
+ body = JSON.stringify(options.json);
342
+ headers["content-type"] ??= "application/json";
343
+ } else if (options.form) {
344
+ body = new URLSearchParams(Object.entries(options.form).map(([k, v]) => /** @type {[string, string]} */ ([k, String(v)])));
345
+ }
346
+
347
+ // A stream can only be read once, so there's nothing to send a second time.
348
+ const attempts = _isStream(body) || options.retry === false
349
+ ? 1
350
+ : typeof options.retry === "number"
351
+ ? Math.max(1, Math.floor(options.retry))
352
+ : retry.attempts ?? ((retry.methods ?? RETRY_METHODS).some((allowed) => allowed.toUpperCase() === method) ? 3 : 1);
353
+
354
+ for (let attempt = 1; ; attempt++) {
355
+ /** @type {HttpRequestConfig} */
356
+ const config = { url: _buildURL(input, options), method, headers: { ...headers }, body, attempt };
357
+ if (hooks?.beforeRequest) for (const hook of hooks.beforeRequest) await hook(config);
358
+ if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
359
+
360
+ const controller = new AbortController();
361
+ let timedOut = false;
362
+ const stopTimer = timeoutMs === undefined ? undefined : setLongTimeout(() => {
363
+ timedOut = true;
364
+ controller.abort();
365
+ }, timeoutMs, true);
366
+ const onAbort = () => controller.abort(signal?.reason);
367
+ signal?.addEventListener("abort", onAbort, { once: true });
368
+ const release = () => {
369
+ stopTimer?.();
370
+ signal?.removeEventListener("abort", onAbort);
371
+ };
372
+
373
+ /** @type {unknown} */
374
+ let failure;
375
+ let retryable = true;
376
+ /** @type {number | undefined} */
377
+ let serverDelay;
378
+ /** @type {Response | undefined} */
379
+ let res;
380
+ /** @type {unknown} */
381
+ let data;
382
+ let streaming = false;
383
+ try {
384
+ const sent = method === "GET" || method === "HEAD" ? undefined : config.body;
385
+ /** @type {any} */
386
+ const init = { method, headers: config.headers, body: sent, signal: controller.signal, redirect: options.redirect ?? "follow" };
387
+ if (_isStream(sent)) init.duplex = "half";
388
+ res = await fetch(config.url, init);
389
+ // An error status is read whole, so the error can show what the server said.
390
+ streaming = options.responseType === "stream" && (res.ok || options.throwHttpErrors === false);
391
+ if (method === "HEAD") {
392
+ data = undefined;
393
+ } else if (streaming) {
394
+ // The headers are in: the time limit was for getting this far, not for reading a body of unknown size.
395
+ stopTimer?.();
396
+ const { Readable } = require("node:stream");
397
+ const stream = res.body ? Readable.fromWeb(/** @type {any} */ (res.body)) : Readable.from([]);
398
+ stream.once("close", release);
399
+ data = stream;
400
+ } else {
401
+ data = await _readBody(res, options.responseType === "stream" ? "auto" : options.responseType, options.onDownloadProgress);
402
+ }
403
+ } catch (error) {
404
+ release();
405
+ if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
406
+ if (timedOut) {
407
+ failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${config.url}`, { timeout: timeoutMs, cause: error });
408
+ } else if (error instanceof HttpError) {
409
+ throw error;
410
+ } else {
411
+ /** @type {any} */
412
+ const cause = error;
413
+ /** @type {string} */
414
+ const reason = cause?.cause?.code ?? cause?.cause?.message ?? cause?.message ?? String(error);
415
+ failure = new HttpError(`Network error (${reason}): ${method} ${config.url}`, { status: 0, url: config.url, method, code: "ERR_NETWORK", cause: error });
416
+ }
417
+ }
418
+
419
+ if (res && failure === undefined) {
420
+ if (!streaming) release();
421
+ /** @type {HttpResponse<any>} */
422
+ let result = {
423
+ status: res.status,
424
+ statusText: res.statusText,
425
+ ok: res.ok,
426
+ headers: Object.fromEntries(res.headers),
427
+ cookies: res.headers.getSetCookie(),
428
+ data,
429
+ url: res.url || config.url,
430
+ duration: Date.now() - started,
431
+ attempts: attempt,
432
+ };
433
+ if (hooks?.afterResponse) {
434
+ for (const hook of hooks.afterResponse) {
435
+ const replaced = await hook(result, config);
436
+ if (replaced) result = replaced;
437
+ }
438
+ }
439
+ if (result.ok || options.throwHttpErrors === false) {
440
+ if (options.schema && result.ok) result.data = options.schema.parse(result.data);
441
+ return result;
442
+ }
443
+ failure = new HttpError(`Request failed with status ${result.status}${result.statusText ? ` ${result.statusText}` : ""}: ${method} ${config.url}`, {
444
+ status: result.status, statusText: result.statusText, url: result.url, method, headers: result.headers, data: result.data,
445
+ });
446
+ retryable = statuses.includes(result.status);
447
+ serverDelay = _retryAfter(res.headers.get("retry-after"));
448
+ }
449
+
450
+ if (!retryable || attempt >= attempts) throw failure;
451
+ const longest = toMs(retry.maxDelay ?? 30_000, "retry.maxDelay");
452
+ let wait = Math.min(serverDelay ?? toMs(retry.delay ?? 300, "retry.delay") * (retry.backoff ?? 2) ** (attempt - 1), longest);
453
+ if (retry.jitter && serverDelay === undefined) wait *= 0.5 + Math.random() / 2;
454
+ if (hooks?.beforeRetry) for (const hook of hooks.beforeRetry) await hook(failure, attempt, wait);
455
+ try {
456
+ await sleep(wait, signal);
457
+ } catch (error) {
458
+ throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal?.reason ?? error });
459
+ }
460
+ }
461
+ };
462
+
463
+ /**
464
+ * @param {unknown} data
465
+ * @param {HttpOptions} options
466
+ * @returns {HttpOptions}
467
+ */
468
+ const _withData = (data, options) => {
469
+ if (data === undefined) return options;
470
+ const plain = data !== null && typeof data === "object" && (Array.isArray(data) || Object.getPrototypeOf(data) === Object.prototype || Object.getPrototypeOf(data) === null);
471
+ return plain || typeof data === "number" || typeof data === "boolean" || data === null ? { ...options, json: data } : { ...options, body: /** @type {HttpBody} */ (data) };
472
+ };
473
+
474
+ /**
475
+ * @param {HttpOptions} defaults
476
+ * @returns {HttpClient}
477
+ */
478
+ const _client = (defaults) => {
479
+ const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
480
+ /** @type {HttpClient} */
481
+ const client = {
482
+ request: (url, options) => _send(url, merged(options)),
483
+ get: (url, options) => _send(url, { ...merged(options), method: "GET" }),
484
+ head: (url, options) => _send(url, { ...merged(options), method: "HEAD" }),
485
+ delete: (url, options) => _send(url, { ...merged(options), method: "DELETE" }),
486
+ post: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "POST" }),
487
+ put: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PUT" }),
488
+ patch: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PATCH" }),
489
+ download: (url, destination, options) => download(url, destination, merged(options)),
490
+ extend: (more) => _client(_mergeOptions(defaults, more)),
491
+ defaults: Object.freeze({ ...defaults }),
492
+ };
493
+ return client;
494
+ };
495
+
496
+ /**
497
+ * Sends a request and resolves with the response, body already read.
498
+ * Throws an `HttpError` for error statuses, a `TimeoutError` when it's too
499
+ * slow and an `AbortError` when cancelled.
500
+ *
501
+ * @example
502
+ * const res = await http.request("https://api.example.com/items/42", {
503
+ * method: "PUT",
504
+ * json: { name: "Lamp" },
505
+ * timeout: "10s",
506
+ * retry: { attempts: 5, statuses: [429, 503] },
507
+ * });
508
+ * res.data; // parsed JSON
509
+ *
510
+ * @template [T=any] The type of `data`, when no `schema` says it.
511
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
512
+ * @param {string | URL} url An absolute URL, or a path when `baseURL` is set.
513
+ * @param {HttpOptionsOf<S>} [options]
514
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
515
+ * @throws {HttpError | TimeoutError | AbortError}
516
+ */
517
+ function request(url, options = {}) {
518
+ return _send(url, options);
519
+ }
520
+
521
+ /**
522
+ * Sends a GET request.
523
+ *
524
+ * @example
525
+ * const { data } = await http.get("https://api.example.com/users", { query: { page: 2, tags: ["a", "b"] } });
526
+ *
527
+ * @template [T=any] The type of `data`, when no `schema` says it.
528
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
529
+ * @param {string | URL} url
530
+ * @param {HttpOptionsOf<S>} [options]
531
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
532
+ */
533
+ function get(url, options = {}) {
534
+ return _send(url, { ...options, method: "GET" });
535
+ }
536
+
537
+ /**
538
+ * Sends a HEAD request: headers only, no body.
539
+ *
540
+ * @example
541
+ * const { headers } = await http.head("https://example.com/big.zip");
542
+ * headers["content-length"];
543
+ *
544
+ * @template [T=undefined]
545
+ * @param {string | URL} url
546
+ * @param {HttpOptions} [options]
547
+ * @returns {Promise<HttpResponse<T>>}
548
+ */
549
+ function head(url, options = {}) {
550
+ return _send(url, { ...options, method: "HEAD" });
551
+ }
552
+
553
+ /**
554
+ * Sends a POST request. Objects, arrays, numbers and booleans go as JSON;
555
+ * strings, Buffers, `FormData`, Blobs and streams go as they are.
556
+ *
557
+ * @example
558
+ * const { data: user } = await http.post("https://api.example.com/users", { name: "Ada" });
559
+ * await http.post(uploadUrl, formData);
560
+ *
561
+ * @template [T=any] The type of `data`, when no `schema` says it.
562
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
563
+ * @param {string | URL} url
564
+ * @param {unknown} [data]
565
+ * @param {HttpOptionsOf<S>} [options]
566
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
567
+ */
568
+ function post(url, data, options = {}) {
569
+ return _send(url, { ..._withData(data, options), method: "POST" });
570
+ }
571
+
572
+ /**
573
+ * Sends a PUT request. The body works like in `post()`.
574
+ *
575
+ * @template [T=any] The type of `data`, when no `schema` says it.
576
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
577
+ * @param {string | URL} url
578
+ * @param {unknown} [data]
579
+ * @param {HttpOptionsOf<S>} [options]
580
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
581
+ */
582
+ function put(url, data, options = {}) {
583
+ return _send(url, { ..._withData(data, options), method: "PUT" });
584
+ }
585
+
586
+ /**
587
+ * Sends a PATCH request. The body works like in `post()`.
588
+ *
589
+ * @template [T=any] The type of `data`, when no `schema` says it.
590
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
591
+ * @param {string | URL} url
592
+ * @param {unknown} [data]
593
+ * @param {HttpOptionsOf<S>} [options]
594
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
595
+ */
596
+ function patch(url, data, options = {}) {
597
+ return _send(url, { ..._withData(data, options), method: "PATCH" });
598
+ }
599
+
600
+ /**
601
+ * Sends a DELETE request.
602
+ *
603
+ * @example
604
+ * await http.delete(`https://api.example.com/users/${id}`);
605
+ *
606
+ * @template [T=any] The type of `data`, when no `schema` says it.
607
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
608
+ * @param {string | URL} url
609
+ * @param {HttpOptionsOf<S>} [options]
610
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
611
+ */
612
+ function del(url, options = {}) {
613
+ return _send(url, { ...options, method: "DELETE" });
614
+ }
615
+
616
+ /**
617
+ * Downloads to a file as a stream, so memory stays flat whatever the size.
618
+ * Folders are created. The data goes to a temporary file that takes the
619
+ * final name once complete, so a failed or interrupted download never
620
+ * leaves half a file where you expect a whole one.
621
+ *
622
+ * `timeout` is how long the server gets to start answering; the transfer
623
+ * itself can take as long as it needs. Use `signal` to put a limit on it.
624
+ *
625
+ * @example
626
+ * await http.download("https://example.com/video.mp4", "./downloads/video.mp4", {
627
+ * onDownloadProgress: ({ percent }) => bar.update(percent ?? 0),
628
+ * });
629
+ *
630
+ * @param {string | URL} url
631
+ * @param {string} destination
632
+ * @param {HttpOptions} [options]
633
+ * @returns {Promise<{ path: string, size: number }>} The absolute path and size in bytes.
634
+ * @throws {HttpError | TimeoutError | AbortError}
635
+ */
636
+ async function download(url, destination, options = {}) {
637
+ const fs = require("node:fs");
638
+ const nodePath = require("node:path");
639
+ const { Transform } = require("node:stream");
640
+ const { pipeline } = require("node:stream/promises");
641
+ const target = nodePath.resolve(destination);
642
+ fs.mkdirSync(nodePath.dirname(target), { recursive: true });
643
+ const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
644
+ const total = Number(res.headers["content-length"]) || undefined;
645
+ const partial = `${target}.${process.pid}-${Date.now().toString(36)}.part`;
646
+ let size = 0;
647
+ const counter = new Transform({
648
+ transform(chunk, _encoding, callback) {
649
+ size += chunk.length;
650
+ options.onDownloadProgress?.({ loaded: size, total, percent: total ? Math.min(100, (size / total) * 100) : undefined });
651
+ callback(null, chunk);
652
+ },
653
+ });
654
+ try {
655
+ await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, fs.createWriteStream(partial), { signal: options.signal });
656
+ fs.renameSync(partial, target);
657
+ } catch (error) {
658
+ fs.rmSync(partial, { force: true });
659
+ if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
660
+ /** @type {any} */
661
+ const cause = error;
662
+ // What went wrong on the disk names a file and stays as it is; the rest is the connection.
663
+ if (typeof cause?.path === "string") throw error;
664
+ throw new HttpError(`Download interrupted (${cause?.cause?.code ?? cause?.code ?? cause?.message ?? String(error)}): ${res.url}`, { status: 0, url: res.url, method: "GET", code: "ERR_NETWORK", cause: error });
665
+ }
666
+ return { path: target, size };
667
+ }
668
+
669
+ /**
670
+ * Creates a client with default options. Options passed to each call are
671
+ * merged on top.
672
+ *
673
+ * @example
674
+ * const github = http.create({
675
+ * baseURL: "https://api.github.com",
676
+ * auth: { bearer: process.env.GITHUB_TOKEN },
677
+ * hooks: { afterResponse: [(res) => nc.debug(res.status, res.url)] },
678
+ * });
679
+ * const { data: repo } = await github.get("/repos/nodejs/node");
680
+ *
681
+ * @param {HttpOptions} [defaults]
682
+ * @returns {HttpClient}
683
+ */
684
+ function create(defaults = {}) {
685
+ return _client(defaults);
686
+ }
687
+
688
+ module.exports = {
689
+ request,
690
+ // not shorthand: Node's ES module export detection stops at a bare `get`
691
+ get: get,
692
+ head,
693
+ post,
694
+ put,
695
+ patch,
696
+ delete: del,
697
+ download,
698
+ create,
699
+ HttpError,
700
+ };