@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/Logger.js CHANGED
@@ -10,8 +10,9 @@
10
10
  * `#ff8800`, `bg#1e1e2e`, `rgb(255, 128, 0)` and `bgRgb(...)`.
11
11
  *
12
12
  * Levels, from chatty to severe: `trace`, `debug`, `info`, `warn`, `error`,
13
- * `fatal`. The default is `info`, or `debug` when `DEBUG` is set; `LOG_LEVEL`
14
- * overrides it. Warnings and errors go to stderr.
13
+ * `fatal`. The default is `info`; `LOG_LEVEL` changes it. `DEBUG=1` shows
14
+ * debug messages, and `DEBUG=db,http:*` only the debug and trace messages of
15
+ * the loggers with these scopes. Warnings and errors go to stderr.
15
16
  *
16
17
  * @example
17
18
  * nc.info("Server listening on port 3000");
@@ -24,7 +25,7 @@ const fs = require("node:fs");
24
25
  const path = require("node:path");
25
26
  const util = require("node:util");
26
27
  const color = require("./Color.js");
27
- const time = require("./Time.js");
28
+ const { timeConfig } = require("./internal/shared.js");
28
29
 
29
30
  /**
30
31
  * Log levels, from the most verbose to the most severe. `silent` hides everything.
@@ -45,6 +46,17 @@ const time = require("./Time.js");
45
46
  * @property {number} [maxFiles] How many rotated files to keep. Defaults to `5`.
46
47
  */
47
48
 
49
+ /**
50
+ * A message as `onLog` receives it.
51
+ * @typedef {object} LogEntry
52
+ * @property {Date} time
53
+ * @property {LogLevel} level
54
+ * @property {"log" | "trace" | "debug" | "info" | "success" | "warn" | "error" | "fatal"} kind The method that was called.
55
+ * @property {string | undefined} scope
56
+ * @property {string} message The line as written, without colors or timestamp.
57
+ * @property {unknown[]} args What was passed to the method, untouched.
58
+ */
59
+
48
60
  /**
49
61
  * Options for `createLogger()` and `configure()`.
50
62
  * @typedef {object} LoggerOptions
@@ -58,6 +70,8 @@ const time = require("./Time.js");
58
70
  * @property {LogStream} [stderr] Where `warn`, `error` and `fatal` go. Defaults to `process.stderr`.
59
71
  * @property {string | LogFileOptions} [file] Also write every message to a file.
60
72
  * @property {Record<string, unknown>} [fields] Added to every JSON record, like a service name.
73
+ * @property {string[]} [redact] Keys whose values are replaced by `"[redacted]"` wherever they appear in logged objects, like `["password", "authorization"]`. Case doesn't matter.
74
+ * @property {(entry: LogEntry) => void | Promise<void>} [onLog] Called for every message that passes the level, to send logs somewhere else. It isn't awaited, and an error it throws (or a promise it rejects) is ignored: logging never breaks the program.
61
75
  */
62
76
 
63
77
  /**
@@ -109,11 +123,42 @@ const STYLE_NAMES = new Set([
109
123
  "bgBlackBright", "bgRedBright", "bgGreenBright", "bgYellowBright", "bgBlueBright", "bgMagentaBright", "bgCyanBright", "bgWhiteBright",
110
124
  ]);
111
125
 
126
+ const REDACTED = "[redacted]";
127
+ const TRIM_LINES = /^(\s*\r?\n)+|(\r?\n\s*)+$/g;
128
+ const RGB = /(bg)?rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/gi;
129
+ const TOKEN = /^(\S+)(\s+|$)/;
130
+
112
131
  /** @returns {LogLevel} */
