AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/dev_reload.rs

Latest change 51aec02c44fe7eb9438a160c80c03b0b12141f7a - feat: akurai-router core + dev SSE live-reload (frontend-platform increment) by Ólafur Búi Ólafsson

//! Dev-only live reload over Server-Sent Events.
//!
//! `akurai dev` watches the project's source dirs on a background thread; when
//! any file changes it bumps a version counter and wakes every connected
//! browser, which reloads. The browser side is one injected `<script>` using
//! the native `EventSource` — no client dependency, no build step. This whole
//! module is inert under `akurai serve`: production never starts the watcher,
//! never serves `/_dev/reload`, and never injects the script.

use akurai_http::{sse, Reply};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, SystemTime};
use std::{fs, thread};

/// The `<script>` injected into dev HTML responses: connect to the reload
/// stream and reload the page on a `reload` event.
pub const SNIPPET: &str = "<script>\n\
(()=>{const es=new EventSource('/_dev/reload');\
es.addEventListener('reload',()=>location.reload());})();\n\
</script>";

/// A shared "something changed" signal that connections block on.
#[derive(Clone)]
pub struct LiveReload {
    inner: Arc<Inner>,
}

struct Inner {
    version: Mutex<u64>,
    changed: Condvar,
}

impl LiveReload {
    fn new() -> LiveReload {
        LiveReload {
            inner: Arc::new(Inner {
                version: Mutex::new(0),
                changed: Condvar::new(),
            }),
        }
    }

    /// The current change version.
    fn current(&self) -> u64 {
        *self.inner.version.lock().unwrap()
    }

    /// Record a change and wake all waiting connections.
    fn bump(&self) {
        *self.inner.version.lock().unwrap() += 1;
        self.inner.changed.notify_all();
    }

    /// Block until the version moves past `last` (or `timeout` elapses), then
    /// return the current version. The timeout lets the caller send heartbeats.
    fn wait_past(&self, last: u64, timeout: Duration) -> u64 {
        let guard = self.inner.version.lock().unwrap();
        let (guard, _) = self
            .inner
            .changed
            .wait_timeout_while(guard, timeout, |v| *v == last)
            .unwrap();
        *guard
    }
}

/// Start watching `dirs` on a background thread and return the signal handle.
/// Polls modification times every 300ms — dependency-free and good enough for
/// human-speed editing.
pub fn start(dirs: Vec<PathBuf>) -> LiveReload {
    let live = LiveReload::new();
    let watcher = live.clone();
    thread::Builder::new()
        .name("akurai-watch".into())
        .spawn(move || {
            let mut last = signature(&dirs);
            loop {
                thread::sleep(Duration::from_millis(300));
                let now = signature(&dirs);
                if now != last {
                    last = now;
                    watcher.bump();
                }
            }
        })
        .expect("failed to spawn the file watcher thread");
    live
}

/// The SSE endpoint browsers connect to. Emits a `reload` event on every change
/// and a comment heartbeat otherwise so the connection (and any proxy) stays up.
pub fn endpoint(live: &LiveReload) -> Reply {
    let live = live.clone();
    sse(move |mut sink| {
        let mut last = live.current();
        loop {
            let version = live.wait_past(last, Duration::from_secs(15));
            if version != last {
                last = version;
                sink.event("reload", &version.to_string())?;
            } else {
                sink.comment("keep-alive")?;
            }
        }
    })
}

/// Insert the live-reload script just before `</body>` (or append it if there
/// is no body tag).
pub fn inject(html: &str) -> String {
    match html.rfind("</body>") {
        Some(idx) => {
            let mut out = String::with_capacity(html.len() + SNIPPET.len());
            out.push_str(&html[..idx]);
            out.push_str(SNIPPET);
            out.push_str(&html[idx..]);
            out
        }
        None => format!("{html}{SNIPPET}"),
    }
}

/// A cheap fingerprint of the watched tree: (file count, newest mtime). A change
/// to either — an edit (mtime up), an add (count up), or a delete (count down)
/// — moves it and triggers a reload.
fn signature(dirs: &[PathBuf]) -> (usize, SystemTime) {
    let mut count = 0;
    let mut newest = SystemTime::UNIX_EPOCH;
    for dir in dirs {
        walk(dir, &mut count, &mut newest);
    }
    (count, newest)
}

fn walk(dir: &Path, count: &mut usize, newest: &mut SystemTime) {
    let Ok(entries) = fs::read_dir(dir) else {
        return; // a missing/unreadable dir simply contributes nothing
    };
    for entry in entries.flatten() {
        let Ok(meta) = entry.metadata() else { continue };
        if meta.is_dir() {
            walk(&entry.path(), count, newest);
        } else {
            *count += 1;
            if let Ok(modified) = meta.modified() {
                if modified > *newest {
                    *newest = modified;
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn injects_before_body_close() {
        let out = inject("<html><body><h1>hi</h1></body></html>");
        assert!(out.contains("EventSource('/_dev/reload')"));
        assert!(out.find("EventSource").unwrap() < out.find("</body>").unwrap());
    }

    #[test]
    fn appends_when_no_body_tag() {
        let out = inject("<p>fragment</p>");
        assert!(out.starts_with("<p>fragment</p>"));
        assert!(out.contains("EventSource"));
    }

    #[test]
    fn bump_advances_version_and_wakes_waiter() {
        let lr = LiveReload::new();
        assert_eq!(lr.current(), 0);
        lr.bump();
        assert_eq!(lr.current(), 1);
        // already past `last=0`, so wait returns immediately with the new value
        assert_eq!(lr.wait_past(0, Duration::from_millis(50)), 1);
    }
}