@ix-xs/node-comfort 2.0.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 (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Http.js CHANGED
@@ -12,11 +12,9 @@
12
12
  * const { data: users } = await api.get("users", { query: { page: 2 } });
13
13
  */
14
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");
15
+ const { types } = require("node:util");
19
16
  const { HttpError, TimeoutError, AbortError } = require("./errors.js");
17
+ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
20
18
 
21
19
  /**
22
20
  * A request body.
@@ -29,19 +27,25 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
29
27
  */
30
28
 
31
29
  /**
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
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
34
37
  */
35
38
 
36
39
  /**
37
40
  * Retry settings.
38
41
  * @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`.
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`.
41
44
  * @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.
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.
43
47
  * @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.
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.
45
49
  */
46
50
 
47
51
  /**
@@ -67,15 +71,17 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
67
71
  * @typedef {object} HttpOptions
68
72
  * @property {HttpMethod} [method] Defaults to `"GET"`.
69
73
  * @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.
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.
72
77
  * @property {unknown} [json] Sent as JSON.
73
78
  * @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
74
79
  * @property {HttpBody} [body] Sent as is: string, Buffer, `FormData`, stream...
75
80
  * @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.
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.
78
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.
79
85
  * @property {boolean} [throwHttpErrors] Throw an `HttpError` when the status isn't 2xx. Defaults to `true`.
80
86
  * @property {AbortSignal} [signal] Cancels the request.
81
87
  * @property {HttpHooks} [hooks]
@@ -91,22 +97,35 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
91
97
  * @property {string} statusText
92
98
  * @property {boolean} ok `true` for 2xx statuses.
93
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.
94
101
  * @property {T} data The body, already read (see `responseType`).
95
102
  * @property {string} url The final URL, after redirects.
96
103
  * @property {number} duration In ms, retries included.
97
104
  * @property {number} attempts How many attempts it took.
98
105
  */
99
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
+
100
119
  /**
101
120
  * A client with its own defaults, from `create()`.
102
121
  * @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.
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.
105
124
  * @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.
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.
110
129
  * @property {(url: string | URL, destination: string, options?: HttpOptions) => Promise<{ path: string, size: number }>} download Saves a response to a file.
111
130
  * @property {(defaults: HttpOptions) => HttpClient} extend A new client with these defaults added.
112
131
  * @property {Readonly<HttpOptions>} defaults
@@ -114,32 +133,100 @@ const { HttpError, TimeoutError, AbortError } = require("./errors.js");
114
133
 
115
134
  const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
116
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;
117
140
 
118
- /** @param {number | string | false | undefined} value */
119
- const _ms = (value) => {
141
+ /**
142
+ * @param {number | string | false | undefined} value
143
+ * @returns {number | undefined} `undefined` for no limit.
144
+ */
145
+ const _timeout = (value) => {
120
146
  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;
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;
126
171
  };
127
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
+
128
205
  /**
129
206
  * @param {HttpOptions} base
130
207
  * @param {HttpOptions} extra
131
208
  * @returns {HttpOptions}
132
209
  */
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
- });
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
+ };
143
230
 
144
231
  /**
145
232
  * @param {string | URL} input
@@ -148,40 +235,44 @@ const _mergeOptions = (base, extra) => ({
148
235
  */
