@ix-xs/node-comfort 2.1.1 → 2.2.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 (68) hide show
  1. package/CHANGELOG.md +135 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/Arr.js +13 -5
  5. package/src/Checker.js +56 -8
  6. package/src/Cli.js +60 -18
  7. package/src/Color.js +32 -6
  8. package/src/Crypto.js +429 -78
  9. package/src/Emitter.js +9 -9
  10. package/src/Env.js +90 -8
  11. package/src/FS.js +366 -56
  12. package/src/Http.js +263 -72
  13. package/src/Id.js +53 -15
  14. package/src/Logger.js +100 -22
  15. package/src/Num.js +2 -1
  16. package/src/Obj.js +225 -36
  17. package/src/SQLite.js +154 -97
  18. package/src/Schema.js +281 -40
  19. package/src/Str.js +77 -29
  20. package/src/Sys.js +123 -22
  21. package/src/Time.js +96 -22
  22. package/src/Utils.js +18 -3
  23. package/src/errors.js +75 -4
  24. package/src/internal/shared.js +68 -14
  25. package/src/internal/terminal.js +49 -0
  26. package/types/index.d.ts +28 -5
  27. package/types/src/Arr.d.ts +4 -2
  28. package/types/src/Arr.d.ts.map +1 -1
  29. package/types/src/Checker.d.ts +29 -3
  30. package/types/src/Checker.d.ts.map +1 -1
  31. package/types/src/Cli.d.ts +6 -4
  32. package/types/src/Cli.d.ts.map +1 -1
  33. package/types/src/Color.d.ts +2 -2
  34. package/types/src/Color.d.ts.map +1 -1
  35. package/types/src/Crypto.d.ts +126 -28
  36. package/types/src/Crypto.d.ts.map +1 -1
  37. package/types/src/Emitter.d.ts +6 -6
  38. package/types/src/Emitter.d.ts.map +1 -1
  39. package/types/src/Env.d.ts +19 -1
  40. package/types/src/Env.d.ts.map +1 -1
  41. package/types/src/FS.d.ts +27 -20
  42. package/types/src/FS.d.ts.map +1 -1
  43. package/types/src/Http.d.ts +42 -5
  44. package/types/src/Http.d.ts.map +1 -1
  45. package/types/src/Id.d.ts.map +1 -1
  46. package/types/src/Logger.d.ts +11 -3
  47. package/types/src/Logger.d.ts.map +1 -1
  48. package/types/src/Num.d.ts.map +1 -1
  49. package/types/src/Obj.d.ts +15 -2
  50. package/types/src/Obj.d.ts.map +1 -1
  51. package/types/src/SQLite.d.ts +104 -12
  52. package/types/src/SQLite.d.ts.map +1 -1
  53. package/types/src/Schema.d.ts +61 -5
  54. package/types/src/Schema.d.ts.map +1 -1
  55. package/types/src/Str.d.ts +32 -7
  56. package/types/src/Str.d.ts.map +1 -1
  57. package/types/src/Sys.d.ts +42 -9
  58. package/types/src/Sys.d.ts.map +1 -1
  59. package/types/src/Time.d.ts +10 -5
  60. package/types/src/Time.d.ts.map +1 -1
  61. package/types/src/Utils.d.ts +16 -6
  62. package/types/src/Utils.d.ts.map +1 -1
  63. package/types/src/errors.d.ts +6 -2
  64. package/types/src/errors.d.ts.map +1 -1
  65. package/types/src/internal/shared.d.ts +7 -0
  66. package/types/src/internal/shared.d.ts.map +1 -1
  67. package/types/src/internal/terminal.d.ts +13 -0
  68. package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Http.js CHANGED
@@ -70,9 +70,10 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
70
70
  * Request options.
71
71
  * @typedef {object} HttpOptions
72
72
  * @property {HttpMethod} [method] Defaults to `"GET"`.
