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