UNPKG

node-cron

Version:

Job scheduling for Node.js with overlap prevention, distributed coordination, and background tasks. Zero dependencies, written in TypeScript.

554 lines (546 loc) 20.5 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var inlineScheduledTask = require('./_shared.cjs'); var path = require('path'); var url = require('url'); var child_process = require('child_process'); var events = require('events'); require('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) => !inlineScheduledTask.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 = inlineScheduledTask.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 = inlineScheduledTask.convertExpression(pattern); if (patterns.length === 5) patterns.unshift('0'); validateFields(patterns, executablePatterns); } const daemonPath = path.resolve(path.dirname(__filename), 'daemon.cjs'); class TaskEmitter extends events.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 = inlineScheduledTask.createID(); this.name = options?.name || this.id; this.emitter = new TaskEmitter(); this.stateMachine = new inlineScheduledTask.StateMachine('stopped'); this.timeMatcher = new inlineScheduledTask.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 || inlineScheduledTask.logger; this.suppressMissedWarning = options?.suppressMissedWarning || false; this.runCoordinator = options?.distributed ? inlineScheduledTask.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 = child_process.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 inlineScheduledTask.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 = __filename; 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 || inlineScheduledTask.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.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 url.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 url.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: inlineScheduledTask.setLogger, setRunCoordinator: inlineScheduledTask.setRunCoordinator, }; exports.setLogger = inlineScheduledTask.setLogger; exports.setRunCoordinator = inlineScheduledTask.setRunCoordinator; exports.createTask = createTask; exports.default = nodeCron; exports.getTask = getTask; exports.getTasks = getTasks; exports.nodeCron = nodeCron; exports.parse = parse; exports.schedule = schedule; exports.solvePath = solvePath; exports.validate = validate; exports.validateDetailed = validateDetailed; //# sourceMappingURL=node-cron.cjs.map