auto-builder-sdk
Version:
SDK for building Auto Builder workflow plugins
425 lines (424 loc) • 17.6 kB
JavaScript
/* eslint-disable @typescript-eslint/no-var-requires */
const credentialRegistry = {};
// Lightweight noop logger compatible with SDK API
export const log = {
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
// Simple error wrappers so plugins can throw the expected error types
export class NodeOperationError extends Error {
node;
constructor(node, message) {
super(message);
this.name = 'NodeOperationError';
this.node = node;
}
}
/**
* Backward compatibility utility to extract input data from both old and new formats
* Handles the transition from flat JSON structure to namespaced structure
*/
export const extractInputData = (inputData, fallbackToLegacy = true) => {
if (!inputData || inputData.length === 0) {
return {};
}
const firstItem = inputData[0]?.json || {};
// Check if this is new namespaced format by looking for metadata indicators
const hasNamespacedData = Object.keys(firstItem).some(key => typeof firstItem[key] === 'object' &&
firstItem[key] !== null &&
firstItem[key]?._metadata?.nodeType);
if (hasNamespacedData && fallbackToLegacy) {
// Extract all non-namespaced properties (legacy data preserved at root level)
const legacyData = {};
Object.keys(firstItem).forEach(key => {
// Skip namespaced objects (they have _metadata.nodeType)
if (typeof firstItem[key] !== 'object' ||
firstItem[key] === null ||
!firstItem[key]?._metadata?.nodeType) {
legacyData[key] = firstItem[key];
}
});
return legacyData;
}
// Return original data (either old format or explicitly requested new format)
return firstItem;
};
/**
* Extract data from a specific namespace in the new format
* Used when you specifically want data from a particular node type
*/
export const extractNamespacedData = (inputData, nodeType) => {
if (!inputData || inputData.length === 0) {
return {};
}
const firstItem = inputData[0]?.json || {};
// Look for the specific namespace
for (const value of Object.values(firstItem)) {
if (typeof value === 'object' &&
value !== null &&
value?._metadata?.nodeType === nodeType) {
return value;
}
}
return {};
};
export class NodeApiError extends Error {
httpStatusCode;
constructor(node, message, opts) {
super(message);
this.name = 'NodeApiError';
this.httpStatusCode = opts?.httpStatusCode;
}
}
// Minimal ParameterResolver with only the public static `resolve` method
export class ParameterResolver {
static deepClone(obj) {
if (obj === null || typeof obj !== 'object')
return obj;
if (obj instanceof Date)
return new Date(obj);
if (Array.isArray(obj))
return obj.map((item) => this.deepClone(item));
const cloned = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key))
cloned[key] = this.deepClone(obj[key]);
}
return cloned;
}
static getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => (current && current[key] !== undefined ? current[key] : undefined), obj);
}
static resolveStringTemplate(template, inputData, context) {
if (!template.includes('{{') || !template.includes('}}'))
return template;
let resolved = template;
resolved = resolved.replace(/\{\{\$json\.([^}]+)\}\}/g, (_, p) => {
const val = this.getNestedValue(inputData, p);
return val !== undefined ? String(val) : _;
});
resolved = resolved.replace(/\{\{\$now\}\}/g, new Date().toISOString());
resolved = resolved.replace(/\{\{new Date\(\)\.toISOString\(\)\}\}/g, new Date().toISOString());
resolved = resolved.replace(/\{\{\$item\}\}/g, JSON.stringify(inputData));
resolved = resolved.replace(/\{\{\$execution\.id\}\}/g, context.executionId || '');
resolved = resolved.replace(/\{\{\$workflow\.id\}\}/g, context.workflowId || '');
return resolved;
}
static resolveTemplates(obj, inputData, context) {
if (typeof obj === 'string')
return this.resolveStringTemplate(obj, inputData, context);
if (Array.isArray(obj))
return obj.map((i) => this.resolveTemplates(i, inputData, context));
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj))
out[k] = this.resolveTemplates(v, inputData, context);
return out;
}
return obj;
}
static resolve(parameters, context) {
if (!parameters)
return {};
const cloned = this.deepClone(parameters);
const inputData = context.inputData?.[context.itemIndex || 0]?.json ?? {};
return this.resolveTemplates(cloned, inputData, context);
}
}
// Basic BaseNodeExecutor implementation sufficient for most plugins
export class BaseNodeExecutor {
resolveParameters(parameters, context) {
return ParameterResolver.resolve(parameters, context);
}
async getCredentials(credentialId) {
if (!credentialId)
return {};
// Fast path: SAFE_MODE sandbox injects credentials into global.__cred
try {
const credMap = globalThis.__cred;
if (credMap && credMap[credentialId]) {
return credMap[credentialId];
}
}
catch {
/* ignore */
}
// vm2 exposes sandbox keys as global variables, which may not be
// attached to `globalThis`. Fallback to a direct free-variable lookup.
// The `typeof` guard prevents ReferenceError if __cred is undefined.
try {
// eslint-disable-next-line no-undef
if (typeof globalThis.__cred !== 'undefined' && globalThis.__cred && globalThis.__cred[credentialId]) {
// eslint-disable-next-line no-undef
return globalThis.__cred[credentialId];
}
}
catch {
/* ignore */
}
const candidates = [
'auto-builder/dist/services/CredentialManager.js',
(() => {
const path = require('node:path');
return path.resolve(__dirname, '../../auto-builder/dist/services/CredentialManager.js');
})(),
];
for (const modPath of candidates) {
try {
// dynamic import handles both ESM & CJS
const m = await import(modPath);
const CredentialManager = m.CredentialManager || (m.default?.CredentialManager);
if (!CredentialManager)
continue;
const manager = CredentialManager.getInstance();
const cred = await manager.getCredentials(credentialId);
if (cred)
return cred;
}
catch {
// try next candidate
}
}
return {};
}
handleContinueOnFail(error, node) {
if (node.continueOnFail) {
return [
{
json: { error: error.message, timestamp: new Date().toISOString() },
binary: {},
pairedItem: { item: 0 },
},
];
}
throw error;
}
/**
* Get the position of this node among nodes of the same type in the workflow
*/
getWorkflowNodePosition(node, context) {
const sameTypeNodes = context.workflow?.nodes?.filter((n) => n.type === node.type) || [];
return sameTypeNodes.findIndex((n) => n.id === node.id) + 1; // 1-based index
}
/**
* Generate a unique prefix for this node based on its type and position in the workflow
* This automatically handles cases where the same node type appears multiple times
*/
generateNodePrefix(node, context) {
const nodePosition = this.getWorkflowNodePosition(node, context);
const cleanType = node.type.replace(/\./g, '_');
return `${cleanType}_${nodePosition}`;
}
/**
* Create a namespaced result object (industry standard approach)
* Instead of prefixing every key, we namespace the entire result under the node type
*/
createNamespacedResult(result, nodeType, nodePosition) {
const cleanType = nodeType.replace(/\./g, '_');
const namespaceKey = nodePosition > 1 ? `${cleanType}_${nodePosition}` : cleanType;
return {
[namespaceKey]: result
};
}
}
/**
* Get the position of a node among nodes of the same type in the workflow
*/
export const getWorkflowNodePosition = (node, context) => {
const sameTypeNodes = context.workflow?.nodes?.filter((n) => n.type === node.type) || [];
const position = sameTypeNodes.findIndex((n) => n.id === node.id) + 1; // 1-based index
return position > 0 ? position : 1; // Default to 1 if not found
};
/**
* Create a namespaced result object (industry standard approach)
* Instead of prefixing every key, we namespace the entire result under the node type
*/
export const createNamespacedResult = (result, nodeType, nodePosition) => {
const cleanType = nodeType.replace(/\./g, '_');
const namespaceKey = nodePosition > 1 ? `${cleanType}_${nodePosition}` : cleanType;
return {
[namespaceKey]: result
};
};
/**
* Create a formatted result that merges input data with namespaced node results
* This prevents key conflicts while preserving all data from previous nodes
* Automatically determines the namespace based on node type and workflow position
*
* @param inputData - The input data from previous nodes
* @param result - The result data from this node's operation
* @param context - The execution context (contains node and workflow info)
* @param dataItemIndex - The index of the current data item being processed (0, 1, 2...)
* @param options - Optional configuration for output format
* @returns Formatted result object with namespaced keys
*/
export const createFormattedResult = (inputData, result, context, dataItemIndex, options = {}) => {
const { legacyMode = false, includeBothFormats = false } = options;
// Legacy mode: use old behavior (no namespacing) - matches original exactly
if (legacyMode) {
return {
json: {
...inputData.json,
...result,
_metadata: {
operation: context.node.parameters?.operation,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
index: dataItemIndex // Use 'index' to match original
}
},
binary: inputData.binary || {},
pairedItem: { item: dataItemIndex }
};
}
// New namespaced approach (industry standard)
const nodePosition = getWorkflowNodePosition(context.node, context);
const namespacedResult = createNamespacedResult({
...result,
_metadata: {
operation: context.node.parameters?.operation,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
nodeType: context.node.type,
dataItemIndex,
workflowNodePosition: nodePosition
}
}, context.node.type, nodePosition);
const namespacedOutput = {
json: {
// Preserve all input data
...inputData.json,
// Add namespaced result data
...namespacedResult
},
binary: inputData.binary || {},
pairedItem: { item: dataItemIndex }
};
// Include both formats for gradual migration
if (includeBothFormats) {
namespacedOutput.json = {
...namespacedOutput.json,
// Also include unprefixed keys for backward compatibility - match original format
...result,
_metadata: {
operation: context.node.parameters?.operation,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
index: dataItemIndex, // Use 'index' to match original
// Additional fields for enhanced functionality
nodeType: context.node.type,
workflowNodePosition: getWorkflowNodePosition(context.node, context),
_notice: 'Legacy keys are deprecated. Use namespaced keys instead.'
}
};
}
return namespacedOutput;
};
/**
* Create a formatted error result that merges input data with namespaced error information
* Automatically determines the namespace based on node type and workflow position
*
* @param inputData - The input data from previous nodes
* @param error - The error that occurred
* @param context - The execution context (contains node and workflow info)
* @param dataItemIndex - The index of the current data item being processed (0, 1, 2...)
* @param options - Optional configuration for output format
* @returns Formatted error result with namespaced keys
*/
export const createFormattedErrorResult = (inputData, error, context, dataItemIndex, options = {}) => {
const { legacyMode = false, includeBothFormats = false } = options;
// Legacy mode: use old behavior (no namespacing) - matches original exactly
if (legacyMode) {
return {
json: {
...inputData.json,
error: error.message,
_metadata: {
operation: context.node.parameters?.operation,
failed: true,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
index: dataItemIndex // Use 'index' to match original
}
},
binary: inputData.binary || {},
pairedItem: { item: dataItemIndex }
};
}
// New namespaced approach (industry standard)
const nodePosition = getWorkflowNodePosition(context.node, context);
const namespacedResult = createNamespacedResult({
error: error.message,
success: false,
_metadata: {
operation: context.node.parameters?.operation,
failed: true,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
nodeType: context.node.type,
dataItemIndex,
workflowNodePosition: nodePosition
}
}, context.node.type, nodePosition);
const namespacedOutput = {
json: {
// Preserve all input data
...inputData.json,
// Add namespaced error data
...namespacedResult
},
binary: inputData.binary || {},
pairedItem: { item: dataItemIndex }
};
// Include both formats for gradual migration
if (includeBothFormats) {
namespacedOutput.json = {
...namespacedOutput.json,
// Also include unprefixed keys for backward compatibility - match original format
error: error.message,
_metadata: {
operation: context.node.parameters?.operation,
failed: true,
timestamp: new Date().toISOString(),
nodeId: context.node.id,
index: dataItemIndex, // Use 'index' to match original
// Additional fields for enhanced functionality
nodeType: context.node.type,
workflowNodePosition: getWorkflowNodePosition(context.node, context),
_notice: 'Legacy keys are deprecated. Use namespaced keys instead.'
}
};
}
return namespacedOutput;
};
export const validatePlugin = (meta) => meta;
export const definePlugin = (meta) => validatePlugin(meta);
// Re-export types (at runtime they're empty objects)
module.exports.makeStubContext = () => ({ executionId: 'stub', workflowId: 'stub' });
module.exports.makeStubNode = () => ({ id: 'stub' });
// Export in CommonJS style as well for consumers using `require`
module.exports.BaseNodeExecutor = BaseNodeExecutor;
module.exports.ParameterResolver = ParameterResolver;
module.exports.NodeOperationError = NodeOperationError;
module.exports.NodeApiError = NodeApiError;
module.exports.definePlugin = definePlugin;
module.exports.validatePlugin = validatePlugin;
module.exports.log = log;
export const registerCredential = (def) => {
if (!def?.name)
throw new Error('Credential definition missing name');
credentialRegistry[def.name] = def;
};
export const getCredentialDef = (name) => credentialRegistry[name];
export const listCredentialDefinitions = () => Object.values(credentialRegistry);
module.exports.registerCredential = registerCredential;
module.exports.getCredentialDef = getCredentialDef;
module.exports.listCredentialDefinitions = listCredentialDefinitions;
// Export formatting utilities for plugin developers
module.exports.createFormattedResult = createFormattedResult;
module.exports.createFormattedErrorResult = createFormattedErrorResult;
// Export backward compatibility utilities
module.exports.extractInputData = extractInputData;
module.exports.extractNamespacedData = extractNamespacedData;