73
- * @property {string} [baseURL] Prefix for relative URLs.
73
+ * @property {string} [baseURL] Prefix for relative URLs. It's a boundary: a path that would leave it, like `"../admin"`, `"//other.host"` or an absolute URL to another place, throws a `TypeError` (see `allowAbsoluteUrls`).
74
+ * @property {boolean} [allowAbsoluteUrls] With a `baseURL`, let an absolute URL go elsewhere: `api.get("https://cdn.example.com/x", { allowAbsoluteUrls: true })`. The client's own `auth`, `headers` and `query` still stay with the base origin, only those of the call go there. Defaults to `false`, so a URL built from outside input can't take the client's credentials to another host.
74
75
  * @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 {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, and `"."` or `".."` throws a `TypeError`, since they would move up the path.
76
77
  * @property {QueryParams | URLSearchParams} [query] Added to the URL's own query string, and to the client's default query.
77
78
  * @property {unknown} [json] Sent as JSON.
78
79
  * @property {Record<string, string | number | boolean>} [form] Sent as a URL-encoded form.
@@ -86,7 +87,9 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
86
87
  * @property {AbortSignal} [signal] Cancels the request.
87
88
  * @property {HttpHooks} [hooks]
88
89
  * @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
+ * @property {number | string} [maxResponseSize] Largest body read into memory, in bytes or like `"50MB"`, counted once decompressed. Past it, the request is cancelled and an `HttpError` with the code `ERR_RESPONSE_TOO_LARGE` is thrown, so a huge or zip bomb answer can't fill the memory. Defaults to `"100MB"`, `Infinity` turns it off. A stream (`responseType: "stream"`) and the file of `download()` aren't held in memory, so they aren't limited.
91
+ * @property {"follow" | "error" | "manual"} [redirect] `"follow"` (the default) follows up to 20 redirects, `"manual"` returns the 3xx response as it is, `"error"` throws an `HttpError` with the code `ERR_REDIRECT`. When a redirect goes to another origin, `authorization`, `cookie`, `proxy-authorization` and the `sensitiveHeaders` are left behind.
92
+ * @property {string[]} [sensitiveHeaders] Headers dropped when a redirect goes to another origin, on top of `authorization`, `cookie` and `proxy-authorization`. Names are case-insensitive. Defaults to `["x-api-key", "x-auth-token", "api-key"]`; a list you give replaces it, so add these to it if you use them.
90
93
  */
91
94
 
92
95
  /**
@@ -134,7 +137,19 @@ const { toMs, setLongTimeout, sleep } = require("./internal/shared.js");
134
137
  const RETRY_STATUSES = [408, 413, 429, 500, 502, 503, 504];
135
138
  const RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
136
139
  const DEFAULT_TIMEOUT = 30_000;
140
+ const DEFAULT_MAX_RESPONSE_SIZE = 100 * 1024 * 1024;
141
+ // What an error body gets when a stream was asked for: enough to tell what went wrong.
142
+ const ERROR_BODY_LIMIT = 1024 * 1024;
143
+ const MAX_REDIRECTS = 20;
144
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
145
+ // Always left behind when a redirect changes origin, whatever `sensitiveHeaders` says.
146
+ const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
147
+ const SENSITIVE_HEADERS = ["x-api-key", "x-auth-token", "api-key"];
148
+ // What describes a body, dropped with it when a redirect turns the request into a GET.
149
+ const BODY_HEADERS = ["content-type", "content-length", "content-encoding", "content-language", "content-location"];
137
150
  const ABSOLUTE = /^[a-z][a-z\d+.-]*:/i;
151
+ // The URL parser skips these at the start and reads "\" as "/", so " //host" or "\\host" would leave the base URL.
152
+ const LEADING = /^[\s\x00-\x1f/\\]+/;
138
153
  // ":name" right after a slash, which leaves "http://host:3000" alone
139
154
  const PARAM = /(?<=\/):([A-Za-z_]\w*)/g;
140
155
 
@@ -194,6 +209,13 @@ const _appendQuery = (params, query) => {
194
209
  }
195
210
  };
196
211
 
212
+ /**
213
+ * A URL for an error message: without its query, which can hold a key.
214
+ * @param {unknown} url
215
+ * @returns {string}
216
+ */
217
+ const _shown = (url) => String(url).replace(/[?#][\s\S]*$/, "");
218
+
197
219
  /**
198
220
  * @template T
199
221
  * @param {T[] | undefined} a
@@ -229,12 +251,25 @@ const _mergeOptions = (base, extra) => {
229
251
  };
230
252
 
231
253
  /**
254
+ * The URL a request goes to, query not added yet. With a `baseURL`, it has
255
+ * to stay under it: same origin, same path prefix. Only an absolute URL
256
+ * passed with `allowAbsoluteUrls` may go elsewhere, and then `foreign` says
257
+ * it left the base origin.
232
258
  * @param {string | URL} input
233
259
  * @param {HttpOptions} options
234
- * @returns {string}
235
- */
236
- const _buildURL = (input, options) => {
260
+ * @returns {{ url: URL, foreign: boolean }}
261
+ */
262
+ const _resolve = (input, options) => {
263
+ let base;
264
+ if (options.baseURL) {
265
+ // One slash at the end, so that relative paths go under it.
266
+ const text = String(options.baseURL);
267
+ let end = text.length;
268
+ while (end > 0 && text[end - 1] === "/") end--;
269
+ base = new URL(`${text.slice(0, end)}/`);
270
+ }
237
271
  let url;
272
+ let absolute = true;
238
273
  if (typeof input !== "string") {
239
274
  url = new URL(String(input));
240
275
  } else {
@@ -242,42 +277,126 @@ const _buildURL = (input, options) => {
242
277
  const path = !params ? input : input.replace(PARAM, (_, name) => {
243
278
  const value = Object.hasOwn(params, name) ? params[name] : undefined;
244
279
  if (value === undefined || value === null) throw new TypeError(`Missing URL parameter "${name}" for ${input}`);
245
- return encodeURIComponent(String(value));
280
+ const text = String(value);
281
+ // Encoding can't help: the URL parser reads "%2e%2e" as "..".
282
+ if (text === "." || text === "..") throw new TypeError(`URL parameter "${name}" can't be "${text}" for ${input}: it would move up the path`);
283
+ return encodeURIComponent(text);
246
284
  });
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`);
285
+ absolute = ABSOLUTE.test(path);
286
+ if (!base) {
287
+ if (!absolute) throw new TypeError(`Invalid URL "${input}": use an absolute URL or set baseURL`);
288
+ url = new URL(path);
289
+ } else {
290
+ url = absolute && options.allowAbsoluteUrls ? new URL(path) : new URL(path.replace(LEADING, ""), base);
291
+ }
250
292
  }
251
- _appendQuery(url.searchParams, options.query);
252
- return url.href;
293
+ if (!base) return { url, foreign: false };
294
+ // The base path ends with "/", so the prefix stops at a segment: "/v1/" takes "/v1" and "/v1/x", not "/v10".
295
+ if (url.origin === base.origin && (url.pathname.startsWith(base.pathname) || `${url.pathname}/` === base.pathname)) return { url, foreign: false };
296
+ if (absolute && options.allowAbsoluteUrls) return { url, foreign: url.origin !== base.origin };
297
+ throw new TypeError(`URL "${String(input)}" leaves the base URL ${base.href}: pass allowAbsoluteUrls: true to send an absolute URL elsewhere on purpose`);
298
+ };
299
+
300
+ /**
301
+ * @param {number | string | undefined} value
302
+ * @returns {number}
303
+ */
304
+ const _maxSize = (value) => {
305
+ if (value === undefined) return DEFAULT_MAX_RESPONSE_SIZE;
306
+ const bytes = typeof value === "number" ? value : require("./Num.js").parseBytes(value);
307
+ if (!(bytes >= 0)) throw new TypeError(`Invalid maxResponseSize: ${String(value)}`);
308
+ return bytes;
309
+ };
310
+
311
+ /**
312
+ * The headers of a request, from its options, with lower-cased names.
313
+ * @param {HttpOptions} options
314
+ * @returns {Record<string, string>}
315
+ */
316
+ const _headersOf = (options) => {
317
+ /** @type {Record<string, string>} */
318
+ const headers = { accept: "application/json, text/plain, */*" };
319
+ const given = _headerRecord(options.headers);
320
+ for (const key of Object.keys(given)) {
321
+ const value = given[key];
322
+ if (value === undefined) delete headers[key];
323
+ else headers[key] = value;
324
+ }
325
+ if (options.auth) {
326
+ headers.authorization = "bearer" in options.auth
327
+ ? `Bearer ${options.auth.bearer}`
328
+ : `Basic ${Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64")}`;
329
+ }
330
+ return headers;
331
+ };
332
+
333
+ /**
334
+ * @param {Record<string, string>} headers Changed in place.
335
+ * @param {Iterable<string>} names Lower-cased.
336
+ */
337
+ const _dropHeaders = (headers, names) => {
338
+ const drop = new Set(names);
339
+ // A hook may have set a header with capitals in its name.
340
+ for (const key of Object.keys(headers)) if (drop.has(key.toLowerCase())) delete headers[key];
253
341
  };
254
342
 
255
343
  /** @param {unknown} body */
256
344
  const _isStream = (body) =>
257
345
  body !== null && typeof body === "object" && (typeof (/** @type {any} */ (body).pipe) === "function" || typeof (/** @type {any} */ (body).getReader) === "function");
258
346
 
347
+ /**
348
+ * How `_readBody()` reads.
349
+ * @typedef {object} _ReadSettings
350
+ * @property {HttpOptions["responseType"]} type
351
+ * @property {HttpOptions["onDownloadProgress"]} onProgress
352
+ * @property {number} limit Most bytes kept, once decompressed.
353
+ * @property {boolean} truncate Past the limit, keep the start instead of throwing.
354
+ * @property {string} method For the error message.
355
+ * @property {() => void} abort Cancels the request.
356
+ */
357
+
259
358
  /**
260
359
  * @param {Response} res
261
- * @param {HttpOptions["responseType"]} type
262
- * @param {HttpOptions["onDownloadProgress"]} onProgress
360
+ * @param {_ReadSettings} settings
263
361
  * @returns {Promise<unknown>}
264
362
  */
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 });
363
+ const _readBody = async (res, { type = "auto", onProgress, limit, truncate, method, abort }) => {
364
+ const total = Number(res.headers.get("content-length")) || undefined;
365
+ const tooLarge = () => {
366
+ abort();
367
+ return new HttpError(`Response larger than the ${limit} bytes allowed by maxResponseSize: ${method} ${res.url}`, {
368
+ status: res.status, statusText: res.statusText, url: res.url, method, headers: Object.fromEntries(res.headers), code: "ERR_RESPONSE_TOO_LARGE",
369
+ });
370
+ };
371
+ if (!truncate && total !== undefined && total > limit) throw tooLarge();
372
+ /** @type {Buffer[]} */
373
+ const chunks = [];
374
+ let loaded = 0;
375
+ if (res.body) {
376
+ // Read piece by piece and count what comes out of the decompression, so a zip bomb stops at the limit.
377
+ const reader = res.body.getReader();
378
+ try {
379
+ for (;;) {
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ if (loaded + value.byteLength > limit) {
383
+ if (!truncate) throw tooLarge();
384
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, limit - loaded));
385
+ loaded = limit;
386
+ reader.cancel().catch(() => {});
387
+ break;
388
+ }
389
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
390
+ loaded += value.byteLength;
391
+ onProgress?.({ loaded, total, percent: total ? Math.min(100, (loaded / total) * 100) : undefined });
392
+ }
393
+ } catch (error) {
394
+ // A callback that throws or a failed read must not leave the connection open.
395
+ reader.cancel().catch(() => {});
396
+ throw error;
276
397
  }
277
- buffer = Buffer.concat(chunks);
278
- } else {
279
- buffer = Buffer.from(await res.arrayBuffer());
280
398
  }
399
+ const buffer = Buffer.concat(chunks, loaded);
281
400
  if (type === "buffer") return buffer;
282
401
  const text = buffer.toString("utf8");
283
402
  const contentType = res.headers.get("content-type") ?? "";
@@ -307,13 +426,60 @@ const _retryAfter = (value) => {
307
426
  return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
308
427
  };
309
428
 
429
+ /**
430
+ * Sends one attempt and follows its redirects, like `fetch` would, but
431
+ * without taking secret headers to another origin.
432
+ * @param {HttpRequestConfig} config
433
+ * @param {{ method: string, body: HttpBody | undefined, redirect: NonNullable<HttpOptions["redirect"]>, sensitive: string[], signal: AbortSignal }} settings
434
+ * @returns {Promise<Response>}
435
+ */
436
+ const _follow = async (config, { method, body, redirect, sensitive, signal }) => {
437
+ let url = config.url;
438
+ const headers = { ...config.headers };
439
+ for (let hops = 0; ; hops++) {
440
+ /** @type {any} */
441
+ const init = { method, headers, body, signal, redirect: "manual" };
442
+ if (_isStream(body)) init.duplex = "half";
443
+ const res = await fetch(url, init);
444
+ const location = redirect !== "manual" && REDIRECT_STATUSES.has(res.status) ? res.headers.get("location") : null;
445
+ if (location === null) return res;
446
+ res.body?.cancel().catch(() => {});
447
+ /** @param {string} why */
448
+ const fail = (why) => new HttpError(`${why}: ${config.method} ${config.url}`, {
449
+ status: res.status, statusText: res.statusText, url, method: config.method, headers: Object.fromEntries(res.headers), code: hops >= MAX_REDIRECTS ? "ERR_TOO_MANY_REDIRECTS" : "ERR_REDIRECT",
450
+ });
451
+ if (redirect === "error") throw fail(`Redirected with status ${res.status} while redirect is "error"`);
452
+ if (hops >= MAX_REDIRECTS) throw fail(`More than ${MAX_REDIRECTS} redirects`);
453
+ /** @type {URL} */
454
+ let next;
455
+ try {
456
+ next = new URL(location, url);
457
+ } catch {
458
+ throw fail(`Redirected to an invalid URL "${location}"`);
459
+ }
460
+ if (next.protocol !== "http:" && next.protocol !== "https:") throw fail(`Redirected to a ${next.protocol} URL`);
461
+ if (((res.status === 301 || res.status === 302) && method === "POST") || (res.status === 303 && method !== "GET" && method !== "HEAD")) {
462
+ // What fetch does: the redirect is fetched with GET, without the body.
463
+ method = "GET";
464
+ body = undefined;
465
+ _dropHeaders(headers, BODY_HEADERS);
466
+ } else if (_isStream(body)) {
467
+ throw fail(`Can't follow the ${res.status} redirect: the body is a stream, already sent`);
468
+ }
469
+ // Another origin (an https to http downgrade is one too) gets no credentials.
470
+ if (next.origin !== new URL(url).origin) _dropHeaders(headers, sensitive);
471
+ url = next.href;
472
+ }
473
+ };
474
+
310
475
  /**
311
476
  * The engine behind every method.
312
477
  * @param {string | URL} input
313
- * @param {HttpOptions} options
478
+ * @param {HttpOptions} options Everything, the client's defaults included.
479
+ * @param {HttpOptions} [own] Only what the call gave, when it comes from a client.
314
480
  * @returns {Promise<HttpResponse<any>>}
315
481
  */
316
- const _send = async (input, options) => {
482
+ const _send = async (input, options, own) => {
317
483
  const started = Date.now();
318
484
  const method = String(options.method ?? "GET").toUpperCase();
319
485
  const signal = options.signal;
@@ -321,20 +487,16 @@ const _send = async (input, options) => {
321
487
  const retry = typeof options.retry === "object" && options.retry !== null ? options.retry : {};
322
488
  const statuses = retry.statuses ?? RETRY_STATUSES;
323
489
  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
- }
490
+ const maxSize = _maxSize(options.maxResponseSize);
491
+ const redirect = options.redirect ?? "follow";
492
+ const sensitive = [...CREDENTIAL_HEADERS, ...(options.sensitiveHeaders ?? SENSITIVE_HEADERS).map((name) => String(name).toLowerCase())];
493
+
494
+ const { url: target, foreign } = _resolve(input, options);
495
+ // Away from the base origin, the client's credentials, headers and query stay home: only the call's go.
496
+ const scoped = foreign && own ? own : options;
497
+ _appendQuery(target.searchParams, scoped.query);
498
+ const href = target.href;
499
+ const headers = _headersOf(scoped);
338
500
  /** @type {HttpBody | undefined} */
339
501
  let body = options.body;
340
502
  if (options.json !== undefined) {
@@ -353,9 +515,9 @@ const _send = async (input, options) => {
353
515
 
354
516
  for (let attempt = 1; ; attempt++) {
355
517
  /** @type {HttpRequestConfig} */
356
- const config = { url: _buildURL(input, options), method, headers: { ...headers }, body, attempt };
518
+ const config = { url: href, method, headers: { ...headers }, body, attempt };
357
519
  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 });
520
+ if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal.reason });
359
521
 
360
522
  const controller = new AbortController();
361
523
  let timedOut = false;
@@ -381,12 +543,11 @@ const _send = async (input, options) => {
381
543
  let data;
382
544
  let streaming = false;
383
545
  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.
546
+ res = await _follow(config, {
547
+ method, redirect, sensitive, signal: controller.signal,
548
+ body: method === "GET" || method === "HEAD" ? undefined : config.body,
549
+ });
550
+ // An error status is read, so the error can show what the server said.
390
551
  streaming = options.responseType === "stream" && (res.ok || options.throwHttpErrors === false);
391
552
  if (method === "HEAD") {
392
553
  data = undefined;
@@ -398,13 +559,22 @@ const _send = async (input, options) => {
398
559
  stream.once("close", release);
399
560
  data = stream;
400
561
  } else {
401
- data = await _readBody(res, options.responseType === "stream" ? "auto" : options.responseType, options.onDownloadProgress);
562
+ // An error answer to a stream request: only its start, the rest could be any size.
563
+ const errorOnly = options.responseType === "stream";
564
+ data = await _readBody(res, {
565
+ type: errorOnly ? "auto" : options.responseType,
566
+ onProgress: options.onDownloadProgress,
567
+ limit: errorOnly ? Math.min(ERROR_BODY_LIMIT, maxSize) : maxSize,
568
+ truncate: errorOnly,
569
+ method,
570
+ abort: () => controller.abort(),
571
+ });
402
572
  }
403
573
  } catch (error) {
404
574
  release();
405
- if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal.reason });
575
+ if (signal?.aborted) throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal.reason });
406
576
  if (timedOut) {
407
- failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${config.url}`, { timeout: timeoutMs, cause: error });
577
+ failure = new TimeoutError(`Request timed out after ${timeoutMs}ms: ${method} ${_shown(config.url)}`, { timeout: timeoutMs, cause: error });
408
578
  } else if (error instanceof HttpError) {
409
579
  throw error;
410
580
  } else {
@@ -455,7 +625,7 @@ const _send = async (input, options) => {
455
625
  try {
456
626
  await sleep(wait, signal);
457
627
  } catch (error) {
458
- throw new AbortError(`Request aborted: ${method} ${config.url}`, { cause: signal?.reason ?? error });
628
+ throw new AbortError(`Request aborted: ${method} ${_shown(config.url)}`, { cause: signal?.reason ?? error });
459
629
  }
460
630
  }
461
631
  };
@@ -479,14 +649,15 @@ const _client = (defaults) => {
479
649
  const merged = (/** @type {HttpOptions | undefined} */ options) => (options ? _mergeOptions(defaults, options) : defaults);
480
650
  /** @type {HttpClient} */
481
651
  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)),
652
+ // The call's own options go along too: they alone may leave the base origin.
653
+ request: (url, options) => _send(url, merged(options), options ?? {}),
654
+ get: (url, options) => _send(url, { ...merged(options), method: "GET" }, options ?? {}),
655
+ head: (url, options) => _send(url, { ...merged(options), method: "HEAD" }, options ?? {}),
656
+ delete: (url, options) => _send(url, { ...merged(options), method: "DELETE" }, options ?? {}),
657
+ post: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "POST" }, options ?? {}),
658
+ put: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PUT" }, options ?? {}),
659
+ patch: (url, data, options) => _send(url, { ..._withData(data, merged(options)), method: "PATCH" }, options ?? {}),
660
+ download: (url, destination, options) => _download(url, destination, merged(options), options ?? {}),
490
661
  extend: (more) => _client(_mergeOptions(defaults, more)),
491
662
  defaults: Object.freeze({ ...defaults }),
492
663
  };
@@ -617,7 +788,9 @@ function del(url, options = {}) {
617
788
  * Downloads to a file as a stream, so memory stays flat whatever the size.
618
789
  * Folders are created. The data goes to a temporary file that takes the
619
790
  * final name once complete, so a failed or interrupted download never
620
- * leaves half a file where you expect a whole one.
791
+ * leaves half a file where you expect a whole one. That temporary file has
792
+ * a random name and is only readable and writable by you (mode `0o600`),
793
+ * which the final file keeps. `maxResponseSize` doesn't apply here.
621
794
  *
622
795
  * `timeout` is how long the server gets to start answering; the transfer
623
796
  * itself can take as long as it needs. Use `signal` to put a limit on it.
@@ -633,16 +806,28 @@ function del(url, options = {}) {
633
806
  * @returns {Promise<{ path: string, size: number }>} The absolute path and size in bytes.
634
807
  * @throws {HttpError | TimeoutError | AbortError}
635
808
  */
636
- async function download(url, destination, options = {}) {
809
+ function download(url, destination, options = {}) {
810
+ return _download(url, destination, options);
811
+ }
812
+
813
+ /**
814
+ * @param {string | URL} url
815
+ * @param {string} destination
816
+ * @param {HttpOptions} options
817
+ * @param {HttpOptions} [own] Only what the call gave, when it comes from a client.
818
+ * @returns {Promise<{ path: string, size: number }>}
819
+ */
820
+ const _download = async (url, destination, options, own) => {
637
821
  const fs = require("node:fs");
638
822
  const nodePath = require("node:path");
639
823
  const { Transform } = require("node:stream");
640
824
  const { pipeline } = require("node:stream/promises");
641
825
  const target = nodePath.resolve(destination);
642
826
  fs.mkdirSync(nodePath.dirname(target), { recursive: true });
643
- const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined });
827
+ const res = await _send(url, { ...options, responseType: "stream", throwHttpErrors: true, onDownloadProgress: undefined, schema: undefined }, own);
644
828
  const total = Number(res.headers["content-length"]) || undefined;
645
- const partial = `${target}.${process.pid}-${Date.now().toString(36)}.part`;
829
+ // A name nobody can guess, created by us alone ("wx"): a link planted there can't make us write elsewhere.
830
+ const partial = `${target}.${require("node:crypto").randomBytes(8).toString("hex")}.part`;
646
831
  let size = 0;
647
832
  const counter = new Transform({
648
833
  transform(chunk, _encoding, callback) {
@@ -651,12 +836,18 @@ async function download(url, destination, options = {}) {
651
836
  callback(null, chunk);
652
837
  },
653
838
  });
839
+ const file = fs.createWriteStream(partial, { flags: "wx", mode: 0o600 });
840
+ let created = false;
841
+ file.once("open", () => {
842
+ created = true;
843
+ });
654
844
  try {
655
- await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, fs.createWriteStream(partial), { signal: options.signal });
845
+ await pipeline(/** @type {import("node:stream").Readable} */ (res.data), counter, file, { signal: options.signal });
656
846
  fs.renameSync(partial, target);
657
847
  } catch (error) {
658
- fs.rmSync(partial, { force: true });
659
- if (options.signal?.aborted) throw new AbortError(`Download aborted: ${String(url)}`, { cause: error });
848
+ // A file that was already there isn't ours to remove.
849
+ if (created) fs.rmSync(partial, { force: true });
850
+ if (options.signal?.aborted) throw new AbortError(`Download aborted: ${_shown(url)}`, { cause: error });
660
851
  /** @type {any} */
661
852
  const cause = error;
662
853
  // What went wrong on the disk names a file and stays as it is; the rest is the connection.
@@ -664,7 +855,7 @@ async function download(url, destination, options = {}) {
664
855
  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
856
  }
666
857
  return { path: target, size };
667
- }
858
+ };
668
859
 
669
860
  /**
670
861
  * Creates a client with default options. Options passed to each call are
package/src/Id.js CHANGED
@@ -77,6 +77,44 @@ const POOL_SIZE = 4096;
77
77
  /** @type {Buffer | undefined} */
78
78
  let _pool;
79
79
  let _poolAt = POOL_SIZE;
80
+ // `process` is a getter on the global object: a local copy is faster to read.
81
+ const _process = process;
82
+ // The process that filled the pool. Another one (a fork, or a process started
83
+ // from a startup snapshot) must not hand out the same bytes again.
84
+ let _poolPid = -1;
85
+
86
+ // State of the generators that count up within a process.
87
+ let _v7LastMs = 0;
88
+ let _v7Counter = 0;
89
+ let _v7Head = "";
90
+ let _ulidLastMs = -1;
91
+ let _ulidHead = "";
92
+ const _ulidRandom = new Uint8Array(16);
93
+ let _oidProcess = "";
94
+ let _oidCounter = 0;
95
+
96
+ /** Forgets the pool and the generator state, as in a new process. */
97
+ const _reset = () => {
98
+ _poolAt = POOL_SIZE;
99
+ _poolPid = -1;
100
+ _v7LastMs = 0;
101
+ _v7Counter = 0;
102
+ _v7Head = "";
103
+ _ulidLastMs = -1;
104
+ _ulidHead = "";
105
+ _oidProcess = "";
106
+ _oidCounter = 0;
107
+ };
108
+
109
+ /** Refills the pool, and starts over first when the process changed. */
110
+ const _refill = () => {
111
+ if (_poolPid !== _process.pid) {
112
+ _reset();
113
+ _poolPid = _process.pid;
114
+ }
115
+ _crypto().randomFillSync((_pool ??= Buffer.allocUnsafeSlow(POOL_SIZE)));
116
+ _poolAt = 0;
117
+ };
80
118
 
81
119
  /**
82
120
  * Reserves `count` fresh random bytes in the pool.
@@ -84,15 +122,24 @@ let _poolAt = POOL_SIZE;
84
122
  * @returns {number} Where they start.
85
123
  */
86
124
  const _take = (count) => {
87
- if (_poolAt + count > POOL_SIZE) {
88
- _crypto().randomFillSync((_pool ??= Buffer.allocUnsafeSlow(POOL_SIZE)));
89
- _poolAt = 0;
90
- }
125
+ if (_poolAt + count > POOL_SIZE || _poolPid !== _process.pid) _refill();
91
126
  const at = _poolAt;
92
127
  _poolAt += count;
93
128
  return at;
94
129
  };
95
130
 
131
+ // A startup snapshot (`node --build-snapshot`, single executable apps) would
132
+ // freeze the pool and the state, so every process started from it would print
133
+ // the same ids. The pid check above misses a reused pid, this doesn't.
134
+ const _snapshot = require("node:v8").startupSnapshot;
135
+ if (_snapshot?.isBuildingSnapshot?.()) {
136
+ _snapshot.addSerializeCallback(() => {
137
+ _pool?.fill(0);
138
+ _reset();
139
+ });
140
+ _snapshot.addDeserializeCallback(_reset);
141
+ }
142
+
96
143
  /**
97
144
  * @param {number} size
98
145
  * @param {string} what
@@ -237,10 +284,6 @@ function uuidv5(name, namespace) {
237
284
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
238
285
  }
239
286
 
240
- let _v7LastMs = 0;
241
- let _v7Counter = 0;
242
- let _v7Head = "";
243
-
244
287
  /**
245
288
  * A UUID v7: it starts with a timestamp, so ids sort by creation time.
246
289
  * That keeps database indexes compact, which makes them great primary
@@ -272,10 +315,6 @@ function uuidv7() {
272
315
  );
273
316
  }
274
317
 
275
- let _ulidLastMs = -1;
276
- let _ulidHead = "";
277
- const _ulidRandom = new Uint8Array(16);
278
-
279
318
  /**
280
319
  * @param {number} ms
281
320
  * @returns {string}
@@ -311,6 +350,7 @@ function ulid(time) {
311
350
  for (let i = at; i < at + 16; i++) out += CROCKFORD[pool[i] & 31];
312
351
  return out;
313
352
  }
353
+ if (_poolPid !== _process.pid) _refill();
314
354
  const now = Date.now();
315
355
  if (now > _ulidLastMs) {
316
356
  _ulidLastMs = now;
@@ -330,9 +370,6 @@ function ulid(time) {
330
370
  return out;
331
371
  }
332
372
 
333
- let _oidProcess = "";
334
- let _oidCounter = 0;
335
-
336
373
  /**
337
374
  * A MongoDB-style ObjectId: 24 hex characters that start with the time, in
338
375
  * seconds, and increase within the process.
@@ -347,6 +384,7 @@ let _oidCounter = 0;
347
384
  * @throws {RangeError} If the time is not a valid timestamp.
348
385
  */
349
386
  function objectId(time) {
387
+ if (_poolPid !== _process.pid) _refill();
350
388
  if (!_oidProcess) {
351
389
  const at = _take(8);
352
390
  const pool = /** @type {Buffer} */ (_pool);