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()
}
}