node-cron
Version:
Job scheduling for Node.js with overlap prevention, distributed coordination, and background tasks. Zero dependencies, written in TypeScript.
539 lines (533 loc) • 20.1 kB
JavaScript
import { c as convertExpression, i as isNthWeekdayToken, a as createID, S as StateMachine, T as TimeMatcher, l as logger, r as resolveRunCoordinator, L as LocalizedTime, s as setRunCoordinator, b as setLogger, I as InlineScheduledTask } from './_shared.js';
import path, { resolve, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { fork } from 'child_process';
import { EventEmitter } from 'events';
import 'node:crypto';
const tasks = new Map();
class TaskRegistry {
add(task) {
if (this.has(task.id)) {
throw Error(`task ${task.id} already registered!`);
}
tasks.set(task.id, task);
task.on('task:destroyed', () => {
this.remove(task);
});
}
get(taskId) {
return tasks.get(taskId);
}
remove(task) {
if (this.has(task.id)) {
task?.destroy();
tasks.delete(task.id);
}
}
all() {
return tasks;
}
has(taskId) {
return tasks.has(taskId);
}
killAll() {
tasks.forEach(id => this.remove(id));
}
}
const validationRegex = /^(?:\d+|\*|\*\/\d+)$/;
const ALLOWED_CHARS_REGEX = /^[a-zA-Z0-9-*/,# ]+$/;
function isValidExpression(expression, min, max) {
const options = expression;
for (const option of options) {
const optionAsInt = parseInt(option, 10);
if ((!Number.isNaN(optionAsInt) &&
(optionAsInt < min || optionAsInt > max)) ||
!validationRegex.test(option))
return false;
}
return true;
}
function isInvalidSecond(expression) {
return !isValidExpression(expression, 0, 59);
}
function isInvalidMinute(expression) {
return !isValidExpression(expression, 0, 59);
}
function isInvalidHour(expression) {
return !isValidExpression(expression, 0, 23);
}
function isInvalidDayOfMonth(expression) {
const days = expression.filter((value) => value !== 'L');
return !isValidExpression(days, 1, 31);
}
function isInvalidMonth(expression) {
return !isValidExpression(expression, 1, 12);
}
function isInvalidWeekDay(expression) {
const days = expression.filter((value) => !isNthWeekdayToken(value) && !/^[0-7]L$/.test(value));
return !isValidExpression(days, 0, 7);
}
function validateFields(patterns, executablePatterns) {
if (isInvalidSecond(executablePatterns[0]))
throw new Error(`${patterns[0]} is a invalid expression for second`);
if (isInvalidMinute(executablePatterns[1]))
throw new Error(`${patterns[1]} is a invalid expression for minute`);
if (isInvalidHour(executablePatterns[2]))
throw new Error(`${patterns[2]} is a invalid expression for hour`);
if (isInvalidDayOfMonth(executablePatterns[3]))
throw new Error(`${patterns[3]} is a invalid expression for day of month`);
if (isInvalidMonth(executablePatterns[4]))
throw new Error(`${patterns[4]} is a invalid expression for month`);
if (isInvalidWeekDay(executablePatterns[5]))
throw new Error(`${patterns[5]} is a invalid expression for week day`);
}
const FIELDS = [
{ key: 'second', label: 'second', invalid: isInvalidSecond },
{ key: 'minute', label: 'minute', invalid: isInvalidMinute },
{ key: 'hour', label: 'hour', invalid: isInvalidHour },
{ key: 'dayOfMonth', label: 'day of month', invalid: isInvalidDayOfMonth },
{ key: 'month', label: 'month', invalid: isInvalidMonth },
{ key: 'dayOfWeek', label: 'week day', invalid: isInvalidWeekDay },
];
function validateDetailed$1(pattern) {
if (typeof pattern !== 'string')
return { valid: false, errors: [{ field: 'expression', message: 'pattern must be a string' }] };
if (!ALLOWED_CHARS_REGEX.test(pattern))
return { valid: false, errors: [{ field: 'expression', value: pattern, message: 'pattern includes illegal characters' }] };
const raw = pattern.replace(/\s{2,}/g, ' ').trim().split(' ');
if (raw.length !== 5 && raw.length !== 6)
return { valid: false, errors: [{ field: 'expression', value: pattern, message: `expected 5 or 6 fields but got ${raw.length}` }] };
const patterns = raw.length === 5 ? ['0', ...raw] : raw;
const executable = convertExpression(pattern);
const errors = [];
FIELDS.forEach((f, i) => {
if (f.invalid(executable[i]))
errors.push({ field: f.key, value: patterns[i], message: `${patterns[i]} is a invalid expression for ${f.label}` });
});
if (errors.length)
return { valid: false, errors };
return {
valid: true,
errors: [],
fields: {
second: executable[0],
minute: executable[1],
hour: executable[2],
dayOfMonth: executable[3],
month: executable[4],
dayOfWeek: executable[5],
},
};
}
function parse$1(pattern) {
const result = validateDetailed$1(pattern);
if (!result.valid)
throw new Error(result.errors[0].message);
return result.fields;
}
function validate$1(pattern) {
if (typeof pattern !== 'string')
throw new TypeError('pattern must be a string!');
if (!ALLOWED_CHARS_REGEX.test(pattern))
throw new TypeError('pattern includes illegal characters!');
const patterns = pattern.split(' ');
const executablePatterns = convertExpression(pattern);
if (patterns.length === 5)
patterns.unshift('0');
validateFields(patterns, executablePatterns);
}
const daemonPath = resolve(dirname(fileURLToPath(import.meta.url)), 'daemon.js');
class TaskEmitter extends EventEmitter {
}
class BackgroundScheduledTask {
emitter;
id;
name;
cronExpression;
taskPath;
options;
forkProcess;
stateMachine;
logger;
suppressMissedWarning;
timeMatcher;
runCount;
runCoordinator;
_lastRun = null;
constructor(cronExpression, taskPath, options) {
this.cronExpression = cronExpression;
this.taskPath = taskPath;
this.options = options;
this.id = createID();
this.name = options?.name || this.id;
this.emitter = new TaskEmitter();
this.stateMachine = new StateMachine('stopped');
this.timeMatcher = new TimeMatcher(cronExpression, options?.timezone);
this.runCount = 0;
this.on('execution:started', () => { this.runCount++; });
this.on('execution:finished', (context) => { this.recordLastRun(context.execution); });
this.on('execution:failed', (context) => { this.recordLastRun(context.execution); });
this.logger = options?.logger || logger;
this.suppressMissedWarning = options?.suppressMissedWarning || false;
this.runCoordinator = options?.distributed ? resolveRunCoordinator(options?.runCoordinator) : undefined;
this.on('task:stopped', () => {
this.forkProcess?.kill();
this.forkProcess = undefined;
this.stateMachine.changeState('stopped');
});
this.on('task:destroyed', () => {
this.forkProcess?.kill();
this.forkProcess = undefined;
this.stateMachine.changeState('destroyed');
});
}
getNextRun() {
if (this.stateMachine.state !== 'stopped') {
return this.timeMatcher.getNextMatch(new Date());
}
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.options?.maxExecutions == null)
return undefined;
return Math.max(0, this.options.maxExecutions - this.runCount);
}
getPattern() {
return this.cronExpression;
}
lastRun() {
return this._lastRun;
}
recordLastRun(execution) {
if (!execution)
return;
const raw = execution.finishedAt ?? execution.startedAt;
const date = raw ? new Date(raw) : new Date();
const lastRun = { date };
if (execution.error) {
lastRun.error = execution.error;
}
else {
lastRun.result = execution.result;
}
this._lastRun = lastRun;
}
start() {
return new Promise((resolve, reject) => {
if (this.forkProcess) {
return resolve(undefined);
}
const startTimeout = this.options?.startTimeout ?? 5000;
const failStart = (error) => {
clearTimeout(timeout);
this.forkProcess?.kill();
this.forkProcess = undefined;
reject(error);
};
const timeout = setTimeout(() => {
failStart(new Error(`Start operation timed out after ${startTimeout}ms. The background task file may have failed to load or taken too long to import; ` +
`verify it runs on its own and consider increasing the \`startTimeout\` option.`));
}, startTimeout);
try {
this.forkProcess = fork(daemonPath);
this.forkProcess.on('error', (err) => {
failStart(new Error(`Error on daemon: ${err.message}`));
});
this.forkProcess.on('exit', (code, signal) => {
if (code !== 0 && signal !== 'SIGTERM') {
const erro = new Error(`node-cron daemon exited with code ${code || signal}`);
this.logger.error(erro);
failStart(erro);
}
});
this.forkProcess.on('message', (message) => {
if (message.type === 'coordinator:shouldRun') {
void this.handleShouldRun(message);
return;
}
if (message.type === 'coordinator:complete') {
this.runCoordinator?.onComplete?.(message.key)?.catch?.((err) => this.logger.error('Run coordinator onComplete failed', err));
return;
}
if (message.event === 'daemon:error') {
failStart(message.jsonError ? deserializeError(message.jsonError) : new Error('Background task failed to start'));
return;
}
if (message.jsonError) {
if (message.context?.execution) {
message.context.execution.error = deserializeError(message.jsonError);
delete message.jsonError;
}
}
if (message.context?.task?.state) {
this.stateMachine.changeState(message.context?.task?.state);
}
if (message.context) {
const execution = message.context?.execution;
delete execution?.hasError;
const context = this.createContext(new Date(message.context.date), execution, message.context.reason);
this.logEvent(message.event, context);
this.emitter.emit(message.event, context);
}
});
this.once('task:started', () => {
this.stateMachine.changeState('idle');
clearTimeout(timeout);
resolve(undefined);
});
this.forkProcess.send({
command: 'task:start',
path: this.taskPath,
cron: this.cronExpression,
options: serializableOptions(this.options)
});
}
catch (error) {
failStart(error);
}
});
}
stop() {
return new Promise((resolve, reject) => {
if (!this.forkProcess) {
return resolve(undefined);
}
const timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
reject(new Error('Stop operation timed out'));
}, 5000);
const cleanupAndResolve = () => {
clearTimeout(timeoutId);
this.off('task:stopped', onStopped);
this.forkProcess = undefined;
resolve(undefined);
};
const onStopped = () => {
cleanupAndResolve();
};
this.once('task:stopped', onStopped);
this.forkProcess.send({
command: 'task:stop'
});
});
}
getStatus() {
return this.stateMachine.state;
}
destroy() {
return new Promise((resolve, reject) => {
if (!this.forkProcess) {
return resolve(undefined);
}
const timeoutId = setTimeout(() => {
clearTimeout(timeoutId);
reject(new Error('Destroy operation timed out'));
}, 5000);
const onDestroy = () => {
clearTimeout(timeoutId);
this.off('task:destroyed', onDestroy);
resolve(undefined);
};
this.once('task:destroyed', onDestroy);
this.forkProcess.send({
command: 'task:destroy'
});
});
}
execute() {
return new Promise((resolve, reject) => {
if (!this.forkProcess) {
return reject(new Error('Cannot execute background task because it hasn\'t been started yet. Please initialize the task using the start() method before attempting to execute it.'));
}
let timeoutId;
if (typeof this.options?.executeTimeout === 'number') {
timeoutId = setTimeout(() => {
cleanupListeners();
reject(new Error('Execution timeout exceeded'));
}, this.options.executeTimeout);
}
const cleanupListeners = () => {
if (timeoutId)
clearTimeout(timeoutId);
this.off('execution:finished', onFinished);
this.off('execution:failed', onFail);
};
const onFinished = (context) => {
cleanupListeners();
resolve(context.execution?.result);
};
const onFail = (context) => {
cleanupListeners();
reject(context.execution?.error || new Error('Execution failed without specific error'));
};
this.once('execution:finished', onFinished);
this.once('execution:failed', onFail);
this.forkProcess.send({
command: 'task:execute'
});
});
}
async handleShouldRun(message) {
let allowed = false;
let error;
try {
allowed = this.runCoordinator ? await this.runCoordinator.shouldRun(message.key, message.ttlMs) : false;
}
catch (err) {
error = err?.message ?? String(err);
}
this.forkProcess?.send({ type: 'coordinator:result', reqId: message.reqId, allowed, error });
}
on(event, fun) {
this.emitter.on(event, fun);
}
off(event, fun) {
this.emitter.off(event, fun);
}
once(event, fun) {
this.emitter.once(event, fun);
}
logEvent(event, context) {
switch (event) {
case 'execution:missed': {
const handled = this.emitter.listenerCount('execution:missed') > 0;
if (!this.suppressMissedWarning && !handled) {
this.logger.warn(`missed execution at ${context.date}! Possible blocking IO or high CPU user at the same process used by node-cron.`);
}
break;
}
case 'execution:overlap':
if (this.options?.noOverlap) {
this.logger.warn('task still running, new execution blocked by overlap prevention!');
}
break;
case 'execution:failed':
if (context.execution?.error) {
this.logger.error(context.execution.error);
}
break;
}
}
createContext(executionDate, execution, reason) {
const localTime = new LocalizedTime(executionDate, this.options?.timezone);
const ctx = {
date: localTime.toDate(),
dateLocalIso: localTime.toISO(),
triggeredAt: new Date(),
task: this,
execution: execution
};
if (reason)
ctx.reason = reason;
return ctx;
}
}
function serializableOptions(options) {
if (!options)
return options;
const { logger: _logger, runCoordinator: _runCoordinator, ...rest } = options;
return rest;
}
function deserializeError(str) {
const data = JSON.parse(str);
const Err = globalThis[data.name] || Error;
const err = new Err(data.message);
if (data.stack) {
err.stack = data.stack;
}
Object.keys(data).forEach(key => {
if (!['name', 'message', 'stack'].includes(key)) {
err[key] = data[key];
}
});
return err;
}
const moduleFilename = fileURLToPath(import.meta.url);
const registry = new TaskRegistry();
function schedule(expression, func, options) {
const task = createTask(expression, func, options);
const started = task.start();
if (started && typeof started.catch === 'function') {
started.catch((error) => {
(options?.logger || logger).error(`Failed to start scheduled task: ${error?.message ?? error}`);
});
}
return task;
}
function createTask(expression, func, options) {
if (options?.distributed && !options.name) {
throw new Error('`distributed` requires a `name` (it forms the coordination key shared across instances).');
}
let task;
if (func instanceof Function) {
task = new InlineScheduledTask(expression, func, options);
}
else {
const taskPath = solvePath(func);
task = new BackgroundScheduledTask(expression, taskPath, options);
}
registry.add(task);
return task;
}
function solvePath(filePath) {
if (path.isAbsolute(filePath))
return pathToFileURL(filePath).href;
if (filePath.startsWith('file://'))
return filePath;
const stackLines = new Error().stack?.split('\n');
if (stackLines) {
stackLines?.shift();
const callerLine = stackLines?.find((line) => { return line.indexOf(moduleFilename) === -1; });
const match = callerLine?.match(/(file:\/\/)?(((\/?)(\w:))?([/\\].+)):\d+:\d+/);
if (match) {
const dir = `${match[5] ?? ""}${path.dirname(match[6])}`;
return pathToFileURL(path.resolve(dir, filePath)).href;
}
}
throw new Error(`Could not locate task file ${filePath}`);
}
function validate(expression) {
try {
validate$1(expression);
return true;
}
catch (e) {
return false;
}
}
const validateDetailed = validateDetailed$1;
const parse = parse$1;
const getTasks = registry.all;
const getTask = registry.get;
const nodeCron = {
schedule,
createTask,
validate,
validateDetailed,
parse,
getTasks,
getTask,
setLogger,
setRunCoordinator,
};
export { createTask, nodeCron as default, getTask, getTasks, nodeCron, parse, schedule, setLogger, setRunCoordinator, solvePath, validate, validateDetailed };
//# sourceMappingURL=node-cron.js.map