From e8edc203b6c862141668b043ef4232c22b8fae24 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 1 May 2026 16:21:38 +0200 Subject: [PATCH] fix: migrate from node-cron to croner (#5407) --- apps/tasks/package.json | 1 + apps/tasks/src/job-manager.ts | 39 ++++++++----- packages/api/src/router/cron-jobs.ts | 4 +- packages/cron-job-api/package.json | 2 - packages/cron-job-api/src/index.ts | 13 ++--- packages/cron-jobs-core/package.json | 3 +- packages/cron-jobs-core/src/creator.ts | 23 ++++---- packages/cron-jobs-core/src/group.ts | 22 ++++---- pnpm-lock.yaml | 78 ++++++-------------------- pnpm-workspace.yaml | 3 +- 10 files changed, 72 insertions(+), 116 deletions(-) diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 3cb918df5..fc1f9c50b 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -37,6 +37,7 @@ "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", + "croner": "catalog:", "dayjs": "catalog:", "dotenv": "catalog:", "fastify": "catalog:", diff --git a/apps/tasks/src/job-manager.ts b/apps/tasks/src/job-manager.ts index f4ec8bf97..6ba3d637e 100644 --- a/apps/tasks/src/job-manager.ts +++ b/apps/tasks/src/job-manager.ts @@ -1,4 +1,4 @@ -import { schedule, validate as validateCron } from "node-cron"; +import { Cron } from "croner"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IJobManager } from "@homarr/cron-job-api"; @@ -21,28 +21,31 @@ export class JobManager implements IJobManager { public async triggerAsync(name: JobGroupKeys): Promise { await this.jobGroup.runManuallyAsync(name); } - public async stopAsync(name: JobGroupKeys): Promise { - await this.jobGroup.stopAsync(name); + public stop(name: JobGroupKeys): void { + this.jobGroup.stop(name); } public async updateIntervalAsync(name: JobGroupKeys, cron: string): Promise { logger.info("Updating cron job interval", { name, expression: cron }); const job = this.jobGroup.getJobRegistry().get(name); if (!job) throw new Error(`Job ${name} not found`); - if (!validateCron(cron)) { - throw new Error(`Invalid cron expression: ${cron}`); - } if (job.preventCustomInterval && cron !== job.cronExpression) { throw new Error(`Custom cron expressions are not allowed for job ${name}`); } - await this.updateConfigurationAsync(name, { cronExpression: cron }); - await this.jobGroup.getTask(name)?.destroy(); + const updatedConfig = await this.updateConfigurationAsync(name, { cronExpression: cron }); + this.jobGroup.getTask(name)?.stop(); this.jobGroup.setTask( name, - schedule(cron, () => void job.executeAsync(), { - name, - }), + new Cron( + cron, + { + name, + timezone: job.timezone, + paused: !updatedConfig.isEnabled, + }, + () => void job.executeAsync(), + ), ); logger.info("Cron job interval updated", { name, expression: cron }); } @@ -52,7 +55,7 @@ export class JobManager implements IJobManager { if (!job) throw new Error(`Job ${name} not found`); await this.updateConfigurationAsync(name, { isEnabled: false }); - await this.jobGroup.stopAsync(name); + this.jobGroup.stop(name); logger.info("Cron job disabled", { name }); } public async enableAsync(name: JobGroupKeys): Promise { @@ -86,21 +89,27 @@ export class JobManager implements IJobManager { name, configuration: JSON.stringify(configuration), }); - return; + return { + name, + cronExpression: configuration.cronExpression ?? existingConfig.cronExpression, + isEnabled: configuration.isEnabled ?? existingConfig.isEnabled, + }; } const job = this.jobGroup.getJobRegistry().get(name); if (!job) throw new Error(`Job ${name} not found`); - await this.db.insert(cronJobConfigurations).values({ + const updatedConfig = { name, cronExpression: configuration.cronExpression ?? job.cronExpression, isEnabled: configuration.isEnabled ?? true, - }); + }; + await this.db.insert(cronJobConfigurations).values(updatedConfig); logger.debug("Cron job configuration updated", { name, configuration: JSON.stringify(configuration), }); + return updatedConfig; } public async getAllAsync(): Promise< diff --git a/packages/api/src/router/cron-jobs.ts b/packages/api/src/router/cron-jobs.ts index 5428ac374..14bcb2019 100644 --- a/packages/api/src/router/cron-jobs.ts +++ b/packages/api/src/router/cron-jobs.ts @@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable"; import z from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { cronExpressionSchema, jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api"; +import { jobGroupKeys, jobNameSchema } from "@homarr/cron-job-api"; import { cronJobApi } from "@homarr/cron-job-api/client"; import type { TaskStatus } from "@homarr/cron-job-status"; import { createCronJobStatusChannel } from "@homarr/cron-job-status"; @@ -35,7 +35,7 @@ export const cronJobsRouter = createTRPCRouter({ .input( z.object({ name: jobNameSchema, - cron: cronExpressionSchema, + cron: z.string(), }), ) .mutation(async ({ input }) => { diff --git a/packages/cron-job-api/package.json b/packages/cron-job-api/package.json index 09bde876b..db59b1326 100644 --- a/packages/cron-job-api/package.json +++ b/packages/cron-job-api/package.json @@ -32,7 +32,6 @@ "@trpc/client": "catalog:", "@trpc/server": "catalog:", "@trpc/tanstack-react-query": "catalog:", - "node-cron": "catalog:", "react": "catalog:", "zod": "catalog:" }, @@ -40,7 +39,6 @@ "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "@types/node-cron": "catalog:", "@types/react": "catalog:", "eslint": "catalog:", "typescript": "catalog:" diff --git a/packages/cron-job-api/src/index.ts b/packages/cron-job-api/src/index.ts index 270a51242..220e70d68 100644 --- a/packages/cron-job-api/src/index.ts +++ b/packages/cron-job-api/src/index.ts @@ -1,5 +1,4 @@ import { initTRPC, TRPCError } from "@trpc/server"; -import { validate } from "node-cron"; import { z } from "zod/v4"; import type { JobGroupKeys } from "@homarr/cron-jobs"; @@ -13,7 +12,7 @@ export const jobNameSchema = z.enum(jobGroup.getKeys()); export interface IJobManager { startAsync(name: JobGroupKeys): Promise; triggerAsync(name: JobGroupKeys): Promise; - stopAsync(name: JobGroupKeys): Promise; + stop(name: JobGroupKeys): void; updateIntervalAsync(name: JobGroupKeys, cron: string): Promise; disableAsync(name: JobGroupKeys): Promise; enableAsync(name: JobGroupKeys): Promise; @@ -52,10 +51,6 @@ const apiKeyProcedure = t.procedure.use(({ ctx, next }) => { }); }); -export const cronExpressionSchema = z.string().refine((expression) => validate(expression), { - error: "Invalid cron expression", -}); - export const jobRouter = createTrpcRouter({ start: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => { await ctx.manager.startAsync(input); @@ -63,14 +58,14 @@ export const jobRouter = createTrpcRouter({ trigger: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => { await ctx.manager.triggerAsync(input); }), - stop: apiKeyProcedure.input(jobNameSchema).mutation(async ({ input, ctx }) => { - await ctx.manager.stopAsync(input); + stop: apiKeyProcedure.input(jobNameSchema).mutation(({ input, ctx }) => { + ctx.manager.stop(input); }), updateInterval: apiKeyProcedure .input( z.object({ name: jobNameSchema, - cron: cronExpressionSchema, + cron: z.string(), }), ) .mutation(async ({ input, ctx }) => { diff --git a/packages/cron-jobs-core/package.json b/packages/cron-jobs-core/package.json index 02570d692..71704bdfc 100644 --- a/packages/cron-jobs-core/package.json +++ b/packages/cron-jobs-core/package.json @@ -27,13 +27,12 @@ "@homarr/common": "workspace:^0.1.0", "@homarr/core": "workspace:^", "@homarr/db": "workspace:^0.1.0", - "node-cron": "catalog:" + "croner": "catalog:" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "@types/node-cron": "catalog:", "eslint": "catalog:", "typescript": "catalog:" } diff --git a/packages/cron-jobs-core/src/creator.ts b/packages/cron-jobs-core/src/creator.ts index 4f7a9310f..f693a054e 100644 --- a/packages/cron-jobs-core/src/creator.ts +++ b/packages/cron-jobs-core/src/creator.ts @@ -1,4 +1,4 @@ -import { createTask, validate } from "node-cron"; +import { Cron } from "croner"; import { Stopwatch } from "@homarr/common"; import type { MaybePromise } from "@homarr/common/types"; @@ -83,10 +83,15 @@ const createCallback = void catchingCallbackAsync(), { - name, - timezone: creatorOptions.timezone, - }); + const scheduledTask = new Cron( + cronExpression, + { + name, + timezone: creatorOptions.timezone, + paused: true, + }, + () => void catchingCallbackAsync(), + ); creatorOptions.logger.logDebug("The scheduled task for cron job was created", { name, cronExpression: defaultCronExpression, @@ -116,6 +121,7 @@ const createCallback = ( 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, diff --git a/packages/cron-jobs-core/src/group.ts b/packages/cron-jobs-core/src/group.ts index 77cd6057d..cfb024a9a 100644 --- a/packages/cron-jobs-core/src/group.ts +++ b/packages/cron-jobs-core/src/group.ts @@ -1,4 +1,4 @@ -import type { ScheduledTask } from "node-cron"; +import type { Cron } from "croner"; import { objectEntries, objectKeys } from "@homarr/common"; import { db } from "@homarr/db"; @@ -34,7 +34,7 @@ export const createJobGroupCreator = ( }); } - const tasks = new Map(); + const tasks = new Map(); return { initializeAsync: async () => { @@ -62,7 +62,7 @@ export const createJobGroupCreator = ( name: job.name, }); await job.onStartAsync(); - await tasks.get(name as string)?.start(); + tasks.get(name as string)?.resume(); }, startAllAsync: async () => { for (const job of jobRegistry.values()) { @@ -74,7 +74,7 @@ export const createJobGroupCreator = ( name: job.name, }); await job.onStartAsync(); - await tasks.get(job.name)?.start(); + tasks.get(job.name)?.resume(); } }, runManuallyAsync: async (name: keyof TJobs) => { @@ -87,23 +87,23 @@ export const createJobGroupCreator = ( options.logger.logInfo("Running schedule cron job manually.", { name: job.name, }); - await tasks.get(name as string)?.execute(); + await tasks.get(name as string)?.trigger(); }, - stopAsync: async (name: keyof TJobs) => { + stop: (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(); + tasks.get(name as string)?.pause(); }, - stopAllAsync: async () => { + stopAll: () => { for (const job of jobRegistry.values()) { options.logger.logInfo("Stopping schedule of cron job.", { name: job.name, }); - await tasks.get(job.name)?.stop(); + tasks.get(job.name)?.pause(); } }, getJobRegistry() { @@ -112,8 +112,8 @@ export const createJobGroupCreator = ( getTask(name: keyof TJobs) { return tasks.get(name as string) ?? null; }, - setTask(name: keyof TJobs, task: ScheduledTask) { - tasks.set(name as string, task); + setTask(name: keyof TJobs, cron: Cron) { + tasks.set(name as string, cron); }, getKeys() { return objectKeys(jobs); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1284a9dc2..fe7c71537 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,9 +240,6 @@ catalogs: '@types/node': specifier: ^24.12.2 version: 24.12.2 - '@types/node-cron': - specifier: ^3.0.11 - version: 3.0.11 '@types/node-unifi': specifier: ^2.5.1 version: 2.5.1 @@ -321,6 +318,9 @@ catalogs: cookies: specifier: ^0.9.1 version: 0.9.1 + croner: + specifier: 10.0.1 + version: 10.0.1 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -435,9 +435,6 @@ catalogs: next-intl: specifier: 4.9.1 version: 4.9.1 - node-cron: - specifier: ^4.2.1 - version: 4.2.1 node-loader: specifier: ^2.1.0 version: 2.1.0 @@ -986,6 +983,9 @@ importers: '@homarr/widgets': specifier: workspace:^0.1.0 version: link:../../packages/widgets + croner: + specifier: 'catalog:' + version: 10.0.1 dayjs: specifier: 'catalog:' version: 1.11.20 @@ -1526,9 +1526,6 @@ importers: '@trpc/tanstack-react-query': specifier: 'catalog:' version: 11.16.0(@tanstack/react-query@5.100.5(react@19.2.5))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.16.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3) - node-cron: - specifier: 'catalog:' - version: 4.2.1 react: specifier: 'catalog:' version: 19.2.5 @@ -1545,9 +1542,6 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript - '@types/node-cron': - specifier: 'catalog:' - version: 3.0.11 '@types/react': specifier: 'catalog:' version: 19.2.14 @@ -1661,9 +1655,9 @@ importers: '@homarr/db': specifier: workspace:^0.1.0 version: link:../db - node-cron: + croner: specifier: 'catalog:' - version: 4.2.1 + version: 10.0.1 devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1674,9 +1668,6 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript - '@types/node-cron': - specifier: 'catalog:' - version: 3.0.11 eslint: specifier: 'catalog:' version: 9.39.4 @@ -4383,7 +4374,6 @@ packages: '@million/lint@1.0.14': resolution: {integrity: sha512-u6/kglVwZRu5+GMmtkNlGLqJVkgTl0TtM+hLa9rBg7pldx+5NG5bk45NvL37uZmAr2Xfa1C6qHb7GrFwfP372g==} - deprecated: This package is no longer maintained hasBin: true '@ndaidong/bellajs@12.0.1': @@ -5688,9 +5678,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-cron@3.0.11': - resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -6635,6 +6622,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -6886,9 +6877,6 @@ packages: dom-walk@0.1.2: resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} - dompurify@3.3.3: - resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} - dompurify@3.4.1: resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==} @@ -7403,10 +7391,6 @@ packages: fast-xml-builder@1.1.5: resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} - fast-xml-parser@5.7.1: - resolution: {integrity: sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==} - hasBin: true - fast-xml-parser@5.7.2: resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} hasBin: true @@ -7503,15 +7487,6 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -8793,10 +8768,6 @@ packages: resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==} engines: {node: ^18 || ^20 || >= 21} - node-cron@4.2.1: - resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} - engines: {node: '>=6.0.0'} - node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -11867,7 +11838,7 @@ snapshots: dependencies: '@ndaidong/bellajs': 12.0.1 cross-fetch: 4.1.0 - fast-xml-parser: 5.7.1 + fast-xml-parser: 5.7.2 html-entities: 2.6.0 transitivePeerDependencies: - encoding @@ -14021,8 +13992,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node-cron@3.0.11': {} - '@types/node-fetch@2.6.13': dependencies: '@types/node': 24.12.2 @@ -14675,7 +14644,7 @@ snapshots: axios@1.9.0: dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.16.0 form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -15103,6 +15072,8 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.5.2 + croner@10.0.1: {} + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -15340,10 +15311,6 @@ snapshots: dom-walk@0.1.2: {} - dompurify@3.3.3: - optionalDependencies: - '@types/trusted-types': 2.0.7 - dompurify@3.4.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -15991,13 +15958,6 @@ snapshots: dependencies: path-expression-matcher: 1.5.0 - fast-xml-parser@5.7.1: - dependencies: - '@nodable/entities': 2.1.0 - fast-xml-builder: 1.1.5 - path-expression-matcher: 1.5.0 - strnum: 2.2.3 - fast-xml-parser@5.7.2: dependencies: '@nodable/entities': 2.1.0 @@ -16102,8 +16062,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.9: {} - follow-redirects@1.16.0: {} for-each@0.3.5: @@ -17512,8 +17470,6 @@ snapshots: node-addon-api@8.3.1: {} - node-cron@4.2.1: {} - node-domexception@1.0.0: {} node-emoji@2.2.0: @@ -19220,7 +19176,7 @@ snapshots: classnames: 2.5.1 css.escape: 1.5.1 deep-extend: 0.6.0 - dompurify: 3.3.3 + dompurify: 3.4.1 ieee754: 1.2.1 immutable: 3.8.2 js-file-download: 0.4.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 869cf5397..e70b39a68 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -86,7 +86,6 @@ catalog: "@types/css-modules": ^1.0.5 "@types/dockerode": ^4.0.1 "@types/node": ^24.12.2 - "@types/node-cron": ^3.0.11 "@types/node-unifi": ^2.5.1 "@types/pg": ^8.20.0 "@types/prismjs": ^1.26.6 @@ -113,6 +112,7 @@ catalog: conventional-changelog-conventionalcommits: ^9.3.1 cookie: 1.1.1 cookies: ^0.9.1 + croner: 10.0.1 cross-env: ^10.1.0 crypto-js: ^4.2.0 dayjs: ^1.11.20 @@ -151,7 +151,6 @@ catalog: next: 16.2.4 next-auth: 5.0.0-beta.31 next-intl: 4.9.1 - node-cron: ^4.2.1 node-loader: ^2.1.0 octokit: ^5.0.5 pg: ^8.20.0