use std::{
io::{BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
sync::{Arc, OnceLock},
};
use anyhow::{Context, Result};
use flate2::{Compression, write::GzEncoder};
use crate::{CategorySet, Graph, api, parse_selector, util::Set};
const MAX_HEAD_BYTES: u64 = 16 * 1024;
const MAX_HEADER_LINES: usize = 100;
const COMPRESS_ABOVE: usize = 900;
const HTML: &str = "text/html; charset=utf-8";
const JS: &str = "application/javascript; charset=utf-8";
const JSON: &str = "application/json; charset=utf-8";
const INDEX_HTML: &str = include_str!("../assets/index.html");
const APP_JS: &str = include_str!("../assets/app.js");
const D3_JS: &str = include_str!("../assets/d3.min.js");
const VUE_JS: &str = include_str!("../assets/vue.global.prod.js");
struct State {
graph: Graph,
sources: Set<String>,
follow_inexact: bool,
}
struct Request {
target: String,
gzip: bool,
}
struct Responder<'a> {
stream: &'a TcpStream,
gzip: bool,
}
impl Responder<'_> {
fn send(&self, status: u16, content_type: &str, body: &[u8]) -> Result<()> {
let compressed = self.maybe_compress(body)?;
let payload = compressed.as_deref().unwrap_or(body);
self.write(status, content_type, payload, compressed.is_some())
}
fn send_cached(
&self,
content_type: &str,
plain: &[u8],
packed: &[u8],
) -> Result<()> {
let use_packed = self.gzip && packed.len() < plain.len();
let payload = if use_packed { packed } else { plain };
self.write(200, content_type, payload, use_packed)
}
fn maybe_compress(&self, body: &[u8]) -> Result<Option<Vec<u8>>> {
if !self.gzip || body.len() < COMPRESS_ABOVE {
return Ok(None);
}
let packed = gzip(body)?;
Ok((packed.len() < body.len()).then_some(packed))
}
fn write(
&self,
status: u16,
content_type: &str,
payload: &[u8],
compressed: bool,
) -> Result<()> {
let reason = if status == 200 { "OK" } else { "Error" };
let encoding = if compressed {
"Content-Encoding: gzip\r\n"
} else {
""
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\n\
Content-Type: {content_type}\r\n\
Content-Length: {}\r\n\
{encoding}\
Vary: Accept-Encoding\r\n\
Cache-Control: no-store\r\n\
Connection: close\r\n\r\n",
payload.len()
);
let mut stream = self.stream;
stream.write_all(head.as_bytes())?;
stream.write_all(payload)?;
stream.flush()?;
Ok(())
}
fn asset(
&self,
content_type: &str,
cell: &'static OnceLock<Vec<u8>>,
text: &str,
) -> Result<()> {
let packed =
cell.get_or_init(|| gzip(text.as_bytes()).unwrap_or_default());
self.send_cached(content_type, text.as_bytes(), packed)
}
fn json(&self, value: &serde_json::Value) -> Result<()> {
self.send(200, JSON, &serde_json::to_vec(value)?)
}
fn fail(&self, err: &anyhow::Error) -> Result<()> {
let body = serde_json::json!({ "error": format!("{err:#}") });
self.send(400, JSON, &serde_json::to_vec(&body)?)
}
fn text(&self, status: u16, body: &str) -> Result<()> {
self.send(status, "text/plain; charset=utf-8", body.as_bytes())
}
fn result(&self, outcome: Result<serde_json::Value>) -> Result<()> {
match outcome {
Ok(value) => self.json(&value),
Err(err) => self.fail(&err),
}
}
}
fn gzip(body: &[u8]) -> Result<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(body)?;
Ok(encoder.finish()?)
}
pub fn run(graph: Graph, addr: SocketAddr, follow_inexact: bool) -> Result<()> {
let listener = TcpListener::bind(addr)
.with_context(|| format!("could not bind {addr}"))?;
let bound = listener.local_addr().unwrap_or(addr);
println!("panicgraph is serving http://{bound}");
println!("press ctrl-c to stop");
serve_on(&listener, graph, follow_inexact)
}
pub fn serve_on(
listener: &TcpListener,
graph: Graph,
follow_inexact: bool,
) -> Result<()> {
let sources = api::source_allowlist(&graph);
let state = Arc::new(State {
graph,
sources,
follow_inexact,
});
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let state = Arc::clone(&state);
std::thread::spawn(move || {
if let Err(err) = handle(&stream, &state) {
eprintln!("panicgraph: request failed: {err}");
}
});
}
Ok(())
}
fn handle(stream: &TcpStream, state: &State) -> Result<()> {
let Some(request) = read_request(stream)? else {
let out = Responder {
stream,
gzip: false,
};
return out.text(400, "bad request");
};
let out = Responder {
stream,
gzip: request.gzip,
};
let (path, query) = split_once_or(&request.target, '?');
route(&out, state, path, query)
}
fn route(
out: &Responder<'_>,
state: &State,
path: &str,
query: &str,
) -> Result<()> {
static APP: OnceLock<Vec<u8>> = OnceLock::new();
static D3: OnceLock<Vec<u8>> = OnceLock::new();
static VUE: OnceLock<Vec<u8>> = OnceLock::new();
static INDEX: OnceLock<Vec<u8>> = OnceLock::new();
match path {
"/" | "/index.html" => out.asset(HTML, &INDEX, INDEX_HTML),
"/app.js" => out.asset(JS, &APP, APP_JS),
"/d3.min.js" => out.asset(JS, &D3, D3_JS),
"/vue.global.prod.js" => out.asset(JS, &VUE, VUE_JS),
"/api/graph" => out.json(&api::graph(&state.graph)),
"/api/solve" => out.result(api::solve(
&state.graph,
suppressed_from(query),
state.follow_inexact,
)),
"/api/flame" => out.result(api::flame(
&state.graph,
suppressed_from(query),
state.follow_inexact,
param(query, "expand").is_none(),
)),
"/api/why" => out.result(api::why(
&state.graph,
param(query, "node")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0),
¶m(query, "category").unwrap_or_default(),
suppressed_from(query),
state.follow_inexact,
)),
"/api/source" => out.result(api::source(
&state.sources,
¶m(query, "file").unwrap_or_default(),
)),
_ => out.text(404, "not found"),
}
}
fn suppressed_from(query: &str) -> CategorySet {
param(query, "suppress").map_or(CategorySet::EMPTY, |text| {
parse_selector(&text).unwrap_or(CategorySet::EMPTY)
})
}
fn read_request(stream: &TcpStream) -> Result<Option<Request>> {
let mut reader = BufReader::new(stream.take(MAX_HEAD_BYTES));
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(None);
}
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or_default();
let target = parts.next().unwrap_or_default().to_owned();
if method != "GET" || target.is_empty() {
return Ok(None);
}
let mut gzip = false;
for _ in 0..MAX_HEADER_LINES {
let mut header = String::new();
if reader.read_line(&mut header)? == 0 || header.trim().is_empty() {
break;
}
let lower = header.to_ascii_lowercase();
if let Some(value) = lower.strip_prefix("accept-encoding:") {
gzip = value.split(',').any(|token| {
token.split(';').next().unwrap_or_default().trim() == "gzip"
});
}
}
Ok(Some(Request { target, gzip }))
}
fn split_once_or(text: &str, sep: char) -> (&str, &str) {
text.split_once(sep).unwrap_or((text, ""))
}
fn param(query: &str, name: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let (key, value) = split_once_or(pair, '=');
(key == name).then(|| percent_decode(value))
})
}
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
if let Ok(byte) = u8::from_str_radix(&text[i + 1..i + 3], 16) {
out.push(byte);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}