UNPKG

auto-builder-sdk

Version:

SDK for building Auto Builder workflow plugins

675 lines (674 loc) โ€ข 28.1 kB
"use strict"; /* eslint-disable @typescript-eslint/no-var-requires */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.getTempFileMetadata = exports.deleteTempFile = exports.readTempFile = exports.createTempFile = exports.listCredentialDefinitions = exports.getCredentialDef = exports.registerCredential = exports.definePlugin = exports.validatePlugin = exports.createFormattedErrorResult = exports.createFormattedResult = exports.createNamespacedResult = exports.getStableOutputKey = exports.getWorkflowNodePosition = exports.BaseNodeExecutor = exports.ParameterResolver = exports.NodeApiError = exports.extractNamespacedData = exports.extractInputData = exports.NodeOperationError = exports.log = void 0; const credentialRegistry = {}; // Lightweight noop logger compatible with SDK API exports.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 class NodeOperationError extends Error { node; constructor(node, message) { super(message); this.name = 'NodeOperationError'; this.node = node; } } exports.NodeOperationError = NodeOperationError; /** * Backward compatibility utility to extract input data from both old and new formats * Handles the transition from flat JSON structure to namespaced structure */ 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; }; exports.extractInputData = extractInputData; /** * Extract data from a specific namespace in the new format * Used when you specifically want data from a particular node type */ 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 {}; }; exports.extractNamespacedData = extractNamespacedData; class NodeApiError extends Error { httpStatusCode; constructor(node, message, opts) { super(message); this.name = 'NodeApiError'; this.httpStatusCode = opts?.httpStatusCode; } } exports.NodeApiError = NodeApiError; // Minimal ParameterResolver with only the public static `resolve` method 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); } } exports.ParameterResolver = ParameterResolver; // Basic BaseNodeExecutor implementation sufficient for most plugins 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 Promise.resolve(`${modPath}`).then(s => __importStar(require(s))); 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 }; } } exports.BaseNodeExecutor = BaseNodeExecutor; /** * Get the position of a node among nodes of the same type in the workflow */ 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 }; exports.getWorkflowNodePosition = getWorkflowNodePosition; /** * 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. */ const getStableOutputKey = (node, context) => { const nodePosition = (0, exports.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 }; }; exports.getStableOutputKey = getStableOutputKey; /** * 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) */ const createNamespacedResult = (result, outputKey) => { return { [outputKey]: result }; }; exports.createNamespacedResult = createNamespacedResult; /** * 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 */ 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 } = (0, exports.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 = (0, exports.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; }; exports.createFormattedResult = createFormattedResult; /** * 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 */ 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 } = (0, exports.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 = (0, exports.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; }; exports.createFormattedErrorResult = createFormattedErrorResult; const validatePlugin = (meta) => meta; exports.validatePlugin = validatePlugin; const definePlugin = (meta) => (0, exports.validatePlugin)(meta); exports.definePlugin = definePlugin; const registerCredential = (def) => { if (!def?.name) throw new Error('Credential definition missing name'); credentialRegistry[def.name] = def; }; exports.registerCredential = registerCredential; const getCredentialDef = (name) => credentialRegistry[name]; exports.getCredentialDef = getCredentialDef; const listCredentialDefinitions = () => Object.values(credentialRegistry); exports.listCredentialDefinitions = listCredentialDefinitions; // ============================================================================ // 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 */ const createTempFile = async (buffer, workflowId, nodeId, fileName, metadata = {}) => { const fs = await Promise.resolve().then(() => __importStar(require('fs/promises'))); const path = await Promise.resolve().then(() => __importStar(require('path'))); const os = await Promise.resolve().then(() => __importStar(require('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)); exports.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 } }; }; exports.createTempFile = createTempFile; /** * Reads a temporary file and returns the buffer * * @param tempFilePath - Path to the temporary file (string) * @returns Promise<Buffer> - Binary data */ const readTempFile = async (tempFilePath) => { const fs = await Promise.resolve().then(() => __importStar(require('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); exports.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'; exports.log.error('โŒ [TEMP_FILE] Failed to read temp file', { path: tempFilePath, error: errorMsg }); throw new Error(`Failed to read temp file '${tempFilePath}': ${errorMsg}`); } }; exports.readTempFile = readTempFile; /** * Deletes a temporary file and its metadata * * @param tempFilePath - Path to the temporary file (string) * @returns Promise<void> */ const deleteTempFile = async (tempFilePath) => { const fs = await Promise.resolve().then(() => __importStar(require('fs/promises'))); const path = await Promise.resolve().then(() => __importStar(require('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 */ } exports.log.debug('๐Ÿงน [TEMP_FILE] Deleted temp file', { path: tempFilePath }); } catch (error) { exports.log.warn('โš ๏ธ [TEMP_FILE] Failed to delete temp file', { path: tempFilePath, error: error instanceof Error ? error.message : 'Unknown error' }); } }; exports.deleteTempFile = deleteTempFile; /** * 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 */ const getTempFileMetadata = async (tempFilePath) => { const fs = await Promise.resolve().then(() => __importStar(require('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; } }; exports.getTempFileMetadata = getTempFileMetadata;