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//! # Reading files at runtime
51//!
52//! [`crate::assets`] reads the registered table by path, anywhere and without a
53//! `Host`, returning the original contents — decoded once if the file is
54//! stored compressed:
55//!
56//! ```ignore
57//! let template = webx::assets::read_str("templates/mail.html");
58//! ```
59//!
60//! [`EmbeddedAssets::registered`] with [`EmbeddedAssets::read`] /
61//! [`EmbeddedAssets::read_str`] is the same lookup on an explicit table.
62//!
63//! # Requirements on a hand-written table
64//!
65//! [`EmbeddedAssets::get`] binary-searches, so `files` **must be sorted by
66//! `path`** in ascending byte order and must not contain duplicates.
67//! [`EmbeddedAssets::debug_assert_sorted`] checks this in debug builds when the
68//! middleware is constructed.
69
70use std::path::Path;
71
72/// How an [`EmbeddedAsset`]'s payload is stored.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ContentEncoding {
75    /// The payload is the file itself.
76    Identity,
77    /// The payload is a brotli stream; `raw_len` is the decoded length.
78    Brotli,
79}
80
81impl ContentEncoding {
82    /// The `Content-Encoding` token the payload is stored in, if any.
83    pub const fn token(self) -> Option<&'static str> {
84        match self {
85            Self::Identity => None,
86            Self::Brotli => Some("br"),
87        }
88    }
89}
90
91/// One file compiled into the executable.
92///
93/// Produced by generated code; the fields are public so a build script can
94/// emit the same table by hand.
95#[derive(Debug, Clone, Copy)]
96pub struct EmbeddedAsset {
97    /// Normalized path relative to the embedded root, `/`-separated and without
98    /// a leading slash (e.g. `assets/app.abc123.js`). Matched case-sensitively.
99    pub path: &'static str,
100    /// The stored payload: the file for [`ContentEncoding::Identity`], its
101    /// brotli stream for [`ContentEncoding::Brotli`]. Placed in the binary's
102    /// read-only data section.
103    pub bytes: &'static [u8],
104    /// Length of the file before compression. Equals `bytes.len()` when the
105    /// payload is stored verbatim.
106    pub raw_len: usize,
107    /// How `bytes` is encoded.
108    pub encoding: ContentEncoding,
109    /// Media type resolved at build time, so no runtime extension lookup is needed.
110    pub content_type: &'static str,
111    /// A strong, quoted `ETag` derived from the content.
112    ///
113    /// Embedded files have no mtime, so this is the only validator they can
114    /// offer. Because it hashes the bytes it is also stable across machines and
115    /// rebuilds, unlike the host's `size-mtime` tag for files on disk.
116    pub etag: &'static str,
117}
118
119impl EmbeddedAsset {
120    /// Decode the stored payload back to the original file.
121    ///
122    /// `None` only when an encoded payload fails to decode, which means the
123    /// table is corrupt; the caller still has [`bytes`](Self::bytes) to serve.
124    pub fn decode(&self) -> Option<Vec<u8>> {
125        match self.encoding {
126            ContentEncoding::Identity => Some(self.bytes.to_vec()),
127            ContentEncoding::Brotli => decode_brotli(self.bytes, self.raw_len),
128        }
129    }
130
131    /// Whether the payload is stored compressed.
132    pub fn is_encoded(&self) -> bool {
133        matches!(self.encoding, ContentEncoding::Brotli)
134    }
135
136    /// The strong validator for one representation of this asset.
137    ///
138    /// A compressed payload is a different representation, so it needs its own
139    /// `ETag`: reusing the identity tag would let a shared cache hand brotli
140    /// bytes to a client that asked for the plain file.
141    pub fn etag_for(&self, encoded: bool) -> String {
142        if !encoded {
143            return self.etag.to_string();
144        }
145        match self.etag.strip_suffix('"') {
146            Some(head) => format!("{head}-br\""),
147            None => format!("{}-br", self.etag),
148        }
149    }
150}
151
152/// Decode one brotli stream, reserving the original length up front.
153fn decode_brotli(payload: &[u8], raw_len: usize) -> Option<Vec<u8>> {
154    use std::io::Read as _;
155
156    let mut out = Vec::with_capacity(raw_len);
157    let mut decoder = brotli_decompressor::Decompressor::new(payload, 4096);
158    decoder.read_to_end(&mut out).ok()?;
159    Some(out)
160}
161
162/// A build-time directory tree of static files.
163#[derive(Debug, Clone, Copy)]
164pub struct EmbeddedAssets {
165    root: &'static str,
166    files: &'static [EmbeddedAsset],
167}
168
169impl EmbeddedAssets {
170    /// Wrap a table produced by generated code.
171    ///
172    /// `root` is the directory the bytes were compiled from, as written by the
173    /// caller (e.g. `"wwwroot"`), and is used as the disk overlay directory when
174    /// no `use_spa(...)` call named one.
175    pub const fn new(root: &'static str, files: &'static [EmbeddedAsset]) -> Self {
176        Self { root, files }
177    }
178
179    /// The directory the bytes were compiled from, as written by the caller.
180    pub const fn root(&self) -> &'static str {
181        self.root
182    }
183
184    /// The table, sorted by [`EmbeddedAsset::path`].
185    pub const fn files(&self) -> &'static [EmbeddedAsset] {
186        self.files
187    }
188
189    /// Number of embedded files.
190    pub fn len(&self) -> usize {
191        self.files.len()
192    }
193
194    /// Whether the table is empty.
195    pub fn is_empty(&self) -> bool {
196        self.files.is_empty()
197    }
198
199    /// Combined size of the stored payloads, in bytes — what the binary holds.
200    pub fn total_bytes(&self) -> u64 {
201        self.files.iter().map(|file| file.bytes.len() as u64).sum()
202    }
203
204    /// Combined size of the files as they exist on disk.
205    ///
206    /// Larger than [`total_bytes`](Self::total_bytes) whenever something was
207    /// compressed; the difference is what brotli saved.
208    pub fn total_raw_bytes(&self) -> u64 {
209        self.files.iter().map(|file| file.raw_len as u64).sum()
210    }
211
212    /// Look up `path`, which must already be normalized: relative, `/`-separated,
213    /// no leading slash and no `..` segments.
214    pub fn get(&self, path: &str) -> Option<&'static EmbeddedAsset> {
215        self.files
216            .binary_search_by(|file| file.path.cmp(path))
217            .ok()
218            .map(|index| &self.files[index])
219    }
220
221    /// The table linked by `#[webx::main(embed)]` (or `#[webx::embed_assets]`),
222    /// if any — the same one the host serves, reachable without a `Host`.
223    ///
224    /// To read a file by path, [`crate::assets::read`] is the shorter form.
225    ///
226    /// # Panics
227    ///
228    /// When more than one table is registered — that is always a wiring mistake
229    /// (e.g. both `#[webx::main(embed)]` and a second `#[webx::embed_assets]`
230    /// in the same binary).
231    pub fn registered() -> Option<&'static EmbeddedAssets> {
232        let mut found = inventory::iter::<EmbeddedAssets>();
233        match (found.next(), found.next()) {
234            (Some(assets), None) => Some(assets),
235            (None, _) => None,
236            (Some(_), Some(_)) => panic!(
237                "more than one compiled-in asset table is registered; \
238                 use `#[webx::main(embed)]` (or `#[webx::embed_assets]`) \
239                 exactly once per binary"
240            ),
241        }
242    }
243
244    /// The original contents of `path`, decoded when stored compressed.
245    ///
246    /// `path` is relative to the embedded root; a leading `/` is accepted so a
247    /// request path works as-is. Verbatim entries are returned without a copy;
248    /// compressed ones are decoded on first use and cached for the life of the
249    /// process, shared with the middleware. `None` when the file is not
250    /// embedded or its payload fails to decode.
251    ///
252    /// Only the compiled-in copy is read: a same-named file in the disk overlay
253    /// and [`EMBED_ENV`](crate::EMBED_ENV) play no part.
254    pub fn read(&self, path: &str) -> Option<&'static [u8]> {
255        let asset = self.get(path.trim_start_matches('/'))?;
256        match asset.encoding {
257            ContentEncoding::Identity => Some(asset.bytes),
258            ContentEncoding::Brotli => crate::spa::decoded_once(asset),
259        }
260    }
261
262    /// [`read`](Self::read) as UTF-8 text; also `None` when it is not UTF-8.
263    pub fn read_str(&self, path: &str) -> Option<&'static str> {
264        std::str::from_utf8(self.read(path)?).ok()
265    }
266
267    /// Panic in debug builds when the table is not sorted by `path`, which would
268    /// make [`EmbeddedAssets::get`] miss entries instead of finding them.
269    pub fn debug_assert_sorted(&self) {
270        debug_assert!(
271            self.files
272                .windows(2)
273                .all(|pair| pair[0].path < pair[1].path),
274            "EmbeddedAssets must be sorted by path, with no duplicates"
275        );
276    }
277
278    /// Embedded paths for which a same-named file exists under `disk_root`.
279    ///
280    /// Those are the files an operator is currently overriding. Overrides are
281    /// the point of the feature, but a stale one silently defeats an updated
282    /// build, so the host logs them at startup.
283    pub fn shadowed_by(&self, disk_root: &Path) -> Vec<&'static str> {
284        let mut shadowed = Vec::new();
285        collect_shadowed(self, disk_root, "", &mut shadowed);
286        shadowed
287    }
288}
289
290fn collect_shadowed(
291    assets: &EmbeddedAssets,
292    dir: &Path,
293    prefix: &str,
294    shadowed: &mut Vec<&'static str>,
295) {
296    let Ok(entries) = std::fs::read_dir(dir) else {
297        return;
298    };
299    for entry in entries.flatten() {
300        let name = entry.file_name();
301        let Some(name) = name.to_str() else {
302            continue;
303        };
304        let key = if prefix.is_empty() {
305            name.to_string()
306        } else {
307            format!("{prefix}/{name}")
308        };
309        let path = entry.path();
310        if path.is_dir() {
311            collect_shadowed(assets, &path, &key, shadowed);
312        } else if let Some(asset) = assets.get(&key) {
313            shadowed.push(asset.path);
314        }
315    }
316}
317
318// Lets `webx::builder()` register the table it generates, so
319// `Host::build` can pick it up without the application naming a value.
320// Absence is fine: apps without embedded assets simply have no entry.
321inventory::collect!(EmbeddedAssets);
322
323#[cfg(test)]
324mod tests {
325    use super::{ContentEncoding, EmbeddedAsset, EmbeddedAssets};
326
327    const FILES: &[EmbeddedAsset] = &[
328        EmbeddedAsset {
329            path: "assets/app.css",
330            bytes: b"a{}",
331            raw_len: 3,
332            encoding: ContentEncoding::Identity,
333            content_type: "text/css",
334            etag: "\"sha256-1\"",
335        },
336        EmbeddedAsset {
337            path: "index.html",
338            bytes: b"index",
339            raw_len: 5,
340            encoding: ContentEncoding::Identity,
341            content_type: "text/html",
342            etag: "\"sha256-2\"",
343        },
344        EmbeddedAsset {
345            path: "only-embedded.txt",
346            bytes: b"embedded",
347            raw_len: 8,
348            encoding: ContentEncoding::Identity,
349            content_type: "text/plain",
350            etag: "\"sha256-3\"",
351        },
352    ];
353
354    /// Brotli-encode `source`, the way the build script would.
355    fn brotli(source: &str) -> Vec<u8> {
356        use std::io::Write as _;
357
358        let mut out = Vec::new();
359        let mut encoder = brotli::CompressorWriter::new(&mut out, 4096, 11, 22);
360        encoder.write_all(source.as_bytes()).unwrap();
361        encoder.flush().unwrap();
362        drop(encoder);
363        out
364    }
365
366    #[test]
367    fn get_finds_every_entry() {
368        let assets = EmbeddedAssets::new("wwwroot", FILES);
369        assets.debug_assert_sorted();
370        for file in FILES {
371            assert_eq!(assets.get(file.path).unwrap().bytes, file.bytes);
372        }
373        assert_eq!(assets.len(), 3);
374        assert_eq!(assets.total_bytes(), 16);
375        assert_eq!(assets.total_raw_bytes(), 16);
376    }
377
378    #[test]
379    fn an_encoded_entry_decodes_back_to_the_original_file() {
380        let source = "body { color: red; }".repeat(32);
381        let payload = brotli(&source);
382        assert!(
383            payload.len() < source.len(),
384            "the fixture must actually compress"
385        );
386
387        let encoded = EmbeddedAsset {
388            path: "app.css",
389            bytes: Box::leak(payload.into_boxed_slice()),
390            raw_len: source.len(),
391            encoding: ContentEncoding::Brotli,
392            content_type: "text/css",
393            etag: "\"sha256-x\"",
394        };
395        assert!(encoded.is_encoded());
396        assert_eq!(encoded.decode().unwrap(), source.as_bytes());
397
398        // Identity entries decode to themselves.
399        assert_eq!(FILES[0].decode().unwrap(), b"a{}");
400        assert!(!FILES[0].is_encoded());
401    }
402
403    #[test]
404    fn totals_separate_the_stored_payload_from_the_original_size() {
405        let source = "x".repeat(4096);
406        let payload = brotli(&source);
407        let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
408            path: "big.txt",
409            bytes: Box::leak(payload.into_boxed_slice()),
410            raw_len: 4096,
411            encoding: ContentEncoding::Brotli,
412            content_type: "text/plain",
413            etag: "\"sha256-y\"",
414        }));
415        let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
416        assert!(assets.total_bytes() < assets.total_raw_bytes());
417        assert_eq!(assets.total_raw_bytes(), 4096);
418    }
419
420    #[test]
421    fn each_representation_gets_its_own_validator() {
422        let plain = &FILES[0];
423        assert_eq!(plain.etag_for(false), "\"sha256-1\"");
424        assert_eq!(plain.etag_for(true), "\"sha256-1-br\"");
425        assert_ne!(plain.etag_for(false), plain.etag_for(true));
426
427        // A hand-written table without the surrounding quotes still yields a
428        // distinct, well-formed tag rather than a truncated one.
429        let bare = EmbeddedAsset {
430            etag: "sha256-z",
431            ..*plain
432        };
433        assert_eq!(bare.etag_for(true), "sha256-z-br");
434    }
435
436    #[test]
437    fn encoding_tokens_are_the_http_ones() {
438        assert_eq!(ContentEncoding::Identity.token(), None);
439        assert_eq!(ContentEncoding::Brotli.token(), Some("br"));
440    }
441
442    #[test]
443    fn get_misses_unknown_and_prefix_paths() {
444        let assets = EmbeddedAssets::new("wwwroot", FILES);
445        assert!(assets.get("assets").is_none());
446        assert!(assets.get("assets/nope.css").is_none());
447        assert!(assets.get("index.htm").is_none());
448        assert!(assets.get("").is_none());
449    }
450
451    #[test]
452    fn read_returns_verbatim_entries_without_a_copy() {
453        let assets = EmbeddedAssets::new("wwwroot", FILES);
454        let bytes = assets.read("index.html").unwrap();
455        assert!(std::ptr::eq(bytes, FILES[1].bytes));
456        // A request path works as-is.
457        assert_eq!(assets.read("/assets/app.css").unwrap(), b"a{}");
458        assert_eq!(assets.read_str("only-embedded.txt"), Some("embedded"));
459        assert!(assets.read("missing.txt").is_none());
460    }
461
462    #[test]
463    fn read_decodes_compressed_entries_once() {
464        let source = "p { margin: 0; }".repeat(64);
465        let encoded: &'static EmbeddedAsset = Box::leak(Box::new(EmbeddedAsset {
466            path: "read-decoded.css",
467            bytes: Box::leak(brotli(&source).into_boxed_slice()),
468            raw_len: source.len(),
469            encoding: ContentEncoding::Brotli,
470            content_type: "text/css",
471            etag: "\"sha256-r\"",
472        }));
473        let assets = EmbeddedAssets::new("wwwroot", std::slice::from_ref(encoded));
474
475        let first = assets.read_str("read-decoded.css").unwrap();
476        assert_eq!(first, source);
477        let second = assets.read_str("read-decoded.css").unwrap();
478        assert!(std::ptr::eq(first.as_ptr(), second.as_ptr()));
479    }
480
481    #[test]
482    fn read_str_rejects_non_utf8() {
483        let binary = EmbeddedAsset {
484            path: "logo.bin",
485            bytes: &[0xff, 0xfe],
486            raw_len: 2,
487            encoding: ContentEncoding::Identity,
488            content_type: "application/octet-stream",
489            etag: "\"sha256-b\"",
490        };
491        let files: &'static [EmbeddedAsset] = Box::leak(Box::new([binary]));
492        let assets = EmbeddedAssets::new("wwwroot", files);
493        assert_eq!(assets.read("logo.bin"), Some(&[0xff, 0xfe][..]));
494        assert!(assets.read_str("logo.bin").is_none());
495    }
496
497    #[test]
498    fn root_is_reported_as_written() {
499        assert_eq!(EmbeddedAssets::new("wwwroot", FILES).root(), "wwwroot");
500        assert_eq!(
501            EmbeddedAssets::new("../wwwroot", FILES).root(),
502            "../wwwroot"
503        );
504    }
505
506    #[test]
507    fn shadowed_by_reports_same_named_disk_files() {
508        let dir = tempfile::tempdir().unwrap();
509        std::fs::create_dir_all(dir.path().join("assets")).unwrap();
510        std::fs::write(dir.path().join("assets/app.css"), b"disk").unwrap();
511        std::fs::write(dir.path().join("only-embedded.txt"), b"disk").unwrap();
512        std::fs::write(dir.path().join("only-disk.txt"), b"disk").unwrap();
513
514        let mut shadowed = EmbeddedAssets::new("wwwroot", FILES).shadowed_by(dir.path());
515        shadowed.sort_unstable();
516        assert_eq!(shadowed, vec!["assets/app.css", "only-embedded.txt"]);
517    }
518
519    #[test]
520    fn shadowed_by_missing_directory_is_empty() {
521        let dir = tempfile::tempdir().unwrap();
522        let assets = EmbeddedAssets::new("wwwroot", FILES);
523        assert!(assets
524            .shadowed_by(&dir.path().join("does-not-exist"))
525            .is_empty());
526    }
527}