refactor(core): Move module logic to @n8n/backend-common (#16528)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Iván Ovejero
2025-06-20 18:55:07 +02:00
committed by GitHub
parent 1573ae6352
commit 37efd209c9
18 changed files with 214 additions and 241 deletions

View File

@@ -23,6 +23,7 @@
"dependencies": {
"@n8n/config": "workspace:^",
"@n8n/constants": "workspace:^",
"@n8n/decorators": "workspace:^",
"@n8n/di": "workspace:^",
"callsites": "catalog:",
"n8n-workflow": "workspace:^",

View File

@@ -4,3 +4,5 @@ export * from './types';
export { inDevelopment, inProduction, inTest } from './environment';
export { isObjectLiteral } from './utils/is-object-literal';
export { Logger } from './logging/logger';
export { ModuleRegistry } from './modules/module-registry';
export { ModulesConfig, ModuleName } from './modules/modules.config';

View File

@@ -1,8 +1,8 @@
import type { LicenseState } from '@n8n/backend-common';
import type { ModuleInterface, ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { LicenseState } from '../../license-state';
import { ModuleConfusionError } from '../errors/module-confusion.error';
import { ModuleRegistry } from '../module-registry';
import { MODULE_NAMES } from '../modules.config';
@@ -43,7 +43,7 @@ describe('loadModules', () => {
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.loadModules([]);
@@ -57,7 +57,7 @@ describe('loadModules', () => {
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.loadModules([]);
@@ -75,7 +75,7 @@ describe('initModules', () => {
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules();
@@ -94,7 +94,7 @@ describe('initModules', () => {
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(true) });
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), licenseState, mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
await moduleRegistry.initModules();
@@ -113,7 +113,7 @@ describe('initModules', () => {
const licenseState = mock<LicenseState>({ isLicensed: jest.fn().mockReturnValue(false) });
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), licenseState, mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock());
await moduleRegistry.initModules();
@@ -130,7 +130,7 @@ describe('initModules', () => {
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
await moduleRegistry.initModules();
@@ -150,7 +150,7 @@ describe('initModules', () => {
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules();
@@ -174,12 +174,13 @@ describe('initModules', () => {
});
Container.get = jest.fn().mockReturnValue(ModuleClass);
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock());
const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock());
// ACT
await moduleRegistry.initModules();
// ASSERT
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(moduleRegistry.isActive(moduleName as any)).toBe(true);
expect(moduleRegistry.getActiveModules()).toEqual([moduleName]);
});

View File

@@ -0,0 +1,113 @@
import { ModuleMetadata } from '@n8n/decorators';
import type { EntityClass, ModuleSettings } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import path from 'path';
import { MissingModuleError } from './errors/missing-module.error';
import { ModuleConfusionError } from './errors/module-confusion.error';
import { ModulesConfig } from './modules.config';
import type { ModuleName } from './modules.config';
import { LicenseState } from '../license-state';
import { Logger } from '../logging/logger';
@Service()
export class ModuleRegistry {
readonly entities: EntityClass[] = [];
readonly settings: Map<string, ModuleSettings> = new Map();
constructor(
private readonly moduleMetadata: ModuleMetadata,
private readonly licenseState: LicenseState,
private readonly logger: Logger,
private readonly modulesConfig: ModulesConfig,
) {}
private readonly defaultModules: ModuleName[] = ['insights', 'external-secrets'];
private readonly activeModules: string[] = [];
get eligibleModules(): ModuleName[] {
const { enabledModules, disabledModules } = this.modulesConfig;
const doubleListed = enabledModules.filter((m) => disabledModules.includes(m));
if (doubleListed.length > 0) throw new ModuleConfusionError(doubleListed);
const defaultPlusEnabled = [...new Set([...this.defaultModules, ...enabledModules])];
return defaultPlusEnabled.filter((m) => !disabledModules.includes(m));
}
/**
* Loads [module name].module.ts for each eligible module.
* This only registers the database entities for module and should be done
* before instantiating the datasource.
*
* This will not register routes or do any other kind of module related
* setup.
*/
async loadModules(modules?: ModuleName[]) {
const moduleDir = process.env.NODE_ENV === 'test' ? 'src' : 'dist';
const modulesDir = path.resolve(__dirname, `../../../../cli/${moduleDir}/modules`);
for (const moduleName of modules ?? this.eligibleModules) {
try {
await import(`${modulesDir}/${moduleName}/${moduleName}.module`);
} catch {
try {
await import(`${modulesDir}/${moduleName}.ee/${moduleName}.module`);
} catch (error) {
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
}
}
}
for (const ModuleClass of this.moduleMetadata.getClasses()) {
const entities = Container.get(ModuleClass).entities?.();
if (!entities || entities.length === 0) continue;
this.entities.push(...entities);
}
}
/**
* Calls `init` on each eligible module.
*
* This will do things like registering routes, setup timers or other module
* specific setup.
*
* `ModuleRegistry.loadModules` must have been called before.
*/
async initModules() {
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
const { licenseFlag, class: ModuleClass } = moduleEntry;
if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) {
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
continue;
}
await Container.get(ModuleClass).init?.();
const moduleSettings = await Container.get(ModuleClass).settings?.();
if (!moduleSettings) continue;
this.settings.set(moduleName, moduleSettings);
this.logger.debug(`Initialized module "${moduleName}"`);
this.activeModules.push(moduleName);
}
}
isActive(moduleName: ModuleName) {
return this.activeModules.includes(moduleName);
}
getActiveModules() {
return this.activeModules;
}
}

View File

@@ -1,5 +1,12 @@
import 'reflect-metadata';
import { inDevelopment, inTest, LicenseState, Logger } from '@n8n/backend-common';
import {
inDevelopment,
inTest,
LicenseState,
Logger,
ModuleRegistry,
ModulesConfig,
} from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { Container } from '@n8n/di';
@@ -27,8 +34,6 @@ import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
import { ExternalHooks } from '@/external-hooks';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { ModuleRegistry } from '@/modules/module-registry';
import { ModulesConfig } from '@/modules/modules.config';
import { NodeTypes } from '@/node-types';
import { PostHogClient } from '@/posthog';
import { ShutdownService } from '@/shutdown/shutdown.service';

View File

@@ -1,3 +1,4 @@
import type { ModuleRegistry } from '@n8n/backend-common';
import type { GlobalConfig, InstanceSettingsConfig } from '@n8n/config';
import { mysqlMigrations } from '@n8n/db';
import { postgresMigrations } from '@n8n/db';
@@ -5,8 +6,6 @@ import { sqliteMigrations } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import path from 'path';
import type { ModuleRegistry } from '@/modules/module-registry';
import { DbConnectionOptions } from '../db-connection-options';
describe('DbConnectionOptions', () => {

View File

@@ -1,3 +1,4 @@
import { ModuleRegistry } from '@n8n/backend-common';
import { DatabaseConfig, InstanceSettingsConfig } from '@n8n/config';
import {
entities,
@@ -16,8 +17,6 @@ import { UserError } from 'n8n-workflow';
import path from 'path';
import type { TlsOptions } from 'tls';
import { ModuleRegistry } from '@/modules/module-registry';
@Service()
export class DbConnectionOptions {
constructor(

View File

@@ -1,6 +1,7 @@
import { Logger } from '@n8n/backend-common';
import { ExecutionRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { LifecycleMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { stringify } from 'flatted';
import { ErrorReporter, InstanceSettings, ExecutionLifecycleHooks } from 'n8n-core';
import type {
@@ -11,7 +12,6 @@ import type {
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { ModuleRegistry } from '@/modules/module-registry';
import { Push } from '@/push';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { isWorkflowIdValid } from '@/utils';
@@ -28,6 +28,72 @@ import {
} from './shared/shared-hook-functions';
import { type ExecutionSaveSettings, toSaveSettings } from './to-save-settings';
@Service()
class ModulesHooksRegistry {
addHooks(hooks: ExecutionLifecycleHooks) {
const handlers = Container.get(LifecycleMetadata).getHandlers();
for (const { handlerClass, methodName, eventName } of handlers) {
const instance = Container.get(handlerClass);
switch (eventName) {
case 'workflowExecuteAfter':
hooks.addHandler(eventName, async function (runData, newStaticData) {
const context = {
type: 'workflowExecuteAfter',
workflow: this.workflowData,
runData,
newStaticData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
});
break;
case 'nodeExecuteBefore':
hooks.addHandler(eventName, async function (nodeName, taskData) {
const context = {
type: 'nodeExecuteBefore',
workflow: this.workflowData,
nodeName,
taskData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
});
break;
case 'nodeExecuteAfter':
hooks.addHandler(eventName, async function (nodeName, taskData, executionData) {
const context = {
type: 'nodeExecuteAfter',
workflow: this.workflowData,
nodeName,
taskData,
executionData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
});
break;
case 'workflowExecuteBefore':
hooks.addHandler(eventName, async function (workflowInstance, executionData) {
const context = {
type: 'workflowExecuteBefore',
workflow: this.workflowData,
workflowInstance,
executionData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
});
break;
}
}
}
}
type HooksSetupParameters = {
saveSettings: ExecutionSaveSettings;
pushRef?: string;
@@ -425,7 +491,7 @@ export function getLifecycleHooksForScalingWorker(
hookFunctionsPush(hooks, optionalParameters);
}
Container.get(ModuleRegistry).registerLifecycleHooks(hooks);
Container.get(ModulesHooksRegistry).addHooks(hooks);
return hooks;
}
@@ -487,7 +553,7 @@ export function getLifecycleHooksForScalingMain(
hooks.handlers.nodeExecuteBefore = [];
hooks.handlers.nodeExecuteAfter = [];
Container.get(ModuleRegistry).registerLifecycleHooks(hooks);
Container.get(ModulesHooksRegistry).addHooks(hooks);
return hooks;
}
@@ -511,6 +577,6 @@ export function getLifecycleHooksForRegularMain(
hookFunctionsSaveProgress(hooks, optionalParameters);
hookFunctionsStatistics(hooks);
hookFunctionsExternalHooks(hooks);
Container.get(ModuleRegistry).registerLifecycleHooks(hooks);
Container.get(ModulesHooksRegistry).addHooks(hooks);
return hooks;
}

View File

@@ -1,214 +0,0 @@
import { LicenseState, Logger } from '@n8n/backend-common';
import { LifecycleMetadata, ModuleMetadata } from '@n8n/decorators';
import type { LifecycleContext, EntityClass, ModuleSettings } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import type { ExecutionLifecycleHooks } from 'n8n-core';
import type {
IDataObject,
IRun,
IRunExecutionData,
ITaskData,
ITaskStartedData,
IWorkflowBase,
Workflow,
} from 'n8n-workflow';
import { MissingModuleError } from './errors/missing-module.error';
import { ModuleConfusionError } from './errors/module-confusion.error';
import { ModulesConfig } from './modules.config';
import type { ModuleName } from './modules.config';
@Service()
export class ModuleRegistry {
readonly entities: EntityClass[] = [];
readonly settings: Map<string, ModuleSettings> = new Map();
constructor(
private readonly moduleMetadata: ModuleMetadata,
private readonly lifecycleMetadata: LifecycleMetadata,
private readonly licenseState: LicenseState,
private readonly logger: Logger,
private readonly modulesConfig: ModulesConfig,
) {}
private readonly defaultModules: ModuleName[] = ['insights', 'external-secrets'];
private readonly activeModules: string[] = [];
get eligibleModules(): ModuleName[] {
const { enabledModules, disabledModules } = this.modulesConfig;
const doubleListed = enabledModules.filter((m) => disabledModules.includes(m));
if (doubleListed.length > 0) throw new ModuleConfusionError(doubleListed);
const defaultPlusEnabled = [...new Set([...this.defaultModules, ...enabledModules])];
return defaultPlusEnabled.filter((m) => !disabledModules.includes(m));
}
/**
* Loads [module name].module.ts for each eligible module.
* This only registers the database entities for module and should be done
* before instantiating the datasource.
*
* This will not register routes or do any other kind of module related
* setup.
*/
async loadModules(modules?: ModuleName[]) {
for (const moduleName of modules ?? this.eligibleModules) {
try {
await import(`../modules/${moduleName}/${moduleName}.module`);
} catch {
try {
await import(`../modules/${moduleName}.ee/${moduleName}.module`);
} catch (error) {
throw new MissingModuleError(moduleName, error instanceof Error ? error.message : '');
}
}
}
for (const ModuleClass of this.moduleMetadata.getClasses()) {
const entities = Container.get(ModuleClass).entities?.();
if (!entities || entities.length === 0) continue;
this.entities.push(...entities);
}
}
/**
* Calls `init` on each eligible module.
*
* This will do things like registering routes, setup timers or other module
* specific setup.
*
* `ModuleRegistry.loadModules` must have been called before.
*/
async initModules() {
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
const { licenseFlag, class: ModuleClass } = moduleEntry;
if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) {
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
continue;
}
await Container.get(ModuleClass).init?.();
const moduleSettings = await Container.get(ModuleClass).settings?.();
if (!moduleSettings) continue;
this.settings.set(moduleName, moduleSettings);
this.logger.debug(`Initialized module "${moduleName}"`);
this.activeModules.push(moduleName);
}
}
isActive(moduleName: ModuleName) {
return this.activeModules.includes(moduleName);
}
getActiveModules() {
return this.activeModules;
}
registerLifecycleHooks(hooks: ExecutionLifecycleHooks) {
const handlers = this.lifecycleMetadata.getHandlers();
for (const { handlerClass, methodName, eventName } of handlers) {
const instance = Container.get(handlerClass);
switch (eventName) {
case 'workflowExecuteAfter':
hooks.addHandler(
eventName,
async function (
this: { workflowData: IWorkflowBase },
runData: IRun,
newStaticData: IDataObject,
) {
const context: LifecycleContext = {
type: 'workflowExecuteAfter',
workflow: this.workflowData,
runData,
newStaticData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
},
);
break;
case 'nodeExecuteBefore':
hooks.addHandler(
eventName,
async function (
this: { workflowData: IWorkflowBase },
nodeName: string,
taskData: ITaskStartedData,
) {
const context: LifecycleContext = {
type: 'nodeExecuteBefore',
workflow: this.workflowData,
nodeName,
taskData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
},
);
break;
case 'nodeExecuteAfter':
hooks.addHandler(
eventName,
async function (
this: { workflowData: IWorkflowBase },
nodeName: string,
taskData: ITaskData,
executionData: IRunExecutionData,
) {
const context: LifecycleContext = {
type: 'nodeExecuteAfter',
workflow: this.workflowData,
nodeName,
taskData,
executionData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
},
);
break;
case 'workflowExecuteBefore':
hooks.addHandler(
eventName,
async function (
this: { workflowData: IWorkflowBase },
workflowInstance: Workflow,
executionData?: IRunExecutionData,
) {
const context: LifecycleContext = {
type: 'workflowExecuteBefore',
workflow: this.workflowData,
workflowInstance,
executionData,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await instance[methodName].call(instance, context);
},
);
break;
}
}
}
}

View File

@@ -1,5 +1,5 @@
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
import { LicenseState, Logger } from '@n8n/backend-common';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig, SecurityConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
import { Container, Service } from '@n8n/di';
@@ -17,7 +17,6 @@ import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { ModuleRegistry } from '@/modules/module-registry';
import { isApiEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config';
import type { CommunityPackagesService } from '@/services/community-packages.service';

View File

@@ -1,8 +1,7 @@
import { ModuleRegistry } from '@n8n/backend-common';
import type { ModuleName } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { ModuleRegistry } from '@/modules/module-registry';
import type { ModuleName } from '@/modules/modules.config';
export async function loadModules(moduleNames: ModuleName[]) {
await Container.get(ModuleRegistry).loadModules(moduleNames);
}

View File

@@ -1,4 +1,5 @@
import { LicenseState } from '@n8n/backend-common';
import { ModuleRegistry } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
@@ -14,7 +15,6 @@ import { AUTH_COOKIE_NAME } from '@/constants';
import { ControllerRegistry } from '@/controller.registry';
import { License } from '@/license';
import { rawBodyReader, bodyParser } from '@/middlewares';
import { ModuleRegistry } from '@/modules/module-registry';
import { PostHogClient } from '@/posthog';
import { Push } from '@/push';
import type { APIRequest } from '@/requests';

3
pnpm-lock.yaml generated
View File

@@ -419,6 +419,9 @@ importers:
'@n8n/constants':
specifier: workspace:^
version: link:../constants
'@n8n/decorators':
specifier: workspace:^
version: link:../decorators
'@n8n/di':
specifier: workspace:^
version: link:../di