From e9731a1ea6d5531d76b6149fbb6e7a638bf91e68 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 15 Sep 2026 17:42:53 +0200 Subject: [PATCH 1/2] fix(webui): ship an icon the browser can decode, and stop answering the shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in a HAR capture from a real session: **sixty-six requests for `/favicon.ico` in fifty-one seconds**, one to two a second without pause, every one answered `200 text/html` — about forty kilobytes of the client shell spent on a tab icon. The cause is three facts that had never met. `webapp/index.html` declares no icon, so the browser asks for `/favicon.ico` by convention. No build has ever produced that file — there is no `webapp/public/` directory at all. And this module is the router fallback, so anything not claimed by an API route gets the shell. The browser cannot decode HTML as an image, learns nothing from a 200, and asks again on the next occasion. The comment in `asset()` already listed "favicon.ico" among the things it serves, assuming a file that never existed. Both halves are fixed because either alone leaves the loop reachable. The icon is declared and shipped — the brand mark from the client's own sidebar, an accent square with four equaliser bars cut out at the heights `.brand-mark i` gives them — so a browser that reads the document asks for that and caches it. And `/favicon.ico` is reserved, so one that asks by convention anyway gets a 404: a definitive answer, given once, instead of a 200 that teaches it nothing. The test asserts the content type and not only the status, because the type is what the browser choked on, and it holds whether or not a client build is embedded in the binary under test — the Rust suite runs against the placeholder. Verified against a real release binary as well: the icon answers `image/svg+xml`, and `/favicon.ico` answers a JSON 404. Claude-Session: https://claude.ai/code/session_01HreLtK4rFopmncpZEHzp59 Signed-off-by: InstaZDLL --- src/webui.rs | 20 ++++++++++++++++++-- tests/service.rs | 18 ++++++++++++++++++ webapp/index.html | 6 ++++++ webapp/public/favicon.svg | 14 ++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 webapp/public/favicon.svg 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..c3021057 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -281,6 +281,24 @@ 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. + 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("text/html")); + // 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 @@ + + + + + + + + + + From 6df57f802d2b7c27ed4196ff493e02108adbb2fe Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 15 Sep 2026 18:14:33 +0200 Subject: [PATCH 2/2] test(webui): say what the icon 404 is, not what it is not "Not `text/html`" is also satisfied by a content type that never arrived, so it pinned nothing about the answer actually given. The route answers a JSON error body, and the assertion says so. Removing the reservation still fails it, now on the status and the type both. Claude-Session: https://claude.ai/code/session_01HreLtK4rFopmncpZEHzp59 Signed-off-by: InstaZDLL --- tests/service.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/service.rs b/tests/service.rs index c3021057..14dae908 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -290,14 +290,18 @@ async fn embedded_web_client_serves_shell_without_shadowing_the_api() { // 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 + assert!(icon .headers() .get("content-type") .map(|value| value.to_str().unwrap().to_owned()) .unwrap_or_default() - .starts_with("text/html")); + .starts_with("application/json")); // A client route that merely starts like a reserved endpoint is not one. let lookalike = get("/reference-guide").await;