1262 lines
42 KiB
JavaScript
1262 lines
42 KiB
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;
|
|
jitterTimeout;
|
|
logger;
|
|
onMissedExecution;
|
|
onOverlap;
|
|
onError;
|
|
beforeRun;
|
|
onFinished;
|
|
onMaxExecutions;
|
|
runCoordinator;
|
|
coordinatorKeyPrefix;
|
|
coordinatorTtl;
|
|
onSkipped;
|
|
unref;
|
|
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.unref = options?.unref ?? false;
|
|
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));
|
|
if (this.unref)
|
|
this.heartBeatTimeout.unref();
|
|
}
|
|
};
|
|
const runTask = async (date) => {
|
|
const execution = {
|
|
id: createID(),
|
|
reason: 'scheduled'
|
|
};
|
|
let shouldExecute;
|
|
try {
|
|
shouldExecute = await this.beforeRun(date, execution);
|
|
}
|
|
catch (error) {
|
|
this.onError(date, error, execution);
|
|
return;
|
|
}
|
|
if (!shouldExecute)
|
|
return;
|
|
const execute = async () => {
|
|
try {
|
|
this.runCount++;
|
|
execution.startedAt = new Date();
|
|
const result = await this.onMatch(date, execution);
|
|
execution.finishedAt = new Date();
|
|
execution.result = result;
|
|
}
|
|
catch (error) {
|
|
execution.finishedAt = new Date();
|
|
execution.error = error;
|
|
try {
|
|
this.onError(date, error, execution);
|
|
}
|
|
catch (hookError) {
|
|
this.onErrorFallback(date, hookError);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await this.onFinished(date, execution);
|
|
}
|
|
catch (hookError) {
|
|
this.onErrorFallback(date, hookError);
|
|
}
|
|
if (this.maxExecutions && this.runCount >= this.maxExecutions) {
|
|
this.onMaxExecutions(date);
|
|
this.stop();
|
|
}
|
|
};
|
|
const randomDelay = Math.floor(Math.random() * this.maxRandomDelay);
|
|
if (randomDelay > 0) {
|
|
await new Promise(resolve => {
|
|
this.jitterTimeout = setTimeout(() => {
|
|
execute().then(() => resolve(), () => resolve());
|
|
}, randomDelay);
|
|
if (this.unref)
|
|
this.jitterTimeout.unref();
|
|
});
|
|
}
|
|
else {
|
|
await execute();
|
|
}
|
|
};
|
|
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);
|
|
}
|
|
});
|
|
lastExecution.catch(() => { });
|
|
}
|
|
armHeartBeat();
|
|
};
|
|
armHeartBeat();
|
|
}
|
|
nextRun() {
|
|
return this.timeMatcher.getNextMatch(new Date());
|
|
}
|
|
stop() {
|
|
this.running = false;
|
|
if (this.heartBeatTimeout) {
|
|
clearTimeout(this.heartBeatTimeout);
|
|
this.heartBeatTimeout = undefined;
|
|
}
|
|
if (this.jitterTimeout) {
|
|
clearTimeout(this.jitterTimeout);
|
|
this.jitterTimeout = undefined;
|
|
}
|
|
}
|
|
isStarted() {
|
|
return !!this.heartBeatTimeout && this.running;
|
|
}
|
|
isStopped() {
|
|
return !this.isStarted();
|
|
}
|
|
setUnref(value) {
|
|
this.unref = value;
|
|
if (this.heartBeatTimeout) {
|
|
if (value)
|
|
this.heartBeatTimeout.unref();
|
|
else
|
|
this.heartBeatTimeout.ref();
|
|
}
|
|
if (this.jitterTimeout) {
|
|
if (value)
|
|
this.jitterTimeout.unref();
|
|
else
|
|
this.jitterTimeout.ref();
|
|
}
|
|
}
|
|
async execute(executionId) {
|
|
const date = new Date();
|
|
const execution = {
|
|
id: executionId ?? createID(),
|
|
reason: 'invoked'
|
|
};
|
|
try {
|
|
const shouldExecute = await this.beforeRun(date, execution);
|
|
if (!shouldExecute)
|
|
return;
|
|
execution.startedAt = new Date();
|
|
const result = await this.onMatch(date, execution);
|
|
execution.finishedAt = new Date();
|
|
execution.result = result;
|
|
}
|
|
catch (error) {
|
|
execution.finishedAt = new Date();
|
|
execution.error = error;
|
|
this.onError(date, error, execution);
|
|
return;
|
|
}
|
|
try {
|
|
await this.onFinished(date, execution);
|
|
}
|
|
catch (hookError) {
|
|
this.onErrorFallback(date, hookError);
|
|
}
|
|
}
|
|
}
|
|
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 = convertWeekDayName(expression, weekDays);
|
|
return convertWeekDayName(expression, shortWeekDays);
|
|
}
|
|
return convertWeekDays;
|
|
})();
|
|
|
|
var convertAsterisksToRanges = (() => {
|
|
function convertAsterisk(expression, replecement) {
|
|
return expression
|
|
.split(',')
|
|
.map((token) => token.indexOf('*') !== -1 ? token.replace('*', replecement) : token)
|
|
.join(',');
|
|
}
|
|
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 = (() => {
|
|
const rangeRegEx = /^(\d+)-(\d+)(?:\/(\d+))?$/;
|
|
const FIELD_BOUNDS = [
|
|
{ min: 0, max: 59 },
|
|
{ min: 0, max: 59 },
|
|
{ min: 0, max: 23 },
|
|
{ min: 1, max: 31 },
|
|
{ min: 1, max: 12 },
|
|
{ min: 0, max: 6 },
|
|
];
|
|
function expandRange(initTxt, endTxt, stepTxt, bounds) {
|
|
const step = parseInt(stepTxt, 10);
|
|
if (!(step >= 1))
|
|
return `${initTxt}-${endTxt}/${stepTxt}`;
|
|
const first = parseInt(initTxt, 10);
|
|
const last = parseInt(endTxt, 10);
|
|
const numbers = [];
|
|
if (first <= last) {
|
|
for (let i = first; i <= last; i += step) {
|
|
numbers.push(i);
|
|
}
|
|
return numbers.join();
|
|
}
|
|
const { min, max } = bounds;
|
|
const size = max - min + 1;
|
|
const span = ((last - first) % size + size) % size;
|
|
for (let offset = 0; offset <= span; offset += step) {
|
|
let value = first + offset;
|
|
if (value > max)
|
|
value -= size;
|
|
numbers.push(value);
|
|
}
|
|
return numbers.join();
|
|
}
|
|
function convertRange(expression, bounds) {
|
|
return expression
|
|
.split(',')
|
|
.map((token) => {
|
|
const match = rangeRegEx.exec(token.trim());
|
|
return match ? expandRange(match[1], match[2], match[3] || '1', bounds) : token;
|
|
})
|
|
.join();
|
|
}
|
|
function convertAllRanges(expressions) {
|
|
for (let i = 0; i < expressions.length; i++) {
|
|
expressions[i] = convertRange(expressions[i], FIELD_BOUNDS[i]);
|
|
}
|
|
return expressions;
|
|
}
|
|
return convertAllRanges;
|
|
})();
|
|
|
|
const NICKNAMES = {
|
|
'@yearly': '0 0 1 1 *',
|
|
'@annually': '0 0 1 1 *',
|
|
'@monthly': '0 0 1 * *',
|
|
'@weekly': '0 0 * * 0',
|
|
'@daily': '0 0 * * *',
|
|
'@midnight': '0 0 * * *',
|
|
'@hourly': '0 * * * *',
|
|
};
|
|
function resolveNickname(expression) {
|
|
const key = expression.trim().toLowerCase();
|
|
return NICKNAMES[key] ?? expression;
|
|
}
|
|
|
|
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 (/^l-\d{1,2}$/i.test(token)) {
|
|
numbers[j] = token.toUpperCase();
|
|
}
|
|
else if (/^[0-7]l$/i.test(token)) {
|
|
numbers[j] = token.toUpperCase();
|
|
}
|
|
else if (/w/i.test(token)) {
|
|
numbers[j] = token.toUpperCase();
|
|
}
|
|
else if (token.indexOf('#') !== -1) {
|
|
numbers[j] = token;
|
|
}
|
|
else if (/^\d+$/.test(token)) {
|
|
numbers[j] = parseInt(token, 10);
|
|
}
|
|
else {
|
|
numbers[j] = token;
|
|
}
|
|
}
|
|
expressions[i] = numbers;
|
|
}
|
|
return expressions;
|
|
}
|
|
function convertQuestionMarks(expressions) {
|
|
if (expressions[3] === '?')
|
|
expressions[3] = '*';
|
|
if (expressions[5] === '?')
|
|
expressions[5] = '*';
|
|
return expressions;
|
|
}
|
|
function interpret(expression) {
|
|
let expressions = removeSpaces(resolveNickname(`${expression}`)).split(' ');
|
|
expressions = appendSecondExpression(expressions);
|
|
expressions = convertQuestionMarks(expressions);
|
|
expressions[4] = monthNamesConversion(expressions[4]);
|
|
expressions[5] = weekDayNamesConversion(expressions[5]);
|
|
expressions = convertAsterisksToRanges(expressions);
|
|
expressions = convertRanges(expressions);
|
|
expressions = normalizeIntegers(expressions);
|
|
const weekdays = expressions[5];
|
|
for (let i = 0; i < weekdays.length; i++) {
|
|
if (weekdays[i] === 7)
|
|
weekdays[i] = 0;
|
|
else if (typeof weekdays[i] === 'string' && weekdays[i].startsWith('7')) {
|
|
weekdays[i] = '0' + weekdays[i].slice(1);
|
|
}
|
|
}
|
|
expressions[5] = [...new Set(weekdays)];
|
|
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';
|
|
const WEEKDAY_TOKEN = /^(\d{1,2}|L)W$/;
|
|
const LAST_DAY_OFFSET_TOKEN = /^L-(\d{1,2})$/;
|
|
function lastDayOfMonth(year, month) {
|
|
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
}
|
|
function nearestWeekday(year, month, target) {
|
|
const last = lastDayOfMonth(year, month);
|
|
if (target < 1 || target > last)
|
|
return -1;
|
|
const weekday = new Date(Date.UTC(year, month - 1, target)).getUTCDay();
|
|
if (weekday === 6)
|
|
return target === 1 ? target + 2 : target - 1;
|
|
if (weekday === 0)
|
|
return target === last ? target - 2 : target + 1;
|
|
return target;
|
|
}
|
|
function matchesDayOfMonth(field, year, month, day) {
|
|
for (const value of field) {
|
|
if (value === day)
|
|
return true;
|
|
if (value === LAST_DAY_TOKEN && day === lastDayOfMonth(year, month))
|
|
return true;
|
|
if (typeof value === 'string') {
|
|
const weekdayMatch = WEEKDAY_TOKEN.exec(value);
|
|
if (weekdayMatch) {
|
|
const target = weekdayMatch[1] === LAST_DAY_TOKEN ? lastDayOfMonth(year, month) : parseInt(weekdayMatch[1], 10);
|
|
if (nearestWeekday(year, month, target) === day)
|
|
return true;
|
|
}
|
|
const offsetMatch = LAST_DAY_OFFSET_TOKEN.exec(value);
|
|
if (offsetMatch) {
|
|
const target = lastDayOfMonth(year, month) - parseInt(offsetMatch[1], 10);
|
|
if (target >= 1 && target === day)
|
|
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 {
|
|
}
|
|
function safeEmit(emitter, event, context, onError) {
|
|
for (const listener of emitter.listeners(event)) {
|
|
try {
|
|
Promise.resolve(listener(context)).catch(onError);
|
|
}
|
|
catch (error) {
|
|
onError(error);
|
|
}
|
|
}
|
|
}
|
|
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.emit('execution:started', this.createContext(date, execution));
|
|
return true;
|
|
},
|
|
onFinished: (date, execution) => {
|
|
if (execution.reason === 'scheduled') {
|
|
this.changeState('idle');
|
|
}
|
|
this.recordLastRun(execution);
|
|
this.emit('execution:finished', this.createContext(date, execution));
|
|
return true;
|
|
},
|
|
onError: (date, error, execution) => {
|
|
this.logger.error(error);
|
|
this.recordLastRun(execution);
|
|
this.emit('execution:failed', this.createContext(date, execution));
|
|
this.changeState('idle');
|
|
},
|
|
onOverlap: (date) => {
|
|
this.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.emit('execution:missed', this.createContext(date));
|
|
},
|
|
onMaxExecutions: (date) => {
|
|
this.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.emit('execution:skipped', this.createContext(date, undefined, reason));
|
|
},
|
|
unref: options?.unref
|
|
};
|
|
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;
|
|
}
|
|
emit(event, context) {
|
|
safeEmit(this.emitter, event, context, (error) => this.logger.error(error));
|
|
}
|
|
changeState(state) {
|
|
if (this.runner.isStarted()) {
|
|
this.stateMachine.changeState(state);
|
|
}
|
|
}
|
|
start() {
|
|
if (this.stateMachine.state === 'destroyed')
|
|
return;
|
|
if (this.runner.isStopped()) {
|
|
this.runner.start();
|
|
this.stateMachine.changeState('idle');
|
|
this.emit('task:started', this.createContext(new Date()));
|
|
}
|
|
}
|
|
stop() {
|
|
if (this.runner.isStarted()) {
|
|
this.runner.stop();
|
|
this.stateMachine.changeState('stopped');
|
|
this.emit('task:stopped', this.createContext(new Date()));
|
|
}
|
|
}
|
|
getStatus() {
|
|
return this.stateMachine.state;
|
|
}
|
|
unref() {
|
|
this.runner.setUnref(true);
|
|
}
|
|
ref() {
|
|
this.runner.setUnref(false);
|
|
}
|
|
destroy() {
|
|
if (this.stateMachine.state === 'destroyed')
|
|
return;
|
|
this.stop();
|
|
this.stateMachine.changeState('destroyed');
|
|
this.emit('task:destroyed', this.createContext(new Date()));
|
|
}
|
|
execute(executionId) {
|
|
const id = executionId ?? createID();
|
|
return new Promise((resolve, reject) => {
|
|
const onFail = (context) => {
|
|
if (context.execution?.id !== id)
|
|
return;
|
|
this.off('execution:finished', onFinished);
|
|
this.off('execution:failed', onFail);
|
|
reject(context.execution?.error);
|
|
};
|
|
const onFinished = (context) => {
|
|
if (context.execution?.id !== id)
|
|
return;
|
|
this.off('execution:finished', onFinished);
|
|
this.off('execution:failed', onFail);
|
|
resolve(context.execution?.result);
|
|
};
|
|
this.on('execution:finished', onFinished);
|
|
this.on('execution:failed', onFail);
|
|
this.runner.execute(id);
|
|
});
|
|
}
|
|
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.resolveNickname = resolveNickname;
|
|
exports.resolveRunCoordinator = resolveRunCoordinator;
|
|
exports.setLogger = setLogger;
|
|
exports.setRunCoordinator = setRunCoordinator;
|
|
//# sourceMappingURL=_shared.cjs.map
|