diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 65b1c0b033..a24fb8f65f 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -74,7 +74,7 @@ export class ModuleRegistry { } for (const ModuleClass of this.moduleMetadata.getClasses()) { - const entities = Container.get(ModuleClass).entities?.(); + const entities = await Container.get(ModuleClass).entities?.(); if (!entities || entities.length === 0) continue; diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 0327ae2994..5e30b1a50b 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -22,7 +22,7 @@ export type ModuleSettings = Record; export interface ModuleInterface { init?(): Promise; - entities?(): EntityClass[]; + entities?(): Promise; settings?(): Promise; } diff --git a/packages/@n8n/eslint-config/src/rules/index.ts b/packages/@n8n/eslint-config/src/rules/index.ts index df6e25c751..2a28310cc5 100644 --- a/packages/@n8n/eslint-config/src/rules/index.ts +++ b/packages/@n8n/eslint-config/src/rules/index.ts @@ -10,6 +10,8 @@ import { NoDynamicImportTemplateRule } from './no-dynamic-import-template.js'; import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js'; import { NoTypeUnsafeEventEmitterRule } from './no-type-unsafe-event-emitter.js'; import { NoUntypedConfigClassFieldRule } from './no-untyped-config-class-field.js'; +import { NoTopLevelRelativeImportsInBackendModuleRule } from './no-top-level-relative-imports-in-backend-module.js'; +import { NoConstructorInBackendModuleRule } from './no-constructor-in-backend-module.js'; import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint'; export const rules = { @@ -25,4 +27,6 @@ export const rules = { 'misplaced-n8n-typeorm-import': MisplacedN8nTypeormImportRule, 'no-type-unsafe-event-emitter': NoTypeUnsafeEventEmitterRule, 'no-untyped-config-class-field': NoUntypedConfigClassFieldRule, + 'no-top-level-relative-imports-in-backend-module': NoTopLevelRelativeImportsInBackendModuleRule, + 'no-constructor-in-backend-module': NoConstructorInBackendModuleRule, } satisfies Record; diff --git a/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.test.ts b/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.test.ts new file mode 100644 index 0000000000..0470921c92 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.test.ts @@ -0,0 +1,75 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { NoConstructorInBackendModuleRule } from './no-constructor-in-backend-module.js'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-constructor-in-backend-module', NoConstructorInBackendModuleRule, { + valid: [ + { + code: ` +@BackendModule({ name: 'test' }) +export class TestModule { + async init() { + // initialization code + } +}`, + }, + { + code: ` +export class RegularClass { + constructor() { + // this is fine in regular classes + } +}`, + }, + { + code: ` +@SomeOtherDecorator() +export class OtherModule { + constructor() { + // this is fine with other decorators + } +}`, + }, + ], + invalid: [ + { + code: ` +@BackendModule({ name: 'test' }) +export class TestModule { + constructor() { + // this should be removed + } +}`, + errors: [{ messageId: 'noConstructorInBackendModule' }], + output: ` +@BackendModule({ name: 'test' }) +export class TestModule { + +}`, + }, + { + code: ` +@BackendModule({ name: 'insights' }) +export class InsightsModule { + constructor(private service: SomeService) { + this.service = service; + } + + async init() { + // other code + } +}`, + errors: [{ messageId: 'noConstructorInBackendModule' }], + output: ` +@BackendModule({ name: 'insights' }) +export class InsightsModule { + + + async init() { + // other code + } +}`, + }, + ], +}); diff --git a/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.ts b/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.ts new file mode 100644 index 0000000000..0ed2c55b1a --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-constructor-in-backend-module.ts @@ -0,0 +1,41 @@ +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +export const NoConstructorInBackendModuleRule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: + 'A class decorated with `@BackendModule` must not have a constructor. This ensures that module dependencies are loaded only when the module is used.', + }, + messages: { + noConstructorInBackendModule: + 'Remove the constructor from the class decorated with `@BackendModule`.', + }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'ClassDeclaration MethodDefinition[kind="constructor"]'(node: TSESTree.MethodDefinition) { + const classDeclaration = node.parent?.parent as TSESTree.ClassDeclaration; + + const isBackendModule = + classDeclaration.decorators?.some( + (d) => + d.expression.type === 'CallExpression' && + d.expression.callee.type === 'Identifier' && + d.expression.callee.name === 'BackendModule', + ) ?? false; + + if (isBackendModule) { + context.report({ + node, + messageId: 'noConstructorInBackendModule', + fix: (fixer) => fixer.remove(node), + }); + } + }, + }; + }, +}); diff --git a/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.test.ts b/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.test.ts new file mode 100644 index 0000000000..10263e9e49 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.test.ts @@ -0,0 +1,54 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { NoTopLevelRelativeImportsInBackendModuleRule } from './no-top-level-relative-imports-in-backend-module.js'; + +const ruleTester = new RuleTester(); + +ruleTester.run( + 'no-top-level-relative-imports-in-backend-module', + NoTopLevelRelativeImportsInBackendModuleRule, + { + valid: [ + { + code: ` +import { Container } from '@n8n/di'; +import { InstanceSettings } from 'n8n-core'; + +@BackendModule({ name: 'test' }) +export class TestModule { + async init() { + const { LocalService } = await import('./local.service'); + } +}`, + }, + ], + invalid: [ + { + code: ` +import { Container } from '@n8n/di'; +import { LocalService } from './local.service'; + +@BackendModule({ name: 'test' }) +export class TestModule { + async init() { + // code + } +}`, + errors: [{ messageId: 'placeInsideInit' }], + }, + { + code: ` +import { BackendModule } from '@n8n/decorators'; +import { helper } from './helper'; +import { config } from './config'; + +@BackendModule({ name: 'test' }) +export class TestModule { + async init() { + // code + } +}`, + errors: [{ messageId: 'placeInsideInit' }, { messageId: 'placeInsideInit' }], + }, + ], + }, +); diff --git a/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.ts b/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.ts new file mode 100644 index 0000000000..bf9b0247b7 --- /dev/null +++ b/packages/@n8n/eslint-config/src/rules/no-top-level-relative-imports-in-backend-module.ts @@ -0,0 +1,26 @@ +import { ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +export const NoTopLevelRelativeImportsInBackendModuleRule = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: + 'Relative imports in `.module.ts` files must be placed inside the `init` method. This ensures that module imports are loaded only when the module is used.', + }, + messages: { + placeInsideInit: + "Place this relative import inside the `init` method, using `await import('./path')` syntax.", + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'Program > ImportDeclaration'(node: TSESTree.ImportDeclaration) { + if (node.source.value.startsWith('.')) { + context.report({ node, messageId: 'placeInsideInit' }); + } + }, + }; + }, +}); diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index a262244213..2a626a67dd 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -99,4 +99,12 @@ export default defineConfig( 'n8n-local-rules/no-uncaught-json-parse': 'warn', }, }, + { + files: ['**/*.module.ts'], + + rules: { + 'n8n-local-rules/no-top-level-relative-imports-in-backend-module': 'error', + 'n8n-local-rules/no-constructor-in-backend-module': 'error', + }, + }, ); diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index 43a1878184..3528c39f8f 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -1,23 +1,16 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import './insights.controller'; import { InstanceSettings } from 'n8n-core'; -import { InsightsByPeriod } from './database/entities/insights-by-period'; -import { InsightsMetadata } from './database/entities/insights-metadata'; -import { InsightsRaw } from './database/entities/insights-raw'; - @BackendModule({ name: 'insights' }) export class InsightsModule implements ModuleInterface { async init() { - const { instanceType } = Container.get(InstanceSettings); - /** * Only main- and webhook-type instances collect insights because * only they are informed of finished workflow executions. */ - if (instanceType === 'worker') return; + if (Container.get(InstanceSettings).instanceType === 'worker') return; await import('./insights.controller'); @@ -25,12 +18,17 @@ export class InsightsModule implements ModuleInterface { Container.get(InsightsService).startTimers(); } - entities() { + async entities() { + const { InsightsByPeriod } = await import('./database/entities/insights-by-period'); + const { InsightsMetadata } = await import('./database/entities/insights-metadata'); + const { InsightsRaw } = await import('./database/entities/insights-raw'); + return [InsightsByPeriod, InsightsMetadata, InsightsRaw]; } async settings() { const { InsightsService } = await import('./insights.service'); + return Container.get(InsightsService).settings(); } }