149
236
  const _buildURL = (input, options) => {
150
237
  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
- }
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`);
163
250
  }
251
+ _appendQuery(url.searchParams, options.query);
164
252
  return url.href;
165
253
  };
166
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
+
167
259
  /**
168
260
  * @param {Response} res
169
- * @param {HttpOptions} options
261
+ * @param {HttpOptions["responseType"]} type
262
+ * @param {HttpOptions["onDownloadProgress"]} onProgress
170
263
  * @returns {Promise<unknown>}
171
264
  */
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([]);
265
+ const _readBody = async (res, type = "auto", onProgress) => {
175
266
  let buffer;
176
- if (options.onDownloadProgress && res.body) {
267
+ if (onProgress && res.body) {
177
268
  const total = Number(res.headers.get("content-length")) || undefined;
178
269
  /** @type {Buffer[]} */
179
270
  const chunks = [];
180
271
  let loaded = 0;
181
272
  for await (const chunk of /** @type {AsyncIterable<Uint8Array>} */ (/** @type {unknown} */ (res.body))) {
182
- chunks.push(Buffer.from(chunk));
273
+ chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
183
274
  loaded += chunk.length;
184
- options.onDownloadProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
275
+ onProgress({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
185
276
  }
186
277
  buffer = Buffer.concat(chunks);
187
278
  } else {
@@ -199,16 +290,10 @@ const _readBody = async (res, options) => {
199
290
  return text;
200
291
  }
201
292
  }
202
- if (type === "text" || type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType)) return text;
293
+ if (type === "text" || (type === "auto" && (/^text\/|xml|javascript|x-www-form-urlencoded/i.test(contentType) || !contentType))) return text;
203
294
  return buffer;
204
295
  };
205
296
 
206
- /**
207
- * @param {Headers} headers
208
- * @returns {Record<string, string>}
209
- */
210
- const _headers = (headers) => Object.fromEntries(headers.entries());
211
-
212
297
  /**
213
298
  * The delay asked for by a `Retry-After` header, in ms.
214
299
  * @param {string | null} value
@@ -231,21 +316,19 @@ const _retryAfter = (value) => {
231
316
  const _send = async (input, options) => {
232
317
  const started = Date.now();
233
318
  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);
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);
243
324
 
244
325
  /** @type {Record<string, string>} */
245
326
  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;
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;
249
332
  }
250
333
  if (options.auth) {
251
334
  headers.authorization = "bearer" in options.auth
@@ -261,62 +344,120 @@ const _send = async (input, options) => {
261
344
  body = new URLSearchParams(Object.entries(options.form).map(([k, v]) => /** @type {[string, string]} */ ([k, String(v)])));
262
345
  }
263
346
 
264
- let lastError;
265
- for (let attempt = 1; attempt <= attempts; attempt++) {
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++) {
266
355
  /** @type {HttpRequestConfig} */
267
356
  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;
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;
272
376
  /** @type {number | undefined} */
273
377
  let serverDelay;
378
+ /** @type {Response | undefined} */
379
+ let res;
380
+ /** @type {unknown} */
381
+ let data;
382
+ let streaming = false;
274
383
  try {
384
+ const sent = method === "GET" || method === "HEAD" ? undefined : config.body;
275
385
  /** @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;
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);
290
402
  }
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
403
  } 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)) {
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 {
303
411
  /** @type {any} */
304
- const failure = error;
412
+ const cause = error;
305
413
  /** @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;
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 });
310
416
  }
311
417
  }
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 });
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 });
317
459
  }
318
460
  }
319
- throw lastError;
320
461
  };
321
462
 
322
463
  /**
@@ -335,17 +476,17 @@ const _withData = (data, options) => {
335
476
  * @returns {HttpClient}
336
477
  */
337
478
  const _client = (defaults) => {
338
- const merged = (/** @type {HttpOptions} */ options) => _mergeOptions(defaults, options ?? {});
479
+ const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
339
480
  /** @type {HttpClient} */
340
481
  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 ?? {})),
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)),
349
490
  extend: (more) => _client(_mergeOptions(defaults, more)),
350
491
  defaults: Object.freeze({ ...defaults }),
351
492
  };
@@ -366,10 +507,11 @@ const _client = (defaults) => {
366
507
  * });
367
508
  * res.data; // parsed JSON
368
509
  *
369
- * @template [T=any]
510
+ * @template [T=any] The type of `data`, when no `schema` says it.
511
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
370
512
  * @param {string | URL} url An absolute URL, or a path when `baseURL` is set.
371
- * @param {HttpOptions} [options]
372
- * @returns {Promise<HttpResponse<T>>}
513
+ * @param {HttpOptionsOf<S>} [options]
514
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
373
515
  * @throws {HttpError | TimeoutError | AbortError}
374
516
  */
375
517
  function request(url, options = {}) {
@@ -382,10 +524,11 @@ function request(url, options = {}) {
382
524
  * @example
383
525
  * const { data } = await http.get("https://api.example.com/users", { query: { page: 2, tags: ["a", "b"] } });
384
526
  *
385
- * @template [T=any]
527
+ * @template [T=any] The type of `data`, when no `schema` says it.
528
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
386
529
  * @param {string | URL} url
387
- * @param {HttpOptions} [options]
388
- * @returns {Promise<HttpResponse<T>>}
530
+ * @param {HttpOptionsOf<S>} [options]
531
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
389
532
  */
390
533
  function get(url, options = {}) {
391
534
  return _send(url, { ...options, method: "GET" });
@@ -415,11 +558,12 @@ function head(url, options = {}) {
415
558
  * const { data: user } = await http.post("https://api.example.com/users", { name: "Ada" });
416
559
  * await http.post(uploadUrl, formData);
417
560
  *
418
- * @template [T=any]
561
+ * @template [T=any] The type of `data`, when no `schema` says it.
562
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
419
563
  * @param {string | URL} url
420
564
  * @param {unknown} [data]
421
- * @param {HttpOptions} [options]
422
- * @returns {Promise<HttpResponse<T>>}
565
+ * @param {HttpOptionsOf<S>} [options]
566
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
423
567
  */
424
568
  function post(url, data, options = {}) {
425
569
  return _send(url, { ..._withData(data, options), method: "POST" });
@@ -428,11 +572,12 @@ function post(url, data, options = {}) {
428
572
  /**
429
573
  * Sends a PUT request. The body works like in `post()`.
430
574
  *
431
- * @template [T=any]
575
+ * @template [T=any] The type of `data`, when no `schema` says it.
576
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
432
577
  * @param {string | URL} url
433
578
  * @param {unknown} [data]
434
- * @param {HttpOptions} [options]
435
- * @returns {Promise<HttpResponse<T>>}
579
+ * @param {HttpOptionsOf<S>} [options]
580
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
436
581
  */
437
582
  function put(url, data, options = {}) {
438
583
  return _send(url, { ..._withData(data, options), method: "PUT" });
@@ -441,11 +586,12 @@ function put(url, data, options = {}) {
441
586
  /**
442
587
  * Sends a PATCH request. The body works like in `post()`.
443
588
  *
444
- * @template [T=any]
589
+ * @template [T=any] The type of `data`, when no `schema` says it.
590
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
445
591
  * @param {string | URL} url
446
592
  * @param {unknown} [data]
447
- * @param {HttpOptions} [options]
448
- * @returns {Promise<HttpResponse<T>>}
593
+ * @param {HttpOptionsOf<S>} [options]
594
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
449
595
  */
450
596
  function patch(url, data, options = {}) {
451
597
  return _send(url, { ..._withData(data, options), method: "PATCH" });
@@ -457,10 +603,11 @@ function patch(url, data, options = {}) {
457
603
  * @example
458
604
  * await http.delete(`https://api.example.com/users/${id}`);
