Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions
+9
View File
@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
+1
View File
@@ -0,0 +1 @@
export * from "./src";
+40
View File
@@ -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"
}
}
+154
View File
@@ -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;
+123
View File
@@ -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);
},
};
};
};
+14
View File
@@ -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,
}),
};
};
+7
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
import type { JobCallback } from "./creator";
export const jobRegistry = new Map<string, ReturnType<JobCallback<string, string>>>();
+60
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;
};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}