refactor(core): Load packaged modules from a static manifest (no-changelog) (#40037)

This commit is contained in:
Charlie Kolb 2026-10-02 13:42:01 +00:00 committed by GitHub
parent 628ada76cd
commit ea38f3a3ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 106 additions and 1 deletions

View File

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

View File

@ -177,6 +177,72 @@ describe('loadModules', () => {
expect(moduleRegistry.entities).toEqual([]);
});
describe('packaged modules', () => {
const newRegistry = () => {
const ModuleClass = { entities: vi.fn().mockReturnValue([]) };
const moduleMetadata = mock<ModuleMetadata>({
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';

View File

@ -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<Record<ModuleName, () => Promise<unknown>>>;
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 {

View File

@ -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'];

View File

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

View File

@ -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 = {};

View File

@ -6,7 +6,8 @@ through Vite aliases.
`<name>/backend` is a reserved path rather than a workspace package — the backend runtime discovers
modules under `packages/cli/src/modules/<name>`. 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.