lupa 0.1.1

Interactive object inspector for Rust — web UI + TUI + snapshot diffing
Documentation
//! HTTP + WebSocket server for the lupa web inspector.
//!
//! This module is enabled only when the `web` feature is active. It spawns two
//! background threads:
//!
//! - **HTTP thread** (`tiny_http`) – serves the static HTML UI and two JSON
//!   endpoints: `/api/snapshots` and `/api/diffs`.
//! - **WebSocket thread** (`tungstenite`) – accepts incoming WebSocket
//!   connections and pushes live updates whenever a new snapshot or diff is
//!   added.
//!
//! The server is started lazily (via `once_cell::sync::Lazy`) the first time
//! `INSPECTOR_SERVER` is dereferenced. This happens automatically when the
//! user calls `keep_alive()`, `run_mode(RunMode::Web)` or
//! `run_mode(RunMode::Both)`.
//!
//! All state is read from the global `INSPECTOR_STATE` (defined in `state.rs`).
//! Updates are broadcast to all connected WebSocket clients using the
//! `broadcast_snapshot` and `broadcast_diff` functions.

#![cfg(feature = "web")]

use std::{
    net::{TcpListener, TcpStream},
    sync::{Arc, Mutex},
    thread,
};

use once_cell::sync::Lazy;
use tiny_http::{Response, Server};
use tungstenite::{accept, Message, WebSocket};

use crate::state::{DiffEvent, INSPECTOR_STATE, Snapshot};

/// Type alias for a thread‑safe list of WebSocket clients.
type WsClients = Arc<Mutex<Vec<WebSocket<TcpStream>>>>;

/// Global list of currently connected WebSocket clients.
///
/// All connected browsers are stored here. The server pushes new snapshots
/// and diffs to every client that is still alive (dead connections are
/// automatically removed during the next broadcast).
static WS_CLIENTS: Lazy<WsClients> = Lazy::new(|| Arc::new(Mutex::new(Vec::new())));

/// The lazy global server instance. Dereferencing it starts the HTTP and
/// WebSocket threads.
pub static INSPECTOR_SERVER: Lazy<InspectorServer> = Lazy::new(InspectorServer::start);

/// Sends a snapshot to all connected WebSocket clients.
///
/// Called from `__internal::send_snapshot` when the `web` feature is enabled.
/// The snapshot is serialised as a JSON message with `kind = "snapshot"`.
pub fn broadcast_snapshot(snap: &Snapshot) {
    let json = serde_json::to_string(&WsEvent::Snapshot { data: snap }).unwrap();
    broadcast(&json);
}

/// Sends a diff event to all connected WebSocket clients.
///
/// Called from `__internal::send_diff` when the `web` feature is enabled.
/// The diff is serialised as a JSON message with `kind = "diff"`.
pub fn broadcast_diff(diff: &DiffEvent) {
    let json = serde_json::to_string(&WsEvent::Diff { data: diff }).unwrap();
    broadcast(&json);
}

/// Internal helper that broadcasts a raw JSON string to all WebSocket clients.
///
/// It locks the client list, iterates over all connections, and sends the
/// message. Any client that fails to receive the message is removed from the
/// list.
fn broadcast(json: &str) {
    let msg = Message::Text(json.to_owned().into());
    let mut clients = WS_CLIENTS.lock().unwrap();
    clients.retain_mut(|ws| ws.send(msg.clone()).is_ok());
}

/// The server handle – a zero‑sized type that exists only to tie the lazy
/// initialisation to a concrete type. The actual work is done in `start()`.
pub struct InspectorServer;

impl InspectorServer {
    /// Starts the HTTP and WebSocket server threads.
    ///
    /// - Binds to `0.0.0.0:7777` (or the port set in `LUPA_PORT` environment
    ///   variable). The WebSocket port is `HTTP_PORT + 1`.
    /// - Spawns a thread for the HTTP server that handles static files and
    ///   the JSON API.
    /// - Spawns a thread for the WebSocket server that accepts connections
    ///   and adds them to the global client list.
    ///
    /// This function panics if binding to the port fails (e.g. address already
    /// in use). It is called exactly once by the lazy static.
    fn start() -> Self {
        let http_port = std::env::var("LUPA_PORT")
            .ok()
            .and_then(|p| p.parse::<u16>().ok())
            .unwrap_or(7777);
        let ws_port = http_port + 1;

        // ─── HTTP thread ───────────────────────────────────────────────────────
        thread::Builder::new()
            .name("lupa-http".into())
            .spawn(move || {
                let addr = format!("0.0.0.0:{http_port}");
                let server = Server::http(&addr)
                    .unwrap_or_else(|e| panic!("lupa: HTTP bind failed on {addr}: {e}"));

                for req in server.incoming_requests() {
                    let url = req.url().to_string();
                    let path = url.split('?').next().unwrap_or("/");
                    let resp = match path {
                        "/" | "/index.html" => Response::from_string(HTML_UI)
                            .with_header(hdr("Content-Type: text/html; charset=utf-8")),
                        "/api/snapshots" => {
                            let snaps = INSPECTOR_STATE.snapshots();
                            let json = serde_json::to_string(&snaps).unwrap_or_default();
                            Response::from_string(json)
                                .with_header(hdr("Content-Type: application/json"))
                        }
                        "/api/diffs" => {
                            let diffs = INSPECTOR_STATE.diffs();
                            let json = serde_json::to_string(&diffs).unwrap_or_default();
                            Response::from_string(json)
                                .with_header(hdr("Content-Type: application/json"))
                        }
                        _ => Response::from_string("404 Not Found").with_status_code(404),
                    };
                    let _ = req.respond(resp);
                }
            })
            .expect("lupa: failed to spawn HTTP thread");

        // ─── WebSocket thread ─────────────────────────────────────────────────
        let clients_ws = WS_CLIENTS.clone();
        thread::Builder::new()
            .name("lupa-ws".into())
            .spawn(move || {
                let addr = format!("0.0.0.0:{ws_port}");
                let listener = TcpListener::bind(&addr)
                    .unwrap_or_else(|e| panic!("lupa: WS bind failed on {addr}: {e}"));

                for stream in listener.incoming().flatten() {
                    let clients_inner = clients_ws.clone();
                    // Spawn a new thread per connection to handle the WebSocket handshake
                    // and add the client to the global list. This thread terminates
                    // immediately after pushing the client; actual message sending
                    // happens in `broadcast()` using the stored WebSocket handle.
                    thread::spawn(move || match accept(stream) {
                        Ok(ws) => clients_inner.lock().unwrap().push(ws),
                        Err(e) => eprintln!("lupa: WS handshake error: {e}"),
                    });
                }
            })
            .expect("lupa: failed to spawn WS thread");

        InspectorServer
    }
}

/// WebSocket message format – a tagged union of snapshot or diff.
///
/// The `tag` field ("kind") is used by the frontend to distinguish between
/// the two event types. Both variants carry the full data.
#[derive(serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum WsEvent<'a> {
    /// A new snapshot has been added.
    Snapshot { data: &'a Snapshot },
    /// A new diff has been added.
    Diff { data: &'a DiffEvent },
}

/// Helper to create a `tiny_http::Header` from a static string.
fn hdr(s: &str) -> tiny_http::Header {
    s.parse().expect("static header is valid")
}

/// The HTML UI source code, embedded at compile time.
const HTML_UI: &str = include_str!("ui.html");