ingenium 0.0.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * 5-field crontab parser + "next fire time" calculator.
3
+ *
4
+ * Grammar (per field):
5
+ * - `*` every value
6
+ * - `N` literal
7
+ * - `N-M` inclusive range
8
+ * - `* / S` or `N-M / S` step
9
+ * - `A,B,C` list (each entry can be any of the above)
10
+ *
11
+ * Supported field ranges:
12
+ * minute 0-59
13
+ * hour 0-23
14
+ * dom 1-31
15
+ * month 1-12, or 3-letter names jan|feb|...|dec
16
+ * dow 0-6 (sunday=0), or 3-letter names sun|mon|...|sat
17
+ *
18
+ * Explicitly NOT supported (would need a different parser):
19
+ * - 6-field syntax with seconds
20
+ * - L (last day-of-month), W (weekday), # (nth-of-month)
21
+ * - Predefined macros (@hourly, @daily, ...)
22
+ *
23
+ * Day-of-month vs day-of-week conflict resolution: when BOTH `dom` and
24
+ * `dow` are restricted (i.e. neither is `*`), the cron fires when EITHER
25
+ * matches (this is the historical Vixie-cron behavior). When only one is
26
+ * restricted, only that one matters. This is what every other production
27
+ * cron implementation does and what users expect.
28
+ */
29
+
30
+ const MONTH_NAMES: Record<string, number> = {
31
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
32
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
33
+ }
34
+
35
+ const DOW_NAMES: Record<string, number> = {
36
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
37
+ }
38
+
39
+ interface FieldSpec {
40
+ min: number
41
+ max: number
42
+ /** Optional name → number map (months, weekdays). */
43
+ names?: Record<string, number>
44
+ }
45
+
46
+ const FIELDS: { minute: FieldSpec; hour: FieldSpec; dom: FieldSpec; month: FieldSpec; dow: FieldSpec } = {
47
+ minute: { min: 0, max: 59 },
48
+ hour: { min: 0, max: 23 },
49
+ dom: { min: 1, max: 31 },
50
+ month: { min: 1, max: 12, names: MONTH_NAMES },
51
+ dow: { min: 0, max: 6, names: DOW_NAMES },
52
+ }
53
+
54
+ /** Parsed match-set. `Set<number>` of all valid integers per field. */
55
+ export interface CronMatch {
56
+ minute: Set<number>
57
+ hour: Set<number>
58
+ dom: Set<number>
59
+ month: Set<number>
60
+ dow: Set<number>
61
+ /** Was the original `dom` field `*`? Used for dom/dow conflict resolution. */
62
+ domIsWild: boolean
63
+ /** Was the original `dow` field `*`? */
64
+ dowIsWild: boolean
65
+ }
66
+
67
+ /**
68
+ * Parse a 5-field crontab spec into a {@link CronMatch}. Throws on any
69
+ * malformed input — out-of-range, wrong field count, garbage characters.
70
+ */
71
+ export function parseCronSpec(spec: string): CronMatch {
72
+ if (typeof spec !== 'string') {
73
+ throw new Error(`ingenium: cron spec must be a string (got ${typeof spec})`)
74
+ }
75
+ const trimmed = spec.trim()
76
+ if (trimmed === '') throw new Error('ingenium: cron spec is empty')
77
+
78
+ const fields = trimmed.split(/\s+/)
79
+ if (fields.length !== 5) {
80
+ throw new Error(
81
+ `ingenium: cron spec must have exactly 5 fields (got ${fields.length}: "${spec}"). ` +
82
+ `Six-field specs with seconds are not supported in v0.0.1.`,
83
+ )
84
+ }
85
+
86
+ const [minuteF, hourF, domF, monthF, dowF] = fields as [string, string, string, string, string]
87
+ return {
88
+ minute: parseField(minuteF, FIELDS.minute, 'minute'),
89
+ hour: parseField(hourF, FIELDS.hour, 'hour'),
90
+ dom: parseField(domF, FIELDS.dom, 'day-of-month'),
91
+ month: parseField(monthF, FIELDS.month, 'month'),
92
+ dow: parseField(dowF, FIELDS.dow, 'day-of-week'),
93
+ domIsWild: domF === '*',
94
+ dowIsWild: dowF === '*',
95
+ }
96
+ }
97
+
98
+ function parseField(field: string, spec: FieldSpec, label: string): Set<number> {
99
+ const out = new Set<number>()
100
+ for (const part of field.split(',')) {
101
+ expandPart(part, spec, label, out)
102
+ }
103
+ if (out.size === 0) {
104
+ throw new Error(`ingenium: cron field "${label}" produced no matches ("${field}")`)
105
+ }
106
+ return out
107
+ }
108
+
109
+ function expandPart(part: string, spec: FieldSpec, label: string, out: Set<number>): void {
110
+ if (part === '') throw new Error(`ingenium: empty cron sub-expression in "${label}"`)
111
+
112
+ // Step: optional. Either `*/N`, `A-B/N`, or `A/N` (treated as `A-max/N`).
113
+ let step = 1
114
+ let body = part
115
+ const slashIdx = part.indexOf('/')
116
+ if (slashIdx >= 0) {
117
+ body = part.slice(0, slashIdx)
118
+ const stepStr = part.slice(slashIdx + 1)
119
+ if (!/^\d+$/.test(stepStr)) {
120
+ throw new Error(`ingenium: cron step in "${label}" must be a positive integer ("${part}")`)
121
+ }
122
+ step = parseInt(stepStr, 10)
123
+ if (step <= 0) {
124
+ throw new Error(`ingenium: cron step in "${label}" must be > 0 ("${part}")`)
125
+ }
126
+ }
127
+
128
+ let lo: number
129
+ let hi: number
130
+ if (body === '*') {
131
+ lo = spec.min
132
+ hi = spec.max
133
+ } else if (body.includes('-')) {
134
+ const [aStr, bStr] = body.split('-')
135
+ const a = parseAtom(aStr ?? '', spec, label)
136
+ const b = parseAtom(bStr ?? '', spec, label)
137
+ if (a > b) {
138
+ throw new Error(`ingenium: cron range "${body}" in "${label}" is reversed (${a} > ${b})`)
139
+ }
140
+ lo = a
141
+ hi = b
142
+ } else {
143
+ const v = parseAtom(body, spec, label)
144
+ if (slashIdx >= 0) {
145
+ // `N/S` form → `N-max/S`
146
+ lo = v
147
+ hi = spec.max
148
+ } else {
149
+ lo = hi = v
150
+ }
151
+ }
152
+
153
+ if (lo < spec.min || hi > spec.max) {
154
+ throw new Error(
155
+ `ingenium: cron value out of range in "${label}" — got ${lo}..${hi}, allowed ${spec.min}..${spec.max}`,
156
+ )
157
+ }
158
+
159
+ for (let i = lo; i <= hi; i += step) out.add(i)
160
+ }
161
+
162
+ function parseAtom(atom: string, spec: FieldSpec, label: string): number {
163
+ if (atom === '') throw new Error(`ingenium: empty cron atom in "${label}"`)
164
+ if (/^-?\d+$/.test(atom)) {
165
+ const n = parseInt(atom, 10)
166
+ if (n < 0) {
167
+ throw new Error(`ingenium: cron value in "${label}" must be non-negative ("${atom}")`)
168
+ }
169
+ return n
170
+ }
171
+ if (spec.names) {
172
+ const lower = atom.toLowerCase()
173
+ if (lower in spec.names) return spec.names[lower]!
174
+ }
175
+ throw new Error(`ingenium: cron atom "${atom}" in "${label}" is not a number or known name`)
176
+ }
177
+
178
+ // ─── Next-fire calculation ──────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Given a parsed {@link CronMatch}, find the next moment >= `from` that
182
+ * matches the spec, in the given IANA timezone. Returns `null` if none
183
+ * within ~5 years (defensive against pathological specs).
184
+ *
185
+ * Algorithm: walk forward minute-by-minute with smart skipping. We start
186
+ * one minute past `from` (cron fires at the START of each minute and we
187
+ * never want to re-fire the same slot back-to-back).
188
+ *
189
+ * Timezone handling:
190
+ * - For `'UTC'` we use direct UTC accessors — fast path.
191
+ * - For other zones we call `Intl.DateTimeFormat` to get the wall-clock
192
+ * fields in that zone for each candidate. This relies on Node's bundled
193
+ * ICU data; full-icu Node ships with a complete tz database.
194
+ *
195
+ * DST: by walking minute-by-minute on the UTC timeline and reading the
196
+ * wall-clock fields per-step, we naturally skip the "spring forward" gap
197
+ * (those minutes simply don't exist in the local clock so they can't match
198
+ * the user's local-time spec) and double-fire on "fall back" (the wall
199
+ * clock visits 1:30am twice; we fire each time). The latter matches Vixie
200
+ * cron's documented behavior — users wanting strict once-per-day semantics
201
+ * should pin their spec to UTC.
202
+ */
203
+ export function nextFireFrom(match: CronMatch, from: Date, timezone = 'UTC'): Date | null {
204
+ // Start at the next whole minute strictly after `from`.
205
+ const start = new Date(from.getTime() + 1)
206
+ start.setUTCSeconds(0, 0)
207
+ // If the rounding above happened to put us at-or-before `from`, bump.
208
+ if (start.getTime() <= from.getTime()) {
209
+ start.setTime(start.getTime() + 60_000)
210
+ start.setUTCSeconds(0, 0)
211
+ }
212
+
213
+ // Cap the search to ~5 years in case the spec matches nothing reachable.
214
+ const maxIterations = 5 * 366 * 24 * 60
215
+ const candidate = start
216
+ const reader = timezone === 'UTC' ? readUtc : makeIntlReader(timezone)
217
+
218
+ for (let i = 0; i < maxIterations; i++) {
219
+ const wall = reader(candidate)
220
+ if (matchesWall(match, wall)) return new Date(candidate.getTime())
221
+ // Step forward one minute.
222
+ candidate.setTime(candidate.getTime() + 60_000)
223
+ }
224
+ return null
225
+ }
226
+
227
+ interface WallClock {
228
+ minute: number
229
+ hour: number
230
+ dom: number
231
+ month: number // 1-12
232
+ dow: number // 0=sunday
233
+ }
234
+
235
+ function matchesWall(m: CronMatch, w: WallClock): boolean {
236
+ if (!m.minute.has(w.minute)) return false
237
+ if (!m.hour.has(w.hour)) return false
238
+ if (!m.month.has(w.month)) return false
239
+ // Vixie-cron dom/dow OR semantics: if BOTH are restricted, either match
240
+ // is sufficient. If only one is restricted, only it matters.
241
+ const domOk = m.dom.has(w.dom)
242
+ const dowOk = m.dow.has(w.dow)
243
+ if (m.domIsWild && m.dowIsWild) {
244
+ // Both wild → both sets are full → both ok (above) → fall through to true.
245
+ if (!domOk || !dowOk) return false
246
+ } else if (m.domIsWild) {
247
+ if (!dowOk) return false
248
+ } else if (m.dowIsWild) {
249
+ if (!domOk) return false
250
+ } else {
251
+ if (!domOk && !dowOk) return false
252
+ }
253
+ return true
254
+ }
255
+
256
+ function readUtc(d: Date): WallClock {
257
+ return {
258
+ minute: d.getUTCMinutes(),
259
+ hour: d.getUTCHours(),
260
+ dom: d.getUTCDate(),
261
+ month: d.getUTCMonth() + 1,
262
+ dow: d.getUTCDay(),
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Build a wall-clock reader for a non-UTC timezone using `Intl.DateTimeFormat`.
268
+ * The reader is reusable (we cache the formatter).
269
+ */
270
+ function makeIntlReader(timezone: string): (d: Date) => WallClock {
271
+ let fmt: Intl.DateTimeFormat
272
+ try {
273
+ fmt = new Intl.DateTimeFormat('en-US', {
274
+ timeZone: timezone,
275
+ hour12: false,
276
+ year: 'numeric',
277
+ month: '2-digit',
278
+ day: '2-digit',
279
+ hour: '2-digit',
280
+ minute: '2-digit',
281
+ second: '2-digit',
282
+ weekday: 'short',
283
+ })
284
+ } catch {
285
+ throw new Error(
286
+ `ingenium: invalid cron timezone "${timezone}". ` +
287
+ `Use a valid IANA zone (e.g. "America/Los_Angeles") or "UTC".`,
288
+ )
289
+ }
290
+ const dowMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
291
+
292
+ return (d: Date): WallClock => {
293
+ const parts = fmt.formatToParts(d)
294
+ let minute = 0, hour = 0, dom = 1, month = 1, dow = 0
295
+ for (const p of parts) {
296
+ switch (p.type) {
297
+ case 'minute': minute = parseInt(p.value, 10); break
298
+ case 'hour': {
299
+ // Intl returns "24" at midnight in en-US hour12:false on some Node versions; normalize.
300
+ const h = parseInt(p.value, 10)
301
+ hour = h === 24 ? 0 : h
302
+ break
303
+ }
304
+ case 'day': dom = parseInt(p.value, 10); break
305
+ case 'month': month = parseInt(p.value, 10); break
306
+ case 'weekday': dow = dowMap[p.value] ?? 0; break
307
+ }
308
+ }
309
+ return { minute, hour, dom, month, dow }
310
+ }
311
+ }
@@ -0,0 +1,49 @@
1
+ import { IngeniumCronJob, type CronHandler, type CronOptions } from './scheduler.ts'
2
+
3
+ /**
4
+ * Holds every registered {@link IngeniumCronJob} for an app. Mirrors the shape
5
+ * of `QueueRegistry` so the integration in `IngeniumApp` is symmetric.
6
+ *
7
+ * Cron jobs are NOT auto-started on registration — `startAll()` runs at
8
+ * compose time so handlers don't fire before the app is ready (e.g. before
9
+ * `app.decorate()` plugins have wired up `ctx`-style state the handler may
10
+ * inspect via the registry from another code path).
11
+ */
12
+ export class CronRegistry {
13
+ private readonly jobs: IngeniumCronJob[] = []
14
+ private started = false
15
+
16
+ /** Register a new cron job. Returns the job for advanced introspection. */
17
+ register(spec: string, opts: CronOptions, handler: CronHandler): IngeniumCronJob {
18
+ const job = new IngeniumCronJob(spec, opts, handler)
19
+ this.jobs.push(job)
20
+ // If the registry has already been started (e.g. plugin registered a
21
+ // cron after compose), start the new job immediately to match what
22
+ // happened to all earlier-registered jobs.
23
+ if (this.started) job.start()
24
+ return job
25
+ }
26
+
27
+ /** Number of registered jobs. */
28
+ count(): number {
29
+ return this.jobs.length
30
+ }
31
+
32
+ /** All registered job names (insertion order). */
33
+ names(): string[] {
34
+ return this.jobs.map((j) => j.name)
35
+ }
36
+
37
+ /** Start every registered job. Idempotent. */
38
+ startAll(): void {
39
+ if (this.started) return
40
+ this.started = true
41
+ for (const j of this.jobs) j.start()
42
+ }
43
+
44
+ /** Stop every registered job. In-flight handlers continue until they finish. */
45
+ stopAll(): void {
46
+ this.started = false
47
+ for (const j of this.jobs) j.stop()
48
+ }
49
+ }
@@ -0,0 +1,153 @@
1
+ import { nextFireFrom, parseCronSpec, type CronMatch } from './parser.ts'
2
+
3
+ /**
4
+ * Handler signature for `app.cron(...)` jobs. Receives the scheduled fire
5
+ * time AND a fresh `now` so handlers can detect drift / late starts.
6
+ */
7
+ export type CronHandler = (ctx: { now: Date; firedAt: Date }) => unknown | Promise<unknown>
8
+
9
+ /** Options for {@link IngeniumCronJob}. */
10
+ export interface CronOptions {
11
+ /** IANA timezone for the spec. Default `'UTC'`. */
12
+ timezone?: string
13
+ /**
14
+ * If `true`, fire once at `start()` BEFORE waiting for the next scheduled
15
+ * slot. The synthetic `firedAt` for this immediate run is `now`. Default `false`.
16
+ */
17
+ runOnStart?: boolean
18
+ /**
19
+ * Behavior when a previous run is still in flight at the next fire time.
20
+ * - `'skip'` → drop the new tick (default).
21
+ * - `'queue'` → queue ONE pending run; subsequent ticks during the same
22
+ * pile-up still drop. (We don't do unbounded queuing; that's an
23
+ * anti-pattern that hides bugs.)
24
+ */
25
+ overlap?: 'skip' | 'queue'
26
+ /** Optional name for logs / introspection. Defaults to the original spec. */
27
+ name?: string
28
+ }
29
+
30
+ /**
31
+ * A single scheduled cron job. Owns its parsed spec, a `setTimeout`-based
32
+ * one-shot rescheduler, and bookkeeping for in-flight runs.
33
+ *
34
+ * Lifecycle: `start()` arms the first timer; `stop()` cancels it. The
35
+ * timer is `unref()`'d so a registered cron alone never keeps the event
36
+ * loop alive — production apps that have an HTTP listener will keep
37
+ * running normally; standalone scripts will exit when other work finishes.
38
+ */
39
+ export class IngeniumCronJob {
40
+ readonly name: string
41
+ readonly spec: string
42
+ readonly timezone: string
43
+ readonly overlap: 'skip' | 'queue'
44
+ private readonly match: CronMatch
45
+ private readonly handler: CronHandler
46
+ private readonly runOnStart: boolean
47
+
48
+ private timer: NodeJS.Timeout | null = null
49
+ private inFlight = 0
50
+ private queuedRun: { firedAt: Date } | null = null
51
+ private nextAt: Date | null = null
52
+ private started = false
53
+ private stopped = false
54
+
55
+ constructor(spec: string, opts: CronOptions, handler: CronHandler) {
56
+ this.spec = spec
57
+ this.match = parseCronSpec(spec)
58
+ this.timezone = opts.timezone ?? 'UTC'
59
+ this.overlap = opts.overlap ?? 'skip'
60
+ this.runOnStart = opts.runOnStart ?? false
61
+ this.name = opts.name ?? spec
62
+ this.handler = handler
63
+ }
64
+
65
+ /** Arm the scheduler. Idempotent. */
66
+ start(): void {
67
+ if (this.started || this.stopped) return
68
+ this.started = true
69
+ if (this.runOnStart) {
70
+ // Fire immediately (synthetic `firedAt = now`), then schedule.
71
+ this.dispatch(new Date())
72
+ }
73
+ this.scheduleNext()
74
+ }
75
+
76
+ /** Cancel the scheduler. In-flight runs continue until they naturally finish. */
77
+ stop(): void {
78
+ this.stopped = true
79
+ this.started = false
80
+ if (this.timer) {
81
+ clearTimeout(this.timer)
82
+ this.timer = null
83
+ }
84
+ this.queuedRun = null
85
+ this.nextAt = null
86
+ }
87
+
88
+ /** Next scheduled fire time, or `null` if not started / stopped. */
89
+ nextRunAt(): Date | null {
90
+ return this.nextAt
91
+ }
92
+
93
+ /** Are there currently any in-flight invocations of the handler? */
94
+ isRunning(): boolean {
95
+ return this.inFlight > 0
96
+ }
97
+
98
+ /** @internal Test helper — does this job have its wake timer armed? */
99
+ hasArmedTimer(): boolean {
100
+ return this.timer !== null
101
+ }
102
+
103
+ private scheduleNext(): void {
104
+ if (this.stopped) return
105
+ const now = new Date()
106
+ const next = nextFireFrom(this.match, now, this.timezone)
107
+ if (!next) {
108
+ // Spec matches nothing reachable — give up silently rather than spin.
109
+ this.nextAt = null
110
+ return
111
+ }
112
+ this.nextAt = next
113
+ const delay = Math.max(1, next.getTime() - now.getTime())
114
+ this.timer = setTimeout(() => {
115
+ this.timer = null
116
+ const firedAt = next
117
+ this.dispatch(firedAt)
118
+ this.scheduleNext()
119
+ }, delay)
120
+ this.timer.unref?.()
121
+ }
122
+
123
+ private dispatch(firedAt: Date): void {
124
+ if (this.inFlight > 0) {
125
+ // Overlap path. `'skip'` drops; `'queue'` records ONE pending run.
126
+ if (this.overlap === 'queue' && this.queuedRun === null) {
127
+ this.queuedRun = { firedAt }
128
+ }
129
+ return
130
+ }
131
+ this.runOnce(firedAt)
132
+ }
133
+
134
+ private runOnce(firedAt: Date): void {
135
+ this.inFlight++
136
+ void Promise.resolve()
137
+ .then(() => this.handler({ now: new Date(), firedAt }))
138
+ .catch(() => {
139
+ // Cron handler errors are observation-only here; framework-level
140
+ // logging belongs in the registry layer (or a user-supplied
141
+ // `onError` if/when we add one). Do not crash the scheduler.
142
+ })
143
+ .finally(() => {
144
+ this.inFlight--
145
+ // If a queued run is waiting, drain it now.
146
+ if (this.queuedRun !== null && this.inFlight === 0 && !this.stopped) {
147
+ const pending = this.queuedRun
148
+ this.queuedRun = null
149
+ this.runOnce(pending.firedAt)
150
+ }
151
+ })
152
+ }
153
+ }