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.

unit tests for edge cases, code coverage

+311 -8
+3 -6
README.md
··· 1 1 # cron-fast 2 2 3 3 [![npm version](https://badge.fury.io/js/cron-fast.svg)](https://www.npmjs.com/package/cron-fast) 4 - [![Node.js](https://img.shields.io/node/v/cron-fast)](https://www.npmjs.com/package/cron-fast) 5 - [![Bundle Size](https://pkg-size.dev/badge/bundle/cron-fast)](https://pkg-size.dev/cron-fast) 4 + [![npm provenance](https://img.shields.io/badge/provenance-attested-brightgreen)](https://www.npmjs.com/package/cron-fast) 6 5 [![JSR](https://jsr.io/badges/@kbilkis/cron-fast)](https://jsr.io/@kbilkis/cron-fast) 7 6 [![JSR Score](https://jsr.io/badges/@kbilkis/cron-fast/score)](https://jsr.io/@kbilkis/cron-fast) 8 7 [![CI](https://github.com/kbilkis/cron-fast/actions/workflows/ci.yml/badge.svg)](https://github.com/kbilkis/cron-fast/actions/workflows/ci.yml) 9 8 [![codecov](https://codecov.io/github/kbilkis/cron-fast/graph/badge.svg?token=5MXFKS45XV)](https://codecov.io/github/kbilkis/cron-fast) 10 - [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?logo=typescript)](https://www.typescriptlang.org/) 11 - [![Last commit](https://img.shields.io/github/last-commit/kbilkis/cron-fast)](https://github.com/kbilkis/cron-fast/commits/master) 12 - [![GitHub stars](https://img.shields.io/github/stars/kbilkis/cron-fast?style=social)](https://github.com/kbilkis/cron-fast) 9 + [![npm bundle size](https://img.shields.io/bundlejs/size/cron-fast)](https://bundlejs.com/?q=cron-fast) 13 10 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 14 11 15 - **Fast JavaScript/TypeScript cron parser with timezone support.** Works everywhere: Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies. 12 + **Fast and tiny JavaScript/TypeScript cron parser with timezone support.** Works everywhere: Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies. 16 13 17 14 ## Features 18 15
+22 -1
jsr.json
··· 1 1 { 2 2 "name": "@kbilkis/cron-fast", 3 3 "version": "0.1.2", 4 - "description": "Fast JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 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 + "keywords": [ 6 + "javascript", 7 + "typescript", 8 + "cron", 9 + "parser", 10 + "scheduler", 11 + "schedule", 12 + "nodejs", 13 + "node", 14 + "deno", 15 + "bun", 16 + "cloudflare-workers", 17 + "browser", 18 + "timezone", 19 + "fast", 20 + "lightweight", 21 + "performance", 22 + "zero-dependencies", 23 + "esm", 24 + "tree-shakeable" 25 + ], 5 26 "exports": "./src/index.ts", 6 27 "publish": { 7 28 "include": ["src/**/*.ts", "README.md", "LICENSE"]
+1 -1
package.json
··· 1 1 { 2 2 "name": "cron-fast", 3 3 "version": "0.1.2", 4 - "description": "Fast JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 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", 7 7 "bun",
+17
test/parser.test.ts
··· 41 41 expect(result.minute).toEqual([10, 20, 30, 40]); 42 42 }); 43 43 44 + it("should parse step values with single start value", () => { 45 + const result = parse("10/15 * * * *"); 46 + expect(result.minute).toEqual([10, 25, 40, 55]); 47 + }); 48 + 44 49 it("should parse comma-separated values", () => { 45 50 const result = parse("0,15,30,45 9,12,15 * * *"); 46 51 expect(result.minute).toEqual([0, 15, 30, 45]); ··· 85 90 86 91 it("should throw on out of range value", () => { 87 92 expect(() => parse("60 * * * *")).toThrow("out of range"); 93 + }); 94 + 95 + it("should throw on out of range value in step range", () => { 96 + expect(() => parse("70-80/5 * * * *")).toThrow("No valid values"); 97 + }); 98 + 99 + it("should throw on out of range value in simple range", () => { 100 + expect(() => parse("70-80 * * * *")).toThrow("No valid values"); 101 + }); 102 + 103 + it("should throw on invalid value name", () => { 104 + expect(() => parse("0 0 * * notaday")).toThrow("Invalid value"); 88 105 }); 89 106 90 107 it("should throw on impossible day/month combination (Feb 31)", () => {
+162
test/scheduler.test.ts
··· 952 952 }); 953 953 }); 954 954 }); 955 + 956 + describe("Edge case coverage", () => { 957 + describe("minute rollover to next hour (scheduler.ts:160-161)", () => { 958 + it("should rollover to next hour when no valid minute exists in current hour", () => { 959 + const from = new Date("2026-03-15T10:58:00Z"); 960 + const next = nextRun("30,45 11 * * *", { from }); 961 + 962 + expect(next.getUTCDate()).toBe(15); 963 + expect(next.getUTCHours()).toBe(11); 964 + expect(next.getUTCMinutes()).toBe(30); 965 + }); 966 + 967 + it("should hit exact branch for minute rollover with targetHour (lines 160-161)", () => { 968 + const from = new Date("2026-03-15T14:45:00Z"); 969 + const next = nextRun("0,15 15,16 * * *", { from }); 970 + 971 + expect(next.getUTCHours()).toBe(15); 972 + expect(next.getUTCMinutes()).toBe(0); 973 + }); 974 + 975 + it("should handle prev direction minute rollover to prev hour (lines 160-161)", () => { 976 + const from = new Date("2026-03-15T14:05:00Z"); 977 + const prev = previousRun("45,59 13,14 * * *", { from }); 978 + 979 + expect(prev.getUTCHours()).toBe(13); 980 + expect(prev.getUTCMinutes()).toBe(59); 981 + }); 982 + 983 + it("should handle minute rollover when at minute 59", () => { 984 + const from = new Date("2026-03-15T23:58:00Z"); 985 + const next = nextRun("30 0 * * *", { from }); 986 + 987 + expect(next.getUTCDate()).toBe(16); 988 + expect(next.getUTCHours()).toBe(0); 989 + expect(next.getUTCMinutes()).toBe(30); 990 + }); 991 + 992 + it("should handle minute rollover with restricted minutes in current hour", () => { 993 + const from = new Date("2026-03-15T14:40:00Z"); 994 + const next = nextRun("0,15,30 15 * * *", { from }); 995 + 996 + expect(next.getUTCDate()).toBe(15); 997 + expect(next.getUTCHours()).toBe(15); 998 + expect(next.getUTCMinutes()).toBe(0); 999 + }); 1000 + 1001 + it("should skip to next hour when no valid minutes left in current hour", () => { 1002 + const from = new Date("2026-03-15T10:50:00Z"); 1003 + const next = nextRun("15,30 11 * * *", { from }); 1004 + 1005 + expect(next.getUTCDate()).toBe(15); 1006 + expect(next.getUTCHours()).toBe(11); 1007 + expect(next.getUTCMinutes()).toBe(15); 1008 + }); 1009 + 1010 + it("should use nextRuns to iterate through hour boundaries", () => { 1011 + const from = new Date("2026-03-15T10:30:00Z"); 1012 + const runs = nextRuns("30,45 11 * * *", 3, { from }); 1013 + 1014 + expect(runs).toHaveLength(3); 1015 + expect(runs[0].getUTCHours()).toBe(11); 1016 + expect(runs[0].getUTCMinutes()).toBe(30); 1017 + expect(runs[1].getUTCMinutes()).toBe(45); 1018 + expect(runs[2].getUTCDate()).toBe(16); 1019 + expect(runs[2].getUTCHours()).toBe(11); 1020 + expect(runs[2].getUTCMinutes()).toBe(30); 1021 + }); 1022 + }); 1023 + 1024 + describe("month boundary with invalid day (scheduler.ts:239-241)", () => { 1025 + it("should move to previous month when current month has no valid day", () => { 1026 + const from = new Date("2026-04-01T00:00:00Z"); 1027 + const prev = previousRun("0 12 31 * *", { from }); 1028 + 1029 + expect(prev.getUTCMonth()).toBe(2); 1030 + expect(prev.getUTCDate()).toBe(31); 1031 + expect(prev.getUTCHours()).toBe(12); 1032 + expect(prev.getUTCMinutes()).toBe(0); 1033 + }); 1034 + 1035 + it("should trigger resetToMonthBoundary when no valid day in month for prev direction", () => { 1036 + const from = new Date("2026-05-01T12:00:00Z"); 1037 + const prev = previousRun("0 9 31 * *", { from }); 1038 + 1039 + expect(prev.getUTCMonth()).toBe(2); 1040 + expect(prev.getUTCDate()).toBe(31); 1041 + expect(prev.getUTCHours()).toBe(9); 1042 + }); 1043 + 1044 + it("should handle February with day 31 going to January", () => { 1045 + const from = new Date("2026-03-01T00:00:00Z"); 1046 + const prev = previousRun("0 9 31 * *", { from }); 1047 + 1048 + expect(prev.getUTCMonth()).toBe(0); 1049 + expect(prev.getUTCDate()).toBe(31); 1050 + expect(prev.getUTCHours()).toBe(9); 1051 + }); 1052 + 1053 + it("should handle April 31 case when going backwards", () => { 1054 + const from = new Date("2026-05-01T00:00:00Z"); 1055 + const prev = previousRun("0 12 31 * *", { from }); 1056 + 1057 + expect(prev.getUTCMonth()).toBe(2); 1058 + expect(prev.getUTCDate()).toBe(31); 1059 + }); 1060 + 1061 + it("should handle June 31 case when going backwards", () => { 1062 + const from = new Date("2026-07-01T00:00:00Z"); 1063 + const prev = previousRun("0 12 31 * *", { from }); 1064 + 1065 + expect(prev.getUTCMonth()).toBe(4); 1066 + expect(prev.getUTCDate()).toBe(31); 1067 + }); 1068 + 1069 + it("should handle September 31 case when going backwards", () => { 1070 + const from = new Date("2026-10-01T00:00:00Z"); 1071 + const prev = previousRun("0 12 31 * *", { from }); 1072 + 1073 + expect(prev.getUTCMonth()).toBe(7); 1074 + expect(prev.getUTCDate()).toBe(31); 1075 + }); 1076 + 1077 + it("should handle November 31 case when going backwards", () => { 1078 + const from = new Date("2026-12-01T00:00:00Z"); 1079 + const prev = previousRun("0 12 31 * *", { from }); 1080 + 1081 + expect(prev.getUTCMonth()).toBe(9); 1082 + expect(prev.getUTCDate()).toBe(31); 1083 + }); 1084 + 1085 + it("should trigger moveToMonth recursion for prev with no valid days", () => { 1086 + const from = new Date("2026-06-15T12:00:00Z"); 1087 + const prev = previousRun("0 12 31 1 *", { from }); 1088 + 1089 + expect(prev.getUTCMonth()).toBe(0); 1090 + expect(prev.getUTCDate()).toBe(31); 1091 + expect(prev.getUTCFullYear()).toBe(2026); 1092 + }); 1093 + }); 1094 + 1095 + describe("MAX_ITERATIONS safety (lines 93-94)", () => { 1096 + it("should find matches efficiently without hitting iteration limit", () => { 1097 + const from = new Date("2026-01-01T00:00:00Z"); 1098 + const sparseCron = "0 0 29 2 1"; 1099 + 1100 + const next = nextRun(sparseCron, { from }); 1101 + 1102 + expect(next.getUTCDate()).toBe(29); 1103 + expect(next.getUTCMonth()).toBe(1); 1104 + expect(next.getUTCFullYear()).toBe(2028); 1105 + }); 1106 + 1107 + it("should handle sparse cron expressions within iteration limit", () => { 1108 + const from = new Date("2026-01-01T00:00:00Z"); 1109 + const next = nextRun("0 0 31 12 *", { from }); 1110 + 1111 + expect(next.getUTCDate()).toBe(31); 1112 + expect(next.getUTCMonth()).toBe(11); 1113 + expect(next.getUTCFullYear()).toBe(2026); 1114 + }); 1115 + }); 1116 + }); 955 1117 });
+106
test/timezone.test.ts
··· 51 51 expect(restored.getTime()).toBe(date.getTime()); 52 52 } 53 53 }); 54 + 55 + describe("24:00:00 format handling", () => { 56 + it("should handle timezone conversions near midnight (24:00:00 format)", () => { 57 + const timezone = "Pacific/Auckland"; 58 + const original = new Date("2026-03-15T00:00:00Z"); 59 + 60 + const converted = convertToTimezone(original, timezone); 61 + const restored = convertFromTimezone(converted, timezone); 62 + 63 + expect(restored.getTime()).toBe(original.getTime()); 64 + }); 65 + 66 + it("should handle conversions across midnight boundary", () => { 67 + const timezone = "Asia/Tokyo"; 68 + const dates = [ 69 + new Date("2026-03-15T14:59:59Z"), 70 + new Date("2026-03-15T15:00:00Z"), 71 + new Date("2026-03-15T23:59:59Z"), 72 + new Date("2026-03-16T00:00:00Z"), 73 + ]; 74 + 75 + for (const date of dates) { 76 + const converted = convertToTimezone(date, timezone); 77 + const restored = convertFromTimezone(converted, timezone); 78 + 79 + expect(restored.getTime()).toBe(date.getTime()); 80 + } 81 + }); 82 + 83 + it("should normalize potential 24:00:00 in convertToTimezone (line 20)", () => { 84 + const timezone = "Asia/Tokyo"; 85 + const utcMidnight = new Date("2026-03-15T00:00:00Z"); 86 + 87 + const converted = convertToTimezone(utcMidnight, timezone); 88 + 89 + expect(converted.getUTCHours()).toBeGreaterThanOrEqual(0); 90 + expect(converted.getUTCHours()).toBeLessThan(24); 91 + }); 92 + 93 + it("should normalize potential 24:00:00 in convertFromTimezone iteration (lines 74, 120)", () => { 94 + const timezone = "Pacific/Auckland"; 95 + const dates = [ 96 + new Date("2026-03-15T11:00:00Z"), 97 + new Date("2026-03-15T11:30:00Z"), 98 + new Date("2026-03-15T11:59:59Z"), 99 + ]; 100 + 101 + for (const date of dates) { 102 + const converted = convertToTimezone(date, timezone); 103 + const restored = convertFromTimezone(converted, timezone); 104 + 105 + expect(restored.getUTCHours()).toBeGreaterThanOrEqual(0); 106 + expect(restored.getUTCHours()).toBeLessThan(24); 107 + expect(restored.getTime()).toBe(date.getTime()); 108 + } 109 + }); 110 + 111 + it("should handle edge case conversions at day boundaries in multiple timezones", () => { 112 + const timezones = [ 113 + "Pacific/Auckland", 114 + "Australia/Sydney", 115 + "Asia/Tokyo", 116 + "Asia/Shanghai", 117 + "Europe/Moscow", 118 + ]; 119 + 120 + const utcTimes = [ 121 + new Date("2026-03-15T10:00:00Z"), 122 + new Date("2026-03-15T11:00:00Z"), 123 + new Date("2026-03-15T12:00:00Z"), 124 + new Date("2026-03-15T13:00:00Z"), 125 + new Date("2026-03-15T14:00:00Z"), 126 + ]; 127 + 128 + for (const tz of timezones) { 129 + for (const time of utcTimes) { 130 + const converted = convertToTimezone(time, tz); 131 + const restored = convertFromTimezone(converted, tz); 132 + 133 + expect(restored.getUTCHours()).toBeGreaterThanOrEqual(0); 134 + expect(restored.getUTCHours()).toBeLessThan(24); 135 + expect(restored.getTime()).toBe(time.getTime()); 136 + } 137 + } 138 + }); 139 + 140 + it("should handle DST gap scenarios (timezone.ts:120)", () => { 141 + const timezone = "America/New_York"; 142 + const beforeDST = new Date("2026-03-08T06:59:59Z"); 143 + 144 + const converted = convertToTimezone(beforeDST, timezone); 145 + const restored = convertFromTimezone(converted, timezone); 146 + 147 + expect(restored.getTime()).toBe(beforeDST.getTime()); 148 + }); 149 + 150 + it("should handle multiple iterations in convertFromTimezone", () => { 151 + const timezone = "Europe/London"; 152 + const date = new Date("2026-03-28T00:30:00Z"); 153 + 154 + const converted = convertToTimezone(date, timezone); 155 + const restored = convertFromTimezone(converted, timezone); 156 + 157 + expect(restored.getTime()).toBe(date.getTime()); 158 + }); 159 + }); 54 160 });