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
|
The frontend must be built before the release binary so that its output can
|
||||||
be embedded. A release build does this automatically.
|
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
|
```sh
|
||||||
# Development: a debug build embeds whatever is in frontend/dist and does
|
# Development: a debug build embeds whatever is in frontend/dist and does
|
||||||
# not run the frontend build.
|
# 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
|
name; without that, the brackets and backticks appear verbatim in the
|
||||||
summary.
|
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
|
## Testing
|
||||||
|
|
||||||
Unit tests live beside the code they test and cover the parser, the codec,
|
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';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
// The build output is embedded in the Rust binary by build.rs, so the
|
// 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
|
// configuration is deliberately plain.
|
||||||
// are fixed rather than hashed so that the embedded table is stable between
|
//
|
||||||
// builds of unchanged sources, which keeps incremental Rust builds cheap.
|
// 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({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
build: {
|
build: {
|
||||||
|
|
@ -16,9 +21,9 @@ export default defineConfig({
|
||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/app.js',
|
entryFileNames: 'assets/[name]-[hash].js',
|
||||||
chunkFileNames: 'assets/[name].js',
|
chunkFileNames: 'assets/[name]-[hash].js',
|
||||||
assetFileNames: 'assets/[name][extname]',
|
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -387,27 +387,74 @@ impl Router {
|
||||||
if !relative.is_empty() && !relative.ends_with('/') {
|
if !relative.is_empty() && !relative.ends_with('/') {
|
||||||
if let Some(bytes) = embedded::get(relative) {
|
if let Some(bytes) = embedded::get(relative) {
|
||||||
return Response::new(Status::Ok, embedded::content_type(relative), bytes.to_vec())
|
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,
|
// 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") {
|
match embedded::get("index.html") {
|
||||||
Some(bytes) => Response::new(Status::Ok, "text/html; charset=utf-8", bytes.to_vec())
|
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"),
|
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
|
/// The entry point names the current bundle, so it is never reused from a
|
||||||
/// browser must revalidate after a deployment. A year long immutable policy
|
/// cache without asking. `must-revalidate` states that requirement to
|
||||||
/// would be correct only for hashed names, and would pin a reader to the
|
/// intermediaries as well as to the browser.
|
||||||
/// bundle that was current when they first loaded the page.
|
const ENTRY_CACHE_POLICY: &str = "no-cache, must-revalidate";
|
||||||
const CACHE_POLICY: &str = "no-cache";
|
|
||||||
|
/// 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.
|
/// Builds the document listing payload.
|
||||||
fn document_index(state: &AppState) -> Value {
|
fn document_index(state: &AppState) -> Value {
|
||||||
|
|
@ -865,6 +912,39 @@ mod tests {
|
||||||
assert_eq!(body.get("ok").unwrap().as_bool(), Some(false));
|
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]
|
#[test]
|
||||||
fn unknown_routes_fall_back_to_the_entry_point() {
|
fn unknown_routes_fall_back_to_the_entry_point() {
|
||||||
let router = router();
|
let router = router();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue