@daloyjs/core 0.35.2 → 0.37.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/README.md +22 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +223 -1
  8. package/dist/app.js +358 -8
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
@@ -0,0 +1,546 @@
1
+ /**
2
+ * In-process scheduled tasks (cron) for DaloyJS.
3
+ *
4
+ * A queue-agnostic schedule primitive — the in-process counterpart to an
5
+ * external job queue. Where a queue answers *“run this work somewhere,
6
+ * eventually”*, the {@link Scheduler} answers *“run this work in
7
+ * this process, on this clock”* — the three things a production
8
+ * in-process scheduler needs:
9
+ *
10
+ * - **Flexible schedules.** Fixed intervals (`intervalMs`) or 5-field cron
11
+ * expressions (`cron`, with `@hourly`/`@daily`/… aliases and an optional
12
+ * IANA `timeZone`), parsed once into a fast matcher with no backtracking
13
+ * regex.
14
+ * - **Single-flight guarantees.** A task never overlaps itself: if a tick
15
+ * fires while the previous run is still in progress, the tick is *skipped*
16
+ * (and counted), so a slow task can never pile up unbounded concurrent runs.
17
+ * - **Graceful-shutdown integration.** {@link Scheduler.stop} clears every
18
+ * timer and waits for in-flight runs to settle (up to a deadline, then
19
+ * aborts their {@link AbortSignal}), so it slots cleanly into the app's
20
+ * `onClose` drain. Timers are `unref`'d, so a scheduler never keeps an
21
+ * otherwise-idle process alive on its own.
22
+ *
23
+ * Everything is built on Web-standard primitives (`AbortController`,
24
+ * `Intl.DateTimeFormat` for timezone wall-clock math, `setTimeout`), so it
25
+ * runs unchanged on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge, with
26
+ * zero runtime dependencies. Pair it with {@link App.cron} for an app-managed
27
+ * scheduler whose lifecycle is tied to graceful shutdown, or drive a
28
+ * {@link Scheduler} directly.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const scheduler = new Scheduler();
33
+ * scheduler.define({ name: "cleanup", cron: "0 * * * *" }, async ({ signal }) => {
34
+ * await purgeExpiredSessions({ signal });
35
+ * });
36
+ * scheduler.start();
37
+ * // ... later, during shutdown:
38
+ * await scheduler.stop(5_000);
39
+ * ```
40
+ *
41
+ * @module
42
+ * @since 0.37.0
43
+ */
44
+ /**
45
+ * Thrown when a cron expression cannot be parsed, or describes a time that can
46
+ * never occur (for example `0 0 30 2 *` — the 30th of February).
47
+ *
48
+ * @since 0.37.0
49
+ */
50
+ export class CronParseError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = "CronParseError";
54
+ }
55
+ }
56
+ const CRON_ALIASES = {
57
+ "@yearly": "0 0 1 1 *",
58
+ "@annually": "0 0 1 1 *",
59
+ "@monthly": "0 0 1 * *",
60
+ "@weekly": "0 0 * * 0",
61
+ "@daily": "0 0 * * *",
62
+ "@midnight": "0 0 * * *",
63
+ "@hourly": "0 * * * *",
64
+ };
65
+ const MONTH_NAMES = {
66
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
67
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
68
+ };
69
+ const DAY_NAMES = {
70
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
71
+ };
72
+ function resolveNamed(token, names) {
73
+ const lower = token.toLowerCase();
74
+ return lower in names ? String(names[lower]) : token;
75
+ }
76
+ /**
77
+ * Parse a single cron field (e.g. `"*\/5"`, `"1-5"`, `"1,15,30"`) into the set
78
+ * of allowed values within `[min, max]`. Parsing is purely arithmetic — it
79
+ * splits on `,`, `-`, and `/` and validates each integer — so there is no
80
+ * regular-expression backtracking to exploit.
81
+ */
82
+ function parseField(field, min, max, fieldName, names) {
83
+ const out = new Set();
84
+ for (const part of field.split(",")) {
85
+ if (part === "") {
86
+ throw new CronParseError(`Empty ${fieldName} segment in cron field "${field}".`);
87
+ }
88
+ const [rangePart, stepPart] = part.split("/");
89
+ let step = 1;
90
+ if (stepPart !== undefined) {
91
+ step = Number(stepPart);
92
+ if (!Number.isInteger(step) || step <= 0) {
93
+ throw new CronParseError(`Invalid step "${stepPart}" in ${fieldName} field.`);
94
+ }
95
+ }
96
+ let lo;
97
+ let hi;
98
+ if (rangePart === "*") {
99
+ lo = min;
100
+ hi = max;
101
+ }
102
+ else if (rangePart.includes("-")) {
103
+ const [a, b] = rangePart.split("-");
104
+ if (b === undefined) {
105
+ throw new CronParseError(`Invalid range "${rangePart}" in ${fieldName} field.`);
106
+ }
107
+ lo = Number(names ? resolveNamed(a, names) : a);
108
+ hi = Number(names ? resolveNamed(b, names) : b);
109
+ }
110
+ else {
111
+ lo = Number(names ? resolveNamed(rangePart, names) : rangePart);
112
+ hi = lo;
113
+ }
114
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
115
+ throw new CronParseError(`Non-integer value in ${fieldName} field "${field}".`);
116
+ }
117
+ if (lo < min || hi > max || lo > hi) {
118
+ throw new CronParseError(`Value out of range in ${fieldName} field "${field}" (allowed ${min}-${max}).`);
119
+ }
120
+ for (let v = lo; v <= hi; v += step)
121
+ out.add(v);
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Parse a 5-field cron expression (or a named alias) into a {@link CronFields}
127
+ * matcher.
128
+ *
129
+ * Supported syntax per field: `*`, lists (`1,2,3`), ranges (`1-5`), steps
130
+ * (`*\/5`, `1-10/2`), and case-insensitive month (`JAN`–`DEC`) / day
131
+ * (`SUN`–`SAT`) names. Day-of-week accepts both `0` and `7` for Sunday.
132
+ *
133
+ * @param expression - A cron expression or alias.
134
+ * @returns The compiled field sets.
135
+ * @throws {@link CronParseError} if the expression is malformed.
136
+ * @since 0.37.0
137
+ */
138
+ export function parseCron(expression) {
139
+ const trimmed = expression.trim();
140
+ const expanded = trimmed.startsWith("@") ? CRON_ALIASES[trimmed.toLowerCase()] : trimmed;
141
+ if (expanded === undefined) {
142
+ throw new CronParseError(`Unknown cron alias "${trimmed}".`);
143
+ }
144
+ const fields = expanded.split(/\s+/);
145
+ if (fields.length !== 5) {
146
+ throw new CronParseError(`Cron expression must have 5 fields, got ${fields.length}: "${expression}".`);
147
+ }
148
+ const [min, hr, dom, mon, dow] = fields;
149
+ const minute = parseField(min, 0, 59, "minute");
150
+ const hour = parseField(hr, 0, 23, "hour");
151
+ const dayOfMonth = parseField(dom, 1, 31, "day-of-month");
152
+ const month = parseField(mon, 1, 12, "month", MONTH_NAMES);
153
+ // Day-of-week allows 7 as an alias for Sunday; normalize 7 -> 0.
154
+ const dowRaw = parseField(dow.replace(/7/g, "0"), 0, 6, "day-of-week", DAY_NAMES);
155
+ return {
156
+ minute,
157
+ hour,
158
+ dayOfMonth,
159
+ month,
160
+ dayOfWeek: dowRaw,
161
+ domRestricted: dom !== "*",
162
+ dowRestricted: dow !== "*",
163
+ };
164
+ }
165
+ const WEEKDAY_INDEX = {
166
+ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
167
+ };
168
+ function wallClockOf(date, timeZone) {
169
+ if (timeZone === undefined || timeZone === "UTC") {
170
+ return {
171
+ minute: date.getUTCMinutes(),
172
+ hour: date.getUTCHours(),
173
+ dayOfMonth: date.getUTCDate(),
174
+ month: date.getUTCMonth() + 1,
175
+ dayOfWeek: date.getUTCDay(),
176
+ };
177
+ }
178
+ const parts = new Intl.DateTimeFormat("en-US", {
179
+ timeZone,
180
+ hour12: false,
181
+ year: "numeric",
182
+ month: "numeric",
183
+ day: "numeric",
184
+ hour: "numeric",
185
+ minute: "numeric",
186
+ weekday: "short",
187
+ }).formatToParts(date);
188
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "0";
189
+ let hour = Number(get("hour"));
190
+ if (hour === 24)
191
+ hour = 0; // some ICU builds render midnight as 24
192
+ return {
193
+ minute: Number(get("minute")),
194
+ hour,
195
+ dayOfMonth: Number(get("day")),
196
+ month: Number(get("month")),
197
+ dayOfWeek: WEEKDAY_INDEX[get("weekday")] ?? 0,
198
+ };
199
+ }
200
+ function matches(fields, wc) {
201
+ if (!fields.minute.has(wc.minute))
202
+ return false;
203
+ if (!fields.hour.has(wc.hour))
204
+ return false;
205
+ if (!fields.month.has(wc.month))
206
+ return false;
207
+ // Cron's day-of-month / day-of-week quirk: when BOTH are restricted, a match
208
+ // on EITHER counts (Vixie cron semantics). When only one is restricted, that
209
+ // one must match.
210
+ const domOk = fields.dayOfMonth.has(wc.dayOfMonth);
211
+ const dowOk = fields.dayOfWeek.has(wc.dayOfWeek);
212
+ if (fields.domRestricted && fields.dowRestricted)
213
+ return domOk || dowOk;
214
+ if (fields.domRestricted)
215
+ return domOk;
216
+ if (fields.dowRestricted)
217
+ return dowOk;
218
+ return true;
219
+ }
220
+ // Five years of minutes — a generous upper bound for finding the next match.
221
+ // A satisfiable cron matches far sooner; an unsatisfiable one (e.g. Feb 30)
222
+ // hits this cap and surfaces as a CronParseError instead of looping forever.
223
+ const MAX_LOOKAHEAD_MINUTES = 5 * 366 * 24 * 60;
224
+ /**
225
+ * Compute the next instant (strictly after `after`) that a cron expression
226
+ * matches, evaluated in `timeZone` (UTC by default).
227
+ *
228
+ * @param expression - A compiled {@link CronFields} or a raw cron expression.
229
+ * @param after - The instant to search after. Defaults to now.
230
+ * @param timeZone - IANA timezone the expression is evaluated in.
231
+ * @returns The next matching `Date`.
232
+ * @throws {@link CronParseError} if no match occurs within five years
233
+ * (an unsatisfiable expression).
234
+ * @since 0.37.0
235
+ */
236
+ export function nextCronRun(expression, after = new Date(), timeZone) {
237
+ const fields = typeof expression === "string" ? parseCron(expression) : expression;
238
+ // Advance to the start of the next whole minute.
239
+ const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
240
+ for (let i = 0; i < MAX_LOOKAHEAD_MINUTES; i++) {
241
+ const candidate = new Date(start + i * 60_000);
242
+ if (matches(fields, wallClockOf(candidate, timeZone)))
243
+ return candidate;
244
+ }
245
+ throw new CronParseError(`Cron expression matches no time within five years (unsatisfiable).`);
246
+ }
247
+ // ── scheduler ───────────────────────────────────────────────────────
248
+ const defaultTimers = {
249
+ set(callback, delayMs) {
250
+ const handle = setTimeout(callback, delayMs);
251
+ handle.unref?.();
252
+ return handle;
253
+ },
254
+ clear(handle) {
255
+ clearTimeout(handle);
256
+ },
257
+ };
258
+ /**
259
+ * An in-process task scheduler with cron / interval schedules, single-flight
260
+ * overlap protection, and graceful shutdown. See the module overview for the
261
+ * design rationale.
262
+ *
263
+ * @since 0.37.0
264
+ */
265
+ export class Scheduler {
266
+ #tasks = new Map();
267
+ #logger;
268
+ #now;
269
+ #timers;
270
+ #started = false;
271
+ #stopped = false;
272
+ constructor(options = {}) {
273
+ this.#logger = options.logger;
274
+ this.#now = options.now ?? Date.now;
275
+ this.#timers = options.timers ?? defaultTimers;
276
+ }
277
+ /** `true` once {@link start} has been called and {@link stop} has not. */
278
+ get running() {
279
+ return this.#started && !this.#stopped;
280
+ }
281
+ /** The number of registered tasks. */
282
+ get size() {
283
+ return this.#tasks.size;
284
+ }
285
+ /**
286
+ * Register a task. May be called before or after {@link start}; tasks added
287
+ * after start are scheduled immediately.
288
+ *
289
+ * @param def - The task definition. Exactly one of `intervalMs` / `cron`.
290
+ * @param handler - The function to run on each tick.
291
+ * @returns This scheduler, for chaining.
292
+ * @throws {RangeError} on invalid options (bad name, both/neither schedule,
293
+ * non-positive interval, negative timeout, duplicate name).
294
+ * @throws {@link CronParseError} if a `cron` expression is malformed.
295
+ */
296
+ define(def, handler) {
297
+ if (typeof def.name !== "string" || def.name.trim() === "") {
298
+ throw new RangeError("Scheduler task requires a non-empty name.");
299
+ }
300
+ if (this.#tasks.has(def.name)) {
301
+ throw new RangeError(`Scheduler task "${def.name}" is already defined.`);
302
+ }
303
+ const hasInterval = def.intervalMs !== undefined;
304
+ const hasCron = def.cron !== undefined;
305
+ if (hasInterval === hasCron) {
306
+ throw new RangeError(`Scheduler task "${def.name}" requires exactly one of intervalMs or cron.`);
307
+ }
308
+ if (hasInterval && (!Number.isInteger(def.intervalMs) || def.intervalMs <= 0)) {
309
+ throw new RangeError(`Scheduler task "${def.name}" intervalMs must be a positive integer.`);
310
+ }
311
+ if (def.timeoutMs !== undefined && (!Number.isInteger(def.timeoutMs) || def.timeoutMs < 0)) {
312
+ throw new RangeError(`Scheduler task "${def.name}" timeoutMs must be a non-negative integer.`);
313
+ }
314
+ const fields = hasCron ? parseCron(def.cron) : undefined;
315
+ const task = {
316
+ def,
317
+ handler,
318
+ ...(fields ? { fields } : {}),
319
+ timer: undefined,
320
+ running: false,
321
+ runs: 0,
322
+ failures: 0,
323
+ skipped: 0,
324
+ };
325
+ this.#tasks.set(def.name, task);
326
+ this.#logger?.debug({ event: "scheduler.task.defined", task: def.name, cron: def.cron, intervalMs: def.intervalMs }, `Scheduled task "${def.name}" defined`);
327
+ if (this.#started && !this.#stopped)
328
+ this.#arm(task, def.runOnStart === true);
329
+ return this;
330
+ }
331
+ /**
332
+ * Start the scheduler. Idempotent: a second call is a no-op. Each task is
333
+ * armed for its next run (or run immediately when `runOnStart` is set).
334
+ *
335
+ * @returns This scheduler, for chaining.
336
+ */
337
+ start() {
338
+ if (this.#started)
339
+ return this;
340
+ this.#started = true;
341
+ this.#stopped = false;
342
+ this.#logger?.info({ event: "scheduler.started", tasks: this.#tasks.size }, "Scheduler started");
343
+ for (const task of this.#tasks.values())
344
+ this.#arm(task, task.def.runOnStart === true);
345
+ return this;
346
+ }
347
+ /**
348
+ * Stop the scheduler gracefully. Clears every pending timer so no new runs
349
+ * start, then waits up to `graceMs` for in-flight runs to finish; any run
350
+ * still going when the grace period elapses has its {@link AbortSignal}
351
+ * aborted. Idempotent.
352
+ *
353
+ * @param graceMs - Milliseconds to wait for in-flight runs. Defaults to 5000.
354
+ * @returns A promise that resolves once all runs have settled (or been
355
+ * aborted and settled).
356
+ */
357
+ async stop(graceMs = 5_000) {
358
+ if (!this.#started || this.#stopped) {
359
+ this.#stopped = true;
360
+ return;
361
+ }
362
+ this.#stopped = true;
363
+ for (const task of this.#tasks.values()) {
364
+ if (task.timer !== undefined) {
365
+ this.#timers.clear(task.timer);
366
+ task.timer = undefined;
367
+ }
368
+ task.nextRunAt = undefined;
369
+ }
370
+ const inflight = () => [...this.#tasks.values()].filter((t) => t.current !== undefined);
371
+ if (inflight().length === 0) {
372
+ this.#logger?.info({ event: "scheduler.stopped" }, "Scheduler stopped");
373
+ return;
374
+ }
375
+ let timedOut = false;
376
+ const deadline = new Promise((resolve) => {
377
+ const t = this.#timers.set(() => {
378
+ timedOut = true;
379
+ resolve();
380
+ }, graceMs);
381
+ // Best-effort: nothing references the deadline handle after resolve.
382
+ void t;
383
+ });
384
+ const settled = Promise.all(inflight().map((t) => t.current.promise)).then(() => undefined);
385
+ await Promise.race([settled, deadline]);
386
+ if (timedOut) {
387
+ const stuck = inflight();
388
+ if (stuck.length > 0) {
389
+ this.#logger?.warn({ event: "scheduler.stop.timeout", tasks: stuck.map((t) => t.def.name) }, `Scheduler grace period elapsed; aborting ${stuck.length} in-flight task(s)`);
390
+ for (const t of stuck)
391
+ t.current.controller.abort();
392
+ // Wait for the aborted runs to unwind.
393
+ await Promise.all(stuck.map((t) => t.current.promise)).catch(() => undefined);
394
+ }
395
+ }
396
+ this.#logger?.info({ event: "scheduler.stopped" }, "Scheduler stopped");
397
+ }
398
+ /**
399
+ * Trigger a task immediately, out of band, respecting the single-flight
400
+ * guarantee (a manual run is skipped if the task is already running). The
401
+ * task's normal schedule is unaffected.
402
+ *
403
+ * @param name - The task name.
404
+ * @returns A promise that resolves when the manual run settles, with `true`
405
+ * if it ran or `false` if it was skipped because a run was in progress.
406
+ * @throws {RangeError} if no task with that name exists.
407
+ */
408
+ async runNow(name) {
409
+ const task = this.#tasks.get(name);
410
+ if (task === undefined) {
411
+ throw new RangeError(`Scheduler has no task named "${name}".`);
412
+ }
413
+ if (task.running) {
414
+ task.skipped++;
415
+ this.#logger?.warn({ event: "scheduler.task.overrun", task: name, trigger: "manual" }, `Manual run of "${name}" skipped: a run is already in progress`);
416
+ return false;
417
+ }
418
+ await this.#run(task);
419
+ return true;
420
+ }
421
+ /**
422
+ * Read a snapshot of a task's execution statistics.
423
+ *
424
+ * @param name - The task name.
425
+ * @returns The {@link TaskState}, or `undefined` if no such task exists.
426
+ */
427
+ getState(name) {
428
+ const task = this.#tasks.get(name);
429
+ return task ? this.#snapshot(task) : undefined;
430
+ }
431
+ /**
432
+ * List execution-statistics snapshots for every registered task.
433
+ *
434
+ * @returns A snapshot array in definition order.
435
+ */
436
+ list() {
437
+ return [...this.#tasks.values()].map((t) => this.#snapshot(t));
438
+ }
439
+ #snapshot(task) {
440
+ return {
441
+ name: task.def.name,
442
+ running: task.running,
443
+ runs: task.runs,
444
+ failures: task.failures,
445
+ skipped: task.skipped,
446
+ lastRunAt: task.lastRunAt,
447
+ lastDurationMs: task.lastDurationMs,
448
+ lastError: task.lastError,
449
+ nextRunAt: task.nextRunAt,
450
+ };
451
+ }
452
+ #arm(task, immediate) {
453
+ if (this.#stopped)
454
+ return;
455
+ if (immediate) {
456
+ task.nextRunAt = this.#now();
457
+ // Defer to a microtask-free timer so start()/define() return first.
458
+ task.timer = this.#timers.set(() => {
459
+ void this.#tick(task);
460
+ }, 0);
461
+ return;
462
+ }
463
+ const delay = this.#nextDelay(task);
464
+ task.nextRunAt = this.#now() + delay;
465
+ task.timer = this.#timers.set(() => {
466
+ void this.#tick(task);
467
+ }, delay);
468
+ }
469
+ #nextDelay(task) {
470
+ if (task.fields !== undefined) {
471
+ const next = nextCronRun(task.fields, new Date(this.#now()), task.def.timeZone);
472
+ return Math.max(0, next.getTime() - this.#now());
473
+ }
474
+ return task.def.intervalMs;
475
+ }
476
+ async #tick(task) {
477
+ task.timer = undefined;
478
+ if (this.#stopped)
479
+ return;
480
+ // Re-arm the next cadence tick FIRST (fixed-rate), so the schedule keeps
481
+ // its cadence independent of how long this run takes. Exactly one timer is
482
+ // ever pending per task.
483
+ const scheduledForMs = task.nextRunAt ?? this.#now();
484
+ this.#arm(task, false);
485
+ if (task.running) {
486
+ // Single-flight: a previous run is still going. Skip this tick.
487
+ task.skipped++;
488
+ this.#logger?.warn({ event: "scheduler.task.overrun", task: task.def.name, trigger: "tick" }, `Tick for "${task.def.name}" skipped: previous run still in progress`);
489
+ return;
490
+ }
491
+ await this.#run(task, scheduledForMs);
492
+ }
493
+ async #run(task, scheduledForMs) {
494
+ const controller = new AbortController();
495
+ const runCount = task.runs + 1;
496
+ const scheduledFor = new Date(scheduledForMs ?? this.#now());
497
+ task.running = true;
498
+ task.lastRunAt = this.#now();
499
+ let timeoutTimer;
500
+ let timedOut = false;
501
+ if (task.def.timeoutMs !== undefined && task.def.timeoutMs > 0) {
502
+ timeoutTimer = this.#timers.set(() => {
503
+ timedOut = true;
504
+ controller.abort();
505
+ }, task.def.timeoutMs);
506
+ }
507
+ const promise = (async () => {
508
+ const startedAt = this.#now();
509
+ try {
510
+ await task.handler({
511
+ name: task.def.name,
512
+ scheduledFor,
513
+ runCount,
514
+ signal: controller.signal,
515
+ });
516
+ task.lastError = undefined;
517
+ }
518
+ catch (error) {
519
+ task.failures++;
520
+ task.lastError = error;
521
+ this.#logger?.error({ event: "scheduler.task.failed", task: task.def.name, runCount, timedOut, err: serializeError(error) }, `Scheduled task "${task.def.name}" failed`);
522
+ try {
523
+ task.def.onError?.(error, { name: task.def.name, runCount, timedOut });
524
+ }
525
+ catch {
526
+ // Never let an onError handler crash the scheduler loop.
527
+ }
528
+ }
529
+ finally {
530
+ if (timeoutTimer !== undefined)
531
+ this.#timers.clear(timeoutTimer);
532
+ task.runs++;
533
+ task.lastDurationMs = this.#now() - startedAt;
534
+ task.running = false;
535
+ task.current = undefined;
536
+ }
537
+ })();
538
+ task.current = { controller, promise };
539
+ await promise;
540
+ }
541
+ }
542
+ function serializeError(error) {
543
+ if (error instanceof Error)
544
+ return { name: error.name, message: error.message };
545
+ return { message: String(error) };
546
+ }
@@ -5,7 +5,7 @@
5
5
  * - safeJsonParse: JSON parser that strips __proto__ / constructor / prototype
