743 righe
17 KiB
JavaScript

(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['moment-timezone'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(
require('moment-timezone'),
require('child_process')
);
} else {
root.Cron = factory(root.moment);
}
})(this, function(moment, childProcess) {
var exports = {};
var timeUnits = [
'second',
'minute',
'hour',
'dayOfMonth',
'month',
'dayOfWeek'
];
var spawn = childProcess && childProcess.spawn;
function CronTime(source, zone, utcOffset) {
this.source = source;
if (zone) {
if (moment.tz.names().indexOf(zone) === -1) {
throw new Error('Invalid timezone.');
}
this.zone = zone;
}
if (typeof utcOffset !== 'undefined') this.utcOffset = utcOffset;
var that = this;
timeUnits.map(function(timeUnit) {
that[timeUnit] = {};
});
if (this.source instanceof Date || this.source._isAMomentObject) {
this.source = moment(this.source);
this.realDate = true;
} else {
this._parse();
this._verifyParse();
}
}
CronTime.constraints = [[0, 59], [0, 59], [0, 23], [1, 31], [0, 11], [0, 6]];
CronTime.monthConstraints = [
31,
29, // support leap year...not perfect
31,
30,
31,
30,
31,
31,
30,
31,
30,
31
];
CronTime.parseDefaults = ['0', '*', '*', '*', '*', '*'];
CronTime.aliases = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6
};
CronTime.prototype = {
_verifyParse: function() {
var months = Object.keys(this.month);
var ok = false;
/* if a dayOfMonth is not found in all months, we only need to fix the last
wrong month to prevent infinite loop */
var lastWrongMonth = NaN;
for (var i = 0; i < months.length; i++) {
var m = months[i];
var con = CronTime.monthConstraints[parseInt(m, 10)];
var dsom = Object.keys(this.dayOfMonth);
for (var j = 0; j < dsom.length; j++) {
var dom = dsom[j];
if (dom <= con) {
ok = true;
}
}
if (!ok) {
// save the month in order to be fixed if all months fails (infinite loop)
lastWrongMonth = m;
console.warn("Month '" + m + "' is limited to '" + con + "' days.");
}
}
// infinite loop detected (dayOfMonth is not found in all months)
if (!ok) {
var con = CronTime.monthConstraints[parseInt(lastWrongMonth, 10)];
var dsom = Object.keys(this.dayOfMonth);
for (var k = 0; k < dsom.length; k++) {
var dom = dsom[k];
if (dom > con) {
delete this.dayOfMonth[dom];
var fixedDay = Number(dom) % con;
this.dayOfMonth[fixedDay] = true;
}
}
}
},
/**
* calculates the next send time
*/
sendAt: function(i) {
var date = this.realDate ? this.source : moment();
// Set the timezone if given (http://momentjs.com/timezone/docs/#/using-timezones/parsing-in-zone/)
if (this.zone) {
date = date.tz(this.zone);
}
if (typeof this.utcOffset !== 'undefined') {
date = date.utcOffset(this.utcOffset);
}
if (this.realDate) {
const diff = moment().diff(date, 's');
if (diff > 0) {
throw new Error('WARNING: Date in past. Will never be fired.');
}
return date;
}
// If the i argument is not given, return the next send time
if (isNaN(i) || i < 0) {
date = this._getNextDateFrom(date);
return date;
} else {
// Else return the next i send times
var dates = [];
for (; i > 0; i--) {
date = this._getNextDateFrom(date);
dates.push(moment(date));
}
return dates;
}
},
/**
* Get the number of milliseconds in the future at which to fire our callbacks.
*/
getTimeout: function() {
return Math.max(-1, this.sendAt() - moment());
},
/**
* writes out a cron string
*/
toString: function() {
return this.toJSON().join(' ');
},
/**
* Json representation of the parsed cron syntax.
*/
toJSON: function() {
var self = this;
return timeUnits.map(function(timeName) {
return self._wcOrAll(timeName);
});
},
/**
* get next date that matches parsed cron time
*/
_getNextDateFrom: function(start, zone) {
var date;
var firstDate = moment(start).valueOf();
if (zone) {
date = moment(start).tz(zone);
} else {
date = moment(start);
}
if (!this.realDate) {
const milliseconds =
(start.milliseconds && start.milliseconds()) ||
(start.getMilliseconds && start.getMilliseconds()) ||
0;
if (milliseconds > 0) {
date.milliseconds(0);
date.seconds(date.seconds() + 1);
}
}
if (date.toString() === 'Invalid date') {
throw new Error('ERROR: You specified an invalid date.');
}
// it shouldn't take more than 5 seconds to find the next execution time
// being very generous with this. Throw error if it takes too long to find the next time to protect from
// infinite loop.
var timeout = Date.now() + 5000;
// determine next date
while (true) {
var diff = date - start;
var prevMonth = date.month();
var prevDay = date.days();
var prevMinute = date.minutes();
var prevSeconds = date.seconds();
var origDate = new Date(date);
if (Date.now() > timeout) {
throw new Error(
`Something went wrong. cron reached maximum iterations.
Please open an issue (https://github.com/kelektiv/node-cron/issues/new) and provide the following string
Time Zone: ${zone || '""'} - Cron String: ${this} - UTC offset: ${date.format(
'Z'
)} - current Date: ${moment().toString()}`
);
}
if (
!(date.month() in this.month) &&
Object.keys(this.month).length !== 12
) {
date.add(1, 'M');
if (date.month() === prevMonth) {
date.add(1, 'M');
}
date.date(1);
date.hours(0);
date.minutes(0);
date.seconds(0);
continue;
}
if (
!(date.date() in this.dayOfMonth) &&
Object.keys(this.dayOfMonth).length !== 31 &&
!(
date.day() in this.dayOfWeek &&
Object.keys(this.dayOfWeek).length !== 7
)
) {
date.add(1, 'd');
if (date.days() === prevDay) {
date.add(1, 'd');
}
date.hours(0);
date.minutes(0);
date.seconds(0);
continue;
}
if (
!(date.day() in this.dayOfWeek) &&
Object.keys(this.dayOfWeek).length !== 7 &&
!(
date.date() in this.dayOfMonth &&
Object.keys(this.dayOfMonth).length !== 31
)
) {
date.add(1, 'd');
if (date.days() === prevDay) {
date.add(1, 'd');
}
date.hours(0);
date.minutes(0);
date.seconds(0);
if (date <= origDate) {
date = this._findDST(origDate);
}
continue;
}
if (
!(date.hours() in this.hour) &&
Object.keys(this.hour).length !== 24
) {
origDate = moment(date);
var curHour = date.hours();
date.hours(
date.hours() === 23 && diff > 86400000 ? 0 : date.hours() + 1
);
/*
* Moment Date will not allow you to set the time to 2 AM if there is no 2 AM (on the day we change the clock)
* We will therefore jump to 3AM if time stayed at 1AM
*/
if (curHour === date.hours()) {
date.hours(date.hours() + 2);
}
date.minutes(0);
date.seconds(0);
if (date <= origDate) {
date = this._findDST(origDate);
}
continue;
}
if (
!(date.minutes() in this.minute) &&
Object.keys(this.minute).length !== 60
) {
origDate = moment(date);
date.minutes(
date.minutes() === 59 && diff > 60 * 60 * 1000
? 0
: date.minutes() + 1
);
date.seconds(0);
if (date <= origDate) {
date = this._findDST(origDate);
}
continue;
}
if (
!(date.seconds() in this.second) &&
Object.keys(this.second).length !== 60
) {
origDate = moment(date);
date.seconds(
date.seconds() === 59 && diff > 60 * 1000 ? 0 : date.seconds() + 1
);
if (date <= origDate) {
date = this._findDST(origDate);
}
continue;
}
if (date.valueOf() === firstDate) {
date.seconds(date.seconds() + 1);
continue;
}
break;
}
return date;
},
/**
* get next date that is a valid DST date
*/
_findDST: function(date) {
var newDate = moment(date);
while (newDate <= date) {
// eslint seems to trigger here, it is wrong
newDate.add(1, 's');
}
return newDate;
},
/**
* wildcard, or all params in array (for to string)
*/
_wcOrAll: function(type) {
if (this._hasAll(type)) return '*';
var all = [];
for (var time in this[type]) {
all.push(time);
}
return all.join(',');
},
_hasAll: function(type) {
var constrain = CronTime.constraints[timeUnits.indexOf(type)];
for (var i = constrain[0], n = constrain[1]; i < n; i++) {
if (!(i in this[type])) return false;
}
return true;
},
_parse: function() {
var aliases = CronTime.aliases;
var source = this.source.replace(/[a-z]{1,3}/gi, function(alias) {
alias = alias.toLowerCase();
if (alias in aliases) {
return aliases[alias];
}
throw new Error('Unknown alias: ' + alias);
});
var split = source.replace(/^\s\s*|\s\s*$/g, '').split(/\s+/);
var cur;
var i = 0;
var len = timeUnits.length;
// seconds are optional
if (split.length < timeUnits.length - 1) {
throw new Error('Too few fields');
}
if (split.length > timeUnits.length) {
throw new Error('Too many fields');
}
for (; i < timeUnits.length; i++) {
// If the split source string doesn't contain all digits,
// assume defaults for first n missing digits.
// This adds support for 5-digit standard cron syntax
cur = split[i - (len - split.length)] || CronTime.parseDefaults[i];
this._parseField(cur, timeUnits[i], CronTime.constraints[i]);
}
},
_parseField: function(field, type, constraints) {
var rangePattern = /^(\d+)(?:-(\d+))?(?:\/(\d+))?$/g;
var typeObj = this[type];
var pointer;
var low = constraints[0];
var high = constraints[1];
var fields = field.split(',');
fields.forEach(function(field) {
var wildcardIndex = field.indexOf('*');
if (wildcardIndex !== -1 && wildcardIndex !== 0) {
throw new Error('Field (' + field + ') has an invalid wildcard expression');
}
});
// * is a shortcut to [lower-upper] range
field = field.replace(/\*/g, low + '-' + high);
// commas separate information, so split based on those
var allRanges = field.split(',');
for (var i = 0; i < allRanges.length; i++) {
if (allRanges[i].match(rangePattern)) {
allRanges[i].replace(rangePattern, function($0, lower, upper, step) {
lower = parseInt(lower, 10);
upper = parseInt(upper, 10) || undefined;
const wasStepDefined = !isNaN(parseInt(step, 10));
if (step === '0') {
throw new Error('Field (' + field + ') has a step of zero');
}
step = parseInt(step, 10) || 1;
if (upper && lower > upper) {
throw new Error('Field (' + field + ') has an invalid range');
}
const outOfRangeError =
lower < low ||
(upper && upper > high) ||
(!upper && lower > high);
if (outOfRangeError) {
throw new Error('Field (' + field + ') value is out of range');
}
// Positive integer higher than constraints[0]
lower = Math.min(Math.max(low, ~~Math.abs(lower)), high);
// Positive integer lower than constraints[1]
if (upper) {
upper = Math.min(high, ~~Math.abs(upper));
} else {
// If step is provided, the default upper range is the highest value
upper = wasStepDefined ? high : lower;
}
// Count from the lower barrier to the upper
pointer = lower;
do {
typeObj[pointer] = true;
pointer += step;
} while (pointer <= upper);
});
} else {
throw new Error('Field (' + field + ') cannot be parsed');
}
}
}
};
function command2function(cmd) {
var command;
var args;
switch (typeof cmd) {
case 'string':
args = cmd.split(' ');
command = args.shift();
cmd = spawn.bind(undefined, command, args);
break;
case 'object':
command = cmd && cmd.command;
if (command) {
args = cmd.args;
var options = cmd.options;
cmd = spawn.bind(undefined, command, args, options);
}
break;
}
return cmd;
}
function CronJob(
cronTime,
onTick,
onComplete,
startNow,
timeZone,
context,
runOnInit,
utcOffset,
unrefTimeout
) {
var _cronTime = cronTime;
var argCount = 0;
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] !== undefined) {
argCount++;
}
}
if (typeof cronTime !== 'string' && argCount === 1) {
// crontime is an object...
onTick = cronTime.onTick;
onComplete = cronTime.onComplete;
context = cronTime.context;
startNow = cronTime.start || cronTime.startNow || cronTime.startJob;
timeZone = cronTime.timeZone;
runOnInit = cronTime.runOnInit;
_cronTime = cronTime.cronTime;
utcOffset = cronTime.utcOffset;
unrefTimeout = cronTime.unrefTimeout;
}
this.context = context || this;
this._callbacks = [];
this.onComplete = command2function(onComplete);
this.cronTime = new CronTime(_cronTime, timeZone, utcOffset);
this.unrefTimeout = unrefTimeout;
addCallback.call(this, command2function(onTick));
if (runOnInit) {
this.lastExecution = new Date();
fireOnTick.call(this);
}
if (startNow) {
start.call(this);
}
return this;
}
var addCallback = function(callback) {
if (typeof callback === 'function') this._callbacks.push(callback);
};
CronJob.prototype.addCallback = addCallback;
CronJob.prototype.setTime = function(time) {
if (!(time instanceof CronTime))
throw new Error('time must be an instance of CronTime.');
this.stop();
this.cronTime = time;
};
CronJob.prototype.nextDate = function() {
return this.cronTime.sendAt();
};
var fireOnTick = function() {
for (var i = this._callbacks.length - 1; i >= 0; i--)
this._callbacks[i].call(this.context, this.onComplete);
};
CronJob.prototype.fireOnTick = fireOnTick;
CronJob.prototype.nextDates = function(i) {
return this.cronTime.sendAt(i);
};
var start = function() {
if (this.running) return;
var MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait.
var self = this;
var timeout = this.cronTime.getTimeout();
var remaining = 0;
var startTime;
if (this.cronTime.realDate) this.runOnce = true;
function _setTimeout(timeout) {
startTime = Date.now();
self._timeout = setTimeout(callbackWrapper, timeout);
if (self.unrefTimeout && typeof self._timeout.unref === 'function') {
self._timeout.unref();
}
}
// The callback wrapper checks if it needs to sleep another period or not
// and does the real callback logic when it's time.
function callbackWrapper() {
var diff = startTime + timeout - Date.now();
if (diff > 0) {
var newTimeout = self.cronTime.getTimeout();
if (newTimeout > diff) {
newTimeout = diff;
}
remaining += newTimeout;
}
// If there is sleep time remaining, calculate how long and go to sleep
// again. This processing might make us miss the deadline by a few ms
// times the number of sleep sessions. Given a MAXDELAY of almost a
// month, this should be no issue.
self.lastExecution = new Date();
if (remaining) {
if (remaining > MAXDELAY) {
remaining -= MAXDELAY;
timeout = MAXDELAY;
} else {
timeout = remaining;
remaining = 0;
}
_setTimeout(timeout);
} else {
// We have arrived at the correct point in time.
self.running = false;
// start before calling back so the callbacks have the ability to stop the cron job
if (!self.runOnce) self.start();
self.fireOnTick();
}
}
if (timeout >= 0) {
this.running = true;
// Don't try to sleep more than MAXDELAY ms at a time.
if (timeout > MAXDELAY) {
remaining = timeout - MAXDELAY;
timeout = MAXDELAY;
}
_setTimeout(timeout);
} else {
this.stop();
}
};
CronJob.prototype.start = start;
CronJob.prototype.lastDate = function() {
return this.lastExecution;
};
/**
* Stop the cronjob.
*/
CronJob.prototype.stop = function() {
if (this._timeout) clearTimeout(this._timeout);
this.running = false;
if (typeof this.onComplete === 'function') this.onComplete();
};
exports.job = function(
cronTime,
onTick,
onComplete,
startNow,
timeZone,
context,
runOnInit,
utcOffset,
unrefTimeout
) {
return new CronJob(
cronTime,
onTick,
onComplete,
startNow,
timeZone,
context,
runOnInit,
utcOffset,
unrefTimeout
);
};
exports.time = function(cronTime, timeZone) {
return new CronTime(cronTime, timeZone);
};
exports.sendAt = function(cronTime) {
return exports.time(cronTime).sendAt();
};
exports.timeout = function(cronTime) {
return exports.time(cronTime).getTimeout();
};
exports.CronJob = CronJob;
exports.CronTime = CronTime;
return exports;
});