@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/Utils.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * const settings = nc.JSONParse(text, {});
10
10
  */
11
11
 
12
- const { AbortError } = require("./errors.js");
13
12
  const { types } = require("node:util");
13
+ const { sleep, toMs } = require("./internal/shared.js");
14
14
 
15
15
  /**
16
16
  * Options for `wait()`.
@@ -22,8 +22,8 @@ const { types } = require("node:util");
22
22
  /**
23
23
  * Options for `when()`.
24
24
  * @typedef {object} WhenOptions
25
- * @property {number} [interval] Milliseconds between two checks. Defaults to `50`.
26
- * @property {number | null} [timeout] Give up after this many milliseconds and emit `"timeout"`.
25
+ * @property {number | string} [interval] Time between two checks, in ms or like `"1s"`. Defaults to `50`.
26
+ * @property {number | string | null} [timeout] Give up after this long, in ms or like `"10s"`, and emit `"timeout"`.
27
27
  * @property {number | null} [max] Stop after this many triggers.
28
28
  */
29
29
 
@@ -62,22 +62,14 @@ const { types } = require("node:util");
62
62
  * @throws {TypeError} If the duration can't be parsed.
63
63
  */
64
64
  function wait(duration, options = {}) {
65
- const ms = typeof duration === "number" ? duration : require("./Time.js").parseDuration(duration);
66
- if (ms === null || !Number.isFinite(ms)) return Promise.reject(new TypeError(`Invalid duration: ${String(duration)}`));
67
- const { signal } = options;
68
- return new Promise((resolve, reject) => {
69
- if (signal?.aborted) return reject(new AbortError(undefined, { cause: signal.reason }));
70
- const onAbort = () => {
71
- clearTimeout(timer);
72
- reject(new AbortError(undefined, { cause: signal?.reason }));
73
- };
74
- const timer = setTimeout(() => {
75
- signal?.removeEventListener("abort", onAbort);
76
- resolve();
77
- }, Math.max(0, ms));
78
- if (options.unref) timer.unref();
79
- signal?.addEventListener("abort", onAbort, { once: true });
80
- });
65
+ let ms;
66
+ try {
67
+ ms = toMs(duration);
68
+ } catch (error) {
69
+ return Promise.reject(error);
70
+ }
71
+ if (!Number.isFinite(ms)) return Promise.reject(new TypeError(`Invalid duration: ${String(duration)}`));
72
+ return sleep(ms, options.signal, options.unref ?? false);
81
73
  }
82
74
 
83
75
  /**
@@ -86,7 +78,7 @@ function wait(duration, options = {}) {
86
78
  * await a condition, `nc.async.poll()` is simpler.
87
79
  *
88
80
  * @example
89
- * nc.when(() => queue.length > 0, { reason: "jobs waiting" }, { interval: 500, max: 1, timeout: 10_000 })
81
+ * nc.when(() => queue.length > 0, { reason: "jobs waiting" }, { interval: 500, max: 1, timeout: "10s" })
90
82
  * .on("trigger", (payload) => nc.info(payload.reason))
91
83
  * .on("timeout", () => nc.warn("Nothing happened in 10s"))
92
84
  * .start();
@@ -97,8 +89,8 @@ function wait(duration, options = {}) {
97
89
  * @returns {WhenTask}
98
90
  */
