Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-simview/src/devserver.rs

//! **The local dev server** — the built page plus the mock contract on one origin, in
//! plain `std::net` (no Python, no framework). For a developer's browser and for tests;
//! never deployed (the bench serves the real contract).
//!
//! * `GET /sim/` → `302 /sim/?component=simview` (the loader mounts what the URL names)
//! * `GET /sim/<path>` → the first mount whose prefix `<path>` starts with
//! * `GET /power`, `POST /power/falkordb/{simulate,permutate}` → the mock's job
//! * `GET /sim/api/{meta.json,nodes.arrow,edges.arrow,ego.arrow}` → the mock's data
//!
//! Run it with `cargo run -p facett-simview --example devserver`.

use std::io::{BufRead as _, BufReader, Write as _};
use std::net::{TcpListener, TcpStream};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};

use crate::contract::{self, Req, TARGET, VERB_GENERATE, VERB_PERMUTATE};
use crate::mock::MockServer;

/// A static directory served under `/sim/<prefix>`.
#[derive(Debug, Clone)]
pub struct Mount {
    pub prefix: String,
    pub dir: PathBuf,
}

/// The server's state: the mounts and the mock.
#[derive(Clone)]
pub struct DevServer {
    pub mounts: Vec<Mount>,
    pub mock: Arc<Mutex<MockServer>>,
}

/// One HTTP answer.
pub struct Answer {
    pub status: u16,
    pub content_type: &'static str,
    pub body: Vec<u8>,
    pub location: Option<String>,
}

impl Answer {
    fn of(status: u16, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
        Answer { status, content_type, body: body.into(), location: None }
    }
    fn text(status: u16, body: impl Into<String>) -> Self {
        Self::of(status, "text/plain; charset=utf-8", body.into().into_bytes())
    }
}

fn content_type(path: &Path) -> &'static str {
    match path.extension().and_then(|e| e.to_str()) {
        Some("html") => "text/html; charset=utf-8",
        Some("js") => "text/javascript",
        Some("wasm") => "application/wasm",
        Some("json") => "application/json",
        Some("css") => "text/css",
        Some("png") => "image/png",
        Some("svg") => "image/svg+xml",
        _ => "application/octet-stream",
    }
}

fn query<'a>(q: &'a str, key: &str) -> Option<&'a str> {
    q.split('&').filter_map(|kv| kv.split_once('=')).find(|(k, _)| *k == key).map(|(_, v)| v)
}

fn list(v: Option<&str>) -> Vec<&str> {
    v.map(|s| s.split(',').map(str::trim).filter(|s| !s.is_empty()).collect()).unwrap_or_default()
}

impl DevServer {
    /// Answer one request (method, path with query). Pure apart from the mock and the
    /// file reads, so a test can call it without a socket.
    pub fn answer(&self, method: &str, target: &str) -> Answer {
        let (path, q) = target.split_once('?').unwrap_or((target, ""));
        let arrow = "application/vnd.apache.arrow.stream";
        let mut mock = self.mock.lock().unwrap();
        let from_mock = |(s, b): (u16, Vec<u8>), ct: &'static str| Answer::of(s, if s == 200 { ct } else { "text/plain" }, b);
        match (method, path) {
            ("GET", "/power") => from_mock(mock.answer(&Req::Power), "application/json"),
            ("POST", p) if p.starts_with("/power/") => {
                let verb = match p.strip_prefix(&format!("/power/{TARGET}/")) {
                    Some(VERB_GENERATE) => VERB_GENERATE,
                    Some(VERB_PERMUTATE) => VERB_PERMUTATE,
                    _ => return Answer::text(404, format!("no such bench verb: {p}")),
                };
                from_mock(mock.answer(&Req::Press(verb)), "application/json")
            }
            ("GET", "/sim/api/meta.json") => from_mock(mock.answer(&Req::Meta), "application/json"),
            ("GET", "/sim/api/nodes.arrow") => {
                if let Some(ids) = query(q, "ids") {
                    let ids: Vec<i64> = ids.split(',').filter_map(|s| s.trim().parse().ok()).collect();
                    return from_mock(mock.answer(&Req::NodesByIds(ids)), arrow);
                }
                let limit = query(q, "limit").and_then(|l| l.parse().ok()).unwrap_or(contract::NODE_LIMIT);
                Answer::of(200, arrow, mock.nodes_arrow(&list(query(q, "labels")), limit))
            }
            ("GET", "/sim/api/edges.arrow") => {
                let limit = query(q, "limit").and_then(|l| l.parse().ok()).unwrap_or(contract::NODE_LIMIT);
                Answer::of(200, arrow, mock.edges_arrow(&list(query(q, "types")), limit))
            }
            ("GET", "/sim/api/ego.arrow") => match query(q, "id").and_then(|i| i.parse::<i64>().ok()) {
                Some(id) => from_mock(mock.answer(&Req::Ego(id)), arrow),
                None => Answer::text(400, "ego.arrow needs ?id=<int>"),
            },
            ("GET", "/sim") | ("GET", "/sim/") if q.is_empty() => {
                Answer { location: Some("/sim/?component=simview".into()), ..Answer::text(302, "to the viewer") }
            }
            ("GET", p) if p.starts_with("/sim/") => {
                drop(mock);
                self.file(p.trim_start_matches("/sim/"))
            }
            _ => Answer::text(404, format!("not found: {method} {path}")),
        }
    }

    fn file(&self, rel: &str) -> Answer {
        let rel = if rel.is_empty() { "index.html" } else { rel };
        // Only plain path segments: nothing climbs out of a mount.
        if !Path::new(rel).components().all(|c| matches!(c, Component::Normal(_))) {
            return Answer::text(400, "bad path");
        }
        for m in &self.mounts {
            if let Some(rest) = rel.strip_prefix(m.prefix.as_str()) {
                let p = m.dir.join(rest);
                if let Ok(body) = std::fs::read(&p) {
                    return Answer::of(200, content_type(&p), body);
                }
            }
        }
        Answer::text(404, format!("no file /sim/{rel} under any mount"))
    }

    /// Serve `listener` forever, one thread per connection.
    pub fn serve(self, listener: TcpListener) {
        for stream in listener.incoming().flatten() {
            let me = self.clone();
            std::thread::spawn(move || {
                let _ = me.connection(stream);
            });
        }
    }

    fn connection(&self, mut stream: TcpStream) -> std::io::Result<()> {
        let mut r = BufReader::new(stream.try_clone()?);
        let mut line = String::new();
        r.read_line(&mut line)?;
        let mut parts = line.split_whitespace();
        let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or("/"));
        // The headers (and a POST's empty body) are not needed.
        let mut h = String::new();
        while r.read_line(&mut h)? > 2 {
            h.clear();
        }
        let a = self.answer(method, target);
        let reason = match a.status {
            200 => "OK",
            202 => "Accepted",
            302 => "Found",
            400 => "Bad Request",
            404 => "Not Found",
            409 => "Conflict",
            _ => "",
        };
        let mut head = format!(
            "HTTP/1.1 {} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n",
            a.status,
            a.content_type,
            a.body.len()
        );
        if let Some(l) = &a.location {
            head.push_str(&format!("Location: {l}\r\n"));
        }
        head.push_str("\r\n");
        stream.write_all(head.as_bytes())?;
        stream.write_all(&a.body)?;
        stream.flush()
    }
}