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 clap::{Parser, Subcommand};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "dreamstack")]
|
#[command(name = "dreamstack")]
|
||||||
|
|
@ -97,7 +99,7 @@ fn cmd_build(file: &Path, output: &Path) {
|
||||||
fs::write(&out_path, &html).unwrap();
|
fs::write(&out_path, &html).unwrap();
|
||||||
println!(" output: {}", out_path.display());
|
println!(" output: {}", out_path.display());
|
||||||
println!("✅ Build complete! ({} bytes)", html.len());
|
println!("✅ Build complete! ({} bytes)", html.len());
|
||||||
println!("");
|
println!();
|
||||||
println!(" Open in browser:");
|
println!(" Open in browser:");
|
||||||
println!(" file://{}", fs::canonicalize(&out_path).unwrap().display());
|
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) {
|
fn cmd_dev(file: &Path, port: u16) {
|
||||||
|
use notify::{Watcher, RecursiveMode};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
println!("🚀 DreamStack dev server");
|
println!("🚀 DreamStack dev server");
|
||||||
println!(" watching: {}", file.display());
|
println!(" watching: {}", file.display());
|
||||||
println!(" serving: http://localhost:{port}");
|
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) {
|
let source = match fs::read_to_string(file) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -122,36 +178,150 @@ fn cmd_dev(file: &Path, port: u16) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let html = match compile(&source) {
|
let start = Instant::now();
|
||||||
Ok(html) => html,
|
match compile(&source) {
|
||||||
Err(e) => {
|
Ok(html) => {
|
||||||
eprintln!("❌ Compile error: {e}");
|
let ms = start.elapsed().as_millis();
|
||||||
std::process::exit(1);
|
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();
|
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();
|
||||||
println!("✅ Server running at http://localhost:{port}");
|
println!("✅ Server running at http://localhost:{port}");
|
||||||
println!(" Press Ctrl+C to stop");
|
println!(" Press Ctrl+C to stop");
|
||||||
println!("");
|
println!();
|
||||||
|
|
||||||
for request in server.incoming_requests() {
|
for request in server.incoming_requests() {
|
||||||
// Re-compile on each request for dev mode (simple hot-reload)
|
let url = request.url().to_string();
|
||||||
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 response = tiny_http::Response::from_string(¤t_html)
|
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(
|
.with_header(
|
||||||
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
|
tiny_http::Header::from_bytes(
|
||||||
.unwrap(),
|
&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);
|
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 graph = ds_analyzer::SignalGraph::from_program(&program);
|
||||||
let views = ds_analyzer::SignalGraph::analyze_views(&program);
|
let views = ds_analyzer::SignalGraph::analyze_views(&program);
|
||||||
|
|
||||||
println!("");
|
println!();
|
||||||
println!(" 📊 Signal Graph:");
|
println!(" 📊 Signal Graph:");
|
||||||
for node in &graph.nodes {
|
for node in &graph.nodes {
|
||||||
let kind_str = match &node.kind {
|
let kind_str = match &node.kind {
|
||||||
ds_analyzer::SignalKind::Source => "source",
|
ds_analyzer::SignalKind::Source => "source",
|
||||||
ds_analyzer::SignalKind::Derived => "derived",
|
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();
|
let deps: Vec<&str> = node.dependencies.iter().map(|d| d.signal_name.as_str()).collect();
|
||||||
if deps.is_empty() {
|
if deps.is_empty() {
|
||||||
|
|
@ -209,7 +379,7 @@ fn cmd_check(file: &Path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("");
|
println!();
|
||||||
println!(" 🖼️ Views:");
|
println!(" 🖼️ Views:");
|
||||||
for view in &views {
|
for view in &views {
|
||||||
println!(" {} ({} bindings)", view.name, view.bindings.len());
|
println!(" {} ({} bindings)", view.name, view.bindings.len());
|
||||||
|
|
@ -235,14 +405,14 @@ fn cmd_check(file: &Path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let topo = graph.topological_order();
|
let topo = graph.topological_order();
|
||||||
println!("");
|
println!();
|
||||||
println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::<Vec<_>>());
|
println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::<Vec<_>>());
|
||||||
|
|
||||||
if errors == 0 {
|
if errors == 0 {
|
||||||
println!("");
|
println!();
|
||||||
println!("✅ No errors found");
|
println!("✅ No errors found");
|
||||||
} else {
|
} else {
|
||||||
println!("");
|
println!();
|
||||||
eprintln!("❌ {} error(s) found", errors);
|
eprintln!("❌ {} error(s) found", errors);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue