diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index 9ad7afc..3d934b2 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -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#" + +"#; + +fn inject_hmr(html: &str) -> String { + // Inject the HMR script just before + if let Some(pos) = html.rfind("") { + format!("{}{}{}", &html[..pos], HMR_CLIENT_SCRIPT, &html[pos..]) + } else { + // No 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#" + +

── COMPILE ERROR ──

+
{e}
+"# + ); + *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| { + 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#" + +

── COMPILE ERROR ──

+
{e}
+"# + ); + *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!("
Compile error:\n{e}
") - }) - } else { - html.clone() - }; + let url = request.url().to_string(); - let response = tiny_http::Response::from_string(¤t_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::>()); if errors == 0 { - println!(""); + println!(); println!("✅ No errors found"); } else { - println!(""); + println!(); eprintln!("❌ {} error(s) found", errors); std::process::exit(1); }