UNPKG

auto-builder-sdk

Version:

SDK for building Auto Builder workflow plugins

619 lines (618 loc) โ€ข 24.7 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 }; /** * Get stable output key for a node * Priority: * 1. Use node.outputKey if already saved (stable - persisted from previous execution) * 2. Generate based on position, ensuring no collision with existing saved keys * * Returns both the key and whether it's newly generated. * When isNewlyGenerated is true, the workflow engine should save outputKey to node config. */ export const getStableOutputKey = (node, context) => { const nodePosition = getWorkflowNodePosition(node, context); // If node already has a saved outputKey, use it (stable) if (node.outputKey && typeof node.outputKey === 'string') { return { outputKey: node.outputKey, isNewlyGenerated: false, workflowNodePosition: nodePosition }; } const cleanType = node.type.replace(/\./g, '_'); // Collect all existing outputKeys from other nodes of the same type // This prevents collisions when nodes are reordered or removed const existingOutputKeys = new Set(); const sameTypeNodes = context.workflow?.nodes?.filter((n) => n.type === node.type) || []; for (const n of sameTypeNodes) { if (n.id !== node.id && n.outputKey && typeof n.outputKey === 'string') { existingOutputKeys.add(n.outputKey); } } // Generate candidate key based on position let candidateKey = nodePosition > 1 ? `${cleanType}_${nodePosition}` : cleanType; // If the candidate is already taken by another node's saved key, find next available if (existingOutputKeys.has(candidateKey)) { let counter = 1; while (counter < 1000) { // Safety limit const testKey = counter === 1 ? cleanType : `${cleanType}_${counter}`; if (!existingOutputKeys.has(testKey)) { candidateKey = testKey; break; } counter++; } } return { outputKey: candidateKey, isNewlyGenerated: true, workflowNodePosition: nodePosition }; }; /** * Create a namespaced result object * Uses the provided outputKey as the namespace key * @param result - The result data * @param outputKey - The namespace key to use (from getStableOutputKey) */ export const createNamespacedResult = (result, outputKey) => { return { [outputKey]: 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, nodeResult, context, dataItemIndex, options = {}) => { const { legacyMode = false, includeBothFormats = false } = options; const result = { ...nodeResult, internal_node_reference: context.node.id }; // Get stable output key (uses saved key if available, otherwise generates) const { outputKey, isNewlyGenerated, workflowNodePosition } = getStableOutputKey(context.node, context); // 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 outputKey, // Include for workflow engine to save isNewlyGenerated // true = workflow engine should save outputKey to node config } }, binary: inputData.binary || {}, pairedItem: { item: dataItemIndex } }; } // New namespaced approach (industry standard) - using stable output key const namespacedResult = createNamespacedResult({ ...result, _metadata: { operation: context.node.parameters?.operation, timestamp: new Date().toISOString(), nodeId: context.node.id, nodeType: context.node.type, dataItemIndex, workflowNodePosition, outputKey, // The stable namespace key used for this node isNewlyGenerated // true = workflow engine should save outputKey to node config } }, outputKey); 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, outputKey, isNewlyGenerated, _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; // Get stable output key (uses saved key if available, otherwise generates) const { outputKey, isNewlyGenerated, workflowNodePosition } = getStableOutputKey(context.node, context); // Legacy mode: use old behavior (no namespacing) - matches original exactly if (legacyMode) { return { json: { ...inputData.json, error: error.message, internal_node_reference: context.node.id, _metadata: { operation: context.node.parameters?.operation, failed: true, timestamp: new Date().toISOString(), nodeId: context.node.id, index: dataItemIndex, // Use 'index' to match original outputKey, // Include for workflow engine to save isNewlyGenerated // true = workflow engine should save outputKey to node config } }, binary: inputData.binary || {}, pairedItem: { item: dataItemIndex } }; } // New namespaced approach (industry standard) - using stable output key const namespacedResult = createNamespacedResult({ error: error.message, success: false, internal_node_reference: context.node.id, _metadata: { operation: context.node.parameters?.operation, failed: true, timestamp: new Date().toISOString(), nodeId: context.node.id, nodeType: context.node.type, dataItemIndex, workflowNodePosition, outputKey, // The stable namespace key used for this node isNewlyGenerated // true = workflow engine should save outputKey to node config } }, outputKey); 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, internal_node_reference: context.node.id, _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, outputKey, isNewlyGenerated, _notice: 'Legacy keys are deprecated. Use namespaced keys instead.' } }; } return namespacedOutput; }; export const validatePlugin = (meta) => meta; export const definePlugin = (meta) => validatePlugin(meta); 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); // ============================================================================ // Temp File Management Utilities (Shared across all nodes) // ============================================================================ /** * Creates a temporary file with metadata tracking for cleanup * Returns a reference object with file path and metadata * * @param buffer - Binary data to store * @param workflowId - Workflow ID for tracking * @param nodeId - Node ID for tracking * @param fileName - Original filename for extension preservation * @param metadata - Additional metadata (optional) * @returns Promise<TempFileReference> - Reference object with file path and metadata */ export const createTempFile = async (buffer, workflowId, nodeId, fileName, metadata = {}) => { const fs = await import('fs/promises'); const path = await import('path'); const os = await import('os'); // Create consistent temp directory structure: /tmp/auto-builder-temp/ const baseTempDir = path.join(os.tmpdir(), 'auto-builder-temp'); try { await fs.mkdir(baseTempDir, { recursive: true }); } catch (error) { // Directory might already exist, ignore error } // Create unique subdirectory for this file const uniqueDir = await fs.mkdtemp(path.join(baseTempDir, 'file-')); // Preserve file extension and sanitize filename const timestamp = Date.now(); const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); const tempFileName = `${timestamp}-${sanitizedFileName}`; const tempFilePath = path.join(uniqueDir, tempFileName); // Write binary data await fs.writeFile(tempFilePath, buffer); // Create metadata file alongside the temp file const metadataPath = `${tempFilePath}.meta.json`; const fileMetadata = { workflowId, nodeId, originalFileName: fileName, fileSize: buffer.length, createdAt: new Date().toISOString(), tempFilePath, tempDir: uniqueDir, ...metadata }; await fs.writeFile(metadataPath, JSON.stringify(fileMetadata, null, 2)); log.debug('๐Ÿ“ [TEMP_FILE] Created temp file with metadata', { tempFilePath, fileSize: buffer.length, workflowId, nodeId, fileName }); return { tempFilePath, tempDir: uniqueDir, metadataPath, reference: { path: tempFilePath, size: buffer.length, originalFileName: fileName, createdAt: fileMetadata.createdAt, workflowId, nodeId } }; }; /** * Reads a temporary file and returns the buffer * * @param tempFilePath - Path to the temporary file (string) * @returns Promise<Buffer> - Binary data */ export const readTempFile = async (tempFilePath) => { const fs = await import('fs/promises'); if (!tempFilePath || typeof tempFilePath !== 'string') { throw new Error('Invalid temp file path: string path is required'); } try { const buffer = await fs.readFile(tempFilePath); log.debug('โœ… [TEMP_FILE] Read temp file', { path: tempFilePath, size: buffer.length }); return buffer; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; log.error('โŒ [TEMP_FILE] Failed to read temp file', { path: tempFilePath, error: errorMsg }); throw new Error(`Failed to read temp file '${tempFilePath}': ${errorMsg}`); } }; /** * Deletes a temporary file and its metadata * * @param tempFilePath - Path to the temporary file (string) * @returns Promise<void> */ export const deleteTempFile = async (tempFilePath) => { const fs = await import('fs/promises'); const path = await import('path'); if (!tempFilePath || typeof tempFilePath !== 'string') { throw new Error('Invalid temp file path: string path is required'); } try { // Delete metadata file if exists try { await fs.unlink(`${tempFilePath}.meta.json`); } catch { /* ignore */ } // Delete the actual file await fs.unlink(tempFilePath); // Try to delete the parent directory if empty try { await fs.rmdir(path.dirname(tempFilePath)); } catch { /* ignore */ } log.debug('๐Ÿงน [TEMP_FILE] Deleted temp file', { path: tempFilePath }); } catch (error) { log.warn('โš ๏ธ [TEMP_FILE] Failed to delete temp file', { path: tempFilePath, error: error instanceof Error ? error.message : 'Unknown error' }); } }; /** * Gets metadata for a temporary file * * @param tempFilePath - Path to the temporary file (string) * @returns Promise<object | null> - Metadata object or null if not found */ export const getTempFileMetadata = async (tempFilePath) => { const fs = await import('fs/promises'); if (!tempFilePath || typeof tempFilePath !== 'string') { return null; } try { const content = await fs.readFile(`${tempFilePath}.meta.json`, 'utf-8'); return JSON.parse(content); } catch { return null; } };