Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
249 lines
8.9 KiB
TypeScript
249 lines
8.9 KiB
TypeScript
import { ImpossibleScheduleError, InvalidCronError } from "./errors.js";
|
|
|
|
/**
|
|
* A single field of a five-field cron expression after alias resolution.
|
|
*
|
|
* `values` is the expanded set of allowed integers; `restricted` is false only
|
|
* for the literal `*`, which matters for the cron dom/dow OR rule: when both
|
|
* fields are restricted the day matches if EITHER matches, and when only one is
|
|
* restricted it alone constrains the day.
|
|
*/
|
|
export interface CronField {
|
|
readonly values: ReadonlySet<number>;
|
|
readonly restricted: boolean;
|
|
}
|
|
|
|
/**
|
|
* Parsed five-field cron expression: minute hour day-of-month month
|
|
* day-of-week. minute/hour/dow are zero-based; month is 1-12, dom 1-31.
|
|
*/
|
|
export interface ParsedCron {
|
|
readonly minute: CronField;
|
|
readonly hour: CronField;
|
|
readonly dayOfMonth: CronField;
|
|
readonly month: CronField;
|
|
readonly dayOfWeek: CronField;
|
|
}
|
|
|
|
interface FieldBounds {
|
|
readonly min: number;
|
|
readonly max: number;
|
|
/** alias -> value, applied before range parsing (MON, JAN, ...) */
|
|
readonly aliases?: Readonly<Record<string, number>>;
|
|
}
|
|
|
|
const MONTH_ALIASES: Readonly<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 DOW_ALIASES: Readonly<Record<string, number>> = {
|
|
SUN: 0,
|
|
MON: 1,
|
|
TUE: 2,
|
|
WED: 3,
|
|
THU: 4,
|
|
FRI: 5,
|
|
SAT: 6,
|
|
};
|
|
|
|
const MINUTE: FieldBounds = { min: 0, max: 59 };
|
|
const HOUR: FieldBounds = { min: 0, max: 23 };
|
|
const DOM: FieldBounds = { min: 1, max: 31 };
|
|
const MONTH: FieldBounds = { min: 1, max: 12, aliases: MONTH_ALIASES };
|
|
const DOW: FieldBounds = { min: 0, max: 6, aliases: DOW_ALIASES };
|
|
|
|
export const FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const;
|
|
|
|
/** Month lengths ignoring leap years (February counted as 29 to stay permissive). */
|
|
const MONTH_LENGTHS: readonly number[] = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
|
|
function applyAliases(token: string, bounds: FieldBounds): string {
|
|
const aliases = bounds.aliases;
|
|
if (!aliases) return token;
|
|
const mapped = aliases[token.toUpperCase()];
|
|
return mapped === undefined ? token : String(mapped);
|
|
}
|
|
|
|
function parseValue(token: string, bounds: FieldBounds, fieldName: string): number {
|
|
const mapped = applyAliases(token, bounds);
|
|
if (!/^\d+$/.test(mapped)) {
|
|
throw new InvalidCronError(`invalid value "${token}" in ${fieldName} field`);
|
|
}
|
|
const value = Number(mapped);
|
|
if (value < bounds.min || value > bounds.max) {
|
|
throw new InvalidCronError(
|
|
`value ${value} out of range ${bounds.min}-${bounds.max} in ${fieldName} field`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseStep(token: string, fieldName: string): number {
|
|
const trimmed = token.trim();
|
|
if (!/^\d+$/.test(trimmed)) {
|
|
throw new InvalidCronError(`invalid step "${token}" in ${fieldName} field`);
|
|
}
|
|
const step = Number(trimmed);
|
|
if (!Number.isInteger(step) || step <= 0) {
|
|
throw new InvalidCronError(`step must be a positive integer, got "${token}" in ${fieldName} field`);
|
|
}
|
|
return step;
|
|
}
|
|
|
|
function parseField(field: string, bounds: FieldBounds, fieldName: string): CronField {
|
|
const raw = field.trim();
|
|
if (raw === "") {
|
|
throw new InvalidCronError(`empty ${fieldName} field`);
|
|
}
|
|
const values = new Set<number>();
|
|
for (const part of raw.split(",")) {
|
|
const token = part.trim();
|
|
if (token === "") {
|
|
throw new InvalidCronError(`empty list element in ${fieldName} field "${field}"`);
|
|
}
|
|
// Split off an optional /step suffix, being careful not to break on a bare value.
|
|
const slash = token.indexOf("/");
|
|
const range = (slash === -1 ? token : token.slice(0, slash)).trim();
|
|
const stepPart = slash === -1 ? null : token.slice(slash + 1).trim();
|
|
if (range === "") {
|
|
throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`);
|
|
}
|
|
if ((token.match(/\//g) ?? []).length > 1) {
|
|
throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`);
|
|
}
|
|
const step = stepPart === null ? 1 : parseStep(stepPart, fieldName);
|
|
let lo: number;
|
|
let hi: number;
|
|
if (range === "*") {
|
|
lo = bounds.min;
|
|
hi = bounds.max;
|
|
} else if (range.includes("-")) {
|
|
const dash = range.indexOf("-");
|
|
const loToken = range.slice(0, dash).trim();
|
|
const hiToken = range.slice(dash + 1).trim();
|
|
if (loToken === "" || hiToken === "") {
|
|
throw new InvalidCronError(`malformed range "${range}" in ${fieldName} field`);
|
|
}
|
|
lo = parseValue(loToken, bounds, fieldName);
|
|
hi = parseValue(hiToken, bounds, fieldName);
|
|
if (lo > hi) {
|
|
throw new InvalidCronError(`descending range "${range}" in ${fieldName} field (use ascending ranges)`);
|
|
}
|
|
} else {
|
|
const value = parseValue(range, bounds, fieldName);
|
|
if (stepPart === null) {
|
|
values.add(value);
|
|
continue;
|
|
}
|
|
// `n/step` means "starting at n, every step" — the vixie shorthand for n-max/step.
|
|
lo = value;
|
|
hi = bounds.max;
|
|
}
|
|
for (let v = lo; v <= hi; v += step) values.add(v);
|
|
}
|
|
if (values.size === 0) {
|
|
throw new InvalidCronError(`${fieldName} field "${field}" selects no values`);
|
|
}
|
|
// Only the bare `*` leaves a field unrestricted; `*/15`, `1-5`, lists and
|
|
// even `*,5` all constrain the field.
|
|
return { values, restricted: raw !== "*" };
|
|
}
|
|
|
|
/**
|
|
* Parse a five-field cron expression. Supported syntax: `*`, integers, ranges
|
|
* `a-b`, steps `n/step`, `a-b/step`, comma lists, and month/day-of-week names
|
|
* (`JAN-DEC`, `MON-SUN`, case-insensitive). Anything else — more or fewer than
|
|
* five fields, seconds/year fields, `@reboot` macros, `?`, `L`, `W`, `#` — is
|
|
* rejected with `InvalidCronError` rather than silently misinterpreted.
|
|
*
|
|
* Throws `ImpossibleScheduleError` when the expression can never fire (for
|
|
* example `0 0 31 2 *`).
|
|
*/
|
|
export function parseCron(expression: string): ParsedCron {
|
|
if (typeof expression !== "string") {
|
|
throw new InvalidCronError(`cron expression must be a string, got ${typeof expression}`);
|
|
}
|
|
const trimmed = expression.trim();
|
|
if (trimmed === "") {
|
|
throw new InvalidCronError("cron expression must not be empty");
|
|
}
|
|
if (trimmed.startsWith("@")) {
|
|
throw new InvalidCronError(
|
|
`cron macros like "${trimmed}" are not supported; use a five-field expression`,
|
|
);
|
|
}
|
|
for (const ch of ["?", "L", "W", "#"] as const) {
|
|
if (trimmed.includes(ch)) {
|
|
throw new InvalidCronError(
|
|
`unsupported cron syntax "${ch}" in "${expression}"; this library implements the five-field syntax only`,
|
|
);
|
|
}
|
|
}
|
|
const fields = trimmed.split(/\s+/);
|
|
if (fields.length !== 5) {
|
|
throw new InvalidCronError(
|
|
`cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got ${fields.length} in "${expression}"`,
|
|
);
|
|
}
|
|
const minute = parseField(fields[0]!, MINUTE, FIELD_NAMES[0]);
|
|
const hour = parseField(fields[1]!, HOUR, FIELD_NAMES[1]);
|
|
const dayOfMonth = parseField(fields[2]!, DOM, FIELD_NAMES[2]);
|
|
const month = parseField(fields[3]!, MONTH, FIELD_NAMES[3]);
|
|
const dayOfWeek = parseField(fields[4]!, DOW, FIELD_NAMES[4]);
|
|
assertPossible(month, dayOfMonth);
|
|
return { minute, hour, dayOfMonth, month, dayOfWeek };
|
|
}
|
|
|
|
function assertPossible(month: CronField, dayOfMonth: CronField): void {
|
|
for (const m of month.values) {
|
|
const maxDay = MONTH_LENGTHS[m] ?? 0;
|
|
for (const d of dayOfMonth.values) {
|
|
if (d <= maxDay) return;
|
|
}
|
|
}
|
|
throw new ImpossibleScheduleError(
|
|
`expression can never fire: no allowed month is long enough for day-of-month ${[
|
|
...dayOfMonth.values,
|
|
].join(", ")}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Does this cron match the given local wall-clock day? Implements the vixie
|
|
* dom/dow OR rule: if both fields are restricted the day matches when either
|
|
* matches, otherwise the restricted field alone decides.
|
|
*/
|
|
export function cronMatchesDay(cron: ParsedCron, year: number, month: number, day: number): boolean {
|
|
if (!cron.month.values.has(month)) return false;
|
|
const domMatch = cron.dayOfMonth.values.has(day);
|
|
const dowMatch = cron.dayOfWeek.values.has(dayOfWeekOf(year, month, day));
|
|
const domRestricted = cron.dayOfMonth.restricted;
|
|
const dowRestricted = cron.dayOfWeek.restricted;
|
|
// Vixie rule: neither restricted -> every day; exactly one restricted ->
|
|
// that field alone; both restricted -> match if EITHER matches.
|
|
if (domRestricted && dowRestricted) return domMatch || dowMatch;
|
|
if (domRestricted) return domMatch;
|
|
if (dowRestricted) return dowMatch;
|
|
return true;
|
|
}
|
|
|
|
function dayOfWeekOf(year: number, month: number, day: number): number {
|
|
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
}
|
|
|
|
/** Does this cron match the given local time-of-day (hour/minute)? */
|
|
export function cronMatchesTime(cron: ParsedCron, hour: number, minute: number, second: number): boolean {
|
|
if (second !== 0) return false;
|
|
return cron.minute.values.has(minute) && cron.hour.values.has(hour);
|
|
}
|