6
6
  * keys to prevent prototype-pollution attacks.
7
7
  * - sanitizeHeaderName / sanitizeHeaderValue: prevent CRLF header injection.
8
- * - timingSafeEqual: constant-time string comparison for token checks.
8
+ * - timingSafeEqual: length-independent string compare for fixed-length token checks.
9
9
  * - randomId: cryptographically strong request id.
10
10
  */
11
11
  /**
@@ -75,12 +75,32 @@ export declare function sanitizeHeaderName(name: string): string;
75
75
  */
76
76
  export declare function sanitizeHeaderValue(value: string): string;
77
77
  /**
78
- * Constant-time string comparison resistant to timing attacks. Use whenever
79
- * comparing secrets such as CSRF tokens, HMAC signatures, or API keys; never
80
- * use `===` for those comparisons.
81
- *
82
- * @param a - First string.
83
- * @param b - Second string.
78
+ * Length-independent string comparison resistant to the *first-mismatch*
79
+ * timing leak. Use whenever comparing secrets such as CSRF tokens, HMAC
80
+ * signatures, or API keys; never use `===` for those comparisons.
81
+ *
82
+ * The comparison always folds every character of the longer input into a
83
+ * single accumulator (no early return), so it does not reveal the position
84
+ * of the first differing character the way `===` does. The byte lengths are
85
+ * mixed in too, so inputs of different lengths can never compare equal.
86
+ *
87
+ * Caveats — read before using for anything other than fixed-length tokens:
88
+ *
89
+ * - **Length is not hidden.** The loop runs `max(a.length, b.length)`
90
+ * iterations, so the running time grows with the longer input. Intended
91
+ * for values whose length is fixed and public (hex/base64 tokens, HMAC
92
+ * digests, API keys). Do not rely on it to conceal the length of a
93
+ * secret from an attacker who controls the other side.
94
+ * - **Not a hardware constant-time primitive.** It compares UTF-16 code
95
+ * units via `charCodeAt`, and the engine's per-character access time is
96
+ * not provably uniform. For raw bytes you already hold in memory, prefer
97
+ * Node's `crypto.timingSafeEqual(Buffer, Buffer)`, which also rejects
98
+ * length mismatches outright.
99
+ * - **Compares code units, not bytes.** Fine for ASCII tokens; for
100
+ * arbitrary binary, compare `Uint8Array`s instead.
101
+ *
102
+ * @param a - First string (typically the attacker-supplied candidate).
103
+ * @param b - Second string (typically the expected secret).
84
104
  * @returns `true` when the strings have the same length and contents.
85
105
  * @since 0.1.0
86
106
  */
