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 = entry.map_err(|source| CacheBusterError::ReadDirectory {
140                path: directory.clone(),
141                source,
142            })?;
143            let path: PathBuf = entry.path();
144
145            if path.is_dir() {
146                directories.push(path);
147                continue;
148            }
149            if !phase.owns(&path) {
150                continue;
151            }
152            // Re-hashing an already-hashed name is the bug this whole pipeline
153            // exists to remove: it produces `main.<h>.<h>.css` and keys the
154            // manifest by a path no template ever asks for.
155            if is_content_hashed(&path) {
156                debug!("`{}` is already hashed; leaving it alone", path.display());
157                continue;
158            }
159
160            let hashed: PathBuf = content_hashed_path(&path, root)?;
161            fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
162                from: path.clone(),
163                to: hashed.clone(),
164                source,
165            })?;
166
167            cache.insert(
168                path.to_string_lossy().to_string(),
169                hashed.to_string_lossy().to_string(),
170            );
171        }
172    }
173
174    Ok(cache)
175}
176
177/// Whether a file name already carries a 32-character hex content hash.
178fn is_content_hashed(path: &Path) -> bool {
179    path.file_name()
180        .and_then(|name| name.to_str())
181        .is_some_and(|name| {
182            name.split('.').any(|segment| {
183                segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
184            })
185        })
186}
187
188/// `dir/name.ext` → `dir/name.<md5>.ext`, hash inserted before the *first*
189/// extension so `main.js.map` stays a `.js.map`.
190fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
191    let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
192        path: file_path.to_path_buf(),
193        source,
194    })?;
195    let mut contents: Vec<u8> = Vec::new();
196    file.read_to_end(&mut contents)
197        .map_err(|source| CacheBusterError::ReadFile {
198            path: file_path.to_path_buf(),
199            source,
200        })?;
201
202    let hash: String = format!("{:x}", md5::compute(contents));
203
204    let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
205    let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
206    let name: &str = relative
207        .file_name()
208        .and_then(|name| name.to_str())
209        .unwrap_or_default();
210
211    let hashed_name: String = match name.split_once('.') {
212        Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
213        None => format!("{name}.{hash}"),
214    };
215
216    Ok(root.join(parent).join(hashed_name))
217}
218
219#[cfg(test)]
220mod tests {
221    use std::path::{Path, PathBuf};
222
223    use super::{Phase, is_content_hashed};
224
225    #[test]
226    fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
227        let hashed: PathBuf =
228            PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
229
230        let expected: bool = true;
231        let actual: bool = is_content_hashed(&hashed);
232        assert_eq!(expected, actual);
233    }
234
235    #[test]
236    fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
237        let expected: bool = false;
238        let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
239        assert_eq!(expected, actual);
240    }
241
242    #[test]
243    fn a_long_but_non_hex_segment_is_not_a_hash() {
244        // 32 characters, but `z` is not hex.
245        let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
246
247        let expected: bool = false;
248        let actual: bool = is_content_hashed(&path);
249        assert_eq!(expected, actual);
250    }
251
252    #[test]
253    fn a_source_map_keeps_its_double_extension() {
254        let hashed: PathBuf =
255            super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
256        let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
257
258        // The hash goes before the FIRST dot, so `main.js.map` stays a `.js.map`
259        // rather than becoming `main.js.<hash>.map`.
260        assert!(name.starts_with("Cargo."));
261        assert!(
262            std::path::Path::new(name)
263                .extension()
264                .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
265        );
266    }
267
268    #[test]
269    fn each_phase_owns_a_disjoint_half_of_the_tree() {
270        let script: &Path = Path::new("static/script/main.js");
271        let image: &Path = Path::new("static/image/social/card.webp");
272
273        assert!(!Phase::NonScripts.owns(script));
274        assert!(Phase::NonScripts.owns(image));
275        assert!(Phase::Scripts.owns(script));
276        assert!(!Phase::Scripts.owns(image));
277    }
278
279    #[test]
280    fn the_subcommands_map_to_their_phases() {
281        assert_eq!(
282            Some(Phase::NonScripts),
283            Phase::from_subcommand("gen-static-assets")
284        );
285        assert_eq!(
286            Some(Phase::Scripts),
287            Phase::from_subcommand("gen-static-scripts")
288        );
289        assert_eq!(None, Phase::from_subcommand("serve"));
290    }
291}