UNPKG

navflow-browser-server

Version:

Standalone Playwright browser server for NavFlow - enables browser automation with API key authentication, workspace device management, session sync, and requires Node.js v22+

618 lines 27.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.FlowExecutor = void 0; const axios_1 = __importDefault(require("axios")); const uuid_1 = require("uuid"); const ScreenShareService_1 = require("./ScreenShareService"); const HumanLoopBannerService_1 = require("./HumanLoopBannerService"); class FlowExecutor { constructor(browserManager, screenShareService, humanLoopBannerService, deviceApiKey, proxyServerUrl) { this.wsClients = new Set(); this.browserManager = browserManager; this.screenShareService = screenShareService || new ScreenShareService_1.ScreenShareService(); this.humanLoopBannerService = humanLoopBannerService || new HumanLoopBannerService_1.HumanLoopBannerService(); this.deviceApiKey = deviceApiKey; this.proxyServerUrl = proxyServerUrl; } addWebSocketClient(ws) { this.wsClients.add(ws); } removeWebSocketClient(ws) { this.wsClients.delete(ws); } broadcastToClients(message) { this.wsClients.forEach(ws => { if (ws.readyState === 1) { // WebSocket.OPEN try { ws.send(JSON.stringify(message)); } catch (error) { console.error('Failed to send WebSocket message:', error); this.wsClients.delete(ws); } } else { this.wsClients.delete(ws); } }); } log(context, message) { const logEntry = `[${new Date().toISOString()}] ${message}`; context.logs.push(logEntry); console.log(logEntry); } async executeFlow(flow, config, userContext, userInputVariables, stepScreenshots) { // Use provided sessionId or generate one const sessionId = flow.sessionId || (0, uuid_1.v4)(); const context = { sessionId, variables: { ...(userInputVariables || {}) }, logs: [], userContext, browserConfig: config, startTime: Date.now(), stepScreenshots: stepScreenshots !== undefined ? stepScreenshots : false // Default to false }; try { const isHeadlessExecution = config?.headless !== false; this.log(context, `Starting flow execution: ${flow.name || flow.id} (${isHeadlessExecution ? 'headless' : 'headed'} mode)`); // Apply headless optimizations to config if in headless mode let optimizedConfig = config; if (isHeadlessExecution && config?.args) { optimizedConfig = { ...config, // Merge custom args with headless optimizations customArgs: [ ...(config.customArgs ? config.customArgs.split(' ') : []), ...config.args ].join(' ') }; this.log(context, `Applied headless optimizations: ${config.args.join(', ')}`); } // Create browser session with configuration and user context for isolation const browserSession = await this.browserManager.createSession(sessionId, optimizedConfig, userContext); // Register the browser session with screen share service for real-time viewing try { this.log(context, `Registering session ${sessionId} for screen sharing`); // Note: We don't start screen sharing here, just register the session as available // Screen sharing will be started when the frontend requests it via /screen-share/start } catch (error) { this.log(context, `Warning: Failed to register session for screen sharing: ${error.message}`); } // Execute nodes using conditional flow routing const executedNodes = new Set(); const startNode = this.findStartNode(flow.nodes || []); if (startNode) { await this.executeFlowFromNode(startNode, flow.nodes || [], flow.edges || [], context, executedNodes); } else { // Fallback to simple topological execution for flows without proper start/end structure const sortedNodes = this.topologicalSort(flow.nodes || [], flow.edges || []); for (const node of sortedNodes) { this.log(context, `Executing node: ${node.id} (${node.type})`); try { const result = await this.executeNode(node, context); if (result) { context.variables[`${node.id}_output`] = result; } } catch (error) { this.log(context, `Error in node ${node.id}: ${error.message}`); throw error; } } } this.log(context, 'Flow execution completed successfully'); return { success: true, outputs: context.variables, logs: context.logs, screenshots: this.extractScreenshots(context.variables), sessionId: context.sessionId, browserConfig: context.browserConfig, executionTime: Date.now() - (context.startTime || Date.now()), nodeCount: (flow.nodes || []).length, executedNodes: executedNodes.size }; } catch (error) { this.log(context, `Flow execution failed: ${error.message}`); return { success: false, error: error.message, logs: context.logs }; } finally { // Always save session state before closing to preserve cookies/auth try { this.log(context, 'Saving session state...'); await this.browserManager.saveSession(sessionId); this.log(context, 'Session state saved successfully'); } catch (error) { this.log(context, `Warning: Failed to save session state: ${error}`); } // Clean up any active human-in-the-loop banners try { await this.humanLoopBannerService.cleanup(); } catch (error) { this.log(context, `Warning: Failed to cleanup human-in-the-loop banners: ${error}`); } // Close browser session after flow completion to free resources (unless keepBrowserOpen is true) if (!config?.keepBrowserOpen) { try { this.log(context, 'Closing browser session...'); await this.browserManager.closeSession(sessionId); this.log(context, 'Browser session closed successfully'); } catch (error) { this.log(context, `Warning: Failed to close browser session: ${error}`); } } else { this.log(context, 'Browser session kept open for debugging (keepBrowserOpen=true)'); } } } async executeNode(node, context) { switch (node.type) { case 'browserAction': return await this.executeBrowserAction(node, context); case 'apiCall': return await this.executeAPICall(node, context); case 'humanInLoop': return await this.executeHumanInLoop(node, context); default: throw new Error(`Unknown node type: ${node.type}`); } } async executeBrowserAction(node, context) { let mainResult; // Execute the main browser action first if (node.data.mode === 'code' && node.data.playwrightCode) { const action = { type: 'playwrightCode', code: this.resolveVariable(node.data.playwrightCode, context), variables: context.variables, captureScreenshots: context.stepScreenshots || false }; mainResult = await this.browserManager.executeAction(context.sessionId, action); } else { // Handle simple mode actions const action = { type: node.data.actionType, selector: this.resolveVariable(node.data.selector, context), text: this.resolveVariable(node.data.value, context), url: this.resolveVariable(node.data.value, context), script: this.resolveVariable(node.data.script, context), fullPage: node.data.fullPage, timeout: node.data.timeout, captureScreenshots: context.stepScreenshots || false }; mainResult = await this.browserManager.executeAction(context.sessionId, action); } // Log screenshots if available if (mainResult.screenshots) { this.log(context, `Screenshots captured for node: ${node.id}`); context.variables[`${node.id}_screenshots`] = { ...mainResult.screenshots, nodeId: node.id, timestamp: new Date().toISOString() }; } // Send response overlay data to frontend via WebSocket const responseData = { data: mainResult, timestamp: new Date().toISOString(), nodeType: 'browserAction', success: mainResult.success !== false, error: mainResult.error }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: responseData }); // Execute test code if enabled if (node.data.testEnabled && node.data.testCode) { this.log(context, `Executing test code for node: ${node.id}`); try { const testAction = { type: 'playwrightCode', code: this.resolveVariable(node.data.testCode, context), variables: context.variables, captureScreenshots: false // Test code never captures screenshots }; const testResult = await this.browserManager.executeAction(context.sessionId, testAction); // The test code should return a boolean value const testPassed = testResult.success && (testResult.result === true || testResult.data === true); this.log(context, `Test code result: ${testPassed ? 'PASS' : 'FAIL'}`); return { ...mainResult, testResult: testPassed, testOutput: testResult }; } catch (error) { this.log(context, `Test code execution failed: ${error.message}`); return { ...mainResult, testResult: false, testError: error.message }; } } return mainResult; } async executeAPICall(node, context) { const config = { method: node.data.method, url: this.resolveVariable(node.data.endpoint, context), headers: this.resolveVariables(node.data.headers || {}, context), data: this.resolveVariables(node.data.body || {}, context) }; const waitForResponse = node.data.waitForResponse !== false; // Default to true // Log detailed request information for debugging this.log(context, `🌐 API Call Request Details:`); this.log(context, ` Method: ${config.method}`); this.log(context, ` URL: ${config.url}`); this.log(context, ` Headers: ${JSON.stringify(config.headers, null, 2)}`); if (config.data && Object.keys(config.data).length > 0) { this.log(context, ` Body: ${JSON.stringify(config.data, null, 2)}`); } else { this.log(context, ` Body: (empty)`); } this.log(context, ` Wait for Response: ${waitForResponse ? 'Yes' : 'No (Fire-and-Forget)'}`); // Fire-and-forget mode: don't wait for response if (!waitForResponse) { this.log(context, `🚀 Fire-and-Forget: Making API call without waiting for response`); // Make the request asynchronously without awaiting (0, axios_1.default)(config).then((response) => { this.log(context, `🔥 Fire-and-Forget Success: ${response.status} ${response.statusText}`); // Send response overlay for fire-and-forget success const responseData = { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers, timestamp: new Date().toISOString(), nodeType: 'apiCall', success: true, fireAndForget: true }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: responseData }); }).catch((error) => { this.log(context, `🔥 Fire-and-Forget Error: ${error.message} (flow continues anyway)`); // Send error response overlay for fire-and-forget failure const errorResponseData = { data: error.response?.data, status: error.response?.status, statusText: error.response?.statusText, headers: error.response?.headers, timestamp: new Date().toISOString(), nodeType: 'apiCall', success: false, error: error.message, fireAndForget: true }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: errorResponseData }); }); // Return immediately without waiting this.log(context, `⚡ Continuing flow execution immediately`); return { fireAndForget: true, message: 'API call initiated without waiting for response' }; } try { const startTime = Date.now(); const response = await (0, axios_1.default)(config); const duration = Date.now() - startTime; // Log successful response details this.log(context, `✅ API Call Success (${duration}ms):`); this.log(context, ` Status: ${response.status} ${response.statusText}`); this.log(context, ` Response Headers: ${JSON.stringify(response.headers, null, 2)}`); // Log response data (truncate if too large) const responseDataStr = JSON.stringify(response.data, null, 2); if (responseDataStr.length > 1000) { this.log(context, ` Response Data: ${responseDataStr.substring(0, 1000)}... (truncated)`); } else { this.log(context, ` Response Data: ${responseDataStr}`); } // Send response overlay data to frontend via WebSocket for successful API calls const responseData = { data: response.data, status: response.status, statusText: response.statusText, headers: response.headers, timestamp: new Date().toISOString(), nodeType: 'apiCall', success: true }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: responseData }); return response.data; } catch (error) { // Enhanced error logging for debugging 401 and other issues this.log(context, `❌ API Call Failed:`); this.log(context, ` Error Message: ${error.message}`); if (error.response) { // Server responded with error status (like 401) this.log(context, ` Status: ${error.response.status} ${error.response.statusText}`); this.log(context, ` Response Headers: ${JSON.stringify(error.response.headers, null, 2)}`); // Log error response body if (error.response.data) { const errorDataStr = JSON.stringify(error.response.data, null, 2); if (errorDataStr.length > 500) { this.log(context, ` Error Response: ${errorDataStr.substring(0, 500)}... (truncated)`); } else { this.log(context, ` Error Response: ${errorDataStr}`); } } // Special handling for 401 errors if (error.response.status === 401) { this.log(context, ` 🔐 Authentication Error Details:`); this.log(context, ` - Check if API key/token is provided in headers`); this.log(context, ` - Verify authorization header format`); this.log(context, ` - Confirm API key is valid and not expired`); } } else if (error.request) { // Request was made but no response received this.log(context, ` No response received from server`); this.log(context, ` Request timeout or network error`); } else { // Something else happened in setting up the request this.log(context, ` Request setup error: ${error.message}`); } // Send error response overlay data to frontend via WebSocket const errorResponseData = { data: error.response?.data, status: error.response?.status, statusText: error.response?.statusText, headers: error.response?.headers, timestamp: new Date().toISOString(), nodeType: 'apiCall', success: false, error: error.message }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: errorResponseData }); throw new Error(`API call failed: ${error.message}`); } } async executeHumanInLoop(node, context) { this.log(context, `Human interaction required: ${node.data.prompt}`); // Handle wait time before pausing const waitTime = node.data.waitTime || 0; if (waitTime > 0) { this.log(context, `Waiting ${waitTime} seconds before pausing for human intervention...`); await new Promise(resolve => setTimeout(resolve, waitTime * 1000)); } this.log(context, `Pausing execution for human intervention...`); // Get the browser session to show banner const session = await this.browserManager.getSession(context.sessionId); if (!session) { throw new Error('Browser session not found for human-in-the-loop interaction'); } const timeout = node.data.timeout || 300; const prompt = node.data.prompt || 'Please complete the required action and click Complete to continue.'; this.log(context, `Showing human-in-the-loop banner with ${timeout}s timeout...`); try { // Show the banner and wait for user response const response = await this.humanLoopBannerService.showBanner(context.sessionId, node.id, session.page, prompt, timeout, this.deviceApiKey, this.proxyServerUrl); this.log(context, `Human-in-the-loop completed with action: ${response.action}`); const humanLoopResult = { userResponse: response.userResponse || `User ${response.action} the task`, action: response.action, waitTime: waitTime, pausedAt: new Date().toISOString(), completedAt: new Date().toISOString(), timeout: response.action === 'timeout' }; // Send response overlay data to frontend via WebSocket const responseData = { data: humanLoopResult, timestamp: new Date().toISOString(), nodeType: 'humanInLoop', success: response.action !== 'timeout', error: response.action === 'timeout' ? 'Human interaction timed out' : undefined }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: responseData }); return humanLoopResult; } catch (error) { this.log(context, `Human-in-the-loop error: ${error.message}`); // Fallback to original simulation behavior this.log(context, `Falling back to simulation with ${Math.min(timeout, 10)}s timeout...`); await new Promise(resolve => setTimeout(resolve, Math.min(timeout * 1000, 10000))); const fallbackResult = { userResponse: 'Simulated user response (banner failed)', action: 'simulate', waitTime: waitTime, pausedAt: new Date().toISOString(), error: error.message }; // Send response overlay data to frontend via WebSocket for fallback const responseData = { data: fallbackResult, timestamp: new Date().toISOString(), nodeType: 'humanInLoop', success: false, error: `Banner failed: ${error.message}` }; this.broadcastToClients({ type: 'node_response', nodeId: node.id, response: responseData }); return fallbackResult; } } resolveVariable(value, context) { if (!value) return ''; // Replace {{variable}} with actual values return value.replace(/\{\{(\w+)\}\}/g, (match, varName) => { return context.variables[varName] || match; }); } resolveVariables(obj, context) { const resolved = {}; for (const [key, value] of Object.entries(obj)) { if (typeof value === 'string') { resolved[key] = this.resolveVariable(value, context); } else { resolved[key] = value; } } return resolved; } extractScreenshots(variables) { const screenshots = []; for (const [key, value] of Object.entries(variables)) { if (key.endsWith('_screenshots') && value?.before && value?.after) { screenshots.push({ nodeId: value.nodeId || key.replace('_screenshots', ''), before: value.before, after: value.after, timestamp: value.timestamp || new Date().toISOString() }); } } return screenshots; } topologicalSort(nodes, edges) { // Simple topological sort for DAG const sorted = []; const visited = new Set(); const visiting = new Set(); const adjacency = {}; // Build adjacency list for (const edge of edges) { if (!adjacency[edge.source]) { adjacency[edge.source] = []; } adjacency[edge.source].push(edge.target); } const visit = (nodeId) => { if (visited.has(nodeId)) return; if (visiting.has(nodeId)) { throw new Error('Cycle detected in flow'); } visiting.add(nodeId); const neighbors = adjacency[nodeId] || []; for (const neighbor of neighbors) { visit(neighbor); } visiting.delete(nodeId); visited.add(nodeId); const node = nodes.find(n => n.id === nodeId); if (node) { sorted.unshift(node); } }; // Visit all nodes for (const node of nodes) { visit(node.id); } return sorted; } /** * Find the start node of the flow (node with no incoming edges) */ findStartNode(nodes) { const nodesWithIncoming = new Set(); // Note: This is a simplified version - in a real implementation, // we would check the edges to find nodes with no incoming connections // For now, return the first node return nodes.length > 0 ? nodes[0] : null; } /** * Execute flow starting from a specific node with conditional branching */ async executeFlowFromNode(currentNode, allNodes, edges, context, executedNodes) { if (executedNodes.has(currentNode.id)) { return; // Already executed this node } this.log(context, `Executing node: ${currentNode.id} (${currentNode.type})`); try { const result = await this.executeNode(currentNode, context); executedNodes.add(currentNode.id); if (result) { context.variables[`${currentNode.id}_output`] = result; } // Handle conditional branching for browser actions with test code if (currentNode.type === 'browserAction' && currentNode.data.testEnabled && result) { const testPassed = result.testResult === true; this.log(context, `Test result for node ${currentNode.id}: ${testPassed ? 'PASS' : 'FAIL'}`); // Find next nodes based on test result const nextNodes = this.getNextNodes(currentNode.id, edges, testPassed ? 'success' : 'fail'); // Execute next nodes for (const nextNode of nextNodes) { const nodeData = allNodes.find(n => n.id === nextNode.target); if (nodeData) { await this.executeFlowFromNode(nodeData, allNodes, edges, context, executedNodes); } } } else { // Normal execution - follow all outgoing edges const nextNodes = this.getNextNodes(currentNode.id, edges); for (const nextNode of nextNodes) { const nodeData = allNodes.find(n => n.id === nextNode.target); if (nodeData) { await this.executeFlowFromNode(nodeData, allNodes, edges, context, executedNodes); } } } } catch (error) { this.log(context, `Error in node ${currentNode.id}: ${error.message}`); throw error; } } /** * Get next nodes based on current node and optional handle ID */ getNextNodes(nodeId, edges, handleId) { return edges.filter(edge => { if (edge.source !== nodeId) return false; if (handleId && edge.sourceHandle !== handleId) return false; return true; }); } } exports.FlowExecutor = FlowExecutor; //# sourceMappingURL=FlowExecutor.js.map