179 lines
5.7 KiB
Python
179 lines
5.7 KiB
Python
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)
|