navflow-browser-server
Version:
Standalone Playwright browser server for NavFlow - enables browser automation with API key authentication, workspace device management, session sync, LLM discovery tools, and requires Node.js v22+
644 lines • 29.6 kB
JavaScript
"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)();
// Log incoming userInputVariables for debugging
console.log('🔍 FlowExecutor.executeFlow - Received userInputVariables:', JSON.stringify(userInputVariables, null, 2));
const context = {
sessionId,
variables: { ...(userInputVariables || {}) },
logs: [],
userContext,
browserConfig: config,
startTime: Date.now(),
stepScreenshots: stepScreenshots !== undefined ? stepScreenshots : false // Default to false
};
// Log the context variables after initialization
console.log('🔍 FlowExecutor - Context variables initialized:', JSON.stringify(context.variables, null, 2));
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()
};
}
// Save session state after important browser actions to preserve auth
// This is especially important for LinkedIn and other auth-heavy sites
const actionType = node.data.mode === 'code' ? 'playwrightCode' : node.data.actionType;
const shouldSaveSession = mainResult.success && (actionType === 'navigate' ||
actionType === 'type' ||
actionType === 'click' ||
(actionType === 'playwrightCode' && node.data.playwrightCode &&
(node.data.playwrightCode.toLowerCase().includes('login') ||
node.data.playwrightCode.toLowerCase().includes('auth') ||
node.data.playwrightCode.toLowerCase().includes('signin') ||
node.data.playwrightCode.toLowerCase().includes('linkedin'))));
if (shouldSaveSession) {
try {
await this.browserManager.saveSession(context.sessionId);
this.log(context, `Session state saved after ${actionType} action`);
}
catch (error) {
this.log(context, `Warning: Failed to save session state after action: ${error}`);
}
}
// 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) => {
const resolvedValue = context.variables[varName] || match;
console.log(`🔍 Variable resolution: {{${varName}}} => ${resolvedValue === match ? '(not found)' : resolvedValue}`);
return resolvedValue;
});
}
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