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    /// The loopback port the server is bound to. Used by the Host-header
56    /// allowlist to defeat DNS-rebinding (see `handlers::loopback_guard`).
57    pub port: u16,
58    pub reload_tx: broadcast::Sender<ReloadEvent>,
59    /// Shared "last editor write" clock (millis since `epoch`) used to suppress
60    /// the duplicate watcher rebuild after an in-browser save. `0` = never.
61    self_write_at_ms: Arc<AtomicU64>,
62    /// Monotonic reference instant for `self_write_at_ms`.
63    epoch: Instant,
64}
65
66impl AppState {
67    /// Construct an `AppState`. Initializes the self-write suppression clock.
68    pub fn new(
69        project_root: PathBuf,
70        out_dir: PathBuf,
71        docs_dir: PathBuf,
72        port: u16,
73        reload_tx: broadcast::Sender<ReloadEvent>,
74    ) -> Self {
75        Self {
76            project_root,
77            out_dir,
78            docs_dir,
79            port,
80            reload_tx,
81            self_write_at_ms: Arc::new(AtomicU64::new(0)),
82            epoch: Instant::now(),
83        }
84    }
85
86    /// Record that the editor just wrote a doc on disk. The watcher will skip
87    /// the next change it sees within [`SELF_WRITE_SUPPRESS`] as a duplicate.
88    pub fn note_self_write(&self) {
89        // Store elapsed + 1 so the "never written" sentinel (0) is unambiguous
90        // even when the write happens at elapsed == 0.
91        let ms = self.epoch.elapsed().as_millis() as u64 + 1;
92        self.self_write_at_ms.store(ms, Ordering::SeqCst);
93    }
94
95    /// Whether a watcher event right now should be suppressed as the echo of a
96    /// recent editor write. Consumes the marker so only one rebuild is skipped.
97    pub fn take_self_write_suppression(&self) -> bool {
98        let marked = self.self_write_at_ms.swap(0, Ordering::SeqCst);
99        if marked == 0 {
100            return false;
101        }
102        let now = self.epoch.elapsed().as_millis() as u64 + 1;
103        now.saturating_sub(marked) <= SELF_WRITE_SUPPRESS.as_millis() as u64
104    }
105}
106
107/// Errors from [`resolve_doc_path`]; each maps to an HTTP status (see `handlers`).
108#[derive(Debug, PartialEq, Eq)]
109pub enum PathGuardError {
110    /// Not a `.md` path. (400)
111    NotMarkdown,
112    /// Absolute path or leading `/`. (400)
113    Absolute,
114    /// `..` component, backslash, or a realpath that escapes `docs/`. (403)
115    Traversal,
116    /// Resolves to something that is not a regular file. (400)
117    NotAFile,
118    /// In-bounds but the file does not exist. (404)
119    NotFound,
120}
121
122/// Resolve a client-supplied doc-relative path (e.g. `"guide/intro.md"`) to a
123/// canonical absolute path strictly inside `docs_dir`, or reject. `docs_dir`
124/// MUST already be canonicalized by the caller. Layered checks mirror the
125/// original `validateRepoDocPath`:
126///
127/// 1. backslash -> `Traversal`; absolute / leading `/` -> `Absolute`.
128/// 2. strip leading `./`; any `..` component -> `Traversal`; empty -> `Traversal`.
129/// 3. require a `.md` suffix -> else `NotMarkdown`.
130/// 4. lexical: `docs_dir.join(rel)` must stay under `docs_dir`.
131/// 5. `canonicalize()`: missing -> `NotFound`; realpath escaping `docs_dir`
132///    (symlink escape) -> `Traversal`.
133/// 6. the canonical target must be a regular file -> else `NotAFile`.
134pub fn resolve_doc_path(docs_dir: &Path, rel: &str) -> Result<PathBuf, PathGuardError> {
135    // (1) gross-shape rejections.
136    if rel.contains('\\') {
137        return Err(PathGuardError::Traversal);
138    }
139    if rel.starts_with('/') || Path::new(rel).is_absolute() {
140        return Err(PathGuardError::Absolute);
141    }
142
143    // (2) normalize + component scan.
144    let trimmed = rel.strip_prefix("./").unwrap_or(rel);
145    if trimmed.is_empty() {
146        return Err(PathGuardError::Traversal);
147    }
148    let mut kept: Vec<&str> = Vec::new();
149    for comp in trimmed.split('/') {
150        match comp {
151            "" | "." => continue, // collapse `//` and `.` segments
152            ".." => return Err(PathGuardError::Traversal),
153            other => kept.push(other),
154        }
155    }
156    if kept.is_empty() {
157        return Err(PathGuardError::Traversal);
158    }
159    let normalized = kept.join("/");
160
161    // (3) extension whitelist (markdown-only; the TS guard also allowed `.svx`,
162    // which the Rust rewrite does not support).
163    if !normalized.ends_with(".md") {
164        return Err(PathGuardError::NotMarkdown);
165    }
166
167    // (4) lexical containment.
168    let candidate = docs_dir.join(&normalized);
169    if !candidate.starts_with(docs_dir) {
170        return Err(PathGuardError::Traversal);
171    }
172
173    // (5) realpath check (catches symlink escapes).
174    // Any canonicalize failure (missing path, permission, etc.) is reported as
175    // NotFound for a dev write endpoint — the client cannot act on finer detail.
176    let canonical = match candidate.canonicalize() {
177        Ok(p) => p,
178        Err(_) => return Err(PathGuardError::NotFound),
179    };
180    if !canonical.starts_with(docs_dir) {
181        return Err(PathGuardError::Traversal);
182    }
183
184    // (6) must be a regular file (not a dir, not a symlink-to-dir).
185    let meta = match std::fs::symlink_metadata(&canonical) {
186        Ok(m) => m,
187        Err(_) => return Err(PathGuardError::NotFound),
188    };
189    if !meta.is_file() {
190        return Err(PathGuardError::NotAFile);
191    }
192
193    Ok(canonical)
194}
195
196/// The dev-only HTML injected before `</body>` of every served page: the editor
197/// css, a tiny inline script that inserts the dev-only edit icon into the
198/// topbar's `.docgen-btn-strip` (next to the diff/full-width controls — so the
199/// static `docgen build` output never contains it), the editor island panel
200/// element, the vendored CodeMirror UMD scripts (loaded in dependency order:
201/// core -> xml mode -> overlay addon -> markdown mode), the editor island JS,
202/// and the live-reload client.
203///
204/// These non-`defer` scripts execute at parse time — before the page's deferred
205/// Alpine script fires `alpine:init` — so `editor.js`'s `docgen.island(...)`
206/// registration lands before Alpine runs the registry. Injected ONLY by the dev
207/// server (`inject_dev_html`); never written to disk by `docgen build`.
208const DEV_HTML: &str = r#"
209<script>(function(){
210  var strip=document.querySelector('.docgen-btn-strip');
211  if(!strip)return;
212  // The full-page CM6 editor lives at /edit/<slug>; the pencil links to it.
213  var slug=location.pathname.replace(/^\/+|\/+$/g,'');
214  if(slug==='')slug='index';
215  var a=document.createElement('a');
216  a.className='icon-only docgen-ctl--edit';
217  a.setAttribute('href','/edit/'+slug);
218  a.setAttribute('aria-label','Edit this page');
219  a.setAttribute('title','Edit this page (dev)');
220  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>';
221  var fw=strip.querySelector('.docgen-ctl--fullwidth');
222  if(fw)strip.insertBefore(a,fw);else strip.appendChild(a);
223})();</script>
224<script src="/__docgen/livereload.js"></script>
225"#;
226
227/// Post-process a served HTML body: inject the reload-client script + editor
228/// toggle + editor island scripts/styles immediately before `</body>`. Dev-only;
229/// never run by `docgen build`. Pure string fn so it is unit-testable.
230pub fn inject_dev_html(html: &str) -> String {
231    match html.rfind("</body>") {
232        Some(i) => {
233            let mut s = String::with_capacity(html.len() + DEV_HTML.len());
234            s.push_str(&html[..i]);
235            s.push_str(DEV_HTML);
236            s.push_str(&html[i..]);
237            s
238        }
239        // Graceful: append if there is no closing body tag.
240        None => format!("{html}{DEV_HTML}"),
241    }
242}
243
244/// The loopback bind address for the dev server. NEVER `0.0.0.0` — the dev
245/// server (editor + write endpoint) must not be reachable off-host.
246pub fn dev_bind_addr(port: u16) -> std::net::SocketAddr {
247    std::net::SocketAddr::from(([127, 0, 0, 1], port))
248}
249
250/// Build the axum router (NO listener) for the given state. Split out so handler
251/// tests can `oneshot` requests without binding a port.
252pub fn router(state: AppState) -> Router {
253    handlers::router(state)
254}
255
256/// Rebuild the site into `state.out_dir` (Dev mode + dev-asset emission), then
257/// broadcast a reload. Called on every debounced fs change AND after a successful
258/// editor write. Returns `Err` only on a hard build failure; the caller logs and
259/// keeps serving the last good build. This guarantee holds because
260/// [`docgen_build::build_site`] is atomic — it stages into a temp dir and only
261/// swaps `out_dir` on full success, so a failed rebuild leaves the served dir
262/// (the previous good build) untouched rather than torn down.
263pub fn rebuild_and_reload(state: &AppState) -> anyhow::Result<()> {
264    let start = std::time::Instant::now();
265    let outcome = docgen_build::build_site(&docgen_build::BuildOptions {
266        project_root: &state.project_root,
267        out_dir: &state.out_dir,
268        mode: docgen_build::BuildMode::Dev,
269    })?;
270    // The dev-only extra step build_site never performs: emit CodeMirror + the
271    // editor island + the reload client into the served dir.
272    docgen_assets::emit(&docgen_assets::dev_assets(), &state.out_dir)?;
273
274    // Ignore "no subscribers" — a reload with nobody listening is fine.
275    let _ = state.reload_tx.send(ReloadEvent::Reload);
276    tracing::info!(
277        pages = outcome.page_count,
278        elapsed_ms = start.elapsed().as_millis(),
279        "rebuilt + reloaded"
280    );
281    Ok(())
282}
283
284/// Run the dev server: initial build, spawn the debounced watcher, bind
285/// `127.0.0.1`, serve until Ctrl-C. Blocking entry point the `docgen dev` CLI
286/// arm calls. Owns its own tokio runtime so the `docgen` bin's `main` stays a
287/// plain `fn main() -> Result<()>`.
288pub fn serve(opts: DevOptions) -> anyhow::Result<()> {
289    // Idempotent: a second `serve` in-process (tests) won't panic.
290    let _ = tracing_subscriber::fmt()
291        .with_env_filter(
292            tracing_subscriber::EnvFilter::try_from_default_env()
293                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
294        )
295        .try_init();
296
297    let runtime = tokio::runtime::Builder::new_multi_thread()
298        .enable_all()
299        .build()?;
300    runtime.block_on(serve_async(opts))
301}
302
303async fn serve_async(opts: DevOptions) -> anyhow::Result<()> {
304    let project_root = opts.project_root.clone();
305    let docs_dir = project_root.join("docs");
306    let docs_canon = docs_dir.canonicalize().unwrap_or_else(|_| docs_dir.clone());
307
308    // A process-owned output dir; kept alive for the whole run, auto-cleaned.
309    let out_tmp = tempfile::tempdir()?;
310    let out_dir = out_tmp.path().to_path_buf();
311
312    let (reload_tx, _rx) = broadcast::channel(16);
313    let state = AppState::new(
314        project_root,
315        out_dir,
316        docs_canon.clone(),
317        opts.port,
318        reload_tx,
319    );
320
321    // Initial build (Dev mode + dev assets).
322    rebuild_and_reload(&state)?;
323
324    // Spawn the debounced fs watcher; it rebuilds + reloads on every change.
325    let _watcher = watch::spawn_watcher(state.clone(), &docs_canon)?;
326
327    let addr = dev_bind_addr(opts.port);
328    let listener = tokio::net::TcpListener::bind(addr).await?;
329    tracing::info!("docgen dev server: http://{addr}");
330    if opts.open {
331        let _ = open_browser(&format!("http://{addr}"));
332    }
333
334    // Clone the reload sender before `state` moves into the router; the shutdown
335    // hook uses it to terminate the open live-reload SSE streams so the graceful
336    // drain can complete (otherwise the 15s keep-alive holds the process open).
337    let shutdown_tx = state.reload_tx.clone();
338    axum::serve(listener, router(state))
339        .with_graceful_shutdown(async move {
340            let _ = tokio::signal::ctrl_c().await;
341            let _ = shutdown_tx.send(ReloadEvent::Shutdown);
342        })
343        .await?;
344    Ok(())
345}
346
347/// Best-effort browser open (dev convenience; failures are non-fatal).
348fn open_browser(url: &str) -> std::io::Result<()> {
349    #[cfg(target_os = "macos")]
350    let cmd = "open";
351    #[cfg(all(unix, not(target_os = "macos")))]
352    let cmd = "xdg-open";
353    #[cfg(windows)]
354    let cmd = "explorer";
355    std::process::Command::new(cmd).arg(url).spawn().map(|_| ())
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn inject_dev_html_inserts_before_body() {
364        let out = inject_dev_html("<html><body><p>hi</p></body></html>");
365        // Reload client + the edit pencil (a link to the /edit/<slug> CM6 editor).
366        for marker in ["__docgen/livereload.js", "docgen-ctl--edit", "/edit/"] {
367            assert!(out.contains(marker), "missing injected marker {marker}");
368        }
369        // Every injected marker precedes the closing body tag.
370        let body = out.rfind("</body>").unwrap();
371        for marker in ["__docgen/livereload.js", "docgen-ctl--edit"] {
372            assert!(
373                out.find(marker).unwrap() < body,
374                "{marker} not before </body>"
375            );
376        }
377        // The static build never contains the dev edit affordance.
378        assert!(!"<html><body></body></html>".contains("docgen-ctl--edit"));
379    }
380
381    #[test]
382    fn inject_dev_html_no_body_appends() {
383        let out = inject_dev_html("<p>no body tag here</p>");
384        assert!(out.contains("__docgen/livereload.js"));
385        assert!(out.contains("docgen-ctl--edit"));
386    }
387
388    #[test]
389    fn self_write_suppression_is_one_shot() {
390        let (tx, _rx) = broadcast::channel(4);
391        let state = AppState::new(
392            PathBuf::from("/x"),
393            PathBuf::from("/x/out"),
394            PathBuf::from("/x/docs"),
395            4321,
396            tx,
397        );
398        // No write yet -> nothing to suppress.
399        assert!(!state.take_self_write_suppression());
400        // After a self-write, exactly one watcher event is suppressed.
401        state.note_self_write();
402        assert!(state.take_self_write_suppression());
403        assert!(!state.take_self_write_suppression());
404    }
405
406    #[test]
407    fn bind_addr_is_loopback() {
408        assert!(dev_bind_addr(4321).ip().is_loopback());
409        assert_eq!(dev_bind_addr(4321).port(), 4321);
410    }
411}