import asyncio import websockets import frappe class CDPSocketClient: """ Manages WebSocket communications with Chrome DevTools Protocol. Ensures robust error handling and consistent logging. """ def __init__(self, websocket_url): self.websocket_url = websocket_url self.connection = None self.message_id = 0 self.pending_messages = {} self.listeners = {} self.listen_task = None self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) def connect(self): """Open the WebSocket connection and start listening for messages.""" self.loop.run_until_complete(self._connect()) self.listen_task = self.loop.create_task(self._listen()) async def _connect(self): try: self.connection = await websockets.connect(self.websocket_url) except Exception: frappe.log_error(title="Failed to connect to WebSocket:", message=f"{frappe.get_traceback()}") raise async def _listen(self): try: async for message in self.connection: self._handle_message(frappe.json.loads(message)) except Exception: frappe.log_error(title="WebSocket listening error:", message=f"{frappe.get_traceback()}") def _handle_message(self, response): method = response.get("method") params = response.get("params", {}) session_id = response.get("sessionId") target_id = params.get("targetId") frame_id = params.get("frameId") message_id = response.get("id") composite_key = (method, session_id, target_id, frame_id) # Handle responses with `id` if message_id and message_id in self.pending_messages: future = self.pending_messages.pop(message_id) if composite_key in self.pending_messages: self.pending_messages.pop(composite_key) future.set_result(response) # Handle responses without `id` using a composite key elif method: if composite_key in self.pending_messages: # print("matched using composite_key", composite_key) future = self.pending_messages.pop(composite_key) future.set_result(response) if method in self.listeners: for callback, future, filters in self.listeners[method]: # added not filters["key"] might cause cross talk between different sessions if ( (not session_id or not filters["sessionId"] or filters["sessionId"] == session_id) and (not target_id or not filters["targetId"] or filters["targetId"] == target_id) and (not frame_id or not filters["frameId"] or filters["frameId"] == frame_id) ): callback(future, response) def disconnect(self): try: if self.listen_task: self.listen_task.cancel() self.loop.run_until_complete(self._disconnect()) # Cancel all pending tasks before stopping the loop was causing degrading performance over time to not cancelled properly pending_tasks = [task for task in asyncio.all_tasks(self.loop) if not task.done()] for task in pending_tasks: task.cancel() try: self.loop.run_until_complete(task) # Ensure tasks finish before loop stops except asyncio.CancelledError: pass # Ignore cancellation errors except Exception: frappe.log_error(title="Error while disconnecting:", message=f"{frappe.get_traceback()}") raise async def _disconnect(self): try: if self.connection: await self.connection.close() self.connection = None except Exception: frappe.log_error( title="Error during WebSocket disconnection:", message=f"{frappe.get_traceback()}" ) def send(self, method, params=None, session_id=None, return_future=False): if return_future: return asyncio.ensure_future( self._send(method, params, session_id, wait_future_fulfill=False), loop=self.loop ) future = self.loop.run_until_complete(self._send(method, params, session_id)) return self._destructure_response(future.result()) async def _send(self, method, params=None, session_id=None, wait_future_fulfill=True): self.message_id += 1 message_id = self.message_id message = { "id": message_id, "method": method, "params": params or {}, } if session_id: message["sessionId"] = session_id if self.connection is None: raise RuntimeError("WebSocket connection is not open.") future = asyncio.Future() self.pending_messages[message_id] = future # Dynamically create the composite key if any( [ method, session_id, params.get("targetId") if params else None, params.get("frameId") if params else None, ] ): composite_key = ( method, session_id, params.get("targetId") if params else None, params.get("frameId") if params else None, ) self.pending_messages[composite_key] = future await self.connection.send(frappe.json.dumps(message)) if wait_future_fulfill: await future return future def _destructure_response(self, response): """Destructure the response to extract useful information.""" result = response.get("result", None) error = response.get("error", None) return result, error def start_listener(self, method, callback, session_id=None, target_id=None, frame_id=None): """Register a listener for a specific CDP event with optional filtering.""" if method not in self.listeners: self.listeners[method] = [] future = self.loop.create_future() event = (callback, future, {"sessionId": session_id, "targetId": target_id, "frameId": frame_id}) if event not in self.listeners[method]: self.listeners[method].append(event) return event def wait_for_event(self, event, timeout=3): if type(event) is tuple: event = event[1] try: self.loop.run_until_complete(asyncio.wait_for(event, timeout)) except TimeoutError: frappe.log_error(title="Timeout waiting for event", message=f"{frappe.get_traceback()}") def remove_listener(self, method, event): """Remove a listener for a specific CDP event.""" self.listeners[method].remove(event)