├── .commitlintrc.js ├── .editorconfig ├── .eslintignore ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .npmrc ├── .prettierrc.js ├── .remarkignore ├── .remarkrc.js ├── .xo-config.js ├── CNAME ├── LICENSE ├── README.md ├── UPGRADING.md ├── ava.config.js ├── config.js ├── examples ├── commonjs │ ├── README.md │ ├── index.js │ ├── jobs │ │ └── job.js │ └── package.json ├── email-queue │ ├── README.md │ ├── emails │ │ └── welcome │ │ │ └── html.pug │ ├── index.js │ ├── jobs │ │ └── email.js │ └── package.json ├── esmodules │ ├── README.md │ ├── index.js │ ├── jobs │ │ └── job.js │ └── package.json ├── typescript-esm │ ├── README.md │ ├── index.ts │ ├── jobs │ │ └── job.ts │ ├── package.json │ └── tsconfig.json ├── typescript-jobserver │ ├── index.ts │ ├── jobs │ │ ├── defaults.ts │ │ └── job.ts │ ├── package.json │ └── tsconfig.json └── typescript │ ├── README.md │ ├── index.ts │ ├── jobs │ └── job.ts │ ├── package.json │ └── tsconfig.json ├── favicon.ico ├── index.html ├── media ├── bree.png ├── favicon.png ├── footer.png ├── github.png └── logo.png ├── nyc.config.js ├── package.json ├── src ├── index.d.ts ├── index.js ├── job-builder.js ├── job-utils.js └── job-validator.js ├── test-d └── index.test-d.ts └── test ├── add.js ├── get-worker-metadata.js ├── index.js ├── issues ├── issue-152.js ├── issue-171.js └── issue-180 │ ├── jobs-no-default-export │ ├── index.mjs │ └── job.js │ ├── jobs │ ├── index.mjs │ └── job.js │ └── test.js ├── job-builder.js ├── job-utils.js ├── job-validator.js ├── jobs ├── basic.js ├── basic.mjs ├── done.js ├── index.js ├── infinite.js ├── leroy.js │ └── test ├── long.js ├── loop.js ├── message-process-exit.js ├── message-ungraceful.js ├── message.js ├── short.js ├── worker-data.js └── worker-options.js ├── noIndexJobs └── basic.js ├── plugins ├── index.js └── init.js ├── remove.js ├── run.js ├── start.js └── stop.js /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.*.js 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | jobs: 5 | test: 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: 10 | - ubuntu-latest 11 | - macos-latest 12 | # TODO: re-enable windows 13 | #- windows-latest 14 | node: 15 | - 14 16 | - 16 17 | - 18 18 | exclude: 19 | - os: macos-latest 20 | node: 14 21 | name: Node ${{ matrix.node }} on ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Setup node 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node }} 28 | - name: Install dependencies 29 | run: npm install 30 | - name: Run tests 31 | run: npm run test-coverage 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | locales/ 8 | package-lock.json 9 | yarn.lock 10 | examples/**/dist 11 | 12 | Thumbs.db 13 | tmp/ 14 | temp/ 15 | *.lcov 16 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged && npm test 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.md,!test/**/*.md': [ 3 | (filenames) => filenames.map((filename) => `remark ${filename} -qfo`) 4 | ], 5 | './package.json': 'fixpack', 6 | '*.js': 'xo --fix', 7 | '*.ts': 'xo --fix' 8 | }; 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "singleQuote": true, 3 | "bracketSpacing": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | test/snapshots/**/*.md 2 | examples/**/* 3 | -------------------------------------------------------------------------------- /.remarkrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['preset-github'] 3 | }; 4 | -------------------------------------------------------------------------------- /.xo-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prettier: true, 3 | space: true, 4 | extends: ['xo-lass'], 5 | ignore: ['config.js'], 6 | rules: { 7 | 'capitalized-comments': 'off', 8 | 'unicorn/catch-error-name': 'off', 9 | 'unicorn/require-post-message-target-origin': 'off', 10 | 'unicorn/prefer-node-protocol': 'warn', 11 | 'unicorn/prefer-top-level-await': 'warn', 12 | 'unicorn/prefer-event-target': 'off', 13 | 'unicorn/no-empty-file': 'warn', 14 | 'unicorn/no-process-exit': 'warn', 15 | 'unicorn/prefer-logical-operator-over-ternary': 'warn' 16 | }, 17 | overrides: [ 18 | { 19 | files: '**/*.mjs', 20 | parserOptions: { 21 | sourceType: 'module' 22 | } 23 | }, 24 | { 25 | files: 'test/jobs/*.js', 26 | rules: { 27 | 'unicorn/no-process-exit': 'off' 28 | } 29 | }, 30 | { 31 | files: ['**/*.d.ts'], 32 | rules: { 33 | 'no-unused-vars': 'off', 34 | '@typescript-eslint/naming-convention': 'off', 35 | 'no-redeclare': 'off', 36 | '@typescript-eslint/no-redeclare': 'off' 37 | } 38 | }, 39 | { 40 | files: ['**/*.test-d.ts'], 41 | rules: { 42 | '@typescript-eslint/no-unsafe-call': 'off', 43 | '@typescript-eslint/no-confusing-void-expression': 'off', // Conflicts with `expectError` assertion. 44 | '@typescript-eslint/no-unsafe-assignment': 'off' 45 | } 46 | } 47 | ] 48 | }; 49 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | jobscheduler.net 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nick Baugh (http://niftylettuce.com/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | bree 3 |

4 |
5 | build status 6 | code style 7 | styled with prettier 8 | made with lass 9 | license 10 | npm downloads 11 |
12 |
13 |
14 | Bree is the best job scheduler for Node.js and JavaScript with cron, dates, ms, later, and human-friendly support. 15 |
16 |
17 |
18 | Works in Node v12.17.0+, uses worker threads (Node.js) to spawn sandboxed processes, and supports async/await, retries, throttling, concurrency, and cancelable jobs with graceful shutdown. Simple, fast, and lightweight. Made for Forward Email and Lad. 19 |
20 | 21 | 22 | ## Table of Contents 23 | 24 | * [Foreword](#foreword) 25 | * [Install](#install) 26 | * [Upgrading](#upgrading) 27 | * [Usage and Examples](#usage-and-examples) 28 | * [ECMAScript modules (ESM)](#ecmascript-modules-esm) 29 | * [CommonJS (CJS)](#commonjs-cjs) 30 | * [Instance Options](#instance-options) 31 | * [Job Options](#job-options) 32 | * [Job Interval and Timeout Values](#job-interval-and-timeout-values) 33 | * [Listening for events](#listening-for-events) 34 | * [Custom error/message handling](#custom-errormessage-handling) 35 | * [Cancellation, Retries, Stalled Jobs, and Graceful Reloading](#cancellation-retries-stalled-jobs-and-graceful-reloading) 36 | * [Interval, Timeout, Date, and Cron Validation](#interval-timeout-date-and-cron-validation) 37 | * [Writing jobs with Promises and async-await](#writing-jobs-with-promises-and-async-await) 38 | * [Callbacks, Done, and Completion States](#callbacks-done-and-completion-states) 39 | * [Long-running jobs](#long-running-jobs) 40 | * [Complex timeouts and intervals](#complex-timeouts-and-intervals) 41 | * [Custom Worker Options](#custom-worker-options) 42 | * [Using functions for jobs](#using-functions-for-jobs) 43 | * [Typescript and Usage with Bundlers](#typescript-and-usage-with-bundlers) 44 | * [Concurrency](#concurrency) 45 | * [Plugins](#plugins) 46 | * [Available Plugins](#available-plugins) 47 | * [Creating plugins for Bree](#creating-plugins-for-bree) 48 | * [Real-world usage](#real-world-usage) 49 | * [Contributors](#contributors) 50 | * [License](#license) 51 | 52 | 53 | ## Foreword 54 | 55 | Bree was created to give you fine-grained control with simplicity, and has built-in support for workers, sandboxed processes, graceful reloading, cron jobs, dates, human-friendly time representations, and much more. 56 | 57 | We recommend you to query a persistent database in your jobs, to prevent specific operations from running more than once. 58 | 59 | Bree does not force you to use an additional database layer of [Redis][] or [MongoDB][] to manage job state. 60 | 61 | In doing so, you should manage boolean job states yourself using queries. For instance, if you have to send a welcome email to users, only send a welcome email to users that do not have a Date value set yet for `welcome_email_sent_at`. 62 | 63 | 64 | ## Install 65 | 66 | [npm][]: 67 | 68 | ```sh 69 | npm install bree 70 | ``` 71 | 72 | [yarn][]: 73 | 74 | ```sh 75 | yarn add bree 76 | ``` 77 | 78 | 79 | ## Upgrading 80 | 81 | To see details about upgrading from the last major version, please see [UPGRADING.md](https://github.com/breejs/bree/blob/master/UPGRADING.md). 82 | 83 | > **IMPORTANT:** [Bree v9.0.0](https://github.com/breejs/bree/releases/tag/v9.0.0) has several breaking changes, please see [UPGRADING.md](https://github.com/breejs/bree/blob/master/UPGRADING.md) for more insight. 84 | 85 | > **NOTE:** [Bree v6.5.0](https://github.com/breejs/bree/releases/tag/v6.5.0) is the last version to support Node v10 and browsers. 86 | 87 | 88 | ## Usage and Examples 89 | 90 | The example below assumes that you have a directory `jobs` in the root of the directory from which you run this example. For example, if the example below is at `/path/to/script.js`, then `/path/to/jobs/` must also exist as a directory. If you wish to disable this feature, then pass `root: false` as an option. 91 | 92 | Inside this `jobs` directory are individual scripts which are run using [Workers][] per optional timeouts, and additionally, an optional interval or cron expression. The example below contains comments, which help to clarify how this works. 93 | 94 | The option `jobs` passed to a new instance of `Bree` (as shown below) is an Array. It contains values which can either be a String (name of a job in the `jobs` directory, which is run on boot) OR it can be an Object with `name`, `path`, `timeout`, and `interval` properties. If you do not supply a `path`, then the path is created using the root directory (defaults to `jobs`) in combination with the `name`. If you do not supply values for `timeout` and/nor `interval`, then these values are defaulted to `0` (which is the default for both, see [index.js](https://github.com/breejs/bree/blob/master/src/index.js) for more insight into configurable default options). 95 | 96 | We have also documented all [Instance Options](#instance-options) and [Job Options](#job-options) in this README below. Be sure to read those sections so you have a complete understanding of how Bree works. 97 | 98 | ### ECMAScript modules (ESM) 99 | 100 | ```js 101 | // app.mjs 102 | 103 | import Bree from 'bree'; 104 | 105 | const bree = new Bree({ 106 | // ... (see below) ... 107 | }); 108 | 109 | // top-level await supported in Node v14.8+ 110 | await bree.start(); 111 | 112 | // ... (see below) ... 113 | ``` 114 | 115 | **Please reference the [#CommonJS](#commonjs-cjs) example below for more insight and options.** 116 | 117 | ### CommonJS (CJS) 118 | 119 | ```js 120 | // app.js 121 | 122 | const path = require('path'); 123 | 124 | // optional 125 | const ms = require('ms'); 126 | const dayjs = require('dayjs'); 127 | const Graceful = require('@ladjs/graceful'); 128 | const Cabin = require('cabin'); 129 | 130 | // required 131 | const Bree = require('bree'); 132 | 133 | // 134 | // NOTE: see the "Instance Options" section below in this README 135 | // for the complete list of options and their defaults 136 | // 137 | const bree = new Bree({ 138 | // 139 | // NOTE: by default the `logger` is set to `console` 140 | // however we recommend you to use CabinJS as it 141 | // will automatically add application and worker metadata 142 | // to your log output, and also masks sensitive data for you 143 | // 144 | // 145 | // NOTE: You can also pass `false` as `logger: false` to disable logging 146 | // 147 | logger: new Cabin(), 148 | 149 | // 150 | // NOTE: instead of passing this Array as an option 151 | // you can create a `./jobs/index.js` file, exporting 152 | // this exact same array as `module.exports = [ ... ]` 153 | // doing so will allow you to keep your job configuration and the jobs 154 | // themselves all in the same folder and very organized 155 | // 156 | // See the "Job Options" section below in this README 157 | // for the complete list of job options and configurations 158 | // 159 | jobs: [ 160 | // runs `./jobs/foo.js` on start 161 | 'foo', 162 | 163 | // runs `./jobs/foo-bar.js` on start 164 | { 165 | name: 'foo-bar' 166 | }, 167 | 168 | // runs `./jobs/some-other-path.js` on start 169 | { 170 | name: 'beep', 171 | path: path.join(__dirname, 'jobs', 'some-other-path') 172 | }, 173 | 174 | // runs `./jobs/worker-1.js` on the last day of the month 175 | { 176 | name: 'worker-1', 177 | interval: 'on the last day of the month' 178 | }, 179 | 180 | // runs `./jobs/worker-2.js` every other day 181 | { 182 | name: 'worker-2', 183 | interval: 'every 2 days' 184 | }, 185 | 186 | // runs `./jobs/worker-3.js` at 10:15am and 5:15pm every day except on Tuesday 187 | { 188 | name: 'worker-3', 189 | interval: 'at 10:15 am also at 5:15pm except on Tuesday' 190 | }, 191 | 192 | // runs `./jobs/worker-4.js` at 10:15am every weekday 193 | { 194 | name: 'worker-4', 195 | cron: '15 10 ? * *', 196 | cronValidate: { 197 | override: { 198 | useBlankDay: true 199 | } 200 | } 201 | }, 202 | 203 | // runs `./jobs/worker-5.js` on after 10 minutes have elapsed 204 | { 205 | name: 'worker-5', 206 | timeout: '10m' 207 | }, 208 | 209 | // runs `./jobs/worker-6.js` after 1 minute and every 5 minutes thereafter 210 | { 211 | name: 'worker-6', 212 | timeout: '1m', 213 | interval: '5m' 214 | // this is unnecessary but shows you can pass a Number (ms) 215 | // interval: ms('5m') 216 | }, 217 | 218 | // runs `./jobs/worker-7.js` after 3 days and 4 hours 219 | { 220 | name: 'worker-7', 221 | // this example uses `human-interval` parsing 222 | timeout: '3 days and 4 hours' 223 | }, 224 | 225 | // runs `./jobs/worker-8.js` at midnight (once) 226 | { 227 | name: 'worker-8', 228 | timeout: 'at 12:00 am' 229 | }, 230 | 231 | // runs `./jobs/worker-9.js` every day at midnight 232 | { 233 | name: 'worker-9', 234 | interval: 'at 12:00 am' 235 | }, 236 | 237 | // runs `./jobs/worker-10.js` at midnight on the 1st of every month 238 | { 239 | name: 'worker-10', 240 | cron: '0 0 1 * *' 241 | }, 242 | 243 | // runs `./jobs/worker-11.js` at midnight on the last day of month 244 | { 245 | name: 'worker-11', 246 | cron: '0 0 L * *', 247 | cronValidate: { 248 | useLastDayOfMonth: true 249 | } 250 | }, 251 | 252 | // runs `./jobs/worker-12.js` at a specific Date (e.g. in 3 days) 253 | { 254 | name: 'worker-12', 255 | // 256 | date: dayjs().add(3, 'days').toDate() 257 | // you can also use momentjs 258 | // 259 | // date: moment('1/1/20', 'M/D/YY').toDate() 260 | // you can pass Date instances (if it's in the past it will not get run) 261 | // date: new Date() 262 | }, 263 | 264 | // runs `./jobs/worker-13.js` on start and every 2 minutes 265 | { 266 | name: 'worker-13', 267 | interval: '2m' 268 | }, 269 | 270 | // runs `./jobs/worker-14.js` on start with custom `new Worker` options (see below) 271 | { 272 | name: 'worker-14', 273 | // 274 | worker: { 275 | workerData: { 276 | foo: 'bar', 277 | beep: 'boop' 278 | } 279 | } 280 | }, 281 | 282 | // runs `./jobs/worker-15.js` **NOT** on start, but every 2 minutes 283 | { 284 | name: 'worker-15', 285 | timeout: false, // <-- specify `false` here to prevent default timeout (e.g. on start) 286 | interval: '2m' 287 | }, 288 | 289 | // runs `./jobs/worker-16.js` on January 1st, 2022 290 | // and at midnight on the 1st of every month thereafter 291 | { 292 | name: 'worker-16', 293 | date: dayjs('1-1-2022', 'M-D-YYYY').toDate(), 294 | cron: '0 0 1 * *' 295 | } 296 | ] 297 | }); 298 | 299 | // handle graceful reloads, pm2 support, and events like SIGHUP, SIGINT, etc. 300 | const graceful = new Graceful({ brees: [bree] }); 301 | graceful.listen(); 302 | 303 | // start all jobs (this is the equivalent of reloading a crontab): 304 | (async () => { 305 | await bree.start(); 306 | })(); 307 | 308 | /* 309 | // start only a specific job: 310 | (async () => { 311 | await bree.start('foo'); 312 | })(); 313 | 314 | // stop all jobs 315 | bree.stop(); 316 | 317 | // stop only a specific job: 318 | bree.stop('beep'); 319 | 320 | // run all jobs (this does not abide by timeout/interval/cron and spawns workers immediately) 321 | bree.run(); 322 | 323 | // run a specific job (...) 324 | bree.run('beep'); 325 | 326 | (async () => { 327 | // add a job array after initialization: 328 | const added = await bree.add(['boop']); // will return array of added jobs 329 | // this must then be started using one of the above methods 330 | 331 | // add a job after initialization: 332 | await bree.add('boop'); 333 | // this must then be started using one of the above methods 334 | })(); 335 | 336 | 337 | // remove a job after initialization: 338 | bree.remove('boop'); 339 | */ 340 | ``` 341 | 342 | For more examples - including setting up bree with TypeScript, ESModules, and implementing an Email Queue, see the [examples](./examples) folder. 343 | 344 | For a more complete demo using express see: [Bree Express Demo](https://github.com/breejs/express-example) 345 | 346 | 347 | ## Instance Options 348 | 349 | Here is the full list of options and their defaults. See [src/index.js](https://github.com/breejs/bree/blob/master/src/index.js) for more insight if necessary. 350 | 351 | | Property | Type | Default Value | Description | 352 | | ----------------------- | -------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 353 | | `logger` | Object | `console` | This is the default logger. **We recommend using [Cabin][cabin]** instead of using `console` as your default logger. Set this value to `false` to disable logging entirely (uses noop function) | 354 | | `root` | String | `path.resolve('jobs')` | Resolves a jobs folder relative to where the project is ran (the directory you call `node` in). Set this value to `false` to prevent requiring a root directory of jobs (e.g. if your jobs are not all in one directory). Set this to `path.join(__dirname, 'jobs')` to keep your jobs directory relative to the file where Bree is set up. | 355 | | `silenceRootCheckError` | Boolean | `false` | Silences errors from requiring the root folder. Set this to `false` if you do not want to see errors from this operation | 356 | | `doRootCheck` | Boolean | `true` | Attempts to `require` the root directory, when `jobs` is empty or `null`. Set this to `false` to prevent requiring the root directory | 357 | | `removeCompleted` | Boolean | `false` | Removes job upon completion. Set this to `true` in order to remove jobs from the array upon completion. | 358 | | `timeout` | Number | `0` | Default timeout for jobs (e.g. a value of `0` means that jobs will start on boot by default unless a job has a property of `timeout` or `interval` defined. Set this to `false` if you do not wish for a default value to be set for jobs. **This value does not apply to jobs with a property of `date`.** | 359 | | `interval` | Number | `0` | Default interval for jobs (e.g. a value of `0` means that there is no interval, and a value greater than zero indicates a default interval will be set with this value). **This value does not apply to jobs with a property of `cron`**. | 360 | | `jobs` | Array | `[]` | Defaults to an empty Array, but if the `root` directory has a `index.js` file, then it will be used. This allows you to keep your jobs and job definition index in the same place. See [Job Options](#job-options) below, and [Usage and Examples](#usage-and-examples) above for more insight. | 361 | | `hasSeconds` | Boolean | `false` | This value is passed to `later` for parsing jobs, and can be overridden on a per job basis. See [later cron parsing](https://breejs.github.io/later/parsers.html#cron) documentation for more insight. Note that setting this to `true` will automatically set `cronValidate` defaults to have `{ preset: 'default', override: { useSeconds: true } }` | 362 | | `cronValidate` | Object | `{}` | This value is passed to `cron-validate` for validation of cron expressions. See the [cron-validate](https://github.com/Airfooox/cron-validate) documentation for more insight. | 363 | | `closeWorkerAfterMs` | Number | `0` | If you set a value greater than `0` here, then it will terminate workers after this specified time (in milliseconds). **As of v6.0.0, workers now terminate after they have been signaled as "online" (as opposed to previous versions which did not take this into account and started the timer when jobs were initially "run").** By default there is no termination done, and jobs can run for infinite periods of time. | 364 | | `defaultRootIndex` | String | `index.js` | This value should be the file name inside of the `root` directory option (if you pass a `root` directory or use the default `root` String value (and your index file name is different than `index.js`). | 365 | | `defaultExtension` | String | `js` | This value can either be `js` or `mjs`. The default is `js`, and is the default extension added to jobs that are simply defined with a name and without a path. For example, if you define a job `test`, then it will look for `/path/to/root/test.js` as the file used for workers. | 366 | | `acceptedExtensions` | Array | `['.js', '.mjs']` | This defines all of the accepted extensions for file validation and job creation. Please note if you add to this list you must override the `createWorker` function to properly handle the new file types. | 367 | | `worker` | Object | `{}` | These are default options to pass when creating a `new Worker` instance. See the [Worker class](https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options) documentation for more insight. | 368 | | `outputWorkerMetadata` | Boolean | `false` | By default worker metadata is not passed to the second Object argument of `logger`. However if you set this to `true`, then `logger` will be invoked internally with two arguments (e.g. `logger.info('...', { worker: ... })`). This `worker` property contains `isMainThread` (Boolean), `resourceLimits` (Object), and `threadId` (String) properties; all of which correspond to [Workers][] metadata. This can be overridden on a per job basis. | 369 | | `errorHandler` | Function | `null` | Set this function to receive a callback when an error is encountered during worker execution (e.g. throws an exception) or when it exits with non-zero code (e.g. `process.exit(1)`). The callback receives two parameters `error` and `workerMetadata`. Important note, when this callback is present default error logging will not be executed. | 370 | | `workerMessageHandler` | Function | `null` | Set this function to receive a callback when a worker sends a message through [parentPort.postMessage](https://nodejs.org/docs/latest-v14.x/api/worker_threads.html#worker_threads_port_postmessage_value_transferlist). The callback receives at least two parameters `name` (of the worker) and `message` (coming from `postMessage`), if `outputWorkerMetadata` is enabled additional metadata will be sent to this handler. | 371 | 372 | 373 | ## Job Options 374 | 375 | See [Interval, Timeout, Date, and Cron Validate](#interval-timeout-date-and-cron-validation) below for more insight besides this table: 376 | 377 | | Property | Type | Description | 378 | | ---------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 379 | | `name` | String | The name of the job. This should match the base file path (e.g. `foo` if `foo.js` is located at `/path/to/jobs/foo.js`) unless `path` option is specified. A value of `index`, `index.js`, and `index.mjs` are reserved values and cannot be used here. | 380 | | `path` | String | The path of the job or function used for spawning a new [Worker][workers] with. If not specified, then it defaults to the value for `name` plus the default file extension specified under [Instance Options](#instance-options). | 381 | | `timeout` | Number, Object, String, or Boolean | Sets the duration in milliseconds before the job starts (it overrides the default inherited `timeout` as set in [Instance Options](#instance-options). A value of `0` indicates it will start immediately. This value can be a Number, String, or a Boolean of `false` (which indicates it will NOT inherit the default `timeout` from [Instance Options](#instance-options)). See [Job Interval and Timeout Values](#job-interval-and-timeout-values) below for more insight into how this value is parsed. | 382 | | `interval` | Number, Object, or String | Sets the duration in milliseconds for the job to repeat itself, otherwise known as its interval (it overrides the default inherited `interval` as set in [Instance Options](#instance-options)). A value of `0` indicates it will not repeat and there will be no interval. If the value is greater than `0` then this value will be used as the interval. See [Job Interval and Timeout Values](#job-interval-and-timeout-values) below for more insight into how this value is parsed. | 383 | | `date` | Date | This must be a valid JavaScript Date (we use `instance of Date` for comparison). If this value is in the past, then it is not run when jobs are started (or run manually). We recommend using [dayjs][] for creating this date, and then formatting it using the `toDate()` method (e.g. `dayjs().add('3, 'days').toDate()`). You could also use [moment][] or any other JavaScript date library, as long as you convert the value to a Date instance here. | 384 | | `cron` | String | A cron expression to use as the job's interval, which is validated against [cron-validate][] and parsed by [later][]. | 385 | | `hasSeconds` | Boolean | Overrides the [Instance Options](#instance-options) `hasSeconds` property if set. Note that setting this to `true` will automatically set `cronValidate` defaults to have `{ preset: 'default', override: { useSeconds: true } }` | 386 | | `cronValidate` | Object | Overrides the [Instance Options](#instance-options) `cronValidate` property if set. | 387 | | `closeWorkerAfterMs` | Number | Overrides the [Instance Options](#instance-options) `closeWorkerAfterMs` property if set. | 388 | | `worker` | Object | Overrides the [Instance Options](#instance-options) `worker` property if set. | 389 | | `outputWorkerMetadata` | Boolean | Overrides the [Instance Options](#instance-options) `outputWorkerMetadata` property if set. | 390 | 391 | 392 | ## Job Interval and Timeout Values 393 | 394 | These values can include Number, Object, and String variable types: 395 | 396 | * Number values indicates the number of milliseconds for the timeout or interval 397 | * Object values must be a [later][] schedule object value (e.g. `later.parse.cron('15 10 * * ? *'))`) 398 | * String values can be either a [later][], [human-interval][], or [ms][] String values (e.g. [later][] supports Strings such as `every 5 mins`, [human-interval][] supports Strings such as `3 days and 4 hours`, and [ms][] supports Strings such as `4h` for four hours) 399 | 400 | 401 | ## Listening for events 402 | 403 | Bree extends from [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter) and emits two events: 404 | 405 | * `worker created` with an argument of `name` 406 | * `worker deleted` with an argument of `name` 407 | 408 | If you'd like to know when your workers are created (or deleted), you can do so through this example: 409 | 410 | ```js 411 | bree.on('worker created', (name) => { 412 | console.log('worker created', name); 413 | console.log(bree.workers.get(name)); 414 | }); 415 | 416 | bree.on('worker deleted', (name) => { 417 | console.log('worker deleted', name); 418 | console.log(!bree.worker.has(name)); 419 | }); 420 | ``` 421 | 422 | 423 | ## Custom error/message handling 424 | 425 | If you'd like to override default behavior for worker error/message handling, provide a callback function as `errorHandler` or `workerMessageHandler` parameter when creating a Bree instance. 426 | 427 | > **NOTE:** Any `console.log` calls, from within the worker, will not be sent to `stdout`/`stderr` until the main thread is available. Furthermore, any `console.log` calls, from within the worker, will not be sent if the process is terminated before the message is printed. You should use `parentPort.postMessage()` alongside `errorHandler` or `workerMessageHandler` to print to `stdout`/`stderr` during worker execution. This is a known [bug](https://github.com/nodejs/node/issues/30491) for workers. 428 | 429 | An example use-case. If you want to call an external service to record an error (like Honeybadger, Sentry, etc.) along with logging the error internally. You can do so with: 430 | 431 | ```js 432 | const logger = ('../path/to/logger'); 433 | const errorService = ('../path/to/error-service'); 434 | 435 | new Bree({ 436 | jobs: [ 437 | { 438 | name: 'job that sometimes throws errors', 439 | path: jobFunction 440 | } 441 | ], 442 | errorHandler: (error, workerMetadata) => { 443 | // workerMetadata will be populated with extended worker information only if 444 | // Bree instance is initialized with parameter `workerMetadata: true` 445 | if (workerMetadata.threadId) { 446 | logger.info(`There was an error while running a worker ${workerMetadata.name} with thread ID: ${workerMetadata.threadId}`) 447 | } else { 448 | logger.info(`There was an error while running a worker ${workerMetadata.name}`) 449 | } 450 | 451 | logger.error(error); 452 | errorService.captureException(error); 453 | } 454 | }); 455 | ``` 456 | 457 | 458 | ## Cancellation, Retries, Stalled Jobs, and Graceful Reloading 459 | 460 | We recommend that you listen for "cancel" event in your worker paths. Doing so will allow you to handle graceful cancellation of jobs. For example, you could use [p-cancelable][] 461 | 462 | Here's a quick example of how to do that (e.g. `./jobs/some-worker.js`): 463 | 464 | ```js 465 | // 466 | const { parentPort } = require('worker_threads'); 467 | 468 | // ... 469 | 470 | function cancel() { 471 | // do cleanup here 472 | // (if you're using @ladjs/graceful, the max time this can run by default is 5s) 473 | 474 | // send a message to the parent that we're ready to terminate 475 | // (you could do `process.exit(0)` or `process.exit(1)` instead if desired 476 | // but this is a bit of a cleaner approach for worker termination 477 | if (parentPort) parentPort.postMessage('cancelled'); 478 | else process.exit(0); 479 | } 480 | 481 | if (parentPort) 482 | parentPort.once('message', message => { 483 | if (message === 'cancel') return cancel(); 484 | }); 485 | ``` 486 | 487 | If you'd like jobs to retry, simply wrap your usage of promises with [p-retry][]. 488 | 489 | We leave it up to you to have as much fine-grained control as you wish. 490 | 491 | See [@ladjs/graceful][lad-graceful] for more insight into how this package works. 492 | 493 | 494 | ## Interval, Timeout, Date, and Cron Validation 495 | 496 | If you need help writing cron expressions, you can reference [crontab.guru](https://crontab.guru/). 497 | 498 | We support [later][], [human-interval][], or [ms][] String values for both `timeout` and `interval`. 499 | 500 | If you pass a `cron` property, then it is validated against [cron-validate][]. 501 | 502 | You can pass a Date as the `date` property, but you cannot combine both `date` and `timeout`. 503 | 504 | If you do pass a Date, then it is only run if it is in the future. 505 | 506 | See [Job Interval and Timeout Values](#job-interval-and-timeout-values) above for more insight. 507 | 508 | 509 | ## Writing jobs with Promises and async-await 510 | 511 | If jobs are running with Node pre-[v14.8.0](https://nodejs.org/en/blog/release/v14.8.0/), which [enables top-level async-await](https://github.com/nodejs/node/commit/62bb2e757f) support, here is the working alternative: 512 | 513 | ```js 514 | const { parentPort } = require('worker_threads'); 515 | 516 | const delay = require('delay'); 517 | const ms = require('ms'); 518 | 519 | (async () => { 520 | // wait for a promise to finish 521 | await delay(ms('10s')); 522 | 523 | // signal to parent that the job is done 524 | if (parentPort) parentPort.postMessage('done'); 525 | else process.exit(0); 526 | })(); 527 | ``` 528 | 529 | 530 | ## Callbacks, Done, and Completion States 531 | 532 | To close out the worker and signal that it is done, you can simply `parentPort.postMessage('done');` and/or `process.exit(0)`. 533 | 534 | While writing your jobs (which will run in [worker][workers] threads), you should do one of the following: 535 | 536 | * Signal to the main thread that the process has completed by sending a "done" message (per the example above in [Writing jobs with Promises and async-await](#writing-jobs-with-promises-and-async-await)) 537 | * Exit the process if there is NOT an error with code `0` (e.g. `process.exit(0);`) 538 | * Throw an error if an error occurs (this will bubble up to the worker event error listener and terminate it) 539 | * Exit the process if there IS an error with code `1` (e.g. `process.exit(1)`) 540 | 541 | 542 | ## Long-running jobs 543 | 544 | If a job is already running, a new worker thread will not be spawned, instead `logger.error` will be invoked with an error message (no error will be thrown, don't worry). This is to prevent bad practices from being used. If you need something to be run more than one time, then make the job itself run the task multiple times. This approach gives you more fine-grained control. 545 | 546 | By default, workers run indefinitely and are not closed until they exit (e.g. via `process.exit(0)` or `process.exit(1)`, OR send to the parent port a "close" message, which will subsequently call `worker.close()` to close the worker thread. 547 | 548 | If you wish to specify a maximum time (in milliseconds) that a worker can run, then pass `closeWorkerAfterMs` (Number) either as a default option when creating a `new Bree()` instance (e.g. `new Bree({ closeWorkerAfterMs: ms('10s') })`) or on a per-job configuration, e.g. `{ name: 'beep', closeWorkerAfterMs: ms('5m') }`. 549 | 550 | As of v6.0.0 when you pass `closeWorkerAfterMs`, the timer will start once the worker is signaled as "online" (as opposed to previous versions which did not take this into account). 551 | 552 | 553 | ## Complex timeouts and intervals 554 | 555 | Since we use [later][], you can pass an instance of `later.parse.recur`, `later.parse.cron`, or `later.parse.text` as the `timeout` or `interval` property values (e.g. if you need to construct something manually). 556 | 557 | You can also use [dayjs][] to construct dates (e.g. from now or a certain date) to millisecond differences using `dayjs().diff(new Date(), 'milliseconds')`. You would then pass that returned Number value as `timeout` or `interval` as needed. 558 | 559 | 560 | ## Custom Worker Options 561 | 562 | You can pass a default worker configuration object as `new Bree({ worker: { ... } });`. 563 | 564 | These options are passed to the `options` argument when we internally invoke `new Worker(path, options)`. 565 | 566 | Additionally, you can pass custom worker options on a per-job basis through a `worker` property Object on the job definition. 567 | 568 | See [complete documentation](https://nodejs.org/api/worker_threads.html#worker_threads_new_worker_filename_options) for options (but you usually don't have to modify these). 569 | 570 | 571 | ## Using functions for jobs 572 | 573 | It is highly recommended to use files instead of functions. However, sometimes it is necessary to use functions. 574 | 575 | You can pass a function to be run as a job: 576 | 577 | ```js 578 | new Bree({ jobs: [someFunction] }); 579 | ``` 580 | 581 | (or) 582 | 583 | ```js 584 | new Bree({ 585 | jobs: [ 586 | { 587 | name: 'job with function', 588 | path: someFunction 589 | } 590 | ] 591 | }); 592 | ``` 593 | 594 | The function will be run as if it's in its own file, therefore no variables or dependencies will be shared from the local context by default. 595 | 596 | You should be able to pass data via `worker.workerData` (see [Custom Worker Options](#custom-worker-options)). 597 | 598 | Note that you cannot pass a built-in nor bound function. 599 | 600 | 601 | ## Typescript and Usage with Bundlers 602 | 603 | When working with a bundler or a tool that transpiles your code in some form or another, we recommend that your bundler is set up in a way that transforms both your application code and your jobs. Because your jobs are in their own files and are run in their own separate threads, they will not be part of your applications dependency graph and need to be setup as their own entry points. You need to ensure you have configured your tool to bundle your jobs into a jobs folder and keep them properly relative to your entry point folder. 604 | 605 | We recommend setting the `root` instance options to `path.join(__dirname,'jobs')` so that bree searches for your jobs folder relative to the file being ran. (by default it searches for jobs relative to where `node` is invoked). We recommend treating each job as an entry point and running all jobs through the same transformations as your app code. 606 | 607 | After an example transformation - you should expect the output in your `dist` folder to look like: 608 | 609 | ```sh 610 | - dist 611 | |-jobs 612 | |-job.js 613 | |-index.js 614 | ``` 615 | 616 | For some example TypeScript set ups - see the [examples folder](https://github.com/breejs/bree/tree/master/examples). 617 | 618 | For another alternative also see the [@breejs/ts-worker](https://github.com/breejs/ts-worker) plugin. 619 | 620 | 621 | ## Concurrency 622 | 623 | We recommend using the following packages in your workers for handling concurrency: 624 | 625 | * 626 | * 627 | * 628 | * 629 | 630 | 631 | ## Plugins 632 | 633 | Plugins can be added to Bree using a similar method to [Day.js](https://day.js.org/) 634 | 635 | To add a plugin use the following method: 636 | 637 | ```js 638 | Bree.extend(plugin, options); 639 | ``` 640 | 641 | ### Available Plugins 642 | 643 | * [API](https://github.com/breejs/api) 644 | * [TypeScript Worker](https://github.com/breejs/ts-worker) 645 | 646 | ### Creating plugins for Bree 647 | 648 | Plugins should be a function that recieves an `options` object and the `Bree` class: 649 | 650 | ```js 651 | const plugin = (options, Bree) => { 652 | /* plugin logic */ 653 | }; 654 | ``` 655 | 656 | 657 | ## Real-world usage 658 | 659 | More detailed examples can be found in [Forward Email][forward-email], [Lad][], and [Ghost][ghost]. 660 | 661 | 662 | ## Contributors 663 | 664 | | Name | Website | 665 | | ---------------- | --------------------------------- | 666 | | **Nick Baugh** | | 667 | | **shadowgate15** | | 668 | 669 | 670 | ## License 671 | 672 | [MIT](LICENSE) © [Nick Baugh](http://niftylettuce.com/) 673 | 674 | 675 | ## 676 | 677 | # 678 | 679 | [ms]: https://github.com/vercel/ms 680 | 681 | [human-interval]: https://github.com/agenda/human-interval 682 | 683 | [npm]: https://www.npmjs.com/ 684 | 685 | [yarn]: https://yarnpkg.com/ 686 | 687 | [workers]: https://nodejs.org/api/worker_threads.html 688 | 689 | [lad]: https://lad.js.org 690 | 691 | [p-retry]: https://github.com/sindresorhus/p-retry 692 | 693 | [p-cancelable]: https://github.com/sindresorhus/p-cancelable 694 | 695 | [later]: https://breejs.github.io/later/parsers.html 696 | 697 | [cron-validate]: https://github.com/Airfooox/cron-validate 698 | 699 | [forward-email]: https://github.com/forwardemail/forwardemail.net 700 | 701 | [dayjs]: https://github.com/iamkun/dayjs 702 | 703 | [redis]: https://redis.io/ 704 | 705 | [mongodb]: https://www.mongodb.com/ 706 | 707 | [lad-graceful]: https://github.com/ladjs/graceful 708 | 709 | [cabin]: https://cabinjs.com 710 | 711 | [moment]: https://momentjs.com 712 | 713 | [ghost]: https://ghost.org/ 714 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | 4 | ## Upgrading from v8 to v9 5 | 6 | **There are three major breaking changes:** 7 | 8 | 1. The usage of `bree.start()` and `bree.run()` methods must be changed to `await bree.start()` or `await bree.run()` (see the example below). 9 | 2. The usage of `bree.add()` must be changed to `await bree.add()` (since we now have asynchronous job validation and loading). 10 | 3. We have opted for `util.debuglog` as opposed to the userland `debug` package for debug logging. This means you must run `NODE_DEBUG=bree node app.js` as opposed to `DEBUG=bree node app.js`. 11 | 12 | Here is a complete list of the underlying changes made: 13 | 14 | * The method `start()` is now a Promise and you should either call `await bree.start()` or additionally call `await bree.init()` (an internal private method called by Bree) before attempting to start or use your Bree instance. 15 | 16 | > CJS: 17 | 18 | ```diff 19 | // if you're using CJS and you run such as `node app.js` 20 | -bree.start(); 21 | 22 | +// async/await iif style 23 | +(async () => { 24 | + await bree.start(); 25 | +})(); 26 | ``` 27 | 28 | > ESM: 29 | 30 | ```diff 31 | -bree.start(); 32 | 33 | +// leverage top-level await support (requires Node v14.8+) 34 | +await bree.start(); 35 | ``` 36 | 37 | * ESM module support has been added (per [#180](https://github.com/breejs/bree/issues/180)) by using dynamic imports to load the job Array (CJS is still supported). 38 | * For a majority of users, you do not need to make any changes to your code for v9 to work when you upgrade from v8 (**with the exception of now having to do `await bree.start()`**). 39 | * Top-level await support is added in Node v14.8+ (without requiring any Node flags), and therefore you can call `await bree.start();` (e.g. if your `package.json` has `"type": "module"` and/or the file extension you're running with Node is `.mjs`). Note that Bree still works in Node v12.17+ 40 | * The major difference is that Bree no longer initializes `this.config.jobs` in the constructor. 41 | * However we have dummy-proofed this new approach, and `bree.init()` will be invoked (if and only if it has not yet been invoked successfully) when you call `bree.start()` (or any similar method that accesses `this.config.jobs` internally). 42 | * Internal methods such as `validate` exported by `src/job-validator.js` are now asynchronous and return Promises (you do not need to worry about this unless you're doing something custom with these functions). 43 | 44 | * The default `root` option will now attempt to resolve an absolute file path for an index since we are using dynamic imports. If you are using `index.mjs` (as opposed to `index.js` then you will need to set a value for the option `defaultRootIndex`). See for more insight. 45 | 46 | * The method `add()` is now a Promise (you should call `await bree.add(jobs)`. 47 | 48 | * Several methods are now Promises in order to dummy-proof Bree for users that may not wish to call `await bree.init()` before calling `await bree.start()` (as per above). 49 | * The method `run()` is now a Promise (**but you do not need to `await` it** if you already called `await bree.start()` or `await bree.init()` or any of the methods listed below). 50 | * The method `stop()` is now a Promise (**but you do not need to `await` it** if you already called `await bree.start()` or `await bree.init()` or `await bree.run()` nor any of the methods listed below). 51 | 52 | * We've also refactored synchronous methods such as `fs.statSync` to `fs.promises.stat` and made job validation asynchronous. 53 | 54 | * Plugins that extend `bree.init()` may need rewritten, as `bree.init()` is now a Promise. 55 | 56 | * If you are on Node version <= v12.20.0, please upgrade to the latest Node v12, but preferably please upgrade to the latest Node LTS (at the time of this writing it is Node v16, but if you can't upgrade to Node v16, at least upgrade to Node v14). Node v12 is EOL as of April 2022. 57 | 58 | * Plugins will need to now `return init()` if you override the `init` function, for example (this is the change we had to make in `@breejs/ts-worker`): 59 | 60 | ```diff 61 | // define accepted extensions 62 | -Bree.prototype.init = function () { 63 | +Bree.prototype.init = async function () { 64 | if (!this.config.acceptedExtensions.includes('.ts')) 65 | this.config.acceptedExtensions.push('.ts'); 66 | 67 | - oldInit.bind(this)(); 68 | + return oldInit.call(this); 69 | }; 70 | ``` 71 | 72 | 73 | ## Upgrading from v7 to v8 74 | 75 | * Some fields have been converted from Objects to [Maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map): 76 | * `closeWorkerAfterMs` 77 | * `workers` 78 | * `timeouts` 79 | * `intervals` 80 | * Instead of accessing them via `bree.workers.NAME`, you should access them with `.get` (e.g. `bree.workers.get(NAME);`). 81 | * The method `start()` will now throw an error if the job has already started. 82 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: [ 3 | 'test/*.js', 4 | 'test/**/*.js', 5 | '!test/jobs', 6 | '!test/noIndexJobs', 7 | '!test/issues/**/jobs', 8 | '!test/issues/**/jobs-no-default-export' 9 | ], 10 | verbose: true, 11 | require: ['events.once/polyfill'] 12 | }; 13 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | docute.init({ 3 | debug: true, 4 | title: 'Bree', 5 | repo: 'breejs/bree', 6 | 'edit-link': 'https://github.com/breejs/bree/tree/master/', 7 | twitter: 'niftylettuce', 8 | nav: { 9 | default: [ 10 | { 11 | title: 'The best job scheduler for Node.js and JavaScript', 12 | path: '/' 13 | }, 14 | { 15 | title: 'Upgrading', 16 | path: '/UPGRADING' 17 | } 18 | ] 19 | }, 20 | // eslint-disable-next-line no-undef 21 | plugins: [docuteEmojify()] 22 | }); 23 | -------------------------------------------------------------------------------- /examples/commonjs/README.md: -------------------------------------------------------------------------------- 1 | # Commonjs Example 2 | 3 | This is the most basic Bree example. 4 | -------------------------------------------------------------------------------- /examples/commonjs/index.js: -------------------------------------------------------------------------------- 1 | const Bree = require('../../src/index.js'); 2 | 3 | const bree = new Bree({ 4 | jobs: ['job'] 5 | }); 6 | 7 | (async () => { 8 | await bree.start(); 9 | })(); 10 | -------------------------------------------------------------------------------- /examples/commonjs/jobs/job.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require('node:worker_threads'); 2 | const process = require('node:process'); 3 | 4 | console.log('Hello Commonjs!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/commonjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree-js-example", 3 | "version": "1.0.0", 4 | "description": "basic bree example", 5 | "main": "index.js", 6 | "author": "Spencer Snyder (https://spencersnyder.io/)", 7 | "license": "MIT" 8 | } 9 | -------------------------------------------------------------------------------- /examples/email-queue/README.md: -------------------------------------------------------------------------------- 1 | # Node.js Email Queue Job Scheduling Example 2 | 3 | A very common use case for a Node.js job scheduler is the sending of emails. 4 | 5 | We highly recommend you to use `bree` in combination with the `email-templates` package (made by the same author). Not only does it let you easily manage email templates, but it also automatically opens email previews in your browser for you during local development (using preview-email). 6 | 7 | ## Tips for production 8 | 9 | You will then create in your application a MongoDB "email" collection (or SQL table) with the following properties (or SQL columns): 10 | 11 | `template` (String) - the name of the email template 12 | `message` (Object) - a Nodemailer message object 13 | `locals` (Object) - an Object of locals that are passed to the template for rendering 14 | 15 | Here are optional properties/columns that you may want to also add (you'll need to implement the logic yourself as the example provided below does not include it): 16 | 17 | `send_at` (Date) - the Date you want to send an email (should default to current Date.now() when record is created, and can be overridden on a per job basis) 18 | `sent_at` (Date) - the Date that the email actually got sent (set by your job in Bree - you would use this when querying for emails to send, and specifically exclude any emails that have a sent_at value sent in your query) 19 | response (Object) - the mixed Object that is returned from Nodemailer sending the message (you should store this for historical data and so you can detect bounces) 20 | 21 | In your application, you will then need to save a new record into the collection or table (where you want to trigger an email to be queued) with values for these properties. 22 | 23 | ## Note on locking 24 | 25 | Lastly, you will need to set up Bree to fetch from the email collection every minute (you can configure how frequent you wish, however you may want to implement locking, by setting an `is_locked` Boolean property, and subsequently unlocking any jobs locked more than X minutes ago – but typically this is not needed unless you are sending thousands of emails and have a slow SMTP transport). 26 | -------------------------------------------------------------------------------- /examples/email-queue/emails/welcome/html.pug: -------------------------------------------------------------------------------- 1 | p Welcome to Tesla 2 | ul 3 | li 4 | strong Foo value: 5 | = ' ' 6 | = foo 7 | li 8 | strong Beep value: 9 | = ' ' 10 | = beep 11 | -------------------------------------------------------------------------------- /examples/email-queue/index.js: -------------------------------------------------------------------------------- 1 | const Bree = require('bree'); 2 | const Graceful = require('@ladjs/graceful'); 3 | const Cabin = require('cabin'); 4 | 5 | // 6 | // we recommend using Cabin as it is security-focused 7 | // and you can easily hook in Slack webhooks and more 8 | // 9 | // 10 | const logger = new Cabin(); 11 | 12 | const bree = new Bree({ 13 | logger, 14 | jobs: [ 15 | { 16 | // runs `./jobs/email.js` every minute 17 | name: 'email', 18 | interval: '10s', 19 | // run on start as well 20 | timeout: 0 21 | } 22 | ] 23 | }); 24 | 25 | // handle graceful reloads, pm2 support, and events like SIGHUP, SIGINT, etc. 26 | const graceful = new Graceful({ brees: [bree] }); 27 | graceful.listen(); 28 | 29 | // start all jobs (this is the equivalent of reloading a crontab): 30 | (async () => { 31 | await bree.start(); 32 | })(); 33 | -------------------------------------------------------------------------------- /examples/email-queue/jobs/email.js: -------------------------------------------------------------------------------- 1 | const os = require('node:os'); 2 | const process = require('node:process'); 3 | const { parentPort } = require('node:worker_threads'); 4 | const Cabin = require('cabin'); 5 | const Email = require('email-templates'); 6 | const pMap = require('p-map'); 7 | 8 | // 9 | // we recommend using Cabin as it is security-focused 10 | // and you can easily hook in Slack webhooks and more 11 | // 12 | // 13 | const logger = new Cabin(); 14 | 15 | // 16 | // we recommend using email-templates to 17 | // create, send, and manage your emails 18 | // 19 | // 20 | const email = new Email({ 21 | message: { 22 | // set a default from that will be set on all messages 23 | // (unless you specifically override it on an individual basis) 24 | from: 'elon@tesla.com' 25 | } 26 | }); 27 | 28 | // store boolean if the job is cancelled 29 | let isCancelled = false; 30 | 31 | // how many emails to send at once 32 | const concurrency = os.cpus().length; 33 | 34 | // example database results 35 | const results = [ 36 | { 37 | template: 'welcome', 38 | message: { 39 | to: 'elon@spacex.com' 40 | }, 41 | locals: { 42 | foo: 'bar', 43 | beep: 'boop' 44 | } 45 | } 46 | // ... 47 | ]; 48 | 49 | async function mapper(result) { 50 | // return early if the job was already cancelled 51 | if (isCancelled) return; 52 | try { 53 | const response = await email.send(result); 54 | logger.info('sent email', { response }); 55 | // here is where you would write to the database that it was sent 56 | return response; 57 | } catch (err) { 58 | // catch the error so if one email fails they all don't fail 59 | logger.error(err); 60 | } 61 | } 62 | 63 | // handle cancellation (this is a very simple example) 64 | if (parentPort) 65 | parentPort.once('message', (message) => { 66 | // 67 | // TODO: once we can manipulate concurrency option to p-map 68 | // we could make it `Number.MAX_VALUE` here to speed cancellation up 69 | // 70 | // 71 | if (message === 'cancel') isCancelled = true; 72 | }); 73 | 74 | (async () => { 75 | // query database results for emails not sent 76 | // and iterate over them with concurrency 77 | await pMap(results, mapper, { concurrency }); 78 | 79 | // signal to parent that the job is done 80 | if (parentPort) parentPort.postMessage('done'); 81 | else process.exit(0); 82 | })(); 83 | -------------------------------------------------------------------------------- /examples/email-queue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-queue", 3 | "version": "0.0.0", 4 | "description": "bree simple email queue example", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@ladjs/graceful": "^1.0.5", 8 | "bree": "^7.1.0", 9 | "cabin": "^9.0.4", 10 | "email-templates": "^8.0.8", 11 | "p-map": "4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/esmodules/README.md: -------------------------------------------------------------------------------- 1 | # Working with ESModules 2 | 3 | This is the most basic Bree example. 4 | 5 | Esmodules are implemented by setting `type` to `module` in the `package.json`. 6 | 7 | No other special steps need to be taken to use `bree` with ESModule sytax. 8 | -------------------------------------------------------------------------------- /examples/esmodules/index.js: -------------------------------------------------------------------------------- 1 | import Bree from '../../src/index.js'; 2 | 3 | const bree = new Bree({ 4 | jobs: ['job'] 5 | }); 6 | 7 | await bree.start(); 8 | -------------------------------------------------------------------------------- /examples/esmodules/jobs/job.js: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import process from 'node:process'; 3 | 4 | console.log('Hello ESM!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/esmodules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree-js-example", 3 | "version": "1.0.0", 4 | "description": "basic bree example", 5 | "main": "index.js", 6 | "author": "Spencer Snyder (https://spencersnyder.io/)", 7 | "license": "MIT", 8 | "type": "module" 9 | } 10 | -------------------------------------------------------------------------------- /examples/typescript-esm/README.md: -------------------------------------------------------------------------------- 1 | # Working with TypeScript 2 | 3 | This is the most basic bree example that shows a basic workflow for working with bree and typescript. 4 | 5 | ## Using the root option 6 | 7 | It is generally best to set the `bree` `root` option when using any tool that may be tranpiling your code. Generally it makes the most sense to set it to `path.join(__dirname, 'jobs')`. If you are compiling to ESModules, you should set it to `path.join(path.dirname(fileURLToPath(import.meta.url)), 'jobs')` as the global `__dirname` variable will be unavailable. 8 | 9 | ## Using TS Node 10 | 11 | [TS Node](https://github.com/TypeStrong/ts-node) is a project that uses hooks to compile typescript on the fly for fast and convenient TypeScript development. In order to use `bree` successfully with TS Node and to be able to write your jobs in TypeScript - one needs to run it in such a way that allows TS Node to transpile child process and worker scripts on the fly. 12 | 13 | We do this by implementing a `dev` script in our `package.json` as is outlined in the [TS Node docs.](https://github.com/TypeStrong/ts-node#other). 14 | 15 | `"dev": "TS_NODE=true NODE_OPTIONS=\"-r ts-node/register\" node ."` 16 | 17 | We further add a TS_NODE env var, as when in a TS Node environment, we need to append ts file extensions to our worker paths instead of the default JS. However, when we compile the code for production with the TypeScript compiler (running `tsc`), we need to keep the default js extensions on our worker paths. 18 | 19 | ## Compiling to ESModules 20 | 21 | To compile to ESModules first set `"type": "module"` in your package.json. Ensure your compiler options are set to compile to ESModules output. After that, we need to change our `dev` script in our `package.json` so TS Node [properly compiles workers](https://github.com/TypeStrong/ts-node#other) to ESModules as well. 22 | 23 | `"dev": "TS_NODE=true NODE_OPTIONS=\"--loader ts-node/esm\" node ."` 24 | 25 | In our `tsconfig.json` we need to ensure that our `moduleResolution` is set to `node` and that `module` is set to an option that will give the output as esmodule syntax as seen in this example. 26 | -------------------------------------------------------------------------------- /examples/typescript-esm/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as process from 'node:process'; 3 | import { fileURLToPath } from 'node:url'; 4 | import Bree from 'bree'; 5 | 6 | const bree = new Bree({ 7 | /** 8 | * Always set the root option when doing any type of 9 | * compiling with bree. This just makes it clearer where 10 | * bree should resolve the jobs folder from. By default it 11 | * resolves to the jobs folder relative to where the program 12 | * is executed. 13 | */ 14 | root: path.join(path.dirname(fileURLToPath(import.meta.url)), 'jobs'), 15 | /** 16 | * We only need the default extension to be "ts" 17 | * when we are running the app with ts-node - otherwise 18 | * the compiled-to-js code still needs to use JS 19 | */ 20 | defaultExtension: process.env.TS_NODE ? 'ts' : 'js', 21 | jobs: ['job'] 22 | }); 23 | 24 | await bree.start(); 25 | -------------------------------------------------------------------------------- /examples/typescript-esm/jobs/job.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import process from 'node:process'; 3 | 4 | console.log('Hello TypeScript with ESM!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/typescript-esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree-js-example", 3 | "version": "1.0.0", 4 | "description": "basic bree example", 5 | "main": "index.ts", 6 | "author": "Spencer Snyder (https://spencersnyder.io/)", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@types/node": "^16.11.7", 10 | "ts-node": "^10.4.0", 11 | "tslib": "^2.3.1", 12 | "typescript": "^4.4.4" 13 | }, 14 | "scripts": { 15 | "dev": "TS_NODE=true NODE_OPTIONS=\"--loader ts-node/esm\" node .", 16 | "start": "rm -rf dist && tsc && node dist/index.js" 17 | }, 18 | "dependencies": { 19 | "bree": "latest" 20 | }, 21 | "type": "module" 22 | } 23 | -------------------------------------------------------------------------------- /examples/typescript-esm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "module": "ES2020", 7 | "moduleResolution": "node" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/typescript-jobserver/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import * as path from 'node:path'; 3 | import Bree from 'bree'; 4 | 5 | const bree = new Bree({ 6 | /** 7 | * Always set the root option when doing any type of 8 | * compiling with bree. This just makes it clearer where 9 | * bree should resolve the jobs folder from. By default it 10 | * resolves to the jobs folder relative to where the program 11 | * is executed. 12 | */ 13 | root: path.join(__dirname, 'jobs'), 14 | /** 15 | * We only need the default extension to be "ts" 16 | * when we are running the app with ts-node - otherwise 17 | * the compiled-to-js code still needs to use JS 18 | */ 19 | defaultExtension: process.env.TS_NODE ? 'ts' : 'js', 20 | acceptedExtensions: ['.ts', '.js'], 21 | jobs: [ 22 | { cron: '* * * * *', name: 'job' }, 23 | { cron: '* * * * *', name: 'defaults' } 24 | ] 25 | }); 26 | 27 | (async () => { 28 | await bree.start(); 29 | })(); 30 | -------------------------------------------------------------------------------- /examples/typescript-jobserver/jobs/defaults.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import process from 'node:process'; 3 | 4 | console.log('Hello TypeScript Defaults!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/typescript-jobserver/jobs/job.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import process from 'node:process'; 3 | 4 | console.log('Hello TypeScript!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/typescript-jobserver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree-js-example-jobserver", 3 | "version": "1.0.0", 4 | "description": "basic bree typescript jobserver example", 5 | "main": "index.ts", 6 | "author": "Mike Valstar (https://valstar.dev/)", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@tsconfig/node20": "^20.1.2", 10 | "@types/node": "^20.8.0", 11 | "ts-node": "^10.9.1", 12 | "tslib": "^2.6.2", 13 | "typescript": "^5.2.2" 14 | }, 15 | "scripts": { 16 | "dev": "TS_NODE=true NODE_OPTIONS=\"-r ts-node/register\" node index.ts", 17 | "start": "rm -rf dist && tsc && node dist/index.js" 18 | }, 19 | "dependencies": { 20 | "bree": "latest" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/typescript-jobserver/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": ["**/*.ts", "jobs/index.js"], 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "moduleResolution": "NodeNext", 7 | "esModuleInterop": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/typescript/README.md: -------------------------------------------------------------------------------- 1 | # Working with TypeScript 2 | 3 | This is the most basic bree example that shows a basic workflow for working with bree and typescript. 4 | 5 | ## Using the root option 6 | 7 | It is generally best to set the `bree` `root` option when using any tool that may be tranpiling your code. Generally it makes the most sense to set it to `path.join(__dirname, 'jobs')`. If you are compiling to ESModules, you should set it to `path.join(path.dirname(fileURLToPath(import.meta.url)), 'jobs')` as the global `__dirname` variable will be unavailable. 8 | 9 | ## Using TS Node 10 | 11 | [TS Node](https://github.com/TypeStrong/ts-node) is a project that uses hooks to compile typescript on the fly for fast and convenient TypeScript development. In order to use `bree` successfully with TS Node and to be able to write your jobs in TypeScript - one needs to run it in such a way that allows TS Node to transpile child process and worker scripts on the fly. 12 | 13 | We do this by implementing a `dev` script in our `package.json` as is outlined in the [TS Node docs.](https://github.com/TypeStrong/ts-node#other). 14 | 15 | `"dev": "TS_NODE=true NODE_OPTIONS=\"-r ts-node/register\" node ."` 16 | 17 | We further add a TS_NODE env var, as when in a TS Node environment, we need to append ts file extensions to our worker paths instead of the default JS. However, when we compile the code for production with the TypeScript compiler (running `tsc`), we need to keep the default js extensions on our worker paths. 18 | 19 | ## Compiling to ESModules 20 | 21 | To compile to ESModules first set `"type": "module"` in your package.json. Ensure your compiler options are set to compile to ESModules output. After that, we need to change our `dev` script in our `package.json` so TS Node [properly compiles workers](https://github.com/TypeStrong/ts-node#other) to ESModules as well. 22 | 23 | `"dev": "TS_NODE=true NODE_OPTIONS=\"--loader ts-node/esm\" node ."` 24 | -------------------------------------------------------------------------------- /examples/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import * as path from 'node:path'; 3 | import Bree from 'bree'; 4 | 5 | const bree = new Bree({ 6 | /** 7 | * Always set the root option when doing any type of 8 | * compiling with bree. This just makes it clearer where 9 | * bree should resolve the jobs folder from. By default it 10 | * resolves to the jobs folder relative to where the program 11 | * is executed. 12 | */ 13 | root: path.join(__dirname, 'jobs'), 14 | /** 15 | * We only need the default extension to be "ts" 16 | * when we are running the app with ts-node - otherwise 17 | * the compiled-to-js code still needs to use JS 18 | */ 19 | defaultExtension: process.env.TS_NODE ? 'ts' : 'js', 20 | jobs: ['job'] 21 | }); 22 | 23 | (async () => { 24 | await bree.start(); 25 | })(); 26 | -------------------------------------------------------------------------------- /examples/typescript/jobs/job.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import process from 'node:process'; 3 | 4 | console.log('Hello TypeScript!'); 5 | 6 | // signal to parent that the job is done 7 | if (parentPort) parentPort.postMessage('done'); 8 | else process.exit(0); 9 | -------------------------------------------------------------------------------- /examples/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree-js-example", 3 | "version": "1.0.0", 4 | "description": "basic bree example", 5 | "main": "index.ts", 6 | "author": "Spencer Snyder (https://spencersnyder.io/)", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@types/node": "^16.11.7", 10 | "ts-node": "^10.4.0", 11 | "tslib": "^2.3.1", 12 | "typescript": "^4.4.4" 13 | }, 14 | "scripts": { 15 | "dev": "TS_NODE=true NODE_OPTIONS=\"-r ts-node/register\" node .", 16 | "start": "rm -rf dist && tsc && node dist/index.js" 17 | }, 18 | "dependencies": { 19 | "bree": "latest" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "moduleResolution": "node" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | The best job scheduler for Node.js and JavaScript 14 | 19 | 20 | 24 | 28 | 29 | 30 | 34 | 35 | 36 | 37 |
38 | 42 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /media/bree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/media/bree.png -------------------------------------------------------------------------------- /media/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/media/favicon.png -------------------------------------------------------------------------------- /media/footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/media/footer.png -------------------------------------------------------------------------------- /media/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/media/github.png -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/media/logo.png -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extension: ['.js'], 3 | 'report-dir': './coverage', 4 | 'temp-dir': './.nyc_output', 5 | reporter: ['lcov', 'html', 'text'], 6 | 'check-coverage': true, 7 | lines: 100, 8 | functions: 100, 9 | branches: 100 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bree", 3 | "description": "The best job scheduler for Node.js and JavaScript with cron, dates, ms, later, and human-friendly support. Works in Node v12.17.0+, uses worker threads to spawn sandboxed processes, and supports async/await, retries, throttling, concurrency, and cancelable promises (graceful shutdown). Simple, fast, and lightweight. Made for Forward Email and Lad.", 4 | "version": "9.2.4", 5 | "author": "Nick Baugh (http://niftylettuce.com/)", 6 | "bugs": { 7 | "url": "https://github.com/breejs/bree/issues", 8 | "email": "niftylettuce@gmail.com" 9 | }, 10 | "contributors": [ 11 | "Nick Baugh (http://niftylettuce.com/)", 12 | "shadowgate15 (https://github.com/shadowgate15)" 13 | ], 14 | "dependencies": { 15 | "@breejs/later": "^4.2.0", 16 | "boolean": "^3.2.0", 17 | "combine-errors": "^3.0.3", 18 | "cron-validate": "^1.4.5", 19 | "human-interval": "^2.0.1", 20 | "is-string-and-not-blank": "^0.0.2", 21 | "is-valid-path": "^0.1.1", 22 | "ms": "^2.1.3", 23 | "p-wait-for": "3", 24 | "safe-timers": "^1.1.0" 25 | }, 26 | "devDependencies": { 27 | "@commitlint/cli": "^19.3.0", 28 | "@commitlint/config-conventional": "^19.2.2", 29 | "@goto-bus-stop/envify": "^5.0.0", 30 | "@sinonjs/fake-timers": "^11.2.2", 31 | "@types/node": "^20.12.7", 32 | "@types/safe-timers": "^1.1.2", 33 | "@typescript-eslint/eslint-plugin": "6.21.0", 34 | "@typescript-eslint/parser": "6.21.0", 35 | "ava": "^5.3.1", 36 | "cross-env": "^7.0.3", 37 | "delay": "5", 38 | "eslint": "8.39.0", 39 | "eslint-config-xo-lass": "^2.0.1", 40 | "events.once": "^2.0.2", 41 | "fixpack": "^4.0.0", 42 | "husky": "8.0.3", 43 | "into-stream": "7", 44 | "lint-staged": "^15.2.2", 45 | "nyc": "^15.1.0", 46 | "remark-cli": "11", 47 | "remark-preset-github": "^4.0.4", 48 | "tsd": "^0.31.0", 49 | "xo": "0.54" 50 | }, 51 | "engines": { 52 | "node": ">=12.17.0 <13.0.0-0||>=13.2.0" 53 | }, 54 | "files": [ 55 | "src" 56 | ], 57 | "homepage": "https://github.com/breejs/bree", 58 | "keywords": [ 59 | "agenda", 60 | "async", 61 | "await", 62 | "bee", 63 | "bree", 64 | "bull", 65 | "callback", 66 | "cancel", 67 | "cancelable", 68 | "child", 69 | "clear", 70 | "cron", 71 | "cronjob", 72 | "crontab", 73 | "date", 74 | "dates", 75 | "day", 76 | "dayjs", 77 | "delay", 78 | "english", 79 | "express", 80 | "expression", 81 | "frequencies", 82 | "frequency", 83 | "frequent", 84 | "friendly", 85 | "graceful", 86 | "human", 87 | "humans", 88 | "interval", 89 | "job", 90 | "jobs", 91 | "js", 92 | "koa", 93 | "koatiming", 94 | "lad", 95 | "lass", 96 | "later", 97 | "moment", 98 | "momentjs", 99 | "mongo", 100 | "mongodb", 101 | "mongoose", 102 | "p-cancel", 103 | "p-cancelable", 104 | "p-retry", 105 | "parse", 106 | "parser", 107 | "pretty", 108 | "process", 109 | "processors", 110 | "promise", 111 | "promises", 112 | "queue", 113 | "queues", 114 | "readable", 115 | "recur", 116 | "recurring", 117 | "redis", 118 | "redis", 119 | "reload", 120 | "restart", 121 | "run", 122 | "runner", 123 | "schedule", 124 | "scheduler", 125 | "setup", 126 | "spawn", 127 | "tab", 128 | "task", 129 | "tasker", 130 | "time", 131 | "timeout", 132 | "timer", 133 | "timers", 134 | "translated", 135 | "universalify", 136 | "worker", 137 | "workers" 138 | ], 139 | "license": "MIT", 140 | "main": "src/index.js", 141 | "publishConfig": { 142 | "access": "public" 143 | }, 144 | "repository": { 145 | "type": "git", 146 | "url": "https://github.com/breejs/bree" 147 | }, 148 | "scripts": { 149 | "ava": "cross-env NODE_ENV=test ava", 150 | "lint": "npm run lint:js && npm run lint:md && fixpack", 151 | "lint:js": "xo --fix --ignore examples/", 152 | "lint:md": "remark . -qfo", 153 | "nyc": "cross-env NODE_ENV=test nyc ava", 154 | "prepare": "husky install", 155 | "pretest": "npm run lint", 156 | "test": "npm run ava && tsd", 157 | "test-coverage": "cross-env NODE_ENV=test nyc npm run test" 158 | }, 159 | "types": "src/index.d.ts" 160 | } 161 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | // Definitions by: Taylor Schley 2 | 3 | import { EventEmitter } from 'node:events'; 4 | import { type WorkerOptions, type Worker } from 'node:worker_threads'; 5 | import { type Timeout, type Interval } from 'safe-timers'; 6 | 7 | export = Bree; 8 | 9 | type AsyncFunction = (...args: A) => Promise; 10 | 11 | declare class Bree extends EventEmitter { 12 | config: Bree.BreeConfigs; 13 | 14 | closeWorkerAfterMs: Map; 15 | workers: Map; 16 | timeouts: Map; 17 | intervals: Map; 18 | 19 | isSchedule: (value: any) => boolean; 20 | getWorkerMetadata: ( 21 | name: string, 22 | meta?: Record 23 | ) => Record; 24 | 25 | run: AsyncFunction<[name?: string], void>; 26 | start: AsyncFunction<[name?: string], void>; 27 | stop: AsyncFunction<[name?: string], void>; 28 | add: AsyncFunction< 29 | [ 30 | jobs: 31 | | string 32 | | (() => void) 33 | | Bree.JobOptions 34 | | Array void) | Bree.JobOptions> 35 | ], 36 | void 37 | >; 38 | 39 | remove: AsyncFunction<[name?: string], void>; 40 | 41 | removeSafeTimer: (type: string, name: string) => void; 42 | 43 | validateJob: AsyncFunction< 44 | [ 45 | job: string | (() => void) | Bree.JobOptions, 46 | i: number, 47 | names: string[], 48 | config: Bree.BreeOptions 49 | ], 50 | void 51 | >; 52 | 53 | getName: (job: string | Record | (() => void)) => string; 54 | 55 | getHumanToMs: (_value: string) => number; 56 | parseValue: ( 57 | value: boolean | string | number | Record 58 | ) => number | boolean | Record; 59 | 60 | createWorker: (filename: string, options: Partial) => Worker; 61 | 62 | handleJobCompletion: (name: string) => void; 63 | 64 | // init: Promise() => void; 65 | // _init: boolean; 66 | 67 | constructor(config?: Bree.BreeOptions); 68 | } 69 | 70 | declare namespace Bree { 71 | type Job = { 72 | name: string; 73 | path: string | (() => void); 74 | timeout: number | string | boolean; 75 | interval: number | string; 76 | date?: Date; 77 | cron?: string; 78 | hasSeconds?: boolean; 79 | cronValidate?: Record; 80 | closeWorkerAfterMs?: number; 81 | worker?: Partial; 82 | outputWorkerMetadata?: boolean; 83 | timezone?: string; 84 | }; 85 | 86 | type JobOptions = Required> & Partial>; 87 | 88 | type BreeConfigs = { 89 | logger: BreeLogger | boolean; 90 | root: string | boolean; 91 | silenceRootCheckError: boolean; 92 | doRootCheck: boolean; 93 | removeCompleted: boolean; 94 | timeout: number | boolean; 95 | interval: number; 96 | timezone: string; 97 | jobs: Job[]; 98 | hasSeconds: boolean; 99 | cronValidate: Record; 100 | closeWorkerAfterMs: number; 101 | defaultRootIndex: string; 102 | defaultExtension: string; 103 | acceptedExtensions: string[]; 104 | worker: WorkerOptions; 105 | errorHandler?: (error: any, workerMetadata: any) => void; 106 | workerMessageHandler?: (message: any, workerMetadata: any) => void; 107 | outputWorkerMetadata: boolean; 108 | }; 109 | 110 | type BreeOptions = Partial> & { 111 | jobs?: Array void) | JobOptions>; 112 | }; 113 | 114 | type PluginFunc = (options: T, c: typeof Bree) => void; 115 | 116 | function extend(plugin: PluginFunc, options?: T): Bree; 117 | 118 | type BreeLogger = { 119 | info: (...args: any[]) => any; 120 | warn: (...args: any[]) => any; 121 | error: (...args: any[]) => any; 122 | }; 123 | } 124 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // 2 | // NOTE: we could use `node:` prefix but it is only supported in ESM v14.13.1+ and v12.20+ 3 | // and since this is a CJS module then it is only supported in v14.18+ and v16+ 4 | // 5 | const fs = require('node:fs'); 6 | const EventEmitter = require('node:events'); 7 | const { pathToFileURL } = require('node:url'); 8 | const { Worker } = require('node:worker_threads'); 9 | const { join, resolve } = require('node:path'); 10 | const { debuglog } = require('node:util'); 11 | const combineErrors = require('combine-errors'); 12 | const isSANB = require('is-string-and-not-blank'); 13 | const isValidPath = require('is-valid-path'); 14 | const later = require('@breejs/later'); 15 | const pWaitFor = require('p-wait-for'); 16 | const { setTimeout, setInterval } = require('safe-timers'); 17 | const { 18 | isSchedule, 19 | getName, 20 | getHumanToMs, 21 | parseValue, 22 | getJobNames 23 | } = require('./job-utils'); 24 | const buildJob = require('./job-builder'); 25 | const validateJob = require('./job-validator'); 26 | 27 | function omit(obj, props) { 28 | obj = { ...obj }; 29 | for (const prop of props) delete obj[prop]; 30 | return obj; 31 | } 32 | 33 | const debug = debuglog('bree'); 34 | 35 | class ImportError extends Error {} 36 | 37 | class Bree extends EventEmitter { 38 | constructor(config) { 39 | super(); 40 | this.config = { 41 | // We recommend using Cabin for logging 42 | // 43 | logger: console, 44 | // Set this to `false` to prevent requiring a root directory of jobs 45 | // (e.g. if your jobs are not all in one directory) 46 | root: resolve('jobs'), 47 | // Set this to `true` to silence root check error log 48 | silenceRootCheckError: false, 49 | // Set this to `false` to prevent requiring a root directory of jobs 50 | doRootCheck: true, 51 | // Remove jobs upon completion 52 | // (set this to `true` if you want jobs to removed from array upon completion) 53 | // this will not remove jobs when `stop` is called 54 | removeCompleted: false, 55 | // Default timeout for jobs 56 | // (set this to `false` if you do not wish for a default timeout to be set) 57 | timeout: 0, 58 | // Default interval for jobs 59 | // (set this to `0` for no interval, and > 0 for a default interval to be set) 60 | interval: 0, 61 | // Default timezone for jobs 62 | // Must be a IANA string (ie. 'America/New_York', 'EST', 'UTC', etc). 63 | // To use the system specified timezone, set this to 'local' or 'system'. 64 | timezone: 'local', 65 | // This is an Array of your job definitions (see README for examples) 66 | jobs: [], 67 | // 68 | // (can be overridden on a job basis with same prop name) 69 | hasSeconds: false, 70 | // 71 | cronValidate: {}, 72 | // If you set a value > 0 here, then it will terminate workers after this time (ms) 73 | closeWorkerAfterMs: 0, 74 | // Could also be mjs if desired 75 | // (this is the default extension if you just specify a job's name without ".js" or ".mjs") 76 | defaultRootIndex: 'index.js', 77 | defaultExtension: 'js', 78 | // an array of accepted extensions 79 | // NOTE: if you add to this array you must extend `createWorker` 80 | // to deal with the conversion to acceptable files for 81 | // Node Workers 82 | acceptedExtensions: ['.js', '.mjs'], 83 | // Default worker options to pass to ~`new Worker` 84 | // (can be overridden on a per job basis) 85 | // 86 | worker: {}, 87 | // Custom handler to execute when error events are emitted by the workers or when they exit 88 | // with non-zero code 89 | // pass in a callback function with following signature: `(error, workerMetadata) => { // custom handling here }` 90 | errorHandler: null, 91 | // Custom handler executed when a `message` event is received from a worker. 92 | // A special 'done' event is also broadcasted while leaving worker shutdown logic in place. 93 | workerMessageHandler: null, 94 | // 95 | // if you set this to `true`, then a second arg is passed to log output 96 | // and it will be an Object with `{ worker: Object }` set, for example: 97 | // (see the documentation at for more insight) 98 | // 99 | // logger.info('...', { 100 | // worker: { 101 | // isMainThread: Boolean 102 | // resourceLimits: Object, 103 | // threadId: String 104 | // } 105 | // }); 106 | // 107 | outputWorkerMetadata: false, 108 | ...config 109 | }; 110 | 111 | // `defaultExtension` option should not start with a period 112 | if (this.config.defaultExtension.indexOf('.') === 0) 113 | throw new Error( 114 | '`defaultExtension` should not start with a ".", please enter the file extension without a leading period' 115 | ); 116 | 117 | // Validate timezone string 118 | // `.toLocaleString()` will throw a `RangeError` if `timeZone` string 119 | // is bogus or not supported by the environment. 120 | if ( 121 | isSANB(this.config.timezone) && 122 | !['local', 'system'].includes(this.config.timezone) 123 | ) { 124 | new Date().toLocaleString('ia', { timeZone: this.config.timezone }); 125 | } 126 | 127 | // 128 | // if `hasSeconds` is `true` then ensure that 129 | // `cronValidate` object has `override` object with `useSeconds` set to `true` 130 | // 131 | // 132 | if (this.config.hasSeconds) { 133 | this.config.cronValidate = { 134 | ...this.config.cronValidate, 135 | preset: 136 | this.config.cronValidate && this.config.cronValidate.preset 137 | ? this.config.cronValidate.preset 138 | : 'default', 139 | override: { 140 | ...(this.config.cronValidate && this.config.cronValidate.override 141 | ? this.config.cronValidate.override 142 | : {}), 143 | useSeconds: true 144 | } 145 | }; 146 | } 147 | 148 | // validate acceptedExtensions 149 | if ( 150 | !this.config.acceptedExtensions || 151 | !Array.isArray(this.config.acceptedExtensions) 152 | ) { 153 | throw new TypeError('`acceptedExtensions` must be defined and an Array'); 154 | } 155 | 156 | // convert `false` logger option into noop 157 | // 158 | if (this.config.logger === false) 159 | this.config.logger = { 160 | /* istanbul ignore next */ 161 | info() {}, 162 | /* istanbul ignore next */ 163 | warn() {}, 164 | /* istanbul ignore next */ 165 | error() {} 166 | }; 167 | 168 | debug('config', this.config); 169 | 170 | this.closeWorkerAfterMs = new Map(); 171 | this.workers = new Map(); 172 | this.timeouts = new Map(); 173 | this.intervals = new Map(); 174 | 175 | this.isSchedule = isSchedule; 176 | this.getWorkerMetadata = this.getWorkerMetadata.bind(this); 177 | this.run = this.run.bind(this); 178 | this.start = this.start.bind(this); 179 | this.stop = this.stop.bind(this); 180 | this.add = this.add.bind(this); 181 | this.remove = this.remove.bind(this); 182 | this.removeSafeTimer = this.removeSafeTimer.bind(this); 183 | this.handleJobCompletion = this.handleJobCompletion.bind(this); 184 | 185 | this.validateJob = validateJob; 186 | this.getName = getName; 187 | this.getHumanToMs = getHumanToMs; 188 | this.parseValue = parseValue; 189 | 190 | // so plugins can extend constructor 191 | this.init = this.init.bind(this); 192 | 193 | // store whether init was successful 194 | this._init = false; 195 | 196 | debug('jobs', this.config.jobs); 197 | } 198 | 199 | async init() { 200 | debug('init'); 201 | 202 | // Validate root 203 | // 204 | if ( 205 | isSANB(this.config.root) /* istanbul ignore next */ && 206 | isValidPath(this.config.root) 207 | ) { 208 | const stats = await fs.promises.stat(this.config.root); 209 | if (!stats.isDirectory()) { 210 | throw new Error(`Root directory of ${this.config.root} does not exist`); 211 | } 212 | } 213 | 214 | // Validate timeout 215 | this.config.timeout = this.parseValue(this.config.timeout); 216 | debug('timeout', this.config.timeout); 217 | 218 | // Validate interval 219 | this.config.interval = this.parseValue(this.config.interval); 220 | debug('interval', this.config.interval); 221 | 222 | // 223 | // if `this.config.jobs` is an empty array 224 | // then we should try to load `jobs/index.js` 225 | // 226 | debug('root', this.config.root); 227 | debug('doRootCheck', this.config.doRootCheck); 228 | debug('jobs', this.config.jobs); 229 | if ( 230 | this.config.root && 231 | this.config.doRootCheck && 232 | (!Array.isArray(this.config.jobs) || this.config.jobs.length === 0) 233 | ) { 234 | try { 235 | const importPath = join(this.config.root, this.config.defaultRootIndex); 236 | debug('importPath', importPath); 237 | const importUrl = pathToFileURL(importPath).toString(); 238 | debug('importUrl', importUrl); 239 | // hint: import statement expect a esm-url-string, not a file-path-string (https://github.com/breejs/bree/issues/202) 240 | // otherwise the following error is expected: 241 | // Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data are supported by the default ESM loader. 242 | // On Windows, absolute paths must be valid file:// URLs. 243 | const obj = await import(importUrl); 244 | if (typeof obj.default !== 'object') { 245 | throw new ImportError( 246 | `Root index file missing default export at: ${importPath}` 247 | ); 248 | } 249 | 250 | this.config.jobs = obj.default; 251 | } catch (err) { 252 | debug(err); 253 | 254 | // 255 | // NOTE: this is only applicable for Node <= 12.20.0 256 | // 257 | /* istanbul ignore next */ 258 | if (err.message === 'Not supported') throw err; 259 | if (err instanceof ImportError) throw err; 260 | if (!this.config.silenceRootCheckError) { 261 | this.config.logger.error(err); 262 | } 263 | } 264 | } 265 | 266 | // 267 | // validate jobs 268 | // 269 | if (!Array.isArray(this.config.jobs)) { 270 | throw new TypeError('Jobs must be an Array'); 271 | } 272 | 273 | // Provide human-friendly errors for complex configurations 274 | const errors = []; 275 | 276 | /* 277 | Jobs = [ 278 | 'name', 279 | { name: 'boot' }, 280 | { name: 'timeout', timeout: ms('3s') }, 281 | { name: 'cron', cron: '* * * * *' }, 282 | { name: 'cron with timeout', timeout: '3s', cron: '* * * * *' }, 283 | { name: 'interval', interval: ms('4s') } 284 | { name: 'interval', path: '/some/path/to/script.js', interval: ms('4s') }, 285 | { name: 'timeout', timeout: 'three minutes' }, 286 | { name: 'interval', interval: 'one minute' }, 287 | { name: 'timeout', timeout: '3s' }, 288 | { name: 'interval', interval: '30d' }, 289 | { name: 'schedule object', interval: { schedules: [] } } 290 | ] 291 | */ 292 | 293 | this.config.jobs = await Promise.all( 294 | this.config.jobs.map(async (job, index) => { 295 | try { 296 | const names = getJobNames(this.config.jobs, index); 297 | await validateJob(job, index, names, this.config); 298 | return buildJob(job, this.config); 299 | } catch (err) { 300 | errors.push(err); 301 | } 302 | }) 303 | ); 304 | 305 | // If there were any errors then throw them 306 | if (errors.length > 0) { 307 | throw combineErrors(errors); 308 | } 309 | 310 | // Otherwise set that init was called successfully 311 | this._init = true; 312 | debug('init was successful'); 313 | } 314 | 315 | getWorkerMetadata(name, meta = {}) { 316 | debug('getWorkerMetadata', name, { meta }); 317 | 318 | if (!this._init) 319 | throw new Error( 320 | 'bree.init() was not called, see ' 321 | ); 322 | 323 | const job = this.config.jobs.find((j) => j.name === name); 324 | if (!job) { 325 | throw new Error(`Job "${name}" does not exist`); 326 | } 327 | 328 | if (!this.config.outputWorkerMetadata && !job.outputWorkerMetadata) { 329 | return meta && (meta.err !== undefined || meta.message !== undefined) 330 | ? meta 331 | : undefined; 332 | } 333 | 334 | if (this.workers.has(name)) { 335 | const worker = this.workers.get(name); 336 | 337 | return { 338 | ...meta, 339 | worker: { 340 | isMainThread: worker.isMainThread, 341 | resourceLimits: worker.resourceLimits, 342 | threadId: worker.threadId 343 | } 344 | }; 345 | } 346 | 347 | return meta; 348 | } 349 | 350 | async run(name) { 351 | debug('run', name); 352 | 353 | if (!this._init) await this.init(); 354 | 355 | if (name) { 356 | const job = this.config.jobs.find((j) => j.name === name); 357 | if (!job) { 358 | throw new Error(`Job "${name}" does not exist`); 359 | } 360 | 361 | if (this.workers.has(name)) { 362 | this.config.logger.warn( 363 | new Error(`Job "${name}" is already running`), 364 | this.getWorkerMetadata(name) 365 | ); 366 | return; 367 | } 368 | 369 | debug('starting worker', name); 370 | const object = { 371 | ...this.config.worker, 372 | ...job.worker, 373 | workerData: { 374 | job: { 375 | ...job, 376 | ...(job.worker ? { worker: omit(job.worker, ['env']) } : {}) 377 | }, 378 | ...(this.config.worker && this.config.worker.workerData 379 | ? this.config.worker.workerData 380 | : {}), 381 | ...(job.worker && job.worker.workerData ? job.worker.workerData : {}) 382 | } 383 | }; 384 | this.workers.set(name, this.createWorker(job.path, object)); 385 | this.emit('worker created', name); 386 | debug('worker started', name); 387 | 388 | const prefix = `Worker for job "${name}"`; 389 | this.workers.get(name).on('online', () => { 390 | // If we specified a value for `closeWorkerAfterMs` 391 | // then we need to terminate it after that execution time 392 | const closeWorkerAfterMs = Number.isFinite(job.closeWorkerAfterMs) 393 | ? job.closeWorkerAfterMs 394 | : this.config.closeWorkerAfterMs; 395 | if (Number.isFinite(closeWorkerAfterMs) && closeWorkerAfterMs > 0) { 396 | debug('worker has close set', name, closeWorkerAfterMs); 397 | this.closeWorkerAfterMs.set( 398 | name, 399 | setTimeout(() => { 400 | /* istanbul ignore else */ 401 | if (this.workers.has(name)) { 402 | debug('worker has been terminated', name); 403 | this.workers.get(name).terminate(); 404 | } 405 | }, closeWorkerAfterMs) 406 | ); 407 | } 408 | 409 | this.config.logger.info( 410 | `${prefix} online`, 411 | this.getWorkerMetadata(name) 412 | ); 413 | }); 414 | this.workers.get(name).on('message', (message) => { 415 | const metadata = this.getWorkerMetadata(name, { message }); 416 | 417 | if (this.config.workerMessageHandler) { 418 | this.config.workerMessageHandler({ 419 | name, 420 | ...metadata 421 | }); 422 | } else if (message === 'done') { 423 | this.config.logger.info(`${prefix} signaled completion`, metadata); 424 | } else { 425 | this.config.logger.info(`${prefix} sent a message`, metadata); 426 | } 427 | 428 | if (message === 'done') { 429 | const worker = this.workers.get(name); 430 | worker.removeAllListeners('message'); 431 | worker.removeAllListeners('exit'); 432 | worker.terminate(); 433 | this.workers.delete(name); 434 | 435 | this.handleJobCompletion(name); 436 | 437 | this.emit('worker deleted', name); 438 | } 439 | }); 440 | // NOTE: you cannot catch messageerror since it is a Node internal 441 | // (if anyone has any idea how to catch this in tests let us know) 442 | /* istanbul ignore next */ 443 | this.workers.get(name).on('messageerror', (err) => { 444 | if (this.config.errorHandler) { 445 | this.config.errorHandler(err, { 446 | name, 447 | ...this.getWorkerMetadata(name, { err }) 448 | }); 449 | } else { 450 | this.config.logger.error( 451 | `${prefix} had a message error`, 452 | this.getWorkerMetadata(name, { err }) 453 | ); 454 | } 455 | }); 456 | this.workers.get(name).on('error', (err) => { 457 | if (this.config.errorHandler) { 458 | this.config.errorHandler(err, { 459 | name, 460 | ...this.getWorkerMetadata(name, { err }) 461 | }); 462 | } else { 463 | this.config.logger.error( 464 | `${prefix} had an error`, 465 | this.getWorkerMetadata(name, { err }) 466 | ); 467 | } 468 | }); 469 | this.workers.get(name).on('exit', (code) => { 470 | const level = code === 0 ? 'info' : 'error'; 471 | if (level === 'error' && this.config.errorHandler) { 472 | this.config.errorHandler( 473 | new Error(`${prefix} exited with code ${code}`), 474 | { 475 | name, 476 | ...this.getWorkerMetadata(name) 477 | } 478 | ); 479 | } else { 480 | this.config.logger[level]( 481 | `${prefix} exited with code ${code}`, 482 | this.getWorkerMetadata(name) 483 | ); 484 | } 485 | 486 | this.workers.delete(name); 487 | 488 | this.handleJobCompletion(name); 489 | 490 | this.emit('worker deleted', name); 491 | }); 492 | return; 493 | } 494 | 495 | for (const job of this.config.jobs) { 496 | this.run(job.name); 497 | } 498 | } 499 | 500 | async start(name) { 501 | debug('start', name); 502 | 503 | if (!this._init) await this.init(); 504 | 505 | if (name) { 506 | const job = this.config.jobs.find((j) => j.name === name); 507 | if (!job) { 508 | throw new Error(`Job ${name} does not exist`); 509 | } 510 | 511 | if ( 512 | this.timeouts.has(name) || 513 | this.intervals.has(name) || 514 | this.workers.has(name) 515 | ) { 516 | throw new Error(`Job "${name}" is already started`); 517 | } 518 | 519 | debug('job', job); 520 | 521 | // Check for date and if it is in the past then don't run it 522 | if (job.date instanceof Date) { 523 | debug('job date', job); 524 | if (job.date.getTime() < Date.now()) { 525 | debug('job date was in the past'); 526 | // not throwing an error so that jobs can be set with a specifc date 527 | // and only run on that date then never run again without changing config 528 | this.config.logger.warn( 529 | `Job "${name}" was skipped because it was in the past.` 530 | ); 531 | this.emit('job past', name); 532 | return; 533 | } 534 | 535 | this.timeouts.set( 536 | name, 537 | setTimeout(() => { 538 | this.run(name); 539 | if (this.isSchedule(job.interval)) { 540 | debug('job.interval is schedule', job); 541 | this.intervals.set( 542 | name, 543 | later.setInterval( 544 | () => this.run(name), 545 | job.interval, 546 | job.timezone 547 | ) 548 | ); 549 | } else if (Number.isFinite(job.interval) && job.interval > 0) { 550 | debug('job.interval is finite', job); 551 | this.intervals.set( 552 | name, 553 | setInterval(() => this.run(name), job.interval) 554 | ); 555 | } else { 556 | debug('job.date was scheduled to run only once', job); 557 | } 558 | 559 | this.timeouts.delete(name); 560 | }, job.date.getTime() - Date.now()) 561 | ); 562 | return; 563 | } 564 | 565 | // This is only complex because both timeout and interval can be a schedule 566 | if (this.isSchedule(job.timeout)) { 567 | debug('job timeout is schedule', job); 568 | this.timeouts.set( 569 | name, 570 | later.setTimeout( 571 | () => { 572 | this.run(name); 573 | if (this.isSchedule(job.interval)) { 574 | debug('job.interval is schedule', job); 575 | this.intervals.set( 576 | name, 577 | later.setInterval( 578 | () => this.run(name), 579 | job.interval, 580 | job.timezone 581 | ) 582 | ); 583 | } else if (Number.isFinite(job.interval) && job.interval > 0) { 584 | debug('job.interval is finite', job); 585 | this.intervals.set( 586 | name, 587 | setInterval(() => this.run(name), job.interval) 588 | ); 589 | } 590 | 591 | this.timeouts.delete(name); 592 | }, 593 | job.timeout, 594 | job.timezone 595 | ) 596 | ); 597 | return; 598 | } 599 | 600 | if (Number.isFinite(job.timeout)) { 601 | debug('job timeout is finite', job); 602 | this.timeouts.set( 603 | name, 604 | setTimeout(() => { 605 | this.run(name); 606 | 607 | if (this.isSchedule(job.interval)) { 608 | debug('job.interval is schedule', job); 609 | this.intervals.set( 610 | name, 611 | later.setInterval( 612 | () => this.run(name), 613 | job.interval, 614 | job.timezone 615 | ) 616 | ); 617 | } else if (Number.isFinite(job.interval) && job.interval > 0) { 618 | debug('job.interval is finite', job.interval); 619 | this.intervals.set( 620 | name, 621 | setInterval(() => this.run(name), job.interval) 622 | ); 623 | } 624 | 625 | this.timeouts.delete(name); 626 | }, job.timeout) 627 | ); 628 | } else if (this.isSchedule(job.interval)) { 629 | debug('job.interval is schedule', job); 630 | this.intervals.set( 631 | name, 632 | later.setInterval(() => this.run(name), job.interval, job.timezone) 633 | ); 634 | } else if (Number.isFinite(job.interval) && job.interval > 0) { 635 | debug('job.interval is finite', job); 636 | this.intervals.set( 637 | name, 638 | setInterval(() => this.run(name), job.interval) 639 | ); 640 | } 641 | 642 | return; 643 | } 644 | 645 | for (const job of this.config.jobs) { 646 | this.start(job.name); 647 | } 648 | } 649 | 650 | async stop(name) { 651 | debug('stop', name); 652 | 653 | if (!this._init) await this.init(); 654 | 655 | if (name) { 656 | this.removeSafeTimer('timeouts', name); 657 | this.removeSafeTimer('intervals', name); 658 | 659 | if (this.workers.has(name)) { 660 | this.workers.get(name).once('message', (message) => { 661 | if (message === 'cancelled') { 662 | this.config.logger.info( 663 | `Gracefully cancelled worker for job "${name}"`, 664 | this.getWorkerMetadata(name) 665 | ); 666 | this.workers.get(name).terminate(); 667 | } 668 | }); 669 | this.workers.get(name).postMessage('cancel'); 670 | } 671 | 672 | this.removeSafeTimer('closeWorkerAfterMs', name); 673 | 674 | return pWaitFor(() => !this.workers.has(name)); 675 | } 676 | 677 | for (const job of this.config.jobs) { 678 | this.stop(job.name); 679 | } 680 | 681 | return pWaitFor(() => this.workers.size === 0); 682 | } 683 | 684 | async add(jobs) { 685 | debug('add', jobs); 686 | 687 | if (!this._init) await this.init(); 688 | 689 | // 690 | // make sure jobs is an array 691 | // 692 | if (!Array.isArray(jobs)) { 693 | jobs = [jobs]; 694 | } 695 | 696 | const errors = []; 697 | const addedJobs = []; 698 | 699 | await Promise.all( 700 | // handle `jobs` in case it is a Set or an Array 701 | // 702 | [...jobs].map(async (job_, index) => { 703 | try { 704 | const names = [ 705 | ...getJobNames(jobs, index), 706 | ...getJobNames(this.config.jobs) 707 | ]; 708 | 709 | await validateJob(job_, index, names, this.config); 710 | const job = buildJob(job_, this.config); 711 | 712 | addedJobs.push(job); 713 | } catch (err) { 714 | errors.push(err); 715 | } 716 | }) 717 | ); 718 | 719 | debug('jobs added', this.config.jobs); 720 | 721 | // If there were any errors then throw them 722 | if (errors.length > 0) { 723 | throw combineErrors(errors); 724 | } 725 | 726 | this.config.jobs.push(...addedJobs); 727 | return addedJobs; 728 | } 729 | 730 | async remove(name) { 731 | debug('remove', name); 732 | 733 | if (!this._init) await this.init(); 734 | 735 | const job = this.config.jobs.find((j) => j.name === name); 736 | if (!job) { 737 | throw new Error(`Job "${name}" does not exist`); 738 | } 739 | 740 | // make sure it also closes any open workers 741 | await this.stop(name); 742 | 743 | this.config.jobs = this.config.jobs.filter((j) => j.name !== name); 744 | } 745 | 746 | /** 747 | * A friendly helper to clear safe-timers timeout and interval 748 | * @param {string} type 749 | * @param {string} name 750 | */ 751 | removeSafeTimer(type, name) { 752 | if (this[type].has(name)) { 753 | const timer = this[type].get(name); 754 | 755 | if (typeof timer === 'object' && typeof timer.clear === 'function') { 756 | timer.clear(); 757 | } 758 | 759 | this[type].delete(name); 760 | } 761 | } 762 | 763 | createWorker(filename, options) { 764 | return new Worker(filename, options); 765 | } 766 | 767 | handleJobCompletion(name) { 768 | debug('handleJobCompletion', name); 769 | 770 | if (!this._init) 771 | throw new Error( 772 | 'bree.init() was not called, see ' 773 | ); 774 | 775 | // remove closeWorkerAfterMs if exist 776 | this.removeSafeTimer('closeWorkerAfterMs', name); 777 | 778 | if ( 779 | this.config.removeCompleted && 780 | !this.timeouts.has(name) && 781 | !this.intervals.has(name) 782 | ) { 783 | this.config.jobs = this.config.jobs.filter((j) => j.name !== name); 784 | } 785 | } 786 | } 787 | 788 | // plugins inspired by Dayjs 789 | Bree.extend = (plugin, options) => { 790 | if (!plugin.$i) { 791 | // install plugin only once 792 | plugin(options, Bree); 793 | plugin.$i = true; 794 | } 795 | 796 | return Bree; 797 | }; 798 | 799 | module.exports = Bree; 800 | -------------------------------------------------------------------------------- /src/job-builder.js: -------------------------------------------------------------------------------- 1 | const { join } = require('node:path'); 2 | const isSANB = require('is-string-and-not-blank'); 3 | const isValidPath = require('is-valid-path'); 4 | const later = require('@breejs/later'); 5 | const { boolean } = require('boolean'); 6 | const { isSchedule, parseValue, getJobPath } = require('./job-utils'); 7 | 8 | later.date.localTime(); 9 | 10 | // eslint-disable-next-line complexity 11 | const buildJob = (job, config) => { 12 | if (isSANB(job)) { 13 | const path = join( 14 | config.root, 15 | getJobPath(job, config.acceptedExtensions, config.defaultExtension) 16 | ); 17 | 18 | const jobObject = { 19 | name: job, 20 | path, 21 | timeout: config.timeout, 22 | interval: config.interval 23 | }; 24 | if (isSANB(config.timezone)) { 25 | jobObject.timezone = config.timezone; 26 | } 27 | 28 | return jobObject; 29 | } 30 | 31 | if (typeof job === 'function') { 32 | const path = `(${job.toString()})()`; 33 | 34 | const jobObject = { 35 | name: job.name, 36 | path, 37 | worker: { eval: true }, 38 | timeout: config.timeout, 39 | interval: config.interval 40 | }; 41 | if (isSANB(config.timezone)) { 42 | jobObject.timezone = config.timezone; 43 | } 44 | 45 | return jobObject; 46 | } 47 | 48 | // Process job.path 49 | if (typeof job.path === 'function') { 50 | const path = `(${job.path.toString()})()`; 51 | 52 | job.path = path; 53 | job.worker = { 54 | eval: true, 55 | ...job.worker 56 | }; 57 | } else { 58 | const path = isSANB(job.path) 59 | ? job.path 60 | : join( 61 | config.root, 62 | getJobPath( 63 | job.name, 64 | config.acceptedExtensions, 65 | config.defaultExtension 66 | ) 67 | ); 68 | 69 | if (isValidPath(path)) { 70 | job.path = path; 71 | } else { 72 | // Assume that it's a transformed eval string 73 | job.worker = { 74 | eval: true, 75 | ...job.worker 76 | }; 77 | } 78 | } 79 | 80 | if (job.timeout !== undefined) { 81 | job.timeout = parseValue(job.timeout); 82 | } 83 | 84 | if (job.interval !== undefined) { 85 | job.interval = parseValue(job.interval); 86 | } 87 | 88 | // Build cron 89 | if (job.cron !== undefined) { 90 | if (isSchedule(job.cron)) { 91 | job.interval = job.cron; 92 | // Delete job.cron; 93 | } else { 94 | job.interval = later.parse.cron( 95 | job.cron, 96 | boolean( 97 | job.hasSeconds === undefined ? config.hasSeconds : job.hasSeconds 98 | ) 99 | ); 100 | } 101 | } 102 | 103 | // If timeout was undefined, cron was undefined, 104 | // and date was undefined then set the default 105 | // (as long as the default timeout is >= 0) 106 | if ( 107 | Number.isFinite(config.timeout) && 108 | config.timeout >= 0 && 109 | job.timeout === undefined && 110 | job.cron === undefined && 111 | job.date === undefined && 112 | job.interval === undefined 113 | ) { 114 | job.timeout = config.timeout; 115 | } 116 | 117 | // If interval was undefined, cron was undefined, 118 | // and date was undefined then set the default 119 | // (as long as the default interval is > 0, or it was a schedule, or it was valid) 120 | if ( 121 | ((Number.isFinite(config.interval) && config.interval > 0) || 122 | isSchedule(config.interval)) && 123 | job.interval === undefined && 124 | job.cron === undefined && 125 | job.date === undefined 126 | ) { 127 | job.interval = config.interval; 128 | } 129 | 130 | if (isSANB(config.timezone) && !job.timezone) { 131 | job.timezone = config.timezone; 132 | } 133 | 134 | return job; 135 | }; 136 | 137 | module.exports = buildJob; 138 | -------------------------------------------------------------------------------- /src/job-utils.js: -------------------------------------------------------------------------------- 1 | const humanInterval = require('human-interval'); 2 | const isSANB = require('is-string-and-not-blank'); 3 | const later = require('@breejs/later'); 4 | const ms = require('ms'); 5 | 6 | /** 7 | * Naively checks if passed value is of later.js schedule format (https://breejs.github.io/later/schedules.html) 8 | * 9 | * @param {*} value to check for schedule format 10 | * @returns {boolean} 11 | */ 12 | const isSchedule = (value) => { 13 | return typeof value === 'object' && Array.isArray(value.schedules); 14 | }; 15 | 16 | /** 17 | * Extracts job name from job definition 18 | * 19 | * @param {string | Object | Function} job definition 20 | * @returns {string} 21 | */ 22 | const getName = (job) => { 23 | if (isSANB(job)) return job; 24 | if (typeof job === 'object' && isSANB(job.name)) return job.name; 25 | if (typeof job === 'function' && isSANB(job.name)) return job.name; 26 | }; 27 | 28 | /** 29 | * Parses provided value into millisecond 30 | * 31 | * @param {string} _value 32 | */ 33 | const getHumanToMs = (_value) => { 34 | const value = humanInterval(_value); 35 | if (Number.isNaN(value)) return ms(_value); 36 | return value; 37 | }; 38 | 39 | /** 40 | * Parses schedule value into "later" schedule object or milliseconds 41 | * 42 | * @param {boolean | string | number | Object} value 43 | * @returns {number | boolean | Object} 44 | */ 45 | const parseValue = (value) => { 46 | const originalValue = value; 47 | 48 | if (value === false) return value; 49 | 50 | if (isSchedule(value)) return value; 51 | 52 | if (isSANB(value)) { 53 | const schedule = later.schedule(later.parse.text(value)); 54 | if (schedule.isValid()) return later.parse.text(value); 55 | value = getHumanToMs(value); 56 | if (value === 0) { 57 | // There is a bug in the human-interval library that causes some invalid 58 | // strings to be parsed as valid, returning 0 as output (instead of NaN). 59 | // Since the user is using a String to define the interval, it is most 60 | // likely that he/she is not trying to set it to 0ms. 61 | // Hence, this must be an error. 62 | throw new Error( 63 | `Value "${originalValue}" is not a String parseable by \`later.parse.text\` (see for examples)` 64 | ); 65 | } 66 | } 67 | 68 | if (!Number.isFinite(value) || value < 0) 69 | throw new Error( 70 | `Value "${originalValue}" must be a finite number >= 0 or a String parseable by \`later.parse.text\` (see for examples)` 71 | ); 72 | 73 | return value; 74 | }; 75 | 76 | /** 77 | * Processes job objects extracting their names 78 | * Can conditionaly skip records by their index 79 | * 80 | * @param {any[]} jobs 81 | * @param {number} excludeIndex 82 | * @returns {string[]} job names 83 | */ 84 | const getJobNames = (jobs, excludeIndex) => { 85 | const names = []; 86 | 87 | for (const [i, job] of jobs.entries()) { 88 | if (i === excludeIndex) continue; 89 | 90 | const name = getName(job); 91 | 92 | if (name) names.push(name); 93 | } 94 | 95 | return names; 96 | }; 97 | 98 | /** 99 | * Processes job name to generate a partial path for the job 100 | * Allows for resiliancy when the path extensions are either 101 | * provided or not on both default and accepted extensions 102 | * 103 | * @param {string} name 104 | * @param {number} acceptedExtensions 105 | * @param {string} defaultExtension 106 | * @returns {string} job path 107 | */ 108 | const getJobPath = (name, acceptedExtensions, defaultExtension) => { 109 | const extFindArray = acceptedExtensions.map((ext) => { 110 | return ext.startsWith('.') ? ext : `.${ext}`; 111 | }); 112 | 113 | const hasExt = extFindArray.find((ext) => name.endsWith(ext)); 114 | 115 | if (hasExt) return name; 116 | 117 | return defaultExtension.startsWith('.') 118 | ? `${name}${defaultExtension}` 119 | : `${name}.${defaultExtension}`; 120 | }; 121 | 122 | module.exports = { 123 | getHumanToMs, 124 | getJobNames, 125 | getJobPath, 126 | getName, 127 | isSchedule, 128 | parseValue 129 | }; 130 | -------------------------------------------------------------------------------- /src/job-validator.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const { join } = require('node:path'); 3 | const combineErrors = require('combine-errors'); 4 | const cron = require('cron-validate'); 5 | const isSANB = require('is-string-and-not-blank'); 6 | const isValidPath = require('is-valid-path'); 7 | const { getName, isSchedule, parseValue, getJobPath } = require('./job-utils'); 8 | 9 | const validateReservedJobName = (name) => { 10 | // Don't allow a job to have the `index` file name 11 | if (['index', 'index.js', 'index.mjs'].includes(name)) { 12 | return new Error( 13 | 'You cannot use the reserved job name of "index", "index.js", nor "index.mjs"' 14 | ); 15 | } 16 | }; 17 | 18 | const validateStringJob = async (job, i, config) => { 19 | const errors = []; 20 | 21 | const jobNameError = validateReservedJobName(job); 22 | if (jobNameError) { 23 | throw jobNameError; 24 | } 25 | 26 | if (!config.root) { 27 | errors.push( 28 | new Error( 29 | `Job #${ 30 | i + 1 31 | } "${job}" requires root directory option to auto-populate path` 32 | ) 33 | ); 34 | throw combineErrors(errors); 35 | } 36 | 37 | const path = join( 38 | config.root, 39 | getJobPath(job, config.acceptedExtensions, config.defaultExtension) 40 | ); 41 | 42 | const stats = await fs.promises.stat(path); 43 | if (!stats.isFile()) { 44 | throw new Error(`Job #${i + 1} "${job}" path missing: ${path}`); 45 | } 46 | }; 47 | 48 | const validateFunctionJob = (job, i) => { 49 | const errors = []; 50 | 51 | const path = `(${job.toString()})()`; 52 | // Can't be a built-in or bound function 53 | if (path.includes('[native code]')) { 54 | errors.push( 55 | new Error(`Job #${i + 1} can't be a bound or built-in function`) 56 | ); 57 | } 58 | 59 | if (errors.length > 0) { 60 | throw combineErrors(errors); 61 | } 62 | }; 63 | 64 | const validateJobPath = async (job, prefix, config) => { 65 | const errors = []; 66 | 67 | if (typeof job.path === 'function') { 68 | const path = `(${job.path.toString()})()`; 69 | 70 | // Can't be a built-in or bound function 71 | if (path.includes('[native code]')) { 72 | errors.push(new Error(`${prefix} can't be a bound or built-in function`)); 73 | } 74 | } else if (!isSANB(job.path) && !config.root) { 75 | errors.push( 76 | new Error( 77 | `${prefix} requires root directory option to auto-populate path` 78 | ) 79 | ); 80 | } else { 81 | // Validate path 82 | const path = isSANB(job.path) 83 | ? job.path 84 | : join( 85 | config.root, 86 | getJobPath( 87 | job.name, 88 | config.acceptedExtensions, 89 | config.defaultExtension 90 | ) 91 | ); 92 | if (isValidPath(path)) { 93 | try { 94 | const stats = await fs.promises.stat(path); 95 | if (!stats.isFile()) { 96 | throw new Error(`${prefix} path missing: ${path}`); 97 | } 98 | } catch (err) { 99 | /* istanbul ignore next */ 100 | errors.push(err); 101 | } 102 | } 103 | } 104 | 105 | return errors; 106 | }; 107 | 108 | const cronValidateWithSeconds = (job, config) => { 109 | const preset = 110 | job.cronValidate && job.cronValidate.preset 111 | ? job.cronValidate.preset 112 | : config.cronValidate && config.cronValidate.preset 113 | ? config.cronValidate.preset 114 | : 'default'; 115 | const override = { 116 | ...(config.cronValidate && config.cronValidate.override 117 | ? config.cronValidate.override 118 | : {}), 119 | ...(job.cronValidate && job.cronValidate.override 120 | ? job.cronValidate.override 121 | : {}), 122 | useSeconds: true 123 | }; 124 | 125 | return { 126 | ...config.cronValidate, 127 | ...job.cronValidate, 128 | preset, 129 | override 130 | }; 131 | }; 132 | 133 | const validateCron = (job, prefix, config) => { 134 | const errors = []; 135 | 136 | if (!isSchedule(job.cron)) { 137 | // If `hasSeconds` was `true` then set `cronValidate` and inherit any existing options 138 | const cronValidate = job.hasSeconds 139 | ? cronValidateWithSeconds(job, config) 140 | : job.cronValidate || config.cronValidate; 141 | 142 | // 143 | // validate cron pattern 144 | // (must support patterns such as `* * L * *` and `0 0/5 14 * * ?` (and aliases too) 145 | // 146 | // 147 | // 148 | const result = cron(job.cron, cronValidate); 149 | 150 | if (!result.isValid()) { 151 | // NOTE: it is always valid 152 | // const schedule = later.schedule( 153 | // later.parse.cron( 154 | // job.cron, 155 | // boolean( 156 | // typeof job.hasSeconds === 'undefined' 157 | // ? config.hasSeconds 158 | // : job.hasSeconds 159 | // ) 160 | // ) 161 | // ); 162 | // if (schedule.isValid()) { 163 | // job.interval = schedule; 164 | // } // else { 165 | // errors.push( 166 | // new Error( 167 | // `${prefix} had an invalid cron schedule (see if you need help)` 168 | // ) 169 | // ); 170 | // } 171 | 172 | for (const message of result.getError()) { 173 | errors.push( 174 | new Error(`${prefix} had an invalid cron pattern: ${message}`) 175 | ); 176 | } 177 | } 178 | } 179 | 180 | return errors; 181 | }; 182 | 183 | const validateJobName = (job, i, reservedNames) => { 184 | const errors = []; 185 | const name = getName(job); 186 | 187 | if (!name) { 188 | errors.push(new Error(`Job #${i + 1} is missing a name`)); 189 | } 190 | 191 | // Throw an error if duplicate job names 192 | if (reservedNames.includes(name)) { 193 | errors.push( 194 | new Error(`Job #${i + 1} has a duplicate job name of ${getName(job)}`) 195 | ); 196 | } 197 | 198 | return errors; 199 | }; 200 | 201 | // eslint-disable-next-line complexity 202 | const validate = async (job, i, names, config) => { 203 | const errors = validateJobName(job, i, names); 204 | 205 | if (errors.length > 0) { 206 | throw combineErrors(errors); 207 | } 208 | 209 | // Support a simple string which we will transform to have a path 210 | if (isSANB(job)) { 211 | return validateStringJob(job, i, config); 212 | } 213 | 214 | // Job is a function 215 | if (typeof job === 'function') { 216 | return validateFunctionJob(job, i); 217 | } 218 | 219 | // Use a prefix for errors 220 | const prefix = `Job #${i + 1} named "${job.name}"`; 221 | 222 | errors.push(...(await validateJobPath(job, prefix, config))); 223 | 224 | // Don't allow users to mix interval AND cron 225 | if (job.interval !== undefined && job.cron !== undefined) { 226 | errors.push( 227 | new Error(`${prefix} cannot have both interval and cron configuration`) 228 | ); 229 | } 230 | 231 | // Don't allow users to mix timeout AND date 232 | if (job.timeout !== undefined && job.date !== undefined) { 233 | errors.push(new Error(`${prefix} cannot have both timeout and date`)); 234 | } 235 | 236 | const jobNameError = validateReservedJobName(job.name); 237 | if (jobNameError) { 238 | errors.push(jobNameError); 239 | } 240 | 241 | // Validate date 242 | if (job.date !== undefined && !(job.date instanceof Date)) { 243 | errors.push(new Error(`${prefix} had an invalid Date of ${job.date}`)); 244 | } 245 | 246 | for (const prop of ['timeout', 'interval']) { 247 | if (job[prop] !== undefined) { 248 | try { 249 | parseValue(job[prop]); 250 | } catch (err) { 251 | errors.push( 252 | combineErrors([ 253 | new Error(`${prefix} had an invalid ${prop} of ${job.timeout}`), 254 | err 255 | ]) 256 | ); 257 | } 258 | } 259 | } 260 | 261 | // Validate hasSeconds 262 | if (job.hasSeconds !== undefined && typeof job.hasSeconds !== 'boolean') { 263 | errors.push( 264 | new Error( 265 | `${prefix} had hasSeconds value of ${job.hasSeconds} (it must be a Boolean)` 266 | ) 267 | ); 268 | } 269 | 270 | // Validate cronValidate 271 | if (job.cronValidate !== undefined && typeof job.cronValidate !== 'object') { 272 | errors.push( 273 | new Error( 274 | `${prefix} had cronValidate value set, but it must be an Object` 275 | ) 276 | ); 277 | } 278 | 279 | if (job.cron !== undefined) { 280 | errors.push(...validateCron(job, prefix, config)); 281 | } 282 | 283 | // Validate closeWorkerAfterMs 284 | if ( 285 | job.closeWorkerAfterMs !== undefined && 286 | (!Number.isFinite(job.closeWorkerAfterMs) || job.closeWorkerAfterMs <= 0) 287 | ) { 288 | errors.push( 289 | new Error( 290 | `${prefix} had an invalid closeWorkersAfterMs value of ${job.closeWorkersAfterMs} (it must be a finite number > 0)` 291 | ) 292 | ); 293 | } 294 | 295 | if (isSANB(job.timezone) && !['local', 'system'].includes(job.timezone)) { 296 | try { 297 | // `.toLocaleString()` will throw a `RangeError` if `timeZone` string 298 | // is bogus or not supported by the environment. 299 | new Date().toLocaleString('ia', { timeZone: job.timezone }); 300 | } catch { 301 | errors.push( 302 | new Error( 303 | `${prefix} had an invalid or unsupported timezone specified: ${job.timezone}` 304 | ) 305 | ); 306 | } 307 | } 308 | 309 | if (errors.length > 0) { 310 | throw combineErrors(errors); 311 | } 312 | }; 313 | 314 | module.exports = validate; 315 | module.exports.cronValidateWithSeconds = cronValidateWithSeconds; 316 | module.exports.validateCron = validateCron; 317 | -------------------------------------------------------------------------------- /test-d/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | import Bree from '../src'; 3 | 4 | const bree = new Bree({}); 5 | 6 | expectType(bree); 7 | 8 | expectType(bree.start); 9 | expectType(bree.stop); 10 | expectType(bree.run); 11 | expectType(bree.add); 12 | expectType(bree.remove); 13 | expectType(bree.createWorker); 14 | -------------------------------------------------------------------------------- /test/add.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const FakeTimers = require('@sinonjs/fake-timers'); 3 | const test = require('ava'); 4 | const Bree = require('../src'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | 8 | test('successfully add jobs as array', async (t) => { 9 | const bree = new Bree({ 10 | root, 11 | jobs: ['infinite'] 12 | }); 13 | 14 | t.is(typeof bree.config.jobs[1], 'undefined'); 15 | 16 | const added = await bree.add(['basic']); 17 | 18 | t.is(added[0].name, 'basic'); 19 | 20 | t.is(typeof bree.config.jobs[1], 'object'); 21 | }); 22 | 23 | test('successfully add job not array', async (t) => { 24 | const bree = new Bree({ 25 | root, 26 | jobs: ['infinite'] 27 | }); 28 | 29 | t.is(typeof bree.config.jobs[1], 'undefined'); 30 | 31 | const added = await bree.add('basic'); 32 | 33 | t.is(added[0].name, 'basic'); 34 | 35 | t.is(typeof bree.config.jobs[1], 'object'); 36 | }); 37 | 38 | test('fails if job already exists', async (t) => { 39 | const bree = new Bree({ 40 | root, 41 | jobs: ['basic'] 42 | }); 43 | 44 | const err = await t.throwsAsync(bree.add(['basic'])); 45 | t.regex(err.message, /Job .* has a duplicate job name of */); 46 | t.falsy(bree.config.jobs[1]); 47 | }); 48 | 49 | test('successfully adds job object', async (t) => { 50 | const bree = new Bree({ root: false }); 51 | function noop() {} 52 | await bree.add({ name: 'basic', path: noop.toString() }); 53 | t.pass(); 54 | }); 55 | 56 | test('missing job name', async (t) => { 57 | const logger = {}; 58 | logger.error = () => {}; 59 | logger.info = () => {}; 60 | 61 | const bree = new Bree({ 62 | root: false, 63 | logger 64 | }); 65 | 66 | const err = await t.throwsAsync(bree.add()); 67 | t.regex(err.message, /Job .* is missing a name/); 68 | }); 69 | 70 | test.serial( 71 | 'job created with cron string is using local timezone', 72 | async (t) => { 73 | t.plan(2); 74 | const bree = new Bree({ 75 | root: false 76 | }); 77 | 78 | await bree.add({ 79 | name: 'basic', 80 | cron: '0 18 * * *', 81 | path: path.join(__dirname, 'jobs/basic.js') 82 | }); 83 | 84 | const clock = FakeTimers.install({ now: Date.now() }); 85 | await bree.start('basic'); 86 | bree.on('worker created', () => { 87 | const now = new Date(clock.now); 88 | const offsetOfLocalDates = new Date().getTimezoneOffset(); 89 | 90 | t.is(now.getTimezoneOffset(), offsetOfLocalDates); 91 | t.is(now.getHours(), 18); 92 | }); 93 | clock.next(); 94 | clock.uninstall(); 95 | } 96 | ); 97 | -------------------------------------------------------------------------------- /test/get-worker-metadata.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const test = require('ava'); 3 | const delay = require('delay'); 4 | const Bree = require('../src'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | 8 | const baseConfig = { 9 | root, 10 | timeout: 0, 11 | interval: 0, 12 | hasSeconds: false, 13 | defaultExtension: 'js' 14 | }; 15 | 16 | test('throws if no job exists', async (t) => { 17 | const bree = new Bree({ 18 | jobs: ['basic'], 19 | ...baseConfig 20 | }); 21 | 22 | await bree.init(); 23 | 24 | t.throws(() => bree.getWorkerMetadata('test'), { 25 | message: 'Job "test" does not exist' 26 | }); 27 | }); 28 | 29 | test('returns undefined if output not set to true', async (t) => { 30 | const bree = new Bree({ 31 | jobs: ['basic'], 32 | ...baseConfig 33 | }); 34 | 35 | await bree.init(); 36 | 37 | const meta = { test: 1 }; 38 | 39 | t.is(typeof bree.getWorkerMetadata('basic', meta), 'undefined'); 40 | }); 41 | 42 | test('returns meta if error', async (t) => { 43 | const bree = new Bree({ 44 | jobs: ['basic'], 45 | ...baseConfig 46 | }); 47 | 48 | await bree.init(); 49 | 50 | const meta = { err: true, message: true }; 51 | 52 | t.is(bree.getWorkerMetadata('basic', meta), meta); 53 | }); 54 | 55 | test('returns meta if output set to true', async (t) => { 56 | const bree = new Bree({ 57 | jobs: ['basic'], 58 | ...baseConfig, 59 | outputWorkerMetadata: true 60 | }); 61 | 62 | await bree.init(); 63 | 64 | const meta = { test: 1 }; 65 | 66 | t.is(bree.getWorkerMetadata('basic', meta), meta); 67 | }); 68 | 69 | test('returns meta and worker data if running', async (t) => { 70 | const logger = { 71 | info() {} 72 | }; 73 | 74 | const bree = new Bree({ 75 | jobs: ['infinite'], 76 | ...baseConfig, 77 | outputWorkerMetadata: true, 78 | logger 79 | }); 80 | 81 | await bree.start(); 82 | await delay(10); 83 | 84 | const meta = { test: 1 }; 85 | 86 | t.is(typeof bree.getWorkerMetadata('infinite', meta).worker, 'object'); 87 | 88 | await bree.stop(); 89 | }); 90 | 91 | test('job with worker data sent by job', async (t) => { 92 | t.plan(1); 93 | 94 | const logger = { 95 | info(...args) { 96 | if (!args[1] || !args[1].message) { 97 | return; 98 | } 99 | 100 | t.is(args[1].message.test, 'test'); 101 | }, 102 | error() {} 103 | }; 104 | 105 | const bree = new Bree({ 106 | root, 107 | jobs: [{ name: 'worker-data', worker: { workerData: { test: 'test' } } }], 108 | outputWorkerMetadata: true, 109 | logger 110 | }); 111 | 112 | await bree.run('worker-data'); 113 | await delay(1000); 114 | 115 | await bree.stop(); 116 | }); 117 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const test = require('ava'); 4 | const delay = require('delay'); 5 | const humanInterval = require('human-interval'); 6 | const FakeTimers = require('@sinonjs/fake-timers'); 7 | const Bree = require('../src'); 8 | 9 | const root = path.join(__dirname, 'jobs'); 10 | const baseConfig = { 11 | root, 12 | timeout: 0, 13 | interval: 0, 14 | hasSeconds: false, 15 | defaultExtension: 'js' 16 | }; 17 | 18 | test('successfully run job', async (t) => { 19 | t.plan(2); 20 | 21 | const logger = { 22 | info() {} 23 | }; 24 | 25 | const bree = new Bree({ 26 | jobs: ['infinite'], 27 | ...baseConfig, 28 | logger 29 | }); 30 | 31 | await bree.start(); 32 | 33 | bree.on('worker created', (name) => { 34 | t.true(bree.workers.has(name)); 35 | }); 36 | 37 | bree.on('worker deleted', (name) => { 38 | t.false(bree.workers.has(name)); 39 | }); 40 | 41 | await delay(100); 42 | 43 | await bree.stop(); 44 | }); 45 | 46 | test('preset and override is set if config.hasSeconds is "true"', (t) => { 47 | const bree = new Bree({ 48 | jobs: ['basic'], 49 | ...baseConfig, 50 | hasSeconds: true 51 | }); 52 | 53 | t.is(bree.config.cronValidate.preset, 'default'); 54 | t.true(typeof bree.config.cronValidate.override === 'object'); 55 | }); 56 | 57 | test('preset and override is set by cronValidate config', (t) => { 58 | const bree = new Bree({ 59 | jobs: ['basic'], 60 | ...baseConfig, 61 | hasSeconds: true, 62 | cronValidate: { 63 | preset: 'test', 64 | override: { test: 'works' } 65 | } 66 | }); 67 | 68 | t.is(bree.config.cronValidate.preset, 'test'); 69 | t.true(typeof bree.config.cronValidate.override === 'object'); 70 | t.is(bree.config.cronValidate.override.test, 'works'); 71 | }); 72 | 73 | test('throws if jobs is not an array and logs ERR_MODULE_NOT_FOUND error by default', async (t) => { 74 | t.plan(3); 75 | 76 | const logger = { 77 | info() {}, 78 | error(err) { 79 | t.is(err.code, 'ERR_MODULE_NOT_FOUND'); 80 | } 81 | }; 82 | 83 | const bree = new Bree({ 84 | jobs: null, 85 | ...baseConfig, 86 | logger, 87 | root: path.join(__dirname, 'noIndexJobs') 88 | }); 89 | const err = await t.throwsAsync(bree.init()); 90 | t.is(err.message, 'Jobs must be an Array'); 91 | }); 92 | 93 | test('logs ERR_MODULE_NOT_FOUND error if array is empty', async (t) => { 94 | t.plan(2); 95 | 96 | const logger = { 97 | info() {}, 98 | error(err) { 99 | t.is(err.code, 'ERR_MODULE_NOT_FOUND'); 100 | } 101 | }; 102 | 103 | const bree = new Bree({ 104 | jobs: [], 105 | ...baseConfig, 106 | logger, 107 | root: path.join(__dirname, 'noIndexJobs') 108 | }); 109 | 110 | await bree.init(); 111 | 112 | t.true(bree instanceof Bree); 113 | }); 114 | 115 | test('does not log ERR_MODULE_NOT_FOUND error if silenceRootCheckError is false', async (t) => { 116 | const logger = { 117 | info() {}, 118 | error() { 119 | t.fail(); 120 | } 121 | }; 122 | 123 | const bree = new Bree({ 124 | jobs: [], 125 | ...baseConfig, 126 | logger, 127 | root: path.join(__dirname, 'noIndexJobs'), 128 | silenceRootCheckError: true 129 | }); 130 | 131 | await bree.init(); 132 | 133 | t.true(bree instanceof Bree); 134 | }); 135 | 136 | test('does not log ERR_MODULE_NOT_FOUND error if doRootCheck is false', async (t) => { 137 | const logger = { 138 | info() {}, 139 | error() { 140 | t.fail(); 141 | } 142 | }; 143 | 144 | const bree = new Bree({ 145 | jobs: [], 146 | ...baseConfig, 147 | logger, 148 | root: path.join(__dirname, 'noIndexJobs'), 149 | doRootCheck: false 150 | }); 151 | 152 | await bree.init(); 153 | 154 | t.true(bree instanceof Bree); 155 | }); 156 | 157 | test('throws during constructor if job-validator throws', async (t) => { 158 | const bree = new Bree({ 159 | jobs: [{ name: 'basic', hasSeconds: 'test' }], 160 | ...baseConfig 161 | }); 162 | const err = await t.throwsAsync(bree.init()); 163 | t.is( 164 | err.message, 165 | 'Job #1 named "basic" had hasSeconds value of test (it must be a Boolean)' 166 | ); 167 | }); 168 | 169 | test('emits "worker created" and "worker started" events', async (t) => { 170 | t.plan(2); 171 | 172 | const bree = new Bree({ 173 | root, 174 | jobs: ['basic'], 175 | timeout: 100 176 | }); 177 | 178 | await bree.start(); 179 | 180 | bree.on('worker created', (name) => { 181 | t.true(bree.workers.has(name)); 182 | }); 183 | bree.on('worker deleted', (name) => { 184 | t.false(bree.workers.has(name)); 185 | }); 186 | 187 | await delay(1000); 188 | 189 | await bree.stop(); 190 | }); 191 | 192 | test.serial('job with long timeout runs', async (t) => { 193 | t.plan(2); 194 | 195 | const bree = new Bree({ 196 | root, 197 | jobs: ['infinite'], 198 | timeout: '3 months' 199 | }); 200 | 201 | await bree.init(); 202 | t.is(bree.config.jobs[0].timeout, humanInterval('3 months')); 203 | 204 | const now = Date.now(); 205 | const clock = FakeTimers.install({ now: Date.now() }); 206 | 207 | await bree.start('infinite'); 208 | bree.on('worker created', () => { 209 | // Only complicated because of runtime - this removes flakiness 210 | t.true( 211 | clock.now - now === humanInterval('3 months') || 212 | clock.now - now === humanInterval('3 months') + 1 213 | ); 214 | }); 215 | // Should run till worker stops running 216 | clock.runAll(); 217 | 218 | clock.uninstall(); 219 | }); 220 | 221 | test.serial( 222 | 'job created with cron string is using local timezone', 223 | async (t) => { 224 | t.plan(2); 225 | const bree = new Bree({ 226 | root, 227 | jobs: [{ name: 'basic', cron: '0 18 * * *' }] 228 | }); 229 | 230 | const clock = FakeTimers.install({ now: Date.now() }); 231 | await bree.start('basic'); 232 | bree.on('worker created', () => { 233 | const now = new Date(clock.now); 234 | const offsetOfLocalDates = new Date().getTimezoneOffset(); 235 | 236 | t.is(now.getTimezoneOffset(), offsetOfLocalDates); 237 | t.is(now.getHours(), 18); 238 | }); 239 | clock.next(); 240 | clock.uninstall(); 241 | } 242 | ); 243 | 244 | test.serial( 245 | 'job created with human interval is using local timezone', 246 | async (t) => { 247 | t.plan(2); 248 | const bree = new Bree({ 249 | root, 250 | jobs: [{ name: 'basic', interval: 'at 13:26' }] 251 | }); 252 | 253 | const clock = FakeTimers.install({ now: Date.now() }); 254 | await bree.start('basic'); 255 | bree.on('worker created', () => { 256 | const now = new Date(clock.now); 257 | t.is(now.getHours(), 13); 258 | t.is(now.getMinutes(), 26); 259 | }); 260 | clock.next(); 261 | clock.uninstall(); 262 | } 263 | ); 264 | 265 | test('throws if acceptedExtensions is not an array', (t) => { 266 | const err = t.throws( 267 | () => 268 | new Bree({ 269 | jobs: ['basic'], 270 | ...baseConfig, 271 | acceptedExtensions: 'test string' 272 | }) 273 | ); 274 | t.is(err.message, '`acceptedExtensions` must be defined and an Array'); 275 | }); 276 | 277 | test('throws if acceptedExtensions is false', (t) => { 278 | const err = t.throws( 279 | () => 280 | new Bree({ 281 | jobs: ['basic'], 282 | ...baseConfig, 283 | acceptedExtensions: false 284 | }) 285 | ); 286 | t.is(err.message, '`acceptedExtensions` must be defined and an Array'); 287 | }); 288 | 289 | test('throws if root is not a directory', async (t) => { 290 | const bree = new Bree({ 291 | jobs: ['basic'], 292 | ...baseConfig, 293 | root: path.resolve(__dirname, 'add.js') 294 | }); 295 | const err = await t.throwsAsync(bree.init()); 296 | t.regex(err.message, /Root directory of .+ does not exist/); 297 | }); 298 | 299 | test('sets logger to noop if set to false', (t) => { 300 | const bree = new Bree({ root, logger: false }); 301 | t.true(typeof bree.config.logger === 'object'); 302 | t.true(typeof bree.config.logger.info === 'function'); 303 | t.true(typeof bree.config.logger.warn === 'function'); 304 | t.true(typeof bree.config.logger.error === 'function'); 305 | }); 306 | 307 | test('removes job on completion when config.removeCompleted is `true`', async (t) => { 308 | const bree = new Bree({ 309 | jobs: ['basic'], 310 | ...baseConfig, 311 | logger: false, 312 | removeCompleted: true 313 | }); 314 | 315 | await bree.run('basic'); 316 | await once(bree.workers.get('basic'), 'exit'); 317 | 318 | t.is(bree.config.jobs.length, 0); 319 | }); 320 | 321 | test('ensures defaultExtension does not start with a period', (t) => { 322 | const err = t.throws(() => new Bree({ defaultExtension: '.js' })); 323 | t.is( 324 | err.message, 325 | '`defaultExtension` should not start with a ".", please enter the file extension without a leading period' 326 | ); 327 | }); 328 | 329 | test('handleJobCompletion throws if bree.init() was not called', (t) => { 330 | const bree = new Bree(); 331 | const err = t.throws(() => bree.handleJobCompletion('foo')); 332 | t.is( 333 | err.message, 334 | 'bree.init() was not called, see ' 335 | ); 336 | }); 337 | 338 | test('getWorkerMetadata throws if bree.init() was not called', (t) => { 339 | const bree = new Bree(); 340 | const err = t.throws(() => bree.getWorkerMetadata()); 341 | t.is( 342 | err.message, 343 | 'bree.init() was not called, see ' 344 | ); 345 | }); 346 | 347 | test(`bree.init() is called if bree.remove() is called`, async (t) => { 348 | const bree = new Bree({ root, jobs: ['basic'] }); 349 | await bree.remove('basic'); 350 | t.true(bree._init); 351 | }); 352 | 353 | test(`bree.init() is called if bree.stop() is called`, async (t) => { 354 | const bree = new Bree({ root, jobs: ['basic'] }); 355 | await bree.stop('basic'); 356 | t.true(bree._init); 357 | }); 358 | 359 | test(`bree.init() is called if bree.add() is called`, async (t) => { 360 | const bree = new Bree({ root, jobs: ['basic'] }); 361 | await bree.add('short'); 362 | t.true(bree._init); 363 | await bree.add('message'); 364 | t.true(bree._init); 365 | }); 366 | -------------------------------------------------------------------------------- /test/issues/issue-152.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const test = require('ava'); 4 | const Bree = require('../../src'); 5 | 6 | const root = path.join(__dirname, '../jobs'); 7 | 8 | test('job terminates after closeWorkerAfterMs and allows run after', async (t) => { 9 | t.plan(4); 10 | 11 | const logger = {}; 12 | logger.info = () => {}; 13 | logger.error = () => {}; 14 | 15 | const bree = new Bree({ 16 | root, 17 | jobs: [{ name: 'long', closeWorkerAfterMs: 2000 }], 18 | logger 19 | }); 20 | 21 | await bree.run('long'); 22 | await once(bree.workers.get('long'), 'online'); 23 | t.true(bree.closeWorkerAfterMs.has('long')); 24 | 25 | const [code] = await once(bree.workers.get('long'), 'exit'); 26 | t.is(code, 1); 27 | t.false(bree.closeWorkerAfterMs.has('long')); 28 | 29 | await bree.run('long'); 30 | await once(bree.workers.get('long'), 'online'); 31 | t.true(bree.closeWorkerAfterMs.has('long')); 32 | }); 33 | -------------------------------------------------------------------------------- /test/issues/issue-171.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { SHARE_ENV } = require('node:worker_threads'); 3 | const test = require('ava'); 4 | const Bree = require('../../src'); 5 | 6 | const root = path.join(__dirname, '../jobs'); 7 | 8 | test('works with SHARE_ENV using top-level', async (t) => { 9 | const bree = new Bree({ 10 | root, 11 | worker: { 12 | env: SHARE_ENV 13 | }, 14 | jobs: [{ name: 'long', closeWorkerAfterMs: 2000 }] 15 | }); 16 | 17 | await t.notThrowsAsync(bree.run('long')); 18 | }); 19 | 20 | test('works with SHARE_ENV using job-specific', async (t) => { 21 | const bree = new Bree({ 22 | root, 23 | jobs: [ 24 | { name: 'long', closeWorkerAfterMs: 2000, worker: { env: SHARE_ENV } } 25 | ] 26 | }); 27 | 28 | await t.notThrowsAsync(bree.run('long')); 29 | }); 30 | 31 | test('works with SHARE_ENV using both', async (t) => { 32 | const bree = new Bree({ 33 | root, 34 | worker: { 35 | env: SHARE_ENV 36 | }, 37 | jobs: [ 38 | { name: 'long', closeWorkerAfterMs: 2000, worker: { env: SHARE_ENV } } 39 | ] 40 | }); 41 | 42 | await t.notThrowsAsync(bree.run('long')); 43 | }); 44 | -------------------------------------------------------------------------------- /test/issues/issue-180/jobs-no-default-export/index.mjs: -------------------------------------------------------------------------------- 1 | export const jobs = ['job']; 2 | -------------------------------------------------------------------------------- /test/issues/issue-180/jobs-no-default-export/job.js: -------------------------------------------------------------------------------- 1 | setTimeout(() => { 2 | console.log('hello'); 3 | }, 300); 4 | -------------------------------------------------------------------------------- /test/issues/issue-180/jobs/index.mjs: -------------------------------------------------------------------------------- 1 | const jobs = ['job']; 2 | export default jobs; 3 | -------------------------------------------------------------------------------- /test/issues/issue-180/jobs/job.js: -------------------------------------------------------------------------------- 1 | setTimeout(() => { 2 | console.log('hello'); 3 | }, 300); 4 | -------------------------------------------------------------------------------- /test/issues/issue-180/test.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const test = require('ava'); 4 | const Bree = require('../../../src'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | 8 | test('defaultRootIndex as an ESM module', async (t) => { 9 | const bree = new Bree({ 10 | root, 11 | defaultRootIndex: 'index.mjs' 12 | }); 13 | 14 | await bree.run('job'); 15 | await once(bree.workers.get('job'), 'online'); 16 | 17 | const [code] = await once(bree.workers.get('job'), 'exit'); 18 | t.is(code, 0); 19 | }); 20 | 21 | test('defaultRootIndex as an ESM module throws error when no default export', async (t) => { 22 | const bree = new Bree({ 23 | root: path.join(__dirname, 'jobs-no-default-export'), 24 | defaultRootIndex: 'index.mjs' 25 | }); 26 | 27 | const err = await t.throwsAsync(bree.run('job')); 28 | t.regex(err.message, /Root index file missing default export at/); 29 | }); 30 | -------------------------------------------------------------------------------- /test/job-builder.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const test = require('ava'); 3 | const later = require('@breejs/later'); 4 | const jobBuilder = require('../src/job-builder'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | const jobPathBasic = path.join(root, 'basic.js'); 8 | 9 | const baseConfig = { 10 | root, 11 | timeout: 0, 12 | interval: 0, 13 | hasSeconds: false, 14 | defaultExtension: 'js', 15 | acceptedExtensions: ['.js', '.mjs'] 16 | }; 17 | 18 | function job(t, _job, config, expected) { 19 | t.deepEqual( 20 | jobBuilder(_job || 'basic', { ...baseConfig, ...config }), 21 | expected 22 | ); 23 | } 24 | 25 | test( 26 | 'job name as file name without extension', 27 | job, 28 | null, 29 | {}, 30 | { name: 'basic', path: jobPathBasic, timeout: 0, interval: 0 } 31 | ); 32 | 33 | test( 34 | 'job name as file name with extension', 35 | job, 36 | 'basic.js', 37 | {}, 38 | { 39 | name: 'basic.js', 40 | path: jobPathBasic, 41 | timeout: 0, 42 | interval: 0 43 | } 44 | ); 45 | 46 | function basic() { 47 | setTimeout(() => { 48 | console.log('hello'); 49 | }, 100); 50 | } 51 | 52 | test( 53 | 'job is function', 54 | job, 55 | basic, 56 | {}, 57 | { 58 | name: 'basic', 59 | path: `(${basic.toString()})()`, 60 | worker: { eval: true }, 61 | timeout: 0, 62 | interval: 0 63 | } 64 | ); 65 | 66 | test( 67 | 'job.path is function', 68 | job, 69 | { path: basic, worker: { test: 1 } }, 70 | {}, 71 | { 72 | path: `(${basic.toString()})()`, 73 | worker: { eval: true, test: 1 }, 74 | timeout: 0 75 | } 76 | ); 77 | 78 | test( 79 | 'job.path is blank and name of job is defined without extension', 80 | job, 81 | { name: 'basic', path: '' }, 82 | {}, 83 | { name: 'basic', path: jobPathBasic, timeout: 0 } 84 | ); 85 | 86 | test( 87 | 'job.path is blank and name of job is defined with extension', 88 | job, 89 | { name: 'basic.js', path: '' }, 90 | {}, 91 | { name: 'basic.js', path: jobPathBasic, timeout: 0 } 92 | ); 93 | 94 | test( 95 | 'job.path is path to file', 96 | job, 97 | { path: jobPathBasic }, 98 | {}, 99 | { path: jobPathBasic, timeout: 0 } 100 | ); 101 | 102 | test( 103 | 'job.path is not a file path', 104 | job, 105 | { path: '*.js', worker: { test: 1 } }, 106 | {}, 107 | { path: '*.js', timeout: 0, worker: { eval: true, test: 1 } } 108 | ); 109 | 110 | test( 111 | 'job.timeout is value', 112 | job, 113 | { path: jobPathBasic, timeout: 10 }, 114 | {}, 115 | { path: jobPathBasic, timeout: 10 } 116 | ); 117 | 118 | test( 119 | 'job.interval is value', 120 | job, 121 | { path: jobPathBasic, interval: 10 }, 122 | {}, 123 | { path: jobPathBasic, interval: 10 } 124 | ); 125 | 126 | test( 127 | 'job.cron is value', 128 | job, 129 | { path: jobPathBasic, cron: '* * * * *' }, 130 | {}, 131 | { 132 | path: jobPathBasic, 133 | cron: '* * * * *', 134 | interval: later.parse.cron('* * * * *') 135 | } 136 | ); 137 | 138 | test( 139 | 'job.cron is value with hasSeconds config', 140 | job, 141 | { path: jobPathBasic, cron: '* * * * *', hasSeconds: false }, 142 | {}, 143 | { 144 | path: jobPathBasic, 145 | cron: '* * * * *', 146 | interval: later.parse.cron('* * * * *'), 147 | hasSeconds: false 148 | } 149 | ); 150 | 151 | test( 152 | 'job.cron is schedule', 153 | job, 154 | { path: jobPathBasic, cron: later.parse.cron('* * * * *') }, 155 | {}, 156 | { 157 | path: jobPathBasic, 158 | cron: later.parse.cron('* * * * *'), 159 | interval: later.parse.cron('* * * * *') 160 | } 161 | ); 162 | 163 | test( 164 | 'default interval is greater than 0', 165 | job, 166 | { name: 'basic', interval: undefined }, 167 | { interval: 10 }, 168 | { name: 'basic', path: jobPathBasic, timeout: 0, interval: 10 } 169 | ); 170 | 171 | test( 172 | 'job as file inherits timezone from config', 173 | job, 174 | 'basic', 175 | { timezone: 'local' }, 176 | { 177 | timezone: 'local', 178 | name: 'basic', 179 | path: jobPathBasic, 180 | timeout: 0, 181 | interval: 0 182 | } 183 | ); 184 | 185 | test( 186 | 'job as function inherits timezone from config', 187 | job, 188 | basic, 189 | { timezone: 'local' }, 190 | { 191 | timezone: 'local', 192 | name: 'basic', 193 | path: `(${basic.toString()})()`, 194 | timeout: 0, 195 | interval: 0, 196 | worker: { eval: true } 197 | } 198 | ); 199 | 200 | test( 201 | 'job as object inherits timezone from config', 202 | job, 203 | { name: 'basic' }, 204 | { timezone: 'local' }, 205 | { 206 | timezone: 'local', 207 | name: 'basic', 208 | path: jobPathBasic, 209 | timeout: 0 210 | } 211 | ); 212 | 213 | test( 214 | 'job as object retains its own timezone', 215 | job, 216 | { name: 'basic', timezone: 'America/New_York' }, 217 | { timezone: 'local' }, 218 | { 219 | timezone: 'America/New_York', 220 | name: 'basic', 221 | path: jobPathBasic, 222 | timeout: 0 223 | } 224 | ); 225 | -------------------------------------------------------------------------------- /test/job-utils.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const jobUtils = require('../src/job-utils'); 3 | 4 | test('isSchedule: passes for valid schedule object', (t) => { 5 | const isSchedule = jobUtils.isSchedule({ schedules: [] }); 6 | 7 | t.true(isSchedule); 8 | }); 9 | 10 | test('isSchedule: fails for invalid schedule object', (t) => { 11 | const isSchedule = jobUtils.isSchedule([]); 12 | 13 | t.false(isSchedule); 14 | }); 15 | 16 | test('getName: extracts job name from a string', (t) => { 17 | t.is(jobUtils.getName('job-name'), 'job-name'); 18 | }); 19 | 20 | test('getName: extracts job name from an object', (t) => { 21 | t.is(jobUtils.getName({ name: 'job-name' }), 'job-name'); 22 | }); 23 | 24 | test('getName: extracts job name from a function', (t) => { 25 | const fn = () => { 26 | return true; 27 | }; 28 | 29 | t.is(jobUtils.getName(fn), 'fn'); 30 | }); 31 | 32 | test('getHumanToMs: converts values into milliseconds', (t) => { 33 | t.is(jobUtils.getHumanToMs('100'), 100); 34 | }); 35 | 36 | test('getHumanToMs: supports human readable format', (t) => { 37 | t.is(jobUtils.getHumanToMs('minute'), 60_000); 38 | }); 39 | 40 | test('parseValue: does not parse false value', (t) => { 41 | t.false(jobUtils.parseValue(false)); 42 | }); 43 | 44 | test('parseValue: returns unmodified schedule value', (t) => { 45 | t.deepEqual(jobUtils.parseValue({ schedules: [1] }), { schedules: [1] }); 46 | }); 47 | 48 | test('parseValue: parses human readable values', (t) => { 49 | t.deepEqual(jobUtils.parseValue('every day'), { 50 | error: 6, 51 | exceptions: [], 52 | schedules: [{ D: [1] }] 53 | }); 54 | }); 55 | 56 | test('parseValue: parses millisecond values', (t) => { 57 | t.is(jobUtils.parseValue('100'), 100); 58 | }); 59 | 60 | test('parseValue: throws for invalid value', (t) => { 61 | t.throws(() => jobUtils.parseValue(-1), { 62 | message: 63 | 'Value "-1" must be a finite number >= 0 or a String parseable by `later.parse.text` (see for examples)' 64 | }); 65 | }); 66 | 67 | test('parseValue: throws if string value is neither a later nor human-interval format', (t) => { 68 | const invalidStringValue = 'on the fifth day of the month'; 69 | t.throws(() => jobUtils.parseValue(invalidStringValue), { 70 | message: `Value "${invalidStringValue}" is not a String parseable by \`later.parse.text\` (see for examples)` 71 | }); 72 | }); 73 | 74 | test('getJobNames: returns all jobNames', (t) => { 75 | const names = jobUtils.getJobNames(['hey', { name: 'hello' }]); 76 | 77 | t.deepEqual(names, ['hey', 'hello']); 78 | }); 79 | 80 | test('getJobNames: ignores name at specific index', (t) => { 81 | const names = jobUtils.getJobNames(['hey', { name: 'hello' }, 'ignored'], 2); 82 | 83 | t.deepEqual(names, ['hey', 'hello']); 84 | }); 85 | 86 | test('getJobNames: ignores jobs with no valid name', (t) => { 87 | const names = jobUtils.getJobNames([ 88 | 'hey', 89 | { name: 'hello' }, 90 | { prop: 'no name' } 91 | ]); 92 | 93 | t.deepEqual(names, ['hey', 'hello']); 94 | }); 95 | 96 | test('getJobPath: missing dot in accepted extensions', (t) => { 97 | const path = jobUtils.getJobPath('foo', ['js', 'ts'], 'js'); 98 | 99 | t.is(path, 'foo.js'); 100 | }); 101 | 102 | test('getJobPath: having dot in default extension', (t) => { 103 | const path = jobUtils.getJobPath('foo', ['js', 'ts'], '.js'); 104 | 105 | t.is(path, 'foo.js'); 106 | }); 107 | -------------------------------------------------------------------------------- /test/job-validator.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const test = require('ava'); 3 | const later = require('@breejs/later'); 4 | const jobValidator = require('../src/job-validator'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | 8 | const baseConfig = { 9 | root, 10 | defaultExtension: 'js', 11 | acceptedExtensions: ['.js', '.mjs'] 12 | }; 13 | 14 | test('does not throw for valid object job', async (t) => { 15 | await t.notThrowsAsync( 16 | jobValidator({ name: 'basic' }, 0, ['exists'], baseConfig) 17 | ); 18 | }); 19 | 20 | test('does not throw for valid object job with extension on name', async (t) => { 21 | await t.notThrowsAsync( 22 | jobValidator({ name: 'basic.js' }, 0, ['exists'], baseConfig) 23 | ); 24 | }); 25 | 26 | test('does not throw for valid string job', async (t) => { 27 | await t.notThrowsAsync(jobValidator('basic', 1, ['exists'], baseConfig)); 28 | }); 29 | 30 | test('does not throw for valid string job with extension', async (t) => { 31 | await t.notThrowsAsync(jobValidator('basic.js', 1, ['exists'], baseConfig)); 32 | }); 33 | 34 | test('throws for non-unique job name', async (t) => { 35 | const err = await t.throwsAsync( 36 | jobValidator( 37 | { 38 | name: 'exists' 39 | }, 40 | 0, 41 | ['exists'], 42 | { 43 | root: false 44 | } 45 | ) 46 | ); 47 | t.regex(err.message, /Job #1 has a duplicate job name of exists/); 48 | }); 49 | 50 | test('constructs default cronValidate configuration', (t) => { 51 | const returned = jobValidator.cronValidateWithSeconds({ name: 'hello' }, {}); 52 | 53 | const expected = { 54 | override: { 55 | useSeconds: true 56 | }, 57 | preset: 'default' 58 | }; 59 | 60 | t.deepEqual(returned, expected); 61 | }); 62 | 63 | // TODO: this can be improved 64 | test("prefers job's cronValidate configuration with validateCron", (t) => { 65 | const job = { 66 | hasSeconds: false, 67 | cronValidate: { 68 | preset: 'custom' 69 | } 70 | }; 71 | const config = { 72 | cronValidate: { 73 | preset: 'global' 74 | } 75 | }; 76 | 77 | const returned = jobValidator.validateCron(job, 'Test prefix', config); 78 | 79 | const expected = [ 80 | new Error( 81 | 'Test prefix had an invalid cron pattern: Option preset custom does not exist.' 82 | ) 83 | ]; 84 | 85 | t.deepEqual(returned, expected); 86 | }); 87 | 88 | test("prefers job's cronValidate configuration", (t) => { 89 | const job = { 90 | cronValidate: { 91 | preset: 'custom' 92 | } 93 | }; 94 | const config = { 95 | cronValidate: { 96 | preset: 'global' 97 | } 98 | }; 99 | 100 | const returned = jobValidator.cronValidateWithSeconds(job, config); 101 | 102 | const expected = { 103 | override: { 104 | useSeconds: true 105 | }, 106 | preset: 'custom' 107 | }; 108 | 109 | t.deepEqual(returned, expected); 110 | }); 111 | 112 | test("prefers config's cronValidate if none in job configuration", (t) => { 113 | const job = {}; 114 | 115 | const config = { 116 | cronValidate: { 117 | preset: 'config-preset' 118 | } 119 | }; 120 | 121 | const returned = jobValidator.cronValidateWithSeconds(job, config); 122 | 123 | const expected = { 124 | override: { 125 | useSeconds: true 126 | }, 127 | preset: 'config-preset' 128 | }; 129 | 130 | t.deepEqual(returned, expected); 131 | }); 132 | 133 | test("uses confg's override cronValidate if none in job configuration", (t) => { 134 | const job = {}; 135 | 136 | const config = { 137 | cronValidate: { 138 | override: { 139 | useSeconds: true 140 | } 141 | } 142 | }; 143 | 144 | const returned = jobValidator.cronValidateWithSeconds(job, config); 145 | 146 | const expected = { 147 | override: { 148 | useSeconds: true 149 | }, 150 | preset: 'default' 151 | }; 152 | 153 | t.deepEqual(returned, expected); 154 | }); 155 | 156 | test("prefers job's override cronValidate if none in job configuration", (t) => { 157 | const job = { 158 | cronValidate: { 159 | override: { 160 | useSeconds: true 161 | } 162 | } 163 | }; 164 | 165 | const config = { 166 | cronValidate: { 167 | override: { 168 | useSeconds: false 169 | } 170 | } 171 | }; 172 | 173 | const returned = jobValidator.cronValidateWithSeconds(job, config); 174 | 175 | const expected = { 176 | override: { 177 | useSeconds: true 178 | }, 179 | preset: 'default' 180 | }; 181 | 182 | t.deepEqual(returned, expected); 183 | }); 184 | 185 | test('throws for reserved job.name', async (t) => { 186 | const err = await t.throwsAsync( 187 | jobValidator({ name: 'index' }, 0, ['exists'], baseConfig) 188 | ); 189 | t.is( 190 | err.message, 191 | 'You cannot use the reserved job name of "index", "index.js", nor "index.mjs"' 192 | ); 193 | }); 194 | 195 | test('throws for reserved job name', async (t) => { 196 | const err = await t.throwsAsync( 197 | jobValidator('index', 0, ['exists'], baseConfig) 198 | ); 199 | t.is( 200 | err.message, 201 | 'You cannot use the reserved job name of "index", "index.js", nor "index.mjs"' 202 | ); 203 | }); 204 | 205 | test('throws for string job if no root directory', async (t) => { 206 | const err = await t.throwsAsync( 207 | jobValidator('basic', 0, ['exists'], { 208 | defaultExtension: 'js' 209 | }) 210 | ); 211 | t.is( 212 | err.message, 213 | 'Job #1 "basic" requires root directory option to auto-populate path' 214 | ); 215 | }); 216 | 217 | test('throws for object job if no root directory', async (t) => { 218 | const err = await t.throwsAsync( 219 | jobValidator({ name: 'basic' }, 0, ['exists'], { 220 | defaultExtension: 'js' 221 | }) 222 | ); 223 | t.is( 224 | err.message, 225 | 'Job #1 named "basic" requires root directory option to auto-populate path' 226 | ); 227 | }); 228 | 229 | test('does not throw for valid job path', (t) => { 230 | t.notThrows(() => 231 | jobValidator( 232 | { name: 'basic', path: root + '/basic.js' }, 233 | 0, 234 | ['exists'], 235 | baseConfig 236 | ) 237 | ); 238 | }); 239 | 240 | test('does not throw for path without extension', (t) => { 241 | t.notThrows(() => jobValidator({ name: 'basic' }, 0, ['exists'], baseConfig)); 242 | }); 243 | 244 | test('does not throw for valid function', (t) => { 245 | const fn = () => { 246 | return true; 247 | }; 248 | 249 | t.notThrows(() => jobValidator(fn, 1, ['exists'], baseConfig)); 250 | }); 251 | 252 | test('throws for bound function', async (t) => { 253 | const fn = () => { 254 | return true; 255 | }; 256 | 257 | const boundFn = fn.bind(this); 258 | 259 | const err = await t.throwsAsync( 260 | jobValidator(boundFn, 1, ['exists'], baseConfig) 261 | ); 262 | t.is(err.message, "Job #2 can't be a bound or built-in function"); 263 | }); 264 | 265 | test('does not throw for valid function in job.path', async (t) => { 266 | const fn = () => { 267 | return true; 268 | }; 269 | 270 | await t.notThrowsAsync( 271 | jobValidator({ path: fn, name: 'fn' }, 1, ['exists'], baseConfig) 272 | ); 273 | }); 274 | 275 | test('throws for bound function in job.path', async (t) => { 276 | const fn = () => { 277 | return true; 278 | }; 279 | 280 | const boundFn = fn.bind(this); 281 | 282 | const err = await t.throwsAsync( 283 | jobValidator({ path: boundFn, name: 'fn' }, 1, ['exists'], baseConfig) 284 | ); 285 | t.is(err.message, 'Job #2 named "fn" can\'t be a bound or built-in function'); 286 | }); 287 | 288 | test('does not throw for valid cron without seconds', async (t) => { 289 | await t.notThrowsAsync( 290 | jobValidator( 291 | { name: 'basic', cron: '* * * * *' }, 292 | 0, 293 | ['exists'], 294 | baseConfig 295 | ) 296 | ); 297 | }); 298 | 299 | test('does not throw for valid cron with "L" in day', async (t) => { 300 | await t.notThrowsAsync( 301 | jobValidator({ name: 'basic', cron: '* * L * *' }, 0, ['exists'], { 302 | root, 303 | defaultExtension: 'js', 304 | acceptedExtensions: ['.js', '.mjs'], 305 | cronValidate: { 306 | override: { 307 | useLastDayOfMonth: true 308 | } 309 | } 310 | }) 311 | ); 312 | }); 313 | 314 | test('does not throw for valid cron with seconds', async (t) => { 315 | await t.notThrowsAsync( 316 | jobValidator( 317 | { name: 'basic', cron: '* * * * * *', hasSeconds: true }, 318 | 0, 319 | ['exists'], 320 | baseConfig 321 | ) 322 | ); 323 | }); 324 | 325 | test('does not throw for valid cron that is a schedule', async (t) => { 326 | await t.notThrowsAsync( 327 | jobValidator( 328 | { name: 'basic', cron: later.parse.cron('* * * * *') }, 329 | 0, 330 | ['exists'], 331 | baseConfig 332 | ) 333 | ); 334 | }); 335 | 336 | test('throws for invalid cron expression', async (t) => { 337 | const err = await t.throwsAsync( 338 | jobValidator( 339 | { name: 'basic', cron: '* * * * * *' }, 340 | 0, 341 | ['exists'], 342 | baseConfig 343 | ) 344 | ); 345 | t.is( 346 | err.message, 347 | 'Job #1 named "basic" had an invalid cron pattern: Expected 5 values, but got 6. (Input cron: \'* * * * * *\')' 348 | ); 349 | }); 350 | 351 | test('throws if no no name exists', async (t) => { 352 | const err = await t.throwsAsync(jobValidator({}, 0, ['exists'], baseConfig)); 353 | t.is(err.message, 'Job #1 is missing a name'); 354 | }); 355 | 356 | test('throws if both interval and cron are used', async (t) => { 357 | const err = await t.throwsAsync( 358 | jobValidator( 359 | { name: 'basic', cron: '* * * * *', interval: 60 }, 360 | 0, 361 | ['exists'], 362 | baseConfig 363 | ) 364 | ); 365 | t.is( 366 | err.message, 367 | 'Job #1 named "basic" cannot have both interval and cron configuration' 368 | ); 369 | }); 370 | 371 | test('throws if both timeout and date are used', async (t) => { 372 | const err = await t.throwsAsync( 373 | jobValidator( 374 | { name: 'basic', timeout: 60, date: new Date('12/30/2020') }, 375 | 0, 376 | ['exists'], 377 | baseConfig 378 | ) 379 | ); 380 | t.is(err.message, 'Job #1 named "basic" cannot have both timeout and date'); 381 | }); 382 | 383 | test('throws if date is not a Date object', async (t) => { 384 | const err = await t.throwsAsync( 385 | jobValidator( 386 | { name: 'basic', date: '12/23/2020' }, 387 | 0, 388 | ['exists'], 389 | baseConfig 390 | ) 391 | ); 392 | t.is(err.message, 'Job #1 named "basic" had an invalid Date of 12/23/2020'); 393 | }); 394 | 395 | test('throws if timeout is invalid', async (t) => { 396 | const err = await t.throwsAsync( 397 | jobValidator({ name: 'basic', timeout: -1 }, 0, ['exists'], baseConfig) 398 | ); 399 | t.regex(err.message, /Job #1 named "basic" had an invalid timeout of -1; */); 400 | }); 401 | 402 | test('throws if interval is invalid', async (t) => { 403 | const err = await t.throwsAsync( 404 | jobValidator({ name: 'basic', interval: -1 }, 0, ['exists'], baseConfig) 405 | ); 406 | t.regex( 407 | err.message, 408 | /Job #1 named "basic" had an invalid interval of undefined; */ 409 | ); 410 | }); 411 | 412 | test('throws if hasSeconds is not a boolean', async (t) => { 413 | const err = await t.throwsAsync( 414 | jobValidator( 415 | { name: 'basic', hasSeconds: 'test' }, 416 | 0, 417 | ['exists'], 418 | baseConfig 419 | ) 420 | ); 421 | t.is( 422 | err.message, 423 | 'Job #1 named "basic" had hasSeconds value of test (it must be a Boolean)' 424 | ); 425 | }); 426 | 427 | test('throws if cronValidate is not an Object', async (t) => { 428 | const err = await t.throwsAsync( 429 | jobValidator( 430 | { name: 'basic', cronValidate: 'test' }, 431 | 0, 432 | ['exists'], 433 | baseConfig 434 | ) 435 | ); 436 | t.is( 437 | err.message, 438 | 'Job #1 named "basic" had cronValidate value set, but it must be an Object' 439 | ); 440 | }); 441 | 442 | test('throws if closeWorkerAfterMs is invalid', async (t) => { 443 | const err = await t.throwsAsync( 444 | jobValidator( 445 | { name: 'basic', closeWorkerAfterMs: -1 }, 446 | 0, 447 | ['exists'], 448 | baseConfig 449 | ) 450 | ); 451 | t.is( 452 | err.message, 453 | 'Job #1 named "basic" had an invalid closeWorkersAfterMs value of undefined (it must be a finite number > 0)' 454 | ); 455 | }); 456 | 457 | test('succeeds if job.timezone is valid', async (t) => { 458 | await t.notThrowsAsync( 459 | jobValidator( 460 | { name: 'basic', timezone: 'America/New_York' }, 461 | 0, 462 | ['exists'], 463 | baseConfig 464 | ) 465 | ); 466 | }); 467 | 468 | test('accepts "local" and "system" as valid job.timezone options', async (t) => { 469 | await t.notThrowsAsync( 470 | jobValidator( 471 | { name: 'basic', timezone: 'local' }, 472 | 0, 473 | ['exists'], 474 | baseConfig 475 | ) 476 | ); 477 | await t.notThrowsAsync( 478 | jobValidator( 479 | { name: 'basic', timezone: 'system' }, 480 | 0, 481 | ['exists'], 482 | baseConfig 483 | ) 484 | ); 485 | }); 486 | 487 | test('throws if job.timezone is invalid or unsupported', async (t) => { 488 | const err = await t.throwsAsync( 489 | jobValidator( 490 | { name: 'basic', timezone: 'bogus' }, 491 | 0, 492 | ['exists'], 493 | baseConfig 494 | ) 495 | ); 496 | t.is( 497 | err.message, 498 | 'Job #1 named "basic" had an invalid or unsupported timezone specified: bogus' 499 | ); 500 | }); 501 | 502 | test('throws if path is not a file during object job', async (t) => { 503 | const err = await t.throwsAsync( 504 | jobValidator({ name: 'leroy.js' }, 0, ['exists'], baseConfig) 505 | ); 506 | t.regex(err.message, /path missing/); 507 | }); 508 | 509 | test('throws if path is not a file during string job', async (t) => { 510 | const err = await t.throwsAsync( 511 | jobValidator('leroy.js', 0, ['exists'], baseConfig) 512 | ); 513 | t.regex(err.message, /path missing/); 514 | }); 515 | -------------------------------------------------------------------------------- /test/jobs/basic.js: -------------------------------------------------------------------------------- 1 | setTimeout(() => { 2 | console.log('hello'); 3 | }, 100); 4 | -------------------------------------------------------------------------------- /test/jobs/basic.mjs: -------------------------------------------------------------------------------- 1 | console.log('hello'); 2 | -------------------------------------------------------------------------------- /test/jobs/done.js: -------------------------------------------------------------------------------- 1 | const { parentPort } = require('node:worker_threads'); 2 | const delay = require('delay'); 3 | 4 | (async () => { 5 | await delay(1); 6 | 7 | if (parentPort) { 8 | parentPort.postMessage('get ready'); 9 | await delay(10); 10 | parentPort.postMessage('done'); 11 | } 12 | })(); 13 | -------------------------------------------------------------------------------- /test/jobs/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line prettier/prettier 2 | module.exports = [ 'basic' ]; 3 | -------------------------------------------------------------------------------- /test/jobs/infinite.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | 3 | setInterval(() => process.exit(0), 100); 4 | -------------------------------------------------------------------------------- /test/jobs/leroy.js/test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breejs/bree/048e9ebdc3defc23d832624fac2325236c96366c/test/jobs/leroy.js/test -------------------------------------------------------------------------------- /test/jobs/long.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | 3 | setTimeout(() => { 4 | process.exit(2); 5 | }, 4000); 6 | -------------------------------------------------------------------------------- /test/jobs/loop.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | const { parentPort } = require('node:worker_threads'); 3 | 4 | setInterval(() => {}, 1000); 5 | 6 | if (parentPort) { 7 | parentPort.on('message', (message) => { 8 | if (message === 'error') throw new Error('oops'); 9 | if (message === 'cancel') { 10 | parentPort.postMessage('cancelled'); 11 | return; 12 | } 13 | 14 | parentPort.postMessage(message); 15 | process.exit(0); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /test/jobs/message-process-exit.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | const { parentPort } = require('node:worker_threads'); 3 | 4 | setInterval(() => {}, 10); 5 | 6 | if (parentPort) { 7 | parentPort.on('message', (message) => { 8 | if (message === 'cancel') { 9 | process.exit(0); 10 | } 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /test/jobs/message-ungraceful.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | const { parentPort } = require('node:worker_threads'); 3 | 4 | setInterval(() => {}, 10); 5 | 6 | if (parentPort) { 7 | parentPort.on('message', (message) => { 8 | if (message === 'cancel') { 9 | parentPort.postMessage('ungraceful'); 10 | process.exit(0); 11 | } 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /test/jobs/message.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | const { parentPort } = require('node:worker_threads'); 3 | const delay = require('delay'); 4 | 5 | setInterval(() => {}, 10); 6 | 7 | if (parentPort) { 8 | parentPort.on('message', (message) => { 9 | if (message === 'error') throw new Error('oops'); 10 | if (message === 'cancel') { 11 | parentPort.postMessage('cancelled'); 12 | return; 13 | } 14 | 15 | parentPort.postMessage(message); 16 | delay(10).then(() => process.exit(0)); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /test/jobs/short.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | 3 | setInterval(() => { 4 | process.exit(2); 5 | }, 10); 6 | -------------------------------------------------------------------------------- /test/jobs/worker-data.js: -------------------------------------------------------------------------------- 1 | const { parentPort, workerData } = require('node:worker_threads'); 2 | 3 | if (parentPort) parentPort.postMessage(workerData); 4 | -------------------------------------------------------------------------------- /test/jobs/worker-options.js: -------------------------------------------------------------------------------- 1 | const process = require('node:process'); 2 | const { parentPort } = require('node:worker_threads'); 3 | 4 | if (parentPort) parentPort.postMessage(process.argv[2]); 5 | -------------------------------------------------------------------------------- /test/noIndexJobs/basic.js: -------------------------------------------------------------------------------- 1 | console.log('hello'); 2 | -------------------------------------------------------------------------------- /test/plugins/index.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const test = require('ava'); 3 | const Bree = require('../../src'); 4 | 5 | const root = path.join(__dirname, '..', 'jobs'); 6 | const baseConfig = { 7 | root, 8 | timeout: 0, 9 | interval: 0, 10 | hasSeconds: false, 11 | defaultExtension: 'js' 12 | }; 13 | 14 | test('successfully add plugin', (t) => { 15 | t.plan(2); 16 | 17 | const plugin = (_, c) => { 18 | c.prototype.test = () => { 19 | t.pass(); 20 | }; 21 | }; 22 | 23 | Bree.extend(plugin); 24 | 25 | t.is(plugin.$i, true); 26 | 27 | const bree = new Bree({ ...baseConfig }); 28 | 29 | bree.test(); 30 | }); 31 | 32 | test('successfully add plugin with options', (t) => { 33 | t.plan(1); 34 | 35 | const plugin = (options, c) => { 36 | c.prototype.test = () => { 37 | if (options.test) { 38 | t.pass(); 39 | } else { 40 | t.fail(); 41 | } 42 | }; 43 | }; 44 | 45 | Bree.extend(plugin, { test: true }); 46 | 47 | const bree = new Bree({ ...baseConfig }); 48 | 49 | bree.test(); 50 | }); 51 | 52 | test('only adds plugin once', (t) => { 53 | t.plan(2); 54 | 55 | let count = 0; 56 | 57 | const plugin = (_, c) => { 58 | if (count === 1) { 59 | t.fail(); 60 | } 61 | 62 | count++; 63 | 64 | c.prototype.test = () => { 65 | t.pass(); 66 | }; 67 | }; 68 | 69 | Bree.extend(plugin); 70 | 71 | t.is(plugin.$i, true); 72 | 73 | Bree.extend(plugin); 74 | 75 | const bree = new Bree({ ...baseConfig }); 76 | 77 | bree.test(); 78 | }); 79 | -------------------------------------------------------------------------------- /test/plugins/init.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const test = require('ava'); 3 | const Bree = require('../../src'); 4 | 5 | const root = path.join(__dirname, '..', 'jobs'); 6 | const baseConfig = { 7 | root, 8 | timeout: 0, 9 | interval: 0, 10 | hasSeconds: false, 11 | defaultExtension: 'js' 12 | }; 13 | 14 | test('plugin can extend init', async (t) => { 15 | t.plan(3); 16 | 17 | const plugin = (_, c) => { 18 | const origInit = c.prototype.init; 19 | 20 | c.prototype.init = function () { 21 | origInit.bind(this)(); 22 | 23 | t.pass(); 24 | }; 25 | }; 26 | 27 | Bree.extend(plugin); 28 | 29 | t.is(plugin.$i, true); 30 | 31 | const bree = new Bree({ ...baseConfig }); 32 | await bree.init(); 33 | 34 | t.true(bree instanceof Bree); 35 | }); 36 | -------------------------------------------------------------------------------- /test/remove.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const delay = require('delay'); 3 | const test = require('ava'); 4 | const Bree = require('../src'); 5 | 6 | const root = path.join(__dirname, 'jobs'); 7 | 8 | test('successfully remove jobs', async (t) => { 9 | const bree = new Bree({ 10 | root, 11 | jobs: ['basic', 'infinite'] 12 | }); 13 | await bree.init(); 14 | t.is(typeof bree.config.jobs[1], 'object'); 15 | 16 | await bree.remove('infinite'); 17 | 18 | t.is(typeof bree.config.jobs[1], 'undefined'); 19 | }); 20 | 21 | test('remove > successfully remove and stop jobs', async (t) => { 22 | const bree = new Bree({ 23 | root, 24 | jobs: ['loop'] 25 | }); 26 | await bree.start('loop'); 27 | await delay(10); 28 | 29 | t.is(bree.config.jobs[0].name, 'loop'); 30 | t.true(bree.workers.has('loop')); 31 | 32 | await bree.remove('loop'); 33 | 34 | t.is(typeof bree.config.jobs[0], 'undefined'); 35 | t.false(bree.workers.has('loop')); 36 | }); 37 | 38 | test('remove > fails if job does not exist', async (t) => { 39 | const bree = new Bree({ 40 | root, 41 | jobs: ['infinite'] 42 | }); 43 | 44 | await t.throwsAsync(() => bree.remove('basic'), { 45 | message: /Job .* does not exist/ 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/run.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const test = require('ava'); 4 | const delay = require('delay'); 5 | const Bree = require('../src'); 6 | 7 | const root = path.join(__dirname, 'jobs'); 8 | 9 | test('job does not exist', async (t) => { 10 | const bree = new Bree({ 11 | root, 12 | jobs: ['basic'] 13 | }); 14 | 15 | const err = await t.throwsAsync(bree.run('leroy')); 16 | t.is(err.message, 'Job "leroy" does not exist'); 17 | }); 18 | 19 | test('job already running', async (t) => { 20 | const logger = {}; 21 | logger.warn = (err, _) => { 22 | t.is(err.message, 'Job "basic" is already running'); 23 | }; 24 | 25 | logger.info = () => {}; 26 | 27 | const bree = new Bree({ 28 | root, 29 | jobs: ['basic'], 30 | logger 31 | }); 32 | 33 | await bree.run('basic'); 34 | await bree.run('basic'); 35 | }); 36 | 37 | test.serial('job terminates after closeWorkerAfterMs', async (t) => { 38 | t.plan(3); 39 | 40 | const logger = {}; 41 | logger.info = () => {}; 42 | logger.error = () => {}; 43 | 44 | const bree = new Bree({ 45 | root, 46 | jobs: [{ name: 'long', closeWorkerAfterMs: 2000 }], 47 | logger 48 | }); 49 | 50 | await bree.run('long'); 51 | await once(bree.workers.get('long'), 'online'); 52 | t.true(bree.closeWorkerAfterMs.has('long')); 53 | 54 | const [code] = await once(bree.workers.get('long'), 'exit'); 55 | t.is(code, 1); 56 | t.false(bree.closeWorkerAfterMs.has('long')); 57 | }); 58 | 59 | test('job terminates before closeWorkerAfterMs', async (t) => { 60 | const logger = {}; 61 | logger.info = () => {}; 62 | logger.error = () => {}; 63 | 64 | const bree = new Bree({ 65 | root, 66 | jobs: [{ name: 'short', closeWorkerAfterMs: 2000 }], 67 | logger 68 | }); 69 | 70 | await bree.run('short'); 71 | await once(bree.workers.get('short'), 'online'); 72 | t.true(bree.closeWorkerAfterMs.has('short')); 73 | 74 | const [code] = await once(bree.workers.get('short'), 'exit'); 75 | t.is(code, 2); 76 | t.false(bree.closeWorkerAfterMs.has('short')); 77 | }); 78 | 79 | test('job terminates should clear closeWorkerAfterMs', async (t) => { 80 | const logger = {}; 81 | logger.info = () => {}; 82 | logger.error = () => {}; 83 | 84 | const bree = new Bree({ 85 | root, 86 | jobs: [{ name: 'short', closeWorkerAfterMs: 2000 }], 87 | logger 88 | }); 89 | 90 | await bree.run('short'); 91 | await once(bree.workers.get('short'), 'online'); 92 | t.true(bree.closeWorkerAfterMs.has('short')); 93 | 94 | const [code] = await once(bree.workers.get('short'), 'exit'); 95 | t.is(code, 2); 96 | t.false(bree.closeWorkerAfterMs.has('short')); 97 | }); 98 | 99 | test('job terminates on message "done"', async (t) => { 100 | const logger = {}; 101 | logger.info = () => {}; 102 | 103 | const bree = new Bree({ 104 | root, 105 | jobs: [{ name: 'done' }], 106 | logger 107 | }); 108 | 109 | await bree.run('done'); 110 | 111 | await delay(10); 112 | 113 | t.true(bree.workers.has('done')); 114 | const [message] = await once(bree.workers.get('done'), 'message'); 115 | t.is(message, 'get ready'); 116 | 117 | await once(bree, 'worker deleted'); 118 | await delay(10); 119 | t.false(bree.workers.has('done')); 120 | }); 121 | 122 | test('job terminates on message "done" should clear closeWorkerAfterMs', async (t) => { 123 | const logger = {}; 124 | logger.info = () => {}; 125 | 126 | const bree = new Bree({ 127 | root, 128 | jobs: [{ name: 'done', closeWorkerAfterMs: 2000 }], 129 | logger 130 | }); 131 | 132 | await bree.run('done'); 133 | 134 | await delay(10); 135 | 136 | t.true(bree.workers.has('done')); 137 | const [message] = await once(bree.workers.get('done'), 'message'); 138 | t.is(message, 'get ready'); 139 | t.true(bree.closeWorkerAfterMs.has('done')); 140 | 141 | await once(bree, 'worker deleted'); 142 | await delay(10); 143 | t.false(bree.workers.has('done')); 144 | t.false(bree.closeWorkerAfterMs.has('done')); 145 | }); 146 | 147 | test('job sent a message', async (t) => { 148 | const logger = {}; 149 | logger.info = (message) => { 150 | if (message === 'Worker for job "message" sent a message') { 151 | t.pass(); 152 | } 153 | }; 154 | 155 | const bree = new Bree({ 156 | root, 157 | jobs: [{ name: 'message' }], 158 | logger 159 | }); 160 | 161 | await bree.run('message'); 162 | 163 | bree.workers.get('message').postMessage('test'); 164 | 165 | await once(bree.workers.get('message'), 'exit'); 166 | }); 167 | 168 | test('job sent an error', async (t) => { 169 | const logger = { 170 | error(message) { 171 | if (message === 'Worker for job "message" had an error') { 172 | t.pass(); 173 | } 174 | }, 175 | info() {} 176 | }; 177 | 178 | const bree = new Bree({ 179 | root, 180 | jobs: [{ name: 'message' }], 181 | logger 182 | }); 183 | 184 | await bree.run('message'); 185 | 186 | bree.workers.get('message').postMessage('error'); 187 | 188 | await once(bree.workers.get('message'), 'error'); 189 | await once(bree.workers.get('message'), 'exit'); 190 | }); 191 | 192 | test('job sent an error with custom handler', async (t) => { 193 | t.plan(5); 194 | const logger = { 195 | error() {}, 196 | info() {} 197 | }; 198 | 199 | const bree = new Bree({ 200 | root, 201 | jobs: [{ name: 'message' }], 202 | logger, 203 | errorHandler(err, workerMeta) { 204 | t.true(workerMeta.name === 'message'); 205 | 206 | if (workerMeta.err) { 207 | t.true(err.message === 'oops'); 208 | t.true(workerMeta.err.name === 'Error'); 209 | } else { 210 | t.true(err.message === 'Worker for job "message" exited with code 1'); 211 | } 212 | } 213 | }); 214 | 215 | await bree.run('message'); 216 | 217 | bree.workers.get('message').postMessage('error'); 218 | 219 | await once(bree.workers.get('message'), 'error'); 220 | await once(bree.workers.get('message'), 'exit'); 221 | }); 222 | 223 | test('job sent a message with custom worker message handler', async (t) => { 224 | t.plan(3); 225 | 226 | const logger = { 227 | error() {}, 228 | info() {} 229 | }; 230 | 231 | const bree = new Bree({ 232 | root, 233 | jobs: [{ name: 'message' }], 234 | logger, 235 | workerMessageHandler(metadata) { 236 | t.is(Object.keys(metadata).length, 2); 237 | t.is(metadata.message, 'hey Bob!'); 238 | t.is(metadata.name, 'message'); 239 | } 240 | }); 241 | 242 | await bree.run('message'); 243 | 244 | bree.workers.get('message').postMessage('hey Bob!'); 245 | 246 | await once(bree.workers.get('message'), 'exit'); 247 | }); 248 | 249 | test('job sent a message with custom worker message handler and metadata', async (t) => { 250 | t.plan(4); 251 | 252 | const logger = { 253 | error() {}, 254 | info() {} 255 | }; 256 | 257 | const bree = new Bree({ 258 | root, 259 | jobs: [{ name: 'message' }], 260 | logger, 261 | outputWorkerMetadata: true, 262 | workerMessageHandler(metadata) { 263 | t.is(Object.keys(metadata).length, 3); 264 | t.is(metadata.message, 'hi Alice!'); 265 | t.is(metadata.name, 'message'); 266 | t.is(Object.keys(metadata.worker).length, 3); 267 | } 268 | }); 269 | 270 | await bree.run('message'); 271 | 272 | bree.workers.get('message').postMessage('hi Alice!'); 273 | 274 | await once(bree.workers.get('message'), 'exit'); 275 | }); 276 | 277 | test('jobs run all when no name designated', async (t) => { 278 | const logger = {}; 279 | logger.info = () => {}; 280 | 281 | const bree = new Bree({ 282 | root, 283 | jobs: ['basic'], 284 | logger 285 | }); 286 | 287 | await bree.run(); 288 | await delay(10); 289 | 290 | t.true(bree.workers.has('basic')); 291 | 292 | const [code] = await once(bree.workers.get('basic'), 'exit'); 293 | t.is(code, 0); 294 | 295 | t.false(bree.workers.has('basic')); 296 | }); 297 | 298 | test('job runs with no worker options in config', async (t) => { 299 | const logger = {}; 300 | logger.info = () => {}; 301 | 302 | const bree = new Bree({ 303 | root, 304 | jobs: ['basic'], 305 | logger, 306 | worker: false 307 | }); 308 | 309 | await bree.run('basic'); 310 | await delay(10); 311 | 312 | t.true(bree.workers.has('basic')); 313 | 314 | const [code] = await once(bree.workers.get('basic'), 'exit'); 315 | t.is(code, 0); 316 | 317 | t.false(bree.workers.has('basic')); 318 | }); 319 | 320 | test('job runs and passes workerData from config', async (t) => { 321 | t.plan(4); 322 | const logger = { 323 | info(...args) { 324 | if (!args[1] || !args[1].message) { 325 | return; 326 | } 327 | 328 | t.is(args[1].message.test, 'test'); 329 | } 330 | }; 331 | 332 | const bree = new Bree({ 333 | root, 334 | jobs: ['worker-data'], 335 | logger, 336 | worker: { 337 | workerData: { 338 | test: 'test' 339 | } 340 | } 341 | }); 342 | 343 | await bree.run('worker-data'); 344 | 345 | await delay(10); 346 | t.true(bree.workers.has('worker-data')); 347 | 348 | const [code] = await once(bree.workers.get('worker-data'), 'exit'); 349 | t.is(code, 0); 350 | 351 | t.false(bree.workers.has('worker-data')); 352 | }); 353 | -------------------------------------------------------------------------------- /test/start.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const FakeTimers = require('@sinonjs/fake-timers'); 4 | const test = require('ava'); 5 | const later = require('@breejs/later'); 6 | const delay = require('delay'); 7 | const Bree = require('../src'); 8 | 9 | const root = path.join(__dirname, 'jobs'); 10 | 11 | const noop = () => { 12 | /* noop */ 13 | }; 14 | 15 | test('throws error if job does not exist', async (t) => { 16 | const bree = new Bree({ 17 | root, 18 | jobs: ['basic'] 19 | }); 20 | 21 | await bree.init(); 22 | 23 | const err = await t.throwsAsync(bree.start('leroy')); 24 | t.is(err.message, 'Job leroy does not exist'); 25 | }); 26 | 27 | test('fails if job already started', async (t) => { 28 | await t.throwsAsync( 29 | async () => { 30 | const bree = new Bree({ 31 | root, 32 | jobs: ['short'] 33 | }); 34 | await bree.init(); 35 | await bree.start('short'); 36 | await delay(10); 37 | await bree.start('short'); 38 | await delay(10); 39 | 40 | await bree.stop(); 41 | }, 42 | { 43 | message: `Job "short" is already started` 44 | } 45 | ); 46 | }); 47 | 48 | test('fails if date is in the past', async (t) => { 49 | t.plan(3); 50 | 51 | const logger = { 52 | warn(msg) { 53 | t.is(msg, `Job "basic" was skipped because it was in the past.`); 54 | } 55 | }; 56 | 57 | const bree = new Bree({ 58 | root, 59 | logger, 60 | jobs: [{ name: 'basic', date: new Date(Date.now() - 10) }] 61 | }); 62 | 63 | await bree.init(); 64 | 65 | bree.on('job past', (name) => { 66 | t.is(name, 'basic'); 67 | }); 68 | 69 | await bree.start('basic'); 70 | await delay(10); 71 | 72 | t.false(bree.timeouts.has('basic')); 73 | await delay(10); 74 | 75 | await bree.stop(); 76 | }); 77 | 78 | test('sets timeout if date is in the future', async (t) => { 79 | const bree = new Bree({ 80 | root, 81 | jobs: [ 82 | { 83 | name: 'infinite', 84 | date: new Date(Date.now() + 500) 85 | } 86 | ] 87 | }); 88 | 89 | await bree.init(); 90 | t.false(bree.timeouts.has('infinite')); 91 | await bree.start('infinite'); 92 | await delay(20); 93 | t.true(bree.timeouts.has('infinite')); 94 | 95 | await delay(500); 96 | 97 | t.false(bree.timeouts.has('infinite')); 98 | 99 | await bree.stop(); 100 | }); 101 | 102 | test('sets interval if date is in the future and interval is schedule', async (t) => { 103 | t.plan(4); 104 | 105 | const bree = new Bree({ 106 | root, 107 | jobs: [ 108 | { 109 | name: 'short', 110 | date: new Date(Date.now() + 1000), 111 | interval: later.parse.text('every 1 second') 112 | } 113 | ] 114 | }); 115 | 116 | await bree.init(); 117 | 118 | t.false(bree.intervals.has('short')); 119 | 120 | await bree.start('short'); 121 | 122 | await once(bree, 'worker created'); 123 | await delay(10); 124 | t.true(bree.intervals.has('short')); 125 | 126 | const [code] = await once(bree.workers.get('short'), 'exit'); 127 | t.is(code, 2); 128 | 129 | await once(bree, 'worker created'); 130 | await delay(10); 131 | t.pass(); 132 | 133 | await bree.stop(); 134 | }); 135 | 136 | test('sets interval if date is in the future and interval is number', async (t) => { 137 | t.plan(4); 138 | 139 | const bree = new Bree({ 140 | root, 141 | jobs: [ 142 | { 143 | name: 'short', 144 | date: new Date(Date.now() + 100), 145 | interval: 1000 146 | } 147 | ] 148 | }); 149 | 150 | await bree.init(); 151 | 152 | t.false(bree.intervals.has('short')); 153 | 154 | await bree.start('short'); 155 | 156 | await once(bree, 'worker created'); 157 | await delay(10); 158 | t.true(bree.intervals.has('short')); 159 | 160 | const [code] = await once(bree.workers.get('short'), 'exit'); 161 | t.is(code, 2); 162 | 163 | await once(bree, 'worker created'); 164 | await delay(10); 165 | t.pass(); 166 | 167 | await bree.stop(); 168 | }); 169 | 170 | test('sets timeout if interval is schedule and timeout is schedule', async (t) => { 171 | t.plan(7); 172 | 173 | const bree = new Bree({ 174 | root, 175 | jobs: [ 176 | { 177 | name: 'short', 178 | timeout: later.parse.text('every 1 sec'), 179 | interval: later.parse.text('every 1 sec') 180 | } 181 | ] 182 | }); 183 | 184 | await bree.init(); 185 | 186 | t.false(bree.timeouts.has('short')); 187 | t.false(bree.intervals.has('short')); 188 | 189 | await bree.start('short'); 190 | t.true(bree.timeouts.has('short')); 191 | 192 | await once(bree, 'worker created'); 193 | await delay(10); 194 | t.true(bree.intervals.has('short')); 195 | t.false(bree.timeouts.has('short')); 196 | 197 | const [code] = await once(bree.workers.get('short'), 'exit'); 198 | t.is(code, 2); 199 | 200 | await once(bree, 'worker created'); 201 | await delay(10); 202 | t.pass(); 203 | 204 | await bree.stop(); 205 | }); 206 | 207 | test('sets timeout if interval is number and timeout is schedule', async (t) => { 208 | t.plan(7); 209 | 210 | const bree = new Bree({ 211 | root, 212 | jobs: [ 213 | { 214 | name: 'short', 215 | timeout: later.parse.text('every 1 sec'), 216 | interval: 1000 217 | } 218 | ] 219 | }); 220 | 221 | await bree.init(); 222 | 223 | t.false(bree.timeouts.has('short')); 224 | t.false(bree.intervals.has('short')); 225 | 226 | await bree.start('short'); 227 | t.true(bree.timeouts.has('short')); 228 | 229 | await once(bree, 'worker created'); 230 | await delay(10); 231 | t.true(bree.intervals.has('short')); 232 | t.false(bree.timeouts.has('short')); 233 | 234 | const [code] = await once(bree.workers.get('short'), 'exit'); 235 | t.is(code, 2); 236 | 237 | await once(bree, 'worker created'); 238 | await delay(10); 239 | t.pass(); 240 | 241 | await bree.stop(); 242 | }); 243 | 244 | test('sets timeout if interval is 0 and timeout is schedule', async (t) => { 245 | t.plan(4); 246 | 247 | const bree = new Bree({ 248 | root, 249 | jobs: [ 250 | { 251 | name: 'short', 252 | timeout: later.parse.text('every 1 sec'), 253 | interval: 0 254 | } 255 | ] 256 | }); 257 | 258 | await bree.init(); 259 | 260 | t.false(bree.timeouts.has('short')); 261 | 262 | await bree.start('short'); 263 | 264 | t.true(bree.timeouts.has('short')); 265 | 266 | await once(bree, 'worker created'); 267 | await delay(10); 268 | 269 | t.false(bree.timeouts.has('short')); 270 | 271 | const [code] = await once(bree.workers.get('short'), 'exit'); 272 | t.is(code, 2); 273 | 274 | await bree.stop(); 275 | }); 276 | 277 | test('sets timeout if interval is schedule and timeout is number', async (t) => { 278 | t.plan(7); 279 | 280 | const bree = new Bree({ 281 | root, 282 | jobs: [ 283 | { 284 | name: 'infinite', 285 | timeout: 10, 286 | interval: later.parse.text('every 1 sec') 287 | } 288 | ] 289 | }); 290 | 291 | await bree.init(); 292 | 293 | t.false(bree.timeouts.has('infinite')); 294 | t.false(bree.intervals.has('infinite')); 295 | 296 | await bree.start('infinite'); 297 | t.true(bree.timeouts.has('infinite')); 298 | 299 | await once(bree, 'worker created'); 300 | await delay(10); 301 | t.true(bree.intervals.has('infinite')); 302 | t.false(bree.timeouts.has('infinite')); 303 | 304 | const [code] = await once(bree.workers.get('infinite'), 'exit'); 305 | t.true(code === 0); 306 | 307 | await once(bree, 'worker created'); 308 | t.pass(); 309 | 310 | await bree.stop(); 311 | }); 312 | 313 | test('sets timeout if interval is number and timeout is number', async (t) => { 314 | t.plan(7); 315 | 316 | const bree = new Bree({ 317 | root, 318 | jobs: [ 319 | { 320 | name: 'infinite', 321 | timeout: 10, 322 | interval: 10 323 | } 324 | ] 325 | }); 326 | 327 | await bree.init(); 328 | 329 | t.false(bree.timeouts.has('infinite')); 330 | t.false(bree.intervals.has('infinite')); 331 | 332 | await bree.start('infinite'); 333 | t.true(bree.timeouts.has('infinite')); 334 | 335 | await once(bree, 'worker created'); 336 | await delay(10); 337 | t.true(bree.intervals.has('infinite')); 338 | t.false(bree.timeouts.has('infinite')); 339 | 340 | const [code] = await once(bree.workers.get('infinite'), 'exit'); 341 | t.true(code === 0); 342 | 343 | await once(bree, 'worker created'); 344 | t.pass(); 345 | 346 | await bree.stop(); 347 | }); 348 | 349 | test('sets interval if interval is schedule', async (t) => { 350 | t.plan(3); 351 | 352 | const bree = new Bree({ 353 | root, 354 | jobs: ['infinite'], 355 | timeout: false, 356 | interval: later.parse.text('every 1 sec') 357 | }); 358 | 359 | await bree.init(); 360 | 361 | t.is(typeof bree.intervals.infinite, 'undefined'); 362 | 363 | await bree.start('infinite'); 364 | 365 | await once(bree, 'worker created'); 366 | await delay(10); 367 | t.true(bree.intervals.has('infinite')); 368 | 369 | const [code] = await once(bree.workers.get('infinite'), 'exit'); 370 | t.true(code === 0); 371 | 372 | await bree.stop(); 373 | }); 374 | 375 | test('sets interval if interval is number', async (t) => { 376 | t.plan(3); 377 | 378 | const bree = new Bree({ 379 | root, 380 | jobs: ['infinite'], 381 | timeout: false, 382 | interval: 1000 383 | }); 384 | 385 | await bree.init(); 386 | 387 | t.is(typeof bree.intervals.infinite, 'undefined'); 388 | 389 | await bree.start('infinite'); 390 | await once(bree, 'worker created'); 391 | await delay(10); 392 | t.true(bree.intervals.has('infinite')); 393 | 394 | const [code] = await once(bree.workers.get('infinite'), 'exit'); 395 | t.true(code === 0); 396 | 397 | await bree.stop(); 398 | }); 399 | 400 | test('does not set interval if interval is 0', async (t) => { 401 | t.plan(2); 402 | 403 | const bree = new Bree({ 404 | root, 405 | jobs: ['infinite'], 406 | timeout: false, 407 | interval: 0 408 | }); 409 | 410 | await bree.init(); 411 | 412 | t.is(typeof bree.intervals.infinite, 'undefined'); 413 | 414 | await bree.start('infinite'); 415 | await delay(10); 416 | 417 | t.is(typeof bree.intervals.infinite, 'undefined'); 418 | 419 | await bree.stop(); 420 | }); 421 | 422 | test.serial('uses job.timezone to schedule a job', async (t) => { 423 | t.plan(3); 424 | 425 | const datetimeNow = new Date('2021-08-22T10:30:00.000-04:00'); // zone = America/New_York 426 | 427 | const clock = FakeTimers.install({ 428 | now: datetimeNow.getTime() 429 | }); 430 | 431 | const bree = new Bree({ 432 | root, 433 | jobs: [ 434 | // TODO: job.date 435 | { 436 | name: 'tz_cron', 437 | path: noop, 438 | timezone: 'America/Mexico_City', 439 | cron: '30 10 * * *' 440 | }, 441 | { 442 | name: 'tz_interval', 443 | path: noop, 444 | timezone: 'Europe/Athens', 445 | interval: later.parse.cron('30 18 * * *') 446 | }, 447 | { 448 | name: 'tz_timeout', 449 | path: noop, 450 | timezone: 'Australia/Canberra', 451 | timeout: later.parse.cron('30 1 * * *') 452 | } 453 | ] 454 | }); 455 | 456 | await bree.init(); 457 | 458 | clock.setTimeout = (fn, ms) => { 459 | t.is(ms, 36e5); 460 | }; 461 | 462 | await bree.start('tz_cron'); 463 | await bree.start('tz_interval'); 464 | await bree.start('tz_timeout'); 465 | 466 | clock.uninstall(); 467 | }); 468 | 469 | test.serial('uses default timezone to schedule a job', async (t) => { 470 | t.plan(6); 471 | 472 | const datetimeNow = new Date('2021-08-22T10:30:00.000-04:00'); // zone = America/New_York 473 | 474 | const clock = FakeTimers.install({ 475 | now: datetimeNow.getTime() 476 | }); 477 | 478 | const bree = new Bree({ 479 | timezone: 'America/Mexico_City', 480 | root, 481 | jobs: [ 482 | // TODO: job.date 483 | { 484 | name: 'tz_cron', 485 | path: noop, 486 | cron: '0 10 * * *' 487 | }, 488 | { 489 | name: 'tz_interval', 490 | path: noop, 491 | interval: later.parse.cron('0 10 * * *') 492 | }, 493 | { 494 | name: 'tz_timeout', 495 | path: noop, 496 | timeout: later.parse.cron('0 10 * * *') 497 | } 498 | ] 499 | }); 500 | 501 | await bree.init(); 502 | 503 | for (const job of bree.config.jobs) t.is(job.timezone, 'America/Mexico_City'); 504 | 505 | clock.setTimeout = (fn, ms) => { 506 | t.is(ms, 18e5); 507 | }; 508 | 509 | await bree.start('tz_cron'); 510 | await bree.start('tz_interval'); 511 | await bree.start('tz_timeout'); 512 | 513 | clock.uninstall(); 514 | }); 515 | -------------------------------------------------------------------------------- /test/stop.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const { once } = require('node:events'); 3 | const test = require('ava'); 4 | const delay = require('delay'); 5 | const Bree = require('../src'); 6 | 7 | const root = path.join(__dirname, 'jobs'); 8 | 9 | test('job stops when "cancel" message is sent', async (t) => { 10 | t.plan(4); 11 | 12 | const logger = {}; 13 | logger.info = (message) => { 14 | if (message === 'Gracefully cancelled worker for job "message"') { 15 | t.true(true); 16 | } 17 | }; 18 | 19 | logger.error = () => {}; 20 | 21 | const bree = new Bree({ 22 | root, 23 | jobs: [{ name: 'message' }], 24 | logger 25 | }); 26 | 27 | t.false(bree.workers.has('message')); 28 | 29 | await bree.start('message'); 30 | await delay(10); 31 | 32 | t.true(bree.workers.has('message')); 33 | 34 | await bree.stop(); 35 | 36 | t.false(bree.workers.has('message')); 37 | }); 38 | 39 | test('job stops when process.exit(0) is called', async (t) => { 40 | t.plan(4); 41 | 42 | const logger = {}; 43 | logger.info = (message) => { 44 | if ( 45 | message === 'Worker for job "message-process-exit" exited with code 0' 46 | ) { 47 | t.true(true); 48 | } 49 | }; 50 | 51 | const bree = new Bree({ 52 | root, 53 | jobs: [{ name: 'message-process-exit' }], 54 | logger 55 | }); 56 | 57 | t.false(bree.workers.has('message-process-exit')); 58 | 59 | await bree.start('message-process-exit'); 60 | await delay(10); 61 | 62 | t.true(bree.workers.has('message-process-exit')); 63 | 64 | await bree.stop(); 65 | 66 | t.false(bree.workers.has('message-process-exit')); 67 | }); 68 | 69 | test('does not send graceful notice if no cancelled message', async (t) => { 70 | const logger = { 71 | info(message) { 72 | if (message === 'Gracefully cancelled worker for job "message"') { 73 | t.fail(); 74 | } 75 | }, 76 | error() {} 77 | }; 78 | 79 | const bree = new Bree({ 80 | root, 81 | jobs: ['message-ungraceful'], 82 | logger 83 | }); 84 | 85 | await bree.start('message-ungraceful'); 86 | await delay(10); 87 | console.log(bree); 88 | await bree.stop('message-ungraceful'); 89 | 90 | t.pass(); 91 | }); 92 | 93 | test('clears closeWorkerAfterMs', async (t) => { 94 | const bree = new Bree({ 95 | root, 96 | jobs: [{ name: 'basic', closeWorkerAfterMs: 10 }] 97 | }); 98 | 99 | t.false(bree.closeWorkerAfterMs.has('basic')); 100 | 101 | await bree.run('basic'); 102 | 103 | await once(bree.workers.get('basic'), 'online'); 104 | t.true(bree.closeWorkerAfterMs.has('basic')); 105 | 106 | await bree.stop('basic'); 107 | 108 | t.false(bree.closeWorkerAfterMs.has('basic')); 109 | }); 110 | 111 | test('deletes closeWorkerAfterMs', async (t) => { 112 | const bree = new Bree({ 113 | root, 114 | jobs: [{ name: 'basic', closeWorkerAfterMs: 10 }] 115 | }); 116 | 117 | t.false(bree.closeWorkerAfterMs.has('basic')); 118 | 119 | await bree.run('basic'); 120 | 121 | await once(bree.workers.get('basic'), 'online'); 122 | t.true(bree.closeWorkerAfterMs.has('basic')); 123 | 124 | await once(bree.workers.get('basic'), 'exit'); 125 | 126 | bree.closeWorkerAfterMs.set('basic', 'test'); 127 | await bree.stop('basic'); 128 | 129 | t.false(bree.closeWorkerAfterMs.has('basic')); 130 | }); 131 | 132 | test('clears timeouts', async (t) => { 133 | const bree = new Bree({ 134 | root, 135 | jobs: [{ name: 'basic', timeout: 1000 }] 136 | }); 137 | 138 | t.false(bree.timeouts.has('basic')); 139 | 140 | await bree.start('basic'); 141 | await bree.stop('basic'); 142 | 143 | t.false(bree.timeouts.has('basic')); 144 | }); 145 | 146 | test('deletes timeouts', async (t) => { 147 | const bree = new Bree({ 148 | root, 149 | jobs: [{ name: 'basic', timeout: 1000 }] 150 | }); 151 | 152 | t.false(bree.timeouts.has('basic')); 153 | 154 | await bree.start('basic'); 155 | bree.timeouts.set('basic', 'test'); 156 | await bree.stop('basic'); 157 | 158 | t.false(bree.timeouts.has('basic')); 159 | }); 160 | 161 | test('deletes intervals', async (t) => { 162 | const bree = new Bree({ 163 | root, 164 | jobs: [{ name: 'basic', interval: 1000 }] 165 | }); 166 | 167 | t.false(bree.intervals.has('basic')); 168 | 169 | await bree.start('basic'); 170 | bree.intervals.set('basic', 'test'); 171 | await bree.stop('basic'); 172 | 173 | t.false(bree.intervals.has('basic')); 174 | }); 175 | --------------------------------------------------------------------------------