diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 0cf59f1cbc..fe5023441d 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -22,7 +22,8 @@ ], "dependencies": { "@n8n/di": "workspace:*", - "reflect-metadata": "catalog:" + "reflect-metadata": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@n8n/typescript-config": "workspace:*" diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index af7e911877..ff0a1ba747 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -1,18 +1,21 @@ +import { z } from 'zod'; + import { Config, Env } from '../decorators'; -/** - * Whether to enable task runners and how to run them - * - internal: Task runners are run as a child process and launched by n8n - * - external: Task runners are run as a separate program not launched by n8n - */ -export type TaskRunnerMode = 'internal' | 'external'; +const runnerModeSchema = z.enum(['internal', 'external']); + +export type TaskRunnerMode = z.infer; @Config export class TaskRunnersConfig { @Env('N8N_RUNNERS_ENABLED') enabled: boolean = false; - @Env('N8N_RUNNERS_MODE') + /** + * Whether the task runner should run as a child process spawned by n8n (internal mode) + * or as a separate process launched outside n8n (external mode). + */ + @Env('N8N_RUNNERS_MODE', runnerModeSchema) mode: TaskRunnerMode = 'internal'; /** Endpoint which task runners connect to */ diff --git a/packages/@n8n/config/src/decorators.ts b/packages/@n8n/config/src/decorators.ts index d9da07740d..8f12e0bde8 100644 --- a/packages/@n8n/config/src/decorators.ts +++ b/packages/@n8n/config/src/decorators.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { Container, Service } from '@n8n/di'; import { readFileSync } from 'fs'; +import { z } from 'zod'; // eslint-disable-next-line @typescript-eslint/ban-types type Class = Function; @@ -10,6 +11,7 @@ type PropertyType = number | boolean | string | Class; interface PropertyMetadata { type: PropertyType; envName?: string; + schema?: z.ZodType; } const globalMetadata = new Map>(); @@ -33,14 +35,22 @@ export const Config: ClassDecorator = (ConfigClass: Class) => { throw new Error('Invalid config class: ' + ConfigClass.name); } - for (const [key, { type, envName }] of classMetadata) { + for (const [key, { type, envName, schema }] of classMetadata) { if (typeof type === 'function' && globalMetadata.has(type)) { config[key] = Container.get(type as Constructable); } else if (envName) { const value = readEnv(envName); if (value === undefined) continue; - if (type === Number) { + if (schema) { + const result = schema.safeParse(value); + if (result.error) { + console.warn( + `Invalid value for ${envName} - ${result.error.issues[0].message}. Falling back to default value.`, + ); + continue; + } + } else if (type === Number) { const parsed = Number(value); if (isNaN(parsed)) { console.warn(`Invalid number value for ${envName}: ${value}`); @@ -84,18 +94,21 @@ export const Nested: PropertyDecorator = (target: object, key: PropertyKey) => { }; export const Env = - (envName: string): PropertyDecorator => + (envName: string, schema?: PropertyMetadata['schema']): PropertyDecorator => (target: object, key: PropertyKey) => { const ConfigClass = target.constructor; const classMetadata = globalMetadata.get(ConfigClass) ?? new Map(); + const type = Reflect.getMetadata('design:type', target, key) as PropertyType; - if (type === Object) { + const isEnum = schema instanceof z.ZodEnum; + if (type === Object && !isEnum) { // eslint-disable-next-line n8n-local-rules/no-plain-errors throw new Error( `Invalid decorator metadata on key "${key as string}" on ${ConfigClass.name}\n Please use explicit typing on all config fields`, ); } - classMetadata.set(key, { type, envName }); + + classMetadata.set(key, { type, envName, schema }); globalMetadata.set(ConfigClass, classMetadata); }; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index c3da048c53..1028bbd97a 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -399,4 +399,20 @@ describe('GlobalConfig', () => { 'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd', ); }); + + describe('string unions', () => { + it('on invalid value, should warn and fall back to default value', () => { + process.env = { + N8N_RUNNERS_MODE: 'non-existing-mode', + }; + + const globalConfig = Container.get(GlobalConfig); + expect(globalConfig.taskRunners.mode).toEqual('internal'); + expect(consoleWarnMock).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid value for N8N_RUNNERS_MODE - Invalid enum value. Expected 'internal' | 'external', received 'non-existing-mode'. Falling back to default value.", + ), + ); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a126b914c4..eb92872e5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: reflect-metadata: specifier: 'catalog:' version: 0.2.2 + zod: + specifier: 'catalog:' + version: 3.24.1 devDependencies: '@n8n/typescript-config': specifier: workspace:*