138 lines
4.4 KiB
Rust
138 lines
4.4 KiB
Rust
//! The embedded SPA. See DESIGN.md §11: `web/` is built by Vite and compiled
|
|
//! into this binary via `include_dir`, so `cargo run` serves the whole UI
|
|
//! with no external files. `build.rs` resolves which directory gets embedded.
|
|
|
|
use axum::http::{header, StatusCode, Uri};
|
|
use axum::response::{IntoResponse, Response};
|
|
use include_dir::{include_dir, Dir};
|
|
|
|
static BUNDLE: Dir<'_> = include_dir!("$ARR_WEB_BUNDLE_DIR");
|
|
|
|
/// Fallback handler for everything the API router did not claim.
|
|
pub async fn serve(uri: Uri) -> Response {
|
|
serve_path(uri.path())
|
|
}
|
|
|
|
/// Serve a path from the bundle.
|
|
///
|
|
/// Content-hashed files under `assets/` are immutable, so they get a year of
|
|
/// `immutable` cache. The shell and everything else is `no-cache`: the shell
|
|
/// references the current hashed asset names and a stale cached copy would
|
|
/// point at files that no longer exist after a rebuild.
|
|
///
|
|
/// A miss falls back to the shell only for extensionless paths (client-side
|
|
/// routes). A miss that names a file — a stale `assets/index-OLD.js` — is a
|
|
/// real 404: serving HTML where the browser expects JS blanks the page. And
|
|
/// `/api/*` never falls through to HTML; an unknown API path is a 404.
|
|
fn serve_path(path: &str) -> Response {
|
|
let trimmed = path.trim_start_matches('/');
|
|
if trimmed == "api" || trimmed.starts_with("api/") {
|
|
return not_found();
|
|
}
|
|
if trimmed.is_empty() {
|
|
return serve_index();
|
|
}
|
|
match BUNDLE.get_file(trimmed) {
|
|
Some(file) => {
|
|
let mime = mime_guess::from_path(trimmed).first_or_octet_stream();
|
|
let cache = if trimmed.starts_with("assets/") {
|
|
"public, max-age=31536000, immutable"
|
|
} else {
|
|
"no-cache"
|
|
};
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, mime.as_ref()),
|
|
(header::CACHE_CONTROL, cache),
|
|
],
|
|
file.contents(),
|
|
)
|
|
.into_response()
|
|
}
|
|
None if names_a_file(trimmed) => not_found(),
|
|
None => serve_index(),
|
|
}
|
|
}
|
|
|
|
fn serve_index() -> Response {
|
|
match BUNDLE.get_file("index.html") {
|
|
Some(file) => (
|
|
[
|
|
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
file.contents(),
|
|
)
|
|
.into_response(),
|
|
// build.rs guarantees index.html exists in whatever it embedded.
|
|
None => not_found(),
|
|
}
|
|
}
|
|
|
|
/// Whether the last segment carries an extension, i.e. names a concrete file
|
|
/// rather than a client-side route.
|
|
fn names_a_file(path: &str) -> bool {
|
|
path.rsplit('/').next().is_some_and(|seg| seg.contains('.'))
|
|
}
|
|
|
|
fn not_found() -> Response {
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
|
"not found",
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn content_type(response: &Response) -> String {
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or_default()
|
|
.to_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn the_shell_is_embedded_html() {
|
|
let response = serve_path("/");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(content_type(&response).starts_with("text/html"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_extensionless_route_falls_back_to_the_shell() {
|
|
let response = serve_path("/movies/42");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(content_type(&response).starts_with("text/html"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_hashed_asset_is_a_404_never_html() {
|
|
let response = serve_path("/assets/index-deadbeef.js");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
assert!(!content_type(&response).starts_with("text/html"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_api_paths_never_fall_through_to_html() {
|
|
let response = serve_path("/api/nope");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
assert!(!content_type(&response).starts_with("text/html"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_shell_is_never_cached() {
|
|
let cache = serve_path("/")
|
|
.headers()
|
|
.get(header::CACHE_CONTROL)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::to_owned);
|
|
assert_eq!(cache.as_deref(), Some("no-cache"));
|
|
}
|
|
}
|