(
expression: string,
timezone: string = 'UTC',
minIntervalSeconds: number = MIN_SCHEDULE_INTERVAL_SECONDS
)
| 16 | * Supports extended 6-field cron: second minute hour day month weekday |
| 17 | */ |
| 18 | export const validateCronExpression = ( |
| 19 | expression: string, |
| 20 | timezone: string = 'UTC', |
| 21 | minIntervalSeconds: number = MIN_SCHEDULE_INTERVAL_SECONDS |
| 22 | ): { valid: boolean; error?: string } => { |
| 23 | if (!expression || typeof expression !== 'string') { |
| 24 | return { valid: false, error: 'Cron expression must be a non-empty string' } |
| 25 | } |
| 26 | |
| 27 | const trimmed = expression.trim() |
| 28 | const fields = trimmed.split(/\s+/) |
| 29 | |
| 30 | if (fields.length !== 5 && fields.length !== 6) { |
| 31 | return { |
| 32 | valid: false, |
| 33 | error: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)' |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // Validate timezone |
| 38 | try { |
| 39 | Intl.DateTimeFormat('en-US', { timeZone: timezone }) |
| 40 | } catch { |
| 41 | return { valid: false, error: `Invalid timezone: ${timezone}` } |
| 42 | } |
| 43 | |
| 44 | // Returns true if s is a valid integer in [min, max] or a valid range "start-end" |
| 45 | const isValidRangeOrNumber = (s: string, min: number, max: number): boolean => { |
| 46 | const dashIdx = s.indexOf('-') |
| 47 | if (dashIdx !== -1) { |
| 48 | const startStr = s.slice(0, dashIdx) |
| 49 | const endStr = s.slice(dashIdx + 1) |
| 50 | if (!/^\d+$/.test(startStr) || !/^\d+$/.test(endStr)) return false |
| 51 | const start = parseInt(startStr, 10) |
| 52 | const end = parseInt(endStr, 10) |
| 53 | return start >= min && start <= max && end >= min && end <= max && start <= end |
| 54 | } |
| 55 | if (!/^\d+$/.test(s)) return false |
| 56 | const n = parseInt(s, 10) |
| 57 | return n >= min && n <= max |
| 58 | } |
| 59 | |
| 60 | // Validate a single cron field: supports *, numbers, ranges (n-m), steps (*/s, n/s, n-m/s), and comma-separated lists. |
| 61 | // When `allowL` is true, also accepts the standalone `L` token (used for the day-of-month field to mean "last day of month"). |
| 62 | const validateCronField = (field: string, min: number, max: number, allowL: boolean = false): boolean => { |
| 63 | const parts = field.split(',') |
| 64 | if (parts.some((p) => p === '')) return false // catches leading/trailing/consecutive commas |
| 65 | |
| 66 | for (const part of parts) { |
| 67 | if (allowL && part === 'L') continue |
| 68 | const slashIdx = part.indexOf('/') |
| 69 | if (slashIdx !== -1) { |
| 70 | const base = part.slice(0, slashIdx) |
| 71 | const stepStr = part.slice(slashIdx + 1) |
| 72 | if (!/^\d+$/.test(stepStr)) return false |
| 73 | const step = parseInt(stepStr, 10) |
| 74 | if (step < 1) return false |
| 75 | // Base must be *, a plain number, or a range |
no test coverage detected