Skip to main content

max / makenotwork

Add static asset cache-busting to multithreaded Port the fingerprinting half of the server's build.rs. Content-hash style.css, htmx.min.js, mt.js and the emitted static/dist JS into one version, then generate the template partials that carry ?v=<hash> on every asset URL. Without this a redeployed file is served stale from browser cache. For the chat island that means old protocol logic against a new server, which presents as chat being broken for some users rather than as a cache problem. Split into _head_assets.html and _body_scripts.html because mt.js is a classic script at the end of body and has to keep running after the document is parsed. _island.html drops the server's islands/ path segment to match MT's tsconfig rootDir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 19:13 UTC
Commit: 2e1e03b1da60dc92541b3370f4fac9cbacc5e9e0
Parent: d5629dd
3 files changed, +97 insertions, -3 deletions
@@ -1,6 +1,11 @@
1 1 # Build artifacts
2 2 /target/
3 3
4 + # Generated by build.rs (asset fingerprinting)
5 + /templates/_head_assets.html
6 + /templates/_body_scripts.html
7 + /templates/_island.html
8 +
4 9 # Environment
5 10 .env
6 11 .env.*
@@ -1,8 +1,98 @@
1 + use std::collections::hash_map::DefaultHasher;
2 + use std::fs;
3 + use std::hash::{Hash, Hasher};
1 4 use std::path::Path;
2 5 use std::process::Command;
3 6
4 7 fn main() {
5 8 build_frontend();
9 + fingerprint_assets();
10 + }
11 +
12 + /// Content-hash the served static files and emit the template partials that
13 + /// carry a `?v=<hash>` cache-buster on every asset URL.
14 + ///
15 + /// Runs after `build_frontend` so the hash covers the JS that was just emitted
16 + /// into `static/dist/`, not the previous build's.
17 + ///
18 + /// One global version rather than a per-file hash: an upgrade to any watched
19 + /// file re-fetches all of them, which costs a handful of requests once and
20 + /// keeps the templates free of per-asset bookkeeping. What it buys is that a
21 + /// redeployed file can never be served stale from browser cache — for a chat
22 + /// island that would mean old protocol logic talking to a new server, which
23 + /// presents as "chat is broken for some people" rather than as a cache bug.
24 + ///
25 + /// The generated partials are gitignored build output. They are written only
26 + /// when their content changes, so a no-op build does not retrigger Askama.
27 + fn fingerprint_assets() {
28 + let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"];
29 +
30 + let mut hasher = DefaultHasher::new();
31 + for path in &static_files {
32 + println!("cargo::rerun-if-changed={path}");
33 + if let Ok(content) = fs::read(path) {
34 + content.hash(&mut hasher);
35 + }
36 + }
37 + // Fold the emitted bundles in so `?v=` also busts when the TypeScript
38 + // changes. Read-only: `frontend/src` is the watched trigger (in
39 + // build_frontend); watching the outputs we generate would loop.
40 + hash_dir_js(Path::new("static/dist"), &mut hasher);
41 + let version = &format!("{:016x}", hasher.finish())[..8];
42 +
43 + // Included by base.html's <head>.
44 + let head = format!(
45 + r#" <link rel="stylesheet" href="/static/style.css?v={version}">
46 + <script src="/static/htmx.min.js?v={version}"></script>"#
47 + );
48 + write_if_changed(Path::new("templates/_head_assets.html"), &head);
49 +
50 + // Included at the end of base.html's <body>. Kept separate from the head
51 + // partial because mt.js is a classic (non-deferred) script and has to keep
52 + // running after the document is parsed.
53 + let scripts = format!(r#" <script src="/static/mt.js?v={version}"></script>"#);
54 + write_if_changed(Path::new("templates/_body_scripts.html"), &scripts);
55 +
56 + // Per-page island loader, for the compiled ESM under static/dist/. Used as
57 + // `{% import "_island.html" as island %}{% call island::island("chat") %}`
58 + // and cache-busted by the same content hash as the head assets.
59 + let island = r#"{% macro island(name) -%}
60 + <script type="module" src="/static/dist/{{ name }}.js?v=__VER__"></script>
61 + {%- endmacro %}
62 + "#
63 + .replace("__VER__", version);
64 + write_if_changed(Path::new("templates/_island.html"), &island);
65 + }
66 +
67 + /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
68 + fn write_if_changed(path: &Path, contents: &str) {
69 + let needs_write = fs::read_to_string(path)
70 + .map(|existing| existing != contents)
71 + .unwrap_or(true);
72 + if needs_write {
73 + fs::write(path, contents)
74 + .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
75 + }
76 + }
77 +
78 + /// Recursively hash every `.js` file under `dir` into `hasher`, in a
79 + /// deterministic order. A missing directory is a no-op — the first build on a
80 + /// fresh checkout runs before the frontend has been compiled.
81 + fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
82 + let Ok(entries) = fs::read_dir(dir) else {
83 + return;
84 + };
85 + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
86 + paths.sort();
87 + for path in paths {
88 + if path.is_dir() {
89 + hash_dir_js(&path, hasher);
90 + } else if path.extension().and_then(|e| e.to_str()) == Some("js")
91 + && let Ok(content) = fs::read(&path)
92 + {
93 + content.hash(hasher);
94 + }
95 + }
6 96 }
7 97
8 98 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
@@ -5,8 +5,7 @@
5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 6 {% if let Some(token) = csrf_token %}<meta name="csrf-token" content="{{ token }}">{% endif %}
7 7 <title>{% block title %}Multithreaded{% endblock %}</title>
8 - <link rel="stylesheet" href="/static/style.css">
9 - <script src="/static/htmx.min.js"></script>
8 + {% include "_head_assets.html" %}
10 9 {% block head %}{% endblock %}
11 10 </head>
12 11 <body data-mnw-url="{{ mnw_base_url }}"{% block body_attrs %}{% endblock %}>
@@ -42,7 +41,7 @@
42 41 </div>
43 42 </div>
44 43 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
45 - <script src="/static/mt.js"></script>
44 + {% include "_body_scripts.html" %}
46 45 {% block scripts %}{% endblock %}
47 46 </body>
48 47 </html>