tower_serve_embedded_build/
lib.rs1use std::env;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26pub struct Builder {
28 dir: PathBuf,
29 hash_len: usize,
30 immutable: Vec<String>,
31}
32
33impl Builder {
34 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 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 pub fn hash_length(mut self, len: usize) -> Self {
72 self.hash_len = len.clamp(1, 64);
73 self
74 }
75
76 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 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 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 let logical = logical_path(&manifest_dir, abs);
112 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 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 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 url: String,
151 hashed_url: Option<String>,
154 hash: String,
155 content_type: String,
156}
157
158fn 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 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 Some(hashed) => routes.push((hashed.clone(), i, immutable)),
204 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 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
254fn 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
275fn 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
285fn 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
307fn lit(s: &str) -> String {
309 format!("{s:?}")
310}
311
312fn 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 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 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 assert!(!is_immutable("css/a.css", &immutable));
404 assert!(!is_immutable("vendor/other.js", &immutable));
405 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}