Skip to main content

playwright_cdp/
web_socket.rs

1//! `WebSocket` — a captured WebSocket connection, reconstructed from CDP
2//! `Network.webSocket*` events.
3//!
4//! CDP does not expose a live WebSocket object; instead the `Network` domain
5//! emits a lifecycle of events for each connection, keyed by `requestId`:
6//!
7//! - `Network.webSocketCreated` — the URL (and optional request).
8//! - `Network.webSocketWillSendHandshakeRequest` — outgoing request headers.
9//! - `Network.webSocketHandshakeResponseReceived` — status code + response headers.
10//! - `Network.webSocketFrameSent` — a frame the page sent.
11//! - `Network.webSocketFrameReceived` — a frame the page received.
12//! - `Network.webSocketFrameError` — a protocol error.
13//! - `Network.webSocketClosed` — the connection closed (with a timestamp).
14//!
15//! [`WebSocketRegistry`] subscribes to the page session, partitions events by
16//! `requestId`, and accumulates them into one [`WebSocket`] per connection. The
17//! page-side `page.on('websocket')` wiring (and the `WebSocket::new` the page
18//! would hand callers) arrives in a later wave — this module provides the type
19//! and the capture/registry logic only.
20//!
21//! The accumulated `WebSocket` is cheaply cloneable (`Arc<Inner>`); clones share
22//! the same live state, so frames arriving after the handle is handed out are
23//! visible via [`frames`](WebSocket::frames) and the `on_*` handlers.
24
25use crate::cdp::session::CdpSession;
26use crate::cdp::CdpEvent;
27use parking_lot::Mutex;
28use serde_json::Value;
29use std::collections::HashMap;
30use std::future::Future;
31use std::sync::Arc;
32use std::time::Duration;
33use tokio::sync::{broadcast, Mutex as AsyncMutex};
34
35/// A single WebSocket frame payload, tagged with its direction.
36#[derive(Debug, Clone)]
37pub struct WebSocketFrame {
38    /// Direction relative to the page.
39    pub direction: FrameDirection,
40    /// The raw payload data. CDP distinguishes text and binary; for text frames
41    /// this is the decoded string, for binary frames it is the raw bytes.
42    pub data: FrameData,
43    /// The opcode (1 = text, 2 = binary, 8 = close, 9 = ping, 10 = pong), when
44    /// reported by CDP.
45    pub opcode: Option<i64>,
46}
47
48/// Direction of a [`WebSocketFrame`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum FrameDirection {
51    /// Page → server.
52    Sent,
53    /// Server → page.
54    Received,
55}
56
57/// A frame's payload, distinguishing text from binary.
58#[derive(Debug, Clone)]
59pub enum FrameData {
60    /// A text frame (decoded UTF-8).
61    Text(String),
62    /// A binary frame.
63    Binary(Vec<u8>),
64}
65
66/// The WebSocket handshake result.
67#[derive(Debug, Clone, Default)]
68pub struct WebSocketHandshake {
69    /// HTTP status code of the handshake response (e.g. 101).
70    pub status: Option<i64>,
71    /// Status text of the handshake response.
72    pub status_text: Option<String>,
73    /// Response headers, as reported by `webSocketHandshakeResponse`.
74    pub response_headers: HashMap<String, String>,
75    /// Request headers, as reported by `webSocketWillSendHandshakeRequest`.
76    pub request_headers: HashMap<String, String>,
77}
78
79/// Information about a closed WebSocket connection.
80#[derive(Debug, Clone, Default)]
81pub struct WebSocketCloseInfo {
82    /// Wall-time timestamp (seconds since epoch) reported by
83    /// `webSocketClosed`, if any.
84    pub timestamp: Option<f64>,
85}
86
87/// A captured WebSocket connection.
88///
89/// State is shared across clones (via `Arc<Inner>`); frames and close info
90/// accumulate as CDP events arrive on the registry's subscription.
91#[derive(Clone)]
92pub struct WebSocket {
93    inner: Arc<WebSocketInner>,
94}
95
96struct WebSocketInner {
97    /// The CDP `requestId` that identifies this connection.
98    request_id: String,
99    /// The WebSocket URL.
100    url: Mutex<String>,
101    /// Accumulated handshake.
102    handshake: Mutex<WebSocketHandshake>,
103    /// Accumulated frames (sent + received), in arrival order.
104    frames: Mutex<Vec<WebSocketFrame>>,
105    /// Protocol error payload, if `webSocketFrameError` fired.
106    error: Mutex<Option<String>>,
107    /// Close info, once `webSocketClosed` fires.
108    close: Mutex<Option<WebSocketCloseInfo>>,
109    /// Broadcast bus for live frame/close/error events (see `on_*` handlers).
110    events: broadcast::Sender<WebSocketLiveEvent>,
111}
112
113/// A live event emitted on a [`WebSocket`]'s broadcast bus.
114#[derive(Debug, Clone)]
115pub enum WebSocketLiveEvent {
116    /// A frame was sent or received.
117    Frame(WebSocketFrame),
118    /// A protocol error occurred.
119    Error(String),
120    /// The connection closed.
121    Closed(WebSocketCloseInfo),
122}
123
124impl WebSocket {
125    /// Construct a captured connection for `request_id`, seeded with `url`.
126    ///
127    /// This is `pub(crate)` so the registry (and, later, the page) can mint
128    /// handles; callers should obtain a `WebSocket` from the page's
129    /// `on('websocket')` handler.
130    pub(crate) fn new(request_id: impl Into<String>, url: impl Into<String>) -> Self {
131        let (tx, _rx) = broadcast::channel(256);
132        Self {
133            inner: Arc::new(WebSocketInner {
134                request_id: request_id.into(),
135                url: Mutex::new(url.into()),
136                handshake: Mutex::new(WebSocketHandshake::default()),
137                frames: Mutex::new(Vec::new()),
138                error: Mutex::new(None),
139                close: Mutex::new(None),
140                events: tx,
141            }),
142        }
143    }
144
145    /// The CDP `requestId` identifying this connection.
146    pub fn request_id(&self) -> &str {
147        &self.inner.request_id
148    }
149
150    /// The WebSocket URL.
151    pub fn url(&self) -> String {
152        self.inner.url.lock().clone()
153    }
154
155    /// A snapshot of the handshake (request + response headers, status).
156    pub fn handshake(&self) -> WebSocketHandshake {
157        self.inner.handshake.lock().clone()
158    }
159
160    /// A snapshot of all accumulated frames, in arrival order.
161    pub fn frames(&self) -> Vec<WebSocketFrame> {
162        self.inner.frames.lock().clone()
163    }
164
165    /// The protocol error reported by `webSocketFrameError`, if any.
166    pub fn error(&self) -> Option<String> {
167        self.inner.error.lock().clone()
168    }
169
170    /// Close info, once the connection has closed. `None` until
171    /// `webSocketClosed` arrives.
172    pub fn close_info(&self) -> Option<WebSocketCloseInfo> {
173        self.inner.close.lock().clone()
174    }
175
176    // --- live event handlers (mirror the page on_* pattern) -----------------
177
178    /// Register a handler invoked for every frame (sent or received) on this
179    /// connection, mirroring Playwright's per-connection event surface.
180    pub fn on_frame<F, Fut>(&self, handler: F)
181    where
182        F: Fn(WebSocketFrame) -> Fut + Send + Sync + 'static,
183        Fut: Future<Output = ()> + Send + 'static,
184    {
185        let mut rx = self.inner.events.subscribe();
186        tokio::spawn(async move {
187            while let Ok(ev) = rx.recv().await {
188                if let WebSocketLiveEvent::Frame(f) = ev {
189                    handler(f).await;
190                }
191            }
192        });
193    }
194
195    /// Register a handler invoked for every frame the page receives.
196    pub fn on_frame_received<F, Fut>(&self, handler: F)
197    where
198        F: Fn(WebSocketFrame) -> Fut + Send + Sync + 'static,
199        Fut: Future<Output = ()> + Send + 'static,
200    {
201        let mut rx = self.inner.events.subscribe();
202        tokio::spawn(async move {
203            while let Ok(ev) = rx.recv().await {
204                if let WebSocketLiveEvent::Frame(f) = ev {
205                    if f.direction == FrameDirection::Received {
206                        handler(f).await;
207                    }
208                }
209            }
210        });
211    }
212
213    /// Register a handler invoked for every frame the page sends.
214    pub fn on_frame_sent<F, Fut>(&self, handler: F)
215    where
216        F: Fn(WebSocketFrame) -> Fut + Send + Sync + 'static,
217        Fut: Future<Output = ()> + Send + 'static,
218    {
219        let mut rx = self.inner.events.subscribe();
220        tokio::spawn(async move {
221            while let Ok(ev) = rx.recv().await {
222                if let WebSocketLiveEvent::Frame(f) = ev {
223                    if f.direction == FrameDirection::Sent {
224                        handler(f).await;
225                    }
226                }
227            }
228        });
229    }
230
231    /// Register a handler invoked when the connection closes.
232    pub fn on_close<F, Fut>(&self, handler: F)
233    where
234        F: Fn(WebSocketCloseInfo) -> Fut + Send + Sync + 'static,
235        Fut: Future<Output = ()> + Send + 'static,
236    {
237        let mut rx = self.inner.events.subscribe();
238        tokio::spawn(async move {
239            while let Ok(ev) = rx.recv().await {
240                if let WebSocketLiveEvent::Closed(c) = ev {
241                    handler(c).await;
242                }
243            }
244        });
245    }
246
247    /// Wait for the next live event of any kind, or time out after `timeout`.
248    ///
249    /// This consumes from a fresh subscription, so it observes only events that
250    /// arrive *after* the call (it is not a replay of accumulated state — use
251    /// [`frames`](Self::frames)/[`close_info`](Self::close_info) for history).
252    pub async fn wait_for_event(&self, timeout: Duration) -> Result<WebSocketLiveEvent, WaitError> {
253        let mut rx = self.inner.events.subscribe();
254        match tokio::time::timeout(timeout, rx.recv()).await {
255            Ok(Ok(ev)) => Ok(ev),
256            Ok(Err(broadcast::error::RecvError::Closed)) => Err(WaitError::Closed),
257            Ok(Err(broadcast::error::RecvError::Lagged(_))) => Err(WaitError::Lagged),
258            Err(_) => Err(WaitError::Timeout),
259        }
260    }
261
262    // --- ingest (used by the registry) --------------------------------------
263
264    /// Apply a CDP `Network.webSocket*` event to this connection's state.
265    /// Unknown methods are ignored.
266    fn ingest(&self, method: &str, params: &Value) {
267        match method {
268            "Network.webSocketCreated" => {
269                if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
270                    *self.inner.url.lock() = url.to_string();
271                }
272            }
273            "Network.webSocketWillSendHandshakeRequest" => {
274                if let Some(req) = params.get("request") {
275                    let mut hs = self.inner.handshake.lock();
276                    if let Some(headers) = req.get("headers").and_then(|v| v.as_object()) {
277                        hs.request_headers = headers_to_map(headers);
278                    }
279                }
280            }
281            // Chrome emits the handshake response as
282            // `Network.webSocketHandshakeResponseReceived` (note the `Received`
283            // suffix). Older CDP revisions used the unsuffixed
284            // `Network.webSocketHandshakeResponse`; accept both for safety.
285            "Network.webSocketHandshakeResponseReceived"
286            | "Network.webSocketHandshakeResponse" => {
287                if let Some(resp) = params.get("response") {
288                    let mut hs = self.inner.handshake.lock();
289                    hs.status = resp.get("status").and_then(|v| v.as_i64());
290                    hs.status_text = resp
291                        .get("statusText")
292                        .and_then(|v| v.as_str())
293                        .map(String::from);
294                    if let Some(headers) = resp.get("headers").and_then(|v| v.as_object()) {
295                        hs.response_headers = headers_to_map(headers);
296                    }
297                }
298            }
299            "Network.webSocketFrameSent" => {
300                self.record_frame(FrameDirection::Sent, params);
301            }
302            "Network.webSocketFrameReceived" => {
303                self.record_frame(FrameDirection::Received, params);
304            }
305            "Network.webSocketFrameError" => {
306                let msg = params
307                    .get("errorMessage")
308                    .and_then(|v| v.as_str())
309                    .unwrap_or("websocket frame error")
310                    .to_string();
311                *self.inner.error.lock() = Some(msg.clone());
312                let _ = self.inner.events.send(WebSocketLiveEvent::Error(msg));
313            }
314            "Network.webSocketClosed" => {
315                let info = WebSocketCloseInfo {
316                    timestamp: params.get("timestamp").and_then(|v| v.as_f64()),
317                };
318                *self.inner.close.lock() = Some(info.clone());
319                let _ = self.inner.events.send(WebSocketLiveEvent::Closed(info));
320            }
321            _ => {}
322        }
323    }
324
325    /// Parse a `webSocketFrame*` payload's `response` (the frame's
326    /// `opcode`/`payloadData`) and record + broadcast it.
327    fn record_frame(&self, direction: FrameDirection, params: &Value) {
328        let frame = params
329            .get("response")
330            .and_then(|f| parse_frame(direction, f));
331        if let Some(frame) = frame {
332            self.inner.frames.lock().push(frame.clone());
333            let _ = self.inner.events.send(WebSocketLiveEvent::Frame(frame));
334        }
335    }
336}
337
338/// Parse one CDP `webSocketFrame` object (`{ opcode, mask, payloadData }`) into
339/// a [`WebSocketFrame`]. Returns `None` if the structure is unexpected.
340fn parse_frame(direction: FrameDirection, frame: &Value) -> Option<WebSocketFrame> {
341    let opcode = frame.get("opcode").and_then(|v| v.as_i64());
342    let payload = frame.get("payloadData").and_then(|v| v.as_str())?;
343    // CDP reports both text and binary frames as a `payloadData` string. Binary
344    // frames are signalled by opcode 2 (and the string may contain non-UTF-8
345    // when lossy-decoded by the browser). We treat opcode 2 as binary; here we
346    // only have the decoded string, so we store its bytes.
347    let data = if opcode == Some(2) {
348        FrameData::Binary(payload.as_bytes().to_vec())
349    } else {
350        FrameData::Text(payload.to_string())
351    };
352    Some(WebSocketFrame {
353        direction,
354        data,
355        opcode,
356    })
357}
358
359/// Flatten a CDP headers object into a `String`-keyed map. Header lookups in
360/// CDP are case-insensitive in practice; we preserve the reported casing.
361fn headers_to_map(headers: &serde_json::Map<String, Value>) -> HashMap<String, String> {
362    let mut out = HashMap::with_capacity(headers.len());
363    for (k, v) in headers {
364        let val = match v {
365            Value::String(s) => s.clone(),
366            other => other.to_string(),
367        };
368        out.insert(k.clone(), val);
369    }
370    out
371}
372
373/// Errors from [`WebSocket::wait_for_event`].
374#[derive(Debug)]
375pub enum WaitError {
376    /// No event arrived before the deadline.
377    Timeout,
378    /// The event bus was closed (the page/session went away).
379    Closed,
380    /// The receiver lagged behind and dropped events.
381    Lagged,
382}
383
384// ---------------------------------------------------------------------------
385// Registry / factory
386// ---------------------------------------------------------------------------
387
388/// A registry that captures WebSocket connections from a page session.
389///
390/// Subscribes to the session's event stream (before `Network` is expected to
391/// emit, though the page session already has `Network` enabled) and routes each
392/// `Network.webSocket*` event to the matching [`WebSocket`], keyed by
393/// `requestId`. The page can later iterate [`all`](Self::all) or look one up by
394/// id when wiring its `on('websocket')` handler.
395///
396/// Cheaply cloneable; clones share the same capture state.
397#[derive(Clone)]
398pub struct WebSocketRegistry {
399    inner: Arc<WebSocketRegistryInner>,
400}
401
402struct WebSocketRegistryInner {
403    /// `requestId` → live `WebSocket`.
404    sockets: AsyncMutex<HashMap<String, WebSocket>>,
405    /// Broadcast bus announcing newly-created connections (one event per
406    /// `webSocketCreated`).
407    created: broadcast::Sender<WebSocket>,
408}
409
410impl WebSocketRegistry {
411    pub(crate) fn new() -> Self {
412        let (tx, _rx) = broadcast::channel(64);
413        Self {
414            inner: Arc::new(WebSocketRegistryInner {
415                sockets: AsyncMutex::new(HashMap::new()),
416                created: tx,
417            }),
418        }
419    }
420
421    /// Begin capturing WebSocket events from `session`. Subscribes to the
422    /// session event stream and spawns a dispatcher task that partitions
423    /// `Network.webSocket*` events by `requestId`. Returns immediately; capture
424    /// continues for the life of the spawned task.
425    ///
426    /// Subscribe-first is honoured: the subscription is taken *before* any
427    /// further `Network` interaction so no events are missed.
428    pub fn attach(&self, session: &Arc<CdpSession>) {
429        let mut rx = session.subscribe();
430        let inner = Arc::clone(&self.inner);
431        tokio::spawn(async move {
432            loop {
433                match rx.recv().await {
434                    Ok(ev) => dispatch(&inner, &ev).await,
435                    Err(broadcast::error::RecvError::Closed) => break,
436                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
437                }
438            }
439        });
440    }
441
442    /// Look up a captured connection by `requestId`.
443    pub async fn get(&self, request_id: &str) -> Option<WebSocket> {
444        self.inner.sockets.lock().await.get(request_id).cloned()
445    }
446
447    /// All connections captured so far.
448    pub async fn all(&self) -> Vec<WebSocket> {
449        self.inner.sockets.lock().await.values().cloned().collect()
450    }
451
452    /// Subscribe to newly-created connections (one event per
453    /// `webSocketCreated`).
454    pub fn on_created(&self) -> broadcast::Receiver<WebSocket> {
455        self.inner.created.subscribe()
456    }
457}
458
459/// Route one CDP event to the right `WebSocket`, creating it on first sight.
460async fn dispatch(reg: &WebSocketRegistryInner, ev: &CdpEvent) {
461    // Every webSocket* event carries a `requestId` (except in malformed input).
462    let request_id = match ev.params.get("requestId").and_then(|v| v.as_str()) {
463        Some(id) => id.to_string(),
464        None => return,
465    };
466
467    // webSocketCreated is the lifecycle start: mint the WebSocket and announce it.
468    if ev.method == "Network.webSocketCreated" {
469        let url = ev
470            .params
471            .get("url")
472            .and_then(|v| v.as_str())
473            .unwrap_or("")
474            .to_string();
475        let ws = WebSocket::new(&request_id, &url);
476        let mut sockets = reg.sockets.lock().await;
477        sockets.entry(request_id.clone()).or_insert_with(|| ws.clone());
478        // Drop the guard before broadcasting.
479        drop(sockets);
480        let _ = reg.created.send(ws);
481        return;
482    }
483
484    // All other events target an existing connection; if we never saw
485    // webSocketCreated (e.g. subscription started mid-connection), materialize
486    // an empty one on demand.
487    let ws = {
488        let mut sockets = reg.sockets.lock().await;
489        sockets
490            .entry(request_id.clone())
491            .or_insert_with(|| WebSocket::new(&request_id, ""))
492            .clone()
493    };
494    ws.ingest(&ev.method, &ev.params);
495}