blob: 86b528f545e3104033a0c202b91f4c198161974b [file]
export type CronFieldName = 'minute' | 'hour' | 'day-of-month' | 'month' | 'day-of-week';
export type CronCompileErrorCode =
| 'invalid_field_count'
| 'unsupported_syntax'
| 'empty_list_item'
| 'invalid_step'
| 'invalid_range'
| 'reversed_range'
| 'invalid_integer'
| 'out_of_range'
| 'empty_field'
| 'unsatisfiable';
export interface CronCompileError {
readonly code: CronCompileErrorCode;
readonly field?: CronFieldName;
readonly min?: number;
readonly max?: number;
}
export interface CronSearchBounds {
/** Inclusive lower bound. The result is still strictly after `after`. */
readonly notBefore?: number;
/** Inclusive absolute upper bound. */
readonly notAfter?: number;
}
export interface CompiledCronExpression {
/** Find the next whole-minute occurrence in the host's local timezone. */
nextAfter(after: number, bounds?: CronSearchBounds): number | null;
}
export type CompileCronExpressionResult =
| { readonly ok: true; readonly value: CompiledCronExpression }
| { readonly ok: false; readonly error: CronCompileError };
interface CronFieldSpec {
readonly name: CronFieldName;
readonly min: number;
readonly max: number;
readonly normalizeSunday?: boolean;
}
interface ParsedCronField {
readonly wildcard: boolean;
readonly values: ReadonlySet<number>;
}
interface ParsedCronExpression {
readonly minute: ParsedCronField;
readonly hour: ParsedCronField;
readonly dayOfMonth: ParsedCronField;
readonly month: ParsedCronField;
readonly dayOfWeek: ParsedCronField;
}
type CronParseResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: CronCompileError };
const MINUTE_MS = 60_000;
const MINUTES_PER_DAY = 24 * 60;
const MAX_SEARCH_MINUTES = 8 * 366 * MINUTES_PER_DAY;
const MAX_DAYS_IN_MONTH = Object.freeze([31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
const FIELD_SPECS = Object.freeze({
minute: { name: 'minute', min: 0, max: 59 },
hour: { name: 'hour', min: 0, max: 23 },
dayOfMonth: { name: 'day-of-month', min: 1, max: 31 },
month: { name: 'month', min: 1, max: 12 },
dayOfWeek: { name: 'day-of-week', min: 0, max: 7, normalizeSunday: true },
} satisfies Record<string, CronFieldSpec>);
/**
* Compile the one canonical five-field cron grammar used by ScheduledTask and
* ScheduledTask cron schedules. Fields are numeric and support lists, ranges,
* and steps. Sunday may be 0 or 7.
*/
export function compileCronExpression(expression: string): CompileCronExpressionResult {
const parts = expression.split(' ');
if (parts.length !== 5) return parseError('invalid_field_count');
const minute = parseCronField(parts[0] ?? '', FIELD_SPECS.minute);
if (!minute.ok) return minute;
const hour = parseCronField(parts[1] ?? '', FIELD_SPECS.hour);
if (!hour.ok) return hour;
const dayOfMonth = parseCronField(parts[2] ?? '', FIELD_SPECS.dayOfMonth);
if (!dayOfMonth.ok) return dayOfMonth;
const month = parseCronField(parts[3] ?? '', FIELD_SPECS.month);
if (!month.ok) return month;
const dayOfWeek = parseCronField(parts[4] ?? '', FIELD_SPECS.dayOfWeek);
if (!dayOfWeek.ok) return dayOfWeek;
const parsed: ParsedCronExpression = {
minute: minute.value,
hour: hour.value,
dayOfMonth: dayOfMonth.value,
month: month.value,
dayOfWeek: dayOfWeek.value,
};
if (hasImpossibleCalendarDate(parsed)) {
return parseError('unsatisfiable', 'day-of-month');
}
return {
ok: true,
value: Object.freeze({
nextAfter(after: number, bounds?: CronSearchBounds): number | null {
return nextCronOccurrence(parsed, after, bounds);
},
}),
};
}
function parseCronField(input: string, spec: CronFieldSpec): CronParseResult<ParsedCronField> {
if (!/^[\d*,/\-]+$/.test(input)) return parseError('unsupported_syntax', spec.name);
const values = new Set<number>();
let wildcard = false;
for (const item of input.split(',')) {
if (!item) return parseError('empty_list_item', spec.name);
const stepParts = item.split('/');
if (stepParts.length > 2) return parseError('invalid_step', spec.name);
const base = stepParts[0] ?? '';
const hasStep = stepParts.length === 2;
const step = hasStep
? parseCronInteger(stepParts[1] ?? '', 1, spec.max - spec.min + 1, spec)
: ({ ok: true, value: 1 } as const);
if (!step.ok) return { ok: false, error: { ...step.error, code: 'invalid_step' } };
let start: number;
let end: number;
if (base === '*') {
wildcard = true;
start = spec.min;
end = spec.max;
} else if (base.includes('-')) {
const range = base.split('-');
if (range.length !== 2) return parseError('invalid_range', spec.name);
const parsedStart = parseCronInteger(range[0] ?? '', spec.min, spec.max, spec);
if (!parsedStart.ok) return parsedStart;
const parsedEnd = parseCronInteger(range[1] ?? '', spec.min, spec.max, spec);
if (!parsedEnd.ok) return parsedEnd;
if (parsedStart.value > parsedEnd.value) return parseError('reversed_range', spec.name);
start = parsedStart.value;
end = parsedEnd.value;
} else {
const parsed = parseCronInteger(base, spec.min, spec.max, spec);
if (!parsed.ok) return parsed;
start = parsed.value;
end = hasStep ? spec.max : start;
}
for (let candidate = start; candidate <= end; candidate += step.value) {
values.add(normalizeCronValue(candidate, spec));
}
}
if (values.size === 0) return parseError('empty_field', spec.name);
return { ok: true, value: { wildcard, values } };
}
function parseCronInteger(
input: string,
min: number,
max: number,
spec: CronFieldSpec,
): CronParseResult<number> {
if (!/^\d+$/.test(input)) return parseError('invalid_integer', spec.name, min, max);
const value = Number(input);
if (!Number.isSafeInteger(value)) return parseError('invalid_integer', spec.name, min, max);
if (value < min || value > max) return parseError('out_of_range', spec.name, min, max);
return { ok: true, value };
}
function normalizeCronValue(value: number, spec: CronFieldSpec): number {
return spec.normalizeSunday === true && value === 7 ? 0 : value;
}
function hasImpossibleCalendarDate(expression: ParsedCronExpression): boolean {
if (
expression.dayOfMonth.wildcard ||
expression.month.wildcard ||
!expression.dayOfWeek.wildcard
) {
return false;
}
const maxDays = Math.max(
...[...expression.month.values].map((month) => MAX_DAYS_IN_MONTH[month - 1] ?? 0),
);
return Math.min(...expression.dayOfMonth.values) > maxDays;
}
function nextCronOccurrence(
expression: ParsedCronExpression,
after: number,
bounds: CronSearchBounds | undefined,
): number | null {
if (!Number.isFinite(after)) return null;
if (bounds?.notBefore !== undefined && !Number.isFinite(bounds.notBefore)) return null;
if (bounds?.notAfter !== undefined && !Number.isFinite(bounds.notAfter)) return null;
const firstMinuteAfter = (Math.floor(after / MINUTE_MS) + 1) * MINUTE_MS;
const boundedStart =
bounds?.notBefore === undefined
? firstMinuteAfter
: Math.max(firstMinuteAfter, Math.ceil(bounds.notBefore / MINUTE_MS) * MINUTE_MS);
const defaultEnd = firstMinuteAfter + (MAX_SEARCH_MINUTES - 1) * MINUTE_MS;
const searchEnd = Math.min(defaultEnd, bounds?.notAfter ?? defaultEnd);
if (
!Number.isFinite(boundedStart) ||
!Number.isFinite(searchEnd) ||
boundedStart > searchEnd ||
Number.isNaN(new Date(boundedStart).getTime())
) {
return null;
}
// Advance in epoch minutes so a local DST fold cannot re-encode a candidate
// at or before `after` and cause a scheduler re-fire loop.
for (let candidate = boundedStart; candidate <= searchEnd; candidate += MINUTE_MS) {
if (cronExpressionMatches(expression, new Date(candidate))) return candidate;
}
return null;
}
function cronExpressionMatches(expression: ParsedCronExpression, date: Date): boolean {
if (!expression.minute.values.has(date.getMinutes())) return false;
if (!expression.hour.values.has(date.getHours())) return false;
if (!expression.month.values.has(date.getMonth() + 1)) return false;
const dayOfMonthMatches = expression.dayOfMonth.values.has(date.getDate());
const dayOfWeekMatches = expression.dayOfWeek.values.has(date.getDay());
if (!expression.dayOfMonth.wildcard && !expression.dayOfWeek.wildcard) {
return dayOfMonthMatches || dayOfWeekMatches;
}
return dayOfMonthMatches && dayOfWeekMatches;
}
function parseError<T = never>(
code: CronCompileErrorCode,
field?: CronFieldName,
min?: number,
max?: number,
): CronParseResult<T> {
return {
ok: false,
error: {
code,
...(field ? { field } : {}),
...(min !== undefined ? { min } : {}),
...(max !== undefined ? { max } : {}),
},
};
}