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//! FORBIDDEN: 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        let n = pages.len();
204        if n == 1 {
205            if let Some(page) = pages.into_iter().next() {
206                return Ok(page);
207            }
208        }
209        Err(format!(
210            "No chromiumoxide Page for session_id={session_id} (pages={n})"
211        ))
212    }
213}
214
215fn format_cdp_err(method: &str, e: CdpError) -> String {
216    format!("CDP error ({method}): {e}")
217}
218
219async fn spawn_event_forwarders(
220    browser: Arc<Mutex<Browser>>,
221    event_tx: broadcast::Sender<CdpEvent>,
222) -> Result<Vec<JoinHandle<()>>, String> {
223    let mut handles = Vec::new();
224    let b = browser.lock().await;
225
226    macro_rules! fwd {
227        ($ty:ty, $method:expr) => {{
228            let mut stream = b
229                .event_listener::<$ty>()
230                .await
231                .map_err(|e| format!("event_listener {}: {e}", $method))?;
232            let tx = event_tx.clone();
233            handles.push(tokio::spawn(async move {
234                while let Some(ev) = stream.next().await {
235                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
236                    let _ = tx.send(CdpEvent {
237                        method: $method.to_string(),
238                        params,
239                        // Browser-level listeners do not expose session; lifecycle accepts None.
240                        session_id: None,
241                    });
242                }
243            }));
244        }};
245    }
246
247    fwd!(EventLoadEventFired, "Page.loadEventFired");
248    fwd!(EventDomContentEventFired, "Page.domContentEventFired");
249    fwd!(EventRequestWillBeSent, "Network.requestWillBeSent");
250    fwd!(EventLoadingFinished, "Network.loadingFinished");
251    fwd!(EventLoadingFailed, "Network.loadingFailed");
252    fwd!(EventRequestPaused, "Fetch.requestPaused");
253    fwd!(EventJavascriptDialogOpening, "Page.javascriptDialogOpening");
254    // Console API (context7/docs-rs): required for --capture-console.
255    fwd!(EventConsoleApiCalled, "Runtime.consoleAPICalled");
256    // Heap / tracing / screencast: required for heap take, perf stop, screencast frames.
257    fwd!(
258        EventAddHeapSnapshotChunk,
259        "HeapProfiler.addHeapSnapshotChunk"
260    );
261    fwd!(
262        EventReportHeapSnapshotProgress,
263        "HeapProfiler.reportHeapSnapshotProgress"
264    );
265    fwd!(EventDataCollected, "Tracing.dataCollected");
266    fwd!(EventTracingComplete, "Tracing.tracingComplete");
267    fwd!(EventScreencastFrame, "Page.screencastFrame");
268
269    drop(b);
270    Ok(handles)
271}
272
273impl CdpClient {
274    /// Attach page-level console listeners (context7 pattern: page.event_listener).
275    /// Complements browser-level forwarders when Runtime events are page-scoped.
276    pub async fn attach_page_console_forwarders(&self) -> Result<(), String> {
277        self.attach_page_event_forwarders_console().await
278    }
279
280    /// Page-level Network.requestWillBeSent (page-scoped CDP events).
281    pub async fn attach_page_network_forwarders(&self) -> Result<(), String> {
282        let browser = self.browser.lock().await;
283        let pages = browser
284            .pages()
285            .await
286            .map_err(|e| format!("Browser::pages for network listeners: {e}"))?;
287        for page in pages {
288            let mut stream = page
289                .event_listener::<EventRequestWillBeSent>()
290                .await
291                .map_err(|e| format!("page EventRequestWillBeSent listener: {e}"))?;
292            let tx = self.event_tx.clone();
293            tokio::spawn(async move {
294                while let Some(ev) = stream.next().await {
295                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
296                    let _ = tx.send(CdpEvent {
297                        method: "Network.requestWillBeSent".to_string(),
298                        params,
299                        session_id: None,
300                    });
301                }
302            });
303        }
304        Ok(())
305    }
306
307    async fn attach_page_event_forwarders_console(&self) -> Result<(), String> {
308        let browser = self.browser.lock().await;
309        let pages = browser
310            .pages()
311            .await
312            .map_err(|e| format!("Browser::pages for console listeners: {e}"))?;
313        for page in pages {
314            let mut stream = page
315                .event_listener::<EventConsoleApiCalled>()
316                .await
317                .map_err(|e| format!("page EventConsoleApiCalled listener: {e}"))?;
318            let tx = self.event_tx.clone();
319            tokio::spawn(async move {
320                while let Some(ev) = stream.next().await {
321                    let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
322                    let _ = tx.send(CdpEvent {
323                        method: "Runtime.consoleAPICalled".to_string(),
324                        params,
325                        session_id: None,
326                    });
327                }
328            });
329        }
330        Ok(())
331    }
332
333    /// Page-scoped CDP events (heap chunks, screencast frames, JS dialogs).
334    /// Browser-level listeners miss target-session events; attach after pages exist.
335    pub async fn attach_page_session_forwarders(&self) -> Result<(), String> {
336        let browser = self.browser.lock().await;
337        let pages = browser
338            .pages()
339            .await
340            .map_err(|e| format!("Browser::pages for session listeners: {e}"))?;
341        for page in pages {
342            // HeapProfiler.addHeapSnapshotChunk
343            {
344                let mut stream = page
345                    .event_listener::<EventAddHeapSnapshotChunk>()
346                    .await
347                    .map_err(|e| format!("page EventAddHeapSnapshotChunk: {e}"))?;
348                let tx = self.event_tx.clone();
349                tokio::spawn(async move {
350                    while let Some(ev) = stream.next().await {
351                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
352                        let _ = tx.send(CdpEvent {
353                            method: "HeapProfiler.addHeapSnapshotChunk".to_string(),
354                            params,
355                            session_id: None,
356                        });
357                    }
358                });
359            }
360            // HeapProfiler.reportHeapSnapshotProgress
361            {
362                let mut stream = page
363                    .event_listener::<EventReportHeapSnapshotProgress>()
364                    .await
365                    .map_err(|e| format!("page EventReportHeapSnapshotProgress: {e}"))?;
366                let tx = self.event_tx.clone();
367                tokio::spawn(async move {
368                    while let Some(ev) = stream.next().await {
369                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
370                        let _ = tx.send(CdpEvent {
371                            method: "HeapProfiler.reportHeapSnapshotProgress".to_string(),
372                            params,
373                            session_id: None,
374                        });
375                    }
376                });
377            }
378            // Page.screencastFrame
379            {
380                let mut stream = page
381                    .event_listener::<EventScreencastFrame>()
382                    .await
383                    .map_err(|e| format!("page EventScreencastFrame: {e}"))?;
384                let tx = self.event_tx.clone();
385                tokio::spawn(async move {
386                    while let Some(ev) = stream.next().await {
387                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
388                        let _ = tx.send(CdpEvent {
389                            method: "Page.screencastFrame".to_string(),
390                            params,
391                            session_id: None,
392                        });
393                    }
394                });
395            }
396            // Page.javascriptDialogOpening (page-scoped; required for eval auto-accept)
397            {
398                let mut stream = page
399                    .event_listener::<EventJavascriptDialogOpening>()
400                    .await
401                    .map_err(|e| format!("page EventJavascriptDialogOpening: {e}"))?;
402                let tx = self.event_tx.clone();
403                tokio::spawn(async move {
404                    while let Some(ev) = stream.next().await {
405                        let params = serde_json::to_value(ev.as_ref()).unwrap_or(Value::Null);
406                        let _ = tx.send(CdpEvent {
407                            method: "Page.javascriptDialogOpening".to_string(),
408                            params,
409                            session_id: None,
410                        });
411                    }
412                });
413            }
414        }
415        Ok(())
416    }
417}