459
605
  *
460
- * @template [T=any]
606
+ * @template [T=any] The type of `data`, when no `schema` says it.
607
+ * @template {ResponseSchema} [S={ parse(data: unknown): T }]
461
608
  * @param {string | URL} url
462
- * @param {HttpOptions} [options]
463
- * @returns {Promise<HttpResponse<T>>}
609
+ * @param {HttpOptionsOf<S>} [options]
610
+ * @returns {Promise<HttpResponse<ReturnType<S["parse"]>>>}
464
611
  */
465
612
  function del(url, options = {}) {
466
613
  return _send(url, { ...options, method: "DELETE" });
@@ -468,7 +615,12 @@ function del(url, options = {}) {
468
615
 
469
616
  /**
470
617
  * 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.
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.
472
624
  *
473
625
  * @example
474
626
  * await http.download("https://example.com/video.mp4", "./downloads/video.mp4", {
@@ -482,10 +634,15 @@ function del(url, options = {}) {
482
634
  * @throws {HttpError | TimeoutError | AbortError}
483
635
  */
484
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");
485
641
  const target = nodePath.resolve(destination);
486
642
  fs.mkdirSync(nodePath.dirname(target), { recursive: true });
487
- const res = await _send(url, { ...options, responseType: "stream", onDownloadProgress: undefined, timeout: options.timeout ?? false });
643
+ const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
488
644
  const total = Number(res.headers["content-length"]) || undefined;
645
+ const partial = `${target}.${process.pid}-${Date.now().toString(36)}.part`;
489
646
  let size = 0;
490
647
  const counter = new Transform({
491
648
  transform(chunk, _encoding, callback) {
@@ -495,11 +652,16 @@ async function download(url, destination, options = {}) {
495
652
  },
496
653
  });
497
654
  try {
498
- await pipeline(/** @type {Readable} */ (res.data), counter, fs.createWriteStream(target), { signal: options.signal });
655
+ await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, fs.createWriteStream(partial), { signal: options.signal });
656
+ fs.renameSync(partial, target);
499
657
  } catch (error) {
500
- fs.rmSync(target, { force: true });
658
+ fs.rmSync(partial, { force: true });
501
659
  if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
502
- throw 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 });
503
665
  }
504
666
  return { path: target, size };
505
667
  }