├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── renovate.json ├── scripts ├── remove-type.ts └── restore-type.ts ├── src ├── fieldCheckers │ ├── dayOfMonthChecker.ts │ ├── dayOfWeekChecker.ts │ ├── hourChecker.ts │ ├── minuteChecker.ts │ ├── monthChecker.ts │ ├── secondChecker.ts │ └── yearChecker.ts ├── helper.ts ├── index.test.ts ├── index.ts ├── matrix.test.ts ├── option.ts ├── presets.ts ├── result.ts └── types.ts ├── tsconfig.build.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true 4 | }, 5 | "extends": [ 6 | "airbnb-base", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier" 9 | ], 10 | "plugins": ["@typescript-eslint"], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module", 15 | "project": "./tsconfig.json" 16 | }, 17 | "rules": { 18 | "no-nested-ternary": "off", 19 | "no-param-reassign": [ 20 | "error", 21 | { 22 | "props": false 23 | } 24 | ], 25 | "no-useless-escape": "off", 26 | "import/no-unresolved": "off", 27 | "import/extensions": "off", 28 | "no-underscore-dangle": "off", 29 | "lines-between-class-members": [ 30 | "error", 31 | "always", 32 | { 33 | "exceptAfterSingleLine": true 34 | } 35 | ], 36 | "@typescript-eslint/no-unused-vars": ["error", { 37 | "vars": "all", 38 | "args": "none", 39 | "ignoreRestSiblings": false 40 | }], 41 | "@typescript-eslint/no-explicit-any": "off", 42 | "@typescript-eslint/no-non-null-assertion": "off", 43 | "@typescript-eslint/no-parameter-properties": "off", 44 | "@typescript-eslint/explicit-function-return-type": "off", 45 | "@typescript-eslint/explicit-member-accessibility": "off", 46 | "@typescript-eslint/interface-name-prefix": "off" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .idea/ 3 | 4 | .env 5 | 6 | node_modules/ 7 | 8 | coverage/ 9 | 10 | lib/ 11 | 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "bracketSpacing": true, 7 | "semi": false, 8 | "useTabs": false, 9 | "arrowParens": "avoid", 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.5.0](https://github.com/Airfooox/cron-validate/compare/v1.4.5...v1.5.0) (2025-03-13) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **deps:** update dependency yup to v1 ([#274](https://github.com/Airfooox/cron-validate/issues/274)) ([f4a61e5](https://github.com/Airfooox/cron-validate/commit/f4a61e597d9a1b284d90dc779b065247943e6224)) 7 | 8 | 9 | ### Features 10 | 11 | * add allowStepping option ([#290](https://github.com/Airfooox/cron-validate/issues/290)) ([f6a0c53](https://github.com/Airfooox/cron-validate/commit/f6a0c531058fbde7def3427dc55cce97db52c26d)) 12 | * add check for non-integer numbers ([#291](https://github.com/Airfooox/cron-validate/issues/291)) ([2977282](https://github.com/Airfooox/cron-validate/commit/2977282ef989749230b3a3ebcb8d1d90953dc07a)) 13 | * add check for second step number (max value, step range) ([#291](https://github.com/Airfooox/cron-validate/issues/291)) ([13e875b](https://github.com/Airfooox/cron-validate/commit/13e875b453a3efbec13fa97458034753405fb907)) 14 | 15 | ## [1.4.5](https://github.com/Airfooox/cron-validate/compare/v1.4.4...v1.4.5) (2022-11-20) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * day of week occurence number cannot be bigger than 5 ([#263](https://github.com/Airfooox/cron-validate/issues/263)) ([62cbb9b](https://github.com/Airfooox/cron-validate/commit/62cbb9ba6ba191eaa49bb51c073de78f3f28eaac)) 21 | 22 | ## [1.4.4](https://github.com/Airfooox/cron-validate/compare/v1.4.3...v1.4.4) (2022-10-12) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * update AWS cloudwatch preset ([f583267](https://github.com/Airfooox/cron-validate/commit/f583267a6c6c18792086160470a98baf5b0e2db7)) 28 | 29 | ## [1.4.3](https://github.com/Airfooox/cron-validate/compare/v1.4.2...v1.4.3) (2021-03-31) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * remove import type for backwards compatibility ([3e4d8ab](https://github.com/Airfooox/cron-validate/commit/3e4d8ab37869b2ed2290a9ca6a58a5b3bcc0beaa)) 35 | * **deps:** update dependency yup to v0.32.9 ([#166](https://github.com/Airfooox/cron-validate/issues/166)) ([0193a3f](https://github.com/Airfooox/cron-validate/commit/0193a3f5e767be95c34d80f965c631dab1cf5331)) 36 | 37 | ## [1.4.2](https://github.com/Airfooox/cron-validate/compare/v1.4.1...v1.4.2) (2020-12-25) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * **deps:** update dependency yup to v0.32.8 ([#146](https://github.com/Airfooox/cron-validate/issues/146)) ([fc2783d](https://github.com/Airfooox/cron-validate/commit/fc2783dfbdf6488cded93d3e639519c9779b1fec)) 43 | 44 | ## [1.4.1](https://github.com/Airfooox/cron-validate/compare/v1.4.0...v1.4.1) (2020-11-21) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * **deps:** update dependency yup to v0.30.0 ([#140](https://github.com/Airfooox/cron-validate/issues/140)) ([5679b0f](https://github.com/Airfooox/cron-validate/commit/5679b0f0310487e98c028444d9f2ca91d87c57fa)) 50 | 51 | # [1.4.0](https://github.com/Airfooox/cron-validate/compare/v1.3.1...v1.4.0) (2020-10-25) 52 | 53 | 54 | ### Features 55 | 56 | * add npm-cron-schedule preset ([#124](https://github.com/Airfooox/cron-validate/issues/124)) ([888bfa9](https://github.com/Airfooox/cron-validate/commit/888bfa925eeaf0ebaf1dd350e1acdd81827f18e6)) 57 | 58 | ## [1.3.1](https://github.com/Airfooox/cron-validate/compare/v1.3.0...v1.3.1) (2020-08-21) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * fix wrong wildcard ('*') check with lower-/upperLimit ([df8b6f2](https://github.com/Airfooox/cron-validate/commit/df8b6f2be19a803d8927e6b25dd71875b5bc262a)) 64 | 65 | # [1.3.0](https://github.com/Airfooox/cron-validate/compare/v1.2.0...v1.3.0) (2020-08-14) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * try to fix default export ([3a66f3f](https://github.com/Airfooox/cron-validate/commit/3a66f3f8998f58537341bb8233ceb2086c86a9d5)) 71 | * **deps:** update dependency yup to v0.29.3 ([#94](https://github.com/Airfooox/cron-validate/issues/94)) ([0080c06](https://github.com/Airfooox/cron-validate/commit/0080c06a1baaf68d591763aa76ca6e55e412ef60)) 72 | 73 | 74 | ### Features 75 | 76 | * Add support for alias ([#85](https://github.com/Airfooox/cron-validate/issues/85)) ([5193dd8](https://github.com/Airfooox/cron-validate/commit/5193dd8eb5f2ef30040d2edca2614e9fbc0c5364)) 77 | 78 | # [1.2.0](https://github.com/Airfooox/cron-validate/compare/v1.1.5...v1.2.0) (2020-07-29) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * **deps:** update dependency yup to v0.29.2 ([#87](https://github.com/Airfooox/cron-validate/issues/87)) ([cb8cdfe](https://github.com/Airfooox/cron-validate/commit/cb8cdfe67e99f652e0afc924a76d55e3b51cb1a3)) 84 | * add explicit return types to avoid Result ([#78](https://github.com/Airfooox/cron-validate/issues/78)) ([177e301](https://github.com/Airfooox/cron-validate/commit/177e30140cad37e70fdd05e38080ecffddc9b6f9)) 85 | 86 | 87 | ### Features 88 | 89 | * Extending AWS Preset wildcards and checks. ([#80](https://github.com/Airfooox/cron-validate/issues/80)) ([a7baca0](https://github.com/Airfooox/cron-validate/commit/a7baca034af13624c314351eb89fe38dadf9c950)) 90 | 91 | ## [1.1.5](https://github.com/Airfooox/cron-validate/compare/v1.1.4...v1.1.5) (2020-07-14) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * fix require().default for non-es modules ([2b0eb95](https://github.com/Airfooox/cron-validate/commit/2b0eb9523d66c168e7de00a298a87541796bda0c)), closes [#69](https://github.com/Airfooox/cron-validate/issues/69) 97 | 98 | ## [1.1.4](https://github.com/Airfooox/cron-validate/compare/v1.1.3...v1.1.4) (2020-05-31) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * **deps:** update dependency yup to v0.29.1 ([58eab14](https://github.com/Airfooox/cron-validate/commit/58eab14b1e66553af3f4a5e1183ccffe75e677f2)) 104 | 105 | ## [1.1.3](https://github.com/Airfooox/cron-validate/compare/v1.1.2...v1.1.3) (2020-05-20) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * fixed missing required fields in yup schema ([6ba1270](https://github.com/Airfooox/cron-validate/commit/6ba1270a6c9bd40936f6886df82757bf098ae004)) 111 | * **deps:** update dependency yup to v0.29.0 ([1bfcbc6](https://github.com/Airfooox/cron-validate/commit/1bfcbc692c193b0cd614dae3eb165f510fff6aab)) 112 | 113 | ## [1.1.2](https://github.com/Airfooox/cron-validate/compare/v1.1.1...v1.1.2) (2020-05-01) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * **deps:** update dependency yup to v0.28.5 ([27c6068](https://github.com/Airfooox/cron-validate/commit/27c60680cebe7b75d3bfb0c238dc59083f9d198b)) 119 | 120 | ## [1.1.1](https://github.com/Airfooox/cron-validate/compare/v1.1.0...v1.1.1) (2020-04-27) 121 | 122 | 123 | ### Bug Fixes 124 | 125 | * **deps:** update dependency yup to v0.28.4 ([de60998](https://github.com/Airfooox/cron-validate/commit/de60998ed1577b70c1e42621546a500df1ebd61e)) 126 | 127 | # [1.1.0](https://github.com/Airfooox/cron-validate/compare/v1.0.4...v1.1.0) (2020-04-18) 128 | 129 | 130 | ### Features 131 | 132 | * add blank day '?' support ([ca71a28](https://github.com/Airfooox/cron-validate/commit/ca71a289307d2e171f32f2298b793b60fbf33be7)) 133 | 134 | ## [1.0.4](https://github.com/Airfooox/cron-validate/compare/v1.0.3...v1.0.4) (2020-04-15) 135 | 136 | 137 | ### Bug Fixes 138 | 139 | * err not containing string array but string ([4d5a972](https://github.com/Airfooox/cron-validate/commit/4d5a9725ef0d2c2222f75d2c6b6412c16ef17037)) 140 | 141 | ## [1.0.3](https://github.com/Airfooox/cron-validate/compare/v1.0.2...v1.0.3) (2020-04-15) 142 | 143 | 144 | ### Bug Fixes 145 | 146 | * update package.json for npm description ([72132c5](https://github.com/Airfooox/cron-validate/commit/72132c54b712dee1abd1fb0d42d9bd5d3395658d)) 147 | 148 | ## [1.0.2](https://github.com/Airfooox/cron-validate/compare/v1.0.1...v1.0.2) (2020-04-15) 149 | 150 | 151 | ### Bug Fixes 152 | 153 | * update README.md (trigger release) ([3880d42](https://github.com/Airfooox/cron-validate/commit/3880d4260520da41317d9de64763b4c98f71e4e7)) 154 | 155 | ## [1.0.1](https://github.com/Airfooox/cron-validate/compare/v1.0.0...v1.0.1) (2020-04-15) 156 | 157 | 158 | ### Bug Fixes 159 | 160 | * add package-lock.json to git release (trigger a new version) ([9f85bd4](https://github.com/Airfooox/cron-validate/commit/9f85bd488a0cefc8347d52a17c4e3b4e448fb4b4)) 161 | 162 | # 1.0.0 (2020-04-15) 163 | 164 | 165 | ### Bug Fixes 166 | 167 | * add check for second step element ([76d59fa](https://github.com/Airfooox/cron-validate/commit/76d59fa73bb99c370c758a18aa45c59bb6e52988)) 168 | * Change imports of option type ([d447be5](https://github.com/Airfooox/cron-validate/commit/d447be55e99e57c7f93767be4a267453894c370d)) 169 | * fix useSeconds not being set on options by preset ([d77ec8c](https://github.com/Airfooox/cron-validate/commit/d77ec8c9cd7a670c21bc80e3ccd546cefc8289fc)) 170 | * fix value limits not applying to wildcard '*' ([00a3379](https://github.com/Airfooox/cron-validate/commit/00a3379414e1a0884a04c3167665eaea4935109e)) 171 | * fix wrong checks ([2f6290e](https://github.com/Airfooox/cron-validate/commit/2f6290ec277752b706bf1352a4d77cb64aa31112)) 172 | 173 | 174 | ### Features 175 | 176 | * add getOptionPreset and getOptionPresets ([9db79fe](https://github.com/Airfooox/cron-validate/commit/9db79fe012a419212d43aaa986c51830cd2de00a)) 177 | * Add individual checker files and add helper functions ([24ecf74](https://github.com/Airfooox/cron-validate/commit/24ecf747729124bb1fbb38e6eb9c7e34d3741540)) 178 | * add option validation with yup ([c08f210](https://github.com/Airfooox/cron-validate/commit/c08f2109ac3538aedf0a2d0a3353f2bbea457914)) 179 | * Add option.ts file, check for value limits ([032dcbe](https://github.com/Airfooox/cron-validate/commit/032dcbe9eaf9fdf875a9cbd39fe74727e8634bee)) 180 | * Add preset system for options ([bac86ee](https://github.com/Airfooox/cron-validate/commit/bac86ee44ad814f7ea9f430cd9fd27e33dc93066)) 181 | * Add reponse types for typesafe returns ([f85f36a](https://github.com/Airfooox/cron-validate/commit/f85f36a60f387aecba448355a0d4ec8c24bfdb92)) 182 | * add two additional option presets ([489e472](https://github.com/Airfooox/cron-validate/commit/489e4725978c1e22c71dea22edc624894cd383dd)) 183 | * Update result ([5bd3fa1](https://github.com/Airfooox/cron-validate/commit/5bd3fa18dc9c6e33a3ad83c66860c8451033f6b8)) 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Konstantin 'Airfooox' Zisiadis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cron-validate 2 | 3 | [![typescript](https://camo.githubusercontent.com/56e4a1d9c38168bd7b1520246d6ee084ab9abbbb/68747470733a2f2f62616467656e2e6e65742f62616467652f69636f6e2f547970655363726970743f69636f6e3d74797065736372697074266c6162656c266c6162656c436f6c6f723d626c756526636f6c6f723d353535353535)](https://www.typescriptlang.org/) 4 | [![dependencies Status](https://img.shields.io/npm/v/cron-validate)](https://www.npmjs.com/package/cron-validate) 5 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | ![npm](https://img.shields.io/npm/dw/cron-validate) 8 | [![dependencies Status](https://david-dm.org/airfooox/cron-validate/status.svg)](https://david-dm.org/airfooox/cron-validate) 9 | 10 | Cron-validate is a cron-expression validator written in TypeScript. 11 | The validation options are customizable and cron fields like seconds and years are supported. 12 | 13 | ## Installation 14 | 15 | Package is available on npm: 16 | 17 | `npm install -S cron-validate` 18 | 19 | ## Usage 20 | 21 | ### Basic usage 22 | 23 | ```typescript 24 | import cron from 'cron-validate' 25 | 26 | const cronResult = cron('* * * * *') 27 | if (cronResult.isValid()) { 28 | // !cronResult.isError() 29 | // valid code 30 | } else { 31 | // error code 32 | } 33 | ``` 34 | 35 | ### Result system 36 | 37 | The `cron` function returns a Result-type, which is either `Valid` or `Err`. 38 | 39 | For checking the returned result, just use `result.isValid()` or `result.isError()` 40 | 41 | Both result types contain values: 42 | 43 | ```typescript 44 | import cron from 'cron-validate' 45 | 46 | const cronResult = cron('* * * * *') 47 | if (cronResult.isValid()) { 48 | const validValue = cronResult.getValue() 49 | 50 | // The valid value is a object containing all cron fields 51 | console.log(validValue) 52 | // In this case, it would be: 53 | // { seconds: undefined, minutes: '*', hours: '*', daysOfMonth: '*', months: '*', daysOfWeek: '*', years: undefiend } 54 | } else { 55 | const errorValue = cronResult.getError() 56 | 57 | // The error value contains an array of strings, which represent the cron validation errors. 58 | console.log(errorValue) // string[] of error messages 59 | } 60 | ``` 61 | 62 | Make sure to test the result type beforehand, because `getValue()` only works on `Valid` and `getError()` only works on `Err`. If you don't check, it will throw an error. 63 | 64 | For further information, you can check out https://github.com/gDelgado14/neverthrow, because I used and modified his code for this package. 65 | (Therefor not every documented function on his package is available on this package.) 66 | 67 | ## Options / Configuration 68 | 69 | To configure the validator, cron-validate uses a preset system. There are already defined presets (default, npm-node-cron or aws), but you can also define your own preset to use for your system. You can also use the override property to set certain option on single cron validates. 70 | 71 | ### Presets 72 | 73 | The following presets are already defined by cron-validate: 74 | 75 | - default (see: http://crontab.org/) 76 | - npm-node-cron (see: https://github.com/kelektiv/node-cron) 77 | - aws-cloud-watch (see: https://docs.aws.amazon.com/de_de/AmazonCloudWatch/latest/events/ScheduledEvents.html) 78 | - npm-cron-schedule (see: https://github.com/P4sca1/cron-schedule) 79 | 80 | To select a preset for your validation, you can simply do this: 81 | 82 | ```typescript 83 | cron('* * * * *', { 84 | preset: 'npm-cron-schedule', 85 | }) 86 | ``` 87 | 88 | #### Defining and using your own preset 89 | 90 | To define your own preset, use this: 91 | 92 | ```typescript 93 | registerOptionPreset('YOUR-PRESET-ID', { 94 | presetId: 'YOUR-PRESET-ID', 95 | useSeconds: false, 96 | useYears: false, 97 | useAliases: false, // optional, default to false 98 | useBlankDay: false, 99 | allowOnlyOneBlankDayField: false, 100 | allowStepping: true, // optional, defaults to true 101 | mustHaveBlankDayField: false, // optional, default to false 102 | useLastDayOfMonth: false, // optional, default to false 103 | useLastDayOfWeek: false, // optional, default to false 104 | useNearestWeekday: false, // optional, default to false 105 | useNthWeekdayOfMonth: false, // optional, default to false 106 | seconds: { 107 | minValue: 0, 108 | maxValue: 59, 109 | lowerLimit: 0, // optional, default to minValue 110 | upperLimit: 59, // optional, default to maxValue 111 | }, 112 | minutes: { 113 | minValue: 0, 114 | maxValue: 59, 115 | lowerLimit: 0, // optional, default to minValue 116 | upperLimit: 59, // optional, default to maxValue 117 | }, 118 | hours: { 119 | minValue: 0, 120 | maxValue: 23, 121 | lowerLimit: 0, // optional, default to minValue 122 | upperLimit: 23, // optional, default to maxValue 123 | }, 124 | daysOfMonth: { 125 | minValue: 1, 126 | maxValue: 31, 127 | lowerLimit: 1, // optional, default to minValue 128 | upperLimit: 31, // optional, default to maxValue 129 | }, 130 | months: { 131 | minValue: 0, 132 | maxValue: 12, 133 | lowerLimit: 0, // optional, default to minValue 134 | upperLimit: 12, // optional, default to maxValue 135 | }, 136 | daysOfWeek: { 137 | minValue: 1, 138 | maxValue: 7, 139 | lowerLimit: 1, // optional, default to minValue 140 | upperLimit: 7, // optional, default to maxValue 141 | }, 142 | years: { 143 | minValue: 1970, 144 | maxValue: 2099, 145 | lowerLimit: 1970, // optional, default to minValue 146 | upperLimit: 2099, // optional, default to maxValue 147 | }, 148 | }) 149 | ``` 150 | 151 | The preset properties explained: 152 | 153 | - `presetId: string` 154 | - same id as in first function parameter 155 | - `useSeconds: boolean` 156 | - enables seconds field in cron expression 157 | - `useYears: boolean` 158 | - enables years field in cron expression 159 | - `useAliases: boolean` 160 | - enables aliases for month and daysOfWeek fields (ignores limits for month and daysOfWeek, so be aware of that) 161 | - `useBlankDay: boolean` 162 | - enables blank day notation '?' in daysOfMonth and daysOfWeek field 163 | - `allowOnlyOneBlankDayField: boolean` 164 | - requires a day field to not be blank (so not both day fields can be blank) 165 | - `allowStepping: boolean` 166 | - optional, will default to true 167 | - when set to false, disallows the use of the '/' operation for valid expressions 168 | - `mustHaveBlankDayField: boolean` 169 | - requires a day field to be blank (so not both day fields are specified) 170 | - when mixed with `allowOnlyOneBlankDayField`, it means that there will always be either day or day of week as `?` 171 | - `useLastDayOfMonth: boolean` 172 | - enables the 'L' character to specify the last day of the month. 173 | - accept negative offset after the 'L' for nth last day of the month. 174 | - e.g.: `L-2` would me the 2nd to last day of the month. 175 | - `useLastDayOfWeek: boolean` 176 | - enables the 'L' character to specify the last occurrence of a weekday in a month. 177 | - e.g.: `5L` would mean the last friday of the month. 178 | - `useNearestWeekday: boolean` 179 | - enables the 'W' character to specify the use of the closest weekday. 180 | - e.g.: `15W` would mean the weekday (mon-fri) closest to the 15th when the 15th is on sat-sun. 181 | - `useNthWeekdayOfMonth: boolean` 182 | - enables the '#' character to specify the Nth weekday of the month. 183 | - e.g.: `6#3` would mean the 3rd friday of the month (assuming 6 = friday). 184 | 185 | * in cron fields (like seconds, minutes etc.): 186 | - `minValue: number` 187 | - minimum value of your cron interpreter (like npm-node-cron only supports 0-6 for weekdays) 188 | - can't be set as override 189 | - `maxValue: number` 190 | - minimum value of your cron interpreter (like npm-node-cron only supports 0-6 for weekdays) 191 | - can't be set as override 192 | - `lowerLimit?: number` 193 | - lower limit for validation 194 | - equal or greater than minValue 195 | - if not set, default to minValue 196 | - `upperLimit?: number` 197 | - upper limit for validation 198 | - equal or lower than maxValue 199 | - if not set, defaults to maxValue 200 | 201 | ### Override preset options 202 | 203 | If you want to override a option for single cron validations, you can use the `override` property: 204 | 205 | ```typescript 206 | console.log(cron('* * * * * *', { 207 | preset: 'default', // second field not supported in default preset 208 | override: { 209 | useSeconds: true // override preset option 210 | } 211 | })) 212 | 213 | console.log(cron('* 10-20 * * * *', { 214 | preset: 'default', 215 | override: { 216 | minutes: { 217 | lowerLimit: 10, // override preset option 218 | upperLimit: 20 // override preset option 219 | } 220 | } 221 | })) 222 | ``` 223 | 224 | ## Examples 225 | 226 | ```typescript 227 | import cron from 'cron-validate' 228 | 229 | console.log(cron('* * * * *').isValid()) // true 230 | 231 | console.log(cron('* * * * *').isError()) // false 232 | 233 | console.log(cron('* 2,3,4 * * *').isValid()) // true 234 | 235 | console.log(cron('0 */2 */5 * *').isValid()) // true 236 | 237 | console.log(cron('* * * * * *', { override: { useSeconds: true } }).isValid()) // true 238 | 239 | console.log(cron('* * * * * *', { override: { useYears: true } }).isValid()) // true 240 | 241 | console.log( 242 | cron('30 * * * * *', { 243 | override: { 244 | useSeconds: true, 245 | seconds: { 246 | lowerLimit: 20, 247 | upperLimit: 40, 248 | }, 249 | }, 250 | }).isValid() 251 | ) // true 252 | 253 | console.log( 254 | cron('* 3 * * *', { 255 | override: { 256 | hours: { 257 | lowerLimit: 0, 258 | upperLimit: 2, 259 | }, 260 | }, 261 | }).isValid() 262 | ) // false 263 | 264 | console.log( 265 | cron('* * ? * *', { 266 | override: { 267 | useBlankDay: true, 268 | }, 269 | }).isValid() 270 | ) // true 271 | 272 | console.log( 273 | cron('* * ? * ?', { 274 | override: { 275 | useBlankDay: true, 276 | allowOnlyOneBlankDayField: true, 277 | }, 278 | }).isValid() 279 | ) // false 280 | ``` 281 | 282 | ## (Planned) Features 283 | 284 | - [x] Basic cron validation. 285 | - [x] Error messenges with information about invalid cron expression. 286 | - [x] Seconds field support. 287 | - [x] Years field support. 288 | - [x] Option presets (classic cron, node-cron, etc.) 289 | - [x] Blank '?' daysOfMonth/daysOfWeek support 290 | - [x] Last day of month. 291 | - [x] Last specific weekday of month. (e.g. last Tuesday) 292 | - [x] Closest weekday to a specific day of the month. 293 | - [x] Nth specific weekday of month. (e.g. 2nd Tuesday) 294 | - [x] Cron alias support. 295 | 296 |
297 | 298 | ### Contributors 299 | 300 | 308 | 309 |
310 | 311 | ### Used by: 312 | 317 | 318 |
319 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', 5 | }; -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "exec": "ts-node --project ./tsconfig.json --transpile-only ./src/index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron-validate", 3 | "version": "1.5.2", 4 | "description": "cron-validate is a cron-expression validator written in TypeScript.", 5 | "scripts": { 6 | "dev": "nodemon", 7 | "build": "tsc -p ./tsconfig.build.json", 8 | "lint": "eslint src/**/*.ts", 9 | "lint-fix": "eslint src/**/*.ts --fix", 10 | "prettier": "prettier --write src/**/*.ts", 11 | "tsc-check": "tsc --project ./tsconfig.json --noEmit", 12 | "release": "npm run build && env-cmd npx semantic-release --branches master --no-ci", 13 | "release-dry": "npm run build && env-cmd npx semantic-release --branches master --no-ci --dry-run", 14 | "release-next": "npm run build && env-cmd npx semantic-release --branches next --no-ci", 15 | "release-next-major": "npm run build && env-cmd npx semantic-release --branches next-major --no-ci", 16 | "test": "jest --coverage", 17 | "test:watch": "jest --watchAll --verbose" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/Airfooox/cron-validate.git" 22 | }, 23 | "author": "Konstantin L. 'Airfox' Zisiadis", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/Airfooox/cron-validate/issues" 27 | }, 28 | "homepage": "https://github.com/Airfooox/cron-validate#readme", 29 | "keywords": [ 30 | "cron-validator", 31 | "cron-validation", 32 | "validation", 33 | "cron", 34 | "cron-expression", 35 | "typescript" 36 | ], 37 | "main": "lib/index.js", 38 | "type": "module", 39 | "types": "lib/index.d.ts", 40 | "files": [ 41 | "lib" 42 | ], 43 | "release": { 44 | "ci": false, 45 | "branches": [ 46 | "master", 47 | { 48 | "name": "next", 49 | "prerelease": true 50 | } 51 | ], 52 | "plugins": [ 53 | "@semantic-release/commit-analyzer", 54 | "@semantic-release/release-notes-generator", 55 | [ 56 | "@semantic-release/changelog", 57 | { 58 | "changelogFile": "CHANGELOG.md" 59 | } 60 | ], 61 | [ 62 | "@semantic-release/npm", 63 | { 64 | "npmPublish": true, 65 | "pkgRoot": "." 66 | } 67 | ], 68 | "@semantic-release/github", 69 | [ 70 | "@semantic-release/git", 71 | { 72 | "assets": [ 73 | "CHANGELOG.md", 74 | "package.json", 75 | "package-lock.json" 76 | ], 77 | "message": "chore(${branch.name}): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 78 | } 79 | ] 80 | ], 81 | "prepare": [ 82 | { 83 | "path": "@semantic-release/exec", 84 | "cmd": "npx tsx scripts/remove-type.ts" 85 | } 86 | ], 87 | "success": [ 88 | { 89 | "path": "@semantic-release/exec", 90 | "cmd": "npx tsx scripts/restore-type.ts" 91 | } 92 | ] 93 | }, 94 | "devDependencies": { 95 | "@semantic-release/changelog": "6.0.3", 96 | "@semantic-release/commit-analyzer": "9.0.2", 97 | "@semantic-release/exec": "7.0.3", 98 | "@semantic-release/git": "10.0.1", 99 | "@semantic-release/github": "8.1.0", 100 | "@semantic-release/release-notes-generator": "10.0.3", 101 | "@types/jest": "28.1.8", 102 | "@types/node": "13.13.52", 103 | "@types/yup": "0.32.0", 104 | "@typescript-eslint/eslint-plugin": "5.40.0", 105 | "@typescript-eslint/parser": "5.40.0", 106 | "env-cmd": "10.1.0", 107 | "eslint": "8.25.0", 108 | "eslint-config-airbnb-base": "15.0.0", 109 | "eslint-config-prettier": "10.1.1", 110 | "jest": "28.1.3", 111 | "nodemon": "3.1.9", 112 | "prettier": "2.3.1", 113 | "semantic-release": "24.2.3", 114 | "ts-jest": "28.0.8", 115 | "ts-node": "10.9.1", 116 | "typescript": "4.8.4" 117 | }, 118 | "dependencies": { 119 | "yup": "1.6.1" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | ":enableRenovate", 4 | ":pinVersions", 5 | ":separateMultipleMajorReleases", 6 | ":combinePatchMinorReleases", 7 | ":ignoreUnstable", 8 | ":semanticCommits", 9 | ":semanticPrefixFixDepsChoreOthers", 10 | ":updateNotScheduled", 11 | ":automergeDisabled", 12 | ":ignoreModulesAndTests", 13 | ":prImmediately", 14 | ":prHourlyLimitNone", 15 | ":prConcurrentLimit20", 16 | ":label(dependencies)", 17 | ":enableVulnerabilityAlertsWithLabel(security)", 18 | ":npm", 19 | ":docker", 20 | "group:monorepos", 21 | "group:recommended", 22 | "helpers:disableTypesNodeMajor" 23 | ], 24 | "baseBranches": ["next"] 25 | } 26 | -------------------------------------------------------------------------------- /scripts/remove-type.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs' 2 | 3 | const packageJsonPath = './package.json' 4 | 5 | // Read package.json 6 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) 7 | 8 | // Store the semantic-release-generated version to ensure it's preserved during the process 9 | // This prevents the version from reverting to an older value when modifying package.json 10 | const currentVersion = packageJson.version 11 | 12 | // Remove "type" key for publishing 13 | delete packageJson.type 14 | 15 | // Ensure the version is preserved 16 | packageJson.version = currentVersion 17 | 18 | // Write back package.json without "type" field but with version preserved 19 | writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) 20 | -------------------------------------------------------------------------------- /scripts/restore-type.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs' 2 | 3 | const packageJsonPath = './package.json' 4 | 5 | // Read current package.json (with new version) 6 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) 7 | 8 | // Store the semantic-release-generated version to ensure it's preserved during the process 9 | // This prevents the version from reverting to an older value when modifying package.json 10 | const currentVersion = packageJson.version 11 | 12 | // Add back the "type" field 13 | packageJson.type = 'module' 14 | 15 | // Ensure the version is preserved 16 | packageJson.version = currentVersion 17 | 18 | // Write back package.json with "type" field restored and version preserved 19 | writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) 20 | -------------------------------------------------------------------------------- /src/fieldCheckers/dayOfMonthChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkDaysOfMonth = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.daysOfMonth) { 11 | return err(['daysOfMonth field is undefined.']) 12 | } 13 | 14 | const { daysOfMonth } = cronData 15 | 16 | if ( 17 | options.allowOnlyOneBlankDayField && 18 | options.useBlankDay && 19 | cronData.daysOfMonth === '?' && 20 | cronData.daysOfWeek === '?' 21 | ) { 22 | return err([ 23 | `Cannot use blank value in daysOfMonth and daysOfWeek field when allowOnlyOneBlankDayField option is enabled.`, 24 | ]) 25 | } 26 | 27 | if ( 28 | options.mustHaveBlankDayField && 29 | cronData.daysOfMonth !== '?' && 30 | cronData.daysOfWeek !== '?' 31 | ) { 32 | return err([ 33 | `Cannot specify both daysOfMonth and daysOfWeek field when mustHaveBlankDayField option is enabled.`, 34 | ]) 35 | } 36 | 37 | // Based on this implementation logic: 38 | // https://github.com/quartz-scheduler/quartz/blob/1e0ed76c5c141597eccd76e44583557729b5a7cb/quartz-core/src/main/java/org/quartz/CronExpression.java#L473 39 | if ( 40 | options.useLastDayOfMonth && 41 | cronData.daysOfMonth.indexOf('L') !== -1 && 42 | cronData.daysOfMonth.match(/[,/]/) 43 | ) { 44 | return err([ 45 | `Cannot specify last day of month with lists, or ranges (symbols ,/).`, 46 | ]) 47 | } 48 | 49 | if ( 50 | options.useNearestWeekday && 51 | cronData.daysOfMonth.indexOf('W') !== -1 && 52 | cronData.daysOfMonth.match(/[,/-]/) 53 | ) { 54 | return err([ 55 | `Cannot specify nearest weekday with lists, steps or ranges (symbols ,-/).`, 56 | ]) 57 | } 58 | 59 | return checkField(daysOfMonth, 'daysOfMonth', options) 60 | } 61 | 62 | export default checkDaysOfMonth 63 | -------------------------------------------------------------------------------- /src/fieldCheckers/dayOfWeekChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkDaysOfWeek = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.daysOfWeek) { 11 | return err(['daysOfWeek field is undefined.']) 12 | } 13 | 14 | const { daysOfWeek } = cronData 15 | 16 | if ( 17 | options.allowOnlyOneBlankDayField && 18 | cronData.daysOfMonth === '?' && 19 | cronData.daysOfWeek === '?' 20 | ) { 21 | return err([ 22 | `Cannot use blank value in daysOfMonth and daysOfWeek field when allowOnlyOneBlankDayField option is enabled.`, 23 | ]) 24 | } 25 | 26 | if ( 27 | options.mustHaveBlankDayField && 28 | cronData.daysOfMonth !== '?' && 29 | cronData.daysOfWeek !== '?' 30 | ) { 31 | return err([ 32 | `Cannot specify both daysOfMonth and daysOfWeek field when mustHaveBlankDayField option is enabled.`, 33 | ]) 34 | } 35 | 36 | // Based on this implementation logic: 37 | // https://github.com/quartz-scheduler/quartz/blob/1e0ed76c5c141597eccd76e44583557729b5a7cb/quartz-core/src/main/java/org/quartz/CronExpression.java#L477 38 | if ( 39 | options.useLastDayOfWeek && 40 | cronData.daysOfWeek.indexOf('L') !== -1 && 41 | cronData.daysOfWeek.match(/[,/-]/) 42 | ) { 43 | return err([ 44 | `Cannot specify last day of week with lists, steps or ranges (symbols ,-/).`, 45 | ]) 46 | } 47 | 48 | if ( 49 | options.useNthWeekdayOfMonth && 50 | cronData.daysOfWeek.indexOf('#') !== -1 && 51 | cronData.daysOfWeek.match(/[,/-]/) 52 | ) { 53 | return err([ 54 | `Cannot specify Nth weekday of month with lists, steps or ranges (symbols ,-/).`, 55 | ]) 56 | } 57 | 58 | return checkField(daysOfWeek, 'daysOfWeek', options) 59 | } 60 | 61 | export default checkDaysOfWeek 62 | -------------------------------------------------------------------------------- /src/fieldCheckers/hourChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkHours = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.hours) { 11 | return err(['hours field is undefined.']) 12 | } 13 | 14 | const { hours } = cronData 15 | 16 | return checkField(hours, 'hours', options) 17 | } 18 | 19 | export default checkHours 20 | -------------------------------------------------------------------------------- /src/fieldCheckers/minuteChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkMinutes = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.minutes) { 11 | return err(['minutes field is undefined.']) 12 | } 13 | 14 | const { minutes } = cronData 15 | 16 | return checkField(minutes, 'minutes', options) 17 | } 18 | 19 | export default checkMinutes 20 | -------------------------------------------------------------------------------- /src/fieldCheckers/monthChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkMonths = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.months) { 11 | return err(['months field is undefined.']) 12 | } 13 | 14 | const { months } = cronData 15 | 16 | return checkField(months, 'months', options) 17 | } 18 | 19 | export default checkMonths 20 | -------------------------------------------------------------------------------- /src/fieldCheckers/secondChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkSeconds = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.seconds) { 11 | return err([ 12 | 'seconds field is undefined, but useSeconds options is enabled.', 13 | ]) 14 | } 15 | 16 | const { seconds } = cronData 17 | 18 | return checkField(seconds, 'seconds', options) 19 | } 20 | 21 | export default checkSeconds 22 | -------------------------------------------------------------------------------- /src/fieldCheckers/yearChecker.ts: -------------------------------------------------------------------------------- 1 | import { CronData } from '../index' 2 | import { err, Result } from '../result' 3 | import checkField from '../helper' 4 | import { Options } from '../types' 5 | 6 | const checkYears = ( 7 | cronData: CronData, 8 | options: Options 9 | ): Result => { 10 | if (!cronData.years) { 11 | return err(['years field is undefined, but useYears option is enabled.']) 12 | } 13 | 14 | const { years } = cronData 15 | 16 | return checkField(years, 'years', options) 17 | } 18 | 19 | export default checkYears 20 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import { CronFieldType } from './index' 2 | import { Err, err, Result, Valid, valid } from './result' 3 | import { Options } from './types' 4 | 5 | // Instead of translating the alias to a number, we just validate that it's an accepted alias. 6 | // This is to avoid managing the limits with the translation to numbers. 7 | // e.g.: For AWS, sun = 1, while for normal cron, sun = 0. Translating to numbers would break that. 8 | const monthAliases = [ 9 | 'jan', 10 | 'feb', 11 | 'mar', 12 | 'apr', 13 | 'may', 14 | 'jun', 15 | 'jul', 16 | 'aug', 17 | 'sep', 18 | 'oct', 19 | 'nov', 20 | 'dec', 21 | ] 22 | const daysOfWeekAliases = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] 23 | 24 | const checkWildcardLimit = (cronFieldType: CronFieldType, options: Options) => 25 | options[cronFieldType].lowerLimit === 26 | options.preset[cronFieldType].minValue && 27 | options[cronFieldType].upperLimit === options.preset[cronFieldType].maxValue 28 | 29 | const checkSingleElementWithinLimits = ( 30 | element: string, 31 | cronFieldType: CronFieldType, 32 | options: Options, 33 | ): Result => { 34 | if ( 35 | cronFieldType === 'months' && 36 | options.useAliases && 37 | monthAliases.indexOf(element.toLowerCase()) !== -1 38 | ) { 39 | return valid(true) 40 | } 41 | 42 | if ( 43 | cronFieldType === 'daysOfWeek' && 44 | options.useAliases && 45 | daysOfWeekAliases.indexOf(element.toLowerCase()) !== -1 46 | ) { 47 | return valid(true) 48 | } 49 | 50 | const number = Number(element) 51 | if (isNaN(number)) { 52 | return err(`Element '${element} of ${cronFieldType} field is invalid.`) 53 | } 54 | 55 | // check if integer and not a decimal 56 | if (number % 1 !== 0) { 57 | return err(`Element '${element} of ${cronFieldType} field is not an integer.`) 58 | } 59 | 60 | const { lowerLimit } = options[cronFieldType] 61 | const { upperLimit } = options[cronFieldType] 62 | if (lowerLimit && number < lowerLimit) { 63 | return err( 64 | `Number ${number} of ${cronFieldType} field is smaller than lower limit '${lowerLimit}'`, 65 | ) 66 | } 67 | 68 | if (upperLimit && number > upperLimit) { 69 | return err( 70 | `Number ${number} of ${cronFieldType} field is bigger than upper limit '${upperLimit}'`, 71 | ) 72 | } 73 | 74 | return valid(true) 75 | } 76 | 77 | const checkSingleElement = ( 78 | element: string, 79 | cronFieldType: CronFieldType, 80 | options: Options, 81 | ): Result => { 82 | if (element === '*') { 83 | if (!checkWildcardLimit(cronFieldType, options)) { 84 | return err( 85 | `Field ${cronFieldType} uses wildcard '*', but is limited to ${options[cronFieldType].lowerLimit}-${options[cronFieldType].upperLimit}`, 86 | ) 87 | } 88 | 89 | return valid(true) 90 | } 91 | 92 | if (element === '') { 93 | return err(`One of the elements is empty in ${cronFieldType} field.`) 94 | } 95 | 96 | if ( 97 | cronFieldType === 'daysOfMonth' && 98 | options.useLastDayOfMonth && 99 | element === 'L' 100 | ) { 101 | return valid(true) 102 | } 103 | 104 | // We must do that check here because L is used with a number to specify the day of the week for which 105 | // we look for the last occurrence in the month. 106 | // We use `endsWith` here because anywhere else is not valid so it will be caught later on. 107 | if ( 108 | cronFieldType === 'daysOfWeek' && 109 | options.useLastDayOfWeek && 110 | element.endsWith('L') 111 | ) { 112 | const day = element.slice(0, -1) 113 | if (day === '') { 114 | // This means that element is only `L` which is the equivalent of saturdayL 115 | return valid(true) 116 | } 117 | 118 | return checkSingleElementWithinLimits(day, cronFieldType, options) 119 | } 120 | 121 | // We must do that check here because W is used with a number to specify the day of the month for which 122 | // we must run over a weekday instead. 123 | // We use `endsWith` here because anywhere else is not valid so it will be caught later on. 124 | if ( 125 | cronFieldType === 'daysOfMonth' && 126 | options.useNearestWeekday && 127 | element.endsWith('W') 128 | ) { 129 | const day = element.slice(0, -1) 130 | if (day === '') { 131 | return err(`The 'W' must be preceded by a day`) 132 | } 133 | 134 | // Edge case where the L can be used with W to form last weekday of month 135 | if (options.useLastDayOfMonth && day === 'L') { 136 | return valid(true) 137 | } 138 | 139 | return checkSingleElementWithinLimits(day, cronFieldType, options) 140 | } 141 | 142 | if ( 143 | cronFieldType === 'daysOfWeek' && 144 | options.useNthWeekdayOfMonth && 145 | element.indexOf('#') !== -1 146 | ) { 147 | const [day, occurrence, ...leftOvers] = element.split('#') 148 | if (leftOvers.length !== 0) { 149 | return err( 150 | `Unexpected number of '#' in ${element}, can only be used once.`, 151 | ) 152 | } 153 | 154 | const occurrenceNum = Number(occurrence) 155 | if (!occurrence || isNaN(occurrenceNum)) { 156 | return err( 157 | `Unexpected value following the '#' symbol, a positive number was expected but found ${occurrence}.`, 158 | ) 159 | } 160 | 161 | if (occurrenceNum > 5) { 162 | return err(`Number of occurrence of the day of the week cannot be greater than 5.`) 163 | } 164 | 165 | return checkSingleElementWithinLimits(day, cronFieldType, options) 166 | } 167 | 168 | return checkSingleElementWithinLimits(element, cronFieldType, options) 169 | } 170 | 171 | const checkRangeElement = ( 172 | element: string, 173 | cronFieldType: CronFieldType, 174 | options: Options, 175 | position: 0 | 1, 176 | ): Result => { 177 | if (element === '*') { 178 | return err(`'*' can't be part of a range in ${cronFieldType} field.`) 179 | } 180 | 181 | if (element === '') { 182 | return err(`One of the range elements is empty in ${cronFieldType} field.`) 183 | } 184 | 185 | // We can have `L` as the first element of a range to specify an offset. 186 | if ( 187 | options.useLastDayOfMonth && 188 | cronFieldType === 'daysOfMonth' && 189 | element === 'L' && 190 | position === 0 191 | ) { 192 | return valid(true) 193 | } 194 | 195 | return checkSingleElementWithinLimits(element, cronFieldType, options) 196 | } 197 | 198 | const checkFirstStepElement = ( 199 | firstStepElement: string, 200 | cronFieldType: CronFieldType, 201 | options: Options, 202 | ): Result => { 203 | const rangeArray = firstStepElement.split('-') 204 | if (rangeArray.length > 2) { 205 | return err( 206 | `List element '${firstStepElement}' is not valid. (More than one '-')`, 207 | ) 208 | } 209 | 210 | if (rangeArray.length === 1) { 211 | return checkSingleElement(rangeArray[0], cronFieldType, options) 212 | } 213 | 214 | if (rangeArray.length === 2) { 215 | const firstRangeElementResult = checkRangeElement( 216 | rangeArray[0], 217 | cronFieldType, 218 | options, 219 | 0, 220 | ) 221 | const secondRangeElementResult = checkRangeElement( 222 | rangeArray[1], 223 | cronFieldType, 224 | options, 225 | 1, 226 | ) 227 | 228 | if (firstRangeElementResult.isError()) { 229 | return firstRangeElementResult 230 | } 231 | 232 | if (secondRangeElementResult.isError()) { 233 | return secondRangeElementResult 234 | } 235 | 236 | if (Number(rangeArray[0]) > Number(rangeArray[1])) { 237 | return err( 238 | `Lower range end '${rangeArray[0]}' is bigger than upper range end '${rangeArray[1]}' of ${cronFieldType} field.`, 239 | ) 240 | } 241 | 242 | return valid(true) 243 | } 244 | 245 | return err( 246 | 'Some other error in checkFirstStepElement (rangeArray less than 1)', 247 | ) 248 | } 249 | 250 | const checkListElement = ( 251 | listElement: string, 252 | cronFieldType: CronFieldType, 253 | options: Options, 254 | ): Result => { 255 | // Checks list element for steps like */2, 10-20/2 256 | const stepArray = listElement.split('/') 257 | if (stepArray.length > 2) { 258 | return err( 259 | `List element '${listElement}' is not valid. (More than one '/')`, 260 | ) 261 | } 262 | 263 | if (!options.allowStepping) { 264 | return err('Stepping (\'/\') is now allowed.') 265 | } 266 | 267 | const firstElementResult = checkFirstStepElement( 268 | stepArray[0], 269 | cronFieldType, 270 | options, 271 | ) 272 | 273 | if (firstElementResult.isError()) { 274 | return firstElementResult 275 | } 276 | 277 | if (stepArray.length === 2) { 278 | const secondStepElement = stepArray[1] 279 | 280 | if (!secondStepElement) { 281 | return err( 282 | `Second step element '${secondStepElement}' of '${listElement}' is not valid (doesnt exist).`, 283 | ) 284 | } 285 | 286 | if (isNaN(Number(secondStepElement))) { 287 | return err( 288 | `Second step element '${secondStepElement}' of '${listElement}' is not valid (not a number).`, 289 | ) 290 | } 291 | 292 | const secondStepNumber = Number(secondStepElement) 293 | if (secondStepNumber === 0) { 294 | return err( 295 | `Second step element '${secondStepElement}' of '${listElement}' cannot be zero.`, 296 | ) 297 | } 298 | 299 | const { lowerLimit, upperLimit } = options[cronFieldType] 300 | 301 | // check if step number is an integer 302 | if (secondStepNumber % 1 !== 0) { 303 | return err( 304 | `Second step element '${secondStepElement}' of '${listElement}' is not an integer.`, 305 | ) 306 | } 307 | 308 | // check if step number is less than the max number 309 | if (upperLimit && secondStepNumber > upperLimit) { 310 | return err( 311 | `Second step element '${secondStepElement}' of '${listElement}' is bigger than the upper limit '${upperLimit}'.`, 312 | ) 313 | } 314 | 315 | // check if the step is inside the allowed range, so 10-20/5 is allowed (10, 15, 20), but 316 | // 10-20/11 is not allowed, because the first value (after the initial) would be 21 but this is bigger than 20 317 | const rangeArray = stepArray[0].split('-') 318 | if (rangeArray.length === 2) { 319 | const rangeStart = Number(rangeArray[0]) 320 | const rangeEnd = Number(rangeArray[1]) 321 | if (!isNaN(rangeStart) && !isNaN(rangeEnd)) { 322 | if (secondStepNumber <= 0) { 323 | return err(`Step value '${secondStepElement}' must be greater than 0.`) 324 | } 325 | 326 | const customRange = rangeEnd - rangeStart + 1 327 | if (secondStepNumber >= customRange) { 328 | return err( 329 | `Step value '${secondStepElement}' is too large for the range '${rangeStart}-${rangeEnd}'.`, 330 | ) 331 | } 332 | } 333 | } 334 | } 335 | 336 | return valid(true) 337 | } 338 | 339 | const checkField = ( 340 | cronField: string, 341 | cronFieldType: CronFieldType, 342 | options: Options, 343 | ): Result => { 344 | if ( 345 | ![ 346 | 'seconds', 347 | 'minutes', 348 | 'hours', 349 | 'daysOfMonth', 350 | 'months', 351 | 'daysOfWeek', 352 | 'years', 353 | ].includes(cronFieldType) 354 | ) { 355 | return err([`Cron field type '${cronFieldType}' does not exist.`]) 356 | } 357 | 358 | // Check for blank day 359 | if (cronField === '?') { 360 | if (cronFieldType === 'daysOfMonth' || cronFieldType === 'daysOfWeek') { 361 | if (options.useBlankDay) { 362 | return valid(true) 363 | } 364 | 365 | return err([ 366 | `useBlankDay is not enabled, but is used in ${cronFieldType} field`, 367 | ]) 368 | } 369 | 370 | return err([`blank notation is not allowed in ${cronFieldType} field`]) 371 | } 372 | 373 | // Check for lists e.g. 4,5,6,8-18,20-40/2 374 | const listArray = cronField.split(',') 375 | const checkResults: (Valid | Err)[] = [] 376 | listArray.forEach((listElement: string) => { 377 | checkResults.push(checkListElement(listElement, cronFieldType, options)) 378 | }) 379 | 380 | if (checkResults.every(value => value.isValid())) { 381 | return valid(true) 382 | } 383 | 384 | const errorArray: string[] = [] 385 | checkResults.forEach(result => { 386 | if (result.isError()) { 387 | errorArray.push(result.getError()) 388 | } 389 | }) 390 | return err(errorArray) 391 | } 392 | 393 | export default checkField 394 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import cron from './index' 2 | import { getOptionPreset, registerOptionPreset } from './option' 3 | 4 | describe('Test cron validation', () => { 5 | it('test issue', () => { 6 | expect( 7 | cron('*/5 * ? * *', { preset: 'aws-cloud-watch' }).isValid(), 8 | ).toBeFalsy() 9 | expect( 10 | cron('*/5 * * * *', { preset: 'aws-cloud-watch' }).isValid(), 11 | ).toBeFalsy() 12 | }) 13 | 14 | it('Test cron length (5 chars allowed)', () => { 15 | const emptyStringCron = cron('') 16 | expect(emptyStringCron.isValid()).toBeFalsy() 17 | 18 | const oneCharStringCron = cron('*') 19 | expect(oneCharStringCron.isValid()).toBeFalsy() 20 | 21 | const twoCharStringCron = cron('* *') 22 | expect(twoCharStringCron.isValid()).toBeFalsy() 23 | 24 | const threeCharStringCron = cron('* * *') 25 | expect(threeCharStringCron.isValid()).toBeFalsy() 26 | 27 | const fourCharStringCron = cron('* * * *') 28 | expect(fourCharStringCron.isValid()).toBeFalsy() 29 | 30 | const fiveCharStringCron = cron('* * * * *') 31 | expect(fiveCharStringCron.isValid()).toBeTruthy() 32 | 33 | const sixCharStringCron = cron('* * * * * *') 34 | expect(sixCharStringCron.isValid()).toBeFalsy() 35 | 36 | const sevenCharStringCron = cron('* * * * * * *') 37 | expect(sevenCharStringCron.isValid()).toBeFalsy() 38 | }) 39 | 40 | it('Test cron length with seconds option (6 chars allowed)', () => { 41 | const fiveCharStringCron = cron('* * * * *', { 42 | override: { useSeconds: true }, 43 | }) 44 | expect(fiveCharStringCron.isValid()).toBeFalsy() 45 | 46 | const sixCharStringCron = cron('* * * * * *', { 47 | override: { useSeconds: true }, 48 | }) 49 | expect(sixCharStringCron.isValid()).toBeTruthy() 50 | 51 | const sevenCharStringCron = cron('* * * * * * *', { 52 | override: { 53 | useSeconds: true, 54 | }, 55 | }) 56 | expect(sevenCharStringCron.isValid()).toBeFalsy() 57 | }) 58 | 59 | it('Test cron length with years option (6 chars allowed)', () => { 60 | const fiveCharStringCron = cron('* * * * *', { 61 | override: { useYears: true }, 62 | }) 63 | expect(fiveCharStringCron.isValid()).toBeFalsy() 64 | 65 | const sixCharStringCron = cron('* * * * * *', { 66 | override: { useYears: true }, 67 | }) 68 | expect(sixCharStringCron.isValid()).toBeTruthy() 69 | 70 | const sevenCharStringCron = cron('* * * * * * *', { 71 | override: { useYears: true }, 72 | }) 73 | expect(sevenCharStringCron.isValid()).toBeFalsy() 74 | }) 75 | 76 | it('Test cron length with seconds and years option (7 chars allowed)', () => { 77 | const fiveCharStringCron = cron('* * * * *', { 78 | override: { 79 | useSeconds: true, 80 | useYears: true, 81 | }, 82 | }) 83 | expect(fiveCharStringCron.isValid()).toBeFalsy() 84 | 85 | const sixCharStringCron = cron('* * * * * *', { 86 | override: { 87 | useSeconds: true, 88 | useYears: true, 89 | seconds: { 90 | upperLimit: 60, 91 | }, 92 | }, 93 | }) 94 | expect(sixCharStringCron.isValid()).toBeFalsy() 95 | 96 | const sevenCharStringCron = cron('* * * * * * *', { 97 | override: { 98 | useSeconds: true, 99 | useYears: true, 100 | }, 101 | }) 102 | expect(sevenCharStringCron.isValid()).toBeTruthy() 103 | 104 | const eightCharStringCron = cron('* * * * * * * *', { 105 | override: { 106 | useSeconds: true, 107 | useYears: true, 108 | }, 109 | }) 110 | expect(eightCharStringCron.isValid()).toBeFalsy() 111 | }) 112 | 113 | it('Test number input', () => { 114 | expect(cron('1,2,3 4,5,6 1 1 1').isValid()).toBeTruthy() 115 | 116 | expect(cron('01,02,03 04,05,06 01 01 01').isValid()).toBeTruthy() 117 | 118 | expect(cron('1 1 1 1 1').isValid()).toBeTruthy() 119 | expect(cron('0.1 1 1 1 1').isValid()).toBeFalsy() 120 | expect(cron('1 0.1 1 1 1').isValid()).toBeFalsy() 121 | expect(cron('1 1 0.1 1 1').isValid()).toBeFalsy() 122 | expect(cron('1 1 1 0.1 1').isValid()).toBeFalsy() 123 | expect(cron('1 1 1 1 0.1').isValid()).toBeFalsy() 124 | 125 | expect(cron('1,1.5,2,2.5 1 1 1 1').isValid()).toBeFalsy() 126 | }) 127 | 128 | it('Test cron field assignment', () => { 129 | const cronResult = cron('0 */4 * 1 6') 130 | expect(cronResult.isValid()).toBeTruthy() 131 | 132 | if (cronResult.isValid()) { 133 | const cronData = cronResult.getValue() 134 | expect(cronData.seconds).toBeUndefined() 135 | expect(cronData.minutes).toBe('0') 136 | expect(cronData.hours).toBe('*/4') 137 | expect(cronData.daysOfMonth).toBe('*') 138 | expect(cronData.months).toBe('1') 139 | expect(cronData.daysOfWeek).toBe('6') 140 | expect(cronData.years).toBeUndefined() 141 | } 142 | }) 143 | 144 | it('Test cron field assignment with useSeconds option', () => { 145 | const cronResult = cron('2 0 */4 * 1 6', { 146 | override: { useSeconds: true }, 147 | }) 148 | expect(cronResult.isValid()).toBeTruthy() 149 | 150 | if (cronResult.isValid()) { 151 | const cronData = cronResult.getValue() 152 | expect(cronData.seconds).toBe('2') 153 | expect(cronData.minutes).toBe('0') 154 | expect(cronData.hours).toBe('*/4') 155 | expect(cronData.daysOfMonth).toBe('*') 156 | expect(cronData.months).toBe('1') 157 | expect(cronData.daysOfWeek).toBe('6') 158 | expect(cronData.years).toBeUndefined() 159 | } 160 | }) 161 | 162 | it('Test cron field assignment with useYears option', () => { 163 | const cronResult = cron('0 */4 * 1 6 2020', { 164 | override: { useYears: true }, 165 | }) 166 | expect(cronResult.isValid()).toBeTruthy() 167 | 168 | if (cronResult.isValid()) { 169 | const cronData = cronResult.getValue() 170 | expect(cronData.seconds).toBeUndefined() 171 | expect(cronData.minutes).toBe('0') 172 | expect(cronData.hours).toBe('*/4') 173 | expect(cronData.daysOfMonth).toBe('*') 174 | expect(cronData.months).toBe('1') 175 | expect(cronData.daysOfWeek).toBe('6') 176 | expect(cronData.years).toBe('2020') 177 | } 178 | }) 179 | 180 | it('Test cron field assignment with useSeconds and useYears option', () => { 181 | const cronResult = cron('2 0 */4 * 1 6 2020', { 182 | override: { 183 | useSeconds: true, 184 | useYears: true, 185 | }, 186 | }) 187 | expect(cronResult.isValid()).toBeTruthy() 188 | 189 | if (cronResult.isValid()) { 190 | const cronData = cronResult.getValue() 191 | expect(cronData.seconds).toBe('2') 192 | expect(cronData.minutes).toBe('0') 193 | expect(cronData.hours).toBe('*/4') 194 | expect(cronData.daysOfMonth).toBe('*') 195 | expect(cronData.months).toBe('1') 196 | expect(cronData.daysOfWeek).toBe('6') 197 | expect(cronData.years).toBe('2020') 198 | } 199 | }) 200 | 201 | it('Test list, range and steps', () => { 202 | expect( 203 | cron('5-7 2-4/2 1,2-4,5-8,10-20/3,20-30/4 * *').isValid(), 204 | ).toBeTruthy() 205 | 206 | expect(cron('5-7,8-9,10-20,21-23 * * * *').isValid()).toBeTruthy() 207 | 208 | expect(cron('7-5 * * *').isValid()).toBeFalsy() 209 | 210 | expect( 211 | cron('7-5 * * * *', { override: { useSeconds: true } }).isValid(), 212 | ).toBeFalsy() 213 | 214 | expect( 215 | cron('* * * * 2020-2019', { 216 | override: { useYears: true }, 217 | }).isValid(), 218 | ).toBeFalsy() 219 | 220 | expect(cron('*/1.5 * * * *').isValid()).toBeFalsy() 221 | }) 222 | 223 | it('Test range limits', () => { 224 | expect(cron('* * * * *').isValid()).toBeTruthy() 225 | 226 | expect( 227 | cron('* * * * *', { 228 | override: { 229 | minutes: { lowerLimit: 10, upperLimit: 20 }, 230 | }, 231 | }).isValid(), 232 | ).toBeFalsy() 233 | 234 | expect( 235 | cron('10 * * * *', { 236 | override: { 237 | minutes: { lowerLimit: 10, upperLimit: 20 }, 238 | }, 239 | }).isValid(), 240 | ).toBeTruthy() 241 | 242 | expect( 243 | cron('15 * * * *', { 244 | override: { 245 | minutes: { lowerLimit: 10, upperLimit: 20 }, 246 | }, 247 | }).isValid(), 248 | ).toBeTruthy() 249 | 250 | expect( 251 | cron('20 * * * *', { 252 | override: { 253 | minutes: { lowerLimit: 10, upperLimit: 20 }, 254 | }, 255 | }).isValid(), 256 | ).toBeTruthy() 257 | 258 | expect( 259 | cron('10-20 * * * *', { 260 | override: { 261 | minutes: { lowerLimit: 10, upperLimit: 20 }, 262 | }, 263 | }).isValid(), 264 | ).toBeTruthy() 265 | 266 | expect( 267 | cron('10-20/2 * * * *', { 268 | override: { 269 | minutes: { lowerLimit: 10, upperLimit: 20 }, 270 | }, 271 | }).isValid(), 272 | ).toBeTruthy() 273 | 274 | expect( 275 | cron('10-21/2 * * * *', { 276 | override: { 277 | minutes: { lowerLimit: 10, upperLimit: 20 }, 278 | }, 279 | }).isValid(), 280 | ).toBeFalsy() 281 | 282 | expect( 283 | cron('*/2 * * * *', { 284 | override: { 285 | minutes: { lowerLimit: 10, upperLimit: 20 }, 286 | }, 287 | }).isValid(), 288 | ).toBeFalsy() 289 | 290 | expect( 291 | cron('10,12,21 * * * *', { 292 | override: { 293 | minutes: { lowerLimit: 10, upperLimit: 20 }, 294 | }, 295 | }).isValid(), 296 | ).toBeFalsy() 297 | }) 298 | 299 | it('Test preset system', () => { 300 | registerOptionPreset('testPreset', { 301 | presetId: 'testPreset', 302 | useSeconds: true, 303 | useYears: false, 304 | useBlankDay: false, 305 | allowOnlyOneBlankDayField: false, 306 | seconds: { 307 | minValue: 0, 308 | maxValue: 59, 309 | }, 310 | minutes: { 311 | minValue: 0, 312 | maxValue: 59, 313 | }, 314 | hours: { 315 | minValue: 0, 316 | maxValue: 23, 317 | }, 318 | daysOfMonth: { 319 | minValue: 0, 320 | maxValue: 31, 321 | }, 322 | months: { 323 | minValue: 0, 324 | maxValue: 12, 325 | }, 326 | daysOfWeek: { 327 | minValue: 0, 328 | maxValue: 7, 329 | }, 330 | years: { 331 | minValue: 1970, 332 | maxValue: 2099, 333 | }, 334 | }) 335 | 336 | registerOptionPreset('testPreset2', { 337 | presetId: 'testPreset2', 338 | useSeconds: true, 339 | useYears: false, 340 | useBlankDay: false, 341 | allowOnlyOneBlankDayField: false, 342 | seconds: { 343 | minValue: 0, 344 | maxValue: 59, 345 | }, 346 | minutes: { 347 | minValue: 0, 348 | maxValue: 59, 349 | lowerLimit: 10, 350 | upperLimit: 30, 351 | }, 352 | hours: { 353 | minValue: 0, 354 | maxValue: 23, 355 | }, 356 | daysOfMonth: { 357 | minValue: 0, 358 | maxValue: 31, 359 | }, 360 | months: { 361 | minValue: 0, 362 | maxValue: 12, 363 | }, 364 | daysOfWeek: { 365 | minValue: 0, 366 | maxValue: 7, 367 | }, 368 | years: { 369 | minValue: 1970, 370 | maxValue: 2099, 371 | }, 372 | }) 373 | 374 | registerOptionPreset('testPreset3', { 375 | presetId: 'testPreset3', 376 | useSeconds: true, 377 | useYears: false, 378 | useBlankDay: false, 379 | allowOnlyOneBlankDayField: false, 380 | seconds: { 381 | minValue: 1, 382 | maxValue: 60, 383 | }, 384 | minutes: { 385 | minValue: 1, 386 | maxValue: 60, 387 | }, 388 | hours: { 389 | minValue: 1, 390 | maxValue: 24, 391 | }, 392 | daysOfMonth: { 393 | minValue: 1, 394 | maxValue: 31, 395 | }, 396 | months: { 397 | minValue: 1, 398 | maxValue: 12, 399 | }, 400 | daysOfWeek: { 401 | minValue: 1, 402 | maxValue: 7, 403 | }, 404 | years: { 405 | minValue: 1970, 406 | maxValue: 2099, 407 | }, 408 | }) 409 | 410 | expect(getOptionPreset('testPreset')).toBeTruthy() 411 | expect(getOptionPreset('testPreset2')).toBeTruthy() 412 | expect(getOptionPreset('testPreset3')).toBeTruthy() 413 | 414 | // testPreset1 415 | 416 | expect( 417 | cron('* * * * *', { 418 | preset: 'testPreset', 419 | }).isValid(), 420 | ).toBeFalsy() 421 | 422 | expect( 423 | cron('* * * * * *', { 424 | preset: 'testPreset', 425 | }).isValid(), 426 | ).toBeTruthy() 427 | 428 | expect( 429 | cron('* * * * * * *', { 430 | preset: 'testPreset', 431 | }).isValid(), 432 | ).toBeFalsy() 433 | 434 | expect( 435 | cron('* * * * * * *', { 436 | preset: 'testPreset', 437 | override: { 438 | useYears: true, 439 | }, 440 | }).isValid(), 441 | ).toBeTruthy() 442 | 443 | expect( 444 | cron('* 10-30 * * * *', { 445 | preset: 'testPreset', 446 | }).isValid(), 447 | ).toBeTruthy() 448 | 449 | expect( 450 | cron('* 9 * * * *', { 451 | preset: 'testPreset', 452 | override: { 453 | minutes: { lowerLimit: 9 }, 454 | }, 455 | }).isValid(), 456 | ).toBeTruthy() 457 | 458 | expect( 459 | cron('* 10-30 */2 * * *', { 460 | preset: 'testPreset', 461 | }).isValid(), 462 | ).toBeTruthy() 463 | 464 | // testPreset 2 465 | 466 | expect( 467 | cron('* * * * *', { 468 | preset: 'testPreset2', 469 | }).isValid(), 470 | ).toBeFalsy() 471 | 472 | expect( 473 | cron('* * * * * *', { 474 | preset: 'testPreset2', 475 | }).isValid(), 476 | ).toBeFalsy() 477 | 478 | expect( 479 | cron('* 10-30 * * * *', { 480 | preset: 'testPreset2', 481 | }).isValid(), 482 | ).toBeTruthy() 483 | 484 | expect( 485 | cron('* 9-30 * * * *', { 486 | preset: 'testPreset2', 487 | }).isValid(), 488 | ).toBeFalsy() 489 | 490 | expect( 491 | cron('* * * * * * *', { 492 | preset: 'testPreset2', 493 | }).isValid(), 494 | ).toBeFalsy() 495 | 496 | expect( 497 | cron('* * * * * * *', { 498 | preset: 'testPreset2', 499 | override: { 500 | useYears: true, 501 | }, 502 | }).isValid(), 503 | ).toBeFalsy() 504 | 505 | expect( 506 | cron('* 20 * * * * *', { 507 | preset: 'testPreset2', 508 | override: { 509 | useYears: true, 510 | }, 511 | }).isValid(), 512 | ).toBeTruthy() 513 | 514 | expect( 515 | cron('* 9 * * * *', { 516 | preset: 'testPreset2', 517 | }).isValid(), 518 | ).toBeFalsy() 519 | 520 | expect( 521 | cron('* 9 * * * *', { 522 | preset: 'testPreset2', 523 | override: { 524 | minutes: { lowerLimit: 9 }, 525 | }, 526 | }).isValid(), 527 | ).toBeTruthy() 528 | 529 | expect( 530 | cron('* * * * * *', { 531 | preset: 'testPreset2', 532 | override: { 533 | minutes: { lowerLimit: 0, upperLimit: 59 }, 534 | }, 535 | }).isValid(), 536 | ).toBeTruthy() 537 | 538 | expect( 539 | cron('* 10-30 */2 * * *', { 540 | preset: 'testPreset2', 541 | }).isValid(), 542 | ).toBeTruthy() 543 | 544 | expect( 545 | cron('* 9-30 */2 * * *', { 546 | preset: 'testPreset2', 547 | }).isValid(), 548 | ).toBeFalsy() 549 | 550 | // testPreset3 551 | 552 | expect( 553 | cron('* * * * *', { 554 | preset: 'testPreset3', 555 | }).isValid(), 556 | ).toBeFalsy() 557 | 558 | expect( 559 | cron('* * * * * *', { 560 | preset: 'testPreset3', 561 | }).isValid(), 562 | ).toBeTruthy() 563 | 564 | expect( 565 | cron('* * * * * * *', { 566 | preset: 'testPreset3', 567 | }).isValid(), 568 | ).toBeFalsy() 569 | 570 | expect( 571 | cron('* * * * * * *', { 572 | preset: 'testPreset3', 573 | override: { 574 | useYears: true, 575 | }, 576 | }).isValid(), 577 | ).toBeTruthy() 578 | 579 | expect( 580 | cron('* 10-30 * * * *', { 581 | preset: 'testPreset3', 582 | }).isValid(), 583 | ).toBeTruthy() 584 | 585 | expect( 586 | cron('* 9 * * * *', { 587 | preset: 'testPreset3', 588 | }).isValid(), 589 | ).toBeTruthy() 590 | 591 | expect( 592 | cron('* 0 * * * *', { 593 | preset: 'testPreset3', 594 | }).isValid(), 595 | ).toBeFalsy() 596 | 597 | // 598 | 599 | expect( 600 | cron('* * ? * 8 *', { 601 | preset: 'aws-cloud-watch', 602 | }).isValid(), 603 | ).toBeFalsy() 604 | }) 605 | 606 | it('Test invalid ranges', () => { 607 | expect( 608 | cron('1-2-3 * * * * *', { 609 | override: { useSeconds: true }, 610 | }).isValid(), 611 | ).toBeFalsy() 612 | 613 | expect(cron('* 1-2-3 * * * ').isValid()).toBeFalsy() 614 | 615 | expect(cron('* * 1-2-3 * * ').isValid()).toBeFalsy() 616 | 617 | expect(cron('1-* * * * *').isValid()).toBeFalsy() 618 | }) 619 | 620 | it('Test invalid steps', () => { 621 | expect( 622 | cron('1/2/3 * * * * *', { 623 | override: { useSeconds: true }, 624 | }).isValid(), 625 | ).toBeFalsy() 626 | 627 | expect(cron('1/2/3 * * * *').isValid()).toBeFalsy() 628 | 629 | expect(cron('1/2/3/4 * * * *').isValid()).toBeFalsy() 630 | 631 | expect(cron('1/* * * * *').isValid()).toBeFalsy() 632 | 633 | expect(cron('1/0 * * * *').isValid()).toBeFalsy() 634 | 635 | expect(cron('*/90 * * * *').isValid()).toBeFalsy() 636 | expect(cron('*/60 * * * *').isValid()).toBeFalsy() 637 | 638 | expect(cron('10-20/11 * * * *').isValid()).toBeFalsy() 639 | 640 | expect(cron('* 6-18/15 * * *').isValid()).toBeFalsy() 641 | 642 | expect(cron('* */25 * * *').isValid()).toBeFalsy() 643 | }) 644 | 645 | it('Test incomplete statements', () => { 646 | expect(cron('1/ * * * *').isValid()).toBeFalsy() 647 | 648 | expect(cron('20-30/ * * * * ').isValid()).toBeFalsy() 649 | 650 | expect(cron('*/ * * * * ').isValid()).toBeFalsy() 651 | }) 652 | 653 | it('Test massive cron-expression', () => { 654 | expect( 655 | cron( 656 | '*/2,11,12,13-17,30-40/4 1,2,3,*/5,10-20 0-3,4-6,8-20/3,23 1,2,3,4,*/2,20-25/2,26-27 1-2,3-7/2,*/2,8-10/2 1,*/2,4-6', 657 | { 658 | override: { useSeconds: true }, 659 | }, 660 | ).isValid(), 661 | ).toBeTruthy() 662 | }) 663 | 664 | it('Test blank day option', () => { 665 | // No useBlankDays 666 | expect(cron('* * ? * *').isValid()).toBeFalsy() 667 | 668 | expect(cron('* * * * ?').isValid()).toBeFalsy() 669 | 670 | expect( 671 | cron('* * * ? * *', { override: { useSeconds: true } }).isValid(), 672 | ).toBeFalsy() 673 | 674 | expect( 675 | cron('* * * * * ?', { override: { useSeconds: true } }).isValid(), 676 | ).toBeFalsy() 677 | 678 | expect( 679 | cron('* * ? * * *', { override: { useYears: true } }).isValid(), 680 | ).toBeFalsy() 681 | 682 | expect( 683 | cron('* * * * * ? *', { override: { useYears: true } }).isValid(), 684 | ).toBeFalsy() 685 | 686 | // useBlankDays true 687 | expect( 688 | cron('* * ? * *', { override: { useBlankDay: true } }).isValid(), 689 | ).toBeTruthy() 690 | 691 | expect( 692 | cron('* * * * ?', { override: { useBlankDay: true } }).isValid(), 693 | ).toBeTruthy() 694 | 695 | expect( 696 | cron('* * * ? * *', { 697 | override: { useSeconds: true, useBlankDay: true }, 698 | }).isValid(), 699 | ).toBeTruthy() 700 | 701 | expect( 702 | cron('* * * * * ?', { 703 | override: { useSeconds: true, useBlankDay: true }, 704 | }).isValid(), 705 | ).toBeTruthy() 706 | 707 | expect( 708 | cron('* * ? * * *', { 709 | override: { useYears: true, useBlankDay: true }, 710 | }).isValid(), 711 | ).toBeTruthy() 712 | 713 | expect( 714 | cron('* * * * ? *', { 715 | override: { useYears: true, useBlankDay: true }, 716 | }).isValid(), 717 | ).toBeTruthy() 718 | 719 | // both fields blank allowed 720 | expect( 721 | cron('* * ? * ?', { override: { useBlankDay: true } }).isValid(), 722 | ).toBeTruthy() 723 | 724 | expect( 725 | cron('* * * ? * ?', { 726 | override: { useSeconds: true, useBlankDay: true }, 727 | }).isValid(), 728 | ).toBeTruthy() 729 | 730 | expect( 731 | cron('* * ? * ? *', { 732 | override: { useYears: true, useBlankDay: true }, 733 | }).isValid(), 734 | ).toBeTruthy() 735 | 736 | expect( 737 | cron('* * * ? * ? *', { 738 | override: { useSeconds: true, useYears: true, useBlankDay: true }, 739 | }).isValid(), 740 | ).toBeTruthy() 741 | 742 | // both fields blank not allowed 743 | expect( 744 | cron('* * ? * ?', { 745 | override: { useBlankDay: true, allowOnlyOneBlankDayField: true }, 746 | }).isValid(), 747 | ).toBeFalsy() 748 | 749 | expect( 750 | cron('* * * ? * ?', { 751 | override: { 752 | useSeconds: true, 753 | useBlankDay: true, 754 | allowOnlyOneBlankDayField: true, 755 | }, 756 | }).isValid(), 757 | ).toBeFalsy() 758 | 759 | expect( 760 | cron('* * ? * ? *', { 761 | override: { 762 | useYears: true, 763 | useBlankDay: true, 764 | allowOnlyOneBlankDayField: true, 765 | }, 766 | }).isValid(), 767 | ).toBeFalsy() 768 | 769 | expect( 770 | cron('* * * ? * ? *', { 771 | override: { 772 | useSeconds: true, 773 | useYears: true, 774 | useBlankDay: true, 775 | allowOnlyOneBlankDayField: true, 776 | }, 777 | }).isValid(), 778 | ).toBeFalsy() 779 | 780 | // minimum one blank required 781 | expect( 782 | cron('* * * * *', { 783 | override: { 784 | useBlankDay: true, 785 | allowOnlyOneBlankDayField: true, 786 | mustHaveBlankDayField: true, 787 | }, 788 | }).isValid(), 789 | ).toBeFalsy() 790 | 791 | expect( 792 | cron('* * * * ?', { 793 | override: { 794 | useBlankDay: true, 795 | allowOnlyOneBlankDayField: true, 796 | mustHaveBlankDayField: true, 797 | }, 798 | }).isValid(), 799 | ).toBeTruthy() 800 | 801 | expect( 802 | cron('* * ? * *', { 803 | override: { 804 | useBlankDay: true, 805 | allowOnlyOneBlankDayField: true, 806 | mustHaveBlankDayField: true, 807 | }, 808 | }).isValid(), 809 | ).toBeTruthy() 810 | 811 | expect( 812 | cron('* * * * * *', { 813 | override: { 814 | useSeconds: true, 815 | useBlankDay: true, 816 | allowOnlyOneBlankDayField: true, 817 | mustHaveBlankDayField: true, 818 | }, 819 | }).isValid(), 820 | ).toBeFalsy() 821 | 822 | expect( 823 | cron('* * * * * *', { 824 | override: { 825 | useYears: true, 826 | useBlankDay: true, 827 | allowOnlyOneBlankDayField: true, 828 | mustHaveBlankDayField: true, 829 | }, 830 | }).isValid(), 831 | ).toBeFalsy() 832 | 833 | expect( 834 | cron('* * * * * * *', { 835 | override: { 836 | useSeconds: true, 837 | useYears: true, 838 | useBlankDay: true, 839 | allowOnlyOneBlankDayField: true, 840 | mustHaveBlankDayField: true, 841 | }, 842 | }).isValid(), 843 | ).toBeFalsy() 844 | }) 845 | 846 | it('Test allowStepping option', () => { 847 | expect( 848 | cron('5-7 2-4/2 1,2-4,5-8,10-20/3,20-30/4 * *').isValid(), 849 | ).toBeTruthy() 850 | 851 | expect(cron('5-7,8-9,10-20,21-23 * * * *').isValid()).toBeTruthy() 852 | 853 | expect( 854 | cron('5-7 2-4/2 1,2-4,5-8,10-20/3,20-30/4 * *', { override: { allowStepping: false } }).isValid(), 855 | ).toBeFalsy() 856 | 857 | expect(cron('5-7,8-9,10-20,21-23 * * * *', { override: { allowStepping: false } }).isValid()).toBeFalsy() 858 | }) 859 | }) 860 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Err, err, Valid, valid, Result } from './result' 2 | import checkSeconds from './fieldCheckers/secondChecker' 3 | import checkMinutes from './fieldCheckers/minuteChecker' 4 | import checkHours from './fieldCheckers/hourChecker' 5 | import checkDaysOfMonth from './fieldCheckers/dayOfMonthChecker' 6 | import checkMonths from './fieldCheckers/monthChecker' 7 | import checkDaysOfWeek from './fieldCheckers/dayOfWeekChecker' 8 | import checkYears from './fieldCheckers/yearChecker' 9 | import { validateOptions } from './option' 10 | import { InputOptions, Options } from './types' 11 | 12 | export interface CronData { 13 | seconds?: string 14 | minutes: string 15 | hours: string 16 | daysOfMonth: string 17 | months: string 18 | daysOfWeek: string 19 | years?: string 20 | } 21 | 22 | export type CronFieldType = 23 | | 'seconds' 24 | | 'minutes' 25 | | 'hours' 26 | | 'daysOfMonth' 27 | | 'months' 28 | | 'daysOfWeek' 29 | | 'years' 30 | 31 | const splitCronString = ( 32 | cronString: string, 33 | options: Options 34 | ): Result => { 35 | const splittedCronString = cronString.trim().split(' ') 36 | 37 | if ( 38 | options.useSeconds && 39 | options.useYears && 40 | splittedCronString.length !== 7 41 | ) { 42 | return err(`Expected 7 values, but got ${splittedCronString.length}.`) 43 | } 44 | if ( 45 | ((options.useSeconds && !options.useYears) || 46 | (options.useYears && !options.useSeconds)) && 47 | splittedCronString.length !== 6 48 | ) { 49 | return err(`Expected 6 values, but got ${splittedCronString.length}.`) 50 | } 51 | if ( 52 | !options.useSeconds && 53 | !options.useYears && 54 | splittedCronString.length !== 5 55 | ) { 56 | return err(`Expected 5 values, but got ${splittedCronString.length}.`) 57 | } 58 | 59 | const cronData: CronData = { 60 | seconds: options.useSeconds ? splittedCronString[0] : undefined, 61 | minutes: splittedCronString[options.useSeconds ? 1 : 0], 62 | hours: splittedCronString[options.useSeconds ? 2 : 1], 63 | daysOfMonth: splittedCronString[options.useSeconds ? 3 : 2], 64 | months: splittedCronString[options.useSeconds ? 4 : 3], 65 | daysOfWeek: splittedCronString[options.useSeconds ? 5 : 4], 66 | years: options.useYears 67 | ? splittedCronString[options.useSeconds ? 6 : 5] 68 | : undefined, 69 | } 70 | 71 | return valid(cronData) 72 | } 73 | 74 | const cron = ( 75 | cronString: string, 76 | inputOptions: InputOptions = {} 77 | ): Err | Valid => { 78 | // Validate option 79 | const optionsResult = validateOptions(inputOptions) 80 | if (optionsResult.isError()) { 81 | return optionsResult 82 | } 83 | const options = optionsResult.getValue() 84 | 85 | const cronDataResult = splitCronString(cronString, options) 86 | if (cronDataResult.isError()) { 87 | return err([`${cronDataResult.getError()} (Input cron: '${cronString}')`]) 88 | } 89 | 90 | const cronData = cronDataResult.getValue() 91 | const checkResults: (Valid | Err)[] = [] 92 | if (options.useSeconds) { 93 | checkResults.push(checkSeconds(cronData, options)) 94 | } 95 | 96 | checkResults.push(checkMinutes(cronData, options)) 97 | checkResults.push(checkHours(cronData, options)) 98 | checkResults.push(checkDaysOfMonth(cronData, options)) 99 | checkResults.push(checkMonths(cronData, options)) 100 | checkResults.push(checkDaysOfWeek(cronData, options)) 101 | 102 | if (options.useYears) { 103 | checkResults.push(checkYears(cronData, options)) 104 | } 105 | 106 | if (checkResults.every(value => value.isValid())) { 107 | return valid(cronData) 108 | } 109 | 110 | // TODO: Right error return 111 | const errorArray: string[] = [] 112 | checkResults.forEach(result => { 113 | if (result.isError()) { 114 | result.getError().forEach((error: string) => { 115 | errorArray.push(error) 116 | }) 117 | } 118 | }) 119 | 120 | // Make sure cron string is in every error 121 | errorArray.forEach((error: string, index: number) => { 122 | errorArray[index] = `${error} (Input cron: '${cronString}')` 123 | }) 124 | 125 | return err(errorArray) 126 | } 127 | 128 | export default cron -------------------------------------------------------------------------------- /src/matrix.test.ts: -------------------------------------------------------------------------------- 1 | import cron from './index' 2 | import { InputOptions } from './types' 3 | 4 | type TestCase = { 5 | value: string 6 | description?: string 7 | } 8 | 9 | type Matrix = { 10 | describe: string 11 | options: InputOptions 12 | 13 | validIndexes?: number[] 14 | valids: TestCase[] 15 | invalids: TestCase[] 16 | unuseds: TestCase[] 17 | } 18 | 19 | describe('test', () => { 20 | const itSucceeds = ( 21 | testCase: TestCase, 22 | expression: string, 23 | options: InputOptions = {} 24 | ) => { 25 | it(`${expression.padEnd(15, ' ')} should be valid ${ 26 | testCase.description ? `(${testCase.description})` : '' 27 | }`, () => { 28 | expect(cron(expression, options).isValid()).toBeTruthy() 29 | }) 30 | } 31 | 32 | const itFails = ( 33 | testCase: TestCase, 34 | expression: string, 35 | options: InputOptions = {} 36 | ) => { 37 | it(`${expression.padEnd(15, ' ')} should be invalid ${ 38 | testCase.description ? `(${testCase.description})` : '' 39 | }`, () => { 40 | expect(cron(expression, options).isValid()).toBeFalsy() 41 | }) 42 | } 43 | 44 | const forEachIndex = (testCase: TestCase): [number, string][] => { 45 | return [0, 1, 2, 3, 4].map((index: number): [number, string] => { 46 | const fragments = Array(5).fill('*') 47 | fragments[index] = testCase.value 48 | return [index, fragments.join(' ')] 49 | }) 50 | } 51 | 52 | const withOptions = (matrix: Matrix) => { 53 | describe('With Options', () => { 54 | for (const valid of matrix.valids) { 55 | for (const [index, expression] of forEachIndex(valid)) { 56 | if (matrix.validIndexes?.indexOf(index) !== -1) { 57 | itSucceeds(valid, expression, matrix.options) 58 | } else { 59 | itFails(valid, expression, matrix.options) 60 | } 61 | } 62 | } 63 | 64 | for (const invalid of matrix.invalids) { 65 | for (const [_index, expression] of forEachIndex(invalid)) { 66 | itFails(invalid, expression, matrix.options) 67 | } 68 | } 69 | }) 70 | } 71 | 72 | const withoutOptions = (matrix: Matrix) => { 73 | describe('Without Options', () => { 74 | for (const testCase of [...matrix.valids, ...matrix.invalids]) { 75 | for (const [_index, expression] of forEachIndex(testCase)) { 76 | itFails(testCase, expression) 77 | } 78 | } 79 | }) 80 | } 81 | 82 | const flagValueUnused = (matrix: Matrix) => { 83 | describe('Flag value unused', () => { 84 | for (const unused of matrix.unuseds) { 85 | for (const index of matrix.validIndexes ?? []) { 86 | const fragments = Array(5).fill('*') 87 | fragments[index] = unused.value 88 | const expression = fragments.join(' ') 89 | itSucceeds(unused, expression, matrix.options) 90 | } 91 | } 92 | }) 93 | } 94 | 95 | const matrixes: Matrix[] = [ 96 | { 97 | describe: 'useLastDayOfMonth', 98 | options: { 99 | override: { 100 | useLastDayOfMonth: true, 101 | daysOfMonth: { lowerLimit: 1, upperLimit: 31 }, 102 | }, 103 | }, 104 | validIndexes: [2], 105 | valids: [ 106 | { value: 'L', description: 'alone' }, 107 | { value: 'L-2', description: 'with offset' }, 108 | ], 109 | invalids: [ 110 | { value: '15,L', description: 'cannot be in a list' }, 111 | { value: '2-L', description: 'cannot be the end of a range' }, 112 | { value: '2/L', description: 'cannot be in a step' }, 113 | { value: 'L/2', description: 'cannot be in a step' }, 114 | { value: 'L-32', description: 'cannot have offset out of limit range' }, 115 | { value: 'LL', description: 'cannot have multiple occurrence' }, 116 | ], 117 | unuseds: [ 118 | { 119 | value: '1-15,20-22', 120 | description: 'no impact when option is on but no L specified', 121 | }, 122 | ], 123 | }, 124 | { 125 | describe: 'useLastDayOfWeek', 126 | options: { 127 | override: { 128 | useLastDayOfWeek: true, 129 | daysOfWeek: { lowerLimit: 1, upperLimit: 7 }, 130 | }, 131 | }, 132 | validIndexes: [4], 133 | valids: [ 134 | { value: 'L', description: 'alone implies last saturday' }, 135 | { 136 | value: '5L', 137 | description: 'with a day implies last 5th weekday of the month', 138 | }, 139 | ], 140 | invalids: [ 141 | { value: '15,5L', description: 'cannot be in a list' }, 142 | { value: '1-5L', description: 'cannot be in a range' }, 143 | { value: '5/L', description: 'cannot be in a step' }, 144 | { value: 'L/5', description: 'cannot be in a step' }, 145 | { 146 | value: '8L', 147 | description: 'cannot have a weekday value out of limit', 148 | }, 149 | { value: 'LL', description: 'cannot have multiple occurrence' }, 150 | ], 151 | unuseds: [ 152 | { 153 | value: '1-3,5-7', 154 | description: 'no impact when option is on but no L specified', 155 | }, 156 | ], 157 | }, 158 | { 159 | describe: 'useNearestWeekday', 160 | options: { 161 | override: { 162 | useNearestWeekday: true, 163 | daysOfMonth: { lowerLimit: 1, upperLimit: 31 }, 164 | }, 165 | }, 166 | validIndexes: [2], 167 | valids: [{ value: '15W', description: 'nearest weekday to the 15th' }], 168 | invalids: [ 169 | { value: 'W', description: 'means nothing alone' }, 170 | { value: '1,15W', description: 'cannot be in a list' }, 171 | { value: '1-15W', description: 'cannot be in a range' }, 172 | { value: '15/W', description: 'cannot be in a step' }, 173 | { value: 'W/15', description: 'cannot be in a step' }, 174 | { value: '1W6W', description: 'cannot have multiple occurrence' }, 175 | ], 176 | unuseds: [ 177 | { 178 | value: '1-15,20-25', 179 | description: 'no impact when option is on but no W specified', 180 | }, 181 | ], 182 | }, 183 | { 184 | describe: 'useNearestWeekday with useLastDayOfMonth', 185 | options: { 186 | override: { 187 | useLastDayOfMonth: true, 188 | useNearestWeekday: true, 189 | daysOfMonth: { lowerLimit: 1, upperLimit: 31 }, 190 | }, 191 | }, 192 | validIndexes: [2], 193 | valids: [{ value: 'LW', description: 'last weekday of month' }], 194 | invalids: [ 195 | { value: '15,LW', description: 'cannot be in a list' }, 196 | { value: 'WL', description: 'cannot be reversed' }, 197 | { value: '1-15LW', description: 'cannot be in a range' }, 198 | { value: '15/LW', description: 'cannot be in a step' }, 199 | { value: 'LW/15', description: 'cannot be in a step' }, 200 | ], 201 | unuseds: [ 202 | { 203 | value: '1-15,20-25', 204 | description: 'no impact when option is on but no W or L specified', 205 | }, 206 | ], 207 | }, 208 | { 209 | describe: 'useNthWeekdayOfMonth', 210 | options: { 211 | override: { 212 | useNthWeekdayOfMonth: true, 213 | daysOfWeek: { lowerLimit: 1, upperLimit: 7 }, 214 | }, 215 | }, 216 | validIndexes: [4], 217 | valids: [ 218 | { value: '6#3', description: '3rd friday of the month' }, 219 | { value: '5#5', description: '5rd thursday of the month' } 220 | ], 221 | invalids: [ 222 | { value: '6#', description: 'must have a number after' }, 223 | { value: '#3', description: 'must have a number before' }, 224 | { value: '2,6#3', description: 'cannot be in a list' }, 225 | { value: '2-6#3', description: 'cannot be in a range' }, 226 | { value: '2/6#3', description: 'cannot be in a step' }, 227 | { value: '6#3/2', description: 'cannot be in a step' }, 228 | { value: '8#3', description: 'must respect limits (days)' }, 229 | { value: '3#6', description: 'must respect limits (occurences of day)' }, 230 | { value: '6#6', description: 'must respect limits (occurences of day)' }, 231 | { value: '6##3', description: 'cannot have multiple occurrence' }, 232 | ], 233 | unuseds: [ 234 | { 235 | value: '1-3,5-7', 236 | description: 'no impact when option is on but no # specified', 237 | }, 238 | ], 239 | }, 240 | { 241 | describe: 'useAliases months', 242 | options: { 243 | override: { 244 | useAliases: true, 245 | }, 246 | }, 247 | validIndexes: [3], 248 | valids: [ 249 | { value: 'jan', description: 'works alone' }, 250 | { value: 'jan-jun', description: 'works in range' }, 251 | { value: 'jan-jun/2', description: 'works as a step nominator' }, 252 | { value: 'jan,feb,mar', description: 'works in list' }, 253 | { value: 'Jan,FEB,MaR', description: 'is case insensitive' }, 254 | { 255 | value: 'jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec', 256 | description: 'works for every month', 257 | }, 258 | ], 259 | invalids: [ 260 | { value: '1/jan', description: 'cannot be a step denominator' }, 261 | { value: 'january', description: 'cannot use full names' }, 262 | ], 263 | unuseds: [ 264 | { 265 | value: '1-2,5-7', 266 | description: 'no impact when option is on but no alias is used', 267 | }, 268 | ], 269 | }, 270 | { 271 | describe: 'useAliases daysOfWeek', 272 | options: { 273 | override: { 274 | useAliases: true, 275 | }, 276 | }, 277 | validIndexes: [4], 278 | valids: [ 279 | { value: 'mon', description: 'works alone' }, 280 | { value: 'mon-wed', description: 'works in range' }, 281 | { value: 'mon-wed/2', description: 'works as a step nominator' }, 282 | { value: 'mon,tue,wed', description: 'works in list' }, 283 | { value: 'Mon,TUE,WeD', description: 'is case insensitive' }, 284 | { 285 | value: 'sun,mon,tue,wed,thu,fri,sat', 286 | description: 'works for every weekday', 287 | }, 288 | ], 289 | invalids: [ 290 | { value: '1/mon', description: 'cannot be a step denominator' }, 291 | { value: 'monday', description: 'cannot use full names' }, 292 | ], 293 | unuseds: [ 294 | { 295 | value: '1-2,5-7', 296 | description: 'no impact when option is on but no alias is used', 297 | }, 298 | ], 299 | }, 300 | ] 301 | 302 | for (const matrix of matrixes) { 303 | describe(matrix.describe, () => { 304 | withOptions(matrix) 305 | withoutOptions(matrix) 306 | flagValueUnused(matrix) 307 | }) 308 | } 309 | }) 310 | -------------------------------------------------------------------------------- /src/option.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup' 2 | import { ValidationError } from 'yup' 3 | import { err, valid, Result } from './result' 4 | import presets from './presets' 5 | import { Options, OptionPreset, InputOptions } from './types' 6 | 7 | const optionPresets: { [presetId: string]: OptionPreset } = { 8 | // http://crontab.org/ 9 | default: { 10 | presetId: 'default', 11 | useSeconds: false, 12 | useYears: false, 13 | useAliases: false, 14 | useBlankDay: false, 15 | allowOnlyOneBlankDayField: false, 16 | // Optional for backward compatibility. 17 | // Undefined implies true. 18 | allowStepping: true, 19 | // Undefined implies false. 20 | mustHaveBlankDayField: false, 21 | useLastDayOfMonth: false, 22 | useLastDayOfWeek: false, 23 | useNearestWeekday: false, 24 | useNthWeekdayOfMonth: false, 25 | // 26 | seconds: { 27 | minValue: 0, 28 | maxValue: 59, 29 | }, 30 | minutes: { 31 | minValue: 0, 32 | maxValue: 59, 33 | }, 34 | hours: { 35 | minValue: 0, 36 | maxValue: 23, 37 | }, 38 | daysOfMonth: { 39 | minValue: 0, 40 | maxValue: 31, 41 | }, 42 | months: { 43 | minValue: 0, 44 | maxValue: 12, 45 | }, 46 | daysOfWeek: { 47 | minValue: 0, 48 | maxValue: 7, 49 | }, 50 | years: { 51 | minValue: 1970, 52 | maxValue: 2099, 53 | }, 54 | }, 55 | } 56 | 57 | const optionPresetSchema = yup 58 | .object({ 59 | presetId: yup.string().required(), 60 | useSeconds: yup.boolean().required(), 61 | useYears: yup.boolean().required(), 62 | useAliases: yup.boolean(), 63 | useBlankDay: yup.boolean().required(), 64 | allowOnlyOneBlankDayField: yup.boolean().required(), 65 | allowStepping: yup.boolean(), 66 | mustHaveBlankDayField: yup.boolean(), 67 | useLastDayOfMonth: yup.boolean(), 68 | useLastDayOfWeek: yup.boolean(), 69 | useNearestWeekday: yup.boolean(), 70 | useNthWeekdayOfMonth: yup.boolean(), 71 | seconds: yup 72 | .object({ 73 | minValue: yup.number().min(0).required(), 74 | maxValue: yup.number().min(0).required(), 75 | lowerLimit: yup.number().min(0), 76 | upperLimit: yup.number().min(0), 77 | }) 78 | .required(), 79 | minutes: yup 80 | .object({ 81 | minValue: yup.number().min(0).required(), 82 | maxValue: yup.number().min(0).required(), 83 | lowerLimit: yup.number().min(0), 84 | upperLimit: yup.number().min(0), 85 | }) 86 | .required(), 87 | hours: yup 88 | .object({ 89 | minValue: yup.number().min(0).required(), 90 | maxValue: yup.number().min(0).required(), 91 | lowerLimit: yup.number().min(0), 92 | upperLimit: yup.number().min(0), 93 | }) 94 | .required(), 95 | daysOfMonth: yup 96 | .object({ 97 | minValue: yup.number().min(0).required(), 98 | maxValue: yup.number().min(0).required(), 99 | lowerLimit: yup.number().min(0), 100 | upperLimit: yup.number().min(0), 101 | }) 102 | .required(), 103 | months: yup 104 | .object({ 105 | minValue: yup.number().min(0).required(), 106 | maxValue: yup.number().min(0).required(), 107 | lowerLimit: yup.number().min(0), 108 | upperLimit: yup.number().min(0), 109 | }) 110 | .required(), 111 | daysOfWeek: yup 112 | .object({ 113 | minValue: yup.number().min(0).required(), 114 | maxValue: yup.number().min(0).required(), 115 | lowerLimit: yup.number().min(0), 116 | upperLimit: yup.number().min(0), 117 | }) 118 | .required(), 119 | years: yup 120 | .object({ 121 | minValue: yup.number().min(0).required(), 122 | maxValue: yup.number().min(0).required(), 123 | lowerLimit: yup.number().min(0), 124 | upperLimit: yup.number().min(0), 125 | }) 126 | .required(), 127 | }) 128 | .required() 129 | 130 | export const getOptionPreset = ( 131 | presetId: string 132 | ): Result => { 133 | if (optionPresets[presetId]) { 134 | return valid(optionPresets[presetId]) 135 | } 136 | 137 | return err(`Option preset '${presetId}' not found.`) 138 | } 139 | 140 | export const getOptionPresets = (): typeof optionPresets => optionPresets 141 | 142 | export const registerOptionPreset = ( 143 | presetName: string, 144 | preset: OptionPreset 145 | ): void => { 146 | optionPresets[presetName] = optionPresetSchema.validateSync(preset, { 147 | strict: false, 148 | abortEarly: false, 149 | stripUnknown: true, 150 | recursive: true, 151 | }) 152 | } 153 | function loadPresets() { 154 | for (let index = 0; index < presets.length; index += 1) { 155 | const { name, preset } = presets[index]; 156 | registerOptionPreset(name, preset) 157 | } 158 | } 159 | loadPresets(); 160 | 161 | type OptionsCacheKey = string; 162 | const optionsCache: Map = new Map(); 163 | 164 | function toOptionsCacheKey(presetId: string, override?: InputOptions["override"]) { 165 | return presetId + (JSON.stringify(override) ?? ""); 166 | } 167 | 168 | function presetToOptionsSchema(preset: OptionPreset) { 169 | return yup 170 | .object({ 171 | presetId: yup.string().required(), 172 | preset: optionPresetSchema.required(), 173 | useSeconds: yup.boolean().required(), 174 | useYears: yup.boolean().required(), 175 | useAliases: yup.boolean(), 176 | useBlankDay: yup.boolean().required(), 177 | allowOnlyOneBlankDayField: yup.boolean().required(), 178 | allowStepping: yup.boolean(), 179 | mustHaveBlankDayField: yup.boolean(), 180 | useLastDayOfMonth: yup.boolean(), 181 | useLastDayOfWeek: yup.boolean(), 182 | useNearestWeekday: yup.boolean(), 183 | useNthWeekdayOfMonth: yup.boolean(), 184 | seconds: yup 185 | .object({ 186 | lowerLimit: yup 187 | .number() 188 | .min(preset.seconds.minValue) 189 | .max(preset.seconds.maxValue), 190 | upperLimit: yup 191 | .number() 192 | .min(preset.seconds.minValue) 193 | .max(preset.seconds.maxValue), 194 | }) 195 | .required(), 196 | minutes: yup 197 | .object({ 198 | lowerLimit: yup 199 | .number() 200 | .min(preset.minutes.minValue) 201 | .max(preset.minutes.maxValue), 202 | upperLimit: yup 203 | .number() 204 | .min(preset.minutes.minValue) 205 | .max(preset.minutes.maxValue), 206 | }) 207 | .required(), 208 | hours: yup 209 | .object({ 210 | lowerLimit: yup 211 | .number() 212 | .min(preset.hours.minValue) 213 | .max(preset.hours.maxValue), 214 | upperLimit: yup 215 | .number() 216 | .min(preset.hours.minValue) 217 | .max(preset.hours.maxValue), 218 | }) 219 | .required(), 220 | daysOfMonth: yup 221 | .object({ 222 | lowerLimit: yup 223 | .number() 224 | .min(preset.daysOfMonth.minValue) 225 | .max(preset.daysOfMonth.maxValue), 226 | upperLimit: yup 227 | .number() 228 | .min(preset.daysOfMonth.minValue) 229 | .max(preset.daysOfMonth.maxValue), 230 | }) 231 | .required(), 232 | months: yup 233 | .object({ 234 | lowerLimit: yup 235 | .number() 236 | .min(preset.months.minValue) 237 | .max(preset.months.maxValue), 238 | upperLimit: yup 239 | .number() 240 | .min(preset.months.minValue) 241 | .max(preset.months.maxValue), 242 | }) 243 | .required(), 244 | daysOfWeek: yup 245 | .object({ 246 | lowerLimit: yup 247 | .number() 248 | .min(preset.daysOfWeek.minValue) 249 | .max(preset.daysOfWeek.maxValue), 250 | upperLimit: yup 251 | .number() 252 | .min(preset.daysOfWeek.minValue) 253 | .max(preset.daysOfWeek.maxValue), 254 | }) 255 | .required(), 256 | years: yup 257 | .object({ 258 | lowerLimit: yup 259 | .number() 260 | .min(preset.years.minValue) 261 | .max(preset.years.maxValue), 262 | upperLimit: yup 263 | .number() 264 | .min(preset.years.minValue) 265 | .max(preset.years.maxValue), 266 | }) 267 | .required(), 268 | }) 269 | .required() 270 | } 271 | 272 | function presetToOptions(preset: OptionPreset, override?: InputOptions["override"]) { 273 | 274 | const unvalidatedConfig = { 275 | presetId: preset.presetId, 276 | preset, 277 | ...{ 278 | useSeconds: preset.useSeconds, 279 | useYears: preset.useYears, 280 | useAliases: preset.useAliases ?? false, 281 | useBlankDay: preset.useBlankDay, 282 | allowOnlyOneBlankDayField: preset.allowOnlyOneBlankDayField, 283 | allowStepping: preset.allowStepping ?? true, 284 | mustHaveBlankDayField: preset.mustHaveBlankDayField ?? false, 285 | useLastDayOfMonth: preset.useLastDayOfMonth ?? false, 286 | useLastDayOfWeek: preset.useLastDayOfWeek ?? false, 287 | useNearestWeekday: preset.useNearestWeekday ?? false, 288 | useNthWeekdayOfMonth: preset.useNthWeekdayOfMonth ?? false, 289 | seconds: { 290 | lowerLimit: preset.seconds.lowerLimit ?? preset.seconds.minValue, 291 | upperLimit: preset.seconds.upperLimit ?? preset.seconds.maxValue, 292 | }, 293 | minutes: { 294 | lowerLimit: preset.minutes.lowerLimit ?? preset.minutes.minValue, 295 | upperLimit: preset.minutes.upperLimit ?? preset.minutes.maxValue, 296 | }, 297 | hours: { 298 | lowerLimit: preset.hours.lowerLimit ?? preset.hours.minValue, 299 | upperLimit: preset.hours.upperLimit ?? preset.hours.maxValue, 300 | }, 301 | daysOfMonth: { 302 | lowerLimit: 303 | preset.daysOfMonth.lowerLimit ?? preset.daysOfMonth.minValue, 304 | upperLimit: 305 | preset.daysOfMonth.upperLimit ?? preset.daysOfMonth.maxValue, 306 | }, 307 | months: { 308 | lowerLimit: preset.months.lowerLimit ?? preset.months.minValue, 309 | upperLimit: preset.months.upperLimit ?? preset.months.maxValue, 310 | }, 311 | daysOfWeek: { 312 | lowerLimit: 313 | preset.daysOfWeek.lowerLimit ?? preset.daysOfWeek.minValue, 314 | upperLimit: 315 | preset.daysOfWeek.upperLimit ?? preset.daysOfWeek.maxValue, 316 | }, 317 | years: { 318 | lowerLimit: preset.years.lowerLimit ?? preset.years.minValue, 319 | upperLimit: preset.years.upperLimit ?? preset.years.maxValue, 320 | }, 321 | }, 322 | ...override, 323 | } 324 | 325 | const optionsSchema = presetToOptionsSchema(preset); 326 | 327 | const validatedConfig: Options = optionsSchema.validateSync( 328 | unvalidatedConfig, 329 | { 330 | strict: false, 331 | abortEarly: false, 332 | stripUnknown: true, 333 | recursive: true, 334 | } 335 | ) 336 | 337 | return validatedConfig 338 | } 339 | 340 | export const validateOptions = ( 341 | inputOptions: InputOptions 342 | ): Result => { 343 | try { 344 | let preset: OptionPreset 345 | if (inputOptions.preset) { 346 | if (typeof inputOptions.preset === 'string') { 347 | if (!optionPresets[inputOptions.preset]) { 348 | return err([`Option preset ${inputOptions.preset} does not exist.`]) 349 | } 350 | 351 | preset = optionPresets[inputOptions.preset as string] 352 | } else { 353 | preset = inputOptions.preset 354 | } 355 | } else { 356 | preset = optionPresets.default 357 | } 358 | 359 | const cacheKey = toOptionsCacheKey(preset.presetId, inputOptions.override); 360 | 361 | const cachedOptions = optionsCache.get(cacheKey); 362 | if (cachedOptions) return valid(cachedOptions); 363 | 364 | const options = presetToOptions(preset, inputOptions.override); 365 | optionsCache.set(cacheKey, options); 366 | return valid(options); 367 | } catch (validationError) { 368 | return err((validationError as ValidationError).errors) 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/presets.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | name: "npm-node-cron", 4 | preset: { 5 | // https://github.com/kelektiv/node-cron 6 | presetId: 'npm-node-cron', 7 | useSeconds: true, 8 | useYears: false, 9 | useAliases: true, 10 | useBlankDay: false, 11 | allowOnlyOneBlankDayField: false, 12 | mustHaveBlankDayField: false, 13 | useLastDayOfMonth: false, 14 | useLastDayOfWeek: false, 15 | useNearestWeekday: false, 16 | useNthWeekdayOfMonth: false, 17 | seconds: { 18 | minValue: 0, 19 | maxValue: 59, 20 | }, 21 | minutes: { 22 | minValue: 0, 23 | maxValue: 59, 24 | }, 25 | hours: { 26 | minValue: 0, 27 | maxValue: 23, 28 | }, 29 | daysOfMonth: { 30 | minValue: 1, 31 | maxValue: 31, 32 | }, 33 | months: { 34 | minValue: 0, 35 | maxValue: 11, 36 | }, 37 | daysOfWeek: { 38 | minValue: 0, 39 | maxValue: 6, 40 | }, 41 | years: { 42 | minValue: 1970, 43 | maxValue: 2099, 44 | }, 45 | } 46 | }, 47 | { 48 | name: "aws-cloud-watch", 49 | preset: { 50 | // https://docs.aws.amazon.com/de_de/AmazonCloudWatch/latest/events/ScheduledEvents.html 51 | presetId: 'aws-cloud-watch', 52 | useSeconds: false, 53 | useYears: true, 54 | useAliases: true, 55 | useBlankDay: true, 56 | allowOnlyOneBlankDayField: true, 57 | mustHaveBlankDayField: true, 58 | useLastDayOfMonth: true, 59 | useLastDayOfWeek: true, 60 | useNearestWeekday: true, 61 | useNthWeekdayOfMonth: true, 62 | seconds: { 63 | minValue: 0, 64 | maxValue: 59, 65 | }, 66 | minutes: { 67 | minValue: 0, 68 | maxValue: 59, 69 | }, 70 | hours: { 71 | minValue: 0, 72 | maxValue: 23, 73 | }, 74 | daysOfMonth: { 75 | minValue: 1, 76 | maxValue: 31, 77 | }, 78 | months: { 79 | minValue: 1, 80 | maxValue: 12, 81 | }, 82 | daysOfWeek: { 83 | minValue: 1, 84 | maxValue: 7, 85 | }, 86 | years: { 87 | minValue: 1970, 88 | maxValue: 2199, 89 | }, 90 | } 91 | }, 92 | { 93 | name: "npm-cron-schedule", 94 | preset: { 95 | // https://github.com/P4sca1/cron-schedule 96 | presetId: 'npm-cron-schedule', 97 | useSeconds: true, 98 | useYears: false, 99 | useAliases: true, 100 | useBlankDay: false, 101 | allowOnlyOneBlankDayField: false, 102 | mustHaveBlankDayField: false, 103 | useLastDayOfMonth: false, 104 | useLastDayOfWeek: false, 105 | useNearestWeekday: false, 106 | useNthWeekdayOfMonth: false, 107 | seconds: { 108 | minValue: 0, 109 | maxValue: 59, 110 | }, 111 | minutes: { 112 | minValue: 0, 113 | maxValue: 59, 114 | }, 115 | hours: { 116 | minValue: 0, 117 | maxValue: 23, 118 | }, 119 | daysOfMonth: { 120 | minValue: 1, 121 | maxValue: 31, 122 | }, 123 | months: { 124 | minValue: 1, 125 | maxValue: 12, 126 | }, 127 | daysOfWeek: { 128 | minValue: 0, 129 | maxValue: 7, 130 | }, 131 | years: { 132 | minValue: 1970, 133 | maxValue: 2099, 134 | }, 135 | } 136 | } 137 | ]; -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | /* 2 | From: 3 | https://dev.to/_gdelgado/type-safe-error-handling-in-typescript-1p4n 4 | https://github.com/gDelgado14/neverthrow 5 | 6 | MIT License 7 | 8 | Copyright (c) 2019 Giorgio Delgado 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | */ 28 | 29 | export type Result = Valid | Err 30 | 31 | export const valid = (value: T): Valid => new Valid(value) 32 | export const err = (error: E): Err => new Err(error) 33 | 34 | export class Valid { 35 | constructor(readonly value: T) {} 36 | 37 | public isValid(): this is Valid { 38 | return true 39 | } 40 | 41 | public isError(): this is Err { 42 | return !this.isValid() 43 | } 44 | 45 | public getValue(): T { 46 | return this.value 47 | } 48 | 49 | public getError(): E { 50 | throw new Error('Tried to get error from a valid.') 51 | } 52 | 53 | public map(func: (t: T) => A): Result { 54 | return valid(func(this.value)) 55 | } 56 | 57 | public mapErr(func: (e: E) => U): Result { 58 | return valid(this.value) 59 | } 60 | } 61 | 62 | export class Err { 63 | constructor(readonly error: E) {} 64 | 65 | public isError(): this is Err { 66 | return true 67 | } 68 | 69 | public isValid(): this is Valid { 70 | return !this.isError() 71 | } 72 | 73 | public getValue(): T { 74 | throw new Error('Tried to get success value from an error.') 75 | } 76 | 77 | public getError(): E { 78 | return this.error 79 | } 80 | 81 | public map(func: (t: T) => A): Result { 82 | return err(this.error) 83 | } 84 | 85 | public mapErr(func: (e: E) => U): Result { 86 | return err(func(this.error)) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type OptionPresetFieldOptions = { 2 | minValue: number 3 | maxValue: number 4 | lowerLimit?: number 5 | upperLimit?: number 6 | } 7 | 8 | type FieldOption = { 9 | lowerLimit?: number 10 | upperLimit?: number 11 | } 12 | 13 | type Fields = { 14 | seconds: T 15 | minutes: T 16 | hours: T 17 | daysOfMonth: T 18 | months: T 19 | daysOfWeek: T 20 | years: T 21 | } 22 | 23 | type ExtendFields = { 24 | useSeconds: boolean 25 | useYears: boolean 26 | } 27 | 28 | type ExtendWildcards = { 29 | useBlankDay: boolean 30 | allowOnlyOneBlankDayField: boolean 31 | 32 | 33 | // Optional for backward compatibility. 34 | // Undefined implies true. 35 | allowStepping?: boolean 36 | // Undefined implies false. 37 | useAliases?: boolean 38 | mustHaveBlankDayField?: boolean 39 | useLastDayOfMonth?: boolean 40 | useLastDayOfWeek?: boolean 41 | useNearestWeekday?: boolean 42 | useNthWeekdayOfMonth?: boolean 43 | } 44 | 45 | export type OptionPreset = { 46 | presetId: string 47 | } & Fields & 48 | ExtendFields & 49 | ExtendWildcards 50 | 51 | export type Options = { 52 | presetId: string 53 | preset: OptionPreset 54 | } & Fields & 55 | ExtendFields & 56 | ExtendWildcards 57 | 58 | export type InputOptions = { 59 | preset?: string | OptionPreset 60 | override?: Partial> & 61 | Partial & 62 | Partial 63 | } & Partial> & 64 | Partial 65 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib" 6 | }, 7 | "include": ["src/**/*.ts"], 8 | "exclude": ["src/**/*.test.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": ["ESNext"], 5 | "target": "ES2017", // ES2017 is 100% supported in node.js 8.10.0. 6 | "module": "commonjs", // Node.js does not yet support es-modules. 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "importsNotUsedAsValues": "preserve" 14 | }, 15 | "include": ["src/**/*.ts", "scripts/**/*.ts"], 16 | "exclude": ["src/**/*.test.ts"] 17 | } 18 | --------------------------------------------------------------------------------