Skip to main content

plana_dev_server/
lib.rs

1//! Dev-mode static file server with hot-reload.
2//!
3//! Provides `DevFileServer` — a self-contained axum service that serves
4//! files from a directory and watches for changes using `notify`.
5//! When vite rebuilds the frontend, the next request automatically picks
6//! up the new files. No process restart needed.
7
8mod cache;
9
10use std::path::PathBuf;
11
12use axum::Router;
13use axum::body::Body;
14use axum::http::{Request, StatusCode};
15use axum::response::Response;
16use notify::Watcher;
17use std::sync::Arc;
18
19use cache::Cache;
20
21pub struct DevFileServerConfig {
22    pub root_dir: PathBuf,
23    /// When true, unknown paths fall back to `index.html` (SPA mode).
24    pub spa_fallback: bool,
25}
26
27pub struct DevFileServer {
28    root: PathBuf,
29    spa: bool,
30    cache: Arc<Cache>,
31}
32
33impl DevFileServer {
34    pub fn new(config: DevFileServerConfig) -> Self {
35        let cache = Arc::new(Cache::new(&config.root_dir));
36        let s = Self {
37            root: config.root_dir,
38            spa: config.spa_fallback,
39            cache,
40        };
41        s.spawn_watcher();
42        s
43    }
44
45    fn spawn_watcher(&self) {
46        let root = self.root.clone();
47        let cache = self.cache.clone();
48
49        std::thread::spawn(move || {
50            let (tx, rx) = std::sync::mpsc::channel();
51            let mut watcher =
52                notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
53                    if let Ok(e) = res {
54                        let _ = tx.send(e);
55                    }
56                })
57                .ok();
58
59            if let Some(ref mut w) = watcher {
60                let _ = w.watch(&root, notify::RecursiveMode::Recursive);
61            }
62
63            for event in rx {
64                for path in &event.paths {
65                    if let Ok(rel) = path.strip_prefix(&root) {
66                        let key = rel.to_string_lossy().replace('\\', "/");
67                        if !key.is_empty() {
68                            cache.invalidate(&key);
69                        }
70                    }
71                }
72            }
73        });
74    }
75
76    pub fn into_router(self) -> Router {
77        let cache = self.cache.clone();
78        let spa = self.spa;
79
80        Router::new().fallback(axum::routing::any(move |req: Request<Body>| {
81            let cache = cache.clone();
82            async move {
83                let path = req.uri().path().trim_start_matches('/');
84                serve_file(&cache, path, spa).await
85            }
86        }))
87    }
88}
89
90async fn serve_file(cache: &Cache, path: &str, spa: bool) -> Response {
91    let key = if path.is_empty() { "index.html" } else { path };
92
93    if let Some(resp) = cache.get(key) {
94        return resp;
95    }
96
97    // Try with trailing index.html for directory paths
98    let index_key = format!("{}/index.html", key.trim_end_matches('/'));
99    if let Some(resp) = cache.get(&index_key) {
100        return resp;
101    }
102
103    // SPA fallback — serve index.html for unknown paths that look like routes
104    if spa
105        && !path.contains('.')
106        && let Some(resp) = cache.get("index.html")
107    {
108        return resp;
109    }
110
111    let mut resp = Response::new(Body::from("Not Found"));
112    *resp.status_mut() = StatusCode::NOT_FOUND;
113    resp
114}