From 92afd82d22f83fe1b08a0debafca339a46f9b49f Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 22 Jun 2024 20:00:20 +0200 Subject: [PATCH] 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 --- apps/tasks/package.json | 3 +- apps/tasks/src/jobs.ts | 4 +- apps/tasks/src/jobs/analytics.ts | 6 +- apps/tasks/src/jobs/icons-updater.ts | 6 +- .../src/jobs/integrations/home-assistant.ts | 6 +- apps/tasks/src/jobs/ping.ts | 6 +- apps/tasks/src/jobs/queue.ts | 7 +- apps/tasks/src/lib/cron-job/constants.ts | 7 -- apps/tasks/src/lib/cron-job/creator.ts | 37 -------- apps/tasks/src/lib/cron-job/group.ts | 45 --------- apps/tasks/src/lib/cron-job/registry.ts | 9 -- apps/tasks/src/lib/jobs.ts | 21 +++++ apps/tasks/src/main.ts | 7 +- packages/cron-jobs-core/eslint.config.js | 9 ++ packages/cron-jobs-core/index.ts | 1 + packages/cron-jobs-core/package.json | 38 ++++++++ packages/cron-jobs-core/src/creator.ts | 94 +++++++++++++++++++ packages/cron-jobs-core/src/expressions.ts | 9 ++ packages/cron-jobs-core/src/group.ts | 64 +++++++++++++ packages/cron-jobs-core/src/index.ts | 15 +++ packages/cron-jobs-core/src/logger.ts | 19 ++++ packages/cron-jobs-core/src/registry.ts | 3 + packages/cron-jobs-core/src/validation.ts | 60 ++++++++++++ packages/cron-jobs-core/tsconfig.json | 8 ++ pnpm-lock.yaml | 37 ++++++-- turbo/generators/templates/package.json.hbs | 4 +- 26 files changed, 397 insertions(+), 128 deletions(-) delete mode 100644 apps/tasks/src/lib/cron-job/constants.ts delete mode 100644 apps/tasks/src/lib/cron-job/creator.ts delete mode 100644 apps/tasks/src/lib/cron-job/group.ts delete mode 100644 apps/tasks/src/lib/cron-job/registry.ts create mode 100644 apps/tasks/src/lib/jobs.ts create mode 100644 packages/cron-jobs-core/eslint.config.js create mode 100644 packages/cron-jobs-core/index.ts create mode 100644 packages/cron-jobs-core/package.json create mode 100644 packages/cron-jobs-core/src/creator.ts create mode 100644 packages/cron-jobs-core/src/expressions.ts create mode 100644 packages/cron-jobs-core/src/group.ts create mode 100644 packages/cron-jobs-core/src/index.ts create mode 100644 packages/cron-jobs-core/src/logger.ts create mode 100644 packages/cron-jobs-core/src/registry.ts create mode 100644 packages/cron-jobs-core/src/validation.ts create mode 100644 packages/cron-jobs-core/tsconfig.json diff --git a/apps/tasks/package.json b/apps/tasks/package.json index c4f9b2ea4..e70c996a7 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -31,8 +31,8 @@ "@homarr/widgets": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0", + "@homarr/cron-jobs-core": "workspace:^0.1.0", "dotenv": "^16.4.5", - "node-cron": "^3.0.3", "superjson": "2.2.1", "undici": "6.19.2" }, @@ -40,7 +40,6 @@ "@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", "@types/node": "^20.14.8", "dotenv-cli": "^7.4.2", "eslint": "^9.5.0", diff --git a/apps/tasks/src/jobs.ts b/apps/tasks/src/jobs.ts index 457a8fc14..88cbb3ac3 100644 --- a/apps/tasks/src/jobs.ts +++ b/apps/tasks/src/jobs.ts @@ -3,9 +3,9 @@ import { smartHomeEntityStateJob } from "~/jobs/integrations/home-assistant"; import { analyticsJob } from "./jobs/analytics"; import { pingJob } from "./jobs/ping"; import { queuesJob } from "./jobs/queue"; -import { createJobGroup } from "./lib/cron-job/group"; +import { createCronJobGroup } from "./lib/jobs"; -export const jobs = createJobGroup({ +export const jobs = createCronJobGroup({ // Add your jobs here: analytics: analyticsJob, iconsUpdater: iconsUpdaterJob, diff --git a/apps/tasks/src/jobs/analytics.ts b/apps/tasks/src/jobs/analytics.ts index bbdd89927..b20cb8a52 100644 --- a/apps/tasks/src/jobs/analytics.ts +++ b/apps/tasks/src/jobs/analytics.ts @@ -1,14 +1,14 @@ import SuperJSON from "superjson"; import { sendServerAnalyticsAsync } from "@homarr/analytics"; +import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions"; import { db, eq } from "@homarr/db"; import { serverSettings } from "@homarr/db/schema/sqlite"; -import { EVERY_WEEK } from "~/lib/cron-job/constants"; -import { createCronJob } from "~/lib/cron-job/creator"; +import { createCronJob } from "~/lib/jobs"; import type { defaultServerSettings } from "../../../../packages/server-settings"; -export const analyticsJob = createCronJob(EVERY_WEEK, { +export const analyticsJob = createCronJob("analytics", EVERY_WEEK, { runOnStart: true, }).withCallback(async () => { const analyticSetting = await db.query.serverSettings.findFirst({ diff --git a/apps/tasks/src/jobs/icons-updater.ts b/apps/tasks/src/jobs/icons-updater.ts index 7fd274dd0..1bc109098 100644 --- a/apps/tasks/src/jobs/icons-updater.ts +++ b/apps/tasks/src/jobs/icons-updater.ts @@ -1,4 +1,5 @@ import { Stopwatch } from "@homarr/common"; +import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions"; import type { InferInsertModel } from "@homarr/db"; import { db, inArray } from "@homarr/db"; import { createId } from "@homarr/db/client"; @@ -6,10 +7,9 @@ import { iconRepositories, icons } from "@homarr/db/schema/sqlite"; import { fetchIconsAsync } from "@homarr/icons"; import { logger } from "@homarr/log"; -import { EVERY_WEEK } from "~/lib/cron-job/constants"; -import { createCronJob } from "~/lib/cron-job/creator"; +import { createCronJob } from "~/lib/jobs"; -export const iconsUpdaterJob = createCronJob(EVERY_WEEK, { +export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, { runOnStart: true, }).withCallback(async () => { logger.info("Updating icon repository cache..."); diff --git a/apps/tasks/src/jobs/integrations/home-assistant.ts b/apps/tasks/src/jobs/integrations/home-assistant.ts index 2b96715a9..d3ac3e040 100644 --- a/apps/tasks/src/jobs/integrations/home-assistant.ts +++ b/apps/tasks/src/jobs/integrations/home-assistant.ts @@ -1,6 +1,7 @@ import SuperJSON from "superjson"; import { decryptSecret } from "@homarr/common"; +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { db, eq } from "@homarr/db"; import { items } from "@homarr/db/schema/sqlite"; import { HomeAssistantIntegration } from "@homarr/integrations"; @@ -8,10 +9,9 @@ import { logger } from "@homarr/log"; import { homeAssistantEntityState } from "@homarr/redis"; import type { WidgetComponentProps } from "@homarr/widgets"; -import { EVERY_MINUTE } from "~/lib/cron-job/constants"; -import { createCronJob } from "~/lib/cron-job/creator"; +import { createCronJob } from "~/lib/jobs"; -export const smartHomeEntityStateJob = createCronJob(EVERY_MINUTE).withCallback(async () => { +export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => { const itemsForIntegration = await db.query.items.findMany({ where: eq(items.kind, "smartHome-entityState"), with: { diff --git a/apps/tasks/src/jobs/ping.ts b/apps/tasks/src/jobs/ping.ts index ab808f06c..90b7b96c0 100644 --- a/apps/tasks/src/jobs/ping.ts +++ b/apps/tasks/src/jobs/ping.ts @@ -1,11 +1,11 @@ +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { logger } from "@homarr/log"; import { sendPingRequestAsync } from "@homarr/ping"; import { pingChannel, pingUrlChannel } from "@homarr/redis"; -import { EVERY_MINUTE } from "~/lib/cron-job/constants"; -import { createCronJob } from "~/lib/cron-job/creator"; +import { createCronJob } from "~/lib/jobs"; -export const pingJob = createCronJob(EVERY_MINUTE).withCallback(async () => { +export const pingJob = createCronJob("ping", EVERY_MINUTE).withCallback(async () => { const urls = await pingUrlChannel.getAllAsync(); for (const url of new Set(urls)) { diff --git a/apps/tasks/src/jobs/queue.ts b/apps/tasks/src/jobs/queue.ts index 8eeb6f4e5..a0fc50bb0 100644 --- a/apps/tasks/src/jobs/queue.ts +++ b/apps/tasks/src/jobs/queue.ts @@ -1,8 +1,9 @@ -import { EVERY_MINUTE } from "../lib/cron-job/constants"; -import { createCronJob } from "../lib/cron-job/creator"; +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; + +import { createCronJob } from "~/lib/jobs"; import { queueWorkerAsync } from "../lib/queue/worker"; // This job processes queues, it runs every minute. -export const queuesJob = createCronJob(EVERY_MINUTE).withCallback(async () => { +export const queuesJob = createCronJob("queues", EVERY_MINUTE).withCallback(async () => { await queueWorkerAsync(); }); diff --git a/apps/tasks/src/lib/cron-job/constants.ts b/apps/tasks/src/lib/cron-job/constants.ts deleted file mode 100644 index 6f40f29cc..000000000 --- a/apps/tasks/src/lib/cron-job/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const EVERY_5_SECONDS = "*/5 * * * * *"; -export const EVERY_MINUTE = "* * * * *"; -export const EVERY_5_MINUTES = "*/5 * * * *"; -export const EVERY_10_MINUTES = "*/10 * * * *"; -export const EVERY_HOUR = "0 * * * *"; -export const EVERY_DAY = "0 0 * * */1"; -export const EVERY_WEEK = "0 0 * * 1"; diff --git a/apps/tasks/src/lib/cron-job/creator.ts b/apps/tasks/src/lib/cron-job/creator.ts deleted file mode 100644 index c59314485..000000000 --- a/apps/tasks/src/lib/cron-job/creator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import cron from "node-cron"; - -import type { MaybePromise } from "@homarr/common/types"; -import { logger } from "@homarr/log"; - -interface CreateCronJobOptions { - runOnStart?: boolean; -} - -export const createCronJob = (cronExpression: string, options: CreateCronJobOptions = { runOnStart: false }) => { - return { - withCallback: (callback: () => MaybePromise) => { - const catchingCallbackAsync = async () => { - try { - await callback(); - } catch (error) { - logger.error( - `apps/tasks/src/lib/cron-job/creator.ts: The callback of a cron job failed, expression ${cronExpression}, with error:`, - error, - ); - } - }; - - if (options.runOnStart) { - void catchingCallbackAsync(); - } - - const task = cron.schedule(cronExpression, () => void catchingCallbackAsync(), { - scheduled: false, - }); - return { - _expression: cronExpression, - _task: task, - }; - }, - }; -}; diff --git a/apps/tasks/src/lib/cron-job/group.ts b/apps/tasks/src/lib/cron-job/group.ts deleted file mode 100644 index 0854259df..000000000 --- a/apps/tasks/src/lib/cron-job/group.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { objectEntries } from "@homarr/common"; - -import type { createCronJob } from "./creator"; -import { jobRegistry } from "./registry"; - -type Jobs = Record["withCallback"]>>; - -export const createJobGroup = (jobs: TJobs) => { - for (const [name, job] of objectEntries(jobs)) { - if (typeof name !== "string") continue; - jobRegistry.set(name, { - name, - expression: job._expression, - active: false, - task: job._task, - }); - } - - return { - start: (name: keyof TJobs) => { - const job = jobRegistry.get(name as string); - if (!job) return; - job.active = true; - job.task.start(); - }, - startAll: () => { - for (const job of jobRegistry.values()) { - job.active = true; - job.task.start(); - } - }, - stop: (name: keyof TJobs) => { - const job = jobRegistry.get(name as string); - if (!job) return; - job.active = false; - job.task.stop(); - }, - stopAll: () => { - for (const job of jobRegistry.values()) { - job.active = false; - job.task.stop(); - } - }, - }; -}; diff --git a/apps/tasks/src/lib/cron-job/registry.ts b/apps/tasks/src/lib/cron-job/registry.ts deleted file mode 100644 index d78b4bc5a..000000000 --- a/apps/tasks/src/lib/cron-job/registry.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type cron from "node-cron"; - -interface Job { - name: string; - expression: string; - active: boolean; - task: cron.ScheduledTask; -} -export const jobRegistry = new Map(); diff --git a/apps/tasks/src/lib/jobs.ts b/apps/tasks/src/lib/jobs.ts new file mode 100644 index 000000000..7cebfc85e --- /dev/null +++ b/apps/tasks/src/lib/jobs.ts @@ -0,0 +1,21 @@ +import { createCronJobFunctions } from "@homarr/cron-jobs-core"; +import type { Logger } from "@homarr/cron-jobs-core/logger"; +import { logger } from "@homarr/log"; + +class WinstonCronJobLogger implements Logger { + logDebug(message: string) { + logger.debug(message); + } + + logInfo(message: string) { + logger.info(message); + } + + logError(error: unknown) { + logger.error(error); + } +} + +export const { createCronJob, createCronJobGroup } = createCronJobFunctions({ + logger: new WinstonCronJobLogger(), +}); diff --git a/apps/tasks/src/main.ts b/apps/tasks/src/main.ts index b428b019a..c876da2da 100644 --- a/apps/tasks/src/main.ts +++ b/apps/tasks/src/main.ts @@ -4,6 +4,7 @@ import "./undici-log-agent-override"; import { jobs } from "./jobs"; import { seedServerSettingsAsync } from "./seed-server-settings"; -jobs.startAll(); - -void seedServerSettingsAsync(); +void (async () => { + await jobs.startAllAsync(); + await seedServerSettingsAsync(); +})(); diff --git a/packages/cron-jobs-core/eslint.config.js b/packages/cron-jobs-core/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/cron-jobs-core/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/cron-jobs-core/index.ts b/packages/cron-jobs-core/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/cron-jobs-core/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/cron-jobs-core/package.json b/packages/cron-jobs-core/package.json new file mode 100644 index 000000000..c2bbf58f5 --- /dev/null +++ b/packages/cron-jobs-core/package.json @@ -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" +} diff --git a/packages/cron-jobs-core/src/creator.ts b/packages/cron-jobs-core/src/creator.ts new file mode 100644 index 000000000..882a2be68 --- /dev/null +++ b/packages/cron-jobs-core/src/creator.ts @@ -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 { + beforeCallback?: (name: TAllowedNames) => MaybePromise; + onCallbackSuccess?: (name: TAllowedNames) => MaybePromise; + onCallbackError?: (name: TAllowedNames, error: unknown) => MaybePromise; + timezone?: string; + logger: Logger; +} + +interface CreateCronJobOptions { + runOnStart?: boolean; +} + +const createCallback = ( + name: TName, + cronExpression: string, + options: CreateCronJobOptions, + creatorOptions: CreateCronJobCreatorOptions, +) => { + return (callback: () => MaybePromise) => { + 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 = 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 (!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 + : "Invalid cron expression"; + }; +}; diff --git a/packages/cron-jobs-core/src/expressions.ts b/packages/cron-jobs-core/src/expressions.ts new file mode 100644 index 000000000..d3b187190 --- /dev/null +++ b/packages/cron-jobs-core/src/expressions.ts @@ -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; diff --git a/packages/cron-jobs-core/src/group.ts b/packages/cron-jobs-core/src/group.ts new file mode 100644 index 000000000..772c4cce8 --- /dev/null +++ b/packages/cron-jobs-core/src/group.ts @@ -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 = { + [name in TAllowedNames]: ReturnType>; +}; + +export interface CreateCronJobGroupCreatorOptions { + logger: Logger; +} + +export const createJobGroupCreator = ( + options: CreateCronJobGroupCreatorOptions, +) => { + return >(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>>; + }, + }; + }; +}; diff --git a/packages/cron-jobs-core/src/index.ts b/packages/cron-jobs-core/src/index.ts new file mode 100644 index 000000000..08ed3e75e --- /dev/null +++ b/packages/cron-jobs-core/src/index.ts @@ -0,0 +1,15 @@ +import type { CreateCronJobCreatorOptions } from "./creator"; +import { createCronJobCreator } from "./creator"; +import { createJobGroupCreator } from "./group"; +import { ConsoleLogger } from "./logger"; + +export const createCronJobFunctions = ( + options: CreateCronJobCreatorOptions = { logger: new ConsoleLogger() }, +) => { + return { + createCronJob: createCronJobCreator(options), + createCronJobGroup: createJobGroupCreator({ + logger: options.logger, + }), + }; +}; diff --git a/packages/cron-jobs-core/src/logger.ts b/packages/cron-jobs-core/src/logger.ts new file mode 100644 index 000000000..2ff1a8030 --- /dev/null +++ b/packages/cron-jobs-core/src/logger.ts @@ -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); + } +} diff --git a/packages/cron-jobs-core/src/registry.ts b/packages/cron-jobs-core/src/registry.ts new file mode 100644 index 000000000..ab8b30a72 --- /dev/null +++ b/packages/cron-jobs-core/src/registry.ts @@ -0,0 +1,3 @@ +import type { JobCallback } from "./creator"; + +export const jobRegistry = new Map>>(); diff --git a/packages/cron-jobs-core/src/validation.ts b/packages/cron-jobs-core/src/validation.ts new file mode 100644 index 000000000..7c17ea5c3 --- /dev/null +++ b/packages/cron-jobs-core/src/validation.ts @@ -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 "" + ? true + : TMaybeCron extends `${CronChars}${infer Rest}` + ? ConstrainedCronString + : 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 `${infer inferedSecond} ${infer inferedMinute} ${infer inferedHour} ${infer inferedMonthDay} ${infer inferedMonth} ${infer inferedWeekDay}` + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? true + : false + : false + : false + : false + : false + : false + : TMaybeCron extends `${infer inferedMinute} ${infer inferedHour} ${infer inferedMonthDay} ${infer inferedMonth} ${infer inferedWeekDay}` + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString extends true + ? ConstrainedCronString 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 = (cron: T) => { + return cron as ValidateCron extends true ? T : void; +}; diff --git a/packages/cron-jobs-core/tsconfig.json b/packages/cron-jobs-core/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/cron-jobs-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b79aef743..f24a19dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@homarr/common': specifier: workspace:^0.1.0 version: link:../../packages/common + '@homarr/cron-jobs-core': + specifier: workspace:^0.1.0 + version: link:../../packages/cron-jobs-core '@homarr/db': specifier: workspace:^0.1.0 version: link:../../packages/db @@ -291,9 +294,6 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 - node-cron: - specifier: ^3.0.3 - version: 3.0.3 superjson: specifier: 2.2.1 version: 2.2.1 @@ -313,9 +313,6 @@ importers: '@types/node': specifier: ^20.14.8 version: 20.14.8 - '@types/node-cron': - specifier: ^3.0.11 - version: 3.0.11 dotenv-cli: specifier: ^7.4.2 version: 7.4.2 @@ -592,6 +589,34 @@ importers: specifier: ^5.5.2 version: 5.5.2 + packages/cron-jobs-core: + dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + node-cron: + specifier: ^3.0.3 + version: 3.0.3 + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 + eslint: + specifier: ^9.5.0 + version: 9.5.0 + typescript: + specifier: ^5.5.2 + version: 5.5.2 + packages/db: dependencies: '@auth/core': diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index 90ca0774f..0b52369f4 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -24,8 +24,8 @@ "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "eslint": "^9.4.0", - "typescript": "^5.4.5" + "eslint": "^9.5.0", + "typescript": "^5.5.2" }, "prettier": "@homarr/prettier-config" } \ No newline at end of file