Skip to main content

webserver_base/assets/
generate.rs

1//! The build-time static-asset pipeline.
2//!
3//! Hashing happens here, at build time, rather than when the server starts.
4//! That ordering is the whole point:
5//!
6//! - **It is idempotent by construction.** Renaming files at startup was not:
7//!   a second run hashed the already-hashed names, the manifest keys stopped
8//!   being the logical paths, and every stylesheet, script and image 404'd. An
9//!   in-place `docker restart` was enough to trigger it.
10//! - **The server never writes to disk**, so a production image needs no
11//!   writable filesystem and two replicas cannot race over a shared volume.
12//! - **One hashing pass serves both consumers.** The manifest feeds the
13//!   templates; the generated TypeScript module feeds the browser. They cannot
14//!   disagree, because they are two views of the same operation.
15//!
16//! It runs in two phases because the JavaScript *contents* depend on the
17//! manifest, while the JavaScript *files* must be hashed once they exist.
18//! Scripts never need their own hash — the layout takes that from the manifest.
19
20use std::collections::BTreeMap;
21use std::fs::{self, File};
22use std::io::Read;
23use std::path::{Path, PathBuf};
24
25use tracing::{debug, info};
26
27use super::error::CacheBusterError;
28use super::manifest::{Manifest, TYPESCRIPT_MODULE_PATH};
29
30/// The directory every project keeps its static assets in.
31///
32/// Not configurable. The layout beneath it is already fixed — `image/favicon/`,
33/// `file/`, `stylesheet/`, `script/` — so the directory name was the one part
34/// that looked adjustable without being so.
35pub const STATIC_DIRECTORY: &str = "static";
36
37/// Where the icon set lives.
38pub const FAVICON_DIRECTORY: &str = "static/image/favicon";
39
40/// The vector icon source, preferred when the art allows it.
41pub const FAVICON_SVG_SOURCE: &str = "static/image/favicon/favicon.svg";
42
43/// The raster icon source, for art that cannot be vectorised — a photograph,
44/// most obviously.
45///
46/// The size is in the name on purpose: it must be exactly
47/// [`SOURCE_PNG_SIZE`](super::icons::SOURCE_PNG_SIZE) square, and a filename
48/// that states the requirement is harder to get wrong than one that does not.
49pub const FAVICON_PNG_SOURCE: &str = "static/image/favicon/favicon-512.png";
50
51/// Which half of the pipeline to run.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Phase {
54    /// Generate the icon set, then hash everything except `static/script/`.
55    /// Emits the manifest and the TypeScript module the JS build consumes.
56    NonScripts,
57    /// Hash `static/script/` once the JavaScript has been built, and merge the
58    /// result into the manifest.
59    Scripts,
60}
61
62impl Phase {
63    /// Parses the CLI subcommand this phase is invoked by.
64    #[must_use]
65    pub fn from_subcommand(subcommand: &str) -> Option<Self> {
66        match subcommand {
67            "gen-static-assets" => Some(Self::NonScripts),
68            "gen-static-scripts" => Some(Self::Scripts),
69            _ => None,
70        }
71    }
72
73    /// Whether this phase owns `path`.
74    fn owns(self, path: &Path) -> bool {
75        let is_script: bool = path.starts_with("static/script");
76        match self {
77            Self::NonScripts => !is_script,
78            Self::Scripts => is_script,
79        }
80    }
81}
82
83/// Runs one phase of the pipeline.
84///
85/// # Errors
86///
87/// [`CacheBusterError`] if `favicon.svg` is missing, an icon cannot be
88/// rendered, a file cannot be read or renamed, or the manifest cannot be
89/// written.
90pub fn generate_static_assets(phase: Phase) -> Result<(), CacheBusterError> {
91    let root: &Path = Path::new(STATIC_DIRECTORY);
92    if !root.is_dir() {
93        return Err(CacheBusterError::MissingStaticDirectory {
94            path: root.to_path_buf(),
95        });
96    }
97
98    // Merge rather than clobber: phase two must not discard phase one's work,
99    // and a re-run must find already-hashed files by their hashed names.
100    let mut manifest: Manifest = Manifest::load_or_empty()?;
101
102    if phase == Phase::NonScripts {
103        super::icons::generate_missing_icons(&manifest)?;
104    }
105
106    manifest.extend(hash_tree(root, phase)?);
107    manifest.write_json()?;
108
109    if phase == Phase::NonScripts {
110        manifest.write_typescript()?;
111        info!(
112            "hashed {} static asset(s); wrote the manifest and {TYPESCRIPT_MODULE_PATH}",
113            manifest.len()
114        );
115    } else {
116        info!(
117            "hashed the built scripts; manifest now holds {} entries",
118            manifest.len()
119        );
120    }
121
122    Ok(())
123}
124
125/// Walks `root`, renaming every file this phase owns to include a hash of its
126/// contents.
127fn hash_tree(root: &Path, phase: Phase) -> Result<BTreeMap<String, String>, CacheBusterError> {
128    let mut cache: BTreeMap<String, String> = BTreeMap::new();
129    let mut directories: Vec<PathBuf> = vec![root.to_path_buf()];
130
131    while let Some(directory) = directories.pop() {
132        let entries =
133            fs::read_dir(&directory).map_err(|source| CacheBusterError::ReadDirectory {
134                path: directory.clone(),
135                source,
136            })?;
137
138        for entry in entries {
139            let entry: std::fs::DirEntry =
140                entry.map_err(|source| CacheBusterError::ReadDirectory {
141                    path: directory.clone(),
142                    source,
143                })?;
144            let path: PathBuf = entry.path();
145
146            if path.is_dir() {
147                directories.push(path);
148                continue;
149            }
150            if !phase.owns(&path) {
151                continue;
152            }
153            // Re-hashing an already-hashed name is the bug this whole pipeline
154            // exists to remove: it produces `main.<h>.<h>.css` and keys the
155            // manifest by a path no template ever asks for.
156            if is_content_hashed(&path) {
157                debug!("`{}` is already hashed; leaving it alone", path.display());
158                continue;
159            }
160
161            let hashed: PathBuf = content_hashed_path(&path, root)?;
162            fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
163                from: path.clone(),
164                to: hashed.clone(),
165                source,
166            })?;
167
168            cache.insert(
169                path.to_string_lossy().to_string(),
170                hashed.to_string_lossy().to_string(),
171            );
172        }
173    }
174
175    Ok(cache)
176}
177
178/// Whether a file name already carries a 32-character hex content hash.
179fn is_content_hashed(path: &Path) -> bool {
180    path.file_name()
181        .and_then(|name| name.to_str())
182        .is_some_and(|name| {
183            name.split('.').any(|segment| {
184                segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
185            })
186        })
187}
188
189/// `dir/name.ext` → `dir/name.<md5>.ext`, hash inserted before the *first*
190/// extension so `main.js.map` stays a `.js.map`.
191fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
192    let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
193        path: file_path.to_path_buf(),
194        source,
195    })?;
196    let mut contents: Vec<u8> = Vec::new();
197    file.read_to_end(&mut contents)
198        .map_err(|source| CacheBusterError::ReadFile {
199            path: file_path.to_path_buf(),
200            source,
201        })?;
202
203    let hash: String = format!("{:x}", md5::compute(contents));
204
205    let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
206    let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
207    let name: &str = relative
208        .file_name()
209        .and_then(|name| name.to_str())
210        .unwrap_or_default();
211
212    let hashed_name: String = match name.split_once('.') {
213        Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
214        None => format!("{name}.{hash}"),
215    };
216
217    Ok(root.join(parent).join(hashed_name))
218}
219
220#[cfg(test)]
221mod tests {
222    use std::path::{Path, PathBuf};
223
224    use super::{Phase, is_content_hashed};
225
226    #[test]
227    fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
228        let hashed: PathBuf =
229            PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
230
231        let expected: bool = true;
232        let actual: bool = is_content_hashed(&hashed);
233        assert_eq!(expected, actual);
234    }
235
236    #[test]
237    fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
238        let expected: bool = false;
239        let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
240        assert_eq!(expected, actual);
241    }
242
243    #[test]
244    fn a_long_but_non_hex_segment_is_not_a_hash() {
245        // 32 characters, but `z` is not hex.
246        let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
247
248        let expected: bool = false;
249        let actual: bool = is_content_hashed(&path);
250        assert_eq!(expected, actual);
251    }
252
253    #[test]
254    fn a_source_map_keeps_its_double_extension() {
255        let hashed: PathBuf =
256            super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
257        let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
258
259        // The hash goes before the FIRST dot, so `main.js.map` stays a `.js.map`
260        // rather than becoming `main.js.<hash>.map`.
261        assert!(name.starts_with("Cargo."));
262        assert!(
263            std::path::Path::new(name)
264                .extension()
265                .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
266        );
267    }
268
269    #[test]
270    fn each_phase_owns_a_disjoint_half_of_the_tree() {
271        let script: &Path = Path::new("static/script/main.js");
272        let image: &Path = Path::new("static/image/social/card.webp");
273
274        assert!(!Phase::NonScripts.owns(script));
275        assert!(Phase::NonScripts.owns(image));
276        assert!(Phase::Scripts.owns(script));
277        assert!(!Phase::Scripts.owns(image));
278    }
279
280    #[test]
281    fn the_subcommands_map_to_their_phases() {
282        assert_eq!(
283            Some(Phase::NonScripts),
284            Phase::from_subcommand("gen-static-assets")
285        );
286        assert_eq!(
287            Some(Phase::Scripts),
288            Phase::from_subcommand("gen-static-scripts")
289        );
290        assert_eq!(None, Phase::from_subcommand("serve"));
291    }
292}