tower_serve_embedded/lib.rs
1//! Embed content-hashed static web assets into your binary and serve them with `tower`.
2//!
3//! `tower-serve-embedded` is similar to [`rust-embed`](https://docs.rs/rust-embed) but tailored
4//! for *serving* web assets from a `tower`/`axum` stack: ordinary files are embedded and exposed at
5//! a content-hashed URL that mirrors their location in your crate
6//! (`assets/css/style.css` → `/assets/css/style.9f3a1c2b.css`), so they can be served `immutable`
7//! with a one-year cache and still update instantly when their content changes.
8//!
9//! # How it works
10//!
11//! The heavy lifting (walking the directory, hashing, MIME detection, codegen) happens at build
12//! time in [`tower-serve-embedded-build`](https://docs.rs/tower-serve-embedded-build), called
13//! from your `build.rs`. There are **no proc macros** — the generated code is plain data plus a
14//! tiny `macro_rules!`, so IDE support stays excellent.
15//!
16//! ```ignore
17//! // build.rs
18//! fn main() {
19//! tower_serve_embedded_build::Builder::new("assets").emit().unwrap();
20//! }
21//! ```
22//!
23//! ```ignore
24//! // src/main.rs
25//! tower_serve_embedded::embed!(); // pulls in `ASSETS` and the `asset!` macro
26//!
27//! // Reference assets by their path relative to the crate root. Resolved at compile time —
28//! // typos are compile errors:
29//! // link rel="stylesheet" href=(asset!("assets/css/style.css"))
30//! // => "/assets/css/style.9f3a1c2b.css"
31//!
32//! // Serve them: generated asset URLs are already full paths, so mount as a fallback.
33//! // Router::new().fallback_service(ASSETS.service())
34//! ```
35//!
36//! See `examples/` in the repository for complete, runnable setups (`axum`, `actix`, `warp`).
37
38mod service;
39
40pub use service::ServeEmbedded;
41
42/// The `Cache-Control` value sent for content-hashed URLs and for assets in an `immutable_dir`:
43/// a one-year, `public`, `immutable` cache. The bytes behind such a URL never change.
44pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
45
46/// A single embedded asset: its content plus the metadata needed to serve it.
47///
48/// Values of this type are generated at build time and stored in a `static` slice; you do not
49/// construct them by hand.
50///
51/// Every file records its stable, non-hashed [`url`](Self::url). Files that are content-hashed
52/// (the default) are served at their cache-busted [`hashed_url`](Self::hashed_url); files in an
53/// `immutable_dir` are not re-hashed, have `hashed_url == None`, and are served at their plain
54/// [`url`](Self::url).
55#[derive(Debug, Clone, Copy)]
56pub struct EmbeddedFile {
57 /// The stable, non-hashed URL mirroring the file's path under the crate root with a leading
58 /// slash, e.g. `/assets/css/style.css`. This URL is served only for assets in an
59 /// `immutable_dir`; ordinary assets are served only at their [`hashed_url`](Self::hashed_url).
60 pub url: &'static str,
61 /// The full, content-hashed, cache-busted URL, e.g. `/assets/css/style.9f3a1c2b.css`, served
62 /// `immutable`. `None` for assets in an `immutable_dir` (already immutable, so not re-hashed).
63 /// When present, this is what the generated `asset!` macro and [`Assets::url`] prefer.
64 pub hashed_url: Option<&'static str>,
65 /// The original path of the file relative to the **crate root**, e.g. `assets/css/style.css`
66 /// — the key you pass to `asset!` and [`Assets::get_logical`].
67 pub logical_path: &'static str,
68 /// The raw file contents (via `include_bytes!`).
69 pub bytes: &'static [u8],
70 /// The guessed MIME type, e.g. `text/css`.
71 pub content_type: &'static str,
72 /// A strong, quoted `ETag` derived from the content hash, e.g. `"\"9f3a1c2b…\""`.
73 pub etag: &'static str,
74 /// The hex content hash used for cache busting and the `ETag`.
75 pub hash: &'static str,
76}
77
78/// A served URL mapped to the file that answers it and the `Cache-Control` to send.
79///
80/// One generated file produces one route: its [`hashed_url`](EmbeddedFile::hashed_url) for
81/// ordinary assets, or its plain [`url`](EmbeddedFile::url) for assets in an `immutable_dir`.
82/// Generated at build time and sorted by `url` for binary search; you do not construct these by
83/// hand outside of tests.
84#[derive(Debug, Clone, Copy)]
85pub struct Route {
86 /// The served URL this route matches.
87 pub url: &'static str,
88 /// Index into the [`Assets`] file slice of the file that answers this URL.
89 pub file: usize,
90 /// The `Cache-Control` value to send. Generated routes use
91 /// `Some(`[`IMMUTABLE_CACHE_CONTROL`]`)` because every served generated URL is immutable.
92 pub cache_control: Option<&'static str>,
93}
94
95/// The outcome of resolving a request path against an [`Assets`] set: which file to serve and the
96/// `Cache-Control` to send with it. Returned by [`Assets::resolve`].
97#[derive(Debug, Clone, Copy)]
98pub struct Resolved {
99 /// The matched file.
100 pub file: &'static EmbeddedFile,
101 /// The `Cache-Control` to set, or `None` to send none for custom route tables.
102 pub cache_control: Option<&'static str>,
103}
104
105/// A collection of [`EmbeddedFile`]s plus the [`Route`] table that maps served URLs to them.
106///
107/// You normally get one via the generated `ASSETS` static (see [`embed!`]), not by calling
108/// [`Assets::new`] yourself.
109#[derive(Debug, Clone, Copy)]
110pub struct Assets {
111 files: &'static [EmbeddedFile],
112 /// Sorted by [`Route::url`] so [`resolve`](Self::resolve) can binary-search.
113 routes: &'static [Route],
114}
115
116impl Assets {
117 /// Construct an asset set from its files and route table. `routes` **must** be sorted by
118 /// [`Route::url`], and each [`Route::file`] must index into `files`; the build script
119 /// guarantees both for the generated `ASSETS` static.
120 pub const fn new(files: &'static [EmbeddedFile], routes: &'static [Route]) -> Self {
121 Self { files, routes }
122 }
123
124 /// Resolve a served URL — a [`hashed_url`](EmbeddedFile::hashed_url) like
125 /// `/assets/css/style.9f3a1c2b.css`, or the plain [`url`](EmbeddedFile::url) of an
126 /// `immutable_dir` asset — to the file that answers it and the `Cache-Control` to send. This is
127 /// the lookup [`ServeEmbedded`] uses; hand-rolled (non-`tower`) integrations call it to build a
128 /// response themselves.
129 pub fn resolve(&self, url: &str) -> Option<Resolved> {
130 let files = self.files;
131 let i = self.routes.binary_search_by_key(&url, |r| r.url).ok()?;
132 let route = self.routes[i];
133 Some(Resolved {
134 file: &files[route.file],
135 cache_control: route.cache_control,
136 })
137 }
138
139 /// Look up a file by its crate-root-relative [`logical_path`](EmbeddedFile::logical_path),
140 /// e.g. `assets/css/style.css`.
141 pub fn get_logical(&self, logical: &str) -> Option<&'static EmbeddedFile> {
142 self.files.iter().find(|f| f.logical_path == logical)
143 }
144
145 /// The URL to reference an asset by, given its crate-root-relative path, e.g.
146 /// `url("assets/css/style.css")` → `Some("/assets/css/style.9f3a1c2b.css")`. Returns the
147 /// cache-busted [`hashed_url`](EmbeddedFile::hashed_url) when there is one, otherwise the plain
148 /// [`url`](EmbeddedFile::url) (for `immutable_dir` assets).
149 ///
150 /// This is the runtime equivalent of the `asset!` macro, for cases where the asset name isn't
151 /// known at compile time. Prefer `asset!` when you can — it's checked at compile time.
152 pub fn url(&self, logical: &str) -> Option<&'static str> {
153 self.get_logical(logical)
154 .map(|f| f.hashed_url.unwrap_or(f.url))
155 }
156
157 /// Iterate over every embedded file.
158 pub fn iter(&self) -> impl Iterator<Item = &'static EmbeddedFile> {
159 self.files.iter()
160 }
161
162 /// How many files are embedded.
163 pub fn len(&self) -> usize {
164 self.files.len()
165 }
166
167 /// Whether there are no embedded files.
168 pub fn is_empty(&self) -> bool {
169 self.files.is_empty()
170 }
171
172 /// A [`tower::Service`](tower_service::Service) that serves these assets.
173 ///
174 /// Generated asset URLs are already full paths, so mount the service as a fallback (no nesting
175 /// or prefix stripping):
176 ///
177 /// ```ignore
178 /// Router::new().fallback_service(ASSETS.service())
179 /// ```
180 pub fn service(&'static self) -> ServeEmbedded {
181 ServeEmbedded::new(self)
182 }
183}
184
185/// Pull in the assets generated by `tower-serve-embedded-build`.
186///
187/// Call this once at module scope (typically at the crate root in `main.rs` or `lib.rs`). It
188/// expands to an `include!` of the build script's generated file, bringing into scope:
189///
190/// - `pub static ASSETS: tower_serve_embedded::Assets` — the embedded asset set, and
191/// - `asset!` — a crate-local compile-time macro mapping a crate-root-relative path to its URL.
192///
193/// The generated `asset!` macro is deliberately not emitted with `#[macro_export]`, because Rust
194/// treats `macro_export` macros generated by another macro as future-incompatible when they are
195/// referenced by absolute paths. If you invoke `embed!()` at the crate root, you can use
196/// `crate::asset!(...)` from other modules.
197///
198/// ```ignore
199/// tower_serve_embedded::embed!();
200///
201/// fn css() -> &'static str { crate::asset!("assets/css/style.css") } // "/assets/css/style.9f3a1c2b.css"
202/// ```
203#[macro_export]
204macro_rules! embed {
205 () => {
206 include!(concat!(env!("OUT_DIR"), "/embed_assets.rs"));
207 };
208}