fix(maestro): 404 missing assets + no-cache shell so redeploys don't blank
ci / rust (push) Successful in 5m50s

The SPA fallback returned index.html (HTTP 200, text/html) for ANY unknown
path, including missing /assets/*. index.html was also served with no
Cache-Control, so browsers cached the shell. After a redeploy rotated Vite's
content hashes, a cached shell requested its old /assets/index-OLDHASH.js,
the server answered with the HTML shell, and the browser tried to execute
HTML as JS -> blank page (seen on Android Chrome while the installed PWA,
with its own warm cache, kept working).

- Missing paths that name a file (have an extension) now 404 instead of
  falling through to index.html; only extensionless SPA routes fall back.
- index.html / sw.js / manifest / icons: Cache-Control: no-cache.
- Content-hashed assets/: public, max-age=31536000, immutable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:46:30 +00:00
co-authored by Claude Opus 4.8
parent 6bb45ea8e4
commit 3ef323f354
+82 -5
View File
@@ -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"));
}
}