Skip to main content

docgen_server/
lib.rs

1//! `docgen-server` is the **dev-only** server behind `docgen dev`: an axum app
2//! (bound to `127.0.0.1` only) that serves the built site, watches `docs/` for
3//! changes (debounced), rebuilds via [`docgen_build::build_site`], pushes a
4//! live-reload signal over SSE, and exposes a path-guarded markdown write
5//! endpoint for the in-browser editor.
6//!
7//! Nothing in this crate ships in a static `docgen build` dist: the editor UI,
8//! the reload client, the write/SSE endpoints, and the vendored CodeMirror
9//! assets exist ONLY while this server runs.
10
11mod handlers;
12mod watch;
13
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use axum::Router;
20use tokio::sync::broadcast;
21
22/// Window after an editor-initiated write during which a watcher event for the
23/// same on-disk change is treated as a duplicate and skipped. Must comfortably
24/// cover the 200ms watcher debounce.
25const SELF_WRITE_SUPPRESS: Duration = Duration::from_millis(750);
26
27/// Dev-server configuration.
28pub struct DevOptions {
29    pub project_root: PathBuf,
30    /// Loopback port. Default 4321.
31    pub port: u16,
32    /// Open a browser on start (off in tests/CI). Default false.
33    pub open: bool,
34}
35
36/// One live-reload signal. Carried over the SSE channel.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ReloadEvent {
39    /// A rebuild finished; browsers should reload.
40    Reload,
41    /// The server is shutting down (Ctrl+C). Tells each live-reload SSE stream to
42    /// END, so axum's graceful shutdown can drain the otherwise-eternal keep-alive
43    /// connections instead of hanging on them.
44    Shutdown,
45}
46
47/// Shared, cheaply-clonable state behind every handler. `Clone` bumps the
48/// `Arc`/broadcast handle.
49#[derive(Clone)]
50pub struct AppState {
51    pub project_root: PathBuf,
52    pub out_dir: PathBuf,
53    /// Canonicalized `docs/` dir — the write-guard root.
54    pub docs_dir: PathBuf,
55    /// Site `base` path (e.g. `/docs`), normalized to a leading-slash, no-trailing-slash
56    /// form; empty when the site is served at the root. The built HTML prefixes every
57    /// asset/nav/wikilink URL with this, so the dev server must strip it off incoming
58    /// request paths before resolving them against `out_dir`.
59    pub base: String,
60    /// The loopback port the server is bound to. Used by the Host-header
61    /// allowlist to defeat DNS-rebinding (see `handlers::loopback_guard`).
62    pub port: u16,
63    pub reload_tx: broadcast::Sender<ReloadEvent>,
64    /// Shared "last editor write" clock (millis since `epoch`) used to suppress
65    /// the duplicate watcher rebuild after an in-browser save. `0` = never.
66    self_write_at_ms: Arc<AtomicU64>,
67    /// Monotonic reference instant for `self_write_at_ms`.
68    epoch: Instant,
69    /// The incremental build engine, shared (and serialized) across the watcher
70    /// thread and the editor-write handler so a single doc edit re-renders only
71    /// the changed page instead of the whole O(n²) site. `None` in handler tests
72    /// that construct an `AppState` without a live engine — those fall back to a
73    /// full [`docgen_build::build_site`] in [`rebuild_and_reload`].
74    engine: Option<Arc<std::sync::Mutex<docgen_build::DevState>>>,
75}
76
77impl AppState {
78    /// Construct an `AppState`. Initializes the self-write suppression clock.
79    pub fn new(
80        project_root: PathBuf,
81        out_dir: PathBuf,
82        docs_dir: PathBuf,
83        port: u16,
84        reload_tx: broadcast::Sender<ReloadEvent>,
85    ) -> Self {
86        Self {
87            project_root,
88            out_dir,
89            docs_dir,
90            base: String::new(),
91            port,
92            reload_tx,
93            self_write_at_ms: Arc::new(AtomicU64::new(0)),
94            epoch: Instant::now(),
95            engine: None,
96        }
97    }
98
99    /// Attach the incremental build engine. Builder-style so the `new()` call
100    /// sites (including tests) stay untouched. Only `serve_async` sets one.
101    pub fn with_engine(mut self, engine: docgen_build::DevState) -> Self {
102        self.engine = Some(Arc::new(std::sync::Mutex::new(engine)));
103        self
104    }
105
106    /// Set the site `base` path, normalized to a leading-slash, no-trailing-slash
107    /// form (`docs` / `/docs/` / `docs/` all become `/docs`; empty stays empty).
108    /// Builder-style so the existing `new()` call sites stay untouched.
109    pub fn with_base(mut self, base: &str) -> Self {
110        self.base = normalize_base(base);
111        self
112    }
113
114    /// Record that the editor just wrote a doc on disk. The watcher will skip
115    /// the next change it sees within [`SELF_WRITE_SUPPRESS`] as a duplicate.
116    pub fn note_self_write(&self) {
117        // Store elapsed + 1 so the "never written" sentinel (0) is unambiguous
118        // even when the write happens at elapsed == 0.
119        let ms = self.epoch.elapsed().as_millis() as u64 + 1;
120        self.self_write_at_ms.store(ms, Ordering::SeqCst);
121    }
122
123    /// Whether a watcher event right now should be suppressed as the echo of a
124    /// recent editor write. Consumes the marker so only one rebuild is skipped.
125    pub fn take_self_write_suppression(&self) -> bool {
126        let marked = self.self_write_at_ms.swap(0, Ordering::SeqCst);
127        if marked == 0 {
128            return false;
129        }
130        let now = self.epoch.elapsed().as_millis() as u64 + 1;
131        now.saturating_sub(marked) <= SELF_WRITE_SUPPRESS.as_millis() as u64
132    }
133}
134
135/// Normalize a configured `base` into a leading-slash, no-trailing-slash form
136/// for prefix matching: `""` -> `""`, `"docs"` / `"/docs/"` / `"docs/"` -> `"/docs"`.
137/// Re-exported from `docgen-config` so the dev server's request-path stripping
138/// and the build's URL prefixing share one canonicalization.
139pub use docgen_config::normalize_base;
140
141/// Strip the site `base` prefix from a (already percent-decoded) request path so
142/// it can be resolved against `out_dir`. `base` must be normalized
143/// (`normalize_base`). A request that does not fall under `base` is returned
144/// unchanged — the caller then resolves it normally (and likely 404s), matching
145/// production where the host only routes in-base requests to the site.
146///
147/// `/docs/x.css` -> `/x.css`; `/docs` and `/docs/` -> `/`; `/other` -> `/other`.
148pub fn strip_base<'a>(path: &'a str, base: &str) -> &'a str {
149    if base.is_empty() {
150        return path;
151    }
152    match path.strip_prefix(base) {
153        Some("") => "/",
154        Some(rest) if rest.starts_with('/') => rest,
155        _ => path,
156    }
157}
158
159/// Errors from [`resolve_doc_path`]; each maps to an HTTP status (see `handlers`).
160#[derive(Debug, PartialEq, Eq)]
161pub enum PathGuardError {
162    /// Not a `.md` path. (400)
163    NotMarkdown,
164    /// Absolute path or leading `/`. (400)
165    Absolute,
166    /// `..` component, backslash, or a realpath that escapes `docs/`. (403)
167    Traversal,
168    /// Resolves to something that is not a regular file. (400)
169    NotAFile,
170    /// In-bounds but the file does not exist. (404)
171    NotFound,
172}
173
174/// Resolve a client-supplied doc-relative path (e.g. `"guide/intro.md"`) to a
175/// canonical absolute path strictly inside `docs_dir`, or reject. `docs_dir`
176/// MUST already be canonicalized by the caller. Layered checks mirror the
177/// original `validateRepoDocPath`:
178///
179/// 1. backslash -> `Traversal`; absolute / leading `/` -> `Absolute`.
180/// 2. strip leading `./`; any `..` component -> `Traversal`; empty -> `Traversal`.
181/// 3. require a `.md` suffix -> else `NotMarkdown`.
182/// 4. lexical: `docs_dir.join(rel)` must stay under `docs_dir`.
183/// 5. `canonicalize()`: missing -> `NotFound`; realpath escaping `docs_dir`
184///    (symlink escape) -> `Traversal`.
185/// 6. the canonical target must be a regular file -> else `NotAFile`.
186pub fn resolve_doc_path(docs_dir: &Path, rel: &str) -> Result<PathBuf, PathGuardError> {
187    // (1) gross-shape rejections.
188    if rel.contains('\\') {
189        return Err(PathGuardError::Traversal);
190    }
191    if rel.starts_with('/') || Path::new(rel).is_absolute() {
192        return Err(PathGuardError::Absolute);
193    }
194
195    // (2) normalize + component scan.
196    let trimmed = rel.strip_prefix("./").unwrap_or(rel);
197    if trimmed.is_empty() {
198        return Err(PathGuardError::Traversal);
199    }
200    let mut kept: Vec<&str> = Vec::new();
201    for comp in trimmed.split('/') {
202        match comp {
203            "" | "." => continue, // collapse `//` and `.` segments
204            ".." => return Err(PathGuardError::Traversal),
205            other => kept.push(other),
206        }
207    }
208    if kept.is_empty() {
209        return Err(PathGuardError::Traversal);
210    }
211    let normalized = kept.join("/");
212
213    // (3) extension whitelist (markdown-only; the TS guard also allowed `.svx`,
214    // which the Rust rewrite does not support).
215    if !normalized.ends_with(".md") {
216        return Err(PathGuardError::NotMarkdown);
217    }
218
219    // (4) lexical containment.
220    let candidate = docs_dir.join(&normalized);
221    if !candidate.starts_with(docs_dir) {
222        return Err(PathGuardError::Traversal);
223    }
224
225    // (5) realpath check (catches symlink escapes).
226    // Any canonicalize failure (missing path, permission, etc.) is reported as
227    // NotFound for a dev write endpoint — the client cannot act on finer detail.
228    let canonical = match candidate.canonicalize() {
229        Ok(p) => p,
230        Err(_) => return Err(PathGuardError::NotFound),
231    };
232    if !canonical.starts_with(docs_dir) {
233        return Err(PathGuardError::Traversal);
234    }
235
236    // (6) must be a regular file (not a dir, not a symlink-to-dir).
237    let meta = match std::fs::symlink_metadata(&canonical) {
238        Ok(m) => m,
239        Err(_) => return Err(PathGuardError::NotFound),
240    };
241    if !meta.is_file() {
242        return Err(PathGuardError::NotAFile);
243    }
244
245    Ok(canonical)
246}
247
248/// The dev-only HTML injected before `</body>` of every served page: the editor
249/// css, a tiny inline script that inserts the dev-only edit icon into the
250/// topbar's `.docgen-btn-strip` (next to the diff/full-width controls — so the
251/// static `docgen build` output never contains it), the editor island panel
252/// element, the vendored CodeMirror UMD scripts (loaded in dependency order:
253/// core -> xml mode -> overlay addon -> markdown mode), the editor island JS,
254/// and the live-reload client.
255///
256/// These non-`defer` scripts execute at parse time — before the page's deferred
257/// Alpine script fires `alpine:init` — so `editor.js`'s `docgen.island(...)`
258/// registration lands before Alpine runs the registry. Injected ONLY by the dev
259/// server (`inject_dev_html`); never written to disk by `docgen build`.
260const DEV_HTML: &str = r#"
261<script>(function(){
262  var strip=document.querySelector('.docgen-btn-strip');
263  if(!strip)return;
264  // The full-page CM6 editor lives at /edit/<slug>; the pencil links to it.
265  var slug=location.pathname.replace(/^\/+|\/+$/g,'');
266  if(slug==='')slug='index';
267  var a=document.createElement('a');
268  a.className='icon-only docgen-ctl--edit';
269  a.setAttribute('href','/edit/'+slug);
270  a.setAttribute('aria-label','Edit this page');
271  a.setAttribute('title','Edit this page (dev)');
272  a.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
273  var fw=strip.querySelector('.docgen-ctl--fullwidth');
274  if(fw)strip.insertBefore(a,fw);else strip.appendChild(a);
275})();</script>
276<script src="/__docgen/livereload.js"></script>
277"#;
278
279/// Post-process a served HTML body: inject the reload-client script + editor
280/// toggle + editor island scripts/styles immediately before `</body>`. Dev-only;
281/// never run by `docgen build`. Pure string fn so it is unit-testable.
282pub fn inject_dev_html(html: &str) -> String {
283    match html.rfind("</body>") {
284        Some(i) => {
285            let mut s = String::with_capacity(html.len() + DEV_HTML.len());
286            s.push_str(&html[..i]);
287            s.push_str(DEV_HTML);
288            s.push_str(&html[i..]);
289            s
290        }
291        // Graceful: append if there is no closing body tag.
292        None => format!("{html}{DEV_HTML}"),
293    }
294}
295
296/// The loopback bind address for the dev server. NEVER `0.0.0.0` — the dev
297/// server (editor + write endpoint) must not be reachable off-host.
298pub fn dev_bind_addr(port: u16) -> std::net::SocketAddr {
299    std::net::SocketAddr::from(([127, 0, 0, 1], port))
300}
301
302/// Build the axum router (NO listener) for the given state. Split out so handler
303/// tests can `oneshot` requests without binding a port.
304pub fn router(state: AppState) -> Router {
305    handlers::router(state)
306}
307
308/// Rebuild the site into `state.out_dir` (Dev mode + dev-asset emission), then
309/// broadcast a reload. Called on every debounced fs change AND after a successful
310/// editor write. Returns `Err` only on a hard build failure; the caller logs and
311/// keeps serving the last good build.
312///
313/// When the incremental [`engine`](AppState::engine) is present (the live dev
314/// server), each rebuild re-renders only the doc(s) that actually changed and
315/// leaves the rest of the site untouched — turning a ~10s full rebuild of a large
316/// corpus into milliseconds. The dev-only assets (CodeMirror, editor island,
317/// reload client) are re-emitted only after a *full* rebuild, since the atomic
318/// staging swap that backs a full build wipes `out_dir`; an incremental rebuild
319/// writes pages in place and leaves the dev assets intact.
320///
321/// Without an engine (handler tests constructing a bare `AppState`), this falls
322/// back to a full atomic [`docgen_build::build_site`] + dev-asset emit — a failed
323/// rebuild leaves the served dir (the previous good build) untouched.
324pub fn rebuild_and_reload(state: &AppState) -> anyhow::Result<()> {
325    let start = std::time::Instant::now();
326
327    let (page_count, kind) = if let Some(engine) = &state.engine {
328        let mut engine = engine
329            .lock()
330            .unwrap_or_else(std::sync::PoisonError::into_inner);
331        let rebuilt = engine.rebuild()?;
332        // A full fallback re-creates `out_dir` from scratch, wiping the dev-only
333        // assets; re-emit them. The incremental path leaves them in place.
334        if rebuilt.kind == docgen_build::RebuildKind::Full {
335            docgen_assets::emit(&docgen_assets::dev_assets(), &state.out_dir)?;
336        }
337        (rebuilt.page_count, Some(rebuilt.kind))
338    } else {
339        let outcome = docgen_build::build_site(&docgen_build::BuildOptions {
340            project_root: &state.project_root,
341            out_dir: &state.out_dir,
342            mode: docgen_build::BuildMode::Dev,
343        })?;
344        docgen_assets::emit(&docgen_assets::dev_assets(), &state.out_dir)?;
345        (outcome.page_count, None)
346    };
347
348    // Ignore "no subscribers" — a reload with nobody listening is fine.
349    let _ = state.reload_tx.send(ReloadEvent::Reload);
350    tracing::info!(
351        pages = page_count,
352        kind = ?kind,
353        elapsed_ms = start.elapsed().as_millis(),
354        "rebuilt + reloaded"
355    );
356    Ok(())
357}
358
359/// Run the dev server: initial build, spawn the debounced watcher, bind
360/// `127.0.0.1`, serve until Ctrl-C. Blocking entry point the `docgen dev` CLI
361/// arm calls. Owns its own tokio runtime so the `docgen` bin's `main` stays a
362/// plain `fn main() -> Result<()>`.
363pub fn serve(opts: DevOptions) -> anyhow::Result<()> {
364    // Idempotent: a second `serve` in-process (tests) won't panic.
365    let _ = tracing_subscriber::fmt()
366        .with_env_filter(
367            tracing_subscriber::EnvFilter::try_from_default_env()
368                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
369        )
370        .try_init();
371
372    let runtime = tokio::runtime::Builder::new_multi_thread()
373        .enable_all()
374        .build()?;
375    runtime.block_on(serve_async(opts))
376}
377
378async fn serve_async(opts: DevOptions) -> anyhow::Result<()> {
379    let project_root = opts.project_root.clone();
380    let docs_dir = project_root.join("docs");
381    let docs_canon = docs_dir.canonicalize().unwrap_or_else(|_| docs_dir.clone());
382
383    // A process-owned output dir; kept alive for the whole run, auto-cleaned.
384    let out_tmp = tempfile::tempdir()?;
385    let out_dir = out_tmp.path().to_path_buf();
386
387    // The built HTML prefixes every URL with the *resolved* `base`; the server
388    // must strip that same prefix off incoming requests. Resolve it exactly as
389    // `build_site` does (DOCGEN_BASE / CI Pages env override docgen.toml) so dev
390    // and build never disagree — otherwise `DOCGEN_BASE=/x docgen dev`, or a dev
391    // run under CI, would serve links the request router can't strip and 404.
392    // A malformed config here is non-fatal for serving (the build step reports
393    // it) — fall back to "served at root".
394    let base = docgen_config::load(&project_root)
395        .map(|c| docgen_config::resolve_base(&c.base))
396        .unwrap_or_default();
397
398    let (reload_tx, _rx) = broadcast::channel(16);
399
400    // Initial full build (Dev mode), which also seeds the incremental engine.
401    // Subsequent rebuilds (watcher / editor write) go through the engine and
402    // re-render only what changed. The user accepts a slower first build for
403    // blazingly fast incremental updates.
404    let start = std::time::Instant::now();
405    let (engine, first) = docgen_build::DevState::initial(&project_root, &out_dir)?;
406    // Dev-only assets (editor island, CodeMirror, reload client) — emitted once
407    // after the initial build; the incremental path leaves them in place.
408    docgen_assets::emit(&docgen_assets::dev_assets(), &out_dir)?;
409    tracing::info!(
410        pages = first.page_count,
411        elapsed_ms = start.elapsed().as_millis(),
412        "initial build"
413    );
414
415    let state = AppState::new(
416        project_root,
417        out_dir,
418        docs_canon.clone(),
419        opts.port,
420        reload_tx,
421    )
422    .with_base(&base)
423    .with_engine(engine);
424
425    // Tell any (re)connecting browser the first build is ready.
426    let _ = state.reload_tx.send(ReloadEvent::Reload);
427
428    // Spawn the debounced fs watcher; it rebuilds + reloads on every change.
429    let _watcher = watch::spawn_watcher(state.clone(), &docs_canon)?;
430
431    let addr = dev_bind_addr(opts.port);
432    let listener = tokio::net::TcpListener::bind(addr).await?;
433    tracing::info!("docgen dev server: http://{addr}");
434    if opts.open {
435        let _ = open_browser(&format!("http://{addr}"));
436    }
437
438    // Clone the reload sender before `state` moves into the router; the shutdown
439    // hook uses it to terminate the open live-reload SSE streams so the graceful
440    // drain can complete (otherwise the 15s keep-alive holds the process open).
441    let shutdown_tx = state.reload_tx.clone();
442    axum::serve(listener, router(state))
443        .with_graceful_shutdown(async move {
444            let _ = tokio::signal::ctrl_c().await;
445            let _ = shutdown_tx.send(ReloadEvent::Shutdown);
446        })
447        .await?;
448    Ok(())
449}
450
451/// Best-effort browser open (dev convenience; failures are non-fatal).
452fn open_browser(url: &str) -> std::io::Result<()> {
453    #[cfg(target_os = "macos")]
454    let cmd = "open";
455    #[cfg(all(unix, not(target_os = "macos")))]
456    let cmd = "xdg-open";
457    #[cfg(windows)]
458    let cmd = "explorer";
459    std::process::Command::new(cmd).arg(url).spawn().map(|_| ())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn inject_dev_html_inserts_before_body() {
468        let out = inject_dev_html("<html><body><p>hi</p></body></html>");
469        // Reload client + the edit pencil (a link to the /edit/<slug> CM6 editor).
470        for marker in ["__docgen/livereload.js", "docgen-ctl--edit", "/edit/"] {
471            assert!(out.contains(marker), "missing injected marker {marker}");
472        }
473        // Every injected marker precedes the closing body tag.
474        let body = out.rfind("</body>").unwrap();
475        for marker in ["__docgen/livereload.js", "docgen-ctl--edit"] {
476            assert!(
477                out.find(marker).unwrap() < body,
478                "{marker} not before </body>"
479            );
480        }
481        // The static build never contains the dev edit affordance.
482        assert!(!"<html><body></body></html>".contains("docgen-ctl--edit"));
483    }
484
485    #[test]
486    fn inject_dev_html_no_body_appends() {
487        let out = inject_dev_html("<p>no body tag here</p>");
488        assert!(out.contains("__docgen/livereload.js"));
489        assert!(out.contains("docgen-ctl--edit"));
490    }
491
492    #[test]
493    fn self_write_suppression_is_one_shot() {
494        let (tx, _rx) = broadcast::channel(4);
495        let state = AppState::new(
496            PathBuf::from("/x"),
497            PathBuf::from("/x/out"),
498            PathBuf::from("/x/docs"),
499            4321,
500            tx,
501        );
502        // No write yet -> nothing to suppress.
503        assert!(!state.take_self_write_suppression());
504        // After a self-write, exactly one watcher event is suppressed.
505        state.note_self_write();
506        assert!(state.take_self_write_suppression());
507        assert!(!state.take_self_write_suppression());
508    }
509
510    #[test]
511    fn normalize_base_canonicalizes() {
512        assert_eq!(normalize_base(""), "");
513        assert_eq!(normalize_base("/"), "");
514        assert_eq!(normalize_base("docs"), "/docs");
515        assert_eq!(normalize_base("/docs"), "/docs");
516        assert_eq!(normalize_base("/docs/"), "/docs");
517        assert_eq!(normalize_base("docs/"), "/docs");
518        assert_eq!(normalize_base("/a/b"), "/a/b");
519    }
520
521    #[test]
522    fn strip_base_handles_prefix_and_misses() {
523        // Empty base: everything passes through unchanged.
524        assert_eq!(strip_base("/docgen.css", ""), "/docgen.css");
525        // In-base requests get the prefix removed.
526        assert_eq!(strip_base("/docs/docgen.css", "/docs"), "/docgen.css");
527        assert_eq!(strip_base("/docs/guide/intro", "/docs"), "/guide/intro");
528        // The base root itself maps to "/".
529        assert_eq!(strip_base("/docs", "/docs"), "/");
530        assert_eq!(strip_base("/docs/", "/docs"), "/");
531        // A path that merely shares a textual prefix is NOT in-base.
532        assert_eq!(strip_base("/docsxyz", "/docs"), "/docsxyz");
533        // Out-of-base requests are returned unchanged.
534        assert_eq!(strip_base("/other", "/docs"), "/other");
535    }
536
537    #[test]
538    fn bind_addr_is_loopback() {
539        assert!(dev_bind_addr(4321).ip().is_loopback());
540        assert_eq!(dev_bind_addr(4321).port(), 4321);
541    }
542}