//! `serve`: a development server over the built site, with browser live reload.
//!
//! `watch` rebuilds but leaves you to serve the output and press reload yourself. This
//! closes that loop: build, watch, serve, and push a reload to the browser when a
//! rebuild lands.
//!
//! Three decisions shape it:
//!
//! 1. **Loopback by default.** A development server binds `127.0.0.1`, not `0.0.0.0`.
//! It serves unreviewed drafts off someone's laptop, and exposing that to the local
//! network should be a thing you ask for (`--host`), never a thing you get.
//! 2. **The reload script is injected at serve time**, never written to disk. The built
//! site is what you deploy, and it must not carry a dev server's JavaScript.
//! 3. **Long-polling, not WebSockets or SSE.** The browser asks "has anything changed
//! since generation N?" and the server holds the request open until something has.
//! That is instant like a push, needs no protocol beyond ordinary HTTP, and — unlike
//! a streamed response — completes, which is what makes it work at all: tiny_http
//! buffers a response until its body ends, so a body that never ends never reaches
//! the client. Long-polling was the version of this that worked.
use std::io;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use anyhow::Result;
use camino::{Utf8Path, Utf8PathBuf};
use tiny_http::{Header, Request, Response, Server, StatusCode};
use crate::site::{build_site, BuildOptions};
/// Where the browser subscribes for reload events. Namespaced so it cannot collide with
/// a real page.
pub const RELOAD_PATH: &str = "/__orgo/reload";
/// How long a poll waits before answering "nothing yet". Long enough that an idle tab is
/// nearly silent, short enough to stay under any proxy or browser idle timeout.
const POLL_TIMEOUT: Duration = Duration::from_secs(25);
/// The script injected into served HTML, carrying the generation the page was built
/// from.
///
/// Baking the generation in is what makes this race-free: if a rebuild lands between the
/// page being served and the first poll going out, the server answers immediately rather
/// than the tab sitting on stale content until the *next* edit.
fn reload_script(generation: u64) -> String {
format!(
"\n\n"
)
}
/// A build counter that event streams wait on.
#[derive(Default)]
struct BuildSignal {
generation: Mutex,
changed: Condvar,
}
impl BuildSignal {
fn bump(&self) {
*self.generation.lock().expect("build signal") += 1;
self.changed.notify_all();
}
}
/// Run the development server until interrupted.
pub fn run(
src: &Utf8Path,
out: &Utf8Path,
opts: &BuildOptions,
host: &str,
port: u16,
) -> Result<()> {
if !src.is_dir() {
anyhow::bail!("serve requires a source directory: serve -o ");
}
let report = build_site(src, out, opts)?;
let address = format!("{host}:{port}");
let server = Server::http(&address).map_err(|e| {
anyhow::anyhow!("cannot listen on {address}: {e}. Is something already using port {port}?")
})?;
let server = Arc::new(server);
let signal = Arc::new(BuildSignal::default());
let root: Utf8PathBuf = out.to_owned();
println!(
"serving {} page(s) from {out} at http://{address}/ — Ctrl-C to stop.",
report.pages.len()
);
// Rebuild in the background; the main thread serves.
{
let (src, out, opts, signal) = (
src.to_owned(),
out.to_owned(),
opts.clone(),
Arc::clone(&signal),
);
std::thread::spawn(move || {
let result = crate::watch::run_with(&src, &out, &opts, |built| {
// Reload on a *successful* rebuild only. Reloading onto a stale page
// because the build just failed tells the author nothing; the error is
// already on their terminal.
if built.is_ok() {
signal.bump();
}
});
if let Err(e) = result {
eprintln!("watch stopped: {e:#}");
}
});
}
// A thread per request. The volume is one developer's browser, and an event stream
// occupies its thread for as long as the tab is open — which a fixed pool would let
// starve everything else.
for request in server.incoming_requests() {
let root = root.clone();
let signal = Arc::clone(&signal);
std::thread::spawn(move || {
if let Err(e) = handle(request, &root, &signal) {
// A browser closing a tab mid-response is routine, not a problem.
if e.kind() != io::ErrorKind::BrokenPipe {
eprintln!("serve: {e}");
}
}
});
}
Ok(())
}
fn handle(request: Request, root: &Utf8Path, signal: &Arc) -> io::Result<()> {
let url = request.url().to_string();
if url.split(['?', '#']).next() == Some(RELOAD_PATH) {
let since = since_parameter(&url);
return serve_poll(request, signal, since);
}
match resolve(root, &url) {
Some(path) => serve_file(request, &path, signal),
None => request.respond(
Response::from_string("404 not found")
.with_status_code(StatusCode(404))
.with_header(header("Content-Type", "text/plain; charset=utf-8")),
),
}
}
fn serve_file(request: Request, path: &Utf8Path, signal: &Arc) -> io::Result<()> {
let Ok(bytes) = std::fs::read(path) else {
return request.respond(
Response::from_string("404 not found").with_status_code(StatusCode(404)),
);
};
let mime = mime_type(path);
let bytes = if mime.starts_with("text/html") {
let generation = *signal.generation.lock().expect("build signal");
inject_reload_script(&bytes, generation)
} else {
bytes
};
request.respond(
Response::from_data(bytes)
.with_header(header("Content-Type", mime))
// A dev server must never be cached, or an edit appears not to have landed.
.with_header(header("Cache-Control", "no-store")),
)
}
/// Put the reload script just before `