113
132
  const _defaultLevel = () => {
114
133
  const fromEnv = String(process.env.LOG_LEVEL ?? "").toLowerCase();
115
- if (fromEnv in LEVELS) return /** @type {LogLevel} */ (fromEnv);
116
- return process.env.DEBUG || process.env.NODE_DEBUG ? "debug" : "info";
134
+ if (Object.hasOwn(LEVELS, fromEnv)) return /** @type {LogLevel} */ (fromEnv);
135
+ return _debugAll(process.env.DEBUG) || process.env.NODE_DEBUG ? "debug" : "info";
136
+ };
137
+
138
+ /**
139
+ * Does `DEBUG` ask for everything? `DEBUG=1`, `DEBUG=true` and `DEBUG=*` do.
140
+ * @param {string | undefined} value
141
+ */
142
+ const _debugAll = (value) => value !== undefined && /^(?:1|true|yes|on|\*)$/i.test(value.trim());
143
+
144
+ /**
145
+ * Does `DEBUG` name this scope? It's a list of scopes, where `*` stands for
146
+ * anything and a leading `-` excludes: `DEBUG=db,http:*,-http:static`.
147
+ * @param {string | undefined} value
148
+ * @param {string | undefined} scope
149
+ */
150
+ const _debugScope = (value, scope) => {
151
+ if (!value || !scope || _debugAll(value)) return false;
152
+ let wanted = false;
153
+ for (const part of value.split(/[\s,]+/)) {
154
+ if (!part) continue;
155
+ const exclude = part[0] === "-";
156
+ const pattern = new RegExp(`^${(exclude ? part.slice(1) : part).split("*").map(_escape).join(".*")}$`);
157
+ if (!pattern.test(scope)) continue;
158
+ if (exclude) return false;
159
+ wanted = true;
160
+ }
161
+ return wanted;
117
162
  };
118
163
 
119
164
  /** @param {string} value */
@@ -152,20 +197,19 @@ const _isStyleToken = (token) =>
152
197
  /**
153
198
  * Renders `<% styles text %>` markup.
154
199
  * @param {string} input
155
- * @param {{ open: string, close: string }} delimiter
200
+ * @param {{ open: string, close: string, regex: RegExp }} delimiter
156
201
  * @param {import("./Color").ColorModule} palette
157
202
  * @returns {string}
158
203
  */
