/** * Cron expression validation. * * `Bun.cron` and Cloudflare Workers Cron Triggers both accept standard 5-field * POSIX-style crontab expressions plus a handful of named aliases: * * ┌───────────── minute (0 - 59) * │ ┌─────────── hour (0 - 23) * │ │ ┌───────── day of month (1 - 31) * │ │ │ ┌─────── month (1 - 12) or Jan-Dec * │ │ │ │ ┌───── day of week (0 - 6) or Sun-Sat (0 and 7 both = Sunday) * │ │ │ │ │ * * * * * * * * Each field supports: `*`, lists (`1,2,3`), ranges (`1-5`), step values * (`*\/15`, `0-30/5`), and for month/day-of-week the named aliases. We keep * the validator *syntactic* — we don't try to catch logically impossible dates * (`31 2 *`) because cron runtimes silently skip them anyway and a too-strict * validator would reject expressions that work fine in production. * * Named aliases (non-standard but widely supported by vixie-cron, Bun, and * Cloudflare): `@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`, * `@midnight`, `@hourly`. We permit these as a single-token input. */ const NAMED_ALIASES = new Set([ "@yearly", "@annually", "@monthly", "@weekly", "@daily", "@midnight", "@hourly", ]); const MONTH_NAMES = new Set([ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", ]); const DOW_NAMES = new Set([ "sun", "mon", "tue", "wed", "thu", "fri", "sat", ]); interface FieldSpec { min: number; max: number; /** Names this field accepts in place of numbers (lowercased). */ names?: Set; } const FIELDS: readonly FieldSpec[] = [ { min: 0, max: 59 }, // minute { min: 0, max: 23 }, // hour { min: 1, max: 31 }, // dom { min: 1, max: 12, names: MONTH_NAMES }, // month { min: 0, max: 7, names: DOW_NAMES }, // dow (0 and 7 both = Sunday) ]; function resolveToken(token: string, spec: FieldSpec): number | null { const lower = token.toLowerCase(); if (spec.names?.has(lower)) { // Return any number in range — we only care that the token is recognized. return spec.min; } if (!/^\d+$/.test(token)) return null; const n = Number(token); if (!Number.isFinite(n)) return null; if (n < spec.min || n > spec.max) return null; return n; } function validateField(field: string, spec: FieldSpec): boolean { if (field.length === 0) return false; // Split on commas for list form. const parts = field.split(","); for (const part of parts) { if (part.length === 0) return false; // Step form: `X/Y` or `*/Y` or `A-B/Y`. const [rangePart, stepPart] = part.split("/"); if (stepPart !== undefined) { if (!/^\d+$/.test(stepPart)) return false; const step = Number(stepPart); if (step < 1) return false; } if (rangePart === "*") continue; // Range form: `A-B`. if (rangePart.includes("-")) { const [lo, hi] = rangePart.split("-"); if (lo === undefined || hi === undefined) return false; const a = resolveToken(lo, spec); const b = resolveToken(hi, spec); if (a === null || b === null) return false; continue; } // Single token. if (resolveToken(rangePart, spec) === null) return false; } return true; } /** * Validate a cron expression at `defineCron` time. Throws with an actionable * error when the schedule is malformed, so the user sees the problem during * boot instead of a silent "job never fires". */ export function validateCronExpression(expr: string): void { if (typeof expr !== "string" || expr.trim().length === 0) { throw new Error( `[@mandujs/core/scheduler] invalid cron expression: expected a non-empty string (got: ${JSON.stringify(expr)}).`, ); } const trimmed = expr.trim(); // Named aliases are a single token. if (trimmed.startsWith("@")) { if (NAMED_ALIASES.has(trimmed.toLowerCase())) return; throw new Error( `[@mandujs/core/scheduler] invalid cron alias "${trimmed}". ` + `Supported aliases: ${[...NAMED_ALIASES].join(", ")}.`, ); } const fields = trimmed.split(/\s+/); if (fields.length !== 5) { throw new Error( `[@mandujs/core/scheduler] invalid cron expression "${expr}" — ` + `expected 5 space-separated fields (minute hour day-of-month month day-of-week), got ${fields.length}.`, ); } for (let i = 0; i < fields.length; i++) { if (!validateField(fields[i], FIELDS[i])) { const fieldNames = ["minute", "hour", "day-of-month", "month", "day-of-week"]; throw new Error( `[@mandujs/core/scheduler] invalid cron expression "${expr}" — ` + `field #${i + 1} (${fieldNames[i]}): "${fields[i]}" is out of range or malformed.`, ); } } } /** * Validate an IANA timezone string. Uses Intl.DateTimeFormat support detection * — the same approach the Temporal proposal recommends. */ export function validateTimezone(tz: string): void { if (typeof tz !== "string" || tz.length === 0) { throw new Error( `[@mandujs/core/scheduler] invalid timezone: expected a non-empty IANA string (got: ${JSON.stringify(tz)}).`, ); } try { // `Intl.DateTimeFormat` throws `RangeError` for unknown zones. Intl.DateTimeFormat("en-US", { timeZone: tz }); } catch { throw new Error( `[@mandujs/core/scheduler] unknown IANA timezone "${tz}". ` + `Examples: "UTC", "America/New_York", "Asia/Seoul".`, ); } }