/** * Lightweight cron expression parser and next-run calculator. * * Supports standard 5-field cron expressions: * * ┌────────────── minute (0–59) * │ ┌──────────── hour (0–23) * │ │ ┌────────── day of month (1–31) * │ │ │ ┌──────── month (1–12) * │ │ │ │ ┌────── day of week (0–6, Sun=0) * │ │ │ │ │ * * * * * * * * Supported syntax per field: * - `*` — any value * - `N` — exact value * - `N-M` — range (inclusive) * - `N/S` — start at N, step S (within field bounds) * - `* /S` — every S (from field min) [no space — shown to avoid comment termination] * - `N-M/S` — range with step * - `N,M,...` — list of values, ranges, or steps * * @module */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** * A parsed cron schedule. Each field is a sorted array of valid integer values * for that field. */ export interface ParsedCron { minutes: number[]; hours: number[]; daysOfMonth: number[]; months: number[]; daysOfWeek: number[]; } // --------------------------------------------------------------------------- // Field bounds // --------------------------------------------------------------------------- interface FieldSpec { min: number; max: number; name: string; } const FIELD_SPECS: FieldSpec[] = [ { min: 0, max: 59, name: "minute" }, { min: 0, max: 23, name: "hour" }, { min: 1, max: 31, name: "day of month" }, { min: 1, max: 12, name: "month" }, { min: 0, max: 6, name: "day of week" }, ]; // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /** * Parse a single cron field token (e.g. `"5"`, `"1-3"`, `"* /10"`, `"1,3,5"`). * * @returns Sorted deduplicated array of matching integer values within bounds. * @throws {Error} on invalid syntax or out-of-range values. */ function parseField(token: string, spec: FieldSpec): number[] { const values = new Set(); // Split on commas first — each part can be a value, range, or step const parts = token.split(","); for (const part of parts) { const trimmed = part.trim(); if (trimmed === "") { throw new Error(`Empty element in cron ${spec.name} field`); } // Check for step syntax: "X/S" where X is "*" or a range or a number const slashIdx = trimmed.indexOf("/"); if (slashIdx !== -1) { const base = trimmed.slice(0, slashIdx); const stepStr = trimmed.slice(slashIdx + 1); const step = parseInt(stepStr, 10); if (isNaN(step) || step <= 0) { throw new Error( `Invalid step "${stepStr}" in cron ${spec.name} field`, ); } let rangeStart = spec.min; let rangeEnd = spec.max; if (base === "*") { // */S — every S from field min } else if (base.includes("-")) { // N-M/S — range with step const [a, b] = base.split("-").map((s) => parseInt(s, 10)); if (isNaN(a!) || isNaN(b!)) { throw new Error( `Invalid range "${base}" in cron ${spec.name} field`, ); } rangeStart = a!; rangeEnd = b!; } else { // N/S — start at N, step S const start = parseInt(base, 10); if (isNaN(start)) { throw new Error( `Invalid start "${base}" in cron ${spec.name} field`, ); } rangeStart = start; } validateBounds(rangeStart, spec); validateBounds(rangeEnd, spec); for (let i = rangeStart; i <= rangeEnd; i += step) { values.add(i); } continue; } // Check for range syntax: "N-M" if (trimmed.includes("-")) { const [aStr, bStr] = trimmed.split("-"); const a = parseInt(aStr!, 10); const b = parseInt(bStr!, 10); if (isNaN(a) || isNaN(b)) { throw new Error( `Invalid range "${trimmed}" in cron ${spec.name} field`, ); } validateBounds(a, spec); validateBounds(b, spec); if (a > b) { throw new Error( `Invalid range ${a}-${b} in cron ${spec.name} field (start > end)`, ); } for (let i = a; i <= b; i++) { values.add(i); } continue; } // Wildcard if (trimmed === "*") { for (let i = spec.min; i <= spec.max; i++) { values.add(i); } continue; } // Single value const val = parseInt(trimmed, 10); if (isNaN(val)) { throw new Error( `Invalid value "${trimmed}" in cron ${spec.name} field`, ); } validateBounds(val, spec); values.add(val); } if (values.size === 0) { throw new Error(`Empty result for cron ${spec.name} field`); } return [...values].sort((a, b) => a - b); } function validateBounds(value: number, spec: FieldSpec): void { if (value < spec.min || value > spec.max) { throw new Error( `Value ${value} out of range [${spec.min}–${spec.max}] for cron ${spec.name} field`, ); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Parse a cron expression string into a structured {@link ParsedCron}. * * @param expression — A standard 5-field cron expression. * @returns Parsed cron with sorted valid values for each field. * @throws {Error} on invalid syntax. * * @example * ```ts * const parsed = parseCron("0 * * * *"); // every hour at minute 0 * // parsed.minutes === [0] * // parsed.hours === [0,1,2,...,23] * ``` */ export function parseCron(expression: string): ParsedCron { const trimmed = expression.trim(); if (!trimmed) { throw new Error("Cron expression must not be empty"); } const tokens = trimmed.split(/\s+/); if (tokens.length !== 5) { throw new Error( `Cron expression must have exactly 5 fields, got ${tokens.length}: "${trimmed}"`, ); } return { minutes: parseField(tokens[0]!, FIELD_SPECS[0]!), hours: parseField(tokens[1]!, FIELD_SPECS[1]!), daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!), months: parseField(tokens[3]!, FIELD_SPECS[3]!), daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!), }; } /** * Validate a cron expression string. Returns `null` if valid, or an error * message string if invalid. * * @param expression — A cron expression string to validate. * @returns `null` on success, error message on failure. */ export function validateCron(expression: string): string | null { try { parseCron(expression); return null; } catch (err) { return err instanceof Error ? err.message : String(err); } } /** * Calculate the next run time after `after` for the given parsed cron schedule. * * Starts from the minute immediately following `after` and walks forward * until a matching minute is found (up to a safety limit of ~4 years to * prevent infinite loops on impossible schedules). * * @param cron — Parsed cron schedule. * @param after — The reference date. The returned date will be strictly after this. * @returns The next matching `Date`, or `null` if no match found within the search window. */ export function nextCronTick(cron: ParsedCron, after: Date): Date | null { // Work in local minutes — start from the minute after `after` const d = new Date(after.getTime()); // Advance to the next whole minute d.setUTCSeconds(0, 0); d.setUTCMinutes(d.getUTCMinutes() + 1); // Safety: search up to 4 years worth of minutes (~2.1M iterations max). // Uses 366 to account for leap years. const MAX_CRON_SEARCH_YEARS = 4; const maxIterations = MAX_CRON_SEARCH_YEARS * 366 * 24 * 60; for (let i = 0; i < maxIterations; i++) { const month = d.getUTCMonth() + 1; // 1-12 const dayOfMonth = d.getUTCDate(); // 1-31 const dayOfWeek = d.getUTCDay(); // 0-6 const hour = d.getUTCHours(); // 0-23 const minute = d.getUTCMinutes(); // 0-59 // Check month if (!cron.months.includes(month)) { // Skip to the first day of the next matching month advanceToNextMonth(d, cron.months); continue; } // Check day of month AND day of week (both must match) if (!cron.daysOfMonth.includes(dayOfMonth) || !cron.daysOfWeek.includes(dayOfWeek)) { // Advance one day d.setUTCDate(d.getUTCDate() + 1); d.setUTCHours(0, 0, 0, 0); continue; } // Check hour if (!cron.hours.includes(hour)) { // Advance to next matching hour within the day const nextHour = findNext(cron.hours, hour); if (nextHour !== null) { d.setUTCHours(nextHour, 0, 0, 0); } else { // No matching hour left today — advance to next day d.setUTCDate(d.getUTCDate() + 1); d.setUTCHours(0, 0, 0, 0); } continue; } // Check minute if (!cron.minutes.includes(minute)) { const nextMin = findNext(cron.minutes, minute); if (nextMin !== null) { d.setUTCMinutes(nextMin, 0, 0); } else { // No matching minute left this hour — advance to next hour d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0); } continue; } // All fields match! return new Date(d.getTime()); } // No match found within the search window return null; } /** * Convenience: parse a cron expression and compute the next run time. * * @param expression — 5-field cron expression string. * @param after — Reference date (defaults to `new Date()`). * @returns The next matching Date, or `null` if no match within 4 years. * @throws {Error} if the cron expression is invalid. */ export function nextCronTickFromExpression( expression: string, after: Date = new Date(), ): Date | null { const cron = parseCron(expression); return nextCronTick(cron, after); } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Find the next value in `sortedValues` that is greater than `current`. * Returns `null` if no such value exists. */ function findNext(sortedValues: number[], current: number): number | null { for (const v of sortedValues) { if (v > current) return v; } return null; } /** * Advance `d` (mutated in place) to midnight UTC of the first day of the next * month whose 1-based month number is in `months`. */ function advanceToNextMonth(d: Date, months: number[]): void { let year = d.getUTCFullYear(); let month = d.getUTCMonth() + 1; // 1-based // Walk months forward until we find one in the set (max 48 iterations = 4 years) for (let i = 0; i < 48; i++) { month++; if (month > 12) { month = 1; year++; } if (months.includes(month)) { d.setUTCFullYear(year, month - 1, 1); d.setUTCHours(0, 0, 0, 0); return; } } }