99
91
  function when(predicate, payload = {}, options = {}) {
100
- const interval = options.interval ?? 50;
101
- const timeout = options.timeout ?? null;
92
+ const interval = options.interval === undefined ? 50 : toMs(options.interval, "interval");
93
+ const timeout = options.timeout === undefined || options.timeout === null ? null : toMs(options.timeout, "timeout");
102
94
  const max = options.max ?? null;
103
95
  /** @type {Record<"trigger" | "error" | "timeout", Set<Function>>} */
104
96
  const listeners = { trigger: new Set(), error: new Set(), timeout: new Set() };
@@ -258,12 +250,13 @@ let _dontCrashDispose;
258
250
 
259
251
  /**
260
252
  * `JSON.stringify` that never throws. Circular references become
261
- * `"[Circular]"`, bigints become strings, Maps become objects and Sets become
262
- * arrays.
253
+ * `"[Circular]"`, bigints become strings, Maps become objects, Sets become
254
+ * arrays, and errors keep their name, message and stack.
263
255
  *
264
256
  * @example
265
257
  * nc.JSONString({ a: 1 }); // '{\n "a": 1\n}'
266
258
  * nc.JSONString({ id: 10n, tags: new Set(["x"]) }, 0); // '{"id":"10","tags":["x"]}'
259
+ * nc.JSONString({ error: new Error("boom") }, 0); // '{"error":{"name":"Error","message":"boom","stack":"..."}}'
267
260
  *
268
261
  * @param {unknown} value
269
262
  * @param {number} [spaces=4] Indentation. Use `0` for a single line.
@@ -272,41 +265,69 @@ let _dontCrashDispose;
272
265
  function JSONString(value, spaces = 4) {
273
266
  /** @type {Array<[holder: object, original: object]>} ancestors of the value being serialized */
274
267
  const stack = [];
275
- const text = JSON.stringify(
276
- value,
277
- /** @this {any} */ function(_, raw) {
278
- if (typeof raw === "bigint") return raw.toString();
279
- if (raw === null || typeof raw !== "object") return raw;
280
- while (stack.length && stack[stack.length - 1][0] !== this) stack.pop();
281
- if (stack.some(([, original]) => original === raw)) return "[Circular]";
282
- const v = types.isMap(raw) ? Object.fromEntries(raw) : types.isSet(raw) ? [...raw] : raw;
283
- stack.push([v, raw]);
284
- return v;
285
- },
286
- spaces,
287
- );
268
+ let text;
269
+ try {
270
+ text = JSON.stringify(
271
+ value,
272
+ /** @this {any} */ function(_, raw) {
273
+ if (typeof raw === "bigint") return raw.toString();
274
+ if (raw === null || typeof raw !== "object") return raw;
275
+ while (stack.length && stack[stack.length - 1][0] !== this) stack.pop();
276
+ for (let i = 0; i < stack.length; i++) if (stack[i][1] === raw) return "[Circular]";
277
+ const v = types.isMap(raw) ? Object.fromEntries(raw) : types.isSet(raw) ? [...raw] : types.isNativeError(raw) ? _plainError(raw) : raw;
278
+ stack.push([v, raw]);
279
+ return v;
280
+ },
281
+ spaces,
282
+ );
283
+ } catch (error) {
284
+ // A getter or a toJSON() threw: describe the value instead of failing.
285
+ try {
286
+ text = JSON.stringify(require("node:util").inspect(value, { depth: 4, breakLength: Infinity }));
287
+ } catch {
288
+ text = JSON.stringify(`[Unserializable: ${error instanceof Error ? error.message : String(error)}]`);
289
+ }
290
+ }
288
291
  return text ?? String(text);
289
292
  }
290
293
 
294
+ /**
295
+ * The fields of an error that `JSON.stringify` would drop.
296
+ * @param {Error} error
297
+ * @returns {Record<string, unknown>}
298
+ */
299
+ const _plainError = (error) => {
300
+ /** @type {Record<string, unknown>} */
301
+ const out = { name: error.name, message: error.message };
302
+ for (const key of Object.keys(error)) out[key] = /** @type {any} */ (error)[key];
303
+ if (error.cause !== undefined) out.cause = error.cause;
304
+ if (error.stack) out.stack = error.stack;
305
+ return out;
306
+ };
307
+
291
308
  /**
292
309
  * `JSON.parse` with an optional fallback. With a fallback, invalid input
293
- * returns it instead of throwing.
310
+ * returns it instead of throwing. A leading byte order mark, which Windows
311
+ * editors like to add, is ignored.
294
312
  *
295
313
  * @example
296
314
  * nc.JSONParse('{"a":1}'); // { a: 1 }
297
315
  * nc.JSONParse("oops", null); // null
298
316
  * nc.JSONParse("oops"); // throws SyntaxError
317
+ * nc.JSONParse(text, {}, (key, value) => (key === "at" ? new Date(value) : value));
299
318
  *
300
319
  * @template [T=any]
301
320
  * @param {string} text
302
321
  * @param {T} [fallback] Returned when `text` isn't valid JSON.
322
+ * @param {(this: any, key: string, value: any) => any} [reviver] Passed to `JSON.parse`.
303
323
  * @returns {T}
304
324
  * @throws {SyntaxError} If `text` is invalid and there's no fallback.
305
325
  */
