From ea38f3a3eee3f32708055fcdbe514308c04df5b9 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Fri, 2 Oct 2026 13:42:01 +0000 Subject: [PATCH] refactor(core): Load packaged modules from a static manifest (no-changelog) (#40037) --- packages/@n8n/backend-common/src/index.ts | 1 + .../modules/__tests__/module-registry.test.ts | 66 +++++++++++++++++++ .../src/modules/module-registry.ts | 21 ++++++ .../src/__tests__/command-registry.test.ts | 7 ++ packages/cli/src/command-registry.ts | 2 + packages/cli/src/modules/modules.manifest.ts | 7 ++ packages/modules/README.md | 3 +- 7 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/modules/modules.manifest.ts diff --git a/packages/@n8n/backend-common/src/index.ts b/packages/@n8n/backend-common/src/index.ts index c92c826c72b..0f24d948d76 100644 --- a/packages/@n8n/backend-common/src/index.ts +++ b/packages/@n8n/backend-common/src/index.ts @@ -5,6 +5,7 @@ export { inDevelopment, inProduction, inTest, isEnvFeatureEnabled } from './envi export { isObjectLiteral } from './utils/is-object-literal'; export { Logger } from './logging/logger'; export { ModuleRegistry } from './modules/module-registry'; +export type { PackagedModules } from './modules/module-registry'; export type { ModuleName } from './modules/modules.config'; export { ModulesConfig } from './modules/modules.config'; export { 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 7d2c5891070..1f12d265817 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 @@ -177,6 +177,72 @@ describe('loadModules', () => { expect(moduleRegistry.entities).toEqual([]); }); + describe('packaged modules', () => { + const newRegistry = () => { + const ModuleClass = { entities: vi.fn().mockReturnValue([]) }; + const moduleMetadata = mock({ + getClasses: vi.fn().mockReturnValue([ModuleClass]), + }); + Container.get = vi.fn().mockReturnValue(ModuleClass); + + return new ModuleRegistry(moduleMetadata, mock(), mock(), mock(), mock()); + }; + + it('should load a module from the manifest instead of the filesystem', async () => { + const importPackagedModule = vi.fn().mockResolvedValue({}); + const moduleRegistry = newRegistry(); + moduleRegistry.registerPackagedModules({ insights: importPackagedModule }); + + await moduleRegistry.loadModules(['insights']); + + expect(importPackagedModule).toHaveBeenCalledTimes(1); + }); + + it('should wrap a packaged module import failure', async () => { + const importError = new Error('Package import failed'); + const moduleRegistry = newRegistry(); + moduleRegistry.registerPackagedModules({ + insights: vi.fn().mockRejectedValue(importError), + }); + + const loading = moduleRegistry.loadModules(['insights']); + + await expect(loading).rejects.toThrow(ModuleLoadError); + await expect(loading).rejects.toThrow(importError.message); + }); + + it('should use the filesystem route for a module that is not in the manifest', async () => { + const importPackagedModule = vi.fn().mockResolvedValue({}); + const moduleRegistry = newRegistry(); + moduleRegistry.registerPackagedModules({ insights: importPackagedModule }); + + await expect(moduleRegistry.loadModules(['otel'])).rejects.toThrow(MissingModuleError); + expect(importPackagedModule).not.toHaveBeenCalled(); + }); + + it('should load an eligible packaged module', async () => { + const importPackagedModule = vi.fn().mockResolvedValue({}); + const moduleRegistry = newRegistry(); + vi.spyOn(moduleRegistry, 'eligibleModules', 'get').mockReturnValue(['insights']); + moduleRegistry.registerPackagedModules({ insights: importPackagedModule }); + + await moduleRegistry.loadModules(); + + expect(importPackagedModule).toHaveBeenCalledTimes(1); + }); + + it('should not load an ineligible packaged module', async () => { + const importPackagedModule = vi.fn().mockResolvedValue({}); + const moduleRegistry = newRegistry(); + vi.spyOn(moduleRegistry, 'eligibleModules', 'get').mockReturnValue([]); + moduleRegistry.registerPackagedModules({ insights: importPackagedModule }); + + await moduleRegistry.loadModules(); + + expect(importPackagedModule).not.toHaveBeenCalled(); + }); + }); + describe('entrypoint resolution', () => { const MISSING_DEPENDENCY = 'n8n-fixture-absent-dependency'; diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index f664e0b2e9e..db0049adb57 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -15,6 +15,9 @@ import { ModuleLoadError } from './errors/module-load.error'; import { ModulesConfig } from './modules.config'; import type { ModuleName } from './modules.config'; +/** Modules that load from workspace packages instead of the n8n modules directory. */ +export type PackagedModules = Partial Promise>>; + const getModuleEntryPath = (modulesDir: string, moduleName: string, isEnterprise = false) => path.join(modulesDir, isEnterprise ? `${moduleName}.ee` : moduleName, `${moduleName}.module.js`); @@ -81,6 +84,12 @@ export class ModuleRegistry { private readonly activeModules: string[] = []; + private readonly packagedModules: PackagedModules = {}; + + registerPackagedModules(packagedModules: PackagedModules) { + Object.assign(this.packagedModules, packagedModules); + } + get eligibleModules(): ModuleName[] { const { enabledModules, disabledModules } = this.modulesConfig; @@ -118,6 +127,18 @@ export class ModuleRegistry { } for (const moduleName of modules ?? this.eligibleModules) { + const importPackagedModule = this.packagedModules[moduleName]; + + if (importPackagedModule) { + try { + await importPackagedModule(); + } catch (error) { + throw new ModuleLoadError(moduleName, error); + } + + continue; + } + const entryPath = getModuleEntryPath(modulesDir, moduleName); try { diff --git a/packages/cli/src/__tests__/command-registry.test.ts b/packages/cli/src/__tests__/command-registry.test.ts index 06c5c47c2ba..a1e05cb98cc 100644 --- a/packages/cli/src/__tests__/command-registry.test.ts +++ b/packages/cli/src/__tests__/command-registry.test.ts @@ -7,6 +7,7 @@ import { mock } from 'vitest-mock-extended'; import { z } from 'zod'; import { CommandRegistry } from '../command-registry'; +import { packagedModules } from '../modules/modules.manifest'; vi.mock('fast-glob'); vi.mock('node:fs/promises', () => ({ access: vi.fn() })); @@ -69,6 +70,12 @@ describe('CommandRegistry', () => { vi.resetAllMocks(); }); + it('should register packaged modules', () => { + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser); + + expect(moduleRegistry.registerPackagedModules).toHaveBeenCalledWith(packagedModules); + }); + it('should execute the specified command', async () => { process.argv = ['node', 'n8n', 'test-command']; diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 2856a200c2f..3d296f9978e 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -8,6 +8,7 @@ import picocolors from 'picocolors'; import { z, ZodError } from 'zod'; import './zod-alias-support'; +import { packagedModules } from './modules/modules.manifest'; /** * Registry that manages CLI commands, their execution, and metadata. @@ -24,6 +25,7 @@ export class CommandRegistry { private readonly cliParser: CliParser, ) { this.commandName = process.argv[2] ?? 'start'; + this.moduleRegistry.registerPackagedModules(packagedModules); } async execute() { diff --git a/packages/cli/src/modules/modules.manifest.ts b/packages/cli/src/modules/modules.manifest.ts new file mode 100644 index 00000000000..179b45a8f69 --- /dev/null +++ b/packages/cli/src/modules/modules.manifest.ts @@ -0,0 +1,7 @@ +import type { PackagedModules } from '@n8n/backend-common'; + +/** + * Backend modules that load from workspace packages instead of this directory. + * Keep each import in a thunk so an ineligible module is not imported. + */ +export const packagedModules: PackagedModules = {}; diff --git a/packages/modules/README.md b/packages/modules/README.md index 6501b71ebeb..89134cbb03f 100644 --- a/packages/modules/README.md +++ b/packages/modules/README.md @@ -6,7 +6,8 @@ through Vite aliases. `/backend` is a reserved path rather than a workspace package — the backend runtime discovers modules under `packages/cli/src/modules/`. The extra nesting level is what lets both halves of -a module sit together later. +a module sit together later. Add packaged backend modules to +`packages/cli/src/modules/modules.manifest.ts`. `packages/modules` itself stays tracked even when it holds nothing: turbo rejects a `--filter` whose directory does not exist.