Static assets: bust caches with a content hash instead of the crate version

/static/app.css, app.js and theme.js URLs now carry the first 12 hex digits
of a SHA-256 over the embedded assets, so any CSS or JS change reaches
browsers that cached the previous build without a version bump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 22:29:13 +00:00
co-authored by Claude Fable 5.1
parent 6b0fa51e87
commit 78e7e4aa2c
3 changed files with 39 additions and 5 deletions
+3 -1
View File
@@ -9,7 +9,9 @@ already be complete.
The deployed binary does not require Node. For UI development, edit The deployed binary does not require Node. For UI development, edit
`src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run `src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run
`npm run css` and commit the regenerated `src/web/static/app.css` alongside the `npm run css` and commit the regenerated `src/web/static/app.css` alongside the
source. CI and release preparation should run `npm run css:check` to detect source. The `/static/*.css|js` URLs carry a hash of the embedded assets
(`web::ASSET_VERSION`), so a new build busts browser caches on its own — no
hard refresh and no version bump needed. CI and release preparation should run `npm run css:check` to detect
stylesheet drift. stylesheet drift.
```sh ```sh
+33 -1
View File
@@ -6,7 +6,7 @@ pub mod session;
pub mod users; pub mod users;
use std::fmt; use std::fmt;
use std::sync::Mutex; use std::sync::{LazyLock, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
use askama::Template; use askama::Template;
@@ -204,8 +204,21 @@ pub struct Page {
pub flash: Option<Flash>, pub flash: Option<Flash>,
pub active_nav: String, pub active_nav: String,
pub version: &'static str, pub version: &'static str,
/// Cache-busting token for `/static/*.css|js` URLs: a content hash, so
/// any stylesheet or script change reaches browsers that cached the
/// previous build (they are served with a one-day `max-age`).
pub asset_version: &'static str,
} }
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
let mut hasher = Sha256::new();
hasher.update(include_str!("static/app.css"));
hasher.update(include_str!("static/app.js"));
hasher.update(include_str!("static/theme.js"));
hex::encode(hasher.finalize())[..12].to_string()
});
impl Page { impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self { pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self { Self {
@@ -214,6 +227,7 @@ impl Page {
flash: None, flash: None,
active_nav: active_nav.to_string(), active_nav: active_nav.to_string(),
version: crate::VERSION, version: crate::VERSION,
asset_version: ASSET_VERSION.as_str(),
} }
} }
@@ -995,6 +1009,24 @@ mod tests {
assert_eq!(session.status(), StatusCode::OK); assert_eq!(session.status(), StatusCode::OK);
} }
#[test]
fn asset_urls_carry_a_content_hash_not_the_crate_version() {
let page = Page::new("t", None, "latest");
assert_eq!(page.asset_version.len(), 12);
assert!(page.asset_version.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(page.asset_version, crate::VERSION);
let html = ErrorTemplate {
page,
heading: "h".into(),
message: "m".into(),
}
.render()
.unwrap();
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
assert!(html.contains(&expected), "{html}");
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
}
#[tokio::test] #[tokio::test]
async fn static_assets_use_content_hash_etags() { async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await; let (_dir, state) = test_state(Config::default()).await;
+3 -3
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }} · The Daily EPUB</title> <title>{{ page.title }} · The Daily EPUB</title>
<script src="/static/theme.js?v={{ page.version }}"></script> <script src="/static/theme.js?v={{ page.asset_version }}"></script>
<link rel="preload" href="/static/Newsreader.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/Newsreader.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}"> <link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml"> <link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg"> <link rel="icon" href="/static/favicon.svg">
</head> </head>
@@ -47,6 +47,6 @@
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %} {% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
<main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main> <main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main>
<footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer> <footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer>
<script src="/static/app.js?v={{ page.version }}"></script> <script src="/static/app.js?v={{ page.asset_version }}"></script>
</body> </body>
</html> </html>