feat: dev server with file watching + poll-based HMR

- File watcher thread using notify crate (100ms debounce)
- Auto-recompiles .ds files on change with timed output
- Poll-based HMR: client fetches /__hmr every 500ms for version
- Injects HMR script into compiled HTML before </body>
- Error pages rendered with DreamStack styling on compile failure
- Version counter shared between watcher and HTTP server via AtomicU64
This commit is contained in:
enzotar 2026-02-25 01:01:59 -08:00
parent bbdeb6b82b
commit c9b1913a57

View file

@ -8,6 +8,8 @@
use clap::{Parser, Subcommand};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}};
use std::time::{Duration, Instant};
#[derive(Parser)]
#[command(name = "dreamstack")]
@ -97,7 +99,7 @@ fn cmd_build(file: &Path, output: &Path) {
fs::write(&out_path, &html).unwrap();
println!(" output: {}", out_path.display());
println!("✅ Build complete! ({} bytes)", html.len());
println!("");
println!();
println!(" Open in browser:");
println!(" file://{}", fs::canonicalize(&out_path).unwrap().display());
}
@ -108,12 +110,66 @@ fn cmd_build(file: &Path, output: &Path) {
}
}
/// HMR client script injected into every page served by `dreamstack dev`.
/// Uses Server-Sent Events to receive reload notifications from the dev server.
const HMR_CLIENT_SCRIPT: &str = r#"
<script>
// ── DreamStack HMR (poll-based) ─────────────
(function() {
let currentVersion = null;
let polling = false;
async function poll() {
if (polling) return;
polling = true;
try {
const res = await fetch('/__hmr');
const version = await res.text();
if (currentVersion === null) {
currentVersion = version;
console.log('[DS HMR] 🟢 watching (v' + version + ')');
} else if (version !== currentVersion) {
console.log('[DS HMR] 🔄 change detected (v' + currentVersion + ' v' + version + '), reloading...');
location.reload();
return;
}
} catch(e) {
// server down — retry silently
}
polling = false;
}
setInterval(poll, 500);
poll();
})();
</script>
"#;
fn inject_hmr(html: &str) -> String {
// Inject the HMR script just before </body>
if let Some(pos) = html.rfind("</body>") {
format!("{}{}{}", &html[..pos], HMR_CLIENT_SCRIPT, &html[pos..])
} else {
// No </body> tag — just append
format!("{html}{HMR_CLIENT_SCRIPT}")
}
}
fn cmd_dev(file: &Path, port: u16) {
use notify::{Watcher, RecursiveMode};
use std::sync::mpsc;
use std::thread;
println!("🚀 DreamStack dev server");
println!(" watching: {}", file.display());
println!(" serving: http://localhost:{port}");
println!("");
println!();
// Shared state: compiled HTML + version counter
let version = Arc::new(AtomicU64::new(1));
let compiled_html = Arc::new(Mutex::new(String::new()));
// Initial compile
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
@ -122,36 +178,150 @@ fn cmd_dev(file: &Path, port: u16) {
}
};
let html = match compile(&source) {
Ok(html) => html,
Err(e) => {
eprintln!("❌ Compile error: {e}");
std::process::exit(1);
let start = Instant::now();
match compile(&source) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let html_with_hmr = inject_hmr(&html);
*compiled_html.lock().unwrap() = html_with_hmr;
println!("✅ Compiled in {ms}ms ({} bytes)", html.len());
}
};
Err(e) => {
eprintln!("⚠️ Compile error: {e}");
let error_html = format!(
r#"<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body {{ background: #0a0a0f; color: #ef4444; font-family: 'JetBrains Mono', monospace; padding: 40px; }}
pre {{ white-space: pre-wrap; line-height: 1.7; }}
h2 {{ color: #f87171; margin-bottom: 16px; }}
</style></head><body>
<h2> COMPILE ERROR </h2>
<pre>{e}</pre>
</body></html>"#
);
*compiled_html.lock().unwrap() = inject_hmr(&error_html);
}
}
// Simple HTTP server
// ── File Watcher Thread ─────────────────────────
let file_path = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
let watch_dir = file_path.parent().unwrap().to_path_buf();
let watch_file = file_path.clone();
let v_watcher = Arc::clone(&version);
let html_watcher = Arc::clone(&compiled_html);
thread::spawn(move || {
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
}).expect("Failed to create file watcher");
watcher.watch(&watch_dir, RecursiveMode::NonRecursive)
.expect("Failed to watch directory");
println!("👁 Watching {} for changes", watch_dir.display());
println!();
// Debounce: coalesce rapid events
let mut last_compile = Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
// Only recompile for .ds file changes
let dominated = event.paths.iter().any(|p| {
p == &watch_file ||
p.extension().map_or(false, |ext| ext == "ds")
});
if !dominated { continue; }
// Debounce: skip if less than 100ms since last compile
if last_compile.elapsed() < Duration::from_millis(100) {
continue;
}
// Recompile
if let Ok(src) = fs::read_to_string(&watch_file) {
let start = Instant::now();
match compile(&src) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1;
*html_watcher.lock().unwrap() = inject_hmr(&html);
println!("🔄 Recompiled in {ms}ms (v{new_version}, {} bytes)", html.len());
last_compile = Instant::now();
}
Err(e) => {
let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1;
let error_html = format!(
r#"<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body {{ background: #0a0a0f; color: #ef4444; font-family: 'JetBrains Mono', monospace; padding: 40px; }}
pre {{ white-space: pre-wrap; line-height: 1.7; }}
h2 {{ color: #f87171; margin-bottom: 16px; }}
</style></head><body>
<h2> COMPILE ERROR </h2>
<pre>{e}</pre>
</body></html>"#
);
*html_watcher.lock().unwrap() = inject_hmr(&error_html);
eprintln!("❌ v{new_version}: {e}");
last_compile = Instant::now();
}
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// No events — loop and check again
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
});
// ── HTTP Server ─────────────────────────────────
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();
println!("✅ Server running at http://localhost:{port}");
println!(" Press Ctrl+C to stop");
println!("");
println!();
for request in server.incoming_requests() {
// Re-compile on each request for dev mode (simple hot-reload)
let current_html = if let Ok(src) = fs::read_to_string(file) {
compile(&src).unwrap_or_else(|e| {
format!("<html><body><pre style='color:red'>Compile error:\n{e}</pre></body></html>")
})
} else {
html.clone()
};
let url = request.url().to_string();
let response = tiny_http::Response::from_string(&current_html)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
.unwrap(),
);
let _ = request.respond(response);
if url == "/__hmr" {
// Version endpoint for HMR polling
let v = version.load(Ordering::SeqCst);
let response = tiny_http::Response::from_string(format!("{v}"))
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/plain"[..],
).unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(
&b"Cache-Control"[..],
&b"no-cache, no-store"[..],
).unwrap(),
);
let _ = request.respond(response);
} else {
// Serve the compiled HTML
let html = compiled_html.lock().unwrap().clone();
let response = tiny_http::Response::from_string(&html)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
).unwrap(),
);
let _ = request.respond(response);
}
}
}
@ -193,13 +363,13 @@ fn cmd_check(file: &Path) {
let graph = ds_analyzer::SignalGraph::from_program(&program);
let views = ds_analyzer::SignalGraph::analyze_views(&program);
println!("");
println!();
println!(" 📊 Signal Graph:");
for node in &graph.nodes {
let kind_str = match &node.kind {
ds_analyzer::SignalKind::Source => "source",
ds_analyzer::SignalKind::Derived => "derived",
ds_analyzer::SignalKind::Handler { event, .. } => "handler",
ds_analyzer::SignalKind::Handler { .. } => "handler",
};
let deps: Vec<&str> = node.dependencies.iter().map(|d| d.signal_name.as_str()).collect();
if deps.is_empty() {
@ -209,7 +379,7 @@ fn cmd_check(file: &Path) {
}
}
println!("");
println!();
println!(" 🖼️ Views:");
for view in &views {
println!(" {} ({} bindings)", view.name, view.bindings.len());
@ -235,14 +405,14 @@ fn cmd_check(file: &Path) {
}
let topo = graph.topological_order();
println!("");
println!();
println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::<Vec<_>>());
if errors == 0 {
println!("");
println!();
println!("✅ No errors found");
} else {
println!("");
println!();
eprintln!("{} error(s) found", errors);
std::process::exit(1);
}