perf(core): Resolve webhook description fields via declared native resolvers (#35404)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mike Repeć
2026-08-06 13:44:14 +02:00
committed by GitHub
parent 1a5642821a
commit 18c2d715e3
21 changed files with 1521 additions and 107 deletions
@@ -61,4 +61,12 @@ export class ExpressionEngineConfig {
/** If set, scale the pool to 0 warm isolates after this many seconds with no acquire. */
@Env('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT')
idleTimeout?: number;
/**
* Whether a production webhook request may skip acquiring an isolate when its
* trigger provably evaluates no expression during the webhook phase. Off
* acquires one for every request.
*/
@Env('N8N_EXPRESSION_ENGINE_ALLOW_WEBHOOK_ISOLATE_SKIP')
allowWebhookIsolateSkip: boolean = true;
}
+1
View File
@@ -636,6 +636,7 @@ describe('GlobalConfig', () => {
tracesEnabled: true,
slowEvaluationThresholdMs: 50,
tracesSampleRate: 0.0,
allowWebhookIsolateSkip: true,
},
instanceSettingsLoader: {
ownerManagedByEnv: false,
@@ -1,18 +1,28 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { WorkflowsConfig } from '@n8n/config';
import type { ExpressionEngineConfig, WorkflowsConfig } from '@n8n/config';
import type { WebhookEntity, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
import type { Response } from 'express';
import type {
IConnections,
IHttpRequestMethods,
INode,
INodeParameters,
INodeProperties,
INodeType,
IWebhookData,
IWebhookDescription,
IWorkflowBase,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import type { Mock } from 'vitest';
import {
fromFunction,
fromParameter,
WEBHOOK_NODE_TYPE,
webhookDescriptionFields,
WorkflowExpression,
} from 'n8n-workflow';
import type { Mock, MockInstance } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
@@ -39,11 +49,16 @@ describe('LiveWebhooks', () => {
const workflowStaticDataService = mock<WorkflowStaticDataService>();
const workflowsConfig = mock<WorkflowsConfig>({ useWorkflowPublicationService: false });
const workflowPublishedDataService = mock<WorkflowPublishedDataService>();
const expressionEngineConfig = mock<ExpressionEngineConfig>({
allowWebhookIsolateSkip: true,
});
let liveWebhooks: LiveWebhooks;
beforeEach(() => {
vi.clearAllMocks();
// `clearAllMocks` resets call history, not properties set on a mock
expressionEngineConfig.allowWebhookIsolateSkip = true;
liveWebhooks = new LiveWebhooks(
mockLogger(),
nodeTypes,
@@ -52,6 +67,7 @@ describe('LiveWebhooks', () => {
workflowStaticDataService,
workflowsConfig,
workflowPublishedDataService,
expressionEngineConfig,
);
// Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues
@@ -603,4 +619,253 @@ describe('LiveWebhooks', () => {
).resolves.toBeDefined();
});
});
describe('webhook-phase isolate acquisition', () => {
const nodeName = 'Trigger';
const webhookPath = 'my-path';
const httpMethod: IHttpRequestMethods = 'GET';
const nodeProperties: INodeProperties[] = [
{ displayName: 'Path', name: 'path', type: 'string', default: '' },
{ displayName: 'Method', name: 'httpMethod', type: 'string', default: 'GET' },
{ displayName: 'Code', name: 'responseCode', type: 'number', default: 200 },
{ displayName: 'Auth', name: 'authentication', type: 'string', default: 'none' },
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
options: [{ displayName: 'Only Run If', name: 'onlyRunIf', type: 'string', default: '' }],
},
];
// Mirrors the real Webhook node's description: every templated field is
// declared once, so it carries a native resolver.
const nativelyResolvableWebhooks: IWebhookDescription[] = [
{
name: 'default',
isFullPath: true,
...webhookDescriptionFields({
path: fromParameter('path'),
httpMethod: fromParameter('httpMethod', 'GET'),
responseCode: fromFunction((p: INodeParameters) => p.responseCode as number),
}),
},
];
const setupMocks = ({
nodeType = WEBHOOK_NODE_TYPE,
typeVersion = 2.1,
parameters = { path: webhookPath, httpMethod },
webhooks = nativelyResolvableWebhooks,
credentials,
}: {
nodeType?: string;
typeVersion?: number;
parameters?: INodeParameters;
webhooks?: IWebhookDescription[];
credentials?: INode['credentials'];
} = {}) => {
const node: INode = {
id: 'trigger-node',
name: nodeName,
type: nodeType,
typeVersion,
position: [0, 0],
parameters,
credentials,
};
const activeVersion = mock<WorkflowHistory>({
versionId: 'v1',
workflowId: WORKFLOW_ID,
nodes: [node],
connections: {},
});
workflowRepository.findOne.mockResolvedValue(
mock<WorkflowEntity>({
id: WORKFLOW_ID,
active: true,
activeVersionId: 'v1',
nodes: [node],
connections: {},
staticData: {},
activeVersion,
shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }],
}),
);
webhookService.findWebhook.mockResolvedValue(
mock<WebhookEntity>({
workflowId: WORKFLOW_ID,
node: nodeName,
webhookPath,
method: httpMethod,
isDynamic: false,
}),
);
webhookService.getWebhookMethods.mockResolvedValue([httpMethod]);
nodeTypes.getByNameAndVersion.mockReturnValue({
description: {
displayName: 'Trigger',
name: nodeType,
group: ['trigger'],
version: 2.1,
description: '',
defaults: { name: 'Trigger' },
inputs: [],
outputs: ['main'],
properties: nodeProperties,
webhooks,
},
webhook: vi.fn(),
});
webhookService.getNodeWebhooks.mockReturnValue([
mock<IWebhookData>({
httpMethod,
path: webhookPath,
node: nodeName,
webhookDescription: { nodeType: undefined } as never,
workflowId: WORKFLOW_ID,
}),
]);
// eslint-disable-next-line @typescript-eslint/require-await
(WebhookHelpers.executeWebhook as Mock).mockImplementation(async (...args: unknown[]) => {
const webhookCallback = args[args.length - 1] as (
error: Error | null,
data: object,
) => void;
void webhookCallback(null, {});
});
return mock<WebhookRequest>({ method: httpMethod, params: { path: webhookPath } });
};
let acquireIsolate: MockInstance<WorkflowExpression['acquireIsolate']>;
let releaseIsolate: MockInstance<WorkflowExpression['releaseIsolate']>;
beforeEach(() => {
acquireIsolate = vi
.spyOn(WorkflowExpression.prototype, 'acquireIsolate')
.mockResolvedValue(true);
releaseIsolate = vi
.spyOn(WorkflowExpression.prototype, 'releaseIsolate')
.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('skips acquisition for a Webhook node with no expression parameters', async () => {
const request = setupMocks();
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).not.toHaveBeenCalled();
// Still released: a no-op here, but correct if the phase acquired one.
expect(releaseIsolate).toHaveBeenCalled();
});
it('acquires when a node parameter holds an expression', async () => {
const request = setupMocks({
parameters: {
path: webhookPath,
httpMethod,
options: { onlyRunIf: '={{ $json.body.id === 1 }}' },
},
});
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('acquires for typeVersion 1, whose body parsing evaluates a template', async () => {
const request = setupMocks({ typeVersion: 1 });
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('still skips for the n8nOAuth2 authentication mode', async () => {
const request = setupMocks({
parameters: { path: webhookPath, httpMethod, authentication: 'n8nOAuth2' },
});
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).not.toHaveBeenCalled();
});
it('acquires for a trigger type that is not on the allowlist', async () => {
const request = setupMocks({ nodeType: 'n8n-nodes-base.formTrigger' });
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('acquires when a webhook description has a template it cannot resolve natively', async () => {
const request = setupMocks({
webhooks: [
{
name: 'default',
httpMethod: 'POST',
path: '={{$parameter["path"]}}',
responseCode: '={{(function (p) { return p.responseCode; })($parameter)}}',
},
],
});
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('acquires when a webhook description nests a template it cannot resolve natively', async () => {
const request = setupMocks({
webhooks: [
{
name: 'default',
httpMethod: 'POST',
path: '={{$parameter["path"]}}',
responseHeaders: { entries: [{ name: 'x-a', value: '={{ $json.body.a }}' }] },
} as unknown as IWebhookDescription,
],
});
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('acquires when the node type declares no webhooks', async () => {
const request = setupMocks({ webhooks: [] });
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
it('still skips for a node that uses credentials', async () => {
const request = setupMocks({ credentials: { httpHeaderAuth: { id: '1', name: 'auth' } } });
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).not.toHaveBeenCalled();
});
it('acquires when the kill switch is off', async () => {
expressionEngineConfig.allowWebhookIsolateSkip = false;
const request = setupMocks();
await liveWebhooks.executeWebhook(request, mock<Response>());
expect(acquireIsolate).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,107 @@
import type {
INode,
INodeParameters,
IWebhookData,
IWorkflowDataProxyAdditionalKeys,
Workflow,
} from 'n8n-workflow';
import { fromFunction, fromParameter, webhookDescriptionFields } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { WebhookExecutionContext } from '../webhook-execution-context';
// Every description-field read goes through this class, so the native-resolver
// short-circuit has to hold on each one: a field that reaches the engine 500s
// on a request that skipped acquiring an isolate.
describe('WebhookExecutionContext', () => {
const workflow = mock<Workflow>({
expression: mock<Workflow['expression']>(),
});
const webhookDescription = {
name: 'default' as const,
httpMethod: 'POST',
...webhookDescriptionFields({
path: fromParameter('path'),
responseData: fromFunction((p: INodeParameters) =>
p.responseMode === 'lastNode' ? 'noData' : undefined,
),
}),
};
const buildContext = (node: INode) =>
new WebhookExecutionContext(
workflow,
node,
// A plain object, not `mock<IWebhookData>`: a deep mock auto-creates a
// resolver for every field, defeating the point of these tests.
{ webhookDescription } as unknown as IWebhookData,
'trigger',
mock<IWorkflowDataProxyAdditionalKeys>(),
);
beforeEach(() => vi.clearAllMocks());
describe('with static node parameters', () => {
const context = buildContext(
mock<INode>({ name: 'Webhook', parameters: { path: 'my-path', responseMode: 'lastNode' } }),
);
it('resolves a simple value natively without touching the engine', () => {
expect(context.evaluateSimpleWebhookDescriptionExpression('path')).toBe('my-path');
expect(workflow.expression.getSimpleParameterValue).not.toHaveBeenCalled();
});
it('resolves a complex value natively without touching the engine', () => {
expect(context.evaluateComplexWebhookDescriptionExpression('responseData')).toBe('noData');
expect(workflow.expression.getComplexParameterValue).not.toHaveBeenCalled();
});
it('returns a natively resolved undefined as-is, not the default value', () => {
const undefinedContext = buildContext(
mock<INode>({ name: 'Webhook', parameters: { responseMode: 'onReceived' } }),
);
expect(
undefinedContext.evaluateComplexWebhookDescriptionExpression(
'responseData',
undefined,
'firstEntryJson',
),
).toBeUndefined();
expect(workflow.expression.getComplexParameterValue).not.toHaveBeenCalled();
});
it('falls back to the engine for a field without a resolver', () => {
context.evaluateSimpleWebhookDescriptionExpression('httpMethod');
expect(workflow.expression.getSimpleParameterValue).toHaveBeenCalledWith(
expect.anything(),
'POST',
'trigger',
expect.anything(),
undefined,
undefined,
);
});
});
describe('with an expression in the node parameters', () => {
const context = buildContext(
mock<INode>({ name: 'Webhook', parameters: { path: '={{ $json.path }}' } }),
);
it('falls back to the engine even for a field with a resolver', () => {
context.evaluateSimpleWebhookDescriptionExpression('path');
expect(workflow.expression.getSimpleParameterValue).toHaveBeenCalledWith(
expect.anything(),
'={{$parameter["path"]}}',
'trigger',
expect.anything(),
undefined,
undefined,
);
});
});
});
@@ -7,7 +7,12 @@ import type {
IWebhookData,
IWorkflowExecuteAdditionalData,
} from 'n8n-workflow';
import { Workflow, WebhookPathTakenError } from 'n8n-workflow';
import {
Workflow,
WebhookPathTakenError,
webhookDescriptionFields,
fromParameter,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
@@ -590,6 +595,45 @@ describe('WebhookService', () => {
expect(webhooks[0].path).not.toMatch(/\s/);
expect(webhooks[0].path).toMatch(/\/path$/);
});
test('should resolve declared fields natively, without the expression engine', async () => {
const node = {
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
disabled: false,
parameters: { path: 'native-path', httpMethod: 'POST' },
} as unknown as INode;
const fields = webhookDescriptionFields({
httpMethod: fromParameter('httpMethod', 'GET'),
path: fromParameter('path'),
});
const nodeType = {
description: {
webhooks: [
{
name: 'default',
...fields,
isFullPath: false,
restartWebhook: false,
},
],
},
} as INodeType;
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
const engineSpy = vi.spyOn(workflow.expression, 'getSimpleParameterValue');
const webhooks = webhookService.getNodeWebhooks(workflow, node, additionalData);
expect(webhooks).toHaveLength(1);
expect(webhooks[0]).toMatchObject({ httpMethod: 'POST' });
expect(webhooks[0].path).toMatch(/\/native-path$/);
// fields with declared resolvers must never engage the expression engine
const engineEvaluatedValues = engineSpy.mock.calls.map((call) => call[1]);
expect(engineEvaluatedValues).not.toContain(fields.path);
expect(engineEvaluatedValues).not.toContain(fields.httpMethod);
});
});
describe('createWebhookIfNotExists()', () => {
+44 -4
View File
@@ -1,9 +1,15 @@
import { Logger } from '@n8n/backend-common';
import { WorkflowsConfig } from '@n8n/config';
import { ExpressionEngineConfig, WorkflowsConfig } from '@n8n/config';
import { WorkflowRepository, type WorkflowEntity, type WorkflowHistory } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Response } from 'express';
import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import {
Workflow,
CHAT_TRIGGER_NODE_TYPE,
WEBHOOK_NODE_TYPE,
nodeParametersAreStatic,
webhookDescriptionIsNativelyResolvable,
} from 'n8n-workflow';
import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -41,6 +47,7 @@ export class LiveWebhooks implements IWebhookManager {
private readonly workflowStaticDataService: WorkflowStaticDataService,
private readonly workflowsConfig: WorkflowsConfig,
private readonly workflowPublishedDataService: WorkflowPublishedDataService,
private readonly expressionEngineConfig: ExpressionEngineConfig,
) {}
async getWebhookMethods(path: string) {
@@ -131,10 +138,15 @@ export class LiveWebhooks implements IWebhookManager {
projectId: ownerProjectId,
});
await workflow.expression.acquireIsolate();
const startNode = workflow.getNode(webhook.node);
if (this.webhookPhaseNeedsIsolate(startNode)) {
await workflow.expression.acquireIsolate();
}
try {
const webhookData = this.webhookService
.getNodeWebhooks(workflow, workflow.getNode(webhook.node) as INode, additionalData)
.getNodeWebhooks(workflow, startNode as INode, additionalData)
.find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData;
if (
@@ -183,10 +195,38 @@ export class LiveWebhooks implements IWebhookManager {
).catch(reject); // ensure the Promise settles even if executeWebhook throws
});
} finally {
// A no-op when the acquire was skipped.
await workflow.expression.releaseIsolate();
}
}
/**
* Expression Engine VM acquisition builds a V8 isolate per request, which is
* worth skipping when the webhook phase provably evaluates nothing: every
* description field of the trigger resolves natively (see
* `webhookDescriptionFields` in n8n-workflow) and the node's own parameters
* contain no expressions. Anything not proven below acquires eagerly.
*/
private webhookPhaseNeedsIsolate(startNode: INode | null): boolean {
if (!this.expressionEngineConfig.allowWebhookIsolateSkip) return true;
if (startNode === null) return true;
// Extend only after reviewing the node type's webhook() for
// evaluateExpression() calls or other internal evaluations.
if (startNode.type !== WEBHOOK_NODE_TYPE) return true;
// typeVersion 1 body parsing evaluates a hardcoded template.
if (startNode.typeVersion === 1) return true;
if (!nodeParametersAreStatic(startNode)) return true;
const webhooks = this.nodeTypes.getByNameAndVersion(startNode.type, startNode.typeVersion)
?.description.webhooks;
if (!webhooks?.length) return true;
return !webhooks.every(webhookDescriptionIsNativelyResolvable);
}
private async loadWebhookExecutionData(
workflowId: string,
): Promise<{ workflow: WorkflowEntity; publishedVersion: WorkflowHistory }> {
@@ -8,6 +8,10 @@ import type {
IWebhookDescription,
NodeParameterValueType,
} from 'n8n-workflow';
import { resolveWebhookDescriptionField } from 'n8n-workflow';
/** The description's evaluable fields — the symbol key holds the resolver map. */
type WebhookDescriptionKey = Exclude<keyof IWebhookDescription, symbol>;
/**
* A helper class that holds the context for the webhook execution.
@@ -26,10 +30,13 @@ export class WebhookExecutionContext {
* Evaluates a simple expression from the webhook description.
*/
evaluateSimpleWebhookDescriptionExpression<T extends boolean | number | string | unknown[]>(
propertyName: keyof IWebhookDescription,
propertyName: WebhookDescriptionKey,
executeData?: IExecuteData,
defaultValue?: T,
): T | undefined {
const native = this.resolveNatively(propertyName);
if (native.resolved) return native.value as T | undefined;
return this.workflow.expression.getSimpleParameterValue(
this.workflowStartNode,
this.webhookData.webhookDescription[propertyName],
@@ -44,10 +51,13 @@ export class WebhookExecutionContext {
* Evaluates a complex expression from the webhook description.
*/
evaluateComplexWebhookDescriptionExpression<T extends NodeParameterValueType>(
propertyName: keyof IWebhookDescription,
propertyName: WebhookDescriptionKey,
executeData?: IExecuteData,
defaultValue?: T,
): T | undefined {
const native = this.resolveNatively(propertyName);
if (native.resolved) return native.value as T | undefined;
return this.workflow.expression.getComplexParameterValue(
this.workflowStartNode,
this.webhookData.webhookDescription[propertyName],
@@ -57,4 +67,19 @@ export class WebhookExecutionContext {
defaultValue,
) as T | undefined;
}
/**
* Resolves a description field without the expression engine when the field
* declares a native resolver and the node's parameters are static (see
* `webhookDescriptionFields` in n8n-workflow). Like the engine path, the
* resolved value is returned as-is: `defaultValue` only stands in for a
* field the description does not define at all.
*/
private resolveNatively(propertyName: WebhookDescriptionKey) {
return resolveWebhookDescriptionField(
this.workflowStartNode,
this.webhookData.webhookDescription,
String(propertyName),
);
}
}
+19 -53
View File
@@ -543,14 +543,7 @@ export async function executeWebhook(
responsePropertyName,
responseContentType,
responseBinaryPropertyName,
} = evaluateResponseOptions(
workflowStartNode,
workflow,
req,
webhookData,
executionMode,
additionalKeys,
);
} = evaluateResponseOptions(context, req);
if (
!['onReceived', 'lastNode', 'responseNode', 'formPage', 'streaming', 'hostedChat'].includes(
@@ -1137,71 +1130,44 @@ export async function executeWebhook(
/**
* Evaluates the response mode, code and data for a webhook node
*/
function evaluateResponseOptions(
workflowStartNode: INode,
workflow: Workflow,
req: WebhookRequest,
webhookData: IWebhookData,
executionMode: WorkflowExecuteMode,
additionalKeys: IWorkflowDataProxyAdditionalKeys,
) {
function evaluateResponseOptions(context: WebhookExecutionContext, req: WebhookRequest) {
const { workflow, workflowStartNode } = context;
//check if response mode should be set automatically, e.g. multipage form
const responseMode =
autoDetectResponseMode(workflowStartNode, workflow, req.method) ??
(workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseMode,
executionMode,
additionalKeys,
context.evaluateSimpleWebhookDescriptionExpression<WebhookResponseMode>(
'responseMode',
undefined,
'onReceived',
) as WebhookResponseMode);
)!;
const responseCode = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseCode as string,
executionMode,
additionalKeys,
const responseCode = context.evaluateSimpleWebhookDescriptionExpression<number>(
'responseCode',
undefined,
200,
) as number;
)!;
// This parameter is used for two different purposes:
// 1. as arbitrary string input defined in the workflow in the "respond immediately" mode,
// 2. as well as WebhookResponseData config in all the other modes
const responseData = workflow.expression.getComplexParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseData,
executionMode,
additionalKeys,
undefined,
'firstEntryJson',
) as WebhookResponseData | string | undefined;
const responseData = context.evaluateComplexWebhookDescriptionExpression<
WebhookResponseData | string
>('responseData', undefined, 'firstEntryJson');
// This is needed for backward compatibility, where only the first main output was checked for data.
// We want to keep existing behavior for webhooks, but change for chat triggers, where checking all main outputs makes more sense.
// We can unify the behavior in the next major release and get rid of this flag
const checkAllMainOutputs = workflowStartNode.type === CHAT_TRIGGER_NODE_TYPE;
const responsePropertyName = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responsePropertyName,
executionMode,
additionalKeys,
) as string | undefined;
const responsePropertyName =
context.evaluateSimpleWebhookDescriptionExpression<string>('responsePropertyName');
const responseContentType = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseContentType,
executionMode,
additionalKeys,
) as string | undefined;
const responseContentType =
context.evaluateSimpleWebhookDescriptionExpression<string>('responseContentType');
const responseBinaryPropertyName = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseBinaryPropertyName,
executionMode,
additionalKeys,
const responseBinaryPropertyName = context.evaluateSimpleWebhookDescriptionExpression<string>(
'responseBinaryPropertyName',
undefined,
'data',
);
+45 -21
View File
@@ -7,6 +7,7 @@ import { ensureError } from '@n8n/utils/errors/ensure-error';
import {
isNodeClassInstance,
NodeHelpers,
resolveWebhookDescriptionField,
UnexpectedError,
WebhookPathTakenError,
} from 'n8n-workflow';
@@ -15,6 +16,7 @@ import type {
INode,
IRunExecutionData,
IWebhookData,
IWebhookDescription,
IWebhookResponseData,
IWorkflowExecuteAdditionalData,
WebhookSetupMethodNames,
@@ -331,7 +333,6 @@ export class WebhookService {
}
const workflowId = workflow.id || '__UNSAVED__';
const mode = 'internal';
const returnData: IWebhookData[] = [];
for (const webhookDescription of nodeType.description.webhooks) {
@@ -339,13 +340,13 @@ export class WebhookService {
continue;
}
let nodeWebhookPath = workflow.expression.getSimpleParameterValue(
let nodeWebhookPath = this.evaluateDescriptionProperty(
workflow,
node,
webhookDescription.path,
mode,
{},
webhookDescription,
'path',
);
if (nodeWebhookPath === undefined) {
if (nodeWebhookPath === undefined || nodeWebhookPath === null) {
this.logger.error(
`No webhook path could be found for node "${node.name}" in workflow "${workflowId}".`,
);
@@ -361,20 +362,18 @@ export class WebhookService {
nodeWebhookPath = nodeWebhookPath.slice(0, -1);
}
const isFullPath: boolean = workflow.expression.getSimpleParameterValue(
const isFullPath = this.evaluateDescriptionProperty(
workflow,
node,
webhookDescription.isFullPath,
'internal',
{},
undefined,
webhookDescription,
'isFullPath',
false,
) as boolean;
const restartWebhook: boolean = workflow.expression.getSimpleParameterValue(
const restartWebhook = this.evaluateDescriptionProperty(
workflow,
node,
webhookDescription.restartWebhook,
'internal',
{},
undefined,
webhookDescription,
'restartWebhook',
false,
) as boolean;
const path = NodeHelpers.getNodeWebhookPath(
@@ -385,12 +384,11 @@ export class WebhookService {
restartWebhook,
);
const webhookMethods = workflow.expression.getSimpleParameterValue(
const webhookMethods = this.evaluateDescriptionProperty(
workflow,
node,
webhookDescription.httpMethod,
mode,
{},
undefined,
webhookDescription,
'httpMethod',
'GET',
);
@@ -426,6 +424,32 @@ export class WebhookService {
return returnData;
}
/**
* Evaluates a webhook-description property, preferring the field's native
* resolver (see `webhookDescriptionFields` in n8n-workflow) so static-parameter
* nodes never engage the expression engine. Falls back to the engine, which
* returns plain values as-is and only evaluates `=` templates.
*/
private evaluateDescriptionProperty(
workflow: Workflow,
node: INode,
webhookDescription: IWebhookDescription,
property: string,
defaultValue?: string | boolean,
) {
const native = resolveWebhookDescriptionField(node, webhookDescription, property);
if (native.resolved) return native.value;
return workflow.expression.getSimpleParameterValue(
node,
webhookDescription[property],
'internal',
{},
undefined,
defaultValue,
);
}
private async _findWebhookConflicts(
workflow: Workflow,
checkEntries: Array<{
@@ -7,7 +7,7 @@ import type {
IWorkflowDataProxyAdditionalKeys,
IWebhookDescription,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { NodeHelpers, resolveWebhookDescriptionField } from 'n8n-workflow';
/** Returns the full webhook description of the webhook with the given name */
export function getWebhookDescription(
@@ -51,21 +51,31 @@ export function getNodeWebhookUrl(
baseUrl = isTest === true ? additionalData.webhookTestBaseUrl : additionalData.webhookBaseUrl;
}
const path = workflow.expression.getSimpleParameterValue(
node,
webhookDescription.path,
mode,
additionalKeys,
);
if (path === undefined) return;
// Prefer the field's native resolver (see `webhookDescriptionFields` in
// n8n-workflow) so static-parameter nodes never engage the expression engine.
const nativePath = resolveWebhookDescriptionField(node, webhookDescription, 'path');
const path = nativePath.resolved
? nativePath.value
: workflow.expression.getSimpleParameterValue(
node,
webhookDescription.path,
mode,
additionalKeys,
);
if (path === undefined || path === null) return;
const isFullPath: boolean = workflow.expression.getSimpleParameterValue(
node,
webhookDescription.isFullPath,
mode,
additionalKeys,
undefined,
false,
const nativeIsFullPath = resolveWebhookDescriptionField(node, webhookDescription, 'isFullPath');
const isFullPath = (
nativeIsFullPath.resolved
? nativeIsFullPath.value
: workflow.expression.getSimpleParameterValue(
node,
webhookDescription.isFullPath,
mode,
additionalKeys,
undefined,
false,
)
) as boolean;
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id, node, path.toString(), isFullPath);
}
@@ -4,6 +4,7 @@ import type {
INodeTypeDescription,
IWebhookDescription,
} from 'n8n-workflow';
import { fromFunction, fromParameter, webhookDescriptionFields } from 'n8n-workflow';
import { getResponseCode, getResponseData } from './utils';
@@ -20,18 +21,23 @@ const n8nOAuth2AuthOption: INodePropertyOptions = {
envFeatureFlag: 'WEBHOOK_PRIVATE_CREDENTIALS',
};
// Each field declares its expression template and native resolver in one place:
// the editor evaluates the generated template strings, while the backend reads
// parameters directly (no expression engine) whenever they are static.
export const defaultWebhookDescription: IWebhookDescription = {
name: 'default',
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
isFullPath: true,
responseCode: `={{(${getResponseCode})($parameter)}}`,
responseMode: '={{$parameter["responseMode"]}}',
responseData: `={{(${getResponseData})($parameter)}}`,
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
responseHeaders: '={{$parameter["options"]["responseHeaders"]}}',
path: '={{$parameter["path"]}}',
...webhookDescriptionFields({
httpMethod: fromParameter('httpMethod', 'GET'),
responseCode: fromFunction(getResponseCode),
responseMode: fromParameter('responseMode'),
responseData: fromFunction(getResponseData),
responseBinaryPropertyName: fromParameter('responseBinaryPropertyName'),
responseContentType: fromParameter(['options', 'responseContentType']),
responsePropertyName: fromParameter(['options', 'responsePropertyName']),
responseHeaders: fromParameter(['options', 'responseHeaders']),
path: fromParameter('path'),
}),
};
export const credentialsProperty = (
@@ -0,0 +1,119 @@
import type { IDataObject, INodeParameters, INodeType, INodeTypes } from 'n8n-workflow';
import { Workflow, WEBHOOK_RESOLVERS, webhookDescriptionIsNativelyResolvable } from 'n8n-workflow';
import { defaultWebhookDescription } from '../description';
import { Webhook } from '../Webhook.node';
// Pins what lets `LiveWebhooks` resolve this description without the expression
// engine: every field declares a native resolver (or is a plain value), each
// resolver returns exactly what its template returns through the engine, and
// the generated template strings are the long-standing hand-written ones the
// editor keeps evaluating.
const webhookNode = new Webhook();
const nodeTypes: INodeTypes = {
getByName: () => webhookNode as unknown as INodeType,
getByNameAndVersion: () => webhookNode as unknown as INodeType,
getKnownTypes: () => ({}) as IDataObject,
};
const nodeWithParameters = (parameters: INodeParameters) => {
const workflow = new Workflow({
id: '1',
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2.1,
id: 'webhook-1',
position: [0, 0],
parameters,
},
],
connections: {},
active: true,
nodeTypes,
});
return { workflow, node: workflow.getNode('Webhook')! };
};
describe('defaultWebhookDescription', () => {
it('is fully resolvable without the expression engine', () => {
expect(webhookDescriptionIsNativelyResolvable(defaultWebhookDescription)).toBe(true);
});
it('generates the same template strings the description has always shipped', () => {
expect(defaultWebhookDescription).toMatchObject({
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
responseMode: '={{$parameter["responseMode"]}}',
responseBinaryPropertyName: '={{$parameter["responseBinaryPropertyName"]}}',
responseContentType: '={{$parameter["options"]["responseContentType"]}}',
responsePropertyName: '={{$parameter["options"]["responsePropertyName"]}}',
responseHeaders: '={{$parameter["options"]["responseHeaders"]}}',
path: '={{$parameter["path"]}}',
});
// The function-body templates inline the functions' source, as before.
expect(defaultWebhookDescription.responseCode).toMatch(/^=\{\{\(.+\)\(\$parameter\)\}\}$/s);
expect(defaultWebhookDescription.responseData).toMatch(/^=\{\{\(.+\)\(\$parameter\)\}\}$/s);
});
describe('resolvers match their templates', () => {
const parameterSets: Array<{ case: string; parameters: INodeParameters }> = [
{ case: 'defaults only', parameters: {} },
{
case: 'respond immediately with a fixed body',
parameters: { responseMode: 'onReceived', options: {} },
},
{
case: 'last node, all entries',
parameters: { responseMode: 'lastNode', responseData: 'allEntries', options: {} },
},
{ case: 'respond via node', parameters: { responseMode: 'responseNode', options: {} } },
{
case: 'no response body',
parameters: { responseMode: 'onReceived', options: { noResponseBody: true } },
},
{
case: 'response data from options',
parameters: { responseMode: 'onReceived', options: { responseData: 'noData' } },
},
{
case: 'custom status code',
parameters: { options: { responseCode: { values: { responseCode: 201 } } } },
},
{
case: 'custom status code with a custom value',
parameters: { options: { responseCode: { values: { responseCode: 0, customCode: 418 } } } },
},
{
case: 'legacy typeVersion 1 status code',
parameters: { responseCode: 302, options: {} },
},
{
case: 'multiple methods',
parameters: { httpMethod: ['GET', 'POST'], multipleMethods: true, options: {} },
},
];
const resolvers = defaultWebhookDescription[WEBHOOK_RESOLVERS]!;
describe.each(parameterSets)('$case', ({ parameters }) => {
const { workflow, node } = nodeWithParameters(parameters);
test.each(Object.keys(resolvers))('%s', (field) => {
const native = resolvers[field].resolve(node.parameters);
const viaEngine = workflow.expression.getSimpleParameterValue(
node,
defaultWebhookDescription[field] as string,
'internal',
{},
);
expect(native).toEqual(viaEngine);
});
});
});
});
@@ -21,6 +21,7 @@ import { DynamicCredentialApiHelper } from './dynamic-credential-api-helper';
import { ExternalSecretsApiHelper } from './external-secrets-api-helper';
import { McpApiHelper } from './mcp-api-helper';
import { McpOAuthApiHelper } from './mcp-oauth-api-helper';
import { MetricsApiHelper } from './metrics-api-helper';
import { ProjectApiHelper } from './project-api-helper';
import { PublicApiHelper } from './public-api-helper';
import { RoleApiHelper } from './role-api-helper';
@@ -69,6 +70,7 @@ export class ApiHelpers {
request: APIRequestContext;
workflows: WorkflowApiHelper;
webhooks: WebhookApiHelper;
metrics: MetricsApiHelper;
mcp: McpApiHelper;
mcpOauth: McpOAuthApiHelper;
projects: ProjectApiHelper;
@@ -89,6 +91,7 @@ export class ApiHelpers {
this.request = requestContext;
this.workflows = new WorkflowApiHelper(this);
this.webhooks = new WebhookApiHelper(this);
this.metrics = new MetricsApiHelper(this);
this.mcp = new McpApiHelper(this);
this.mcpOauth = new McpOAuthApiHelper(this);
this.projects = new ProjectApiHelper(this);
@@ -0,0 +1,21 @@
import { TestError } from '../Types';
import type { ApiHelpers } from './api-helper';
/** Reads Prometheus counters from `/metrics` (requires `N8N_METRICS=true`). */
export class MetricsApiHelper {
constructor(private readonly api: ApiHelpers) {}
/** Returns the value of the first metric line starting with `name`, or 0 when absent. */
async getCounter(name: string): Promise<number> {
const response = await this.api.request.get('/metrics');
if (!response.ok()) {
throw new TestError(
`Failed to fetch /metrics (is N8N_METRICS enabled?): ${response.status()}`,
);
}
const metrics = await response.text();
const line = metrics.split('\n').find((l) => l.startsWith(name));
return line ? Number(line.split(' ')[1]) : 0;
}
}
@@ -0,0 +1,184 @@
import { test, expect } from '../../../fixtures/base';
import type { ApiHelpers } from '../../../services/api-helper';
/**
* A production webhook whose trigger provably evaluates nothing skips acquiring
* an expression isolate for the webhook phase (`LiveWebhooks`). That proof is a
* scan of declared values — it cannot see an `evaluateExpression()` call added
* to a node's `webhook()` or to a helper it calls. Such a call throws
* `IsolateError: No bridge acquired` under `N8N_EXPRESSION_ENGINE=vm`, which is
* what these tests guard: a new evaluation on the webhook phase turns the
* request into a 500.
*/
const ACQUIRED_METRIC = 'n8n_expression_pool_acquired_total';
// The acquire counter is instance-global, so the metric-delta tests must not
// run concurrently with this file's other webhook-firing tests.
test.describe.configure({ mode: 'default' });
async function isolateAcquires(api: ApiHelpers) {
return await api.metrics.getCounter(ACQUIRED_METRIC);
}
/** Acquires attributable to one request, webhook phase and execution together. */
async function acquiresForTrigger(api: ApiHelpers, workflowFile: string, expected: string) {
const { webhookPath } = await api.workflows.importWorkflowFromFile(workflowFile);
const before = await isolateAcquires(api);
const response = await api.webhooks.trigger(`/webhook/${webhookPath}`, { method: 'POST' });
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ result: expected });
return (await isolateAcquires(api)) - before;
}
/**
* Imports the static workflow with a transform over the trigger's parameters
* (and optionally the node itself), then triggers it. Every case in the
* request-path matrix below runs with the webhook-phase isolate skipped, so a
* newly added evaluation anywhere on that path fails these as a 500.
*/
async function triggerTransformed(
api: ApiHelpers,
transformNode: (node: {
typeVersion: number;
parameters: Record<string, unknown>;
}) => void,
requestOptions: Parameters<ApiHelpers['webhooks']['trigger']>[1] = {},
) {
const { webhookPath } = await api.workflows.importWorkflowFromFile(
'webhook-isolate-skip-static.json',
{
transform: (workflow) => {
transformNode(workflow.nodes![0] as never);
return workflow;
},
},
);
return await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
...requestOptions,
});
}
test.describe(
'Webhook isolate skip',
{ annotation: [{ type: 'owner', description: 'Catalysts' }] },
() => {
test('skips the webhook-phase isolate only when the trigger evaluates nothing', async ({
api,
}) => {
// Identical workflows but for the trigger's parameters, so the difference
// in acquires is the webhook phase — the execution costs the same in both
const staticAcquires = await acquiresForTrigger(
api,
'webhook-isolate-skip-static.json',
'static-ok',
);
const expressionAcquires = await acquiresForTrigger(
api,
'webhook-isolate-skip-expression.json',
'expression-ok',
);
expect(expressionAcquires).toBeGreaterThan(staticAcquires);
});
test('serves an immediate response with custom code, body and headers natively', async ({
api,
}) => {
const response = await triggerTransformed(api, (node) => {
node.parameters.responseMode = 'onReceived';
node.parameters.options = {
responseData: 'custom-body',
responseCode: { values: { responseCode: 202 } },
responseHeaders: { entries: [{ name: 'x-isolate-skip', value: 'native' }] },
};
});
expect(response.status()).toBe(202);
expect(await response.text()).toBe('custom-body');
expect(response.headers()['x-isolate-skip']).toBe('native');
});
test('serves an empty-body response natively', async ({ api }) => {
const response = await triggerTransformed(api, (node) => {
node.parameters.responseMode = 'onReceived';
node.parameters.options = { noResponseBody: true };
});
expect(response.status()).toBe(200);
expect(await response.text()).toBe('');
});
test('serves a last-node response with property selection and content type natively', async ({
api,
}) => {
const response = await triggerTransformed(api, (node) => {
node.parameters.options = {
responsePropertyName: 'result',
responseContentType: 'text/plain',
};
});
expect(response.ok()).toBe(true);
expect(response.headers()['content-type']).toContain('text/plain');
expect(await response.text()).toContain('static-ok');
});
test('parses a multipart body without the webhook-phase isolate', async ({ api }) => {
const boundary = '----isolateSkipBoundary';
const response = await triggerTransformed(api, () => {}, {
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
data: [
`--${boundary}`,
'Content-Disposition: form-data; name="field1"',
'',
'value1',
`--${boundary}--`,
'',
].join('\r\n'),
});
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ result: 'static-ok' });
});
test('acquires for typeVersion 1, whose body parsing evaluates a template', async ({ api }) => {
// Same comparative shape as the headline test: v1 must fall back to the
// engine, so it costs strictly more acquires than the gated v2 request.
const staticAcquires = await acquiresForTrigger(
api,
'webhook-isolate-skip-static.json',
'static-ok',
);
const before = await isolateAcquires(api);
const response = await triggerTransformed(api, (node) => {
node.typeVersion = 1;
});
expect(response.ok()).toBe(true);
const v1Acquires = (await isolateAcquires(api)) - before;
expect(v1Acquires).toBeGreaterThan(staticAcquires);
});
test('rejects an unauthenticated n8n user-auth request without the webhook-phase isolate', async ({
api,
}) => {
// The deepest skip-path case: `authentication: 'n8nOAuth2'` is a static
// parameter, so the skip applies, and the 401 is produced inside
// `webhook()` after resolving the webhook URL natively. Credential
// resolution never uses this workflow's bridge — a 500 here would mean
// it started to.
const response = await triggerTransformed(api, (node) => {
node.parameters.authentication = 'n8nOAuth2';
});
expect(response.status()).toBe(401);
expect(response.headers()['www-authenticate']).toContain('n8n Webhook');
});
},
);
@@ -0,0 +1,57 @@
{
"name": "Webhook Isolate Skip - Expression Parameter",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "isolate-skip-expression",
"responseMode": "lastNode",
"options": {
"onlyRunIf": "={{ true }}"
}
},
"id": "b1e1c9b0-0000-4000-8000-000000000011",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "b1e1c9b0-0000-4000-8000-000000000011"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result",
"name": "result",
"value": "expression-ok",
"type": "string"
}
]
},
"options": {}
},
"id": "b1e1c9b0-0000-4000-8000-000000000012",
"name": "Set Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [220, 0]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {},
"pinData": {}
}
@@ -0,0 +1,45 @@
{
"name": "Webhook Isolate Skip - Static Parameters",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "isolate-skip-static",
"responseMode": "lastNode",
"options": {}
},
"id": "b1e1c9b0-0000-4000-8000-000000000001",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "b1e1c9b0-0000-4000-8000-000000000001"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result",
"name": "result",
"value": "static-ok",
"type": "string"
}
]
},
"options": {}
},
"id": "b1e1c9b0-0000-4000-8000-000000000002",
"name": "Set Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [220, 0]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Set Result", "type": "main", "index": 0 }]] }
},
"settings": {},
"pinData": {}
}
+10
View File
@@ -15,6 +15,16 @@ export * from './dynamic-credentials-helpers';
export * from './safe-regex';
export * from './global-state';
export * from './interfaces';
export {
fromFunction,
fromParameter,
nodeParametersAreStatic,
resolveWebhookDescriptionField,
webhookDescriptionFields,
webhookDescriptionIsNativelyResolvable,
type NativeResolution,
type WebhookDescriptionField,
} from './webhook-description-fields';
export * from './sub-workflow-output';
export * from './run-execution-data-factory';
export * from './message-event-bus';
+23
View File
@@ -3004,8 +3004,31 @@ export interface IWebhookData {
export type WebhookType = 'default' | 'setup';
/**
* Key under which an {@link IWebhookDescription} holds native (engine-free)
* resolvers for its expression-template fields, keyed by field name. Populated
* by `webhookDescriptionFields()` and read via `resolveWebhookDescriptionField()`.
* Backend-only: not serialized with the description.
*/
export const WEBHOOK_RESOLVERS: unique symbol = Symbol.for('n8n.webhookDescriptionResolvers');
/**
* Native resolvers for a webhook description's fields, keyed by field name.
* Each entry pairs the expression template a field carries with a function
* computing the same value from the node's parameters, without the expression
* engine. Stored under {@link WEBHOOK_RESOLVERS}.
*/
export type NativeParameterResolvers = Record<
string,
{
template: string;
resolve: (parameters: INodeParameters) => NodeParameterValueType | undefined;
}
>;
export interface IWebhookDescription {
[key: string]: IHttpRequestMethods | WebhookResponseMode | boolean | string | undefined;
[WEBHOOK_RESOLVERS]?: NativeParameterResolvers;
httpMethod: IHttpRequestMethods | string;
isFullPath?: boolean;
name: WebhookType;
@@ -0,0 +1,153 @@
/**
* Single-source declaration of webhook-description fields.
*
* A webhook description needs values derived from the node instance's
* parameters, historically encoded as expression templates such as
* `={{$parameter["path"]}}`. The editor still needs those strings (they are
* what survives the JSON serialization of node descriptions), but on the
* backend they force the expression engine (under `N8N_EXPRESSION_ENGINE=vm`,
* a V8 isolate) into every webhook request even when the user authored no
* expression.
*
* The helpers here generate the template string and a native resolver from one
* declaration, so the two cannot drift. Backend resolution sites call
* {@link resolveWebhookDescriptionField} first and only fall back to the engine
* when the user's parameters actually contain expressions.
*/
import type {
INode,
INodeParameters,
IWebhookDescription,
NativeParameterResolvers,
NodeParameterValueType,
} from './interfaces';
import { WEBHOOK_RESOLVERS } from './interfaces';
import { isResourceLocatorValue } from './type-guards';
export type WebhookDescriptionField = NativeParameterResolvers[string];
/**
* A field that reads a node parameter, e.g. `fromParameter('httpMethod', 'GET')`
* → template `={{$parameter["httpMethod"] || "GET"}}` and a resolver applying
* the same truthiness fallback. Nested reads take a path array. Walking into a
* missing parent yields `undefined`, matching what the engine returns for the
* same reference.
*/
export function fromParameter(path: string | string[], fallback?: string): WebhookDescriptionField {
const segments = Array.isArray(path) ? path : [path];
const accessor = segments.map((segment) => `["${segment}"]`).join('');
const tail = fallback === undefined ? '' : ` || ${JSON.stringify(fallback)}`;
return {
template: `={{$parameter${accessor}${tail}}}`,
resolve: (parameters) => {
let current: unknown = parameters;
for (const key of segments) {
if (current === null || current === undefined) return fallback;
current = (current as INodeParameters)[key];
}
// `||` in the template is plain JS truthiness, not a nullish check.
if (fallback !== undefined && !current) return fallback;
return current as NodeParameterValueType | undefined;
},
};
}
/**
* A field computed by a function of the parameters: the template inlines the
* function's source and the resolver is the function itself, so both
* representations run the same code.
*
* The function MUST be self-contained: closing over an outer identifier (an
* import, a module constant) produces a template the expression sandbox cannot
* evaluate. The parity test in the Webhook node's description.test.ts catches
* this; add one for any node that declares `fromFunction` fields.
*/
export function fromFunction<P extends INodeParameters>(
fn: (parameters: P) => NodeParameterValueType | undefined,
): WebhookDescriptionField {
return {
template: `={{(${String(fn)})($parameter)}}`,
resolve: fn as WebhookDescriptionField['resolve'],
};
}
/**
* Spreads into an {@link IWebhookDescription}: the template string per field,
* plus the resolver map under the backend-only {@link WEBHOOK_RESOLVERS} key.
*/
export function webhookDescriptionFields<K extends string>(
fields: Record<K, WebhookDescriptionField>,
): Record<K, string> & { [WEBHOOK_RESOLVERS]: NativeParameterResolvers } {
const templates = {} as Record<K, string>;
const resolvers: NativeParameterResolvers = {};
for (const [field, entry] of Object.entries<WebhookDescriptionField>(fields)) {
templates[field as K] = entry.template;
resolvers[field] = entry;
}
return { ...templates, [WEBHOOK_RESOLVERS]: resolvers };
}
export type NativeResolution =
| { resolved: true; value: NodeParameterValueType | undefined }
| { resolved: false };
const NOT_RESOLVED: NativeResolution = { resolved: false };
/**
* Resolves a description field without the expression engine when the field
* declares a resolver, still carries the exact template that resolver was
* generated from (a description built by spreading another inherits resolvers
* for fields it overrides), and the node's parameters are static.
*
* Pass the workflow's own node (`workflow.nodes[name]`): its parameters
* include the defaults the `Workflow` constructor applies.
*/
export function resolveWebhookDescriptionField(
node: Pick<INode, 'parameters'>,
description: IWebhookDescription,
field: string,
): NativeResolution {
const entry = description[WEBHOOK_RESOLVERS]?.[field];
if (entry === undefined || description[field] !== entry.template) return NOT_RESOLVED;
if (!nodeParametersAreStatic(node)) return NOT_RESOLVED;
return { resolved: true, value: entry.resolve(node.parameters) };
}
/**
* Whether every field of the description is engine-free for a static-parameter
* node: either a resolver is declared for it, or the value contains no
* expression template at any depth. A template added later without a resolver
* makes this false, so callers skipping isolate acquisition on the strength of
* this stay correct automatically.
*/
export function webhookDescriptionIsNativelyResolvable(description: IWebhookDescription): boolean {
const resolvers = description[WEBHOOK_RESOLVERS];
return Object.entries(description).every(
([field, value]) => resolvers?.[field]?.template === value || !containsDynamicValue(value),
);
}
/**
* Whether a node's parameters can stand in for the `$parameter` proxy: beyond a
* plain property read, the proxy only resolves `=` values, unwraps
* resource-locator values and handles `&sibling` lookups — all of which require
* a dynamic value somewhere in the parameters.
*/
export function nodeParametersAreStatic(node: Pick<INode, 'parameters'>): boolean {
return !containsDynamicValue(node.parameters);
}
function containsDynamicValue(value: unknown): boolean {
if (typeof value === 'string') return value.startsWith('=');
if (Array.isArray(value)) return value.some(containsDynamicValue);
if (value !== null && typeof value === 'object') {
if (isResourceLocatorValue(value)) return true;
return Object.keys(value).some((key) => containsDynamicValue(Reflect.get(value, key)));
}
return false;
}
@@ -0,0 +1,303 @@
import type {
IDataObject,
INodeParameters,
INodeType,
INodeTypes,
IWebhookDescription,
} from '../src/interfaces';
import { WEBHOOK_RESOLVERS } from '../src/interfaces';
import {
fromFunction,
fromParameter,
nodeParametersAreStatic,
resolveWebhookDescriptionField,
webhookDescriptionFields,
webhookDescriptionIsNativelyResolvable,
} from '../src/webhook-description-fields';
import { Workflow } from '../src/workflow';
// Template and resolver are generated from one declaration, so this parity
// corpus is the entire sync guarantee: for every field shape, the resolver must
// return exactly what the generated template returns through the engine.
// Declaring the parameters matters: the `Workflow` constructor runs
// `NodeHelpers.getNodeParameters`, which applies declared defaults — and the
// engine's `$parameter` proxy reads that same post-processed object, which is
// what resolvers read too.
const webhookNodeType: INodeType = {
description: {
displayName: 'Webhook',
name: 'webhook',
group: ['trigger'],
version: 1,
description: '',
defaults: { name: 'Webhook' },
inputs: [],
outputs: ['main'],
properties: [
{ displayName: 'Method', name: 'httpMethod', type: 'string', default: 'GET' },
{ displayName: 'Path', name: 'path', type: 'string', default: '' },
{ displayName: 'Respond', name: 'responseMode', type: 'string', default: 'onReceived' },
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
options: [
{ displayName: 'Content-Type', name: 'responseContentType', type: 'string', default: '' },
{ displayName: 'No Body', name: 'noResponseBody', type: 'boolean', default: false },
],
},
],
},
};
const nodeTypes: INodeTypes = {
getByName: () => webhookNodeType,
getByNameAndVersion: () => webhookNodeType,
getKnownTypes: () => ({}) as IDataObject,
};
const nodeWithParameters = (parameters: INodeParameters) => {
const workflow = new Workflow({
id: '1',
nodes: [
{
name: 'Webhook',
typeVersion: 1,
type: 'test.webhook',
id: 'webhook-1',
position: [0, 0],
parameters,
},
],
connections: {},
active: false,
nodeTypes,
});
return { workflow, node: workflow.getNode('Webhook')! };
};
// This file runs under both vitest projects (legacy and vm), so parity is
// pinned against both engines; the vm engine needs an acquired isolate.
const viaEngine = async (parameters: INodeParameters, template: string) => {
const { workflow, node } = nodeWithParameters(parameters);
await workflow.expression.acquireIsolate();
try {
return workflow.expression.getSimpleParameterValue(node, template, 'internal', {});
} finally {
await workflow.expression.releaseIsolate();
}
};
const viaResolver = (parameters: INodeParameters, field: ReturnType<typeof fromParameter>) => {
const { node } = nodeWithParameters(parameters);
return field.resolve(node.parameters);
};
describe('fromParameter', () => {
it('generates the long-standing template shapes', () => {
expect(fromParameter('path').template).toBe('={{$parameter["path"]}}');
expect(fromParameter('httpMethod', 'GET').template).toBe(
'={{$parameter["httpMethod"] || "GET"}}',
);
expect(fromParameter(['options', 'responseContentType']).template).toBe(
'={{$parameter["options"]["responseContentType"]}}',
);
});
describe('resolver matches the engine', () => {
const cases: Array<[string, ReturnType<typeof fromParameter>, INodeParameters]> = [
['single segment, present', fromParameter('path'), { path: 'my-path' }],
['single segment, default applies', fromParameter('path'), {}],
['fallback, value present', fromParameter('httpMethod', 'POST'), { httpMethod: 'DELETE' }],
['fallback, value falsy', fromParameter('responseMode', 'onReceived'), { responseMode: '' }],
['fallback, value missing', fromParameter('httpMethod', 'HEAD'), {}],
[
'nested, present',
fromParameter(['options', 'responseContentType']),
{ options: { responseContentType: 'text/plain' } },
],
['nested, missing leaf', fromParameter(['options', 'responseContentType']), { options: {} }],
[
'nested boolean, falsy present',
fromParameter(['options', 'noResponseBody']),
{ options: { noResponseBody: false } },
],
];
test.each(cases)('%s', async (_name, field, parameters) => {
expect(viaResolver(parameters, field)).toEqual(await viaEngine(parameters, field.template));
});
});
it('yields undefined when walking into a missing parent, like the engine', async () => {
// 'undeclared' is not in the node type's properties, so no default is
// applied and the parent is truly absent — pins that the engine also
// yields undefined (it does, under both vitest engine projects) rather
// than throwing on the nested access.
const field = fromParameter(['undeclared', 'x']);
expect(viaResolver({}, field)).toBeUndefined();
expect(await viaEngine({}, field.template)).toBeUndefined();
expect(fromParameter(['options', 'responseContentType']).resolve({})).toBeUndefined();
});
it('applies the fallback when walking into a missing parent', () => {
expect(fromParameter(['options', 'responseContentType'], 'text/html').resolve({})).toBe(
'text/html',
);
});
});
describe('fromFunction', () => {
const getResponseData = (parameters: INodeParameters) =>
parameters.responseMode === 'lastNode' ? 'noData' : undefined;
it('inlines the function source into the template and reuses it as resolver', () => {
const field = fromFunction(getResponseData);
expect(field.template).toBe(`={{(${getResponseData})($parameter)}}`);
expect(field.resolve).toBe(getResponseData);
});
test.each<[string, INodeParameters]>([
['branch taken', { responseMode: 'lastNode' }],
['branch not taken', { responseMode: 'onReceived' }],
])('resolver matches the engine: %s', async (_name, parameters) => {
const field = fromFunction(getResponseData);
expect(viaResolver(parameters, field)).toEqual(await viaEngine(parameters, field.template));
});
});
describe('webhookDescriptionFields', () => {
const fields = webhookDescriptionFields({
path: fromParameter('path'),
httpMethod: fromParameter('httpMethod', 'GET'),
});
it('spreads template strings and keeps resolvers under the symbol key', () => {
expect(fields.path).toBe('={{$parameter["path"]}}');
expect(fields.httpMethod).toBe('={{$parameter["httpMethod"] || "GET"}}');
expect(Object.keys(fields[WEBHOOK_RESOLVERS])).toEqual(['path', 'httpMethod']);
});
it('does not leak resolvers through JSON serialization', () => {
expect(JSON.parse(JSON.stringify(fields))).toEqual({
path: '={{$parameter["path"]}}',
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
});
});
});
describe('resolveWebhookDescriptionField', () => {
const description: IWebhookDescription = {
name: 'default',
isFullPath: true,
...webhookDescriptionFields({
path: fromParameter('path'),
httpMethod: fromParameter('httpMethod', 'GET'),
}),
};
it('resolves a declared field for a static-parameter node', () => {
expect(
resolveWebhookDescriptionField({ parameters: { path: 'my-path' } }, description, 'path'),
).toEqual({ resolved: true, value: 'my-path' });
});
it('does not resolve when the node parameters contain an expression', () => {
expect(
resolveWebhookDescriptionField(
{ parameters: { path: 'my-path', extra: '={{ $json.x }}' } },
description,
'path',
),
).toEqual({ resolved: false });
});
it('does not resolve a field without a resolver', () => {
expect(resolveWebhookDescriptionField({ parameters: {} }, description, 'isFullPath')).toEqual({
resolved: false,
});
});
});
describe('spread-and-override descriptions (Wait/GitHub pattern)', () => {
// Object spread copies the resolver map's symbol key, so a description built
// by spreading another inherits resolvers for fields it may override. The
// template-identity check must invalidate exactly those.
const base: IWebhookDescription = {
name: 'default',
httpMethod: 'GET',
...webhookDescriptionFields({
path: fromParameter('path'),
responseMode: fromParameter('responseMode'),
}),
};
const overridden: IWebhookDescription = {
...base,
path: '={{$parameter["options"]["webhookSuffix"] || ""}}',
};
it('does not apply an inherited resolver to an overridden template', () => {
expect(
resolveWebhookDescriptionField(
{ parameters: { options: { webhookSuffix: 'abc' } } },
overridden,
'path',
),
).toEqual({ resolved: false });
});
it('still applies inherited resolvers whose templates are untouched', () => {
expect(
resolveWebhookDescriptionField(
{ parameters: { responseMode: 'lastNode' } },
overridden,
'responseMode',
),
).toEqual({ resolved: true, value: 'lastNode' });
});
it('is not fully resolvable once a template is overridden without a resolver', () => {
expect(webhookDescriptionIsNativelyResolvable(overridden)).toBe(false);
});
});
describe('webhookDescriptionIsNativelyResolvable', () => {
it('accepts a description whose templates all have resolvers', () => {
const description: IWebhookDescription = {
name: 'default',
isFullPath: true,
httpMethod: 'GET',
...webhookDescriptionFields({ path: fromParameter('path') }),
};
expect(webhookDescriptionIsNativelyResolvable(description)).toBe(true);
});
it('rejects a description with a template lacking a resolver', () => {
const description: IWebhookDescription = {
name: 'default',
path: '={{$parameter["path"]}}',
httpMethod: 'GET',
};
expect(webhookDescriptionIsNativelyResolvable(description)).toBe(false);
});
});
describe('nodeParametersAreStatic', () => {
test.each<[string, INodeParameters, boolean]>([
['plain values', { path: 'x', options: { a: 1 } }, true],
['expression string', { path: '={{ $json.x }}' }, false],
['nested expression string', { options: { a: '={{ 1 }}' } }, false],
['expression in array', { list: ['a', '={{ 1 }}'] }, false],
[
'resource locator',
{ target: { __rl: true, mode: 'id', value: 'x' } as unknown as INodeParameters },
false,
],
])('%s', (_name, parameters, expected) => {
expect(nodeParametersAreStatic({ parameters })).toBe(expected);
});
});