├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json └── scheduler.jpg /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Coder of Salvation / Leon van Kammen 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 | Rolled your own parse-server, and realized scheduled jobs don't work out of the box? 2 | 3 | ## Usage 4 | 5 | run `npm install parse-server-scheduler` in your `cloud/index.js` or `app.js` simply include: 6 | 7 | ``` 8 | require('parse-server-scheduler')(Parse) 9 | ``` 10 | 11 | > Voila! Profit! 12 | 13 | ## Why 14 | 15 | ![](https://raw.githubusercontent.com/coderofsalvation/parse-server-scheduler/master/scheduler.jpg) 16 | 17 | Parse exposes scheduled jobs as HTTP endpoints, which is great and a disappointment at the same time :)

18 | This empowers your server with an internal scheduler using the `cron` and `moment` npm-packages. 19 | 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const cron = require('cron') 2 | const moment = require('moment') 3 | const Parse = require('parse/node') 4 | const rp = require('request-promise') 5 | 6 | const CronJob = cron.CronJob 7 | const PARSE_TIMEZONE = 'UTC' 8 | 9 | let cronJobs = {} 10 | 11 | module.exports = (Parse) => { 12 | 13 | /** 14 | * Parse job schedule object 15 | * @typedef {Object} _JobSchedule 16 | * @property {String} id The job id 17 | */ 18 | 19 | /** 20 | * Recreate the cron schedules for a specific _JobSchedule or all _JobSchedule objects 21 | * @param {_JobSchedule | string} [job=null] The job schedule to recreate. If not specified, all jobs schedules will be recreated. 22 | * Can be a _JobSchedule object or the id of a _JobSchedule object. 23 | */ 24 | const recreateSchedule = async (job) => { 25 | if (job) { 26 | if (job instanceof String || typeof job === 'string') { 27 | try { 28 | const jobObject = await Parse.Object.extend('_JobSchedule').createWithoutData(job).fetch({ 29 | useMasterKey: true 30 | }) 31 | if (jobObject) { 32 | recreateJobSchedule(jobObject) 33 | } else { 34 | throw new Error(`No _JobSchedule was found with id ${job}`) 35 | } 36 | } catch (error) { 37 | throw error 38 | } 39 | } else if (job instanceof Parse.Object && job.className === '_JobSchedule') { 40 | recreateJobSchedule(job) 41 | } else { 42 | throw new Error('Invalid job type. Must be a string or a _JobSchedule') 43 | } 44 | } else { 45 | try { 46 | recreateScheduleForAllJobs() 47 | } catch (error) { 48 | throw error 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * (Re)creates all schedules (crons) for all _JobSchedule from the Parse server 55 | */ 56 | const recreateScheduleForAllJobs = async () => { 57 | if (!Parse.applicationId) { 58 | throw new Error('Parse is not initialized') 59 | } 60 | 61 | try { 62 | const results = await new Parse.Query('_JobSchedule').find({ 63 | useMasterKey: true 64 | }) 65 | 66 | destroySchedules() 67 | 68 | for (let job of results) { 69 | try { 70 | recreateJobSchedule(job) 71 | } catch (error) { 72 | console.log(error) 73 | } 74 | } 75 | console.log(`${Object.keys(cronJobs).length} job(s) scheduled.`) 76 | } catch (error) { 77 | throw error 78 | } 79 | } 80 | 81 | /** 82 | * (Re)creates the schedule (crons) of a _JobSchedule 83 | * @param {_JobSchedule} job The _JobSchedule 84 | */ 85 | const recreateJobSchedule = (job) => { 86 | destroySchedule(job.id) 87 | cronJobs[job.id] = createCronJobs(job) 88 | } 89 | 90 | /** 91 | * Stop all jobs and remove them from the list of jobs 92 | */ 93 | const destroySchedules = () => { 94 | for (let key of Object.keys(cronJobs)) { 95 | destroySchedule(key) 96 | } 97 | cronJobs = {} 98 | } 99 | 100 | /** 101 | * Destroy a planned cron job 102 | * @param {String} id The _JobSchedule id 103 | */ 104 | const destroySchedule = (id) => { 105 | const jobs = cronJobs[id] 106 | if (jobs) { 107 | for (let job of jobs) { 108 | job.stop() 109 | } 110 | delete cronJobs[id] 111 | } 112 | } 113 | 114 | const createCronJobs = (job) => { 115 | const startDate = new Date(job.get('startAfter')) 116 | const repeatMinutes = job.get('repeatMinutes') 117 | const jobName = job.get('jobName') 118 | const params = job.get('params') 119 | const now = moment() 120 | 121 | // Launch just once 122 | if (!repeatMinutes) { 123 | return [ 124 | new CronJob( 125 | startDate, 126 | () => { // On tick 127 | performJob(jobName, params) 128 | }, 129 | null, // On complete 130 | true, // Start 131 | PARSE_TIMEZONE // Timezone 132 | ) 133 | ] 134 | } 135 | // Periodic job. Create a cron to launch the periodic job a the start date. 136 | let timeOfDay = moment(job.get('timeOfDay'), 'HH:mm:ss.Z').utc() 137 | const daysOfWeek = job.get('daysOfWeek') 138 | const cronDoW = (daysOfWeek) ? daysOfWeekToCronString(daysOfWeek) : '*' 139 | const minutes = repeatMinutes % 60 140 | const hours = Math.floor(repeatMinutes / 60) 141 | 142 | let cron = '0 ' 143 | // Minutes 144 | if (minutes) { 145 | cron += `${timeOfDay.minutes()}-59/${minutes} ` 146 | } else { 147 | cron += `0 ` 148 | } 149 | 150 | // Hours 151 | cron += `${timeOfDay.hours()}-23` 152 | if (hours) { 153 | cron += `/${hours}` 154 | } 155 | cron += ' ' 156 | 157 | // Day of month 158 | cron += '* ' 159 | 160 | // Month 161 | cron += '* ' 162 | 163 | // Days of week 164 | cron += cronDoW 165 | 166 | console.log(`${jobName}: ${cron}`) 167 | 168 | const actualJob = new CronJob( 169 | cron, 170 | () => { // On tick 171 | performJob(jobName, params) 172 | }, 173 | null, // On complete 174 | false, // Start 175 | PARSE_TIMEZONE // Timezone 176 | ) 177 | 178 | // If startDate is before now, start the cron now 179 | if (moment(startDate).isBefore(now)) { 180 | actualJob.start() 181 | return [actualJob] 182 | } 183 | 184 | // Otherwise, schedule a cron that is going to launch our actual cron at the time of the day 185 | const startCron = new CronJob( 186 | startDate, 187 | () => { // On tick 188 | console.log('Start the cron') 189 | actualJob.start() 190 | }, 191 | null, // On complete 192 | true, // Start 193 | PARSE_TIMEZONE // Timezone 194 | ) 195 | 196 | return [startCron, actualJob] 197 | } 198 | 199 | /** 200 | * Converts the Parse scheduler days of week 201 | * @param {Array} daysOfWeek An array of seven elements for the days of the week. 1 to schedule the task for the day, otherwise 0. 202 | */ 203 | const daysOfWeekToCronString = (daysOfWeek) => { 204 | const daysNumbers = [] 205 | for (let i = 0; i < daysOfWeek.length; i++) { 206 | if (daysOfWeek[i]) { 207 | daysNumbers.push((i + 1) % 7) 208 | } 209 | } 210 | return daysNumbers.join(',') 211 | } 212 | 213 | /** 214 | * Perform a background job 215 | * @param {String} jobName The job name on Parse Server 216 | * @param {Object=} params The parameters to pass to the request 217 | */ 218 | const performJob = async (jobName, params) => { 219 | try { 220 | const request = rp({ 221 | method: 'POST', 222 | uri: Parse.serverURL + '/jobs/' + jobName, 223 | headers: { 224 | 'X-Parse-Application-Id': Parse.applicationId, 225 | 'X-Parse-Master-Key': Parse.masterKey 226 | }, 227 | json: true // Automatically parses the JSON string in the response 228 | }) 229 | if (params) { 230 | request.body = params 231 | } 232 | console.log(`Job ${jobName} launched.`) 233 | } catch (error) { 234 | console.log(error) 235 | } 236 | } 237 | 238 | const init = () => { 239 | 240 | // Recreates all crons when the server is launched 241 | recreateSchedule() 242 | 243 | // Recreates schedule when a job schedule has changed 244 | Parse.Cloud.afterSave('_JobSchedule', async (request) => { 245 | recreateSchedule(request.object) 246 | }) 247 | 248 | // Destroy schedule for removed job 249 | Parse.Cloud.afterDelete('_JobSchedule', async (request) => { 250 | destroySchedule(request.object.id) 251 | }) 252 | } 253 | 254 | init() 255 | 256 | } 257 | 258 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-server-scheduler", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@babel/runtime": { 8 | "version": "7.9.2", 9 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", 10 | "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", 11 | "requires": { 12 | "regenerator-runtime": "^0.13.4" 13 | } 14 | }, 15 | "@babel/runtime-corejs3": { 16 | "version": "7.9.2", 17 | "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.9.2.tgz", 18 | "integrity": "sha512-HHxmgxbIzOfFlZ+tdeRKtaxWOMUoCG5Mu3wKeUmOxjYrwb3AAHgnmtCUbPPK11/raIWLIBK250t8E2BPO0p7jA==", 19 | "requires": { 20 | "core-js-pure": "^3.0.0", 21 | "regenerator-runtime": "^0.13.4" 22 | } 23 | }, 24 | "bluebird": { 25 | "version": "3.7.2", 26 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", 27 | "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" 28 | }, 29 | "core-js-pure": { 30 | "version": "3.6.5", 31 | "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.5.tgz", 32 | "integrity": "sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==" 33 | }, 34 | "cron": { 35 | "version": "1.8.2", 36 | "resolved": "https://registry.npmjs.org/cron/-/cron-1.8.2.tgz", 37 | "integrity": "sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==", 38 | "requires": { 39 | "moment-timezone": "^0.5.x" 40 | } 41 | }, 42 | "crypto-js": { 43 | "version": "4.0.0", 44 | "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz", 45 | "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==" 46 | }, 47 | "lodash": { 48 | "version": "4.17.15", 49 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", 50 | "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" 51 | }, 52 | "moment": { 53 | "version": "2.26.0", 54 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz", 55 | "integrity": "sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==" 56 | }, 57 | "moment-timezone": { 58 | "version": "0.5.31", 59 | "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.31.tgz", 60 | "integrity": "sha512-+GgHNg8xRhMXfEbv81iDtrVeTcWt0kWmTEY1XQK14dICTXnWJnT0dxdlPspwqF3keKMVPXwayEsk1DI0AA/jdA==", 61 | "requires": { 62 | "moment": ">= 2.9.0" 63 | } 64 | }, 65 | "parse": { 66 | "version": "2.13.0", 67 | "resolved": "https://registry.npmjs.org/parse/-/parse-2.13.0.tgz", 68 | "integrity": "sha512-0W7FBmtjtVpaxf+AK8S2+o6rosV4vzzvB9ba/8Y+7wWNPiy/9L8s+F0gI5ADYx/KbRqF0uR/9Q+FzbJB6IZ0Sw==", 69 | "requires": { 70 | "@babel/runtime": "7.9.2", 71 | "@babel/runtime-corejs3": "7.9.2", 72 | "crypto-js": "4.0.0", 73 | "react-native-crypto-js": "1.0.0", 74 | "uuid": "3.3.3", 75 | "ws": "7.2.5", 76 | "xmlhttprequest": "1.8.0" 77 | } 78 | }, 79 | "psl": { 80 | "version": "1.8.0", 81 | "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", 82 | "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" 83 | }, 84 | "punycode": { 85 | "version": "2.1.1", 86 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", 87 | "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" 88 | }, 89 | "react-native-crypto-js": { 90 | "version": "1.0.0", 91 | "resolved": "https://registry.npmjs.org/react-native-crypto-js/-/react-native-crypto-js-1.0.0.tgz", 92 | "integrity": "sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==" 93 | }, 94 | "regenerator-runtime": { 95 | "version": "0.13.5", 96 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", 97 | "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" 98 | }, 99 | "request-promise": { 100 | "version": "4.2.5", 101 | "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.5.tgz", 102 | "integrity": "sha512-ZgnepCykFdmpq86fKGwqntyTiUrHycALuGggpyCZwMvGaZWgxW6yagT0FHkgo5LzYvOaCNvxYwWYIjevSH1EDg==", 103 | "requires": { 104 | "bluebird": "^3.5.0", 105 | "request-promise-core": "1.1.3", 106 | "stealthy-require": "^1.1.1", 107 | "tough-cookie": "^2.3.3" 108 | } 109 | }, 110 | "request-promise-core": { 111 | "version": "1.1.3", 112 | "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", 113 | "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", 114 | "requires": { 115 | "lodash": "^4.17.15" 116 | } 117 | }, 118 | "stealthy-require": { 119 | "version": "1.1.1", 120 | "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", 121 | "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" 122 | }, 123 | "tough-cookie": { 124 | "version": "2.5.0", 125 | "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", 126 | "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", 127 | "requires": { 128 | "psl": "^1.1.28", 129 | "punycode": "^2.1.1" 130 | } 131 | }, 132 | "uuid": { 133 | "version": "3.3.3", 134 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", 135 | "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" 136 | }, 137 | "ws": { 138 | "version": "7.2.5", 139 | "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.5.tgz", 140 | "integrity": "sha512-C34cIU4+DB2vMyAbmEKossWq2ZQDr6QEyuuCzWrM9zfw1sGc0mYiJ0UnG9zzNykt49C2Fi34hvr2vssFQRS6EA==" 141 | }, 142 | "xmlhttprequest": { 143 | "version": "1.8.0", 144 | "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", 145 | "integrity": "sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw=" 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-server-scheduler", 3 | "version": "1.0.21", 4 | "description": "get scheduled jobs to run automatically (without external server) in parse", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/coderofsalvation/parse-server-scheduler.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/coderofsalvation/parse-server-scheduler/issues" 17 | }, 18 | "homepage": "https://github.com/coderofsalvation/parse-server-scheduler#readme", 19 | "dependencies": { 20 | "cron": "^1.8.2", 21 | "moment": "^2.26.0", 22 | "parse": "^2.13.0", 23 | "request-promise": "^4.2.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /scheduler.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coderofsalvation/parse-server-scheduler/86a9aa70b6cb2490b414472d315aac1adb5b6f8c/scheduler.jpg --------------------------------------------------------------------------------