//! Serves the embedded web client (feature `web-ui`, //! architecture/web-client.md): the trunk bundle staged by `build.rs` //! ships inside the binary and answers every request the gRPC route //! did not claim. Assets are public by design — the app shell is a //! login page at worst; every RPC behind it stays gated by the auth //! layer. use axum::body::Body; use axum::response::Response; use http::{header, HeaderValue, Method, Request, StatusCode, Uri}; use include_dir::{include_dir, Dir}; /// The staged trunk bundle (or the build.rs placeholder page). static DIST: Dir<'_> = include_dir!("$OUT_DIR/webdist"); /// Content type by file extension. The bundle is fully known at build /// time, so an unknown extension is a programmer omission — served as /// octet-stream rather than panicking. fn content_type(path: &str) -> &'static str { match path.rsplit_once('.').map(|(_, ext)| ext) { Some("html") => "text/html; charset=utf-8", Some("css") => "text/css", Some("js") => "application/javascript", Some("wasm") => "application/wasm", Some("svg") => "image/svg+xml", Some("png") => "image/png", Some("ico") => "image/x-icon", Some("txt") => "text/plain; charset=utf-8", _ => "application/octet-stream", } } /// The asset for `uri`, falling back to `index.html` for pathless GETs /// (the app owns its own view state; deep links reload the shell). fn lookup(uri: &Uri) -> (&'static str, &'static [u8]) { let path = uri.path().trim_start_matches('/'); let file = if path.is_empty() { None } else { DIST.get_file(path) }; match file { Some(file) => (content_type(path), file.contents()), None => ( "text/html; charset=utf-8", DIST.get_file("index.html") .map(include_dir::File::contents) // The build script always stages an index.html; an empty // page is the harmless fallback if it ever did not. .unwrap_or(b""), ), } } /// The fallback handler: serves bundle assets for GET/HEAD, 404s /// everything else (non-GET traffic belongs to the gRPC route). pub async fn serve_asset(request: Request) -> Response { if request.method() != Method::GET && request.method() != Method::HEAD { return Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) .expect("static response"); } let (content_type, bytes) = lookup(request.uri()); let body = if request.method() == Method::HEAD { Body::empty() } else { Body::from(bytes) }; Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) .body(body) .expect("static response") } #[cfg(test)] mod tests { use super::*; fn get(path: &str) -> Request { Request::builder() .method(Method::GET) .uri(path) .body(Body::empty()) .expect("request") } async fn body_string(response: Response) -> String { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); String::from_utf8_lossy(&bytes).into_owned() } #[tokio::test] async fn the_root_serves_the_app_shell() { let response = serve_asset(get("/")).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8" ); let html = body_string(response).await; assert!(html.contains("crabidy"), "app shell served"); } #[tokio::test] async fn unknown_paths_fall_back_to_the_shell_get_only() { let response = serve_asset(get("/some/deep/link")).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8" ); let post = Request::builder() .method(Method::POST) .uri("/not-grpc") .body(Body::empty()) .expect("request"); let response = serve_asset(post).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[test] fn content_types_cover_the_bundle() { assert_eq!(content_type("a.wasm"), "application/wasm"); assert_eq!(content_type("a.js"), "application/javascript"); assert_eq!(content_type("a.css"), "text/css"); assert_eq!(content_type("weird.bin"), "application/octet-stream"); } }