Skip to main content

agent_first_http/sdk/
inline.rs

1//! Inline ephemeral host support for CLI/SDK convenience.
2//!
3//! The returned `Client` owns an `InlineHost` guard. Dropping the last clone
4//! sends listener shutdown, aborts the server task, drops the browser handle,
5//! and therefore removes the ephemeral profile tempdir.
6
7use crate::sdk::client::Client;
8use crate::sdk::endpoint::Endpoint;
9use crate::shared::error::{Error, ErrorCode};
10
11#[cfg(feature = "host")]
12use std::path::PathBuf;
13#[cfg(feature = "host")]
14use std::sync::Arc;
15
16#[cfg(feature = "host")]
17use crate::host::bootstrap::BrowserChoice;
18
19/// Browser configuration for an inline ephemeral host.
20///
21/// `Default` keeps the historical behavior — `BrowserChoice::Auto` with no
22/// explicit binary, i.e. auto-discovery. Set `browser_bin` to point at a
23/// specific browser when auto-discovery can't find one (e.g. a non-standard
24/// install location).
25#[cfg(feature = "host")]
26#[derive(Debug, Clone, Default)]
27pub struct InlineConfig {
28    pub browser: BrowserChoice,
29    pub browser_bin: Option<PathBuf>,
30}
31
32#[cfg(feature = "host")]
33#[derive(Clone)]
34pub(crate) struct InlineHost {
35    inner: Arc<InlineHostInner>,
36}
37
38#[cfg(feature = "host")]
39struct InlineHostInner {
40    config: InlineConfig,
41    launched: tokio::sync::Mutex<Option<LaunchedInlineHost>>,
42}
43
44#[cfg(feature = "host")]
45struct LaunchedInlineHost {
46    endpoint: Endpoint,
47    _guard: InlineHostGuard,
48}
49
50#[cfg(feature = "host")]
51struct InlineHostGuard {
52    shutdown: Option<tokio::sync::oneshot::Sender<()>>,
53    task: tokio::task::JoinHandle<()>,
54    _state: crate::host::listener::AppState,
55}
56
57#[cfg(feature = "host")]
58impl Drop for InlineHostGuard {
59    fn drop(&mut self) {
60        if let Some(tx) = self.shutdown.take() {
61            let _ = tx.send(());
62        }
63        self.task.abort();
64    }
65}
66
67#[cfg(feature = "host")]
68impl InlineHost {
69    pub(crate) fn lazy(config: InlineConfig) -> Self {
70        Self {
71            inner: Arc::new(InlineHostInner {
72                config,
73                launched: tokio::sync::Mutex::new(None),
74            }),
75        }
76    }
77
78    pub(crate) async fn launch_now(config: InlineConfig) -> Result<Self, Error> {
79        let host = Self::lazy(config);
80        let _ = host.endpoint().await?;
81        Ok(host)
82    }
83
84    pub(crate) async fn endpoint(&self) -> Result<Endpoint, Error> {
85        let mut guard = self.inner.launched.lock().await;
86        if let Some(launched) = guard.as_ref() {
87            return Ok(launched.endpoint.clone());
88        }
89        let launched = launch_inline_host(&self.inner.config).await?;
90        let endpoint = launched.endpoint.clone();
91        *guard = Some(launched);
92        Ok(endpoint)
93    }
94
95    pub(crate) async fn is_started(&self) -> bool {
96        self.inner.launched.lock().await.is_some()
97    }
98
99    #[cfg(test)]
100    async fn from_state_for_tests(state: crate::host::listener::AppState) -> Result<Self, Error> {
101        let host = Self::lazy(InlineConfig::default());
102        let launched = serve_state(state).await?;
103        *host.inner.launched.lock().await = Some(launched);
104        Ok(host)
105    }
106}
107
108#[cfg(not(feature = "host"))]
109#[derive(Clone)]
110pub(crate) struct InlineHost;
111
112#[cfg(not(feature = "host"))]
113impl InlineHost {
114    pub(crate) async fn endpoint(&self) -> Result<Endpoint, Error> {
115        Err(Error::new(
116            ErrorCode::RenderUnavailable,
117            "Client::inline_ephemeral requires the `host` feature",
118        ))
119    }
120
121    pub(crate) async fn is_started(&self) -> bool {
122        false
123    }
124}
125
126#[cfg(feature = "host")]
127impl Client {
128    /// Spawn a private `afhttp host` listener bound to a random local
129    /// port, launch a chromium backend, and connect to it. The host runs
130    /// in the same process as a tokio task and is shut down when the
131    /// last clone of the returned `Client` is dropped.
132    pub async fn inline_ephemeral() -> Result<Self, Error> {
133        Self::inline_ephemeral_with(InlineConfig::default()).await
134    }
135
136    /// Like [`inline_ephemeral`](Self::inline_ephemeral) but with explicit
137    /// browser configuration (backend choice and/or binary path). Use this when
138    /// auto-discovery can't find a browser on the host.
139    pub async fn inline_ephemeral_with(config: InlineConfig) -> Result<Self, Error> {
140        let inline = InlineHost::launch_now(config).await?;
141        let endpoint = inline.endpoint().await?;
142        let client = Client::connect(&endpoint.cdp_ws_url())?.with_inline_host(inline);
143        Ok(client)
144    }
145
146    pub(crate) async fn inline_ephemeral_lazy(config: InlineConfig) -> Result<Self, Error> {
147        let inline = InlineHost::lazy(config);
148        Ok(Client::connect("ws://127.0.0.1:0")?.with_inline_host(inline))
149    }
150}
151
152#[cfg(not(feature = "host"))]
153impl Client {
154    /// Not available without the `host` feature.
155    pub async fn inline_ephemeral() -> Result<Self, Error> {
156        Err(Error::new(
157            ErrorCode::RenderUnavailable,
158            "Client::inline_ephemeral requires the `host` feature",
159        ))
160    }
161}
162
163#[cfg(feature = "host")]
164async fn launch_inline_host(config: &InlineConfig) -> Result<LaunchedInlineHost, Error> {
165    use crate::host::bootstrap::{
166        DisplayMode, HealthPublic, HostArgs, ProfileChoice, Takeover, install_rustls_provider,
167    };
168    use crate::host::listener::AppState;
169
170    // The host side builds a reqwest client (CDP /json fetch) before the SDK
171    // client's own provider guard runs; install it up front so SDK callers of
172    // inline_ephemeral_with don't panic.
173    install_rustls_provider();
174
175    let args = HostArgs {
176        listen: "tcp:127.0.0.1:0".into(),
177        profile: ProfileChoice::Ephemeral,
178        display: DisplayMode::Headless,
179        takeover: Takeover::Off,
180        display_quality: 100,
181        browser: config.browser.clone(),
182        browser_bin: config.browser_bin.clone(),
183        token: None,
184        takeover_enabled: false,
185        health_enabled: true,
186        health_public: HealthPublic::Off,
187        engine_envs: Vec::new(),
188        browser_args: Vec::new(),
189        proxy: None,
190        recent_requests_cap: 0,
191    };
192    let state = AppState::launch(&args).await?;
193    serve_state(state).await
194}
195
196#[cfg(feature = "host")]
197async fn serve_state(state: crate::host::listener::AppState) -> Result<LaunchedInlineHost, Error> {
198    use tokio::net::TcpListener;
199
200    use crate::host::listener::build_router;
201
202    let app = build_router(state.clone());
203    let listener = TcpListener::bind("127.0.0.1:0")
204        .await
205        .map_err(|e| Error::new(ErrorCode::IoError, format!("inline_ephemeral bind: {e}")))?;
206    let addr = listener.local_addr().map_err(|e| {
207        Error::new(
208            ErrorCode::IoError,
209            format!("inline_ephemeral local_addr: {e}"),
210        )
211    })?;
212    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
213    let task = tokio::spawn(async move {
214        let _ = axum::serve(listener, app)
215            .with_graceful_shutdown(async {
216                let _ = rx.await;
217            })
218            .await;
219    });
220    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
221
222    Ok(LaunchedInlineHost {
223        endpoint: Endpoint::parse(&format!("ws://{addr}"))?,
224        _guard: InlineHostGuard {
225            shutdown: Some(tx),
226            task,
227            _state: state,
228        },
229    })
230}
231
232#[cfg(all(test, feature = "host"))]
233mod tests {
234    use std::sync::Arc;
235
236    use crate::host::bootstrap::HealthPublic;
237    use crate::host::browser::BrowserHandle;
238    use crate::host::listener::test_state;
239
240    use super::*;
241
242    #[tokio::test]
243    async fn inline_guard_drop_closes_port_and_removes_ephemeral_profile() {
244        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
245        let tempdir = tempfile::Builder::new()
246            .prefix("afhttp-inline-test-")
247            .tempdir()
248            .expect("tempdir");
249        let profile_path = tempdir.path().to_path_buf();
250        let state = test_state(None, HealthPublic::Off)
251            .with_default_browser(Arc::new(BrowserHandle::synthetic_ephemeral(tempdir)));
252        let inline = InlineHost::from_state_for_tests(state)
253            .await
254            .expect("inline state");
255        let endpoint = inline.endpoint().await.expect("endpoint");
256        let base = endpoint.http_base();
257        let ok = reqwest::Client::new()
258            .get(format!("{base}/health"))
259            .send()
260            .await
261            .expect("health");
262        assert!(ok.status().is_success());
263
264        drop(inline);
265        for _ in 0..20 {
266            if !profile_path.exists()
267                && reqwest::Client::new()
268                    .get(format!("{base}/health"))
269                    .send()
270                    .await
271                    .is_err()
272            {
273                return;
274            }
275            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
276        }
277        assert!(
278            !profile_path.exists(),
279            "ephemeral profile should be removed: {}",
280            profile_path.display()
281        );
282        assert!(
283            reqwest::Client::new()
284                .get(format!("{base}/health"))
285                .send()
286                .await
287                .is_err(),
288            "inline listener should stop accepting connections"
289        );
290    }
291}