import cron from "node-cron"; import { Stopwatch } from "@homarr/common"; import type { MaybePromise } from "@homarr/common/types"; import type { Logger } from "./logger"; import type { ValidateCron } from "./validation"; export interface CreateCronJobCreatorOptions { beforeCallback?: (name: TAllowedNames) => MaybePromise; onCallbackSuccess?: (name: TAllowedNames) => MaybePromise; onCallbackError?: (name: TAllowedNames, error: unknown) => MaybePromise; timezone?: string; logger: Logger; } interface CreateCronJobOptions { runOnStart?: boolean; expectedMaximumDurationInMillis?: number; beforeStart?: () => MaybePromise; } const createCallback = ( name: TName, cronExpression: string, options: CreateCronJobOptions, creatorOptions: CreateCronJobCreatorOptions, ) => { const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 1000; return (callback: () => MaybePromise) => { const catchingCallbackAsync = async () => { try { creatorOptions.logger.logDebug(`The callback of '${name}' cron job started`); const stopwatch = new Stopwatch(); await creatorOptions.beforeCallback?.(name); const beforeCallbackTook = stopwatch.getElapsedInHumanWords(); await callback(); const callbackTook = stopwatch.getElapsedInHumanWords(); creatorOptions.logger.logDebug( `The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`, ); const durationInMillis = stopwatch.getElapsedInMilliseconds(); if (durationInMillis > expectedMaximumDurationInMillis) { creatorOptions.logger.logWarning( `The callback of '${name}' succeeded but took ${(durationInMillis - expectedMaximumDurationInMillis).toFixed(2)}ms longer than expected (${expectedMaximumDurationInMillis}ms). This may indicate that your network performance, host performance or something else is too slow. If this happens too often, it should be looked into.`, ); } await creatorOptions.onCallbackSuccess?.(name); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`); await creatorOptions.onCallbackError?.(name, error); } }; /** * We are not using the runOnInit method as we want to run the job only once we start the cron job schedule manually. * This allows us to always run it once we start it. Additionally it will not run the callback if only the cron job file is imported. */ let scheduledTask: cron.ScheduledTask | null = null; if (cronExpression !== "never") { scheduledTask = cron.schedule(cronExpression, () => void catchingCallbackAsync(), { scheduled: false, name, timezone: creatorOptions.timezone, }); creatorOptions.logger.logDebug( `The cron job '${name}' was created with expression ${cronExpression} in timezone ${creatorOptions.timezone} and runOnStart ${options.runOnStart}`, ); } return { name, cronExpression, scheduledTask, async onStartAsync() { if (options.beforeStart) { creatorOptions.logger.logDebug(`Running beforeStart for job: ${name}`); await options.beforeStart(); } if (!options.runOnStart) return; creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`); await catchingCallbackAsync(); }, }; }; }; export type JobCallback = ReturnType< typeof createCallback >; export const createCronJobCreator = ( creatorOptions: CreateCronJobCreatorOptions, ) => { return ( name: TName, cronExpression: TExpression, options: CreateCronJobOptions = { runOnStart: false }, ) => { creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`); if (cronExpression !== "never" && !cron.validate(cronExpression)) { throw new Error(`Invalid cron expression '${cronExpression}' for job '${name}'`); } creatorOptions.logger.logDebug(`Cron job expression '${cronExpression}' for job ${name} is valid`); const returnValue = { withCallback: createCallback(name, cronExpression, options, creatorOptions), }; // This is a type guard to check if the cron expression is valid and give the user a type hint return returnValue as unknown as ValidateCron extends true ? typeof returnValue : TExpression extends "never" ? typeof returnValue : "Invalid cron expression"; }; };