diff --git a/crates/maestro-web/src/static_bundle.rs b/crates/maestro-web/src/static_bundle.rs index fd3b508..0ef6b19 100644 --- a/crates/maestro-web/src/static_bundle.rs +++ b/crates/maestro-web/src/static_bundle.rs @@ -17,6 +17,11 @@ static BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist"); /// Serve `/index.html`, falling back to a tiny placeholder when the bundle /// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough /// (so refreshing on `#/sessions/foo` doesn't 404). +/// +/// The shell is sent `Cache-Control: no-cache` so the browser revalidates it +/// on every load. It must not be cached: `index.html` references the current +/// content-hashed `/assets/*` filenames, and a redeploy changes those hashes. +/// A stale cached shell would point at asset names that no longer exist. pub fn serve_index() -> Response { match BUNDLE.get_file("index.html") { Some(f) => html_response(f.contents()), @@ -25,6 +30,17 @@ pub fn serve_index() -> Response { } /// Serve any path beneath the bundle (e.g. `assets/index-deadbeef.js`). +/// +/// Content-hashed files under `assets/` are immutable (the hash changes when +/// the contents do), so they get a long `immutable` cache. Everything else +/// (`sw.js`, `manifest.webmanifest`, icons) is `no-cache` so redeploys take +/// effect immediately. +/// +/// A miss only falls through to the SPA shell for *extensionless* paths (real +/// client-side routes like `/sessions/foo`). A miss on a path that names a file +/// — anything with an extension, e.g. a stale `assets/index-OLDHASH.js` — +/// returns **404**, never the HTML shell. Serving HTML where the client expects +/// JS makes the page silently blank after a redeploy rotates the hashes. pub fn serve_path(path: &str) -> Response { let trimmed = path.trim_start_matches('/'); if trimmed.is_empty() { @@ -33,21 +49,49 @@ pub fn serve_path(path: &str) -> Response { match BUNDLE.get_file(trimmed) { Some(f) => { 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::CONTENT_TYPE, mime.as_ref()), + (header::CACHE_CONTROL, cache), + ], f.contents().to_vec(), ) .into_response() } - // SPA fallback: any unknown path falls through to index.html so the - // client-side router can resolve the route. + None if looks_like_file(trimmed) => not_found(), + // SPA fallback: an extensionless route falls through to index.html so + // the client-side router can resolve it. None => serve_index(), } } +/// Whether the last path segment carries a filename extension (e.g. +/// `assets/index-abc123.js`, `favicon.ico`). Such paths name a concrete file; +/// a miss is a genuine 404 rather than a SPA route. +fn looks_like_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")], + Body::from("not found"), + ) + .into_response() +} + fn html_response(bytes: &[u8]) -> Response { ( - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], bytes.to_vec(), ) .into_response() @@ -94,8 +138,9 @@ mod tests { } #[test] - fn unknown_path_falls_back_to_index() { + fn unknown_extensionless_route_falls_back_to_index() { let resp = serve_path("/some/missing/route"); + assert_eq!(resp.status(), StatusCode::OK); let ct = resp .headers() .get(header::CONTENT_TYPE) @@ -103,4 +148,36 @@ mod tests { .unwrap_or(""); assert!(ct.starts_with("text/html"), "ct={ct}"); } + + #[test] + fn missing_hashed_asset_404s_instead_of_serving_html() { + // A stale shell requesting an old asset hash must get 404, not the + // HTML shell — otherwise the browser executes HTML as JS and blanks. + let resp = serve_path("/assets/index-deadbeef.js"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let ct = resp + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!(!ct.starts_with("text/html"), "ct={ct}"); + } + + #[test] + fn index_is_sent_no_cache() { + let cc = serve_index() + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + assert_eq!(cc.as_deref(), Some("no-cache")); + } + + #[test] + fn looks_like_file_distinguishes_assets_from_routes() { + assert!(looks_like_file("assets/index-abc123.js")); + assert!(looks_like_file("favicon.ico")); + assert!(!looks_like_file("sessions/foo")); + assert!(!looks_like_file("some/missing/route")); + } }