Skip to main content

tower_serve_embedded_build/
lib.rs

1//! Build-time helper for [`tower-serve-embedded`](https://docs.rs/tower-serve-embedded).
2//!
3//! Call this from your crate's `build.rs`. It walks an asset directory, content-hashes every file
4//! with BLAKE3, and writes a generated Rust file to `OUT_DIR` containing the embedded manifest
5//! (`ASSETS`) and a compile-time `asset!` macro. Pull that into your crate with
6//! [`tower_serve_embedded::embed!()`](https://docs.rs/tower-serve-embedded).
7//!
8//! ```ignore
9//! // build.rs
10//! fn main() {
11//!     tower_serve_embedded_build::Builder::new("assets").emit().unwrap();
12//! }
13//! ```
14//!
15//! Asset paths are expressed **relative to the crate root**, so the embedded URL mirrors the
16//! file's location in your project. Ordinary files are served at a cache-busted URL
17//! (`/assets/css/style.<hash>.css`). Files under [`Builder::immutable_dir`] are treated as already
18//! versioned and served only at their plain URL (`/assets/lib/htmx-1.9.10.min.js`). Hidden files
19//! and directories (names starting with `.`) and symlinks are ignored.
20
21use std::env;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26/// Configures and runs asset embedding from a `build.rs`.
27pub struct Builder {
28    dir: PathBuf,
29    hash_len: usize,
30    immutable: Vec<String>,
31}
32
33impl Builder {
34    /// Embed every file under `dir`, resolved relative to `CARGO_MANIFEST_DIR` (your crate root).
35    ///
36    /// Files keep their crate-root-relative paths, so embedding `assets/` makes
37    /// `assets/css/style.css` available as `asset!("assets/css/style.css")`. Ordinary files are
38    /// served at a cache-busted URL (`/assets/css/style.<hash>.css`). Files in an
39    /// [`immutable_dir`] are served only at their plain URL (`/assets/lib/htmx-1.9.10.min.js`).
40    /// The hash defaults to 16 hex chars.
41    ///
42    /// [`immutable_dir`]: Builder::immutable_dir
43    pub fn new(dir: impl Into<PathBuf>) -> Self {
44        Self {
45            dir: dir.into(),
46            hash_len: 16,
47            immutable: Vec::new(),
48        }
49    }
50
51    /// Mark a directory (and everything under it) as **already immutable**, given as a path
52    /// **relative to the embedded directory** passed to [`Builder::new`].
53    ///
54    /// Files under it are still embedded and served, but at their original, non-hashed URL with a
55    /// one-year `immutable` cache — they are *not* given an extra content hash. Use this for assets
56    /// whose names already encode a version (vendored libraries, CDN-style
57    /// `lib/htmx-1.9.10.min.js`), so `asset!("assets/lib/htmx-1.9.10.min.js")` resolves to
58    /// `/assets/lib/htmx-1.9.10.min.js`. A leading or trailing slash is accepted (`"/lib"`,
59    /// `"lib/"`); matching is by path prefix, not a glob. Call it multiple times for several
60    /// directories.
61    pub fn immutable_dir(mut self, dir: impl AsRef<str>) -> Self {
62        let normalized = dir.as_ref().trim_matches('/').to_string();
63        if !normalized.is_empty() {
64            self.immutable.push(normalized);
65        }
66        self
67    }
68
69    /// Number of hex characters of the BLAKE3 hash to embed in filenames and ETags (default 16,
70    /// i.e. 64 bits — clamped to 1..=64).
71    pub fn hash_length(mut self, len: usize) -> Self {
72        self.hash_len = len.clamp(1, 64);
73        self
74    }
75
76    /// Walk the directory, hash the files, and write the generated code to `OUT_DIR`.
77    ///
78    /// Also emits `cargo:rerun-if-changed` lines so a content change refreshes the hashes and
79    /// added/removed files are picked up — pair it with `cargo watch` for hot reload.
80    pub fn emit(self) -> io::Result<()> {
81        let manifest_dir = PathBuf::from(env_var("CARGO_MANIFEST_DIR")?);
82        let root = manifest_dir.join(&self.dir);
83        let out_dir = PathBuf::from(env_var("OUT_DIR")?);
84
85        // Re-run if the build script itself changes (emitting any rerun-if-changed line opts out
86        // of cargo's default "rerun if any package file changed" behaviour).
87        println!("cargo:rerun-if-changed=build.rs");
88
89        let mut files = Vec::new();
90        let mut dirs = Vec::new();
91        if root.is_dir() {
92            collect(&root, &mut files, &mut dirs)?;
93        } else {
94            println!(
95                "cargo:warning=tower-serve-embedded: asset directory {} not found",
96                root.display()
97            );
98        }
99
100        // rerun-if-changed for every directory (catches added/removed files) and every file
101        // (catches content edits, which change the hash).
102        for dir in &dirs {
103            println!("cargo:rerun-if-changed={}", dir.display());
104        }
105
106        let mut assets: Vec<Asset> = Vec::with_capacity(files.len());
107        for abs in &files {
108            println!("cargo:rerun-if-changed={}", abs.display());
109            let bytes = fs::read(abs)?;
110            // Relative to the crate root, so the URL mirrors the project layout.
111            let logical = logical_path(&manifest_dir, abs);
112            // Relative to the embedded dir, for matching against `immutable_dir` entries.
113            let rel = logical_path(&root, abs);
114            let immutable = is_immutable(&rel, &self.immutable);
115            let hash = hash_hex(&bytes, self.hash_len);
116            let url = format!("/{logical}");
117            // Already-immutable assets keep their plain URL; everything else gets a hashed alias.
118            let hashed_url = if immutable {
119                None
120            } else {
121                Some(hashed_path(&logical, &hash))
122            };
123            let content_type = mime_guess::from_path(abs)
124                .first_or_octet_stream()
125                .to_string();
126            assets.push(Asset {
127                abs: abs.clone(),
128                logical,
129                url,
130                hashed_url,
131                hash,
132                content_type,
133            });
134        }
135
136        // Stable, deterministic codegen order.
137        assets.sort_by(|a, b| a.logical.cmp(&b.logical));
138
139        let code = generate(&assets);
140        fs::write(out_dir.join("embed_assets.rs"), code)?;
141        Ok(())
142    }
143}
144
145struct Asset {
146    abs: PathBuf,
147    logical: String,
148    /// The plain, non-hashed URL, e.g. `/assets/css/style.css`. This is served only for assets in
149    /// an `immutable_dir`.
150    url: String,
151    /// The cache-busted served URL, e.g. `/assets/css/style.<hash>.css`. `None` for assets in an
152    /// `immutable_dir`, which are served only at `url`.
153    hashed_url: Option<String>,
154    hash: String,
155    content_type: String,
156}
157
158/// Whether `rel` (a path relative to the embedded dir) is inside one of the `immutable` dirs —
159/// either the dir itself or a descendant (prefix match on path components).
160fn is_immutable(rel: &str, immutable: &[String]) -> bool {
161    immutable
162        .iter()
163        .any(|i| rel == i || rel.starts_with(&format!("{i}/")))
164}
165
166fn generate(assets: &[Asset]) -> String {
167    let mut out = String::new();
168    out.push_str("// @generated by tower-serve-embedded-build. Do not edit.\n");
169
170    out.push_str("#[doc(hidden)]\n");
171    out.push_str("static __TSE_FILES: &[::tower_serve_embedded::EmbeddedFile] = &[\n");
172    for a in assets {
173        let etag = format!("\"{}\"", a.hash);
174        out.push_str("    ::tower_serve_embedded::EmbeddedFile {\n");
175        out.push_str(&format!("        url: {},\n", lit(&a.url)));
176        out.push_str(&format!(
177            "        hashed_url: {},\n",
178            opt_lit(&a.hashed_url)
179        ));
180        out.push_str(&format!("        logical_path: {},\n", lit(&a.logical)));
181        out.push_str(&format!(
182            "        bytes: ::core::include_bytes!({}),\n",
183            lit(&a.abs.to_string_lossy())
184        ));
185        out.push_str(&format!(
186            "        content_type: {},\n",
187            lit(&a.content_type)
188        ));
189        out.push_str(&format!("        etag: {},\n", lit(&etag)));
190        out.push_str(&format!("        hash: {},\n", lit(&a.hash)));
191        out.push_str("    },\n");
192    }
193    out.push_str("];\n\n");
194
195    // Every served URL → (file index, Cache-Control), sorted by URL for binary search. Hashable
196    // assets serve only their cache-busted alias; already-immutable assets serve only their plain
197    // URL.
198    let immutable = "::core::option::Option::Some(::tower_serve_embedded::IMMUTABLE_CACHE_CONTROL)";
199    let mut routes: Vec<(String, usize, &str)> = Vec::with_capacity(assets.len());
200    for (i, a) in assets.iter().enumerate() {
201        match &a.hashed_url {
202            // Hashable asset: only the hashed URL is served, immutable.
203            Some(hashed) => routes.push((hashed.clone(), i, immutable)),
204            // Already-immutable asset: served only at its plain URL, immutable.
205            None => routes.push((a.url.clone(), i, immutable)),
206        }
207    }
208    routes.sort_by(|a, b| a.0.cmp(&b.0));
209
210    out.push_str("#[doc(hidden)]\n");
211    out.push_str("static __TSE_ROUTES: &[::tower_serve_embedded::Route] = &[\n");
212    for (url, index, cache_control) in &routes {
213        out.push_str("    ::tower_serve_embedded::Route {\n");
214        out.push_str(&format!("        url: {},\n", lit(url)));
215        out.push_str(&format!("        file: {index}usize,\n"));
216        out.push_str(&format!("        cache_control: {cache_control},\n"));
217        out.push_str("    },\n");
218    }
219    out.push_str("];\n\n");
220
221    out.push_str(
222        "/// Assets embedded at build time by `tower-serve-embedded`.\n\
223         pub static ASSETS: ::tower_serve_embedded::Assets =\n    \
224         ::tower_serve_embedded::Assets::new(__TSE_FILES, __TSE_ROUTES);\n\n",
225    );
226
227    // A compile-time map from crate-root-relative path to the URL to reference it by (the
228    // cache-busted alias when present, otherwise the plain URL). Unknown names are a compile error.
229    out.push_str(
230        "/// Resolve a crate-root-relative asset path to its served URL at compile time.\n",
231    );
232    out.push_str("#[doc(hidden)]\n");
233    out.push_str("macro_rules! __tower_serve_embedded_asset {\n");
234    for a in assets {
235        let referenced = a.hashed_url.as_deref().unwrap_or(&a.url);
236        out.push_str(&format!(
237            "    ({}) => {{ {} }};\n",
238            lit(&a.logical),
239            lit(referenced)
240        ));
241    }
242    out.push_str(
243        "    ($other:literal) => {\n        \
244         ::core::compile_error!(::core::concat!(\"tower-serve-embedded: unknown asset `\", $other, \"`\"))\n    \
245         };\n",
246    );
247    out.push_str("}\n");
248    out.push_str("#[doc(hidden)]\n");
249    out.push_str("pub(crate) use __tower_serve_embedded_asset as asset;\n");
250
251    out
252}
253
254/// Recursively collect files (into `files`) and directories (into `dirs`), skipping dotfiles and
255/// symlinks. Entries are visited in sorted order for deterministic output.
256fn collect(dir: &Path, files: &mut Vec<PathBuf>, dirs: &mut Vec<PathBuf>) -> io::Result<()> {
257    dirs.push(dir.to_path_buf());
258    let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
259    entries.sort_by_key(|e| e.file_name());
260    for entry in entries {
261        if entry.file_name().to_string_lossy().starts_with('.') {
262            continue;
263        }
264        let file_type = entry.file_type()?;
265        let path = entry.path();
266        if file_type.is_dir() {
267            collect(&path, files, dirs)?;
268        } else if file_type.is_file() {
269            files.push(path);
270        }
271    }
272    Ok(())
273}
274
275/// The path of `file` relative to `base`, using `/` separators (e.g. `assets/css/style.css`).
276fn logical_path(base: &Path, file: &Path) -> String {
277    file.strip_prefix(base)
278        .unwrap_or(file)
279        .components()
280        .map(|c| c.as_os_str().to_string_lossy())
281        .collect::<Vec<_>>()
282        .join("/")
283}
284
285/// Insert `hash` before the extension and prepend a leading slash:
286/// `assets/css/style.css` + `9f3a1c2b` → `/assets/css/style.9f3a1c2b.css`.
287fn hashed_path(logical: &str, hash: &str) -> String {
288    let (dir, file) = match logical.rsplit_once('/') {
289        Some((d, f)) => (Some(d), f),
290        None => (None, logical),
291    };
292    let hashed_file = match file.rsplit_once('.') {
293        Some((stem, ext)) if !stem.is_empty() => format!("{stem}.{hash}.{ext}"),
294        _ => format!("{file}.{hash}"),
295    };
296    match dir {
297        Some(d) => format!("/{d}/{hashed_file}"),
298        None => format!("/{hashed_file}"),
299    }
300}
301
302fn hash_hex(bytes: &[u8], len: usize) -> String {
303    let full = blake3::hash(bytes).to_hex();
304    full[..len.min(full.len())].to_string()
305}
306
307/// Render `s` as a valid Rust string literal (handles quotes, backslashes, etc.).
308fn lit(s: &str) -> String {
309    format!("{s:?}")
310}
311
312/// Render an optional string as a Rust `Option<&'static str>` expression.
313fn opt_lit(s: &Option<String>) -> String {
314    match s {
315        Some(s) => format!("::core::option::Option::Some({})", lit(s)),
316        None => "::core::option::Option::None".to_string(),
317    }
318}
319
320fn env_var(key: &str) -> io::Result<String> {
321    env::var(key).map_err(|_| {
322        io::Error::new(
323            io::ErrorKind::NotFound,
324            format!("environment variable {key} is not set (is this running from build.rs?)"),
325        )
326    })
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn hashed_path_inserts_hash_before_extension() {
335        assert_eq!(
336            hashed_path("assets/css/style.css", "abcd"),
337            "/assets/css/style.abcd.css"
338        );
339        assert_eq!(hashed_path("static/app.js", "abcd"), "/static/app.abcd.js");
340        assert_eq!(hashed_path("a/b/c.png", "ff"), "/a/b/c.ff.png");
341    }
342
343    #[test]
344    fn hashed_path_handles_no_extension_and_multi_dot() {
345        assert_eq!(
346            hashed_path("assets/LICENSE", "abcd"),
347            "/assets/LICENSE.abcd"
348        );
349        assert_eq!(hashed_path("a.tar.gz", "ff"), "/a.tar.ff.gz");
350    }
351
352    #[test]
353    fn hash_is_deterministic_and_truncated() {
354        let a = hash_hex(b"hello world", 16);
355        let b = hash_hex(b"hello world", 16);
356        assert_eq!(a, b);
357        assert_eq!(a.len(), 16);
358        assert_ne!(hash_hex(b"hello world", 16), hash_hex(b"goodbye world", 16));
359    }
360
361    #[test]
362    fn lit_escapes() {
363        assert_eq!(lit("a\"b"), "\"a\\\"b\"");
364    }
365
366    #[test]
367    fn collect_walks_every_dir_including_what_will_be_immutable() {
368        let base =
369            std::env::temp_dir().join(format!("tse_collect_{}_{}", std::process::id(), line!()));
370        let _ = fs::remove_dir_all(&base);
371        fs::create_dir_all(base.join("css")).unwrap();
372        fs::create_dir_all(base.join("lib/sub")).unwrap();
373        fs::write(base.join("css/a.css"), "a").unwrap();
374        fs::write(base.join("root.txt"), "r").unwrap();
375        fs::write(base.join("lib/b.js"), "b").unwrap();
376        fs::write(base.join("lib/sub/c.js"), "c").unwrap();
377
378        let mut files = Vec::new();
379        let mut dirs = Vec::new();
380        collect(&base, &mut files, &mut dirs).unwrap();
381
382        // Everything is collected — nothing is skipped anymore.
383        let mut logicals: Vec<String> = files.iter().map(|f| logical_path(&base, f)).collect();
384        logicals.sort();
385        assert_eq!(
386            logicals,
387            vec!["css/a.css", "lib/b.js", "lib/sub/c.js", "root.txt"]
388        );
389        // The "immutable" directory is walked and watched like any other.
390        assert!(dirs.iter().any(|d| logical_path(&base, d) == "lib"));
391        assert!(dirs.iter().any(|d| logical_path(&base, d) == "lib/sub"));
392
393        fs::remove_dir_all(&base).unwrap();
394    }
395
396    #[test]
397    fn is_immutable_matches_dir_and_descendants_only() {
398        let immutable = vec!["lib".to_string(), "vendor/pkg".to_string()];
399        assert!(is_immutable("lib/htmx.js", &immutable));
400        assert!(is_immutable("lib/sub/a.js", &immutable));
401        assert!(is_immutable("vendor/pkg/x.css", &immutable));
402        // Not under an immutable dir.
403        assert!(!is_immutable("css/a.css", &immutable));
404        assert!(!is_immutable("vendor/other.js", &immutable));
405        // A prefix that isn't a path boundary must not match.
406        assert!(!is_immutable("library/a.js", &immutable));
407    }
408
409    #[test]
410    fn generated_routes_only_include_plain_urls_for_immutable_assets() {
411        let assets = vec![
412            Asset {
413                abs: PathBuf::from("/tmp/assets/css/style.css"),
414                logical: "assets/css/style.css".to_string(),
415                url: "/assets/css/style.css".to_string(),
416                hashed_url: Some("/assets/css/style.abcd.css".to_string()),
417                hash: "abcd".to_string(),
418                content_type: "text/css".to_string(),
419            },
420            Asset {
421                abs: PathBuf::from("/tmp/assets/lib/htmx-1.9.10.min.js"),
422                logical: "assets/lib/htmx-1.9.10.min.js".to_string(),
423                url: "/assets/lib/htmx-1.9.10.min.js".to_string(),
424                hashed_url: None,
425                hash: "beef".to_string(),
426                content_type: "text/javascript".to_string(),
427            },
428        ];
429
430        let code = generate(&assets);
431        let routes = code
432            .split("static __TSE_ROUTES")
433            .nth(1)
434            .unwrap()
435            .split("];")
436            .next()
437            .unwrap();
438
439        assert!(routes.contains("url: \"/assets/css/style.abcd.css\""));
440        assert!(!routes.contains("url: \"/assets/css/style.css\""));
441        assert!(routes.contains("url: \"/assets/lib/htmx-1.9.10.min.js\""));
442    }
443
444    #[test]
445    fn generated_asset_macro_is_not_macro_exported() {
446        let assets = vec![Asset {
447            abs: PathBuf::from("/tmp/assets/css/style.css"),
448            logical: "assets/css/style.css".to_string(),
449            url: "/assets/css/style.css".to_string(),
450            hashed_url: Some("/assets/css/style.abcd.css".to_string()),
451            hash: "abcd".to_string(),
452            content_type: "text/css".to_string(),
453        }];
454
455        let code = generate(&assets);
456
457        assert!(!code.contains("#[macro_export]"));
458        assert!(code.contains("macro_rules! __tower_serve_embedded_asset"));
459        assert!(code.contains("pub(crate) use __tower_serve_embedded_asset as asset;"));
460    }
461}