krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

20a54d84ca28c93ebb442f224df7398c348938dc

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T06:06:46Z

v0.14: serve command with live reload

`watch` rebuilt but left you to serve the output and press reload. This closes the loop:
build, watch, serve, and reload the browser when a rebuild lands.

Three decisions shape it:

Loopback by default. A development server serves unreviewed drafts off someone's laptop,
so reaching the local network is something you ask for with --host, never something you
get.

The reload script is injected on the way out, never written to disk. What you deploy is
the built site and it must not carry a dev server's JavaScript — there is a test that the
file on disk stays clean while the served response does not.

Long-polling rather than WebSockets or SSE. The browser asks "anything since generation
N?" and the server holds the request until there is; the generation the page was built
from is baked into the injected script, so a rebuild landing between serving a page and
its first poll is answered immediately rather than leaving the tab stale. A timeout
answers with the current generation, so a client whose counter is ahead of a restarted
server self-corrects instead of hanging forever.

SSE was the first attempt and did not work: tiny_http buffers a response until its body
ends, so a body that never ends never reaches the client — not one byte, not even the
headers. Long-polling uses tiny_http exactly as designed.

URL resolution is the server's security boundary, so it is a pure function with its own
tests rather than something only reachable through a running server. Paths are built
from accepted segments rather than normalized after joining, because it is far easier to
reason about a list that never contained a `..` than about removing one correctly.
Traversal via `..`, percent-encoded `..`, backslashes, absolute paths and embedded NULs
all resolve to nothing, verified both directly and against the running server.

Mutation-testing that guard was worth it twice: replacing `..`-rejection with
`..`-popping did *not* fail the tests, and should not have — a path built from an empty
vec cannot pop below its root, so that variant is equally safe. Treating `..` as an
ordinary segment does fail them, which is the mutation that matters, because the
textual `starts_with(root)` check alone would have let `out/../secret.txt` through.

All 182 incumbent URLs still reproduced.
 Cargo.lock     |  33 ++++++-
 Cargo.toml     |   3 +-
 README.md      |  33 ++++++-
 src/lib.rs     |   1 +
 src/main.rs    |  39 ++++++++
 src/serve.rs   | 300 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/watch.rs   |  15 ++-
 tests/serve.rs | 240 +++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 659 insertions(+), 5 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index f82aab3..52e225c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -85,6 +85,12 @@ version = "0.7.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
 
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
 [[package]]
 name = "autocfg"
 version = "1.5.1"
@@ -171,6 +177,12 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
 [[package]]
 name = "clap"
 version = "4.6.6"
@@ -401,6 +413,12 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
 [[package]]
 name = "iana-time-zone"
 version = "0.1.65"
@@ -657,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.13.0"
+version = "0.14.0"
 dependencies = [
  "anyhow",
  "blake3",
@@ -672,6 +690,7 @@ dependencies = [
  "serde_json",
  "syntect",
  "thiserror",
+ "tiny_http",
  "toml",
  "walkdir",
 ]
@@ -982,6 +1001,18 @@ dependencies = [
  "time-core",
 ]
 
+[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
 [[package]]
 name = "toml"
 version = "1.1.4+spec-1.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index e437d55..40d467a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.13.0"
+version = "0.14.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
@@ -33,6 +33,7 @@ thiserror = "2"
 rayon = "1.12.0"
 toml = "1.1.4"
 notify = "8.2.0"
+tiny_http = "0.12.0"
 
 [dev-dependencies]
 insta = { version = "1", features = ["json"] }
diff --git a/README.md b/README.md
index 5b8ba44..44db78b 100644
--- a/README.md
+++ b/README.md
@@ -356,6 +356,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
 | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** |
 | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** |
+| **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** |
 
 ### v0.2 in / out
 
@@ -452,6 +453,32 @@ types keep their content verbatim.
 (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: `
 lines.
 
+## Serving
+
+```bash
+cargo run -- serve my-site -o _site        # http://127.0.0.1:3000
+```
+
+Builds, watches, serves, and reloads the browser when a rebuild lands — the loop `watch`
+leaves half-open.
+
+- **Loopback by default.** A dev server serves unreviewed drafts off your laptop, so
+  reaching the local network is something you ask for with `--host 0.0.0.0`, never
+  something you get.
+- **The reload script is injected on the way out**, never written to disk. What you
+  deploy is the built site, and it must not carry a dev server's JavaScript.
+- **Long-polling, not WebSockets or SSE.** The browser asks "anything since generation
+  N?" and the server holds the request until there is. Instant like a push, no protocol
+  beyond ordinary HTTP, and no dependency. A streamed response would have been more
+  elegant and does not work: tiny_http buffers a response until its body ends, so a body
+  that never ends never reaches the client.
+- A reload only follows a **successful** rebuild. Reloading onto a stale page because the
+  build just failed tells you nothing; the error is already on your terminal.
+
+URL resolution is the server's security boundary and is written as a pure function with
+its own tests: `..`, percent-encoded `..`, backslashes, absolute paths and embedded NULs
+all resolve to nothing rather than to somewhere outside the output directory.
+
 ## Watching
 
 ```bash
@@ -636,20 +663,22 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
 line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax
 highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later),
 `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel
-PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
+PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `tiny_http` (the `serve`
+development server), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
 `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle.
 
 ## Build & test
 
 ```
 cargo build
-cargo test                                                # 142 tests
+cargo test                                                # 152 tests
 cargo run -- init my-site                                 # scaffold a new site
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
 cargo run -- audit fixtures/site                          # corpus audit (Phase 0)
 cargo run -- build fixtures/site -o _site --no-cache      # force a full rebuild
 cargo run -- watch fixtures/site -o _site                 # rebuild on filesystem events
+cargo run -- serve fixtures/site -o _site                 # ... and serve with live reload
 cargo run -- clean _site                                  # remove output + cache
 ```
 
diff --git a/src/lib.rs b/src/lib.rs
index bb0564f..8a959a3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -16,6 +16,7 @@ pub mod model;
 pub mod parser;
 pub mod render;
 pub mod resolve;
+pub mod serve;
 pub mod site;
 pub mod template;
 pub mod util;
diff --git a/src/main.rs b/src/main.rs
index 459b76d..c356fcc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -64,6 +64,27 @@ enum Command {
         #[arg(long)]
         drafts: bool,
     },
+    /// Serve the built site locally, rebuilding and reloading the browser on change.
+    Serve {
+        /// Source directory to build and watch.
+        input: Utf8PathBuf,
+        /// Output directory to serve.
+        #[arg(short, long)]
+        output: Utf8PathBuf,
+        /// Port to listen on.
+        #[arg(short, long, default_value_t = 3000)]
+        port: u16,
+        /// Address to bind. Defaults to loopback; set `0.0.0.0` to expose the server to
+        /// your network, which also exposes any drafts you are building.
+        #[arg(long, default_value = "127.0.0.1")]
+        host: String,
+        /// Include pages marked `#+DRAFT:`.
+        #[arg(long)]
+        drafts: bool,
+        /// Config file to use, overriding `org-ssg.toml` in the source directory.
+        #[arg(long, value_name = "FILE")]
+        config: Option<Utf8PathBuf>,
+    },
     /// Remove the build output directory (which holds the cache manifest).
     Clean {
         /// Output directory to remove.
@@ -145,6 +166,24 @@ fn main() -> Result<()> {
             print!("{}", org_ssg::audit::report(&result));
             Ok(())
         }
+        Command::Serve {
+            input,
+            output,
+            port,
+            host,
+            drafts,
+            config,
+        } => org_ssg::serve::run(
+            &input,
+            &output,
+            &BuildOptions {
+                drafts,
+                config_path: config,
+                ..Default::default()
+            },
+            &host,
+            port,
+        ),
         Command::Init { directory } => init(&directory),
         Command::Clean { output } => {
             if output.exists() {
diff --git a/src/serve.rs b/src/serve.rs
new file mode 100644
index 0000000..228c8b7
--- /dev/null
+++ b/src/serve.rs
@@ -0,0 +1,300 @@
+//! `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 = "/__org-ssg/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<script>(function p(n){{fetch(\"{RELOAD_PATH}?since=\"+n)\
+         .then(function(r){{return r.json()}})\
+         .then(function(g){{g>n?location.reload():p(g)}})\
+         .catch(function(){{setTimeout(function(){{p(n)}},1000)}})}})({generation})</script>\n"
+    )
+}
+
+/// A build counter that event streams wait on.
+#[derive(Default)]
+struct BuildSignal {
+    generation: Mutex<u64>,
+    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 <src-dir> -o <out-dir>");
+    }
+    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<BuildSignal>) -> 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<BuildSignal>) -> 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 `</body>`, or at the end if there is none.
+pub fn inject_reload_script(bytes: &[u8], generation: u64) -> Vec<u8> {
+    let Ok(text) = std::str::from_utf8(bytes) else {
+        return bytes.to_vec();
+    };
+    let script = reload_script(generation);
+    match text.rfind("</body>") {
+        Some(at) => format!("{}{script}{}", &text[..at], &text[at..]).into_bytes(),
+        None => format!("{text}{script}").into_bytes(),
+    }
+}
+
+/// Answer a poll: block until the build generation passes `since`, then report it.
+///
+/// A timeout answers with the *current* generation, which the client compares itself —
+/// so a slow answer is indistinguishable from a fast one and no event can be missed.
+fn serve_poll(request: Request, signal: &Arc<BuildSignal>, since: u64) -> io::Result<()> {
+    let guard = signal.generation.lock().expect("build signal");
+    let (guard, _) = signal
+        .changed
+        .wait_timeout_while(guard, POLL_TIMEOUT, |generation| *generation <= since)
+        .expect("build signal");
+    let generation = *guard;
+    drop(guard);
+
+    request.respond(
+        Response::from_string(generation.to_string())
+            .with_header(header("Content-Type", "application/json"))
+            .with_header(header("Cache-Control", "no-store")),
+    )
+}
+
+/// The `since=N` parameter of a poll request.
+pub fn since_parameter(url: &str) -> u64 {
+    url.split_once('?')
+        .map(|(_, query)| query)
+        .into_iter()
+        .flat_map(|query| query.split('&'))
+        .find_map(|pair| pair.strip_prefix("since="))
+        .and_then(|value| value.parse().ok())
+        .unwrap_or(0)
+}
+
+/// Map a request URL onto a file inside `root`, or `None` if it does not name one.
+///
+/// This is the server's security boundary, so it is a pure function with its own tests.
+/// A URL is attacker-controlled input even on a development server: `..` segments,
+/// percent-encoded `..`, absolute paths and backslashes all have to resolve to nothing
+/// rather than to somewhere outside the output directory.
+pub fn resolve(root: &Utf8Path, url: &str) -> Option<Utf8PathBuf> {
+    let path = url.split(['?', '#']).next().unwrap_or("");
+    let decoded = percent_decode(path);
+
+    // Build the path from scratch out of accepted segments. Normalizing a joined path
+    // afterwards is the version of this that has bugs: it is far easier to reason about
+    // a list that never contained a `..` than about removing one correctly.
+    let mut segments: Vec<&str> = Vec::new();
+    for segment in decoded.split(['/', '\\']) {
+        match segment {
+            "" | "." => {}
+            ".." => return None,
+            // A NUL or a path separator that survived decoding is not a filename.
+            s if s.contains('\0') => return None,
+            s => segments.push(s),
+        }
+    }
+
+    let mut candidate = root.to_owned();
+    for segment in &segments {
+        candidate.push(segment);
+    }
+    // Directories, and the bare root, serve their index.
+    if decoded.ends_with('/') || segments.is_empty() || candidate.is_dir() {
+        candidate.push("index.html");
+    }
+    // Defence in depth: whatever the segment logic did, the result must be inside root.
+    if !candidate.starts_with(root) {
+        return None;
+    }
+    candidate.is_file().then_some(candidate)
+}
+
+/// Decode `%XX` escapes. `+` is left alone: it means a space in a query string, not in a
+/// path, and turning `a+b.html` into `a b.html` would break a real filename.
+fn percent_decode(input: &str) -> String {
+    let bytes = input.as_bytes();
+    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'%' && i + 2 < bytes.len() {
+            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
+            if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
+                out.push(byte);
+                i += 3;
+                continue;
+            }
+        }
+        out.push(bytes[i]);
+        i += 1;
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+fn mime_type(path: &Utf8Path) -> &'static str {
+    match path.extension().unwrap_or("").to_ascii_lowercase().as_str() {
+        "html" | "htm" => "text/html; charset=utf-8",
+        "css" => "text/css; charset=utf-8",
+        "js" => "text/javascript; charset=utf-8",
+        "json" => "application/json",
+        "xml" | "rss" | "atom" => "application/xml; charset=utf-8",
+        "txt" => "text/plain; charset=utf-8",
+        "svg" => "image/svg+xml",
+        "png" => "image/png",
+        "jpg" | "jpeg" => "image/jpeg",
+        "gif" => "image/gif",
+        "webp" => "image/webp",
+        "avif" => "image/avif",
+        "ico" => "image/x-icon",
+        "woff2" => "font/woff2",
+        "woff" => "font/woff",
+        "ttf" => "font/ttf",
+        "pdf" => "application/pdf",
+        _ => "application/octet-stream",
+    }
+}
+
+fn header(name: &str, value: &str) -> Header {
+    Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("static header is valid")
+}
diff --git a/src/watch.rs b/src/watch.rs
index 6756f44..e881fa2 100644
--- a/src/watch.rs
+++ b/src/watch.rs
@@ -150,6 +150,17 @@ fn is_editor_scratch(name: &str) -> bool {
 
 /// Build once, then rebuild whenever the source changes. Runs until interrupted.
 pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
+    run_with(src, out, opts, |_| {})
+}
+
+/// As [`run`], calling `on_rebuild` after each rebuild attempt — which is how `serve`
+/// learns that it has something new to tell the browser.
+pub fn run_with(
+    src: &Utf8Path,
+    out: &Utf8Path,
+    opts: &BuildOptions,
+    mut on_rebuild: impl FnMut(&Result<crate::site::SiteReport>),
+) -> Result<()> {
     if !src.is_dir() {
         anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
     }
@@ -186,7 +197,8 @@ pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
         }
 
         let summary = summarize(&changed);