package/dist/security.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * - safeJsonParse: JSON parser that strips __proto__ / constructor / prototype
6
6
  * keys to prevent prototype-pollution attacks.
7
7
  * - sanitizeHeaderName / sanitizeHeaderValue: prevent CRLF header injection.
8
- * - timingSafeEqual: constant-time string comparison for token checks.
8
+ * - timingSafeEqual: length-independent string compare for fixed-length token checks.
9
9
  * - randomId: cryptographically strong request id.
10
10
  */
11
11
  import { PayloadTooLargeError, BadRequestError, } from "./errors.js";
@@ -160,12 +160,32 @@ export function sanitizeHeaderValue(value) {
160
160
  return value;
161
161
  }
162
162
  /**
163
- * Constant-time string comparison resistant to timing attacks. Use whenever
164
- * comparing secrets such as CSRF tokens, HMAC signatures, or API keys; never
165
- * use `===` for those comparisons.
166
- *
167
- * @param a - First string.
168
- * @param b - Second string.
163
+ * Length-independent string comparison resistant to the *first-mismatch*
164
+ * timing leak. Use whenever comparing secrets such as CSRF tokens, HMAC
165
+ * signatures, or API keys; never use `===` for those comparisons.
166
+ *
167
+ * The comparison always folds every character of the longer input into a
168
+ * single accumulator (no early return), so it does not reveal the position
169
+ * of the first differing character the way `===` does. The byte lengths are
170
+ * mixed in too, so inputs of different lengths can never compare equal.
171
+ *
172
+ * Caveats — read before using for anything other than fixed-length tokens:
173
+ *
174
+ * - **Length is not hidden.** The loop runs `max(a.length, b.length)`
175
+ * iterations, so the running time grows with the longer input. Intended
176
+ * for values whose length is fixed and public (hex/base64 tokens, HMAC
177
+ * digests, API keys). Do not rely on it to conceal the length of a
178
+ * secret from an attacker who controls the other side.
179
+ * - **Not a hardware constant-time primitive.** It compares UTF-16 code
180
+ * units via `charCodeAt`, and the engine's per-character access time is
181
+ * not provably uniform. For raw bytes you already hold in memory, prefer
182
+ * Node's `crypto.timingSafeEqual(Buffer, Buffer)`, which also rejects
183
+ * length mismatches outright.
184
+ * - **Compares code units, not bytes.** Fine for ASCII tokens; for
185
+ * arbitrary binary, compare `Uint8Array`s instead.
186
+ *
187
+ * @param a - First string (typically the attacker-supplied candidate).
188
+ * @param b - Second string (typically the expected secret).
169
189
  * @returns `true` when the strings have the same length and contents.
170
190
  * @since 0.1.0
171
191
  */
package/dist/session.js CHANGED
@@ -290,12 +290,12 @@ export function session(opts) {
290
290
  }
291
291
  internal.activeId = id;
292
292
  const regenerate = async (keepData) => {
293
+ // Only destroy a persisted session id. An id created earlier this
294
+ // request (or a prior mid-request rotation) was never written to the
295
+ // store, so there is nothing to destroy — we just discard it.
293
296
  if (internal.activeId && internal.originalId === internal.activeId) {
294
297
  await store.destroy(internal.activeId);
295
298
  }
296
- else if (internal.activeId && internal.activeId !== internal.originalId) {
297
- // We rotated mid-request previously; throw away the unsaved id.
298
- }
299
299
  const next = generator();
300
300
  if (!next)
301
301
  throw new Error("session(): generator returned an empty id.");