fob_cli/dev/
server.rs

1//! Development server with hot reload via Server-Sent Events.
2//!
3//! Serves bundled files from memory cache and provides SSE endpoint
4//! for push-based reload notifications.
5
6use crate::dev::{error_overlay, DevConfig, SharedState};
7use crate::error::Result;
8use axum::{
9    body::Body,
10    extract::State,
11    http::{header, StatusCode, Uri},
12    response::{IntoResponse, Response, Sse},
13    routing::get,
14    Router,
15};
16use tokio_stream::{wrappers::ReceiverStream, StreamExt};
17use tower_http::cors::{Any, CorsLayer};
18
19/// Development server.
20pub struct DevServer {
21    /// Server configuration
22    config: DevConfig,
23    /// Shared application state
24    state: SharedState,
25}
26
27impl DevServer {
28    /// Create a new development server.
29    ///
30    /// # Arguments
31    ///
32    /// * `config` - Server configuration
33    /// * `state` - Shared state for caching and client tracking
34    pub fn new(config: DevConfig, state: SharedState) -> Self {
35        Self { config, state }
36    }
37
38    /// Start the development server.
39    ///
40    /// Creates an axum router with:
41    /// - SSE endpoint for reload events
42    /// - Static file serving from cache
43    /// - HTML injection for reload script
44    /// - CORS headers (allow all origins for dev)
45    ///
46    /// # Returns
47    ///
48    /// Server handle that can be gracefully shut down
49    ///
50    /// # Errors
51    ///
52    /// Returns error if server cannot bind to configured address
53    pub async fn start(self) -> Result<()> {
54        let addr = self.config.addr;
55        let server_url = self.config.server_url();
56
57        // Build router
58        let app = self.build_router();
59
60        // Create listener
61        let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
62            crate::error::CliError::Server(format!("Failed to bind to {}: {}", addr, e))
63        })?;
64
65        crate::ui::success(&format!("Development server running at {}", server_url));
66
67        // Start server
68        axum::serve(listener, app)
69            .await
70            .map_err(|e| crate::error::CliError::Server(format!("Server error: {}", e)))?;
71
72        Ok(())
73    }
74
75    /// Build the axum router with all routes.
76    fn build_router(self) -> Router {
77        let state = self.state.clone();
78
79        Router::new()
80            // SSE endpoint for reload events
81            .route("/__fob_sse__", get(handle_sse))
82            // Reload client script
83            .route("/__fob_reload__.js", get(handle_reload_script))
84            // Asset serving (WASM, images, etc.)
85            .route("/__fob_assets__/{*path}", get(crate::dev::handle_asset))
86            // Favicon handler to prevent 404s
87            .route("/favicon.ico", get(handle_favicon))
88            // All other routes serve bundled files
89            .fallback(handle_request)
90            .layer(
91                // CORS: Allow all origins for dev (standard practice)
92                CorsLayer::new()
93                    .allow_origin(Any)
94                    .allow_methods(Any)
95                    .allow_headers(Any),
96            )
97            .with_state(state)
98    }
99}
100
101/// Handle SSE connections for reload events.
102async fn handle_sse(
103    State(state): State<SharedState>,
104) -> Sse<
105    impl tokio_stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>,
106> {
107    use axum::response::sse::Event;
108
109    // Register this client
110    let (id, rx) = state.register_client();
111
112    crate::ui::info(&format!("Client {} connected via SSE", id));
113
114    // Notify about connection
115    let _ = state
116        .broadcast(&crate::dev::DevEvent::ClientConnected { id })
117        .await;
118
119    // Convert receiver to stream for SSE
120    let stream = ReceiverStream::new(rx).map(|data| Ok(Event::default().data(data)));
121
122    Sse::new(stream).keep_alive(
123        axum::response::sse::KeepAlive::new()
124            .interval(std::time::Duration::from_secs(15))
125            .text("ping"),
126    )
127}
128
129/// Serve the reload client script.
130async fn handle_reload_script() -> impl IntoResponse {
131    const RELOAD_SCRIPT: &str = include_str!("../../assets/dev/reload-client.js");
132
133    Response::builder()
134        .status(StatusCode::OK)
135        .header(header::CONTENT_TYPE, "application/javascript")
136        .header(header::CACHE_CONTROL, "no-cache")
137        .body(Body::from(RELOAD_SCRIPT))
138        .unwrap()
139}
140
141/// Handle favicon requests with 204 No Content.
142async fn handle_favicon() -> impl IntoResponse {
143    StatusCode::NO_CONTENT
144}
145
146/// Handle all other requests (serve bundled files or error overlay).
147async fn handle_request(
148    State(state): State<SharedState>,
149    uri: Uri,
150) -> Result<impl IntoResponse, Response> {
151    let path = uri.path();
152
153    // Check build status
154    let status = state.get_status();
155
156    // If build failed, show error overlay
157    if let Some(error) = status.error() {
158        let html = error_overlay::generate_error_overlay(error).map_err(|e| {
159            Response::builder()
160                .status(StatusCode::INTERNAL_SERVER_ERROR)
161                .header(header::CONTENT_TYPE, "text/plain")
162                .body(Body::from(format!(
163                    "Failed to generate error overlay: {}",
164                    e
165                )))
166                .unwrap()
167        })?;
168
169        return Ok(Response::builder()
170            .status(StatusCode::OK)
171            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
172            .header(header::CACHE_CONTROL, "no-cache")
173            .body(Body::from(html))
174            .unwrap());
175    }
176
177    // Try to serve from cache
178    if let Some((content, content_type)) = state.get_cached_file(path) {
179        return Ok(Response::builder()
180            .status(StatusCode::OK)
181            .header(header::CONTENT_TYPE, content_type)
182            .header(header::CACHE_CONTROL, "no-cache")
183            .body(Body::from(content))
184            .unwrap());
185    }
186
187    // Try to serve from disk (for assets in subdirectories)
188    let file_path = state.get_out_dir().join(path.trim_start_matches('/'));
189    if file_path.exists() && file_path.is_file() {
190        match tokio::fs::read(&file_path).await {
191            Ok(content) => {
192                let content_type = determine_content_type(path);
193                return Ok(Response::builder()
194                    .status(StatusCode::OK)
195                    .header(header::CONTENT_TYPE, content_type)
196                    .header(header::CACHE_CONTROL, "no-cache")
197                    .body(Body::from(content))
198                    .unwrap());
199            }
200            Err(e) => {
201                crate::ui::warning(&format!(
202                    "Failed to read file {}: {}",
203                    file_path.display(),
204                    e
205                ));
206            }
207        }
208    }
209
210    // Special handling for root path
211    if path == "/" {
212        // Try index.html or index.js
213        if let Some((content, content_type)) = state.get_cached_file("/index.html") {
214            let html = inject_reload_script(&content, &content_type);
215
216            return Ok(Response::builder()
217                .status(StatusCode::OK)
218                .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
219                .header(header::CACHE_CONTROL, "no-cache")
220                .body(Body::from(html))
221                .unwrap());
222        }
223
224        // Fallback: serve minimal HTML that loads the bundle
225        // Find the first JavaScript file in the cache as the entry point
226        let entry_point = find_entry_point_from_cache(&state);
227        let html = generate_index_html(entry_point.as_deref()).map_err(|e| {
228            Response::builder()
229                .status(StatusCode::INTERNAL_SERVER_ERROR)
230                .header(header::CONTENT_TYPE, "text/plain")
231                .body(Body::from(format!("Failed to generate HTML: {}", e)))
232                .unwrap()
233        })?;
234
235        return Ok(Response::builder()
236            .status(StatusCode::OK)
237            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
238            .header(header::CACHE_CONTROL, "no-cache")
239            .body(Body::from(html))
240            .unwrap());
241    }
242
243    // File not found
244    Err(Response::builder()
245        .status(StatusCode::NOT_FOUND)
246        .header(header::CONTENT_TYPE, "text/plain")
247        .body(Body::from(format!("File not found: {}", path)))
248        .unwrap())
249}
250
251/// Inject reload script into HTML content.
252///
253/// Adds the reload client script before the closing </body> tag.
254fn inject_reload_script(content: &[u8], content_type: &str) -> Vec<u8> {
255    // Only inject into HTML files
256    if !content_type.starts_with("text/html") {
257        return content.to_vec();
258    }
259
260    let html = String::from_utf8_lossy(content);
261    let script_tag = r#"<script src="/__fob_reload__.js"></script>"#;
262
263    // Try to inject before </body>
264    if let Some(pos) = html.rfind("</body>") {
265        let mut result = String::with_capacity(html.len() + script_tag.len() + 10);
266        result.push_str(&html[..pos]);
267        result.push_str("\n  ");
268        result.push_str(script_tag);
269        result.push('\n');
270        result.push_str(&html[pos..]);
271        return result.into_bytes();
272    }
273
274    // Fallback: append at end
275    let mut result = html.to_string();
276    result.push('\n');
277    result.push_str(script_tag);
278    result.into_bytes()
279}
280
281/// Find the entry point JavaScript file from the cache.
282///
283/// Returns the first JavaScript file found in the cache, or None if no JS files exist.
284fn find_entry_point_from_cache(state: &SharedState) -> Option<String> {
285    state.cache.read().find_entry_point()
286}
287
288/// Generate a minimal index.html that loads the bundle.
289///
290/// This HTML template serves as the shell for the React SPA. It provides:
291/// - The <div id="root"></div> where React will mount
292/// - A script tag that loads the JavaScript bundle
293/// - Hot reload script for development
294///
295/// React 19 components can render <title> and <meta> tags which will be
296/// automatically hoisted into this <head> section.
297///
298/// # Arguments
299///
300/// * `entry_point` - Optional entry point script path (e.g., "/index.js")
301///   If None, falls back to "/virtual_gumbo-client-entry.js"
302///
303/// # Errors
304///
305/// Returns an error if HTML generation fails. This should be treated as a bug.
306fn generate_index_html(entry_point: Option<&str>) -> Result<String, String> {
307    use fob_gen::{Allocator, HtmlBuilder};
308
309    let allocator = Allocator::default();
310    let html_builder = HtmlBuilder::new(&allocator);
311
312    html_builder
313        .index_html(entry_point)
314        .map_err(|e| format!("Failed to generate index.html: {}", e))
315}
316
317/// Determine content type from file extension.
318fn determine_content_type(path: &str) -> &'static str {
319    let extension = std::path::Path::new(path)
320        .extension()
321        .and_then(|ext| ext.to_str())
322        .unwrap_or("");
323
324    match extension {
325        "wasm" => "application/wasm",
326        "js" | "mjs" => "application/javascript",
327        "json" => "application/json",
328        "map" => "application/json",
329        "html" => "text/html; charset=utf-8",
330        "css" => "text/css",
331        "png" => "image/png",
332        "jpg" | "jpeg" => "image/jpeg",
333        "svg" => "image/svg+xml",
334        "woff" => "font/woff",
335        "woff2" => "font/woff2",
336        "ttf" => "font/ttf",
337        _ => "application/octet-stream",
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_inject_reload_script_with_body() {
347        let html = b"<html><body><h1>Test</h1></body></html>";
348        let result = inject_reload_script(html, "text/html");
349
350        let result_str = String::from_utf8(result).unwrap();
351        assert!(result_str.contains(r#"<script src="/__fob_reload__.js"></script>"#));
352        assert!(result_str.contains("</body>"));
353
354        // Script should be before </body>
355        let script_pos = result_str
356            .find(r#"<script src="/__fob_reload__.js"></script>"#)
357            .unwrap();
358        let body_pos = result_str.find("</body>").unwrap();
359        assert!(script_pos < body_pos);
360    }
361
362    #[test]
363    fn test_inject_reload_script_without_body() {
364        let html = b"<html><h1>Test</h1></html>";
365        let result = inject_reload_script(html, "text/html");
366
367        let result_str = String::from_utf8(result).unwrap();
368        assert!(result_str.contains(r#"<script src="/__fob_reload__.js"></script>"#));
369    }
370
371    #[test]
372    fn test_inject_reload_script_non_html() {
373        let js = b"console.log('test');";
374        let result = inject_reload_script(js, "application/javascript");
375
376        // Should not modify non-HTML content
377        assert_eq!(result, js);
378    }
379
380    #[test]
381    fn test_generate_index_html_structure() {
382        let html = generate_index_html(Some("/index.js")).expect("HTML generation should succeed");
383
384        assert!(html.contains("<!DOCTYPE html>"));
385        assert!(html.contains("<div id=\"root\"></div>"));
386        assert!(html.contains(r#"<script type="module" src="/index.js"></script>"#));
387        assert!(html.contains(r#"<script src="/__fob_reload__.js"></script>"#));
388    }
389
390    #[test]
391    fn test_generate_index_html_default_entry() {
392        let html = generate_index_html(None).expect("HTML generation should succeed");
393
394        assert!(html.contains("<!DOCTYPE html>"));
395        assert!(html
396            .contains(r#"<script type="module" src="/virtual_gumbo-client-entry.js"></script>"#));
397    }
398}