refactor: add cron job core package (#704)

* refactor: add cron job core package

* docs: add comments to cron validation types

* chore(deps): move node-cron dependencies from tasks app to cron-jobs-core package

* fix: runOnInit is not running on start and rather on job creation

* fix: format issues

* fix: build fails when using top level await in cjs

* chore: update turbo gen package json typescript version to 5.5.2

* fix: format issue

* fix: deepsource issues

* chore: update turbo gen package json eslint version to 9.5.0

* chore: fix frozen lockfile and format
This commit is contained in:
Meier Lukas
2024-06-22 20:00:20 +02:00
committed by GitHub
parent ea12da991a
commit 92afd82d22
26 changed files with 397 additions and 128 deletions

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,38 @@
{
"name": "@homarr/cron-jobs-core",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": "./index.ts",
"./expressions": "./src/expressions.ts",
"./logger": "./src/logger.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"node-cron": "^3.0.3",
"@homarr/common": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.5.0",
"typescript": "^5.5.2"
},
"prettier": "@homarr/prettier-config"
}

View File

@@ -0,0 +1,94 @@
import cron from "node-cron";
import type { MaybePromise } from "@homarr/common/types";
import type { Logger } from "./logger";
import type { ValidateCron } from "./validation";
export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
beforeCallback?: (name: TAllowedNames) => MaybePromise<void>;
onCallbackSuccess?: (name: TAllowedNames) => MaybePromise<void>;
onCallbackError?: (name: TAllowedNames, error: unknown) => MaybePromise<void>;
timezone?: string;
logger: Logger;
}
interface CreateCronJobOptions {
runOnStart?: boolean;
}
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
name: TName,
cronExpression: string,
options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => {
return (callback: () => MaybePromise<void>) => {
const catchingCallbackAsync = async () => {
try {
creatorOptions.logger.logDebug(`The callback of '${name}' cron job started`);
await creatorOptions.beforeCallback?.(name);
await callback();
creatorOptions.logger.logInfo(`The callback of '${name}' cron job succeeded`);
await creatorOptions.onCallbackSuccess?.(name);
} catch (error) {
creatorOptions.logger.logError(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.
*/
const 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.runOnStart) return;
creatorOptions.logger.logDebug(`The cron job '${name}' is running because runOnStart is set to true`);
await catchingCallbackAsync();
},
};
};
};
export type JobCallback<TAllowedNames extends string, TName extends TAllowedNames> = ReturnType<
typeof createCallback<TAllowedNames, TName>
>;
export const createCronJobCreator = <TAllowedNames extends string = string>(
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => {
return <TName extends TAllowedNames, TExpression extends string>(
name: TName,
cronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false },
) => {
creatorOptions.logger.logDebug(`Validating cron expression '${cronExpression}' for job: ${name}`);
if (!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<TAllowedNames, TName>(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<TExpression> extends true
? typeof returnValue
: "Invalid cron expression";
};
};

View File

@@ -0,0 +1,9 @@
import { checkCron } from "./validation";
export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string;
export const EVERY_MINUTE = checkCron("* * * * *") satisfies string;
export const EVERY_5_MINUTES = checkCron("*/5 * * * *") satisfies string;
export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;
export const EVERY_HOUR = checkCron("0 * * * *") satisfies string;
export const EVERY_DAY = checkCron("0 0 * * */1") satisfies string;
export const EVERY_WEEK = checkCron("0 0 * * 1") satisfies string;

View File

@@ -0,0 +1,64 @@
import { objectEntries } from "@homarr/common";
import type { JobCallback } from "./creator";
import type { Logger } from "./logger";
import { jobRegistry } from "./registry";
type Jobs<TAllowedNames extends string> = {
[name in TAllowedNames]: ReturnType<JobCallback<TAllowedNames, name>>;
};
export interface CreateCronJobGroupCreatorOptions {
logger: Logger;
}
export const createJobGroupCreator = <TAllowedNames extends string = string>(
options: CreateCronJobGroupCreatorOptions,
) => {
return <TJobs extends Jobs<TAllowedNames>>(jobs: TJobs) => {
options.logger.logDebug(`Creating job group with ${Object.keys(jobs).length} jobs.`);
for (const [key, job] of objectEntries(jobs)) {
if (typeof key !== "string" || typeof job.name !== "string") continue;
options.logger.logDebug(`Added job ${job.name} to the job registry.`);
jobRegistry.set(key, {
...job,
name: job.name,
});
}
return {
startAsync: async (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
options.logger.logInfo(`Starting schedule cron job ${job.name}.`);
await job.onStartAsync();
job.scheduledTask.start();
},
startAllAsync: async () => {
for (const job of jobRegistry.values()) {
options.logger.logInfo(`Starting schedule of cron job ${job.name}.`);
await job.onStartAsync();
job.scheduledTask.start();
}
},
stop: (name: keyof TJobs) => {
const job = jobRegistry.get(name as string);
if (!job) return;
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
job.scheduledTask.stop();
},
stopAll: () => {
for (const job of jobRegistry.values()) {
options.logger.logInfo(`Stopping schedule cron job ${job.name}.`);
job.scheduledTask.stop();
}
},
getJobRegistry() {
return jobRegistry as Map<TAllowedNames, ReturnType<JobCallback<TAllowedNames, TAllowedNames>>>;
},
};
};
};

View File

@@ -0,0 +1,15 @@
import type { CreateCronJobCreatorOptions } from "./creator";
import { createCronJobCreator } from "./creator";
import { createJobGroupCreator } from "./group";
import { ConsoleLogger } from "./logger";
export const createCronJobFunctions = <TAllowedNames extends string>(
options: CreateCronJobCreatorOptions<TAllowedNames> = { logger: new ConsoleLogger() },
) => {
return {
createCronJob: createCronJobCreator<TAllowedNames>(options),
createCronJobGroup: createJobGroupCreator<TAllowedNames>({
logger: options.logger,
}),
};
};

View File

@@ -0,0 +1,19 @@
export interface Logger {
logDebug(message: string): void;
logInfo(message: string): void;
logError(error: unknown): void;
}
export class ConsoleLogger implements Logger {
public logDebug(message: string) {
console.log(message);
}
public logInfo(message: string) {
console.log(message);
}
public logError(error: unknown) {
console.error(error);
}
}

View File

@@ -0,0 +1,3 @@
import type { JobCallback } from "./creator";
export const jobRegistry = new Map<string, ReturnType<JobCallback<string, string>>>();

View File

@@ -0,0 +1,60 @@
// The below two types are used for a number with arbitrary length. By default the type `${number}` allows spaces which is not allowed in cron expressions.
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type NumberWithoutSpaces = `${Digit}${number | ""}` & `${number | ""}${Digit}`;
// The below type is used to constrain the cron expression to only allow valid characters. This will find any invalid characters in the cron expression.
type CronChars = `${"*" | "/" | "-" | "," | NumberWithoutSpaces}`;
/**
* Will return false if the TMaybeCron string contains any invalid characters.
* Otherwise it will return true.
*/
type ConstrainedCronString<TMaybeCron extends string> = TMaybeCron extends ""
? true
: TMaybeCron extends `${CronChars}${infer Rest}`
? ConstrainedCronString<Rest>
: false;
/**
* Will return true if the TMaybeCron string is a valid cron expression.
* Otherwise it will return false.
*
* It allows cron expressions with 5 or 6 parts. (Seconds are optional)
* See https://nodecron.com/docs/
*/
export type ValidateCron<TMaybeCron extends string> =
TMaybeCron extends `${infer inferedSecond} ${infer inferedMinute} ${infer inferedHour} ${infer inferedMonthDay} ${infer inferedMonth} ${infer inferedWeekDay}`
? ConstrainedCronString<inferedSecond> extends true
? ConstrainedCronString<inferedMinute> extends true
? ConstrainedCronString<inferedHour> extends true
? ConstrainedCronString<inferedMonthDay> extends true
? ConstrainedCronString<inferedMonth> extends true
? ConstrainedCronString<inferedWeekDay> extends true
? true
: false
: false
: false
: false
: false
: false
: TMaybeCron extends `${infer inferedMinute} ${infer inferedHour} ${infer inferedMonthDay} ${infer inferedMonth} ${infer inferedWeekDay}`
? ConstrainedCronString<inferedMinute> extends true
? ConstrainedCronString<inferedHour> extends true
? ConstrainedCronString<inferedMonthDay> extends true
? ConstrainedCronString<inferedMonth> extends true
? ConstrainedCronString<inferedWeekDay> extends true
? true
: false
: false
: false
: false
: false
: false;
/**
* Will return the cron expression if it is valid.
* Otherwise it will return void (as type).
*/
export const checkCron = <const T extends string>(cron: T) => {
return cron as ValidateCron<T> extends true ? T : void;
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}