feat(core): Add instance types option to backend modules (no-changelog) (#21990)

This commit is contained in:
Iván Ovejero 2025-11-18 18:30:34 +01:00 committed by GitHub
parent 2830665f7a
commit da7b171a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 101 additions and 25 deletions

View file

@ -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<ModuleMetadata>({
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<ModuleMetadata>({
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', () => {

View file

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

View file

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

View file

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

View file

@ -251,7 +251,7 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
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

View file

@ -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() {

View file

@ -114,7 +114,7 @@ export class Worker extends BaseCommand<z.infer<typeof flagsSchema>> {
}),
);
await this.moduleRegistry.initModules();
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
}
async initEventBus() {

View file

@ -324,7 +324,7 @@ export const setupTestServer = ({
}
}
await Container.get(ModuleRegistry).initModules();
await Container.get(ModuleRegistry).initModules('main');
Container.get(ControllerRegistry).activate(app);
}
});

View file

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