-        match build_site(src, out, opts) {
+        let result = build_site(src, out, opts);
+        match &result {
             Ok(report) => println!(
                 "{summary}: {} rendered, {} cached",
                 report.rendered.len(),
@@ -196,6 +208,7 @@ pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
             // half-saved file, and the next keystroke fixes it.
             Err(e) => eprintln!("{summary}: build failed: {e:#}"),
         }
+        on_rebuild(&result);
     }
 }
 
diff --git a/tests/serve.rs b/tests/serve.rs
new file mode 100644
index 0000000..309925f
--- /dev/null
+++ b/tests/serve.rs
@@ -0,0 +1,240 @@
+//! `serve`: URL resolution, reload-script injection, and one live server.
+//!
+//! `resolve` is the server's security boundary. A URL is attacker-controlled input even
+//! on a development server — a page someone is previewing can contain a link, an image
+//! or a fetch to anything — so it gets tested as the pure function it deliberately is,
+//! rather than only through a running server.
+
+use std::io::{Read, Write};
+use std::net::TcpStream;
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::time::{Duration, Instant};
+
+use camino::{Utf8Path, Utf8PathBuf};
+
+use org_ssg::serve::{inject_reload_script, resolve, since_parameter};
+use org_ssg::site::BuildOptions;
+
+fn tmpdir(tag: &str) -> Utf8PathBuf {
+    static N: AtomicU32 = AtomicU32::new(0);
+    let n = N.fetch_add(1, Ordering::Relaxed);
+    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
+        .expect("utf-8 temp dir")
+        .join(format!("org-ssg-serve-{}-{tag}-{n}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&base);
+    std::fs::create_dir_all(&base).unwrap();
+    base
+}
+
+/// An output tree to serve.
+fn write_output(out: &Utf8PathBuf) {
+    std::fs::create_dir_all(out.join("blog")).unwrap();
+    std::fs::write(out.join("index.html"), "<html><body>home</body></html>").unwrap();
+    std::fs::write(out.join("blog/index.html"), "<html><body>blog</body></html>").unwrap();
+    std::fs::write(out.join("syntax.css"), "body{}").unwrap();
+    std::fs::write(out.join("a file.html"), "<html><body>spaced</body></html>").unwrap();
+}
+
+// ---------------------------------------------------------------------------
+// URL resolution
+// ---------------------------------------------------------------------------
+
+#[test]
+fn urls_resolve_to_files_and_directory_indexes() {
+    let out = tmpdir("resolve");
+    write_output(&out);
+
+    let at = |url: &str| resolve(&out, url).map(|p| p.strip_prefix(&out).unwrap().to_string());
+    assert_eq!(at("/"), Some("index.html".into()), "the root serves its index");
+    assert_eq!(at("/index.html"), Some("index.html".into()));
+    assert_eq!(at("/blog/"), Some("blog/index.html".into()), "a directory serves its index");
+    assert_eq!(at("/blog"), Some("blog/index.html".into()), "even without the slash");
+    assert_eq!(at("/syntax.css"), Some("syntax.css".into()));
+    assert_eq!(at("/index.html?v=1#frag"), Some("index.html".into()), "query and fragment");
+    assert_eq!(at("/a%20file.html"), Some("a file.html".into()), "percent-decoded");
+    assert_eq!(at("/nope.html"), None, "a file that does not exist");
+}
+
+/// The one that matters. A dev server sits on a laptop with a home directory behind it.
+#[test]
+fn no_url_can_escape_the_output_directory() {
+    let root = tmpdir("traversal");
+    let out = root.join("out");
+    write_output(&out);
+    // A file next to the output that must stay unreachable.
+    std::fs::write(root.join("secret.txt"), "private").unwrap();
+
+    for attack in [
+        "/../secret.txt",
+        "/../../etc/passwd",
+        "/blog/../../secret.txt",
+        "/%2e%2e/secret.txt",
+        "/%2E%2E/secret.txt",
+        "/..%2fsecret.txt",
+        "/....//secret.txt",
+        "/\\../secret.txt",
+        "//../secret.txt",
+        "/./../secret.txt",
+        "/blog/%2e%2e/%2e%2e/secret.txt",
+    ] {
+        assert_eq!(resolve(&out, attack), None, "{attack} must not resolve");
+    }
+    // And the file really was reachable by its true path, so the test is not vacuous.
+    assert!(root.join("secret.txt").is_file());
+}
+
+/// A percent-encoded NUL is a classic way to truncate a path in a C-backed API.
+#[test]
+fn embedded_nul_bytes_are_rejected() {
+    let out = tmpdir("nul");
+    write_output(&out);
+    assert_eq!(resolve(&out, "/index.html%00.txt"), None);
+    assert_eq!(resolve(&out, "/%00"), None);
+}
+
+/// `+` means a space in a query string, not in a path — decoding it would break the
+/// perfectly ordinary filename `c++.html`.
+#[test]
+fn plus_is_not_decoded_as_a_space() {
+    let out = tmpdir("plus");
+    write_output(&out);
+    std::fs::write(out.join("c++.html"), "<html><body>cpp</body></html>").unwrap();
+    assert_eq!(
+        resolve(&out, "/c++.html").map(|p| p.strip_prefix(&out).unwrap().to_string()),
+        Some("c++.html".into())
+    );
+}
+
+#[test]
+fn the_poll_parameter_is_read_from_the_query() {
+    assert_eq!(since_parameter("/__org-ssg/reload?since=7"), 7);
+    assert_eq!(since_parameter("/__org-ssg/reload?x=1&since=42"), 42);
+    assert_eq!(since_parameter("/__org-ssg/reload"), 0, "absent means start from zero");
+    assert_eq!(since_parameter("/__org-ssg/reload?since=nope"), 0, "unparseable means zero");
+}
+
+// ---------------------------------------------------------------------------
+// Reload script injection
+// ---------------------------------------------------------------------------
+
+/// The built site is what gets deployed. A dev server's JavaScript must never be in it,
+/// which is why injection happens on the way out rather than at build time.
+#[test]
+fn the_reload_script_is_injected_before_the_closing_body_tag() {
+    let page = b"<html><body><p>hi</p></body></html>";
+    let served = String::from_utf8(inject_reload_script(page, 3)).unwrap();
+
+    assert!(served.contains("<p>hi</p>"), "content is preserved");
+    assert!(served.contains("})(3)"), "the generation is baked in: {served}");
+    let script_at = served.find("<script>").unwrap();
+    let body_at = served.rfind("</body>").unwrap();
+    assert!(script_at < body_at, "the script goes inside the body: {served}");
+}
+
+/// A fragment with no `</body>` — a partial, or a hand-written page — still gets it.
+#[test]
+fn injection_falls_back_to_appending() {
+    let served = String::from_utf8(inject_reload_script(b"<p>bare</p>", 1)).unwrap();
+    assert!(served.starts_with("<p>bare</p>"));
+    assert!(served.contains("<script>"));
+}
+
+/// Binary content that happens to be served as HTML must not be corrupted into garbage.
+#[test]
+fn non_utf8_content_is_passed_through_untouched() {
+    let bytes = vec![0xff, 0xfe, 0x00, 0x42];
+    assert_eq!(inject_reload_script(&bytes, 1), bytes);
+}
+
+// ---------------------------------------------------------------------------
+// A live server
+// ---------------------------------------------------------------------------
+
+/// Minimal HTTP client: send a request, return the whole response.
+fn get(port: u16, path: &str, timeout: Duration) -> Option<String> {
+    let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?;
+    stream.set_read_timeout(Some(timeout)).ok()?;
+    write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").ok()?;
+    let mut response = Vec::new();
+    stream.read_to_end(&mut response).ok()?;
+    Some(String::from_utf8_lossy(&response).into_owned())
+}
+
+fn start_server(src: &Utf8Path, out: &Utf8Path) -> u16 {
+    // Unique per test as well as per process: these tests run in parallel, and two of
+    // them sharing a port means one silently queries the other's site.
+    static NEXT: AtomicU32 = AtomicU32::new(0);
+    let port = 20000 + ((std::process::id() % 10000) as u16) + NEXT.fetch_add(1, Ordering::Relaxed) as u16;
+    let (s, o) = (src.to_owned(), out.to_owned());
+    std::thread::spawn(move || {
+        let _ = org_ssg::serve::run(&s, &o, &BuildOptions::default(), "127.0.0.1", port);
+    });
+    let deadline = Instant::now() + Duration::from_secs(20);
+    while Instant::now() < deadline {
+        if get(port, "/", Duration::from_millis(500)).is_some() {
+            return port;
+        }
+        std::thread::sleep(Duration::from_millis(100));
+    }
+    panic!("server did not start on port {port}");
+}
+
+/// Serve a real site, and confirm an edit both rebuilds and answers a waiting poll —
+/// which together are the whole point of the command.
+#[test]
+fn serving_a_site_reloads_the_browser_when_a_source_changes() {
+    let root = tmpdir("live");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
+    // Output outside the source, so the test's own writes cannot be mistaken for edits.
+    let out = root.join("out");
+    let port = start_server(&src, &out);
+
+    let home = get(port, "/", Duration::from_secs(5)).expect("a response");
+    assert!(home.contains("200 OK"), "{home}");
+    assert!(home.contains("First version."), "the page is served: {home}");
+    assert!(home.contains("__org-ssg/reload"), "with the reload script: {home}");
+    assert!(
+        !std::fs::read_to_string(out.join("index.html")).unwrap().contains("__org-ssg"),
+        "but the file on disk stays clean"
+    );
+
+    // A poll for a generation we already have must block, not answer immediately.
+    let poller = std::thread::spawn(move || {
+        let started = Instant::now();
+        let body = get(port, "/__org-ssg/reload?since=0", Duration::from_secs(30));
+        (started.elapsed(), body)
+    });
+    std::thread::sleep(Duration::from_millis(400));
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
+
+    let (waited, body) = poller.join().expect("poller");
+    let body = body.expect("poll response");
+    assert!(
+        waited >= Duration::from_millis(300),
+        "the poll should have waited for the edit, not returned at once ({waited:?})"
+    );
+    assert!(body.trim_end().ends_with('1'), "it reports the new generation: {body}");
+
+    let updated = get(port, "/", Duration::from_secs(5)).expect("a response");
+    assert!(updated.contains("Second version."), "and the rebuild is served: {updated}");
+}
+
+/// A traversal attempt against the running server, not just the resolver.
+#[test]
+fn the_running_server_refuses_to_escape_its_root() {
+    let root = tmpdir("livetraversal");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nBody.\n").unwrap();
+    std::fs::write(root.join("secret.txt"), "private").unwrap();
+    let out = root.join("out");
+    let port = start_server(&src, &out);
+
+    for attack in ["/../secret.txt", "/%2e%2e/secret.txt", "/../../etc/passwd"] {
+        let response = get(port, attack, Duration::from_secs(5)).expect("a response");
+        assert!(response.contains("404"), "{attack} should 404: {response}");
+        assert!(!response.contains("private"), "{attack} leaked the file: {response}");
+    }
+}