[fix](trx-frontend-http): stop freezing the page in the browser cache #57

Merged
sjg merged 1 commits from fix/asset-cache-revalidation into main 2026-08-07 19:02:46 +02:00
2 changed files with 229 additions and 19 deletions
Showing only changes of commit c8b6f2d536 - Show all commits
@@ -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<String>) -
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-<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.
@@ -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!(
@@ -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() {