[fix](trx-frontend-http): stop freezing the page in the browser cache
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m37s
CI / frontend (pull_request) Successful in 4m33s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Failing after 14m3s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m39s
CI / reuse (push) Successful in 6s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #57.
This commit is contained in:
sjg
2026-08-07 10:50:26 +02:00
co-authored by Claude Opus 5
parent 17300170cc
commit c8b6f2d536
2 changed files with 229 additions and 19 deletions
@@ -10,7 +10,9 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; 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; use crate::server::status;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -62,55 +64,100 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
#[get("/")] #[get("/")]
pub(crate) async fn index(req: HttpRequest) -> impl Responder { pub(crate) async fn index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/map")]
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder { pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/digital-modes")]
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder { pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/recorder")]
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder { pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/settings")]
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder { pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/about")]
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder { pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder { pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/satellites")]
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder { pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder { pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html(); 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")] #[get("/style.css")]
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder { pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
let c = gz_style_css(); 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")] #[get("/themes.css")]
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder { pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
let c = gz_themes_css(); 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 // 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<String>) -
let Some(entry) = generated_asset_cache().get(filename.as_str()) else { let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
return HttpResponse::NotFound().finish(); 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-<hash>.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. /// 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")] #[get("/bandplan.json")]
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder { pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
let c = gz_bandplan_json(); 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")] #[get("/vendor/opus-decoder-0.7.11.min.js")]
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder { pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
let c = gz_opus_decoder_js(); 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")] #[get("/vendor/leaflet.js")]
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder { pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_js(); 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")] #[get("/vendor/leaflet.css")]
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder { pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
let c = gz_leaflet_css(); 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")] #[get("/vendor/marker-icon.png")]
@@ -355,6 +432,44 @@ mod tests {
assert!(!generated_asset_cache().contains_key("../app.js")); 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] #[test]
fn generated_asset_mime_types_are_restricted() { fn generated_asset_mime_types_are_restricted() {
assert_eq!( assert_eq!(
@@ -307,10 +307,35 @@ where
} }
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets. /// 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( fn static_asset_response(
req: &HttpRequest, req: &HttpRequest,
content_type: &'static str, content_type: &'static str,
entry: &GzCacheEntry, entry: &GzCacheEntry,
caching: AssetCaching,
) -> HttpResponse { ) -> HttpResponse {
let etag = &entry.etag; let etag = &entry.etag;
// Check If-None-Match for conditional GET. // Check If-None-Match for conditional GET.
@@ -319,7 +344,7 @@ fn static_asset_response(
if val == etag || val == "*" { if val == etag || val == "*" {
return HttpResponse::NotModified() return HttpResponse::NotModified()
.insert_header((header::ETAG, etag.to_owned())) .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(); .finish();
} }
} }
@@ -339,7 +364,7 @@ fn static_asset_response(
.insert_header((header::CONTENT_TYPE, content_type)) .insert_header((header::CONTENT_TYPE, content_type))
.insert_header((header::CONTENT_ENCODING, encoding)) .insert_header((header::CONTENT_ENCODING, encoding))
.insert_header((header::ETAG, etag.to_owned())) .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)) .body(Bytes::copy_from_slice(body))
} }
@@ -840,6 +865,76 @@ mod tests {
// Endpoint tests using actix_web::test // 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. /// GET /status returns 200 with valid JSON containing rig snapshot fields.
#[actix_web::test] #[actix_web::test]
async fn test_status_endpoint_returns_json() { async fn test_status_endpoint_returns_json() {