Created
April 11, 2025 08:00
-
-
Save mary-ext/d79bd9738482e5fff0bcf1ec2fa2aca9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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