Menu
Public repository / 91bd2a37654909c5a3aef6ddd614991bd378ccb8
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);
}
}