Skip to main content

dbgflow_core/
server.rs

1//! Embedded HTTP server for serving the debugger UI.
2//!
3//! This module provides a minimal HTTP server that serves the browser-based
4//! debugging interface and exposes API endpoints for session data and reruns.
5
6use std::io::{Read, Write};
7use std::net::{TcpListener, TcpStream};
8use std::sync::{Arc, Mutex};
9use std::thread;
10
11use serde::Serialize;
12
13use crate::session::Session;
14
15/// Type alias for rerun handler closures.
16pub type RerunHandler = Arc<dyn Fn() -> std::io::Result<Session> + Send + Sync>;
17
18/// Internal server state shared across request handlers.
19struct ServerState {
20    session: Session,
21    generation: u64,
22    running: bool,
23    last_error: Option<String>,
24}
25
26/// Status response returned by the `/api/status` endpoint.
27#[derive(Serialize)]
28struct StatusResponse<'a> {
29    running: bool,
30    can_rerun: bool,
31    generation: u64,
32    session_title: &'a str,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    last_error: Option<&'a str>,
35}
36
37// ---------------------------------------------------------------------------
38// HTTP helpers
39// ---------------------------------------------------------------------------
40
41/// Returns the MIME content type for a given request path.
42fn content_type_for_path(path: &str) -> &'static str {
43    match path {
44        "/" => "text/html; charset=utf-8",
45        "/app.js" => "application/javascript; charset=utf-8",
46        "/app.css" => "text/css; charset=utf-8",
47        "/globals.css" => "text/css; charset=utf-8",
48        "/session.json" => "application/json; charset=utf-8",
49        "/api/status" => "application/json; charset=utf-8",
50        "/api/rerun" => "application/json; charset=utf-8",
51        _ => "text/plain; charset=utf-8",
52    }
53}
54
55/// Writes an HTTP response to the given stream.
56fn write_response(
57    stream: &mut TcpStream,
58    method: &str,
59    status: &str,
60    content_type: &str,
61    body: &str,
62) -> std::io::Result<()> {
63    let body_len = body.len();
64    let response = if method == "HEAD" {
65        format!(
66            "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {body_len}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
67        )
68    } else {
69        format!(
70            "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {body_len}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}"
71        )
72    };
73    stream.write_all(response.as_bytes())
74}
75
76/// Parses an HTTP request and returns the method and path.
77fn parse_request(stream: &mut TcpStream) -> std::io::Result<(String, String)> {
78    let mut buffer = [0_u8; 4096];
79    let bytes_read = stream.read(&mut buffer)?;
80    let request = String::from_utf8_lossy(&buffer[..bytes_read]);
81    let line = request.lines().next().unwrap_or_default();
82    let mut parts = line.split_whitespace();
83    let method = parts.next().unwrap_or("GET");
84    let raw_path = parts.next().unwrap_or("/");
85    let path = raw_path.split(['?', '#']).next().unwrap_or("/");
86    Ok((method.to_owned(), path.to_owned()))
87}
88
89/// Serializes a value to a JSON string.
90fn json_body<T: Serialize>(value: &T) -> std::io::Result<String> {
91    serde_json::to_string(value).map_err(std::io::Error::other)
92}
93
94// ---------------------------------------------------------------------------
95// Server implementation
96// ---------------------------------------------------------------------------
97
98/// Core server loop implementation.
99fn serve_session_inner(
100    session: Session,
101    host: &str,
102    port: u16,
103    rerun: Option<RerunHandler>,
104) -> std::io::Result<()> {
105    let listener = TcpListener::bind((host, port))?;
106    let shared = Arc::new(Mutex::new(ServerState {
107        session,
108        generation: 0,
109        running: false,
110        last_error: None,
111    }));
112
113    let html = super::ui::index_html();
114    let app_js = super::ui::app_js();
115    let app_css = super::ui::app_css();
116    let globals_css = super::ui::globals_css();
117
118    println!("Debugger UI: http://{host}:{port}");
119
120    for stream in listener.incoming() {
121        let mut stream = match stream {
122            Ok(stream) => stream,
123            Err(_) => continue,
124        };
125
126        let (method, path) = match parse_request(&mut stream) {
127            Ok(request) => request,
128            Err(_) => continue,
129        };
130
131        let result = handle_request(
132            &mut stream,
133            &method,
134            &path,
135            &shared,
136            &rerun,
137            &html,
138            &app_js,
139            &app_css,
140            &globals_css,
141        );
142
143        if let Err(error) = result {
144            // Ignore broken pipe and connection reset errors
145            if !matches!(
146                error.kind(),
147                std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset
148            ) {
149                return Err(error);
150            }
151        }
152    }
153
154    Ok(())
155}
156
157/// Handles a single HTTP request.
158#[allow(clippy::too_many_arguments)]
159fn handle_request(
160    stream: &mut TcpStream,
161    method: &str,
162    path: &str,
163    shared: &Arc<Mutex<ServerState>>,
164    rerun: &Option<RerunHandler>,
165    html: &str,
166    app_js: &str,
167    app_css: &str,
168    globals_css: &str,
169) -> std::io::Result<()> {
170    match (method, path) {
171        (_, "/") => write_response(stream, method, "200 OK", content_type_for_path("/"), html),
172        (_, "/app.js") => write_response(
173            stream,
174            method,
175            "200 OK",
176            content_type_for_path("/app.js"),
177            app_js,
178        ),
179        (_, "/app.css") => write_response(
180            stream,
181            method,
182            "200 OK",
183            content_type_for_path("/app.css"),
184            app_css,
185        ),
186        (_, "/globals.css") => write_response(
187            stream,
188            method,
189            "200 OK",
190            content_type_for_path("/globals.css"),
191            globals_css,
192        ),
193        (_, "/session.json") => {
194            let body = {
195                let state = shared
196                    .lock()
197                    .expect("dbgflow-core serve session mutex poisoned");
198                json_body(&state.session)?
199            };
200            write_response(
201                stream,
202                method,
203                "200 OK",
204                content_type_for_path("/session.json"),
205                &body,
206            )
207        }
208        (_, "/api/status") => {
209            let body = {
210                let state = shared
211                    .lock()
212                    .expect("dbgflow-core serve session mutex poisoned");
213                let status = StatusResponse {
214                    running: state.running,
215                    can_rerun: rerun.is_some(),
216                    generation: state.generation,
217                    session_title: &state.session.title,
218                    last_error: state.last_error.as_deref(),
219                };
220                json_body(&status)?
221            };
222            write_response(
223                stream,
224                method,
225                "200 OK",
226                content_type_for_path("/api/status"),
227                &body,
228            )
229        }
230        ("POST", "/api/rerun") => handle_rerun_request(stream, method, shared, rerun),
231        _ => write_response(
232            stream,
233            method,
234            "404 Not Found",
235            content_type_for_path(""),
236            "not found",
237        ),
238    }
239}
240
241/// Handles a POST request to trigger a rerun.
242fn handle_rerun_request(
243    stream: &mut TcpStream,
244    method: &str,
245    shared: &Arc<Mutex<ServerState>>,
246    rerun: &Option<RerunHandler>,
247) -> std::io::Result<()> {
248    let body = if let Some(rerun_handler) = rerun.clone() {
249        let should_spawn = {
250            let mut state = shared
251                .lock()
252                .expect("dbgflow-core serve session mutex poisoned");
253            if state.running {
254                false
255            } else {
256                state.running = true;
257                state.last_error = None;
258                true
259            }
260        };
261
262        if should_spawn {
263            let shared = Arc::clone(shared);
264            thread::spawn(move || {
265                let rerun_result = rerun_handler();
266                let mut state = shared
267                    .lock()
268                    .expect("dbgflow-core serve session mutex poisoned");
269                state.running = false;
270                match rerun_result {
271                    Ok(session) => {
272                        state.session = session;
273                        state.generation += 1;
274                    }
275                    Err(error) => {
276                        state.last_error = Some(error.to_string());
277                    }
278                }
279            });
280        }
281
282        let state = shared
283            .lock()
284            .expect("dbgflow-core serve session mutex poisoned");
285        let status = StatusResponse {
286            running: state.running,
287            can_rerun: true,
288            generation: state.generation,
289            session_title: &state.session.title,
290            last_error: state.last_error.as_deref(),
291        };
292        json_body(&status)?
293    } else {
294        json_body(&serde_json::json!({
295            "running": false,
296            "can_rerun": false,
297            "generation": 0_u64,
298            "last_error": "rerun is not available for this session"
299        }))?
300    };
301
302    let status = if rerun.is_some() {
303        "202 Accepted"
304    } else {
305        "405 Method Not Allowed"
306    };
307
308    write_response(
309        stream,
310        method,
311        status,
312        content_type_for_path("/api/rerun"),
313        &body,
314    )
315}
316
317// ---------------------------------------------------------------------------
318// Public API
319// ---------------------------------------------------------------------------
320
321/// Serves a session over the embedded local HTTP server.
322pub fn serve_session(session: Session, host: &str, port: u16) -> std::io::Result<()> {
323    serve_session_inner(session, host, port, None)
324}
325
326/// Serves a session over the embedded local HTTP server and exposes a rerun API.
327///
328/// The UI can call `POST /api/rerun` to request a fresh session. The provided
329/// closure must run the underlying pipeline or test command and return the new
330/// captured session snapshot.
331pub fn serve_session_with_rerun(
332    session: Session,
333    host: &str,
334    port: u16,
335    rerun: impl Fn() -> std::io::Result<Session> + Send + Sync + 'static,
336) -> std::io::Result<()> {
337    serve_session_inner(session, host, port, Some(Arc::new(rerun)))
338}