├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── jsr.yml │ ├── npm-prerelease.yml │ ├── npm.yml │ ├── pages.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── SECURITY.md ├── build ├── build.ts └── package.template.json ├── croner.png ├── deno.json ├── docs ├── _config.ts ├── deno.json └── src │ ├── _data.json │ ├── _includes │ ├── head.njk │ └── multiplex.njk │ ├── contributing.md │ ├── css │ └── custom.css │ ├── index.md │ ├── installation.md │ ├── migration.md │ ├── sponsors.md │ └── usage │ ├── basics.md │ ├── configuration.md │ ├── examples.md │ ├── index.md │ └── pattern.md ├── src ├── croner.ts ├── date.ts ├── helpers │ └── minitz.ts ├── options.ts ├── pattern.ts └── utils.ts └── test ├── croner.test.ts ├── options.test.ts ├── pattern.test.ts ├── range.test.ts ├── stepping.test.ts ├── timezone.test.ts └── utils.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | src/helpers/minitz.ts linguist-vendored -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [hexagon] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. Questions and feature requests should be posted on the Discussions-page. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | Example: 17 | 1. At 6 o'clock a friday night 18 | 2. Run code 19 | ``` 20 | // Code 21 | ``` 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **System:** 27 | - OS: [e.g. iOS, Linux, Windows] 28 | - Runtime [Node.js, Deno, Bun, Chrome, Safari, Firefox] 29 | - Runtime Version [e.g. 22] 30 | - Croner Version [e.g. 9.0.0-dev.11] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: Publish to jsr.io 2 | on: 3 | release: 4 | types: [released] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | publish: 9 | permissions: 10 | contents: read 11 | id-token: write 12 | uses: cross-org/workflows/.github/workflows/jsr-publish.yml@main -------------------------------------------------------------------------------- /.github/workflows/npm-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM (prerelease) 2 | on: 3 | release: 4 | types: [prereleased] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | ref: dev 12 | 13 | - name: Setup Deno 14 | uses: denoland/setup-deno@v2 15 | with: 16 | deno-version: "2.x" 17 | 18 | - uses: actions/setup-node@v3.5.1 19 | with: 20 | node-version: '16.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - run: deno task build 24 | 25 | - run: npm publish --tag dev 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | release: 4 | types: [released] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | ref: master 12 | 13 | - name: Setup Deno 14 | uses: denoland/setup-deno@v2 15 | with: 16 | deno-version: "2.x" 17 | 18 | - uses: actions/setup-node@v3.5.1 19 | with: 20 | node-version: '18.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - run: deno task build 24 | 25 | - run: npm publish 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Lumocs with GitHub Pages 2 | 3 | on: 4 | # Runs on pushes targeting the main branch 5 | push: 6 | branches: 7 | - main 8 | - dev 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 20 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 21 | concurrency: 22 | group: "pages" 23 | cancel-in-progress: false 24 | 25 | jobs: 26 | # Build job 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v3 32 | 33 | - name: Setup Pages 34 | uses: actions/configure-pages@v3 35 | 36 | - name: Install Deno 37 | uses: denoland/setup-deno@v1 38 | with: 39 | deno-version: v1.39.1 40 | 41 | - name: Run Lume 42 | run: deno task lume 43 | working-directory: ./docs 44 | 45 | - name: Upload artifact 46 | uses: actions/upload-pages-artifact@v2 47 | with: 48 | path: ./docs/_site 49 | 50 | # Deployment job 51 | deploy: 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | runs-on: ubuntu-latest 56 | needs: build 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v2 -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Cross Runtime Tests 2 | 3 | on: 4 | push: 5 | branches: [main, dev, dev-9.0] 6 | pull_request: 7 | branches: [main, dev, dev-9.0] 8 | 9 | jobs: 10 | deno_ci: 11 | uses: cross-org/workflows/.github/workflows/deno-ci.yml@main 12 | with: 13 | entrypoint: src/croner.ts 14 | lint_docs: false 15 | bun_ci: 16 | uses: cross-org/workflows/.github/workflows/bun-ci.yml@main 17 | with: 18 | jsr_dependencies: "@cross/test @std/assert" 19 | node_ci: 20 | uses: cross-org/workflows/.github/workflows/node-ci.yml@main 21 | with: 22 | test_target: "test/*.test.ts" 23 | jsr_dependencies: "@cross/test @std/assert" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Deno lock file 17 | deno.lock 18 | 19 | # Bun lock file 20 | bun.lockb 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 33 | node_modules 34 | 35 | # Built docs 36 | dist-docs 37 | 38 | # Local tests 39 | /test.js 40 | 41 | # VSCode user configuration 42 | .vscode 43 | 44 | # Lume built site 45 | _site 46 | 47 | # TypeScript incremental compilation information 48 | .tsbuildinfo 49 | 50 | # Built NPM package 51 | dist 52 | package.json 53 | package-lock.json 54 | 55 | # TypeScript configuration is added on-demand 56 | tsconfig.json -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2021 Hexagon 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Croner
3 | Trigger functions or evaluate cron expressions in JavaScript or TypeScript. No dependencies. All features. Node. Deno. Bun. Browser.

Try it live on jsfiddle, and check out the full documentation on croner.56k.guru.
4 |

