Cron Schedule Parser And Next Run
Parse five-field cron expressions with ranges, steps, lists and names, then compute the next matching UTC minute.
Code
export interface CronSchedule {
minutes: Set<number>;
hours: Set<number>;
daysOfMonth: Set<number>;
months: Set<number>;
daysOfWeek: Set<number>;
source: string;
}
const MONTH_NAMES: Record<string, number> = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
const DAY_NAMES: Record<string, number> = {
sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
};
function resolveValue(token: string, names?: Record<string, number>): number {
const normalized = token.trim().toLowerCase();
const named = names?.[normalized];
const value = named ?? Number(normalized);
if (!Number.isInteger(value)) throw new RangeError("invalid cron value: " + token);
return value;
}
function parseField(field: string, min: number, max: number, names?: Record<string, number>): Set<number> {
const values = new Set<number>();
for (const part of field.split(",")) {
const [rangePart, stepPart] = part.split("/");
const step = stepPart === undefined ? 1 : Number(stepPart);
if (!Number.isInteger(step) || step <= 0) throw new RangeError("invalid step: " + part);
let start: number;
let end: number;
if (rangePart === "*") {
start = min;
end = max;
} else if (rangePart.includes("-")) {
const [rawStart, rawEnd] = rangePart.split("-");
start = resolveValue(rawStart, names);
end = resolveValue(rawEnd, names);
} else {
start = resolveValue(rangePart, names);
end = stepPart === undefined ? start : max;
}
if (start < min || end > max || start > end) {
throw new RangeError("cron field out of range: " + part);
}
for (let value = start; value <= end; value += step) values.add(value);
}
if (values.size === 0) throw new RangeError("empty cron field: " + field);
return values;
}
export function parseCron(expression: string): CronSchedule {
const fields = expression.trim().split(/\s+/);
if (fields.length !== 5) throw new RangeError("cron expression must have five fields");
const [minutes, hours, daysOfMonth, months, daysOfWeek] = fields as [string, string, string, string, string];
return {
source: expression,
minutes: parseField(minutes, 0, 59),
hours: parseField(hours, 0, 23),
daysOfMonth: parseField(daysOfMonth, 1, 31),
months: parseField(months, 1, 12, MONTH_NAMES),
daysOfWeek: parseField(daysOfWeek, 0, 6, DAY_NAMES),
};
}
export function matchesCron(schedule: CronSchedule, date: Date): boolean {
const domRestricted = schedule.daysOfMonth.size !== 31;
const dowRestricted = schedule.daysOfWeek.size !== 7;
const domMatch = schedule.daysOfMonth.has(date.getUTCDate());
const dowMatch = schedule.daysOfWeek.has(date.getUTCDay());
const dayMatch = domRestricted && dowRestricted ? domMatch || dowMatch : domMatch && dowMatch;
return schedule.minutes.has(date.getUTCMinutes())
&& schedule.hours.has(date.getUTCHours())
&& schedule.months.has(date.getUTCMonth() + 1)
&& dayMatch;
}
export function nextCronRun(expression: string, from: Date = new Date()): Date {
const schedule = parseCron(expression);
const cursor = new Date(Math.floor(from.getTime() / 60000) * 60000 + 60000);
for (let attempt = 0; attempt < 527040; attempt += 1) {
if (matchesCron(schedule, cursor)) return new Date(cursor);
cursor.setUTCMinutes(cursor.getUTCMinutes() + 1);
}
throw new RangeError("no cron run found within one year");
}Report a problem with this exampleOpens a pre-filled issue on GitHub.