UNPKG

node-cron

Version:

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

1,248 lines (1,226 loc) 42.4 kB
import { EventEmitter } from 'events'; import { randomUUID } from 'node:crypto'; function createID() { return 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 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; } } export { InlineScheduledTask as I, LocalizedTime as L, StateMachine as S, TimeMatcher as T, createID as a, resolveRunCoordinator as b, convertExpression as c, setLogger as d, isNthWeekdayToken as i, logger as l, noopLogger as n, resolveNickname as r, setRunCoordinator as s }; //# sourceMappingURL=_shared.js.map