From 165d78175eb5b44689415eca978da881ca5d1b01 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 09:06:10 +0800 Subject: [PATCH] web: name assets with a content hash so a stale bundle cannot be served --- README.md | 7 +++ docs/design.md | 32 ++++++++++++++ frontend/vite.config.ts | 17 ++++--- src/api/mod.rs | 98 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 139 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3f42fac..cf01a93 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ The service follows the constraints of the workspace it belongs to. The frontend must be built before the release binary so that its output can be embedded. A release build does this automatically. +Every built asset carries a content hash in its name, so replacing the binary +replaces those names. A browser therefore cannot be holding a stale bundle: +the new bundle is requested under a URL it has never seen. The entry point +is served with `no-cache, must-revalidate`, and hashed assets with +`public, max-age=31536000, immutable`. The service registers no service +worker. + ```sh # Development: a debug build embeds whatever is in frontend/dist and does # not run the frontend build. diff --git a/docs/design.md b/docs/design.md index 66c7208..434a422 100644 --- a/docs/design.md +++ b/docs/design.md @@ -324,6 +324,38 @@ comment uses. A shortcut reference such as `[`Vec3`]` is reduced to the item name; without that, the brackets and backticks appear verbatim in the summary. +## Caching + +The service serves one binary that is replaced wholesale on an upgrade, so +the only question caching has to answer is: can a browser be holding a copy +that is no longer the one this binary embeds? + +The build answers it by putting a content hash in every asset name. A build +that changes a file also changes that file's name, so the URL of the new +bundle is one the browser has never requested and therefore cannot have +cached. The service then states the policy that follows from the name alone: + +- A file whose name carries a hash is `public, max-age=31536000, + immutable`. The file is kept for a year and never revalidated, because + nothing will ask for that name again. +- The entry point is `no-cache, must-revalidate`. It names the current + bundle, so it is never served from a cache without asking, and the + requirement is stated to intermediaries as well as to the browser. +- A file whose name carries no hash is `no-cache, must-revalidate`, because + a later build reuses that name. + +The decision reads the name only, so it is correct for a file embedded by an +earlier build as well as for one embedded by the current build. + +Two alternatives were rejected. `no-store` on every file removes the +question at the cost of re-downloading the bundle on every page load. +`no-cache` on every file is correct but, without a conditional request +handler on the service, makes the browser re-download content it could have +reused. Neither is as fast as naming the content. + +The service registers no service worker and emits no manifest, so there is +no second cache layer to reason about. + ## Testing Unit tests live beside the code they test and cover the parser, the codec, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ccd4172..89f7920 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,9 +2,14 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; // The build output is embedded in the Rust binary by build.rs, so the -// configuration is deliberately plain. The entry point and the asset names -// are fixed rather than hashed so that the embedded table is stable between -// builds of unchanged sources, which keeps incremental Rust builds cheap. +// configuration is deliberately plain. +// +// The asset names carry a content hash. A new build therefore changes the +// name of every file whose contents changed, which is what lets the service +// mark them immutable: a browser that has an old copy cannot be asked for it +// again, because the URL of the new bundle is a different one. The entry +// point keeps a fixed name so that opening the site always fetches the +// document that names the current bundle. export default defineConfig({ plugins: [svelte()], build: { @@ -16,9 +21,9 @@ export default defineConfig({ cssCodeSplit: false, rollupOptions: { output: { - entryFileNames: 'assets/app.js', - chunkFileNames: 'assets/[name].js', - assetFileNames: 'assets/[name][extname]', + entryFileNames: 'assets/[name]-[hash].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', }, }, }, diff --git a/src/api/mod.rs b/src/api/mod.rs index 9376c96..dc0e414 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -387,27 +387,74 @@ impl Router { if !relative.is_empty() && !relative.ends_with('/') { if let Some(bytes) = embedded::get(relative) { return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec()) - .with_cache_control(CACHE_POLICY); + .with_cache_control(cache_policy(relative)); } } // A directory request, or an unknown path that looks like a route, - // returns the single page entry point. + // returns the single page entry point, which names the current + // bundle and is therefore never served from a cache. match embedded::get("index.html") { Some(bytes) => Response::new(Status::Ok, "text/html; charset=utf-8", bytes.to_vec()) - .with_cache_control("no-cache"), + .with_cache_control(ENTRY_CACHE_POLICY), None => not_found("asset"), } } } -/// The cache policy for every asset. +/// The cache policy for the entry point. /// -/// The build writes stable file names rather than content hashed ones, so a -/// browser must revalidate after a deployment. A year long immutable policy -/// would be correct only for hashed names, and would pin a reader to the -/// bundle that was current when they first loaded the page. -const CACHE_POLICY: &str = "no-cache"; +/// The entry point names the current bundle, so it is never reused from a +/// cache without asking. `must-revalidate` states that requirement to +/// intermediaries as well as to the browser. +const ENTRY_CACHE_POLICY: &str = "no-cache, must-revalidate"; + +/// The cache policy for a file whose name carries a content hash. +/// +/// A build that changes a file also changes its name, so the URL of the new +/// file has never been requested before and cannot be in a cache. Marking it +/// immutable is therefore both safe and free: the browser keeps it until it +/// is no longer named by any entry point. +const HASHED_CACHE_POLICY: &str = "public, max-age=31536000, immutable"; + +/// The cache policy for a file whose name carries no content hash. +/// +/// Such a file is a name that a future build reuses, so it must be +/// revalidated rather than reused from a cache. +const UNHASHED_CACHE_POLICY: &str = "no-cache, must-revalidate"; + +/// Returns the cache policy for an asset path. +/// +/// The decision is made from the file name alone, which is the same signal +/// the build uses to decide whether the contents changed. Nothing about the +/// contents is read, so the policy is correct for a file that was embedded by +/// an earlier build as well as for one embedded by the current one. +fn cache_policy(path: &str) -> &'static str { + if is_content_hashed(path) { + HASHED_CACHE_POLICY + } else { + UNHASHED_CACHE_POLICY + } +} + +/// Returns `true` when a file name ends with a content hash. +/// +/// The build appends the hash before the extension. The alphabet is the one +/// the bundler emits, which is base64 and therefore mixes cases, so the +/// check accepts both and only rejects characters that cannot appear in a +/// hash. A name without one is a name a later build reuses. +fn is_content_hashed(path: &str) -> bool { + let Some((stem, _)) = path.rsplit_once('.') else { + return false; + }; + let Some((_, hash)) = stem.rsplit_once('-') else { + return false; + }; + hash.len() >= 8 + && hash + .chars() + .all(|character| character.is_ascii_alphanumeric()) +} /// Builds the document listing payload. fn document_index(state: &AppState) -> Value { @@ -865,6 +912,39 @@ mod tests { assert_eq!(body.get("ok").unwrap().as_bool(), Some(false)); } + #[test] + fn a_hashed_asset_is_immutable_and_a_plain_one_is_not() { + // A content hash in the name means the name changes with the + // contents, so the browser can keep it for as long as it likes. + assert_eq!( + cache_policy("assets/index-Vou83oaV.js"), + "public, max-age=31536000, immutable" + ); + assert_eq!( + cache_policy("assets/style-CEH25aoZ.css"), + "public, max-age=31536000, immutable" + ); + // A name without one is reused by the next build. + assert_eq!(cache_policy("index.html"), UNHASHED_CACHE_POLICY); + assert_eq!(cache_policy("assets/app.js"), UNHASHED_CACHE_POLICY); + assert_eq!(cache_policy("favicon.ico"), UNHASHED_CACHE_POLICY); + // A name that merely ends in a dash is not hashed. + assert_eq!(cache_policy("assets/app-.js"), UNHASHED_CACHE_POLICY); + assert_eq!(cache_policy("assets/no-extension"), UNHASHED_CACHE_POLICY); + } + + #[test] + fn a_hash_is_recognised_by_its_shape() { + // The bundler emits a mixed case base64 hash. + assert!(is_content_hashed("index-Vou83oaV.js")); + assert!(is_content_hashed("style-CEH25aoZ.css")); + assert!(is_content_hashed("app-deadbeef.js")); + assert!(!is_content_hashed("app-abc.js"), "too few digits"); + assert!(!is_content_hashed("app-na-me.js"), "a dash is not a hash"); + assert!(!is_content_hashed("app.js"), "no hash segment"); + assert!(!is_content_hashed("noextension")); + } + #[test] fn unknown_routes_fall_back_to_the_entry_point() { let router = router();