From c8b6f2d536f39fd0d030e0d8755d98bcf6816a21 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 7 Aug 2026 10:50:26 +0200 Subject: [PATCH] [fix](trx-frontend-http): stop freezing the page in the browser cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html, the stylesheets and the entry bundles are all served from fixed URLs and answered with `public, max-age=31536000, immutable`. Nothing in those URLs changes when the bytes behind them do, and immutable tells the browser not to ask, so a client that visited once could go on running the page it downloaded then — for a year, with the build-stamped ETag never consulted. That is how a layout fix ships and one browser still shows the old behaviour while every other one has it: not a rendering difference, a copy of last week's stylesheet. Only the shared chunks are content-addressed — esbuild hashes their names — so only they can be kept forever. Everything served from a stable URL now answers `no-cache`, which asks and gets a 304 in the ordinary case, at the cost of one conditional request per asset per load. Vendored files with a version in the URL stay immutable; Leaflet, whose URL does not name its version, revalidates with the rest. Covered both ways: a unit test on the policy each asset gets, and endpoint tests that the page, the stylesheet and app.js come back revalidating with an ETag, and that an unchanged one answers 304 under the same policy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- .../trx-frontend-http/src/api/assets.rs | 149 ++++++++++++++++-- .../trx-frontend-http/src/api/mod.rs | 99 +++++++++++- 2 files changed, 229 insertions(+), 19 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs index 8b25e898..18a4146f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs @@ -10,7 +10,9 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder}; use std::collections::HashMap; use std::sync::OnceLock; -use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES}; +use super::{ + gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES, +}; use crate::server::status; // --------------------------------------------------------------------------- @@ -62,55 +64,100 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css"); #[get("/")] pub(crate) async fn index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/map")] pub(crate) async fn map_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/digital-modes")] pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/recorder")] pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/settings")] pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/about")] pub(crate) async fn about_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/statistics")] pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/satellites")] pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/bookmarks")] pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); - static_asset_response(&req, "text/html; charset=utf-8", c) + static_asset_response( + &req, + "text/html; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } // --------------------------------------------------------------------------- @@ -148,13 +195,13 @@ pub(crate) async fn logo() -> impl Responder { #[get("/style.css")] pub(crate) async fn style_css(req: HttpRequest) -> impl Responder { let c = gz_style_css(); - static_asset_response(&req, "text/css; charset=utf-8", c) + static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate) } #[get("/themes.css")] pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder { let c = gz_themes_css(); - static_asset_response(&req, "text/css; charset=utf-8", c) + static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate) } // Generated filenames are supplied only by build.rs and resolved through this @@ -178,7 +225,22 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path) - let Some(entry) = generated_asset_cache().get(filename.as_str()) else { return HttpResponse::NotFound().finish(); }; - static_asset_response(&req, content_type, entry) + static_asset_response( + &req, + content_type, + entry, + generated_asset_caching(&filename), + ) +} + +/// esbuild names shared chunks `chunk-.js` and leaves the entry points on +/// a fixed name, so only the chunks are safe to keep forever. +fn generated_asset_caching(filename: &str) -> AssetCaching { + if filename.starts_with("chunk-") { + AssetCaching::Immutable + } else { + AssetCaching::Revalidate + } } /// Serve a received SSTV picture out of the local cache. @@ -221,7 +283,12 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse { #[get("/bandplan.json")] pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder { let c = gz_bandplan_json(); - static_asset_response(&req, "application/json; charset=utf-8", c) + static_asset_response( + &req, + "application/json; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } // --------------------------------------------------------------------------- @@ -239,7 +306,12 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder { #[get("/vendor/opus-decoder-0.7.11.min.js")] pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder { let c = gz_opus_decoder_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) + static_asset_response( + &req, + "application/javascript; charset=utf-8", + c, + AssetCaching::Immutable, + ) } // --------------------------------------------------------------------------- @@ -249,13 +321,18 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder { #[get("/vendor/leaflet.js")] pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder { let c = gz_leaflet_js(); - static_asset_response(&req, "application/javascript; charset=utf-8", c) + static_asset_response( + &req, + "application/javascript; charset=utf-8", + c, + AssetCaching::Revalidate, + ) } #[get("/vendor/leaflet.css")] pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder { let c = gz_leaflet_css(); - static_asset_response(&req, "text/css; charset=utf-8", c) + static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate) } #[get("/vendor/marker-icon.png")] @@ -355,6 +432,44 @@ mod tests { assert!(!generated_asset_cache().contains_key("../app.js")); } + /// A browser that visited before an upgrade must not keep the old page. + /// index.html, the stylesheets and the entry bundles all keep their URL + /// from one build to the next, so an immutable year-long policy on them + /// leaves a client running whatever it first downloaded — a fixed layout + /// stays broken in the browser that happened to cache it, and no reload + /// short of a forced one gets the fix. + #[test] + fn assets_that_keep_their_url_across_builds_are_revalidated() { + for name in ["app.js", "aprs.js", "map-core.js"] { + assert_eq!( + generated_asset_caching(name), + AssetCaching::Revalidate, + "{name} is served from the same URL after every build" + ); + } + assert_eq!( + AssetCaching::Revalidate.header_value(), + "no-cache", + "revalidating assets must ask before they are reused" + ); + } + + /// Only the chunks carry a hash of their own bytes, so only they can be + /// kept forever. + #[test] + fn content_addressed_chunks_stay_immutable() { + let chunk = status::GENERATED_ASSETS + .iter() + .map(|(name, _)| *name) + .find(|name| name.starts_with("chunk-")) + .expect("the bundle splits into at least one shared chunk"); + assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable); + assert!( + AssetCaching::Immutable.header_value().contains("immutable"), + "a hashed name is safe to keep" + ); + } + #[test] fn generated_asset_mime_types_are_restricted() { assert_eq!( diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index 47bb9c03..9143a60b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -307,10 +307,35 @@ where } /// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets. +/// How long a browser may hold an asset before asking about it again. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum AssetCaching { + /// The name carries a hash of the bytes, so a change is a new URL and the + /// old one can be kept forever. + Immutable, + /// Served from the same URL for the life of the deployment, with different + /// bytes after an upgrade: index.html, the stylesheets, the entry bundles. + /// These must be revalidated, or a browser that visited before the upgrade + /// keeps running the old page — for a year, with the ETag never consulted, + /// which is how a fixed layout stays broken in one browser and not another. + /// The ETag makes the revalidation a 304 in the usual case. + Revalidate, +} + +impl AssetCaching { + pub(crate) fn header_value(self) -> &'static str { + match self { + Self::Immutable => "public, max-age=31536000, immutable", + Self::Revalidate => "no-cache", + } + } +} + fn static_asset_response( req: &HttpRequest, content_type: &'static str, entry: &GzCacheEntry, + caching: AssetCaching, ) -> HttpResponse { let etag = &entry.etag; // Check If-None-Match for conditional GET. @@ -319,7 +344,7 @@ fn static_asset_response( if val == etag || val == "*" { return HttpResponse::NotModified() .insert_header((header::ETAG, etag.to_owned())) - .insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable")) + .insert_header((header::CACHE_CONTROL, caching.header_value())) .finish(); } } @@ -339,7 +364,7 @@ fn static_asset_response( .insert_header((header::CONTENT_TYPE, content_type)) .insert_header((header::CONTENT_ENCODING, encoding)) .insert_header((header::ETAG, etag.to_owned())) - .insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable")) + .insert_header((header::CACHE_CONTROL, caching.header_value())) .body(Bytes::copy_from_slice(body)) } @@ -840,6 +865,76 @@ mod tests { // Endpoint tests using actix_web::test // ====================================================================== + /// The page and the bundles it pulls in are served from the same URLs after + /// every upgrade, so the browser has to ask whether they changed. Served + /// as immutable for a year, a browser that visited once kept the old page + /// and never saw a fix again -- which is how a corrected layout stays + /// broken in one browser while every other one has it. + #[actix_web::test] + async fn documents_and_entry_bundles_are_revalidated_not_frozen() { + let app = actix_test::init_service( + App::new() + .service(assets::index) + .service(assets::style_css) + .service(assets::generated_asset), + ) + .await; + + for path in ["/", "/style.css", "/app.js"] { + let req = actix_test::TestRequest::get().uri(path).to_request(); + let resp = actix_test::call_service(&app, req).await; + assert_eq!(resp.status(), 200, "{path} should be served"); + let cache_control = resp + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + assert_eq!(cache_control, "no-cache", "{path} must be revalidated"); + assert!( + resp.headers().contains_key(header::ETAG), + "{path} needs an ETag, or revalidating costs a full download" + ); + } + } + + /// Revalidation has to stay cheap: an unchanged asset answers 304, and the + /// policy on that answer matches the one on the body. + #[actix_web::test] + async fn an_unchanged_document_answers_not_modified() { + let app = actix_test::init_service(App::new().service(assets::style_css)).await; + + let first = actix_test::call_service( + &app, + actix_test::TestRequest::get() + .uri("/style.css") + .to_request(), + ) + .await; + let etag = first + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .expect("style.css carries an ETag") + .to_owned(); + + let conditional = actix_test::call_service( + &app, + actix_test::TestRequest::get() + .uri("/style.css") + .insert_header((header::IF_NONE_MATCH, etag)) + .to_request(), + ) + .await; + assert_eq!(conditional.status(), 304); + assert_eq!( + conditional + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-cache") + ); + } + /// GET /status returns 200 with valid JSON containing rig snapshot fields. #[actix_web::test] async fn test_status_endpoint_returns_json() {