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:
parent
bbdeb6b82b
commit
c9b1913a57
1 changed files with 200 additions and 30 deletions
|
|
@ -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(¤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::<Vec<_>>());
|
||||
|
||||
if errors == 0 {
|
||||
println!("");
|
||||
println!();
|
||||
println!("✅ No errors found");
|
||||
} else {
|
||||
println!("");
|
||||
println!();
|
||||
eprintln!("❌ {} error(s) found", errors);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue