From da7b171a1982c696aa5285e4f093b83764a18e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 18 Nov 2025 18:30:34 +0100 Subject: [PATCH] feat(core): Add instance types option to backend modules (no-changelog) (#21990) --- .../modules/__tests__/module-registry.test.ts | 60 +++++++++++++++---- .../src/modules/module-registry.ts | 14 ++++- .../decorators/src/module/module-metadata.ts | 10 ++-- packages/@n8n/decorators/src/module/module.ts | 19 +++++- packages/cli/src/commands/start.ts | 2 +- packages/cli/src/commands/webhook.ts | 2 +- packages/cli/src/commands/worker.ts | 2 +- .../integration/shared/utils/test-server.ts | 2 +- .../backend-module/backend-module-guide.md | 15 +++++ 9 files changed, 101 insertions(+), 25 deletions(-) diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index b26db01de42..4d5ab6a50d7 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -15,8 +15,8 @@ beforeEach(() => { describe('eligibleModules', () => { it('should consider all default modules eligible', () => { - // 'mcp' and 'chat-hub' aren't (yet) eligible modules by default - const NON_DEFAULT_MODULES = ['mcp', 'chat-hub']; + // 'chat-hub' isn't (yet) an eligible module by default + const NON_DEFAULT_MODULES = ['chat-hub']; const expectedModules = MODULE_NAMES.filter((name) => !NON_DEFAULT_MODULES.includes(name)); expect(Container.get(ModuleRegistry).eligibleModules).toEqual(expectedModules); }); @@ -27,6 +27,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'mcp', 'provisioning', 'breaking-changes', ]); @@ -39,6 +40,7 @@ describe('eligibleModules', () => { 'external-secrets', 'community-packages', 'data-table', + 'mcp', 'provisioning', 'breaking-changes', ]); @@ -98,7 +100,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); expect(ModuleClass.init).toHaveBeenCalled(); }); @@ -117,7 +119,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock()); - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); expect(ModuleClass.init).toHaveBeenCalled(); }); @@ -136,7 +138,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, licenseState, mock(), mock()); - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); expect(ModuleClass.init).not.toHaveBeenCalled(); }); @@ -153,9 +155,9 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); - await expect(moduleRegistry.initModules()).resolves.not.toThrow(); + await expect(moduleRegistry.initModules('main')).resolves.not.toThrow(); }); it('registers settings', async () => { @@ -174,7 +176,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); // ACT - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); // ASSERT expect(ModuleClass.settings).toHaveBeenCalled(); @@ -198,7 +200,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); // ACT - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); // ASSERT // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -220,7 +222,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); // ACT - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); // ASSERT // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -244,7 +246,7 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); // ACT - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); // ASSERT expect(ModuleClass.context).toHaveBeenCalled(); @@ -264,11 +266,45 @@ describe('initModules', () => { const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); // ACT - await moduleRegistry.initModules(); + await moduleRegistry.initModules('main'); // ASSERT expect(moduleRegistry.context.has(moduleName)).toBe(false); }); + + it('should init module with matching instance type', async () => { + const ModuleClass = { init: jest.fn() }; + const moduleMetadata = mock({ + getEntries: jest + .fn() + .mockReturnValue([ + ['test-module', { instanceTypes: ['main', 'worker'], class: ModuleClass }], + ]), + }); + Container.get = jest.fn().mockReturnValue(ModuleClass); + + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); + + await moduleRegistry.initModules('main'); + + expect(ModuleClass.init).toHaveBeenCalled(); + }); + + it('should skip init for module with non-matching instance type', async () => { + const ModuleClass = { init: jest.fn() }; + const moduleMetadata = mock({ + getEntries: jest + .fn() + .mockReturnValue([['test-module', { instanceTypes: ['worker'], class: ModuleClass }]]), + }); + Container.get = jest.fn().mockReturnValue(ModuleClass); + + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); + + await moduleRegistry.initModules('main'); + + expect(ModuleClass.init).not.toHaveBeenCalled(); + }); }); describe('loadDir', () => { diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index a119745e181..21234be3f01 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -1,3 +1,4 @@ +import type { InstanceType } from '@n8n/constants'; import { ModuleMetadata } from '@n8n/decorators'; import type { EntityClass, ModuleContext, ModuleSettings } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; @@ -33,9 +34,9 @@ export class ModuleRegistry { 'external-secrets', 'community-packages', 'data-table', + 'mcp', 'provisioning', 'breaking-changes', - 'mcp', ]; private readonly activeModules: string[] = []; @@ -107,15 +108,22 @@ export class ModuleRegistry { * * `ModuleRegistry.loadModules` must have been called before. */ - async initModules() { + async initModules(instanceType: InstanceType) { for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) { - const { licenseFlag, class: ModuleClass } = moduleEntry; + const { licenseFlag, instanceTypes, class: ModuleClass } = moduleEntry; if (licenseFlag !== undefined && !this.licenseState.isLicensed(licenseFlag)) { this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`); continue; } + if (instanceTypes !== undefined && !instanceTypes.includes(instanceType)) { + this.logger.debug( + `Skipped init for module "${moduleName}" (instance type "${instanceType}" not in: ${instanceTypes.join(', ')})`, + ); + continue; + } + await Container.get(ModuleClass).init?.(); const moduleSettings = await Container.get(ModuleClass).settings?.(); diff --git a/packages/@n8n/decorators/src/module/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts index 2404c910d67..0166ffe9249 100644 --- a/packages/@n8n/decorators/src/module/module-metadata.ts +++ b/packages/@n8n/decorators/src/module/module-metadata.ts @@ -1,14 +1,16 @@ +import type { InstanceType } from '@n8n/constants'; import { Service } from '@n8n/di'; import type { LicenseFlag, ModuleClass } from './module'; +/** + * Internal representation of a registered module. + * For field descriptions, see {@link BackendModuleOptions}. + */ type ModuleEntry = { class: ModuleClass; - /* - * If singular, checks if that feature ls licensed, - * if multiple, checks that any of the features are licensed - */ licenseFlag?: LicenseFlag | LicenseFlag[]; + instanceTypes?: InstanceType[]; }; @Service() diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 61413d3098c..96df70a5657 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -1,4 +1,4 @@ -import type { LICENSE_FEATURES } from '@n8n/constants'; +import type { LICENSE_FEATURES, InstanceType } from '@n8n/constants'; import { Container, Service, type Constructable } from '@n8n/di'; import { ModuleMetadata } from './module-metadata'; @@ -83,12 +83,27 @@ export type ModuleClass = Constructable; export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]; +export type BackendModuleOptions = { + /** Canonical name of the backend module. Use kebab-case.*/ + name: string; + + /** + * If present, initialize the module only if the instance has access to a licensed feature. + * Multiple license flags use `OR` logic, i.e. at least one must be licensed. + */ + licenseFlag?: LicenseFlag | LicenseFlag[]; + + /** If present, initialize the module only if the instance type is one of the specified types. */ + instanceTypes?: InstanceType[]; +}; + export const BackendModule = - (opts: { name: string; licenseFlag?: LicenseFlag | LicenseFlag[] }): ClassDecorator => + (opts: BackendModuleOptions): ClassDecorator => (target) => { Container.get(ModuleMetadata).register(opts.name, { class: target as unknown as ModuleClass, licenseFlag: opts?.licenseFlag, + instanceTypes: opts?.instanceTypes, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a11c48a40fe..ed1c2133239 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -251,7 +251,7 @@ export class Start extends BaseCommand> { await this.generateStaticAssets(); } - await this.moduleRegistry.initModules(); + await this.moduleRegistry.initModules(this.instanceSettings.instanceType); if (this.instanceSettings.isMultiMain) { // we instantiate `PrometheusMetricsService` early to register its multi-main event handlers diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index b30ed9966ec..3de15f6837d 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -82,7 +82,7 @@ export class Webhook extends BaseCommand { }); Container.get(LogStreamingEventRelay).init(); - await this.moduleRegistry.initModules(); + await this.moduleRegistry.initModules(this.instanceSettings.instanceType); } async run() { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index f84e6ebf8fc..447b9308b77 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -114,7 +114,7 @@ export class Worker extends BaseCommand> { }), ); - await this.moduleRegistry.initModules(); + await this.moduleRegistry.initModules(this.instanceSettings.instanceType); } async initEventBus() { diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index defcef38ac7..5a6a8ed9239 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -324,7 +324,7 @@ export const setupTestServer = ({ } } - await Container.get(ModuleRegistry).initModules(); + await Container.get(ModuleRegistry).initModules('main'); Container.get(ControllerRegistry).activate(app); } }); diff --git a/scripts/backend-module/backend-module-guide.md b/scripts/backend-module/backend-module-guide.md index d8a595b5851..fd0f4e1e82c 100644 --- a/scripts/backend-module/backend-module-guide.md +++ b/scripts/backend-module/backend-module-guide.md @@ -118,6 +118,21 @@ export class ExternalSecretsModule implements ModuleInterface { } ``` +A module may be restricted to specific instance types: + +```ts +@BackendModule({ + name: 'my-feature', + instanceTypes: ['main', 'webhook'] +}) +export class MyFeatureModule implements ModuleInterface { + // This module will only be initialized on main and webhook instances, + // not on worker instances. +} +``` + +If `instanceTypes` is omitted, the module will be initialized on all instance types (`main`, `webhook`, and `worker`). + If a module is only _partially_ behind a license flag, e.g. insights, then use the `@Licensed()` decorator instead: ```ts