From c3b83f30d17b8a0ac33ee64a3ccedfc51287b217 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 24 Jun 2026 09:46:23 -0700 Subject: [PATCH] fix(server): scope Cache-Control no-cache to HTML pages, not static assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket `no-cache` from the earlier fix (#99) regressed the web-e2e cycle regression gate: it reloads /play-classic 240 times, and with no-cache the browser re-validated and recompiled the wasm on every load, blowing past the 30s bridge-ready timeout (green at #98, red from #99 onward). Scope no-cache to just the `include_str!` HTML routes (which change on every deploy and have no validators — the actual staleness source). The `/web` + `/assets` ServeDir keep their default Last-Modified caching, so repeated page loads reuse the downloaded/compiled wasm and the cycle gate is fast again. HTML freshness — the fix Rhys needed — is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- solitaire_server/src/lib.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 4d06791..17ba7c5 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -203,7 +203,12 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { // and the wasm-bindgen-generated `web/pkg/`). The HTML page is the // same regardless of `:id` — it reads the path from `location` in JS // and fetches the replay JSON from `/api/replays/:id`. - let web = Router::new() + // HTML pages are `include_str!`'d into the binary and change on every + // deploy, so they get `Cache-Control: no-cache` (always revalidate). The + // `/web` + `/assets` static files keep ServeDir's default Last-Modified + // caching — applying no-cache to *those* too made the e2e cycle gate's 240 + // page reloads recompile the wasm each time and time out. + let html_pages = Router::new() .route( "/", get(|| async { Html(include_str!("../web/home.html")) }), @@ -233,6 +238,10 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { "/replays", get(|| async { Html(include_str!("../web/replays.html")) }), ) + .layer(axum_middleware::from_fn(no_cache_headers)); + + let web = Router::new() + .merge(html_pages) .nest_service("/web", ServeDir::new("solitaire_server/web")) .nest_service("/assets", ServeDir::new("assets")) .layer(axum_middleware::from_fn(security_headers)); @@ -267,14 +276,18 @@ async fn security_headers(req: Request, next: axum_middleware: HeaderValue::from_static("nosniff"), ); headers.insert("X-Frame-Options", HeaderValue::from_static("DENY")); - // Force revalidation of the web assets. The HTML pages are compiled into - // the binary via `include_str!` and the wasm-bindgen output (canvas.js, - // canvas_bg.wasm, solitaire_wasm.*) keeps fixed filenames that change in - // place on every deploy. Without this, browsers heuristically cache them - // and keep serving stale builds even after a hard reload. `no-cache` lets - // the browser keep a copy but revalidate first; ServeDir supplies - // Last-Modified/ETag so unchanged assets still return a cheap 304. - headers.insert("Cache-Control", HeaderValue::from_static("no-cache")); + res +} + +/// Adds `Cache-Control: no-cache` so the browser always revalidates before +/// using a cached copy. Scoped to the `include_str!` HTML pages (which change +/// on every deploy and have no validators) — not the ServeDir static assets, +/// which keep normal Last-Modified caching so repeated page loads can reuse the +/// already-downloaded/compiled wasm. +async fn no_cache_headers(req: Request, next: axum_middleware::Next) -> Response { + let mut res = next.run(req).await; + res.headers_mut() + .insert("Cache-Control", HeaderValue::from_static("no-cache")); res }