1use axum::{
2 Router,
3 body::{Body, Bytes},
4 http::{Method, StatusCode, header},
5 response::{IntoResponse, Response},
6 routing::{any, get},
7};
8#[cfg(not(debug_assertions))]
9use include_dir::File;
10#[allow(unused_imports)]
11use log::{debug, info, trace, warn};
12use std::path::PathBuf;
13use std::process::{Child, Command};
14use std::sync::Arc;
15
16pub mod frameworks;
17use frameworks::Framework;
18
19pub use include_dir;
24pub use include_dir::Dir;
25
26#[macro_export]
45macro_rules! embedded_dir {
46 ($path:tt) => {{
47 #[cfg(not(debug_assertions))]
48 {
49 static DIR: $crate::Dir<'static> = $crate::include_dir::include_dir!($path);
50 Some(&DIR)
51 }
52 #[cfg(debug_assertions)]
53 {
54 None::<&$crate::Dir<'static>>
57 }
58 }};
59}
60
61#[derive(Clone, Debug)]
63pub struct ViteConfig {
64 pub dev_port: u16,
66 pub dev_host: String,
69 pub dir: Option<&'static Dir<'static>>,
79 pub not_found: String,
81 pub prefix: String,
83 pub frontend_root: Option<PathBuf>,
85 pub dev_command: String,
87 pub auto_start: bool,
89 pub framework: Framework,
91 pub dev_script: String,
97 pub manifest_key: String,
103 pub excluded_prefixes: Vec<String>,
108 #[cfg(debug_assertions)]
111 pub client: reqwest::Client,
112}
113
114impl Default for ViteConfig {
115 fn default() -> Self {
116 Self {
117 dev_port: 5173,
118 dev_host: "localhost".to_string(),
119 dir: None,
120 not_found: "404.html".to_string(),
121 prefix: "/static/".to_string(),
122 frontend_root: None,
123 dev_command: "npm run dev".to_string(),
124 auto_start: false,
125 framework: Framework::default(),
126 dev_script: "src/main.tsx".to_string(),
127 manifest_key: "index.html".to_string(),
128 excluded_prefixes: Vec::new(),
129 #[cfg(debug_assertions)]
130 client: reqwest::Client::new(),
131 }
132 }
133}
134
135impl ViteConfig {
136 pub fn from_env(dir: Option<&'static Dir<'static>>) -> Self {
137 let port = std::env::var("VITE_PORT")
138 .unwrap_or_else(|_| "5173".to_string())
139 .parse()
140 .unwrap_or(5173);
141
142 let prefix = std::env::var("VITE_STATIC_PREFIX").unwrap_or_else(|_| "/static/".to_string());
143
144 let frontend_root = std::env::var("VITE_ROOT").ok().map(PathBuf::from);
145
146 let dev_command =
147 std::env::var("VITE_DEV_CMD").unwrap_or_else(|_| "npm run dev".to_string());
148
149 let auto_start = std::env::var("VITE_AUTO_START")
150 .map(|v| v == "true" || v == "1")
151 .unwrap_or(false);
152
153 let framework = std::env::var("VITE_FRAMEWORK")
154 .map(|v| match v.to_lowercase().as_str() {
155 "react" => Framework::React,
156 "vue" => Framework::Vue,
157 "svelte" => Framework::Svelte,
158 _ => Framework::None,
159 })
160 .unwrap_or_default();
161
162 let dev_host = std::env::var("VITE_DEV_HOST").unwrap_or_else(|_| "localhost".to_string());
163
164 Self {
165 dev_port: port,
166 dev_host,
167 dir,
168 not_found: "404.html".to_string(),
169 prefix,
170 frontend_root,
171 dev_command,
172 auto_start,
173 framework,
174 dev_script: std::env::var("VITE_DEV_SCRIPT")
175 .unwrap_or_else(|_| "src/main.tsx".to_string()),
176 manifest_key: std::env::var("VITE_MANIFEST_KEY")
177 .unwrap_or_else(|_| "index.html".to_string()),
178 excluded_prefixes: Vec::new(),
179 #[cfg(debug_assertions)]
180 client: reqwest::Client::new(),
181 }
182 }
183
184 pub fn hmr_scripts(&self) -> String {
189 if !cfg!(debug_assertions) {
190 return String::new();
191 }
192
193 let prefix = self.prefix.trim_end_matches('/');
194
195 let mut scripts = String::new();
197
198 if let Some(preamble) = self.framework.preamble(prefix) {
199 scripts.push_str(&preamble);
200 scripts.push('\n');
201 }
202
203 scripts.push_str(&format!(
204 r#"<script type="module" src="{}/{}"></script>"#,
205 prefix, "@vite/client"
206 ));
207
208 scripts
209 }
210}
211
212#[derive(Clone, Default, Debug)]
238pub struct EntryAssets {
239 pub script: String,
241 pub stylesheets: Vec<String>,
243}
244
245impl ViteConfig {
246 pub fn entry_assets(&self) -> EntryAssets {
261 self.entry_assets_for(&self.manifest_key, &self.dev_script)
262 }
263
264 #[cfg_attr(debug_assertions, allow(unused))]
287 pub fn entry_assets_for(&self, manifest_key: &str, dev_script: &str) -> EntryAssets {
288 let base = self.prefix.trim_end_matches('/');
289
290 #[cfg(not(debug_assertions))]
291 if let Some(dir) = self.dir {
292 if let Some(file) = dir.get_file(".vite/manifest.json") {
293 if let Some(json) = file.contents_utf8() {
294 return EntryAssets::from_manifest(json, base, manifest_key);
295 }
296 }
297 warn!(
298 "[axum-vite] entry_assets: dist/.vite/manifest.json not found in embedded dir. \
299 Add `build: {{ manifest: true }}` to vite.config and rebuild the frontend. \
300 Falling back to dev-mode paths — assets will 404 in production."
301 );
302 }
303
304 #[cfg(debug_assertions)]
305 let _ = manifest_key;
306
307 EntryAssets {
309 script: format!("{base}/{}", dev_script),
310 stylesheets: vec![],
311 }
312 }
313}
314
315impl EntryAssets {
316 #[cfg(not(debug_assertions))]
317 fn from_manifest(json: &str, base: &str, key: &str) -> Self {
318 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(json) else {
319 warn!("[axum-vite] entry_assets: failed to parse manifest.json as JSON");
320 return Self::default();
321 };
322 let Some(entries) = manifest.as_object() else {
323 return Self::default();
324 };
325 let Some(entry) = entries.get(key) else {
326 warn!(
327 "[axum-vite] entry_assets: key {:?} not found in manifest.json. \
328 Available keys: {}",
329 key,
330 entries.keys().cloned().collect::<Vec<_>>().join(", ")
331 );
332 return Self::default();
333 };
334
335 let script = entry
336 .get("file")
337 .and_then(|f: &serde_json::Value| f.as_str())
338 .map(|f| format!("{base}/{f}"))
339 .unwrap_or_default();
340
341 let stylesheets = entry
342 .get("css")
343 .and_then(|c: &serde_json::Value| c.as_array())
344 .into_iter()
345 .flatten()
346 .filter_map(|s: &serde_json::Value| s.as_str())
347 .map(|s| format!("{base}/{s}"))
348 .collect();
349
350 Self {
351 script,
352 stylesheets,
353 }
354 }
355}
356
357pub struct DevServerHandle {
370 child: Child,
371}
372
373impl std::fmt::Debug for DevServerHandle {
374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 f.debug_struct("DevServerHandle").finish_non_exhaustive()
376 }
377}
378
379impl Drop for DevServerHandle {
380 fn drop(&mut self) {
381 let _ = self.child.kill();
382 let _ = self.child.wait();
384 }
385}
386
387pub fn spawn_dev_server(config: &ViteConfig) -> std::io::Result<DevServerHandle> {
392 let root = config.frontend_root.as_ref().ok_or_else(|| {
393 std::io::Error::new(
394 std::io::ErrorKind::InvalidInput,
395 "frontend_root must be set to spawn dev server",
396 )
397 })?;
398
399 info!(
400 "[axum-vite] spawning dev server in {:?} (`{}`)",
401 root, config.dev_command
402 );
403
404 #[cfg(unix)]
405 {
406 Command::new("sh")
410 .arg("-c")
411 .arg(format!("exec {}", config.dev_command))
412 .current_dir(root)
413 .spawn()
414 .map(|child| DevServerHandle { child })
415 }
416 #[cfg(windows)]
417 {
418 Command::new("cmd")
423 .arg("/C")
424 .arg(&config.dev_command)
425 .current_dir(root)
426 .spawn()
427 .map(|child| DevServerHandle { child })
428 }
429}
430
431impl ViteConfig {
432 pub fn maybe_spawn_dev_server(&self) -> Option<DevServerHandle> {
450 #[cfg(debug_assertions)]
451 if self.auto_start {
452 match spawn_dev_server(self) {
453 Ok(handle) => {
454 info!("[axum-vite] Vite dev server spawned");
455 return Some(handle);
456 }
457 Err(e) => {
458 if log::log_enabled!(log::Level::Warn) {
459 warn!("[axum-vite] failed to spawn Vite dev server: {e}");
460 } else {
461 eprintln!("[axum-vite] failed to spawn Vite dev server: {e}");
462 }
463 }
464 }
465 }
466 None
467 }
468}
469
470pub fn router<S>(config: ViteConfig) -> Router<S>
471where
472 S: Clone + Send + Sync + 'static,
473{
474 let config = Arc::new(config);
475
476 Router::new().route(
477 "/{*path}",
478 any(move |
479 method: Method,
480 axum::extract::Path(path): axum::extract::Path<String>,
481 uri: axum::http::Uri,
482 headers: axum::http::HeaderMap,
483 body: Bytes,
484 | {
485 let config = config.clone();
486 let full_path = match uri.query() {
493 Some(q) => format!("{}?{}", path, q),
494 None => path,
495 };
496 async move { serve_asset(Some(full_path), None, headers, method, body, config).await }
497 }),
498 )
499}
500
501pub fn spa_router<S>(config: ViteConfig) -> Router<S>
522where
523 S: Clone + Send + Sync + 'static,
524{
525 let static_prefix = format!("/{}", config.prefix.trim_matches('/'));
526 let config = Arc::new(config);
527 let c1 = config.clone();
528 let c2 = config.clone();
529 let c3 = config.clone();
530 let c_mw = config;
531
532 let mut r = Router::new()
540 .route(
541 "/",
542 get(move || {
543 let c = c1.clone();
544 async move { _serve_index(c).await }
545 }),
546 )
547 .route(
548 "/{*path}",
549 any(
550 move |method: Method,
551 axum::extract::Path(path): axum::extract::Path<String>,
552 uri: axum::http::Uri,
553 headers: axum::http::HeaderMap,
554 body: Bytes| {
555 let c = c2.clone();
556 let full_path = match uri.query() {
560 Some(q) => format!("{}?{}", path, q),
561 None => path,
562 };
563 async move { _serve_spa_catchall(full_path, headers, method, body, c).await }
564 },
565 ),
566 );
567
568 if static_prefix != "/" {
572 r = r.nest(&static_prefix, router((*c3).clone()));
573 }
574
575 r.layer(axum::middleware::from_fn_with_state(
576 c_mw,
577 hmr_injection_middleware,
578 ))
579}
580
581pub async fn serve_index(config: ViteConfig) -> impl IntoResponse {
605 _serve_index(Arc::new(config)).await
606}
607
608#[cfg(debug_assertions)]
609async fn _serve_index(config: Arc<ViteConfig>) -> Response {
610 let headers = axum::http::HeaderMap::new();
611 proxy_to_vite("", &config, &headers, &Method::GET, Bytes::new()).await
613}
614
615#[cfg(debug_assertions)]
620async fn _serve_spa_catchall(
621 path: String,
622 headers: axum::http::HeaderMap,
623 method: Method,
624 body: Bytes,
625 config: Arc<ViteConfig>,
626) -> Response {
627 let response = proxy_raw(&path, &config, &headers, &method, body).await;
628 let is_html_nav = method == Method::GET
633 && headers
634 .get(axum::http::header::ACCEPT)
635 .and_then(|v| v.to_str().ok())
636 .is_some_and(|s| s.contains("text/html"));
637 if response.status() == StatusCode::NOT_FOUND && is_html_nav {
638 _serve_index(config).await
639 } else {
640 response
641 }
642}
643
644#[cfg(not(debug_assertions))]
645async fn _serve_spa_catchall(
646 path: String,
647 headers: axum::http::HeaderMap,
648 method: Method,
649 _body: Bytes,
650 config: Arc<ViteConfig>,
651) -> Response {
652 let clean = path.trim_start_matches('/');
653 let file_key = clean.split_once('?').map_or(clean, |(p, _)| p);
654
655 if config
658 .excluded_prefixes
659 .iter()
660 .any(|p| file_key.starts_with(p.as_str()))
661 {
662 return StatusCode::NOT_FOUND.into_response();
663 }
664
665 if let Some(dir) = config.dir {
666 if let Some(file) = dir.get_file(file_key) {
667 return serve_embedded_file(file, None, &headers);
668 }
669 }
670
671 let is_html_nav = method == Method::GET
676 && headers
677 .get(axum::http::header::ACCEPT)
678 .and_then(|v| v.to_str().ok())
679 .is_some_and(|s| s.contains("text/html"));
680 if is_html_nav {
681 _serve_index(config).await
682 } else {
683 StatusCode::NOT_FOUND.into_response()
684 }
685}
686
687#[cfg(not(debug_assertions))]
688async fn _serve_index(config: Arc<ViteConfig>) -> Response {
689 let dir = match config.dir {
690 Some(d) => d,
691 None => {
692 warn!(
693 "[axum-vite] serve_index: no embedded dir configured — pass the include_dir! output to ViteConfig::from_env"
694 );
695 return StatusCode::NOT_FOUND.into_response();
696 }
697 };
698 match dir.get_file("index.html") {
699 Some(file) => Response::builder()
700 .status(StatusCode::OK)
701 .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
702 .header(header::CACHE_CONTROL, "no-store")
703 .body(Body::from(file.contents()))
704 .unwrap(),
705 None => {
706 warn!("[axum-vite] serve_index: index.html not found in embedded dir");
707 StatusCode::NOT_FOUND.into_response()
708 }
709 }
710}
711
712#[cfg(debug_assertions)]
719async fn do_proxy(
720 url: &str,
721 log_path: &str,
722 config: &ViteConfig,
723 headers: &axum::http::HeaderMap,
724 method: &Method,
725 body: Bytes,
726) -> Response {
727 trace!("[axum-vite] → /{}", log_path);
728
729 let mut request_builder = config.client.request(method.clone(), url);
730 for (name, value) in headers.iter() {
731 if name != axum::http::header::HOST && name != axum::http::header::ACCEPT_ENCODING {
736 request_builder = request_builder.header(name, value);
737 }
738 }
739
740 match request_builder.body(body).send().await {
741 Ok(resp) => {
742 let mut builder = Response::builder().status(resp.status());
743 for (name, value) in resp.headers().iter() {
744 if name != header::TRANSFER_ENCODING && name != header::CONTENT_LENGTH {
750 builder = builder.header(name, value);
751 }
752 }
753 builder
754 .body(Body::from(resp.bytes().await.unwrap_or_default()))
755 .unwrap()
756 }
757 Err(_) => {
758 warn!("[axum-vite] dev server unreachable at {}", url);
759 Response::builder()
760 .status(StatusCode::SERVICE_UNAVAILABLE)
761 .body(Body::from("Vite dev server unreachable"))
762 .unwrap()
763 }
764 }
765}
766
767#[cfg(debug_assertions)]
771async fn proxy_to_vite(
772 path_str: &str,
773 config: &ViteConfig,
774 headers: &axum::http::HeaderMap,
775 method: &Method,
776 body: Bytes,
777) -> Response {
778 let prefix = config.prefix.trim_matches('/');
779 let clean = path_str.trim_start_matches('/');
786 let url = if prefix.is_empty() {
787 format!("http://{}:{}/{}", config.dev_host, config.dev_port, clean)
788 } else {
789 format!(
790 "http://{}:{}/{}/{}",
791 config.dev_host, config.dev_port, prefix, clean
792 )
793 };
794 do_proxy(&url, path_str, config, headers, method, body).await
795}
796
797#[cfg(debug_assertions)]
802async fn proxy_raw(
803 path_str: &str,
804 config: &ViteConfig,
805 headers: &axum::http::HeaderMap,
806 method: &Method,
807 body: Bytes,
808) -> Response {
809 let clean = path_str.trim_start_matches('/');
810 let url = format!("http://{}:{}/{}", config.dev_host, config.dev_port, clean);
811 do_proxy(&url, path_str, config, headers, method, body).await
812}
813
814#[cfg(debug_assertions)]
815pub async fn serve_asset(
816 path: Option<String>,
817 _mime_type: Option<&str>,
818 headers: axum::http::HeaderMap,
819 method: Method,
820 body: Bytes,
821 config: Arc<ViteConfig>,
822) -> impl IntoResponse {
823 match path {
824 Some(path_str) => proxy_to_vite(&path_str, &config, &headers, &method, body)
825 .await
826 .into_response(),
827 None => (StatusCode::NOT_FOUND, "Not Found").into_response(),
828 }
829}
830
831#[cfg(debug_assertions)]
844pub async fn hmr_injection_middleware(
845 axum::extract::State(config): axum::extract::State<Arc<ViteConfig>>,
846 request: axum::extract::Request,
847 next: axum::middleware::Next,
848) -> Response {
849 let response = next.run(request).await;
850 if let Some(content_type) = response.headers().get(header::CONTENT_TYPE)
851 && content_type.to_str().unwrap_or("").contains("text/html")
852 {
853 let hmr_scripts = config.hmr_scripts();
854 if hmr_scripts.is_empty() {
855 return response;
856 }
857
858 let (parts, body) = response.into_parts();
859 let bytes = match axum::body::to_bytes(body, usize::MAX).await {
860 Ok(b) => b,
861 Err(_) => return Response::from_parts(parts, Body::empty()),
862 };
863
864 if let Ok(mut html) = String::from_utf8(bytes.to_vec()) {
865 if html.contains("@vite/client") {
868 return Response::from_parts(parts, Body::from(html));
869 }
870 if let Some(pos) = html.find("</head>") {
871 html.insert_str(pos, &format!("\n{}\n", hmr_scripts));
872 } else if let Some(pos) = html.find("</body>") {
873 html.insert_str(pos, &format!("\n{}\n", hmr_scripts));
874 } else {
875 html.push_str(&format!("\n{}\n", hmr_scripts));
876 }
877 let mut res = Response::from_parts(parts, Body::from(html.clone()));
878 res.headers_mut().insert(
879 header::CONTENT_LENGTH,
880 axum::http::HeaderValue::from(html.len()),
881 );
882 return res;
883 } else {
884 return Response::from_parts(parts, Body::from(bytes));
885 }
886 }
887 response
888}
889
890#[cfg(not(debug_assertions))]
894fn file_etag(bytes: &[u8]) -> String {
895 use std::collections::hash_map::DefaultHasher;
896 use std::hash::{Hash, Hasher};
897 let mut h = DefaultHasher::new();
898 bytes.hash(&mut h);
899 format!("\"{}\"", h.finish())
901}
902
903#[cfg(not(debug_assertions))]
907fn serve_embedded_file(
908 file: &'static File<'static>,
909 mime_type: Option<&str>,
910 request_headers: &axum::http::HeaderMap,
911) -> Response {
912 let path_buf = PathBuf::from(file.path());
913 let resolved_mime = match mime_type {
914 Some(m) => m.to_string(),
915 None => mime_guess::from_path(&path_buf)
916 .first_or_octet_stream()
917 .to_string(),
918 };
919 let cache_header = if resolved_mime.contains("text/html") {
925 "no-store"
926 } else if path_buf
927 .file_name()
928 .and_then(|n| n.to_str())
929 .is_some_and(|n| matches!(n, "sw.js" | "service-worker.js" | "service-worker.ts"))
930 {
931 "no-store"
932 } else if resolved_mime.contains("manifest")
933 || path_buf
934 .extension()
935 .and_then(|e| e.to_str())
936 .is_some_and(|e| e == "webmanifest")
937 {
938 "public, max-age=86400"
939 } else {
940 if path_buf.components().any(|c| c.as_os_str() == "assets") {
945 "public, max-age=31536000, immutable"
946 } else {
947 "public, no-cache"
948 }
949 };
950 let etag = file_etag(file.contents());
951 if let Some(if_none_match) = request_headers.get(header::IF_NONE_MATCH) {
953 if if_none_match.as_bytes() == etag.as_bytes() {
954 return Response::builder()
955 .status(StatusCode::NOT_MODIFIED)
956 .header(header::ETAG, &etag)
957 .body(Body::empty())
958 .unwrap();
959 }
960 }
961 Response::builder()
962 .status(StatusCode::OK)
963 .header(header::CONTENT_TYPE, resolved_mime)
964 .header(header::CACHE_CONTROL, cache_header)
965 .header(header::ETAG, etag)
966 .body(Body::from(file.contents()))
969 .unwrap()
970}
971
972#[cfg(not(debug_assertions))]
974pub async fn hmr_injection_middleware(
975 axum::extract::State(_config): axum::extract::State<Arc<ViteConfig>>,
976 request: axum::extract::Request,
977 next: axum::middleware::Next,
978) -> Response {
979 next.run(request).await
980}
981
982#[cfg(not(debug_assertions))]
983pub async fn serve_asset(
984 path: Option<String>,
985 mime_type: Option<&str>,
986 headers: axum::http::HeaderMap,
987 _method: Method,
988 _body: Bytes,
989 config: Arc<ViteConfig>,
990) -> impl IntoResponse {
991 let serve_not_found = || {
992 if let Some(dir) = config.dir {
993 if let Some(f) = dir.get_file(&config.not_found) {
994 let mut res = serve_embedded_file(
997 f,
998 Some("text/html; charset=utf-8"),
999 &axum::http::HeaderMap::new(),
1000 );
1001 *res.status_mut() = StatusCode::NOT_FOUND;
1002 return res.into_response();
1003 }
1004 }
1005 StatusCode::NOT_FOUND.into_response()
1006 };
1007
1008 match path {
1009 Some(path_str) => {
1010 let clean = path_str.trim_start_matches('/');
1014 let file_key = clean.split_once('?').map_or(clean, |(p, _)| p);
1018
1019 if config
1020 .excluded_prefixes
1021 .iter()
1022 .any(|p| file_key.starts_with(p.as_str()))
1023 {
1024 return serve_not_found();
1025 }
1026
1027 if let Some(dir) = config.dir {
1028 if let Some(file) = dir.get_file(file_key) {
1029 debug!("[axum-vite] serving /{}", clean);
1030 return serve_embedded_file(file, mime_type, &headers).into_response();
1031 }
1032 }
1033 warn!("[axum-vite] 404 /{}", clean);
1034 serve_not_found()
1035 }
1036 None => serve_not_found(),
1037 }
1038}
1039
1040#[cfg(test)]
1044#[allow(clippy::useless_vec)]
1045mod tests {
1046 use super::*;
1047 use axum::{
1048 Router,
1049 body::Body,
1050 http::{Request, StatusCode},
1051 routing::get,
1052 };
1053 use tower::ServiceExt; #[test]
1060 fn default_config_values() {
1061 let config = ViteConfig::default();
1062 assert_eq!(config.dev_port, 5173);
1063 assert_eq!(config.dev_host, "localhost");
1064 assert_eq!(config.prefix, "/static/");
1065 assert_eq!(config.not_found, "404.html");
1066 assert!(!config.auto_start);
1067 assert!(config.excluded_prefixes.is_empty());
1068 assert!(config.dir.is_none());
1069 }
1070
1071 #[test]
1072 fn hmr_scripts_empty_in_release() {
1073 let config = ViteConfig {
1074 framework: frameworks::Framework::None,
1075 prefix: "/static/".to_string(),
1076 ..Default::default()
1077 };
1078 let scripts = config.hmr_scripts();
1079 if cfg!(debug_assertions) {
1080 assert!(scripts.contains("@vite/client"), "missing @vite/client");
1081 assert!(
1082 !scripts.contains("@react-refresh"),
1083 "unexpected react preamble"
1084 );
1085 } else {
1086 assert!(scripts.is_empty(), "expected empty in release");
1087 }
1088 }
1089
1090 #[test]
1091 fn hmr_scripts_react_preamble_in_debug() {
1092 if !cfg!(debug_assertions) {
1093 return;
1094 }
1095 let config = ViteConfig {
1096 framework: frameworks::Framework::React,
1097 prefix: "/static/".to_string(),
1098 ..Default::default()
1099 };
1100 let scripts = config.hmr_scripts();
1101 assert!(scripts.contains("@react-refresh"), "missing react preamble");
1102 assert!(
1103 scripts.contains("injectIntoGlobalHook"),
1104 "missing injectIntoGlobalHook"
1105 );
1106 let preamble_pos = scripts.find("@react-refresh").unwrap();
1107 let client_pos = scripts.find("@vite/client").unwrap();
1108 assert!(
1109 preamble_pos < client_pos,
1110 "preamble must precede @vite/client"
1111 );
1112 }
1113
1114 #[test]
1115 fn hmr_scripts_prefix_interpolated() {
1116 if !cfg!(debug_assertions) {
1117 return;
1118 }
1119 let config = ViteConfig {
1120 framework: frameworks::Framework::React,
1121 prefix: "/assets/".to_string(),
1122 ..Default::default()
1123 };
1124 let scripts = config.hmr_scripts();
1125 assert!(
1126 scripts.contains("/assets/@react-refresh"),
1127 "prefix not interpolated in preamble"
1128 );
1129 assert!(
1130 scripts.contains("/assets/@vite/client"),
1131 "prefix not interpolated in @vite/client"
1132 );
1133 }
1134
1135 #[test]
1140 fn excluded_prefixes_default_empty() {
1141 assert!(ViteConfig::default().excluded_prefixes.is_empty());
1142 }
1143
1144 #[test]
1145 fn excluded_prefixes_match_correctly() {
1146 let excluded = vec!["templates/".to_string(), "index.html".to_string()];
1147 let is_excluded = |path: &str| excluded.iter().any(|p| path.starts_with(p.as_str()));
1148 assert!(is_excluded("templates/base.html"));
1149 assert!(is_excluded("index.html"));
1150 assert!(!is_excluded("assets/main.js"));
1151 assert!(!is_excluded("favicon.ico"));
1152 }
1153
1154 #[test]
1159 fn spawn_dev_server_errors_without_root() {
1160 let config = ViteConfig::default(); let err = spawn_dev_server(&config).expect_err("expected error when root is None");
1162 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1163 }
1164
1165 #[test]
1170 fn maybe_spawn_dev_server_returns_none_when_auto_start_false() {
1171 let config = ViteConfig {
1173 frontend_root: Some(std::path::PathBuf::from(".")),
1174 ..Default::default()
1175 };
1176 assert!(config.maybe_spawn_dev_server().is_none());
1177 }
1178
1179 #[cfg(debug_assertions)]
1180 #[test]
1181 fn maybe_spawn_dev_server_returns_none_without_frontend_root() {
1182 let config = ViteConfig {
1184 auto_start: true,
1185 ..Default::default() };
1187 assert!(config.maybe_spawn_dev_server().is_none());
1188 }
1189
1190 #[cfg(not(debug_assertions))]
1191 #[test]
1192 fn maybe_spawn_dev_server_always_none_in_release() {
1193 let config = ViteConfig {
1195 auto_start: true,
1196 frontend_root: Some(std::path::PathBuf::from(".")),
1197 ..Default::default()
1198 };
1199 assert!(config.maybe_spawn_dev_server().is_none());
1200 }
1201
1202 #[cfg(debug_assertions)]
1207 #[tokio::test]
1208 async fn router_returns_unavailable_when_vite_not_running() {
1209 let config = ViteConfig {
1210 dev_port: 1,
1211 prefix: "/static/".to_string(),
1212 ..Default::default()
1213 };
1214 let app: Router = Router::new().nest("/static", router(config));
1215 let response = app
1216 .oneshot(
1217 Request::builder()
1218 .uri("/static/main.js")
1219 .body(Body::empty())
1220 .unwrap(),
1221 )
1222 .await
1223 .unwrap();
1224 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1225 }
1226
1227 #[cfg(debug_assertions)]
1230 #[tokio::test]
1231 async fn router_no_prefix_returns_unavailable() {
1232 let config = ViteConfig {
1233 dev_port: 1,
1234 prefix: "/".to_string(),
1235 ..Default::default()
1236 };
1237 let app: Router = router(config);
1238 let response = app
1239 .oneshot(
1240 Request::builder()
1241 .uri("/main.js")
1242 .body(Body::empty())
1243 .unwrap(),
1244 )
1245 .await
1246 .unwrap();
1247 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1248 }
1249
1250 #[cfg(debug_assertions)]
1255 #[tokio::test]
1256 async fn spa_router_unknown_path_passes_through_vite_response() {
1257 let config = ViteConfig {
1258 dev_port: 1,
1259 prefix: "/static/".to_string(),
1260 ..Default::default()
1261 };
1262 let app: Router = spa_router(config);
1263 let response = app
1264 .oneshot(
1265 Request::builder()
1266 .uri("/some/spa/page")
1267 .body(Body::empty())
1268 .unwrap(),
1269 )
1270 .await
1271 .unwrap();
1272 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1274 }
1275
1276 #[cfg(debug_assertions)]
1281 #[tokio::test]
1282 async fn spa_router_post_to_unknown_path_is_not_swallowed() {
1283 let config = ViteConfig {
1284 dev_port: 1,
1285 prefix: "/static/".to_string(),
1286 ..Default::default()
1287 };
1288 let app: Router = spa_router(config);
1289 let response = app
1290 .oneshot(
1291 Request::builder()
1292 .method("POST")
1293 .uri("/api/missing")
1294 .body(Body::empty())
1295 .unwrap(),
1296 )
1297 .await
1298 .unwrap();
1299 assert_ne!(
1300 response.status(),
1301 StatusCode::OK,
1302 "POST to unknown path must not return 200 (index.html swallow)"
1303 );
1304 }
1305
1306 #[cfg(debug_assertions)]
1311 #[tokio::test]
1312 async fn serve_index_unavailable_when_vite_not_running() {
1313 let config = ViteConfig {
1314 dev_port: 1,
1315 ..Default::default()
1316 };
1317 let response = serve_index(config).await.into_response();
1318 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1319 }
1320
1321 #[cfg(debug_assertions)]
1322 #[tokio::test]
1323 async fn serve_index_route_registered() {
1324 let config = ViteConfig {
1325 dev_port: 1,
1326 ..Default::default()
1327 };
1328 let app: Router = Router::new().route(
1329 "/",
1330 get({
1331 let c = config.clone();
1332 move || serve_index(c.clone())
1333 }),
1334 );
1335 let response = app
1336 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
1337 .await
1338 .unwrap();
1339 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1340 }
1341
1342 #[cfg(debug_assertions)]
1348 fn make_hmr_app(framework: frameworks::Framework, prefix: &str) -> Router {
1349 let config = Arc::new(ViteConfig {
1350 framework,
1351 prefix: prefix.to_string(),
1352 ..Default::default()
1353 });
1354 let html_handler = || async {
1356 axum::response::Response::builder()
1357 .status(StatusCode::OK)
1358 .header(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
1359 .body(Body::from(
1360 "<html><head><title>T</title></head><body></body></html>",
1361 ))
1362 .unwrap()
1363 };
1364 let js_handler = || async {
1365 axum::response::Response::builder()
1366 .status(StatusCode::OK)
1367 .header(axum::http::header::CONTENT_TYPE, "application/javascript")
1368 .body(Body::from("console.log('hi')"))
1369 .unwrap()
1370 };
1371 Router::new()
1372 .route("/page", get(html_handler))
1373 .route("/app.js", get(js_handler))
1374 .layer(axum::middleware::from_fn_with_state(
1375 config,
1376 hmr_injection_middleware,
1377 ))
1378 }
1379
1380 #[cfg(debug_assertions)]
1381 #[tokio::test]
1382 async fn hmr_middleware_injects_before_head_close() {
1383 let app = make_hmm_app(frameworks::Framework::React, "/static/");
1384 let response = app
1385 .oneshot(Request::builder().uri("/page").body(Body::empty()).unwrap())
1386 .await
1387 .unwrap();
1388 assert_eq!(response.status(), StatusCode::OK);
1389 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1390 .await
1391 .unwrap();
1392 let html = String::from_utf8(body.to_vec()).unwrap();
1393 let head_pos = html.find("</head>").expect("missing </head>");
1394 let client_pos = html
1395 .find("@vite/client")
1396 .expect("@vite/client not injected");
1397 assert!(
1398 client_pos < head_pos,
1399 "@vite/client should be before </head>"
1400 );
1401 }
1402
1403 #[cfg(debug_assertions)]
1404 #[tokio::test]
1405 async fn hmr_middleware_skips_already_injected_html() {
1406 let config = Arc::new(ViteConfig {
1408 framework: frameworks::Framework::React,
1409 ..Default::default()
1410 });
1411 let html_with_client = r#"<html><head><script type="module" src="/@vite/client"></script></head><body></body></html>"#;
1412 let handler = move || {
1413 let h = html_with_client;
1414 async move {
1415 axum::response::Response::builder()
1416 .status(StatusCode::OK)
1417 .header(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
1418 .body(Body::from(h))
1419 .unwrap()
1420 }
1421 };
1422 let app =
1423 Router::new()
1424 .route("/page", get(handler))
1425 .layer(axum::middleware::from_fn_with_state(
1426 config,
1427 hmr_injection_middleware,
1428 ));
1429 let response = app
1430 .oneshot(Request::builder().uri("/page").body(Body::empty()).unwrap())
1431 .await
1432 .unwrap();
1433 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1434 .await
1435 .unwrap();
1436 let html = String::from_utf8(body.to_vec()).unwrap();
1437 assert_eq!(
1438 html.matches("@vite/client").count(),
1439 1,
1440 "should not double-inject"
1441 );
1442 }
1443
1444 #[cfg(debug_assertions)]
1445 #[tokio::test]
1446 async fn hmr_middleware_leaves_non_html_untouched() {
1447 let app = make_hmm_app(frameworks::Framework::None, "/static/");
1448 let response = app
1449 .oneshot(
1450 Request::builder()
1451 .uri("/app.js")
1452 .body(Body::empty())
1453 .unwrap(),
1454 )
1455 .await
1456 .unwrap();
1457 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1458 .await
1459 .unwrap();
1460 assert_eq!(body.as_ref(), b"console.log('hi')");
1461 }
1462
1463 #[cfg(debug_assertions)]
1464 #[tokio::test]
1465 async fn hmr_middleware_respects_custom_prefix_and_framework() {
1466 let app = make_hmm_app(frameworks::Framework::React, "/assets/");
1468 let response = app
1469 .oneshot(Request::builder().uri("/page").body(Body::empty()).unwrap())
1470 .await
1471 .unwrap();
1472 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1473 .await
1474 .unwrap();
1475 let html = String::from_utf8(body.to_vec()).unwrap();
1476 assert!(html.contains("/assets/@vite/client"), "wrong prefix used");
1477 assert!(
1478 html.contains("/assets/@react-refresh"),
1479 "wrong framework/prefix"
1480 );
1481 }
1482
1483 #[cfg(debug_assertions)]
1485 fn make_hmm_app(framework: frameworks::Framework, prefix: &str) -> Router {
1486 make_hmr_app(framework, prefix)
1487 }
1488
1489 #[test]
1494 fn embedded_dir_returns_none_in_debug_mode() {
1495 #[cfg(debug_assertions)]
1499 {
1500 let result: Option<&'static Dir<'static>> =
1501 embedded_dir!("$CARGO_MANIFEST_DIR/nonexistent/path");
1502 assert!(
1503 result.is_none(),
1504 "embedded_dir! must return None in debug builds"
1505 );
1506 }
1507 #[cfg(not(debug_assertions))]
1510 {}
1511 }
1512
1513 #[test]
1514 fn embedded_dir_type_is_option_dir() {
1515 #[cfg(debug_assertions)]
1518 {
1519 let result: Option<&'static Dir<'static>> = embedded_dir!("$CARGO_MANIFEST_DIR");
1520 assert!(result.is_none());
1521 }
1522 }
1523
1524 #[cfg(debug_assertions)]
1531 #[test]
1532 fn entry_assets_dev_returns_dev_script() {
1533 let config = ViteConfig {
1534 prefix: "/static/".to_string(),
1535 ..Default::default()
1536 };
1537 let entry = config.entry_assets();
1538 assert_eq!(entry.script, "/static/src/main.tsx"); assert!(
1540 entry.stylesheets.is_empty(),
1541 "dev mode must not return stylesheets"
1542 );
1543 }
1544
1545 #[cfg(debug_assertions)]
1546 #[test]
1547 fn entry_assets_dev_respects_custom_dev_script() {
1548 let config = ViteConfig {
1549 prefix: "/assets/".to_string(),
1550 dev_script: "src/index.ts".to_string(),
1551 ..Default::default()
1552 };
1553 let entry = config.entry_assets();
1554 assert_eq!(entry.script, "/assets/src/index.ts");
1555 }
1556
1557 #[cfg(debug_assertions)]
1560 #[test]
1561 fn entry_assets_for_dev_uses_explicit_dev_script() {
1562 let config = ViteConfig {
1563 prefix: "/static/".to_string(),
1564 dev_script: "src/main.tsx".to_string(), ..Default::default()
1566 };
1567 let entry = config.entry_assets_for(
1568 "templates/components/editor.html", "src/features/PostEditor/editor.tsx",
1570 );
1571 assert_eq!(entry.script, "/static/src/features/PostEditor/editor.tsx");
1572 assert!(entry.stylesheets.is_empty());
1573 }
1574
1575 #[cfg(debug_assertions)]
1577 #[test]
1578 fn entry_assets_delegates_to_entry_assets_for() {
1579 let config = ViteConfig {
1580 prefix: "/static/".to_string(),
1581 dev_script: "src/app.ts".to_string(),
1582 manifest_key: "index.html".to_string(),
1583 ..Default::default()
1584 };
1585 let via_shortcut = config.entry_assets();
1586 let via_explicit = config.entry_assets_for(&config.manifest_key, &config.dev_script);
1587 assert_eq!(via_shortcut.script, via_explicit.script);
1588 assert_eq!(via_shortcut.stylesheets, via_explicit.stylesheets);
1589 }
1590
1591 #[cfg(debug_assertions)]
1592 #[test]
1593 fn entry_assets_dev_trims_trailing_slash_in_prefix() {
1594 let config = ViteConfig {
1597 prefix: "/static/".to_string(),
1598 ..Default::default()
1599 };
1600 let entry = config.entry_assets();
1601 assert!(
1602 !entry.script.contains("//"),
1603 "double-slash in script path: {}",
1604 entry.script
1605 );
1606 }
1607
1608 #[test]
1611 fn entry_assets_from_manifest_secondary_entry_no_css() {
1612 let json = r#"{
1614 "index.html": {
1615 "file": "assets/main-A1b2C3.js",
1616 "css": ["assets/index-B2c3D4.css"]
1617 },
1618 "templates/components/editor.html": {
1619 "file": "assets/templates/components/editor-oW_rDizN.js"
1620 }
1621 }"#;
1622 let entry = parse_manifest_for_test(json, "/static", "templates/components/editor.html");
1623 assert_eq!(
1624 entry.script,
1625 "/static/assets/templates/components/editor-oW_rDizN.js"
1626 );
1627 assert!(
1628 entry.stylesheets.is_empty(),
1629 "secondary entry has no CSS chunk"
1630 );
1631 }
1632
1633 #[test]
1634 fn entry_assets_from_manifest_happy_path() {
1635 let json = r#"{
1636 "index.html": {
1637 "file": "assets/main-A1b2C3.js",
1638 "css": ["assets/index-B2c3D4.css"]
1639 }
1640 }"#;
1641 let entry = parse_manifest_for_test(json, "/static", "index.html");
1642 assert_eq!(entry.script, "/static/assets/main-A1b2C3.js");
1643 assert_eq!(entry.stylesheets, vec!["/static/assets/index-B2c3D4.css"]);
1644 }
1645
1646 #[test]
1647 fn entry_assets_from_manifest_multiple_css() {
1648 let json = r#"{
1649 "index.html": {
1650 "file": "assets/main.js",
1651 "css": ["assets/a.css", "assets/b.css"]
1652 }
1653 }"#;
1654 let entry = parse_manifest_for_test(json, "/s", "index.html");
1655 assert_eq!(entry.stylesheets.len(), 2);
1656 assert_eq!(entry.stylesheets[0], "/s/assets/a.css");
1657 assert_eq!(entry.stylesheets[1], "/s/assets/b.css");
1658 }
1659
1660 #[test]
1661 fn entry_assets_from_manifest_no_css_key() {
1662 let json = r#"{"index.html": {"file": "assets/main.js"}}"#;
1664 let entry = parse_manifest_for_test(json, "/static", "index.html");
1665 assert_eq!(entry.script, "/static/assets/main.js");
1666 assert!(entry.stylesheets.is_empty());
1667 }
1668
1669 #[test]
1670 fn entry_assets_from_manifest_key_not_found_returns_default() {
1671 let json = r#"{"index.html": {"file": "assets/main.js"}}"#;
1672 let entry = parse_manifest_for_test(json, "/static", "admin/index.html");
1673 assert!(
1674 entry.script.is_empty(),
1675 "expected empty script on missing key"
1676 );
1677 assert!(entry.stylesheets.is_empty());
1678 }
1679
1680 #[test]
1681 fn entry_assets_from_manifest_invalid_json_returns_default() {
1682 let entry = parse_manifest_for_test("not json at all {{{", "/static", "index.html");
1683 assert!(entry.script.is_empty());
1684 assert!(entry.stylesheets.is_empty());
1685 }
1686
1687 #[test]
1688 fn entry_assets_from_manifest_prefix_no_trailing_slash() {
1689 let json = r#"{"index.html": {"file": "assets/main.js", "css": ["assets/a.css"]}}"#;
1692 let entry = parse_manifest_for_test(json, "/static/", "index.html");
1693 assert!(
1694 !entry.script.contains("//"),
1695 "double-slash in script: {}",
1696 entry.script
1697 );
1698 assert!(
1699 !entry.stylesheets[0].contains("//"),
1700 "double-slash in css: {}",
1701 entry.stylesheets[0]
1702 );
1703 }
1704
1705 fn parse_manifest_for_test(json: &str, base: &str, key: &str) -> EntryAssets {
1709 let base = base.trim_end_matches('/');
1710 let Ok(manifest) = serde_json::from_str::<serde_json::Value>(json) else {
1711 return EntryAssets::default();
1712 };
1713 let Some(entries) = manifest.as_object() else {
1714 return EntryAssets::default();
1715 };
1716 let Some(entry) = entries.get(key) else {
1717 return EntryAssets::default();
1718 };
1719 let script = entry
1720 .get("file")
1721 .and_then(|f: &serde_json::Value| f.as_str())
1722 .map(|f| format!("{base}/{f}"))
1723 .unwrap_or_default();
1724 let stylesheets = entry
1725 .get("css")
1726 .and_then(|c: &serde_json::Value| c.as_array())
1727 .into_iter()
1728 .flatten()
1729 .filter_map(|s: &serde_json::Value| s.as_str())
1730 .map(|s| format!("{base}/{s}"))
1731 .collect();
1732 EntryAssets {
1733 script,
1734 stylesheets,
1735 }
1736 }
1737
1738 fn compute_etag_for_test(bytes: &[u8]) -> String {
1745 use std::collections::hash_map::DefaultHasher;
1746 use std::hash::{Hash, Hasher};
1747 let mut h = DefaultHasher::new();
1748 bytes.hash(&mut h);
1749 format!("\"{}\"", h.finish())
1750 }
1751
1752 #[test]
1753 fn etag_is_quoted_string() {
1754 let etag = compute_etag_for_test(b"hello world");
1755 assert!(etag.starts_with('"'), "ETag must start with '\"'");
1756 assert!(etag.ends_with('"'), "ETag must end with '\"'");
1757 assert!(etag.len() > 2, "ETag must not be empty between quotes");
1758 }
1759
1760 #[test]
1761 fn etag_same_bytes_same_value() {
1762 let a = compute_etag_for_test(b"assets/main-abc.js content");
1763 let b = compute_etag_for_test(b"assets/main-abc.js content");
1764 assert_eq!(a, b, "same bytes must produce same ETag");
1765 }
1766
1767 #[test]
1768 fn etag_different_bytes_different_value() {
1769 let a = compute_etag_for_test(b"version one");
1770 let b = compute_etag_for_test(b"version two");
1771 assert_ne!(a, b, "different bytes must produce different ETags");
1772 }
1773
1774 #[cfg(not(debug_assertions))]
1777 #[test]
1778 fn serve_embedded_file_returns_etag_header() {
1779 use include_dir::{Dir, DirEntry, File};
1780 static BYTES: &[u8] = b"console.log('hi')";
1783 let etag = compute_etag_for_test(BYTES);
1785 assert!(etag.starts_with('"'));
1786 assert!(etag.ends_with('"'));
1787 }
1788
1789 #[cfg(not(debug_assertions))]
1790 #[test]
1791 fn serve_embedded_file_304_on_matching_etag() {
1792 let bytes = b"some asset content";
1799 let etag = compute_etag_for_test(bytes);
1800
1801 let mut headers = axum::http::HeaderMap::new();
1803 headers.insert(
1804 header::IF_NONE_MATCH,
1805 axum::http::HeaderValue::from_str(&etag).unwrap(),
1806 );
1807 let client_etag = headers
1808 .get(header::IF_NONE_MATCH)
1809 .map(|v| v.as_bytes().to_vec());
1810 let server_etag = etag.as_bytes().to_vec();
1811 assert_eq!(
1812 client_etag.as_deref(),
1813 Some(server_etag.as_slice()),
1814 "ETag round-trip: If-None-Match must equal computed ETag"
1815 );
1816 }
1817
1818 #[test]
1819 fn etag_empty_bytes() {
1820 let etag = compute_etag_for_test(b"");
1822 assert!(etag.starts_with('"'));
1823 assert!(etag.ends_with('"'));
1824 }
1825}