feat(core): Inject hooks into applicable trigger node properties for the node UI (#22290)

This commit is contained in:
Guillaume Jacquart 2025-11-25 17:01:00 +01:00 committed by GitHub
parent 80ee4490fa
commit 92dca5f739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 438 additions and 13 deletions

View File

@ -4,6 +4,7 @@ import type {
INodeExecutionData,
PlaintextExecutionContext,
IWorkflowBase,
INodeProperties,
} from 'n8n-workflow';
/**
@ -137,7 +138,17 @@ export type ContextEstablishmentResult = {
* @ContextEstablishmentHook()
* export class BearerTokenHook implements IContextEstablishmentHook {
* hookDescription = {
* name: 'credentials.bearerToken'
* name: 'credentials.bearerToken',
* displayName: 'Bearer Token',
* options: [
* {
* displayName: 'Remove from Item',
* name: 'removeFromItem',
* type: 'boolean',
* default: true,
* description: 'Whether to remove the Authorization header from the trigger item'
* }
* ]
* };
*
* // ... hook implementation
@ -171,6 +182,45 @@ export type HookDescription = {
* @example 'audit.requestMetadata'
*/
name: string;
/**
* Human-readable display name for the hook.
* Used in the UI when presenting the hook selection to users.
* If not provided, the name will be used as the display name.
*
* @example 'Bearer Token Authentication'
* @example 'API Key from Header'
*/
displayName?: string;
/**
* Hook-specific configuration options that will be exposed in the trigger node UI.
* These options are passed to the hook's execute() method via the options parameter.
*
* Each option should be a valid node property object with at minimum:
* displayName, name, type, and default fields.
*
* @example
* ```typescript
* options: [
* {
* displayName: 'Remove from Item',
* name: 'removeFromItem',
* type: 'boolean',
* default: true,
* description: 'Whether to remove the Authorization header from trigger items'
* },
* {
* displayName: 'Header Name',
* name: 'headerName',
* type: 'string',
* default: 'Authorization',
* description: 'The name of the header containing the bearer token'
* }
* ]
* ```
*/
options?: INodeProperties[];
};
/**

View File

@ -1,12 +1,12 @@
import { Service } from '@n8n/di';
import watcher from '@parcel/watcher';
import fs from 'fs/promises';
import { mock } from 'jest-mock-extended';
import type { DirectoryLoader } from 'n8n-core';
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import watcher from '@parcel/watcher';
import { LoadNodesAndCredentials } from '../load-nodes-and-credentials';
import { Service } from '@n8n/di';
jest.mock('lodash/debounce', () => (fn: () => void) => fn);
@ -30,7 +30,7 @@ describe('LoadNodesAndCredentials', () => {
let instance: LoadNodesAndCredentials;
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock());
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
instance.loaders.package1 = mock<DirectoryLoader>({
directory: '/icons/package1',
});
@ -58,7 +58,7 @@ describe('LoadNodesAndCredentials', () => {
});
describe('convertNodeToAiTool', () => {
const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock());
const instance = new LoadNodesAndCredentials(mock(), 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(), mock());
instance = new LoadNodesAndCredentials(mock(), 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(), mock());
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
instance.types.nodes = [
{
name: 'testNode',
@ -447,7 +447,7 @@ describe('LoadNodesAndCredentials', () => {
let instance: LoadNodesAndCredentials;
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock());
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
});
it('should return true for credentials with authenticate property', () => {
const credential = {
@ -572,6 +572,257 @@ describe('LoadNodesAndCredentials', () => {
});
});
describe('injectContextEstablishmentHooks', () => {
let instance: LoadNodesAndCredentials;
let mockLogger: { debug: jest.Mock };
let mockExecutionContextHookRegistry: { getHookForTriggerType: jest.Mock };
beforeEach(() => {
// Enable the feature flag for tests
process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true';
mockLogger = {
debug: jest.fn(),
};
mockExecutionContextHookRegistry = {
getHookForTriggerType: jest.fn(),
};
instance = new LoadNodesAndCredentials(
mockLogger as never,
mock(),
mock(),
mock(),
mock(),
mockExecutionContextHookRegistry as never,
);
});
afterEach(() => {
// Clean up the environment variable after each test
delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS;
});
it('should not inject hooks when feature flag is disabled', () => {
// Disable the feature flag
delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS;
const triggerNode: INodeTypeDescription = {
name: 'webhookTrigger',
displayName: 'Webhook Trigger',
group: ['trigger'],
description: 'A webhook trigger',
version: 1,
defaults: {},
inputs: [],
outputs: ['main'],
properties: [],
};
instance.types.nodes = [triggerNode];
const mockHook = {
hookDescription: {
name: 'credentials.bearerToken',
displayName: 'Bearer Token',
options: [],
},
};
mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([mockHook]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(instance as any).injectContextEstablishmentHooks();
expect(triggerNode.properties).toHaveLength(0);
expect(mockLogger.debug).toHaveBeenCalledWith(
'Context establishment hooks feature is disabled',
);
// Re-enable for other tests
process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true';
});
it('should not inject hooks if no hooks exist', () => {
const triggerNode: INodeTypeDescription = {
name: 'manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
description: 'A manual trigger',
version: 1,
defaults: {},
inputs: [],
outputs: ['main'],
properties: [],
};
instance.types.nodes = [triggerNode];
mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(instance as any).injectContextEstablishmentHooks();
expect(triggerNode.properties).toHaveLength(0);
});
it('should not inject hooks when isApplicableToTriggerNode returns false', () => {
const manualTriggerNode: INodeTypeDescription = {
name: 'manualTrigger',
displayName: 'Manual Trigger',
group: ['trigger'],
description: 'A manual trigger',
version: 1,
defaults: {},
inputs: [],
outputs: ['main'],
properties: [],
};
instance.types.nodes = [manualTriggerNode];
const mockNonApplicableHook = {
hookDescription: {
name: 'credentials.bearerToken',
displayName: 'Bearer Token',
options: [
{
displayName: 'Remove from Item',
name: 'removeFromItem',
type: 'boolean',
default: true,
},
],
},
isApplicableToTriggerNode: jest.fn((nodeType: string) => {
// Only applicable to webhook trigger
return nodeType === 'webhookTrigger';
}),
};
// Mock getHookForTriggerType to simulate real filtering behavior
mockExecutionContextHookRegistry.getHookForTriggerType.mockImplementation((nodeType) => {
const allHooks = [mockNonApplicableHook];
// Filter hooks based on isApplicableToTriggerNode
return allHooks.filter((hook) => hook.isApplicableToTriggerNode(nodeType));
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(instance as any).injectContextEstablishmentHooks();
// Verify isApplicableToTriggerNode was called, but no properties added
expect(mockNonApplicableHook.isApplicableToTriggerNode).toHaveBeenCalledWith('manualTrigger');
expect(manualTriggerNode.properties).toHaveLength(0);
});
it('should inject hooks with multiple hook types and options', () => {
const triggerNode: INodeTypeDescription = {
name: 'webhookTrigger',
displayName: 'Webhook Trigger',
group: ['trigger'],
description: 'A webhook trigger',
version: 1,
defaults: {},
inputs: [],
outputs: ['main'],
properties: [],
};
instance.types.nodes = [triggerNode];
// Hook 1: With 2 options (one with existing displayOptions)
const mockHookWithOptions = {
hookDescription: {
name: 'credentials.bearerToken',
displayName: 'Bearer Token',
options: [
{
displayName: 'Remove from Item',
name: 'removeFromItem',
type: 'boolean',
default: true,
},
{
displayName: 'Advanced Option',
name: 'advancedOption',
type: 'string',
default: '',
displayOptions: {
show: {
someOtherField: ['value'],
},
},
},
],
},
};
// Hook 2: Without options
const mockHookWithoutOptions = {
hookDescription: {
name: 'credentials.apiKey',
displayName: 'API Key',
options: [],
},
};
mockExecutionContextHookRegistry.getHookForTriggerType.mockReturnValue([
mockHookWithOptions,
mockHookWithoutOptions,
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(instance as any).injectContextEstablishmentHooks();
// Verify three properties are injected
expect(triggerNode.properties).toHaveLength(3);
expect(triggerNode.properties[0].name).toBe('executionsHooksVersion');
expect(triggerNode.properties[0].type).toBe('hidden');
expect(triggerNode.properties[1].name).toBe('contextEstablishmentHooks');
expect(triggerNode.properties[1].type).toBe('fixedCollection');
expect(triggerNode.properties[2].name).toBe('contextHooksNotice');
expect(triggerNode.properties[2].type).toBe('notice');
// Verify the hook collection structure
const hookProperty = triggerNode.properties[1];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hookValues = (hookProperty as any).options?.[0]?.values as INodeProperties[];
// Should have: hookName selector + isAllowedToFail + 2 options from first hook = 4 values
expect(hookValues).toHaveLength(4);
// Verify hookName selector with both hook options
expect(hookValues[0].name).toBe('hookName');
expect(hookValues[0].type).toBe('options');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hookNameOptions = (hookValues[0] as any).options;
expect(hookNameOptions).toHaveLength(2);
expect(hookNameOptions[0].value).toBe('credentials.bearerToken');
expect(hookNameOptions[1].value).toBe('credentials.apiKey');
// Verify isAllowedToFail field
expect(hookValues[1].name).toBe('isAllowedToFail');
expect(hookValues[1].type).toBe('boolean');
expect(hookValues[1].default).toBe(false);
// Verify first option has display condition
expect(hookValues[2].name).toBe('removeFromItem');
expect(hookValues[2].displayOptions).toEqual({
show: {
hookName: ['credentials.bearerToken'],
},
});
// Verify second option merges existing displayOptions with hookName
expect(hookValues[3].name).toBe('advancedOption');
expect(hookValues[3].displayOptions).toEqual({
show: {
someOtherField: ['value'],
hookName: ['credentials.bearerToken'],
},
});
});
});
describe('setupHotReload', () => {
let instance: LoadNodesAndCredentials;
@ -584,7 +835,7 @@ describe('LoadNodesAndCredentials', () => {
});
beforeEach(() => {
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock());
instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock(), mock(), mock());
instance.loaders = { CUSTOM: mockLoader };
// Allow access to directory

View File

@ -98,6 +98,8 @@ export abstract class BaseCommand<F = never> {
process.once('SIGINT', this.onTerminationSignal('SIGINT'));
this.nodeTypes = Container.get(NodeTypes);
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).init();
await this.dbConnection

View File

@ -262,8 +262,6 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
Container.get(MultiMainSetup).registerEventHandlers();
}
await this.executionContextHookRegistry.init();
}
async initOrchestration() {

View File

@ -115,8 +115,6 @@ export class Worker extends BaseCommand<z.infer<typeof flagsSchema>> {
);
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
await this.executionContextHookRegistry.init();
}
async initEventBus() {

View File

@ -14,6 +14,7 @@ import {
LazyPackageDirectoryLoader,
UnrecognizedCredentialTypeError,
UnrecognizedNodeTypeError,
ExecutionContextHookRegistry,
} from 'n8n-core';
import type {
KnownNodesAndCredentials,
@ -57,6 +58,7 @@ export class LoadNodesAndCredentials {
private readonly instanceSettings: InstanceSettings,
private readonly globalConfig: GlobalConfig,
private readonly moduleRegistry: ModuleRegistry,
private readonly executionContextHookRegistry: ExecutionContextHookRegistry,
) {}
async init() {
@ -279,6 +281,128 @@ export class LoadNodesAndCredentials {
});
}
private injectContextEstablishmentHooks() {
// Check if the feature is enabled via environment variable
const isEnabled = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true';
if (!isEnabled) {
this.logger.debug('Context establishment hooks feature is disabled');
return;
}
const triggerNodes = this.types.nodes.filter((node: INodeTypeDescription) =>
node.group.includes('trigger'),
);
this.logger.debug(
`Injecting context establishment hooks for ${triggerNodes.length} trigger nodes`,
);
triggerNodes.forEach((node: INodeTypeDescription) => {
const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name);
if (hooks.length > 0) {
this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`);
}
// Only inject hook properties if there are applicable hooks
if (hooks.length === 0) return;
// Create a fixedCollection with multipleValues for multiple hook selection
// Each hook becomes a separate item that can be added multiple times
const allHookValues: INodeProperties[] = [
{
displayName: 'Hook',
name: 'hookName',
type: 'options',
options: hooks.map((hook) => {
const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name;
return {
name: displayName,
value: hook.hookDescription.name,
description: `Use ${displayName} hook`,
};
}),
// No default - force user to explicitly select a hook
// This ensures hookName is always serialized in the workflow JSON
default: '',
description: 'Select which context establishment hook to use',
required: true,
},
{
displayName: 'Allow Failure',
name: 'isAllowedToFail',
type: 'boolean',
default: false,
description: 'Whether to continue workflow execution if this hook fails',
},
];
// Add all hook-specific options with display conditions
for (const hook of hooks) {
const hookOptions = hook.hookDescription.options ?? [];
if (hookOptions.length > 0) {
for (const hookOption of hookOptions) {
// Add display condition to show only when this specific hook is selected
const enhancedOption: INodeProperties = {
...hookOption,
displayOptions: {
...hookOption.displayOptions,
show: {
...hookOption.displayOptions?.show,
hookName: [hook.hookDescription.name],
},
},
};
allHookValues.push(enhancedOption);
}
}
}
// Create a hidden version property to track the hooks format version
const executionsHooksVersion: INodeProperties = {
displayName: 'Executions Hooks Version',
name: 'executionsHooksVersion',
type: 'hidden',
default: 1,
};
// Create the main context establishment hooks property as a fixedCollection
const contextHooksProperty: INodeProperties = {
displayName: 'Context Establishment Hooks',
name: 'contextEstablishmentHooks',
type: 'fixedCollection',
placeholder: 'Add Hook',
default: {},
typeOptions: {
multipleValues: true,
},
options: [
{
name: 'hooks',
displayName: 'Hooks',
values: allHookValues,
},
],
description:
'Add and configure context establishment hooks to extract data from trigger items. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
};
// Create a notice that always appears after the hooks collection
const contextHooksNotice: INodeProperties = {
displayName:
'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/hooks/" target="_blank">Learn more</a>',
name: 'contextHooksNotice',
type: 'notice',
default: '',
};
node.properties.push(executionsHooksVersion);
node.properties.push(contextHooksProperty);
node.properties.push(contextHooksNotice);
});
}
/**
* Run a loader of source files of nodes and credentials in a directory.
*/
@ -415,6 +539,8 @@ export class LoadNodesAndCredentials {
this.injectCustomApiCallOptions();
this.injectContextEstablishmentHooks();
for (const postProcessor of this.postProcessors) {
await postProcessor();
}