1use serde_json::{json, Value};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use std::sync::{Arc, Mutex, OnceLock};
12use std::time::Duration;
13use tokio::process::{Child, Command};
14
15const MAX_PER_SESSION: usize = 8;
17const STOP_WAIT: Duration = Duration::from_secs(3);
18
19#[derive(Clone, Debug)]
20pub struct InstanceInfo {
21 pub id: String,
22 pub session: String,
23 pub kind: String,
24 pub url: String,
25 pub port: u16,
26 pub root: Option<String>,
27}
28
29enum Runtime {
30 Static {
31 server: tesserax::RunningServer,
33 },
34 Http {
35 child: Child,
36 },
37}
38
39struct Instance {
40 info: InstanceInfo,
41 runtime: Runtime,
42 log: Arc<Mutex<Vec<String>>>,
43}
44
45struct PoolState {
46 by_session: BTreeMap<String, BTreeMap<String, Instance>>,
47}
48
49pub struct LocalServerPool {
50 inner: Mutex<PoolState>,
51}
52
53impl LocalServerPool {
54 fn new() -> Self {
55 Self {
56 inner: Mutex::new(PoolState {
57 by_session: BTreeMap::new(),
58 }),
59 }
60 }
61
62 pub fn global() -> Arc<LocalServerPool> {
63 static POOL: OnceLock<Arc<LocalServerPool>> = OnceLock::new();
64 POOL.get_or_init(|| Arc::new(LocalServerPool::new())).clone()
65 }
66
67 pub fn kinds() -> Value {
68 json!([
69 {"kind": "static", "description": "Serve a directory with tesserax-http StaticDir (SPA fallback when index.html exists)."},
70 {"kind": "http", "description": "Run a user binary that listens on PORT (loopback). cwd and env are optional."}
71 ])
72 }
73
74 pub async fn start(
75 &self,
76 session: &str,
77 kind: &str,
78 root: Option<&str>,
79 cwd: Option<&str>,
80 bin: Option<&str>,
81 env: Option<&Value>,
82 spa: bool,
83 ) -> Result<InstanceInfo, String> {
84 if session.is_empty() || session.len() > 128 {
85 return Err("invalid session".into());
86 }
87 {
88 let g = self.inner.lock().map_err(|_| "lock")?;
89 let n = g.by_session.get(session).map(|m| m.len()).unwrap_or(0);
90 if n >= MAX_PER_SESSION {
91 return Err(format!("session already has {MAX_PER_SESSION} local servers"));
92 }
93 }
94 let id = format!("ls-{}", &uuid::Uuid::new_v4().to_string()[..8]);
95 let (info, runtime) = match kind {
96 "static" => {
97 let root = root.ok_or("static kind requires root")?;
98 let root_path = PathBuf::from(root);
99 if !root_path.is_absolute() {
100 return Err("root must be an absolute path".into());
101 }
102 if !root_path.is_dir() {
103 return Err("root is not a directory".into());
104 }
105 start_static(&id, session, &root_path, spa).await?
106 }
107 "http" => {
108 let bin = bin.or(cwd).ok_or("http kind requires bin or cwd")?;
109 let cwd_path = cwd.map(PathBuf::from);
110 start_http(&id, session, bin, cwd_path.as_deref(), env).await?
111 }
112 _ => return Err(format!("unknown kind: {kind}")),
113 };
114 let log = Arc::new(Mutex::new(Vec::new()));
115 {
116 let mut g = self.inner.lock().map_err(|_| "lock")?;
117 g.by_session
118 .entry(session.to_owned())
119 .or_default()
120 .insert(id.clone(), Instance { info: info.clone(), runtime, log });
121 }
122 Ok(info)
123 }
124
125 pub async fn stop(&self, session: &str, id: &str) -> Result<(), String> {
126 let inst = {
127 let mut g = self.inner.lock().map_err(|_| "lock")?;
128 g.by_session
129 .get_mut(session)
130 .and_then(|m| m.remove(id))
131 .ok_or_else(|| "not found".to_owned())?
132 };
133 stop_runtime(inst.runtime).await;
134 let mut g = self.inner.lock().map_err(|_| "lock")?;
135 if let Some(m) = g.by_session.get(session) {
136 if m.is_empty() {
137 g.by_session.remove(session);
138 }
139 }
140 Ok(())
141 }
142
143 pub fn list(&self, session: &str) -> Value {
144 let g = match self.inner.lock() {
145 Ok(g) => g,
146 Err(_) => return json!({"kinds": Self::kinds(), "instances": []}),
147 };
148 let instances: Vec<Value> = g
149 .by_session
150 .get(session)
151 .map(|m| {
152 m.values()
153 .map(|i| {
154 json!({
155 "id": i.info.id,
156 "kind": i.info.kind,
157 "url": i.info.url,
158 "port": i.info.port,
159 "root": i.info.root,
160 })
161 })
162 .collect()
163 })
164 .unwrap_or_default();
165 json!({ "kinds": Self::kinds(), "instances": instances, "session": session })
166 }
167
168 pub fn logs(&self, session: &str, id: &str, limit: usize) -> Result<Value, String> {
169 let g = self.inner.lock().map_err(|_| "lock")?;
170 let inst = g
171 .by_session
172 .get(session)
173 .and_then(|m| m.get(id))
174 .ok_or_else(|| "not found".to_owned())?;
175 let lines = inst.log.lock().map_err(|_| "lock")?;
176 let start = lines.len().saturating_sub(limit.max(1).min(200));
177 Ok(json!({ "id": id, "lines": lines[start..].to_vec() }))
178 }
179
180 pub async fn stop_session(&self, session: &str) {
181 let ids: Vec<String> = {
182 let g = match self.inner.lock() {
183 Ok(g) => g,
184 Err(_) => return,
185 };
186 g.by_session
187 .get(session)
188 .map(|m| m.keys().cloned().collect())
189 .unwrap_or_default()
190 };
191 for id in ids {
192 let _ = self.stop(session, &id).await;
193 }
194 }
195}
196
197async fn start_static(
198 id: &str,
199 session: &str,
200 root: &Path,
201 spa: bool,
202) -> Result<(InstanceInfo, Runtime), String> {
203 use tesserax::{ServerBuilder, Transport};
204 use tesserax_http::assets::StaticDir;
205 use tesserax_http::HttpExt;
206
207 let mut dir = StaticDir::new(root);
208 if spa {
209 let index = root.join("index.html");
210 if index.is_file() {
211 dir = dir.with_spa_fallback(index);
212 }
213 }
214 let built = ServerBuilder::new(format!("hatchery-local-{id}"))
215 .transport(Transport::local(0))
216 .with_static_dir(dir)
217 .build()
218 .await
219 .map_err(|e| format!("build static server: {e}"))?;
220 let running = built
221 .start()
222 .await
223 .map_err(|e| format!("start static server: {e}"))?;
224 let addr = running.local_addr();
225 if !addr.ip().is_loopback() {
226 running.shutdown();
227 let _ = running.wait().await;
228 return Err("refused non-loopback bind".into());
229 }
230 let port = addr.port();
231 let url = format!("http://127.0.0.1:{port}/");
232 Ok((
233 InstanceInfo {
234 id: id.to_owned(),
235 session: session.to_owned(),
236 kind: "static".into(),
237 url,
238 port,
239 root: Some(root.display().to_string()),
240 },
241 Runtime::Static { server: running },
242 ))
243}
244
245async fn start_http(
246 id: &str,
247 session: &str,
248 bin: &str,
249 cwd: Option<&Path>,
250 env: Option<&Value>,
251) -> Result<(InstanceInfo, Runtime), String> {
252 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
254 .await
255 .map_err(|e| format!("port alloc: {e}"))?;
256 let port = listener.local_addr().map_err(|e| e.to_string())?.port();
257 drop(listener);
258
259 let mut cmd = Command::new(bin);
260 if let Some(cwd) = cwd {
261 cmd.current_dir(cwd);
262 }
263 cmd.env("PORT", port.to_string())
264 .env("HOST", "127.0.0.1")
265 .stdin(Stdio::null())
266 .stdout(Stdio::null())
267 .stderr(Stdio::null())
268 .kill_on_drop(true);
269 crate::windows_process::hide_console_tokio(&mut cmd);
270 if let Some(Value::Object(map)) = env {
271 for (k, v) in map {
272 if let Some(s) = v.as_str() {
273 cmd.env(k, s);
274 }
275 }
276 }
277 let child = cmd.spawn().map_err(|e| format!("spawn http server: {e}"))?;
278 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
280 loop {
281 if tokio::net::TcpStream::connect(("127.0.0.1", port))
282 .await
283 .is_ok()
284 {
285 break;
286 }
287 if tokio::time::Instant::now() > deadline {
288 return Err("http server did not accept on PORT in time".into());
289 }
290 tokio::time::sleep(Duration::from_millis(50)).await;
291 }
292 let url = format!("http://127.0.0.1:{port}/");
293 Ok((
294 InstanceInfo {
295 id: id.to_owned(),
296 session: session.to_owned(),
297 kind: "http".into(),
298 url,
299 port,
300 root: cwd.map(|p| p.display().to_string()),
301 },
302 Runtime::Http { child },
303 ))
304}
305
306async fn stop_runtime(runtime: Runtime) {
307 match runtime {
308 Runtime::Static { server } => {
309 server.shutdown();
310 let _ = tokio::time::timeout(STOP_WAIT, server.wait()).await;
311 }
312 Runtime::Http { mut child } => {
313 let _ = child.kill().await;
314 let _ = tokio::time::timeout(STOP_WAIT, child.wait()).await;
315 }
316 }
317}
318
319pub async fn handle_op(op: &str, payload: &Value) -> Result<Value, String> {
321 let pool = LocalServerPool::global();
322 let session = payload
323 .get("session")
324 .and_then(Value::as_str)
325 .ok_or("session required")?;
326 match op {
327 "local_server.list" => Ok(pool.list(session)),
328 "local_server.start" => {
329 let kind = payload
330 .get("kind")
331 .and_then(Value::as_str)
332 .ok_or("kind required")?;
333 let root = payload.get("root").and_then(Value::as_str);
334 let cwd = payload.get("cwd").and_then(Value::as_str);
335 let bin = payload
336 .get("bin")
337 .and_then(Value::as_str)
338 .or_else(|| payload.get("command").and_then(Value::as_str));
339 let spa = payload
340 .get("spa")
341 .and_then(Value::as_bool)
342 .unwrap_or(true);
343 let env = payload.get("env");
344 let info = pool
345 .start(session, kind, root, cwd, bin, env, spa)
346 .await?;
347 let open = payload
348 .get("open")
349 .and_then(Value::as_bool)
350 .unwrap_or(false);
351 if open {
352 #[cfg(feature = "d2b")]
353 {
354 let browsers = crate::browser_capture::shared_map();
355 let url = info.url.clone();
356 let sess = session.to_owned();
357 if let Ok(bytes) = hatchery_core::encode_browser_navigate(&url) {
359 let _ = browsers.input(&sess, &bytes).await;
360 }
361 }
362 }
363 Ok(json!({
364 "id": info.id,
365 "url": info.url,
366 "port": info.port,
367 "kind": info.kind,
368 }))
369 }
370 "local_server.stop" => {
371 let id = payload
372 .get("id")
373 .and_then(Value::as_str)
374 .ok_or("id required")?;
375 pool.stop(session, id).await?;
376 Ok(json!({"ok": true, "id": id}))
377 }
378 "local_server.logs" => {
379 let id = payload
380 .get("id")
381 .and_then(Value::as_str)
382 .ok_or("id required")?;
383 let limit = payload
384 .get("limit")
385 .and_then(Value::as_u64)
386 .unwrap_or(50) as usize;
387 pool.logs(session, id, limit)
388 }
389 _ => Err("unknown local_server op".into()),
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use std::io::Write;
397
398 #[tokio::test]
399 async fn static_start_list_stop_on_loopback() {
400 let dir = tempfile::tempdir().unwrap();
401 let mut f = std::fs::File::create(dir.path().join("index.html")).unwrap();
402 write!(f, "<h1>hi</h1>").unwrap();
403 let pool = LocalServerPool::new();
404 let info = pool
405 .start("pty-test-1", "static", Some(dir.path().to_str().unwrap()), None, None, None, true)
406 .await
407 .unwrap();
408 assert!(info.url.starts_with("http://127.0.0.1:"));
409 let list = pool.list("pty-test-1");
410 assert_eq!(list["instances"].as_array().unwrap().len(), 1);
411 let body = reqwest::get(&info.url).await.unwrap().text().await.unwrap();
412 assert!(body.contains("hi"));
413 pool.stop("pty-test-1", &info.id).await.unwrap();
414 assert!(pool.list("pty-test-1")["instances"]
415 .as_array()
416 .unwrap()
417 .is_empty());
418 }
419}