Skip to main content

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}