Skip to main content

rws_server/vite/
mod.rs

1//! The Vite manifest helper (18.35.0, #94): the `<script>` and `<link>`
2//! tags for an entry point of a frontend built by [Vite](https://vite.dev),
3//! from its `.vite/manifest.json` in production or from the Vite dev
4//! server in development. `serde` feature.
5//!
6//! ```rust
7//! # extern crate rws_server as rust_web_server;
8//! use rust_web_server::vite::Vite;
9//!
10//! let manifest = r#"{
11//!   "src/main.ts": {"file": "assets/main-4f2a.js", "isEntry": true, "css": ["assets/main-9c1b.css"], "imports": ["_vendor.js"]},
12//!   "_vendor.js": {"file": "assets/vendor-77aa.js"}
13//! }"#;
14//! let vite = Vite::from_manifest_str(manifest, "/").unwrap();
15//! let tags = vite.tags("src/main.ts").unwrap();
16//! assert!(tags.contains(r#"<script type="module" src="/assets/main-4f2a.js"></script>"#));
17//! assert!(tags.contains(r#"<link rel="stylesheet" href="/assets/main-9c1b.css">"#));
18//! assert!(tags.contains(r#"<link rel="modulepreload" href="/assets/vendor-77aa.js">"#));
19//!
20//! let dev = Vite::dev("http://localhost:5173");
21//! assert!(dev.tags("src/main.ts").unwrap().contains("http://localhost:5173/@vite/client"));
22//! ```
23//!
24//! Configure Vite with `build.manifest: true`; the manifest is
25//! `dist/.vite/manifest.json`. [`Vite::from_env`] picks the mode:
26//! `VITE_DEV_SERVER` set → dev server, else the manifest at `VITE_MANIFEST`
27//! (default `frontend/dist/.vite/manifest.json`). [`install`] makes one
28//! process-wide, which the `vite(entry=…)` template function reads.
29
30#[cfg(test)]
31mod tests;
32
33use std::collections::{BTreeMap, HashSet};
34use std::sync::{OnceLock, RwLock};
35
36/// One manifest chunk (only the fields the tags need).
37#[derive(Debug, Clone, Default, serde::Deserialize)]
38struct Chunk {
39    file: String,
40    #[serde(default)]
41    css: Vec<String>,
42    #[serde(default)]
43    imports: Vec<String>,
44}
45
46/// The tags for a Vite build or dev server.
47#[derive(Debug, Clone)]
48pub enum Vite {
49    /// Built assets: chunks keyed by their source path, served under `base`.
50    Manifest { base: String, chunks: BTreeMap<String, ChunkFiles> },
51    /// The dev server at this URL (`http://localhost:5173`).
52    Dev(String),
53}
54
55/// A manifest chunk's output files.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct ChunkFiles {
58    pub file: String,
59    pub css: Vec<String>,
60    pub imports: Vec<String>,
61}
62
63impl Vite {
64    /// Read the manifest at `path`, assets served under `base` (`/`).
65    pub fn from_manifest_file(path: &str, base: &str) -> Result<Vite, String> {
66        let text = std::fs::read_to_string(path).map_err(|e| format!("Vite manifest {path}: {e}"))?;
67        Self::from_manifest_str(&text, base)
68    }
69
70    /// Parse a manifest's JSON, assets served under `base` (`/`).
71    pub fn from_manifest_str(json: &str, base: &str) -> Result<Vite, String> {
72        let raw: BTreeMap<String, Chunk> = serde_json::from_str(json).map_err(|e| format!("Vite manifest: {e}"))?;
73        let chunks = raw.into_iter().map(|(k, c)| (k, ChunkFiles { file: c.file, css: c.css, imports: c.imports })).collect();
74        let base = if base.ends_with('/') { base.to_string() } else { format!("{base}/") };
75        Ok(Vite::Manifest { base, chunks })
76    }
77
78    /// Tags that load entries from the Vite dev server at `url`.
79    pub fn dev(url: &str) -> Vite {
80        Vite::Dev(url.trim_end_matches('/').to_string())
81    }
82
83    /// `VITE_DEV_SERVER` → [`Vite::dev`]; else the manifest at
84    /// `VITE_MANIFEST` (default `frontend/dist/.vite/manifest.json`), assets
85    /// under `VITE_BASE` (default `/`).
86    pub fn from_env() -> Result<Vite, String> {
87        if let Ok(url) = std::env::var("VITE_DEV_SERVER") {
88            if !url.is_empty() {
89                return Ok(Vite::dev(&url));
90            }
91        }
92        let path = std::env::var("VITE_MANIFEST").unwrap_or_else(|_| "frontend/dist/.vite/manifest.json".into());
93        let base = std::env::var("VITE_BASE").unwrap_or_else(|_| "/".into());
94        Self::from_manifest_file(&path, &base)
95    }
96
97    /// The HTML for `entry` (`src/main.ts`): its CSS and that of the chunks
98    /// it imports, `modulepreload` links for the imports, and the module
99    /// script. `None` when the manifest has no such entry.
100    pub fn tags(&self, entry: &str) -> Option<String> {
101        match self {
102            Vite::Dev(url) => Some(format!(
103                "<script type=\"module\" src=\"{0}/@vite/client\"></script>\n<script type=\"module\" src=\"{0}/{1}\"></script>",
104                escape(url),
105                escape(entry.trim_start_matches('/'))
106            )),
107            Vite::Manifest { base, chunks } => {
108                let root = chunks.get(entry)?;
109                let mut css = Vec::new();
110                let mut preload = Vec::new();
111                css.extend(root.css.iter().cloned());
112                // Every chunk the entry imports, directly or not, once (the
113                // graph can have cycles).
114                let mut seen: HashSet<&str> = HashSet::new();
115                let mut queue: Vec<&str> = root.imports.iter().map(String::as_str).collect();
116                while let Some(key) = queue.pop() {
117                    if !seen.insert(key) {
118                        continue;
119                    }
120                    if let Some(chunk) = chunks.get(key) {
121                        preload.push(chunk.file.clone());
122                        css.extend(chunk.css.iter().cloned());
123                        queue.extend(chunk.imports.iter().map(String::as_str));
124                    }
125                }
126                let mut out = Vec::new();
127                let mut emitted = HashSet::new();
128                for file in css {
129                    if emitted.insert(file.clone()) {
130                        out.push(format!("<link rel=\"stylesheet\" href=\"{}{}\">", escape(base), escape(&file)));
131                    }
132                }
133                for file in preload {
134                    if emitted.insert(file.clone()) {
135                        out.push(format!("<link rel=\"modulepreload\" href=\"{}{}\">", escape(base), escape(&file)));
136                    }
137                }
138                out.push(format!("<script type=\"module\" src=\"{}{}\"></script>", escape(base), escape(&root.file)));
139                Some(out.join("\n"))
140            }
141        }
142    }
143
144    /// The URL of the built file for `source` (an image, a font): its
145    /// manifest `file` under `base`, or the dev server's URL for it.
146    pub fn asset(&self, source: &str) -> Option<String> {
147        match self {
148            Vite::Dev(url) => Some(format!("{url}/{}", source.trim_start_matches('/'))),
149            Vite::Manifest { base, chunks } => chunks.get(source).map(|c| format!("{base}{}", c.file)),
150        }
151    }
152}
153
154/// HTML attribute escaping.
155fn escape(text: &str) -> String {
156    text.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
157}
158
159fn global() -> &'static RwLock<Option<Vite>> {
160    static VITE: OnceLock<RwLock<Option<Vite>>> = OnceLock::new();
161    VITE.get_or_init(|| RwLock::new(None))
162}
163
164/// Make `vite` the process-wide helper the `vite` template function uses
165/// (installing again replaces it).
166pub fn install(vite: Vite) {
167    if let Ok(mut slot) = global().write() {
168        *slot = Some(vite);
169    }
170}
171
172/// [`Vite::tags`] on the installed helper; `None` before [`install`].
173pub fn tags(entry: &str) -> Option<String> {
174    global().read().ok()?.as_ref()?.tags(entry)
175}
176
177/// [`Vite::asset`] on the installed helper; `None` before [`install`].
178pub fn asset(source: &str) -> Option<String> {
179    global().read().ok()?.as_ref()?.asset(source)
180}