Cron Schedule Parser And Next Run

Parse five-field cron expressions with ranges, steps, lists and names, then compute the next matching UTC minute.

TypescriptResilience

Code

snippet
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");
}