diff --git a/src/webui.rs b/src/webui.rs index acfc8a3a..f1d95caf 100644 --- a/src/webui.rs +++ b/src/webui.rs @@ -32,7 +32,23 @@ const RESERVED_PREFIXES: [&str; 3] = ["api/", "rest/", "share/"]; /// Single endpoints, matched whole. Comparing these by prefix would swallow /// client routes that merely start the same way — `/reference-guide` is a /// legitimate page, not a mistyped `/reference`. -const RESERVED_EXACT: [&str; 4] = ["health", "ready", "openapi.json", "reference"]; +/// +/// `favicon.ico` is here for a different reason than the rest: not because the +/// server claims it, but because no build produces it and answering the shell +/// was worse than answering nothing. A browser that asks for an icon and +/// receives `200 text/html` cannot decode it, learns nothing, and asks again — +/// sixty-six times in fifty-one seconds on a real session, forty kilobytes of +/// HTML spent on a tab icon. A 404 is a definitive answer and is asked once. +/// The icon this build does ship is declared in `index.html` and served as +/// `favicon.svg`; shipping a real `.ico` beside it would mean taking this line +/// out, since a reserved path is refused before the assets are looked at. +const RESERVED_EXACT: [&str; 5] = [ + "health", + "ready", + "openapi.json", + "reference", + "favicon.ico", +]; pub async fn handler(uri: Uri) -> Response { let path = uri.path().trim_start_matches('/'); @@ -55,7 +71,7 @@ fn asset(path: &str) -> Option { let content_type = HeaderValue::from_str(mime).unwrap_or(HeaderValue::from_static("application/octet-stream")); // Only files under the bundler's output directory carry a content hash, so - // only they are safe to freeze. Everything else — the shell, favicon.ico, + // only they are safe to freeze. Everything else — the shell, favicon.svg, // robots.txt, a service worker — keeps a stable name across deploys, and // pinning those for a year would strand clients on a stale build. let cache_control = if path.starts_with("assets/") { diff --git a/tests/service.rs b/tests/service.rs index 9aed7685..14dae908 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -281,6 +281,28 @@ async fn embedded_web_client_serves_shell_without_shadowing_the_api() { .unwrap_or_default() .starts_with("text/html")); + // An icon request is answered once, or not at all — never with the shell. + // + // No build produces a `favicon.ico`, so the fallback used to hand the + // browser the client page. It cannot decode HTML as an image, learns + // nothing from a 200, and asks again: sixty-six requests in fifty-one + // seconds on a real session, forty kilobytes of markup spent on a tab icon. + // A 404 is a definitive answer. Asserted on the content type rather than on + // the status alone, because that is what the browser choked on — and this + // holds whether or not a client build is embedded in the binary under test. + // + // Stated as what it *is* rather than as what it is not: "not HTML" is also + // satisfied by a header that never arrived, so it would pin nothing about + // the answer actually given. + let icon = get("/favicon.ico").await; + assert_eq!(icon.status(), StatusCode::NOT_FOUND); + assert!(icon + .headers() + .get("content-type") + .map(|value| value.to_str().unwrap().to_owned()) + .unwrap_or_default() + .starts_with("application/json")); + // A client route that merely starts like a reserved endpoint is not one. let lookalike = get("/reference-guide").await; assert_eq!(lookalike.status(), StatusCode::OK); diff --git a/webapp/index.html b/webapp/index.html index e1bb9918..7061b505 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -9,6 +9,12 @@ content="WaveFlow is your private, self-hosted music library." /> + + WaveFlow diff --git a/webapp/public/favicon.svg b/webapp/public/favicon.svg new file mode 100644 index 00000000..d4ef734d --- /dev/null +++ b/webapp/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + +