UNPKG

super-pancake-automation

Version:

A lightweight DOM-based UI automation framework using Chrome DevTools Protocol

227 lines (199 loc) 7.46 kB
import { SuperPancakeError, SessionError, withErrorRecovery, withRetry, withCircuitBreaker, StackTraceError } from './errors.js'; let msgId = 0; const sessionTimeouts = new Map(); // Track message timeouts export function createSession(ws, browser = 'chrome') { if (!ws || typeof ws.send !== 'function') { throw new SessionError('Invalid WebSocket provided to createSession', { wsState: ws?.readyState }); } const browserType = ws.browserType || browser; const sessionId = `session_${browserType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; console.log(`📞 Creating ${browserType} session: ${sessionId}`); // Enhanced send with comprehensive error handling const send = withCircuitBreaker( withRetry( withErrorRecovery( (method, params = {}, timeout = 30000) => { return new Promise((resolve, reject) => { try { // Validate WebSocket state if (!ws || ws.readyState !== 1) { // 1 = OPEN throw new SessionError('WebSocket is not in OPEN state', { state: ws?.readyState, method, sessionId }); } const id = ++msgId; const message = { id, method, params }; // Set up timeout for this specific message const timeoutId = setTimeout(() => { sessionTimeouts.delete(id); reject(new SessionError( `Method ${method} timed out after ${timeout}ms`, { method, params, timeout, messageId: id, sessionId } )); }, timeout); sessionTimeouts.set(id, timeoutId); // Set up response listener with error handling const listener = (msg) => { try { const data = JSON.parse(msg); if (data.id === id) { // Clear timeout and remove listener const timeoutId = sessionTimeouts.get(id); if (timeoutId) { clearTimeout(timeoutId); sessionTimeouts.delete(id); } ws.off('message', listener); if (data.error) { reject(new SessionError( `CDP method ${method} failed: ${data.error.message || 'Unknown error'}`, { method, params, cdpError: data.error, messageId: id, sessionId } )); } else { resolve(data.result || {}); } } } catch (parseError) { console.error(`❌ Failed to parse CDP response for ${method}:`, parseError.message); reject(new SessionError( `Failed to parse CDP response for ${method}`, { parseError: parseError.message, method, sessionId } )); } }; // Send message with error handling try { ws.send(JSON.stringify(message)); ws.on('message', listener); console.log(`📤 Sent ${method} (ID: ${id})`); } catch (sendError) { // Clear timeout if send fails const timeoutId = sessionTimeouts.get(id); if (timeoutId) { clearTimeout(timeoutId); sessionTimeouts.delete(id); } throw new SessionError( `Failed to send CDP message for ${method}: ${sendError.message}`, { method, params, sendError: sendError.message, sessionId } ); } } catch (error) { // Catch any synchronous errors and wrap them if (error instanceof SessionError) { throw error; } else { throw new StackTraceError(error, `session.send(${method})`, { sessionId }); } } }); }, 'session.send' ), { maxRetries: 3, operation: 'session.send' } ), 'session-operations', { failureThreshold: 5, recoveryTimeout: 30000 } ); // Enhanced session object with health monitoring const session = { id: sessionId, send, // Connection health check async isHealthy() { try { await send('Runtime.evaluate', { expression: '1+1', returnByValue: true }, 5000); return true; } catch (error) { console.warn(`⚠️ Session ${sessionId} health check failed:`, error.message); return false; } }, // Enhanced navigation with error recovery async navigateTo(url) { return withErrorRecovery(async () => { if (!url || typeof url !== 'string') { throw new SessionError('Invalid URL provided to navigateTo', { url, sessionId }); } console.log(`🌐 Navigating to: ${url}`); // Enable required domains await send('Page.enable'); await send('Runtime.enable'); // Navigate with timeout await send('Page.navigate', { url }); // Wait for load with enhanced error handling return new Promise((resolve, reject) => { const loadTimeout = setTimeout(() => { ws.off('message', loadListener); reject(new SessionError( `Navigation to ${url} timed out after 30 seconds`, { url, sessionId } )); }, 30000); const loadListener = (msg) => { try { const data = JSON.parse(msg); if (data.method === 'Page.loadEventFired') { clearTimeout(loadTimeout); ws.off('message', loadListener); console.log(`✅ Navigation completed: ${url}`); resolve(); } } catch (error) { clearTimeout(loadTimeout); ws.off('message', loadListener); reject(new SessionError( `Error processing load event for ${url}: ${error.message}`, { url, error: error.message, sessionId } )); } }; ws.on('message', loadListener); }); }, 'session.navigateTo')(); }, // Get session statistics getStats() { return { id: sessionId, messageId: msgId, pendingTimeouts: sessionTimeouts.size, wsState: ws?.readyState, created: new Date().toISOString() }; }, // Clean up session resources destroy() { console.log(`🔒 Destroying session: ${sessionId}`); // Clear all pending timeouts for (const [id, timeoutId] of sessionTimeouts) { clearTimeout(timeoutId); } sessionTimeouts.clear(); // Remove all listeners (if possible) try { if (ws && typeof ws.removeAllListeners === 'function') { ws.removeAllListeners('message'); } } catch (error) { console.warn(`⚠️ Error removing listeners during session cleanup: ${error.message}`); } } }; return session; }