Replace entire codebase with homarr-labs/homarr
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@homarr/cron-jobs-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./expressions": "./src/expressions.ts",
|
||||
"./logger": "./src/logger.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"node-cron": "^4.2.1"
|
||||
},
|
||||
"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.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createTask, validate } from "node-cron";
|
||||
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
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;
|
||||
preventManualExecution?: boolean;
|
||||
expectedMaximumDurationInMillis?: number;
|
||||
beforeStart?: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
|
||||
name: TName,
|
||||
defaultCronExpression: string,
|
||||
options: CreateCronJobOptions,
|
||||
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
|
||||
) => {
|
||||
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 2500;
|
||||
return (callback: () => MaybePromise<void>) => {
|
||||
const catchingCallbackAsync = async () => {
|
||||
try {
|
||||
creatorOptions.logger.logDebug("The callback of cron job started", {
|
||||
name,
|
||||
});
|
||||
const stopwatch = new Stopwatch();
|
||||
await creatorOptions.beforeCallback?.(name);
|
||||
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
|
||||
await callback();
|
||||
const callbackTook = stopwatch.getElapsedInHumanWords();
|
||||
creatorOptions.logger.logDebug("The callback of cron job succeeded", {
|
||||
name,
|
||||
beforeCallbackTook,
|
||||
callbackTook,
|
||||
});
|
||||
|
||||
const durationInMillis = stopwatch.getElapsedInMilliseconds();
|
||||
if (durationInMillis > expectedMaximumDurationInMillis) {
|
||||
creatorOptions.logger.logWarning("The callback of cron job took longer than expected", {
|
||||
name,
|
||||
durationInMillis,
|
||||
expectedMaximumDurationInMillis,
|
||||
});
|
||||
}
|
||||
await creatorOptions.onCallbackSuccess?.(name);
|
||||
} catch (error) {
|
||||
creatorOptions.logger.logError(
|
||||
new ErrorWithMetadata(
|
||||
"The callback of cron job failed",
|
||||
{
|
||||
name,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
await creatorOptions.onCallbackError?.(name, error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
async createTaskAsync() {
|
||||
const configuration = await db.query.cronJobConfigurations.findFirst({
|
||||
where: (cronJobConfigurations, { eq }) => eq(cronJobConfigurations.name, name),
|
||||
});
|
||||
|
||||
const scheduledTask = createTask(
|
||||
configuration?.cronExpression ?? defaultCronExpression,
|
||||
() => void catchingCallbackAsync(),
|
||||
{
|
||||
name,
|
||||
timezone: creatorOptions.timezone,
|
||||
},
|
||||
);
|
||||
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
timezone: creatorOptions.timezone,
|
||||
runOnStart: options.runOnStart,
|
||||
});
|
||||
|
||||
return 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 is configured to run on start, executing callback", {
|
||||
name,
|
||||
});
|
||||
await catchingCallbackAsync();
|
||||
},
|
||||
async executeAsync() {
|
||||
await catchingCallbackAsync();
|
||||
},
|
||||
preventManualExecution: options.preventManualExecution ?? false,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
defaultCronExpression: TExpression,
|
||||
options: CreateCronJobOptions = { runOnStart: false },
|
||||
) => {
|
||||
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
});
|
||||
if (!validate(defaultCronExpression)) {
|
||||
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
|
||||
}
|
||||
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
|
||||
name,
|
||||
cronExpression: defaultCronExpression,
|
||||
});
|
||||
|
||||
const returnValue = {
|
||||
withCallback: createCallback<TAllowedNames, TName>(name, defaultCronExpression, 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";
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { checkCron } from "./validation";
|
||||
|
||||
export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string;
|
||||
export const EVERY_30_SECONDS = checkCron("*/30 * * * * *") 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;
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ScheduledTask } from "node-cron";
|
||||
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
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.", {
|
||||
jobCount: Object.keys(jobs).length,
|
||||
});
|
||||
for (const [key, job] of objectEntries(jobs)) {
|
||||
if (typeof key !== "string" || typeof job.name !== "string") continue;
|
||||
|
||||
options.logger.logDebug("Registering job in the job registry.", {
|
||||
name: job.name,
|
||||
});
|
||||
jobRegistry.set(key, {
|
||||
...job,
|
||||
name: job.name,
|
||||
});
|
||||
}
|
||||
|
||||
const tasks = new Map<string, ScheduledTask>();
|
||||
|
||||
return {
|
||||
initializeAsync: async () => {
|
||||
const configurations = await db.query.cronJobConfigurations.findMany();
|
||||
for (const job of jobRegistry.values()) {
|
||||
const configuration = configurations.find(({ name }) => name === job.name);
|
||||
if (configuration?.isEnabled === false) {
|
||||
continue;
|
||||
}
|
||||
if (tasks.has(job.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledTask = await job.createTaskAsync();
|
||||
|
||||
tasks.set(job.name, scheduledTask);
|
||||
}
|
||||
},
|
||||
startAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
if (!tasks.has(job.name)) return;
|
||||
|
||||
options.logger.logInfo("Starting schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(name as string)?.start();
|
||||
},
|
||||
startAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
if (!tasks.has(job.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.logger.logInfo("Starting schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await job.onStartAsync();
|
||||
await tasks.get(job.name)?.start();
|
||||
}
|
||||
},
|
||||
runManuallyAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
if (job.preventManualExecution) {
|
||||
throw new Error(`The job "${job.name}" can not be executed manually.`);
|
||||
}
|
||||
|
||||
options.logger.logInfo("Running schedule cron job manually.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.execute();
|
||||
},
|
||||
stopAsync: async (name: keyof TJobs) => {
|
||||
const job = jobRegistry.get(name as string);
|
||||
if (!job) return;
|
||||
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(name as string)?.stop();
|
||||
},
|
||||
stopAllAsync: async () => {
|
||||
for (const job of jobRegistry.values()) {
|
||||
options.logger.logInfo("Stopping schedule of cron job.", {
|
||||
name: job.name,
|
||||
});
|
||||
await tasks.get(job.name)?.stop();
|
||||
}
|
||||
},
|
||||
getJobRegistry() {
|
||||
return jobRegistry as Map<TAllowedNames, ReturnType<JobCallback<TAllowedNames, TAllowedNames>>>;
|
||||
},
|
||||
getTask(name: keyof TJobs) {
|
||||
return tasks.get(name as string) ?? null;
|
||||
},
|
||||
setTask(name: keyof TJobs, task: ScheduledTask) {
|
||||
tasks.set(name as string, task);
|
||||
},
|
||||
getKeys() {
|
||||
return objectKeys(jobs);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CreateCronJobCreatorOptions } from "./creator";
|
||||
import { createCronJobCreator } from "./creator";
|
||||
import { createJobGroupCreator } from "./group";
|
||||
|
||||
export const createCronJobFunctions = <TAllowedNames extends string>(
|
||||
options: CreateCronJobCreatorOptions<TAllowedNames>,
|
||||
) => {
|
||||
return {
|
||||
createCronJob: createCronJobCreator<TAllowedNames>(options),
|
||||
createCronJobGroup: createJobGroupCreator<TAllowedNames>({
|
||||
logger: options.logger,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Logger {
|
||||
logDebug(message: string, metadata?: Record<string, unknown>): void;
|
||||
logInfo(message: string, metadata?: Record<string, unknown>): void;
|
||||
logError(message: string, metadata?: Record<string, unknown>): void;
|
||||
logError(error: unknown): void;
|
||||
logWarning(message: string, metadata?: Record<string, unknown>): void;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { JobCallback } from "./creator";
|
||||
|
||||
export const jobRegistry = new Map<string, ReturnType<JobCallback<string, string>>>();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user