oxedyne/fe2o3/fe2o3_steel/src/srv/dev/refresh.rs
15.4 KiB, 21 runs
created by r1870400018:983, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Development mode functionality for automatic rebuilding and page refresh. |
| 2 | //! |
| 3 | //! # Development Strategy |
| 4 | //! The development mode operates in two stages: |
| 5 | //! |
| 6 | //! 1. Source File Processing: |
| 7 | //! - Monitors `www/src/js` and `www/src/styles` for changes. |
| 8 | //! - When JavaScript/TypeScript files change, rebundles to `www/public/js/bundle.js`. |
| 9 | //! - When SCSS files change, recompiles to `www/public/css/styles.css`. |
| 10 | //! |
| 11 | //! 2. Public File Monitoring: |
| 12 | //! - Watches `www/public` directory for any file changes. |
| 13 | //! - When bundled files are written or other public files change, notifies clients. |
| 14 | //! - Excludes temporary files (vim swaps, backups) from triggering refresh. |
| 15 | //! |
| 16 | //! This two-stage approach ensures that source changes trigger rebuilding first, |
| 17 | //! then the resulting file changes in public trigger browser refresh. |
| 18 | use crate::srv::dev::{ |
| 19 | js::{ |
| 20 | FileType, |
| 21 | JsBundle, |
| 22 | }, |
| 23 | sass::SassBundle, |
| 24 | }; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | |
| 28 | use std::{ |
| 29 | path::{ |
| 30 | Path, |
| 31 | PathBuf, |
| 32 | }, |
| 33 | sync::{ |
| 34 | Arc, |
| 35 | atomic::{ |
| 36 | AtomicBool, |
| 37 | Ordering, |
| 38 | }, |
| 39 | }, |
| 40 | time::Duration, |
| 41 | }; |
| 42 | |
| 43 | use notify::{ |
| 44 | RecommendedWatcher, |
| 45 | Watcher, |
| 46 | RecursiveMode, |
| 47 | Event, |
| 48 | EventKind, |
| 49 | event::{ |
| 50 | ModifyKind, |
| 51 | CreateKind, |
| 52 | RemoveKind, |
| 53 | }, |
| 54 | }; |
| 55 | use tokio::sync::broadcast; |
| 56 | |
| 57 | /// Handles WebSocket connections from browser clients in development mode to support |
| 58 | /// automatic rebuilding and page refresh when files change. Each client that connects to the |
| 59 | /// /dev-refresh endpoint gets a DevRefreshHandler instance. When the file watcher |
| 60 | /// detects changes, all connected clients receive a refresh message. The handler |
| 61 | /// ignores any incoming messages from clients as this is a one-way notification |
| 62 | /// system. |
| 63 | #[derive(Clone, Debug)] |
| 64 | pub struct DevRefreshManager { |
| 65 | sender: broadcast::Sender<()>, |
| 66 | running: Arc<AtomicBool>, |
| 67 | js_bundles_map: Vec<(PathBuf, PathBuf)>, |
| 68 | js_import_aliases: Vec<(String, PathBuf)>, |
| 69 | css_paths: (PathBuf, PathBuf), |
| 70 | src_path: PathBuf, |
| 71 | public_path: PathBuf, |
| 72 | } |
| 73 | |
| 74 | impl DevRefreshManager { |
| 75 | |
| 76 | pub fn new( |
| 77 | root_path: &Path, |
| 78 | js_bundles_map: Vec<(PathBuf, PathBuf)>, |
| 79 | js_import_aliases: Vec<(String, PathBuf)>, |
| 80 | css_paths: (PathBuf, PathBuf), |
| 81 | ) |
| 82 | -> Self |
| 83 | { |
| 84 | // Buffer size of 16 should be plenty. |
| 85 | let (sender, _) = broadcast::channel(16); |
| 86 | |
| 87 | // Validate inputs and filter out invalid ones. |
| 88 | let valid_js_bundles: Vec<(PathBuf, PathBuf)> = js_bundles_map |
| 89 | .into_iter() |
| 90 | .filter(|(src, _)| src.exists()) |
| 91 | .collect(); |
| 92 | |
| 93 | let valid_css_paths = if css_paths.0.exists() { |
| 94 | css_paths |
| 95 | } else { |
| 96 | (PathBuf::new(), PathBuf::new()) // Empty paths = disabled. |
| 97 | }; |
| 98 | |
| 99 | info!("DevRefreshManager initialised with {} JS bundles, CSS: {}", |
| 100 | valid_js_bundles.len(), |
| 101 | if valid_css_paths.0.as_os_str().is_empty() { |
| 102 | "disabled" |
| 103 | } else { |
| 104 | "enabled" |
| 105 | } |
| 106 | ); |
| 107 | |
| 108 | Self { |
| 109 | sender, |
| 110 | running: Arc::new(AtomicBool::new(true)), |
| 111 | js_bundles_map: valid_js_bundles, |
| 112 | js_import_aliases, |
| 113 | css_paths: valid_css_paths, |
| 114 | src_path: root_path.join("www/src"), |
| 115 | public_path: root_path.join("www/public"), |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | pub fn get_receiver(&self) -> broadcast::Receiver<()> { |
| 120 | self.sender.subscribe() |
| 121 | } |
| 122 | |
| 123 | pub fn stop(&self) { |
| 124 | self.running.store(false, Ordering::SeqCst); |
| 125 | } |
| 126 | |
| 127 | /// Determines whether a file should trigger processing based on its path. |
| 128 | /// Excludes temporary and backup files. |
| 129 | fn should_process_file(path: &Path) -> bool { |
| 130 | if path.is_file() { |
| 131 | if let Some(filename) = path.file_name() { |
| 132 | if let Some(filename) = filename.to_str() { |
| 133 | return !filename.starts_with(".") && !filename.contains('~'); |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | false |
| 138 | } |
| 139 | |
| 140 | pub fn refresh(&self) -> Outcome<()> { |
| 141 | // Only bundle JS if we have valid bundles configured. |
| 142 | if !self.js_bundles_map.is_empty() { |
| 143 | res!(self.bundle_js()); |
| 144 | } else { |
| 145 | debug!("Skipping JS bundling - no bundles configured"); |
| 146 | } |
| 147 | |
| 148 | // Only bundle SASS if we have valid CSS paths. |
| 149 | if !self.css_paths.0.as_os_str().is_empty() && self.css_paths.0.exists() { |
| 150 | res!(self.bundle_sass()); |
| 151 | } else { |
| 152 | debug!("Skipping SASS bundling - no valid CSS source directory"); |
| 153 | } |
| 154 | |
| 155 | Ok(()) |
| 156 | } |
| 157 | |
| 158 | pub fn bundle_js(&self) -> Outcome<()> { |
| 159 | Self::js_bundler( |
| 160 | self.src_path.clone(), |
| 161 | self.js_bundles_map.clone(), |
| 162 | self.js_import_aliases.clone(), |
| 163 | ) |
| 164 | } |
| 165 | |
| 166 | pub fn bundle_sass(&self) -> Outcome<()> { |
| 167 | Self::sass_bundler( |
| 168 | &self.css_paths, |
| 169 | ) |
| 170 | } |
| 171 | |
| 172 | /// Associated function for bundling javascript. |
| 173 | pub fn js_bundler( |
| 174 | src_path: PathBuf, |
| 175 | js_bundles_map: Vec<(PathBuf, PathBuf)>, |
| 176 | js_import_aliases: Vec<(String, PathBuf)>, |
| 177 | ) |
| 178 | -> Outcome<()> |
| 179 | { |
| 180 | let bundler = JsBundle::new( |
| 181 | js_bundles_map, |
| 182 | js_import_aliases, |
| 183 | ); |
| 184 | |
| 185 | // Bundle all JS/TS files. |
| 186 | res!(bundler.bundle_entries( |
| 187 | &src_path.join("js"), |
| 188 | )); |
| 189 | |
| 190 | debug!("JavaScript/TypeScript bundling completed."); |
| 191 | |
| 192 | Ok(()) |
| 193 | } |
| 194 | |
| 195 | /// Associated function for bundling css. |
| 196 | pub fn sass_bundler( |
| 197 | css_paths: &(PathBuf, PathBuf), |
| 198 | ) |
| 199 | -> Outcome<()> |
| 200 | { |
| 201 | let bundler = SassBundle::new(); |
| 202 | |
| 203 | // Compile all SCSS files. |
| 204 | res!(bundler.compile_directory(css_paths)); |
| 205 | |
| 206 | debug!("SCSS compilation completed."); |
| 207 | |
| 208 | Ok(()) |
| 209 | } |
| 210 | |
| 211 | /// Processes source file changes by running appropriate bundler. |
| 212 | async fn handle_src_change( |
| 213 | src_path: PathBuf, |
| 214 | js_bundles_map: Vec<(PathBuf, PathBuf)>, |
| 215 | js_import_aliases: Vec<(String, PathBuf)>, |
| 216 | css_paths: &(PathBuf, PathBuf), |
| 217 | path: &Path, |
| 218 | ) |
| 219 | -> Outcome<()> |
| 220 | { |
| 221 | // Determine file type and run appropriate bundler. |
| 222 | if let Some(ext) = path.extension() { |
| 223 | if let Some(ext_str) = ext.to_str() { |
| 224 | match FileType::from_str(ext_str) { |
| 225 | Ok(_) => { |
| 226 | debug!("JavaScript/TypeScript file changed, rebundling..."); |
| 227 | res!(Self::js_bundler( |
| 228 | src_path, |
| 229 | js_bundles_map, |
| 230 | js_import_aliases, |
| 231 | )); |
| 232 | } |
| 233 | _ => if ext_str == "scss" || ext_str == "sass" { |
| 234 | debug!("SCSS file changed, recompiling..."); |
| 235 | res!(Self::sass_bundler( |
| 236 | css_paths, |
| 237 | )); |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | Ok(()) |
| 243 | } |
| 244 | |
| 245 | pub fn watch(&self) -> Outcome<()> { |
| 246 | info!("Starting file watchers for src and public directories."); |
| 247 | |
| 248 | let sender = self.sender.clone(); |
| 249 | let src_path = self.src_path.clone(); |
| 250 | let js_bundles_map = self.js_bundles_map.clone(); |
| 251 | let js_import_aliases = self.js_import_aliases.clone(); |
| 252 | let css_paths = self.css_paths.clone(); |
| 253 | |
| 254 | // Create watcher for source files. |
| 255 | let mut src_watcher: RecommendedWatcher = res!(notify::recommended_watcher( |
| 256 | move |res: Result<Event, _>| { |
| 257 | if let Ok(event) = res { |
| 258 | //debug!("Source file event detected: {:?}", event); |
| 259 | |
| 260 | for path in &event.paths { |
| 261 | if !Self::should_process_file(path) { |
| 262 | continue; |
| 263 | } |
| 264 | |
| 265 | // Only process actual file modifications. |
| 266 | match event.kind { |
| 267 | EventKind::Modify(ModifyKind::Data(_)) | |
| 268 | EventKind::Create(CreateKind::File) | |
| 269 | EventKind::Remove(RemoveKind::File) => { |
| 270 | if path.starts_with(&src_path) && |
| 271 | Self::should_process_file(path) |
| 272 | { |
| 273 | let src_path = src_path.clone(); |
| 274 | let js_bundles_map = js_bundles_map.clone(); |
| 275 | let js_import_aliases = js_import_aliases.clone(); |
| 276 | //let css_paths = css_paths.clone(); |
| 277 | |
| 278 | // Handle source changes asynchronously. |
| 279 | let rt = match tokio::runtime::Runtime::new() { |
| 280 | Ok(rt) => rt, |
| 281 | Err(e) => { |
| 282 | error!(err!(e, |
| 283 | "Failed to create Tokio runtime for bundling."; |
| 284 | Init)); |
| 285 | return; |
| 286 | } |
| 287 | }; |
| 288 | |
| 289 | if let Err(e) = rt.block_on(Self::handle_src_change( |
| 290 | src_path, |
| 291 | js_bundles_map, |
| 292 | js_import_aliases, |
| 293 | &css_paths, |
| 294 | path, |
| 295 | )) { |
| 296 | // Continue serving if there is a failure. |
| 297 | error!(err!(e, |
| 298 | "Error processing source file change: {:?}", path; |
| 299 | IO, File)); |
| 300 | } |
| 301 | } |
| 302 | } |
| 303 | _ => debug!("Ignoring event: {:?}", event.kind), |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | )); |
| 309 | |
| 310 | let public_path = self.public_path.clone(); |
| 311 | |
| 312 | // Create watcher for public files. |
| 313 | let mut public_watcher: RecommendedWatcher = res!(notify::recommended_watcher( |
| 314 | move |res: Result<Event, _>| { |
| 315 | if let Ok(event) = res { |
| 316 | //debug!("Public file event detected: {:?}", event); |
| 317 | |
| 318 | // Only trigger on actual file content changes. |
| 319 | match event.kind { |
| 320 | EventKind::Modify(ModifyKind::Data(_)) | |
| 321 | EventKind::Create(CreateKind::File) | |
| 322 | EventKind::Remove(RemoveKind::File) => { |
| 323 | for path in &event.paths { |
| 324 | if path.starts_with(&public_path) && |
| 325 | Self::should_process_file(path) |
| 326 | { |
| 327 | info!("Broadcasting refresh for file: {:?}.", path); |
| 328 | match sender.send(()) { |
| 329 | Ok(_) => debug!("Refresh notification sent successfully."), |
| 330 | Err(e) => debug!("No active subscribers: {}", e), |
| 331 | } |
| 332 | break; |
| 333 | } |
| 334 | } |
| 335 | } |
| 336 | _ => debug!("Ignoring event: {:?}", event.kind), |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | )); |
| 341 | |
| 342 | info!("Starting source file watcher for: {:?}", self.src_path); |
| 343 | res!(src_watcher.watch(&self.src_path, RecursiveMode::Recursive)); |
| 344 | info!("Starting public file watcher for: {:?}", self.public_path); |
| 345 | res!(public_watcher.watch(&self.public_path, RecursiveMode::Recursive)); |
| 346 | |
| 347 | while self.running.load(Ordering::SeqCst) { |
| 348 | std::thread::park_timeout(Duration::from_millis(100)); |
| 349 | } |
| 350 | |
| 351 | info!("File watchers stopped."); |
| 352 | |
| 353 | Ok(()) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // Modifies HTML content to inject refresh script in dev mode. |
| 358 | pub struct HtmlModifier; |
| 359 | |
| 360 | impl HtmlModifier { |
| 361 | pub fn inject_dev_refresh(html: &str) -> Outcome<String> { |
| 362 | if !html.contains("</body>") { |
| 363 | return Ok(html.to_string()); |
| 364 | } |
| 365 | let dev_refresh_script = r#" |
| 366 | <script> |
| 367 | function getTimestamp() { |
| 368 | return new Date().toISOString(); |
| 369 | } |
| 370 | const initDevRefresh = () => { |
| 371 | const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| 372 | const wsUrl = `${wsProtocol}//${window.location.host}/ws`; |
| 373 | console.log('Connecting to WebSocket:', wsUrl); |
| 374 | |
| 375 | const ws = new WebSocket(wsUrl); |
| 376 | let pingInterval; |
| 377 | |
| 378 | ws.onopen = () => { |
| 379 | console.log('Development refresh connection opened.'); |
| 380 | ws.send('dev_connect'); |
| 381 | }; |
| 382 | |
| 383 | ws.onmessage = (event) => { |
| 384 | console.log('Received message:', event.data); |
| 385 | if (event.data === 'info "connected"') { |
| 386 | //if (event.data.includes('connected')) { |
| 387 | console.log('Development refresh connection established.'); |
| 388 | // Start sending periodic pings to keep connection alive |
| 389 | pingInterval = setInterval(() => { |
| 390 | if (ws.readyState === WebSocket.OPEN) { |
| 391 | console.log('Sending ping...'); |
| 392 | ws.send('dev_ping'); |
| 393 | } |
| 394 | }, 15000); |
| 395 | } else if (event.data === 'info "pong"') { |
| 396 | //} else if (event.data.includes('pong')) { |
| 397 | console.log('Received dev pong response.'); |
| 398 | } else if (event.data === 'dev_refresh') { |
| 399 | console.log('Server requested page refresh.'); |
| 400 | window.location.reload(); |
| 401 | } |
| 402 | }; |
| 403 | |
| 404 | ws.onclose = (event) => { |
| 405 | console.log('WebSocket closed:', event.code, event.reason || '<no reason>', event.wasClean); |
| 406 | //console.log('Development refresh connection closed, attempting reconnect...'); |
| 407 | console.log('Development refresh connection closed'); |
| 408 | if (pingInterval) { |
| 409 | clearInterval(pingInterval); |
| 410 | } |
| 411 | //setTimeout(initDevRefresh, 2000); |
| 412 | }; |
| 413 | |
| 414 | ws.onerror = (error) => { |
| 415 | console.error('WebSocket error:', error); |
| 416 | }; |
| 417 | |
| 418 | // Ensure clean shutdown |
| 419 | window.addEventListener('beforeunload', () => { |
| 420 | if (ws.readyState === WebSocket.OPEN) { |
| 421 | ws.close(); |
| 422 | } |
| 423 | }); |
| 424 | }; |
| 425 | |
| 426 | // Start connection when document loads |
| 427 | if (document.readyState === 'loading') { |
| 428 | console.log(`${getTimestamp()} Document loading, waiting for DOMContentLoaded`); |
| 429 | document.addEventListener('DOMContentLoaded', initDevRefresh); |
| 430 | } else { |
| 431 | console.log(`${getTimestamp()} Document ready, initialising immediately`); |
| 432 | initDevRefresh(); |
| 433 | } |
| 434 | </script> |
| 435 | </body>"#; |
| 436 | Ok(html.replace("</body>", dev_refresh_script)) |
| 437 | } |
| 438 | } |