/** * Minimal 5-field cron parsing and next-run computation for scheduled task * runs. Framework-free and dependency-free so the scheduler and controller * share one tiny pure module. * * Grammar: five whitespace-separated fields, 分 时 日 月 周. Every field * supports the wildcard, step (wildcard or range + "/n"), single value, * inclusive range a-b, and comma lists mixing any of those. Ranges: minutes * 0-59, hours 0-23, days 1-31, months 1-12, weekdays 0-7 (0 and 7 both mean * Sunday). When both the day and weekday fields are restricted they combine * with OR semantics (standard cron). Invalid expressions parse to null and * are rejected by the UI/controller. */ /** The parsed match sets of one cron expression. */ export interface CronSchedule { minutes: ReadonlySet hours: ReadonlySet days: ReadonlySet months: ReadonlySet /** Weekdays 0-6, 0 = Sunday (input 7 normalized to 0). */ weekdays: ReadonlySet /** Whether the day-of-month field was the literal '*' (unrestricted). */ dayWildcard: boolean /** Whether the weekday field was the literal '*' (unrestricted). */ weekdayWildcard: boolean } /** Inclusive ranges per field, in cron order. */ const FIELD_RANGES: ReadonlyArray = [ [0, 59], // minutes [0, 23], // hours [1, 31], // days [1, 12], // months [0, 7], // weekdays (7 = Sunday, normalized below) ] /** * Parse a 5-field cron expression. * @returns the match sets, or null when the expression is invalid. */ export function parseCron(expr: string): CronSchedule | null { const fields = expr.trim().split(/\s+/) if (fields.length !== 5) return null const sets: Set[] = [] for (let index = 0; index < 5; index++) { const [min, max] = FIELD_RANGES[index] const set = new Set() if (!parseField(fields[index], min, max, set)) return null sets.push(set) } const weekdays = new Set() for (const day of sets[4]) weekdays.add(day === 7 ? 0 : day) return { minutes: sets[0], hours: sets[1], days: sets[2], months: sets[3], weekdays, // Only the literal '*' marks a field unrestricted: an explicit full // enumeration such as '1-31' is a restricted field and must not collapse // into the wildcard (it participates in day/weekday OR semantics). dayWildcard: fields[2] === '*', weekdayWildcard: fields[4] === '*', } } /** Whether the expression parses. */ export function isValidCron(expr: string): boolean { return parseCron(expr) !== null } /** * Compute the next matching instant after `fromMs` (ms epoch), in local time, * at minute granularity, strictly greater than `fromMs`. Returns the ms epoch * of the matching minute's start, or undefined when the calendar constraint * can never match (for example `0 0 30 2 *`). The five-year horizon includes * a full leap cycle, so a valid February 29 schedule remains reachable from * every non-leap year. * * Walks candidate year/month/day/hour/minute values straight from the parsed * field sets instead of scanning every minute: a sparse expression such as * `0 0 29 2 *` used to iterate ~1.5M wall-clock minutes before reaching the * next leap day. Wall-clock field construction + the final `matches` re-check * preserve the old minute scan's DST semantics exactly (nonexistent spring * minutes normalize forward and the repeated fall-back hour is never visited). */ export function nextRunAtMs(expr: string, fromMs: number): number | undefined { const schedule = parseCron(expr) if (schedule === null) return undefined if (!hasPossibleCalendarDay(schedule)) return undefined const from = new Date(fromMs) const limitMs = fromMs + 5 * 366 * 24 * 60 * 60 * 1000 const sortedMinutes = [...schedule.minutes].sort((a, b) => a - b) const sortedHours = [...schedule.hours].sort((a, b) => a - b) const sortedMonths = [...schedule.months].sort((a, b) => a - b) let year = from.getFullYear() let month = from.getMonth() + 1 let day = from.getDate() let hour = from.getHours() // Strictly after fromMs: the old scan started from the next minute. let minute = from.getMinutes() + 1 while (new Date(year, month - 1, 1, 0, 0, 0, 0).getTime() <= limitMs) { for (const candidateMonth of sortedMonths) { if (candidateMonth < month) continue const daysInMonth = new Date(year, candidateMonth, 0).getDate() const dayStart = candidateMonth === month ? day : 1 for (let candidateDay = dayStart; candidateDay <= daysInMonth; candidateDay += 1) { const dayProbe = new Date(year, candidateMonth - 1, candidateDay, 0, 0, 0, 0) if (!dayCandidate(schedule, dayProbe)) continue const hourStart = candidateMonth === month && candidateDay === day ? hour : 0 for (const candidateHour of sortedHours) { if (candidateHour < hourStart) continue const minuteStart = candidateMonth === month && candidateDay === day && candidateHour === hour ? minute : 0 for (const candidateMinute of sortedMinutes) { if (candidateMinute < minuteStart) continue const candidate = new Date(year, candidateMonth - 1, candidateDay, candidateHour, candidateMinute, 0, 0) const time = candidate.getTime() if (time <= fromMs) continue if (time > limitMs) return undefined if (matches(schedule, candidate)) return time } } } } year += 1 month = 1 day = 1 hour = 0 minute = 0 } return undefined } /** Day/weekday OR gate shared by {@link matches} and the candidate scan. */ function dayCandidate(schedule: CronSchedule, date: Date): boolean { const dayMatches = schedule.days.has(date.getDate()) const weekdayMatches = schedule.weekdays.has(date.getDay()) if (schedule.dayWildcard) return weekdayMatches if (schedule.weekdayWildcard) return dayMatches return dayMatches || weekdayMatches } /** Reject impossible month/day pairs without spending the multi-year scan. */ function hasPossibleCalendarDay(schedule: CronSchedule): boolean { if (schedule.dayWildcard || !schedule.weekdayWildcard) return true const maximumDay = new Map([ [1, 31], [2, 29], [3, 31], [4, 30], [5, 31], [6, 30], [7, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31], ]) for (const month of schedule.months) { const maximum = maximumDay.get(month) ?? 0 if ([...schedule.days].some(day => day <= maximum)) return true } return false } /** Parse one comma-list field into the match set. */ function parseField(field: string, min: number, max: number, out: Set): boolean { if (field === '*') { for (let value = min; value <= max; value++) out.add(value) return true } for (const part of field.split(',')) { if (part === '') return false const [range, stepRaw] = part.split('/') let low: number let high: number if (range === '*') { low = min high = max } else if (range.includes('-')) { const [a, b] = range.split('-') if (a === '' || b === '' || !isDigits(a) || !isDigits(b)) return false low = Number(a) high = Number(b) } else if (isDigits(range)) { low = Number(range) // Standard cron: a bare value carrying a step runs from that value to the // field maximum (minutes 5/15 are 5,20,35,50), while a bare value alone is // exactly itself (#1493). high = stepRaw === undefined ? low : max } else { return false } if (low < min || high > max || low > high) return false const step = stepRaw === undefined ? 1 : isDigits(stepRaw) ? Number(stepRaw) : NaN if (!Number.isInteger(step) || step < 1) return false for (let value = low; value <= high; value += step) out.add(value) } return true } /** Day/weekday OR semantics: a restricted day field alone gates, and vice versa. */ function matches(schedule: CronSchedule, date: Date): boolean { if (!schedule.minutes.has(date.getMinutes())) return false if (!schedule.hours.has(date.getHours())) return false if (!schedule.months.has(date.getMonth() + 1)) return false return dayCandidate(schedule, date) } function isDigits(value: string): boolean { return /^\d+$/.test(value) }