UNPKG

auto-builder-sdk

Version:

SDK for building Auto Builder workflow plugins

425 lines (424 loc) 17.6 kB
/* 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;