/** Minimal 5-field POSIX cron (UTC) as used by `on.schedule`: lists, ranges, steps and month/day names. */ const FIELD_RANGES: Array<[number, number]> = [ [0, 59], [0, 23], [1, 31], [1, 12], [0, 6], ]; const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; const DAY_FIELD = 2; const MONTH_FIELD = 3; const WEEKDAY_FIELD = 4; const SUNDAY_ALIAS = 7; const FIELD_COUNT = 5; const MINUTE_MS = 60_000; const DAY_MS = 24 * 60 * MINUTE_MS; const MAX_SEARCH_DAYS = 366 * 4; export type CronSchedule = { minutes: number[]; hours: number[]; days: Set; months: Set; weekdays: Set; dayRestricted: boolean; weekdayRestricted: boolean; }; function value(token: string, field: number): number { const lower = token.toLowerCase(); if (field === MONTH_FIELD && MONTHS.includes(lower)) return MONTHS.indexOf(lower) + 1; if (field === WEEKDAY_FIELD && DAYS.includes(lower)) return DAYS.indexOf(lower); if (!/^\d+$/.test(token)) throw new Error(`invalid cron value '${token}'`); const parsed = Number(token); return field === WEEKDAY_FIELD && parsed === SUNDAY_ALIAS ? 0 : parsed; } function parseField(source: string, field: number): number[] { const [min, max] = FIELD_RANGES[field]; const out = new Set(); for (const part of source.split(",")) { const [range, stepText] = part.split("/"); const step = stepText === undefined ? 1 : Number(stepText); if (!Number.isInteger(step) || step < 1) throw new Error(`invalid cron step in '${part}'`); let start = min; let end = max; if (range !== "*") { const [from, to] = range.split("-"); start = value(from, field); end = to === undefined ? (stepText === undefined ? start : max) : value(to, field); } if (start < min || end > (field === WEEKDAY_FIELD ? SUNDAY_ALIAS : max) || start > end) throw new Error(`cron value out of range in '${part}'`); for (let current = start; current <= end; current += step) out.add(field === WEEKDAY_FIELD ? current % SUNDAY_ALIAS : current); } return [...out].sort((a, b) => a - b); } export function parseCron(expression: string): CronSchedule { const fields = expression.trim().split(/\s+/); if (fields.length !== FIELD_COUNT) throw new Error("cron expressions need exactly 5 fields"); const [minutes, hours, days, months, weekdays] = fields.map((field, index) => parseField(field, index)); return { minutes, hours, days: new Set(days), months: new Set(months), weekdays: new Set(weekdays), dayRestricted: fields[DAY_FIELD] !== "*", weekdayRestricted: fields[WEEKDAY_FIELD] !== "*", }; } function dayMatches(schedule: CronSchedule, date: Date): boolean { if (!schedule.months.has(date.getUTCMonth() + 1)) return false; const dom = schedule.days.has(date.getUTCDate()); const dow = schedule.weekdays.has(date.getUTCDay()); if (schedule.dayRestricted && schedule.weekdayRestricted) return dom || dow; if (schedule.dayRestricted) return dom; if (schedule.weekdayRestricted) return dow; return true; } /** First fire time strictly after `after` (ms), or null if none within four years. */ export function nextFireTime(schedule: CronSchedule, after: number): number | null { const start = Math.floor(after / MINUTE_MS) * MINUTE_MS + MINUTE_MS; const firstDay = Math.floor(start / DAY_MS) * DAY_MS; for (let offset = 0; offset < MAX_SEARCH_DAYS; offset++) { const day = firstDay + offset * DAY_MS; if (!dayMatches(schedule, new Date(day))) continue; for (const hour of schedule.hours) { for (const minute of schedule.minutes) { const candidate = day + (hour * 60 + minute) * MINUTE_MS; if (candidate >= start) return candidate; } } } return null; } /** Smallest gap between consecutive fires over the next `samples` fires, used to reject overly frequent schedules. */ export function minimumInterval(schedule: CronSchedule, from: number, samples = 48): number { let previous = nextFireTime(schedule, from); let smallest = Number.POSITIVE_INFINITY; for (let index = 0; previous !== null && index < samples; index++) { const next = nextFireTime(schedule, previous); if (next === null) break; smallest = Math.min(smallest, next - previous); previous = next; } return smallest; }