Skip to main content

webserver_base/assets/
manifest.rs

1//! The cache-buster manifest: logical asset path to content-hashed path.
2//!
3//! Two consumers read it, and both read the *same* build output. The server
4//! loads the JSON at boot to resolve `{{lookup cache_buster …}}` in templates;
5//! the JavaScript build inlines the generated TypeScript module so browser code
6//! can resolve an asset without a network round trip.
7//!
8//! Both are build outputs, so both belong in `.gitignore`. They used to be
9//! committed because the server wrote the manifest and the *next* build
10//! consumed it — a cycle that made a stale checked-in copy able to feed the JS
11//! build silently. Generating them before anything reads them removes the cycle
12//! and the whole class of bug.
13
14use std::collections::BTreeMap;
15use std::fmt::Write as _;
16use std::fs::File;
17use std::io::BufWriter;
18use std::path::{Path, PathBuf};
19
20use super::error::CacheBusterError;
21
22/// Where the JSON manifest is written, relative to the project root.
23pub const MANIFEST_PATH: &str = "cache-buster.json";
24
25/// Where the generated TypeScript module is written.
26pub const TYPESCRIPT_MODULE_PATH: &str = "static/script/generated/cache-buster.ts";
27
28/// A logical-path to hashed-path map.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct Manifest {
31    entries: BTreeMap<String, String>,
32}
33
34impl Manifest {
35    /// Reads the manifest, or an empty one if it does not exist yet.
36    ///
37    /// # Errors
38    ///
39    /// [`CacheBusterError::ReadFile`] if it exists but cannot be read, or
40    /// [`CacheBusterError::ParseManifest`] if it is not valid JSON.
41    pub fn load_or_empty() -> Result<Self, CacheBusterError> {
42        Self::load_or_empty_in(Path::new(""))
43    }
44
45    /// [`load_or_empty`](Self::load_or_empty), under `root`.
46    pub(crate) fn load_or_empty_in(root: &Path) -> Result<Self, CacheBusterError> {
47        if !root.join(MANIFEST_PATH).is_file() {
48            return Ok(Self::default());
49        }
50        Self::load_in(root)
51    }
52
53    /// Reads the manifest.
54    ///
55    /// # Errors
56    ///
57    /// [`CacheBusterError::ReadFile`] if it cannot be read, or
58    /// [`CacheBusterError::ParseManifest`] if it is not valid JSON.
59    pub fn load() -> Result<Self, CacheBusterError> {
60        Self::load_in(Path::new(""))
61    }
62
63    /// [`load`](Self::load), under `root`.
64    pub(crate) fn load_in(root: &Path) -> Result<Self, CacheBusterError> {
65        let path: PathBuf = root.join(MANIFEST_PATH);
66        let contents: String =
67            std::fs::read_to_string(&path).map_err(|source| CacheBusterError::ReadFile {
68                path: path.clone(),
69                source,
70            })?;
71        let entries: BTreeMap<String, String> = serde_json::from_str(&contents)
72            .map_err(|source| CacheBusterError::ParseManifest { path, source })?;
73        Ok(Self { entries })
74    }
75
76    /// Adds or replaces entries.
77    pub fn extend(&mut self, entries: BTreeMap<String, String>) {
78        self.entries.extend(entries);
79    }
80
81    /// How many assets are hashed.
82    #[must_use]
83    pub fn len(&self) -> usize {
84        self.entries.len()
85    }
86
87    /// Whether nothing is hashed.
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.entries.is_empty()
91    }
92
93    /// The underlying map, as the templates see it.
94    #[must_use]
95    pub const fn entries(&self) -> &BTreeMap<String, String> {
96        &self.entries
97    }
98
99    /// Consumes this manifest for its map.
100    #[must_use]
101    pub fn into_entries(self) -> BTreeMap<String, String> {
102        self.entries
103    }
104
105    /// The hashed path for `original`, or `original` itself when it is not a
106    /// hashed asset.
107    #[must_use]
108    pub fn resolve<'a>(&'a self, original: &'a str) -> &'a str {
109        let key: &str = original.trim_start_matches('/');
110        self.entries.get(key).map_or(original, String::as_str)
111    }
112
113    /// Whether this manifest knows `original` as a hashed asset.
114    #[must_use]
115    pub fn contains(&self, original: &str) -> bool {
116        self.entries.contains_key(original.trim_start_matches('/'))
117    }
118
119    /// Writes the JSON manifest.
120    ///
121    /// # Errors
122    ///
123    /// [`CacheBusterError::WriteManifest`] if it cannot be created or written.
124    pub fn write_json(&self) -> Result<(), CacheBusterError> {
125        self.write_json_in(Path::new(""))
126    }
127
128    /// [`write_json`](Self::write_json), under `root`.
129    pub(crate) fn write_json_in(&self, root: &Path) -> Result<(), CacheBusterError> {
130        let path: PathBuf = root.join(MANIFEST_PATH);
131        let file: File = File::create(&path).map_err(|source| CacheBusterError::WriteManifest {
132            path: path.clone(),
133            source,
134        })?;
135        serde_json::to_writer_pretty(BufWriter::new(file), &self.entries).map_err(|source| {
136            CacheBusterError::WriteManifest {
137                path,
138                source: std::io::Error::other(source),
139            }
140        })
141    }
142
143    /// Writes the generated TypeScript module.
144    ///
145    /// It is emitted `as const` with a key union, so a mistyped asset path is a
146    /// compile error rather than a silent `undefined` and a broken image.
147    ///
148    /// # Errors
149    ///
150    /// [`CacheBusterError::WriteManifest`] if the file cannot be created or
151    /// written.
152    pub fn write_typescript(&self) -> Result<(), CacheBusterError> {
153        self.write_typescript_in(Path::new(""))
154    }
155
156    /// [`write_typescript`](Self::write_typescript), under `root`.
157    pub(crate) fn write_typescript_in(&self, root: &Path) -> Result<(), CacheBusterError> {
158        let path: PathBuf = root.join(TYPESCRIPT_MODULE_PATH);
159        if let Some(parent) = path.parent() {
160            std::fs::create_dir_all(parent).map_err(|source| CacheBusterError::WriteManifest {
161                path: parent.to_path_buf(),
162                source,
163            })?;
164        }
165
166        let mut source: String = String::from(
167            "// Generated by `gen_static_assets`. Do not edit, do not commit.\n\n\
168             /**\n\
169             \x20* Maps a logical static-asset path to its content-hashed one. The build\n\
170             \x20* inlines this, so resolving an asset costs the browser nothing at\n\
171             \x20* runtime.\n\
172             \x20*/\n\
173             export const CACHE_BUSTER = {\n",
174        );
175        for (original, hashed) in &self.entries {
176            // The write cannot fail: the target is an in-memory String.
177            let _ = writeln!(source, "  {original:?}: {hashed:?},");
178        }
179        source.push_str(
180            "} as const;\n\n\
181             /** Every asset path the build knows about. */\n\
182             export type CacheBustedPath = keyof typeof CACHE_BUSTER;\n\n\
183             /** Resolves a static asset to its root-absolute, hashed URL. */\n\
184             export function asset(path: CacheBustedPath): string {\n\
185             \x20 return `/${CACHE_BUSTER[path]}`;\n\
186             }\n",
187        );
188
189        std::fs::write(&path, source)
190            .map_err(|source| CacheBusterError::WriteManifest { path, source })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::collections::BTreeMap;
197
198    use super::Manifest;
199
200    fn manifest() -> Manifest {
201        let mut entries: BTreeMap<String, String> = BTreeMap::new();
202        entries.insert(
203            String::from("static/stylesheet/main.css"),
204            String::from("static/stylesheet/main.abc123.css"),
205        );
206        Manifest { entries }
207    }
208
209    #[test]
210    fn a_known_asset_resolves_to_its_hashed_path() {
211        let manifest: Manifest = manifest();
212
213        let expected: &str = "static/stylesheet/main.abc123.css";
214        let actual: &str = manifest.resolve("static/stylesheet/main.css");
215        assert_eq!(expected, actual);
216    }
217
218    #[test]
219    fn a_leading_slash_still_resolves() {
220        let manifest: Manifest = manifest();
221
222        let expected: &str = "static/stylesheet/main.abc123.css";
223        let actual: &str = manifest.resolve("/static/stylesheet/main.css");
224        assert_eq!(expected, actual);
225    }
226
227    #[test]
228    fn an_unknown_asset_is_returned_unchanged() {
229        let manifest: Manifest = manifest();
230
231        let expected: &str = "https://cdn.example.com/a.css";
232        let actual: &str = manifest.resolve("https://cdn.example.com/a.css");
233        assert_eq!(expected, actual);
234    }
235
236    #[test]
237    fn extending_replaces_an_existing_entry_rather_than_duplicating_it() {
238        let mut manifest: Manifest = manifest();
239        let mut second: BTreeMap<String, String> = BTreeMap::new();
240        second.insert(
241            String::from("static/stylesheet/main.css"),
242            String::from("static/stylesheet/main.def456.css"),
243        );
244        manifest.extend(second);
245
246        let expected: usize = 1;
247        let actual: usize = manifest.len();
248        assert_eq!(expected, actual);
249
250        let expected: &str = "static/stylesheet/main.def456.css";
251        let actual: &str = manifest.resolve("static/stylesheet/main.css");
252        assert_eq!(expected, actual);
253    }
254}