Oregami
Repositories/oxedyne/fe2o3

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.
18use crate::srv::dev::{
19 js::{
20 FileType,
21 JsBundle,
22 },
23 sass::SassBundle,
24};
25
26use oxedyne_fe2o3_core::prelude::*;
27
28use 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
43use notify::{
44 RecommendedWatcher,
45 Watcher,
46 RecursiveMode,
47 Event,
48 EventKind,
49 event::{
50 ModifyKind,
51 CreateKind,
52 RemoveKind,
53 },
54};
55use 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)]
64pub 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
74impl 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.
358pub struct HtmlModifier;
359
360impl 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}