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() {