From cb81826cf16a215e1da08432e5bb6ce29db1099e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 13 Jun 2025 11:49:38 +0200 Subject: [PATCH] perf(core): Skip init for unlicensed modules (#16311) --- .../src/module/__tests__/module.test.ts | 10 +++++----- .../decorators/src/module/module-metadata.ts | 17 +++++++++++------ packages/@n8n/decorators/src/module/module.ts | 18 +++++++++++++----- .../external-secrets.module.ts | 2 +- packages/cli/src/modules/module-registry.ts | 11 +++++++++-- .../external-secrets.api.test.ts | 4 ++++ 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/@n8n/decorators/src/module/__tests__/module.test.ts b/packages/@n8n/decorators/src/module/__tests__/module.test.ts index 761a4326ec..f7bca69854 100644 --- a/packages/@n8n/decorators/src/module/__tests__/module.test.ts +++ b/packages/@n8n/decorators/src/module/__tests__/module.test.ts @@ -19,7 +19,7 @@ describe('@BackendModule decorator', () => { initialize() {} } - const registeredModules = Array.from(moduleMetadata.getModules()); + const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); expect(registeredModules).toContain(TestModule); expect(registeredModules).toHaveLength(1); @@ -41,7 +41,7 @@ describe('@BackendModule decorator', () => { initialize() {} } - const registeredModules = Array.from(moduleMetadata.getModules()); + const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); expect(registeredModules).toContain(FirstModule); expect(registeredModules).toContain(SecondModule); @@ -53,7 +53,7 @@ describe('@BackendModule decorator', () => { @BackendModule() class TestModule {} - const registeredModules = Array.from(moduleMetadata.getModules()); + const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); expect(registeredModules).toContain(TestModule); expect(registeredModules).toHaveLength(1); @@ -69,7 +69,7 @@ describe('@BackendModule decorator', () => { } } - const registeredModules = Array.from(moduleMetadata.getModules()); + const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); expect(registeredModules).toContain(TestModule); @@ -87,7 +87,7 @@ describe('@BackendModule decorator', () => { @BackendModule() class SecondModule {} - const registeredModules = Array.from(moduleMetadata.getModules()); + const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); expect(registeredModules).toContain(FirstModule); expect(registeredModules).toContain(SecondModule); diff --git a/packages/@n8n/decorators/src/module/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts index d6bcb7ac8f..baebd2453a 100644 --- a/packages/@n8n/decorators/src/module/module-metadata.ts +++ b/packages/@n8n/decorators/src/module/module-metadata.ts @@ -1,16 +1,21 @@ import { Service } from '@n8n/di'; -import type { ModuleClass } from './module'; +import type { LicenseFlag, ModuleClass } from './module'; + +type ModuleEntry = { + class: ModuleClass; + licenseFlag?: LicenseFlag; +}; @Service() export class ModuleMetadata { - private readonly modules: Set = new Set(); + private readonly modules: Map = new Map(); - register(module: ModuleClass) { - this.modules.add(module); + register(moduleName: string, moduleEntry: ModuleEntry) { + this.modules.set(moduleName, moduleEntry); } - getModules() { - return this.modules.keys(); + getEntries() { + return [...this.modules.values()]; } } diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index cdc1d2a042..988e8b8a70 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -1,3 +1,4 @@ +import type { LICENSE_FEATURES } from '@n8n/constants'; import { Container, Service, type Constructable } from '@n8n/di'; import { ModuleMetadata } from './module-metadata'; @@ -24,9 +25,16 @@ export interface ModuleInterface { export type ModuleClass = Constructable; -export const BackendModule = (): ClassDecorator => (target) => { - Container.get(ModuleMetadata).register(target as unknown as ModuleClass); +export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Service()(target); -}; +export const BackendModule = + (opts?: { licenseFlag: LicenseFlag }): ClassDecorator => + (target) => { + Container.get(ModuleMetadata).register(target.name, { + class: target as unknown as ModuleClass, + licenseFlag: opts?.licenseFlag, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); + }; diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts index c084b5a212..30256ede17 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts @@ -2,7 +2,7 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule } from '@n8n/decorators'; import { Container } from '@n8n/di'; -@BackendModule() +@BackendModule({ licenseFlag: 'feat:externalSecrets' }) export class ExternalSecretsModule implements ModuleInterface { async init() { await import('./external-secrets.controller.ee'); diff --git a/packages/cli/src/modules/module-registry.ts b/packages/cli/src/modules/module-registry.ts index 3baf546cbf..bdb7dc5abb 100644 --- a/packages/cli/src/modules/module-registry.ts +++ b/packages/cli/src/modules/module-registry.ts @@ -1,3 +1,4 @@ +import { LicenseState, Logger } from '@n8n/backend-common'; import { LifecycleMetadata, ModuleMetadata } from '@n8n/decorators'; import type { LifecycleContext, EntityClass } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; @@ -19,16 +20,22 @@ export class ModuleRegistry { constructor( private readonly moduleMetadata: ModuleMetadata, private readonly lifecycleMetadata: LifecycleMetadata, + private readonly licenseState: LicenseState, + private readonly logger: Logger, ) {} async initModules() { - for (const ModuleClass of this.moduleMetadata.getModules()) { + for (const { class: ModuleClass, licenseFlag } of this.moduleMetadata.getEntries()) { + if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) { + this.logger.debug(`Skipped init for unlicensed module "${ModuleClass.name}"`); + continue; + } await Container.get(ModuleClass).init?.(); } } addEntities() { - for (const ModuleClass of this.moduleMetadata.getModules()) { + for (const { class: ModuleClass } of this.moduleMetadata.getEntries()) { const entities = Container.get(ModuleClass).entities?.(); if (!entities || entities.length === 0) continue; diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index aa4eaba7a9..97402c3192 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -1,3 +1,4 @@ +import { LicenseState } from '@n8n/backend-common'; import { SettingsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -31,6 +32,9 @@ let authMemberAgent: SuperAgentTest; const mockProvidersInstance = new MockProviders(); mockInstance(ExternalSecretsProviders, mockProvidersInstance); +const licenseMock = mock(); +licenseMock.isLicensed.mockReturnValue(true); +Container.set(LicenseState, licenseMock); const testServer = setupTestServer({ endpointGroups: ['externalSecrets'],