Fast and tiny JavaScript/TypeScript cron parser with timezone support
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

throwing errors instead in returning null in scheduler.

+147 -41
+19 -11
README.md
··· 1 1 # cron-fast 2 2 3 - [![npm version](https://badge.fury.io/js/cron-fast.svg)](https://www.npmjs.com/package/cron-fast) 4 - [![npm provenance](https://img.shields.io/badge/provenance-attested-brightgreen)](https://www.npmjs.com/package/cron-fast) 3 + [![npm version](https://img.shields.io/npm/v/cron-fast.svg?logo=npm)](https://www.npmjs.com/package/cron-fast) 4 + [![npm provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=npm)](https://www.npmjs.com/package/cron-fast) 5 5 [![JSR](https://jsr.io/badges/@kbilkis/cron-fast)](https://jsr.io/@kbilkis/cron-fast) 6 6 [![JSR Score](https://jsr.io/badges/@kbilkis/cron-fast/score)](https://jsr.io/@kbilkis/cron-fast) 7 7 [![CI](https://github.com/kbilkis/cron-fast/actions/workflows/ci.yml/badge.svg)](https://github.com/kbilkis/cron-fast/actions/workflows/ci.yml) 8 - [![codecov](https://codecov.io/github/kbilkis/cron-fast/graph/badge.svg?token=5MXFKS45XV)](https://codecov.io/github/kbilkis/cron-fast) 9 - [![npm bundle size](https://img.shields.io/bundlejs/size/cron-fast)](https://bundlejs.com/?q=cron-fast) 8 + [![codecov](https://codecov.io/github/kbilkis/cron-fast/graph/badge.svg)](https://codecov.io/github/kbilkis/cron-fast) 9 + [![npm bundle size](https://img.shields.io/bundlejs/size/cron-fast?logo=esbuild)](https://bundlejs.com/?q=cron-fast) 10 10 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 11 11 12 12 **Fast and tiny JavaScript/TypeScript cron parser with timezone support.** Works everywhere: Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies. ··· 71 71 72 72 ### `nextRun(expression, options?)` 73 73 74 - Get the next execution time for a cron expression. 74 + Get the next execution time for a cron expression. **Throws** if the expression or timezone is invalid. 75 75 76 76 ```typescript 77 77 nextRun("0 9 * * *"); // Next 9:00 AM UTC ··· 81 81 82 82 ### `previousRun(expression, options?)` 83 83 84 - Get the previous execution time. 84 + Get the previous execution time. **Throws** if the expression or timezone is invalid. 85 85 86 86 ```typescript 87 87 previousRun("0 9 * * *"); // Last 9:00 AM UTC ··· 90 90 91 91 ### `nextRuns(expression, count, options?)` 92 92 93 - Get next N execution times. 93 + Get next N execution times. **Throws** if the expression or timezone is invalid. 94 94 95 95 ```typescript 96 96 nextRuns("0 9 * * *", 5); // Next 5 occurrences ··· 107 107 108 108 ### `isMatch(expression, date, options?)` 109 109 110 - Check if a date matches the cron expression. 110 + Check if a date matches the cron expression. **Throws** if the expression or timezone is invalid. 111 111 112 112 ```typescript 113 113 isMatch("0 9 * * *", new Date("2026-03-15T09:00:00Z")); // true ··· 230 230 ### Validation and Parsing 231 231 232 232 ```typescript 233 - // Validate before using 233 + // Functions throw on invalid input, but you can pre-validate user input 234 234 if (!isValid(userInput)) { 235 - throw new Error("Invalid cron expression"); 235 + console.log("Invalid cron expression"); 236 + return; 237 + } 238 + 239 + // Or use try/catch 240 + try { 241 + const next = nextRun(userInput); 242 + } catch (e) { 243 + console.log("Invalid cron expression"); 236 244 } 237 245 238 246 // Parse to see what it means ··· 264 272 265 273 ## Tips & Gotchas 266 274 275 + - **Invalid input throws**: `nextRun`, `previousRun`, `nextRuns`, and `isMatch` throw an error for invalid cron expressions or invalid timezones. Use `isValid()` to pre-validate user input, or wrap calls in try/catch. 267 276 - **Timezone handling**: The cron expression is interpreted in the timezone you specify, but the returned Date is always in UTC 268 277 - **Daylight saving time**: Use IANA timezone names (like "America/New_York") instead of abbreviations (like "EST") 269 - - **Validation**: Always check `isValid()` before parsing user input 270 278 - **Day 0 and 7**: Both represent Sunday in the day-of-week field 271 279 - **Ranges are inclusive**: `1-5` includes both 1 and 5 272 280
+1 -1
jsr.json
··· 1 1 { 2 2 "name": "@kbilkis/cron-fast", 3 - "version": "1.0.0", 3 + "version": "2.0.0", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "javascript",
+1 -1
package.json
··· 1 1 { 2 2 "name": "cron-fast", 3 - "version": "1.0.0", 3 + "version": "2.0.0", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "browser",
+25 -20
src/scheduler.ts
··· 31 31 }, 32 32 } as const; 33 33 34 - /** Get the next execution time for a cron expression. Returns null if invalid or no match found. */ 35 - export function nextRun(expression: string, options?: CronOptions): Date | null { 34 + /** Get the next execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */ 35 + export function nextRun(expression: string, options?: CronOptions): Date { 36 36 const parsed = parse(expression); 37 - if (!parsed) return null; 37 + if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 38 38 39 39 const from = options?.from || new Date(); 40 40 const tz = options?.timezone; 41 41 42 42 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from); 43 - if (!start) return null; 43 + if (!start) throw new Error(`Invalid timezone: "${tz}"`); 44 44 45 45 start.setUTCSeconds(0, 0); 46 46 start.setUTCMinutes(start.getUTCMinutes() + 1); 47 47 48 - return findMatch(parsed, start, "next", tz); 48 + return findMatch(parsed, start, "next", tz, expression); 49 49 } 50 50 51 - /** Get the previous execution time for a cron expression. Returns null if invalid or no match found. */ 52 - export function previousRun(expression: string, options?: CronOptions): Date | null { 51 + /** Get the previous execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */ 52 + export function previousRun(expression: string, options?: CronOptions): Date { 53 53 const parsed = parse(expression); 54 - if (!parsed) return null; 54 + if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 55 55 56 56 const from = options?.from || new Date(); 57 57 const tz = options?.timezone; 58 58 59 59 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from); 60 - if (!start) return null; 60 + if (!start) throw new Error(`Invalid timezone: "${tz}"`); 61 61 62 62 start.setUTCSeconds(0, 0); 63 63 start.setUTCMinutes(start.getUTCMinutes() - 1); 64 64 65 - return findMatch(parsed, start, "prev", tz); 65 + return findMatch(parsed, start, "prev", tz, expression); 66 66 } 67 67 68 - /** Get next N execution times. Returns empty array if invalid. */ 68 + /** Get next N execution times. Throws if expression or timezone is invalid. */ 69 69 export function nextRuns(expression: string, count: number, options?: CronOptions): Date[] { 70 70 if (count <= 0) return []; 71 71 ··· 74 74 75 75 for (let i = 0; i < count; i++) { 76 76 const next = nextRun(expression, { ...options, from: current }); 77 - if (!next) break; 78 77 results.push(next); 79 78 current = new Date(next.getTime() + ONE_MINUTE_MS); 80 79 } 81 80 return results; 82 81 } 83 82 84 - /** Check if a date matches the cron expression. Returns false if invalid. */ 83 + /** Check if a date matches the cron expression. Throws if expression or timezone is invalid. */ 85 84 export function isMatch( 86 85 expression: string, 87 86 date: Date, 88 87 options?: Pick<CronOptions, "timezone">, 89 88 ): boolean { 90 89 const parsed = parse(expression); 91 - if (!parsed) return false; 90 + if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`); 92 91 93 - const checkDate = 94 - options?.timezone !== undefined ? convertToTimezone(date, options.timezone) : new Date(date); 92 + const tz = options?.timezone; 93 + const checkDate = tz !== undefined ? convertToTimezone(date, tz) : new Date(date); 95 94 96 - if (!checkDate) return false; 95 + if (!checkDate) throw new Error(`Invalid timezone: "${tz}"`); 97 96 return matches(parsed, checkDate); 98 97 } 99 98 100 99 /** Find matching time using smart field-increment algorithm */ 101 - function findMatch(parsed: ParsedCron, start: Date, dir: Direction, tz?: string): Date | null { 100 + function findMatch( 101 + parsed: ParsedCron, 102 + start: Date, 103 + dir: Direction, 104 + tz?: string, 105 + expression?: string, 106 + ): Date { 102 107 const current = new Date(start); 103 108 104 109 for (let i = 0; i < MAX_ITERATIONS; i++) { 105 110 if (matches(parsed, current)) { 106 - return tz !== undefined ? convertFromTimezone(current, tz) : current; 111 + return tz !== undefined ? convertFromTimezone(current, tz)! : current; 107 112 } 108 113 advanceDate(parsed, current, dir); 109 114 } 110 - return null; 115 + throw new Error(`No match found for "${expression}" within iteration limit`); 111 116 } 112 117 113 118 /**
+101 -8
test/scheduler.test.ts
··· 3 3 import { getDaysInMonth } from "../src/matcher.js"; 4 4 5 5 describe("scheduler", () => { 6 + describe("Invalid cron expression handling", () => { 7 + describe("nextRun", () => { 8 + it("should throw for invalid cron expression", () => { 9 + expect(() => nextRun("invalid")).toThrow('Invalid cron expression: "invalid"'); 10 + }); 11 + 12 + it("should throw for empty string", () => { 13 + expect(() => nextRun("")).toThrow('Invalid cron expression: ""'); 14 + }); 15 + 16 + it("should throw for wrong number of fields", () => { 17 + expect(() => nextRun("* * *")).toThrow('Invalid cron expression: "* * *"'); 18 + }); 19 + 20 + it("should throw for impossible day/month combination", () => { 21 + expect(() => nextRun("0 0 31 2 *")).toThrow('Invalid cron expression: "0 0 31 2 *"'); 22 + }); 23 + }); 24 + 25 + describe("previousRun", () => { 26 + it("should throw for invalid cron expression", () => { 27 + expect(() => previousRun("invalid")).toThrow('Invalid cron expression: "invalid"'); 28 + }); 29 + 30 + it("should throw for empty string", () => { 31 + expect(() => previousRun("")).toThrow('Invalid cron expression: ""'); 32 + }); 33 + }); 34 + 35 + describe("nextRuns", () => { 36 + it("should throw for invalid cron expression", () => { 37 + expect(() => nextRuns("invalid", 5)).toThrow('Invalid cron expression: "invalid"'); 38 + }); 39 + 40 + it("should throw for empty string", () => { 41 + expect(() => nextRuns("", 3)).toThrow('Invalid cron expression: ""'); 42 + }); 43 + }); 44 + 45 + describe("isMatch", () => { 46 + it("should throw for invalid cron expression", () => { 47 + const date = new Date("2026-03-15T09:00:00Z"); 48 + expect(() => isMatch("invalid", date)).toThrow('Invalid cron expression: "invalid"'); 49 + }); 50 + 51 + it("should throw for empty string", () => { 52 + const date = new Date("2026-03-15T09:00:00Z"); 53 + expect(() => isMatch("", date)).toThrow('Invalid cron expression: ""'); 54 + }); 55 + }); 56 + }); 57 + 6 58 describe("Basic functionality", () => { 7 59 describe("nextRun", () => { 8 60 it("should find next run for simple expression", () => { ··· 550 602 // March 2021: DST started March 14, so EDT (UTC-4) 551 603 expect(next.getUTCHours()).toBe(14); // 10 AM EDT = 2 PM UTC 552 604 expect(next.getTime()).toBeGreaterThan(from.getTime()); 605 + }); 606 + }); 607 + 608 + describe("previousRun with seconds in current minute", () => { 609 + it("should NOT return the current minute when called mid-minute", () => { 610 + // At 9:01:30, previousRun should return 9:00, not 9:01 611 + // because we subtract 1 minute after zeroing seconds 612 + const from = new Date("2025-03-15T09:01:30Z"); 613 + const prev = previousRun("* * * * *", { from }); 614 + 615 + expect(prev.getUTCHours()).toBe(9); 616 + expect(prev.getUTCMinutes()).toBe(0); // 9:00, not 9:01 617 + expect(prev.getUTCSeconds()).toBe(0); 618 + expect(prev.getTime()).toBeLessThan(from.getTime()); 619 + }); 620 + 621 + it("should return previous minute even at 9:01:00 exactly (start of minute)", () => { 622 + // At 9:01:00, after zeroing seconds it's still 9:01:00 623 + // Then subtracting 1 minute gives 9:00:00 624 + const from = new Date("2025-03-15T09:01:00Z"); 625 + const prev = previousRun("* * * * *", { from }); 626 + 627 + expect(prev.getUTCHours()).toBe(9); 628 + expect(prev.getUTCMinutes()).toBe(0); 629 + expect(prev.getUTCSeconds()).toBe(0); 630 + }); 631 + 632 + it("should return current minute when called at minute boundary (9:01:00.001)", () => { 633 + // At 9:00:00.001, after zeroing seconds it's 9:00:00 634 + // Then subtracting 1 minute gives 8:59:00 635 + const from = new Date("2025-03-15T09:00:00.001Z"); 636 + const prev = previousRun("* * * * *", { from }); 637 + 638 + expect(prev.getUTCHours()).toBe(8); 639 + expect(prev.getUTCMinutes()).toBe(59); 553 640 }); 554 641 }); 555 642 ··· 1381 1468 }); 1382 1469 1383 1470 describe("invalid timezone", () => { 1384 - it("should return null for invalid timezone in nextRun", () => { 1471 + it("should throw for invalid timezone in nextRun", () => { 1385 1472 const from = new Date("2026-03-15T14:00:00Z"); 1386 - expect(nextRun("0 9 * * *", { from, timezone: "Invalid/Timezone" })).toBeNull(); 1473 + expect(() => nextRun("0 9 * * *", { from, timezone: "Invalid/Timezone" })).toThrow( 1474 + 'Invalid timezone: "Invalid/Timezone"', 1475 + ); 1387 1476 }); 1388 1477 1389 - it("should return null for invalid timezone in previousRun", () => { 1478 + it("should throw for invalid timezone in previousRun", () => { 1390 1479 const from = new Date("2026-03-15T14:00:00Z"); 1391 - expect(previousRun("0 9 * * *", { from, timezone: "NotATimezone" })).toBeNull(); 1480 + expect(() => previousRun("0 9 * * *", { from, timezone: "NotATimezone" })).toThrow( 1481 + 'Invalid timezone: "NotATimezone"', 1482 + ); 1392 1483 }); 1393 1484 1394 - it("should return false for invalid timezone in isMatch", () => { 1485 + it("should throw for invalid timezone in isMatch", () => { 1395 1486 const date = new Date("2026-03-15T09:00:00Z"); 1396 - expect(isMatch("0 9 * * *", date, { timezone: "Fake/Zone" })).toBe(false); 1487 + expect(() => isMatch("0 9 * * *", date, { timezone: "Fake/Zone" })).toThrow( 1488 + 'Invalid timezone: "Fake/Zone"', 1489 + ); 1397 1490 }); 1398 1491 1399 - it("should return null for empty timezone string in nextRun", () => { 1492 + it("should throw for empty timezone string in nextRun", () => { 1400 1493 const from = new Date("2026-03-15T14:00:00Z"); 1401 - expect(nextRun("0 9 * * *", { from, timezone: "" })).toBeNull(); 1494 + expect(() => nextRun("0 9 * * *", { from, timezone: "" })).toThrow('Invalid timezone: ""'); 1402 1495 }); 1403 1496 }); 1404 1497 });