306
- function JSONParse(text, fallback) {
307
- if (arguments.length < 2) return JSON.parse(text);
326
+ function JSONParse(text, fallback, reviver) {
327
+ const clean = typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
328
+ if (arguments.length < 2) return JSON.parse(clean);
308
329
  try {
309
- return JSON.parse(text);
330
+ return JSON.parse(clean, reviver);
310
331
  } catch {
311
332
  return /** @type {T} */ (fallback);
312
333
  }
package/src/errors.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * The errors node-comfort throws. They all extend `NodeComfortError` (itself
5
- * a regular `Error`) and carry a stable `code`, so you can branch on the
6
- * kind of failure without parsing messages.
4
+ * The errors node-comfort throws when something goes wrong at run time: a
5
+ * request fails, data is invalid, a command exits badly. They all extend
6
+ * `NodeComfortError` (itself a regular `Error`) and carry a stable `code`,
7
+ * so you can branch on the kind of failure without parsing messages.
8
+ *
9
+ * A mistake in your own code, like a wrong argument, still throws a plain
10
+ * `TypeError` or `RangeError`, as it does everywhere in JavaScript.
7
11
  *
8
12
  * @example
9
13
  * try {
@@ -40,6 +44,40 @@ class NodeComfortError extends Error {
40
44
  /** Stable, machine-readable code. */
41
45
  this.code = options.code ?? "ERR_NODE_COMFORT";
42
46
  }
47
+
48
+ /**
49
+ * What `JSON.stringify()` writes for the error: its name, code, message
50
+ * and own fields, without the stack. Handy for API responses and logs.
51
+ *
52
+ * @example
53
+ * res.status(400).json({ error });
54
+ * // {"error":{"name":"ValidationError","code":"ERR_VALIDATION","message":"...","issues":[...]}}
55
+ *
56
+ * @returns {Record<string, unknown>}
57
+ */
58
+ toJSON() {
59
+ /** @type {Record<string, unknown>} */
60
+ const out = { name: this.name, code: this.code, message: this.message };
61
+ for (const key of Object.keys(this)) if (!(key in out)) out[key] = /** @type {any} */ (this)[key];
62
+ const cause = /** @type {any} */ (this.cause);
63
+ if (cause !== undefined) out.cause = cause instanceof Error ? { name: cause.name, message: cause.message, code: /** @type {any} */ (cause).code } : cause;
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Makes `instanceof` work when two copies of the package are installed,
69
+ * which would otherwise give two unrelated `HttpError` classes.
70
+ *
71
+ * @override
72
+ * @param {unknown} value
73
+ * @returns {boolean}
74
+ */
75
+ static [Symbol.hasInstance](value) {
76
+ if (Function.prototype[Symbol.hasInstance].call(this, value)) return true;
77
+ const brand = Symbol.for(`@ix-xs/node-comfort/${this.name}`);
78
+ if (!Object.prototype.hasOwnProperty.call(this.prototype, brand)) return false;
79
+ return value !== null && typeof value === "object" && /** @type {any} */ (value)[brand] === true;
80
+ }
43
81
  }
44
82
 
45
83
  /**
@@ -54,7 +92,7 @@ class AssertionError extends NodeComfortError {
54
92
  * @param {NodeComfortErrorOptions} [options]
55
93
  */
56
94
  constructor(message = "Assertion failed", options = {}) {
57
- super(message, { code: "ERR_ASSERTION", ...options });
95
+ super(message, { ...options, code: options.code ?? "ERR_ASSERTION" });
58
96
  }
59
97
  }
60
98
 
@@ -75,7 +113,7 @@ class TimeoutError extends NodeComfortError {
75
113
  * @param {NodeComfortErrorOptions & { timeout?: number }} [options] Can include the time limit in milliseconds.
76
114
  */
77
115
  constructor(message = "Operation timed out", options = {}) {
78
- super(message, { code: "ETIMEDOUT", ...options });
116
+ super(message, { ...options, code: options.code ?? "ETIMEDOUT" });
79
117
  /** The time limit in milliseconds, when known. */
80
118
  this.timeout = options.timeout;
81
119
  }
@@ -91,7 +129,7 @@ class AbortError extends NodeComfortError {
91
129
  * @param {NodeComfortErrorOptions} [options] Usually `{ cause: signal.reason }`.
92
130
  */
93
131
  constructor(message = "The operation was aborted", options = {}) {
94
- super(message, { code: "ABORT_ERR", ...options });
132
+ super(message, { ...options, code: options.code ?? "ABORT_ERR" });
95
133
  }
96
134
  }
97
135
 
@@ -123,7 +161,7 @@ class ValidationError extends NodeComfortError {
123
161
  * @param {NodeComfortErrorOptions & { message?: string }} [options]
124
162
  */
125
163
  constructor(issues, options = {}) {
126
- super(options.message ?? ValidationError.format(issues), { code: "ERR_VALIDATION", ...options });
164
+ super(options.message ?? ValidationError.format(issues), { ...options, code: options.code ?? "ERR_VALIDATION" });
127
165
  /** Every problem found, in order. */
128
166
  this.issues = issues;
129
167
  }
@@ -179,7 +217,7 @@ class HttpError extends NodeComfortError {
179
217
  * @param {NodeComfortErrorOptions & { status?: number, statusText?: string, url?: string, method?: string, headers?: Record<string, string>, data?: unknown }} [options]
180
218
  */
181
219
  constructor(message, options = {}) {
182
- super(message, { code: "ERR_HTTP", ...options });
220
+ super(message, { ...options, code: options.code ?? "ERR_HTTP" });
183
221
  /** HTTP status, like `404`. `0` when no response came back. */
184
222
  this.status = options.status ?? 0;
185
223
  /** HTTP status text, like `"Not Found"`. */
@@ -214,7 +252,7 @@ class JWTError extends NodeComfortError {
214
252
  * @param {NodeComfortErrorOptions & { expiredAt?: Date }} [options]
215
253
  */
216
254
  constructor(message, options = {}) {
217
- super(message, { code: "ERR_JWT", ...options });
255
+ super(message, { ...options, code: options.code ?? "ERR_JWT" });
218
256
  /** When the token expired. Only set for `ERR_JWT_EXPIRED`. */
219
257
  this.expiredAt = options.expiredAt;
220
258
  }
@@ -237,7 +275,7 @@ class ProcessError extends NodeComfortError {
237
275
  * @param {NodeComfortErrorOptions & { command?: string, exitCode?: number | null, signal?: string | null, stdout?: string, stderr?: string, timedOut?: boolean }} [options]
238
276
  */
239
277
  constructor(message, options = {}) {
240
- super(message, { code: options.timedOut ? "ETIMEDOUT" : "ERR_PROCESS", ...options });
278
+ super(message, { ...options, code: options.code ?? (options.timedOut ? "ETIMEDOUT" : "ERR_PROCESS") });
241
279
  /** The command that ran. */
242
280
  this.command = options.command ?? "";
243
281
  /** Exit code, or `null` if a signal killed the process. */
@@ -271,7 +309,7 @@ class SQLiteError extends NodeComfortError {
271
309
  * @param {NodeComfortErrorOptions & { sql?: string, sqliteCode?: string }} [options]
272
310
  */
273
311
  constructor(message, options = {}) {
274
- super(message, { code: "ERR_SQLITE", ...options });
312
+ super(message, { ...options, code: options.code ?? "ERR_SQLITE" });
275
313
  /** The SQL that failed, when there is one. */
276
314
  this.sql = options.sql;
277
315
  /** SQLite's error code, like `"SQLITE_CONSTRAINT_UNIQUE"`, when known. */
@@ -279,6 +317,11 @@ class SQLiteError extends NodeComfortError {
279
317
  }
280
318
  }
281
319
 
320
+ const _classes = [NodeComfortError, AssertionError, TimeoutError, AbortError, ValidationError, HttpError, JWTError, ProcessError, SQLiteError];
321
+ for (const Class of _classes) {
322
+ Object.defineProperty(Class.prototype, Symbol.for(`@ix-xs/node-comfort/${Class.name}`), { value: true });
323
+ }
324
+
282
325
  module.exports = {
283
326
  NodeComfortError,
284
327
  AssertionError,