node-cron
Version:
Job scheduling for Node.js with overlap prevention, distributed coordination, and background tasks. Zero dependencies, written in TypeScript.
1,097 lines (1,075 loc) • 37.3 kB
JavaScript
'use strict';
var events = require('events');
var node_crypto = require('node:crypto');
function createID() {
return node_crypto.randomUUID();
}
const levelColors = {
INFO: '\x1b[36m',
WARN: '\x1b[33m',
ERROR: '\x1b[31m',
DEBUG: '\x1b[35m',
};
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
function log(level, message, extra) {
const timestamp = new Date().toISOString();
const color = levelColors[level] ?? '';
const prefix = `[${timestamp}] [PID: ${process.pid}] ${GREEN}[NODE-CRON]${GREEN} ${color}[${level}]${RESET}`;
const output = `${prefix} ${message}`;
switch (level) {
case 'ERROR':
console.error(output, extra ?? '');
break;
case 'DEBUG':
console.debug(output, extra ?? '');
break;
case 'WARN':
console.warn(output);
break;
case 'INFO':
default:
console.info(output);
break;
}
}
const defaultLogger = {
info(message) {
log('INFO', message);
},
warn(message) {
log('WARN', message);
},
error(message, err) {
if (message instanceof Error) {
log('ERROR', message.message, message);
}
else {
log('ERROR', message, err);
}
},
debug(message, err) {
if (message instanceof Error) {
log('DEBUG', message.message, message);
}
else {
log('DEBUG', message, err);
}
},
};
const noopLogger = {
info() { },
warn() { },
error() { },
debug() { },
};
let activeLogger = defaultLogger;
function setLogger(logger) {
activeLogger = logger ?? defaultLogger;
}
const logger = {
info: (message) => activeLogger.info(message),
warn: (message) => activeLogger.warn(message),
error: (message, err) => activeLogger.error(message, err),
debug: (message, err) => activeLogger.debug(message, err),
};
class TrackedPromise {
promise;
error;
state;
value;
constructor(executor) {
this.state = 'pending';
this.promise = new Promise((resolve, reject) => {
executor((value) => {
this.state = 'fulfilled';
this.value = value;
resolve(value);
}, (error) => {
this.state = 'rejected';
this.error = error;
reject(error);
});
});
}
getPromise() {
return this.promise;
}
getState() {
return this.state;
}
isPending() {
return this.state === 'pending';
}
isFulfilled() {
return this.state === 'fulfilled';
}
isRejected() {
return this.state === 'rejected';
}
getValue() {
return this.value;
}
getError() {
return this.error;
}
then(onfulfilled, onrejected) {
return this.promise.then(onfulfilled, onrejected);
}
catch(onrejected) {
return this.promise.catch(onrejected);
}
finally(onfinally) {
return this.promise.finally(onfinally);
}
}
function planBeat(expected, now, toleranceMs, getNextMatch) {
const missed = [];
let slot = expected;
while (true) {
const nowMs = now.getTime();
const slotMs = slot.getTime();
if (nowMs < slotMs) {
return { missed, next: slot };
}
const next = getNextMatch(slot);
if (next.getTime() <= slotMs) {
return { missed, next: getNextMatch(now) };
}
const gap = next.getTime() - slotMs;
const lateBy = nowMs - slotMs;
if (lateBy <= toleranceMs && lateBy < gap) {
return { missed, run: slot, next };
}
missed.push(slot);
slot = next;
}
}
const DEFAULT_MISSED_EXECUTION_TOLERANCE = 1000;
function emptyOnFn() { }
function emptySkipFn() { }
function emptyHookFn() { return true; }
const DEFAULT_COORDINATOR_TTL = 30000;
class Runner {
timeMatcher;
onMatch;
noOverlap;
maxExecutions;
maxRandomDelay;
missedExecutionTolerance;
runCount;
running;
heartBeatTimeout;
logger;
onMissedExecution;
onOverlap;
onError;
beforeRun;
onFinished;
onMaxExecutions;
runCoordinator;
coordinatorKeyPrefix;
coordinatorTtl;
onSkipped;
constructor(timeMatcher, onMatch, options) {
this.timeMatcher = timeMatcher;
this.onMatch = onMatch;
this.noOverlap = options == undefined || options.noOverlap === undefined ? false : options.noOverlap;
this.maxExecutions = options?.maxExecutions;
this.maxRandomDelay = options?.maxRandomDelay || 0;
this.missedExecutionTolerance = options?.missedExecutionTolerance ?? DEFAULT_MISSED_EXECUTION_TOLERANCE;
this.logger = options?.logger || logger;
this.onMissedExecution = options?.onMissedExecution || emptyOnFn;
this.onOverlap = options?.onOverlap || emptyOnFn;
this.onError = options?.onError || ((date, error) => this.logger.error('Task failed with error!', error));
this.onFinished = options?.onFinished || emptyHookFn;
this.beforeRun = options?.beforeRun || emptyHookFn;
this.onMaxExecutions = options?.onMaxExecutions || emptyOnFn;
this.runCoordinator = options?.runCoordinator;
this.coordinatorKeyPrefix = options?.coordinatorKeyPrefix || '';
this.coordinatorTtl = options?.coordinatorTtl ?? DEFAULT_COORDINATOR_TTL;
this.onSkipped = options?.onSkipped || emptySkipFn;
this.runCount = 0;
this.running = false;
}
onErrorFallback = (date, error) => {
this.logger.error('Task failed with error!', error);
};
async runCoordinated(slot, run) {
if (!this.runCoordinator) {
await run();
return;
}
const key = `${this.coordinatorKeyPrefix}:${slot.toISOString()}`;
let allowed;
try {
allowed = await this.runCoordinator.shouldRun(key, this.coordinatorTtl);
}
catch (err) {
this.logger.error('Run coordinator failed; skipping execution (fail-closed)', err);
this.emitSkipped(slot, 'coordinator-error');
return;
}
if (!allowed) {
this.emitSkipped(slot, 'not-elected');
return;
}
try {
await run();
}
finally {
try {
await this.runCoordinator.onComplete?.(key);
}
catch (err) {
this.logger.error('Run coordinator onComplete failed', err);
}
}
}
emitSkipped(slot, reason) {
Promise.resolve(this.onSkipped(slot, reason)).catch((err) => this.onErrorFallback(slot, err));
}
start() {
this.running = true;
let lastExecution;
let expectedNextExecution = this.timeMatcher.getNextMatch(nowWithoutMs());
const armHeartBeat = () => {
if (this.running) {
clearTimeout(this.heartBeatTimeout);
this.heartBeatTimeout = setTimeout(heartBeat, getDelay(expectedNextExecution));
}
};
const runTask = (date) => {
return new Promise(async (resolve) => {
const execution = {
id: createID(),
reason: 'scheduled'
};
const shouldExecute = await this.beforeRun(date, execution);
const randomDelay = Math.floor(Math.random() * this.maxRandomDelay);
if (shouldExecute) {
const execute = async () => {
try {
this.runCount++;
execution.startedAt = new Date();
const result = await this.onMatch(date, execution);
execution.finishedAt = new Date();
execution.result = result;
this.onFinished(date, execution);
if (this.maxExecutions && this.runCount >= this.maxExecutions) {
this.onMaxExecutions(date);
this.stop();
}
}
catch (error) {
execution.finishedAt = new Date();
execution.error = error;
this.onError(date, error, execution);
}
resolve(true);
};
if (randomDelay > 0) {
setTimeout(execute, randomDelay);
}
else {
execute();
}
}
else {
resolve(true);
}
});
};
const heartBeat = async () => {
const currentDate = nowWithoutMs();
const plan = planBeat(expectedNextExecution, currentDate, this.missedExecutionTolerance, (date) => this.timeMatcher.getNextMatch(date));
expectedNextExecution = plan.next;
for (const missedSlot of plan.missed) {
runAsync(this.onMissedExecution, missedSlot, this.onErrorFallback);
}
if (plan.run) {
if (lastExecution && lastExecution.getState() === 'pending') {
runAsync(this.onOverlap, plan.run, this.onErrorFallback);
if (this.noOverlap) {
this.logger.warn('task still running, new execution blocked by overlap prevention!');
armHeartBeat();
return;
}
}
const slot = plan.run;
lastExecution = new TrackedPromise(async (resolve, reject) => {
try {
await this.runCoordinated(slot, () => runTask(slot));
resolve(true);
}
catch (err) {
reject(err);
}
});
}
armHeartBeat();
};
armHeartBeat();
}
nextRun() {
return this.timeMatcher.getNextMatch(new Date());
}
stop() {
this.running = false;
if (this.heartBeatTimeout) {
clearTimeout(this.heartBeatTimeout);
this.heartBeatTimeout = undefined;
}
}
isStarted() {
return !!this.heartBeatTimeout && this.running;
}
isStopped() {
return !this.isStarted();
}
async execute() {
const date = new Date();
const execution = {
id: createID(),
reason: 'invoked'
};
try {
const shouldExecute = await this.beforeRun(date, execution);
if (shouldExecute) {
this.runCount++;
execution.startedAt = new Date();
const result = await this.onMatch(date, execution);
execution.finishedAt = new Date();
execution.result = result;
this.onFinished(date, execution);
}
}
catch (error) {
execution.finishedAt = new Date();
execution.error = error;
this.onError(date, error, execution);
}
}
}
async function runAsync(fn, date, onError) {
try {
await fn(date);
}
catch (error) {
onError(date, error);
}
}
function getDelay(nextRun) {
const maxDelay = 86400000;
const now = new Date();
const delay = nextRun.getTime() - now.getTime();
if (delay > maxDelay) {
return maxDelay;
}
return Math.max(0, delay);
}
function nowWithoutMs() {
const date = new Date();
date.setMilliseconds(0);
return date;
}
var monthNamesConversion = (() => {
const months = ['january', 'february', 'march', 'april', 'may', 'june', 'july',
'august', 'september', 'october', 'november', 'december'];
const shortMonths = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug',
'sep', 'oct', 'nov', 'dec'];
function convertMonthName(expression, items) {
for (let i = 0; i < items.length; i++) {
expression = expression.replace(new RegExp(items[i], 'gi'), i + 1);
}
return expression;
}
function interpret(monthExpression) {
monthExpression = convertMonthName(monthExpression, months);
monthExpression = convertMonthName(monthExpression, shortMonths);
return monthExpression;
}
return interpret;
})();
var weekDayNamesConversion = (() => {
const weekDays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday',
'friday', 'saturday'];
const shortWeekDays = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
function convertWeekDayName(expression, items) {
for (let i = 0; i < items.length; i++) {
expression = expression.replace(new RegExp(items[i], 'gi'), i);
}
return expression;
}
function convertWeekDays(expression) {
expression = expression.replace('7', '0');
expression = convertWeekDayName(expression, weekDays);
return convertWeekDayName(expression, shortWeekDays);
}
return convertWeekDays;
})();
var convertAsterisksToRanges = (() => {
function convertAsterisk(expression, replecement) {
if (expression.indexOf('*') !== -1) {
return expression.replace('*', replecement);
}
return expression;
}
function convertAsterisksToRanges(expressions) {
expressions[0] = convertAsterisk(expressions[0], '0-59');
expressions[1] = convertAsterisk(expressions[1], '0-59');
expressions[2] = convertAsterisk(expressions[2], '0-23');
expressions[3] = convertAsterisk(expressions[3], '1-31');
expressions[4] = convertAsterisk(expressions[4], '1-12');
expressions[5] = convertAsterisk(expressions[5], '0-6');
return expressions;
}
return convertAsterisksToRanges;
})();
var convertRanges = (() => {
function replaceWithRange(expression, text, init, end, stepTxt) {
const step = parseInt(stepTxt);
const numbers = [];
let last = parseInt(end);
let first = parseInt(init);
if (first > last) {
last = parseInt(init);
first = parseInt(end);
}
for (let i = first; i <= last; i += step) {
numbers.push(i);
}
return expression.replace(new RegExp(text, 'i'), numbers.join());
}
function convertRange(expression) {
const rangeRegEx = /(\d+)-(\d+)(\/(\d+)|)/;
let match = rangeRegEx.exec(expression);
while (match !== null && match.length > 0) {
expression = replaceWithRange(expression, match[0], match[1], match[2], match[4] || '1');
match = rangeRegEx.exec(expression);
}
return expression;
}
function convertAllRanges(expressions) {
for (let i = 0; i < expressions.length; i++) {
expressions[i] = convertRange(expressions[i]);
}
return expressions;
}
return convertAllRanges;
})();
var convertExpression = (() => {
function appendSecondExpression(expressions) {
if (expressions.length === 5) {
return ['0'].concat(expressions);
}
return expressions;
}
function removeSpaces(str) {
return str.replace(/\s{2,}/g, ' ').trim();
}
function normalizeIntegers(expressions) {
for (let i = 0; i < expressions.length; i++) {
const numbers = expressions[i].split(',');
for (let j = 0; j < numbers.length; j++) {
const token = String(numbers[j]).trim();
if (/^l$/i.test(token)) {
numbers[j] = 'L';
}
else if (/^[0-7]l$/i.test(token)) {
numbers[j] = token.toUpperCase();
}
else if (token.indexOf('#') !== -1) {
numbers[j] = token;
}
else {
numbers[j] = parseInt(numbers[j]);
}
}
expressions[i] = numbers;
}
return expressions;
}
function interpret(expression) {
let expressions = removeSpaces(`${expression}`).split(' ');
expressions = appendSecondExpression(expressions);
expressions[4] = monthNamesConversion(expressions[4]);
expressions[5] = weekDayNamesConversion(expressions[5]);
expressions = convertAsterisksToRanges(expressions);
expressions = convertRanges(expressions);
expressions = normalizeIntegers(expressions);
return expressions;
}
return interpret;
})();
class LocalizedTime {
timestamp;
parts;
timezone;
constructor(date, timezone) {
this.timestamp = date.getTime();
this.timezone = timezone;
this.parts = buildDateParts(date, timezone);
}
toDate() {
return new Date(this.timestamp);
}
toISO() {
const gmt = this.parts.gmt.replace(/^GMT/, '');
const offset = gmt ? gmt : 'Z';
const pad = (n) => String(n).padStart(2, '0');
return `${this.parts.year}-${pad(this.parts.month)}-${pad(this.parts.day)}`
+ `T${pad(this.parts.hour)}:${pad(this.parts.minute)}:${pad(this.parts.second)}`
+ `.${String(this.parts.millisecond).padStart(3, '0')}`
+ offset;
}
getParts() {
return this.parts;
}
}
function getOffsetMinutes(date, timezone) {
const offset = parseOffsetMinutes(getTimezoneGMT(date, timezone).replace(/^GMT/, '') || 'Z');
return offset ?? 0;
}
function readsBackTo(timestamp, parts, timezone) {
const p = buildDateParts(new Date(timestamp), timezone);
return p.year === parts.year && p.month === parts.month && p.day === parts.day
&& p.hour === parts.hour && p.minute === parts.minute && p.second === parts.second;
}
function localTimeToTimestamp(parts, timezone) {
const guess = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, parts.millisecond);
const firstOffset = getOffsetMinutes(new Date(guess), timezone);
const candidate1 = guess - firstOffset * 60000;
const secondOffset = getOffsetMinutes(new Date(candidate1), timezone);
if (secondOffset === firstOffset) {
return candidate1;
}
const candidate2 = guess - secondOffset * 60000;
if (readsBackTo(candidate1, parts, timezone))
return candidate1;
if (readsBackTo(candidate2, parts, timezone))
return candidate2;
return Math.max(candidate1, candidate2);
}
const partsFormatterCache = new Map();
const offsetFormatterCache = new Map();
function getPartsFormatter(timezone) {
const key = timezone ?? '';
let formatter = partsFormatterCache.get(key);
if (!formatter) {
const dftOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
weekday: 'short',
hour12: false
};
if (timezone) {
dftOptions.timeZone = timezone;
}
formatter = new Intl.DateTimeFormat('en-US', dftOptions);
partsFormatterCache.set(key, formatter);
}
return formatter;
}
function getOffsetFormatter(timezone) {
const key = timezone ?? '';
let formatter = offsetFormatterCache.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'shortOffset'
});
offsetFormatterCache.set(key, formatter);
}
return formatter;
}
function buildDateParts(date, timezone) {
const dateFormat = getPartsFormatter(timezone);
const parts = dateFormat.formatToParts(date).filter(part => {
return part.type !== 'literal';
}).reduce((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
const result = {
day: parseInt(parts.day),
month: parseInt(parts.month),
year: parseInt(parts.year),
hour: parts.hour === '24' ? 0 : parseInt(parts.hour),
minute: parseInt(parts.minute),
second: parseInt(parts.second),
millisecond: date.getMilliseconds(),
weekday: parts.weekday
};
let gmt;
Object.defineProperty(result, 'gmt', {
enumerable: true,
configurable: true,
get() {
return gmt ??= getTimezoneGMT(date, timezone);
}
});
return result;
}
function parseOffsetMinutes(isoString) {
if (isoString.endsWith('Z'))
return 0;
const match = isoString.match(/([+-])(\d{2}):(\d{2})$/);
if (!match)
return null;
const sign = match[1] === '+' ? 1 : -1;
return sign * (parseInt(match[2]) * 60 + parseInt(match[3]));
}
function getTimezoneGMT(date, timezone) {
const fmt = getOffsetFormatter(timezone);
const parts = fmt.formatToParts(date);
const tzPart = parts.find(p => p.type === 'timeZoneName');
if (!tzPart)
return 'Z';
const tzValue = tzPart.value;
if (tzValue === 'GMT')
return 'Z';
const match = tzValue.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/);
if (!match)
return 'Z';
const sign = match[1];
const hoursNum = parseInt(match[2]);
const minutesNum = parseInt(match[3] || '0');
if (hoursNum === 0 && minutesNum === 0)
return 'Z';
const hours = match[2].padStart(2, '0');
const minutes = (match[3] || '00').padStart(2, '0');
return `GMT${sign}${hours}:${minutes}`;
}
const LAST_DAY_TOKEN = 'L';
function lastDayOfMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
function matchesDayOfMonth(field, year, month, day) {
if (field.includes(day))
return true;
if (field.includes(LAST_DAY_TOKEN) && day === lastDayOfMonth(year, month))
return true;
return false;
}
const LAST_WEEKDAY_REGEX = /^([0-7])L$/i;
const NTH_WEEKDAY_REGEX = /^([0-7])#([1-5])$/;
function parseLastWeekdayToken(value) {
if (typeof value !== 'string')
return null;
const match = LAST_WEEKDAY_REGEX.exec(value);
if (!match)
return null;
const weekday = parseInt(match[1], 10);
return weekday === 7 ? 0 : weekday;
}
function isLastWeekdayOfMonth(year, month, day) {
const date = new Date(Date.UTC(year, month - 1, day));
const inSevenDays = new Date(date.getTime());
inSevenDays.setUTCDate(inSevenDays.getUTCDate() + 7);
return inSevenDays.getUTCMonth() + 1 !== month;
}
function isNthWeekdayToken(value) {
return typeof value === 'string' && NTH_WEEKDAY_REGEX.test(value);
}
function parseNthWeekday(value) {
if (typeof value !== 'string')
return null;
const match = NTH_WEEKDAY_REGEX.exec(value);
if (!match)
return null;
const weekday = parseInt(match[1], 10) % 7;
const nth = parseInt(match[2], 10);
return { weekday, nth };
}
function occurrenceInMonth(day) {
return Math.floor((day - 1) / 7) + 1;
}
function matchesNthWeekday(token, year, month, day) {
const parsed = parseNthWeekday(token);
if (!parsed)
return false;
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
if (weekday !== parsed.weekday)
return false;
return occurrenceInMonth(day) === parsed.nth;
}
function matchesDayOfWeek(field, year, month, day, weekday) {
for (const value of field) {
if (value === weekday)
return true;
if (isNthWeekdayToken(value)) {
if (matchesNthWeekday(value, year, month, day))
return true;
continue;
}
const lastWeekday = parseLastWeekdayToken(value);
if (lastWeekday !== null &&
lastWeekday === weekday &&
isLastWeekdayOfMonth(year, month, day)) {
return true;
}
}
return false;
}
const MAX_DAYS = 366 * 100;
class MatcherWalker {
baseDate;
timeMatcher;
timezone;
seconds;
minutes;
hours;
days;
months;
weekdays;
constructor(timeMatcher, baseDate, timezone) {
this.baseDate = baseDate;
this.timeMatcher = timeMatcher;
this.timezone = timezone;
const expressions = timeMatcher.expressions;
this.seconds = sortedAsc(expressions[0]);
this.minutes = sortedAsc(expressions[1]);
this.hours = sortedAsc(expressions[2]);
this.days = expressions[3];
this.months = expressions[4];
this.weekdays = expressions[5];
}
isMatching() {
return this.timeMatcher.match(this.baseDate);
}
matchNext() {
const months = this.months;
const days = this.days;
const baseMs = Math.floor(this.baseDate.getTime() / 1000) * 1000;
const baseParts = new LocalizedTime(new Date(baseMs), this.timezone).getParts();
let { year, month, day } = baseParts;
for (let i = 0; i < MAX_DAYS; i++) {
if (months.includes(month) && matchesDayOfMonth(days, year, month, day) && this.matchesWeekday(year, month, day)) {
const lowerBound = i === 0 ? baseParts : null;
const found = this.firstTimeOnDay(year, month, day, lowerBound, baseMs);
if (found !== null) {
return new LocalizedTime(new Date(found), this.timezone);
}
}
({ year, month, day } = nextDay(year, month, day));
}
throw new Error('Could not find next matching date within reasonable time range');
}
firstTimeOnDay(year, month, day, lowerBound, baseMs) {
const { seconds, minutes, hours } = this;
for (const hour of hours) {
if (lowerBound && hour < lowerBound.hour)
continue;
for (const minute of minutes) {
for (const second of seconds) {
if (lowerBound && !isLaterInDay(hour, minute, second, lowerBound))
continue;
const ts = localTimeToTimestamp({ year, month, day, hour, minute, second, millisecond: 0 }, this.timezone);
if (ts > baseMs && this.timeMatcher.match(new Date(ts))) {
return ts;
}
}
}
}
return null;
}
matchesWeekday(year, month, day) {
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
return matchesDayOfWeek(this.weekdays, year, month, day, weekday);
}
}
function nextDay(year, month, day) {
const d = new Date(Date.UTC(year, month - 1, day + 1));
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
function sortedAsc(values) {
return [...values].sort((a, b) => a - b);
}
function isLaterInDay(hour, minute, second, bound) {
return hour * 3600 + minute * 60 + second > bound.hour * 3600 + bound.minute * 60 + bound.second;
}
function matchValue(allowedValues, value) {
return allowedValues.indexOf(value) !== -1;
}
class TimeMatcher {
timezone;
pattern;
expressions;
constructor(pattern, timezone) {
this.timezone = timezone;
this.pattern = pattern;
this.expressions = convertExpression(pattern);
}
match(date) {
const localizedTime = new LocalizedTime(date, this.timezone);
const parts = localizedTime.getParts();
const runOnSecond = matchValue(this.expressions[0], parts.second);
const runOnMinute = matchValue(this.expressions[1], parts.minute);
const runOnHour = matchValue(this.expressions[2], parts.hour);
const runOnDay = matchesDayOfMonth(this.expressions[3], parts.year, parts.month, parts.day);
const runOnMonth = matchValue(this.expressions[4], parts.month);
const weekday = parseInt(weekDayNamesConversion(parts.weekday));
const runOnWeekDay = matchesDayOfWeek(this.expressions[5], parts.year, parts.month, parts.day, weekday);
return runOnSecond && runOnMinute && runOnHour && runOnDay && runOnMonth && runOnWeekDay;
}
getNextMatch(date) {
const walker = new MatcherWalker(this, date, this.timezone);
const next = walker.matchNext();
return next.toDate();
}
}
const allowedTransitions = {
'stopped': ['stopped', 'idle', 'destroyed'],
'idle': ['idle', 'running', 'stopped', 'destroyed'],
'running': ['running', 'idle', 'stopped', 'destroyed'],
'destroyed': ['destroyed']
};
class StateMachine {
state;
constructor(initial = 'stopped') {
this.state = initial;
}
changeState(state) {
if (allowedTransitions[this.state].includes(state)) {
this.state = state;
}
else {
throw new Error(`invalid transition from ${this.state} to ${state}`);
}
}
}
class EnvVarRunCoordinator {
envName;
constructor(envName = 'NODE_CRON_RUN') {
this.envName = envName;
this.read();
}
shouldRun() {
return this.read();
}
read() {
const value = process.env[this.envName];
if (value !== 'true' && value !== 'false') {
throw new Error(`node-cron: a \`distributed\` task needs ${this.envName} set to 'true' or 'false'. ` +
`Set it to 'true' on exactly one instance and 'false' on the others, ` +
`or provide a coordinator via cron.setRunCoordinator(...).`);
}
return value === 'true';
}
}
let globalRunCoordinator;
function setRunCoordinator(coordinator) {
globalRunCoordinator = coordinator;
}
function resolveRunCoordinator(perTask) {
return perTask ?? globalRunCoordinator ?? new EnvVarRunCoordinator();
}
class TaskEmitter extends events.EventEmitter {
}
class InlineScheduledTask {
emitter;
cronExpression;
timeMatcher;
runner;
id;
name;
stateMachine;
timezone;
logger;
suppressMissedWarning;
_lastRun = null;
constructor(cronExpression, taskFn, options) {
this.emitter = new TaskEmitter();
this.cronExpression = cronExpression;
this.id = createID();
this.name = options?.name || this.id;
this.timezone = options?.timezone;
this.logger = options?.logger || logger;
this.suppressMissedWarning = options?.suppressMissedWarning || false;
this.timeMatcher = new TimeMatcher(cronExpression, options?.timezone);
this.stateMachine = new StateMachine();
const runnerOptions = {
timezone: options?.timezone,
noOverlap: options?.noOverlap,
maxExecutions: options?.maxExecutions,
maxRandomDelay: options?.maxRandomDelay,
missedExecutionTolerance: options?.missedExecutionTolerance,
logger: this.logger,
beforeRun: (date, execution) => {
if (execution.reason === 'scheduled') {
this.changeState('running');
}
this.emitter.emit('execution:started', this.createContext(date, execution));
return true;
},
onFinished: (date, execution) => {
if (execution.reason === 'scheduled') {
this.changeState('idle');
}
this.recordLastRun(execution);
this.emitter.emit('execution:finished', this.createContext(date, execution));
return true;
},
onError: (date, error, execution) => {
this.logger.error(error);
this.recordLastRun(execution);
this.emitter.emit('execution:failed', this.createContext(date, execution));
this.changeState('idle');
},
onOverlap: (date) => {
this.emitter.emit('execution:overlap', this.createContext(date));
},
onMissedExecution: (date) => {
const handled = this.emitter.listenerCount('execution:missed') > 0;
if (!this.suppressMissedWarning && !handled) {
this.logger.warn(`missed execution at ${date}! Possible blocking IO or high CPU user at the same process used by node-cron.`);
}
this.emitter.emit('execution:missed', this.createContext(date));
},
onMaxExecutions: (date) => {
this.emitter.emit('execution:maxReached', this.createContext(date));
this.destroy();
},
runCoordinator: options?.distributed ? resolveRunCoordinator(options?.runCoordinator) : undefined,
coordinatorKeyPrefix: this.name,
coordinatorTtl: options?.distributedLease,
onSkipped: (date, reason) => {
this.emitter.emit('execution:skipped', this.createContext(date, undefined, reason));
}
};
this.runner = new Runner(this.timeMatcher, (date, execution) => {
return taskFn(this.createContext(date, execution));
}, runnerOptions);
}
getNextRun() {
if (this.stateMachine.state !== 'stopped') {
return this.runner.nextRun();
}
return null;
}
getNextRuns(count) {
const runs = [];
let from = new Date();
for (let i = 0; i < count; i++) {
from = this.timeMatcher.getNextMatch(from);
runs.push(from);
}
return runs;
}
match(date) {
return this.timeMatcher.match(date);
}
msToNext() {
const next = this.getNextRun();
return next ? next.getTime() - Date.now() : null;
}
isBusy() {
return this.getStatus() === 'running';
}
runsLeft() {
if (this.runner.maxExecutions == null)
return undefined;
return Math.max(0, this.runner.maxExecutions - this.runner.runCount);
}
getPattern() {
return this.cronExpression;
}
lastRun() {
return this._lastRun;
}
recordLastRun(execution) {
const date = execution.finishedAt;
const lastRun = { date };
if (execution.error) {
lastRun.error = execution.error;
}
else {
lastRun.result = execution.result;
}
this._lastRun = lastRun;
}
changeState(state) {
if (this.runner.isStarted()) {
this.stateMachine.changeState(state);
}
}
start() {
if (this.runner.isStopped()) {
this.runner.start();
this.stateMachine.changeState('idle');
this.emitter.emit('task:started', this.createContext(new Date()));
}
}
stop() {
if (this.runner.isStarted()) {
this.runner.stop();
this.stateMachine.changeState('stopped');
this.emitter.emit('task:stopped', this.createContext(new Date()));
}
}
getStatus() {
return this.stateMachine.state;
}
destroy() {
if (this.stateMachine.state === 'destroyed')
return;
this.stop();
this.stateMachine.changeState('destroyed');
this.emitter.emit('task:destroyed', this.createContext(new Date()));
}
execute() {
return new Promise((resolve, reject) => {
const onFail = (context) => {
this.off('execution:finished', onFinished);
reject(context.execution?.error);
};
const onFinished = (context) => {
this.off('execution:failed', onFail);
resolve(context.execution?.result);
};
this.once('execution:finished', onFinished);
this.once('execution:failed', onFail);
this.runner.execute();
});
}
on(event, fun) {
this.emitter.on(event, fun);
}
off(event, fun) {
this.emitter.off(event, fun);
}
once(event, fun) {
this.emitter.once(event, fun);
}
createContext(executionDate, execution, reason) {
const localTime = new LocalizedTime(executionDate, this.timezone);
const ctx = {
date: localTime.toDate(),
dateLocalIso: localTime.toISO(),
triggeredAt: new Date(),
task: this,
execution: execution
};
if (reason)
ctx.reason = reason;
return ctx;
}
}
exports.InlineScheduledTask = InlineScheduledTask;
exports.LocalizedTime = LocalizedTime;
exports.StateMachine = StateMachine;
exports.TimeMatcher = TimeMatcher;
exports.convertExpression = convertExpression;
exports.createID = createID;
exports.isNthWeekdayToken = isNthWeekdayToken;
exports.logger = logger;
exports.noopLogger = noopLogger;
exports.resolveRunCoordinator = resolveRunCoordinator;
exports.setLogger = setLogger;
exports.setRunCoordinator = setRunCoordinator;
//# sourceMappingURL=_shared.cjs.map