mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Add instance types option to backend modules (no-changelog) (#21990)
This commit is contained in:
parent
2830665f7a
commit
da7b171a19
9 changed files with 101 additions and 25 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ export const setupTestServer = ({
|
|||
}
|
||||
}
|
||||
|
||||
await Container.get(ModuleRegistry).initModules();
|
||||
await Container.get(ModuleRegistry).initModules('main');
|
||||
Container.get(ControllerRegistry).activate(app);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue