Skip to main content

max / makenotwork

8.1 KB · 200 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::fs;
3 use std::hash::{Hash, Hasher};
4 use std::path::Path;
5 use std::process::Command;
6
7 fn main() {
8 stamp_git_hash();
9 build_frontend();
10 fingerprint_assets();
11 }
12
13 /// Stamp the short commit sha into `GIT_HASH` for `option_env!()` to pick up
14 /// in the `/api/health` body.
15 ///
16 /// Empty on a build with no git metadata (a source tarball, a container
17 /// without `.git`), which the health body maps to `null` rather than to a
18 /// misleading empty string.
19 fn stamp_git_hash() {
20 let hash = Command::new("git")
21 .args(["rev-parse", "--short", "HEAD"])
22 .output()
23 .ok()
24 .filter(|o| o.status.success())
25 .and_then(|o| String::from_utf8(o.stdout).ok())
26 .map(|s| s.trim().to_string())
27 .unwrap_or_default();
28
29 println!("cargo::rustc-env=GIT_HASH={hash}");
30 // Only re-run when HEAD moves.
31 println!("cargo::rerun-if-changed=.git/HEAD");
32 }
33
34 /// Content-hash the served static files and emit the template partials that
35 /// carry a `?v=<hash>` cache-buster on every asset URL.
36 ///
37 /// Runs after `build_frontend` so the hash covers the JS that was just emitted
38 /// into `static/dist/`, not the previous build's.
39 ///
40 /// One global version rather than a per-file hash: an upgrade to any watched
41 /// file re-fetches all of them, which costs a handful of requests once and
42 /// keeps the templates free of per-asset bookkeeping. What it buys is that a
43 /// redeployed file can never be served stale from browser cache, for a chat
44 /// island that would mean old protocol logic talking to a new server, which
45 /// presents as "chat is broken for some people" rather than as a cache bug.
46 ///
47 /// The generated partials are gitignored build output. They are written only
48 /// when their content changes, so a no-op build does not retrigger Askama.
49 fn fingerprint_assets() {
50 // These watches now do double duty. They have always decided when the `?v=`
51 // hash is recomputed; since `static/` is compiled into the binary
52 // (src/static_assets.rs), they also decide when the embed is refreshed. An
53 // asset outside this set is an asset that can go stale in the binary.
54 //
55 // `static/fonts` is watched as a directory — cargo walks it — and is safe to
56 // watch because nothing in the build writes there.
57 //
58 // `static/dist` is deliberately NOT watched: `build_frontend` rewrites it on
59 // every run, so watching it would rerun this script (and npm) on every
60 // build. It is covered transitively instead, by the `frontend/src` watch in
61 // `build_frontend` — a build-script rerun recompiles the crate, which is
62 // when `include_dir!` re-reads the emitted bundles.
63 println!("cargo::rerun-if-changed=static/fonts");
64 let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"];
65
66 let mut hasher = DefaultHasher::new();
67 for path in &static_files {
68 println!("cargo::rerun-if-changed={path}");
69 if let Ok(content) = fs::read(path) {
70 content.hash(&mut hasher);
71 }
72 }
73 // Fold the emitted bundles in so `?v=` also busts when the TypeScript
74 // changes. Read-only: `frontend/src` is the watched trigger (in
75 // build_frontend); watching the outputs we generate would loop.
76 hash_dir_js(Path::new("static/dist"), &mut hasher);
77 let version = &format!("{:016x}", hasher.finish())[..8];
78
79 // Included by base.html's <head>.
80 let head = format!(
81 r#" <link rel="stylesheet" href="/static/style.css?v={version}">
82 <script src="/static/htmx.min.js?v={version}"></script>"#
83 );
84 write_if_changed(Path::new("templates/_head_assets.html"), &head);
85
86 // Included at the end of base.html's <body>. Kept separate from the head
87 // partial because mt.js is a classic (non-deferred) script and has to keep
88 // running after the document is parsed.
89 let scripts = format!(r#" <script src="/static/mt.js?v={version}"></script>"#);
90 write_if_changed(Path::new("templates/_body_scripts.html"), &scripts);
91
92 // Per-page island loader, for the compiled ESM under static/dist/. Used as
93 // `{% import "_island.html" as island %}{% call island::island("chat") %}`
94 // and cache-busted by the same content hash as the head assets.
95 let island = r#"{% macro island(name) -%}
96 <script type="module" src="/static/dist/{{ name }}.js?v=__VER__"></script>
97 {%- endmacro %}
98 "#
99 .replace("__VER__", version);
100 write_if_changed(Path::new("templates/_island.html"), &island);
101 }
102
103 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
104 fn write_if_changed(path: &Path, contents: &str) {
105 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
106 if needs_write {
107 fs::write(path, contents)
108 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
109 }
110 }
111
112 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
113 /// deterministic order. A missing directory is a no-op, the first build on a
114 /// fresh checkout runs before the frontend has been compiled.
115 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
116 let Ok(entries) = fs::read_dir(dir) else {
117 return;
118 };
119 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
120 paths.sort();
121 for path in paths {
122 if path.is_dir() {
123 hash_dir_js(&path, hasher);
124 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
125 && let Ok(content) = fs::read(&path)
126 {
127 content.hash(hasher);
128 }
129 }
130 }
131
132 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
133 /// `static/dist/` via `npm run build` (which runs `tsc`).
134 ///
135 /// This runs from `cargo build` on purpose. `static/dist/` is gitignored
136 /// regenerable output, so a fresh checkout has no JS at all, and the deploy path
137 /// builds on astra rather than on the machine that edited the TypeScript. Making
138 /// it a build script means there is no "remember to run npm build first" step
139 /// standing between an edit and a working deploy.
140 ///
141 /// Self-contained: on a host without `node_modules` it runs `npm ci` first.
142 ///
143 /// Best-effort and non-fatal. A missing Node or a compile error emits a
144 /// `cargo::warning` and lets the Rust build succeed against whatever
145 /// `static/dist/` already holds, because a type error in a chat widget should
146 /// not be able to stop the forum from building. The Sando `code_smoke` tier is
147 /// where a frontend failure is meant to be fatal.
148 ///
149 /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely.
150 fn build_frontend() {
151 println!("cargo::rerun-if-changed=frontend/src");
152 println!("cargo::rerun-if-changed=frontend/package.json");
153 println!("cargo::rerun-if-changed=frontend/package-lock.json");
154 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
155
156 if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() {
157 println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)");
158 return;
159 }
160
161 if !Path::new("frontend/node_modules").is_dir() {
162 match Command::new("npm")
163 .args(["ci"])
164 .current_dir("frontend")
165 .status()
166 {
167 Ok(s) if s.success() => {}
168 Ok(s) => {
169 println!(
170 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
171 s.code()
172 );
173 return;
174 }
175 Err(e) => {
176 println!(
177 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
178 );
179 return;
180 }
181 }
182 }
183
184 match Command::new("npm")
185 .args(["run", "build"])
186 .current_dir("frontend")
187 .status()
188 {
189 Ok(s) if s.success() => {}
190 Ok(s) => println!(
191 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
192 s.code()
193 ),
194 Err(e) => println!(
195 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
196 skipping (serving existing static/dist)"
197 ),
198 }
199 }
200