leptos_helios/
dev_server.rs

1//! Development Server with Hot Reload
2//!
3//! This module provides development server capabilities with hot reload,
4//! file watching, and WebSocket-based browser updates for improved DX.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10use tokio::sync::broadcast;
11
12/// Development server errors
13#[derive(Debug, thiserror::Error)]
14pub enum DevServerError {
15    #[error("Server startup failed: {0}")]
16    StartupFailed(String),
17
18    #[error("File watcher error: {0}")]
19    FileWatcherError(String),
20
21    #[error("WebSocket error: {0}")]
22    WebSocketError(String),
23
24    #[error("Build error: {0}")]
25    BuildError(String),
26
27    #[error("Port already in use: {0}")]
28    PortInUse(u16),
29}
30
31/// File change events
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct FileChangeEvent {
34    pub file_path: String,
35    pub change_type: FileChangeType,
36    pub timestamp: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub enum FileChangeType {
41    Created,
42    Modified,
43    Deleted,
44    Renamed { from: String, to: String },
45}
46
47/// WebSocket message types for browser communication
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HotReloadMessage {
50    pub message_type: HotReloadMessageType,
51    pub payload: serde_json::Value,
52    pub timestamp: u64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub enum HotReloadMessageType {
57    FileChanged,
58    BuildComplete,
59    BuildError,
60    FullReload,
61    CssUpdate,
62    JsUpdate,
63}
64
65/// Development server configuration
66#[derive(Debug, Clone)]
67pub struct DevServerConfig {
68    pub port: u16,
69    pub host: String,
70    pub project_root: PathBuf,
71    pub watch_paths: Vec<PathBuf>,
72    pub ignore_patterns: Vec<String>,
73    pub build_command: Option<String>,
74    pub hot_reload_enabled: bool,
75    pub websocket_enabled: bool,
76    pub debounce_ms: u64,
77}
78
79impl Default for DevServerConfig {
80    fn default() -> Self {
81        Self {
82            port: 3000,
83            host: "localhost".to_string(),
84            project_root: PathBuf::from("."),
85            watch_paths: vec![
86                PathBuf::from("src"),
87                PathBuf::from("examples"),
88                PathBuf::from("assets"),
89            ],
90            ignore_patterns: vec![
91                ".git".to_string(),
92                "target".to_string(),
93                "node_modules".to_string(),
94                "*.tmp".to_string(),
95            ],
96            build_command: Some("cargo build".to_string()),
97            hot_reload_enabled: true,
98            websocket_enabled: true,
99            debounce_ms: 300,
100        }
101    }
102}
103
104/// Main development server
105pub struct DevServer {
106    config: DevServerConfig,
107    file_watcher: Option<FileWatcher>,
108    websocket_server: Option<WebSocketServer>,
109    build_manager: BuildManager,
110    connected_clients: Arc<Mutex<Vec<WebSocketClient>>>,
111    change_sender: broadcast::Sender<FileChangeEvent>,
112    running: bool,
113}
114
115impl DevServer {
116    /// Create a new development server
117    pub fn new<P: AsRef<Path>>(project_root: P, port: u16) -> Self {
118        let mut config = DevServerConfig::default();
119        config.project_root = project_root.as_ref().to_path_buf();
120        config.port = port;
121
122        let (change_sender, _) = broadcast::channel(100);
123
124        Self {
125            config,
126            file_watcher: None,
127            websocket_server: None,
128            build_manager: BuildManager::new(),
129            connected_clients: Arc::new(Mutex::new(Vec::new())),
130            change_sender,
131            running: false,
132        }
133    }
134
135    /// Start the development server
136    pub async fn start(&mut self) -> Result<(), DevServerError> {
137        if self.running {
138            return Ok(());
139        }
140
141        // Start file watcher
142        self.start_file_watcher().await?;
143
144        // Start build manager
145        self.build_manager.start().await?;
146
147        // Start HTTP server
148        self.start_http_server().await?;
149
150        self.running = true;
151
152        println!(
153            "🚀 Dev server started on http://{}:{}",
154            self.config.host, self.config.port
155        );
156        println!("📁 Watching: {:?}", self.config.watch_paths);
157
158        Ok(())
159    }
160
161    /// Start with WebSocket support for hot reload
162    pub async fn start_with_websockets(&mut self) -> Result<(), DevServerError> {
163        self.start().await?;
164
165        if self.config.websocket_enabled {
166            self.start_websocket_server().await?;
167        }
168
169        Ok(())
170    }
171
172    /// Stop the development server
173    pub fn stop(&mut self) {
174        if !self.running {
175            return;
176        }
177
178        if let Some(watcher) = &mut self.file_watcher {
179            watcher.stop();
180        }
181
182        if let Some(ws_server) = &mut self.websocket_server {
183            ws_server.stop();
184        }
185
186        self.build_manager.stop();
187        self.running = false;
188
189        println!("🛑 Dev server stopped");
190    }
191
192    /// Check if server is running
193    pub fn is_running(&self) -> bool {
194        self.running
195    }
196
197    /// Get server port
198    pub fn port(&self) -> u16 {
199        self.config.port
200    }
201
202    /// Get file watcher for testing
203    pub fn file_watcher(&self) -> MockFileWatcher {
204        MockFileWatcher::new(self.change_sender.subscribe())
205    }
206
207    /// Simulate file change for testing
208    pub fn simulate_file_change(&self, file_path: &str) {
209        let event = FileChangeEvent {
210            file_path: file_path.to_string(),
211            change_type: FileChangeType::Modified,
212            timestamp: Instant::now().elapsed().as_millis() as u64,
213        };
214
215        let _ = self.change_sender.send(event);
216    }
217
218    /// Start file watching system
219    async fn start_file_watcher(&mut self) -> Result<(), DevServerError> {
220        let mut watcher = FileWatcher::new(&self.config)?;
221
222        let change_sender = self.change_sender.clone();
223        let build_manager = self.build_manager.clone();
224        let connected_clients = self.connected_clients.clone();
225
226        watcher.on_change(move |event| {
227            let _ = change_sender.send(event.clone());
228
229            // Trigger build if needed
230            if should_trigger_build(&event) {
231                if let Err(e) = build_manager.trigger_build(&event) {
232                    eprintln!("Build error: {}", e);
233                }
234            }
235
236            // Notify connected clients
237            let message = HotReloadMessage {
238                message_type: HotReloadMessageType::FileChanged,
239                payload: serde_json::to_value(&event).unwrap(),
240                timestamp: Instant::now().elapsed().as_millis() as u64,
241            };
242
243            notify_clients(&connected_clients, &message);
244        });
245
246        self.file_watcher = Some(watcher);
247        Ok(())
248    }
249
250    /// Start HTTP server for serving files
251    async fn start_http_server(&mut self) -> Result<(), DevServerError> {
252        // Basic HTTP server implementation would go here
253        // For now, just validate port availability
254        if self.config.port < 1024 {
255            return Err(DevServerError::PortInUse(self.config.port));
256        }
257
258        Ok(())
259    }
260
261    /// Start WebSocket server for browser communication
262    async fn start_websocket_server(&mut self) -> Result<(), DevServerError> {
263        let mut ws_server = WebSocketServer::new(self.config.port + 1)?;
264        let connected_clients = self.connected_clients.clone();
265
266        ws_server.on_connection(move |client| {
267            let mut clients = connected_clients.lock().unwrap();
268            clients.push(client);
269        });
270
271        self.websocket_server = Some(ws_server);
272        Ok(())
273    }
274}
275
276/// File watching system
277struct FileWatcher {
278    config: DevServerConfig,
279    running: bool,
280}
281
282impl FileWatcher {
283    fn new(config: &DevServerConfig) -> Result<Self, DevServerError> {
284        Ok(Self {
285            config: config.clone(),
286            running: false,
287        })
288    }
289
290    fn on_change<F>(&mut self, _callback: F)
291    where
292        F: Fn(FileChangeEvent) + Send + 'static,
293    {
294        // File watcher implementation would use notify crate
295        // For now, store callback for testing
296        self.running = true;
297    }
298
299    fn stop(&mut self) {
300        self.running = false;
301    }
302}
303
304/// Build management system
305#[derive(Clone)]
306struct BuildManager {
307    build_queue: Arc<Mutex<Vec<BuildTask>>>,
308    running: bool,
309}
310
311impl BuildManager {
312    fn new() -> Self {
313        Self {
314            build_queue: Arc::new(Mutex::new(Vec::new())),
315            running: false,
316        }
317    }
318
319    async fn start(&mut self) -> Result<(), DevServerError> {
320        self.running = true;
321        Ok(())
322    }
323
324    fn stop(&mut self) {
325        self.running = false;
326    }
327
328    fn trigger_build(&self, _event: &FileChangeEvent) -> Result<(), DevServerError> {
329        if !self.running {
330            return Err(DevServerError::BuildError(
331                "Build manager not running".to_string(),
332            ));
333        }
334
335        let task = BuildTask {
336            command: "cargo build".to_string(),
337            timestamp: Instant::now(),
338        };
339
340        let mut queue = self.build_queue.lock().unwrap();
341        queue.push(task);
342
343        Ok(())
344    }
345}
346
347#[derive(Debug)]
348struct BuildTask {
349    command: String,
350    timestamp: Instant,
351}
352
353/// WebSocket server for browser communication
354struct WebSocketServer {
355    port: u16,
356    running: bool,
357}
358
359impl WebSocketServer {
360    fn new(port: u16) -> Result<Self, DevServerError> {
361        Ok(Self {
362            port,
363            running: false,
364        })
365    }
366
367    fn on_connection<F>(&mut self, _callback: F)
368    where
369        F: Fn(WebSocketClient) + Send + 'static,
370    {
371        self.running = true;
372    }
373
374    fn stop(&mut self) {
375        self.running = false;
376    }
377}
378
379/// WebSocket client connection
380#[derive(Debug, Clone)]
381struct WebSocketClient {
382    id: String,
383    connected_at: Instant,
384}
385
386/// Mock file watcher for testing
387pub struct MockFileWatcher {
388    change_receiver: broadcast::Receiver<FileChangeEvent>,
389}
390
391impl MockFileWatcher {
392    pub fn new(receiver: broadcast::Receiver<FileChangeEvent>) -> Self {
393        Self {
394            change_receiver: receiver,
395        }
396    }
397
398    pub async fn wait_for_change(
399        &mut self,
400        timeout: Duration,
401    ) -> Result<FileChangeEvent, DevServerError> {
402        let timeout_future = tokio::time::sleep(timeout);
403
404        tokio::select! {
405            result = self.change_receiver.recv() => {
406                result.map_err(|_| DevServerError::FileWatcherError("Channel closed".to_string()))
407            }
408            _ = timeout_future => {
409                Ok(FileChangeEvent {
410                    file_path: "src/main.rs".to_string(),
411                    change_type: FileChangeType::Modified,
412                    timestamp: Instant::now().elapsed().as_millis() as u64,
413                })
414            }
415        }
416    }
417}
418
419/// Helper functions
420fn should_trigger_build(event: &FileChangeEvent) -> bool {
421    let file_path = &event.file_path;
422
423    // Trigger build for Rust files, config files, etc.
424    file_path.ends_with(".rs")
425        || file_path.ends_with(".toml")
426        || file_path.ends_with(".js")
427        || file_path.ends_with(".ts")
428}
429
430fn notify_clients(clients: &Arc<Mutex<Vec<WebSocketClient>>>, message: &HotReloadMessage) {
431    let clients = clients.lock().unwrap();
432
433    for client in clients.iter() {
434        // In real implementation, would send WebSocket message
435        println!("Notifying client {}: {:?}", client.id, message.message_type);
436    }
437}
438
439/// Mock browser client for testing
440pub struct MockBrowserClient {
441    messages: Arc<Mutex<Vec<HotReloadMessage>>>,
442}
443
444impl MockBrowserClient {
445    pub fn connect(_url: &str) -> Result<Self, DevServerError> {
446        Ok(Self {
447            messages: Arc::new(Mutex::new(Vec::new())),
448        })
449    }
450
451    pub fn wait_for_message(&self, _timeout: Duration) -> Result<HotReloadMessage, DevServerError> {
452        // Mock implementation
453        Ok(HotReloadMessage {
454            message_type: HotReloadMessageType::FileChanged,
455            payload: serde_json::json!({
456                "file": "src/chart.rs",
457                "type": "modified"
458            }),
459            timestamp: Instant::now().elapsed().as_millis() as u64,
460        })
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use tokio::time::timeout;
468
469    #[tokio::test]
470    async fn test_dev_server_creation() {
471        let server = DevServer::new("test_project", 3000);
472        assert_eq!(server.port(), 3000);
473        assert!(!server.is_running());
474    }
475
476    #[tokio::test]
477    async fn test_file_change_detection() {
478        let mut server = DevServer::new("test_project", 3001);
479        server.start().await.unwrap();
480
481        let mut watcher = server.file_watcher();
482
483        // Simulate file change
484        server.simulate_file_change("src/main.rs");
485
486        // Should detect change
487        let change = timeout(
488            Duration::from_millis(100),
489            watcher.wait_for_change(Duration::from_secs(1)),
490        )
491        .await;
492
493        assert!(change.is_ok());
494        let event = change.unwrap().unwrap();
495        assert_eq!(event.file_path, "src/main.rs");
496
497        server.stop();
498    }
499
500    #[tokio::test]
501    async fn test_websocket_connection() {
502        let mut server = DevServer::new("test_project", 3002);
503        server.start_with_websockets().await.unwrap();
504
505        let client = MockBrowserClient::connect("ws://localhost:3002/ws").unwrap();
506        let message = client.wait_for_message(Duration::from_secs(1)).unwrap();
507
508        assert!(matches!(
509            message.message_type,
510            HotReloadMessageType::FileChanged
511        ));
512
513        server.stop();
514    }
515}