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}