@ix-xs/node-comfort 2.1.0 → 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 (71) hide show
  1. package/CHANGELOG.md +153 -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 +369 -59
  12. package/src/Func.js +1 -1
  13. package/src/Http.js +266 -75
  14. package/src/Id.js +53 -15
  15. package/src/Logger.js +100 -22
  16. package/src/Num.js +3 -2
  17. package/src/Obj.js +225 -36
  18. package/src/SQLite.js +155 -98
  19. package/src/Schema.js +281 -40
  20. package/src/Str.js +77 -29
  21. package/src/Sys.js +124 -23
  22. package/src/Time.js +98 -24
  23. package/src/Utils.js +18 -3
  24. package/src/errors.js +75 -4
  25. package/src/internal/shared.js +68 -14
  26. package/src/internal/terminal.js +49 -0
  27. package/types/index.d.ts +28 -5
  28. package/types/src/Arr.d.ts +4 -2
  29. package/types/src/Arr.d.ts.map +1 -1
  30. package/types/src/Checker.d.ts +29 -3
  31. package/types/src/Checker.d.ts.map +1 -1
  32. package/types/src/Cli.d.ts +6 -4
  33. package/types/src/Cli.d.ts.map +1 -1
  34. package/types/src/Color.d.ts +2 -2
  35. package/types/src/Color.d.ts.map +1 -1
  36. package/types/src/Crypto.d.ts +126 -28
  37. package/types/src/Crypto.d.ts.map +1 -1
  38. package/types/src/Emitter.d.ts +6 -6
  39. package/types/src/Emitter.d.ts.map +1 -1
  40. package/types/src/Env.d.ts +19 -1
  41. package/types/src/Env.d.ts.map +1 -1
  42. package/types/src/FS.d.ts +30 -23
  43. package/types/src/FS.d.ts.map +1 -1
  44. package/types/src/Func.d.ts +1 -1
  45. package/types/src/Http.d.ts +45 -8
  46. package/types/src/Http.d.ts.map +1 -1
  47. package/types/src/Id.d.ts.map +1 -1
  48. package/types/src/Logger.d.ts +11 -3
  49. package/types/src/Logger.d.ts.map +1 -1
  50. package/types/src/Num.d.ts +1 -1
  51. package/types/src/Num.d.ts.map +1 -1
  52. package/types/src/Obj.d.ts +15 -2
  53. package/types/src/Obj.d.ts.map +1 -1
  54. package/types/src/SQLite.d.ts +105 -13
  55. package/types/src/SQLite.d.ts.map +1 -1
  56. package/types/src/Schema.d.ts +61 -5
  57. package/types/src/Schema.d.ts.map +1 -1
  58. package/types/src/Str.d.ts +32 -7
  59. package/types/src/Str.d.ts.map +1 -1
  60. package/types/src/Sys.d.ts +43 -10
  61. package/types/src/Sys.d.ts.map +1 -1
  62. package/types/src/Time.d.ts +12 -7
  63. package/types/src/Time.d.ts.map +1 -1
  64. package/types/src/Utils.d.ts +16 -6
  65. package/types/src/Utils.d.ts.map +1 -1
  66. package/types/src/errors.d.ts +6 -2
  67. package/types/src/errors.d.ts.map +1 -1
  68. package/types/src/internal/shared.d.ts +7 -0
  69. package/types/src/internal/shared.d.ts.map +1 -1
  70. package/types/src/internal/terminal.d.ts +13 -0
  71. package/types/src/internal/terminal.d.ts.map +1 -0
package/src/Time.js CHANGED
@@ -17,9 +17,10 @@
17
17
  const { types } = require("node:util");
18
18
 
19
19
  /**
20
- * A date: a `Date`, a timestamp in ms, or a string `new Date()` understands
21
- * (ISO 8601 is safest).
22
- * @typedef {Date | number | string} DateInput
20
+ * A date: a `Date`, a timestamp in ms, a string `new Date()` understands
21
+ * (ISO 8601 is safest), or an instant from `Temporal`, like a
22
+ * `Temporal.Instant` or a `Temporal.ZonedDateTime`.
23
+ * @typedef {Date | number | string | { readonly epochMilliseconds: number }} DateInput
23
24
  */
24
25
 
25
26
  /**
@@ -117,8 +118,13 @@ const { types } = require("node:util");
117
118
  */
118
119
 
