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+

861 lines 32.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.browserManager = exports.app = void 0; const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const body_parser_1 = __importDefault(require("body-parser")); const http_1 = require("http"); const ws_1 = require("ws"); const promises_1 = __importDefault(require("fs/promises")); const path_1 = __importDefault(require("path")); const BrowserManager_1 = require("./BrowserManager"); const FlowExecutor_1 = require("./FlowExecutor"); const ScreenShareService_1 = require("./ScreenShareService"); const DeviceRegistry_1 = require("./DeviceRegistry"); const PlaywrightInstaller_1 = require("./PlaywrightInstaller"); const HeartbeatService_1 = require("./HeartbeatService"); const UpdateService_1 = require("./UpdateService"); const HumanLoopBannerService_1 = require("./HumanLoopBannerService"); const DataDirectory_1 = require("./DataDirectory"); const app = (0, express_1.default)(); exports.app = app; const PORT = Number(process.env.BROWSER_SERVER_PORT) || 3002; const deviceRegistry = new DeviceRegistry_1.DeviceRegistry(); const browserManager = new BrowserManager_1.BrowserManager(deviceRegistry); exports.browserManager = browserManager; const screenShareService = new ScreenShareService_1.ScreenShareService(); const humanLoopBannerService = new HumanLoopBannerService_1.HumanLoopBannerService(); // Initialize FlowExecutor with device info after device registration let flowExecutor; let heartbeatService = null; let updateService = null; // Middleware app.use((0, cors_1.default)({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Device-API-Key'], credentials: false })); app.use(body_parser_1.default.json({ limit: '50mb' })); app.use(body_parser_1.default.urlencoded({ extended: true })); // Health check app.get('/health', (req, res) => { const deviceInfo = deviceRegistry.getDeviceInfo(); res.json({ status: 'healthy', activeSessions: browserManager.getSessionCount(), timestamp: new Date().toISOString(), device: deviceInfo ? { name: deviceInfo.deviceName, macAddress: deviceInfo.macAddress, port: deviceInfo.port } : null }); }); // Device information endpoint app.get('/device', (req, res) => { const deviceInfo = deviceRegistry.getDeviceInfo(); if (!deviceInfo) { return res.status(500).json({ error: 'Device not initialized' }); } res.json({ name: deviceInfo.deviceName, macAddress: deviceInfo.macAddress, port: deviceInfo.port, createdAt: deviceInfo.createdAt }); }); // API Key Authentication Middleware for protected routes const requireApiKey = deviceRegistry.getAuthMiddleware(); // Authentication session management - use global directory for persistence across versions const SESSIONS_DIR = DataDirectory_1.DataDirectory.getSessionsDir(); // Ensure sessions directory exists and migrate legacy data const initializeDataDirectories = async () => { await DataDirectory_1.DataDirectory.ensureDirectories(); // Check for and migrate legacy session data const migration = await DataDirectory_1.DataDirectory.migrateLegacyData(); if (migration.errors.length > 0) { migration.errors.forEach(error => console.error('Migration error:', error)); } }; // Initialize data directories and migration on startup initializeDataDirectories(); // Get all authentication sessions app.get('/api/sessions', requireApiKey, async (req, res) => { try { await initializeDataDirectories(); const sessionFiles = await promises_1.default.readdir(SESSIONS_DIR); const sessions = []; for (const file of sessionFiles) { if (file.endsWith('.json')) { try { const sessionPath = path_1.default.join(SESSIONS_DIR, file); const sessionData = await promises_1.default.readFile(sessionPath, 'utf-8'); const session = JSON.parse(sessionData); sessions.push({ name: session.name, description: session.description, createdAt: session.createdAt, lastUsed: session.lastUsed }); } catch (error) { console.error(`Failed to read session file ${file}:`, error); } } } res.json(sessions); } catch (error) { console.error('Failed to list authentication sessions:', error); res.status(500).json({ error: 'Failed to list sessions', message: error.message }); } }); // Create new authentication session app.post('/api/sessions', requireApiKey, async (req, res) => { try { const { name, description } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { return res.status(400).json({ error: 'Session name is required' }); } const sessionName = name.trim(); const sessionPath = path_1.default.join(SESSIONS_DIR, `${sessionName}.json`); // Check if session already exists try { await promises_1.default.access(sessionPath); return res.status(409).json({ error: 'Session already exists' }); } catch { // Session doesn't exist, which is what we want } const sessionData = { name: sessionName, description: description || '', createdAt: new Date().toISOString(), lastUsed: null, cookies: [], localStorage: {}, sessionStorage: {} }; await initializeDataDirectories(); await promises_1.default.writeFile(sessionPath, JSON.stringify(sessionData, null, 2)); res.json({ success: true, name: sessionName, message: 'Authentication session created successfully' }); } catch (error) { console.error('Failed to create authentication session:', error); res.status(500).json({ error: 'Failed to create session', message: error.message }); } }); // Delete authentication session app.delete('/api/sessions/:sessionName', requireApiKey, async (req, res) => { try { const { sessionName } = req.params; if (!sessionName) { return res.status(400).json({ error: 'Session name is required' }); } const sessionPath = path_1.default.join(SESSIONS_DIR, `${sessionName}.json`); try { await promises_1.default.unlink(sessionPath); res.json({ success: true, message: 'Authentication session deleted successfully' }); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Session not found' }); } throw error; } } catch (error) { console.error('Failed to delete authentication session:', error); res.status(500).json({ error: 'Failed to delete session', message: error.message }); } }); // Create a new browser session app.post('/sessions', requireApiKey, async (req, res) => { try { const { sessionId, config, userContext } = req.body; if (!sessionId) { return res.status(400).json({ error: 'sessionId is required' }); } const session = await browserManager.createSession(sessionId, config, userContext); res.json({ success: true, sessionId: session.id, message: 'Browser session created successfully', isolatedSession: !!userContext?.userId }); } catch (error) { console.error('Failed to create session:', error); res.status(500).json({ error: 'Failed to create browser session', message: error.message }); } }); // Execute an action in a browser session app.post('/sessions/:sessionId/actions', requireApiKey, async (req, res) => { try { const { sessionId } = req.params; const { action } = req.body; if (!action) { return res.status(400).json({ error: 'action is required' }); } const result = await browserManager.executeAction(sessionId, action); if (result.success) { res.json(result); } else { res.status(500).json(result); } } catch (error) { console.error('Failed to execute action:', error); res.status(500).json({ error: 'Failed to execute action', message: error.message }); } }); // Get session info app.get('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; const session = await browserManager.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } res.json({ sessionId: session.id, createdAt: session.createdAt, lastUsed: session.lastUsed, status: 'active' }); } catch (error) { console.error('Failed to get session info:', error); res.status(500).json({ error: 'Failed to get session info', message: error.message }); } }); // Close a browser session app.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; await browserManager.closeSession(sessionId); res.json({ success: true, message: 'Browser session closed successfully' }); } catch (error) { console.error('Failed to close session:', error); res.status(500).json({ error: 'Failed to close browser session', message: error.message }); } }); // List all active sessions app.get('/sessions', (req, res) => { try { const activeSessions = browserManager.getActiveSessions(); res.json({ sessions: activeSessions, count: activeSessions.length }); } catch (error) { console.error('Failed to list sessions:', error); res.status(500).json({ error: 'Failed to list sessions', message: error.message }); } }); // Save session state (cookies, localStorage, etc.) app.post('/sessions/:sessionId/save', async (req, res) => { try { const { sessionId } = req.params; await browserManager.saveSession(sessionId); res.json({ success: true, message: 'Session state saved successfully' }); } catch (error) { console.error('Failed to save session:', error); res.status(500).json({ error: 'Failed to save session state', message: error.message }); } }); // Sync session with proxy-server for workspace sharing app.post('/sessions/:sessionId/sync', requireApiKey, async (req, res) => { try { const { sessionId } = req.params; const { workspaceId, userId, sessionName, sessionType = 'private' } = req.body; if (!workspaceId || !userId) { return res.status(400).json({ error: 'Missing required fields', message: 'workspaceId and userId are required' }); } // Get browser session data const session = await browserManager.getSession(sessionId); if (!session) { return res.status(404).json({ error: 'Session not found' }); } // Extract session data for syncing const sessionData = { name: sessionName || `Session ${new Date().toLocaleString()}`, sessionType: sessionType, cookies: [], // Will be populated by BrowserManager localStorage: {}, sessionStorage: {}, browserContext: { userAgent: session.context ? await session.context.userAgent() : undefined, viewport: session.context ? session.context.viewportSize() : undefined } }; // Try to extract cookies if context exists if (session.context) { try { sessionData.cookies = await session.context.cookies(); } catch (error) { console.warn('Could not extract cookies:', error); } } // Sync with proxy-server const success = await deviceRegistry.syncSessionWithProxy(sessionId, sessionData, workspaceId, userId); if (success) { res.json({ success: true, message: 'Session synced with workspace successfully' }); } else { res.status(500).json({ error: 'Failed to sync session', message: 'Could not sync session with proxy-server' }); } } catch (error) { console.error('Failed to sync session:', error); res.status(500).json({ error: 'Failed to sync session', message: error.message }); } }); // Get synced sessions from proxy-server app.get('/synced-sessions', requireApiKey, async (req, res) => { try { const { userId } = req.query; const sessions = await deviceRegistry.getSyncedSessions(userId); res.json({ success: true, sessions: sessions, count: sessions.length }); } catch (error) { console.error('Failed to get synced sessions:', error); res.status(500).json({ error: 'Failed to get synced sessions', message: error.message }); } }); // Load synced session from proxy-server app.post('/sessions/:sessionId/load-synced', requireApiKey, async (req, res) => { try { const { sessionId } = req.params; const { syncedSessionId, userId } = req.body; if (!syncedSessionId) { return res.status(400).json({ error: 'Missing synced session ID', message: 'syncedSessionId is required' }); } // Get synced sessions and find the one we want const syncedSessions = await deviceRegistry.getSyncedSessions(userId); const targetSession = syncedSessions.find(s => s.id === syncedSessionId); if (!targetSession) { return res.status(404).json({ error: 'Synced session not found', message: 'The requested synced session was not found or is not accessible' }); } // Create or get browser session with user context for isolation let session; try { session = await browserManager.getSession(sessionId); } catch { // Session doesn't exist, create it with user isolation const userContext = { userId: userId, sessionName: targetSession.name }; session = await browserManager.createSession(sessionId, { viewport: targetSession.browserContext?.viewport || { width: 1920, height: 1080 } }, userContext); } // Apply synced session data if (session && session.context && targetSession.cookies) { try { await session.context.addCookies(targetSession.cookies); console.log(`✅ Loaded ${targetSession.cookies.length} cookies from synced session`); } catch (error) { console.warn('Could not load cookies from synced session:', error); } } res.json({ success: true, message: `Synced session "${targetSession.name}" loaded successfully`, sessionData: { name: targetSession.name, type: targetSession.sessionType, cookieCount: targetSession.cookies?.length || 0, lastUsed: targetSession.lastUsed } }); } catch (error) { console.error('Failed to load synced session:', error); res.status(500).json({ error: 'Failed to load synced session', message: error.message }); } }); // Execute a flow app.post('/execute-flow', requireApiKey, async (req, res) => { try { const { flow, browserConfig, userContext, userInputVariables, stepScreenshots, sessionId } = req.body; if (!flow) { return res.status(400).json({ error: 'Flow data is required' }); } if (!flowExecutor) { return res.status(503).json({ error: 'FlowExecutor not initialized' }); } // If sessionId is provided directly in request body, use it // This maintains backward compatibility while supporting both patterns const flowWithSessionId = { ...flow, sessionId: sessionId || flow.sessionId // Prioritize direct sessionId over flow.sessionId }; const result = await flowExecutor.executeFlow(flowWithSessionId, browserConfig, userContext, userInputVariables, stepScreenshots); res.json(result); } catch (error) { console.error('Failed to execute flow:', error); res.status(500).json({ error: 'Failed to execute flow', message: error.message }); } }); // Get available browsers app.get('/browsers', async (req, res) => { try { // Return available browser types const browsers = [ { type: 'chromium', name: 'Chromium' }, { type: 'firefox', name: 'Firefox' }, { type: 'webkit', name: 'WebKit (Safari)' } ]; res.json({ success: true, browsers }); } catch (error) { console.error('Failed to get available browsers:', error); res.status(500).json({ error: 'Failed to get available browsers', message: error.message }); } }); // Screen sharing endpoints app.post('/sessions/:sessionId/screen-share/start', async (req, res) => { try { const { sessionId } = req.params; const browserSession = await browserManager.getSession(sessionId); if (!browserSession) { return res.status(404).json({ error: 'Browser session not found' }); } await screenShareService.startScreenShare(sessionId, browserSession); res.json({ success: true, message: 'Screen sharing started successfully' }); } catch (error) { console.error('Failed to start screen sharing:', error); res.status(500).json({ error: 'Failed to start screen sharing', message: error.message }); } }); app.post('/sessions/:sessionId/screen-share/stop', async (req, res) => { try { const { sessionId } = req.params; await screenShareService.stopScreenShare(sessionId); res.json({ success: true, message: 'Screen sharing stopped successfully' }); } catch (error) { console.error('Failed to stop screen sharing:', error); res.status(500).json({ error: 'Failed to stop screen sharing', message: error.message }); } }); app.get('/sessions/:sessionId/screen-share/status', async (req, res) => { try { const { sessionId } = req.params; const status = screenShareService.getScreenShareStatus(sessionId); res.json({ success: true, ...status }); } catch (error) { console.error('Failed to get screen share status:', error); res.status(500).json({ error: 'Failed to get screen share status', message: error.message }); } }); app.post('/sessions/:sessionId/screen-share/offer', async (req, res) => { try { const { sessionId } = req.params; const offer = await screenShareService.createOffer(sessionId); res.json({ success: true, offer }); } catch (error) { console.error('Failed to create WebRTC offer:', error); res.status(500).json({ error: 'Failed to create WebRTC offer', message: error.message }); } }); app.post('/sessions/:sessionId/screen-share/answer', async (req, res) => { try { const { sessionId } = req.params; const { answer } = req.body; if (!answer) { return res.status(400).json({ error: 'Answer is required' }); } await screenShareService.handleAnswer(sessionId, answer); res.json({ success: true, message: 'Answer processed successfully' }); } catch (error) { console.error('Failed to handle WebRTC answer:', error); res.status(500).json({ error: 'Failed to handle WebRTC answer', message: error.message }); } }); app.post('/sessions/:sessionId/screen-share/ice-candidate', async (req, res) => { try { const { sessionId } = req.params; const { candidate } = req.body; if (!candidate) { return res.status(400).json({ error: 'ICE candidate is required' }); } await screenShareService.addIceCandidate(sessionId, candidate); res.json({ success: true, message: 'ICE candidate added successfully' }); } catch (error) { console.error('Failed to add ICE candidate:', error); res.status(500).json({ error: 'Failed to add ICE candidate', message: error.message }); } }); // Human-in-the-loop banner API endpoints app.post('/api/human-loop/complete', async (req, res) => { try { console.log('📞 Human-loop complete request received:', req.body); const { sessionKey, userResponse } = req.body; if (!sessionKey) { return res.status(400).json({ error: 'sessionKey is required' }); } await humanLoopBannerService.handleUserResponse(sessionKey, 'complete', userResponse || 'User completed the task'); res.json({ success: true, message: 'Task marked as complete' }); } catch (error) { console.error('Failed to handle human-loop completion:', error); res.status(500).json({ error: 'Failed to complete task', message: error.message }); } }); app.post('/api/human-loop/extend', async (req, res) => { try { console.log('📞 Human-loop extend request received:', req.body); const { sessionKey, extensionTime } = req.body; if (!sessionKey) { return res.status(400).json({ error: 'sessionKey is required' }); } const extension = extensionTime || 300; // Default 5 minutes await humanLoopBannerService.handleUserResponse(sessionKey, 'extend', undefined, extension); res.json({ success: true, message: `Time extended by ${Math.floor(extension / 60)} minutes`, extensionTime: extension }); } catch (error) { console.error('Failed to extend human-loop time:', error); res.status(500).json({ error: 'Failed to extend time', message: error.message }); } }); app.get('/api/human-loop/status', async (req, res) => { try { const activeSessions = humanLoopBannerService.getActiveSessions(); res.json({ success: true, activeSessions, count: activeSessions.length }); } catch (error) { console.error('Failed to get human-loop status:', error); res.status(500).json({ error: 'Failed to get status', message: error.message }); } }); // Start recording browser actions app.post('/record', async (req, res) => { try { const sessionId = req.body.sessionId || `recording_${Date.now()}`; // Create browser session for recording const session = await browserManager.createSession(sessionId, { headless: false, // Recording should be visible viewport: { width: 1920, height: 1080 } }); res.json({ success: true, sessionId: session.id, message: 'Recording started. Browser window opened for interaction.' }); } catch (error) { console.error('Failed to start recording:', error); res.status(500).json({ error: 'Failed to start recording', message: error.message }); } }); // Create HTTP server const server = (0, http_1.createServer)(app); // Set up WebSocket server for real-time communication (optional) const wss = new ws_1.WebSocketServer({ server }); wss.on('connection', (ws) => { console.log('WebSocket client connected'); // Register WebSocket client with FlowExecutor for response overlay messages if (flowExecutor) { console.log('Registering WebSocket client with FlowExecutor'); flowExecutor.addWebSocketClient(ws); } else { console.log('FlowExecutor not ready, will register client later'); // Store the websocket to register later when FlowExecutor is ready const registerLater = () => { if (flowExecutor) { console.log('Late registering WebSocket client with FlowExecutor'); flowExecutor.addWebSocketClient(ws); } else { setTimeout(registerLater, 100); } }; registerLater(); } ws.on('message', async (message) => { try { const data = JSON.parse(message.toString()); if (data.type === 'execute_action') { const result = await browserManager.executeAction(data.sessionId, data.action); ws.send(JSON.stringify({ type: 'action_result', ...result })); } } catch (error) { ws.send(JSON.stringify({ type: 'error', error: error.message })); } }); ws.on('close', () => { console.log('WebSocket client disconnected'); // Remove WebSocket client from FlowExecutor if (flowExecutor) { flowExecutor.removeWebSocketClient(ws); } }); }); // Graceful shutdown process.on('SIGTERM', async () => { console.log('Received SIGTERM, shutting down gracefully...'); // Stop heartbeat service if (heartbeatService) { heartbeatService.stop(); } // Close WebSocket connection deviceRegistry.closeWebSocket(); // Clean up human-in-the-loop banners await humanLoopBannerService.cleanup(); // Close all browser sessions const sessions = browserManager.getActiveSessions(); await Promise.all(sessions.map(sessionId => browserManager.closeSession(sessionId))); server.close(() => { console.log('Server closed'); process.exit(0); }); }); process.on('SIGINT', async () => { console.log('Received SIGINT, shutting down gracefully...'); // Stop heartbeat service if (heartbeatService) { heartbeatService.stop(); } // Close WebSocket connection deviceRegistry.closeWebSocket(); // Clean up human-in-the-loop banners await humanLoopBannerService.cleanup(); // Close all browser sessions const sessions = browserManager.getActiveSessions(); await Promise.all(sessions.map(sessionId => browserManager.closeSession(sessionId))); server.close(() => { console.log('Server closed'); process.exit(0); }); }); // Start server server.listen(PORT, async () => { console.log(`🌊 NavFlow Browser Server starting on port ${PORT}...`); console.log(`📍 Health check: http://localhost:${PORT}/health`); console.log(`🔌 WebSocket: ws://localhost:${PORT}`); console.log(''); try { // Check and install Playwright browsers await PlaywrightInstaller_1.PlaywrightInstaller.ensurePlaywrightBrowsers(); // Display system status await PlaywrightInstaller_1.PlaywrightInstaller.displaySystemStatus(); // Initialize device registry console.log('🔧 Initializing device registry...'); await deviceRegistry.initialize(PORT); deviceRegistry.displayDeviceInfo(); // Initialize FlowExecutor with device info for human-loop banner support const deviceInfo = deviceRegistry.getDeviceInfo(); const proxyServerUrl = process.env.PROXY_SERVER_URL || 'https://navflow-proxy-858493283701.us-central1.run.app'; flowExecutor = new FlowExecutor_1.FlowExecutor(browserManager, screenShareService, humanLoopBannerService, deviceInfo?.apiKey, proxyServerUrl); console.log('🔧 FlowExecutor initialized with device API key for banner communication'); console.log('🔌 FlowExecutor ready for WebSocket connections'); const packageJson = require('../package.json'); console.log(`🚀 NavFlow Browser Server v${packageJson.version} is ready for API key connections!`); // Ensure device is registered with proxy-server and establish WebSocket connection setTimeout(async () => { await deviceRegistry.ensureProxyRegistration(); // Establish WebSocket connection to proxy-server for device communication // This allows the proxy-server to forward requests to this device // without needing direct HTTP access to localhost await deviceRegistry.connectToProxyServer(); // Initialize heartbeat service after device registration const deviceInfo = deviceRegistry.getDeviceInfo(); if (deviceInfo) { const packageJson = require('../package.json'); heartbeatService = new HeartbeatService_1.HeartbeatService(browserManager, deviceInfo.apiKey, packageJson.version); heartbeatService.start(); console.log('💓 Heartbeat service started'); // Initialize update service for npm version monitoring updateService = new UpdateService_1.UpdateService('navflow-browser-server', packageJson.version, async () => { console.log('💤 Preparing for auto-update shutdown...'); if (heartbeatService) { heartbeatService.stop(); } deviceRegistry.closeWebSocket(); await new Promise(resolve => setTimeout(resolve, 1000)); }); // Schedule periodic npm version checks (every 2 hours) const updateCheckInterval = updateService.scheduleUpdateCheck(120); console.log('🔄 Update monitoring enabled (checks every 2 hours)'); // Cleanup on shutdown process.on('SIGTERM', () => { if (updateCheckInterval) clearInterval(updateCheckInterval); }); process.on('SIGINT', () => { if (updateCheckInterval) clearInterval(updateCheckInterval); }); } }, 2000); // Wait 2 seconds for server to be fully ready } catch (error) { console.error('❌ Failed to initialize browser server:', error); console.log(''); console.log('💡 Troubleshooting tips:'); console.log(' • Ensure Node.js version >= 22.0.0'); console.log(' • Check internet connection for Playwright download'); console.log(' • Try running: npx playwright install'); console.log(''); process.exit(1); } }); //# sourceMappingURL=index.js.map