use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub struct Builder {
dir: PathBuf,
hash_len: usize,
immutable: Vec<String>,
}
impl Builder {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
hash_len: 16,
immutable: Vec::new(),
}
}
pub fn immutable_dir(mut self, dir: impl AsRef<str>) -> Self {
let normalized = dir.as_ref().trim_matches('/').to_string();
if !normalized.is_empty() {
self.immutable.push(normalized);
}
self
}
pub fn hash_length(mut self, len: usize) -> Self {
self.hash_len = len.clamp(1, 64);
self
}
pub fn emit(self) -> io::Result<()> {
let manifest_dir = PathBuf::from(env_var("CARGO_MANIFEST_DIR")?);
let root = manifest_dir.join(&self.dir);
let out_dir = PathBuf::from(env_var("OUT_DIR")?);
println!("cargo:rerun-if-changed=build.rs");
let mut files = Vec::new();
let mut dirs = Vec::new();
if root.is_dir() {
collect(&root, &mut files, &mut dirs)?;
} else {
println!(
"cargo:warning=tower-serve-embedded: asset directory {} not found",
root.display()
);
}
for dir in &dirs {
println!("cargo:rerun-if-changed={}", dir.display());
}
let mut assets: Vec<Asset> = Vec::with_capacity(files.len());
for abs in &files {
println!("cargo:rerun-if-changed={}", abs.display());
let bytes = fs::read(abs)?;
let logical = logical_path(&manifest_dir, abs);
let rel = logical_path(&root, abs);
let immutable = is_immutable(&rel, &self.immutable);
let hash = hash_hex(&bytes, self.hash_len);
let url = format!("/{logical}");
let hashed_url = if immutable {
None
} else {
Some(hashed_path(&logical, &hash))
};
let content_type = mime_guess::from_path(abs)
.first_or_octet_stream()
.to_string();
assets.push(Asset {
abs: abs.clone(),
logical,
url,
hashed_url,
hash,
content_type,
});
}
assets.sort_by(|a, b| a.logical.cmp(&b.logical));
let code = generate(&assets);
fs::write(out_dir.join("embed_assets.rs"), code)?;
Ok(())
}
}
struct Asset {
abs: PathBuf,
logical: String,
url: String,
hashed_url: Option<String>,
hash: String,
content_type: String,
}
fn is_immutable(rel: &str, immutable: &[String]) -> bool {
immutable
.iter()
.any(|i| rel == i || rel.starts_with(&format!("{i}/")))
}
fn generate(assets: &[Asset]) -> String {
let mut out = String::new();
out.push_str("// @generated by tower-serve-embedded-build. Do not edit.\n");
out.push_str("#[doc(hidden)]\n");
out.push_str("static __TSE_FILES: &[::tower_serve_embedded::EmbeddedFile] = &[\n");
for a in assets {
let etag = format!("\"{}\"", a.hash);
out.push_str(" ::tower_serve_embedded::EmbeddedFile {\n");
out.push_str(&format!(" url: {},\n", lit(&a.url)));
out.push_str(&format!(
" hashed_url: {},\n",
opt_lit(&a.hashed_url)
));
out.push_str(&format!(" logical_path: {},\n", lit(&a.logical)));
out.push_str(&format!(
" bytes: ::core::include_bytes!({}),\n",
lit(&a.abs.to_string_lossy())
));
out.push_str(&format!(
" content_type: {},\n",
lit(&a.content_type)
));
out.push_str(&format!(" etag: {},\n", lit(&etag)));
out.push_str(&format!(" hash: {},\n", lit(&a.hash)));
out.push_str(" },\n");
}
out.push_str("];\n\n");
let immutable = "::core::option::Option::Some(::tower_serve_embedded::IMMUTABLE_CACHE_CONTROL)";
let mut routes: Vec<(String, usize, &str)> = Vec::with_capacity(assets.len());
for (i, a) in assets.iter().enumerate() {
match &a.hashed_url {
Some(hashed) => routes.push((hashed.clone(), i, immutable)),
None => routes.push((a.url.clone(), i, immutable)),
}
}
routes.sort_by(|a, b| a.0.cmp(&b.0));
out.push_str("#[doc(hidden)]\n");
out.push_str("static __TSE_ROUTES: &[::tower_serve_embedded::Route] = &[\n");
for (url, index, cache_control) in &routes {
out.push_str(" ::tower_serve_embedded::Route {\n");
out.push_str(&format!(" url: {},\n", lit(url)));
out.push_str(&format!(" file: {index}usize,\n"));
out.push_str(&format!(" cache_control: {cache_control},\n"));
out.push_str(" },\n");
}
out.push_str("];\n\n");
out.push_str(
"/// Assets embedded at build time by `tower-serve-embedded`.\n\
pub static ASSETS: ::tower_serve_embedded::Assets =\n \
::tower_serve_embedded::Assets::new(__TSE_FILES, __TSE_ROUTES);\n\n",
);
out.push_str(
"/// Resolve a crate-root-relative asset path to its served URL at compile time.\n",
);
out.push_str("#[doc(hidden)]\n");
out.push_str("macro_rules! __tower_serve_embedded_asset {\n");
for a in assets {
let referenced = a.hashed_url.as_deref().unwrap_or(&a.url);
out.push_str(&format!(
" ({}) => {{ {} }};\n",
lit(&a.logical),
lit(referenced)
));
}
out.push_str(
" ($other:literal) => {\n \
::core::compile_error!(::core::concat!(\"tower-serve-embedded: unknown asset `\", $other, \"`\"))\n \
};\n",
);
out.push_str("}\n");
out.push_str("#[doc(hidden)]\n");
out.push_str("pub(crate) use __tower_serve_embedded_asset as asset;\n");
out
}
fn collect(dir: &Path, files: &mut Vec<PathBuf>, dirs: &mut Vec<PathBuf>) -> io::Result<()> {
dirs.push(dir.to_path_buf());
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_dir() {
collect(&path, files, dirs)?;
} else if file_type.is_file() {
files.push(path);
}
}
Ok(())
}
fn logical_path(base: &Path, file: &Path) -> String {
file.strip_prefix(base)
.unwrap_or(file)
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn hashed_path(logical: &str, hash: &str) -> String {
let (dir, file) = match logical.rsplit_once('/') {
Some((d, f)) => (Some(d), f),
None => (None, logical),
};
let hashed_file = match file.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => format!("{stem}.{hash}.{ext}"),
_ => format!("{file}.{hash}"),
};
match dir {
Some(d) => format!("/{d}/{hashed_file}"),
None => format!("/{hashed_file}"),
}
}
fn hash_hex(bytes: &[u8], len: usize) -> String {
let full = blake3::hash(bytes).to_hex();
full[..len.min(full.len())].to_string()
}
fn lit(s: &str) -> String {
format!("{s:?}")
}
fn opt_lit(s: &Option<String>) -> String {
match s {
Some(s) => format!("::core::option::Option::Some({})", lit(s)),
None => "::core::option::Option::None".to_string(),
}
}
fn env_var(key: &str) -> io::Result<String> {
env::var(key).map_err(|_| {
io::Error::new(
io::ErrorKind::NotFound,
format!("environment variable {key} is not set (is this running from build.rs?)"),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashed_path_inserts_hash_before_extension() {
assert_eq!(
hashed_path("assets/css/style.css", "abcd"),
"/assets/css/style.abcd.css"
);
assert_eq!(hashed_path("static/app.js", "abcd"), "/static/app.abcd.js");
assert_eq!(hashed_path("a/b/c.png", "ff"), "/a/b/c.ff.png");
}
#[test]
fn hashed_path_handles_no_extension_and_multi_dot() {
assert_eq!(
hashed_path("assets/LICENSE", "abcd"),
"/assets/LICENSE.abcd"
);
assert_eq!(hashed_path("a.tar.gz", "ff"), "/a.tar.ff.gz");
}
#[test]
fn hash_is_deterministic_and_truncated() {
let a = hash_hex(b"hello world", 16);
let b = hash_hex(b"hello world", 16);
assert_eq!(a, b);
assert_eq!(a.len(), 16);
assert_ne!(hash_hex(b"hello world", 16), hash_hex(b"goodbye world", 16));
}
#[test]
fn lit_escapes() {
assert_eq!(lit("a\"b"), "\"a\\\"b\"");
}
#[test]
fn collect_walks_every_dir_including_what_will_be_immutable() {
let base =
std::env::temp_dir().join(format!("tse_collect_{}_{}", std::process::id(), line!()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("css")).unwrap();
fs::create_dir_all(base.join("lib/sub")).unwrap();
fs::write(base.join("css/a.css"), "a").unwrap();
fs::write(base.join("root.txt"), "r").unwrap();
fs::write(base.join("lib/b.js"), "b").unwrap();
fs::write(base.join("lib/sub/c.js"), "c").unwrap();
let mut files = Vec::new();
let mut dirs = Vec::new();
collect(&base, &mut files, &mut dirs).unwrap();
let mut logicals: Vec<String> = files.iter().map(|f| logical_path(&base, f)).collect();
logicals.sort();
assert_eq!(
logicals,
vec!["css/a.css", "lib/b.js", "lib/sub/c.js", "root.txt"]
);
assert!(dirs.iter().any(|d| logical_path(&base, d) == "lib"));
assert!(dirs.iter().any(|d| logical_path(&base, d) == "lib/sub"));
fs::remove_dir_all(&base).unwrap();
}
#[test]
fn is_immutable_matches_dir_and_descendants_only() {
let immutable = vec!["lib".to_string(), "vendor/pkg".to_string()];
assert!(is_immutable("lib/htmx.js", &immutable));
assert!(is_immutable("lib/sub/a.js", &immutable));
assert!(is_immutable("vendor/pkg/x.css", &immutable));
assert!(!is_immutable("css/a.css", &immutable));
assert!(!is_immutable("vendor/other.js", &immutable));
assert!(!is_immutable("library/a.js", &immutable));
}
#[test]
fn generated_routes_only_include_plain_urls_for_immutable_assets() {
let assets = vec![
Asset {
abs: PathBuf::from("/tmp/assets/css/style.css"),
logical: "assets/css/style.css".to_string(),
url: "/assets/css/style.css".to_string(),
hashed_url: Some("/assets/css/style.abcd.css".to_string()),
hash: "abcd".to_string(),
content_type: "text/css".to_string(),
},
Asset {
abs: PathBuf::from("/tmp/assets/lib/htmx-1.9.10.min.js"),
logical: "assets/lib/htmx-1.9.10.min.js".to_string(),
url: "/assets/lib/htmx-1.9.10.min.js".to_string(),
hashed_url: None,
hash: "beef".to_string(),
content_type: "text/javascript".to_string(),
},
];
let code = generate(&assets);
let routes = code
.split("static __TSE_ROUTES")
.nth(1)
.unwrap()
.split("];")
.next()
.unwrap();
assert!(routes.contains("url: \"/assets/css/style.abcd.css\""));
assert!(!routes.contains("url: \"/assets/css/style.css\""));
assert!(routes.contains("url: \"/assets/lib/htmx-1.9.10.min.js\""));
}
#[test]
fn generated_asset_macro_is_not_macro_exported() {
let assets = vec![Asset {
abs: PathBuf::from("/tmp/assets/css/style.css"),
logical: "assets/css/style.css".to_string(),
url: "/assets/css/style.css".to_string(),
hashed_url: Some("/assets/css/style.abcd.css".to_string()),
hash: "abcd".to_string(),
content_type: "text/css".to_string(),
}];
let code = generate(&assets);
assert!(!code.contains("#[macro_export]"));
assert!(code.contains("macro_rules! __tower_serve_embedded_asset"));
assert!(code.contains("pub(crate) use __tower_serve_embedded_asset as asset;"));
}
}