@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/Sys.js CHANGED
@@ -14,21 +14,26 @@
14
14
  * });
15
15
  */
16
16
 
17
- const { spawn } = require("node:child_process");
18
17
  const fs = require("node:fs");
19
18
  const os = require("node:os");
20
19
  const nodePath = require("node:path");
21
20
  const { ProcessError, AbortError } = require("./errors.js");
21
+ const { toMs } = require("./internal/shared.js");
22
+
23
+ // Loaded on first use: most programs never start another one.
24
+ /** @type {typeof import("node:child_process") | undefined} */
25
+ let _childProcess;
26
+ const _cp = () => (_childProcess ??= require("node:child_process"));
22
27
 
23
28
  /**
24
29
  * Options for `run()` and `exec()`.
25
30
  * @typedef {object} RunOptions
26
31
  * @property {string} [cwd] Where to run the command.
27
- * @property {Record<string, string | undefined>} [env] Extra environment variables, added to `process.env`.
32
+ * @property {Record<string, string | undefined>} [env] Extra environment variables, added to `process.env`. `undefined` removes one.
28
33
  * @property {boolean} [extendEnv] Set to `false` to pass only `env`, without `process.env`.
29
34
  * @property {string | Buffer} [input] Written to the command's stdin.
30
- * @property {number | string} [timeout] Kill the command after this long, like `"30s"`.
31
- * @property {AbortSignal} [signal] Kills the command when aborted.
35
+ * @property {number | string} [timeout] Kill the command after this long, like `"30s"`. On Windows, what it started is stopped with it.
36
+ * @property {AbortSignal} [signal] Kills the command when aborted. On Windows, what it started is stopped with it.
32
37
  * @property {"pipe" | "inherit"} [stdio] `"inherit"` shows the output live instead of capturing it. Defaults to `"pipe"`.
33
38
  * @property {(line: string) => void} [onStdout] Called for each line of stdout.
34
39
  * @property {(line: string) => void} [onStderr] Called for each line of stderr.
@@ -55,6 +60,12 @@ const { ProcessError, AbortError } = require("./errors.js");
55
60
  * @property {NodeJS.Signals[]} [signals] Signals that trigger it. Defaults to `SIGINT`, `SIGTERM` and `SIGHUP`.
56
61
  */
57
62
 
63
+ /**
64
+ * Why the process is stopping: the signal it got, or `"shutdown"` when
65
+ * `sys.shutdown()` was called.
66
+ * @typedef {NodeJS.Signals | "shutdown"} ShutdownReason
67
+ */
68
+
58
69
  /**
59
70
  * Returned by `info()`.
60
71
  * @typedef {object} SystemInfo
@@ -105,6 +116,23 @@ const _prepare = (file, args) => {
105
116
  return { file: process.env.comspec || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
106
117
  };
107
118
 
119
+ /**
120
+ * @param {RunOptions} options
121
+ * @returns {Record<string, string | undefined> | undefined}
122
+ */
123
+ const _env = (options) => {
124
+ if (options.extendEnv === false) return options.env;
125
+ if (!options.env) return process.env;
126
+ /** @type {Record<string, string | undefined>} */
127
+ const merged = { ...process.env };
128
+ if (process.platform === "win32") {
129
+ // Windows reads PATH and Path as one variable: ours has to replace it, not sit next to it.
130
+ const given = new Set(Object.keys(options.env).map((key) => key.toUpperCase()));
131
+ for (const key of Object.keys(merged)) if (given.has(key.toUpperCase())) delete merged[key];
132
+ }
133
+ return Object.assign(merged, options.env);
134
+ };
135
+
108
136
  /**
109
137
  * @param {string} file
110
138
  * @param {string[]} args
@@ -117,14 +145,13 @@ const _spawn = (file, args, shell, display, options) =>
117
145
  new Promise((resolve, reject) => {
118
146
  const started = Date.now();
119
147
  const inherit = options.stdio === "inherit";
120
- const env = options.extendEnv === false ? options.env : { ...process.env, ...options.env };
121
148
  const prepared = shell ? { file, args, verbatim: false } : _prepare(file, args);
122
- const timeoutMs = options.timeout === undefined ? undefined : typeof options.timeout === "number" ? options.timeout : require("./Time.js").parseDuration(options.timeout) ?? undefined;
149
+ const timeoutMs = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout");
123
150
  if (options.signal?.aborted) return reject(new AbortError(`Command aborted: ${display}`, { cause: options.signal.reason }));
124
151
 
125
- const child = spawn(prepared.file, prepared.args, {
152
+ const child = _cp().spawn(prepared.file, prepared.args, {
126
153
  cwd: options.cwd,
127
- env: /** @type {NodeJS.ProcessEnv} */ (env),
154
+ env: /** @type {NodeJS.ProcessEnv} */ (_env(options)),
128
155
  shell,
129
156
  windowsHide: true,
130
157
  windowsVerbatimArguments: prepared.verbatim,
@@ -168,12 +195,20 @@ const _spawn = (file, args, shell, display, options) =>
168
195
  child.stdin.end(options.input);
169
196
  }
170
197
 
198
+ let stopped = false;
171
199
  const kill = () => {
200
+ stopped = true;
201
+ if (process.platform === "win32" && child.pid !== undefined) {
202
+ // On Windows, child.kill() ends cmd.exe and leaves what it started running.
203
+ const killer = _cp().spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
204
+ killer.on("error", () => child.kill());
205
+ return;
206
+ }
172
207
  child.kill("SIGTERM");
173
208
  forceTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
174
209
  forceTimer.unref();
175
210
  };
176
- if (timeoutMs !== undefined) {
211
+ if (timeoutMs !== undefined && timeoutMs !== Infinity) {
177
212
  killTimer = setTimeout(() => {
178
213
  timedOut = true;
179
214
  kill();
@@ -187,6 +222,13 @@ const _spawn = (file, args, shell, display, options) =>
187
222
  options.signal?.removeEventListener("abort", onAbort);
188
223
  reject(new ProcessError(`Failed to start "${display}": ${error.message}`, { command: display, cause: error }));
189
224
  });
225
+ child.on("exit", () => {
226
+ // What the command started may outlive it and keep its output open:
227
+ // once the command was stopped on purpose, there is nothing more to wait for.
228
+ if (!stopped) return;
229
+ child.stdout?.destroy();
230
+ child.stderr?.destroy();
231
+ });
190
232
  child.on("close", (exitCode, signal) => {
191
233
  clearTimeout(killTimer);
192
234
  clearTimeout(forceTimer);
@@ -248,6 +290,20 @@ function exec(command, options = {}) {
248
290
  return _spawn(command, [], true, command, options);
249
291
  }
250
292
 
293
+ /** @type {Map<string, string>} */
294
+ const _found = new Map();
295
+
296
+ /** @param {string} file */
297
+ const _runnable = (file) => {
298
+ try {
299
+ if (!fs.statSync(file).isFile()) return false;
300
+ if (process.platform !== "win32") fs.accessSync(file, fs.constants.X_OK);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ };
306
+
251
307
  /**
252
308
  * Finds an executable in `PATH`, like the `which` command.
253
309
  *
@@ -259,27 +315,33 @@ function exec(command, options = {}) {
259
315
  * @returns {string | undefined}
260
316
  */
261
317
  function which(command) {
318
+ if (typeof command !== "string" || !command) return undefined;
262
319
  const isWin = process.platform === "win32";
320
+ const searchPath = process.env.PATH ?? process.env.Path ?? "";
321
+ const key = `${command}\0${searchPath}\0${process.cwd()}`;
322
+ const known = _found.get(key);
323
+ // A found program is checked again with one call, in case it was removed since.
324
+ if (known !== undefined && _runnable(known)) return known;
263
325
  const exts = isWin ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
264
326
  const candidates = (/** @type {string} */ base) => (isWin && !nodePath.extname(base) ? exts.map((e) => base + e.toLowerCase()) : isWin ? [base, ...exts.map((e) => base + e.toLowerCase())] : [base]);
265
- const usable = (/** @type {string} */ file) => {
266
- try {
267
- if (!fs.statSync(file).isFile()) return false;
268
- if (!isWin) fs.accessSync(file, fs.constants.X_OK);
269
- return true;
270
- } catch {
271
- return false;
272
- }
273
- };
327
+ /** @type {string | undefined} */
328
+ let found;
274
329
  if (command.includes("/") || command.includes("\\")) {
275
- return candidates(nodePath.resolve(command)).find(usable);
330
+ found = candidates(nodePath.resolve(command)).find(_runnable);
331
+ } else {
332
+ for (const dir of searchPath.split(nodePath.delimiter)) {
333
+ if (!dir) continue;
334
+ found = candidates(nodePath.join(dir.replace(/^"|"$/g, ""), command)).find(_runnable);
335
+ if (found) break;
336
+ }
276
337
  }
277
- for (const dir of (process.env.PATH ?? process.env.Path ?? "").split(nodePath.delimiter)) {
278
- if (!dir) continue;
279
- const found = candidates(nodePath.join(dir.replace(/^"|"$/g, ""), command)).find(usable);
280
- if (found) return found;
338
+ if (found) {
339
+ if (_found.size >= 200) _found.clear();
340
+ _found.set(key, found);
341
+ } else {
342
+ _found.delete(key);
281
343
  }
282
- return undefined;
344
+ return found;
283
345
  }
284
346
 
285
347
  /**
@@ -296,8 +358,8 @@ function open(target) {
296
358
  const value = /^[a-z][a-z\d+.-]*:/i.test(target) ? target : nodePath.resolve(target);
297
359
  const child =
298
360
  process.platform === "win32"
299
- ? spawn(process.env.comspec || "cmd.exe", ["/d", "/s", "/c", `start "" ${_escapeArg(value, false)}`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
300
- : spawn(process.platform === "darwin" ? "open" : "xdg-open", [value], { detached: true, stdio: "ignore" });
361
+ ? _cp().spawn(process.env.comspec || "cmd.exe", ["/d", "/s", "/c", `start "" ${_escapeArg(value, false)}`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
362
+ : _cp().spawn(process.platform === "darwin" ? "open" : "xdg-open", [value], { detached: true, stdio: "ignore" });
301
363
  child.on("error", () => {});
302
364
  child.unref();
303
365
  return true;
@@ -306,9 +368,10 @@ function open(target) {
306
368
  }
307
369
  }
308
370
 
309
- /** @type {Array<() => unknown>} */
371
+ /** @type {Array<(reason: ShutdownReason) => unknown>} */
310
372
  const _shutdownHandlers = [];
311
- let _shutdownInstalled = false;
373
+ /** @type {Set<string>} */
374
+ const _shutdownSignals = new Set();
312
375
  let _shuttingDown = false;
313
376
  /** @type {{ timeout: number, exitCode: number }} */
314
377
  const _shutdownConfig = { timeout: 10_000, exitCode: 0 };
@@ -322,29 +385,26 @@ const _shutdownConfig = { timeout: 10_000, exitCode: 0 };
322
385
  * @example
323
386
  * const server = app.listen(3000);
324
387
  * sys.onShutdown(() => new Promise((done) => server.close(done)));
325
- * sys.onShutdown(() => db.close());
388
+ * sys.onShutdown((reason) => log.info(`Stopping (${reason})`));
326
389
  *
327
- * @param {() => unknown} handler Can be async.
390
+ * @param {(reason: ShutdownReason) => unknown} handler Can be async. Gets the signal that stopped the process.
328
391
  * @param {ShutdownOptions} [options]
329
392
  * @returns {() => void} Removes the handler.
330
393
  */
331
394
  function onShutdown(handler, options = {}) {
332
- if (options.timeout !== undefined) {
333
- _shutdownConfig.timeout = typeof options.timeout === "number" ? options.timeout : require("./Time.js").parseDuration(options.timeout) ?? 10_000;
334
- }
395
+ if (options.timeout !== undefined) _shutdownConfig.timeout = toMs(options.timeout, "timeout");
335
396
  if (options.exitCode !== undefined) _shutdownConfig.exitCode = options.exitCode;
336
397
  _shutdownHandlers.push(handler);
337
- if (!_shutdownInstalled) {
338
- _shutdownInstalled = true;
339
- const signals = options.signals ?? /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP", ...(process.platform === "win32" ? ["SIGBREAK"] : [])]);
340
- for (const signal of signals) {
341
- try {
342
- process.on(signal, () => {
343
- void _shutdown(signal);
344
- });
345
- } catch {
346
- // unsupported signal on this platform
347
- }
398
+ const signals = options.signals ?? /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP", ...(process.platform === "win32" ? ["SIGBREAK"] : [])]);
399
+ for (const signal of signals) {
400
+ if (_shutdownSignals.has(signal)) continue;
401
+ _shutdownSignals.add(signal);
402
+ try {
403
+ process.on(signal, () => {
404
+ void _shutdown(signal, undefined);
405
+ });
406
+ } catch {
407
+ // unsupported signal on this platform
348
408
  }
349
409
  }
350
410
  return () => {
@@ -354,9 +414,11 @@ function onShutdown(handler, options = {}) {
354
414
  }
355
415
 
356
416
  /**
357
- * @param {string} reason
417
+ * @param {ShutdownReason} reason
418
+ * @param {number | undefined} exitCode
419
+ * @returns {Promise<void>}
358
420
  */
359
- const _shutdown = async (reason) => {
421
+ const _shutdown = async (reason, exitCode) => {
360
422
  if (_shuttingDown) {
361
423
  process.exit(1);
362
424
  return;
@@ -365,21 +427,40 @@ const _shutdown = async (reason) => {
365
427
  const force = setTimeout(() => {
366
428
  process.stderr.write(`[@ix-xs/node-comfort] Shutdown (${reason}) took longer than ${_shutdownConfig.timeout}ms, forcing exit\n`);
367
429
  process.exit(1);
368
- }, _shutdownConfig.timeout);
369
- force.unref();
430
+ }, Math.min(_shutdownConfig.timeout, 2 ** 31 - 1));
431
+ // Not unref'd: a handler that never settles must not let the process end
432
+ // quietly with the wrong exit code once nothing else keeps it alive.
370
433
  let failed = false;
371
434
  for (const handler of [..._shutdownHandlers].reverse()) {
372
435
  try {
373
- await handler();
436
+ await handler(reason);
374
437
  } catch (error) {
375
438
  failed = true;
376
439
  process.stderr.write(`[@ix-xs/node-comfort] Shutdown handler failed: ${error instanceof Error ? error.stack : String(error)}\n`);
377
440
  }
378
441
  }
379
442
  clearTimeout(force);
380
- process.exit(failed ? 1 : _shutdownConfig.exitCode);
443
+ process.exit(failed ? 1 : exitCode ?? _shutdownConfig.exitCode);
381
444
  };
382
445
 
446
+ /**
447
+ * Stops the process the clean way: runs the `onShutdown()` handlers, then
448
+ * exits. Use it instead of `process.exit()` when something goes wrong for
449
+ * good, so servers and databases still get closed.
450
+ *
451
+ * @example
452
+ * process.on("uncaughtException", (error) => {
453
+ * log.fatal(error);
454
+ * sys.shutdown(1);
455
+ * });
456
+ *
457
+ * @param {number} [exitCode] Defaults to the `exitCode` given to `onShutdown()`, or `0`. A failing handler makes it `1`.
458
+ * @returns {Promise<void>} Never resolves: the process exits first.
459
+ */
460
+ function shutdown(exitCode) {
461
+ return _shutdown("shutdown", exitCode);
462
+ }
463
+
383
464
  /**
384
465
  * Are we on Windows?
385
466
  * @returns {boolean}
@@ -423,19 +504,23 @@ function isCI() {
423
504
  );
424
505
  }
425
506
 
507
+ /** @type {boolean | undefined} */
508
+ let _docker;
509
+
426
510
  /**
427
511
  * Are we running inside a container?
428
512
  *
429
513
  * @returns {boolean}
430
514
  */
431
515
  function isDocker() {
432
- if (process.platform !== "linux") return false;
516
+ if (_docker !== undefined) return _docker;
517
+ if (process.platform !== "linux") return (_docker = false);
433
518
  try {
434
- if (fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv")) return true;
435
- return /docker|kubepods|containerd/.test(fs.readFileSync("/proc/self/cgroup", "utf8"));
519
+ _docker = fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv") || /docker|kubepods|containerd/.test(fs.readFileSync("/proc/self/cgroup", "utf8"));
436
520
  } catch {
437
- return false;
521
+ _docker = false;
438
522
  }
523
+ return _docker;
439
524
  }
440
525
 
441
526
  /**
@@ -481,6 +566,61 @@ function info() {
481
566
  };
482
567
  }
483
568
 
569
+ /**
570
+ * Finds a TCP port nobody is listening on. Give a preferred port to get it
571
+ * back when it's free, and another one when it's taken.
572
+ *
573
+ * @example
574
+ * const port = await sys.freePort(3000); // 3000, or a free one if 3000 is in use
575
+ * app.listen(port);
576
+ *
577
+ * @param {number} [preferred] Tried first.
578
+ * @param {string} [host] The address to check. Defaults to all of them.
579
+ * @returns {Promise<number>}
580
+ */
581
+ function freePort(preferred, host) {
582
+ const net = require("node:net");
583
+ /**
584
+ * @param {number} port
585
+ * @returns {Promise<number>}
586
+ */
587
+ const listen = (port) =>
588
+ new Promise((resolve, reject) => {
589
+ const server = net.createServer();
590
+ server.unref();
591
+ server.once("error", reject);
592
+ server.listen({ port, host, exclusive: true }, () => {
593
+ const address = server.address();
594
+ server.close(() => resolve(address !== null && typeof address === "object" ? address.port : port));
595
+ });
596
+ });
597
+ if (preferred === undefined) return listen(0);
598
+ if (!Number.isInteger(preferred) || preferred < 0 || preferred > 65535) return Promise.reject(new RangeError(`freePort(): expected a port from 0 to 65535, got ${preferred}`));
599
+ return listen(preferred).catch(() => listen(0));
600
+ }
601
+
602
+ /**
603
+ * The address of this machine on the local network, like `"192.168.1.20"`.
604
+ * It's what you print next to `localhost` so a phone on the same Wi-Fi can
605
+ * reach a dev server.
606
+ *
607
+ * @example
608
+ * console.log(`http://${sys.localIP() ?? "localhost"}:3000`);
609
+ *
610
+ * @param {4 | 6} [family=4] IPv4 or IPv6.
611
+ * @returns {string | undefined} `undefined` when the machine has no network.
612
+ */
613
+ function localIP(family = 4) {
614
+ const wanted = family === 6 ? "IPv6" : "IPv4";
615
+ for (const addresses of Object.values(os.networkInterfaces())) {
616
+ for (const address of addresses ?? []) {
617
+ // fe80:: addresses only work with an interface name attached, so they're no use in a URL
618
+ if (address.family === wanted && !address.internal && !address.address.startsWith("fe80:")) return address.address;
619
+ }
620
+ }
621
+ return undefined;
622
+ }
623
+
484
624
  /**
485
625
  * Memory used by this process, in bytes.
486
626
  *
@@ -500,6 +640,9 @@ module.exports = {
500
640
  which,
501
641
  open,
502
642
  onShutdown,
643
+ shutdown,
644
+ freePort,
645
+ localIP,
503
646
  isWindows,
504
647
  isMac,
505
648
  isLinux,