119
120
  /**
120
- * A schedule returned by `every()` and `cron()`.
121
- * @typedef {object} ScheduledTask
121
+ * A schedule returned by `every()` and `cron()`. With `using`, it stops at
122
+ * the end of the block.
123
+ * @typedef {_ScheduledTaskMethods & Disposable} ScheduledTask
124
+ */
125
+
126
+ /**
127
+ * @typedef {object} _ScheduledTaskMethods
122
128
  * @property {() => void} stop Stops the schedule. A run in progress finishes.
123
129
  * @property {() => Date | undefined} next When the next run is planned.
124
130
  * @property {() => boolean} isRunning Whether a run is in progress.
@@ -145,7 +151,13 @@ const _config = shared.timeConfig;
145
151
  * @param {DateInput | undefined} value
146
152
  * @returns {Date}
147
153
  */
148
- const _toDate = (value) => (types.isDate(value) ? new Date(value.getTime()) : value === undefined ? new Date() : new Date(value));
154
+ const _toDate = (value) => {
155
+ if (types.isDate(value)) return new Date(value.getTime());
156
+ if (value === undefined) return new Date();
157
+ // Temporal.Instant and Temporal.ZonedDateTime say which instant they are.
158
+ if (typeof value === "object" && value !== null && typeof value.epochMilliseconds === "number") return new Date(value.epochMilliseconds);
159
+ return new Date(/** @type {string | number} */ (value));
160
+ };
149
161
 
150
162
  /**
151
163
  * @param {number} n
@@ -157,6 +169,34 @@ const _pad = (n, len = 2) => String(Math.abs(n)).padStart(len, "0");
157
169
  /** @type {Map<string, Intl.DateTimeFormat>} */
158
170
  const _dtfCache = new Map();
159
171
 
172
+ /**
173
+ * Temporal, where the runtime has it. Each zone is tried once: one that
174
+ * Temporal refuses goes through Intl, which then says what is wrong with it.
175
+ * @type {any}
176
+ */
177
+ const _temporal = typeof (/** @type {any} */ (globalThis).Temporal) === "object" ? /** @type {any} */ (globalThis).Temporal : undefined;
178
+ /** @type {Map<string, boolean>} */
179
+ const _temporalZones = new Map();
180
+ /**
181
+ * @param {string} timeZone
182
+ * @returns {boolean}
183
+ */
184
+ const _byTemporal = (timeZone) => {
185
+ if (!_temporal) return false;
186
+ let ok = _temporalZones.get(timeZone);
187
+ if (ok === undefined) {
188
+ try {
189
+ _temporal.Instant.fromEpochMilliseconds(0).toZonedDateTimeISO(timeZone);
190
+ ok = true;
191
+ } catch {
192
+ ok = false;
193
+ }
194
+ if (_temporalZones.size >= 500) _temporalZones.clear();
195
+ _temporalZones.set(timeZone, ok);
196
+ }
197
+ return ok;
198
+ };
199
+
160
200
  /**
161
201
  * Calendar parts of a date in a time zone.
162
202
  * @param {Date} d
@@ -170,6 +210,10 @@ const _parts = (d, timeZone) => {
170
210
  minute: d.getMinutes(), second: d.getSeconds(), ms: d.getMilliseconds(), weekday: d.getDay(),
171
211
  };
172
212
  }
213
+ if (_byTemporal(timeZone)) {
214
+ const z = _temporal.Instant.fromEpochMilliseconds(d.getTime()).toZonedDateTimeISO(timeZone);
215
+ return { year: z.year, month: z.month, day: z.day, hour: z.hour, minute: z.minute, second: z.second, ms: z.millisecond, weekday: z.dayOfWeek % 7 };
216
+ }
173
217
  let dtf = _dtfCache.get(timeZone);
174
218
  if (!dtf) {
175
219
  dtf = new Intl.DateTimeFormat("en-US", {
@@ -196,6 +240,7 @@ const _parts = (d, timeZone) => {
196
240
  */
197
241
  const _offset = (d, timeZone) => {
198
242
  if (!timeZone) return -d.getTimezoneOffset();
243
+ if (_byTemporal(timeZone)) return Math.round(_temporal.Instant.fromEpochMilliseconds(d.getTime()).toZonedDateTimeISO(timeZone).offsetNanoseconds / 6e10);
199
244
  const p = _parts(d, timeZone);
200
245
  const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second, p.ms);
201
246
  return Math.round((asUTC - d.getTime()) / 60_000);
@@ -246,6 +291,16 @@ const _daysIn = (year, month) => (month === 2 ? ((year % 4 === 0 && year % 100 !
246
291
  const _instantOf = (year, month, day, hour, minute, second, ms, timeZone) => {
247
292
  if (!timeZone) return new Date(year, month - 1, day, hour, minute, second, ms);
248
293
  const guess = Date.UTC(year, month - 1, day, hour, minute, second, ms);
294
+ if (_byTemporal(timeZone) && Number.isFinite(guess)) {
295
+ // Date.UTC carries what overflows (day 32, hour 24) like the code below does.
296
+ const at = new Date(guess);
297
+ const wall = _temporal.PlainDateTime.from({
298
+ year: at.getUTCFullYear(), month: at.getUTCMonth() + 1, day: at.getUTCDate(),
299
+ hour: at.getUTCHours(), minute: at.getUTCMinutes(), second: at.getUTCSeconds(), millisecond: at.getUTCMilliseconds(),
300
+ });
301
+ // "compatible": a time that happens twice keeps the first one, a skipped time moves past the gap.
302
+ return new Date(wall.toZonedDateTime(timeZone, { disambiguation: "compatible" }).epochMilliseconds);
303
+ }
249
304
  // The offsets a day before and a day after frame any clock change.
250
305
  const early = guess - _offset(new Date(guess - MS.d), timeZone) * 60_000;
251
306
  const late = guess - _offset(new Date(guess + MS.d), timeZone) * 60_000;
@@ -337,6 +392,12 @@ const _addMonths = (d, months, timeZone) => {
337
392
  return _instantOf(year, month, Math.min(at.day, _daysIn(year, month)), at.hour, at.minute, at.second, at.ms, timeZone);
338
393
  };
339
394
 
395
+ const _MAX_DURATION_TEXT = 256;
396
+
397
+ // One "1.5h" of a text duration, with the spaces, commas and "and" around it.
398
+ // Anchored, and run on what is left of the text: it never starts again further on.
399
+ const _DURATION_TOKEN = /^(?:[\s,]|and)*([-+]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)(?:[\s,]|and)*/;
400
+
340
401
  /**
341
402
  * Adds a duration written as text: a fixed one (`"1h30m"`) or one with
342
403
  * calendar units (`"1 month"`, `"2y 3d"`).
@@ -347,16 +408,20 @@ const _addMonths = (d, months, timeZone) => {
347
408
  * @returns {Date}
348
409
  */
349
410
  const _addText = (d, text, sign, timeZone) => {
411
+ // No duration is that long: a longer text is refused before any regex reads it.
412
+ if (text.length > _MAX_DURATION_TEXT && text.trim().length > _MAX_DURATION_TEXT) throw new TypeError(`Invalid duration: longer than ${_MAX_DURATION_TEXT} characters`);
350
413
  const fixed = shared.parseDuration(text);
351
414
  // Under a day there is no calendar to follow.
352
415
  if (fixed !== null && Math.abs(fixed) < MS.d) return new Date(d.getTime() + sign * fixed);
353
- const regex = /([-+]?\d*\.?\d+)\s*([a-zA-Z]+)/g;
354
416
  let months = 0;
355
417
  let days = 0;
356
418
  let ms = 0;
357
- let consumed = "";
358
- let match;
359
- while ((match = regex.exec(text)) !== null) {
419
+ let tokens = 0;
420
+ let at = 0;
421
+ // The tokens must follow each other and cover the whole text.
422
+ while (at < text.length) {
423
+ const match = _DURATION_TOKEN.exec(text.slice(at));
424
+ if (match === null) break;
360
425
  const unit = _unit(match[2]);
361
426
  if (!unit) break;
362
427
  const amount = Number(match[1]);
@@ -364,9 +429,10 @@ const _addText = (d, text, sign, timeZone) => {
364
429
  else if (unit === "y") months += amount * 12;
365
430
  else if ((unit === "d" || unit === "w") && Number.isInteger(amount)) days += unit === "w" ? amount * 7 : amount;
366
431
  else ms += amount * MS[unit];
367
- consumed += match[0];
432
+ tokens++;
433
+ at += match[0].length;
368
434
  }
369
- if (!consumed || consumed.replace(/\s+/g, "") !== text.replace(/[\s,]+|and/g, "")) {
435
+ if (!tokens || at !== text.length) {
370
436
  // A bare number of milliseconds, as text.
371
437
  if (fixed !== null) return new Date(d.getTime() + sign * fixed);
372
438
  throw new TypeError(`Invalid duration: ${JSON.stringify(text)}`);
@@ -802,7 +868,11 @@ function parse(text, pattern, options = {}) {
802
868
  if (expression.startsWith("(")) tokens.push(compiled[i]);
803
869
  source += expression + escape(compiled[i + 1]);
804
870
  }
805
- parser = { regex: new RegExp(`^\\s*${source}\\s*$`), tokens };
871
+ // Spaces at the ends of the text are allowed. Next to a pattern that starts or ends with a space,
872
+ // that "\s*" would make "\s+\s*", which takes a time that grows with the square of a run of spaces.
873
+ const lead = source.startsWith("\\s+") ? "" : "\\s*";
874
+ const trail = source.endsWith("\\s+") ? "" : "\\s*";
875
+ parser = { regex: new RegExp(`^${lead}${source}${trail}$`), tokens };
806
876
  if (_parsers.size >= 200) _parsers.clear();
807
877
  _parsers.set(pattern, parser);
808
878
  }
@@ -891,7 +961,7 @@ function toISODate(date, options = {}) {
891
961
  *
892
962
  * @param {DateInput} date
893
963
  * @param {number | string} amount A number with `unit`, or a duration like `"1h30m"` or `"1 month"`.
894
- * @param {CalendarUnit | TimeZoneOptions} [unit="ms"] The unit of a number. With a text amount there is none to give: the options can come here.
964
+ * @param {CalendarUnit | TimeZoneOptions} [unit="ms"] The unit of a number. A text amount carries its own units, so the options can come here instead.
895
965
  * @param {TimeZoneOptions} [options] The zone whose calendar is followed.
896
966
  * @returns {Date}
897
967
  * @throws {TypeError} If a text amount isn't a duration, or the unit isn't one.
@@ -939,7 +1009,7 @@ function subtract(date, amount, unit = "ms", options = {}) {
939
1009
  * @param {DateInput} a
940
1010
  * @param {DateInput} b
941
1011
  * @param {CalendarUnit} [unit="ms"]
942
- * @param {TimeZoneOptions} [options] The zone whose calendar months and years follow.
1012
+ * @param {TimeZoneOptions} [options] The zone whose calendar is followed for months and years.
943
1013
  * @returns {number}
944
1014
  * @throws {TypeError} If the unit isn't one.
945
1015
  */
@@ -1385,14 +1455,16 @@ function every(interval, task, options = {}) {
1385
1455
  };
1386
1456
  cancel = _longTimeout(tick, nextAt - Date.now(), options.unref ?? false);
1387
1457
 
1458
+ const stop = () => {
1459
+ stopped = true;
1460
+ cancel();
1461
+ };
1388
1462
  return {
1389
- stop: () => {
1390
- stopped = true;
1391
- cancel();
1392
- },
1463
+ stop,
1393
1464
  next: () => (stopped ? undefined : new Date(nextAt)),
1394
1465
  isRunning: () => running,
1395
1466
  runs: () => runs,
1467
+ [Symbol.dispose]: stop,
1396
1468
  };
1397
1469
  }
1398
1470
 
@@ -1521,15 +1593,17 @@ function cron(expression, task, options = {}) {
1521
1593
  });
1522
1594
  }
1523
1595
 
1596
+ const stop = () => {
1597
+ stopped = true;
1598
+ nextDate = undefined;
1599
+ cancel();
1600
+ };
1524
1601
  return {
1525
- stop: () => {
1526
- stopped = true;
1527
- nextDate = undefined;
1528
- cancel();
1529
- },
1602
+ stop,
1530
1603
  next: () => (stopped ? undefined : nextDate),
1531
1604
  isRunning: () => running,
1532
1605
  runs: () => runs,
1606
+ [Symbol.dispose]: stop,
1533
1607
  };
1534
1608
  }
1535
1609
 
package/src/Utils.js CHANGED
@@ -29,7 +29,12 @@ const { sleep, toMs } = require("./internal/shared.js");
29
29
 
30
30
  /**
31
31
  * The task returned by `when()`. Every method returns the task, so calls chain.
32
- * @typedef {object} WhenTask
32
+ * With `using`, it stops at the end of the block.
33
+ * @typedef {_WhenTaskMethods & Disposable} WhenTask
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} _WhenTaskMethods
33
38
  * @property {() => WhenTask} start Starts checking. A stopped task can't be restarted.
34
39
  * @property {() => WhenTask} stop Stops checking and clears the timers.
35
40
  * @property {<E extends "trigger" | "error" | "timeout">(event: E, handler: E extends "error" ? (error: unknown) => void : (payload: any) => void) => WhenTask} on Listens to `"trigger"` (the condition is true), `"error"` (it threw) or `"timeout"`.
@@ -41,8 +46,12 @@ const { sleep, toMs } = require("./internal/shared.js");
41
46
  */
42
47
 
43
48
  /**
44
- * Returned by `dontCrash()`.
45
- * @typedef {object} DontCrashController
49
+ * Returned by `dontCrash()`. With `using`, what it installed goes at the end of the block.
50
+ * @typedef {_DontCrashMethods & Disposable} DontCrashController
51
+ */
52
+
53
+ /**
54
+ * @typedef {object} _DontCrashMethods
46
55
  * @property {<E extends DontCrashEvent>(event: E, handler?: E extends "error" ? (error: unknown) => void : E extends "sig" ? (signal: NodeJS.Signals) => void : (code: number) => void) => DontCrashController} on Replaces the handler for an event. Call it without a handler to restore the default one.
47
56
  * @property {() => void} dispose Removes everything `dontCrash()` installed.
48
57
  */
@@ -155,6 +164,9 @@ function when(predicate, payload = {}, options = {}) {
155
164
  listeners[event]?.add(handler);
156
165
  return task;
157
166
  },
167
+ [Symbol.dispose]() {
168
+ halt();
169
+ },
158
170
  };
159
171
  return task;
160
172
  }
@@ -239,6 +251,9 @@ function dontCrash() {
239
251
  unlisten(installed.map(([event]) => event));
240
252
  if (_dontCrashDispose === controller.dispose) _dontCrashDispose = undefined;
241
253
  },
254
+ [Symbol.dispose]() {
255
+ controller.dispose();
256
+ },
242
257
  };
243
258
  _dontCrashDispose = controller.dispose;
244
259
  controller.on("error").on("exit").on("sig");
package/src/errors.js CHANGED
@@ -200,10 +200,65 @@ class ValidationError extends NodeComfortError {
200
200
  }
201
201
  }
202
202
 
203
+ // Query parameters whose value is likely a secret: api_key, access_token, sig, code...
204
+ const SECRET_PARAM = /key|token|secret|password|passwd|pwd|auth|sig|signature|session|credential|code/i;
205
+ // Headers `HttpError.toJSON()` leaves out: cookies and credentials, either way.
206
+ const SECRET_HEADERS = new Set(["set-cookie", "cookie", "authorization", "proxy-authorization", "www-authenticate", "proxy-authenticate", "x-api-key", "x-auth-token", "api-key"]);
207
+ // A body larger than this, in bytes, stays out of `HttpError.toJSON()`.
208
+ const JSON_DATA_LIMIT = 4096;
209
+
210
+ /** @param {string} name */
211
+ const _decodeName = (name) => {
212
+ try {
213
+ return decodeURIComponent(name.replace(/\+/g, " "));
214
+ } catch {
215
+ return name;
216
+ }
217
+ };
218
+
219
+ /**
220
+ * A URL with `***` for the values of the query parameters that look secret.
221
+ * The rest is left exactly as it was.
222
+ * @param {string} url
223
+ * @returns {string}
224
+ */
225
+ const _maskURL = (url) => {
226
+ const start = url.indexOf("?");
227
+ if (start === -1) return url;
228
+ const hash = url.indexOf("#", start);
229
+ const end = hash === -1 ? url.length : hash;
230
+ const query = url.slice(start + 1, end).split("&").map((pair) => {
231
+ const equals = pair.indexOf("=");
232
+ if (equals === -1 || equals === pair.length - 1) return pair;
233
+ const name = pair.slice(0, equals);
234
+ return SECRET_PARAM.test(_decodeName(name)) ? `${name}=***` : pair;
235
+ });
236
+ return `${url.slice(0, start + 1)}${query.join("&")}${url.slice(end)}`;
237
+ };
238
+
239
+ /**
240
+ * @param {unknown} data
241
+ * @returns {number} Roughly how many bytes it takes as JSON.
242
+ */
243
+ const _sizeOf = (data) => {
244
+ if (data === undefined || data === null) return 0;
245
+ if (typeof data === "string") return Buffer.byteLength(data);
246
+ if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) return data.byteLength;
247
+ try {
248
+ return Buffer.byteLength(JSON.stringify(data) ?? "");
249
+ } catch {
250
+ return Infinity;
251
+ }
252
+ };
253
+
203
254
  /**
204
255
  * Thrown by `nc.http` when the response status isn't 2xx (unless you pass
205
256
  * `throwHttpErrors: false`), and for network failures (`status` 0).
206
257
  *
258
+ * The values of query parameters that look secret (`api_key`, `token`,
259
+ * `sig`...) read `***` in the message and in `url`, so the error can go to
260
+ * logs as it is.
261
+ *
207
262
  * @example
208
263
  * try {
209
264
  * await nc.http.get(url);
@@ -213,17 +268,18 @@ class ValidationError extends NodeComfortError {
213
268
  */
214
269
  class HttpError extends NodeComfortError {
215
270
  /**
216
- * @param {string} message
271
+ * @param {string} message URLs in it have their secret query values masked.
217
272
  * @param {NodeComfortErrorOptions & { status?: number, statusText?: string, url?: string, method?: string, headers?: Record<string, string>, data?: unknown }} [options]
218
273
  */
219
274
  constructor(message, options = {}) {
220
- super(message, { ...options, code: options.code ?? "ERR_HTTP" });
275
+ // The lookbehind keeps it linear on a long "a.a.a..." (the server picks statusText).
276
+ super(String(message).replace(/(?<![a-z\d+.-])[a-z][a-z\d+.-]*:\/\/\S+/gi, _maskURL), { ...options, code: options.code ?? "ERR_HTTP" });
221
277
  /** HTTP status, like `404`. `0` when no response came back. */
222
278
  this.status = options.status ?? 0;
223
279
  /** HTTP status text, like `"Not Found"`. */
224
280
  this.statusText = options.statusText ?? "";
225
- /** The requested URL. */
226
- this.url = options.url ?? "";
281
+ /** The requested URL, with `***` for the values of query parameters that look secret. */
282
+ this.url = _maskURL(String(options.url ?? ""));
227
283
  /** The HTTP method, like `"GET"`. */
228
284
  this.method = options.method ?? "GET";
229
285
  /** Response headers, with lower-cased names. */
@@ -231,6 +287,21 @@ class HttpError extends NodeComfortError {
231
287
  /** The response body: parsed JSON when possible, text otherwise. */
232
288
  this.data = options.data;
233
289
  }
290
+
291
+ /**
292
+ * Like the base `toJSON()`, without what shouldn't reach a log or an API
293
+ * response: cookie and credential headers, and a body larger than 4 KB.
294
+ * `error.headers` and `error.data` themselves stay whole.
295
+ *
296
+ * @override
297
+ * @returns {Record<string, unknown>}
298
+ */
299
+ toJSON() {
300
+ const out = super.toJSON();
301
+ out.headers = Object.fromEntries(Object.entries(this.headers ?? {}).filter(([name]) => !SECRET_HEADERS.has(name.toLowerCase())));
302
+ if (_sizeOf(this.data) > JSON_DATA_LIMIT) delete out.data;
303
+ return out;
304
+ }
234
305
  }
235
306
 
236
307
  /**
@@ -43,7 +43,20 @@ const UNIT_ALIASES = {
43
43
  * @param {string} unit
44
44
  * @returns {keyof typeof MS | "M" | "y" | undefined}
45
45
  */
46
- const unitOf = (unit) => UNIT_ALIASES[unit] ?? UNIT_ALIASES[unit.toLowerCase()];
46
+ const unitOf = (unit) => {
47
+ // Own keys only: "constructor" or "toString" are not units.
48
+ if (Object.hasOwn(UNIT_ALIASES, unit)) return UNIT_ALIASES[unit];
49
+ const lower = unit.toLowerCase();
50
+ return Object.hasOwn(UNIT_ALIASES, lower) ? UNIT_ALIASES[lower] : undefined;
51
+ };
52
+
53
+ // Longer texts are refused, and never cached: no real duration or cron
54
+ // expression comes close, so they can only be junk or an attack.
55
+ const MAX_TEXT = 256;
56
+
57
+ // One "1.5h" of a text duration, with the spaces, commas and "and" around it.
58
+ // Sticky, so the tokens must follow each other and cover the whole text.
59
+ const DURATION_TOKEN = /(?:[\s,]|and)*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)(?:[\s,]|and)*/y;
47
60
 
48
61
  /** @type {Map<string, number | null>} */
49
62
  const _durations = new Map();
@@ -53,6 +66,7 @@ const _durations = new Map();
53
66
  * @returns {number | null}
54
67
  */
55
68
  const _readDuration = (text) => {
69
+ if (text.length > MAX_TEXT) return null;
56
70
  if (/^[-+]?\d+(?:\.\d+)?$/.test(text)) return Number(text);
57
71
 
58
72
  const iso = /^([-+])?P(?:(\d+(?:[.,]\d+)?)W)?(?:(\d+(?:[.,]\d+)?)D)?(?:T(?:(\d+(?:[.,]\d+)?)H)?(?:(\d+(?:[.,]\d+)?)M)?(?:(\d+(?:[.,]\d+)?)S)?)?$/i.exec(text);
@@ -70,18 +84,16 @@ const _readDuration = (text) => {
70
84
  return sign === "-" ? -total : total;
71
85
  }
72
86
 
73
- const regex = /(-?\d*\.?\d+)\s*([a-zA-Z]+)/g;
74
87
  let total = 0;
75
- let consumed = "";
76
88
  let match;
77
- while ((match = regex.exec(text)) !== null) {
89
+ DURATION_TOKEN.lastIndex = 0;
90
+ while (DURATION_TOKEN.lastIndex < text.length && (match = DURATION_TOKEN.exec(text)) !== null) {
78
91
  const unit = unitOf(match[2]);
79
92
  if (!unit || unit === "M" || unit === "y") return null;
80
93
  total += Number(match[1]) * MS[unit];
81
- consumed += match[0];
82
94
  }
83
- if (!consumed || consumed.replace(/\s+/g, "") !== text.replace(/[\s,]+|and/g, "")) return null;
84
- return total;
95
+ // A token that doesn't fit stops the loop before the end.
96
+ return match && DURATION_TOKEN.lastIndex === text.length ? total : null;
85
97
  };
86
98
 
87
99
  /**
@@ -97,8 +109,10 @@ const parseDuration = (input) => {
97
109
  const text = input.trim();
98
110
  const result = text ? _readDuration(text) : null;
99
111
  // Durations are almost always literals, so a handful of entries covers a whole program.
100
- if (_durations.size >= 500) _durations.clear();
101
- _durations.set(input, result);
112
+ if (input.length <= MAX_TEXT) {
113
+ if (_durations.size >= 500) _durations.clear();
114
+ _durations.set(input, result);
115
+ }
102
116
  return result;
103
117
  };
104
118
 
@@ -253,6 +267,30 @@ const _identity = (value) => {
253
267
  return id;
254
268
  };
255
269
 
270
+ /**
271
+ * The replacer of `_keyPart()`. It reads the raw value (JSON has already
272
+ * called `toJSON()` on the one it passes) and tags it, so `"u"` and
273
+ * `undefined`, `"i1"` and `1n`, or a date and its ISO string stay apart.
274
+ * Only plain objects and arrays are read by content: a `Set`, a `Map` or a
275
+ * class instance is keyed by identity, like at the top level.
276
+ * @this {any}
277
+ * @param {string} key
278
+ * @returns {unknown}
279
+ */
280
+ const _replacer = function(key) {
281
+ const value = this[key];
282
+ switch (typeof value) {
283
+ case "string": return `s${value}`;
284
+ case "number": return Number.isFinite(value) && !Object.is(value, -0) ? value : _keyPart(value);
285
+ case "boolean": return value;
286
+ case "object": {
287
+ if (value === null || Array.isArray(value) || isPlainObject(value)) return value;
288
+ return types.isDate(value) ? `d${value.getTime()}` : `O${_identity(value)}`;
289
+ }
290
+ default: return _keyPart(value);
291
+ }
292
+ };
293
+
256
294
  /**
257
295
  * @param {unknown} value
258
296
  * @returns {string}
@@ -276,8 +314,9 @@ const _keyPart = (value) => {
276
314
  if (types.isDate(object)) return `d${object.getTime()}`;
277
315
  if (!Array.isArray(object) && !isPlainObject(object)) return `O${_identity(object)}`;
278
316
  try {
279
- // The replacer keeps what JSON drops or refuses: undefined, bigint, functions, symbols.
280
- return `j${JSON.stringify(object, (_, v) => (v !== null && typeof v === "object" ? v : typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : _keyPart(v)))}`;
317
+ // Every value comes out tagged: strings start with "s", everything JSON
318
+ // would drop or change (undefined, bigint, dates, NaN...) has its own tag.
319
+ return `j${JSON.stringify(object, _replacer)}`;
281
320
  } catch {
282
321
  return `O${_identity(object)}`;
283
322
  }
@@ -328,7 +367,9 @@ const _cronSpecs = new Map();
328
367
  const parseCron = (expression) => {
329
368
  const cached = _cronSpecs.get(expression);
330
369
  if (cached) return cached;
331
- const text = CRON_MACROS[expression.trim().toLowerCase()] ?? expression.trim();
370
+ const trimmed = expression.trim();
371
+ const macro = trimmed.toLowerCase();
372
+ const text = Object.hasOwn(CRON_MACROS, macro) ? CRON_MACROS[macro] : trimmed;
332
373
  const fields = text.split(/\s+/);
333
374
  if (fields.length !== 5 && fields.length !== 6) throw new SyntaxError(`Invalid cron expression "${expression}": expected 5 or 6 fields`);
334
375
  if (fields.length === 5) fields.unshift("0");
@@ -337,6 +378,8 @@ const parseCron = (expression) => {
337
378
  /** @type {Set<number>} */
338
379
  const values = new Set();
339
380
  for (const part of field.split(",")) {
381
+ // "*/5/x" or "1-5-x" is a typo, not a step or a range with something extra.
382
+ if (part.split("/").length > 2 || part.split("/")[0].split("-").length > 2) throw new SyntaxError(`Invalid field "${part}" in cron expression "${expression}"`);
340
383
  const [rangePart, stepPart] = part.split("/");
341
384
  const step = stepPart === undefined ? 1 : Number(stepPart);
342
385
  if (!Number.isInteger(step) || step < 1) throw new SyntaxError(`Invalid step "${part}" in cron expression "${expression}"`);
@@ -379,8 +422,10 @@ const parseCron = (expression) => {
379
422
  anyWeekday: fields[5] === "*" || fields[5] === "?",
380
423
  lastDay,
381
424
  };
382
- if (_cronSpecs.size >= 200) _cronSpecs.clear();
383
- _cronSpecs.set(expression, spec);
425
+ if (expression.length <= MAX_TEXT) {
426
+ if (_cronSpecs.size >= 200) _cronSpecs.clear();
427
+ _cronSpecs.set(expression, spec);
428
+ }
384
429
  return spec;
385
430
  };
386
431
 
@@ -393,7 +438,16 @@ const sleepSync = (ms) => {
393
438
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
394
439
  };
395
440
 
441
+ /**
442
+ * A function that undoes something, usable with `using` too: the undoing then
443
+ * happens at the end of the block.
444
+ * @param {() => void} fn
445
+ * @returns {(() => void) & Disposable}
446
+ */
447
+ const disposable = (fn) => /** @type {(() => void) & Disposable} */ (Object.defineProperty(fn, Symbol.dispose, { value: fn }));
448
+
396
449
  module.exports = {
450
+ disposable,
397
451
  UNSAFE_KEYS,
398
452
  MS,
399
453
  timeConfig,
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ // What the logger and the CLI print goes through here, so a value can't
4
+ // drive the terminal.
5
+
6
+ // What a terminal could act on, once colors, links, new lines and tabs are set aside.
7
+ const _UNSAFE = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u{2028}\u{2029}]/u;
8
+ // A whole link (opened, text, closed: one left open would take in everything
9
+ // printed after it), a color, a Windows new line, or one control character.
10
+ const _TERMINAL = /\x1b\]8;[^;\x00-\x1f\x7f-\x9f]*;([^\x00-\x1f\x7f-\x9f]+)(?:\x07|\x1b\\)((?:[^\x00-\x1f\x7f-\x9f\u{2028}\u{2029}]|\x1b\[[\d;:]*m)*?)\x1b\]8;;(?:\x07|\x1b\\)|\x1b\[[\d;:]*m|\r\n|[\x00-\x08\x0b-\x1f\x7f-\x9f\u{2028}\u{2029}]/gu;
11
+ const _SGR = /\x1b\[[\d;:]*m/g;
12
+ /** @type {Record<string, string>} */
13
+ const _NAMED = { "\b": "\\b", "\v": "\\v", "\f": "\\f", "\r": "\\r" };
14
+
15
+ /**
16
+ * A control character as text, like `\x1b` or `\r`.
17
+ * @param {string} char
18
+ */
19
+ const _visible = (char) => {
20
+ const code = char.charCodeAt(0);
21
+ return _NAMED[char] ?? (code > 0xff ? `\\u${code.toString(16).padStart(4, "0")}` : `\\x${code.toString(16).padStart(2, "0")}`);
22
+ };
23
+
24
+ /** @param {string} match */
25
+ const _keepStyles = (match) => (match.length > 1 ? match : _visible(match));
26
+
27
+ /**
28
+ * @param {string} match
29
+ * @param {string | undefined} url
30
+ * @param {string} label
31
+ */
32
+ const _dropStyles = (match, url, label) => {
33
+ if (url !== undefined) return `${label.replace(_SGR, "")} (${url})`;
34
+ return match === "\r\n" ? match : match.length > 1 ? "" : _visible(match);
35
+ };
36
+
37
+ /**
38
+ * Text that can't drive the terminal: every control character and escape
39
+ * sequence is shown as text (`\x1b`, `\r`), except new lines, tabs, colors
40
+ * and whole links like `link()` makes. Without colors, colors are removed
41
+ * too, and a link becomes `text (url)`. Used by the logger and the CLI on
42
+ * what they print.
43
+ * @param {string} text
44
+ * @param {boolean} colors
45
+ * @returns {string}
46
+ */
47
+ const _safe = (text, colors) => (_UNSAFE.test(text) ? text.replace(_TERMINAL, colors ? _keepStyles : _dropStyles) : text);
48
+
49
+ module.exports = { safe: _safe };