From c6ae71817eb6a1fdd3b2a90ae3866dc24770cae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 14 Aug 2025 11:12:08 +0200 Subject: [PATCH] refactor(core): Allow modules to define paths to load nodes from (#18193) --- .../modules/__tests__/module-registry.test.ts | 19 +++++++++++++++++++ .../src/modules/module-registry.ts | 8 ++++++-- packages/@n8n/decorators/src/module/module.ts | 6 ++++++ .../load-nodes-and-credentials.test.ts | 10 +++++----- .../cli/src/load-nodes-and-credentials.ts | 7 ++++++- 5 files changed, 42 insertions(+), 8 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 97078ddd04..39c7513d69 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 @@ -218,3 +218,22 @@ describe('initModules', () => { expect(moduleRegistry.getActiveModules()).toEqual([moduleName]); }); }); + +describe('loadDir', () => { + it('should load dirs defined by modules', async () => { + const TEST_LOAD_DIR = '/path/to/module/load/dir'; + const ModuleClass = { + entities: jest.fn().mockReturnValue([]), + loadDir: jest.fn().mockReturnValue(TEST_LOAD_DIR), + }; + const moduleMetadata = mock({ + getClasses: jest.fn().mockReturnValue([ModuleClass]), + }); + Container.get = jest.fn().mockReturnValue(ModuleClass); + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); + + await moduleRegistry.loadModules([]); // empty to skip dynamic imports + + expect(moduleRegistry.loadDirs).toEqual([TEST_LOAD_DIR]); + }); +}); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index d192bc4165..dcaeb6fa61 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -15,6 +15,8 @@ import { Logger } from '../logging/logger'; export class ModuleRegistry { readonly entities: EntityClass[] = []; + readonly loadDirs: string[] = []; + readonly settings: Map = new Map(); constructor( @@ -79,9 +81,11 @@ export class ModuleRegistry { for (const ModuleClass of this.moduleMetadata.getClasses()) { const entities = await Container.get(ModuleClass).entities?.(); - if (!entities || entities.length === 0) continue; + if (entities?.length) this.entities.push(...entities); - this.entities.push(...entities); + const loadDir = Container.get(ModuleClass).loadDir?.(); + + if (loadDir) this.loadDirs.push(loadDir); } } diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 3f29e99b29..349c245686 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -31,6 +31,12 @@ export interface ModuleInterface { shutdown?(): Promise; entities?(): Promise; settings?(): Promise; + + /** + * @returns Path to a dir to load nodes and credentials from. + * @example '/Users/nathan/.n8n/nodes/node_modules' + */ + loadDir?(): string; } export type ModuleClass = Constructable; diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index 864d3c22fd..fde57b632c 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -30,7 +30,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); instance.loaders.package1 = mock({ directory: '/icons/package1', }); @@ -58,7 +58,7 @@ describe('LoadNodesAndCredentials', () => { }); describe('convertNodeToAiTool', () => { - const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); let fullNodeWrapper: { description: INodeTypeDescription }; @@ -290,7 +290,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); instance.knownNodes['n8n-nodes-base.test'] = { className: 'Test', sourcePath: '/nodes-base/dist/nodes/Test/Test.node.js', @@ -330,7 +330,7 @@ describe('LoadNodesAndCredentials', () => { let instance: LoadNodesAndCredentials; beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); instance.types.nodes = [ { name: 'testNode', @@ -455,7 +455,7 @@ describe('LoadNodesAndCredentials', () => { }); beforeEach(() => { - instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock()); + instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock()); instance.loaders = { CUSTOM: mockLoader }; // Allow access to directory diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index af7fa01731..b409ed9593 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -1,4 +1,4 @@ -import { inTest, isContainedWithin, Logger } from '@n8n/backend-common'; +import { inTest, isContainedWithin, Logger, ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Container, Service } from '@n8n/di'; import type ParcelWatcher from '@parcel/watcher'; @@ -58,6 +58,7 @@ export class LoadNodesAndCredentials { private readonly errorReporter: ErrorReporter, private readonly instanceSettings: InstanceSettings, private readonly globalConfig: GlobalConfig, + private readonly moduleRegistry: ModuleRegistry, ) {} async init() { @@ -98,6 +99,10 @@ export class LoadNodesAndCredentials { ); } + for (const dir of this.moduleRegistry.loadDirs) { + await this.loadNodesFromNodeModules(dir); + } + await this.loadNodesFromCustomDirectories(); await this.postProcessLoaders(); }