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
`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
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.
```sh
+33 -1
View File
@@ -6,7 +6,7 @@ pub mod session;
pub mod users;
use std::fmt;
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
use askama::Template;
@@ -204,8 +204,21 @@ pub struct Page {
pub flash: Option<Flash>,
pub active_nav: String,
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 {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self {
@@ -214,6 +227,7 @@ impl Page {
flash: None,
active_nav: active_nav.to_string(),
version: crate::VERSION,
asset_version: ASSET_VERSION.as_str(),
}
}
@@ -995,6 +1009,24 @@ mod tests {
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]
async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await;
+3 -3
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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="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="icon" href="/static/favicon.svg">
</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 %}
<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>
<script src="/static/app.js?v={{ page.version }}"></script>
<script src="/static/app.js?v={{ page.asset_version }}"></script>
</body>
</html>