@stacksjs/cron 0.70.54 → 0.70.55

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/cron",
3
3
  "type": "module",
4
- "version": "0.70.54",
4
+ "version": "0.70.55",
5
5
  "description": "The Stacks cron.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -40,7 +40,8 @@
40
40
  "types": "dist/index.d.ts",
41
41
  "files": [
42
42
  "README.md",
43
- "dist"
43
+ "dist",
44
+ "src"
44
45
  ],
45
46
  "scripts": {
46
47
  "build": "bun build.ts",
@@ -0,0 +1,41 @@
1
+ declare module 'bun' {
2
+ type CronExpression =
3
+ | '* * * * *'
4
+ | '@yearly'
5
+ | '@annually'
6
+ | '@monthly'
7
+ | '@weekly'
8
+ | '@daily'
9
+ | '@midnight'
10
+ | '@hourly'
11
+ | (string & {})
12
+
13
+ interface BunCron {
14
+ /**
15
+ * Register an OS-level cron job that runs a JavaScript/TypeScript module on a schedule.
16
+ *
17
+ * @param path - Path to the script to run (resolved relative to caller)
18
+ * @param schedule - Cron expression (5-field) or predefined nickname (@daily, @hourly, etc.)
19
+ * @param title - Unique job identifier (alphanumeric, hyphens, underscores only)
20
+ */
21
+ (path: string, schedule: CronExpression, title: string): Promise<void>
22
+
23
+ /**
24
+ * Parse a cron expression and return the next matching UTC Date.
25
+ *
26
+ * @param expression - A 5-field cron expression or nickname
27
+ * @param relativeDate - Starting point for the search (defaults to Date.now())
28
+ * @returns The next Date matching the expression, or null if no match within ~4 years
29
+ */
30
+ parse(expression: CronExpression, relativeDate?: Date | number): Date | null
31
+
32
+ /**
33
+ * Remove a previously registered OS-level cron job by its title.
34
+ *
35
+ * @param title - The title of the cron job to remove
36
+ */
37
+ remove(title: string): Promise<void>
38
+ }
39
+
40
+ var cron: BunCron
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ /// <reference path="./bun-cron.d.ts" />
2
+
3
+ export * from './types'
4
+ export { parseCron } from './parser'
5
+
6
+ import { parseCron } from './parser'
7
+
8
+ // Parse a cron expression and return the next matching Date.
9
+ // Uses Bun's native cron parser when available, otherwise falls back
10
+ // to our own 5-field parser with identical behavior.
11
+ //
12
+ // Examples:
13
+ // parse('0 0 * * *') → next midnight UTC
14
+ // parse('@hourly') → next hour
15
+ // parse('0,15,30,45 * * * *') → next 15-min mark
16
+ export function parse(expression: string, relativeDate?: Date | number): Date | null {
17
+ // Use native Bun.cron.parse when available (ships with Bun.cron PR)
18
+ if (typeof Bun !== 'undefined' && Bun.cron?.parse) {
19
+ return Bun.cron.parse(expression, relativeDate)
20
+ }
21
+
22
+ return parseCron(expression, relativeDate)
23
+ }
24
+
25
+ /**
26
+ * Register an OS-level cron job that persists across process restarts.
27
+ *
28
+ * Requires Bun's native cron support (crontab on Linux, launchd on macOS,
29
+ * schtasks on Windows).
30
+ *
31
+ * The target script must export a `scheduled(controller)` handler:
32
+ * ```ts
33
+ * export default {
34
+ * async scheduled(controller) {
35
+ * // controller.cron, controller.scheduledTime
36
+ * await doWork()
37
+ * }
38
+ * }
39
+ * ```
40
+ *
41
+ * @param path - Path to the script to run
42
+ * @param schedule - Cron expression or nickname (@daily, @hourly, etc.)
43
+ * @param title - Unique job identifier (alphanumeric, hyphens, underscores)
44
+ */
45
+ export async function register(path: string, schedule: string, title: string): Promise<void> {
46
+ if (typeof Bun === 'undefined' || !Bun.cron) {
47
+ throw new Error('Bun.cron is not available. OS-level cron requires a Bun version with native cron support.')
48
+ }
49
+
50
+ await Bun.cron(path, schedule, title)
51
+ }
52
+
53
+ /**
54
+ * Remove a previously registered OS-level cron job.
55
+ *
56
+ * @param title - The title of the cron job to remove
57
+ */
58
+ export async function remove(title: string): Promise<void> {
59
+ if (typeof Bun === 'undefined' || !Bun.cron?.remove) {
60
+ throw new Error('Bun.cron is not available. OS-level cron requires a Bun version with native cron support.')
61
+ }
62
+
63
+ await Bun.cron.remove(title)
64
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Native 5-field cron expression parser.
3
+ *
4
+ * Implements the same API as Bun.cron.parse() — when Bun ships native
5
+ * cron support, this can be swapped out with a one-line change.
6
+ *
7
+ * Supports:
8
+ * - Standard 5-field format: minute hour dayOfMonth month dayOfWeek
9
+ * - Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
10
+ * - Operators: * (all), , (list), - (range), / (step)
11
+ * - Named values: JAN-DEC, SUN-SAT (case-insensitive)
12
+ * - POSIX OR logic: when both dayOfMonth and dayOfWeek are specified (neither *),
13
+ * the expression matches when either condition is true
14
+ */
15
+
16
+ const NICKNAMES: Record<string, string> = {
17
+ '@yearly': '0 0 1 1 *',
18
+ '@annually': '0 0 1 1 *',
19
+ '@monthly': '0 0 1 * *',
20
+ '@weekly': '0 0 * * 0',
21
+ '@daily': '0 0 * * *',
22
+ '@midnight': '0 0 * * *',
23
+ '@hourly': '0 * * * *',
24
+ }
25
+
26
+ const MONTH_NAMES: Record<string, number> = {
27
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
28
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
29
+ january: 1, february: 2, march: 3, april: 4, june: 6,
30
+ july: 7, august: 8, september: 9, october: 10, november: 11, december: 12,
31
+ }
32
+
33
+ const DAY_NAMES: Record<string, number> = {
34
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
35
+ sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6,
36
+ }
37
+
38
+ /**
39
+ * Track which 6-field expressions we've already warned about so a
40
+ * scheduler tick doesn't spam the log every iteration
41
+ * (stacksjs/stacks#1877 Cr-6, mirroring #1872 Q-12).
42
+ */
43
+ const sixFieldWarnedFor = new Set<string>()
44
+
45
+ function warnSecondsIgnored(expression: string, secondsField: string): void {
46
+ if (sixFieldWarnedFor.has(expression)) return
47
+ sixFieldWarnedFor.add(expression)
48
+ // eslint-disable-next-line no-console
49
+ console.warn(
50
+ `[cron] 6-field cron expression '${expression}' has seconds='${secondsField}' — the parser is 5-field only. `
51
+ + `Seconds field IGNORED. For second-precision use the scheduler's '.everySecond()' instead.`,
52
+ )
53
+ }
54
+
55
+ function resolveNames(value: string, names: Record<string, number>): string {
56
+ return value.replace(/[a-z]+/gi, match => {
57
+ const num = names[match.toLowerCase()]
58
+ if (num === undefined) throw new Error(`Invalid cron value: ${match}`)
59
+ return String(num)
60
+ })
61
+ }
62
+
63
+ function parseField(field: string, min: number, max: number, names?: Record<string, number>): Set<number> {
64
+ const resolved = names ? resolveNames(field, names) : field
65
+ const values = new Set<number>()
66
+
67
+ for (const part of resolved.split(',')) {
68
+ const stepMatch = part.match(/^(.+)\/(\d+)$/)
69
+ let range: string
70
+ let step = 1
71
+
72
+ if (stepMatch) {
73
+ range = stepMatch[1] ?? ''
74
+ step = Number.parseInt(stepMatch[2] ?? '', 10)
75
+ if (step <= 0) throw new Error(`Invalid step: ${step}`)
76
+ }
77
+ else {
78
+ range = part
79
+ }
80
+
81
+ if (range === '*') {
82
+ for (let i = min; i <= max; i += step) values.add(i)
83
+ }
84
+ else if (range.includes('-')) {
85
+ const [startStr, endStr] = range.split('-')
86
+ const start = Number.parseInt(startStr ?? '', 10)
87
+ const end = Number.parseInt(endStr ?? '', 10)
88
+ if (Number.isNaN(start) || Number.isNaN(end)) throw new Error(`Invalid range: ${range}`)
89
+ if (start < min || end > max) throw new Error(`Range out of bounds: ${range} (${min}-${max})`)
90
+ for (let i = start; i <= end; i += step) values.add(i)
91
+ }
92
+ else {
93
+ const num = Number.parseInt(range, 10)
94
+ if (Number.isNaN(num)) throw new Error(`Invalid cron value: ${range}`)
95
+ if (num < min || num > max) throw new Error(`Value out of range: ${num} (${min}-${max})`)
96
+ values.add(num)
97
+ }
98
+ }
99
+
100
+ return values
101
+ }
102
+
103
+ /**
104
+ * Options for `parseCron` (stacksjs/stacks#1877 Cr-5).
105
+ */
106
+ export interface ParseCronOptions {
107
+ /**
108
+ * IANA timezone (e.g. `'America/Los_Angeles'`) the cron expression
109
+ * should be interpreted against. When set, field comparisons run
110
+ * in local time within that zone — `'0 14 * * *'` fires at 14:00
111
+ * local instead of 14:00 UTC. Defaults to UTC for backwards-compat.
112
+ *
113
+ * **DST caveat (Cr-1, deferred):** spring-forward (e.g. 02:00→03:00
114
+ * in US Pacific) and fall-back (02:00 happens twice) can cause
115
+ * fires to be skipped or duplicated for expressions whose hour
116
+ * lands in the transition window. Most scheduled tasks don't care
117
+ * because they fire on the 0th minute of every hour. Tasks that
118
+ * MUST fire exactly once during the transition need a TZ-aware
119
+ * scheduler layer above this parser.
120
+ */
121
+ tz?: string
122
+ }
123
+
124
+ /**
125
+ * Parse a cron expression and return the next matching Date.
126
+ *
127
+ * @param expression - A 5-field cron expression or nickname (@daily, etc.)
128
+ * @param relativeDate - Starting point for the search (defaults to Date.now())
129
+ * @param options - Optional `{ tz }` for timezone-local interpretation
130
+ * @returns The next Date matching the expression, or null if no match within ~4 years
131
+ */
132
+ export function parseCron(expression: string, relativeDate?: Date | number, options: ParseCronOptions = {}): Date | null {
133
+ const trimmed = expression.trim()
134
+ const normalized = NICKNAMES[trimmed.toLowerCase()] ?? trimmed
135
+
136
+ let fields = normalized.split(/\s+/).filter(Boolean)
137
+
138
+ // 6-field cron (with leading seconds) is widely used by Quartz /
139
+ // Spring schedulers. This parser is 5-field, but rather than
140
+ // throw and break the user's app at boot, drop the seconds field
141
+ // with a once-per-expression warn (stacksjs/stacks#1877 Cr-6).
142
+ // If the seconds field is non-zero/non-wildcard, the user is
143
+ // expressing intent that's silently lost — the warn surfaces that.
144
+ if (fields.length === 6) {
145
+ const secondsField = fields[0]!
146
+ if (secondsField !== '0' && secondsField !== '*')
147
+ warnSecondsIgnored(expression, secondsField)
148
+ fields = fields.slice(1)
149
+ }
150
+
151
+ if (fields.length !== 5) {
152
+ throw new Error(`Invalid cron expression: expected 5 fields (or 6 with leading seconds), got ${fields.length}`)
153
+ }
154
+
155
+ const [minuteField, hourField, domField, monthField, dowField] = fields as [string, string, string, string, string]
156
+ const minutes = parseField(minuteField, 0, 59)
157
+ const hours = parseField(hourField, 0, 23)
158
+ const daysOfMonth = parseField(domField, 1, 31)
159
+ const months = parseField(monthField, 1, 12, MONTH_NAMES)
160
+ const daysOfWeek = parseField(dowField, 0, 7, DAY_NAMES)
161
+
162
+ // Normalize Sunday: 7 → 0
163
+ if (daysOfWeek.has(7)) {
164
+ daysOfWeek.add(0)
165
+ daysOfWeek.delete(7)
166
+ }
167
+
168
+ // POSIX: if both dayOfMonth and dayOfWeek are specified (neither is *), use OR logic
169
+ const domWild = domField === '*'
170
+ const dowWild = dowField === '*'
171
+
172
+ // Start point
173
+ const base = relativeDate instanceof Date
174
+ ? relativeDate.getTime()
175
+ : typeof relativeDate === 'number'
176
+ ? relativeDate
177
+ : Date.now()
178
+
179
+ if (Number.isNaN(base) || !Number.isFinite(base)) {
180
+ throw new Error('Invalid date')
181
+ }
182
+
183
+ // When a timezone is configured, build a part-extractor that reads
184
+ // local time in that zone instead of UTC (stacksjs/stacks#1877 Cr-5).
185
+ // The search loop still advances in UTC milliseconds, but field
186
+ // comparisons happen against the TZ-local view. The extra cost is
187
+ // an Intl.DateTimeFormat per iteration — measurable on a fast loop
188
+ // but negligible for the once-per-tick cron path.
189
+ const tz = options.tz
190
+ const getParts = tz
191
+ ? makeTzPartsExtractor(tz)
192
+ : (date: Date) => ({
193
+ year: date.getUTCFullYear(),
194
+ month: date.getUTCMonth() + 1,
195
+ day: date.getUTCDate(),
196
+ hour: date.getUTCHours(),
197
+ minute: date.getUTCMinutes(),
198
+ dow: date.getUTCDay(),
199
+ })
200
+
201
+ // Begin search from the next minute
202
+ const d = new Date(base)
203
+ d.setUTCSeconds(0, 0)
204
+ d.setUTCMinutes(d.getUTCMinutes() + 1)
205
+
206
+ // Search up to ~4 years
207
+ const maxTime = d.getTime() + 4 * 365.25 * 24 * 60 * 60 * 1000
208
+
209
+ while (d.getTime() < maxTime) {
210
+ const parts = getParts(d)
211
+
212
+ // Month check
213
+ if (!months.has(parts.month)) {
214
+ d.setUTCMonth(d.getUTCMonth() + 1, 1)
215
+ d.setUTCHours(0, 0, 0, 0)
216
+ continue
217
+ }
218
+
219
+ // Day check (POSIX OR logic)
220
+ const domMatch = daysOfMonth.has(parts.day)
221
+ const dowMatch = daysOfWeek.has(parts.dow)
222
+
223
+ let dayMatch: boolean
224
+ if (domWild && dowWild)
225
+ dayMatch = true
226
+ else if (domWild)
227
+ dayMatch = dowMatch
228
+ else if (dowWild)
229
+ dayMatch = domMatch
230
+ else
231
+ dayMatch = domMatch || dowMatch // POSIX OR
232
+
233
+ if (!dayMatch) {
234
+ d.setUTCDate(d.getUTCDate() + 1)
235
+ d.setUTCHours(0, 0, 0, 0)
236
+ continue
237
+ }
238
+
239
+ // Hour check
240
+ if (!hours.has(parts.hour)) {
241
+ d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0)
242
+ continue
243
+ }
244
+
245
+ // Minute check
246
+ if (!minutes.has(parts.minute)) {
247
+ d.setUTCMinutes(d.getUTCMinutes() + 1, 0, 0)
248
+ continue
249
+ }
250
+
251
+ return new Date(d.getTime())
252
+ }
253
+
254
+ return null // No match within ~4 years (e.g. Feb 30)
255
+ }
256
+
257
+ /**
258
+ * Build a part-extractor for the given IANA timezone. Uses
259
+ * `Intl.DateTimeFormat.formatToParts` which is fast in Bun and
260
+ * handles DST automatically (the same instant in UTC may produce
261
+ * different local parts on either side of a transition).
262
+ *
263
+ * The day-of-week is computed via the `weekday: 'short'` field
264
+ * mapped to 0-6 (Sunday = 0) to match the cron convention.
265
+ */
266
+ function makeTzPartsExtractor(tz: string): (d: Date) => { year: number, month: number, day: number, hour: number, minute: number, dow: number } {
267
+ const fmt = new Intl.DateTimeFormat('en-US', {
268
+ timeZone: tz,
269
+ hourCycle: 'h23',
270
+ year: 'numeric',
271
+ month: 'numeric',
272
+ day: 'numeric',
273
+ hour: 'numeric',
274
+ minute: 'numeric',
275
+ weekday: 'short',
276
+ })
277
+ const DOW_MAP: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
278
+ return (d: Date) => {
279
+ const parts = fmt.formatToParts(d)
280
+ let year = 0
281
+ let month = 0
282
+ let day = 0
283
+ let hour = 0
284
+ let minute = 0
285
+ let dow = 0
286
+ for (const p of parts) {
287
+ if (p.type === 'year') year = Number.parseInt(p.value, 10)
288
+ else if (p.type === 'month') month = Number.parseInt(p.value, 10)
289
+ else if (p.type === 'day') day = Number.parseInt(p.value, 10)
290
+ else if (p.type === 'hour') hour = Number.parseInt(p.value, 10) % 24 // 24:00 → 00 normalization
291
+ else if (p.type === 'minute') minute = Number.parseInt(p.value, 10)
292
+ else if (p.type === 'weekday') dow = DOW_MAP[p.value] ?? 0
293
+ }
294
+ return { year, month, day, hour, minute, dow }
295
+ }
296
+ }
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type CatchCallbackFn = (_error: Error) => void
2
+ export type ProtectCallbackFn = () => void
3
+
4
+ export type IntRange<Min extends number, Max extends number> = number extends Min | Max
5
+ ? never
6
+ : number | [Min | number, Max | number]