refactor(core): Extract webhook invocation (no-changelog) (#39452)

This commit is contained in:
Tomi Turtiainen
2026-09-24 08:53:14 +00:00
committed by GitHub
parent b096147894
commit d1e5637929
2 changed files with 223 additions and 58 deletions
@@ -80,6 +80,7 @@ import {
handleHostedChatResponse,
executeWebhook,
_privateGetWebhookErrorMessage,
invokeWebhook,
} from '../webhook-helpers';
import { WebhookService } from '../webhook.service';
import type { IWebhookResponseCallbackData, WebhookRequest } from '../webhook.types';
@@ -1200,11 +1201,131 @@ const executionContextService = mockInstance(ExecutionContextService);
const userRepository = mockInstance(UserRepository);
mockInstance(AuthService);
mockInstance(EventService);
mockInstance(WorkflowStatisticsService);
const workflowStatisticsService = mockInstance(WorkflowStatisticsService);
const WORKFLOW_ID = 'wf-1';
const EXECUTION_ID = 'exec-1';
describe('invokeWebhook', () => {
const workflowStartNode = mock<INode>({
name: 'Webhook',
type: WEBHOOK_NODE_TYPE,
typeVersion: 2,
parameters: {},
});
const workflow = mock<Workflow>({ id: WORKFLOW_ID });
const webhookData = mock<IWebhookData>();
const additionalData = mock<IWorkflowExecuteAdditionalData>();
beforeEach(() => {
vi.clearAllMocks();
});
it('returns webhook data and emits the success event', async () => {
const webhookResultData = { workflowData: [[{ json: { ok: true } }]] };
webhookService.runWebhook.mockResolvedValue(webhookResultData);
const responseCallback = vi.fn();
const result = await invokeWebhook({
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode: 'webhook',
runExecutionData: undefined,
webhookType: 'Webhook',
responseCallback,
});
expect(webhookService.runWebhook).toHaveBeenCalledWith(
workflow,
webhookData,
workflowStartNode,
additionalData,
'webhook',
null,
);
expect(result).toEqual({
webhookResultData,
didSendResponse: false,
runExecutionDataChanges: {},
});
expect(responseCallback).not.toHaveBeenCalled();
expect(workflowStatisticsService.emit).toHaveBeenCalledWith('nodeFetchedData', {
workflowId: WORKFLOW_ID,
node: workflowStartNode,
});
});
it.each([
{
name: 'masks an internal error',
error: new Error('private details'),
expectedMessage: 'Workflow Webhook Error: Workflow could not be started!',
},
{
name: 'exposes a workflow configuration error',
error: new WorkflowConfigurationError(workflowStartNode, new Error('bad setup')),
expectedMessage: 'bad setup',
},
])('$name', async ({ error, expectedMessage }) => {
webhookService.runWebhook.mockRejectedValue(error);
const responseCallback = vi.fn();
const result = await invokeWebhook({
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode: 'webhook',
runExecutionData: undefined,
webhookType: 'Webhook',
responseCallback,
});
expect(responseCallback).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining(expectedMessage) }),
{},
);
expect(result.didSendResponse).toBe(true);
expect(result.webhookResultData).toEqual({
noWebhookResponse: true,
workflowData: [[{ json: {} }]],
});
expect(result.runExecutionDataChanges.resultData).toEqual(
expect.objectContaining({ lastNodeExecuted: 'Webhook', error: expect.any(Object) }),
);
expect(workflowStatisticsService.emit).not.toHaveBeenCalledWith(
'nodeFetchedData',
expect.any(Object),
);
});
it('normalizes and reports non-error rejections', async () => {
webhookService.runWebhook.mockRejectedValue('failure');
await invokeWebhook({
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode: 'webhook',
runExecutionData: undefined,
webhookType: 'Webhook',
responseCallback: vi.fn(),
});
expect(Container.get(ErrorReporter).error).toHaveBeenCalledWith(
expect.any(Error),
expect.any(Object),
);
expect(workflowStatisticsService.emit).not.toHaveBeenCalledWith(
'nodeFetchedData',
expect.any(Object),
);
});
});
describe('executeWebhook form content type', () => {
const runRequest = async (startNode: INode, rawBody = '{') => {
ownershipService.getWorkflowProjectCached.mockResolvedValue(
+101 -57
View File
@@ -114,6 +114,20 @@ const SUPPORTED_RESPONSE_MODES = new Set<WebhookResponseMode>([
'hostedChat',
]);
interface WebhookInvocationResult {
webhookResultData: IWebhookResponseData;
didSendResponse: boolean;
runExecutionDataChanges: WebhookExecutionDataChanges;
}
interface WebhookExecutionDataChanges {
resultData?: {
runData: Record<string, never>;
lastNodeExecuted: string;
error: Record<string, unknown>;
};
}
const deferCleanupUntilStreamEnds = (
stream: Readable,
res: express.Response,
@@ -542,6 +556,80 @@ function shouldEstablishTriggerIdentity(workflowStartNode: INode): boolean {
);
}
/** Invokes the webhook node and maps invocation errors to execution data. */
export async function invokeWebhook({
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode,
runExecutionData,
webhookType,
responseCallback,
}: {
workflow: Workflow;
webhookData: IWebhookData;
workflowStartNode: INode;
additionalData: IWorkflowExecuteAdditionalData;
executionMode: WorkflowExecuteMode;
runExecutionData: IRunExecutionData | undefined;
webhookType: 'Form' | 'Webhook';
responseCallback: (
error: Error | null,
data: IWebhookResponseCallbackData | WebhookResponse,
) => void;
}): Promise<WebhookInvocationResult> {
try {
const webhookResultData = await Container.get(WebhookService).runWebhook(
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode,
runExecutionData ?? null,
);
Container.get(WorkflowStatisticsService).emit('nodeFetchedData', {
workflowId: workflow.id,
node: workflowStartNode,
});
return { webhookResultData, didSendResponse: false, runExecutionDataChanges: {} };
} catch (e: unknown) {
const error = ensureError(e);
const errorMessage = _privateGetWebhookErrorMessage(error, webhookType);
Container.get(ErrorReporter).error(error, {
extra: {
nodeName: workflowStartNode.name,
nodeType: workflowStartNode.type,
nodeVersion: workflowStartNode.typeVersion,
workflowId: workflow.id,
},
});
responseCallback(new UnexpectedError(errorMessage), {});
return {
didSendResponse: true,
runExecutionDataChanges: {
resultData: {
runData: {},
lastNodeExecuted: workflowStartNode.name,
error: {
...error,
message: error.message,
stack: error.stack,
},
},
},
webhookResultData: {
noWebhookResponse: true,
workflowData: [[{ json: {} }]],
},
};
}
}
/**
* Reconciles a pre-seeded execution stack (identity/context trigger flows) with the
* webhook node's real output. No-op unless the start node seeded execution data.
@@ -879,10 +967,6 @@ export async function executeWebhook(
const engineV2Webhooks = Container.get(EngineV2Webhooks);
let cleanupMultipartFiles: (() => Promise<void>) | undefined;
try {
// Run the webhook function to see what should be returned and if
// the workflow should be executed or not
let webhookResultData: IWebhookResponseData;
// Before the node runs: a streaming node, and the chat, MCP and Agent365
// triggers, answer the request themselves, so a later refusal could not send
// the 400. Also before the request body is parsed, so no file is stored for a
@@ -938,59 +1022,19 @@ export async function executeWebhook(
});
}
try {
webhookResultData = await Container.get(WebhookService).runWebhook(
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode,
runExecutionData ?? null,
);
Container.get(WorkflowStatisticsService).emit('nodeFetchedData', {
workflowId: workflow.id,
node: workflowStartNode,
});
} catch (e: unknown) {
const error = ensureError(e);
// Send error response to webhook caller
const webhookType = ['formTrigger', 'form'].includes(nodeType.description.name)
? 'Form'
: 'Webhook';
const errorMessage = _privateGetWebhookErrorMessage(error, webhookType);
Container.get(ErrorReporter).error(error, {
extra: {
nodeName: workflowStartNode.name,
nodeType: workflowStartNode.type,
nodeVersion: workflowStartNode.typeVersion,
workflowId: workflow.id,
},
});
responseCallback(new UnexpectedError(errorMessage), {});
didSendResponse = true;
// Add error to execution data that it can be logged and send to Editor-UI
runExecutionDataMerge = {
resultData: {
runData: {},
lastNodeExecuted: workflowStartNode.name,
error: {
...error,
message: error.message,
stack: error.stack,
},
},
};
webhookResultData = {
noWebhookResponse: true,
// Add empty data that it at least tries to "execute" the webhook
// which then so gets the chance to throw the error.
workflowData: [[{ json: {} }]],
};
}
const invocationResult = await invokeWebhook({
workflow,
webhookData,
workflowStartNode,
additionalData,
executionMode,
runExecutionData,
webhookType: ['formTrigger', 'form'].includes(nodeType.description.name) ? 'Form' : 'Webhook',
responseCallback,
});
const { webhookResultData } = invocationResult;
didSendResponse = invocationResult.didSendResponse;
runExecutionDataMerge = invocationResult.runExecutionDataChanges;
if (cleanupMultipartFiles && webhookResultData.webhookResponse instanceof Readable) {
deferCleanupUntilStreamEnds(webhookResultData.webhookResponse, res, cleanupMultipartFiles);