use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock, mpsc};
use std::thread;
use std::time::Duration;
use plates::mime_type_from_ext;
use plates_render::percent_decode;
use crate::build::{BuiltSite, build_sites, plural};
use crate::cli::SiteArgs;
use crate::session::Session;
const REV_PATH: &str = "/__plates/rev";
const DEFAULT_PORT: u16 = 4321;
const PORT_ATTEMPTS: u16 = 10;
const POLL: Duration = Duration::from_millis(500);
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_HEAD: usize = 64 * 1024;
struct Snapshot {
revision: u64,
sites: Vec<BuiltSite>,
error: Option<String>,
}
struct State {
snapshot: RwLock<Arc<Snapshot>>,
active: AtomicBool,
rooted: bool,
}
impl State {
fn snapshot(&self) -> Arc<Snapshot> {
match self.snapshot.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn publish(&self, snapshot: Snapshot) {
let next = Arc::new(snapshot);
match self.snapshot.write() {
Ok(mut guard) => *guard = next,
Err(poisoned) => *poisoned.into_inner() = next,
}
}
}
pub fn serve(site: &SiteArgs, host: &str, port: Option<u16>, open: bool) -> Result<(), String> {
let root = Session::open()?.root_dir;
let listener = bind(host, port).map_err(|e| format!("cannot listen on {host}: {e}"))?;
let addr = listener
.local_addr()
.map_err(|e| format!("cannot read the listening address: {e}"))?;
let state = Arc::new(State {
snapshot: RwLock::new(Arc::new(Snapshot {
revision: 0,
sites: Vec::new(),
error: None,
})),
active: AtomicBool::new(false),
rooted: site.site.is_some(),
});
let (ready_tx, ready_rx) = mpsc::channel::<()>();
{
let state = state.clone();
let root = root.clone();
let site = site.clone();
thread::Builder::new()
.name("plates-build".to_string())
.spawn(move || builder_loop(state, root, site, ready_tx))
.map_err(|e| format!("cannot start the builder thread: {e}"))?;
}
if ready_rx.recv().is_err() {
return Err("the builder stopped before it produced anything".to_string());
}
let first = state.snapshot();
if let Some(error) = &first.error {
return Err(error.clone());
}
let origin = format!("http://{}", display_addr(host, addr.port()));
println!(
"✓ Serving {} site{} from {}",
first.sites.len(),
plural(first.sites.len()),
root.display()
);
for built in &first.sites {
let path = if state.rooted {
"/".to_string()
} else {
format!("/{}/", built.name)
};
println!(
" {origin}{path} — {}, {} page{}",
built.audience,
built.pages,
plural(built.pages)
);
}
println!();
println!(" Reloads on change. Press Ctrl-C to stop.");
if open {
let first_path = match (state.rooted, first.sites.first()) {
(true, _) | (_, None) => String::new(),
(false, Some(built)) => format!("/{}/", built.name),
};
open_browser(&format!("{origin}{first_path}"));
}
drop(first);
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let state = state.clone();
if thread::Builder::new()
.spawn(move || serve_connection(stream, &state))
.is_err()
{
eprintln!("! Dropped a connection: could not spawn a thread for it");
}
}
Ok(())
}
fn bind(host: &str, port: Option<u16>) -> std::io::Result<TcpListener> {
if let Some(port) = port {
return TcpListener::bind((host, port));
}
for port in DEFAULT_PORT..DEFAULT_PORT.saturating_add(PORT_ATTEMPTS) {
match TcpListener::bind((host, port)) {
Ok(listener) => return Ok(listener),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => continue,
Err(e) => return Err(e),
}
}
TcpListener::bind((host, 0))
}
fn display_addr(host: &str, port: u16) -> String {
match host {
"0.0.0.0" | "::" | "[::]" => format!("localhost:{port}"),
host if host.contains(':') => format!("[{host}]:{port}"),
host => format!("{host}:{port}"),
}
}
fn open_browser(url: &str) {
#[cfg(target_os = "macos")]
let (program, args) = ("open", vec![url]);
#[cfg(target_os = "windows")]
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let (program, args) = ("xdg-open", vec![url]);
if std::process::Command::new(program)
.args(args)
.spawn()
.is_err()
{
eprintln!("! Could not open a browser; visit {url}");
}
}
fn builder_loop(
state: Arc<State>,
root: std::path::PathBuf,
site: SiteArgs,
ready: mpsc::Sender<()>,
) {
let mut revision = 0u64;
let mut fingerprint = crate::scan::fingerprint(&root, None);
revision += 1;
state.publish(build(&site, revision));
let _ = ready.send(());
loop {
thread::sleep(POLL);
if !state.active.swap(false, Ordering::Relaxed) {
continue;
}
let next = crate::scan::fingerprint(&root, None);
if next == fingerprint {
continue;
}
fingerprint = next;
revision += 1;
let snapshot = build(&site, revision);
match &snapshot.error {
Some(error) => eprintln!("✗ Rebuild failed: {error}"),
None => println!(
" rebuilt {} site{} (#{revision})",
snapshot.sites.len(),
plural(snapshot.sites.len())
),
}
state.publish(snapshot);
}
}
fn build(site: &SiteArgs, revision: u64) -> Snapshot {
let built = Session::open().and_then(|session| {
crate::commands::report(&session.warnings);
build_sites(&session, site.site.as_deref(), site.base_url.as_deref())
});
match built {
Ok(sites) => {
for built in &sites {
crate::commands::report(&built.warnings);
}
Snapshot {
revision,
sites,
error: None,
}
}
Err(error) => Snapshot {
revision,
sites: Vec::new(),
error: Some(error),
},
}
}
struct Reply {
status: u16,
content_type: String,
body: Vec<u8>,
location: Option<String>,
}
impl Reply {
fn html(status: u16, body: String) -> Self {
Self {
status,
content_type: "text/html; charset=utf-8".to_string(),
body: body.into_bytes(),
location: None,
}
}
fn text(status: u16, body: String) -> Self {
Self {
status,
content_type: "text/plain; charset=utf-8".to_string(),
body: body.into_bytes(),
location: None,
}
}
fn redirect(to: &str) -> Self {
Self {
status: 302,
content_type: "text/plain; charset=utf-8".to_string(),
body: Vec::new(),
location: Some(to.to_string()),
}
}
fn file(rel: &str, body: Vec<u8>) -> Self {
Self {
status: 200,
content_type: content_type_for(rel),
body,
location: None,
}
}
fn is_html(&self) -> bool {
self.content_type.starts_with("text/html")
}
}
fn content_type_for(rel: &str) -> String {
let lower = rel.to_ascii_lowercase();
if lower.ends_with(".html") {
"text/html; charset=utf-8".to_string()
} else if lower.ends_with(".css") {
"text/css; charset=utf-8".to_string()
} else if lower.ends_with(".xml") {
"application/xml; charset=utf-8".to_string()
} else if lower.ends_with(".txt") {
"text/plain; charset=utf-8".to_string()
} else if lower.ends_with(".json") {
"application/json".to_string()
} else {
mime_type_from_ext(Path::new(rel))
}
}
fn serve_connection(stream: TcpStream, state: &State) {
let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
let _ = stream.set_nodelay(true);
let Some((method, target)) = read_request(&stream) else {
return;
};
let mut reply = match method.as_str() {
"GET" | "HEAD" => route(state, &target),
_ => Reply::text(405, format!("{method} is not supported\n")),
};
if reply.is_html() {
inject_reload(&mut reply);
}
let head_only = method == "HEAD";
let _ = write_reply(stream, &reply, head_only);
}
fn read_request(stream: &TcpStream) -> Option<(String, String)> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
if reader.read_line(&mut line).ok()? == 0 {
return None;
}
let mut parts = line.split_whitespace();
let method = parts.next()?.to_string();
let target = parts.next()?.to_string();
let mut consumed = line.len();
loop {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) => break,
Ok(n) => {
consumed += n;
if header.trim().is_empty() || consumed > MAX_HEAD {
break;
}
}
Err(_) => return None,
}
}
Some((method, target))
}
fn write_reply(mut stream: TcpStream, reply: &Reply, head_only: bool) -> std::io::Result<()> {
let mut head = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\
Cache-Control: no-store\r\nConnection: close\r\n",
reply.status,
reason(reply.status),
reply.content_type,
reply.body.len(),
);
if let Some(location) = &reply.location {
head.push_str(&format!("Location: {location}\r\n"));
}
head.push_str("\r\n");
stream.write_all(head.as_bytes())?;
if !head_only {
stream.write_all(&reply.body)?;
}
stream.flush()
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
302 => "Found",
404 => "Not Found",
405 => "Method Not Allowed",
500 => "Internal Server Error",
_ => "OK",
}
}
fn route(state: &State, target: &str) -> Reply {
let snapshot = state.snapshot();
let path = percent_decode(target.split(['?', '#']).next().unwrap_or("/"));
state.active.store(true, Ordering::Relaxed);
if path == REV_PATH {
return Reply::text(200, snapshot.revision.to_string());
}
if let Some(error) = &snapshot.error {
return Reply::html(500, error_page(error));
}
let rest = path.strip_prefix('/').unwrap_or(&path);
let (site, rel) = if state.rooted {
(snapshot.sites.first(), rest.to_string())
} else {
match rest.split_once('/') {
Some((name, rel)) => (find(&snapshot.sites, name), rel.to_string()),
None if rest.is_empty() => return Reply::html(200, index_page(&snapshot.sites)),
None => {
return match find(&snapshot.sites, rest) {
Some(site) => Reply::redirect(&format!("/{}/", site.name)),
None => Reply::html(404, missing_page(&snapshot.sites, None, &path)),
};
}
}
};
let Some(site) = site else {
return Reply::html(404, missing_page(&snapshot.sites, None, &path));
};
let rel = if rel.is_empty() || rel.ends_with('/') {
format!("{rel}index.html")
} else {
rel
};
match read(site, &rel) {
Some(bytes) => Reply::file(&rel, bytes),
None => Reply::html(404, missing_page(&snapshot.sites, Some(site), &path)),
}
}
fn find<'s>(sites: &'s [BuiltSite], name: &str) -> Option<&'s BuiltSite> {
sites.iter().find(|site| site.name == name)
}
fn read(site: &BuiltSite, rel: &str) -> Option<Vec<u8>> {
if let Some(bytes) = site.files.get(rel) {
return Some(bytes.clone());
}
std::fs::read(site.attachments.get(rel)?).ok()
}
const RELOAD_SCRIPT: &str = r#"<script>
(function () {
var rev = null;
function poll() {
fetch('/__plates/rev', { cache: 'no-store' })
.then(function (r) { return r.text(); })
.then(function (next) {
if (rev === null) { rev = next; }
else if (next !== rev) { location.reload(); return; }
setTimeout(poll, 700);
})
.catch(function () { setTimeout(poll, 2000); });
}
poll();
})();
</script>"#;
fn inject_reload(reply: &mut Reply) {
let Ok(html) = std::str::from_utf8(&reply.body) else {
return;
};
let injected = match html.rfind("</body>") {
Some(at) => format!("{}{RELOAD_SCRIPT}{}", &html[..at], &html[at..]),
None => format!("{html}{RELOAD_SCRIPT}"),
};
reply.body = injected.into_bytes();
}
const PAGE_CSS: &str = "body{font:16px/1.5 system-ui,sans-serif;max-width:42rem;\
margin:4rem auto;padding:0 1.5rem;color:#1c1c1c;background:#fbfbfa}\
a{color:#2f5fdf}h1{font-size:1.4rem}li{margin:.35rem 0}\
pre{white-space:pre-wrap;background:#f2f0ec;padding:1rem;border-radius:.4rem}\
.dim{color:#6b6b6b;font-size:.9rem}";
fn shell(title: &str, body: &str) -> String {
format!(
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>{}</title><style>{PAGE_CSS}</style></head><body>{body}</body></html>",
escape(title)
)
}
fn index_page(sites: &[BuiltSite]) -> String {
let items: String = sites
.iter()
.map(|site| {
format!(
"<li><a href=\"/{name}/\">{name}</a> <span class=\"dim\">— {audience}, \
{pages} page{s}</span></li>",
name = escape(&site.name),
audience = escape(&site.audience),
pages = site.pages,
s = plural(site.pages),
)
})
.collect();
shell(
"plates — sites",
&format!("<h1>Sites in this archive</h1><ul>{items}</ul>"),
)
}
fn error_page(error: &str) -> String {
shell(
"plates — build failed",
&format!(
"<h1>This archive did not render</h1><pre>{}</pre>\
<p class=\"dim\">Fix it and this page reloads itself.</p>",
escape(error)
),
)
}
fn missing_page(sites: &[BuiltSite], site: Option<&BuiltSite>, path: &str) -> String {
const SHOWN: usize = 100;
let body = match site {
Some(site) => {
let pages: Vec<&String> = site
.files
.keys()
.filter(|key| key.ends_with(".html"))
.collect();
let items: String = pages
.iter()
.take(SHOWN)
.map(|page| {
format!(
"<li><a href=\"/{prefix}{page}\">{page}</a></li>",
prefix = escape(&format!("{}/", site.name)),
page = escape(page),
)
})
.collect();
let more = pages.len().saturating_sub(SHOWN);
let note = if more > 0 {
format!("<p class=\"dim\">and {more} more</p>")
} else {
String::new()
};
format!(
"<h1>Not in <em>{}</em></h1><p class=\"dim\">{}</p>\
<p>This site holds:</p><ul>{items}</ul>{note}",
escape(&site.name),
escape(path),
)
}
None => {
let items: String = sites
.iter()
.map(|site| {
format!(
"<li><a href=\"/{name}/\">{name}</a></li>",
name = escape(&site.name)
)
})
.collect();
format!(
"<h1>No such site</h1><p class=\"dim\">{}</p>\
<p>This archive serves:</p><ul>{items}</ul>",
escape(path)
)
}
};
shell("plates — not found", &body)
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn built(name: &str, pages: &[&str]) -> BuiltSite {
BuiltSite {
name: name.to_string(),
audience: "public".to_string(),
files: pages
.iter()
.map(|p| ((*p).to_string(), b"<html><body>x</body></html>".to_vec()))
.collect(),
attachments: BTreeMap::new(),
pages: pages.len(),
warnings: Vec::new(),
}
}
fn state(sites: Vec<BuiltSite>, rooted: bool) -> State {
State {
snapshot: RwLock::new(Arc::new(Snapshot {
revision: 7,
sites,
error: None,
})),
active: AtomicBool::new(false),
rooted,
}
}
#[test]
fn a_site_is_mounted_under_its_name() {
let state = state(
vec![built("blog", &["index.html", "notes/post.html"])],
false,
);
assert_eq!(route(&state, "/blog/").status, 200);
assert_eq!(route(&state, "/blog/notes/post.html").status, 200);
assert_eq!(route(&state, "/blog/missing.html").status, 404);
}
#[test]
fn a_bare_site_name_redirects_to_its_root() {
let state = state(vec![built("blog", &["index.html"])], false);
let reply = route(&state, "/blog");
assert_eq!(reply.status, 302);
assert_eq!(reply.location.as_deref(), Some("/blog/"));
}
#[test]
fn a_single_site_serves_at_the_root() {
let state = state(
vec![built("blog", &["index.html", "notes/post.html"])],
true,
);
assert_eq!(route(&state, "/").status, 200);
assert_eq!(route(&state, "/notes/post.html").status, 200);
assert_eq!(route(&state, "/blog/").status, 404);
}
#[test]
fn served_html_carries_the_reload_script() {
let state = state(vec![built("blog", &["index.html"])], true);
let mut reply = route(&state, "/");
inject_reload(&mut reply);
let html = String::from_utf8(reply.body).unwrap();
assert!(html.contains(REV_PATH), "the poll endpoint is named");
assert!(
html.find(REV_PATH) < html.find("</body>"),
"and the script is inside the body it was injected into"
);
}
#[test]
fn the_build_number_is_served_through_a_failure() {
let state = State {
snapshot: RwLock::new(Arc::new(Snapshot {
revision: 12,
sites: vec![built("blog", &["index.html"])],
error: Some("bad frontmatter".to_string()),
})),
active: AtomicBool::new(false),
rooted: true,
};
let rev = route(&state, REV_PATH);
assert_eq!(rev.status, 200);
assert_eq!(String::from_utf8(rev.body).unwrap(), "12");
let page = route(&state, "/");
assert_eq!(page.status, 500, "and the page says so rather than lying");
assert!(
String::from_utf8(page.body)
.unwrap()
.contains("bad frontmatter")
);
}
#[test]
fn percent_encoded_paths_reach_their_page() {
let state = state(vec![built("blog", &["my note.html"])], true);
assert_eq!(route(&state, "/my%20note.html").status, 200);
}
#[test]
fn a_query_string_is_not_part_of_the_path() {
let state = state(vec![built("blog", &["index.html"])], true);
assert_eq!(route(&state, "/?t=1").status, 200);
}
#[test]
fn content_types_cover_both_halves_of_a_site() {
assert!(content_type_for("style.css").starts_with("text/css"));
assert!(content_type_for("feed.xml").starts_with("application/xml"));
assert_eq!(content_type_for("img/photo.jpg"), "image/jpeg");
}
}