Skip to content

Instantly share code, notes, and snippets.

@mary-ext
Created April 11, 2025 08:00
Show Gist options
  • Save mary-ext/d79bd9738482e5fff0bcf1ec2fa2aca9 to your computer and use it in GitHub Desktop.
Save mary-ext/d79bd9738482e5fff0bcf1ec2fa2aca9 to your computer and use it in GitHub Desktop.
/**
* interface representing a cron schedule.
*/
export interface CronSchedule {
/** minute */
minute: number[];
/** hour */
hour: number[];
/** day of month */
dayOfMonth: number[];
/** month */
month: number[];
/** day of week */
dayOfWeek: number[];
}
const TOKEN_RE = /^(?<start>\d+)(?:-(?<end>\d+))?(?:\/(?<step>\d+))?$/;
/**
* parses a cron expression into a schedule.
* @param expression cron expression to parse
* @returns resulting cron schedule
*/
export const parse = (expression: string): CronSchedule => {
const parts = expression.split(' ');
if (parts.length !== 5) {
throw new SyntaxError(`invalid cron expression; got ${parts.length} parts, expected 5`);
}
return {
minute: parseField(parts[0], 0, 59),
hour: parseField(parts[1], 0, 23),
dayOfMonth: parseField(parts[2], 1, 31),
month: parseField(parts[3], 1, 12),
dayOfWeek: parseField(parts[4], 0, 6),
};
};
const parseField = (field: string, min: number, max: number): number[] => {
if (field === '*') {
const values: number[] = [];
for (let i = min; i <= max; i++) {
values.push(i);
}
return values;
}
const values = new Set<number>();
const tokens = field.split(',');
for (const tok of tokens) {
const match = TOKEN_RE.exec(tok);
if (!match) {
throw new SyntaxError(`invalid cron expression; invalid token ${tok}`);
}
const start = parseInt(match.groups!.start);
const end = match.groups!.end ? parseInt(match.groups!.end) : start;
const step = match.groups!.step ? parseInt(match.groups!.step) : 1;
if (start < min || end > max) {
throw new SyntaxError(`invalid cron expression; token ${tok} is out of range`);
}
if (end < start) {
throw new SyntaxError(`invalid cron expression; token ${tok} is invalid`);
}
for (let i = start; i <= end; i += step) {
values.add(i);
}
}
return [...values].sort((a, b) => a - b);
};
/**
* tests if the given date matches the provided cron schedule.
* @param schedule cron schedule to test against
* @param date date to test
* @returns true if the date matches schedule, false otherwise
*/
export const matchesSchedule = (schedule: CronSchedule, date: Date): boolean => {
const minute = date.getMinutes();
const hour = date.getHours();
const dayOfMonth = date.getDate();
const month = date.getMonth() + 1;
const dayOfWeek = date.getDay();
return (
schedule.minute.includes(minute) &&
schedule.hour.includes(hour) &&
schedule.dayOfMonth.includes(dayOfMonth) &&
schedule.month.includes(month) &&
schedule.dayOfWeek.includes(dayOfWeek)
);
};
/**
* returns the next date that matches the provided cron schedule.
* @param schedule cron schedule to match against
* @param from optional start date, defaults to current time
* @returns the next date that matches the schedule
*/
export const nextOccurrence = (schedule: CronSchedule, from?: Date): Date => {
const date = from ? new Date(from) : new Date();
date.setSeconds(0, 0);
date.setMinutes(date.getMinutes() + 1);
while (true) {
const minute = date.getMinutes();
const hour = date.getHours();
const dayOfMonth = date.getDate();
const month = date.getMonth() + 1;
const dayOfWeek = date.getDay();
// check month first (largest time unit)
if (!schedule.month.includes(month)) {
// jump to the next valid month
const nextMonthIndex = schedule.month.findIndex((m) => m > month);
if (nextMonthIndex !== -1) {
// there's a valid month later this year
date.setMonth(schedule.month[nextMonthIndex] - 1, 1);
} else {
// move to first valid month of next year
date.setFullYear(date.getFullYear() + 1);
date.setMonth(schedule.month[0] - 1, 1);
}
date.setHours(0, 0, 0, 0);
continue;
}
// check day (both day of month and day of week must match)
if (!schedule.dayOfMonth.includes(dayOfMonth) || !schedule.dayOfWeek.includes(dayOfWeek)) {
// move to next day
date.setDate(dayOfMonth + 1);
date.setHours(0, 0, 0, 0);
continue;
}
// check hour
if (!schedule.hour.includes(hour)) {
// find next valid hour
const nextHourIndex = schedule.hour.findIndex((h) => h > hour);
if (nextHourIndex !== -1) {
// there's a valid hour later today
date.setHours(schedule.hour[nextHourIndex], 0, 0, 0);
} else {
// move to first hour of next day
date.setDate(dayOfMonth + 1);
date.setHours(schedule.hour[0], 0, 0, 0);
}
continue;
}
// check minute
if (!schedule.minute.includes(minute)) {
// find next valid minute
const nextMinuteIndex = schedule.minute.findIndex((m) => m > minute);
if (nextMinuteIndex !== -1) {
// there's a valid minute later this hour
date.setMinutes(schedule.minute[nextMinuteIndex], 0, 0);
} else {
// move to first minute of next hour
date.setHours(hour + 1, schedule.minute[0], 0, 0);
}
continue;
}
// all components match, we found the next occurrence
return date;
}
};
interface ScheduledTask {
name: string;
schedule: CronSchedule;
nextRun: Date;
run: () => Promise<void> | void;
timerId: number | null;
}
export interface ScheduledTaskOptions {
name: string;
cron: string;
run: () => Promise<void> | void;
}
export class Scheduler {
#tasks: ScheduledTask[] = [];
#running = false;
get running() {
return this.#running;
}
add({ name, cron, run }: ScheduledTaskOptions) {
const schedule = parse(cron);
const nextRun = nextOccurrence(schedule);
this.#tasks.push({ name, schedule, nextRun, run, timerId: null });
if (this.#running) {
this.#scheduleTask(this.#tasks[this.#tasks.length - 1]);
}
}
start() {
if (this.#running) {
return;
}
this.#running = true;
for (const task of this.#tasks) {
this.#scheduleTask(task);
}
}
stop() {
if (!this.#running) {
return;
}
this.#running = false;
for (const task of this.#tasks) {
if (task.timerId !== null) {
clearTimeout(task.timerId);
task.timerId = null;
}
}
}
#scheduleTask(task: ScheduledTask) {
const now = new Date();
if (task.nextRun < now) {
task.nextRun = nextOccurrence(task.schedule);
}
const delay = task.nextRun.getTime() - now.getTime();
if (task.timerId !== null) {
clearTimeout(task.timerId);
}
task.timerId = setTimeout(() => {
this.#executeTask(task);
}, delay);
}
#executeTask(task: ScheduledTask) {
task.timerId = null;
task.nextRun = nextOccurrence(task.schedule);
this.#scheduleTask(task);
(async () => {
try {
await task.run();
} catch (err) {
console.error(`error running task ${task.name}:`, err);
}
})();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment