fix(maestro): 404 missing assets + no-cache shell so redeploys don't blank
ci / rust (push) Successful in 5m50s
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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user