UNPKG

cronbake

Version:

A powerful and flexible cron job manager built with TypeScript

849 lines (842 loc) 24.6 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // lib/index.ts var index_exports = {}; __export(index_exports, { Baker: () => baker_default, Cron: () => cron_default, CronParser: () => parser_default, default: () => index_default }); module.exports = __toCommonJS(index_exports); // lib/parser.ts var CronParser = class { /** * Creates a new instance of the `CronParser` class. */ constructor(cron) { this.cron = cron; } /** * A map of cron expression aliases to their corresponding cron expressions. */ aliases = /* @__PURE__ */ new Map([ ["@every_second", "* * * * * *"], ["@every_minute", "0 * * * * *"], ["@yearly", "0 0 1 1 * *"], ["@annually", "0 0 1 1 * *"], ["@monthly", "0 0 1 * * *"], ["@weekly", "0 0 * * 0 *"], ["@daily", "0 0 * * * *"], ["@hourly", "0 * * * * *"] ]); /** * Parses a string in the format "@every_<value>_<unit>" and returns the corresponding cron expression. */ parseEveryStr(str) { const [, value, unit] = str.split("_"); switch (unit) { case "seconds": return `*/${value} * * * * *`; case "minutes": return `0 */${value} * * * *`; case "hours": return `0 0 */${value} * * *`; case "dayOfMonth": return `0 0 0 */${value} * *`; case "months": return `0 0 0 0 */${value} *`; case "dayOfWeek": return `0 0 0 0 0 */${value}`; default: return "* * * * * *"; } } /** * Parses a string in the format "@at_<time>" and returns the corresponding cron expression. */ parseAtHourStr(str) { const [, time] = str.split("_"); const [hour, minute] = time.split(":"); return `0 ${minute} ${hour} * * *`; } /** * Parses a string in the format "@on_<day>_<unit>" and returns the corresponding cron expression. */ parseOnDayStr(str) { const [, day, _unit] = str.split("_"); const days = /* @__PURE__ */ new Map([ ["sunday", 1], ["monday", 2], ["tuesday", 3], ["wednesday", 4], ["thursday", 5], ["friday", 6], ["saturday", 7] ]); return `0 0 0 * * ${days.get(day)}`; } /** * Parses a string in the format "@between_<start>_<end>" and returns the corresponding cron expression. */ parseBetweenStr(str) { const [, start, end] = str.split("_"); return `0 0 ${start}-${end} * * *`; } /** * Parses the input string and returns the corresponding cron expression. */ parseStr(str) { if (this.aliases.has(str)) { return this.aliases.get(str) || ""; } if (str.includes("@every_")) { return this.parseEveryStr(str); } if (str.includes("@at_")) { return this.parseAtHourStr(str); } if (str.includes("@on_")) { return this.parseOnDayStr(str); } if (str.includes("@between_")) { return this.parseBetweenStr( str ); } return str; } /** * Parses the cron expression and returns a `CronTime` object representing the parsed cron expression. * @returns A `CronTime` object representing the parsed cron expression. */ parse() { this.cron = this.parseStr(this.cron); const [second, minute, hour, dayOfMonth, month, dayOfWeek] = this.cron.split(" "); return { second: this.parseCronTime(second, 0, 59), minute: this.parseCronTime(minute, 0, 59), hour: this.parseCronTime(hour, 0, 23), dayOfMonth: this.parseCronTime(dayOfMonth, 1, 31), month: this.parseCronTime(month, 1, 12), dayOfWeek: this.parseCronTime(dayOfWeek, 0, 6) }; } /** * Gets the next execution time based on the current time. * @returns A `Date` object representing the next execution time. */ getNext() { const cronTime = this.parse(); const now = /* @__PURE__ */ new Date(); let next = new Date( now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() ); next.setMilliseconds(0); next.setSeconds(next.getSeconds() + 1); while (!this.checkCronTime(cronTime, next)) { next.setSeconds(next.getSeconds() + 1); } return next; } /** * Gets the previous execution time based on the current time. * @returns A `Date` object representing the previous execution time. */ getPrevious() { const cronTime = this.parse(); const now = /* @__PURE__ */ new Date(); let previous = new Date( now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() ); previous.setMilliseconds(0); previous.setSeconds(previous.getSeconds() - 1); while (!this.checkCronTime(cronTime, previous)) { previous.setSeconds(previous.getSeconds() - 1); } return previous; } /** * Parses a cron time string and returns an array of numbers representing the valid values for that field. */ parseCronTime(cronTime, min, max) { const result = []; switch (true) { case cronTime === "*": for (let i = min; i <= max; i++) { result.push(i); } break; case cronTime.includes("-"): { const [start, end] = cronTime.split("-"); const step = this.getStep(cronTime); for (let i = parseInt(start); i <= parseInt(end); i += step) { result.push(i); } break; } case cronTime.includes("/"): { const [value, step] = cronTime.split("/"); const start = value === "*" ? min : parseInt(value); for (let i = start; i <= max; i += parseInt(step)) { result.push(i); } break; } case cronTime.includes(","): { const times = cronTime.split(","); for (let i = 0; i < times.length; i++) { result.push(parseInt(times[i])); } break; } default: result.push(parseInt(cronTime)); break; } return result; } /** * Gets the step value for a cron time string. */ getStep(cronTime) { const [, step] = cronTime.split("/"); const parsedStep = parseInt(step); return isNaN(parsedStep) ? 1 : parsedStep; } /** * Checks if the given date matches the cron time. */ checkCronTime(cronTime, date) { if (cronTime.second && !cronTime.second.includes(date.getSeconds())) { return false; } if (cronTime.minute && !cronTime.minute.includes(date.getMinutes())) { return false; } if (cronTime.hour && !cronTime.hour.includes(date.getHours())) { return false; } if (cronTime.dayOfMonth && !cronTime.dayOfMonth.includes(date.getDate())) { return false; } if (cronTime.month && !cronTime.month.includes(date.getMonth() + 1)) { return false; } if (cronTime.dayOfWeek && !cronTime.dayOfWeek.includes(date.getDay())) { return false; } return true; } }; var parser_default = CronParser; // lib/utils.ts var resolveIfPromise = async (value) => value instanceof Promise ? await value : value; var CBResolver = async (callback, onError) => { try { if (callback) { await resolveIfPromise(callback()); } } catch (error) { if (onError) { onError(error); } else { console.warn("Callback execution failed:", error); } } }; // lib/cron.ts var Cron = class _Cron { name; cron; callback; onTick; onComplete; onError; priority; interval = null; timeout = null; next = null; status = "stopped"; parser; history = []; metrics = { totalExecutions: 0, successfulExecutions: 0, failedExecutions: 0, averageExecutionTime: 0 }; maxHistory; useCalculatedTimeouts; pollingInterval; /** * Creates a new instance of the `Cron` class. */ constructor(options, config) { if (!options.name || typeof options.name !== "string") { throw new Error("Cron job name is required and must be a string"); } this.name = options.name; this.cron = options.cron; this.callback = options.callback; this.onTick = CBResolver.bind(this, options.onTick); this.onComplete = CBResolver.bind(this, options.onComplete); this.onError = options.onError; this.priority = options.priority ?? 0; this.maxHistory = options.maxHistory ?? 100; this.useCalculatedTimeouts = config?.useCalculatedTimeouts ?? true; this.pollingInterval = config?.pollingInterval ?? 1e3; this.start = this.start.bind(this); this.stop = this.stop.bind(this); this.pause = this.pause.bind(this); this.resume = this.resume.bind(this); this.destroy = this.destroy.bind(this); this.getStatus = this.getStatus.bind(this); this.isRunning = this.isRunning.bind(this); this.lastExecution = this.lastExecution.bind(this); this.nextExecution = this.nextExecution.bind(this); this.remaining = this.remaining.bind(this); this.time = this.time.bind(this); this.getHistory = this.getHistory.bind(this); this.getMetrics = this.getMetrics.bind(this); this.resetMetrics = this.resetMetrics.bind(this); this.parser = new parser_default(this.cron); this.validateCronExpression(); if (options.start) { this.start(); } } /** * Validates the cron expression and custom preset bounds */ validateCronExpression() { if (typeof this.cron === "string") { if (this.cron.includes("@every_")) { const parts = this.cron.split("_"); if (parts.length >= 3) { const value = parseInt(parts[1]); if (isNaN(value) || value <= 0) { throw new Error(`Invalid value in custom preset: ${this.cron}`); } } } if (this.cron.includes("@at_")) { const timeMatch = this.cron.match(/@at_(\d+):(\d+)/); if (timeMatch) { const hour = parseInt(timeMatch[1]); const minute = parseInt(timeMatch[2]); if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { throw new Error(`Invalid time in custom preset: ${this.cron}`); } } } if (this.cron.includes("@between_")) { const betweenMatch = this.cron.match(/@between_(\d+)_(\d+)/); if (betweenMatch) { const start = parseInt(betweenMatch[1]); const end = parseInt(betweenMatch[2]); if (start < 0 || start > 23 || end < 0 || end > 23 || start >= end) { throw new Error(`Invalid hour range in custom preset: ${this.cron}`); } } } } try { this.parser.parse(); } catch (error) { throw new Error(`Invalid cron expression: ${this.cron}`); } } start() { if (this.status === "running") { return; } this.status = "running"; this.next = this.parser.getNext(); if (this.useCalculatedTimeouts) { this.scheduleWithTimeout(); } else { this.scheduleWithInterval(); } } /** * Schedules execution using calculated timeouts for better efficiency */ scheduleWithTimeout() { if (this.status !== "running" || !this.next) return; const delay = Math.max(0, this.next.getTime() - Date.now()); const MAX_TIMEOUT_VALUE = 2147483647; if (delay > MAX_TIMEOUT_VALUE) { if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } this.scheduleWithInterval(); return; } this.timeout = setTimeout(async () => { if (this.status === "running") { await this.executeJob(); this.next = this.parser.getNext(); this.scheduleWithTimeout(); } }, delay); } /** * Schedules execution using traditional polling interval */ scheduleWithInterval() { this.interval = setInterval(async () => { if (this.next && this.next.getTime() <= Date.now() && this.status === "running") { await this.executeJob(); this.next = this.parser.getNext(); } }, this.pollingInterval); } /** * Executes the job and handles metrics/history tracking */ async executeJob() { const startTime = Date.now(); let success = true; let error; try { await CBResolver(this.callback, this.onError); this.onTick(); this.metrics.successfulExecutions++; } catch (err) { success = false; error = err instanceof Error ? err.message : String(err); this.metrics.failedExecutions++; this.metrics.lastError = error; if (this.onError) { try { this.onError(err instanceof Error ? err : new Error(String(err))); } catch (handlerError) { console.warn("Error handler failed:", handlerError); } } } const duration = Date.now() - startTime; this.metrics.totalExecutions++; this.metrics.lastExecutionTime = duration; this.metrics.averageExecutionTime = (this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + duration) / this.metrics.totalExecutions; const historyEntry = { timestamp: new Date(startTime), duration, success, error }; this.history.unshift(historyEntry); if (this.history.length > this.maxHistory) { this.history = this.history.slice(0, this.maxHistory); } } stop() { if (this.status === "stopped") { return; } this.status = "stopped"; this.clearSchedulers(); } pause() { if (this.status !== "running") { return; } this.status = "paused"; this.clearSchedulers(); } resume() { if (this.status !== "paused") { return; } this.status = "running"; this.next = this.parser.getNext(); if (this.useCalculatedTimeouts) { this.scheduleWithTimeout(); } else { this.scheduleWithInterval(); } } /** * Clears all active schedulers */ clearSchedulers() { if (this.interval) { clearInterval(this.interval); this.interval = null; } if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } } destroy() { this.clearSchedulers(); this.status = "stopped"; this.onComplete(); } getStatus() { return this.status; } isRunning() { return this.status === "running"; } lastExecution() { return this.parser.getPrevious(); } nextExecution() { return this.next || /* @__PURE__ */ new Date(); } remaining() { return this.next ? this.next.getTime() - Date.now() : 0; } time() { return Date.now(); } getHistory() { return [...this.history]; } getMetrics() { return { ...this.metrics }; } resetMetrics() { this.history = []; this.metrics = { totalExecutions: 0, successfulExecutions: 0, failedExecutions: 0, averageExecutionTime: 0 }; } /** * Creates a new cron job with the specified options. * @returns A new `ICron` object representing the cron job. */ static create(options, config) { return new _Cron(options, config); } /** * Parses the specified cron expression and returns a `CronTime` object. * @returns A `CronTime` object representing the parsed cron expression. */ static parse(cron) { return new parser_default(cron).parse(); } /** * Gets the next execution time for the specified cron expression. * @template T The type of the cron expression. * @returns A `Date` object representing the next execution time. */ static getNext(cron) { return new parser_default(cron).getNext(); } /** * Gets the previous execution time for the specified cron expression. * @returns A `Date` object representing the previous execution time. */ static getPrevious(cron) { return new parser_default(cron).getPrevious(); } /** * Checks if the specified string is a valid cron expression. * @returns `true` if the string is a valid cron expression, `false` otherwise. */ static isValid(cron) { try { new parser_default(cron).parse(); return true; } catch (e) { return false; } } }; var cron_default = Cron; // lib/baker.ts var fs = __toESM(require("fs"), 1); var path = __toESM(require("path"), 1); var Baker = class _Baker { crons = /* @__PURE__ */ new Map(); config; persistence; enableMetrics; onError; constructor(options = {}) { this.config = { pollingInterval: options.schedulerConfig?.pollingInterval ?? 1e3, useCalculatedTimeouts: options.schedulerConfig?.useCalculatedTimeouts ?? true, maxHistoryEntries: options.schedulerConfig?.maxHistoryEntries ?? 100 }; this.persistence = { enabled: options.persistence?.enabled ?? false, filePath: options.persistence?.filePath ?? "./cronbake-state.json", autoRestore: options.persistence?.autoRestore ?? true }; this.enableMetrics = options.enableMetrics ?? true; this.onError = options.onError; if (this.persistence.enabled && this.persistence.autoRestore) { this.restoreState().catch((err) => { console.warn("Failed to restore state:", err); }); } if (options.autoStart) { this.bakeAll(); } } add(options) { if (this.crons.has(options.name)) { throw new Error(`Cron job with name '${options.name}' already exists`); } const cronConfig = { useCalculatedTimeouts: this.config.useCalculatedTimeouts, pollingInterval: this.config.pollingInterval }; const enhancedOptions = { ...options, maxHistory: options.maxHistory ?? this.config.maxHistoryEntries, onError: options.onError ?? ((error) => { if (this.onError) { this.onError(error, options.name); } }) }; const cron = cron_default.create(enhancedOptions, cronConfig); this.crons.set(cron.name, cron); if (this.persistence.enabled) { this.saveState().catch((err) => { console.warn("Failed to save state:", err); }); } return cron; } remove(name) { const cron = this.crons.get(name); if (cron) { cron.destroy(); this.crons.delete(name); if (this.persistence.enabled) { this.saveState().catch((err) => { console.warn("Failed to save state:", err); }); } } } bake(name) { const cron = this.crons.get(name); if (cron) { cron.start(); } } stop(name) { const cron = this.crons.get(name); if (cron) { cron.stop(); } } pause(name) { const cron = this.crons.get(name); if (cron) { cron.pause(); } } resume(name) { const cron = this.crons.get(name); if (cron) { cron.resume(); } } destroy(name) { const cron = this.crons.get(name); if (cron) { cron.destroy(); this.crons.delete(name); if (this.persistence.enabled) { this.saveState().catch((err) => { console.warn("Failed to save state:", err); }); } } } getStatus(name) { const cron = this.crons.get(name); return cron ? cron.getStatus() : "stopped"; } isRunning(name) { const cron = this.crons.get(name); return cron ? cron.isRunning() : false; } lastExecution(name) { const cron = this.crons.get(name); return cron ? cron.lastExecution() : /* @__PURE__ */ new Date(); } nextExecution(name) { const cron = this.crons.get(name); return cron ? cron.nextExecution() : /* @__PURE__ */ new Date(); } remaining(name) { const cron = this.crons.get(name); return cron ? cron.remaining() : 0; } time(name) { const cron = this.crons.get(name); return cron ? cron.time() : 0; } getHistory(name) { const cron = this.crons.get(name); return cron && this.enableMetrics ? cron.getHistory() : []; } getMetrics(name) { const cron = this.crons.get(name); return cron && this.enableMetrics ? cron.getMetrics() : { totalExecutions: 0, successfulExecutions: 0, failedExecutions: 0, averageExecutionTime: 0 }; } getJobNames() { return Array.from(this.crons.keys()); } getAllJobs() { return new Map(this.crons); } bakeAll() { this.crons.forEach((cron) => cron.start()); } stopAll() { this.crons.forEach((cron) => cron.stop()); } pauseAll() { this.crons.forEach((cron) => cron.pause()); } resumeAll() { this.crons.forEach((cron) => cron.resume()); } destroyAll() { this.crons.forEach((cron) => cron.destroy()); this.crons.clear(); if (this.persistence.enabled) { this.saveState().catch((err) => { console.warn("Failed to save state:", err); }); } } resetAllMetrics() { if (this.enableMetrics) { this.crons.forEach((cron) => cron.resetMetrics()); } } async saveState() { if (!this.persistence.enabled) return; try { const state = { timestamp: (/* @__PURE__ */ new Date()).toISOString(), jobs: Array.from(this.crons.entries()).map(([name, cron]) => ({ name, cron: cron.cron, status: cron.getStatus(), priority: cron.priority, metrics: this.enableMetrics ? cron.getMetrics() : void 0, history: this.enableMetrics ? cron.getHistory() : void 0 })), config: this.config }; const filePath = path.resolve(this.persistence.filePath); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), "utf8"); } catch (error) { throw new Error(`Failed to save state: ${error}`); } } async restoreState() { if (!this.persistence.enabled) return; try { const filePath = path.resolve(this.persistence.filePath); if (!fs.existsSync(filePath)) { return; } const data = await fs.promises.readFile(filePath, "utf8"); const state = JSON.parse(data); if (!state.jobs || !Array.isArray(state.jobs)) { throw new Error("Invalid state file format"); } for (const jobData of state.jobs) { if (!jobData.name || !jobData.cron) { console.warn("Skipping invalid job data:", jobData); continue; } try { const options = { name: jobData.name, cron: jobData.cron, callback: () => { console.warn(`Restored job '${jobData.name}' executed but no callback was provided`); }, priority: jobData.priority, start: jobData.status === "running" }; this.add(options); } catch (error) { console.warn(`Failed to restore job '${jobData.name}':`, error); } } console.log(`Restored ${state.jobs.length} cron jobs from persistence`); } catch (error) { throw new Error(`Failed to restore state: ${error}`); } } /** * Creates a new instance of `Baker`. */ static create(options = {}) { return new _Baker(options); } }; var baker_default = Baker; // lib/index.ts var index_default = baker_default; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Baker, Cron, CronParser });