fob_cli/dev/
state.rs

1//! Shared state for the development server.
2//!
3//! Provides thread-safe access to build artifacts, client connections,
4//! and build status using parking_lot RwLock for better performance.
5
6use fob_bundler::builders::asset_registry::AssetRegistry;
7use parking_lot::RwLock;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Instant;
12
13/// Build status tracking.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum BuildStatus {
16    /// No build has been performed yet
17    NotStarted,
18    /// Build is currently in progress
19    InProgress { started_at: Instant },
20    /// Build completed successfully
21    Success { duration_ms: u64 },
22    /// Build failed with error
23    Failed { error: String },
24}
25
26impl BuildStatus {
27    /// Check if build is currently running.
28    pub fn is_in_progress(&self) -> bool {
29        matches!(self, BuildStatus::InProgress { .. })
30    }
31
32    /// Check if last build succeeded.
33    pub fn is_success(&self) -> bool {
34        matches!(self, BuildStatus::Success { .. })
35    }
36
37    /// Check if build has not started yet.
38    pub fn is_not_started(&self) -> bool {
39        matches!(self, BuildStatus::NotStarted)
40    }
41
42    /// Get error message if failed.
43    pub fn error(&self) -> Option<&str> {
44        match self {
45            BuildStatus::Failed { error } => Some(error),
46            _ => None,
47        }
48    }
49}
50
51/// In-memory bundle cache for serving without disk I/O.
52///
53/// Maps file paths to their bundled content (JavaScript, source maps, etc.).
54/// This allows instant serving while background disk writes happen.
55#[derive(Debug, Clone, Default)]
56pub struct BundleCache {
57    /// Cached file contents: path -> (content, content-type)
58    files: HashMap<String, (Vec<u8>, String)>,
59}
60
61impl BundleCache {
62    /// Create a new empty cache.
63    pub fn new() -> Self {
64        Self {
65            files: HashMap::new(),
66        }
67    }
68
69    /// Insert a file into the cache.
70    ///
71    /// # Arguments
72    ///
73    /// * `path` - URL path (e.g., "/index.js")
74    /// * `content` - File content as bytes
75    /// * `content_type` - MIME type (e.g., "application/javascript")
76    pub fn insert(&mut self, path: String, content: Vec<u8>, content_type: String) {
77        self.files.insert(path, (content, content_type));
78    }
79
80    /// Get a file from the cache.
81    ///
82    /// # Returns
83    ///
84    /// Option containing (content, content_type) if found
85    pub fn get(&self, path: &str) -> Option<&(Vec<u8>, String)> {
86        self.files.get(path)
87    }
88
89    /// Clear all cached files.
90    pub fn clear(&mut self) {
91        self.files.clear();
92    }
93
94    /// Get number of cached files.
95    pub fn len(&self) -> usize {
96        self.files.len()
97    }
98
99    /// Check if cache is empty.
100    pub fn is_empty(&self) -> bool {
101        self.files.is_empty()
102    }
103
104    /// Find the first JavaScript entry point file.
105    ///
106    /// Returns the path of the first .js or .mjs file that is not a source map
107    /// or internal file (starting with /__).
108    ///
109    /// # Returns
110    ///
111    /// Option containing the entry point path if found
112    pub fn find_entry_point(&self) -> Option<String> {
113        for path in self.files.keys() {
114            if path.ends_with(".js") || path.ends_with(".mjs") {
115                // Skip source maps and internal files
116                if !path.contains(".map") && !path.starts_with("/__") {
117                    return Some(path.clone());
118                }
119            }
120        }
121        None
122    }
123}
124
125/// Client connection tracker for Server-Sent Events.
126///
127/// Tracks connected clients to broadcast reload events.
128pub type ClientRegistry = Arc<RwLock<HashMap<usize, tokio::sync::mpsc::Sender<String>>>>;
129
130/// Shared development server state.
131///
132/// All fields use parking_lot::RwLock for thread-safe access with minimal overhead.
133/// Multiple readers can access simultaneously, writers get exclusive access.
134pub struct DevServerState {
135    /// Current build status
136    pub status: RwLock<BuildStatus>,
137
138    /// In-memory bundle cache
139    pub cache: RwLock<BundleCache>,
140
141    /// Connected SSE clients
142    pub clients: ClientRegistry,
143
144    /// Next client ID
145    pub next_client_id: RwLock<usize>,
146
147    /// Asset registry for serving static assets
148    pub asset_registry: RwLock<Arc<AssetRegistry>>,
149
150    /// Output directory for serving files from disk
151    pub out_dir: PathBuf,
152}
153
154impl DevServerState {
155    /// Create new dev server state.
156    ///
157    /// # Arguments
158    ///
159    /// * `out_dir` - Output directory for serving files from disk
160    pub fn new(out_dir: PathBuf) -> Self {
161        Self {
162            status: RwLock::new(BuildStatus::NotStarted),
163            cache: RwLock::new(BundleCache::new()),
164            clients: Arc::new(RwLock::new(HashMap::new())),
165            next_client_id: RwLock::new(0),
166            asset_registry: RwLock::new(Arc::new(AssetRegistry::new())),
167            out_dir,
168        }
169    }
170
171    /// Create new dev server state with a specific asset registry.
172    #[cfg(test)]
173    pub fn new_with_registry(registry: Arc<AssetRegistry>) -> Self {
174        Self {
175            status: RwLock::new(BuildStatus::NotStarted),
176            cache: RwLock::new(BundleCache::new()),
177            clients: Arc::new(RwLock::new(HashMap::new())),
178            next_client_id: RwLock::new(0),
179            asset_registry: RwLock::new(registry),
180            out_dir: PathBuf::from("dist"),
181        }
182    }
183
184    /// Update build status to in-progress.
185    pub fn start_build(&self) {
186        *self.status.write() = BuildStatus::InProgress {
187            started_at: Instant::now(),
188        };
189    }
190
191    /// Update build status to success.
192    ///
193    /// # Arguments
194    ///
195    /// * `duration_ms` - Build duration in milliseconds
196    pub fn complete_build(&self, duration_ms: u64) {
197        *self.status.write() = BuildStatus::Success { duration_ms };
198    }
199
200    /// Update build status to failed.
201    ///
202    /// # Arguments
203    ///
204    /// * `error` - Error message describing the failure
205    pub fn fail_build(&self, error: String) {
206        *self.status.write() = BuildStatus::Failed { error };
207    }
208
209    /// Get current build status.
210    pub fn get_status(&self) -> BuildStatus {
211        self.status.read().clone()
212    }
213
214    /// Update the bundle cache.
215    ///
216    /// # Arguments
217    ///
218    /// * `new_cache` - New cache to replace current cache
219    pub fn update_cache(&self, new_cache: BundleCache) {
220        *self.cache.write() = new_cache;
221    }
222
223    /// Get a file from the cache.
224    pub fn get_cached_file(&self, path: &str) -> Option<(Vec<u8>, String)> {
225        self.cache.read().get(path).cloned()
226    }
227
228    /// Clear the cache.
229    pub fn clear_cache(&self) {
230        self.cache.write().clear();
231    }
232
233    /// Register a new SSE client.
234    ///
235    /// # Returns
236    ///
237    /// Client ID and receiver for events
238    pub fn register_client(&self) -> (usize, tokio::sync::mpsc::Receiver<String>) {
239        let id = {
240            let mut next_id = self.next_client_id.write();
241            let id = *next_id;
242            *next_id += 1;
243            id
244        };
245
246        let (tx, rx) = tokio::sync::mpsc::channel(100);
247        self.clients.write().insert(id, tx);
248
249        (id, rx)
250    }
251
252    /// Unregister an SSE client.
253    pub fn unregister_client(&self, id: usize) {
254        self.clients.write().remove(&id);
255    }
256
257    /// Broadcast an event to all connected clients.
258    ///
259    /// # Arguments
260    ///
261    /// * `event` - Event data to send (will be JSON serialized)
262    pub async fn broadcast(&self, event: &crate::dev::DevEvent) {
263        let json = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
264        // Don't format as SSE here - let axum's Event::data() handle it
265
266        // Get all client senders
267        let clients = self.clients.read().clone();
268
269        // Collect failed client IDs first to avoid modifying HashMap during iteration
270        let mut failed_ids = Vec::new();
271
272        // Send to each client (non-blocking)
273        for (id, tx) in clients {
274            if tx.send(json.clone()).await.is_err() {
275                // Client disconnected, mark for removal
276                failed_ids.push(id);
277            }
278        }
279
280        // Remove failed clients after iteration
281        for id in failed_ids {
282            self.unregister_client(id);
283        }
284    }
285
286    /// Get number of connected clients.
287    pub fn client_count(&self) -> usize {
288        self.clients.read().len()
289    }
290
291    /// Get the asset registry.
292    pub fn asset_registry(&self) -> Arc<AssetRegistry> {
293        Arc::clone(&*self.asset_registry.read())
294    }
295
296    /// Update the asset registry.
297    pub fn update_asset_registry(&self, registry: Arc<AssetRegistry>) {
298        *self.asset_registry.write() = registry;
299    }
300
301    /// Get the output directory.
302    pub fn get_out_dir(&self) -> &PathBuf {
303        &self.out_dir
304    }
305}
306
307impl Default for DevServerState {
308    fn default() -> Self {
309        Self::new(PathBuf::from("dist"))
310    }
311}
312
313/// Shared state handle for passing around the application.
314pub type SharedState = Arc<DevServerState>;
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_build_status_is_in_progress() {
322        let status = BuildStatus::InProgress {
323            started_at: Instant::now(),
324        };
325        assert!(status.is_in_progress());
326        assert!(!status.is_success());
327        assert!(status.error().is_none());
328    }
329
330    #[test]
331    fn test_build_status_success() {
332        let status = BuildStatus::Success { duration_ms: 100 };
333        assert!(!status.is_in_progress());
334        assert!(status.is_success());
335        assert!(status.error().is_none());
336    }
337
338    #[test]
339    fn test_build_status_failed() {
340        let status = BuildStatus::Failed {
341            error: "Test error".to_string(),
342        };
343        assert!(!status.is_in_progress());
344        assert!(!status.is_success());
345        assert_eq!(status.error(), Some("Test error"));
346    }
347
348    #[test]
349    fn test_bundle_cache_operations() {
350        let mut cache = BundleCache::new();
351        assert!(cache.is_empty());
352
353        cache.insert(
354            "/index.js".to_string(),
355            b"console.log('test')".to_vec(),
356            "application/javascript".to_string(),
357        );
358
359        assert_eq!(cache.len(), 1);
360        assert!(!cache.is_empty());
361
362        let file = cache.get("/index.js");
363        assert!(file.is_some());
364
365        let (content, content_type) = file.unwrap();
366        assert_eq!(content, b"console.log('test')");
367        assert_eq!(content_type, "application/javascript");
368
369        cache.clear();
370        assert!(cache.is_empty());
371    }
372
373    #[test]
374    fn test_dev_server_state_build_lifecycle() {
375        let state = DevServerState::new(PathBuf::from("dist"));
376
377        assert!(matches!(state.get_status(), BuildStatus::NotStarted));
378
379        state.start_build();
380        assert!(state.get_status().is_in_progress());
381
382        state.complete_build(150);
383        assert!(state.get_status().is_success());
384
385        state.fail_build("Test error".to_string());
386        assert_eq!(state.get_status().error(), Some("Test error"));
387    }
388
389    #[test]
390    fn test_dev_server_state_cache() {
391        let state = DevServerState::new(PathBuf::from("dist"));
392
393        let mut cache = BundleCache::new();
394        cache.insert(
395            "/test.js".to_string(),
396            b"test".to_vec(),
397            "application/javascript".to_string(),
398        );
399
400        state.update_cache(cache);
401
402        let file = state.get_cached_file("/test.js");
403        assert!(file.is_some());
404
405        state.clear_cache();
406        assert!(state.get_cached_file("/test.js").is_none());
407    }
408
409    #[tokio::test]
410    async fn test_client_registration() {
411        let state = Arc::new(DevServerState::new(PathBuf::from("dist")));
412
413        let (id1, _rx1) = state.register_client();
414        let (id2, _rx2) = state.register_client();
415
416        assert_eq!(state.client_count(), 2);
417        assert_ne!(id1, id2);
418
419        state.unregister_client(id1);
420        assert_eq!(state.client_count(), 1);
421    }
422}