refactor(core): Lint to enforce lazyloading in modules (#16843)

Co-authored-by: Juuso Tapaninen <juuso@n8n.io>
This commit is contained in:
Iván Ovejero
2025-07-01 10:45:30 +02:00
committed by GitHub
parent 4abb6a6aa1
commit 06f49c294a
9 changed files with 217 additions and 11 deletions

View File

@@ -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;

View File

@@ -22,7 +22,7 @@ export type ModuleSettings = Record<string, unknown>;
export interface ModuleInterface {
init?(): Promise<void>;
entities?(): EntityClass[];
entities?(): Promise<EntityClass[]>;
settings?(): Promise<ModuleSettings>;
}

View File

@@ -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<string, AnyRuleModule>;

View File

@@ -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
}
}`,
},
],
});

View File

@@ -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),
});
}
},
};
},
});

View File

@@ -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' }],
},
],
},
);

View File

@@ -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' });
}
},
};
},
});

View File

@@ -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',
},
},
);

View File

@@ -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();
}
}