Skip to main content

webx_spa/
embed.rs

1//! Static files compiled into the executable.
2//!
3//! This is the rust-webx counterpart of ASP.NET Core's
4//! `ManifestEmbeddedFileProvider`: a `wwwroot` tree becomes a build-time table
5//! of `&'static` byte slices, and the middleware serves it whenever the same
6//! file is not present on disk. Deployment then consists of a single executable,
7//! while an operator can still drop one or two files into `wwwroot/` next to it
8//! to override what is baked in.
9//!
10//! Build a table with `webx::builder()` in `build.rs`, then opt in at the
11//! entry point with `#[webx::main(embed)]`:
12//!
13//! ```ignore
14//! // build.rs — compile-time source tree (baked into the binary)
15//! fn main() -> Result<(), webx::Error> {
16//!     webx::builder()
17//!         .web_root("wwwroot")
18//!         .build()
19//! }
20//!
21//! // main.rs — (embed) links the table; use_spa is the runtime disk overlay
22//! #[webx::main(embed)]
23//! async fn main() {
24//!     Host::builder()
25//!         .use_spa("wwwroot")
26//!         .build()
27//!         .run()
28//!         .await
29//!         .unwrap();
30//! }
31//! ```
32//!
33//! Without `(embed)` (and without `build.rs` web root), SPA is disk-only.
34//! `web_root` and `use_spa` can differ — build from `frontend/dist`, overrides
35//! in deploy-time `wwwroot`.
36//!
37//! # Payload encoding
38//!
39//! Compressible files are stored brotli-encoded ([`ContentEncoding::Brotli`])
40//! alongside their original length, which is what stops a large `wwwroot` from
41//! becoming a large binary. The middleware hands the stored stream straight to
42//! clients that accept `br`, and only decodes — once per file, then cached — for
43//! clients that do not. Media that is already compressed stays verbatim
44//! ([`ContentEncoding::Identity`]).
45//!
46//! The build script decides all of this per file. A hand-written table must
47//! keep the two fields consistent: `raw_len` is the decoded length, and
48//! `bytes.len() == raw_len` whenever `encoding` is `Identity`.
49//!
50//! # Requirements on a hand-written table
51//!
52//! [`EmbeddedAssets::get`] binary-searches, so `files` **must be sorted by
53//! `path`** in ascending byte order and must not contain duplicates.
54//! [`EmbeddedAssets::debug_assert_sorted`] checks this in debug builds when the
55//! middleware is constructed.
56
57use std::path::Path;
58
59/// How an [`EmbeddedAsset`]'s payload is stored.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ContentEncoding {
62    /// The payload is the file itself.
63    Identity,
64    /// The payload is a brotli stream; `raw_len` is the decoded length.
65    Brotli,
66}
67
68impl ContentEncoding {
69    /// The `Content-Encoding` token the payload is stored in, if any.
70    pub const fn token(self) -> Option<&'static str> {
71        match self {
72            Self::Identity => None,
73            Self::Brotli => Some("br"),
74        }
75    }
76}
77
78/// One file compiled into the executable.
79///
80/// Produced by generated code; the fields are public so a build script can
81/// emit the same table by hand.
82#[derive(Debug, Clone, Copy)]
83pub struct EmbeddedAsset {
84    /// Normalized path relative to the embedded root, `/`-separated and without
85    /// a leading slash (e.g. `assets/app.abc123.js`). Matched case-sensitively.
86    pub path: &'static str,
87    /// The stored payload: the file for [`ContentEncoding::Identity`], its
88    /// brotli stream for [`ContentEncoding::Brotli`]. Placed in the binary's
89    /// read-only data section.
90    pub bytes: &'static [u8],
91    /// Length of the file before compression. Equals `bytes.len()` when the
92    /// payload is stored verbatim.
93    pub raw_len: usize,
94    /// How `bytes` is encoded.
95    pub encoding: ContentEncoding,
96    /// Media type resolved at build time, so no runtime extension lookup is needed.
97    pub content_type: &'static str,
98    /// A strong, quoted `ETag` derived from the content.
99    ///
100    /// Embedded files have no mtime, so this is the only validator they can
101    /// offer. Because it hashes the bytes it is also stable across machines and
102    /// rebuilds, unlike the host's `size-mtime` tag for files on disk.
103    pub etag: &'static str,
104}
105
106impl EmbeddedAsset {
107    /// Decode the stored payload back to the original file.
108    ///
109    /// `None` only when an encoded payload fails to decode, which means the
110    /// table is corrupt; the caller still has [`bytes`](Self::bytes) to serve.
111    pub fn decode(&self) -> Option<Vec<u8>> {
112        match self.encoding {
113            ContentEncoding::Identity => Some(self.bytes.to_vec()),
114            ContentEncoding::Brotli => decode_brotli(self.bytes, self.raw_len),
115        }
116    }
117
118    /// Whether the payload is stored compressed.
119    pub fn is_encoded(&self) -> bool {
120        matches!(self.encoding, ContentEncoding::Brotli)
121    }
122
123    /// The strong validator for one representation of this asset.
124    ///
125    /// A compressed payload is a different representation, so it needs its own
126    /// `ETag`: reusing the identity tag would let a shared cache hand brotli
127    /// bytes to a client that asked for the plain file.
128    pub fn etag_for(&self, encoded: bool) -> String {
129        if !encoded {
130            return self.etag.to_string();
131        }
132        match self.etag.strip_suffix('"') {
133            Some(head) => format!("{head}-br\""),
134            None => format!("{}-br", self.etag),
135        }
136    }
137}
138
139/// Decode one brotli stream, reserving the original length up front.
140fn decode_brotli(payload: &[u8], raw_len: usize) -> Option<Vec<u8>> {
141    use std::io::Read as _;
142
143    let mut out = Vec::with_capacity(raw_len);
144    let mut decoder = brotli_decompressor::Decompressor::new(payload, 4096);
145    decoder.read_to_end(&mut out).ok()?;
146    Some(out)
147}
148
149/// A build-time directory tree of static files.
150#[derive(Debug, Clone, Copy)]
151pub struct EmbeddedAssets {
152    root: &'static str,
153    files: &'static [EmbeddedAsset],
154}
155
156impl EmbeddedAssets {
157    /// Wrap a table produced by generated code.
158    ///
159    /// `root` is the directory the bytes were compiled from, as written by the
160    /// caller (e.g. `"wwwroot"`), and is used as the disk overlay directory when
161    /// no `use_spa(...)` call named one.
162    pub const fn new(root: &'static str, files: &'static [EmbeddedAsset]) -> Self {
163        Self { root, files }
164    }
165
166    /// The directory the bytes were compiled from, as written by the caller.
167    pub const fn root(&self) -> &'static str {
168        self.root
169    }
170
171    /// The table, sorted by [`EmbeddedAsset::path`].
172    pub const fn files(&self) -> &'static [EmbeddedAsset] {
173        self.files
174    }
175
176    /// Number of embedded files.
177    pub fn len(&self) -> usize {
178        self.files.len()
179    }
180
181    /// Whether the table is empty.
182    pub fn is_empty(&self) -> bool {
183        self.files.is_empty()
184    }
185
186    /// Combined size of the stored payloads, in bytes — what the binary holds.
187    pub fn total_bytes(&self) -> u64 {
188        self.files.iter().map(|file| file.bytes.len() as u64).sum()
189    }
190
191    /// Combined size of the files as they exist on disk.
192    ///
193    /// Larger than [`total_bytes`](Self::total_bytes) whenever something was
194    /// compressed; the difference is what brotli saved.
195    pub fn total_raw_bytes(&self) -> u64 {
196        self.files.iter().map(|file| file.raw_len as u64).sum()
197    }
198
199    /// Look up `path`, which must already be normalized: relative, `/`-separated,
200    /// no leading slash and no `..` segments.
201    pub fn get(&self, path: &str) -> Option<&'static EmbeddedAsset> {
202        self.files
203            .binary_search_by(|file| file.path.cmp(path))
204            .ok()
205            .map(|index| &self.files[index])
206    }
207
208    /// Panic in debug builds when the table is not sorted by `path`, which would
209    /// make [`EmbeddedAssets::get`] miss entries instead of finding them.
210    pub fn debug_assert_sorted(&self) {
211        debug_assert!(
212            self.files
213                .windows(2)
214                .all(|pair| pair[0].path < pair[1].path),
215            "EmbeddedAssets must be sorted by path, with no duplicates"
216        );
217    }
218
219    /// Embedded paths for which a same-named file exists under `disk_root`.
220    ///
221    /// Those are the files an operator is currently overriding. Overrides are
222    /// the point of the feature, but a stale one silently defeats an updated
223    /// build, so the host logs them at startup.
224    pub fn shadowed_by(&self, disk_root: &Path) -> Vec<&'static str> {
225        let mut shadowed = Vec::new();
226        collect_shadowed(self, disk_root, "", &mut shadowed);
227        shadowed
228    }
229}
230
231fn collect_shadowed(
232    assets: &EmbeddedAssets,
233    dir: &Path,
234    prefix: &str,
235    shadowed: &mut Vec<&'static str>,
236) {
237    let Ok(entries) = std::fs::read_dir(dir) else {
238        return;
239    };
240    for entry in entries.flatten() {
241        let name = entry.file_name();
242        let Some(name) = name.to_str() else {
243            continue;
244        };
245        let key = if prefix.is_empty() {
246            name.to_string()
247        } else {
248            format!("{prefix}/{name}")
249        };
250        let path = entry.path();
251        if path.is_dir() {
252            collect_shadowed(assets, &path, &key, shadowed);
253        } else if let Some(asset) = assets.get(&key) {
254            shadowed.push(asset.path);
255        }
256    }
257}
258
259// Lets `webx::builder()` register the table it generates, so
260// `Host::build` can pick it up without the application naming a value.
261// Absence is fine: apps without embedded assets simply have no entry.
262inventory::collect!(EmbeddedAssets);
263
264#[cfg(test)]
265mod tests {
266    use super::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
267
268    const FILES: &[EmbeddedAsset] = &[
269        EmbeddedAsset {
270            path: "assets/app.css",
271            bytes: b"a{}",
272            raw_len: 3,
273            encoding: ContentEncoding::Identity,
274            content_type: "text/css",
275            etag: "\"sha256-1\"",
276        },
277        EmbeddedAsset {
278            path: "index.html",
279            bytes: b"index",
280            raw_len: 5,
281            encoding: ContentEncoding::Identity,
282            content_type: "text/html",
283            etag: "\"sha256-2\"",
284        },
285        EmbeddedAsset {
286            path: "only-embedded.txt",
287            bytes: b"embedded",
288            raw_len: 8,
289            encoding: ContentEncoding::Identity,
290            content_type: "text/plain",
291            etag: "\"sha256-3\"",
292        },
293    ];
294
295    /// Brotli-encode `source`, the way the build script would.
296    fn brotli(source: &str) -> Vec<u8> {
297        use std::io::Write as _;
298
299        let mut out = Vec::new();
300        let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
301        encoder.write_all(source.as_bytes()).unwrap();
302        encoder.flush().unwrap();
303        drop(encoder);
304        out
305    }
306
307    #[test]
308    fn get_finds_every_entry() {
309        let assets = EmbeddedAssets::new("wwwroot", FILES);
310        assets.debug_assert_sorted();
311        for file in FILES {
312            assert_eq!(assets.get(file.path).unwrap().bytes, file.bytes);
313        }
314        assert_eq!(assets.len(), 3);
315        assert_eq!(assets.total_bytes(), 16);
316        assert_eq!(assets.total_raw_bytes(), 16);
317    }
318
319    #[test]
320    fn an_encoded_entry_decodes_back_to_the_original_file() {
321        let source = "body { color: red; }".repeat(32);
322        let payload = brotli(&source);
323        assert!(
324            payload.len() < source.len(),
325            "the fixture must actually compress"
326        );
327
328        let encoded = EmbeddedAsset {
329            path: "app.css",
330            bytes: Box::leak(payload.into_boxed_slice()),
331            raw_len: source.len(),
332            encoding: ContentEncoding::Brotli,
333            content_type: "text/css",
334            etag: "\"sha256-x\"",
335        };
336        assert!(encoded.is_encoded());
337        assert_eq!(encoded.decode().unwrap(), source.as_bytes());
338
339        // Identity entries decode to themselves.
340        assert_eq!(FILES[0].decode().unwrap(), b"a{}");
341        assert!(!FILES[0].is_encoded());
342    }
343
344    #[test]
345    fn totals_separate_the_stored_payload_from_the_original_size() {
346        let source = "x".repeat(4096);
347        let payload = brotli(&source);
348        let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
349            path: "big.txt",
350            bytes: Box::leak(payload.into_boxed_slice()),
351            raw_len: 4096,
352            encoding: ContentEncoding::Brotli,
353            content_type: "text/plain",
354            etag: "\"sha256-y\"",
355        }));
356        let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
357        assert!(assets.total_bytes() < assets.total_raw_bytes());
358        assert_eq!(assets.total_raw_bytes(), 4096);
359    }
360
361    #[test]
362    fn each_representation_gets_its_own_validator() {
363        let plain = &FILES[0];
364        assert_eq!(plain.etag_for(false), "\"sha256-1\"");
365        assert_eq!(plain.etag_for(true), "\"sha256-1-br\"");
366        assert_ne!(plain.etag_for(false), plain.etag_for(true));
367
368        // A hand-written table without the surrounding quotes still yields a
369        // distinct, well-formed tag rather than a truncated one.
370        let bare = EmbeddedAsset {
371            etag: "sha256-z",
372            ..*plain
373        };
374        assert_eq!(bare.etag_for(true), "sha256-z-br");
375    }
376
377    #[test]
378    fn encoding_tokens_are_the_http_ones() {
379        assert_eq!(ContentEncoding::Identity.token(), None);
380        assert_eq!(ContentEncoding::Brotli.token(), Some("br"));
381    }
382
383    #[test]
384    fn get_misses_unknown_and_prefix_paths() {
385        let assets = EmbeddedAssets::new("wwwroot", FILES);
386        assert!(assets.get("assets").is_none());
387        assert!(assets.get("assets/nope.css").is_none());
388        assert!(assets.get("index.htm").is_none());
389        assert!(assets.get("").is_none());
390    }
391
392    #[test]
393    fn root_is_reported_as_written() {
394        assert_eq!(EmbeddedAssets::new("wwwroot", FILES).root(), "wwwroot");
395        assert_eq!(
396            EmbeddedAssets::new("../wwwroot", FILES).root(),
397            "../wwwroot"
398        );
399    }
400
401    #[test]
402    fn shadowed_by_reports_same_named_disk_files() {
403        let dir = tempfile::tempdir().unwrap();
404        std::fs::create_dir_all(dir.path().join("assets")).unwrap();
405        std::fs::write(dir.path().join("assets/app.css"), b"disk").unwrap();
406        std::fs::write(dir.path().join("only-embedded.txt"), b"disk").unwrap();
407        std::fs::write(dir.path().join("only-disk.txt"), b"disk").unwrap();
408
409        let mut shadowed = EmbeddedAssets::new("wwwroot", FILES).shadowed_by(dir.path());
410        shadowed.sort_unstable();
411        assert_eq!(shadowed, vec!["assets/app.css", "only-embedded.txt"]);
412    }
413
414    #[test]
415    fn shadowed_by_missing_directory_is_empty() {
416        let dir = tempfile::tempdir().unwrap();
417        let assets = EmbeddedAssets::new("wwwroot", FILES);
418        assert!(assets
419            .shadowed_by(&dir.path().join("does-not-exist"))
420            .is_empty());
421    }
422}