web: name assets with a content hash so a stale bundle cannot be served

This commit is contained in:
dev 2026-10-01 09:06:10 +08:00
parent eb65230a44
commit 165d78175e
4 changed files with 139 additions and 15 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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]',
},
},
},

View File

@ -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();