5 | 6 | # Croner - Cron for JavaScript and TypeScript 7 | 8 | [![npm version](https://badge.fury.io/js/croner.svg)](https://badge.fury.io/js/croner) [![JSR](https://jsr.io/badges/@hexagon/croner)](https://jsr.io/@hexagon/croner) [![NPM Downloads](https://img.shields.io/npm/dw/croner.svg)](https://www.npmjs.org/package/croner) 9 | ![No dependencies](https://img.shields.io/badge/dependencies-none-brightgreen) [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Hexagon/croner/blob/master/LICENSE) 10 | 11 | * Trigger functions in JavaScript using [Cron](https://en.wikipedia.org/wiki/Cron#CRON_expression) syntax. 12 | * Evaluate cron expressions and get a list of upcoming run times. 13 | * Uses Vixie-cron [pattern](#pattern), with additional features such as `L` for last day and weekday of month and `#` for nth weekday of month. 14 | * Works in Node.js >=18 (both require and import), Deno >=1.16 and Bun >=1.0.0. 15 | * Works in browsers as standalone, UMD or ES-module. 16 | * Target different [time zones](https://croner.56k.guru/usage/examples/#time-zone). 17 | * Built-in [overrun protection](https://croner.56k.guru/usage/examples/#overrun-protection) 18 | * Built-in [error handling](https://croner.56k.guru/usage/examples/#error-handling) 19 | * Includes [TypeScript](https://www.typescriptlang.org/) typings. 20 | * Support for asynchronous functions. 21 | * Pause, resume, or stop execution after a task is scheduled. 22 | * Operates in-memory, with no need for a database or configuration files. 23 | * Zero dependencies. 24 | 25 | Quick examples: 26 | 27 | ```javascript 28 | // Basic: Run a function at the interval defined by a cron expression 29 | const job = new Cron('*/5 * * * * *', () => { 30 | console.log('This will run every fifth second'); 31 | }); 32 | 33 | // Enumeration: What dates do the next 100 sundays occur on? 34 | const nextSundays = new Cron('0 0 0 * * 7').nextRuns(100); 35 | console.log(nextSundays); 36 | 37 | // Days left to a specific date 38 | const msLeft = new Cron('59 59 23 24 DEC *').nextRun() - new Date(); 39 | console.log(Math.floor(msLeft/1000/3600/24) + " days left to next christmas eve"); 40 | 41 | // Run a function at a specific date/time using a non-local timezone (time is ISO 8601 local time) 42 | // This will run 2024-01-23 00:00:00 according to the time in Asia/Kolkata 43 | new Cron('2024-01-23T00:00:00', { timezone: 'Asia/Kolkata' }, () => { console.log('Yay!') }); 44 | 45 | ``` 46 | 47 | More [examples](https://croner.56k.guru/usage/examples/)... 48 | 49 | ## Installation 50 | 51 | Full documentation on installation and usage is found at 52 | 53 | > **Note** 54 | > If you are migrating from a different library such as `cron` or `node-cron`, or upgrading from a older version of croner, see the [migration section](https://croner.56k.guru/migration/) of the manual. 55 | 56 | Install croner using your favorite package manager or CDN, then include it in you project: 57 | 58 | Using Node.js or Bun 59 | 60 | ```javascript 61 | // ESM Import ... 62 | import { Cron } from "croner"; 63 | 64 | // ... or CommonJS Require, destructure to add type hints 65 | const { Cron } = require("croner"); 66 | ``` 67 | 68 | Using Deno 69 | 70 | ```typescript 71 | // From deno.land/x 72 | import { Cron } from "https://deno.land/x/croner@8.1.2/dist/croner.js"; 73 | 74 | // ... or jsr.io 75 | import { Cron } from "jsr:@hexagon/croner@8.1.2"; 76 | ``` 77 | 78 | In a webpage using the UMD-module 79 | 80 | ```html 81 | 82 | ``` 83 | 84 | ## Documentation 85 | 86 | ### Signature 87 | 88 | Cron takes three arguments 89 | 90 | * [pattern](#pattern) 91 | * [options](#options) (optional) 92 | * scheduled function (optional) 93 | 94 | ```javascript 95 | // Parameters 96 | // - First: Cron pattern, js date object (fire once), or ISO 8601 time string (fire once) 97 | // - Second: Options (optional) 98 | // - Third: Function run trigger (optional) 99 | const job = new Cron("* * * * * *", { maxRuns: 1 }, () => {} ); 100 | 101 | // If function is omitted in constructor, it can be scheduled later 102 | job.schedule(job, /* optional */ context) => {}); 103 | ``` 104 | 105 | The job will be sceduled to run at next matching time unless you supply option `{ paused: true }`. The `new Cron(...)` constructor will return a Cron instance, later called `job`, which have a couple of methods and properties listed below. 106 | 107 | #### Status 108 | 109 | ```javascript 110 | job.nextRun( /*optional*/ startFromDate ); // Get a Date object representing the next run. 111 | job.nextRuns(10, /*optional*/ startFromDate ); // Get an array of Dates, containing the next n runs. 112 | job.msToNext( /*optional*/ startFromDate ); // Get the milliseconds left until the next execution. 113 | job.currentRun(); // Get a Date object showing when the current (or last) run was started. 114 | job.previousRun( ); // Get a Date object showing when the previous job was started. 115 | 116 | job.isRunning(); // Indicates if the job is scheduled and not paused or killed (true or false). 117 | job.isStopped(); // Indicates if the job is permanently stopped using `stop()` (true or false). 118 | job.isBusy(); // Indicates if the job is currently busy doing work (true or false). 119 | 120 | job.getPattern(); // Returns the original pattern string 121 | ``` 122 | 123 | #### Control functions 124 | 125 | ```javascript 126 | job.trigger(); // Force a trigger instantly 127 | job.pause(); // Pause trigger 128 | job.resume(); // Resume trigger 129 | job.stop(); // Stop the job completely. It is not possible to resume after this. 130 | // Note that this also removes named jobs from the exported `scheduledJobs` array. 131 | ``` 132 | 133 | #### Properties 134 | 135 | ```javascript 136 | job.name // Optional job name, populated if a name were passed to options 137 | ``` 138 | 139 | #### Options 140 | 141 | | Key | Default value | Data type | Remarks | 142 | |--------------|----------------|----------------|---------------------------------------| 143 | | name | undefined | String | If you specify a name for the job, Croner will keep a reference to the job in the exported array `scheduledJobs`. The reference will be removed on `.stop()`. | 144 | | maxRuns | Infinite | Number | | 145 | | catch | false | Boolean\|Function | Catch unhandled errors in triggered function. Passing `true` will silently ignore errors. Passing a callback function will trigger this callback on error. | 146 | | timezone | undefined | String | Timezone in Europe/Stockholm format | 147 | | startAt | undefined | String | ISO 8601 formatted datetime (2021-10-17T23:43:00)
in local time (according to timezone parameter if passed) | 148 | | stopAt | undefined | String | ISO 8601 formatted datetime (2021-10-17T23:43:00)
in local time (according to timezone parameter if passed) | 149 | | interval | 0 | Number | Minimum number of seconds between triggers. | 150 | | paused | false | Boolean | If the job should be paused from start. | 151 | | context | undefined | Any | Passed as the second parameter to triggered function | 152 | | legacyMode | true | boolean | Combine day-of-month and day-of-week using true = OR, false = AND | 153 | | unref | false | boolean | Setting this to true unrefs the internal timer, which allows the process to exit even if a cron job is running. | 154 | | utcOffset | undefined | number | Schedule using a specific utc offset in minutes. This does not take care of daylight savings time, you probably want to use option `timezone` instead. | 155 | | protect | undefined | boolean\|Function | Enabled over-run protection. Will block new triggers as long as an old trigger is in progress. Pass either `true` or a callback function to enable | 156 | 157 | > **Warning** 158 | > Unreferencing timers (option `unref`) is only supported by Node.js and Deno. 159 | > Browsers have not yet implemented this feature, and it does not make sense to use it in a browser environment. 160 | 161 | #### Pattern 162 | 163 | The expressions used by Croner are very similar to those of Vixie Cron, but with a few additions and changes as outlined below: 164 | 165 | ```javascript 166 | // ┌──────────────── (optional) second (0 - 59) 167 | // │ ┌────────────── minute (0 - 59) 168 | // │ │ ┌──────────── hour (0 - 23) 169 | // │ │ │ ┌────────── day of month (1 - 31) 170 | // │ │ │ │ ┌──────── month (1 - 12, JAN-DEC) 171 | // │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon) 172 | // │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0) 173 | // │ │ │ │ │ │ 174 | // * * * * * * 175 | ``` 176 | 177 | * Croner expressions have the following additional modifiers: 178 | - *?*: The question mark is substituted with the time of initialization. For example, ? ? * * * * would be substituted with 25 8 * * * * if the time is :08:25 at the time of new Cron('? ? * * * *', <...>). The question mark can be used in any field. 179 | - *L*: The letter 'L' can be used in the day of the month field to indicate the last day of the month. When used in the day of the week field in conjunction with the # character, it denotes the last specific weekday of the month. For example, `5#L` represents the last Friday of the month. 180 | - *#*: The # character specifies the "nth" occurrence of a particular day within a month. For example, supplying 181 | `5#2` in the day of week field signifies the second Friday of the month. This can be combined with ranges and supports day names. For instance, MON-FRI#2 would match the Monday through Friday of the second week of the month. 182 | 183 | * Croner allows you to pass a JavaScript Date object or an ISO 8601 formatted string as a pattern. The scheduled function will trigger at the specified date/time and only once. If you use a timezone different from the local timezone, you should pass the ISO 8601 local time in the target location and specify the timezone using the options (2nd parameter). 184 | 185 | * Croner also allows you to change how the day-of-week and day-of-month conditions are combined. By default, Croner (and Vixie cron) will trigger when either the day-of-month OR the day-of-week conditions match. For example, `0 20 1 * MON` will trigger on the first of the month as well as each Monday. If you want to use AND (so that it only triggers on Mondays that are also the first of the month), you can pass `{ legacyMode: false }`. For more information, see issue [#53](https://github.com/Hexagon/croner/issues/53). 186 | 187 | | Field | Required | Allowed values | Allowed special characters | Remarks | 188 | |--------------|----------|----------------|----------------------------|---------------------------------------| 189 | | Seconds | Optional | 0-59 | * , - / ? | | 190 | | Minutes | Yes | 0-59 | * , - / ? | | 191 | | Hours | Yes | 0-23 | * , - / ? | | 192 | | Day of Month | Yes | 1-31 | * , - / ? L | | 193 | | Month | Yes | 1-12 or JAN-DEC| * , - / ? | | 194 | | Day of Week | Yes | 0-7 or SUN-MON | * , - / ? L # | 0 to 6 are Sunday to Saturday
7 is Sunday, the same as 0
# is used to specify nth occurrence of a weekday | 195 | 196 | > **Note** 197 | > Weekday and month names are case-insensitive. Both `MON` and `mon` work. 198 | > When using `L` in the Day of Week field, it affects all specified weekdays. For example, `5-6#L` means the last Friday and Saturday in the month." 199 | > The # character can be used to specify the "nth" weekday of the month. For example, 5#2 represents the second Friday of the month. 200 | 201 | It is also possible to use the following "nicknames" as pattern. 202 | 203 | | Nickname | Description | 204 | | -------- | ----------- | 205 | | \@yearly | Run once a year, ie. "0 0 1 1 *". | 206 | | \@annually | Run once a year, ie. "0 0 1 1 *". | 207 | | \@monthly | Run once a month, ie. "0 0 1 * *". | 208 | | \@weekly | Run once a week, ie. "0 0 * * 0". | 209 | | \@daily | Run once a day, ie. "0 0 * * *". | 210 | | \@hourly | Run once an hour, ie. "0 * * * *". | 211 | 212 | ## Why another JavaScript cron implementation 213 | 214 | Because the existing ones are not good enough. They have serious bugs, use bloated dependencies, do not work in all environments, and/or simply do not work as expected. 215 | 216 | | | croner | cronosjs | node-cron | cron | node-schedule | 217 | |---------------------------|:-------------------:|:-------------------:|:---------:|:-------------------------:|:-------------------:| 218 | | **Platforms** | 219 | | Node.js (CommonJS) | ✓ | ✓ | ✓ | ✓ | ✓ | 220 | | Browser (ESMCommonJS) | ✓ | ✓ | | | | 221 | | Deno (ESM) | ✓ | | | | | 222 | | **Features** | 223 | | Over-run protection | ✓ | | | | | 224 | | Error handling | ✓ | | | | ✓ | 225 | | Typescript typings | ✓ | ✓ | | ✓ | | 226 | | Unref timers (optional) | ✓ | | | ✓ | | 227 | | dom-OR-dow | ✓ | ✓ | ✓ | ✓ | ✓ | 228 | | dom-AND-dow (optional) | ✓ | | | | | 229 | | Next run | ✓ | ✓ | | ✓ | ✓ | 230 | | Next n runs | ✓ | ✓ | | ✓ | | 231 | | Timezone | ✓ | ✓ | ✓ | ✓ | ✓ | 232 | | Minimum interval | ✓ | | | | | 233 | | Controls (stop/resume) | ✓ | ✓ | ✓ | ✓ | ✓ | 234 | | Range (0-13) | ✓ | ✓ | ✓ | ✓ | ✓ | 235 | | Stepping (*/5) | ✓ | ✓ | ✓ | ✓ | ✓ | 236 | | Last day of month (L) | ✓ | ✓ | | | | 237 | | Nth weekday of month (#) | ✓ | ✓ | | | | 238 | 239 |
240 | In depth comparison of various libraries 241 | 242 | | | croner | cronosjs | node-cron | cron | node-schedule | 243 | |---------------------------|:-------------------:|:-------------------:|:---------:|:-------------------------:|:-------------------:| 244 | | **Size** | 245 | | Minified size (KB) | 17.0 | 14.9 | 15.2 | 85.4 :warning: | 100.5 :warning: | 246 | | Bundlephobia minzip (KB) | 5.0 | 5.1 | 5.7 | 25.8 | 29.2 :warning: | 247 | | Dependencies | 0 | 0 | 1 | 1 | 3 :warning: | 248 | | **Popularity** | 249 | | Downloads/week [^1] | 2019K | 31K | 447K | 1366K | 1046K | 250 | | **Quality** | 251 | | Issues [^1] | 0 | 2 | 133 :warning: | 13 | 145 :warning: | 252 | | Code coverage | 99% | 98% | 100% | 81% | 94% | 253 | | **Performance** | 254 | | Ops/s `1 2 3 4 5 6` | 160 651 | 55 593 | N/A :x: | 6 313 :warning: | 2 726 :warning: | 255 | | Ops/s `0 0 0 29 2 *` | 176 714 | 67 920 | N/A :x: | 3 104 :warning: | 737 :warning: | 256 | | **Tests** | **11/11** | **10/11** | **0/11** [^4] :question: | **7/11** :warning: | **9/11** | 257 | 258 | > **Note** 259 | > * Table last updated at 2023-10-10 260 | > * node-cron has no interface to predict when the function will run, so tests cannot be carried out. 261 | > * All tests and benchmarks were carried out using [https://github.com/Hexagon/cron-comparison](https://github.com/Hexagon/cron-comparison) 262 | 263 | [^1]: As of 2023-10-10 264 | [^2]: Requires support for L-modifier 265 | [^3]: In dom-AND-dow mode, only supported by croner at the moment. 266 | [^4]: Node-cron has no way of showing next run time. 267 | 268 |
269 | 270 | ## Development 271 | 272 | ### Master branch 273 | 274 | ![Node.js CI](https://github.com/Hexagon/croner/workflows/Node.js%20CI/badge.svg?branch=master) ![Deno CI](https://github.com/Hexagon/croner/workflows/Deno%20CI/badge.svg?branch=master) ![Bun CI](https://github.com/Hexagon/croner/workflows/Bun%20CI/badge.svg?branch=master) 275 | 276 | This branch contains the latest stable code, released on npm's default channel `latest`. You can install the latest stable revision by running the command below. 277 | 278 | ``` 279 | npm install croner --save 280 | ``` 281 | 282 | ### Dev branch 283 | 284 | ![Node.js CI](https://github.com/Hexagon/croner/workflows/Node.js%20CI/badge.svg?branch=dev) ![Deno CI](https://github.com/Hexagon/croner/workflows/Deno%20CI/badge.svg?branch=dev) ![Bun CI](https://github.com/Hexagon/croner/workflows/Bun%20CI/badge.svg?branch=dev) 285 | 286 | This branch contains code currently being tested, and is released at channel `dev` on npm. You can install the latest revision of the development branch by running the command below. 287 | 288 | ``` 289 | npm install croner@dev 290 | ``` 291 | 292 | > **Warning** 293 | > Expect breaking changes if you do not pin to a specific version. 294 | 295 | A list of fixes and features currently released in the `dev` branch is available [here](https://github.com/Hexagon/croner/issues?q=is%3Aopen+is%3Aissue+label%3Areleased-in-dev) 296 | 297 | ## Contributing & Support 298 | 299 | Croner is founded and actively maintained by Hexagon. If you find value in Croner and want to contribute: 300 | 301 | - Code Contributions: See our [Contribution Guide](https://croner.56k.guru/contributing/) for details on how to contribute code. 302 | 303 | - Sponsorship and Donations: See [github.com/sponsors/hexagon](https://github.com/sponsors/hexagon) 304 | 305 | Your trust, support, and contributions drive the project. Every bit, irrespective of its size, is deeply appreciated. 306 | 307 | ## License 308 | 309 | MIT License 310 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | >= 4.x | :white_check_mark: | 8 | | < 4.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Email hexagon@56k.guru. Do NOT report an issue, we will have a look at it asap. 13 | -------------------------------------------------------------------------------- /build/build.ts: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import { dtsPlugin } from "esbuild-plugin-d.ts"; 3 | import { cp, readFile, writeFile } from "@cross/fs"; 4 | 5 | /** 6 | * Build helpers 7 | */ 8 | export async function build( 9 | baseConfig: esbuild.BuildOptions, 10 | configs?: esbuild.BuildOptions[], 11 | ): Promise { 12 | const buildConfigs = configs?.map((config) => ({ ...baseConfig, ...config })) || [baseConfig]; 13 | try { 14 | await Promise.all(buildConfigs.map((config) => esbuild.build(config))); 15 | console.log("All builds completed successfully."); 16 | } catch (error) { 17 | console.error("Build failed:", error); 18 | } 19 | } 20 | async function readJson(filePath: string): Promise { 21 | const jsonData = await readFile(filePath); 22 | return JSON.parse(new TextDecoder().decode(jsonData)) as T; 23 | } 24 | 25 | /** 26 | * Now the actual build script 27 | */ 28 | import { dirname, fromFileUrl, resolve } from "@std/path"; 29 | 30 | /* Preparations - Work out paths */ 31 | const baseRelativeProjectRoot = "../"; // Where is this script located relative to the project root 32 | const currentScriptDir = dirname(fromFileUrl(import.meta.url)); 33 | const relativeProjectRoot = resolve(currentScriptDir, baseRelativeProjectRoot); 34 | const resolvedDistPath = resolve(relativeProjectRoot, "dist"); 35 | 36 | /* Handle argument `clean`: Rimraf build artifacts */ 37 | if (Deno.args[1] === "clean") { 38 | for ( 39 | const filePath of [ 40 | "package.json", 41 | "tsconfig.json", 42 | "node_modules", 43 | "dist", 44 | ] 45 | ) { 46 | try { 47 | await Deno.remove(filePath, { recursive: true }); 48 | } catch (_e) { /* No-op */ } 49 | } 50 | 51 | /* Handle argument `build`: Transpile and generate typings */ 52 | } else if (Deno.args[1] === "build") { 53 | await build({ 54 | entryPoints: [resolve(relativeProjectRoot, "src/croner.ts")], 55 | bundle: true, 56 | minify: true, 57 | sourcemap: false, 58 | }, [ 59 | { 60 | outdir: resolvedDistPath, 61 | platform: "node", 62 | format: "cjs", 63 | outExtension: { ".js": ".cjs" }, 64 | }, 65 | { 66 | entryPoints: [], 67 | stdin: { 68 | contents: 'import { Cron } from "./croner.ts";module.exports = Cron;', 69 | resolveDir: "./src/", 70 | }, 71 | outfile: resolve(resolvedDistPath, "croner.umd.js"), 72 | platform: "browser", 73 | format: "iife", 74 | globalName: "Cron", 75 | }, 76 | { 77 | outdir: resolvedDistPath, 78 | platform: "neutral", 79 | format: "esm", 80 | plugins: [dtsPlugin({ 81 | experimentalBundling: true, 82 | tsconfig: { 83 | compilerOptions: { 84 | declaration: true, 85 | emitDeclarationOnly: true, 86 | allowImportingTsExtensions: true, 87 | lib: ["es6", "dom"], 88 | }, 89 | }, 90 | })], 91 | }, 92 | ]); 93 | 94 | // Just re-use the .d.ts for commonjs, as .d.cts 95 | await cp( 96 | resolve(resolvedDistPath, "croner.d.ts"), 97 | resolve(resolvedDistPath, "croner.d.cts"), 98 | ); 99 | 100 | /* Handle argument `package`: Generate package.json based on a base config and values from deno,json */ 101 | } else if (Deno.args[1] === "package") { 102 | // Read version from deno.json 103 | const denoConfig = await readJson<{ version: string }>(resolve(relativeProjectRoot, "deno.json")); 104 | 105 | // Write package.json 106 | await writeFile( 107 | resolve(relativeProjectRoot, "package.json"), 108 | new TextEncoder().encode(JSON.stringify( 109 | { 110 | ...await readJson(resolve(relativeProjectRoot, "build/package.template.json")), 111 | version: denoConfig.version, 112 | }, 113 | null, 114 | 2, 115 | )), 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /build/package.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "croner", 3 | "description": "Trigger functions and/or evaluate cron expressions in JavaScript. No dependencies. Most features. All environments.", 4 | "author": "Hexagon ", 5 | "homepage": "https://croner.56k.guru", 6 | "contributors": [ 7 | { 8 | "name": "Pehr Boman", 9 | "email": "github.com/unkelpehr" 10 | } 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/hexagon/croner" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/hexagon/croner/issues" 18 | }, 19 | "files": [ 20 | "dist/*.js", 21 | "dist/*.cjs", 22 | "dist/*.d.ts", 23 | "dist/*.d.cts" 24 | ], 25 | "engines": { 26 | "node": ">=18.0" 27 | }, 28 | "keywords": [ 29 | "cron", 30 | "front-end", 31 | "backend", 32 | "parser", 33 | "croner", 34 | "schedule", 35 | "scheduler", 36 | "timer", 37 | "task", 38 | "job", 39 | "isomorphic", 40 | "crontab" 41 | ], 42 | "type": "module", 43 | "main": "./dist/croner.cjs", 44 | "browser": "./dist/croner.umd.js", 45 | "module": "./dist/croner.js", 46 | "types": "./dist/croner.d.ts", 47 | "exports": { 48 | "./package.json": "./package.json", 49 | ".": { 50 | "import": { 51 | "types": "./dist/croner.d.ts", 52 | "default": "./dist/croner.js" 53 | }, 54 | "require": { 55 | "types": "./dist/croner.d.cts", 56 | "default": "./dist/croner.cjs" 57 | }, 58 | "browser": "./dist/croner.umd.js" 59 | } 60 | }, 61 | "license": "MIT" 62 | } 63 | -------------------------------------------------------------------------------- /croner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hexagon/croner/c7e2a8359ddf9303c3aff31aada7de5708bdd133/croner.png -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hexagon/croner", 3 | "version": "9.0.0", 4 | "exports": "./src/croner.ts", 5 | "lint": { 6 | "include": ["src", "build"] 7 | }, 8 | "fmt": { 9 | "include": ["src", "test", "build"], 10 | "lineWidth": 100 11 | }, 12 | "tasks": { 13 | "pre-commit": "deno fmt --check && deno lint && deno check src/croner.ts", 14 | "test": "deno task build:clean && deno test --allow-read", 15 | "build:prep": "deno cache --allow-scripts=npm:esbuild build/build.ts", 16 | "build:clean": "deno run --allow-read --allow-write --allow-env build/build.ts -- clean", 17 | "build:npm": "deno run --allow-read --allow-write --allow-env build/build.ts -- package", 18 | "build:esbuild": "deno run --allow-read --allow-write --allow-env --allow-run build/build.ts -- build", 19 | "build": "deno task build:clean && deno task test && deno task build:prep && deno task build:esbuild && deno task build:npm", 20 | "check-deps": "deno run -A jsr:@check/deps" 21 | }, 22 | "imports": { 23 | "@cross/fs": "jsr:@cross/fs@~0.1.11", 24 | "@cross/test": "jsr:@cross/test@~0.0.9", 25 | "@std/assert": "jsr:@std/assert@~1.0.6", 26 | "@std/path": "jsr:@std/path@~1.0.6", 27 | "esbuild": "npm:esbuild@~0.24.0", 28 | "esbuild-plugin-d.ts": "npm:esbuild-plugin-d.ts@~1.3.1" 29 | }, 30 | "publish": { 31 | "exclude": ["build", "dist", "docs", "test", "package.json", ".github"] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/_config.ts: -------------------------------------------------------------------------------- 1 | import lume from "lume/mod.ts"; 2 | import lumocs from "lumocs/mod.ts"; 3 | 4 | const site = lume({ 5 | src: "src", 6 | location: new URL("https://croner.56k.guru"), 7 | }); 8 | 9 | site.use(lumocs()); 10 | 11 | export default site; -------------------------------------------------------------------------------- /docs/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "lume": "echo \"import 'lume/cli.ts'\" | deno run --unstable -A -", 4 | "build": "deno task lume", 5 | "serve": "deno task lume -s --port=8000" 6 | }, 7 | "imports": { 8 | "lume/": "https://deno.land/x/lume@v2.0.1/", 9 | "lumocs/": "https://deno.land/x/lumocs@0.1.2/" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "metas": { 3 | "title": "=title", 4 | "site": "Croner", 5 | "lang": "en", 6 | "description": "Cron for JavaScript and TypeScript" 7 | }, 8 | "substitute": { 9 | "$CRONER_VERSION": "8.1.2" 10 | }, 11 | "top_links": [ 12 | { 13 | "icon": "fab fa-github", 14 | "title": "GitHub Repsitory", 15 | "url": "https://github.com/hexagon/croner" 16 | }, 17 | { 18 | "icon": "fas fa-cube", 19 | "title": "Deno.land/x", 20 | "url": "https://deno.land/x/croner" 21 | }, 22 | { 23 | "icon": "fab fa-npm", 24 | "title": "NPM Library", 25 | "url": "https://npmjs.com/package/croner" 26 | } 27 | ], 28 | "nav_links": [ 29 | { 30 | "title": "GitHub Repsitory", 31 | "url": "https://github.com/hexagon/croner" 32 | }, 33 | { 34 | "title": "Deno.land/x", 35 | "url": "https://deno.land/x/croner" 36 | }, 37 | { 38 | "title": "NPM Library", 39 | "url": "https://npmjs.com/package/croner" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /docs/src/_includes/head.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/src/_includes/multiplex.njk: -------------------------------------------------------------------------------- 1 | 3 | 8 | -------------------------------------------------------------------------------- /docs/src/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Contributing" 3 | nav_order: 6 4 | --- 5 | 6 | # Contributing to Croner 7 | 8 | --- 9 | 10 | ## Getting started 11 | 12 | To get an overview of the project, read the [README](https://github.com/Hexagon/croner). Here are some resources to help you get started with open source contributions: 13 | 14 | - [Set up Git](https://docs.github.com/en/get-started/quickstart/set-up-git) 15 | - [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow) 16 | - [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests) 17 | 18 | If you're looking to support Croner financially, consider checking out our [Sponsorship page](/sponsors). 19 | 20 | ### Issues 21 | 22 | #### Create a new issue 23 | 24 | If you spot a problem with Croner, [search if an issue already exists](https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments). If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/hexagon/croner/issues/new/choose). 25 | 26 | #### Solve an issue 27 | 28 | Scan through our [existing issues](https://github.com/hexagon/croner/issues) to find one that interests you. You can narrow down the search using `labels` as filters. If you find an issue to work on, make a note in the comments so we can assign it to you. Then you are welcome to open a PR with a fix. 29 | 30 | ### Make Changes 31 | 32 | #### Setting up the environment 33 | 34 | We recommend using VS Code with eslint extensions, which will automatically check your code against the defined rules as you write it. 35 | 36 | 1. Fork the repository. 37 | - Using GitHub Desktop: 38 | - [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop. 39 | - Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)! 40 | - Using the command line: 41 | - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. 42 | 2. Install or update to **Node.js v16**. 43 | 3. Base your work on the `dev` branch. 44 | 4. Create a working branch `feature/my-cool-feature` or `bugfix/issue-14` and start with your changes! 45 | 46 | ### Testing your changes 47 | 48 | Make sure you add test cases for your changes. While developing, use `npm run test` to run run quick tests against `/src/*`. 49 | 50 | ### Commit your update 51 | 52 | Please run `npm run build` before committing, to update the dist-files, and to make sure every test and check passes. When using this command, both source code, and all generated code will be tested, so it can take a while. 53 | 54 | If you make changes to any function Interface, or to JSDoc in general, you should also run `npm run build:docs` to update the generated documentation. 55 | 56 | See [package.json](https://github.com/Hexagon/croner/blob/master/package.json) scripts section for all available scripts. 57 | 58 | Then, commit the changes once you are happy with them. 59 | 60 | ### Pull Request 61 | 62 | When you're finished with the changes, create a pull request, also known as a PR. 63 | - Don't forget to [link PR to issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) if you are solving one. 64 | - Enable the checkbox to [allow maintainer edits](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork) so the branch can be updated for a merge. 65 | Once you submit your PR, a team member will review your proposal. We may ask questions or request for additional information. 66 | - We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. 67 | - As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). 68 | - If you run into any merge issues, checkout this [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) to help you resolve merge conflicts and other issues. 69 | 70 | ### Success 71 | 72 | This guide is based on [GitHub Docs CONTRIBUTING.md](https://github.com/github/docs/blob/main/CONTRIBUTING.md) 73 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Force white background on logo */ 2 | .logo { 3 | background-color: var(--bg__light); 4 | padding-right: 8px; 5 | padding-left: 5px; 6 | padding-top: 5px; 7 | border-radius: 5px; 8 | margin-bottom: 15px; 9 | } -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Overview" 3 | nav_order: 1 4 | --- 5 | 6 |

7 |
8 | Trigger functions or evaluate cron expressions in JavaScript or TypeScript. No dependencies. All features. Node. Deno. Bun. Browser.

Try it live on jsfiddle.
9 |

10 | 11 | ## Features 12 | 13 | * Trigger functions in JavaScript using [Cron](https://en.wikipedia.org/wiki/Cron#CRON_expression) syntax. 14 | * Evaluate cron expressions and get a list of upcoming run times. 15 | * Uses Vixie-cron [pattern](usage/pattern.md), with additional features such as `L` for last day and weekday of month and `#` for nth weekday of month. 16 | * Works in Node.js >=18.0 (both require and import), Deno >=1.16 and Bun >=1.0.0. 17 | * Works in browsers as standalone, UMD or ES-module. 18 | * Target different [time zones](usage/examples.md#time-zone). 19 | * Built-in [overrun protection](usage/examples.md#overrun-protection) 20 | * Built-in [error handling](usage/examples.md#error-handling) 21 | * Includes [TypeScript](https://www.typescriptlang.org/) typings. 22 | * Support for asynchronous functions. 23 | * Pause, resume, or stop execution after a task is scheduled. 24 | * Operates in-memory, with no need for a database or configuration files. 25 | * Zero dependencies. 26 | * Thoroughly tested and is relied upon by well-known projects such as [pm2](https://github.com/unitech/pm2), [Uptime Kuma](https://github.com/louislam/uptime-kuma), [ZWave JS](https://github.com/zwave-js/zwave-js-ui) and [TrueNAS](https://github.com/truenas/webui). 27 | 28 | ## Quick examples 29 | 30 | **Run a function at the interval defined by a cron expression** 31 | 32 | ```ts 33 | const job = new Cron('*/5 * * * * *', () => { 34 | console.log('This will run every five seconds'); 35 | }); 36 | ``` 37 | 38 | **What dates do the next 100 sundays occur on?** 39 | 40 | ```ts 41 | const nextSundays = new Cron('0 0 0 * * 7').nextRuns(100); 42 | console.log(nextSundays); 43 | ``` 44 | 45 | **Days left to a specific date** 46 | 47 | ```ts 48 | const msLeft = new Cron('59 59 23 24 DEC *').nextRun() - new Date(); 49 | console.log(Math.floor(msLeft/1000/3600/24) + " days left to next christmas eve"); 50 | ``` 51 | 52 | **Run a function at a specific date/time using a non-local timezone** 53 | 54 | Time is ISO 8601 local time, this will run 2024-01-23 00:00:00 according to the time in Asia/Kolkata 55 | 56 | ```ts 57 | new Cron('2024-01-23T00:00:00', { timezone: 'Asia/Kolkata' }, () => { console.log('Yay!') }); 58 | ``` 59 | 60 | More examples at [usage/examples.md]([usage/examples.md]) 61 | 62 | ## Feature comparison 63 | 64 | In this comparison, we outline the key differences between Croner and four other popular scheduling libraries: cronosjs, node-cron, cron, and node-schedule. The libraries are compared across various features such as platform compatibility, functionality, error handling, and Typescript support. 65 | 66 | The table below provides a brief overview of each library's features. 67 | 68 | | | croner | cronosjs | node-cron | cron | node-schedule | 69 | |---------------------------|:-------------------:|:-------------------:|:---------:|:-------------------------:|:-------------------:| 70 | | **Platforms** | | | | | | 71 | | Node.js (CommonJS) | ✓ | ✓ | ✓ | ✓ | ✓ | 72 | | Browser (ESMCommonJS) | ✓ | ✓ | | | | 73 | | Deno (ESM) | ✓ | | | | | 74 | | **Features** | | | | | | 75 | | Over-run protection | ✓ | | | | | 76 | | Error handling | ✓ | | | | ✓ | 77 | | Typescript typings | ✓ | ✓ | | ✓ | | 78 | | Unref timers (optional) | ✓ | | | ✓ | | 79 | | dom-OR-dow* | ✓ | ✓ | ✓ | ✓ | ✓ | 80 | | dom-AND-dow* (optional) | ✓ | | | | | 81 | | Next run | ✓ | ✓ | | ✓ | ✓ | 82 | | Next n runs | ✓ | ✓ | | ✓ | | 83 | | Timezone | ✓ | ✓ | ✓ | ✓ | ✓ | 84 | | Minimum interval | ✓ | | | | | 85 | | Controls (stop/resume) | ✓ | ✓ | ✓ | ✓ | ✓ | 86 | | Range (0-13) | ✓ | ✓ | ✓ | ✓ | ✓ | 87 | | Stepping (*/5) | ✓ | ✓ | ✓ | ✓ | ✓ | 88 | | Last day of month (L) | ✓ | ✓ | | | | 89 | | Nth weekday of month (#) | ✓ | ✓ | | | | 90 | 91 | > **DOM and DOW?**
92 | > DOM stands for Day of Month, and DOW stands for Day of Week. 93 | { .note } 94 | 95 | -------------------------------------------------------------------------------- /docs/src/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Installation" 3 | nav_order: 2 4 | --- 5 | 6 | # Installation 7 | 8 | --- 9 | 10 | Croner can be installed using your preferred package manager or CDN. After installation, it can be included in your project as follows: 11 | 12 | > If you are migrating from a different library such as `cron` or `node-cron`, or upgrading from an older version of croner, see the [Migration Guide](migration.md). 13 | { .note } 14 | 15 | {% include multiplex.html %} 16 | 17 | ### Importing with Node.js or Bun 18 | 19 | For Node.js or Bun, you can use ESM Import or CommonJS Require: 20 | 21 | ```ts 22 | // ESM Import 23 | import { Cron } from "croner"; 24 | 25 | // or CommonJS Require, destructure to add type hints 26 | const { Cron } = require("croner"); 27 | ``` 28 | 29 | ### Importing with Deno 30 | 31 | For Deno, import Croner from the provided URL: 32 | 33 | ```ts 34 | // From deno.land/x 35 | import { Cron } from "https://deno.land/x/croner@$CRONER_VERSION/dist/croner.js"; 36 | 37 | // ... or jsr.io 38 | import { Cron } from "jsr:@hexagon/croner@$CRONER_VERSION"; 39 | ``` 40 | 41 | Make sure to replace `$CRONER_VERSION` with the latest version. 42 | 43 | ### In a Webpage Using the UMD-module 44 | 45 | To use Croner in a webpage, you can load it as a UMD module from a CDN: 46 | 47 | `` 48 | -------------------------------------------------------------------------------- /docs/src/migration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Migration" 3 | nav_order: 4 4 | --- 5 | 6 | # Migration 7 | 8 | --- 9 | 10 | 11 | 12 | {% include multiplex.html %} 13 | 14 | ## Upgrading Croner 15 | 16 | This section covers upgrading to Croner from previous versions. Croner follows the Semantic Versioning (SemVer) standard. Be mindful that major updates may cause breaking changes. 17 | 18 | ### Upgrading from 4.x to 5.x 19 | 20 | If upgrading from version `4.x` to `5.x`, the most significant change is the way day-of-month and day-of-week are combined. You can read more about this in issue #53. The new mode is called `legacyMode` and can be disabled using the options. 21 | 22 | ### Upgrading from 5.x to 6.x 23 | 24 | For upgrading from version `5.x` to `6.x`, CommonJS and UMD builds were separated in the dist folder. Several method names were also changed to make them more descriptive: 25 | 26 | * `next()` -> `nextRun()` 27 | * `enumerate()` -> `nextRuns()` 28 | * `current()` -> `currentRun()` 29 | * `previous()`-> `previousRun()` 30 | * `running()` -> `isRunning()` 31 | * `busy()` -> `isBusy()` 32 | 33 | ### Upgrading from 6.x to 7.x 34 | 35 | Version `7.x` introduces significant changes, including the introduction of the nth weekday specifier `#`. Furthermore, there's a modification in the way `L` functions in the day-of-week field. In version `6.x`, `L` had flexibility in its positioning: both `LSUN` and `SUNL` were valid expressions to denote the last Sunday of the month. However, starting from version `7.x`, `L` must be used in conjunction with the nth weekday specifier `#`, like so: `SUN#L`. 36 | 37 | ### Upgrading from 7.x to 8.x 38 | 39 | Version `8.x` introduces no significant changes in the API for Croner. However, a major change is the discontinuation of support for Node.js versions prior to `18.0`. It is crucial to ensure that your environment is running Node.js `18.0` or higher before upgrading to `8.x`. If you rely on Node <= `16` you should stick with `7.x`. 40 | 41 | ### Upgrading from 8.x to 9.x 42 | 43 | Version 9.x brings several changes to Croner to fix existing issues and ensure consistency in code: 44 | 45 | ### Changes to the API 46 | 47 | * `new` is no longer optional. Previously, it was possible to instantiate Croner using `Cron(/*...*/)` now, you need to use `new Cron(/* ... */)`. 48 | 49 | * The default export is removed. Previously, you could `import Cron from`, now you need to `import { Cron } from`. The same goes for require. 50 | 51 | ### Changes to the file structure 52 | 53 | * Files in the `/dist` directory are always minified, no need to be explicit with that through the file names. As a result, `croner.min.js` is now `croner.js`. 54 | 55 | * Typings are moved from `/types` to `/dist`. 56 | 57 | * Only the `/src` directory is exposed in the JSR module. 58 | 59 | ## Switching from Cron 60 | 61 | If you're currently using the cron package and want to migrate to Croner, the following steps can guide you: 62 | 63 | ### Step 1: Install Croner 64 | 65 | To install the croner package, run the following command in your terminal: 66 | 67 | ```bash 68 | npm install croner 69 | ``` 70 | 71 | ### Step 2: Update your code to use Croner 72 | 73 | The croner package has a different API compared to the cron package. Here's an example of how to create a new `CronJob` using croner: 74 | 75 | ```ts 76 | // CronJob constructor is called just Cron in Croner 77 | const { Cron } = require('croner'); 78 | 79 | // If you have a lot of code using the CrobJob constructor, you can re-use the name like this 80 | // const { Cron as CronJob } = require('croner'); 81 | 82 | const job = new Cron('0 0 12 * * *', { /* options */ }, () => { 83 | console.log('This job will run at 12:00 PM every day.'); 84 | }); 85 | 86 | job.start(); 87 | ``` 88 | 89 | ### Step 3: Update your tests 90 | 91 | If you have tests for your code, you'll need to update them to use Croner instead of Cron. Make sure to test that your jobs are still running as expected after the migration. 92 | 93 | ## Switching from node-cron 94 | 95 | Here's how to migrate from the node-cron package to Croner: 96 | 97 | ### Install croner package: 98 | 99 | First, install the croner package in your project: 100 | 101 | ```bash 102 | npm install croner 103 | ``` 104 | 105 | ### Replace the import statement: 106 | 107 | Next, update the import statement for cron to croner. Replace the line that reads: 108 | 109 | ```ts 110 | const cron = require('node-cron'); 111 | ``` 112 | 113 | with: 114 | 115 | ```ts 116 | const cron = require('croner'); 117 | ``` 118 | 119 | ### Update your cron job: 120 | 121 | Here's an example of how to migrate a cron job: 122 | 123 | ```ts 124 | // node-cron 125 | cron.schedule('0 * 14 * * *', () => { 126 | console.log('Running a task every minute'); 127 | }, { timezone: "Europe/Oslo" }); 128 | 129 | // croner 130 | Cron('0 * 14 * * *', { timezone: "Europe/Oslo" }, () => { 131 | console.log('Running a task every minute'); 132 | }); 133 | ``` 134 | 135 | By following these steps, you should be able to migrate from `node-cron` to `croner` without any issues. 136 | -------------------------------------------------------------------------------- /docs/src/sponsors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Donations" 3 | nav_order: 7 4 | --- 5 | 6 | # Supporting Croner through Sponsorship 7 | 8 | --- 9 | 10 | Croner was founded and is actively maintained by [Hexagon](https://github.com/Hexagon). While I've poured my heart and soul into the project, I'm deeply grateful to the broader community of [contributors](https://github.com/Hexagon/croner/graphs/contributors) who have collaborated to make Croner what it is today. 11 | 12 | ## Why Sponsor? 13 | 14 | Your sponsorship directly supports my ongoing efforts to enhance and improve Croner. By becoming a sponsor, you're fostering a broader open-source ecosystem and ensuring it remains free and accessible to all. 15 | 16 | ## Sponsorship 17 | 18 | You can support by **monthly** and **one-time** sponsorship tiers at [github.com/sponsors/Hexagon](https://github.com/sponsors/Hexagon?frequency=one-time&sponsor=Hexagon) 19 | 20 | - **Monthly Tiers**: These tiers offer benefits such listing in Croner's README, priority issue resolution, and more. 21 | 22 | - **One-Time Contributions**: More relaxed option with buying Hexagon anything from a coffe and up. Each contribution, no matter the size, ensures continued dedication to the project. -------------------------------------------------------------------------------- /docs/src/usage/basics.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Basics" 3 | parent: "Usage" 4 | nav_order: 1 5 | --- 6 | 7 | # Basic usage 8 | 9 | --- 10 | 11 | Croner uses the function `new Cron()` which takes in three arguments: 12 | 13 | ```ts 14 | const job = new Cron( 15 | /* The pattern */ 16 | "* * * * * *", 17 | /* Options (optional) */ 18 | { maxRuns: 1 }, 19 | /* Function (optional) */ 20 | () => {} 21 | ); 22 | ``` 23 | 24 | If the function is omitted in the constructor, it can be scheduled later: 25 | 26 | ```ts 27 | job.schedule(job, /* optional */ context) => {}); 28 | ``` 29 | 30 | The job will be scheduled to run at the next matching time unless you supply the option `{ paused: true }`. The `Cron(...)` constructor will return a Cron instance, later referred to as `job`, which have a few methods and properties. 31 | 32 | {% include multiplex.html %} 33 | 34 | ## Status 35 | 36 | Check the status of the job using the following methods: 37 | 38 | ```ts 39 | job.nextRun( /*optional*/ startFromDate ); // Get a Date object representing the next run. 40 | job.nextRuns(10, /*optional*/ startFromDate ); // Get an array of Dates, containing the next n runs. 41 | job.msToNext( /*optional*/ startFromDate ); // Get the milliseconds left until the next execution. 42 | job.currentRun(); // Get a Date object showing when the current (or last) run was started. 43 | job.previousRun( ); // Get a Date object showing when the previous job was started. 44 | 45 | job.isRunning(); // Indicates if the job is scheduled and not paused or killed (true or false). 46 | job.isStopped(); // Indicates if the job is permanently stopped using `stop()` (true or false). 47 | job.isBusy(); // Indicates if the job is currently busy doing work (true or false). 48 | 49 | job.getPattern(); // Returns the original pattern string 50 | ``` 51 | 52 | ## Control Functions 53 | 54 | Control the job using the following methods: 55 | 56 | ```ts 57 | job.trigger(); // Force a trigger instantly 58 | job.pause(); // Pause trigger 59 | job.resume(); // Resume trigger 60 | job.stop(); // Stop the job completely. It is not possible to resume after this. 61 | // Note that this also removes named jobs from the exported `scheduledJobs` array. 62 | ``` 63 | 64 | ## Properties 65 | 66 | ```ts 67 | job.name // Optional job name, populated if a name were passed to options 68 | ``` -------------------------------------------------------------------------------- /docs/src/usage/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Configuration" 3 | parent: "Usage" 4 | nav_order: 3 5 | --- 6 | 7 | # Configuration 8 | 9 | --- 10 | 11 | | Key | Default value | Data type | Remarks | 12 | |--------------|----------------|----------------|---------------------------------------| 13 | | name | undefined | String | If you specify a name for the job, Croner will keep a reference to the job in the exported array `scheduledJobs`. The reference will be removed on `.stop()`. | 14 | | maxRuns | Infinite | Number | | 15 | | catch | false | Boolean\|Function | Catch unhandled errors in triggered function. Passing `true` will silently ignore errors. Passing a callback function will trigger this callback on error. | 16 | | timezone | undefined | String | Timezone in Europe/Stockholm format | 17 | | startAt | undefined | String | ISO 8601 formatted datetime (2021-10-17T23:43:00)
in local time (according to timezone parameter if passed) | 18 | | stopAt | undefined | String | ISO 8601 formatted datetime (2021-10-17T23:43:00)
in local time (according to timezone parameter if passed) | 19 | | interval | 0 | Number | Minimum number of seconds between triggers. | 20 | | paused | false | Boolean | If the job should be paused from start. | 21 | | context | undefined | Any | Passed as the second parameter to triggered function | 22 | | legacyMode | true | boolean | Combine day-of-month and day-of-week using true = OR, false = AND | 23 | | unref | false | boolean | Setting this to true unrefs the internal timer, which allows the process to exit even if a cron job is running. | 24 | | utcOffset | undefined | number | Schedule using a specific utc offset in minutes. This does not take care of daylight savings time, you probably want to use option `timezone` instead. | 25 | | protect | undefined | boolean\|Function | Enabled over-run protection. Will block new triggers as long as an old trigger is in progress. Pass either `true` or a callback function to enable | 26 | 27 | > **Warning** 28 | > Unreferencing timers (option `unref`) is only supported by Node.js and Deno. 29 | > Browsers have not yet implemented this feature, and it does not make sense to use it in a browser environment. 30 | { .warning } 31 | -------------------------------------------------------------------------------- /docs/src/usage/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Examples" 3 | parent: "Usage" 4 | nav_order: 3 5 | --- 6 | 7 | # Examples 8 | 9 | --- 10 | 11 | 12 | 13 | {% include multiplex.html %} 14 | 15 | ### Find dates 16 | 17 | ```ts 18 | // Find next month 19 | const nextMonth = new Cron("@monthly").nextRun(), 20 | nextSunday = new Cron("@weekly").nextRun(), 21 | nextSat29feb = new Cron("0 0 0 29 2 6", { legacyMode: false }).nextRun(), 22 | nextSunLastOfMonth = new Cron("0 0 0 L * 7", { legacyMode: false }).nextRun(), 23 | nextLastSundayOfMonth = new Cron("0 0 0 * * L7").nextRun(); 24 | 25 | console.log("First day of next month: " + nextMonth.toLocaleDateString()); 26 | console.log("Next sunday: " + nextSunday.toLocaleDateString()); 27 | console.log("Next saturday at 29th of february: " + nextSat29feb.toLocaleDateString()); // 2048-02-29 28 | console.log("Next month ending with a sunday: " + nextSunLastOfMonth.toLocaleDateString()); 29 | console.log("Next last sunday of month: " + nextLastSundayOfMonth.toLocaleDateString()); 30 | ``` 31 | 32 | ### Job controls 33 | ```ts 34 | const job = new Cron('* * * * * *', (self) => { 35 | console.log('This will run every second. Pause on second 10. Resume on 15. And quit on 20.'); 36 | console.log('Current second: ', new Date().getSeconds()); 37 | console.log('Previous run: ' + self.previousRun()); 38 | console.log('Next run: ' + self.nextRun()); 39 | }); 40 | 41 | new Cron('10 * * * * *', {maxRuns: 1}, () => job.pause()); 42 | new Cron('15 * * * * *', {maxRuns: 1}, () => job.resume()); 43 | new Cron('20 * * * * *', {maxRuns: 1}, () => job.stop()); 44 | ``` 45 | 46 | ### Options 47 | ```ts 48 | import { Cron } from "./dist/croner.js"; 49 | 50 | const job = new Cron( 51 | '* * * * *', 52 | { 53 | startAt: "2023-11-01T00:00:00", 54 | stopAt: "2023-12-01T00:00:00", 55 | timezone: "Europe/Stockholm" 56 | }, 57 | function() { 58 | console.log('This will run every minute, from 2023-11-01 to 2023-12-01 00:00:00'); 59 | } 60 | ); 61 | 62 | console.log('Will run first time at', job.nextRun().toLocaleString()); 63 | ``` 64 | 65 | ### Interval 66 | ```ts 67 | // Trigger on specific interval combined with cron expression 68 | new Cron('* * 7-16 * * MON-FRI', { interval: 90 }, function () { 69 | console.log('This will trigger every 90th second at 7-16 on mondays to fridays.'); 70 | }); 71 | ``` 72 | 73 | ### Passing a context 74 | ```ts 75 | const data = { 76 | what: "stuff" 77 | }; 78 | 79 | new Cron('* * * * * *', { context: data }, (_self, context) => { 80 | console.log('This will print stuff: ' + context.what); 81 | }); 82 | 83 | new Cron('*/5 * * * * *', { context: data }, (self, context) => { 84 | console.log('After this, other stuff will be printed instead'); 85 | context.what = "other stuff"; 86 | self.stop(); 87 | }); 88 | ``` 89 | 90 | ### Fire on a specific date/time 91 | ```ts 92 | // A javascript date, or a ISO 8601 local time string can be passed, to fire a function once. 93 | // Always specify which timezone the ISO 8601 time string has with the timezone option. 94 | let job = new Cron("2025-01-01T23:00:00",{timezone: "Europe/Stockholm"},() => { 95 | console.log('This will run at 2025-01-01 23:00:00 in timezone Europe/Stockholm'); 96 | }); 97 | 98 | if (job.nextRun() === null) { 99 | // The job will not fire for some reason 100 | } else { 101 | console.log("Job will fire at " + job.nextRun()); 102 | } 103 | ``` 104 | 105 | ### Time zone 106 | ```ts 107 | let job = new Cron("0 0 14 * * *", { timezone: "Europe/Stockholm" }, () => { 108 | console.log('This will every day at 14:00 in time zone Europe/Stockholm'); 109 | }); 110 | 111 | 112 | if (job.nextRun() === null) { 113 | // The job will not fire for some reason 114 | } else { 115 | console.log("Job will fire at " + job.nextRun()); 116 | } 117 | ``` 118 | 119 | ### Naming jobs 120 | 121 | If you provide a name for the job using the option { name: '...' }, a reference to the job will be stored in the exported array `scheduledJobs`. Naming a job makes it accessible throughout your application. 122 | 123 | {: .note } 124 | If a job is stopped using `.stop()`, it will be removed from the scheduledJobs array. 125 | 126 | 127 | ```ts 128 | // import { Cron, scheduledJobs } ... 129 | 130 | // Scoped job 131 | (() => { 132 | 133 | // As we specify a name for the job, a reference will be kept in `scheduledJobs` 134 | const job = new Cron("* * * * * *", { name: "Job1" }, function () { 135 | console.log("This will run every second"); 136 | }); 137 | 138 | job.pause(); 139 | console.log("Job paused"); 140 | 141 | })(); 142 | 143 | // Another scope, delayed 5 seconds 144 | setTimeout(() => { 145 | 146 | // Find our job 147 | // - scheduledJobs can either be imported separately { Cron, scheduledJobs } 148 | // or access through Cron.scheduledJobs 149 | const job = scheduledJobs.find(j => j.name === "Job1"); 150 | 151 | // Resume it 152 | if (job) { 153 | if(job.resume()) { 154 | // This will happen 155 | console.log("Job resumed successfully"); 156 | } else { 157 | console.log("Job found, but could not be restarted. This should never happen, as the named jobs is _removed_ when using `.stop()`."); 158 | } 159 | } else { 160 | console.error("Job not found"); 161 | } 162 | 163 | }, 5000); 164 | 165 | ``` 166 | 167 | ### Act at completion 168 | 169 | ```ts 170 | // Start a job firing once each 5th second, run at most 3 times 171 | const job = new Cron("0/5 * * * * *", { maxRuns: 3 }, (job) => { 172 | 173 | // Do work 174 | console.log('Job Running'); 175 | 176 | // Is this the last execution? 177 | if (!job.nextRun()) { 178 | console.log('Last execution'); 179 | } 180 | 181 | }); 182 | 183 | // Will there be no executions? 184 | // This would trigger if you change maxRuns to 0, or manage to compose 185 | // an impossible cron expression. 186 | if (!job.nextRun() && !job.previousRun()) { 187 | console.log('No executions scheduled'); 188 | } 189 | ``` 190 | 191 | ### Error handling 192 | 193 | ```ts 194 | 195 | // Prepare an error handler 196 | const errorHandler = (e) => { 197 | console.error(e); 198 | }; 199 | 200 | // Start a job firing every second 201 | const job = new Cron("* * * * * *", { catch: errorHandler }, (job) => { 202 | console.log('This will print!'); 203 | throw new Error("This will be catched and printed by the error handler"); 204 | console.log('This will not print, but the job will keep on triggering'); 205 | }); 206 | 207 | ``` 208 | 209 | ### Overrun protection 210 | 211 | ```ts 212 | // Demo blocking function 213 | const blockForAWhile = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 214 | 215 | // (Optional) Callback to be triggered on a blocked call 216 | const protectCallback = (job) => console.log(`Call at ${new Date().toISOString()} were blocked by call started at ${job.currentRun().toISOString()}`); 217 | 218 | // protect: can be set to ether true or a callback function, to enable over-run protection 219 | new Cron("* * * * * *", { protect: protectCallback }, async (job) => { 220 | console.log(`Call started at ${job.currentRun().toISOString()} started`); 221 | await blockForAWhile(4000); 222 | console.log(`Call started at ${job.currentRun().toISOString()} finished ${new Date().toISOString()}`); 223 | }); 224 | 225 | /* Output 226 | Call started at 2023-02-16T20:47:07.005Z started 227 | Call at 2023-02-16T20:47:08.014Z were blocked by call started at 2023-02-16T20:47:07.005Z 228 | Call at 2023-02-16T20:47:09.012Z were blocked by call started at 2023-02-16T20:47:07.005Z 229 | Call at 2023-02-16T20:47:10.009Z were blocked by call started at 2023-02-16T20:47:07.005Z 230 | Call at 2023-02-16T20:47:11.007Z were blocked by call started at 2023-02-16T20:47:07.005Z 231 | Call started at 2023-02-16T20:47:07.005Z finished 2023-02-16T20:47:11.039Z 232 | */ 233 | ``` -------------------------------------------------------------------------------- /docs/src/usage/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Usage" 3 | has_children: true 4 | nav_order: 3 5 | --- 6 | 7 | # Usage 8 | 9 | --- 10 | 11 | The most basic usage of Croner for scheduling is: 12 | 13 | ```ts 14 | new Cron("0 12 * * *, () => { 15 | console.log("This will run every day at 12:00"); 16 | }); 17 | ``` 18 | 19 | And the most basic usage of Croner for getting next execution time of a pattern is: 20 | 21 | ```ts 22 | console.log(new Cron("0 12 * * *).next()); 23 | // 2023-07-08T12:00:00 24 | ``` -------------------------------------------------------------------------------- /docs/src/usage/pattern.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Pattern" 3 | parent: "Usage" 4 | nav_order: 2 5 | --- 6 | 7 | # Pattern 8 | 9 | --- 10 | 11 | {% include multiplex.html %} 12 | 13 | The expressions used by Croner are very similar to those of Vixie Cron, but with a few additions and changes as outlined below: 14 | 15 | ```ts 16 | // ┌──────────────── (optional) second (0 - 59) 17 | // │ ┌────────────── minute (0 - 59) 18 | // │ │ ┌──────────── hour (0 - 23) 19 | // │ │ │ ┌────────── day of month (1 - 31) 20 | // │ │ │ │ ┌──────── month (1 - 12, JAN-DEC) 21 | // │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon) 22 | // │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0) 23 | // │ │ │ │ │ │ 24 | // * * * * * * 25 | ``` 26 | 27 | * Croner expressions have the following additional modifiers: 28 | - *?*: The question mark is substituted with the time of initialization. For example, ? ? * * * * would be substituted with 25 8 * * * * if the time is :08:25 at the time of new Cron('? ? * * * *', <...>). The question mark can be used in any field. 29 | - *L*: The letter 'L' can be used in the day of the month field to indicate the last day of the month. When used in the day of the week field in conjunction with the # character, it denotes the last specific weekday of the month. For example, `5#L` represents the last Friday of the month. 30 | - *#*: The # character specifies the "nth" occurrence of a particular day within a month. For example, supplying 31 | `5#2` in the day of week field signifies the second Friday of the month. This can be combined with ranges and supports day names. For instance, MON-FRI#2 would match the Monday through Friday of the second week of the month. 32 | 33 | * Croner allows you to pass a JavaScript Date object or an ISO 8601 formatted string as a pattern. The scheduled function will trigger at the specified date/time and only once. If you use a timezone different from the local timezone, you should pass the ISO 8601 local time in the target location and specify the timezone using the options (2nd parameter). 34 | 35 | * Croner also allows you to change how the day-of-week and day-of-month conditions are combined. By default, Croner (and Vixie cron) will trigger when either the day-of-month OR the day-of-week conditions match. For example, `0 20 1 * MON` will trigger on the first of the month as well as each Monday. If you want to use AND (so that it only triggers on Mondays that are also the first of the month), you can pass `{ legacyMode: false }`. For more information, see issue [#53](https://github.com/Hexagon/croner/issues/53). 36 | 37 | | Field | Required | Allowed values | Allowed special characters | Remarks | 38 | |--------------|----------|----------------|----------------------------|---------------------------------------| 39 | | Seconds | Optional | 0-59 | * , - / ? | | 40 | | Minutes | Yes | 0-59 | * , - / ? | | 41 | | Hours | Yes | 0-23 | * , - / ? | | 42 | | Day of Month | Yes | 1-31 | * , - / ? L | | 43 | | Month | Yes | 1-12 or JAN-DEC| * , - / ? | | 44 | | Day of Week | Yes | 0-7 or SUN-MON | * , - / ? L # | 0 to 6 are Sunday to Saturday
7 is Sunday, the same as 0
# is used to specify nth occurrence of a weekday | 45 | 46 | > Weekday and month names are case-insensitive. Both `MON` and `mon` work. 47 | > When using `L` in the Day of Week field, it affects all specified weekdays. For example, `5-6#L` means the last Friday and Saturday in the month." 48 | > The # character can be used to specify the "nth" weekday of the month. For example, 5#2 represents the second Friday of the month. 49 | { .note } 50 | 51 | It is also possible to use the following "nicknames" as pattern. 52 | 53 | | Nickname | Description | 54 | | -------- | ----------- | 55 | | \@yearly | Run once a year, ie. "0 0 1 1 *". | 56 | | \@annually | Run once a year, ie. "0 0 1 1 *". | 57 | | \@monthly | Run once a month, ie. "0 0 1 * *". | 58 | | \@weekly | Run once a week, ie. "0 0 * * 0". | 59 | | \@daily | Run once a day, ie. "0 0 * * *". | 60 | | \@hourly | Run once an hour, ie. "0 * * * *". | 61 | -------------------------------------------------------------------------------- /src/croner.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file ban-types 2 | /* ------------------------------------------------------------------------------------ 3 | 4 | Croner - MIT License - Hexagon 5 | 6 | Pure JavaScript Isomorphic cron parser and scheduler without dependencies. 7 | 8 | ------------------------------------------------------------------------------------ 9 | 10 | License: 11 | 12 | Copyright (c) 2015-2024 Hexagon 13 | 14 | Permission is hereby granted, free of charge, to any person obtaining a copy 15 | of this software and associated documentation files (the "Software"), to deal 16 | in the Software without restriction, including without limitation the rights 17 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 18 | copies of the Software, and to permit persons to whom the Software is 19 | furnished to do so, subject to the following conditions: 20 | The above copyright notice and this permission notice shall be included in 21 | all copies or substantial portions of the Software. 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 28 | THE SOFTWARE. 29 | 30 | ------------------------------------------------------------------------------------ */ 31 | import { CronDate } from "./date.ts"; 32 | import { CronPattern } from "./pattern.ts"; 33 | import { type CronOptions, CronOptionsHandler } from "./options.ts"; 34 | import { isFunction, unrefTimer } from "./utils.ts"; 35 | 36 | /** 37 | * Many JS engines stores the delay as a 32-bit signed integer internally. 38 | * This causes an integer overflow when using delays larger than 2147483647, 39 | * resulting in the timeout being executed immediately. 40 | * 41 | * All JS engines implements an immediate execution of delays larger that a 32-bit 42 | * int to keep the behaviour concistent. 43 | * 44 | * With this in mind, the absolute maximum value to use is 45 | * 46 | * const maxDelay = Math.pow(2, 32 - 1) - 1 47 | * 48 | * But due to a problem with certain javascript engines not behaving well when the 49 | * computer is suspended, we'll never wait more than 30 seconds between each trigger. 50 | */ 51 | const maxDelay: number = 30 * 1000; 52 | 53 | /** 54 | * An array containing all named cron jobs. 55 | */ 56 | const scheduledJobs: Cron[] = []; 57 | 58 | /** 59 | * Encapsulate all internal states in the Cron instance. 60 | * Duplicate all options that can change to internal states, for example maxRuns and paused. 61 | */ 62 | type CronState = { 63 | kill: boolean; 64 | blocking: boolean; 65 | /** 66 | * Start time of previous trigger, updated after each trigger 67 | * 68 | * Stored to use as the actual previous run, even while a new trigger 69 | * is started. Used by the public funtion `.previousRun()` 70 | */ 71 | previousRun: CronDate | undefined; 72 | /** 73 | * Start time of current trigger, this is updated just before triggering 74 | * 75 | * This is used internally as "previous run", as we mostly want to know 76 | * when the previous run _started_ 77 | */ 78 | currentRun: CronDate | undefined; 79 | once: CronDate | undefined; 80 | //@ts-ignore Cross Runtime 81 | currentTimeout: NodeJS.Timer | number | undefined; 82 | maxRuns: number | undefined; 83 | paused: boolean | undefined; 84 | pattern: CronPattern; 85 | }; 86 | 87 | /** 88 | * Cron entrypoint 89 | * 90 | * @constructor 91 | * @param pattern - Input pattern, input date, or input ISO 8601 time string 92 | * @param [fnOrOptions1] - Options or function to be run each iteration of pattern 93 | * @param [fnOrOptions2] - Options or function to be run each iteration of pattern 94 | */ 95 | class Cron { 96 | name: string | undefined; 97 | options: CronOptions; 98 | private _states: CronState; 99 | private fn?: Function; 100 | constructor( 101 | pattern: string | Date, 102 | fnOrOptions1?: CronOptions | Function, 103 | fnOrOptions2?: CronOptions | Function, 104 | ) { 105 | // Make options and func optional and interchangable 106 | let options, func; 107 | 108 | if (isFunction(fnOrOptions1)) { 109 | func = fnOrOptions1; 110 | } else if (typeof fnOrOptions1 === "object") { 111 | options = fnOrOptions1; 112 | } else if (fnOrOptions1 !== void 0) { 113 | throw new Error( 114 | "Cron: Invalid argument passed for optionsIn. Should be one of function, or object (options).", 115 | ); 116 | } 117 | 118 | if (isFunction(fnOrOptions2)) { 119 | func = fnOrOptions2; 120 | } else if (typeof fnOrOptions2 === "object") { 121 | options = fnOrOptions2; 122 | } else if (fnOrOptions2 !== void 0) { 123 | throw new Error( 124 | "Cron: Invalid argument passed for funcIn. Should be one of function, or object (options).", 125 | ); 126 | } 127 | 128 | this.name = options?.name; 129 | this.options = CronOptionsHandler(options); 130 | 131 | this._states = { 132 | kill: false, 133 | blocking: false, 134 | previousRun: void 0, 135 | currentRun: void 0, 136 | once: void 0, 137 | currentTimeout: void 0, 138 | maxRuns: options ? options.maxRuns : void 0, 139 | paused: options ? options.paused : false, 140 | pattern: new CronPattern("* * * * *"), 141 | }; 142 | 143 | // Check if we got a date, or a pattern supplied as first argument 144 | // Then set either this._states.once or this._states.pattern 145 | if ( 146 | pattern && 147 | (pattern instanceof Date || ((typeof pattern === "string") && pattern.indexOf(":") > 0)) 148 | ) { 149 | this._states.once = new CronDate(pattern, this.options.timezone || this.options.utcOffset); 150 | } else { 151 | this._states.pattern = new CronPattern(pattern as string, this.options.timezone); 152 | } 153 | 154 | // Only store the job in scheduledJobs if a name is specified in the options. 155 | if (this.name) { 156 | const existing = scheduledJobs.find((j) => j.name === this.name); 157 | if (existing) { 158 | throw new Error( 159 | "Cron: Tried to initialize new named job '" + this.name + "', but name already taken.", 160 | ); 161 | } else { 162 | scheduledJobs.push(this); 163 | } 164 | } 165 | 166 | // Allow shorthand scheduling 167 | if (func !== void 0 && isFunction(func)) { 168 | this.fn = func as Function; 169 | this.schedule(); 170 | } 171 | 172 | return this; 173 | } 174 | 175 | /** 176 | * Find next runtime, based on supplied date. Strips milliseconds. 177 | * 178 | * @param prev - Optional. Date to start from. Can be a CronDate, Date object, or a string representing a date. 179 | * @returns The next run time as a Date object, or null if there is no next run. 180 | */ 181 | public nextRun(prev?: CronDate | Date | string | null): Date | null { 182 | const next = this._next(prev); 183 | return next ? next.getDate(false) : null; 184 | } 185 | 186 | /** 187 | * Find next n runs, based on supplied date. Strips milliseconds. 188 | * 189 | * @param n - Number of runs to enumerate 190 | * @param previous - Date to start from 191 | * @returns - Next n run times 192 | */ 193 | public nextRuns(n: number, previous?: Date | string): Date[] { 194 | if (this._states.maxRuns !== undefined && n > this._states.maxRuns) { 195 | n = this._states.maxRuns; 196 | } 197 | const enumeration: Date[] = []; 198 | let prev: CronDate | Date | string | undefined | null = previous || this._states.currentRun || 199 | undefined; 200 | while (n-- && (prev = this.nextRun(prev))) { 201 | enumeration.push(prev); 202 | } 203 | 204 | return enumeration; 205 | } 206 | 207 | /** 208 | * Return the original pattern, if there was one 209 | * 210 | * @returns Original pattern 211 | */ 212 | public getPattern(): string | undefined { 213 | return this._states.pattern ? this._states.pattern.pattern : void 0; 214 | } 215 | 216 | /** 217 | * Indicates whether or not the cron job is scheduled and running, e.g. awaiting next trigger 218 | * 219 | * @returns Running or not 220 | */ 221 | public isRunning(): boolean { 222 | const nextRunTime = this.nextRun(this._states.currentRun); 223 | 224 | const isRunning = !this._states.paused; 225 | const isScheduled = this.fn !== void 0; 226 | // msLeft will be null if _states.kill is set to true, so we don't need to check this one, but we do anyway... 227 | const notIsKilled = !this._states.kill; 228 | 229 | return isRunning && isScheduled && notIsKilled && nextRunTime !== null; 230 | } 231 | 232 | /** 233 | * Indicates whether or not the cron job is permanently stopped 234 | * 235 | * @returns Running or not 236 | */ 237 | public isStopped(): boolean { 238 | return this._states.kill; 239 | } 240 | 241 | /** 242 | * Indicates whether or not the cron job is currently working 243 | * 244 | * @returns Running or not 245 | */ 246 | public isBusy(): boolean { 247 | return this._states.blocking; 248 | } 249 | 250 | /** 251 | * Return current/previous run start time 252 | * 253 | * @returns Current (if running) or previous run time 254 | */ 255 | public currentRun(): Date | null { 256 | return this._states.currentRun ? this._states.currentRun.getDate() : null; 257 | } 258 | 259 | /** 260 | * Return previous run start time 261 | * 262 | * @returns Previous run time 263 | */ 264 | public previousRun(): Date | null { 265 | return this._states.previousRun ? this._states.previousRun.getDate() : null; 266 | } 267 | 268 | /** 269 | * Returns number of milliseconds to next run 270 | * 271 | * @param prev Starting date, defaults to now - minimum interval 272 | */ 273 | public msToNext(prev?: CronDate | Date | string): number | null { 274 | prev = prev || new Date(); 275 | 276 | // Get next run time 277 | const next = this._next(prev); 278 | 279 | if (next) { 280 | if (prev instanceof CronDate || prev instanceof Date) { 281 | return (next.getTime() - prev.getTime()); 282 | } else { 283 | return (next.getTime() - new CronDate(prev).getTime()); 284 | } 285 | } else { 286 | return null; 287 | } 288 | } 289 | 290 | /** 291 | * Stop execution 292 | * 293 | * Running this will forcefully stop the job, and prevent furter exection. `.resume()` will not work after stopping. 294 | * It will also be removed from the scheduledJobs array if it were named. 295 | */ 296 | public stop(): void { 297 | // If there is a job in progress, it will finish gracefully ... 298 | 299 | // Flag as killed 300 | this._states.kill = true; 301 | 302 | // Stop any waiting timer 303 | if (this._states.currentTimeout) { 304 | clearTimeout(this._states.currentTimeout as number); 305 | } 306 | 307 | // Remove job from the scheduledJobs array to free up the name, and allow the job to be 308 | // garbage collected 309 | const jobIndex = scheduledJobs.indexOf(this); 310 | if (jobIndex >= 0) { 311 | scheduledJobs.splice(jobIndex, 1); 312 | } 313 | } 314 | 315 | /** 316 | * Pause execution 317 | * 318 | * @returns Wether pause was successful 319 | */ 320 | public pause(): boolean { 321 | this._states.paused = true; 322 | 323 | return !this._states.kill; 324 | } 325 | 326 | /** 327 | * Resume execution 328 | * 329 | * @returns Wether resume was successful 330 | */ 331 | public resume(): boolean { 332 | this._states.paused = false; 333 | 334 | return !this._states.kill; 335 | } 336 | 337 | /** 338 | * Schedule a new job 339 | * 340 | * @param func - Function to be run each iteration of pattern 341 | */ 342 | public schedule(func?: Function): Cron { 343 | // If a function is already scheduled, bail out 344 | if (func && this.fn) { 345 | throw new Error( 346 | "Cron: It is not allowed to schedule two functions using the same Croner instance.", 347 | ); 348 | 349 | // Update function if passed 350 | } else if (func) { 351 | this.fn = func; 352 | } 353 | 354 | // Get actual ms to next run, bail out early if any of them is null (no next run) 355 | let waitMs = this.msToNext(); 356 | 357 | // Get the target date based on previous run 358 | const target = this.nextRun(this._states.currentRun); 359 | 360 | // isNaN added to prevent infinite loop 361 | if (waitMs === null || waitMs === undefined || isNaN(waitMs) || target === null) return this; 362 | 363 | // setTimeout cant handle more than Math.pow(2, 32 - 1) - 1 ms 364 | if (waitMs > maxDelay) { 365 | waitMs = maxDelay; 366 | } 367 | 368 | // Start the timer loop 369 | // _checkTrigger will either call _trigger (if it's time, croner isn't paused and whatever), 370 | // or recurse back to this function to wait for next trigger 371 | this._states.currentTimeout = setTimeout(() => this._checkTrigger(target), waitMs); 372 | 373 | // If unref option is set - unref the current timeout, which allows the process to exit even if there is a pending schedule 374 | if (this._states.currentTimeout && this.options.unref) { 375 | unrefTimer(this._states.currentTimeout); 376 | } 377 | 378 | return this; 379 | } 380 | 381 | /** 382 | * Internal function to trigger a run, used by both scheduled and manual trigger 383 | */ 384 | private async _trigger(initiationDate?: Date) { 385 | this._states.blocking = true; 386 | 387 | this._states.currentRun = new CronDate( 388 | void 0, // We should use initiationDate, but that does not play well with fake timers in third party tests. In real world there is not much difference though */ 389 | this.options.timezone || this.options.utcOffset, 390 | ); 391 | 392 | if (this.options.catch) { 393 | try { 394 | if (this.fn !== undefined) { 395 | await this.fn(this, this.options.context); 396 | } 397 | } catch (_e) { 398 | if (isFunction(this.options.catch)) { 399 | (this.options.catch as Function)(_e, this); 400 | } 401 | } 402 | } else { 403 | // Trigger the function without catching 404 | if (this.fn !== undefined) { 405 | await this.fn(this, this.options.context); 406 | } 407 | } 408 | 409 | this._states.previousRun = new CronDate( 410 | initiationDate, 411 | this.options.timezone || this.options.utcOffset, 412 | ); 413 | 414 | this._states.blocking = false; 415 | } 416 | 417 | /** 418 | * Trigger a run manually 419 | */ 420 | public async trigger() { 421 | await this._trigger(); 422 | } 423 | 424 | /** 425 | * Returns number of runs left, undefined = unlimited 426 | */ 427 | public runsLeft(): number | undefined { 428 | return this._states.maxRuns; 429 | } 430 | 431 | /** 432 | * Called when it's time to trigger. 433 | * Checks if all conditions are currently met, 434 | * then instantly triggers the scheduled function. 435 | */ 436 | private _checkTrigger(target: Date) { 437 | const now = new Date(), 438 | shouldRun = !this._states.paused && now.getTime() >= target.getTime(), 439 | isBlocked = this._states.blocking && this.options.protect; 440 | 441 | if (shouldRun && !isBlocked) { 442 | if (this._states.maxRuns !== undefined) { 443 | this._states.maxRuns--; 444 | } 445 | 446 | // We do not await this 447 | this._trigger(); 448 | } else { 449 | // If this trigger were blocked, and protect is a function, trigger protect (without awaiting it, even if it's an synchronous function) 450 | if (shouldRun && isBlocked && isFunction(this.options.protect)) { 451 | setTimeout(() => (this.options.protect as Function)(this), 0); 452 | } 453 | } 454 | 455 | // Always reschedule 456 | this.schedule(); 457 | } 458 | 459 | /** 460 | * Internal version of next. Cron needs millseconds internally, hence _next. 461 | */ 462 | private _next(previousRun?: CronDate | Date | string | null) { 463 | let hasPreviousRun = (previousRun || this._states.currentRun) ? true : false; 464 | 465 | // If no previous run, and startAt and interval is set, calculate when the last run should have been 466 | let startAtInFutureWithInterval = false; 467 | if (!previousRun && this.options.startAt && this.options.interval) { 468 | [previousRun, hasPreviousRun] = this._calculatePreviousRun(previousRun, hasPreviousRun); 469 | startAtInFutureWithInterval = (!previousRun) ? true : false; 470 | } 471 | 472 | // Ensure previous run is a CronDate 473 | previousRun = new CronDate(previousRun, this.options.timezone || this.options.utcOffset); 474 | 475 | // Previous run should never be before startAt 476 | if ( 477 | this.options.startAt && previousRun && 478 | previousRun.getTime() < (this.options.startAt as CronDate).getTime() 479 | ) { 480 | previousRun = this.options.startAt; 481 | } 482 | 483 | // Calculate next run according to pattern or one-off timestamp, pass actual previous run to increment 484 | let nextRun: CronDate | null = this._states.once || 485 | new CronDate(previousRun, this.options.timezone || this.options.utcOffset); 486 | 487 | // if the startAt is in the future and the interval is set, then the prev is already set to the startAt, so there is no need to increment it 488 | if (!startAtInFutureWithInterval && nextRun !== this._states.once) { 489 | nextRun = nextRun.increment( 490 | this._states.pattern, 491 | this.options, 492 | hasPreviousRun, // hasPreviousRun is used to allow 493 | ); 494 | } 495 | 496 | if (this._states.once && this._states.once.getTime() <= (previousRun as CronDate).getTime()) { 497 | return null; 498 | } else if ( 499 | (nextRun === null) || 500 | (this._states.maxRuns !== undefined && this._states.maxRuns <= 0) || 501 | (this._states.kill) || 502 | (this.options.stopAt && nextRun.getTime() >= (this.options.stopAt as CronDate).getTime()) 503 | ) { 504 | return null; 505 | } else { 506 | // All seem good, return next run 507 | return nextRun; 508 | } 509 | } 510 | /** 511 | * Calculate the previous run if no previous run is supplied, but startAt and interval are set. 512 | * This calculation is only necessary if the startAt time is before the current time. 513 | * Should only be called from the _next function. 514 | */ 515 | private _calculatePreviousRun( 516 | prev: CronDate | Date | string | undefined | null, 517 | hasPreviousRun: boolean, 518 | ): [CronDate | undefined, boolean] { 519 | const now = new CronDate(undefined, this.options.timezone || this.options.utcOffset); 520 | let newPrev: CronDate | undefined | null = prev as CronDate; 521 | if ((this.options.startAt as CronDate).getTime() <= now.getTime()) { 522 | newPrev = this.options.startAt as CronDate; 523 | let prevTimePlusInterval = (newPrev as CronDate).getTime() + this.options.interval! * 1000; 524 | while (prevTimePlusInterval <= now.getTime()) { 525 | newPrev = new CronDate(newPrev, this.options.timezone || this.options.utcOffset).increment( 526 | this._states.pattern, 527 | this.options, 528 | true, 529 | ); 530 | prevTimePlusInterval = (newPrev as CronDate).getTime() + this.options.interval! * 1000; 531 | } 532 | hasPreviousRun = true; 533 | } 534 | if (newPrev === null) { 535 | newPrev = undefined; 536 | } 537 | return [newPrev, hasPreviousRun]; 538 | } 539 | } 540 | 541 | export { Cron, CronDate, type CronOptions, CronPattern, scheduledJobs }; 542 | -------------------------------------------------------------------------------- /src/date.ts: -------------------------------------------------------------------------------- 1 | import { minitz } from "./helpers/minitz.ts"; 2 | 3 | import type { CronOptions as CronOptions } from "./options.ts"; 4 | import { 5 | ANY_OCCURRENCE, 6 | type CronPattern, 7 | LAST_OCCURRENCE, 8 | OCCURRENCE_BITMASKS, 9 | } from "./pattern.ts"; 10 | 11 | /** 12 | * Constant defining the minimum number of days per month where index 0 = January etc. 13 | * 14 | * Used to look if a date _could be_ out of bounds. The "could be" part is why february is pinned to 28 days. 15 | * 16 | * @private 17 | * 18 | * @constant 19 | * @type {Number[]} 20 | */ 21 | const DaysOfMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 22 | 23 | /** 24 | * Array of work to be done, consisting of subarrays described below: 25 | * 26 | * [ 27 | * First item is which member to process, 28 | * Second item is which member to increment if we didn't find a mathch in current item, 29 | * Third item is an offset. if months is handled 0-11 in js date object, and we get 1-12 from `this.minute` 30 | * from pattern. Offset should be -1 31 | * ] 32 | */ 33 | type RecursionTarget = "month" | "day" | "hour" | "minute" | "second"; 34 | type RecursionTargetNext = RecursionTarget | "year"; 35 | type RecursionStep = [RecursionTarget, RecursionTargetNext, number]; 36 | const RecursionSteps: RecursionStep[] = [ 37 | ["month", "year", 0], 38 | ["day", "month", -1], 39 | ["hour", "day", 0], 40 | ["minute", "hour", 0], 41 | ["second", "minute", 0], 42 | ]; 43 | 44 | /** 45 | * Converts date to CronDate 46 | * 47 | * @param d Input date, if using string representation ISO 8001 (2015-11-24T19:40:00) local timezone is expected 48 | * @param tz String representation of target timezone in Europe/Stockholm format, or a number representing offset in minutes. 49 | */ 50 | class CronDate { 51 | tz: string | number | undefined; 52 | 53 | /** 54 | * Current milliseconds 55 | * @type {number} 56 | */ 57 | ms!: number; 58 | 59 | /** 60 | * Current second (0-59), in local time or target timezone specified by `this.tz` 61 | * @type {number} 62 | */ 63 | second!: number; 64 | 65 | /** 66 | * Current minute (0-59), in local time or target timezone specified by `this.tz` 67 | * @type {number} 68 | */ 69 | minute!: number; 70 | 71 | /** 72 | * Current hour (0-23), in local time or target timezone specified by `this.tz` 73 | * @type {number} 74 | */ 75 | hour!: number; 76 | 77 | /** 78 | * Current day (1-31), in local time or target timezone specified by `this.tz` 79 | * @type {number} 80 | */ 81 | day!: number; 82 | 83 | /** 84 | * Current month (1-12), in local time or target timezone specified by `this.tz` 85 | * @type {number} 86 | */ 87 | month!: number; 88 | /** 89 | * Current full year, in local time or target timezone specified by `this.tz` 90 | */ 91 | year!: number; 92 | 93 | constructor(d?: CronDate | Date | string | null, tz?: string | number) { 94 | /** 95 | * TimeZone 96 | * @type {string|number|undefined} 97 | */ 98 | this.tz = tz; 99 | 100 | // Populate object using input date, or throw 101 | if (d && d instanceof Date) { 102 | if (!isNaN(d as unknown as number)) { 103 | this.fromDate(d); 104 | } else { 105 | throw new TypeError("CronDate: Invalid date passed to CronDate constructor"); 106 | } 107 | } else if (d === void 0) { 108 | this.fromDate(new Date()); 109 | } else if (d && typeof d === "string") { 110 | this.fromString(d); 111 | } else if (d instanceof CronDate) { 112 | this.fromCronDate(d); 113 | } else { 114 | throw new TypeError( 115 | "CronDate: Invalid type (" + typeof d + ") passed to CronDate constructor", 116 | ); 117 | } 118 | } 119 | 120 | /** 121 | * Check if the given date is the nth occurrence of a weekday in its month. 122 | * 123 | * @param year The year. 124 | * @param month The month (0 for January, 11 for December). 125 | * @param day The day of the month. 126 | * @param nth The nth occurrence (bitmask). 127 | * 128 | * @return True if the date is the nth occurrence of its weekday, false otherwise. 129 | */ 130 | private isNthWeekdayOfMonth(year: number, month: number, day: number, nth: number): boolean { 131 | const date = new Date(Date.UTC(year, month, day)); 132 | const weekday = date.getUTCDay(); 133 | 134 | // Count occurrences of the weekday up to and including the current date 135 | let count = 0; 136 | for (let d = 1; d <= day; d++) { 137 | if (new Date(Date.UTC(year, month, d)).getUTCDay() === weekday) { 138 | count++; 139 | } 140 | } 141 | 142 | // Check for nth occurrence 143 | if (nth & ANY_OCCURRENCE && OCCURRENCE_BITMASKS[count - 1] & nth) { 144 | return true; 145 | } 146 | 147 | // Check for last occurrence 148 | if (nth & LAST_OCCURRENCE) { 149 | const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); 150 | for (let d = day + 1; d <= daysInMonth; d++) { 151 | if (new Date(Date.UTC(year, month, d)).getUTCDay() === weekday) { 152 | return false; // There's another occurrence of the same weekday later in the month 153 | } 154 | } 155 | return true; // The current date is the last occurrence of the weekday in the month 156 | } 157 | 158 | return false; 159 | } 160 | 161 | /** 162 | * Sets internals using a Date 163 | */ 164 | private fromDate(inDate: Date) { 165 | /* If this instance of CronDate has a target timezone set, 166 | * use minitz to convert input date object to target timezone 167 | * before extracting hours, minutes, seconds etc. 168 | * 169 | * If not, extract all parts from inDate as-is. 170 | */ 171 | if (this.tz !== void 0) { 172 | if (typeof this.tz === "number") { 173 | this.ms = inDate.getUTCMilliseconds(); 174 | this.second = inDate.getUTCSeconds(); 175 | this.minute = inDate.getUTCMinutes() + this.tz; 176 | this.hour = inDate.getUTCHours(); 177 | this.day = inDate.getUTCDate(); 178 | this.month = inDate.getUTCMonth(); 179 | this.year = inDate.getUTCFullYear(); 180 | // Minute could be out of bounds, apply 181 | this.apply(); 182 | } else { 183 | const d = minitz.toTZ(inDate, this.tz); 184 | this.ms = inDate.getMilliseconds(); 185 | this.second = d.s; 186 | this.minute = d.i; 187 | this.hour = d.h; 188 | this.day = d.d; 189 | this.month = d.m - 1; 190 | this.year = d.y; 191 | } 192 | } else { 193 | this.ms = inDate.getMilliseconds(); 194 | this.second = inDate.getSeconds(); 195 | this.minute = inDate.getMinutes(); 196 | this.hour = inDate.getHours(); 197 | this.day = inDate.getDate(); 198 | this.month = inDate.getMonth(); 199 | this.year = inDate.getFullYear(); 200 | } 201 | } 202 | 203 | /** 204 | * Sets internals by deep copying another CronDate 205 | * 206 | * @param {CronDate} d - Input date 207 | */ 208 | private fromCronDate(d: CronDate) { 209 | this.tz = d.tz; 210 | this.year = d.year; 211 | this.month = d.month; 212 | this.day = d.day; 213 | this.hour = d.hour; 214 | this.minute = d.minute; 215 | this.second = d.second; 216 | this.ms = d.ms; 217 | } 218 | 219 | /** 220 | * Reset internal parameters (seconds, minutes, hours) if any of them have exceeded (or could have exceeded) their normal ranges 221 | * 222 | * Will alway return true on february 29th, as that is a date that _could_ be out of bounds 223 | */ 224 | private apply() { 225 | // If any value could be out of bounds, apply 226 | if ( 227 | this.month > 11 || this.day > DaysOfMonth[this.month] || this.hour > 59 || this.minute > 59 || 228 | this.second > 59 || this.hour < 0 || this.minute < 0 || this.second < 0 229 | ) { 230 | const d = new Date( 231 | Date.UTC(this.year, this.month, this.day, this.hour, this.minute, this.second, this.ms), 232 | ); 233 | this.ms = d.getUTCMilliseconds(); 234 | this.second = d.getUTCSeconds(); 235 | this.minute = d.getUTCMinutes(); 236 | this.hour = d.getUTCHours(); 237 | this.day = d.getUTCDate(); 238 | this.month = d.getUTCMonth(); 239 | this.year = d.getUTCFullYear(); 240 | return true; 241 | } else { 242 | return false; 243 | } 244 | } 245 | 246 | /** 247 | * Sets internals by parsing a string 248 | */ 249 | private fromString(str: string) { 250 | if (typeof this.tz === "number") { 251 | // Parse without timezone 252 | const inDate = minitz.fromTZISO(str); 253 | this.ms = inDate.getUTCMilliseconds(); 254 | this.second = inDate.getUTCSeconds(); 255 | this.minute = inDate.getUTCMinutes(); 256 | this.hour = inDate.getUTCHours(); 257 | this.day = inDate.getUTCDate(); 258 | this.month = inDate.getUTCMonth(); 259 | this.year = inDate.getUTCFullYear(); 260 | this.apply(); 261 | } else { 262 | return this.fromDate(minitz.fromTZISO(str, this.tz)); 263 | } 264 | } 265 | 266 | /** 267 | * Find next match of current part 268 | */ 269 | private findNext( 270 | options: CronOptions, 271 | target: RecursionTarget, 272 | pattern: CronPattern, 273 | offset: number, 274 | ): number { 275 | const originalTarget = this[target]; 276 | 277 | // In the conditions below, local time is not relevant. And as new Date(Date.UTC(y,m,d)) is way faster 278 | // than new Date(y,m,d). We use the UTC functions to set/get date parts. 279 | 280 | // Pre-calculate last day of month if needed 281 | let lastDayOfMonth; 282 | if (pattern.lastDayOfMonth) { 283 | // This is an optimization for every month except february, which has different number of days different years 284 | if (this.month !== 1) { 285 | lastDayOfMonth = DaysOfMonth[this.month]; // About 20% performance increase when using L 286 | } else { 287 | lastDayOfMonth = new Date(Date.UTC(this.year, this.month + 1, 0, 0, 0, 0, 0)).getUTCDate(); 288 | } 289 | } 290 | 291 | // Pre-calculate weekday if needed 292 | // Calculate offset weekday by ((fDomWeekDay + (targetDate - 1)) % 7) 293 | const fDomWeekDay = (!pattern.starDOW && target == "day") 294 | ? new Date(Date.UTC(this.year, this.month, 1, 0, 0, 0, 0)).getUTCDay() 295 | : undefined; 296 | 297 | for (let i = this[target] + offset; i < pattern[target].length; i++) { 298 | // this applies to all "levels" 299 | let match: number = pattern[target][i]; 300 | 301 | // Special case for last day of month 302 | if (target === "day" && pattern.lastDayOfMonth && i - offset == lastDayOfMonth) { 303 | match = 1; 304 | } 305 | 306 | // Special case for day of week 307 | if (target === "day" && !pattern.starDOW) { 308 | let dowMatch = pattern.dayOfWeek[(fDomWeekDay! + ((i - offset) - 1)) % 7]; 309 | 310 | // Extra check for nth weekday of month 311 | // 0b011111 === All occurences of weekday in month 312 | // 0b100000 === Last occurence of weekday in month 313 | if (dowMatch && (dowMatch & ANY_OCCURRENCE)) { 314 | dowMatch = this.isNthWeekdayOfMonth(this.year, this.month, i - offset, dowMatch) ? 1 : 0; 315 | } else if (dowMatch) { 316 | throw new Error(`CronDate: Invalid value for dayOfWeek encountered. ${dowMatch}`); 317 | } 318 | 319 | // If we use legacyMode, and dayOfMonth is specified - use "OR" to combine day of week with day of month 320 | // In all other cases use "AND" 321 | if (options.legacyMode && !pattern.starDOM) { 322 | match = match || dowMatch; 323 | } else { 324 | match = match && dowMatch; 325 | } 326 | } 327 | 328 | if (match) { 329 | this[target] = i - offset; 330 | 331 | // Return 2 if changed, 1 if unchanged 332 | return (originalTarget !== this[target]) ? 2 : 1; 333 | } 334 | } 335 | 336 | // Return 3 if part was not matched 337 | return 3; 338 | } 339 | 340 | /** 341 | * Increment to next run time recursively. 342 | * 343 | * This function traverses the date components (year, month, day, hour, minute, second) 344 | * to find the next date and time that matches the cron pattern. It uses a recursive 345 | * approach to handle the dependencies between different components. For example, 346 | * if the day changes, the hour, minute, and second need to be reset. 347 | * 348 | * The recursion is currently limited to the year 3000 to prevent potential 349 | * infinite loops or excessive stack depth. If you need to schedule beyond 350 | * the year 3000, please open an issue on GitHub to discuss possible solutions. 351 | * 352 | * @param pattern The cron pattern used to determine the next run time. 353 | * @param options The cron options that influence the incrementing behavior. 354 | * @param doing The index of the `RecursionSteps` array indicating the current 355 | * date component being processed. 0 represents "month", 1 represents "day", etc. 356 | * 357 | * @returns This `CronDate` instance for chaining, or null if incrementing 358 | * was not possible (e.g., reached year 3000 limit or no matching date). 359 | * 360 | * @private 361 | */ 362 | private recurse(pattern: CronPattern, options: CronOptions, doing: number): CronDate | null { 363 | // Find next month (or whichever part we're at) 364 | const res = this.findNext(options, RecursionSteps[doing][0], pattern, RecursionSteps[doing][2]); 365 | 366 | // Month (or whichever part we're at) changed 367 | if (res > 1) { 368 | // Flag following levels for reset 369 | let resetLevel = doing + 1; 370 | while (resetLevel < RecursionSteps.length) { 371 | this[RecursionSteps[resetLevel][0]] = -RecursionSteps[resetLevel][2]; 372 | resetLevel++; 373 | } 374 | // Parent changed 375 | if (res === 3) { 376 | // Do increment parent, and reset current level 377 | this[RecursionSteps[doing][1]]++; 378 | this[RecursionSteps[doing][0]] = -RecursionSteps[doing][2]; 379 | this.apply(); 380 | 381 | // Restart 382 | return this.recurse(pattern, options, 0); 383 | } else if (this.apply()) { 384 | return this.recurse(pattern, options, doing - 1); 385 | } 386 | } 387 | 388 | // Move to next level 389 | doing += 1; 390 | 391 | // Done? 392 | if (doing >= RecursionSteps.length) { 393 | return this; 394 | 395 | // ... or out of bounds ? 396 | } else if (this.year >= 3000) { 397 | return null; 398 | 399 | // ... oh, go to next part then 400 | } else { 401 | return this.recurse(pattern, options, doing); 402 | } 403 | } 404 | 405 | /** 406 | * Increment to next run time 407 | * 408 | * @param pattern The pattern used to increment the current date. 409 | * @param options Cron options used for incrementing. 410 | * @param hasPreviousRun True if there was a previous run, false otherwise. This is used to determine whether to apply the minimum interval. 411 | * @returns This CronDate instance for chaining, or null if incrementing was not possible (e.g., reached year 3000 limit). 412 | */ 413 | public increment( 414 | pattern: CronPattern, 415 | options: CronOptions, 416 | hasPreviousRun: boolean, 417 | ): CronDate | null { 418 | // Move to next second, or increment according to minimum interval indicated by option `interval: x` 419 | // Do not increment a full interval if this is the very first run 420 | this.second += (options.interval !== undefined && options.interval > 1 && hasPreviousRun) 421 | ? options.interval 422 | : 1; 423 | 424 | // Always reset milliseconds, so we are at the next second exactly 425 | this.ms = 0; 426 | 427 | // Make sure seconds has not gotten out of bounds 428 | this.apply(); 429 | 430 | // Recursively change each part (y, m, d ...) until next match is found, return null on failure 431 | return this.recurse(pattern, options, 0); 432 | } 433 | 434 | /** 435 | * Convert current state back to a javascript Date() 436 | * 437 | * @param internal If this is an internal call 438 | */ 439 | public getDate(internal?: boolean): Date { 440 | // If this is an internal call, return the date as is 441 | // Also use this option when no timezone or utcOffset is set 442 | if (internal || this.tz === void 0) { 443 | return new Date( 444 | this.year, 445 | this.month, 446 | this.day, 447 | this.hour, 448 | this.minute, 449 | this.second, 450 | this.ms, 451 | ); 452 | } else { 453 | // If .tz is a number, it indicates offset in minutes. UTC timestamp of the internal date objects will be off by the same number of minutes. 454 | // Restore this, and return a date object with correct time set. 455 | if (typeof this.tz === "number") { 456 | return new Date( 457 | Date.UTC( 458 | this.year, 459 | this.month, 460 | this.day, 461 | this.hour, 462 | this.minute - this.tz, 463 | this.second, 464 | this.ms, 465 | ), 466 | ); 467 | 468 | // If .tz is something else (hopefully a string), it indicates the timezone of the "local time" of the internal date object 469 | // Use minitz to create a normal Date object, and return that. 470 | } else { 471 | return minitz.fromTZ( 472 | minitz.tp( 473 | this.year, 474 | this.month + 1, 475 | this.day, 476 | this.hour, 477 | this.minute, 478 | this.second, 479 | this.tz, 480 | ), 481 | false, 482 | ); 483 | } 484 | } 485 | } 486 | 487 | /** 488 | * Convert current state back to a javascript Date() and return UTC milliseconds 489 | */ 490 | public getTime(): number { 491 | return this.getDate(false).getTime(); 492 | } 493 | } 494 | 495 | export { CronDate }; 496 | -------------------------------------------------------------------------------- /src/helpers/minitz.ts: -------------------------------------------------------------------------------- 1 | /* ------------------------------------------------------------------------------------ 2 | 3 | minitz - MIT License - Hexagon 4 | 5 | Version 5.0.0 6 | 7 | ------------------------------------------------------------------------------------ 8 | 9 | License: 10 | 11 | Copyright (c) 2022 Hexagon 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | The above copyright notice and this permission notice shall be included in 20 | all copies or substantial portions of the Software. 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | 29 | ------------------------------------------------------------------------------------ */ 30 | 31 | interface TimePoint { 32 | y: number; // 1970-- 33 | m: number; // 1-12 34 | d: number; // 1-31 35 | h: number; // 0-24 36 | i: number; // 0-60 Minute 37 | s: number; // 0-60 38 | tz?: string; // Time zone in IANA database format 'Europe/Stockholm' 39 | } 40 | 41 | /** 42 | * Converts a date/time from a specific timezone to a normal date object using the system local time 43 | * 44 | * Shortcut for minitz.fromTZ(minitz.tp(...)); 45 | * 46 | * @constructor 47 | * 48 | * @param {Number} y - 1970-- 49 | * @param {Number} m - 1-12 50 | * @param {Number} d - 1-31 51 | * @param {Number} h - 0-24 52 | * @param {Number} i - 0-60 Minute 53 | * @param {Number} s - 0-60 54 | * @param {string} tz - Time zone in IANA database format 'Europe/Stockholm' 55 | * @param {boolean} [throwOnInvalid] - Default is to return the adjusted time if the call happens during a Daylight-Saving-Time switch. 56 | * E.g. Value "01:01:01" is returned if input time is 00:01:01 while one hour got actually 57 | * skipped, going from 23:59:59 to 01:00:00. Setting this flag makes the library throw an exception instead. 58 | * @returns {date} - Normal date object with correct UTC and system local time 59 | */ 60 | function minitz( 61 | y: number, 62 | m: number, 63 | d: number, 64 | h: number, 65 | i: number, 66 | s: number, 67 | tz: string, 68 | throwOnInvalid?: boolean, 69 | ) { 70 | return minitz.fromTZ(minitz.tp(y, m, d, h, i, s, tz), throwOnInvalid); 71 | } 72 | 73 | /** 74 | * Converts a date/time from a specific timezone to a normal date object using the system local time 75 | * 76 | * @public 77 | * @static 78 | * 79 | * @param {string} localTimeStr - ISO8601 formatted local time string, non UTC 80 | * @param {string} tz - Time zone in IANA database format 'Europe/Stockholm' 81 | * @param {boolean} [throwOnInvalid] - Default is to return the adjusted time if the call happens during a Daylight-Saving-Time switch. 82 | * E.g. Value "01:01:01" is returned if input time is 00:01:01 while one hour got actually 83 | * skipped, going from 23:59:59 to 01:00:00. Setting this flag makes the library throw an exception instead. 84 | * @return {date} - Normal date object 85 | */ 86 | minitz.fromTZISO = (localTimeStr: string, tz?: string, throwOnInvalid?: boolean) => { 87 | return minitz.fromTZ(parseISOLocal(localTimeStr, tz), throwOnInvalid); 88 | }; 89 | 90 | /** 91 | * Converts a date/time from a specific timezone to a normal date object using the system local time 92 | * 93 | * @public 94 | * @static 95 | * 96 | * @param {TimePoint} tp - Object with specified timezone 97 | * @param {boolean} [throwOnInvalid] - Default is to return the adjusted time if the call happens during a Daylight-Saving-Time switch. 98 | * E.g. Value "01:01:01" is returned if input time is 00:01:01 while one hour got actually 99 | * skipped, going from 23:59:59 to 01:00:00. Setting this flag makes the library throw an exception instead. 100 | * @returns {date} - Normal date object 101 | */ 102 | minitz.fromTZ = function (tp: TimePoint, throwOnInvalid?: boolean) { 103 | const // Construct a fake Date object with UTC date/time set to local date/time in source timezone 104 | inDate = new Date(Date.UTC( 105 | tp.y, 106 | tp.m - 1, 107 | tp.d, 108 | tp.h, 109 | tp.i, 110 | tp.s, 111 | )), 112 | // Get offset between UTC and source timezone 113 | offset = getTimezoneOffset(tp.tz, inDate), 114 | // Remove offset from inDate to hopefully get a true date object 115 | dateGuess = new Date(inDate.getTime() - offset), 116 | // Get offset between UTC and guessed time in target timezone 117 | dateOffsGuess = getTimezoneOffset(tp.tz, dateGuess); 118 | 119 | // If offset between guessed true date object and UTC matches initial calculation, the guess 120 | // was spot on 121 | if ((dateOffsGuess - offset) === 0) { 122 | return dateGuess; 123 | } else { 124 | // Not quite there yet, make a second try on guessing the local time, adjust by the offset indicated by the previous guess 125 | // Try recreating input time again 126 | // Then calculate and check the offset again 127 | const dateGuess2 = new Date(inDate.getTime() - dateOffsGuess), 128 | dateOffsGuess2 = getTimezoneOffset(tp.tz, dateGuess2); 129 | if ((dateOffsGuess2 - dateOffsGuess) === 0) { 130 | // All good, return local time 131 | return dateGuess2; 132 | } else if (!throwOnInvalid && (dateOffsGuess2 - dateOffsGuess) > 0) { 133 | // We're most probably dealing with a DST transition where we should use the offset of the second guess 134 | return dateGuess2; 135 | } else if (!throwOnInvalid) { 136 | // We're most probably dealing with a DST transition where we should use the offset of the initial guess 137 | return dateGuess; 138 | } else { 139 | // Input time is invalid, and the library is instructed to throw, so let's do it 140 | throw new Error("Invalid date passed to fromTZ()"); 141 | } 142 | } 143 | }; 144 | 145 | /** 146 | * Converts a date to a specific time zone and returns an object containing year, month, 147 | * day, hour, (...) and timezone used for the conversion 148 | * 149 | * **Please note**: If you just want to _display_ date/time in another 150 | * time zone, use vanilla JS. See the example below. 151 | * 152 | * @public 153 | * @static 154 | * 155 | * @param {d} date - Input date 156 | * @param {string} [tzStr] - Timezone string in Europe/Stockholm format 157 | * 158 | * @returns {TimePoint} 159 | * 160 | * @example Example using minitz: 161 | * let normalDate = new Date(); // d is a normal Date instance, with local timezone and correct utc representation 162 | * 163 | * tzDate = minitz.toTZ(d, 'America/New_York'); 164 | * 165 | * // Will result in the following object: 166 | * // { 167 | * // y: 2022, 168 | * // m: 9, 169 | * // d: 28, 170 | * // h: 13, 171 | * // i: 28, 172 | * // s: 28, 173 | * // tz: "America/New_York" 174 | * // } 175 | * 176 | * @example Example using vanilla js: 177 | * console.log( 178 | * // Display current time in America/New_York, using sv-SE locale 179 | * new Date().toLocaleTimeString("sv-SE", { timeZone: "America/New_York" }), 180 | * ); 181 | */ 182 | minitz.toTZ = function (d: Date, tzStr: string) { 183 | // - replace narrow no break space with regular space to compensate for bug in Node.js 19.1 184 | const localDateString = d.toLocaleString("en-US", { timeZone: tzStr }).replace(/[\u202f]/, " "); 185 | 186 | const td = new Date(localDateString); 187 | return { 188 | y: td.getFullYear(), 189 | m: td.getMonth() + 1, 190 | d: td.getDate(), 191 | h: td.getHours(), 192 | i: td.getMinutes(), 193 | s: td.getSeconds(), 194 | tz: tzStr, 195 | }; 196 | }; 197 | 198 | /** 199 | * Convenience function which returns a TimePoint object for later use in fromTZ 200 | * 201 | * @public 202 | * @static 203 | * 204 | * @param {Number} y - 1970-- 205 | * @param {Number} m - 1-12 206 | * @param {Number} d - 1-31 207 | * @param {Number} h - 0-24 208 | * @param {Number} i - 0-60 Minute 209 | * @param {Number} s - 0-60 210 | * @param {string} tz - Time zone in format 'Europe/Stockholm' 211 | * 212 | * @returns {TimePoint} 213 | */ 214 | minitz.tp = (y: number, m: number, d: number, h: number, i: number, s: number, tz?: string) => { 215 | return { y, m, d, h, i, s, tz: tz }; 216 | }; 217 | 218 | /** 219 | * Helper function that returns the current UTC offset (in ms) for a specific timezone at a specific point in time 220 | * 221 | * @private 222 | * 223 | * @param {timeZone} string - Target time zone in IANA database format 'Europe/Stockholm' 224 | * @param {date} [date] - Point in time to use as base for offset calculation 225 | * 226 | * @returns {number} - Offset in ms between UTC and timeZone 227 | */ 228 | function getTimezoneOffset(timeZone?: string, date = new Date()) { 229 | // Get timezone 230 | const tz = 231 | date.toLocaleString("en-US", { timeZone: timeZone, timeZoneName: "shortOffset" }).split(" ") 232 | .slice(-1)[0]; 233 | 234 | // Extract time in en-US format 235 | // - replace narrow no break space with regular space to compensate for bug in Node.js 19.1 236 | const dateString = date.toLocaleString("en-US").replace(/[\u202f]/, " "); 237 | 238 | // Check ms offset between GMT and extracted timezone 239 | return Date.parse(`${dateString} GMT`) - Date.parse(`${dateString} ${tz}`); 240 | } 241 | 242 | /** 243 | * Helper function that takes a ISO8001 local date time string and creates a Date object. 244 | * Throws on failure. Throws on invalid date or time. 245 | * 246 | * @private 247 | * 248 | * @param {string} dtStr - an ISO 8601 format date and time string 249 | * with all components, e.g. 2015-11-24T19:40:00 250 | * @returns {TimePoint} - TimePoint instance from parsing the string 251 | */ 252 | function parseISOLocal(dtStr: string, tz?: string) { 253 | // Parse date using built in Date.parse 254 | const pd = new Date(Date.parse(dtStr)); 255 | 256 | // Check for completeness 257 | if (isNaN(pd as unknown as number)) { 258 | throw new Error("minitz: Invalid ISO8601 passed to parser."); 259 | } 260 | 261 | // If 262 | // * date/time is specified in UTC (Z-flag included) 263 | // * or UTC offset is specified (+ or - included after character 9 (20200101 or 2020-01-0)) 264 | // Return time in utc, else return local time and include timezone identifier 265 | const stringEnd = dtStr.substring(9); 266 | if (dtStr.includes("Z") || stringEnd.includes("-") || stringEnd.includes("+")) { 267 | return minitz.tp( 268 | pd.getUTCFullYear(), 269 | pd.getUTCMonth() + 1, 270 | pd.getUTCDate(), 271 | pd.getUTCHours(), 272 | pd.getUTCMinutes(), 273 | pd.getUTCSeconds(), 274 | "Etc/UTC", 275 | ); 276 | } else { 277 | return minitz.tp( 278 | pd.getFullYear(), 279 | pd.getMonth() + 1, 280 | pd.getDate(), 281 | pd.getHours(), 282 | pd.getMinutes(), 283 | pd.getSeconds(), 284 | tz, 285 | ); 286 | } 287 | // Treat date as local time, in target timezone 288 | } 289 | 290 | minitz.minitz = minitz; 291 | 292 | export default minitz; 293 | export { minitz }; 294 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { CronDate } from "./date.ts"; 2 | import type { Cron } from "./croner.ts"; 3 | 4 | type CatchCallbackFn = (e: unknown, job: Cron) => void; 5 | type ProtectCallbackFn = (job: Cron) => void; 6 | 7 | /** 8 | * Options for configuring cron jobs. 9 | * 10 | * @interface 11 | */ 12 | interface CronOptions { 13 | /** 14 | * The name of the cron job. If provided, the job will be added to the 15 | * `scheduledJobs` array, allowing it to be accessed by name. 16 | */ 17 | name?: string; 18 | 19 | /** 20 | * If true, the job will be paused initially. 21 | * @default false 22 | */ 23 | paused?: boolean; 24 | 25 | /** 26 | * If true, the job will be stopped permanently. 27 | * @default false 28 | */ 29 | kill?: boolean; 30 | 31 | /** 32 | * If true, errors thrown by the job function will be caught. 33 | * If a function is provided, it will be called with the error and the job instance. 34 | * @default false 35 | */ 36 | catch?: boolean | CatchCallbackFn; 37 | 38 | /** 39 | * If true, the underlying timer will be unreferenced, allowing the Node.js 40 | * process to exit even if the job is still running. 41 | * @default false 42 | */ 43 | unref?: boolean; 44 | 45 | /** 46 | * The maximum number of times the job will run. 47 | * @default Infinity 48 | */ 49 | maxRuns?: number; 50 | 51 | /** 52 | * The minimum interval between job executions, in seconds. 53 | * @default 1 54 | */ 55 | interval?: number; 56 | 57 | /** 58 | * If true, prevents the job from running if the previous execution is still in progress. 59 | * If a function is provided, it will be called if the job is blocked. 60 | * @default false 61 | */ 62 | protect?: boolean | ProtectCallbackFn; 63 | 64 | /** 65 | * The date and time at which the job should start running. 66 | */ 67 | startAt?: string | Date | CronDate; 68 | 69 | /** 70 | * The date and time at which the job should stop running. 71 | */ 72 | stopAt?: string | Date | CronDate; 73 | 74 | /** 75 | * The timezone for the cron job. 76 | */ 77 | timezone?: string; 78 | 79 | /** 80 | * The UTC offset for the cron job, in minutes. 81 | */ 82 | utcOffset?: number; 83 | 84 | /** 85 | * If true, enables legacy mode for compatibility with older cron implementations. 86 | * @default true 87 | */ 88 | legacyMode?: boolean; 89 | 90 | /** 91 | * An optional context object that will be passed to the job function. 92 | */ 93 | context?: unknown; 94 | } 95 | 96 | /** 97 | * Processes and validates cron options. 98 | * 99 | * @param options The cron options to handle. 100 | * @returns The processed and validated cron options. 101 | * @throws {Error} If any of the options are invalid. 102 | */ 103 | function CronOptionsHandler(options?: CronOptions): CronOptions { 104 | if (options === void 0) { 105 | options = {}; 106 | } 107 | 108 | delete options.name; 109 | 110 | options.legacyMode = options.legacyMode === void 0 ? true : options.legacyMode; 111 | options.paused = options.paused === void 0 ? false : options.paused; 112 | options.maxRuns = options.maxRuns === void 0 ? Infinity : options.maxRuns; 113 | options.catch = options.catch === void 0 ? false : options.catch; 114 | options.interval = options.interval === void 0 ? 0 : parseInt(options.interval.toString(), 10); 115 | options.utcOffset = options.utcOffset === void 0 116 | ? void 0 117 | : parseInt(options.utcOffset.toString(), 10); 118 | options.unref = options.unref === void 0 ? false : options.unref; 119 | 120 | if (options.startAt) { 121 | options.startAt = new CronDate(options.startAt, options.timezone); 122 | } 123 | if (options.stopAt) { 124 | options.stopAt = new CronDate(options.stopAt, options.timezone); 125 | } 126 | 127 | if (options.interval !== null) { 128 | if (isNaN(options.interval)) { 129 | throw new Error("CronOptions: Supplied value for interval is not a number"); 130 | } else if (options.interval < 0) { 131 | throw new Error("CronOptions: Supplied value for interval can not be negative"); 132 | } 133 | } 134 | 135 | if (options.utcOffset !== void 0) { 136 | if (isNaN(options.utcOffset)) { 137 | throw new Error( 138 | "CronOptions: Invalid value passed for utcOffset, should be number representing minutes offset from UTC.", 139 | ); 140 | } else if (options.utcOffset < -870 || options.utcOffset > 870) { 141 | throw new Error("CronOptions: utcOffset out of bounds."); 142 | } 143 | 144 | if (options.utcOffset !== void 0 && options.timezone) { 145 | throw new Error("CronOptions: Combining 'utcOffset' with 'timezone' is not allowed."); 146 | } 147 | } 148 | 149 | if (options.unref !== true && options.unref !== false) { 150 | throw new Error("CronOptions: Unref should be either true, false or undefined(false)."); 151 | } 152 | 153 | return options; 154 | } 155 | 156 | export { type CronOptions, CronOptionsHandler }; 157 | -------------------------------------------------------------------------------- /src/pattern.ts: -------------------------------------------------------------------------------- 1 | import { CronDate } from "./date.ts"; 2 | 3 | /** 4 | * Name for each part of the cron pattern 5 | */ 6 | type CronPatternPart = "second" | "minute" | "hour" | "day" | "month" | "dayOfWeek"; 7 | 8 | /** 9 | * Offset, 0 or -1. 10 | * 11 | * 0 offset is used for seconds, minutes, and hours as they start on 1. 12 | * -1 on days and months, as they start on 0. 13 | */ 14 | type CronIndexOffset = number; 15 | 16 | /** 17 | * Constants to represent different occurrences of a weekday in its month. 18 | * - `LAST_OCCURRENCE`: The last occurrence of a weekday. 19 | * - `ANY_OCCURRENCE`: Combines all individual weekday occurrence bitmasks, including the last. 20 | * - `OCCURRENCE_BITMASKS`: An array of bitmasks, with each index representing the respective occurrence of a weekday (0-indexed). 21 | */ 22 | export const LAST_OCCURRENCE = 0b100000; 23 | export const ANY_OCCURRENCE = 0b00001 | 0b00010 | 0b00100 | 0b01000 | 0b10000 | LAST_OCCURRENCE; 24 | export const OCCURRENCE_BITMASKS = [0b00001, 0b00010, 0b00100, 0b01000, 0b10000]; 25 | 26 | /** 27 | * Create a CronPattern instance from pattern string ('* * * * * *') 28 | * @constructor 29 | * @param {string} pattern - Input pattern 30 | * @param {string} timezone - Input timezone, used for '?'-substitution 31 | */ 32 | class CronPattern { 33 | pattern: string; 34 | timezone?: string; 35 | second: number[]; 36 | minute: number[]; 37 | hour: number[]; 38 | day: number[]; 39 | month: number[]; 40 | dayOfWeek: number[]; 41 | lastDayOfMonth: boolean; 42 | starDOM: boolean; 43 | starDOW: boolean; 44 | 45 | constructor(pattern: string, timezone?: string) { 46 | this.pattern = pattern; 47 | this.timezone = timezone; 48 | 49 | this.second = Array(60).fill(0); // 0-59 50 | this.minute = Array(60).fill(0); // 0-59 51 | this.hour = Array(24).fill(0); // 0-23 52 | this.day = Array(31).fill(0); // 0-30 in array, 1-31 in config 53 | this.month = Array(12).fill(0); // 0-11 in array, 1-12 in config 54 | this.dayOfWeek = Array(7).fill(0); // 0-7 Where 0 = Sunday and 7=Sunday; Value is a bitmask 55 | 56 | this.lastDayOfMonth = false; 57 | 58 | this.starDOM = false; // Asterisk used for dayOfMonth 59 | this.starDOW = false; // Asterisk used for dayOfWeek 60 | 61 | this.parse(); 62 | } 63 | 64 | /** 65 | * Parse current pattern, will throw on any type of failure 66 | * @private 67 | */ 68 | 69 | private parse(): void { 70 | // Sanity check 71 | //@ts-ignore string check 72 | if (!(typeof this.pattern === "string" || this.pattern instanceof String)) { 73 | throw new TypeError("CronPattern: Pattern has to be of type string."); 74 | } 75 | 76 | // Handle @yearly, @monthly etc 77 | if (this.pattern.indexOf("@") >= 0) this.pattern = this.handleNicknames(this.pattern).trim(); 78 | 79 | // Split configuration on whitespace 80 | const parts = this.pattern.replace(/\s+/g, " ").split(" "); 81 | 82 | // Validite number of configuration entries 83 | if (parts.length < 5 || parts.length > 6) { 84 | throw new TypeError( 85 | "CronPattern: invalid configuration format ('" + this.pattern + 86 | "'), exactly five or six space separated parts are required.", 87 | ); 88 | } 89 | 90 | // If seconds is omitted, insert 0 for seconds 91 | if (parts.length === 5) { 92 | parts.unshift("0"); 93 | } 94 | 95 | // Convert 'L' to lastDayOfMonth flag in day-of-month field 96 | if (parts[3].indexOf("L") >= 0) { 97 | parts[3] = parts[3].replace("L", ""); 98 | this.lastDayOfMonth = true; 99 | } 100 | 101 | // Check for starDOM 102 | if (parts[3] == "*") { 103 | this.starDOM = true; 104 | } 105 | 106 | // Replace alpha representations 107 | if (parts[4].length >= 3) parts[4] = this.replaceAlphaMonths(parts[4]); 108 | if (parts[5].length >= 3) parts[5] = this.replaceAlphaDays(parts[5]); 109 | 110 | // Check for starDOW 111 | if (parts[5] == "*") { 112 | this.starDOW = true; 113 | } 114 | 115 | // Implement '?' in the simplest possible way - replace ? with current value, before further processing 116 | if (this.pattern.indexOf("?") >= 0) { 117 | const initDate = new CronDate(new Date(), this.timezone).getDate(true); 118 | parts[0] = parts[0].replace("?", initDate.getSeconds().toString()); 119 | parts[1] = parts[1].replace("?", initDate.getMinutes().toString()); 120 | parts[2] = parts[2].replace("?", initDate.getHours().toString()); 121 | if (!this.starDOM) parts[3] = parts[3].replace("?", initDate.getDate().toString()); 122 | parts[4] = parts[4].replace("?", (initDate.getMonth() + 1).toString()); // getMonth is zero indexed while pattern starts from 1 123 | if (!this.starDOW) parts[5] = parts[5].replace("?", initDate.getDay().toString()); 124 | } 125 | 126 | // Check part content 127 | this.throwAtIllegalCharacters(parts); 128 | 129 | // Parse parts into arrays, validates as we go 130 | this.partToArray("second", parts[0], 0, 1); 131 | this.partToArray("minute", parts[1], 0, 1); 132 | this.partToArray("hour", parts[2], 0, 1); 133 | this.partToArray("day", parts[3], -1, 1); 134 | this.partToArray("month", parts[4], -1, 1); 135 | this.partToArray("dayOfWeek", parts[5], 0, ANY_OCCURRENCE); 136 | 137 | // 0 = Sunday, 7 = Sunday 138 | if (this.dayOfWeek[7]) { 139 | this.dayOfWeek[0] = this.dayOfWeek[7]; 140 | } 141 | } 142 | 143 | /** 144 | * Convert current part (seconds/minutes etc) to an array of 1 or 0 depending on if the part is about to trigger a run or not. 145 | */ 146 | private partToArray( 147 | type: CronPatternPart, 148 | conf: string, 149 | valueIndexOffset: CronIndexOffset, 150 | defaultValue: number, 151 | ) { 152 | const arr = this[type]; 153 | 154 | // Error on empty part 155 | const lastDayOfMonth = type === "day" && this.lastDayOfMonth; 156 | if (conf === "" && !lastDayOfMonth) { 157 | throw new TypeError( 158 | "CronPattern: configuration entry " + type + " (" + conf + 159 | ") is empty, check for trailing spaces.", 160 | ); 161 | } 162 | 163 | // First off, handle wildcard 164 | if (conf === "*") return arr.fill(defaultValue); 165 | 166 | // Handle separated entries (,) by recursion 167 | const split = conf.split(","); 168 | if (split.length > 1) { 169 | for (let i = 0; i < split.length; i++) { 170 | this.partToArray(type, split[i], valueIndexOffset, defaultValue); 171 | } 172 | 173 | // Handle range with stepping (x-y/z) 174 | } else if (conf.indexOf("-") !== -1 && conf.indexOf("/") !== -1) { 175 | this.handleRangeWithStepping(conf, type, valueIndexOffset, defaultValue); 176 | 177 | // Handle range 178 | } else if (conf.indexOf("-") !== -1) { 179 | this.handleRange(conf, type, valueIndexOffset, defaultValue); 180 | 181 | // Handle stepping 182 | } else if (conf.indexOf("/") !== -1) { 183 | this.handleStepping(conf, type, valueIndexOffset, defaultValue); 184 | 185 | // Anything left should be a number 186 | } else if (conf !== "") { 187 | this.handleNumber(conf, type, valueIndexOffset, defaultValue); 188 | } 189 | } 190 | 191 | /** 192 | * After converting JAN-DEC, SUN-SAT only 0-9 * , / - are allowed, throw if anything else pops up 193 | * @throws On error 194 | */ 195 | private throwAtIllegalCharacters(parts: string[]) { 196 | for (let i = 0; i < parts.length; i++) { 197 | const reValidCron = i === 5 ? /[^/*0-9,\-#L]+/ : /[^/*0-9,-]+/; 198 | if (reValidCron.test(parts[i])) { 199 | throw new TypeError( 200 | "CronPattern: configuration entry " + i + " (" + parts[i] + 201 | ") contains illegal characters.", 202 | ); 203 | } 204 | } 205 | } 206 | 207 | /** 208 | * Nothing but a number left, handle that 209 | * 210 | * @param conf Current part, expected to be a number, as a string 211 | * @param type One of "seconds", "minutes" etc 212 | * @param valueIndexOffset -1 for day of month, and month, as they start at 1. 0 for seconds, hours, minutes 213 | */ 214 | private handleNumber( 215 | conf: string, 216 | type: CronPatternPart, 217 | valueIndexOffset: number, 218 | defaultValue: number, 219 | ) { 220 | const result = this.extractNth(conf, type); 221 | 222 | const i = parseInt(result[0], 10) + valueIndexOffset; 223 | 224 | if (isNaN(i)) { 225 | throw new TypeError("CronPattern: " + type + " is not a number: '" + conf + "'"); 226 | } 227 | 228 | this.setPart(type, i, result[1] || defaultValue); 229 | } 230 | 231 | /** 232 | * Set a specific value for a specific part of the CronPattern. 233 | * 234 | * @param part The specific part of the CronPattern, e.g., "second", "minute", etc. 235 | * @param index The index to modify. 236 | * @param value The value to set, typically 0 or 1, in case of "nth weekday" it will be the weekday number used for further processing 237 | */ 238 | private setPart(part: CronPatternPart, index: number, value: number | string) { 239 | // Ensure the part exists in our CronPattern. 240 | if (!Object.prototype.hasOwnProperty.call(this, part)) { 241 | throw new TypeError("CronPattern: Invalid part specified: " + part); 242 | } 243 | 244 | // Special handling for dayOfWeek 245 | if (part === "dayOfWeek") { 246 | // SUN can both be 7 and 0, normalize to 0 here 247 | if (index === 7) index = 0; 248 | if (index < 0 || index > 6) { 249 | throw new RangeError("CronPattern: Invalid value for dayOfWeek: " + index); 250 | } 251 | this.setNthWeekdayOfMonth(index, value); 252 | return; 253 | } 254 | 255 | // Validate the value for the specified part. 256 | if (part === "second" || part === "minute") { 257 | if (index < 0 || index >= 60) { 258 | throw new RangeError("CronPattern: Invalid value for " + part + ": " + index); 259 | } 260 | } else if (part === "hour") { 261 | if (index < 0 || index >= 24) { 262 | throw new RangeError("CronPattern: Invalid value for " + part + ": " + index); 263 | } 264 | } else if (part === "day") { 265 | if (index < 0 || index >= 31) { 266 | throw new RangeError("CronPattern: Invalid value for " + part + ": " + index); 267 | } 268 | } else if (part === "month") { 269 | if (index < 0 || index >= 12) { 270 | throw new RangeError("CronPattern: Invalid value for " + part + ": " + index); 271 | } 272 | } 273 | 274 | // Set the value for the specific part and index. 275 | this[part][index] = value as number; 276 | } 277 | 278 | /** 279 | * Take care of ranges with stepping (e.g. 3-23/5) 280 | * 281 | * @param conf Current part, expected to be a string like 3-23/5 282 | * @param type One of "seconds", "minutes" etc 283 | * @param valueIndexOffset -1 for day of month, and month, as they start at 1. 0 for seconds, hours, minutes 284 | */ 285 | private handleRangeWithStepping( 286 | conf: string, 287 | type: CronPatternPart, 288 | valueIndexOffset: number, 289 | defaultValue: number, 290 | ) { 291 | const result = this.extractNth(conf, type); 292 | 293 | const matches = result[0].match(/^(\d+)-(\d+)\/(\d+)$/); 294 | 295 | if (matches === null) { 296 | throw new TypeError("CronPattern: Syntax error, illegal range with stepping: '" + conf + "'"); 297 | } 298 | 299 | const [, lowerMatch, upperMatch, stepMatch] = matches; 300 | 301 | const lower = parseInt(lowerMatch, 10) + valueIndexOffset; 302 | const upper = parseInt(upperMatch, 10) + valueIndexOffset; 303 | const steps = parseInt(stepMatch, 10); 304 | 305 | if (isNaN(lower)) throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)"); 306 | if (isNaN(upper)) throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)"); 307 | if (isNaN(steps)) throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)"); 308 | 309 | if (steps === 0) throw new TypeError("CronPattern: Syntax error, illegal stepping: 0"); 310 | if (steps > this[type].length) { 311 | throw new TypeError( 312 | "CronPattern: Syntax error, steps cannot be greater than maximum value of part (" + 313 | this[type].length + ")", 314 | ); 315 | } 316 | 317 | if (lower > upper) { 318 | throw new TypeError("CronPattern: From value is larger than to value: '" + conf + "'"); 319 | } 320 | 321 | for (let i = lower; i <= upper; i += steps) { 322 | this.setPart(type, i, result[1] || defaultValue); 323 | } 324 | } 325 | 326 | /* 327 | * Break out nth weekday (#) if exists 328 | * - only allow if type os dayOfWeek 329 | */ 330 | private extractNth(conf: string, type: string): [string, string | undefined] { 331 | let rest = conf; 332 | let nth; 333 | if (rest.includes("#")) { 334 | if (type !== "dayOfWeek") { 335 | throw new Error("CronPattern: nth (#) only allowed in day-of-week field"); 336 | } 337 | nth = rest.split("#")[1]; 338 | rest = rest.split("#")[0]; 339 | } 340 | return [rest, nth]; 341 | } 342 | 343 | /** 344 | * Take care of ranges (e.g. 1-20) 345 | * 346 | * @param conf - Current part, expected to be a string like 1-20, can contain L for last 347 | * @param type - One of "seconds", "minutes" etc 348 | * @param valueIndexOffset - -1 for day of month, and month, as they start at 1. 0 for seconds, hours, minutes 349 | */ 350 | private handleRange( 351 | conf: string, 352 | type: CronPatternPart, 353 | valueIndexOffset: number, 354 | defaultValue: number, 355 | ) { 356 | const result = this.extractNth(conf, type); 357 | 358 | const split = result[0].split("-"); 359 | 360 | if (split.length !== 2) { 361 | throw new TypeError("CronPattern: Syntax error, illegal range: '" + conf + "'"); 362 | } 363 | 364 | const lower = parseInt(split[0], 10) + valueIndexOffset, 365 | upper = parseInt(split[1], 10) + valueIndexOffset; 366 | 367 | if (isNaN(lower)) { 368 | throw new TypeError("CronPattern: Syntax error, illegal lower range (NaN)"); 369 | } else if (isNaN(upper)) { 370 | throw new TypeError("CronPattern: Syntax error, illegal upper range (NaN)"); 371 | } 372 | 373 | // 374 | if (lower > upper) { 375 | throw new TypeError("CronPattern: From value is larger than to value: '" + conf + "'"); 376 | } 377 | 378 | for (let i = lower; i <= upper; i++) { 379 | this.setPart(type, i, result[1] || defaultValue); 380 | } 381 | } 382 | 383 | /** 384 | * Handle stepping (e.g. * / 14) 385 | * 386 | * @param conf Current part, expected to be a string like * /20 (without the space) 387 | * @param type One of "seconds", "minutes" etc 388 | */ 389 | private handleStepping( 390 | conf: string, 391 | type: CronPatternPart, 392 | valueIndexOffset: number, 393 | defaultValue: number, 394 | ) { 395 | const result = this.extractNth(conf, type); 396 | 397 | const split = result[0].split("/"); 398 | 399 | if (split.length !== 2) { 400 | throw new TypeError("CronPattern: Syntax error, illegal stepping: '" + conf + "'"); 401 | } 402 | 403 | // Inject missing asterisk (/3 insted of */3) 404 | if (split[0] === "") { 405 | split[0] = "*"; 406 | } 407 | 408 | let start = 0; 409 | if (split[0] !== "*") { 410 | start = parseInt(split[0], 10) + valueIndexOffset; 411 | } 412 | 413 | const steps = parseInt(split[1], 10); 414 | 415 | if (isNaN(steps)) throw new TypeError("CronPattern: Syntax error, illegal stepping: (NaN)"); 416 | if (steps === 0) throw new TypeError("CronPattern: Syntax error, illegal stepping: 0"); 417 | if (steps > this[type].length) { 418 | throw new TypeError( 419 | "CronPattern: Syntax error, max steps for part is (" + this[type].length + ")", 420 | ); 421 | } 422 | 423 | for (let i = start; i < this[type].length; i += steps) { 424 | this.setPart(type, i, result[1] || defaultValue); 425 | } 426 | } 427 | 428 | /** 429 | * Replace day name with day numbers 430 | * 431 | * @param conf Current part, expected to be a string that might contain sun,mon etc. 432 | * 433 | * @returns Conf with 0 instead of sun etc. 434 | */ 435 | private replaceAlphaDays(conf: string): string { 436 | return conf 437 | .replace(/-sun/gi, "-7") // choose 7 if sunday is the upper value of a range because the upper value must not be smaller than the lower value 438 | .replace(/sun/gi, "0") 439 | .replace(/mon/gi, "1") 440 | .replace(/tue/gi, "2") 441 | .replace(/wed/gi, "3") 442 | .replace(/thu/gi, "4") 443 | .replace(/fri/gi, "5") 444 | .replace(/sat/gi, "6"); 445 | } 446 | 447 | /** 448 | * Replace month name with month numbers 449 | * 450 | * @param conf Current part, expected to be a string that might contain jan,feb etc. 451 | * 452 | * @returns conf with 0 instead of sun etc. 453 | */ 454 | private replaceAlphaMonths(conf: string): string { 455 | return conf 456 | .replace(/jan/gi, "1") 457 | .replace(/feb/gi, "2") 458 | .replace(/mar/gi, "3") 459 | .replace(/apr/gi, "4") 460 | .replace(/may/gi, "5") 461 | .replace(/jun/gi, "6") 462 | .replace(/jul/gi, "7") 463 | .replace(/aug/gi, "8") 464 | .replace(/sep/gi, "9") 465 | .replace(/oct/gi, "10") 466 | .replace(/nov/gi, "11") 467 | .replace(/dec/gi, "12"); 468 | } 469 | 470 | /** 471 | * Replace nicknames with actual cron patterns 472 | * 473 | * @param pattern Pattern, may contain nicknames, or not 474 | * 475 | * @returns Pattern, with cron expression insted of nicknames 476 | */ 477 | private handleNicknames(pattern: string): string { 478 | // Replace textual representations of pattern 479 | const cleanPattern = pattern.trim().toLowerCase(); 480 | if (cleanPattern === "@yearly" || cleanPattern === "@annually") { 481 | return "0 0 1 1 *"; 482 | } else if (cleanPattern === "@monthly") { 483 | return "0 0 1 * *"; 484 | } else if (cleanPattern === "@weekly") { 485 | return "0 0 * * 0"; 486 | } else if (cleanPattern === "@daily") { 487 | return "0 0 * * *"; 488 | } else if (cleanPattern === "@hourly") { 489 | return "0 * * * *"; 490 | } else { 491 | return pattern; 492 | } 493 | } 494 | 495 | /** 496 | * Handle the nth weekday of the month logic using hash sign (e.g. FRI#2 for the second Friday of the month) 497 | * 498 | * @param index Weekday, example: 5 for friday 499 | * @param nthWeekday bitmask, 2 (0x00010) for 2nd friday, 31 (ANY_OCCURRENCE, 0b100000) for any day 500 | */ 501 | private setNthWeekdayOfMonth(index: number, nthWeekday: number | string) { 502 | if (typeof nthWeekday !== "number" && nthWeekday === "L") { 503 | this["dayOfWeek"][index] = this["dayOfWeek"][index] | LAST_OCCURRENCE; 504 | } else if (nthWeekday === ANY_OCCURRENCE) { 505 | this["dayOfWeek"][index] = ANY_OCCURRENCE; 506 | } else if (nthWeekday as number < 6 && nthWeekday as number > 0) { 507 | this["dayOfWeek"][index] = this["dayOfWeek"][index] | 508 | OCCURRENCE_BITMASKS[nthWeekday as number - 1]; 509 | } else { 510 | throw new TypeError( 511 | `CronPattern: nth weekday out of range, should be 1-5 or L. Value: ${nthWeekday}, Type: ${typeof nthWeekday}`, 512 | ); 513 | } 514 | } 515 | } 516 | 517 | export { CronPattern }; 518 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to check if a variable is a function. 3 | * 4 | * @param v The variable to check. 5 | * @returns True if the variable is a function, false otherwise. 6 | * @private 7 | */ 8 | function isFunction(v: unknown) { 9 | return ( 10 | Object.prototype.toString.call(v) === "[object Function]" || 11 | "function" === typeof v || 12 | v instanceof Function 13 | ); 14 | } 15 | 16 | /** 17 | * Helper function to unref a timer in both Deno and Node.js. 18 | * 19 | * @param timer The timer to unref. 20 | * @private 21 | */ 22 | //@ts-ignore Cross Runtime 23 | function unrefTimer(timer: NodeJS.Timeout | number) { 24 | //@ts-ignore Cross Runtime 25 | if (typeof Deno !== "undefined" && typeof Deno.unrefTimer !== "undefined") { 26 | //@ts-ignore Cross Runtime 27 | Deno.unrefTimer(timer as number); 28 | //@ts-ignore Cross Runtime 29 | } else if (timer && typeof (timer as NodeJS.Timeout).unref !== "undefined") { 30 | //@ts-ignore Cross Runtime 31 | (timer as NodeJS.Timeout).unref(); 32 | } 33 | } 34 | 35 | export { isFunction, unrefTimer }; 36 | -------------------------------------------------------------------------------- /test/options.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "@std/assert"; 2 | import { test } from "@cross/test"; 3 | import { Cron } from "../src/croner.ts"; 4 | 5 | test("name should be undefined if it's not specified", function () { 6 | const scheduler = new Cron("* * * * * *"); 7 | assertEquals(scheduler.name, undefined); 8 | }); 9 | 10 | test("name should be defined if it's specified", function () { 11 | const uniqueName = "TestJob5" + new Date().getTime().toString(); 12 | const scheduler = new Cron("* * * * * *", { name: uniqueName }); 13 | assertEquals(scheduler.name, uniqueName); 14 | }); 15 | 16 | test("Valid startAt with DateTime string should not throw", function () { 17 | let scheduler = new Cron("0 0 12 * * *", { startAt: "2016-12-01 00:00:00" }); 18 | scheduler.nextRun(); 19 | }); 20 | 21 | test("startAt with Date string should not throw (treated like local 00:00:00)", function () { 22 | let scheduler = new Cron("0 0 12 * * *", { startAt: "2016-12-01" }); 23 | scheduler.nextRun(); 24 | }); 25 | 26 | test("Invalid startat should throw", function () { 27 | assertThrows(() => { 28 | let scheduler = new Cron("0 0 12 * * *", { startAt: "hellu throw" }); 29 | scheduler.nextRun(); 30 | }); 31 | }); 32 | 33 | test("startAt with time only should throw", function () { 34 | assertThrows(() => { 35 | let scheduler = new Cron("0 0 12 * * *", { startAt: "00:35:00" }); 36 | scheduler.nextRun(); 37 | }); 38 | }); 39 | 40 | test("Valid stopAt with Date should not throw", function () { 41 | let dayBefore = new Date(new Date().getTime() - 24 * 60 * 60 * 1000), // Subtract one day 42 | scheduler = new Cron("0 0 12 * * *", { stopAt: dayBefore }); 43 | scheduler.nextRun(); 44 | }); 45 | 46 | test("Valid stopAt with DateTime string should not throw", function () { 47 | let scheduler = new Cron("0 0 12 * * *", { stopAt: "2016-12-01 00:00:00" }); 48 | scheduler.nextRun(); 49 | }); 50 | 51 | test("Valid stopAt with Date string should not throw", function () { 52 | let scheduler = new Cron("0 0 12 * * *", { stopAt: "2016-12-01" }); 53 | scheduler.nextRun(); 54 | }); 55 | 56 | test("Invalid stopAt should throw", function () { 57 | assertThrows(() => { 58 | let scheduler = new Cron("0 0 12 * * *", { stopAt: "hellu throw" }); 59 | scheduler.nextRun(); 60 | }); 61 | }); 62 | 63 | test("Invalid unref should throw", function () { 64 | assertThrows(() => { 65 | //@ts-ignore Invalid option 66 | let scheduler = new Cron("0 0 12 * * *", { unref: "hellu throw" }); 67 | scheduler.nextRun(); 68 | }); 69 | }); 70 | test("Valid unref should not throw", function () { 71 | let scheduler = new Cron("0 0 12 * * *", { unref: true }); 72 | scheduler.nextRun(); 73 | }); 74 | test("Setting unref to true should work", function () { 75 | let scheduler = new Cron("0 0 12 * * *", { unref: true }, () => {}); 76 | scheduler.nextRun(); 77 | scheduler.stop(); 78 | assertEquals(scheduler.options.unref, true); 79 | }); 80 | test("Undefined unref should set unref to false", function () { 81 | let scheduler = new Cron("0 0 12 * * *"); 82 | scheduler.nextRun(); 83 | assertEquals(scheduler.options.unref, false); 84 | }); 85 | test("Valid utc offset should not throw", function () { 86 | new Cron("0 0 12 * * *", { utcOffset: -120 }); 87 | }); 88 | test("Invalid utc offset should throw", function () { 89 | assertThrows(() => { 90 | //@ts-ignore Invalid option is expected 91 | new Cron("0 0 12 * * *", { utcOffset: "hello" }); 92 | }); 93 | }); 94 | test("Out of bounds utc offset should throw", function () { 95 | assertThrows(() => { 96 | new Cron("0 0 12 * * *", { utcOffset: 3000 }); 97 | }); 98 | }); 99 | test("Combining utcOffset with timezone should throw", function () { 100 | assertThrows(() => { 101 | new Cron("0 0 12 * * *", { utcOffset: 60, timezone: "Europe/Stockholm" }); 102 | }); 103 | }); 104 | test("stopAt with time only should throw", function () { 105 | assertThrows(() => { 106 | let scheduler = new Cron("0 0 12 * * *", { stopAt: "00:35:00" }); 107 | scheduler.nextRun(); 108 | }); 109 | }); 110 | test("0 0 0 * * * with startdate yesterday should return tomorrow, at 12:00:00", function () { 111 | let dayBefore = new Date(new Date().getTime() - 24 * 60 * 60 * 1000), // Subtract one day 112 | nextDay = new Date(new Date().getTime() + 24 * 60 * 60 * 1000), // Add two days 113 | scheduler, 114 | nextRun; 115 | 116 | // Set a fixed hour later than startAt, to be sure that the days doesn't overlap 117 | dayBefore = new Date(dayBefore.setHours(0)); 118 | nextDay = new Date(nextDay.setHours(0)); 119 | 120 | scheduler = new Cron("0 0 0 * * *", { startAt: dayBefore }); 121 | nextRun = scheduler.nextRun(); 122 | 123 | // Set seconds, minutes and hours to 00:00:00 124 | nextDay.setMilliseconds(0); 125 | nextDay.setSeconds(0); 126 | nextDay.setMinutes(0); 127 | nextDay.setHours(0); 128 | 129 | // Do comparison 130 | assertEquals(nextRun?.getTime(), nextDay.getTime()); 131 | }); 132 | 133 | test("0 0 12 * * * with stopdate yesterday should return null", function () { 134 | let dayBefore = new Date(new Date().getTime() - 24 * 60 * 60 * 1000), // Subtract one day 135 | scheduler = new Cron("0 0 12 * * *", { 136 | timezone: "Etc/UTC", 137 | stopAt: dayBefore.toISOString(), 138 | }), 139 | nextRun = scheduler.nextRun(); 140 | 141 | // Do comparison 142 | assertEquals(nextRun, null); 143 | }); 144 | 145 | test("Invalid interval should throw", function () { 146 | assertThrows(() => { 147 | //@ts-ignore Invalid option is expected 148 | new Cron("* * * * * *", { interval: "a" }).nextRuns(3, "2022-02-17T00:00:00"); 149 | }); 150 | }); 151 | 152 | test("Negative interval should throw", function () { 153 | assertThrows(() => { 154 | //@ts-ignore Invalid option is expected 155 | new Cron("* * * * * *", { interval: "-1" }).nextRuns(3, "2022-02-17T00:00:00"); 156 | }); 157 | }); 158 | 159 | test("Positive string interval should not throw", function () { 160 | //@ts-ignore Invalid option is expected 161 | new Cron("* * * * * *", { interval: "102" }).nextRuns(3, "2022-02-17T00:00:00"); 162 | }); 163 | 164 | test("Valid interval should give correct run times", function () { 165 | let nextRuns = new Cron("0,30 * * * * *", { interval: 90 }).nextRuns(3, "2022-02-16T00:00:00"); 166 | 167 | assertEquals(nextRuns[0].getFullYear(), 2022); 168 | assertEquals(nextRuns[0].getMonth(), 1); 169 | assertEquals(nextRuns[0].getDate(), 16); 170 | assertEquals(nextRuns[0].getHours(), 0); 171 | assertEquals(nextRuns[0].getMinutes(), 1); 172 | assertEquals(nextRuns[0].getSeconds(), 30); 173 | assertEquals(nextRuns[1].getHours(), 0); 174 | assertEquals(nextRuns[1].getMinutes(), 3); 175 | assertEquals(nextRuns[1].getSeconds(), 0); 176 | }); 177 | 178 | test("The number of run times returned by enumerate() should not be more than maxRuns", function () { 179 | let nextRuns = new Cron("* * * * * *", { maxRuns: 5 }).nextRuns(10); 180 | 181 | assertEquals(nextRuns.length, 5); 182 | }); 183 | 184 | test("Valid interval starting in the past should give correct start date", function () { 185 | const now = new Date(); 186 | 187 | const yesterday = new Date(now); 188 | yesterday.setDate(now.getDate() - 1); 189 | yesterday.setHours(19, 31, 2); 190 | 191 | const sixDaysFromNow = new Date(now); 192 | sixDaysFromNow.setDate(now.getDate() + 6); 193 | sixDaysFromNow.setHours(19, 31, 2); 194 | 195 | const nextRun = new Cron("* * * * * *", { 196 | interval: 60 * 60 * 24 * 7, 197 | startAt: yesterday.toISOString(), 198 | }).nextRun(); 199 | 200 | assertEquals(nextRun?.getFullYear(), sixDaysFromNow.getFullYear()); 201 | assertEquals(nextRun?.getMonth(), sixDaysFromNow.getMonth()); 202 | assertEquals(nextRun?.getDate(), sixDaysFromNow.getDate()); 203 | assertEquals(nextRun?.getHours(), sixDaysFromNow.getHours()); 204 | assertEquals(nextRun?.getMinutes(), sixDaysFromNow.getMinutes()); 205 | assertEquals(nextRun?.getSeconds(), sixDaysFromNow.getSeconds()); 206 | }); 207 | 208 | test("Valid interval starting in the future should give correct start date", function () { 209 | const now = new Date(); 210 | 211 | const tomorrow = new Date(now); 212 | tomorrow.setDate(now.getDate() + 1); 213 | tomorrow.setHours(0, 31, 2); 214 | 215 | const nextRun = new Cron("* * * * * *", { 216 | interval: 60 * 60 * 24 * 7, 217 | startAt: tomorrow.toISOString(), 218 | }).nextRun(); 219 | 220 | assertEquals(nextRun?.getFullYear(), tomorrow.getFullYear()); 221 | assertEquals(nextRun?.getMonth(), tomorrow.getMonth()); 222 | assertEquals(nextRun?.getDate(), tomorrow.getDate()); 223 | assertEquals(nextRun?.getHours(), tomorrow.getHours()); 224 | assertEquals(nextRun?.getMinutes(), tomorrow.getMinutes()); 225 | assertEquals(nextRun?.getSeconds(), tomorrow.getSeconds()); 226 | }); 227 | -------------------------------------------------------------------------------- /test/pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "@std/assert"; 2 | import { test } from "@cross/test"; 3 | import { Cron } from "../src/croner.ts"; 4 | 5 | test("Stepping without asterisk should not throw", function () { 6 | let scheduler = new Cron("/3 * * * * *"); 7 | scheduler.nextRun(); 8 | }); 9 | 10 | test("Clean 6 part pattern should not throw", function () { 11 | let scheduler = new Cron("* * * * * *"); 12 | scheduler.nextRun(); 13 | }); 14 | 15 | test("Clean 5 part pattern should not throw", function () { 16 | let scheduler = new Cron("* * * * *"); 17 | scheduler.nextRun(); 18 | }); 19 | 20 | test("Pattern should be returned by .getPattern() (0 0 0 * * *)", function () { 21 | let job = new Cron("0 0 0 * * *"); 22 | assertEquals(job.getPattern(), "0 0 0 * * *"); 23 | }); 24 | 25 | test("String object pattern should not throw", function () { 26 | //@ts-ignore 27 | let scheduler = new Cron(new String("* * * * * *")); 28 | scheduler.nextRun(); 29 | }); 30 | 31 | test("Short pattern should throw", function () { 32 | assertThrows(() => { 33 | let scheduler = new Cron("* * * *"); 34 | scheduler.nextRun(); 35 | }); 36 | }); 37 | 38 | test("Long pattern should throw", function () { 39 | assertThrows(() => { 40 | let scheduler = new Cron("* * * * * * *"); 41 | scheduler.nextRun(); 42 | }); 43 | }); 44 | 45 | test("Letter in pattern should throw", function () { 46 | assertThrows(() => { 47 | let scheduler = new Cron("* a * * * *"); 48 | scheduler.nextRun(); 49 | }); 50 | }); 51 | 52 | test("Letter combined with star in pattern should throw", function () { 53 | assertThrows(() => { 54 | let scheduler = new Cron("* *a * * * *"); 55 | scheduler.nextRun(); 56 | }); 57 | }); 58 | 59 | test("Number combined with star in pattern should throw", function () { 60 | assertThrows(() => { 61 | let scheduler = new Cron("* *1 * * * *"); 62 | scheduler.nextRun(); 63 | }); 64 | }); 65 | 66 | test("Invalid data type of pattern should throw", function () { 67 | assertThrows(() => { 68 | //@ts-ignore 69 | let scheduler = new Cron(new Object()); 70 | scheduler.nextRun(); 71 | }); 72 | }); 73 | 74 | test("Weekday 0 (sunday) and weekday 7 (sunday) should both be valid patterns", function () { 75 | let scheduler0 = new Cron("0 0 0 * * 0"); 76 | scheduler0.nextRun(); 77 | let scheduler7 = new Cron("0 0 0 * * 7"); 78 | scheduler7.nextRun(); 79 | }); 80 | 81 | test("Weekday 0 (sunday) and weekday 7 (sunday) should give the same run time", function () { 82 | let scheduler0 = new Cron("0 0 0 * * 0"), 83 | scheduler7 = new Cron("0 0 0 * * 7"), 84 | nextRun0 = scheduler0.nextRun(), 85 | nextRun7 = scheduler7.nextRun(); 86 | assertEquals(nextRun0?.getTime(), nextRun7?.getTime()); 87 | }); 88 | 89 | test("0 0 0 * * * should return tomorrow, at 00:00:00", function () { 90 | let scheduler = new Cron("0 0 0 * * *"), 91 | nextRun = scheduler.nextRun(), 92 | // ToDay/nextDay is a fix for DST in test 93 | toDay = new Date(), 94 | nextDay = new Date(new Date().getTime() + 24 * 60 * 60 * 1000); // Add one day 95 | 96 | // Set seconds, minutes and hours to 00:00:00 97 | toDay.setMilliseconds(0); 98 | toDay.setSeconds(0); 99 | toDay.setMinutes(0); 100 | toDay.setHours(0); 101 | nextDay = new Date(toDay.getTime() + 36 * 60 * 60 * 1000); 102 | nextDay.setMilliseconds(0); 103 | nextDay.setSeconds(0); 104 | nextDay.setMinutes(0); 105 | nextDay.setHours(0); 106 | 107 | // Do comparison 108 | assertEquals(nextRun?.getTime(), nextDay.getTime()); 109 | }); 110 | 111 | test('new String("0 0 0 * * *") should return tomorrow, at 00:00:00', function () { 112 | //@ts-ignore 113 | let scheduler = new Cron(new String("0 0 0 * * *")), 114 | nextRun = scheduler.nextRun(), 115 | // ToDay/nextDay is a fix for DST in test 116 | toDay = new Date(), 117 | nextDay = new Date(new Date().getTime() + 24 * 60 * 60 * 1000); // Add one day 118 | 119 | // Set seconds, minutes and hours to 00:00:00 120 | toDay.setMilliseconds(0); 121 | toDay.setSeconds(0); 122 | toDay.setMinutes(0); 123 | toDay.setHours(0); 124 | nextDay = new Date(toDay.getTime() + 36 * 60 * 60 * 1000); 125 | nextDay.setMilliseconds(0); 126 | nextDay.setSeconds(0); 127 | nextDay.setMinutes(0); 128 | nextDay.setHours(0); 129 | 130 | // Do comparison 131 | assertEquals(nextRun?.getTime(), nextDay.getTime()); 132 | }); 133 | 134 | test("0 0 12 * * * with startdate tomorrow should return day after tomorrow, at 12:00:00", function () { 135 | let nextDay = new Date(new Date().getTime() + 24 * 60 * 60 * 1000), // Add one day 136 | dayAfterNext = new Date(new Date().getTime() + 48 * 60 * 60 * 1000), // Add two days 137 | scheduler, 138 | nextRun; 139 | 140 | // Set a fixed hour later than startAt, to be sure that the days doesn't overlap 141 | nextDay = new Date(nextDay.setUTCHours(14)); 142 | scheduler = new Cron("0 0 12 * * *", { timezone: "Etc/UTC", startAt: nextDay.toISOString() }); 143 | nextRun = scheduler.nextRun(); 144 | 145 | // Set seconds, minutes and hours to 00:00:00 146 | dayAfterNext.setMilliseconds(0); 147 | dayAfterNext.setUTCSeconds(0); 148 | dayAfterNext.setUTCMinutes(0); 149 | dayAfterNext.setUTCHours(12); 150 | 151 | // Do comparison 152 | assertEquals(nextRun?.getTime(), dayAfterNext.getTime()); 153 | }); 154 | 155 | test("* 17 * * * should return today, at 17:00:00 (if time is before 17:00:00)", function () { 156 | let todayAt12 = new Date(), // Subtract one day 157 | scheduler, 158 | nextRun; 159 | 160 | todayAt12.setHours(12); 161 | todayAt12.setMinutes(34); 162 | todayAt12.setMinutes(54); 163 | 164 | scheduler = new Cron("* * 17 * * *"); 165 | nextRun = scheduler.nextRun(todayAt12); 166 | 167 | // Do comparison 168 | assertEquals(nextRun?.getHours(), 17); 169 | assertEquals(nextRun?.getMinutes(), 0); 170 | assertEquals(nextRun?.getMinutes(), 0); 171 | }); 172 | 173 | test("*/5 * 15 * * should return today, at 15:00:00 (if time is before 17:00:00)", function () { 174 | let todayAt12 = new Date(), // Subtract one day 175 | scheduler, 176 | nextRun; 177 | 178 | todayAt12.setHours(12); 179 | todayAt12.setMinutes(34); 180 | todayAt12.setMinutes(54); 181 | 182 | scheduler = new Cron("*/5 * 15 * * *"); 183 | nextRun = scheduler.nextRun(todayAt12); 184 | 185 | // Do comparison 186 | assertEquals(nextRun?.getHours(), 15); 187 | assertEquals(nextRun?.getMinutes(), 0); 188 | assertEquals(nextRun?.getMinutes(), 0); 189 | }); 190 | 191 | test("* * 15 * * should return today, at 15:00:00 (if time is before 17:00:00)", function () { 192 | let todayAt12 = new Date(), // Subtract one day 193 | scheduler, 194 | nextRun; 195 | 196 | todayAt12.setHours(12); 197 | todayAt12.setMinutes(34); 198 | todayAt12.setMinutes(54); 199 | 200 | scheduler = new Cron("* * 15 * * *"); 201 | nextRun = scheduler.nextRun(todayAt12); 202 | 203 | // Do comparison 204 | assertEquals(nextRun?.getHours(), 15); 205 | assertEquals(nextRun?.getMinutes(), 0); 206 | assertEquals(nextRun?.getMinutes(), 0); 207 | }); 208 | test("59 * ? ? ? ? should (almost always) run within a minute", function () { 209 | let now = new Date(), 210 | scheduler, 211 | nextRun; 212 | 213 | // Set seconds to a low value to make sure the hour does not tip over 214 | now.setSeconds(30); 215 | 216 | scheduler = new Cron("59 * ? ? ? ?"); 217 | nextRun = scheduler.nextRun(now); 218 | 219 | // Do compariso 220 | assertEquals(nextRun && nextRun?.getTime() < now.getTime() + 60000, true); 221 | assertEquals(nextRun && nextRun?.getTime() >= now.getTime(), true); 222 | }); 223 | 224 | test("? * ? ? ? ? should (almost always) run within a minute", function () { 225 | let now = new Date(), 226 | scheduler, 227 | nextRun; 228 | 229 | // Set seconds to a low value to make sure the hour/minute does not tip over 230 | now.setSeconds(30); 231 | 232 | scheduler = new Cron("? * ? ? ? ?"); 233 | nextRun = scheduler.nextRun(now); 234 | 235 | // Do compariso 236 | assertEquals(nextRun && nextRun.getTime() < now.getTime() + 60000, true); 237 | assertEquals(nextRun && nextRun.getTime() >= now.getTime(), true); 238 | }); 239 | 240 | test("* * ? ? ? ? should return correct hour when used with a custom time zone", function () { 241 | let now = new Date(), 242 | scheduler, 243 | nextRun; 244 | 245 | // Set seconds to a low value to make sure the hour/minute does not tip over 246 | now.setSeconds(30); 247 | 248 | scheduler = new Cron("* * ? ? ? ?", { timezone: "America/New_York" }); 249 | nextRun = scheduler.nextRun(now); 250 | 251 | // Do comparison 252 | assertEquals(nextRun && nextRun.getUTCHours(), now.getUTCHours()); 253 | }); 254 | 255 | test("* ? ? ? ? ? should (almost always) run within a second", function () { 256 | let now = new Date(), 257 | scheduler, 258 | nextRun; 259 | 260 | // Set seconds to a low value to make sure the hour does not tip over 261 | now.setSeconds(30); 262 | 263 | scheduler = new Cron("* ? ? ? ? ?"); 264 | nextRun = scheduler.nextRun(now); 265 | 266 | // Do compariso 267 | assertEquals(nextRun && nextRun.getTime() < now.getTime() + 1500, true); 268 | assertEquals(nextRun && nextRun.getTime() >= now.getTime(), true); 269 | }); 270 | 271 | test("*/5 * 11 * * should return next day, at 11:00:00, if time is 12", function () { 272 | let todayAt12 = new Date(), // Subtract one day 273 | scheduler, 274 | nextRun; 275 | 276 | todayAt12.setHours(12); 277 | todayAt12.setMinutes(34); 278 | todayAt12.setMinutes(54); 279 | 280 | scheduler = new Cron("*/5 * 11 * * *"), nextRun = scheduler.nextRun(todayAt12); 281 | 282 | // Do comparison 283 | assertEquals(nextRun && nextRun.getHours(), 11); 284 | assertEquals(nextRun && nextRun.getMinutes(), 0); 285 | assertEquals(nextRun && nextRun.getMinutes(), 0); 286 | }); 287 | 288 | test("0 0 0 L 2 * should find last day of february(28 2022)", function () { 289 | let scheduler = new Cron("0 0 0 L 2 *"), 290 | prevRun = new Date(1643930208380), // From 4th of february 2022 291 | nextRun = scheduler.nextRun(prevRun); 292 | 293 | // Do comparison 294 | assertEquals(nextRun && nextRun.getDate(), 28); 295 | assertEquals(nextRun && nextRun.getMonth(), 1); 296 | assertEquals(nextRun && nextRun.getFullYear(), 2022); 297 | }); 298 | 299 | test("0 0 0 L 2 * should find last day of february (29 2024)", function () { 300 | let scheduler = new Cron("0 0 0 L 2 *"), 301 | prevRun = new Date(1703891808380), // From 30th of december 2023 302 | nextRun = scheduler.nextRun(prevRun); 303 | 304 | // Do comparison 305 | assertEquals(nextRun && nextRun.getDate(), 29); 306 | assertEquals(nextRun && nextRun.getMonth(), 1); 307 | assertEquals(nextRun && nextRun.getFullYear(), 2024); 308 | }); 309 | 310 | test("0 0 0 * 2 SUN#L should find last sunday of february 2024 (25/2 2024)", function () { 311 | let scheduler = new Cron("0 0 0 * 2 SUN#L"), 312 | prevRun = new Date(1703891808380), // From 30th of december 2023 313 | nextRun = scheduler.nextRun(prevRun); 314 | 315 | // Do comparison 316 | assertEquals(nextRun && nextRun.getDate(), 25); 317 | assertEquals(nextRun && nextRun.getMonth(), 1); 318 | assertEquals(nextRun && nextRun.getFullYear(), 2024); 319 | }); 320 | 321 | test("0 0 0 * 2 SUN#L should find last thursday of february 2024 (29/2 2024)", function () { 322 | let scheduler = new Cron("0 0 0 * 2 THU#L"), 323 | prevRun = new Date(1703891808380), // From 30th of december 2023 324 | nextRun = scheduler.nextRun(prevRun); 325 | 326 | // Do comparison 327 | assertEquals(nextRun && nextRun.getDate(), 29); 328 | assertEquals(nextRun && nextRun.getMonth(), 1); 329 | assertEquals(nextRun && nextRun.getFullYear(), 2024); 330 | }); 331 | 332 | test("0 0 0 * 2 FRI#L should find last friday of february 2024 (23/2 2024)", function () { 333 | let scheduler = new Cron("0 0 0 * 2 FRI#L"), 334 | prevRun = new Date(1703891808380), // From 30th of december 2023 335 | nextRun = scheduler.nextRun(prevRun); 336 | 337 | // Do comparison 338 | assertEquals(nextRun && nextRun.getDate(), 23); 339 | assertEquals(nextRun && nextRun.getMonth(), 1); 340 | assertEquals(nextRun && nextRun.getFullYear(), 2024); 341 | }); 342 | 343 | test("0 0 0 * 2 THU-FRI#L should find last thursday or friday of february 2024 (23/2 2024)", function () { 344 | let scheduler = new Cron("0 0 0 * 2 THU-FRI#L"), 345 | prevRun = new Date(1703891808380), // From 30th of december 2023 346 | nextRun = scheduler.nextRun(prevRun); 347 | 348 | // Do comparison 349 | assertEquals(nextRun && nextRun.getDate(), 23); 350 | assertEquals(nextRun && nextRun.getMonth(), 1); 351 | assertEquals(nextRun && nextRun.getFullYear(), 2024); 352 | }); 353 | 354 | test("0 0 0 * * SAT-SUN#L,SUN#1 should find last saturday or sunday of august 2023 (26-27/8 2023) as well as fist sunday of september", function () { 355 | let scheduler = new Cron("0 0 0 * * SAT-SUN#L,SUN#1"), 356 | prevRun = new Date(1691536579072), // From 9th of august 2023 357 | nextRun = scheduler.nextRuns(5, prevRun); 358 | 359 | // Do comparison 360 | assertEquals(nextRun[0].getDate(), 26); 361 | assertEquals(nextRun[0].getMonth(), 7); 362 | assertEquals(nextRun[0].getFullYear(), 2023); 363 | 364 | assertEquals(nextRun[1].getDate(), 27); 365 | assertEquals(nextRun[1].getMonth(), 7); 366 | assertEquals(nextRun[1].getFullYear(), 2023); 367 | 368 | assertEquals(nextRun[2].getDate(), 3); 369 | assertEquals(nextRun[2].getMonth(), 8); 370 | assertEquals(nextRun[2].getFullYear(), 2023); 371 | 372 | assertEquals(nextRun[3].getDate(), 24); 373 | assertEquals(nextRun[3].getMonth(), 8); 374 | assertEquals(nextRun[3].getFullYear(), 2023); 375 | 376 | assertEquals(nextRun[4].getDate(), 30); 377 | assertEquals(nextRun[4].getMonth(), 8); 378 | assertEquals(nextRun[4].getFullYear(), 2023); 379 | }); 380 | 381 | test("0 0 0 * * SUN-MON#3,MON-TUE#1 should work", function () { 382 | let scheduler = new Cron("0 0 0 * * SUN-MON#3,MON-TUE#1"), 383 | prevRun = new Date(1691536579072), // From 9th of august 2023 384 | nextRun = scheduler.nextRuns(5, prevRun); 385 | 386 | // Do comparison 387 | assertEquals(nextRun[0].getDate(), 20); 388 | assertEquals(nextRun[0].getMonth(), 7); 389 | assertEquals(nextRun[0].getFullYear(), 2023); 390 | 391 | assertEquals(nextRun[1].getDate(), 21); 392 | assertEquals(nextRun[1].getMonth(), 7); 393 | assertEquals(nextRun[1].getFullYear(), 2023); 394 | 395 | assertEquals(nextRun[2].getDate(), 4); 396 | assertEquals(nextRun[2].getMonth(), 8); 397 | assertEquals(nextRun[2].getFullYear(), 2023); 398 | 399 | assertEquals(nextRun[3].getDate(), 5); 400 | assertEquals(nextRun[3].getMonth(), 8); 401 | assertEquals(nextRun[3].getFullYear(), 2023); 402 | }); 403 | -------------------------------------------------------------------------------- /test/range.test.ts: -------------------------------------------------------------------------------- 1 | import { assertThrows } from "@std/assert"; 2 | import { test } from "@cross/test"; 3 | import { Cron } from "../src/croner.ts"; 4 | 5 | test("Slash in pattern should not throw", function () { 6 | let scheduler = new Cron("* */5 * * * *"); 7 | scheduler.nextRun(); 8 | }); 9 | 10 | test("Slash in pattern with number first should throw", function () { 11 | assertThrows(() => { 12 | let scheduler = new Cron("* 5/* * * * *"); 13 | scheduler.nextRun(); 14 | }); 15 | }); 16 | 17 | test("Slash in pattern without following number should throw", function () { 18 | assertThrows(() => { 19 | let scheduler = new Cron("* */ * * * *"); 20 | scheduler.nextRun(); 21 | }); 22 | }); 23 | 24 | test("Slash in pattern with preceding number should not throw", function () { 25 | let scheduler = new Cron("* 5/5 * * * *"); 26 | scheduler.nextRun(); 27 | }); 28 | 29 | test("Slash in pattern with preceding letter should throw", function () { 30 | assertThrows(() => { 31 | let scheduler = new Cron("* a/5 * * * *"); 32 | scheduler.nextRun(); 33 | }); 34 | }); 35 | 36 | test("Slash in pattern with preceding comma separated entries should not throw", function () { 37 | let scheduler = new Cron("* 1,2/5 * * * *"); 38 | scheduler.nextRun(); 39 | }); 40 | 41 | test("Slash in pattern with preceding range should not throw", function () { 42 | let scheduler = new Cron("* 1-15/5 * * * *"); 43 | scheduler.nextRun(); 44 | }); 45 | 46 | test("Slash in pattern with preceding range separated by comma should not throw", function () { 47 | let scheduler = new Cron("* 1-15/5,6 * * * *"); 48 | scheduler.nextRun(); 49 | }); 50 | 51 | test("Range separated by comma should not throw", function () { 52 | let scheduler = new Cron("* 1-15,17 * * * *"); 53 | scheduler.nextRun(); 54 | }); 55 | 56 | test("Missing lower range should throw", function () { 57 | assertThrows(() => { 58 | let scheduler = new Cron("* -9 * * * *"); 59 | scheduler.nextRun(); 60 | }); 61 | }); 62 | 63 | test("Missing upper range should throw", function () { 64 | assertThrows(() => { 65 | let scheduler = new Cron("* 0- * * * *"); 66 | scheduler.nextRun(); 67 | }); 68 | }); 69 | 70 | test("Higher upper range than lower range should throw", function () { 71 | assertThrows(() => { 72 | let scheduler = new Cron("* 12-2 * * * *"); 73 | scheduler.nextRun(); 74 | }); 75 | }); 76 | 77 | test("Rangerange should throw", function () { 78 | assertThrows(() => { 79 | let scheduler = new Cron("* 0-0-0 * * * *"); 80 | scheduler.nextRun(); 81 | }); 82 | }); 83 | 84 | test("Valid range should not throw", function () { 85 | let scheduler = new Cron("* 0-9 * * * *"); 86 | scheduler.nextRun(); 87 | }); 88 | 89 | test("Valid seconds should not throw", function () { 90 | let scheduler = new Cron("0-59 * * * * *"); 91 | scheduler.nextRun(); 92 | }); 93 | 94 | test("Too high second should throw", function () { 95 | assertThrows(() => { 96 | let scheduler = new Cron("0-60 * * * * *"); 97 | scheduler.nextRun(); 98 | }); 99 | }); 100 | 101 | test("Valid minutes should not throw", function () { 102 | let scheduler = new Cron("* 0-59 * * * *"); 103 | scheduler.nextRun(); 104 | }); 105 | 106 | test("Too high minute should throw", function () { 107 | assertThrows(() => { 108 | let scheduler = new Cron("* 0-5,55,60 * * * *"); 109 | scheduler.nextRun(); 110 | }); 111 | }); 112 | 113 | test("Valid hours should not throw", function () { 114 | let scheduler = new Cron("* * 0-23 * * *"); 115 | scheduler.nextRun(); 116 | }); 117 | 118 | test("Valid days should not throw", function () { 119 | let scheduler = new Cron("* * * 1-31 * *"); 120 | scheduler.nextRun(); 121 | }); 122 | 123 | test("Sunday as lower value of range should not throw", function () { 124 | let scheduler = new Cron("* * * * * SUN-MON"); 125 | scheduler.nextRun(); 126 | }); 127 | 128 | test("Sunday as upper value of range should not throw", function () { 129 | let scheduler = new Cron("* * * * * MON-SUN"); 130 | scheduler.nextRun(); 131 | }); 132 | -------------------------------------------------------------------------------- /test/stepping.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "@std/assert"; 2 | import { test } from "@cross/test"; 3 | import { Cron } from "../src/croner.ts"; 4 | 5 | test("Slash in pattern with wildcards both pre and post should throw", function () { 6 | assertThrows(() => { 7 | let scheduler = new Cron("* */* * * * *"); 8 | scheduler.nextRun(); 9 | }); 10 | }); 11 | 12 | test("Slash in pattern with range pre should not throw", function () { 13 | let scheduler = new Cron("* 15-45/15 * * * *"); 14 | scheduler.nextRun(); 15 | }); 16 | 17 | test("Slash in pattern with zero stepping should throw", function () { 18 | assertThrows(() => { 19 | let scheduler = new Cron("* */0 * * * *"); 20 | scheduler.nextRun(); 21 | }); 22 | }); 23 | 24 | test("Range with stepping with zero stepping should throw", function () { 25 | assertThrows(() => { 26 | let scheduler = new Cron("* 10-20/0 * * * *"); 27 | scheduler.nextRun(); 28 | }); 29 | }); 30 | 31 | test("Range with stepping with illegal upper range should throw", function () { 32 | assertThrows(() => { 33 | let scheduler = new Cron("* 10-70/5 * * * *"); 34 | scheduler.nextRun(); 35 | }); 36 | }); 37 | 38 | test("Range with stepping with illegal range should throw", function () { 39 | assertThrows(() => { 40 | let scheduler = new Cron("* 50-40/5 * * * *"); 41 | scheduler.nextRun(); 42 | }); 43 | }); 44 | 45 | test("Slash in pattern with letter after should throw should throw", function () { 46 | assertThrows(() => { 47 | let scheduler = new Cron("* */a * * * *"); 48 | scheduler.nextRun(); 49 | }); 50 | }); 51 | 52 | test("Slash in pattern with too high stepping should throw", function () { 53 | assertThrows(() => { 54 | let scheduler = new Cron("* */61 * * * *"); 55 | scheduler.nextRun(); 56 | }); 57 | }); 58 | 59 | test("Multiple stepping should throw", function () { 60 | assertThrows(() => { 61 | let scheduler = new Cron("* */5/5 * * * *"); 62 | scheduler.nextRun(); 63 | }); 64 | }); 65 | 66 | test("Steps for hours should yield correct hours", function () { 67 | let nextRuns = new Cron("1 1 */3 * * *").nextRuns(10, "2020-01-01T00:00:00"); 68 | assertEquals(nextRuns[0].getHours(), 0); 69 | assertEquals(nextRuns[1].getHours(), 3); 70 | assertEquals(nextRuns[2].getHours(), 6); 71 | assertEquals(nextRuns[3].getHours(), 9); 72 | assertEquals(nextRuns[4].getHours(), 12); 73 | assertEquals(nextRuns[5].getHours(), 15); 74 | assertEquals(nextRuns[6].getHours(), 18); 75 | assertEquals(nextRuns[7].getHours(), 21); 76 | assertEquals(nextRuns[8].getHours(), 0); 77 | assertEquals(nextRuns[9].getHours(), 3); 78 | }); 79 | 80 | test("Steps for hours should yield correct hours with range", function () { 81 | let nextRuns = new Cron("1 1 0-23/3 * * *").nextRuns(10, "2020-01-01T00:00:00"); 82 | assertEquals(nextRuns[0].getHours(), 0); 83 | assertEquals(nextRuns[1].getHours(), 3); 84 | assertEquals(nextRuns[2].getHours(), 6); 85 | assertEquals(nextRuns[3].getHours(), 9); 86 | assertEquals(nextRuns[4].getHours(), 12); 87 | assertEquals(nextRuns[5].getHours(), 15); 88 | assertEquals(nextRuns[6].getHours(), 18); 89 | assertEquals(nextRuns[7].getHours(), 21); 90 | assertEquals(nextRuns[8].getHours(), 0); 91 | assertEquals(nextRuns[9].getHours(), 3); 92 | }); 93 | 94 | test("Steps for hours should yield correct hours with range and stepping and comma-separated values", function () { 95 | let nextRuns = new Cron("1 1 0-12/3,1,10 * * *").nextRuns(10, "2020-01-01T00:00:00"); 96 | assertEquals(nextRuns[0].getHours(), 0); 97 | assertEquals(nextRuns[1].getHours(), 1); 98 | assertEquals(nextRuns[2].getHours(), 3); 99 | assertEquals(nextRuns[3].getHours(), 6); 100 | assertEquals(nextRuns[4].getHours(), 9); 101 | assertEquals(nextRuns[5].getHours(), 10); 102 | assertEquals(nextRuns[6].getHours(), 12); 103 | }); 104 | 105 | test("Steps for hours should yield correct hours with stepping and comma-separated values", function () { 106 | let nextRuns = new Cron("1 1 12/3,1,10 * * *").nextRuns(10, "2020-01-01T00:00:00"); 107 | assertEquals(nextRuns[0].getHours(), 1); 108 | assertEquals(nextRuns[1].getHours(), 10); 109 | assertEquals(nextRuns[2].getHours(), 12); 110 | assertEquals(nextRuns[3].getHours(), 15); 111 | assertEquals(nextRuns[4].getHours(), 18); 112 | assertEquals(nextRuns[5].getHours(), 21); 113 | }); 114 | 115 | test("Steps for hours should yield correct hours with range and comma-separated values", function () { 116 | let nextRuns = new Cron("1 1 0-6,1,10 * * *").nextRuns(10, "2020-01-01T00:00:00"); 117 | assertEquals(nextRuns[0].getHours(), 0); 118 | assertEquals(nextRuns[1].getHours(), 1); 119 | assertEquals(nextRuns[2].getHours(), 2); 120 | assertEquals(nextRuns[3].getHours(), 3); 121 | assertEquals(nextRuns[4].getHours(), 4); 122 | assertEquals(nextRuns[5].getHours(), 5); 123 | assertEquals(nextRuns[6].getHours(), 6); 124 | assertEquals(nextRuns[7].getHours(), 10); 125 | }); 126 | 127 | test("Steps for hours should yield correct hours with offset range and comma-separated values on wednesdays (legacy mode)", function () { 128 | let nextRuns = new Cron("1 1 3-8/2,1,10 * * sat").nextRuns(10, "2020-01-01T00:00:00"); 129 | assertEquals(nextRuns[0].getFullYear(), 2020); 130 | assertEquals(nextRuns[0].getMonth(), 0); 131 | assertEquals(nextRuns[0].getDate(), 4); 132 | assertEquals(nextRuns[0].getHours(), 1); 133 | assertEquals(nextRuns[1].getHours(), 3); 134 | assertEquals(nextRuns[2].getHours(), 5); 135 | assertEquals(nextRuns[3].getHours(), 7); 136 | assertEquals(nextRuns[4].getHours(), 10); 137 | assertEquals(nextRuns[5].getHours(), 1); 138 | }); 139 | 140 | test("Steps for months should yield correct months", function () { 141 | let nextRuns = new Cron("1 1 1 */3 *").nextRuns(10, "2020-12-31T23:59:59"); 142 | assertEquals(nextRuns[0].getMonth(), 0); 143 | assertEquals(nextRuns[1].getMonth(), 3); 144 | assertEquals(nextRuns[2].getMonth(), 6); 145 | assertEquals(nextRuns[3].getMonth(), 9); 146 | }); 147 | 148 | test("Steps for months should yield correct months with range", function () { 149 | let nextRuns = new Cron("1 1 1 1-12/3 *").nextRuns(10, "2020-12-31T23:59:59"); 150 | assertEquals(nextRuns[0].getMonth(), 0); 151 | assertEquals(nextRuns[1].getMonth(), 3); 152 | assertEquals(nextRuns[2].getMonth(), 6); 153 | assertEquals(nextRuns[3].getMonth(), 9); 154 | }); 155 | 156 | test("Steps for months should yield correct months with range and start date", function () { 157 | let nextRuns = new Cron("1 1 1 5/2 *").nextRuns(10, "2020-12-31T23:59:59"); 158 | assertEquals(nextRuns[0].getMonth(), 4); 159 | assertEquals(nextRuns[1].getMonth(), 6); 160 | assertEquals(nextRuns[2].getMonth(), 8); 161 | assertEquals(nextRuns[3].getMonth(), 10); 162 | assertEquals(nextRuns[4].getMonth(), 4); 163 | }); 164 | 165 | test("Steps for days should yield correct days with range and start date", function () { 166 | let nextRuns = new Cron("1 1 5/3 * *").nextRuns(10, "2020-12-31T23:59:59"); 167 | assertEquals(nextRuns[0].getDate(), 5); 168 | assertEquals(nextRuns[1].getDate(), 8); 169 | assertEquals(nextRuns[2].getDate(), 11); 170 | }); 171 | -------------------------------------------------------------------------------- /test/timezone.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "@std/assert"; 2 | import { test } from "@cross/test"; 3 | import { Cron } from "../src/croner.ts"; 4 | 5 | test("DST/Timezone", function () { 6 | let dayOne = new Date("2021-10-31T20:00:00"), // Last day of DST 7 | scheduler = new Cron("0 0 12 * * *", { timezone: "Etc/UTC", startAt: dayOne }), 8 | nextRun = scheduler.nextRun(); // Next run in local time 9 | 10 | // Do comparison 11 | assertEquals(nextRun?.getUTCHours(), 12); 12 | }); 13 | 14 | test("Zero UTC offset", function () { 15 | let dayOne = new Date("2021-10-31T20:00:00"), 16 | scheduler = new Cron("0 0 12 * * *", { utcOffset: 0, startAt: dayOne }), 17 | nextRun = scheduler.nextRun(); // Next run in local time 18 | 19 | // Do comparison 20 | assertEquals(nextRun?.getUTCHours(), 12); 21 | }); 22 | 23 | test("Neagtive UTC offset", function () { 24 | let dayOne = new Date("2021-10-31T20:00:00"), 25 | scheduler = new Cron("0 0 13 * * *", { utcOffset: -120, startAt: dayOne }), 26 | nextRun = scheduler.nextRun(); // Next run in local time 27 | 28 | // Do comparison 29 | assertEquals(nextRun?.getUTCHours(), 15); 30 | }); 31 | 32 | test("Positive UTC offset", function () { 33 | let dayOne = new Date("2021-10-31T20:00:00"), 34 | scheduler = new Cron("0 0 13 * * *", { utcOffset: 480, startAt: dayOne }), 35 | nextRun = scheduler.nextRun(); // Next run in local time 36 | 37 | // Do comparison 38 | assertEquals(nextRun?.getUTCHours(), 5); 39 | }); 40 | 41 | test("getTime should return expected difference with different timezones (now)", function () { 42 | let timeStockholm = new Cron("* * * * * *", { timezone: "Europe/Stockholm" }).nextRun() 43 | ?.getTime(), 44 | timeNewYork = new Cron("* * * * * *", { timezone: "America/New_York" }).nextRun()?.getTime(); 45 | 46 | // The time right now should be the same in utc whether in new york or stockholm. Allow a 4 second difference. 47 | assertEquals(true, timeStockholm && timeStockholm >= (timeNewYork || 0) - 4000); 48 | assertEquals(true, timeStockholm && timeStockholm <= (timeNewYork || 0) + 4000); 49 | }); 50 | test("getTime should return expected difference with different timezones (next 31st october)", function () { 51 | let refTime = new Date(); 52 | refTime.setFullYear(2021); 53 | refTime.setMonth(8); 54 | 55 | let timeStockholm = new Cron("0 0 12 30 10 *", { timezone: "Europe/Stockholm" }).nextRun(refTime) 56 | ?.getTime(), 57 | timeNewYork = new Cron("0 0 12 30 10 *", { timezone: "America/New_York" }).nextRun(refTime) 58 | ?.getTime(), 59 | diff = ((timeNewYork || 0) - (timeStockholm || 0)) / 1000 / 3600; 60 | 61 | // The time when next sunday 1st november occur should be with 6 hours difference (seen from utc) 62 | assertEquals(diff, 6); 63 | }); 64 | 65 | test("Should return expected time, date and weekday different timezones", function () { 66 | let refTime = new Date(); 67 | refTime.setFullYear(2022); 68 | refTime.setDate(8); 69 | refTime.setMonth(1); 70 | refTime.setHours(12); 71 | 72 | let timeStockholm = new Cron("0 0 23 8 2 2", { timezone: "Europe/Stockholm" }).nextRun(refTime), 73 | timeNewYork = new Cron("0 0 23 8 2 2", { timezone: "America/New_York" }).nextRun(refTime); 74 | 75 | assertEquals(timeStockholm?.getUTCMonth(), 1); 76 | assertEquals(timeStockholm?.getUTCDate(), 8); 77 | assertEquals(timeStockholm?.getUTCHours(), 22); 78 | assertEquals(timeStockholm?.getUTCFullYear(), 2022); 79 | 80 | assertEquals(timeNewYork?.getUTCMonth(), 1); 81 | assertEquals(timeNewYork?.getUTCDate(), 9); 82 | assertEquals(timeNewYork?.getUTCHours(), 4); 83 | assertEquals(timeNewYork?.getUTCFullYear(), 2022); 84 | }); 85 | 86 | test("getTime should return expected difference with different timezones (next 1st november)", function () { 87 | let timeStockholm = new Cron("0 0 12 1 11 *", { timezone: "Europe/Stockholm" }).nextRun() 88 | ?.getTime(), 89 | timeNewYork = new Cron("0 0 12 1 11 *", { timezone: "America/New_York" }).nextRun()?.getTime(), 90 | diff = ((timeNewYork || 0) - (timeStockholm || 0)) / 1000 / 3600; 91 | 92 | // The time when next sunday 1st november occur should be with 6 hours difference (seen from utc) 93 | assertEquals(diff, 5); 94 | }); 95 | 96 | test("0 0 0 * * * with 365 iterations should return 365 days from now in America/New_York", function () { 97 | let startAt = new Date(Date.parse("2023-01-01T12:00:00.000Z")), 98 | scheduler = new Cron("0 0 0 * * *", { timezone: "America/New_York", startAt }), 99 | nextRun, 100 | prevRun = new Date(startAt.getTime()), 101 | iterations = 365, 102 | compareDay = new Date(startAt.getTime()); 103 | 104 | compareDay.setDate(compareDay.getDate() + iterations); 105 | 106 | while (iterations-- > 0) { 107 | //@ts-ignore 108 | nextRun = scheduler.nextRun(prevRun), prevRun = nextRun; 109 | } 110 | 111 | // Set seconds, minutes and hours to 00:00:00 112 | compareDay.setMilliseconds(0); 113 | compareDay.setSeconds(0); 114 | compareDay.setMinutes(0); 115 | compareDay.setHours(0); 116 | 117 | // Do comparison 118 | //@ts-ignore 119 | assertEquals(Math.abs(nextRun?.getTime() - compareDay.getTime()) < 13 * 60 * 60 * 1000, true); 120 | }); 121 | 122 | test("0 30 2 * * * with 365 iterations should return 365 days from now in America/New_York", function () { 123 | let startAt = new Date(Date.parse("2023-01-01T12:00:00.000Z")), 124 | scheduler = new Cron("0 30 2 * * *", { timezone: "America/New_York", startAt }), 125 | nextRun, 126 | prevRun = new Date(startAt.getTime()), 127 | iterations = 365, 128 | compareDay = new Date(startAt.getTime()); 129 | 130 | compareDay.setDate(compareDay.getDate() + iterations); 131 | 132 | while (iterations-- > 0) { 133 | //@ts-ignore 134 | nextRun = scheduler.nextRun(prevRun), prevRun = nextRun; 135 | } 136 | 137 | // Set seconds, minutes and hours to 00:00:00 138 | compareDay.setMilliseconds(0); 139 | compareDay.setSeconds(0); 140 | compareDay.setMinutes(0); 141 | compareDay.setHours(0); 142 | 143 | // Do comparison 144 | //@ts-ignore 145 | assertEquals(Math.abs(nextRun?.getTime() - compareDay.getTime()) < 13 * 60 * 60 * 1000, true); 146 | }); 147 | 148 | test("0 30 1 * * * with 365 iterations should return 365 days from now in America/New_York", function () { 149 | let startAt = new Date(Date.parse("2023-01-01T12:00:00.000Z")), 150 | scheduler = new Cron("0 30 1 * * *", { timezone: "America/New_York", startAt }), 151 | nextRun, 152 | prevRun = new Date(startAt.getTime()), 153 | iterations = 365, 154 | compareDay = new Date(startAt.getTime()); 155 | 156 | compareDay.setDate(compareDay.getDate() + iterations); 157 | 158 | while (iterations-- > 0) { 159 | //@ts-ignore 160 | nextRun = scheduler.nextRun(prevRun), prevRun = nextRun; 161 | } 162 | 163 | // Set seconds, minutes and hours to 00:00:00 164 | compareDay.setMilliseconds(0); 165 | compareDay.setSeconds(0); 166 | compareDay.setMinutes(0); 167 | compareDay.setHours(0); 168 | 169 | // Do comparison 170 | //@ts-ignore 171 | assertEquals(Math.abs(nextRun?.getTime() - compareDay.getTime()) < 13 * 60 * 60 * 1000, true); 172 | }); 173 | 174 | test("0 30 2 * * * with 365 iterations should return 366 days from now in Europe/Berlin", function () { 175 | let startAt = new Date(Date.parse("2023-02-15T12:00:00.000Z")), 176 | scheduler = new Cron("0 30 2 * * *", { timezone: "Europe/Berlin", startAt }), 177 | prevRun = new Date(startAt.getTime()), 178 | nextRun, 179 | iterations = 365, 180 | compareDay = new Date(startAt.getTime()); 181 | 182 | compareDay.setDate(compareDay.getDate() + iterations); 183 | 184 | while (iterations-- > 0) { 185 | nextRun = scheduler.nextRun(prevRun); 186 | //@ts-ignore 187 | prevRun = nextRun; 188 | } 189 | 190 | // Set seconds, minutes and hours to 00:00:00 191 | compareDay.setMilliseconds(0); 192 | compareDay.setSeconds(0); 193 | compareDay.setMinutes(0); 194 | compareDay.setHours(0); 195 | 196 | // Do comparison 197 | //@ts-ignore 198 | assertEquals(Math.abs(nextRun?.getTime() - compareDay.getTime()) < 13 * 60 * 60 * 1000, true); 199 | }); 200 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | // Convenience function for asynchronous testing 2 | export function timeout(timeoutMs: number, fn: Function) { 3 | return () => { 4 | //@ts-ignore Cross Runtime 5 | let to: number | NodeJS.Timeout | undefined; 6 | return new Promise((resolve, reject) => { 7 | fn(resolve, reject); 8 | to = setTimeout(() => { 9 | reject(new Error("Timeout")); 10 | }, timeoutMs); 11 | }).finally(() => { 12 | clearTimeout(to as number); 13 | }); 14 | }; 15 | } 16 | 17 | export function sleep(ms: number) { 18 | return new Promise((resolve) => setTimeout(resolve, ms)); 19 | } 20 | --------------------------------------------------------------------------------