Skip to main content

mesh_llm_ui/
lib.rs

1//! Web-console asset accessors.
2//!
3//! With the default `embed-assets` feature, `include_dir!` bundles the
4//! built React console (`dist/`) into the crate at compile time and
5//! [`index`] / [`asset`] serve those bytes.
6//!
7//! With `embed-assets` disabled, the crate compiles down to ~nothing and
8//! both accessors return `None`. Callers (notably
9//! `mesh-llm-host-runtime`'s console asset routes) treat that as "no UI
10//! bundled" and surface 404s for the asset paths while keeping every
11//! other management-API surface working. This lets lib-style consumers
12//! of `mesh-llm-host-runtime` drop several MB of embedded payload by
13//! opting out of `default-features`.
14//!
15//! SDKs can use [`FileSystemConsoleAssets`] to serve the same built console
16//! from package resources without baking those assets into every native
17//! library variant.
18
19use std::{borrow::Cow, path::PathBuf};
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct UiAsset {
23    pub contents: Cow<'static, [u8]>,
24    pub content_type: &'static str,
25    pub cache_control: &'static str,
26}
27
28pub trait ConsoleAssetProvider: Send + Sync {
29    fn index(&self) -> Option<UiAsset>;
30    fn asset(&self, path: &str) -> Option<UiAsset>;
31}
32
33#[derive(Clone, Copy, Debug, Default)]
34pub struct EmbeddedConsoleAssets;
35
36#[derive(Clone, Debug)]
37pub struct FileSystemConsoleAssets {
38    root: PathBuf,
39}
40
41impl FileSystemConsoleAssets {
42    pub fn new(root: impl Into<PathBuf>) -> Self {
43        Self { root: root.into() }
44    }
45}
46
47#[cfg(feature = "embed-assets")]
48mod embedded {
49    use include_dir::{Dir, include_dir};
50
51    pub(super) static CONSOLE_DIST: Dir<'_> = include_dir!("$MESH_LLM_UI_DIST");
52}
53
54pub fn index() -> Option<UiAsset> {
55    EmbeddedConsoleAssets.index()
56}
57
58pub fn asset(path: &str) -> Option<UiAsset> {
59    EmbeddedConsoleAssets.asset(path)
60}
61
62impl ConsoleAssetProvider for EmbeddedConsoleAssets {
63    fn index(&self) -> Option<UiAsset> {
64        self.asset("index.html").map(|mut asset| {
65            asset.cache_control = "public, max-age=3600";
66            asset
67        })
68    }
69
70    #[cfg(feature = "embed-assets")]
71    fn asset(&self, path: &str) -> Option<UiAsset> {
72        let rel = clean_relative_path(path)?;
73        let file = embedded::CONSOLE_DIST.get_file(rel)?;
74        Some(UiAsset {
75            contents: Cow::Borrowed(file.contents()),
76            content_type: content_type(rel),
77            cache_control: cache_control(rel),
78        })
79    }
80
81    #[cfg(not(feature = "embed-assets"))]
82    fn asset(&self, _path: &str) -> Option<UiAsset> {
83        None
84    }
85}
86
87impl ConsoleAssetProvider for FileSystemConsoleAssets {
88    fn index(&self) -> Option<UiAsset> {
89        self.asset("index.html").map(|mut asset| {
90            asset.cache_control = "public, max-age=3600";
91            asset
92        })
93    }
94
95    fn asset(&self, path: &str) -> Option<UiAsset> {
96        let rel = clean_relative_path(path)?;
97        let root = self.root.canonicalize().ok()?;
98        let full_path = root.join(rel).canonicalize().ok()?;
99        if !full_path.starts_with(&root) {
100            return None;
101        }
102        let contents = std::fs::read(full_path).ok()?;
103        Some(UiAsset {
104            contents: Cow::Owned(contents),
105            content_type: content_type(rel),
106            cache_control: cache_control(rel),
107        })
108    }
109}
110
111fn clean_relative_path(path: &str) -> Option<&str> {
112    let rel = path.trim_start_matches('/');
113    if rel.is_empty() || rel.contains("..") || rel.starts_with('.') {
114        return None;
115    }
116    Some(rel)
117}
118
119pub fn content_type(path: &str) -> &'static str {
120    match path.rsplit('.').next().unwrap_or("") {
121        "html" => "text/html; charset=utf-8",
122        "js" | "mjs" => "text/javascript; charset=utf-8",
123        "css" => "text/css; charset=utf-8",
124        "svg" => "image/svg+xml",
125        "json" => "application/json; charset=utf-8",
126        "png" => "image/png",
127        "jpg" | "jpeg" => "image/jpeg",
128        "webp" => "image/webp",
129        "woff2" => "font/woff2",
130        "wasm" => "application/wasm",
131        _ => "application/octet-stream",
132    }
133}
134
135pub fn cache_control(path: &str) -> &'static str {
136    if path.starts_with("assets/") {
137        "public, max-age=31536000, immutable"
138    } else {
139        "public, max-age=3600"
140    }
141}
142
143#[cfg(all(test, feature = "embed-assets"))]
144mod tests {
145    use super::{asset, content_type};
146
147    #[test]
148    fn rejects_parent_directory_paths() {
149        assert!(asset("../index.html").is_none());
150    }
151
152    #[test]
153    fn maps_common_asset_content_types() {
154        assert_eq!(content_type("index.html"), "text/html; charset=utf-8");
155        assert_eq!(
156            content_type("assets/app.js"),
157            "text/javascript; charset=utf-8"
158        );
159        assert_eq!(content_type("assets/app.css"), "text/css; charset=utf-8");
160        assert_eq!(
161            content_type("manifest.json"),
162            "application/json; charset=utf-8"
163        );
164    }
165}
166
167#[cfg(test)]
168mod filesystem_tests {
169    use super::{ConsoleAssetProvider, FileSystemConsoleAssets};
170    use std::fs;
171
172    #[test]
173    fn filesystem_assets_read_from_root() {
174        let root = std::env::temp_dir().join(format!("mesh-llm-ui-assets-{}", std::process::id()));
175        let _ = fs::remove_dir_all(&root);
176        fs::create_dir_all(root.join("assets")).expect("create temp asset root");
177        fs::write(root.join("index.html"), "<html></html>").expect("write index");
178        fs::write(root.join("assets/app.js"), "console.log('ok')").expect("write js");
179
180        let assets = FileSystemConsoleAssets::new(&root);
181        assert_eq!(
182            assets.index().expect("index").contents.as_ref(),
183            b"<html></html>"
184        );
185        assert_eq!(
186            assets.asset("/assets/app.js").expect("asset").content_type,
187            "text/javascript; charset=utf-8"
188        );
189        assert!(assets.asset("../secret").is_none());
190
191        let _ = fs::remove_dir_all(root);
192    }
193
194    #[cfg(unix)]
195    #[test]
196    fn filesystem_assets_reject_symlinks_outside_root() {
197        let temp =
198            std::env::temp_dir().join(format!("mesh-llm-ui-assets-symlink-{}", std::process::id()));
199        let root = temp.join("root");
200        let secret = temp.join("secret.txt");
201        let _ = fs::remove_dir_all(&temp);
202        fs::create_dir_all(root.join("assets")).expect("create temp asset root");
203        fs::write(root.join("index.html"), "<html></html>").expect("write index");
204        fs::write(&secret, "secret").expect("write secret");
205        std::os::unix::fs::symlink(&secret, root.join("assets/secret.txt"))
206            .expect("create symlink");
207
208        let assets = FileSystemConsoleAssets::new(&root);
209        assert!(assets.asset("/assets/secret.txt").is_none());
210
211        let _ = fs::remove_dir_all(temp);
212    }
213}
214
215#[cfg(all(test, not(feature = "embed-assets")))]
216mod stub_tests {
217    use super::{asset, index};
218
219    #[test]
220    fn returns_none_when_assets_not_embedded() {
221        assert!(index().is_none());
222        assert!(asset("index.html").is_none());
223        assert!(asset("assets/app.js").is_none());
224    }
225}