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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- 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
|
+
}
|