134 lines
4.6 KiB
Rust
134 lines
4.6 KiB
Rust
//! 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<Body>) -> 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<Body> {
|
|
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");
|
|
}
|
|
}
|