// rrule@2.8.1 has no "exports" field and ships ESM that some bundlers can't // resolve via either default OR named imports. Use createRequire to bridge // to the CJS entry — works under NodeNext at runtime and Turbopack at build. import { createRequire } from "node:module"; import type { RRule as RRuleType } from "rrule"; import { DateTime } from "luxon"; const require = createRequire(import.meta.url); const rrulePkg = require("rrule") as typeof import("rrule"); const { RRule, rrulestr } = rrulePkg; export const MIN_INTERVAL_MS = 5 * 60 * 1000; /** * Sentinel prefix marking a cron expression stored in the same column * as RRULE strings. e.g. "CRON:0 9 * * 1-5" → 09:00 every weekday. * * Cron is more expressive than our subset of RRULE (true sec/min/ * hour/day/month/year combinational scheduling) so we let the user * supply one directly. The bot dispatches on this prefix. */ export const CRON_PREFIX = "CRON:"; export function isCronRule(rule: string): boolean { return rule.startsWith(CRON_PREFIX); } export function stripCronPrefix(rule: string): string { return rule.startsWith(CRON_PREFIX) ? rule.slice(CRON_PREFIX.length) : rule; } export function parseRRule(rule: string): RRuleType { const parsed = rrulestr(rule); if (!(parsed instanceof RRule)) { throw new Error("Compound RRULE/RRSET not supported"); } return parsed; } export function nextOccurrence(rule: string, timezone: string, after: Date): Date | null { if (isCronRule(rule)) { // Lazy require keeps cron-parser out of the import graph for callers // that never use cron rules. const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser"); // The picker can store multiple cron expressions joined by newlines // ("every Monday at 09:00\nevery Friday at 17:00"). We compute the // next match for each and return the earliest — that's the very // next time *any* of the schedules fires. const lines = stripCronPrefix(rule) .split("\n") .map((s) => s.trim()) .filter(Boolean); if (lines.length === 0) return null; let earliest: Date | null = null; for (const expr of lines) { try { const it = CronExpressionParser.parse(expr, { currentDate: after, tz: timezone, }); const next = it.next().toDate(); if (!earliest || next < earliest) earliest = next; } catch { // Skip the malformed line; if all lines are bad the function // returns null below. } } return earliest; } const parsed = parseRRule(rule); const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate(); const next = parsed.after(afterInZone, false); return next ?? null; } export type IntervalCheck = { ok: true } | { ok: false; reason: string }; export function validateMinInterval(rule: string, timezone: string): IntervalCheck { if (isCronRule(rule)) { const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser"); // Validate every line independently — a multi-rule schedule fires // as the union, so any single rule firing too often is enough to // breach the minimum interval. const lines = stripCronPrefix(rule) .split("\n") .map((s) => s.trim()) .filter(Boolean); if (lines.length === 0) { return { ok: false, reason: "Empty cron rule" }; } for (const expr of lines) { try { const it = CronExpressionParser.parse(expr, { currentDate: new Date(), tz: timezone, }); const first = it.next().toDate(); const second = it.next().toDate(); const gap = second.getTime() - first.getTime(); if (gap < MIN_INTERVAL_MS) { return { ok: false, reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`, }; } } catch (err) { return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` }; } } return { ok: true }; } const parsed = parseRRule(rule); const now = new Date(); const first = parsed.after(now, false); if (!first) return { ok: true }; const second = parsed.after(first, false); if (!second) return { ok: true }; const gap = second.getTime() - first.getTime(); if (gap < MIN_INTERVAL_MS) { return { ok: false, reason: `Recurrence fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`, }; } return { ok: true }; } /** Validate a cron expression — returns null on success, error message on failure. */ export function validateCronExpression(expr: string, timezone: string): string | null { const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser"); try { CronExpressionParser.parse(expr, { tz: timezone }); return null; } catch (err) { return (err as Error).message; } }