Skip to main content

browser_automation_cli/native/cdp/
client.rs

1//! CDP client over chromiumoxide (single connection — no dual WebSocket).
2#![allow(missing_docs)]
3//!
4//! Chrome one-shot: `Browser::launch` only.
5//! Lightpanda / attach path: `Browser::connect` only.
6//! PROIBIDO: second `tokio-tungstenite` attach to the same browser.
7
8use std::borrow::Cow;
9use std::sync::Arc;
10
11use chromiumoxide::browser::Browser;
12use chromiumoxide::cdp::browser_protocol::fetch::EventRequestPaused;
13use chromiumoxide::cdp::browser_protocol::network::{
14    EventLoadingFailed, EventLoadingFinished, EventRequestWillBeSent,
15};
16use chromiumoxide::cdp::browser_protocol::page::{
17    EventDomContentEventFired, EventJavascriptDialogOpening, EventLoadEventFired,
18    EventScreencastFrame,
19};
20use chromiumoxide::cdp::browser_protocol::tracing::{EventDataCollected, EventTracingComplete};
21use chromiumoxide::cdp::js_protocol::heap_profiler::{
22    EventAddHeapSnapshotChunk, EventReportHeapSnapshotProgress,
23};
24use chromiumoxide::cdp::js_protocol::runtime::EventConsoleApiCalled;
25use chromiumoxide::error::CdpError;
26use chromiumoxide::page::Page;
27use chromiumoxide::types::{Command, Method, MethodId};
28use chromiumoxide::Handler;
29use futures::StreamExt;
30use serde::Serialize;
31use serde_json::Value;
32use tokio::sync::{broadcast, Mutex};
33use tokio::task::JoinHandle;
34
35use super::types::CdpEvent;
36
37/// Dynamic CDP command for `Browser::execute` / `Page::execute`.
38#[derive(Debug, Clone)]
39struct RawCdpCommand {
40    method: String,
41    params: Value,
42}
43
44impl Serialize for RawCdpCommand {
45    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
46    where
47        S: serde::Serializer,
48    {
49        match &self.params {
50            Value::Null => {
51                use serde::ser::SerializeMap;
52                let map = serializer.serialize_map(Some(0))?;
53                map.end()
54            }
55            other => other.serialize(serializer),
56        }
57    }
58}
59
60impl Method for RawCdpCommand {
61    fn identifier(&self) -> MethodId {
62        Cow::Owned(self.method.clone())
63    }
64}
65
66impl Command for RawCdpCommand {
67    type Response = Value;
68}
69
70pub struct CdpClient {
71    browser: Arc<Mutex<Browser>>,
72    event_tx: broadcast::Sender<CdpEvent>,
73    _handler: JoinHandle<()>,
74    _event_forwarders: Vec<JoinHandle<()>>,
75}
76
77impl CdpClient {
78    /// Build client from an already-launched or connected chromiumoxide Browser + handler.
79    pub async fn from_browser(browser: Browser, mut handler: Handler) -> Result<Self, String> {
80        let handler_task = tokio::spawn(async move {
81            while let Some(h) = handler.next().await {
82                if h.is_err() {
83                    break;
84                }
85            }
86        });
87
88        let (event_tx, _) = broadcast::channel(4096);
89
90        let browser = Arc::new(Mutex::new(browser));
91        let event_forwarders = spawn_event_forwarders(browser.clone(), event_tx.clone()).await?;
92
93        Ok(Self {
94            browser,
95            event_tx,
96            _handler: handler_task,
97            _event_forwarders: event_forwarders,
98        })
99    }
100
101    /// Attach via chromiumoxide `Browser::connect` (lightpanda only).
102    pub async fn connect(url: &str) -> Result<Self, String> {
103        Self::connect_with_headers(url, None).await
104    }
105
106    /// Headers are ignored on the oxide path (chromiumoxide connect has no custom WS headers API).
107    pub async fn connect_with_headers(
108        url: &str,
109        _headers: Option<Vec<(String, String)>>,
110    ) -> Result<Self, String> {
111        let (browser, handler) = Browser::connect(url)
112            .await
113            .map_err(|e| format!("CDP Browser::connect failed: {e}"))?;
114        Self::from_browser(browser, handler).await
115    }
116
117    /// Shared browser handle (for FINALIZE close/wait/kill).
118    pub fn browser(&self) -> Arc<Mutex<Browser>> {
119        self.browser.clone()
120    }
121
122    pub async fn send_command(
123        &self,
124        method: &str,
125        params: Option<Value>,
126        session_id: Option<&str>,
127    ) -> Result<Value, String> {
128        let cmd = RawCdpCommand {
129            method: method.to_string(),
130            params: params.unwrap_or(Value::Null),
131        };
132
133        let result = if let Some(sid) = session_id.filter(|s| !s.is_empty()) {
134            let page = self.page_for_session(sid).await?;
135            page.execute(cmd)
136                .await
137                .map_err(|e| format_cdp_err(method, e))?
138        } else {
139            let browser = self.browser.lock().await;
140            browser
141                .execute(cmd)
142                .await
143                .map_err(|e| format_cdp_err(method, e))?
144        };
145
146        Ok(result.result)
147    }
148
149    pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
150        self.event_tx.subscribe()
151    }
152
153    pub async fn send_command_typed<P: serde::Serialize, R: serde::de::DeserializeOwned>(
154        &self,
155        method: &str,
156        params: &P,
157        session_id: Option<&str>,
158    ) -> Result<R, String> {
159        let params_value = serde_json::to_value(params)
160            .map_err(|e| format!("Failed to serialize params: {}", e))?;
161        let result = self
162            .send_command(method, Some(params_value), session_id)
163            .await?;
164        serde_json::from_value(result)
165            .map_err(|e| format!("Failed to deserialize CDP response for {}: {}", method, e))
166    }
167
168    pub async fn send_command_no_params(
169        &self,
170        method: &str,
171        session_id: Option<&str>,
172    ) -> Result<Value, String> {
173        self.send_command(method, None, session_id).await
174    }
175
176    /// Best-effort command (still awaits oxide execute).
177    pub async fn send_command_no_wait(
178        &self,
179        method: &str,
180        params: Option<Value>,
181        session_id: Option<&str>,
182    ) -> Result<(), String> {
183        let _ = self.send_command(method, params, session_id).await;
184        Ok(())
185    }
186
187    async fn page_for_session(&self, session_id: &str) -> Result<Page, String> {
188        let browser = self.browser.lock().await;
189        let pages = browser
190            .pages()
191            .await
192            .map_err(|e| format!("Browser::pages failed: {e}"))?;
193        for page in pages {
194            if page.session_id().as_ref() == session_id {
195                return Ok(page);
196            }
197        }
198        // Fallback: first page if only one exists (session id mismatch after attach).
199        let pages = browser
200            .pages()
201            .await
202            .map_err(|e| format!("Browser::pages failed: {e}"))?;
203        if pages.len() == 1 {
204            return Ok(pages.into_iter().next().unwrap());
205        }
206        Err(format!(
207            "No chromiumoxide Page for session_id={session_id} (pages={})",
208            pages.len()
209        ))
210    }
211}
212
213fn format_cdp_err(method: &str, e: CdpError) -> String {
214    format!("CDP error ({method}): {e}")
215}
216
217async fn spawn_event_forwarders(
218    browser: Arc<Mutex<Browser>>,
219    event_tx: broadcast::Sender<CdpEvent>,
220) -> Result<Vec<JoinHandle<()>>, String> {
221    let mut handles = Vec::new();
222    let b = browser.lock().await;
223
224    macro_rules! fwd {
225        ($ty:ty, $method:expr) => {{
226            let mut stream = b
227                .event_listener::<$ty>()
228                .await
229                .map_err(|e| format!("event_listener {}: {e}", $method))?;
230            let tx = event_tx.clone();
231            handles.push(tokio::spawn(async move {
232                while let Some(ev) = stream.next().await {
233                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
234                    let _ = tx.send(CdpEvent {
235                        method: $method.to_string(),
236                        params,
237                        // Browser-level listeners do not expose session; lifecycle accepts None.
238                        session_id: None,
239                    });
240                }
241            }));
242        }};
243    }
244
245    fwd!(EventLoadEventFired, "Page.loadEventFired");
246    fwd!(EventDomContentEventFired, "Page.domContentEventFired");
247    fwd!(EventRequestWillBeSent, "Network.requestWillBeSent");
248    fwd!(EventLoadingFinished, "Network.loadingFinished");
249    fwd!(EventLoadingFailed, "Network.loadingFailed");
250    fwd!(EventRequestPaused, "Fetch.requestPaused");
251    fwd!(EventJavascriptDialogOpening, "Page.javascriptDialogOpening");
252    // Console API (context7/docs-rs): required for --capture-console.
253    fwd!(EventConsoleApiCalled, "Runtime.consoleAPICalled");
254    // Heap / tracing / screencast: required for heap take, perf stop, screencast frames.
255    fwd!(
256        EventAddHeapSnapshotChunk,
257        "HeapProfiler.addHeapSnapshotChunk"
258    );
259    fwd!(
260        EventReportHeapSnapshotProgress,
261        "HeapProfiler.reportHeapSnapshotProgress"
262    );
263    fwd!(EventDataCollected, "Tracing.dataCollected");
264    fwd!(EventTracingComplete, "Tracing.tracingComplete");
265    fwd!(EventScreencastFrame, "Page.screencastFrame");
266
267    drop(b);
268    Ok(handles)
269}
270
271impl CdpClient {
272    /// Attach page-level console listeners (context7 pattern: page.event_listener).
273    /// Complements browser-level forwarders when Runtime events are page-scoped.
274    pub async fn attach_page_console_forwarders(&self) -> Result<(), String> {
275        self.attach_page_event_forwarders_console().await
276    }
277
278    /// Page-level Network.requestWillBeSent (page-scoped CDP events).
279    pub async fn attach_page_network_forwarders(&self) -> Result<(), String> {
280        let browser = self.browser.lock().await;
281        let pages = browser
282            .pages()
283            .await
284            .map_err(|e| format!("Browser::pages for network listeners: {e}"))?;
285        for page in pages {
286            let mut stream = page
287                .event_listener::<EventRequestWillBeSent>()
288                .await
289                .map_err(|e| format!("page EventRequestWillBeSent listener: {e}"))?;
290            let tx = self.event_tx.clone();
291            tokio::spawn(async move {
292                while let Some(ev) = stream.next().await {
293                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
294                    let _ = tx.send(CdpEvent {
295                        method: "Network.requestWillBeSent".to_string(),
296                        params,
297                        session_id: None,
298                    });
299                }
300            });
301        }
302        Ok(())
303    }
304
305    async fn attach_page_event_forwarders_console(&self) -> Result<(), String> {
306        let browser = self.browser.lock().await;
307        let pages = browser
308            .pages()
309            .await
310            .map_err(|e| format!("Browser::pages for console listeners: {e}"))?;
311        for page in pages {
312            let mut stream = page
313                .event_listener::<EventConsoleApiCalled>()
314                .await
315                .map_err(|e| format!("page EventConsoleApiCalled listener: {e}"))?;
316            let tx = self.event_tx.clone();
317            tokio::spawn(async move {
318                while let Some(ev) = stream.next().await {
319                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
320                    let _ = tx.send(CdpEvent {
321                        method: "Runtime.consoleAPICalled".to_string(),
322                        params,
323                        session_id: None,
324                    });
325                }
326            });
327        }
328        Ok(())
329    }
330
331    /// Page-scoped CDP events (heap chunks, screencast frames, JS dialogs).
332    /// Browser-level listeners miss target-session events; attach after pages exist.
333    pub async fn attach_page_session_forwarders(&self) -> Result<(), String> {
334        let browser = self.browser.lock().await;
335        let pages = browser
336            .pages()
337            .await
338            .map_err(|e| format!("Browser::pages for session listeners: {e}"))?;
339        for page in pages {
340            // HeapProfiler.addHeapSnapshotChunk
341            {
342                let mut stream = page
343                    .event_listener::<EventAddHeapSnapshotChunk>()
344                    .await
345                    .map_err(|e| format!("page EventAddHeapSnapshotChunk: {e}"))?;
346                let tx = self.event_tx.clone();
347                tokio::spawn(async move {
348                    while let Some(ev) = stream.next().await {
349                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
350                        let _ = tx.send(CdpEvent {
351                            method: "HeapProfiler.addHeapSnapshotChunk".to_string(),
352                            params,
353                            session_id: None,
354                        });
355                    }
356                });
357            }
358            // HeapProfiler.reportHeapSnapshotProgress
359            {
360                let mut stream = page
361                    .event_listener::<EventReportHeapSnapshotProgress>()
362                    .await
363                    .map_err(|e| format!("page EventReportHeapSnapshotProgress: {e}"))?;
364                let tx = self.event_tx.clone();
365                tokio::spawn(async move {
366                    while let Some(ev) = stream.next().await {
367                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
368                        let _ = tx.send(CdpEvent {
369                            method: "HeapProfiler.reportHeapSnapshotProgress".to_string(),
370                            params,
371                            session_id: None,
372                        });
373                    }
374                });
375            }
376            // Page.screencastFrame
377            {
378                let mut stream = page
379                    .event_listener::<EventScreencastFrame>()
380                    .await
381                    .map_err(|e| format!("page EventScreencastFrame: {e}"))?;
382                let tx = self.event_tx.clone();
383                tokio::spawn(async move {
384                    while let Some(ev) = stream.next().await {
385                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
386                        let _ = tx.send(CdpEvent {
387                            method: "Page.screencastFrame".to_string(),
388                            params,
389                            session_id: None,
390                        });
391                    }
392                });
393            }
394            // Page.javascriptDialogOpening (page-scoped; required for eval auto-accept)
395            {
396                let mut stream = page
397                    .event_listener::<EventJavascriptDialogOpening>()
398                    .await
399                    .map_err(|e| format!("page EventJavascriptDialogOpening: {e}"))?;
400                let tx = self.event_tx.clone();
401                tokio::spawn(async move {
402                    while let Some(ev) = stream.next().await {
403                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
404                        let _ = tx.send(CdpEvent {
405                            method: "Page.javascriptDialogOpening".to_string(),
406                            params,
407                            session_id: None,
408                        });
409                    }
410                });
411            }
412        }
413        Ok(())
414    }
415}