trillium_static_compiled/lib.rs
1#![forbid(unsafe_code)]
2#![deny(
3 clippy::dbg_macro,
4 missing_copy_implementations,
5 rustdoc::missing_crate_level_docs,
6 missing_debug_implementations,
7 missing_docs,
8 nonstandard_style,
9 unused_qualifications
10)]
11
12//! Serves static file assets from memory, as included in the binary at
13//! compile time. Because this includes file system content at compile
14//! time, it requires a macro interface, [`static_compiled`].
15//!
16//! If the root is a directory, it will recursively serve any files
17//! relative to the path that this handler is mounted at, or an index file
18//! if one is configured with
19//! [`with_index_file`](crate::StaticCompiledHandler::with_index_file).
20//!
21//! If the root is a file, it will serve that file at all request paths.
22//!
23//! ## ETag headers
24//!
25//! On by default. Each file's source bytes are hashed at compile time via
26//! [`etag::EntityTag::from_data`][et] and the resulting tag is emitted as
27//! the `ETag` response header on every response. The baked tag is
28//! byte-identical to what [`trillium_caching_headers::Etag`][cce] would
29//! compute at runtime, so chaining `Etag::new()` after this handler
30//! observes the precomputed tag, skips rehashing the body, and handles
31//! `If-None-Match` / `304 Not Modified` for free. Opt out per invocation
32//! with `static_compiled!("./public", etag = false)`.
33//!
34//! [et]: https://docs.rs/etag/latest/etag/struct.EntityTag.html#method.from_data
35//! [cce]: https://docs.rs/trillium-caching-headers/latest/trillium_caching_headers/struct.Etag.html
36//!
37//! ## Precompression
38//!
39//! Optionally pre-compress bundle contents into Brotli, Zstd, and Gzip
40//! variants at build time, behind cargo features (`brotli`, `zstd`,
41//! `gzip`, or the `compression` meta-feature) and an opt-in macro
42//! argument:
43//!
44//! ```ignore
45//! // requires e.g. trillium-static-compiled = { features = ["compression"] }
46//! static_compiled!("./public", compress); // all enabled encoders
47//! static_compiled!("./public", compress = [Brotli, Gzip]); // explicit subset
48//! ```
49//!
50//! Encoders run at maximum quality in parallel via rayon at macro
51//! expansion time. Per-file variants are sorted smallest-first and only
52//! baked when they save at least 5%; files under 256 bytes are skipped
53//! entirely. The handler picks the smallest variant the client's
54//! `Accept-Encoding` allows, sets `Content-Encoding`, and emits
55//! `Vary: Accept-Encoding` (per-file, only when variants are baked).
56//! Composes with [`trillium-compression`][tc], which passes through any
57//! response that already has `Content-Encoding` set.
58//!
59//! [tc]: https://docs.rs/trillium-compression/
60//!
61//! ## Origin
62//!
63//! This crate contains code from [`include_dir`][include_dir], but with
64//! several tweaks to make it more suitable for this specific use case.
65//!
66//! [include_dir]:https://docs.rs/include_dir/latest/include_dir/
67//!
68//! ```
69//! use trillium_static_compiled::static_compiled;
70//! use trillium_testing::TestServer;
71//!
72//! # trillium_testing::block_on(async {
73//! let handler = static_compiled!("./examples/files").with_index_file("index.html");
74//!
75//! // given the following directory layout
76//! //
77//! // examples/files
78//! // ├── index.html
79//! // ├── subdir
80//! // │ └── index.html
81//! // └── subdir_with_no_index
82//! // └── plaintext.txt
83//!
84//! let app = TestServer::new(handler).await;
85//!
86//! let index = include_str!("../examples/files/index.html");
87//! app.get("/")
88//! .await
89//! .assert_ok()
90//! .assert_body(index)
91//! .assert_header("content-type", "text/html");
92//!
93//! app.get("/file_that_does_not_exist.txt")
94//! .await
95//! .assert_status(404);
96//! app.get("/index.html").await.assert_ok();
97//!
98//! app.get("/subdir/index.html")
99//! .await
100//! .assert_ok()
101//! .assert_body("subdir index.html 🎈\n")
102//! .assert_header("content-type", "text/html; charset=utf-8");
103//!
104//! app.get("/subdir")
105//! .await
106//! .assert_ok()
107//! .assert_body("subdir index.html 🎈\n");
108//!
109//! app.get("/subdir_with_no_index").await.assert_status(404);
110//!
111//! app.get("/subdir_with_no_index/plaintext.txt")
112//! .await
113//! .assert_ok()
114//! .assert_body("plaintext file\n")
115//! .assert_header("content-type", "text/plain");
116//!
117//! // with a different index file
118//! let plaintext_index = static_compiled!("./examples/files").with_index_file("plaintext.txt");
119//! let app2 = TestServer::new(plaintext_index).await;
120//!
121//! app2.get("/").await.assert_status(404);
122//! app2.get("/subdir").await.assert_status(404);
123//!
124//! app2.get("/subdir_with_no_index")
125//! .await
126//! .assert_ok()
127//! .assert_body("plaintext file\n")
128//! .assert_header("content-type", "text/plain");
129//!
130//! // with no index file
131//! let no_index = static_compiled!("./examples/files");
132//! let app3 = TestServer::new(no_index).await;
133//!
134//! app3.get("/").await.assert_status(404);
135//! app3.get("/subdir").await.assert_status(404);
136//! app3.get("/subdir_with_no_index").await.assert_status(404);
137//! # });
138//! ```
139
140#[cfg(test)]
141#[doc = include_str!("../README.md")]
142mod readme {}
143
144use trillium::{
145 Conn, Handler, HeaderValues,
146 KnownHeaderName::{
147 AcceptEncoding, AcceptRanges, ContentEncoding, ContentRange, ContentType, Etag, IfRange,
148 LastModified, Range, Vary,
149 },
150 Status,
151};
152
153mod dir;
154mod dir_entry;
155mod encoding;
156mod file;
157mod metadata;
158mod range;
159
160pub use crate::encoding::Encoding;
161pub(crate) use crate::{dir::Dir, dir_entry::DirEntry, file::File, metadata::Metadata};
162
163#[doc(hidden)]
164pub mod __macro_internals {
165 pub use crate::{
166 dir::Dir, dir_entry::DirEntry, encoding::Encoding, file::File, metadata::Metadata,
167 };
168 pub use trillium_static_compiled_macros::{include_dir, include_entry};
169}
170
171fn append_vary_accept_encoding(conn: &mut Conn) {
172 let vary = conn
173 .response_headers()
174 .get_str(Vary)
175 .map(|existing| HeaderValues::from(format!("{existing}, Accept-Encoding")))
176 .unwrap_or_else(|| HeaderValues::from("Accept-Encoding"));
177 conn.response_headers_mut().insert(Vary, vary);
178}
179
180/// The static compiled handler which contains the compile-time loaded
181/// assets
182#[derive(Debug, Clone, Copy)]
183pub struct StaticCompiledHandler {
184 root: DirEntry,
185 index_file: Option<&'static str>,
186}
187
188impl StaticCompiledHandler {
189 /// Constructs a new StaticCompiledHandler. This must be used in
190 /// conjunction with [`root!`](crate::root). See crate-level docs for
191 /// example usage.
192 pub fn new(root: DirEntry) -> Self {
193 Self {
194 root,
195 index_file: None,
196 }
197 }
198
199 /// Configures the optional index file for this
200 /// StaticCompiledHandler. See the crate-level docs for example
201 /// usage.
202 pub fn with_index_file(mut self, file: &'static str) -> Self {
203 if let Some(file) = self.root.as_file() {
204 panic!(
205 "root is a file ({:?}), with_index_file is not meaningful.",
206 file.path()
207 );
208 }
209 self.index_file = Some(file);
210 self
211 }
212
213 fn serve_file(&self, mut conn: Conn, file: File) -> Conn {
214 let mime = mime_guess::from_path(file.path()).first_or_text_plain();
215
216 let is_ascii = file.contents().is_ascii();
217 let is_text = matches!(
218 (mime.type_(), mime.subtype()),
219 (mime::APPLICATION, mime::JAVASCRIPT) | (mime::TEXT, _) | (_, mime::HTML)
220 );
221
222 conn.response_headers_mut().try_insert(
223 ContentType,
224 if is_text && !is_ascii {
225 format!("{mime}; charset=utf-8")
226 } else {
227 mime.to_string()
228 },
229 );
230
231 if let Some(metadata) = file.metadata() {
232 conn.response_headers_mut()
233 .try_insert(LastModified, httpdate::fmt_http_date(metadata.modified()));
234 }
235
236 if let Some(etag) = file.etag() {
237 conn.response_headers_mut().try_insert(Etag, etag);
238 }
239
240 // Always advertise range support for static content.
241 conn.response_headers_mut()
242 .try_insert(AcceptRanges, "bytes");
243
244 let total = file.contents().len() as u64;
245 let range_spec = conn
246 .request_headers()
247 .get_str(Range)
248 .and_then(range::parse)
249 .filter(|_| {
250 // If-Range gate: present and matching → honor; present and
251 // non-matching → ignore Range and serve full body.
252 conn.request_headers()
253 .get_str(IfRange)
254 .is_none_or(|if_range| {
255 range::if_range_matches(
256 if_range,
257 file.etag(),
258 file.metadata().map(Metadata::modified),
259 )
260 })
261 });
262
263 if let Some(spec) = range_spec {
264 return match range::resolve(spec, total) {
265 Some((start, end)) => {
266 let slice = &file.contents()[start as usize..=end as usize];
267 conn.response_headers_mut()
268 .insert(ContentRange, format!("bytes {start}-{end}/{total}"));
269 if !file.encodings().is_empty() {
270 append_vary_accept_encoding(&mut conn);
271 }
272 conn.with_status(Status::PartialContent).with_body(slice)
273 }
274 None => {
275 conn.response_headers_mut()
276 .insert(ContentRange, format!("bytes */{total}"));
277 conn.with_status(Status::RequestedRangeNotSatisfiable)
278 .with_body("")
279 }
280 };
281 }
282
283 let accept = conn.request_headers().get_str(AcceptEncoding);
284 let body = match file.pick_encoding(accept) {
285 Some((encoding, bytes)) => {
286 conn.response_headers_mut()
287 .insert(ContentEncoding, encoding.token());
288 bytes
289 }
290 None => file.contents(),
291 };
292
293 if !file.encodings().is_empty() {
294 append_vary_accept_encoding(&mut conn);
295 }
296
297 conn.ok(body)
298 }
299
300 fn get_item(&self, path: &str) -> Option<DirEntry> {
301 if path.is_empty() || self.root.is_file() {
302 Some(self.root)
303 } else {
304 self.root.as_dir().and_then(|dir| {
305 dir.get_dir(path)
306 .copied()
307 .map(DirEntry::Dir)
308 .or_else(|| dir.get_file(path).copied().map(DirEntry::File))
309 })
310 }
311 }
312}
313
314impl Handler for StaticCompiledHandler {
315 async fn run(&self, conn: Conn) -> Conn {
316 match (
317 self.get_item(conn.path().trim_start_matches('/')),
318 self.index_file,
319 ) {
320 (None, _) => conn,
321 (Some(DirEntry::File(file)), _) => self.serve_file(conn, file),
322 (Some(DirEntry::Dir(_)), None) => conn,
323 (Some(DirEntry::Dir(dir)), Some(index_file)) => {
324 if let Some(file) = dir.get_file(dir.path().join(index_file)) {
325 self.serve_file(conn, *file)
326 } else {
327 conn
328 }
329 }
330 }
331 }
332}
333
334/// The preferred interface to build a [`StaticCompiledHandler`].
335///
336/// `static_compiled!("assets")` is identical to
337/// `StaticCompiledHandler::new(root!("assets"))`.
338///
339/// ## Arguments
340///
341/// The first argument is a string literal naming a path on disk. After
342/// the path, any number of optional arguments may follow in any order,
343/// separated by commas:
344///
345/// ```ignore
346/// static_compiled!("./public"); // defaults
347/// static_compiled!("./public", etag = false); // skip etag
348/// static_compiled!("./public", compress); // all enabled encoders
349/// static_compiled!("./public", compress = [Brotli, Gzip]); // specific encoders
350/// static_compiled!("./public", compress, etag = false); // both
351/// ```
352///
353/// ### `etag = bool`
354///
355/// On by default. When true, an entity tag is computed at compile time
356/// for each file's source bytes and emitted as the `ETag` response
357/// header. See the [crate-level docs][crate#etag-headers] for details.
358///
359/// ### `compress` and `compress = [Brotli, Zstd, Gzip]`
360///
361/// Off by default and gated behind the `brotli` / `zstd` / `gzip` /
362/// `compression` cargo features. The bare form `compress` bakes every
363/// encoding whose feature is enabled; the listed form bakes a specified
364/// subset and is a compile error if a listed encoding's feature is not
365/// enabled. See the [crate-level docs][crate#precompression].
366///
367/// ## Relative paths
368///
369/// Relative paths are expanded and canonicalized relative to
370/// `$CARGO_MANIFEST_DIR`, which is usually the directory that contains
371/// your Cargo.toml. If compiled within a workspace, this will be the
372/// subcrate's Cargo.toml.
373///
374/// ## Environment variable expansion
375///
376/// If the path argument to `static_compiled!` contains substrings that
377/// are formatted like an environment variable, beginning with a `$`,
378/// they will be interpreted in the compile time environment.
379///
380/// For example `"$OUT_DIR/some_directory"` will expand to the directory
381/// `some_directory` within the env variable `$OUT_DIR` set by cargo. See
382/// [this link][env_vars] for further documentation on the environment
383/// variables set by cargo.
384///
385/// [env_vars]:https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
386#[macro_export]
387macro_rules! static_compiled {
388 ($($args:tt)*) => {
389 $crate::StaticCompiledHandler::new($crate::root!($($args)*))
390 };
391}
392
393/// Include the path as root. To be passed into
394/// [`StaticCompiledHandler::new`].
395///
396/// Most callers want [`static_compiled!`] instead, which wraps this in
397/// the handler constructor.
398///
399/// Accepts the same arguments as [`static_compiled!`] — see its
400/// documentation for the path-argument grammar, optional `etag` and
401/// `compress` arguments, relative-path resolution, and environment
402/// variable expansion.
403#[macro_export]
404macro_rules! root {
405 ($($args:tt)*) => {{
406 use $crate::__macro_internals::{Dir, DirEntry, Encoding, File, Metadata, include_entry};
407 const ENTRY: DirEntry = include_entry!($($args)*);
408 ENTRY
409 }};
410}