1use 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
19pub struct DevServer {
21 config: DevConfig,
23 state: SharedState,
25}
26
27impl DevServer {
28 pub fn new(config: DevConfig, state: SharedState) -> Self {
35 Self { config, state }
36 }
37
38 pub async fn start(self) -> Result<()> {
54 let addr = self.config.addr;
55 let server_url = self.config.server_url();
56
57 let app = self.build_router();
59
60 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 axum::serve(listener, app)
69 .await
70 .map_err(|e| crate::error::CliError::Server(format!("Server error: {}", e)))?;
71
72 Ok(())
73 }
74
75 fn build_router(self) -> Router {
77 let state = self.state.clone();
78
79 Router::new()
80 .route("/__fob_sse__", get(handle_sse))
82 .route("/__fob_reload__.js", get(handle_reload_script))
84 .route("/__fob_assets__/{*path}", get(crate::dev::handle_asset))
86 .route("/favicon.ico", get(handle_favicon))
88 .fallback(handle_request)
90 .layer(
91 CorsLayer::new()
93 .allow_origin(Any)
94 .allow_methods(Any)
95 .allow_headers(Any),
96 )
97 .with_state(state)
98 }
99}
100
101async 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 let (id, rx) = state.register_client();
111
112 crate::ui::info(&format!("Client {} connected via SSE", id));
113
114 let _ = state
116 .broadcast(&crate::dev::DevEvent::ClientConnected { id })
117 .await;
118
119 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
129async 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
141async fn handle_favicon() -> impl IntoResponse {
143 StatusCode::NO_CONTENT
144}
145
146async fn handle_request(
148 State(state): State<SharedState>,
149 uri: Uri,
150) -> Result<impl IntoResponse, Response> {
151 let path = uri.path();
152
153 let status = state.get_status();
155
156 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 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 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 if path == "/" {
212 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 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 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
251fn inject_reload_script(content: &[u8], content_type: &str) -> Vec<u8> {
255 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 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 let mut result = html.to_string();
276 result.push('\n');
277 result.push_str(script_tag);
278 result.into_bytes()
279}
280
281fn find_entry_point_from_cache(state: &SharedState) -> Option<String> {
285 state.cache.read().find_entry_point()
286}
287
288fn 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
317fn 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 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 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}