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 8336dcca510..14f6ec67c69 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 @@ -221,6 +221,48 @@ describe('initModules', () => { expect(moduleRegistry.isActive(moduleName as any)).toBe(true); expect(moduleRegistry.getActiveModules()).toEqual([moduleName]); }); + + it('registers context for module with `context` method', async () => { + // ARRANGE + const moduleName = 'test-module'; + const moduleContext = { proxy: 'test-proxy', config: { enabled: true } }; + const ModuleClass: ModuleInterface = { + init: jest.fn(), + context: jest.fn().mockReturnValue(moduleContext), + }; + const moduleMetadata = mock({ + getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]), + }); + Container.get = jest.fn().mockReturnValue(ModuleClass); + + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); + + // ACT + await moduleRegistry.initModules(); + + // ASSERT + expect(ModuleClass.context).toHaveBeenCalled(); + expect(moduleRegistry.context.has(moduleName)).toBe(true); + expect(moduleRegistry.context.get(moduleName)).toBe(moduleContext); + }); + + it('does not register context for module without `context` method', async () => { + // ARRANGE + const moduleName = 'test-module'; + const ModuleClass: ModuleInterface = { init: jest.fn() }; + const moduleMetadata = mock({ + getEntries: jest.fn().mockReturnValue([[moduleName, { class: ModuleClass }]]), + }); + Container.get = jest.fn().mockReturnValue(ModuleClass); + + const moduleRegistry = new ModuleRegistry(moduleMetadata, mock(), mock(), mock()); + + // ACT + await moduleRegistry.initModules(); + + // ASSERT + expect(moduleRegistry.context.has(moduleName)).toBe(false); + }); }); describe('loadDir', () => { diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index dda93a28e39..3ae22b62f47 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -1,5 +1,5 @@ import { ModuleMetadata } from '@n8n/decorators'; -import type { EntityClass, ModuleSettings } from '@n8n/decorators'; +import type { EntityClass, ModuleContext, ModuleSettings } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import { existsSync } from 'fs'; import path from 'path'; @@ -19,6 +19,8 @@ export class ModuleRegistry { readonly settings: Map = new Map(); + readonly context: Map = new Map(); + constructor( private readonly moduleMetadata: ModuleMetadata, private readonly licenseState: LicenseState, @@ -116,6 +118,10 @@ export class ModuleRegistry { if (moduleSettings) this.settings.set(moduleName, moduleSettings); + const moduleContext = await Container.get(ModuleClass).context?.(); + + if (moduleContext) this.context.set(moduleName, moduleContext); + this.logger.debug(`Initialized module "${moduleName}"`); this.activeModules.push(moduleName); diff --git a/packages/@n8n/decorators/src/module/index.ts b/packages/@n8n/decorators/src/module/index.ts index f8dc040d817..3992e5aa486 100644 --- a/packages/@n8n/decorators/src/module/index.ts +++ b/packages/@n8n/decorators/src/module/index.ts @@ -1,2 +1,8 @@ -export { ModuleInterface, BackendModule, EntityClass, ModuleSettings } from './module'; +export { + ModuleInterface, + BackendModule, + EntityClass, + ModuleSettings, + ModuleContext, +} from './module'; export { ModuleMetadata } from './module-metadata'; diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 93558caa723..413395431e7 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -25,14 +25,54 @@ export interface TimestampedEntity { export type EntityClass = new () => BaseEntity | TimestampedEntity; export type ModuleSettings = Record; +export type ModuleContext = Record; export interface ModuleInterface { init?(): Promise; shutdown?(): Promise; + + /** + * Return a list of entities to register with the typeorm database connection. + * + * @example [ InsightsByPeriod, InsightsMetadata, InsightsRaw ] + */ entities?(): Promise; + + /** + * Return an object with settings to send to the client via `/module-settings`. + * + * @example { summary: true, dashboard: false } + */ settings?(): Promise; /** + * Return an object to merge into workflow context, a.k.a. `WorkflowExecuteAdditionalData`. + * This object will be namespaced under the module name set by `@BackendModule('name')`. + * + * @example + * ```ts + * // at Module.context() + * { proxy: Container.get(InsightsProxyService) } + * + * // at callsite + * additionalData.insights.proxy.method() + * ``` + * + * For type safety, add the module context to `IWorkflowExecuteAdditionalData`. + * + * ```ts + * export interface IWorkflowExecuteAdditionalData { + * insights?: { + * proxy: { method: () => void }; + * }; + * } + * ``` + */ + context?(): Promise; + + /** + * Return a path to a dir to load nodes and credentials from. + * * @returns Path to a dir to load nodes and credentials from. `null` to skip. * @example '/Users/nathan/.n8n/nodes/node_modules' */ diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 3efda924909..73226d94928 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -384,7 +384,7 @@ export async function getBase( ) : undefined; - return { + const additionalData: IWorkflowExecuteAdditionalData = { dataStoreProxyProvider, currentNodeExecutionIndex: 0, credentialsHelper: Container.get(CredentialsHelper), @@ -450,4 +450,12 @@ export async function getBase( logAiEvent: (eventName: keyof AiEventMap, payload: AiEventPayload) => eventService.emit(eventName, payload), }; + + for (const [moduleName, moduleContext] of Container.get(ModuleRegistry).context.entries()) { + // @ts-expect-error Adding an index signature `[key: string]: unknown` + // to `IWorkflowExecuteAdditionalData` triggers complex type errors for derived types. + additionalData[moduleName] = moduleContext; + } + + return additionalData; }