159
204
  const _markup = (input, delimiter, palette) => {
160
205
  if (!input.includes(delimiter.open)) return input;
161
- const regex = new RegExp(`${_escape(delimiter.open)}([\\s\\S]*?)${_escape(delimiter.close)}`, "g");
162
- return input.replace(regex, (_, content) => {
163
- const normalized = String(content).replace(/(bg)?rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/gi, (m0, bg, r, g, b) => `${bg ?? ""}rgb(${r},${g},${b})`);
206
+ return input.replace(delimiter.regex, (_, content) => {
207
+ const normalized = String(content).replace(RGB, (m0, bg, r, g, b) => `${bg ?? ""}rgb(${r},${g},${b})`);
164
208
  /** @type {string[]} */
165
209
  const tokens = [];
166
210
  let rest = normalized.replace(/^\s+/, "");
167
211
  for (;;) {
168
- const match = /^(\S+)(\s+|$)/.exec(rest);
212
+ const match = TOKEN.exec(rest);
169
213
  if (!match || !_isStyleToken(match[1])) break;
170
214
  tokens.push(match[1]);
171
215
  rest = rest.slice(match[0].length);
@@ -174,12 +218,42 @@ const _markup = (input, delimiter, palette) => {
174
218
  });
175
219
  };
176
220
 
221
+ /**
222
+ * A copy of a value with the secret keys masked, for values `JSON.stringify`
223
+ * can't handle on its own: class instances, circular references.
224
+ * @param {unknown} value
225
+ * @param {Set<string>} secrets Lower-cased key names.
226
+ * @param {Map<object, unknown>} seen
227
+ * @returns {unknown}
228
+ */
229
+ const _redact = (value, secrets, seen) => {
230
+ if (value === null || typeof value !== "object" || value instanceof Date || value instanceof RegExp || value instanceof Error || ArrayBuffer.isView(value)) return value;
231
+ const known = seen.get(value);
232
+ if (known !== undefined) return known;
233
+ if (Array.isArray(value)) {
234
+ /** @type {unknown[]} */
235
+ const list = [];
236
+ seen.set(value, list);
237
+ for (const item of value) list.push(_redact(item, secrets, seen));
238
+ return list;
239
+ }
240
+ if (value instanceof Map || value instanceof Set) return value;
241
+ /** @type {Record<string, unknown>} */
242
+ const copy = Object.create(Object.getPrototypeOf(value));
243
+ seen.set(value, copy);
244
+ for (const key of Object.keys(value)) {
245
+ copy[key] = secrets.has(key.toLowerCase()) ? REDACTED : _redact(/** @type {Record<string, unknown>} */ (value)[key], secrets, seen);
246
+ }
247
+ return copy;
248
+ };
249
+
177
250
  /**
178
251
  * @param {unknown} value
179
252
  * @param {boolean} colors
253
+ * @param {Set<string> | undefined} secrets
180
254
  * @returns {string}
181
255
  */
182
- const _stringify = (value, colors) => {
256
+ const _stringify = (value, colors, secrets) => {
183
257
  if (typeof value === "string") return value;
184
258
  if (value instanceof Error) {
185
259
  let text = value.stack ?? `${value.name}: ${value.message}`;
@@ -195,13 +269,13 @@ const _stringify = (value, colors) => {
195
269
  const proto = Object.getPrototypeOf(value);
196
270
  if (Array.isArray(value) || proto === Object.prototype || proto === null) {
197
271
  try {
198
- const json = JSON.stringify(value, (_, v) => (typeof v === "bigint" ? `${v}n` : v), 2);
272
+ const json = JSON.stringify(value, (key, v) => (secrets?.has(key.toLowerCase()) ? REDACTED : typeof v === "bigint" ? `${v}n` : v), 2);
199
273
  if (json !== undefined) return json;
200
274
  } catch {
201
275
  // circular or exotic: fall back to util.inspect
202
276
  }
203
277
  }
204
- return util.inspect(value, { depth: 6, colors, breakLength: 100 });
278
+ return util.inspect(secrets ? _redact(value, secrets, new Map()) : value, { depth: 6, colors, breakLength: 100 });
205
279
  }
206
280
  return typeof value === "symbol" || typeof value === "function" ? util.inspect(value, { colors }) : String(value);
207
281
  };
@@ -211,11 +285,13 @@ const _stringify = (value, colors) => {
211
285
  * @param {unknown[]} args
212
286
  * @param {string | undefined} scope
213
287
  * @param {Record<string, unknown>} fields
288
+ * @param {Set<string> | undefined} secrets
289
+ * @param {Date} now
214
290
  * @returns {string}
215
291
  */
216
- const _json = (level, args, scope, fields) => {
292
+ const _json = (level, args, scope, fields, secrets, now) => {
217
293
  /** @type {Record<string, unknown>} */
218
- const record = { time: new Date().toISOString(), level, ...fields };
294
+ const record = { time: now.toISOString(), level, ...fields };
219
295
  if (scope) record.scope = scope;
220
296
  const messages = [];
221
297
  for (const arg of args) {
@@ -224,49 +300,93 @@ const _json = (level, args, scope, fields) => {
224
300
  } else if (arg !== null && typeof arg === "object" && !Array.isArray(arg)) {
225
301
  Object.assign(record, arg);
226
302
  } else {
227
- messages.push(typeof arg === "string" ? color.strip(arg) : _stringify(arg, false));
303
+ messages.push(typeof arg === "string" ? color.strip(arg) : _stringify(arg, false, secrets));
228
304
  }
229
305
  }
230
306
  record.msg = messages.join(" ");
231
307
  try {
232
- return JSON.stringify(record, (_, v) => (typeof v === "bigint" ? v.toString() : v));
308
+ return JSON.stringify(record, (key, v) => (secrets?.has(key.toLowerCase()) ? REDACTED : typeof v === "bigint" ? v.toString() : v));
233
309
  } catch {
234
310
  return JSON.stringify({ time: record.time, level, msg: `${record.msg} [unserializable fields]` });
235
311
  }
236
312
  };
237
313
 
238
314
  /**
239
- * Appends a line, rotating the file when it gets too big.
315
+ * A log file: where it is, how big it may get, how big it is now.
316
+ * @typedef {{ target: string, limit: number | undefined, keep: number, size: number, ready: boolean }} _LogFile
317
+ */
318
+
319
+ /**
240
320
  * @param {LogFileOptions} file
321
+ * @returns {_LogFile}
322
+ */
323
+ const _openFile = (file) => ({
324
+ target: path.resolve(file.path),
325
+ limit: file.maxSize === undefined ? undefined : typeof file.maxSize === "number" ? file.maxSize : require("./Num.js").parseBytes(file.maxSize),
326
+ keep: Math.max(1, file.maxFiles ?? 5),
327
+ size: 0,
328
+ ready: false,
329
+ });
330
+
331
+ /** @param {string} target */
332
+ const _sizeOf = (target) => {
333
+ try {
334
+ return fs.statSync(target).size;
335
+ } catch {
336
+ return 0;
337
+ }
338
+ };
339
+
340
+ /**
341
+ * Appends a line, rotating the file when it gets too big. The folder is
342
+ * made once and the size is counted here, so a line costs one write.
343
+ * @param {_LogFile} file
241
344
  * @param {string} line
242
345
  */
243
346
  const _writeFile = (file, line) => {
347
+ const text = `${line}\n`;
244
348
  try {
245
- const target = path.resolve(file.path);
246
- fs.mkdirSync(path.dirname(target), { recursive: true });
247
- if (file.maxSize !== undefined) {
248
- const limit = typeof file.maxSize === "number" ? file.maxSize : require("./Num.js").parseBytes(file.maxSize);
249
- let size = 0;
250
- try {
251
- size = fs.statSync(target).size;
252
- } catch {
253
- size = 0;
254
- }
255
- if (size > 0 && size + Buffer.byteLength(line) > limit) {
256
- const keep = Math.max(1, file.maxFiles ?? 5);
257
- for (let i = keep - 1; i >= 1; i--) {
258
- if (fs.existsSync(`${target}.${i}`)) fs.renameSync(`${target}.${i}`, `${target}.${i + 1}`);
349
+ if (!file.ready) {
350
+ fs.mkdirSync(path.dirname(file.target), { recursive: true });
351
+ file.size = _sizeOf(file.target);
352
+ file.ready = true;
353
+ }
354
+ const bytes = Buffer.byteLength(text);
355
+ if (file.limit !== undefined && file.size > 0 && file.size + bytes - 1 > file.limit) {
356
+ // Another process may have rotated or removed the file: look before moving anything.
357
+ file.size = _sizeOf(file.target);
358
+ if (file.size > 0 && file.size + bytes - 1 > file.limit) {
359
+ for (let i = file.keep - 1; i >= 1; i--) {
360
+ if (fs.existsSync(`${file.target}.${i}`)) fs.renameSync(`${file.target}.${i}`, `${file.target}.${i + 1}`);
259
361
  }
260
- if (fs.existsSync(`${target}.${keep + 1}`)) fs.rmSync(`${target}.${keep + 1}`);
261
- fs.renameSync(target, `${target}.1`);
362
+ if (fs.existsSync(`${file.target}.${file.keep + 1}`)) fs.rmSync(`${file.target}.${file.keep + 1}`);
363
+ fs.renameSync(file.target, `${file.target}.1`);
364
+ file.size = 0;
262
365
  }
263
366
  }
264
- fs.appendFileSync(target, `${line}\n`);
367
+ try {
368
+ fs.appendFileSync(file.target, text);
369
+ } catch {
370
+ // The folder was removed since: make it again, once.
371
+ fs.mkdirSync(path.dirname(file.target), { recursive: true });
372
+ fs.appendFileSync(file.target, text);
373
+ file.size = 0;
374
+ }
375
+ file.size += bytes;
265
376
  } catch {
266
377
  // Logging must never crash the application.
267
378
  }
268
379
  };
269
380
 
381
+ /** @param {number} n */
382
+ const _two = (n) => (n < 10 ? `0${n}` : String(n));
383
+
384
+ /**
385
+ * What a message looks like on one output: its colors, and the parts that
386
+ * are the same on every line, styled once.
387
+ * @typedef {{ palette: import("./Color").ColorModule, colors: boolean, labels: Record<string, string>, scope: string, stamp: string, stampAt: number, stampWidth: number }} _Face
388
+ */
389
+
270
390
  /**
271
391
  * @param {LoggerOptions} options
272
392
  * @returns {Logger}
@@ -283,70 +403,170 @@ const _create = (options) => {
283
403
  stderr: options.stderr,
284
404
  file: typeof options.file === "string" ? { path: options.file } : options.file,
285
405
  fields: options.fields ?? {},
406
+ redact: options.redact,
407
+ onLog: options.onLog,
286
408
  };
287
409
  /** @type {string[]} */
288
410
  const groups = [];
289
411
  /** @type {Map<string, bigint>} */
290
412
  const timers = new Map();
291
413
 
414
+ // Everything below is worked out from the settings, and again when they change.
415
+ let threshold = 0;
416
+ let debugScope = false;
417
+ // What each method does with a message, decided once: a call that prints
418
+ // nothing must cost next to nothing.
419
+ /** @type {Record<keyof typeof BADGES, boolean>} */
420
+ const on = { log: true, trace: false, debug: false, info: true, success: true, warn: true, error: true, fatal: true };
421
+ /** @type {{ open: string, close: string, regex: RegExp }} */
422
+ let delimiter;
423
+ /** @type {Set<string> | undefined} */
424
+ let secrets;
425
+ /** @type {_LogFile | undefined} */
426
+ let file;
427
+ /** @type {{ stdout?: _Face, stderr?: _Face, file?: _Face }} */
428
+ let faces = {};
429
+
430
+ const gate = () => {
431
+ threshold = LEVELS[settings.level];
432
+ for (const kind of /** @type {Array<keyof typeof BADGES>} */ (Object.keys(BADGES))) {
433
+ on[kind] = LEVELS[BADGES[kind].level] >= threshold || (debugScope && threshold !== Infinity && (kind === "debug" || kind === "trace"));
434
+ }
435
+ };
436
+
437
+ const refresh = () => {
438
+ // DEBUG=db,http:* turns debug on for those scopes only, whatever the level.
439
+ debugScope = _debugScope(process.env.DEBUG, settings.scope);
440
+ gate();
441
+ const { open, close } = settings.delimiter;
442
+ delimiter = { open, close, regex: new RegExp(`${_escape(open)}([\\s\\S]*?)${_escape(close)}`, "g") };
443
+ secrets = settings.redact?.length ? new Set(settings.redact.map((key) => String(key).toLowerCase())) : undefined;
444
+ file = settings.file ? _openFile(settings.file) : undefined;
445
+ faces = {};
446
+ };
447
+
292
448
  /** @param {"stdout" | "stderr"} which */
293
449
  const streamOf = (which) => (which === "stderr" ? settings.stderr ?? process.stderr : settings.stdout ?? process.stdout);
294
450
 
295
- /** @param {"stdout" | "stderr"} which */
451
+ /** @param {"stdout" | "stderr" | "file"} which */
296
452
  const paletteOf = (which) => {
453
+ if (which === "file" || settings.colors === false) return color.create(0);
297
454
  if (settings.colors === true) return color.create(color.level > 0 ? color.level : 3);
298
- if (settings.colors === false) return color.create(0);
299
455
  const stream = streamOf(which);
300
456
  return stream === process.stdout || stream === process.stderr ? color.create(color.detect(/** @type {any} */ (stream))) : color.create(0);
301
457
  };
302
458
 
303
- const plain = color.create(0);
459
+ /**
460
+ * @param {"stdout" | "stderr" | "file"} which
461
+ * @returns {_Face}
462
+ */
463
+ const faceOf = (which) => {
464
+ let face = faces[which];
465
+ if (face) return face;
466
+ const palette = paletteOf(which);
467
+ face = { palette, colors: palette.level > 0, labels: {}, scope: settings.scope ? palette.magenta(`[${settings.scope}]`) : "", stamp: "", stampAt: -1, stampWidth: 0 };
468
+ for (const kind of Object.keys(BADGES)) face.labels[kind] = BADGES[kind].label ? _style(palette, BADGES[kind].style.split(" "), BADGES[kind].label) : "";
469
+ faces[which] = face;
470
+ return face;
471
+ };
472
+
473
+ /**
474
+ * The timestamp, written again only when the second changes.
475
+ * @param {_Face} face
476
+ * @param {Date} now
477
+ */
478
+ const stampOf = (face, now) => {
479
+ const second = Math.floor(now.getTime() / 1000);
480
+ if (second === face.stampAt) return;
481
+ face.stampAt = second;
482
+ // A pattern, or a time zone set with time.setTimezone(), needs the real formatter.
483
+ const text = typeof settings.timestamp === "string" || timeConfig.timeZone !== undefined
484
+ ? require("./Time.js").format(now, typeof settings.timestamp === "string" ? settings.timestamp : "HH:mm:ss")
485
+ : `${_two(now.getHours())}:${_two(now.getMinutes())}:${_two(now.getSeconds())}`;
486
+ face.stamp = `${face.palette.gray(`[${text}]`)} `;
487
+ face.stampWidth = color.width(text) + 3;
488
+ };
489
+
490
+ /**
491
+ * @param {_Face} face
492
+ * @param {keyof typeof BADGES} kind
493
+ * @param {unknown[]} args
494
+ * @param {Date} now
495
+ * @param {boolean} stamped
496
+ * @returns {string}
497
+ */
498
+ const render = (face, kind, args, now, stamped) => {
499
+ const { palette } = face;
500
+ let body = "";
501
+ for (let i = 0; i < args.length; i++) {
502
+ const text = _markup(_stringify(args[i], face.colors, secrets), delimiter, palette);
503
+ body += (i ? " " : "") + (face.colors && (kind === "debug" || kind === "trace") ? palette.gray(text) : text);
504
+ }
505
+ let prefix = "";
506
+ let padWidth = 0;
507
+ if (stamped) {
508
+ stampOf(face, now);
509
+ prefix = face.stamp;
510
+ padWidth = face.stampWidth;
511
+ }
512
+ if (groups.length) {
513
+ prefix += " ".repeat(groups.length);
514
+ padWidth += groups.length * 2;
515
+ }
516
+ if (face.scope) prefix += `${face.scope} `;
517
+ if (face.labels[kind]) prefix += `${face.labels[kind]} `;
518
+ if (body.indexOf("\n") === -1) return (prefix + body).trimEnd();
519
+ const lines = body.replace(TRIM_LINES, "").split(/\r?\n/);
520
+ const pad = " ".repeat(padWidth);
521
+ let out = (prefix + lines[0]).trimEnd();
522
+ for (let i = 1; i < lines.length; i++) out += `\n${pad}${lines[i]}`;
523
+ return out;
524
+ };
304
525
 
305
526
  /**
306
527
  * @param {keyof typeof BADGES} kind
307
528
  * @param {unknown[]} args
308
529
  */
309
530
  const emit = (kind, args) => {
531
+ if (!on[kind]) return;
310
532
  const badge = BADGES[kind];
311
- if (LEVELS[badge.level] < LEVELS[settings.level] || settings.level === "silent") return;
533
+ const now = new Date();
534
+ const stream = streamOf(badge.stream);
312
535
  if (settings.format === "json") {
313
- const line = _json(kind === "log" ? "info" : kind, args, settings.scope, settings.fields);
314
- streamOf(badge.stream).write(`${line}\n`);
315
- if (settings.file) _writeFile(settings.file, line);
316
- return;
536
+ const line = _json(kind === "log" ? "info" : kind, args, settings.scope, settings.fields, secrets, now);
537
+ stream.write(`${line}\n`);
538
+ if (file) _writeFile(file, line);
539
+ } else {
540
+ const face = faceOf(badge.stream);
541
+ const line = render(face, kind, args, now, Boolean(settings.timestamp));
542
+ stream.write(`${line}\n`);
543
+ // The file gets the same line without colors: the one just made, when it has none.
544
+ if (file) _writeFile(file, face.colors ? render(faceOf("file"), kind, args, now, Boolean(settings.timestamp)) : line);
545
+ }
546
+ if (settings.onLog) {
547
+ try {
548
+ /** @type {unknown} */
549
+ const pending = settings.onLog({ time: now, level: badge.level, kind: /** @type {LogEntry["kind"]} */ (kind), scope: settings.scope, message: render(faceOf("file"), kind, args, now, false), args });
550
+ // An async hook that fails would otherwise be an unhandled rejection.
551
+ if (pending && typeof (/** @type {any} */ (pending).then) === "function") /** @type {PromiseLike<unknown>} */ (pending).then(undefined, () => {});
552
+ } catch {
553
+ // A broken hook must not take the program down with it.
554
+ }
317
555
  }
318
- const render = (/** @type {import("./Color").ColorModule} */ palette) => {
319
- const colors = palette.level > 0;
320
- const stamp = settings.timestamp
321
- ? _style(palette, ["gray"], `[${time.format(new Date(), typeof settings.timestamp === "string" ? settings.timestamp : "HH:mm:ss")}]`)
322
- : "";
323
- const scope = settings.scope ? _style(palette, ["magenta"], `[${settings.scope}]`) : "";
324
- const label = badge.label ? _style(palette, badge.style.split(" "), badge.label) : "";
325
- const body = args
326
- .map((arg) => _markup(_stringify(arg, colors), settings.delimiter, palette))
327
- .map((text) => (kind === "debug" || kind === "trace" ? _style(palette, ["gray"], text) : text))
328
- .join(" ");
329
- const indent = " ".repeat(groups.length);
330
- const tags = [scope, label].filter(Boolean).join(" ");
331
- const prefix = `${stamp ? `${stamp} ` : ""}${indent}${tags ? `${tags} ` : ""}`;
332
- const lines = body.replace(/^(\s*\r?\n)+|(\r?\n\s*)+$/g, "").split(/\r?\n/);
333
- const pad = " ".repeat(color.width(stamp ? `${stamp} ` : "") + indent.length);
334
- return lines.map((line, i) => (i === 0 ? `${prefix}${line}`.trimEnd() : pad + line)).join("\n");
335
- };
336
- streamOf(badge.stream).write(`${render(paletteOf(badge.stream))}\n`);
337
- if (settings.file) _writeFile(settings.file, render(plain));
338
556
  };
339
557
 
558
+ refresh();
559
+
340
560
  /** @type {Logger} */
341
561
  const logger = {
342
- log: (...args) => (emit("log", args), logger),
343
- trace: (...args) => (emit("trace", args), logger),
344
- debug: (...args) => (emit("debug", args), logger),
345
- info: (...args) => (emit("info", args), logger),
346
- success: (...args) => (emit("success", args), logger),
347
- warn: (...args) => (emit("warn", args), logger),
348
- error: (...args) => (emit("error", args), logger),
349
- fatal: (...args) => (emit("fatal", args), logger),
562
+ log: (...args) => (on.log && emit("log", args), logger),
563
+ trace: (...args) => (on.trace && emit("trace", args), logger),
564
+ debug: (...args) => (on.debug && emit("debug", args), logger),
565
+ info: (...args) => (on.info && emit("info", args), logger),
566
+ success: (...args) => (on.success && emit("success", args), logger),
567
+ warn: (...args) => (on.warn && emit("warn", args), logger),
568
+ error: (...args) => (on.error && emit("error", args), logger),
569
+ fatal: (...args) => (on.fatal && emit("fatal", args), logger),
350
570
  group: (label = "") => {
351
571
  emit("log", [`▼ ${label}`.trimEnd()]);
352
572
  groups.push(label);
@@ -365,10 +585,10 @@ const _create = (options) => {
365
585
  if (start === undefined) return logger.warn(`Timer "${label}" does not exist`);
366
586
  timers.delete(label);
367
587
  const ms = Number(process.hrtime.bigint() - start) / 1e6;
368
- return logger.log(`${label}: ${ms < 1000 ? `${Math.round(ms * 100) / 100}ms` : time.formatDuration(ms, { units: 2 })}`);
588
+ return logger.log(`${label}: ${ms < 1000 ? `${Math.round(ms * 100) / 100}ms` : require("./Time.js").formatDuration(ms, { units: 2 })}`);
369
589
  },
370
- table: (rows, tableOptions) => logger.log(require("./Cli.js").table(rows, { ...tableOptions, colors: paletteOf("stdout").level > 0 })),
371
- box: (text, boxOptions) => logger.log(require("./Cli.js").box(text, { ...boxOptions, colors: paletteOf("stdout").level > 0 })),
590
+ table: (rows, tableOptions) => logger.log(require("./Cli.js").table(rows, { ...tableOptions, colors: faceOf("stdout").colors })),
591
+ box: (text, boxOptions) => logger.log(require("./Cli.js").box(text, { ...boxOptions, colors: faceOf("stdout").colors })),
372
592
  divider: (title) => {
373
593
  const columns = /** @type {any} */ (streamOf("stdout")).columns ?? 80;
374
594
  const width = Math.max(10, Math.min(columns, 100) - (settings.timestamp ? 11 : 0) - groups.length * 2);
@@ -383,12 +603,13 @@ const _create = (options) => {
383
603
  scope: settings.scope ? `${settings.scope}:${scope}` : scope,
384
604
  }),
385
605
  setLevel: (level) => {
386
- if (!(level in LEVELS)) throw new RangeError(`Unknown log level "${level}"`);
606
+ if (!Object.hasOwn(LEVELS, level)) throw new RangeError(`Unknown log level "${level}"`);
387
607
  settings.level = level;
608
+ gate();
388
609
  return logger;
389
610
  },
390
611
  getLevel: () => settings.level,
391
- isLevelEnabled: (level) => settings.level !== "silent" && LEVELS[level] >= LEVELS[settings.level],
612
+ isLevelEnabled: (level) => threshold !== Infinity && (LEVELS[level] >= threshold || (debugScope && (level === "debug" || level === "trace"))),
392
613
  configure: (next) => {
393
614
  if (next.level !== undefined) logger.setLevel(next.level);
394
615
  if (next.scope !== undefined) settings.scope = next.scope;
@@ -400,6 +621,9 @@ const _create = (options) => {
400
621
  if (next.stderr !== undefined) settings.stderr = next.stderr;
401
622
  if (next.file !== undefined) settings.file = typeof next.file === "string" ? { path: next.file } : next.file;
402
623
  if (next.fields !== undefined) settings.fields = next.fields;
624
+ if (next.redact !== undefined) settings.redact = next.redact;
625
+ if (next.onLog !== undefined) settings.onLog = next.onLog;
626
+ refresh();
403
627
  return logger;
404
628
  },
405
629
  };
@@ -431,7 +655,7 @@ function createLogger(options = {}) {
431
655
  * pretty-printed, and several arguments are joined with spaces.
432
656
  *
433
657
  * @example
434
- * nc.log("<% green ✔ Saved %> in <% bold 12ms %>");
658
+ * nc.log("<% green ✔ Saved%> in <% bold 12ms%>");
435
659
  * nc.log("user:", { id: 1, roles: ["admin"] });
436
660
  *
437
661
  * @param {...unknown} args
@@ -566,7 +790,7 @@ function groupEnd() {
566
790
  * @example
567
791
  * nc.timeStart("import");
568
792
  * await importProducts();
569
- * nc.timeEnd("import"); // "import: 1.24s"
793
+ * nc.timeEnd("import"); // "import: 1s 240ms"
570
794
  *
571
795
  * @param {string} [label="default"]
572
796
  * @returns {typeof import("./Logger")}