[fix](trx-frontend-http): stop freezing the page in the browser cache #57
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user