Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

171 lines · 6.0 KBRaw History
1/**
2 * POSIX 5-field cron (minute hour day-of-month month day-of-week), evaluated
3 * in UTC, as used by `on.schedule`. Supports `*`, lists, ranges, steps and
4 * month/day names. As in POSIX cron, when both day-of-month and day-of-week
5 * are restricted a date matches if either one matches.
6 */
7
8export interface ParsedCron {
9 minutes: Set<number>;
10 hours: Set<number>;
11 daysOfMonth: Set<number>;
12 months: Set<number>;
13 daysOfWeek: Set<number>;
14 /** Day-of-month field was `*` (or `*` with a step). */
15 domWildcard: boolean;
16 /** Day-of-week field was `*` (or `*` with a step). */
17 dowWildcard: boolean;
18}
19
20export class CronError extends Error {
21 constructor(message: string) {
22 super(message);
23 this.name = "CronError";
24 }
25}
26
27const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
28const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
29
30interface FieldSpec {
31 name: string;
32 min: number;
33 max: number;
34 names?: string[];
35 namesBase?: number;
36}
37
38const FIELDS: FieldSpec[] = [
39 { name: "minute", min: 0, max: 59 },
40 { name: "hour", min: 0, max: 23 },
41 { name: "day of month", min: 1, max: 31 },
42 { name: "month", min: 1, max: 12, names: MONTHS, namesBase: 1 },
43 // 7 is accepted as Sunday and normalised to 0.
44 { name: "day of week", min: 0, max: 7, names: DAYS, namesBase: 0 },
45];
46
47function parseValue(text: string, spec: FieldSpec, cron: string): number {
48 const upper = text.toUpperCase();
49 if (spec.names) {
50 const idx = spec.names.indexOf(upper);
51 if (idx >= 0) return idx + spec.namesBase!;
52 }
53 if (!/^\d+$/.test(text)) throw new CronError(`Invalid ${spec.name} value '${text}' in cron '${cron}'`);
54 const n = Number(text);
55 if (n < spec.min || n > spec.max) {
56 throw new CronError(`${spec.name} value ${n} out of range ${spec.min}-${spec.max} in cron '${cron}'`);
57 }
58 return n;
59}
60
61function parseField(field: string, spec: FieldSpec, cron: string): { values: Set<number>; wildcard: boolean } {
62 const values = new Set<number>();
63 let wildcard = false;
64 for (const part of field.split(",")) {
65 if (part === "") throw new CronError(`Empty list element in ${spec.name} field of cron '${cron}'`);
66 const [range, stepText, extra] = part.split("/");
67 if (extra !== undefined) throw new CronError(`Invalid step in ${spec.name} field of cron '${cron}'`);
68 let step = 1;
69 if (stepText !== undefined) {
70 if (!/^\d+$/.test(stepText) || Number(stepText) === 0) {
71 throw new CronError(`Invalid step '${stepText}' in ${spec.name} field of cron '${cron}'`);
72 }
73 step = Number(stepText);
74 }
75 let lo: number;
76 let hi: number;
77 if (range === "*") {
78 lo = spec.min;
79 hi = spec.name === "day of week" ? 6 : spec.max;
80 wildcard = true;
81 } else {
82 const bounds = range!.split("-");
83 if (bounds.length > 2) throw new CronError(`Invalid range '${range}' in cron '${cron}'`);
84 lo = parseValue(bounds[0]!, spec, cron);
85 hi = bounds.length === 2 ? parseValue(bounds[1]!, spec, cron) : stepText !== undefined ? spec.max : lo;
86 if (hi < lo) throw new CronError(`Invalid range '${range}' (end before start) in cron '${cron}'`);
87 }
88 for (let v = lo; v <= hi; v += step) values.add(spec.name === "day of week" && v === 7 ? 0 : v);
89 }
90 return { values, wildcard };
91}
92
93/** Parses a cron expression; throws CronError with a descriptive message when invalid. */
94export function parseCron(cron: string): ParsedCron {
95 const fields = cron.trim().split(/\s+/);
96 if (fields.length !== 5 || fields[0] === "") {
97 throw new CronError(`Cron '${cron}' must have exactly 5 fields (minute hour day-of-month month day-of-week)`);
98 }
99 const [mi, h, dom, mo, dow] = fields.map((f, i) => parseField(f, FIELDS[i]!, cron));
100 return {
101 minutes: mi!.values,
102 hours: h!.values,
103 daysOfMonth: dom!.values,
104 months: mo!.values,
105 daysOfWeek: dow!.values,
106 domWildcard: dom!.wildcard,
107 dowWildcard: dow!.wildcard,
108 };
109}
110
111/** Returns an error message for an invalid cron, or undefined. */
112export function validateCron(cron: string): string | undefined {
113 try {
114 parseCron(cron);
115 return undefined;
116 } catch (e) {
117 return (e as Error).message;
118 }
119}
120
121function matchesParsed(c: ParsedCron, date: Date): boolean {
122 if (!c.minutes.has(date.getUTCMinutes())) return false;
123 if (!c.hours.has(date.getUTCHours())) return false;
124 if (!c.months.has(date.getUTCMonth() + 1)) return false;
125 const domOk = c.daysOfMonth.has(date.getUTCDate());
126 const dowOk = c.daysOfWeek.has(date.getUTCDay());
127 if (c.domWildcard || c.dowWildcard) return domOk && dowOk;
128 return domOk || dowOk;
129}
130
131/** True if `date` (minute precision, UTC) matches the cron expression. */
132export function cronMatches(cron: string | ParsedCron, date: Date): boolean {
133 return matchesParsed(typeof cron === "string" ? parseCron(cron) : cron, date);
134}
135
136/**
137 * The first minute strictly after `after` that matches the cron, or null if
138 * none is found within ~5 years (e.g. `0 0 31 2 *`).
139 */
140export function nextCronMatch(cron: string | ParsedCron, after: Date): Date | null {
141 const c = typeof cron === "string" ? parseCron(cron) : cron;
142 const t = new Date(after.getTime());
143 t.setUTCSeconds(0, 0);
144 t.setUTCMinutes(t.getUTCMinutes() + 1);
145 const limit = after.getTime() + 5 * 366 * 24 * 3600 * 1000;
146 while (t.getTime() <= limit) {
147 if (!c.months.has(t.getUTCMonth() + 1)) {
148 t.setUTCMonth(t.getUTCMonth() + 1, 1);
149 t.setUTCHours(0, 0, 0, 0);
150 continue;
151 }
152 const domOk = c.daysOfMonth.has(t.getUTCDate());
153 const dowOk = c.daysOfWeek.has(t.getUTCDay());
154 const dayOk = c.domWildcard || c.dowWildcard ? domOk && dowOk : domOk || dowOk;
155 if (!dayOk) {
156 t.setUTCDate(t.getUTCDate() + 1);
157 t.setUTCHours(0, 0, 0, 0);
158 continue;
159 }
160 if (!c.hours.has(t.getUTCHours())) {
161 t.setUTCHours(t.getUTCHours() + 1, 0, 0, 0);
162 continue;
163 }
164 if (!c.minutes.has(t.getUTCMinutes())) {
165 t.setUTCMinutes(t.getUTCMinutes() + 1, 0, 0);
166 continue;
167 }
168 return t;
169 }
170 return null;
171}