use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; use std::path::Path; use std::process::Command; fn main() { stamp_git_hash(); build_frontend(); fingerprint_assets(); } /// Stamp the short commit sha into `GIT_HASH` for `option_env!()` to pick up /// in the `/api/health` body. /// /// Empty on a build with no git metadata (a source tarball, a container /// without `.git`), which the health body maps to `null` rather than to a /// misleading empty string. fn stamp_git_hash() { println!("cargo::rustc-env=GIT_HASH={}", git_hash()); } /// Content-hash the served static files and emit the template partials that /// carry a `?v=` cache-buster on every asset URL. /// /// Runs after `build_frontend` so the hash covers the JS that was just emitted /// into `static/dist/`, not the previous build's. /// /// One global version rather than a per-file hash: an upgrade to any watched /// file re-fetches all of them, which costs a handful of requests once and /// keeps the templates free of per-asset bookkeeping. What it buys is that a /// redeployed file can never be served stale from browser cache, for a chat /// island that would mean old protocol logic talking to a new server, which /// presents as "chat is broken for some people" rather than as a cache bug. /// /// The generated partials are gitignored build output. They are written only /// when their content changes, so a no-op build does not retrigger Askama. fn fingerprint_assets() { // These watches now do double duty. They have always decided when the `?v=` // hash is recomputed; since `static/` is compiled into the binary // (src/static_assets.rs), they also decide when the embed is refreshed. An // asset outside this set is an asset that can go stale in the binary. // // `static/fonts` is watched as a directory — cargo walks it — and is safe to // watch because nothing in the build writes there. // // `static/dist` is deliberately NOT watched: `build_frontend` rewrites it on // every run, so watching it would rerun this script (and npm) on every // build. It is covered transitively instead, by the `frontend/src` watch in // `build_frontend` — a build-script rerun recompiles the crate, which is // when `include_dir!` re-reads the emitted bundles. println!("cargo::rerun-if-changed=static/fonts"); let static_files = ["static/style.css", "static/htmx.min.js", "static/mt.js"]; let mut hasher = DefaultHasher::new(); for path in &static_files { println!("cargo::rerun-if-changed={path}"); if let Ok(content) = fs::read(path) { content.hash(&mut hasher); } } // Fold the emitted bundles in so `?v=` also busts when the TypeScript // changes. Read-only: `frontend/src` is the watched trigger (in // build_frontend); watching the outputs we generate would loop. hash_dir_js(Path::new("static/dist"), &mut hasher); let version = &format!("{:016x}", hasher.finish())[..8]; // Included by base.html's . let head = format!( r#" "# ); write_if_changed(Path::new("templates/_head_assets.html"), &head); // Included at the end of base.html's . Kept separate from the head // partial because mt.js is a classic (non-deferred) script and has to keep // running after the document is parsed. let scripts = format!(r#" "#); write_if_changed(Path::new("templates/_body_scripts.html"), &scripts); // Per-page island loader, for the compiled ESM under static/dist/. Used as // `{% import "_island.html" as island %}{% call island::island("chat") %}` // and cache-busted by the same content hash as the head assets. let island = r#"{% macro island(name) -%} {%- endmacro %} "# .replace("__VER__", version); write_if_changed(Path::new("templates/_island.html"), &island); } /// Write `contents` to `path` only if it differs, to avoid needless rebuilds. fn write_if_changed(path: &Path, contents: &str) { let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents); if needs_write { fs::write(path, contents) .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display())); } } /// Recursively hash every `.js` file under `dir` into `hasher`, in a /// deterministic order. A missing directory is a no-op, the first build on a /// fresh checkout runs before the frontend has been compiled. fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) { let Ok(entries) = fs::read_dir(dir) else { return; }; let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); paths.sort(); for path in paths { if path.is_dir() { hash_dir_js(&path, hasher); } else if path.extension().and_then(|e| e.to_str()) == Some("js") && let Ok(content) = fs::read(&path) { content.hash(hasher); } } } /// Compile the TypeScript frontend (`frontend/`) to browser ESM in /// `static/dist/` via `npm run build` (which runs `tsc`). /// /// This runs from `cargo build` on purpose. `static/dist/` is gitignored /// regenerable output, so a fresh checkout has no JS at all, and the deploy path /// builds on astra rather than on the machine that edited the TypeScript. Making /// it a build script means there is no "remember to run npm build first" step /// standing between an edit and a working deploy. /// /// Self-contained: on a host without `node_modules` it runs `npm ci` first. /// /// Best-effort and non-fatal. A missing Node or a compile error emits a /// `cargo::warning` and lets the Rust build succeed against whatever /// `static/dist/` already holds, because a type error in a chat widget should /// not be able to stop the forum from building. The Sando `code_smoke` tier is /// where a frontend failure is meant to be fatal. /// /// Set `MT_SKIP_FRONTEND_BUILD=1` to opt out entirely. fn build_frontend() { println!("cargo::rerun-if-changed=frontend/src"); println!("cargo::rerun-if-changed=frontend/package.json"); println!("cargo::rerun-if-changed=frontend/package-lock.json"); println!("cargo::rerun-if-changed=frontend/tsconfig.json"); if std::env::var_os("MT_SKIP_FRONTEND_BUILD").is_some() { println!("cargo::warning=frontend build skipped (MT_SKIP_FRONTEND_BUILD set)"); return; } if !Path::new("frontend/node_modules").is_dir() { match Command::new("npm") .args(["ci"]) .current_dir("frontend") .status() { Ok(s) if s.success() => {} Ok(s) => { println!( "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)", s.code() ); return; } Err(e) => { println!( "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)" ); return; } } } match Command::new("npm") .args(["run", "build"]) .current_dir("frontend") .status() { Ok(s) if s.success() => {} Ok(s) => println!( "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist", s.code() ), Err(e) => println!( "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \ skipping (serving existing static/dist)" ), } } /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide /// when this build script has to run again. /// /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that /// does not exist as *changed*, so a watch on a path that can never exist makes /// this script re-run on every single cargo invocation, and re-running it /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves /// against the package root: there is no `.git` in `server/` or in /// `multithreaded/` because the repository root is `MNW/`. So the watch never /// resolved, and the crate recompiled every time. /// /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded, /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the /// `cargo_test` gate, and it cost the same on every local `cargo build`, /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were /// the only two in the pipeline that recompiled on a second cargo invocation; /// the ones without one were already free. /// /// Two rules follow, and both matter: /// - resolve the paths through git rather than guessing them, and /// - emit a watch ONLY for a path that exists, or the bug comes straight back. fn git_hash() -> String { // An explicit hash wins and skips git entirely, for a build system that // already knows the sha. Nothing sets this today: Sando would have to set it // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env` // and therefore part of the crate fingerprint, so a value present for the // release build and absent for `cargo_test` would force the recompile this // function exists to remove. println!("cargo::rerun-if-env-changed=MNW_GIT_HASH"); if let Ok(h) = std::env::var("MNW_GIT_HASH") { let h = h.trim().to_string(); if !h.is_empty() { return h; } } // Watch what git actually rewrites when the checkout moves. `.git/HEAD` // alone is not enough even when resolved: committing on a branch rewrites // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale // in the ordinary case of a commit. watch_if_exists(git_path("HEAD").as_deref()); if let Some(r) = git_output(&["symbolic-ref", "-q", "HEAD"]) { watch_if_exists(git_path(&r).as_deref()); } // A ref that has been packed has no loose file, so this is the fallback // that keeps the watch honest after a `git gc`. watch_if_exists(git_path("packed-refs").as_deref()); git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default() } /// Run a git command in the package directory and return its trimmed stdout. fn git_output(args: &[&str]) -> Option { Command::new("git") .args(args) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } /// Resolve a name inside the git directory to a path, honouring worktrees and a /// `.git` file that points elsewhere. `None` when this is not a checkout at all, /// which is the case in a vendored or packaged build. fn git_path(name: &str) -> Option { git_output(&["rev-parse", "--git-path", name]) } /// Emit a watch for a path, but only when it exists. /// /// The guard is the fix. A missing path reads as changed to cargo, so emitting /// one unconditionally is what caused the recompile-every-time bug. fn watch_if_exists(path: Option<&str>) { if let Some(p) = path && Path::new(p).exists() { println!("cargo::rerun-if-changed={p}"); } }