@ix-xs/node-comfort 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/Arr.js +13 -5
- package/src/Checker.js +56 -8
- package/src/Cli.js +60 -18
- package/src/Color.js +32 -6
- package/src/Crypto.js +429 -78
- package/src/Emitter.js +9 -9
- package/src/Env.js +90 -8
- package/src/FS.js +366 -56
- package/src/Http.js +263 -72
- package/src/Id.js +53 -15
- package/src/Logger.js +100 -22
- package/src/Num.js +2 -1
- package/src/Obj.js +225 -36
- package/src/SQLite.js +154 -97
- package/src/Schema.js +281 -40
- package/src/Str.js +77 -29
- package/src/Sys.js +123 -22
- package/src/Time.js +96 -22
- package/src/Utils.js +18 -3
- package/src/errors.js +75 -4
- package/src/internal/shared.js +68 -14
- package/src/internal/terminal.js +49 -0
- package/types/index.d.ts +28 -5
- package/types/src/Arr.d.ts +4 -2
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Checker.d.ts +29 -3
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +6 -4
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +2 -2
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +126 -28
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +6 -6
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +19 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +27 -20
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Http.d.ts +42 -5
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +11 -3
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +15 -2
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +104 -12
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +61 -5
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +32 -7
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +42 -9
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +10 -5
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +16 -6
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +6 -2
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +7 -0
- package/types/src/internal/shared.d.ts.map +1 -1
- package/types/src/internal/terminal.d.ts +13 -0
- 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,
|
|
21
|
-
* (ISO 8601 is safest)
|
|
22
|
-
*
|
|
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
|
-
*
|
|
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) =>
|
|
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
|
|
358
|
-
let
|
|
359
|
-
|
|
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
|
-
|
|
432
|
+
tokens++;
|
|
433
|
+
at += match[0].length;
|
|
368
434
|
}
|
|
369
|
-
if (!
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
/**
|
package/src/internal/shared.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
101
|
-
|
|
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
|
-
//
|
|
280
|
-
|
|
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
|
|
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 (
|
|
383
|
-
|
|
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 };
|