web: name assets with a content hash so a stale bundle cannot be served
This commit is contained in:
parent
eb65230a44
commit
165d78175e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue