n8n/packages/@n8n/workflow-sdk/src/workflow-builder.ts
Yuval Dinodia 0f4c5b396d
fix(core): Improve error messages for invalid node and trigger input (#28053)
Co-authored-by: Declan Carroll <declan@n8n.io>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
2026-04-24 12:24:01 +00:00

1253 lines
43 KiB
TypeScript

import type {
WorkflowBuilder,
WorkflowBuilderStatic,
WorkflowSettings,
WorkflowJSON,
NodeInstance,
ConnectionTarget,
GraphNode,
IDataObject,
NodeChain,
GeneratePinDataOptions,
WorkflowBuilderOptions,
ToJSONOptions,
} from './types/base';
import { isNodeChain } from './types/base';
import type { ValidationOptions, ValidationResult, ValidationErrorCode } from './validation/index';
import { ValidationError, ValidationWarning } from './validation/index';
import { resolveTargetNodeName as resolveTargetNodeNameUtil } from './workflow-builder/connection-utils';
import { isInputTarget, cloneNodeWithId } from './workflow-builder/node-builders/node-builder';
import { shouldGeneratePinData } from './workflow-builder/pin-data-utils';
import { registerDefaultPlugins } from './workflow-builder/plugins/defaults';
import { pluginRegistry, type PluginRegistry } from './workflow-builder/plugins/registry';
import { jsonSerializer } from './workflow-builder/plugins/serializers';
import type {
PluginContext,
MutablePluginContext,
ValidationIssue,
SerializerContext,
} from './workflow-builder/plugins/types';
import { generateDeterministicNodeId } from './workflow-builder/string-utils';
import { addNodeWithSubnodes as addNodeWithSubnodesUtil } from './workflow-builder/subnode-utils';
import { parseWorkflowJSON } from './workflow-builder/workflow-import';
// Ensure default plugins are registered on module load
registerDefaultPlugins(pluginRegistry);
/**
* Internal workflow builder implementation
*/
class WorkflowBuilderImpl implements WorkflowBuilder {
readonly id: string;
readonly name: string;
private _settings: WorkflowSettings;
private _nodes: Map<string, GraphNode>;
private _currentNode: string | null;
private _currentOutput: number;
private _pinData?: Record<string, IDataObject[]>;
private _meta?: { templateId?: string; instanceId?: string; [key: string]: unknown };
private _registry?: PluginRegistry;
private _staleIdToKeyMap?: Map<string, string>;
private _branchDepth = 0;
private _dispatchedComposites = new WeakSet<object>();
private static readonly MAX_BRANCH_DEPTH = 500;
constructor(
id: string,
name: string,
settings: WorkflowSettings = {},
nodes?: Map<string, GraphNode>,
currentNode?: string | null,
pinData?: Record<string, IDataObject[]>,
meta?: { templateId?: string; instanceId?: string; [key: string]: unknown },
registry?: PluginRegistry,
) {
this.id = id;
this.name = name;
this._settings = { ...settings };
this._nodes = nodes ? new Map<string, GraphNode>(nodes) : new Map<string, GraphNode>();
this._currentNode = currentNode ?? null;
this._currentOutput = 0;
this._pinData = pinData;
this._meta = meta;
this._registry = registry;
}
/**
* Create a MutablePluginContext for composite handlers.
* This provides helper methods that allow plugins to add nodes to the graph.
* @param nodes The mutable nodes map
* @param nameMapping Optional map to track node ID → actual map key for renamed nodes
*/
private createMutablePluginContext(
nodes: Map<string, GraphNode>,
nameMapping?: Map<string, string>,
): MutablePluginContext {
const effectiveNameMapping = nameMapping ?? new Map<string, string>();
return {
nodes,
workflowId: this.id,
workflowName: this.name,
settings: this._settings,
pinData: this._pinData,
nameMapping: effectiveNameMapping,
addNodeWithSubnodes: (node: NodeInstance<string, string, unknown>) => {
const actualKey = this.addNodeWithSubnodes(nodes, node);
// Auto-track renames when node is stored under a different key
if (actualKey && actualKey !== node.name) {
effectiveNameMapping.set(node.id, actualKey);
}
return actualKey;
},
addBranchToGraph: (branch: unknown) => {
return this.addBranchToGraph(
nodes,
branch as NodeInstance<string, string, unknown>,
effectiveNameMapping,
);
},
trackRename: (nodeId: string, actualKey: string) => {
effectiveNameMapping.set(nodeId, actualKey);
},
};
}
/**
* Collect pinData from a node and merge it with existing pinData
*/
private collectPinData(
node: NodeInstance<string, string, unknown>,
): Record<string, IDataObject[]> | undefined {
const nodePinData = node.config?.pinData;
if (!nodePinData || nodePinData.length === 0) {
return this._pinData;
}
// Merge with existing pinData
return {
...this._pinData,
[node.name]: nodePinData,
};
}
/**
* Collect pinData from all nodes in a chain
*/
private collectPinDataFromChain(chain: NodeChain): Record<string, IDataObject[]> | undefined {
let pinData = this._pinData;
const registry = this._registry ?? pluginRegistry;
for (const chainNode of chain.allNodes) {
// Try plugin dispatch for composites
const handler = registry.findCompositeHandler(chainNode);
if (handler?.collectPinData) {
handler.collectPinData(chainNode, (node) => {
pinData = this.collectPinDataFromNode(node, pinData);
});
} else if (chainNode?.config?.pinData) {
// Regular node with pinData
pinData = this.collectPinDataFromNode(chainNode, pinData);
}
}
return pinData;
}
/**
* Helper to collect pinData from a single node and merge with existing pinData
*/
private collectPinDataFromNode(
node: NodeInstance<string, string, unknown>,
existingPinData: Record<string, IDataObject[]> | undefined,
): Record<string, IDataObject[]> | undefined {
const nodePinData = node.config?.pinData;
if (nodePinData && nodePinData.length > 0) {
return {
...existingPinData,
[node.name]: nodePinData,
};
}
return existingPinData;
}
add(node: unknown): WorkflowBuilder {
// Handle plain array (fan-out)
// This adds all targets without creating a primary connection
if (Array.isArray(node)) {
for (const target of node) {
if (isInputTarget(target)) {
// InputTarget - add the target node
const inputTargetNode = target.node;
if (!this._nodes.has(inputTargetNode.name)) {
this.addNodeWithSubnodes(this._nodes, inputTargetNode);
}
} else if (isNodeChain(target)) {
// Chain - add all nodes from the chain
for (const chainNode of target.allNodes) {
if (!this._nodes.has(chainNode.name)) {
this.addNodeWithSubnodes(this._nodes, chainNode);
}
}
this.addConnectionTargetNodes(this._nodes, target);
} else {
// Regular node
const targetNode = target as NodeInstance<string, string, unknown>;
if (!this._nodes.has(targetNode.name)) {
this.addNodeWithSubnodes(this._nodes, targetNode);
}
}
}
return this;
}
// Check for plugin composite handlers FIRST
// This allows registered handlers to intercept composites before built-in handling
// Always use global pluginRegistry as fallback (like we do for validators)
const addRegistry = this._registry ?? pluginRegistry;
const addHandler = addRegistry.findCompositeHandler(node);
if (addHandler) {
const ctx = this.createMutablePluginContext(this._nodes);
const headName = addHandler.addNodes(node, ctx);
this._currentNode = headName;
this._currentOutput = 0;
return this;
}
// Check if this is a NodeChain
if (isNodeChain(node)) {
// Track node ID -> actual map key for renamed nodes
const nameMapping = new Map<string, string>();
// Add all nodes from the chain, handling composites that may have been chained
for (const chainNode of node.allNodes) {
// Try plugin dispatch for composites - nameMapping is propagated through context
const pluginResult = this.tryPluginDispatch(this._nodes, chainNode, nameMapping);
if (pluginResult === undefined) {
// Not a composite - add as regular node
const actualKey = this.addNodeWithSubnodes(this._nodes, chainNode);
// Track the actual key if it was renamed
if (actualKey && actualKey !== chainNode.name) {
nameMapping.set(chainNode.id, actualKey);
}
}
}
// Also add nodes from connections that aren't in allNodes (e.g., onError handlers)
this.addConnectionTargetNodes(this._nodes, node, nameMapping);
// Collect pinData from all nodes in the chain
this._pinData = this.collectPinDataFromChain(node);
// Set currentNode to the tail (last node in the chain)
// Use nameMapping to get the actual key if the tail was renamed
this._currentNode = nameMapping.get(node.tail.id) ?? node.tail.name;
this._currentOutput = 0;
return this;
}
// At this point, plugin dispatch has handled IfElseBuilder/SwitchCaseBuilder, and we've
// handled NodeChain. The remaining type is NodeInstance or TriggerInstance.
// Cast to NodeInstance to satisfy TypeScript (type narrowing).
const regularNode = node as NodeInstance<string, string, unknown>;
// Regular node or trigger
const actualKey = this.addNodeWithSubnodes(this._nodes, regularNode) ?? regularNode.name;
// Also add connection target nodes (e.g., onError handlers)
// This is important when re-adding a node that already exists but has new connections
this.addSingleNodeConnectionTargets(this._nodes, regularNode);
// Collect pinData from the node if present
this._pinData = this.collectPinData(regularNode);
this._currentNode = actualKey;
this._currentOutput = 0;
return this;
}
to(nodeOrComposite: unknown): WorkflowBuilder {
// Handle InputTarget (e.g., mergeNode.input(0))
if (isInputTarget(nodeOrComposite)) {
const actualNode = nodeOrComposite.node;
const actualKey = this.addNodeWithSubnodes(this._nodes, actualNode) ?? actualNode.name;
// Connect from current node to the target with the specified input index
if (this._currentNode) {
const currentGraphNode = this._nodes.get(this._currentNode);
if (currentGraphNode) {
const mainConns =
currentGraphNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConns: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? [];
const alreadyConnected = outputConns.some(
(c) => c.node === actualKey && c.index === nodeOrComposite.inputIndex,
);
if (!alreadyConnected) {
outputConns.push({
node: actualKey,
type: 'main',
index: nodeOrComposite.inputIndex,
});
}
mainConns.set(this._currentOutput, outputConns);
currentGraphNode.connections.set('main', mainConns);
}
}
this._currentNode = actualKey;
this._currentOutput = 0;
return this;
}
// Handle array of nodes (fan-out pattern)
if (Array.isArray(nodeOrComposite)) {
return this.handleFanOut(nodeOrComposite);
}
// Handle NodeChain (e.g., node().to().to())
// This must come before composite checks since chains have composite-like properties
if (isNodeChain(nodeOrComposite)) {
return this.handleNodeChain(nodeOrComposite);
}
// Check for plugin composite handlers
// This allows registered handlers to intercept composites before built-in handling
// Always use global pluginRegistry as fallback (like we do for validators)
const thenRegistry = this._registry ?? pluginRegistry;
const thenHandler = thenRegistry.findCompositeHandler(nodeOrComposite);
if (thenHandler) {
const ctx = this.createMutablePluginContext(this._nodes);
const headName = thenHandler.addNodes(nodeOrComposite, ctx);
// Connect current node to head of composite
if (this._currentNode) {
const currentGraphNode = this._nodes.get(this._currentNode);
if (currentGraphNode) {
const mainConns =
currentGraphNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConns: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? [];
outputConns.push({ node: headName, type: 'main', index: 0 });
mainConns.set(this._currentOutput, outputConns);
currentGraphNode.connections.set('main', mainConns);
}
}
this._currentNode = headName;
this._currentOutput = 0;
return this;
}
// At this point, plugin dispatch handled all composite types (IfElse, SwitchCase, Merge, SplitInBatches).
// Remaining type is a regular NodeInstance.
const node = nodeOrComposite as NodeInstance<string, string, unknown>;
// addNodeWithSubnodes is idempotent: returns existing key for same instance,
// generates unique name for name collisions, or adds new node.
const actualKey = this.addNodeWithSubnodes(this._nodes, node) ?? node.name;
// Add connection target nodes (e.g., onError handlers)
this.addSingleNodeConnectionTargets(this._nodes, node);
// Connect from current node if exists
if (this._currentNode) {
const currentGraphNode = this._nodes.get(this._currentNode);
if (currentGraphNode) {
const mainConns =
currentGraphNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConnections: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? [];
// Check for duplicate connections
const alreadyConnected = outputConnections.some((c) => c.node === actualKey);
if (!alreadyConnected) {
mainConns.set(this._currentOutput, [
...outputConnections,
{ node: actualKey, type: 'main', index: 0 },
]);
currentGraphNode.connections.set('main', mainConns);
}
}
}
// Collect pinData from the node if present
this._pinData = this.collectPinData(node);
this._currentNode = actualKey;
this._currentOutput = 0;
return this;
}
output(): never {
throw new Error(
'Cannot call .output() on the workflow builder. ' +
'Use .output() on a node variable instead: myNode.output(0).to(targetNode)',
);
}
input(): never {
throw new Error(
'Cannot call .input() on the workflow builder. ' +
'Use .input() on a node variable instead: myNode.input(1)',
);
}
settings(settings: WorkflowSettings): WorkflowBuilder {
this._settings = { ...this._settings, ...settings };
return this;
}
connect(
source: NodeInstance<string, string, unknown>,
sourceOutput: number,
target: NodeInstance<string, string, unknown>,
targetInput: number,
): WorkflowBuilder {
// Ensure both nodes exist in the graph
if (!this._nodes.has(source.name)) {
this.addNodeWithSubnodes(this._nodes, source);
}
if (!this._nodes.has(target.name)) {
this.addNodeWithSubnodes(this._nodes, target);
}
// Add the explicit connection from source to target
const sourceNode = this._nodes.get(source.name);
if (sourceNode) {
const mainConns = sourceNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConns = mainConns.get(sourceOutput) ?? [];
// Check if connection already exists
const alreadyExists = outputConns.some(
(c: ConnectionTarget) => c.node === target.name && c.index === targetInput,
);
if (!alreadyExists) {
outputConns.push({ node: target.name, type: 'main', index: targetInput });
mainConns.set(sourceOutput, outputConns);
sourceNode.connections.set('main', mainConns);
}
}
return this;
}
getNode(name: string): NodeInstance<string, string, unknown> | undefined {
// First try direct lookup (for backward compatibility and nodes added via add/then)
const directLookup = this._nodes.get(name);
if (directLookup) {
return directLookup.instance;
}
// Otherwise search by instance.name (for nodes loaded via fromJSON)
for (const graphNode of this._nodes.values()) {
if (graphNode.instance.name === name) {
return graphNode.instance;
}
}
return undefined;
}
toJSON(options?: ToJSONOptions): WorkflowJSON {
// Ensure composite targets from .onError() connections are added to the graph.
// This handles cases where a chain node has .onError(ifElseBuilder) — the composite
// isn't in the chain's allNodes, so it wasn't dispatched during chain processing.
this.addMissingCompositeTargets();
// Merge connections declared on node instances via .to() into the graph
this.mergeInstanceConnections();
// Create serializer context and delegate to jsonSerializer
const ctx: SerializerContext = {
nodes: this._nodes,
workflowId: this.id,
workflowName: this.name,
settings: this._settings,
pinData: this._pinData,
meta: this._meta,
tidyUp: options?.tidyUp ?? false,
resolveTargetNodeName: (target: unknown) => this.resolveTargetNodeName(target),
};
return jsonSerializer.serialize(ctx);
}
/**
* Scan all nodes in the graph for connection targets that are composite types
* (e.g., IfElseBuilder from .onError()) and dispatch them to add their nodes.
* This runs once before serialization to catch composites missed during chain processing.
*/
private addMissingCompositeTargets(): void {
const registry = this._registry ?? pluginRegistry;
// Iterate over a snapshot of current nodes to avoid issues with map mutation during iteration
const currentNodes = [...this._nodes.values()];
for (const graphNode of currentNodes) {
if (typeof graphNode.instance.getConnections !== 'function') continue;
const connections = graphNode.instance.getConnections();
for (const { target } of connections) {
if (registry.isCompositeType(target)) {
// Skip composites already dispatched during parsing (.add(), .to(), chain processing).
// Only dispatch composites that were missed (e.g., .onError() on chain nodes).
if (
typeof target === 'object' &&
target !== null &&
this._dispatchedComposites.has(target)
) {
continue;
}
this.tryPluginDispatch(this._nodes, target);
}
}
}
}
/**
* Merge connections declared on node instances via .to() into the graph connections.
* This prepares the graph for serialization by ensuring all connections are stored
* in graphNode.connections.
*/
private mergeInstanceConnections(): void {
for (const graphNode of this._nodes.values()) {
// Only process if the node instance has getConnections() (nodes from builder, not fromJSON)
if (typeof graphNode.instance.getConnections === 'function') {
const nodeConns = graphNode.instance.getConnections();
for (const { target, outputIndex, targetInputIndex, connectionType } of nodeConns) {
const connType = connectionType ?? 'main';
// Resolve target node name - handles both NodeInstance and composites.
// Pass _staleIdToKeyMap so stale target references (from pre-clone
// instances after regenerateNodeIds) resolve to the correct map key.
const targetName = this.resolveTargetNodeName(target, this._staleIdToKeyMap);
if (!targetName) continue;
const typeConns =
graphNode.connections.get(connType) ?? new Map<number, ConnectionTarget[]>();
const outputConns: ConnectionTarget[] = typeConns.get(outputIndex) ?? [];
// Avoid duplicates - check both target node AND input index
const targetIndex = targetInputIndex ?? 0;
const alreadyExists = outputConns.some(
(c) => c.node === targetName && c.index === targetIndex,
);
if (!alreadyExists) {
outputConns.push({ node: targetName, type: 'main', index: targetIndex });
typeConns.set(outputIndex, outputConns);
graphNode.connections.set(connType, typeConns);
}
}
}
}
}
/**
* Regenerate all node IDs using deterministic hashing based on workflow ID, node type, and node name.
* This ensures that the same workflow structure always produces the same node IDs,
* which is critical for the AI workflow builder where code may be re-parsed multiple times.
*
* Node IDs are generated using SHA-256 hash of `${workflowId}:${nodeType}:${nodeName}`,
* formatted as a valid UUID v4 structure.
*/
regenerateNodeIds(): void {
const newNodes = new Map<string, GraphNode>();
// Build mapping from old instance IDs to map keys BEFORE cloning.
// Cloned instances' _connections still reference original target instances
// with old IDs. This mapping allows mergeInstanceConnections() to resolve
// those stale references to the correct map key (important for auto-renamed nodes).
const staleIdToKeyMap = new Map<string, string>();
for (const [mapKey, graphNode] of this._nodes) {
const instance = graphNode.instance;
staleIdToKeyMap.set(instance.id, mapKey);
const newId = generateDeterministicNodeId(this.id, instance.type, mapKey);
// Clone the instance with the new deterministic ID
const newInstance = cloneNodeWithId(instance, newId);
newNodes.set(mapKey, {
instance: newInstance,
connections: graphNode.connections,
});
}
this._staleIdToKeyMap = staleIdToKeyMap;
// Replace the nodes map
this._nodes = newNodes;
}
validate(options: ValidationOptions = {}): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
// Run plugin-based validators (use provided registry or global)
const registry = this._registry ?? pluginRegistry;
const pluginCtx: PluginContext = {
nodes: this._nodes,
workflowId: this.id,
workflowName: this.name,
settings: this._settings,
pinData: this._pinData,
validationOptions: {
allowDisconnectedNodes: options.allowDisconnectedNodes,
allowNoTrigger: options.allowNoTrigger,
nodeTypesProvider: options.nodeTypesProvider,
},
};
// Run validators for each node
for (const [_mapKey, graphNode] of this._nodes) {
const nodeType = graphNode.instance.type;
const validators = registry.getValidatorsForNodeType(nodeType);
for (const validator of validators) {
const issues = validator.validateNode(graphNode.instance, graphNode, pluginCtx);
this.collectValidationIssues(issues, errors, warnings, ValidationError, ValidationWarning);
}
}
// Run workflow-level validators
for (const validator of registry.getValidators()) {
if (validator.validateWorkflow) {
const issues = validator.validateWorkflow(pluginCtx);
this.collectValidationIssues(issues, errors, warnings, ValidationError, ValidationWarning);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Collect validation issues from plugins and add them to errors/warnings arrays
*/
private collectValidationIssues(
issues: ValidationIssue[],
errors: ValidationError[],
warnings: ValidationWarning[],
ValidationErrorClass: typeof ValidationError,
ValidationWarningClass: typeof ValidationWarning,
): void {
for (const issue of issues) {
// Cast code to ValidationErrorCode - plugins can use custom codes
// that extend the built-in set
const code = issue.code as ValidationErrorCode;
if (issue.severity === 'error') {
errors.push(
new ValidationErrorClass(
code,
issue.message,
issue.nodeName,
undefined,
issue.violationLevel,
),
);
} else {
warnings.push(
new ValidationWarningClass(
code,
issue.message,
issue.nodeName,
issue.parameterPath,
issue.originalName,
issue.violationLevel,
),
);
}
}
}
toString(): string {
return JSON.stringify(this.toJSON(), null, 2);
}
toFormat<T>(format: string): T {
const registry = this._registry;
if (!registry) {
throw new Error(
`No serializer registered for format '${format}'. Provide a registry with serializers when creating the workflow.`,
);
}
const serializer = registry.getSerializer(format);
if (!serializer) {
throw new Error(`No serializer registered for format '${format}'`);
}
const ctx: SerializerContext = {
nodes: this._nodes,
workflowId: this.id,
workflowName: this.name,
settings: this._settings,
pinData: this._pinData,
meta: this._meta,
resolveTargetNodeName: (target: unknown) => this.resolveTargetNodeName(target),
};
return serializer.serialize(ctx) as T;
}
generatePinData(options?: GeneratePinDataOptions): WorkflowBuilder {
const { beforeWorkflow } = options ?? {};
// Build set of existing node names from beforeWorkflow for quick lookup
const existingNodeNames = beforeWorkflow
? new Set(beforeWorkflow.nodes.map((n) => n.name))
: undefined;
for (const graphNode of this._nodes.values()) {
const node = graphNode.instance;
const nodeName = node.name;
// Skip if node exists in beforeWorkflow (only process NEW nodes)
if (existingNodeNames?.has(nodeName)) {
continue;
}
// Skip if node already has pin data in current workflow
if (this._pinData?.[nodeName]) {
continue;
}
// Only generate for nodes that meet pin data criteria
if (!shouldGeneratePinData(node)) {
continue;
}
// Generate pin data from output declaration
const output = node.config?.output;
if (output && output.length > 0) {
this._pinData = this._pinData ?? {};
this._pinData[nodeName] = output;
}
}
return this;
}
/**
* Resolve the target node name from a connection target.
* Delegates to the resolveTargetNodeName utility function.
*/
private resolveTargetNodeName(
target: unknown,
nameMapping?: Map<string, string>,
): string | undefined {
const registry = this._registry ?? pluginRegistry;
return resolveTargetNodeNameUtil(target, this._nodes, registry, nameMapping);
}
/**
* Add target nodes from a chain's connections that aren't already in the nodes map.
* This handles nodes added via .onError() which aren't included in the chain's allNodes.
* @param nameMapping - Optional map from node ID to actual map key (used when nodes are renamed)
*/
private addConnectionTargetNodes(
nodes: Map<string, GraphNode>,
chain: NodeChain,
nameMapping?: Map<string, string>,
): void {
const registry = this._registry ?? pluginRegistry;
const connections = chain.getConnections();
for (const { target } of connections) {
// Skip composite types — they are handled either:
// - In the caller's allNodes iteration (for chain members)
// - Via addSingleNodeConnectionTargets (for .onError() targets)
if (registry.isCompositeType(target)) continue;
// Handle NodeChains - use addBranchToGraph to add all nodes with their connections
if (isNodeChain(target)) {
this.addBranchToGraph(nodes, target, nameMapping);
continue;
}
// Handle InputTarget - add the referenced node
if (isInputTarget(target)) {
const inputTargetNode = target.node;
if (!nodes.has(inputTargetNode.name)) {
const actualKey = this.addNodeWithSubnodes(nodes, inputTargetNode);
if (actualKey && nameMapping && actualKey !== inputTargetNode.name) {
nameMapping.set(inputTargetNode.id, actualKey);
}
this.addSingleNodeConnectionTargets(nodes, inputTargetNode);
}
continue;
}
// Add the target node if not already in the map
const targetNode = target;
if (!nodes.has(targetNode.name)) {
const actualKey = this.addNodeWithSubnodes(nodes, targetNode);
if (actualKey && nameMapping && actualKey !== targetNode.name) {
nameMapping.set(targetNode.id, actualKey);
}
this.addSingleNodeConnectionTargets(nodes, targetNode);
}
}
}
/**
* Add target nodes from a single node's connections (e.g., onError handlers).
* This handles connection targets that aren't part of a chain.
*/
private addSingleNodeConnectionTargets(
nodes: Map<string, GraphNode>,
nodeInstance: NodeInstance<string, string, unknown>,
): void {
// Check if node has getConnections method (some composites don't)
if (typeof nodeInstance.getConnections !== 'function') return;
const registry = this._registry ?? pluginRegistry;
const connections = nodeInstance.getConnections();
for (const { target } of connections) {
// Dispatch composite types (e.g., IfElseBuilder from .onError()) to plugin handlers
if (registry.isCompositeType(target)) {
this.tryPluginDispatch(nodes, target);
continue;
}
// Handle NodeChains - use addBranchToGraph to add all nodes with their connections
if (isNodeChain(target)) {
this.addBranchToGraph(nodes, target);
continue;
}
// Handle InputTarget - add the referenced node
if (isInputTarget(target)) {
const inputTargetNode = target.node;
if (!nodes.has(inputTargetNode.name)) {
this.addNodeWithSubnodes(nodes, inputTargetNode);
this.addSingleNodeConnectionTargets(nodes, inputTargetNode);
}
continue;
}
// Add the target node if not already in the map
const targetNode = target;
if (!nodes.has(targetNode.name)) {
this.addNodeWithSubnodes(nodes, targetNode);
this.addSingleNodeConnectionTargets(nodes, targetNode);
}
}
}
/**
* Try to dispatch a composite to a plugin handler.
* Returns the head node name if a handler processed it, undefined otherwise.
*
* This is used to replace inline composite handling methods with plugin-based dispatch.
* The method checks for duplicate processing using the main node name and delegates
* to the appropriate plugin handler if one is registered.
*
* @param nodes The mutable nodes map
* @param target The target to dispatch (composite, builder, or node)
* @param nameMapping Optional map to track node ID → actual map key for renamed nodes
*/
private tryPluginDispatch(
nodes: Map<string, GraphNode>,
target: unknown,
nameMapping?: Map<string, string>,
): string | undefined {
// NOTE: We intentionally don't skip if the main node already exists.
// Handlers like ifElseHandler are designed to MERGE connections when the node exists.
// This is important for patterns like:
// .add(is_Approved.to(merge1.input(1))) // Adds IF node first
// .add(merge_node.to(set_Default_True_2.to(is_Approved.onTrue(x_Post.to(x_Result)))))
// The second line needs to add the onTrue() branch even though the IF node already exists.
// Skip re-dispatch of already-processed composites.
// The first dispatch fully processes all branch nodes. Re-dispatching
// the same object causes exponential recursion in convergence patterns
// (multiple paths reaching the same node).
const registry = this._registry ?? pluginRegistry;
if (typeof target === 'object' && target !== null && this._dispatchedComposites.has(target)) {
return registry.resolveCompositeHeadName(target, nameMapping);
}
// Try plugin dispatch
const handler = registry.findCompositeHandler(target);
if (handler) {
// Track dispatched composites so addMissingCompositeTargets can skip them
if (typeof target === 'object' && target !== null) {
this._dispatchedComposites.add(target);
}
const ctx = this.createMutablePluginContext(nodes, nameMapping);
return handler.addNodes(target, ctx);
}
return undefined;
}
/**
* Add a node and its subnodes to the nodes map, creating AI connections.
* Delegates to the addNodeWithSubnodes utility function.
*/
private addNodeWithSubnodes(
nodes: Map<string, GraphNode>,
nodeInstance: NodeInstance<string, string, unknown>,
): string | undefined {
return addNodeWithSubnodesUtil(nodes, nodeInstance);
}
/**
* Handle fan-out pattern - connects current node to multiple target nodes
* Supports NodeChain targets (e.g., workflow.to([x1, fb, linkedin.to(sheets)]))
*
* Each array element maps to a different output index (branching).
* Use null to skip an output index.
*/
private handleFanOut(nodes: unknown[]): WorkflowBuilder {
if (nodes.length === 0) {
return this;
}
const currentGraphNode = this._currentNode ? this._nodes.get(this._currentNode) : undefined;
// Add all target nodes and connect them to the current node
nodes.forEach((node, index) => {
// Skip null values (empty branches) but preserve the index for correct output mapping
if (node === null) {
return;
}
// Use addBranchToGraph to handle NodeChains properly
// This returns the head node name for connection
const headNodeName = this.addBranchToGraph(
this._nodes,
node as NodeInstance<string, string, unknown>,
);
// Connect from current node to the head of this target
// Array syntax always uses incrementing output indices (branching behavior)
if (this._currentNode && currentGraphNode) {
const mainConns =
currentGraphNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConnections: ConnectionTarget[] = mainConns.get(index) ?? [];
mainConns.set(index, [
...outputConnections,
{ node: headNodeName, type: 'main', index: 0 },
]);
currentGraphNode.connections.set('main', mainConns);
}
});
// Set the last non-null node in the array as the current node (for continued chaining)
// For NodeChains, use the tail node name (if tail is not null)
const nonNullNodes = nodes.filter((n): n is NonNullable<unknown> => n !== null);
const lastNode = nonNullNodes[nonNullNodes.length - 1];
this._currentNode = lastNode
? isNodeChain(lastNode)
? (lastNode.tail?.name ?? this._currentNode)
: (lastNode as NodeInstance<string, string, unknown>).name
: this._currentNode;
this._currentOutput = 0;
return this;
}
/**
* Handle a NodeChain passed to workflow.to()
* This is used when chained node calls are passed directly, e.g., workflow.to(node().to().to())
*/
private handleNodeChain(chain: NodeChain): WorkflowBuilder {
// Add the head node and connect from current workflow position
const headNodeName = this.addBranchToGraph(this._nodes, chain);
// Connect from current workflow node to the head of the chain
if (this._currentNode) {
const currentGraphNode = this._nodes.get(this._currentNode);
if (currentGraphNode) {
const mainConns =
currentGraphNode.connections.get('main') ?? new Map<number, ConnectionTarget[]>();
const outputConnections: ConnectionTarget[] = mainConns.get(this._currentOutput) ?? [];
// Standard behavior: connect to chain head
outputConnections.push({ node: headNodeName, type: 'main', index: 0 });
mainConns.set(this._currentOutput, outputConnections);
currentGraphNode.connections.set('main', mainConns);
}
}
// Collect pinData from the chain
this._pinData = this.collectPinDataFromChain(chain);
// Set current node to the tail of the chain
this._currentNode = chain.tail?.name ?? headNodeName;
this._currentOutput = 0;
return this;
}
/**
* Add a branch to the graph, handling both single nodes and NodeChains.
* Returns the name of the first node in the branch (for connection from IF).
* @param nameMapping - Optional map from node ID to actual map key (used when nodes are renamed)
*/
private addBranchToGraph(
nodes: Map<string, GraphNode>,
branch: NodeInstance<string, string, unknown>,
nameMapping?: Map<string, string>,
): string {
// Guard against infinite recursion from cycles in branch chains
if (this._branchDepth >= WorkflowBuilderImpl.MAX_BRANCH_DEPTH) {
throw new Error(
`Maximum branch depth (${WorkflowBuilderImpl.MAX_BRANCH_DEPTH}) exceeded while building workflow graph`,
);
}
this._branchDepth++;
try {
return this._addBranchToGraphInner(nodes, branch, nameMapping);
} finally {
this._branchDepth--;
}
}
private _addBranchToGraphInner(
nodes: Map<string, GraphNode>,
branch: NodeInstance<string, string, unknown>,
nameMapping?: Map<string, string>,
): string {
// Create nameMapping if not passed (tracks node ID -> actual map key for renamed nodes)
const effectiveNameMapping = nameMapping ?? new Map<string, string>();
const registry = this._registry ?? pluginRegistry;
// Try plugin dispatch first - handles all composite types
const pluginResult = this.tryPluginDispatch(nodes, branch, effectiveNameMapping);
if (pluginResult !== undefined) {
return pluginResult;
}
// Check if the branch is a NodeChain
if (isNodeChain(branch)) {
// Add all nodes from the chain, handling composites that may have been chained
for (const chainNode of branch.allNodes) {
// Skip null values (can occur when .to([null, node]) is used)
if (chainNode === null) {
continue;
}
// Skip invalid objects that aren't valid nodes or composites
// An object is valid if it has a 'name' property (NodeInstance) or is a registered composite type
if (
typeof chainNode !== 'object' ||
(!('name' in chainNode) && !registry.isCompositeType(chainNode))
) {
continue;
}
// Try plugin dispatch for composites
const chainPluginResult = this.tryPluginDispatch(nodes, chainNode, effectiveNameMapping);
if (chainPluginResult === undefined) {
// Not a composite - add as regular node
const actualKey = this.addNodeWithSubnodes(nodes, chainNode);
// Track the actual key if it was renamed
if (actualKey && actualKey !== chainNode.name) {
effectiveNameMapping.set(chainNode.id, actualKey);
}
}
}
// Process connections declared on the chain (from .to() calls)
const connections = branch.getConnections();
for (const { target, outputIndex, targetInputIndex, connectionType } of connections) {
const connType = connectionType ?? 'main';
// Find the source node in the chain that declared this connection
// by looking for the node whose .to() was called
for (const chainNode of branch.allNodes) {
// Skip null values (from array syntax like [null, node])
if (chainNode === null) {
continue;
}
// Get the actual node instance that might have connections
// Nodes without getConnections (like SplitInBatchesBuilder) are skipped
if (typeof chainNode.getConnections !== 'function') {
continue;
}
const nodeToCheck = chainNode;
const nodeName = chainNode.name;
if (nodeToCheck && nodeName && typeof nodeToCheck.getConnections === 'function') {
const nodeConns = nodeToCheck.getConnections();
if (
nodeConns.some(
(c) =>
c.target === target &&
c.outputIndex === outputIndex &&
c.targetInputIndex === targetInputIndex &&
(c.connectionType ?? 'main') === connType,
)
) {
// This chain node declared this connection
// First, ensure target nodes are added to the graph (e.g., error handler chains)
if (isNodeChain(target)) {
const chainTarget = target;
// Add each node in the chain that isn't already in the map
// We can't just check the head because the chain may reuse an existing
// node as head (e.g., set_content) while having new nodes after it
for (const targetChainNode of chainTarget.allNodes) {
if (targetChainNode === null) continue;
// Try plugin dispatch for composites
const targetPluginResult = this.tryPluginDispatch(
nodes,
targetChainNode,
effectiveNameMapping,
);
if (targetPluginResult === undefined && !nodes.has(targetChainNode.name)) {
// Not a composite and not already present - add as regular node
this.addNodeWithSubnodes(nodes, targetChainNode);
this.addSingleNodeConnectionTargets(nodes, targetChainNode);
}
}
} else if (registry.isCompositeType(target)) {
// Only dispatch if the composite's head node isn't already in the graph
// (avoids re-dispatching composites already handled by the allNodes loop above)
const compositeHeadName = this.resolveTargetNodeName(target, effectiveNameMapping);
if (!compositeHeadName || !nodes.has(compositeHeadName)) {
this.tryPluginDispatch(nodes, target, effectiveNameMapping);
}
} else if (
typeof (target as NodeInstance<string, string, unknown>).name === 'string' &&
!nodes.has((target as NodeInstance<string, string, unknown>).name)
) {
this.addNodeWithSubnodes(nodes, target as NodeInstance<string, string, unknown>);
this.addSingleNodeConnectionTargets(
nodes,
target as NodeInstance<string, string, unknown>,
);
}
// Use the effectiveNameMapping to get the actual key if the node was renamed
const mappedKey = nodeToCheck && effectiveNameMapping.get(nodeToCheck.id);
const actualSourceKey = mappedKey ?? nodeName;
const sourceGraphNode = nodes.get(actualSourceKey);
if (sourceGraphNode) {
const targetName = this.resolveTargetNodeName(target, effectiveNameMapping);
if (targetName) {
const typeConns =
sourceGraphNode.connections.get(connType) ??
new Map<number, ConnectionTarget[]>();
const outputConns: ConnectionTarget[] = typeConns.get(outputIndex) ?? [];
if (
!outputConns.some(
(c) => c.node === targetName && c.index === (targetInputIndex ?? 0),
)
) {
outputConns.push({
node: targetName,
type: 'main',
index: targetInputIndex ?? 0,
});
typeConns.set(outputIndex, outputConns);
sourceGraphNode.connections.set(connType, typeConns);
}
}
}
break; // Connection attributed to this node; stop searching chain
}
}
}
}
// Return the head node name (first node in the chain)
// Use effectiveNameMapping to get the actual key if the head was renamed
const headKey = effectiveNameMapping.get(branch.head.id) ?? branch.head.name;
return headKey;
} else {
// Single node - add it and return its name
// Note: Composites are handled by tryPluginDispatch at the entry point
const alreadyPresent = nodes.has(branch.name);
const actualKey = this.addNodeWithSubnodes(nodes, branch);
if (!alreadyPresent) {
this.addSingleNodeConnectionTargets(nodes, branch);
}
// If the node was renamed, track it and return the actual key
if (actualKey && actualKey !== branch.name) {
effectiveNameMapping.set(branch.id, actualKey);
}
return actualKey ?? branch.name;
}
}
}
/**
* Helper to check if options is a WorkflowBuilderOptions object
*/
function isWorkflowBuilderOptions(
options: WorkflowSettings | WorkflowBuilderOptions | undefined,
): options is WorkflowBuilderOptions {
if (!options) return false;
// WorkflowBuilderOptions has 'settings' or 'registry' as keys
// WorkflowSettings has keys like 'timezone', 'executionOrder', etc.
return 'settings' in options || 'registry' in options;
}
/**
* Create a new workflow builder
*/
function createWorkflow(
id: string,
name: string,
options?: WorkflowSettings | WorkflowBuilderOptions,
): WorkflowBuilder {
if (typeof id !== 'string') {
const receivedId = Array.isArray(id) ? 'an array' : typeof id;
throw new TypeError(
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
'workflow() requires (id: string, name: string). ' +
`workflow() requires a string id as first argument, but received ${receivedId}. ` +
"Example: workflow('my-workflow-id', 'My Workflow Name')",
);
}
if (typeof name !== 'string') {
const receivedName = Array.isArray(name) ? 'an array' : typeof name;
throw new TypeError(
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
'workflow() requires (id: string, name: string). ' +
`workflow() requires a string name as second argument, but received ${receivedName}. ` +
"Example: workflow('my-workflow-id', 'My Workflow Name')",
);
}
if (
options !== undefined &&
(Array.isArray(options) ||
(typeof options === 'object' &&
options !== null &&
('nodes' in options || 'connections' in options)))
) {
throw new TypeError(
'workflow() third argument is settings, not workflow structure. ' +
'Do not pass nodes or connections here — use .add() and .to() to build the workflow. ' +
"Example: workflow('id', 'Name').add(trigger({...})).to(node({...}))",
);
}
if (isWorkflowBuilderOptions(options)) {
return new WorkflowBuilderImpl(
id,
name,
options.settings,
undefined,
undefined,
undefined,
undefined,
options.registry,
);
}
return new WorkflowBuilderImpl(id, name, options);
}
/**
* Import workflow from n8n JSON format
*/
function fromJSON(json: WorkflowJSON): WorkflowBuilder {
const parsed = parseWorkflowJSON(json);
return new WorkflowBuilderImpl(
parsed.id,
parsed.name,
parsed.settings,
parsed.nodes,
parsed.lastNode,
parsed.pinData,
parsed.meta,
);
}
/**
* Workflow builder factory function with static methods
*/
export const workflow: WorkflowBuilderStatic = Object.assign(createWorkflow, {
fromJSON,
});