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        let path: &Path = Path::new(MANIFEST_PATH);
43        if !path.is_file() {
44            return Ok(Self::default());
45        }
46        Self::load()
47    }
48
49    /// Reads the manifest.
50    ///
51    /// # Errors
52    ///
53    /// [`CacheBusterError::ReadFile`] if it cannot be read, or
54    /// [`CacheBusterError::ParseManifest`] if it is not valid JSON.
55    pub fn load() -> Result<Self, CacheBusterError> {
56        let path: PathBuf = PathBuf::from(MANIFEST_PATH);
57        let contents: String =
58            std::fs::read_to_string(&path).map_err(|source| CacheBusterError::ReadFile {
59                path: path.clone(),
60                source,
61            })?;
62        let entries: BTreeMap<String, String> = serde_json::from_str(&contents)
63            .map_err(|source| CacheBusterError::ParseManifest { path, source })?;
64        Ok(Self { entries })
65    }
66
67    /// Adds or replaces entries.
68    pub fn extend(&mut self, entries: BTreeMap<String, String>) {
69        self.entries.extend(entries);
70    }
71
72    /// How many assets are hashed.
73    #[must_use]
74    pub fn len(&self) -> usize {
75        self.entries.len()
76    }
77
78    /// Whether nothing is hashed.
79    #[must_use]
80    pub fn is_empty(&self) -> bool {
81        self.entries.is_empty()
82    }
83
84    /// The underlying map, as the templates see it.
85    #[must_use]
86    pub const fn entries(&self) -> &BTreeMap<String, String> {
87        &self.entries
88    }
89
90    /// Consumes this manifest for its map.
91    #[must_use]
92    pub fn into_entries(self) -> BTreeMap<String, String> {
93        self.entries
94    }
95
96    /// The hashed path for `original`, or `original` itself when it is not a
97    /// hashed asset.
98    #[must_use]
99    pub fn resolve<'a>(&'a self, original: &'a str) -> &'a str {
100        let key: &str = original.trim_start_matches('/');
101        self.entries.get(key).map_or(original, String::as_str)
102    }
103
104    /// Whether this manifest knows `original` as a hashed asset.
105    #[must_use]
106    pub fn contains(&self, original: &str) -> bool {
107        self.entries.contains_key(original.trim_start_matches('/'))
108    }
109
110    /// Writes the JSON manifest.
111    ///
112    /// # Errors
113    ///
114    /// [`CacheBusterError::WriteManifest`] if it cannot be created or written.
115    pub fn write_json(&self) -> Result<(), CacheBusterError> {
116        let path: PathBuf = PathBuf::from(MANIFEST_PATH);
117        let file: File = File::create(&path).map_err(|source| CacheBusterError::WriteManifest {
118            path: path.clone(),
119            source,
120        })?;
121        serde_json::to_writer_pretty(BufWriter::new(file), &self.entries).map_err(|source| {
122            CacheBusterError::WriteManifest {
123                path,
124                source: std::io::Error::other(source),
125            }
126        })
127    }
128
129    /// Writes the generated TypeScript module.
130    ///
131    /// It is emitted `as const` with a key union, so a mistyped asset path is a
132    /// compile error rather than a silent `undefined` and a broken image.
133    ///
134    /// # Errors
135    ///
136    /// [`CacheBusterError::WriteManifest`] if the file cannot be created or
137    /// written.
138    pub fn write_typescript(&self) -> Result<(), CacheBusterError> {
139        let path: PathBuf = PathBuf::from(TYPESCRIPT_MODULE_PATH);
140        if let Some(parent) = path.parent() {
141            std::fs::create_dir_all(parent).map_err(|source| CacheBusterError::WriteManifest {
142                path: parent.to_path_buf(),
143                source,
144            })?;
145        }
146
147        let mut source: String = String::from(
148            "// Generated by `gen_static_assets`. Do not edit, do not commit.\n\n\
149             /**\n\
150             \x20* Maps a logical static-asset path to its content-hashed one. The build\n\
151             \x20* inlines this, so resolving an asset costs the browser nothing at\n\
152             \x20* runtime.\n\
153             \x20*/\n\
154             export const CACHE_BUSTER = {\n",
155        );
156        for (original, hashed) in &self.entries {
157            // The write cannot fail: the target is an in-memory String.
158            let _ = writeln!(source, "  {original:?}: {hashed:?},");
159        }
160        source.push_str(
161            "} as const;\n\n\
162             /** Every asset path the build knows about. */\n\
163             export type CacheBustedPath = keyof typeof CACHE_BUSTER;\n\n\
164             /** Resolves a static asset to its root-absolute, hashed URL. */\n\
165             export function asset(path: CacheBustedPath): string {\n\
166             \x20 return `/${CACHE_BUSTER[path]}`;\n\
167             }\n",
168        );
169
170        std::fs::write(&path, source)
171            .map_err(|source| CacheBusterError::WriteManifest { path, source })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use std::collections::BTreeMap;
178
179    use super::Manifest;
180
181    fn manifest() -> Manifest {
182        let mut entries: BTreeMap<String, String> = BTreeMap::new();
183        entries.insert(
184            String::from("static/stylesheet/main.css"),
185            String::from("static/stylesheet/main.abc123.css"),
186        );
187        Manifest { entries }
188    }
189
190    #[test]
191    fn a_known_asset_resolves_to_its_hashed_path() {
192        let manifest: Manifest = manifest();
193
194        let expected: &str = "static/stylesheet/main.abc123.css";
195        let actual: &str = manifest.resolve("static/stylesheet/main.css");
196        assert_eq!(expected, actual);
197    }
198
199    #[test]
200    fn a_leading_slash_still_resolves() {
201        let manifest: Manifest = manifest();
202
203        let expected: &str = "static/stylesheet/main.abc123.css";
204        let actual: &str = manifest.resolve("/static/stylesheet/main.css");
205        assert_eq!(expected, actual);
206    }
207
208    #[test]
209    fn an_unknown_asset_is_returned_unchanged() {
210        let manifest: Manifest = manifest();
211
212        let expected: &str = "https://cdn.example.com/a.css";
213        let actual: &str = manifest.resolve("https://cdn.example.com/a.css");
214        assert_eq!(expected, actual);
215    }
216
217    #[test]
218    fn extending_replaces_an_existing_entry_rather_than_duplicating_it() {
219        let mut manifest: Manifest = manifest();
220        let mut second: BTreeMap<String, String> = BTreeMap::new();
221        second.insert(
222            String::from("static/stylesheet/main.css"),
223            String::from("static/stylesheet/main.def456.css"),
224        );
225        manifest.extend(second);
226
227        let expected: usize = 1;
228        let actual: usize = manifest.len();
229        assert_eq!(expected, actual);
230
231        let expected: &str = "static/stylesheet/main.def456.css";
232        let actual: &str = manifest.resolve("static/stylesheet/main.css");
233        assert_eq!(expected, actual);
234    }
235}