agent_first_http/sdk/
inline.rs1use 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#[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 pub async fn inline_ephemeral() -> Result<Self, Error> {
133 Self::inline_ephemeral_with(InlineConfig::default()).await
134 }
135
136 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 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 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}