n8n
Version:
n8n Workflow Automation Tool
498 lines • 21.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.partialUpdateOperationSchema = void 0;
exports.applyOperations = applyOperations;
exports.toWorkflowSlice = toWorkflowSlice;
const n8n_workflow_1 = require("n8n-workflow");
const uuid_1 = require("uuid");
const zod_1 = require("zod");
const positionSchema = () => zod_1.z
.array(zod_1.z.number())
.length(2)
.transform((v) => [v[0], v[1]])
.describe('Canvas position as [x, y]');
const credentialsSchema = zod_1.z.record(zod_1.z.string(), zod_1.z.object({ id: zod_1.z.string().optional(), name: zod_1.z.string() }));
exports.partialUpdateOperationSchema = zod_1.z.discriminatedUnion('type', [
zod_1.z.object({
type: zod_1.z.literal('updateNodeParameters'),
nodeName: zod_1.z.string().describe('Name of the existing node to update.'),
parameters: zod_1.z
.record(zod_1.z.string(), zod_1.z.unknown())
.describe('Parameter object to merge into (or replace) the node parameters.'),
replace: zod_1.z
.boolean()
.optional()
.describe('If true, replace the node parameters entirely with `parameters`. If false or omitted, deep-merge `parameters` into the existing parameters.'),
}),
zod_1.z.object({
type: zod_1.z.literal('setNodeParameter'),
nodeName: zod_1.z.string().describe('Name of the existing node to update.'),
path: zod_1.z
.string()
.min(2)
.describe('JSON Pointer (RFC 6901) path to the parameter to set, e.g. "/jsonSchema" or "/options/systemMessage". Must start with "/". Intermediate objects are created on demand. Array indices are NOT supported — to change a value inside an array, set the whole array. Use this instead of `updateNodeParameters` when you only need to set one nested key — the payload stays small regardless of the rest of the parameters object.'),
value: zod_1.z
.unknown()
.refine((v) => v !== undefined, { message: 'value is required' })
.describe('Value to set at the path. Any defined JSON value.'),
}),
zod_1.z.object({
type: zod_1.z.literal('addNode'),
node: zod_1.z
.object({
name: zod_1.z.string().describe('Unique node name. Must not collide with an existing node.'),
type: zod_1.z.string().describe('Fully qualified node type, e.g. "n8n-nodes-base.set".'),
typeVersion: zod_1.z.number(),
parameters: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
position: positionSchema().optional(),
credentials: credentialsSchema.optional(),
disabled: zod_1.z.boolean().optional(),
notes: zod_1.z.string().optional(),
id: zod_1.z.string().optional().describe('Optional node id. Generated if omitted.'),
})
.describe('The node to add to the workflow.'),
}),
zod_1.z.object({
type: zod_1.z.literal('removeNode'),
nodeName: zod_1.z
.string()
.describe('Name of the node to remove. All inbound and outbound connections are removed, including any sub-node attachments (LLM models, memory, tools) — the sub-nodes themselves remain in the workflow but become disconnected and will not be re-attached automatically. To modify a node, use updateNodeParameters or setNodeParameter instead.'),
}),
zod_1.z.object({
type: zod_1.z.literal('renameNode'),
oldName: zod_1.z.string(),
newName: zod_1.z.string().describe('New unique node name.'),
}),
zod_1.z.object({
type: zod_1.z.literal('addConnection'),
source: zod_1.z.string().describe('Name of the source node.'),
target: zod_1.z.string().describe('Name of the target node.'),
sourceIndex: zod_1.z
.number()
.int()
.nonnegative()
.optional()
.describe('Source output index. Default 0.'),
targetIndex: zod_1.z
.number()
.int()
.nonnegative()
.optional()
.describe('Target input index. Default 0.'),
connectionType: zod_1.z
.string()
.optional()
.describe('Connection type, e.g. "main" or "ai_languageModel". Default "main".'),
}),
zod_1.z.object({
type: zod_1.z.literal('removeConnection'),
source: zod_1.z.string(),
target: zod_1.z.string(),
sourceIndex: zod_1.z.number().int().nonnegative().optional(),
targetIndex: zod_1.z.number().int().nonnegative().optional(),
connectionType: zod_1.z.string().optional(),
}),
zod_1.z.object({
type: zod_1.z.literal('setNodeCredential'),
nodeName: zod_1.z.string(),
credentialKey: zod_1.z
.string()
.describe('Credential key on the node, e.g. "slackApi" or "httpHeaderAuth".'),
credentialId: zod_1.z.string(),
credentialName: zod_1.z.string(),
}),
zod_1.z.object({
type: zod_1.z.literal('setNodePosition'),
nodeName: zod_1.z.string(),
position: positionSchema(),
}),
zod_1.z.object({
type: zod_1.z.literal('setNodeDisabled'),
nodeName: zod_1.z.string(),
disabled: zod_1.z.boolean(),
}),
zod_1.z.object({
type: zod_1.z.literal('setNodeSettings'),
nodeName: zod_1.z.string().describe('Name of the existing node to update.'),
settings: zod_1.z
.object({
onError: zod_1.z
.enum(['stopWorkflow', 'continueRegularOutput', 'continueErrorOutput'])
.optional()
.describe('How the node behaves on error. "stopWorkflow" halts the run; "continueRegularOutput" forwards an empty item on the main output; "continueErrorOutput" routes the failure to the node\'s error output. Required for sub-nodes (LLM model, memory, tools) since the canvas UI does not expose this setting for them.'),
retryOnFail: zod_1.z.boolean().optional(),
maxTries: zod_1.z
.number()
.int()
.min(2)
.max(5)
.optional()
.describe('Number of attempts when retryOnFail is true (2–5).'),
waitBetweenTries: zod_1.z
.number()
.int()
.min(0)
.max(5000)
.optional()
.describe('Milliseconds to wait between retry attempts (0–5000).'),
alwaysOutputData: zod_1.z.boolean().optional(),
executeOnce: zod_1.z.boolean().optional(),
})
.refine((s) => Object.keys(s).length > 0, {
message: 'settings must specify at least one field',
})
.describe('Node-level execution settings. Only the keys you include are written; omitted keys are left unchanged.'),
}),
zod_1.z.object({
type: zod_1.z.literal('setWorkflowMetadata'),
name: zod_1.z.string().max(128).optional(),
description: zod_1.z.string().max(255).optional(),
}),
]);
const cloneWorkflow = (workflow) => ({
name: workflow.name,
description: workflow.description,
nodes: workflow.nodes.map((node) => structuredClone(node)),
connections: structuredClone(workflow.connections),
});
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
const sanitizeUnsafeKeys = (value) => {
if (Array.isArray(value))
return value.map(sanitizeUnsafeKeys);
if (!isPlainObject(value))
return value;
const out = {};
for (const [key, v] of Object.entries(value)) {
if (!(0, n8n_workflow_1.isSafeObjectProperty)(key))
continue;
out[key] = sanitizeUnsafeKeys(v);
}
return out;
};
const parseJsonPointer = (path) => {
if (!path.startsWith('/'))
return null;
const tail = path.slice(1);
if (tail.length === 0)
return null;
const rawSegments = tail.split('/');
const segments = [];
for (const raw of rawSegments) {
if (/~(?:[^01]|$)/.test(raw))
return null;
const seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
if (seg.length === 0 || !(0, n8n_workflow_1.isSafeObjectProperty)(seg))
return null;
segments.push(seg);
}
return segments;
};
const setAtPointer = (root, segments, value) => {
let cursor = root;
for (let i = 0; i < segments.length - 1; i++) {
const key = segments[i];
const next = cursor[key];
if (next === undefined) {
const child = {};
cursor[key] = child;
cursor = child;
}
else if (isPlainObject(next)) {
cursor = next;
}
else {
return `cannot descend into non-object at '/${segments.slice(0, i + 1).join('/')}'`;
}
}
cursor[segments[segments.length - 1]] = sanitizeUnsafeKeys(value);
return null;
};
const deepMerge = (target, source) => {
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (!(0, n8n_workflow_1.isSafeObjectProperty)(key))
continue;
const existing = Object.prototype.hasOwnProperty.call(result, key) ? result[key] : undefined;
if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value);
}
else {
result[key] = sanitizeUnsafeKeys(value);
}
}
return result;
};
const removeConnectionsFor = (connections, nodeName) => {
delete connections[nodeName];
for (const sourceName of Object.keys(connections)) {
const byType = connections[sourceName];
for (const connectionType of Object.keys(byType)) {
const outputs = byType[connectionType];
for (let i = 0; i < outputs.length; i++) {
const targets = outputs[i];
if (!targets)
continue;
outputs[i] = targets.filter((c) => c.node !== nodeName);
}
if (outputs.every((o) => !o || o.length === 0)) {
delete byType[connectionType];
}
}
if (Object.keys(byType).length === 0) {
delete connections[sourceName];
}
}
};
const renameInConnections = (connections, oldName, newName) => {
if (connections[oldName]) {
connections[newName] = connections[oldName];
delete connections[oldName];
}
for (const sourceName of Object.keys(connections)) {
const byType = connections[sourceName];
for (const connectionType of Object.keys(byType)) {
const outputs = byType[connectionType];
for (const targets of outputs) {
if (!targets)
continue;
for (const conn of targets) {
if (conn.node === oldName)
conn.node = newName;
}
}
}
}
};
const ensureOutputSlot = (connections, source, connectionType, sourceIndex) => {
const byType = (connections[source] ??= {});
const outputs = (byType[connectionType] ??= []);
while (outputs.length <= sourceIndex)
outputs.push(null);
const slot = outputs[sourceIndex] ?? [];
outputs[sourceIndex] = slot;
return slot;
};
const pruneConnectionShape = (connections, source, connectionType) => {
const byType = connections[source];
if (!byType)
return;
const outputs = byType[connectionType];
if (outputs && outputs.every((o) => !o || o.length === 0)) {
delete byType[connectionType];
}
if (Object.keys(byType).length === 0) {
delete connections[source];
}
};
const fail = (opIndex, message) => ({
success: false,
error: `Operation ${opIndex} failed: ${message}`,
opIndex,
});
function applyOperations(input, operations) {
const workflow = cloneWorkflow(input);
const nodeByName = new Map(workflow.nodes.map((n) => [n.name, n]));
const addedNodeNames = new Set();
for (let i = 0; i < operations.length; i++) {
const op = operations[i];
switch (op.type) {
case 'updateNodeParameters': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
const sanitized = sanitizeUnsafeKeys(op.parameters);
const merged = op.replace
? sanitized
: deepMerge((node.parameters ?? {}), sanitized);
node.parameters = merged;
break;
}
case 'setNodeParameter': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
const segments = parseJsonPointer(op.path);
if (!segments) {
return fail(i, `path '${op.path}' is invalid or contains unsafe segments`);
}
const params = (node.parameters ?? {});
const setError = setAtPointer(params, segments, op.value);
if (setError)
return fail(i, setError);
node.parameters = params;
break;
}
case 'addNode': {
if (!(0, n8n_workflow_1.isSafeObjectProperty)(op.node.name)) {
return fail(i, `node name '${op.node.name}' is not allowed`);
}
if (nodeByName.has(op.node.name)) {
return fail(i, `a node named '${op.node.name}' already exists`);
}
const node = {
id: op.node.id ?? (0, uuid_1.v4)(),
name: op.node.name,
type: op.node.type,
typeVersion: op.node.typeVersion,
position: op.node.position ?? [0, 0],
parameters: (sanitizeUnsafeKeys(op.node.parameters ?? {}) ?? {}),
};
if (op.node.credentials) {
const credentialEntries = [];
for (const [key, cred] of Object.entries(op.node.credentials)) {
if (!(0, n8n_workflow_1.isSafeObjectProperty)(key)) {
return fail(i, `credential key '${key}' is not allowed`);
}
credentialEntries.push([key, { id: cred.id ?? null, name: cred.name }]);
}
node.credentials = Object.fromEntries(credentialEntries);
}
if (op.node.disabled !== undefined)
node.disabled = op.node.disabled;
if (op.node.notes !== undefined)
node.notes = op.node.notes;
workflow.nodes.push(node);
nodeByName.set(node.name, node);
addedNodeNames.add(node.name);
break;
}
case 'removeNode': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
workflow.nodes.splice(workflow.nodes.indexOf(node), 1);
nodeByName.delete(op.nodeName);
removeConnectionsFor(workflow.connections, op.nodeName);
addedNodeNames.delete(op.nodeName);
break;
}
case 'renameNode': {
if (op.oldName === op.newName)
break;
if (!(0, n8n_workflow_1.isSafeObjectProperty)(op.newName)) {
return fail(i, `node name '${op.newName}' is not allowed`);
}
const node = nodeByName.get(op.oldName);
if (!node)
return fail(i, `node '${op.oldName}' not found`);
if (nodeByName.has(op.newName)) {
return fail(i, `a node named '${op.newName}' already exists`);
}
node.name = op.newName;
nodeByName.delete(op.oldName);
nodeByName.set(op.newName, node);
renameInConnections(workflow.connections, op.oldName, op.newName);
if (addedNodeNames.delete(op.oldName))
addedNodeNames.add(op.newName);
break;
}
case 'addConnection': {
if (!nodeByName.has(op.source)) {
return fail(i, `source node '${op.source}' not found`);
}
if (!nodeByName.has(op.target)) {
return fail(i, `target node '${op.target}' not found`);
}
const connectionType = (op.connectionType ??
n8n_workflow_1.NodeConnectionTypes.Main);
if (!(0, n8n_workflow_1.isSafeObjectProperty)(op.source) || !(0, n8n_workflow_1.isSafeObjectProperty)(connectionType)) {
return fail(i, 'connection name is not allowed');
}
const sourceIndex = op.sourceIndex ?? 0;
const targetIndex = op.targetIndex ?? 0;
const slot = ensureOutputSlot(workflow.connections, op.source, connectionType, sourceIndex);
const exists = slot.some((c) => c.node === op.target && c.type === connectionType && c.index === targetIndex);
if (!exists) {
slot.push({ node: op.target, type: connectionType, index: targetIndex });
}
break;
}
case 'removeConnection': {
const connectionType = (op.connectionType ??
n8n_workflow_1.NodeConnectionTypes.Main);
const sourceIndex = op.sourceIndex ?? 0;
const targetIndex = op.targetIndex ?? 0;
const byType = workflow.connections[op.source];
const outputs = byType?.[connectionType];
const slot = outputs?.[sourceIndex];
if (!slot) {
return fail(i, `no '${connectionType}' connection from '${op.source}'`);
}
const filtered = slot.filter((c) => !(c.node === op.target && c.type === connectionType && c.index === targetIndex));
if (filtered.length === slot.length) {
return fail(i, `connection from '${op.source}'[${sourceIndex}] to '${op.target}'[${targetIndex}] does not exist`);
}
outputs[sourceIndex] = filtered;
pruneConnectionShape(workflow.connections, op.source, connectionType);
break;
}
case 'setNodeCredential': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
if (!(0, n8n_workflow_1.isSafeObjectProperty)(op.credentialKey)) {
return fail(i, `credential key '${op.credentialKey}' is not allowed`);
}
node.credentials = {
...(node.credentials ?? {}),
[op.credentialKey]: { id: op.credentialId, name: op.credentialName },
};
break;
}
case 'setNodePosition': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
node.position = op.position;
break;
}
case 'setNodeDisabled': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
node.disabled = op.disabled;
break;
}
case 'setNodeSettings': {
const node = nodeByName.get(op.nodeName);
if (!node)
return fail(i, `node '${op.nodeName}' not found`);
const s = op.settings;
if (s.onError !== undefined)
node.onError = s.onError;
if (s.retryOnFail !== undefined)
node.retryOnFail = s.retryOnFail;
if (s.maxTries !== undefined)
node.maxTries = s.maxTries;
if (s.waitBetweenTries !== undefined)
node.waitBetweenTries = s.waitBetweenTries;
if (s.alwaysOutputData !== undefined)
node.alwaysOutputData = s.alwaysOutputData;
if (s.executeOnce !== undefined)
node.executeOnce = s.executeOnce;
break;
}
case 'setWorkflowMetadata': {
if (op.name !== undefined)
workflow.name = op.name;
if (op.description !== undefined)
workflow.description = op.description;
break;
}
default: {
op;
return fail(i, 'unknown operation type');
}
}
}
return { success: true, workflow, addedNodeNames: [...addedNodeNames] };
}
function toWorkflowSlice(workflow) {
return {
name: workflow.name ?? '',
description: workflow.description,
nodes: workflow.nodes,
connections: workflow.connections,
};
}
//# sourceMappingURL=workflow-operations.js.map