Skip to main content

max / makenotwork

7.2 KB · 186 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 let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"];
51
52 let mut hasher = DefaultHasher::new();
53 for path in &static_files {
54 println!("cargo::rerun-if-changed={path}");
55 if let Ok(content) = fs::read(path) {
56 content.hash(&mut hasher);
57 }
58 }
59 // Fold the emitted bundles in so `?v=` also busts when the TypeScript
60 // changes. Read-only: `frontend/src` is the watched trigger (in
61 // build_frontend); watching the outputs we generate would loop.
62 hash_dir_js(Path::new("static/dist"), &mut hasher);
63 let version = &format!("{:016x}", hasher.finish())[..8];
64
65 // Included by base.html's <head>.
66 let head = format!(
67 r#" <link rel="stylesheet" href="/static/style.css?v={version}">
68 <script src="/static/htmx.min.js?v={version}"></script>"#
69 );
70 write_if_changed(Path::new("templates/_head_assets.html"), &head);
71
72 // Included at the end of base.html's <body>. Kept separate from the head
73 // partial because mt.js is a classic (non-deferred) script and has to keep
74 // running after the document is parsed.
75 let scripts = format!(r#" <script src="/static/mt.js?v={version}"></script>"#);
76 write_if_changed(Path::new("templates/_body_scripts.html"), &scripts);
77
78 // Per-page island loader, for the compiled ESM under static/dist/. Used as
79 // `{% import "_island.html" as island %}{% call island::island("chat") %}`
80 // and cache-busted by the same content hash as the head assets.
81 let island = r#"{% macro island(name) -%}
82 <script type="module" src="/static/dist/{{ name }}.js?v=__VER__"></script>
83 {%- endmacro %}
84 "#
85 .replace("__VER__", version);
86 write_if_changed(Path::new("templates/_island.html"), &island);
87 }
88
89 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
90 fn write_if_changed(path: &Path, contents: &str) {
91 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
92 if needs_write {
93 fs::write(path, contents)
94 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
95 }
96 }
97
98 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
99 /// deterministic order. A missing directory is a no-op, the first build on a
100 /// fresh checkout runs before the frontend has been compiled.
101 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
102 let Ok(entries) = fs::read_dir(dir) else {
103 return;
104 };
105 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
106 paths.sort();
107 for path in paths {
108 if path.is_dir() {
109 hash_dir_js(&path, hasher);
110 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
111 && let Ok(content) = fs::read(&path)
112 {
113 content.hash(hasher);
114 }
115 }
116 }
117
118 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
119 /// `static/dist/` via `npm run build` (which runs `tsc`).
120 ///
121 /// This runs from `cargo build` on purpose. `static/dist/` is gitignored
122 /// regenerable output, so a fresh checkout has no JS at all, and the deploy path
123 /// builds on astra rather than on the machine that edited the TypeScript. Making
124 /// it a build script means there is no "remember to run npm build first" step
125 /// standing between an edit and a working deploy.
126 ///
127 /// Self-contained: on a host without `node_modules` it runs `npm ci` first.
128 ///
129 /// Best-effort and non-fatal. A missing Node or a compile error emits a
130 /// `cargo::warning` and lets the Rust build succeed against whatever
131 /// `static/dist/` already holds, because a type error in a chat widget should
132 /// not be able to stop the forum from building. The Sando `code_smoke` tier is
133 /// where a frontend failure is meant to be fatal.
134 ///
135 /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely.
136 fn build_frontend() {
137 println!("cargo::rerun-if-changed=frontend/src");
138 println!("cargo::rerun-if-changed=frontend/package.json");
139 println!("cargo::rerun-if-changed=frontend/package-lock.json");
140 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
141
142 if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() {
143 println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)");
144 return;
145 }
146
147 if !Path::new("frontend/node_modules").is_dir() {
148 match Command::new("npm")
149 .args(["ci"])
150 .current_dir("frontend")
151 .status()
152 {
153 Ok(s) if s.success() => {}
154 Ok(s) => {
155 println!(
156 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
157 s.code()
158 );
159 return;
160 }
161 Err(e) => {
162 println!(
163 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
164 );
165 return;
166 }
167 }
168 }
169
170 match Command::new("npm")
171 .args(["run", "build"])
172 .current_dir("frontend")
173 .status()
174 {
175 Ok(s) if s.success() => {}
176 Ok(s) => println!(
177 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
178 s.code()
179 ),
180 Err(e) => println!(
181 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
182 skipping (serving existing static/dist)"
183 ),
184 }
185 }
186