cronbake
Version:
A powerful and flexible cron job manager built with TypeScript
810 lines (805 loc) • 22.9 kB
JavaScript
// 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(/:(\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(/_(\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
import * as fs from "fs";
import * as path from "path";
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;
export {
baker_default as Baker,
cron_default as Cron,
parser_default as CronParser,
index_default as default
};