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    generate_static_assets_in(Path::new(""), phase)
92}
93
94/// [`generate_static_assets`], under `root`.
95pub(crate) fn generate_static_assets_in(root: &Path, phase: Phase) -> Result<(), CacheBusterError> {
96    let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
97    if !static_directory.is_dir() {
98        return Err(CacheBusterError::MissingStaticDirectory {
99            path: static_directory,
100        });
101    }
102
103    // Merge rather than clobber: phase two must not discard phase one's work,
104    // and a re-run must find already-hashed files by their hashed names.
105    let mut manifest: Manifest = Manifest::load_or_empty_in(root)?;
106
107    if phase == Phase::NonScripts {
108        super::icons::generate_missing_icons_in(root, &manifest)?;
109    }
110
111    manifest.extend(hash_tree(root, phase)?);
112    manifest.write_json_in(root)?;
113
114    if phase == Phase::NonScripts {
115        manifest.write_typescript_in(root)?;
116        info!(
117            "hashed {} static asset(s); wrote the manifest and {TYPESCRIPT_MODULE_PATH}",
118            manifest.len()
119        );
120    } else {
121        info!(
122            "hashed the built scripts; manifest now holds {} entries",
123            manifest.len()
124        );
125    }
126
127    Ok(())
128}
129
130/// Walks `root`'s static directory, renaming every file this phase owns to
131/// include a hash of its contents.
132///
133/// Keys and values are relative to `root`: they are URL paths as much as file
134/// paths, and a manifest that named the directory it was built in would serve
135/// nothing once deployed anywhere else.
136fn hash_tree(root: &Path, phase: Phase) -> Result<BTreeMap<String, String>, CacheBusterError> {
137    let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
138    let mut cache: BTreeMap<String, String> = BTreeMap::new();
139    let mut directories: Vec<PathBuf> = vec![static_directory.clone()];
140
141    while let Some(directory) = directories.pop() {
142        let entries =
143            fs::read_dir(&directory).map_err(|source| CacheBusterError::ReadDirectory {
144                path: directory.clone(),
145                source,
146            })?;
147
148        for entry in entries {
149            let entry: std::fs::DirEntry =
150                entry.map_err(|source| CacheBusterError::ReadDirectory {
151                    path: directory.clone(),
152                    source,
153                })?;
154            let path: PathBuf = entry.path();
155
156            if path.is_dir() {
157                directories.push(path);
158                continue;
159            }
160            let relative: &Path = path.strip_prefix(root).unwrap_or(&path);
161            if !phase.owns(relative) {
162                continue;
163            }
164            // Re-hashing an already-hashed name is the bug this whole pipeline
165            // exists to remove: it produces `main.<h>.<h>.css` and keys the
166            // manifest by a path no template ever asks for.
167            if is_content_hashed(&path) {
168                debug!("`{}` is already hashed; leaving it alone", path.display());
169                continue;
170            }
171
172            let hashed: PathBuf = content_hashed_path(&path, &static_directory)?;
173            fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
174                from: path.clone(),
175                to: hashed.clone(),
176                source,
177            })?;
178
179            cache.insert(
180                relative.to_string_lossy().to_string(),
181                hashed
182                    .strip_prefix(root)
183                    .unwrap_or(&hashed)
184                    .to_string_lossy()
185                    .to_string(),
186            );
187        }
188    }
189
190    Ok(cache)
191}
192
193/// Whether a file name already carries a 32-character hex content hash.
194fn is_content_hashed(path: &Path) -> bool {
195    path.file_name()
196        .and_then(|name| name.to_str())
197        .is_some_and(|name| {
198            name.split('.').any(|segment| {
199                segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
200            })
201        })
202}
203
204/// `dir/name.ext` → `dir/name.<md5>.ext`, hash inserted before the *first*
205/// extension so `main.js.map` stays a `.js.map`.
206fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
207    let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
208        path: file_path.to_path_buf(),
209        source,
210    })?;
211    let mut contents: Vec<u8> = Vec::new();
212    file.read_to_end(&mut contents)
213        .map_err(|source| CacheBusterError::ReadFile {
214            path: file_path.to_path_buf(),
215            source,
216        })?;
217
218    let hash: String = format!("{:x}", md5::compute(contents));
219
220    let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
221    let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
222    let name: &str = relative
223        .file_name()
224        .and_then(|name| name.to_str())
225        .unwrap_or_default();
226
227    let hashed_name: String = match name.split_once('.') {
228        Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
229        None => format!("{name}.{hash}"),
230    };
231
232    Ok(root.join(parent).join(hashed_name))
233}
234
235#[cfg(test)]
236mod tests {
237    use std::path::{Path, PathBuf};
238
239    use super::{Phase, is_content_hashed};
240
241    #[test]
242    fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
243        let hashed: PathBuf =
244            PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
245
246        let expected: bool = true;
247        let actual: bool = is_content_hashed(&hashed);
248        assert_eq!(expected, actual);
249    }
250
251    #[test]
252    fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
253        let expected: bool = false;
254        let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
255        assert_eq!(expected, actual);
256    }
257
258    #[test]
259    fn a_long_but_non_hex_segment_is_not_a_hash() {
260        // 32 characters, but `z` is not hex.
261        let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
262
263        let expected: bool = false;
264        let actual: bool = is_content_hashed(&path);
265        assert_eq!(expected, actual);
266    }
267
268    #[test]
269    fn a_source_map_keeps_its_double_extension() {
270        let hashed: PathBuf =
271            super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
272        let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
273
274        // The hash goes before the FIRST dot, so `main.js.map` stays a `.js.map`
275        // rather than becoming `main.js.<hash>.map`.
276        assert!(name.starts_with("Cargo."));
277        assert!(
278            std::path::Path::new(name)
279                .extension()
280                .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
281        );
282    }
283
284    #[test]
285    fn each_phase_owns_a_disjoint_half_of_the_tree() {
286        let script: &Path = Path::new("static/script/main.js");
287        let image: &Path = Path::new("static/image/social/card.webp");
288
289        assert!(!Phase::NonScripts.owns(script));
290        assert!(Phase::NonScripts.owns(image));
291        assert!(Phase::Scripts.owns(script));
292        assert!(!Phase::Scripts.owns(image));
293    }
294
295    #[test]
296    fn the_subcommands_map_to_their_phases() {
297        assert_eq!(
298            Some(Phase::NonScripts),
299            Phase::from_subcommand("gen-static-assets")
300        );
301        assert_eq!(
302            Some(Phase::Scripts),
303            Phase::from_subcommand("gen-static-scripts")
304        );
305        assert_eq!(None, Phase::from_subcommand("serve"));
306    }
307}