Rust library to generate static websites
at fix/misc-errors 284 lines 12 kB view raw
1pub(crate) mod server; 2 3mod build; 4mod filterer; 5 6use notify::{ 7 EventKind, RecursiveMode, 8 event::{CreateKind, ModifyKind, RemoveKind}, 9}; 10use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 11use quanta::Instant; 12use server::WebSocketMessage; 13use std::{fs, path::Path}; 14use tokio::{ 15 signal, 16 sync::{broadcast, mpsc::channel}, 17 task::JoinHandle, 18}; 19use tracing::{error, info}; 20 21use crate::dev::build::BuildManager; 22 23pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 24 let start_time = Instant::now(); 25 info!(name: "dev", "Preparing dev environment…"); 26 27 let (sender_websocket, _) = broadcast::channel::<WebSocketMessage>(100); 28 29 // Create build manager (it will create its own status state internally) 30 let build_manager = BuildManager::new(sender_websocket.clone()); 31 32 // Do initial build 33 info!(name: "build", "Doing initial build…"); 34 let initial_build_success = build_manager.do_initial_build().await?; 35 36 // Set up file watching with debouncer 37 let (tx, mut rx) = channel::<DebounceEventResult>(1000); 38 39 let directories = fs::read_dir(cwd)? 40 .filter_map(|entry| entry.ok()) 41 .filter(|entry| entry.path().is_dir()) 42 .filter(|entry| { 43 let path = entry.path(); 44 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); 45 !matches!(file_name, "target" | ".git" | "dist") 46 }) 47 .map(|entry| entry.path()) 48 .collect::<Vec<_>>(); 49 50 let mut debouncer = new_debouncer( 51 std::time::Duration::from_millis(100), 52 None, 53 move |result: DebounceEventResult| { 54 tx.blocking_send(result).unwrap_or(()); 55 }, 56 )?; 57 58 // Watch the root directly both for changes to files like Cargo.toml and for new directories 59 debouncer.watch(cwd, RecursiveMode::NonRecursive)?; 60 61 // It'd seems like it'd be much easier to just watch recursively from cwd, but the problem is that this ends up 62 // watching a looooooot of files that we don't want to watch (target directory, dist directory, .git directory, etc.) which is causing about a million issues in Notify. 63 // The fork of Notify we use has support for filtering while watching, but it doesn't seem to work super well in practice. 64 // So instead we just watch the top-level directories (excluding known ones to ignore) and then add/remove watches for new/deleted directories as needed. 65 for dir in &directories { 66 debouncer.watch(dir, RecursiveMode::Recursive)?; 67 } 68 69 let mut web_server_thread: Option<tokio::task::JoinHandle<()>> = None; 70 71 // If initial build succeeded, start web server immediately 72 if initial_build_success { 73 info!(name: "dev", "Starting web server..."); 74 web_server_thread = Some(tokio::spawn(server::start_dev_web_server( 75 start_time, 76 sender_websocket.clone(), 77 host, 78 port, 79 None, 80 build_manager.current_status(), 81 ))); 82 } 83 84 // Clone build manager for the file watcher task 85 let build_manager_watcher = build_manager.clone(); 86 let sender_websocket_watcher = sender_websocket.clone(); 87 88 let file_watcher_task = tokio::spawn(async move { 89 let mut dev_server_started = initial_build_success; 90 let mut dev_server_handle: Option<JoinHandle<()>> = None; 91 92 loop { 93 tokio::select! { 94 // Handle file system events 95 result = rx.recv() => { 96 let Some(result) = result else { 97 break; // Channel closed 98 }; 99 100 match result { 101 Ok(events) => { 102 // TODO: Handle rescan events, I don't fully understand the implication of them yet 103 // some issues: 104 // - https://github.com/notify-rs/notify/issues/434 105 // - https://github.com/notify-rs/notify/issues/412 106 107 let should_rebuild = events.iter().any(should_rebuild_for_event); 108 109 // If new folder are created or removed, add/remove watches as needed 110 for event in &events { 111 if let EventKind::Create(CreateKind::Folder) = event.kind { 112 for path in &event.paths { 113 if should_watch_path(path) { 114 if let Err(e) = debouncer.watch(path, RecursiveMode::Recursive) { 115 error!(name: "watch", "Failed to add watch for new directory {:?}: {}", path, e); 116 } else { 117 info!(name: "watch", "Added watch for new directory {:?}", path); 118 } 119 } 120 } 121 } 122 123 // TODO: This doesn't seem to always work, sometimes removed folders are considered renames (maybe because of trash?), but it's fine I think 124 if let EventKind::Remove(RemoveKind::Folder) = event.kind { 125 for path in &event.paths { 126 if should_watch_path(path) { 127 if let Err(e) = debouncer.unwatch(path) { 128 error!(name: "watch", "Failed to remove watch for directory {:?}: {}", path, e); 129 } else { 130 info!(name: "watch", "Removed watch for directory {:?}", path); 131 } 132 } 133 } 134 } 135 } 136 137 if should_rebuild { 138 if !dev_server_started { 139 // Initial build failed, retry it 140 info!(name: "watch", "Files changed, retrying initial build..."); 141 let start_time = Instant::now(); 142 match build_manager_watcher.do_initial_build().await { 143 Ok(true) => { 144 info!(name: "build", "Initial build succeeded! Starting web server..."); 145 dev_server_started = true; 146 147 dev_server_handle = 148 Some(tokio::spawn(server::start_dev_web_server( 149 start_time, 150 sender_websocket_watcher.clone(), 151 host, 152 port, 153 None, 154 build_manager_watcher.current_status(), 155 ))); 156 } 157 Ok(false) => { 158 // Still failing, continue waiting 159 } 160 Err(e) => { 161 error!(name: "build", "Failed to retry initial build: {}", e); 162 } 163 } 164 } else { 165 // Normal rebuild - spawn in background so file watcher can continue 166 info!(name: "watch", "Files changed, rebuilding..."); 167 let build_manager_clone = build_manager_watcher.clone(); 168 tokio::spawn(async move { 169 match build_manager_clone.start_build().await { 170 Ok(_) => { 171 // Build completed (success or failure already logged) 172 } 173 Err(e) => { 174 error!(name: "build", "Failed to start build: {}", e); 175 } 176 } 177 }); 178 } 179 } 180 } 181 Err(errors) => { 182 for error in errors { 183 error!(name: "watch", "Watch error: {}", error); 184 } 185 } 186 } 187 } 188 // Monitor dev server - if it ends, file watcher ends too 189 _ = async { 190 if let Some(handle) = &mut dev_server_handle { 191 handle.await 192 } else { 193 std::future::pending().await // Never resolves if no dev server 194 } 195 } => { 196 break; 197 } 198 } 199 } 200 }); 201 202 // Wait for either the web server, file watcher, or shutdown signal 203 tokio::select! { 204 _ = shutdown_signal() => { 205 info!(name: "dev", "Shutting down dev environment..."); 206 } 207 _ = async { 208 if let Some(web_server) = web_server_thread { 209 tokio::select! { 210 _ = web_server => {}, 211 _ = file_watcher_task => {}, 212 } 213 } else { 214 // No web server started yet, just wait for file watcher 215 // If it started the web server, it'll also close itself if the web server ends 216 file_watcher_task.await.unwrap(); 217 } 218 } => {} 219 } 220 Ok(()) 221} 222 223fn should_rebuild_for_event(event: &DebouncedEvent) -> bool { 224 event.paths.iter().any(|path| { 225 should_watch_path(path) 226 && match event.kind { 227 // Only rebuild on actual content modifications, not metadata changes 228 EventKind::Modify(ModifyKind::Data(_)) => true, 229 EventKind::Modify(ModifyKind::Name(_)) => true, 230 EventKind::Modify(ModifyKind::Any) => true, 231 EventKind::Modify(ModifyKind::Other) => true, 232 // Skip metadata-only changes (permissions, timestamps, etc.) 233 EventKind::Modify(ModifyKind::Metadata(_)) => false, 234 // Include file creation and removal 235 EventKind::Create(_) => true, 236 EventKind::Remove(_) => true, 237 // Skip other event types 238 _ => false, 239 } 240 }) 241} 242 243fn should_watch_path(path: &Path) -> bool { 244 // Skip .DS_Store files 245 if let Some(file_name) = path.file_name() 246 && file_name == ".DS_Store" 247 { 248 return false; 249 } 250 251 // Skip dist and target directories, normally ignored by the watcher, but just in case 252 if path 253 .ancestors() 254 .any(|p| p.ends_with("dist") || p.ends_with("target") || p.ends_with(".git")) 255 { 256 return false; 257 } 258 259 true 260} 261 262async fn shutdown_signal() { 263 let ctrl_c = async { 264 signal::ctrl_c() 265 .await 266 .expect("failed to install Ctrl+C handler"); 267 }; 268 269 #[cfg(unix)] 270 let terminate = async { 271 signal::unix::signal(signal::unix::SignalKind::terminate()) 272 .expect("failed to install signal handler") 273 .recv() 274 .await; 275 }; 276 277 #[cfg(not(unix))] 278 let terminate = std::future::pending::<()>(); 279 280 tokio::select! { 281 _ = ctrl_c => {}, 282 _ = terminate => {}, 283 } 284}