├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ └── index.js ├── lib └── index.js └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [package.json] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | package.json 2 | coverage 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "extends": "@hapi/eslint-config-hapi", 7 | "parserOptions": { 8 | "sourceType": "module", 9 | "ecmaVersion": 8 10 | }, 11 | "globals": { 12 | "describe": true, 13 | "it": true, 14 | "expect": true, 15 | "jest": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | # Commenting this out is preferred by some people, see 3 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 4 | node_modules 5 | 6 | # Webstorm project files 7 | .idea 8 | 9 | # OSX files 10 | .DS_Store 11 | 12 | # Coverage files 13 | coverage 14 | 15 | # Logs 16 | *.log 17 | 18 | # NPM 19 | package-lock.json 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "10" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Anton Samper Rivaya. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 4 | following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 7 | disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote 13 | products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 16 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 18 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 20 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 21 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hapi-cron [![Build Status](https://travis-ci.org/antonsamper/hapi-cron.svg?branch=master)](https://travis-ci.org/antonsamper/hapi-cron) [![Greenkeeper badge](https://badges.greenkeeper.io/antonsamper/hapi-cron.svg)](https://greenkeeper.io/) 2 | 3 | A Hapi plugin to setup cron jobs that will call predefined server routes at specified times. 4 | 5 | 6 | ## Requirements 7 | This plugin is compatible with **hapi** v17+ and requires Node v8+. 8 | If you need a version compatible with **hapi** v16 please install version [0.0.3](https://github.com/antonsamper/hapi-cron/releases/tag/v0.0.3). 9 | 10 | 11 | ## Installation 12 | Add `hapi-cron` as a dependency to your project: 13 | 14 | ```bash 15 | $ npm install --save hapi-cron 16 | ``` 17 | 18 | 19 | ## Usage 20 | ```javascript 21 | const Hapi = require('@hapi/hapi'); 22 | const HapiCron = require('hapi-cron'); 23 | 24 | const server = new Hapi.Server(); 25 | 26 | async function allSystemsGo() { 27 | 28 | try { 29 | await server.register({ 30 | plugin: HapiCron, 31 | options: { 32 | jobs: [{ 33 | name: 'testcron', 34 | time: '*/10 * * * * *', 35 | timezone: 'Europe/London', 36 | request: { 37 | method: 'GET', 38 | url: '/test-url' 39 | }, 40 | onComplete: (res) => { 41 | console.log(res); // 'hello world' 42 | } 43 | }] 44 | } 45 | }); 46 | 47 | server.route({ 48 | method: 'GET', 49 | path: '/test-url', 50 | handler: function (request, h) { 51 | return 'hello world' 52 | } 53 | }); 54 | 55 | await server.start(); 56 | } 57 | catch (err) { 58 | console.info('there was an error'); 59 | } 60 | } 61 | 62 | allSystemsGo(); 63 | ``` 64 | 65 | ## Options 66 | * `name` - A unique name for the cron job 67 | * `time` - A valid cron value. [See cron configuration](#cron-configuration) 68 | * `timezone` - A valid [timezone](https://momentjs.com/timezone/) 69 | * `request` - The request object containing the route url path. Other [options](https://hapi.dev/api/#-await-serverinjectoptions) can also be passed into the request object 70 | * `url` - Route path to request 71 | * `method` - Request method (defaults to `GET`) - `optional` 72 | * `onComplete` - A synchronous function to run after the route has been requested. The function will contain the result from the request - `optional` 73 | 74 | 75 | ## Cron configuration 76 | This plugin uses the [node-cron](https://github.com/kelektiv/node-cron) module to setup the cron job. 77 | 78 | 79 | ### Available cron patterns: 80 | ``` 81 | Asterisk. E.g. * 82 | Ranges. E.g. 1-3,5 83 | Steps. E.g. */2 84 | ``` 85 | 86 | 87 | [Read up on cron patterns here](http://crontab.org). Note the examples in the link have five fields, and 1 minute as the finest granularity, but the node cron module allows six fields, with 1 second as the finest granularity. 88 | 89 | ### Cron Ranges 90 | When specifying your cron values you'll need to make sure that your values fall within the ranges. For instance, some cron's use a 0-7 range for the day of week where both 0 and 7 represent Sunday. We do not. 91 | 92 | * Seconds: 0-59 93 | * Minutes: 0-59 94 | * Hours: 0-23 95 | * Day of Month: 1-31 96 | * Months: 0-11 97 | * Day of Week: 0-6 98 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | 1. Dependencies 3 | *********************************************************************************/ 4 | 5 | const HapiCron = require('../lib'); 6 | const Hapi = require('@hapi/hapi'); 7 | 8 | 9 | /********************************************************************************* 10 | 2. Exports 11 | *********************************************************************************/ 12 | 13 | describe('registration assertions', () => { 14 | 15 | it('should register plugin without errors', async () => { 16 | 17 | const server = new Hapi.Server(); 18 | 19 | await server.register({ 20 | plugin: HapiCron 21 | }); 22 | }); 23 | 24 | it('should throw error when a job is defined with an existing name', async () => { 25 | 26 | const server = new Hapi.Server(); 27 | 28 | try { 29 | await server.register({ 30 | plugin: HapiCron, 31 | options: { 32 | jobs: [{ 33 | name: 'testname', 34 | time: '*/10 * * * * *', 35 | timezone: 'Europe/London', 36 | request: { 37 | url: '/test-url' 38 | } 39 | }, { 40 | name: 'testname', 41 | time: '*/10 * * * * *', 42 | timezone: 'Europe/London', 43 | request: { 44 | url: '/test-url' 45 | } 46 | }] 47 | } 48 | }); 49 | } 50 | catch (err) { 51 | expect(err.message).toEqual('Job name has already been defined'); 52 | } 53 | }); 54 | 55 | it('should throw error when a job is defined without a name', async () => { 56 | 57 | const server = new Hapi.Server(); 58 | 59 | try { 60 | await server.register({ 61 | plugin: HapiCron, 62 | options: { 63 | jobs: [{ 64 | time: '*/10 * * * * *', 65 | timezone: 'Europe/London', 66 | request: { 67 | url: '/test-url' 68 | } 69 | }] 70 | } 71 | }); 72 | } 73 | catch (err) { 74 | expect(err.message).toEqual('Missing job name'); 75 | } 76 | }); 77 | 78 | it('should throw error when a job is defined without a time', async () => { 79 | 80 | const server = new Hapi.Server(); 81 | 82 | try { 83 | await server.register({ 84 | plugin: HapiCron, 85 | options: { 86 | jobs: [{ 87 | name: 'testcron', 88 | timezone: 'Europe/London', 89 | request: { 90 | url: '/test-url' 91 | } 92 | }] 93 | } 94 | }); 95 | } 96 | catch (err) { 97 | expect(err.message).toEqual('Missing job time'); 98 | } 99 | }); 100 | 101 | it('should throw error when a job is defined with an invalid time', async () => { 102 | 103 | const server = new Hapi.Server(); 104 | 105 | try { 106 | await server.register({ 107 | plugin: HapiCron, 108 | options: { 109 | jobs: [{ 110 | name: 'testcron', 111 | time: 'invalid cron', 112 | timezone: 'Europe/London', 113 | request: { 114 | url: '/test-url' 115 | } 116 | }] 117 | } 118 | }); 119 | } 120 | catch (err) { 121 | expect(err.message).toEqual('Time is not a cron expression'); 122 | } 123 | }); 124 | 125 | it('should throw error when a job is defined with an invalid timezone', async () => { 126 | 127 | const server = new Hapi.Server(); 128 | 129 | try { 130 | await server.register({ 131 | plugin: HapiCron, 132 | options: { 133 | jobs: [{ 134 | name: 'testcron', 135 | time: '*/10 * * * * *', 136 | timezone: 'invalid', 137 | request: { 138 | url: '/test-url' 139 | } 140 | }] 141 | } 142 | }); 143 | } 144 | catch (err) { 145 | expect(err.message).toEqual('Invalid timezone. See https://momentjs.com/timezone for valid timezones'); 146 | } 147 | }); 148 | 149 | it('should throw error when a job is defined without a timezone', async () => { 150 | 151 | const server = new Hapi.Server(); 152 | 153 | try { 154 | await server.register({ 155 | plugin: HapiCron, 156 | options: { 157 | jobs: [{ 158 | name: 'testcron', 159 | time: '*/10 * * * * *', 160 | request: { 161 | url: '/test-url' 162 | } 163 | }] 164 | } 165 | }); 166 | } 167 | catch (err) { 168 | expect(err.message).toEqual('Missing job time zone'); 169 | } 170 | }); 171 | 172 | it('should throw error when a job is defined without a request object', async () => { 173 | 174 | const server = new Hapi.Server(); 175 | 176 | try { 177 | await server.register({ 178 | plugin: HapiCron, 179 | options: { 180 | jobs: [{ 181 | name: 'testcron', 182 | time: '*/10 * * * * *', 183 | timezone: 'Europe/London' 184 | }] 185 | } 186 | }); 187 | } 188 | catch (err) { 189 | expect(err.message).toEqual('Missing job request options'); 190 | } 191 | }); 192 | 193 | it('should throw error when a job is defined without a url in the request object', async () => { 194 | 195 | const server = new Hapi.Server(); 196 | 197 | try { 198 | await server.register({ 199 | plugin: HapiCron, 200 | options: { 201 | jobs: [{ 202 | name: 'testcron', 203 | time: '*/10 * * * * *', 204 | timezone: 'Europe/London', 205 | request: { 206 | method: 'GET' 207 | } 208 | }] 209 | } 210 | }); 211 | } 212 | catch (err) { 213 | expect(err.message).toEqual('Missing job request url'); 214 | } 215 | }); 216 | 217 | it('should throw error when a job is defined with an invalid onComplete value', async () => { 218 | 219 | const server = new Hapi.Server(); 220 | 221 | try { 222 | await server.register({ 223 | plugin: HapiCron, 224 | options: { 225 | jobs: [{ 226 | name: 'testcron', 227 | time: '*/10 * * * * *', 228 | timezone: 'Europe/London', 229 | request: { 230 | method: 'GET', 231 | url: '/test-url' 232 | }, 233 | onComplete: 'invalid' 234 | }] 235 | } 236 | }); 237 | } 238 | catch (err) { 239 | expect(err.message).toEqual('onComplete value must be a function'); 240 | } 241 | }); 242 | }); 243 | 244 | describe('plugin functionality', () => { 245 | 246 | it('should expose access to the registered jobs', async () => { 247 | 248 | const server = new Hapi.Server(); 249 | 250 | await server.register({ 251 | plugin: HapiCron, 252 | options: { 253 | jobs: [{ 254 | name: 'testcron', 255 | time: '*/10 * * * * *', 256 | timezone: 'Europe/London', 257 | request: { 258 | method: 'GET', 259 | url: '/test-url' 260 | } 261 | }] 262 | } 263 | }); 264 | 265 | expect(server.plugins['hapi-cron']).toBeDefined(); 266 | expect(server.plugins['hapi-cron'].jobs.testcron).toBeDefined(); 267 | }); 268 | 269 | it('should ensure the request and callback from the plugin options are triggered', async (done) => { 270 | 271 | const onComplete = jest.fn(); 272 | const server = new Hapi.Server(); 273 | 274 | await server.register({ 275 | plugin: HapiCron, 276 | options: { 277 | jobs: [{ 278 | name: 'testcron', 279 | time: '*/10 * * * * *', 280 | timezone: 'Europe/London', 281 | request: { 282 | method: 'GET', 283 | url: '/test-url' 284 | }, 285 | onComplete 286 | }] 287 | } 288 | }); 289 | 290 | server.route({ 291 | method: 'GET', 292 | path: '/test-url', 293 | handler: () => 'hello world' 294 | }); 295 | 296 | server.events.on('response', (request) => { 297 | 298 | expect(request.method).toBe('get'); 299 | expect(request.path).toBe('/test-url'); 300 | done(); 301 | }); 302 | 303 | expect(onComplete).not.toHaveBeenCalled(); 304 | 305 | await server.plugins['hapi-cron'].jobs.testcron._callbacks[0](); 306 | 307 | expect(onComplete).toHaveBeenCalledTimes(1); 308 | expect(onComplete).toHaveBeenCalledWith('hello world'); 309 | }); 310 | 311 | it('should not start the jobs until the server starts', async () => { 312 | 313 | const server = new Hapi.Server(); 314 | 315 | await server.register({ 316 | plugin: HapiCron, 317 | options: { 318 | jobs: [{ 319 | name: 'testcron', 320 | time: '*/10 * * * * *', 321 | timezone: 'Europe/London', 322 | request: { 323 | method: 'GET', 324 | url: '/test-url' 325 | } 326 | }] 327 | } 328 | }); 329 | 330 | expect(server.plugins['hapi-cron'].jobs.testcron.running).toBeUndefined(); 331 | 332 | await server.start(); 333 | 334 | expect(server.plugins['hapi-cron'].jobs.testcron.running).toBe(true); 335 | 336 | await server.stop(); 337 | }); 338 | 339 | it('should stop cron jobs when the server stops', async () => { 340 | 341 | const server = new Hapi.Server(); 342 | 343 | await server.register({ 344 | plugin: HapiCron, 345 | options: { 346 | jobs: [{ 347 | name: 'testcron', 348 | time: '*/10 * * * * *', 349 | timezone: 'Europe/London', 350 | request: { 351 | method: 'GET', 352 | url: '/test-url' 353 | } 354 | }] 355 | } 356 | }); 357 | 358 | await server.start(); 359 | 360 | expect(server.plugins['hapi-cron'].jobs.testcron.running).toBe(true); 361 | 362 | await server.stop(); 363 | 364 | expect(server.plugins['hapi-cron'].jobs.testcron.running).toBe(false); 365 | }); 366 | }); 367 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | 1. Dependencies 3 | *********************************************************************************/ 4 | 5 | const Hoek = require('@hapi/hoek'); 6 | const CronJob = require('cron').CronJob; 7 | const PluginPackage = require('../package.json'); 8 | 9 | 10 | /********************************************************************************* 11 | 2. Internals 12 | *********************************************************************************/ 13 | 14 | const internals = {}; 15 | 16 | internals.trigger = (server, job) => { 17 | 18 | return async () => { 19 | 20 | server.log([PluginPackage.name], job.name); 21 | 22 | const res = await server.inject(job.request); 23 | 24 | /* istanbul ignore else */ 25 | if (job.onComplete) { 26 | job.onComplete(res.result); 27 | } 28 | }; 29 | }; 30 | 31 | internals.onPostStart = (jobs) => { 32 | 33 | return () => { 34 | 35 | for (const key of Object.keys(jobs)) { 36 | jobs[key].start(); 37 | } 38 | }; 39 | }; 40 | 41 | internals.onPreStop = (jobs) => { 42 | 43 | return () => { 44 | 45 | for (const key of Object.keys(jobs)) { 46 | jobs[key].stop(); 47 | } 48 | }; 49 | }; 50 | 51 | 52 | /********************************************************************************* 53 | 3. Exports 54 | *********************************************************************************/ 55 | 56 | const PluginRegistration = (server, options) => { 57 | 58 | const jobs = {}; 59 | 60 | if (!options.jobs || !options.jobs.length) { 61 | server.log([PluginPackage.name], 'No cron jobs provided.'); 62 | } 63 | else { 64 | options.jobs.forEach((job) => { 65 | 66 | Hoek.assert(!jobs[job.name], 'Job name has already been defined'); 67 | Hoek.assert(job.name, 'Missing job name'); 68 | Hoek.assert(job.time, 'Missing job time'); 69 | Hoek.assert(job.timezone, 'Missing job time zone'); 70 | Hoek.assert(job.request, 'Missing job request options'); 71 | Hoek.assert(job.request.url, 'Missing job request url'); 72 | Hoek.assert(typeof job.onComplete === 'function' || typeof job.onComplete === 'undefined', 'onComplete value must be a function'); 73 | 74 | try { 75 | jobs[job.name] = new CronJob(job.time, internals.trigger(server, job), null, false, job.timezone); 76 | } 77 | catch (err) { 78 | if (err.message === 'Invalid timezone.') { 79 | Hoek.assert(!err, 'Invalid timezone. See https://momentjs.com/timezone for valid timezones'); 80 | } 81 | else { 82 | Hoek.assert(!err, 'Time is not a cron expression'); 83 | } 84 | } 85 | }); 86 | } 87 | 88 | server.expose('jobs', jobs); 89 | server.ext('onPostStart', internals.onPostStart(jobs)); 90 | server.ext('onPreStop', internals.onPreStop(jobs)); 91 | }; 92 | 93 | exports.plugin = { 94 | register: PluginRegistration, 95 | pkg: PluginPackage 96 | }; 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-cron", 3 | "version": "1.0.6", 4 | "description": "A Hapi plugin to setup cron jobs that will call predefined server routes at specified times", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "npm run eslint && jest --runInBand", 8 | "eslint": "eslint ." 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/antonsamper/hapi-cron.git" 13 | }, 14 | "keywords": [ 15 | "hapi", 16 | "plugin", 17 | "cron" 18 | ], 19 | "author": "Anton Samper Rivaya", 20 | "license": "BSD-3-Clause", 21 | "bugs": { 22 | "url": "https://github.com/antonsamper/hapi-cron/issues" 23 | }, 24 | "homepage": "https://github.com/antonsamper/hapi-cron#readme", 25 | "devDependencies": { 26 | "eslint": "^6.0.0", 27 | "@hapi/eslint-config-hapi": "^12.1.0", 28 | "@hapi/eslint-plugin-hapi": "^4.3.3", 29 | "@hapi/hapi": "^18.3.1", 30 | "jest": "^24.8.0" 31 | }, 32 | "dependencies": { 33 | "cron": "^1.7.1", 34 | "@hapi/hoek": "^7.2.0" 35 | }, 36 | "engines": { 37 | "node": ">=8.0.0" 38 | }, 39 | "jest": { 40 | "collectCoverage": true, 41 | "coverageThreshold": { 42 | "global": { 43 | "branches": 100, 44 | "functions": 100, 45 | "lines": 100, 46 | "statements": 100 47 | } 48 | }, 49 | "testEnvironment": "node" 50 | } 51 | } 52 | --------------------------------------------------------------------------------