Skip to main content

axum_vite/
lib.rs

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
19/// Re-export of the [`include_dir`] crate and its [`Dir`] type.
20///
21/// Prefer the [`embedded_dir!`] macro over using these directly — it handles
22/// the `#[cfg(debug_assertions)]` boilerplate for you.
23pub use include_dir;
24pub use include_dir::Dir;
25
26/// Embeds a directory at compile time in **release** builds; returns `None` in **debug** builds.
27///
28/// This is the recommended way to pass the built frontend assets to [`ViteConfig::from_env`].
29/// Internally it calls [`include_dir::include_dir!`], so paths must follow include_dir's rules:
30/// prefix relative paths with `$CARGO_MANIFEST_DIR` so the proc-macro can resolve them, or use
31/// any other `$ENV_VAR` that expands to an absolute path at compile time.
32///
33/// In **debug** builds the `include_dir!` call is not compiled at all — the macro expands
34/// to `None` so you never have to build the frontend just to do `cargo run`.
35///
36/// In **release** builds the directory is baked into the binary and `Some(&'static Dir)` is
37/// returned, matching the signature of [`ViteConfig::from_env`].
38///
39/// # Example
40///
41/// ```rust,ignore
42/// let config = ViteConfig::from_env(axum_vite::embedded_dir!("$CARGO_MANIFEST_DIR/dist"));
43/// ```
44#[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            // Explicit type annotation so inference never fails when the result
55            // is stored in a variable without a type annotation.
56            None::<&$crate::Dir<'static>>
57        }
58    }};
59}
60
61/// Configuration for the Vite dev server proxy and asset serving.
62#[derive(Clone, Debug)]
63pub struct ViteConfig {
64    /// Port of the Vite dev server (default: 5173)
65    pub dev_port: u16,
66    /// Hostname of the Vite dev server (default: "localhost")
67    /// Configurable via `VITE_DEV_HOST` for remote or custom local dev servers.
68    pub dev_host: String,
69    /// The directory containing the built assets (release mode).
70    ///
71    /// Embed your dist folder in your application crate and pass the result:
72    /// ```rust,ignore
73    /// static DIST: axum_vite::Dir<'static> =
74    ///     axum_vite::include_dir::include_dir!("$CARGO_MANIFEST_DIR/dist");
75    /// # let config = axum_vite::ViteConfig::from_env(Some(&DIST));
76    /// ```
77    /// Pass `None` in dev mode — the Vite proxy handles asset serving.
78    pub dir: Option<&'static Dir<'static>>,
79    /// Fallback file for 404s (e.g., "404.html")
80    pub not_found: String,
81    /// Static prefix used by Vite for assets (e.g., "/static/")
82    pub prefix: String,
83    /// Path to the frontend project root (where package.json is located)
84    pub frontend_root: Option<PathBuf>,
85    /// Command to start the Vite dev server (e.g., "npm run dev")
86    pub dev_command: String,
87    /// Whether to automatically start the Vite dev server on startup
88    pub auto_start: bool,
89    /// The frontend framework being used for HMR preamble generation
90    pub framework: Framework,
91    /// Path to the JS/TS entry file relative to the Vite project root.
92    ///
93    /// Used as the `<script src>` in dev mode, where Vite serves source files
94    /// directly. Typical values: `"src/main.tsx"`, `"src/index.ts"`.
95    /// Defaults to `"src/main.tsx"`.
96    pub dev_script: String,
97    /// Key to look up in `dist/.vite/manifest.json` in production builds.
98    ///
99    /// For single-page apps this is `"index.html"` (the Vite default).
100    /// For multi-page apps use [`ViteConfig::entry_assets_for`] to pass a
101    /// different key per page while sharing the same config.
102    pub manifest_key: String,
103    /// Path prefixes to exclude from static asset serving (release mode only).
104    /// Paths starting with any of these strings will return a 404 instead of
105    /// serving the file. Useful for protecting server-rendered templates that
106    /// are embedded in the asset directory but must not be served directly.
107    pub excluded_prefixes: Vec<String>,
108    /// Shared HTTP client for proxying requests to the Vite dev server.
109    /// Created once per `ViteConfig` and reused across requests via `Clone`.
110    #[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    /// Generates the HTML scripts required for Vite HMR and framework-specific preambles.
185    ///
186    /// Returns an empty string in release builds. In debug builds, produces
187    /// the `@vite/client` script tag and any framework-specific preamble.
188    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        // Framework preamble MUST come before @vite/client for React Refresh
196        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/// Resolved asset paths for the JS entry point of a Vite project.
213///
214/// In **dev** mode, `script` points at the raw source file (`src/main.tsx`)
215/// and `stylesheets` is empty — Vite injects CSS via the JS module at runtime.
216///
217/// In **production**, both are read from `dist/.vite/manifest.json` and carry
218/// the content hash (e.g. `assets/main-A1b2C3.js`).
219///
220/// Obtain an instance via [`ViteConfig::entry_assets`].
221///
222/// # Example
223///
224/// ```rust,no_run
225/// use axum_vite::{ViteConfig, embedded_dir, frameworks::Framework};
226///
227/// let config = ViteConfig {
228///     framework: Framework::React,
229///     ..ViteConfig::from_env(embedded_dir!("$CARGO_MANIFEST_DIR/frontend/dist"))
230/// };
231/// let entry = config.entry_assets();
232/// // entry.script  → "/static/src/main.tsx"           (dev)
233/// //               → "/static/assets/main-A1b2C3.js"  (release)
234/// // entry.stylesheets → []                            (dev)
235/// //                   → ["/static/assets/index-B2c3.css"] (release)
236/// ```
237#[derive(Clone, Default, Debug)]
238pub struct EntryAssets {
239    /// Value for `<script type="module" src="…">`.
240    pub script: String,
241    /// Values for `<link rel="stylesheet" href="…">` tags.
242    pub stylesheets: Vec<String>,
243}
244
245impl ViteConfig {
246    /// Resolves the JS entry point and CSS paths using [`ViteConfig::manifest_key`]
247    /// and [`ViteConfig::dev_script`].
248    ///
249    /// In dev mode returns `dev_script` prefixed with [`ViteConfig::prefix`] and
250    /// an empty `stylesheets` vec — Vite injects CSS via the JS module at runtime.
251    /// In release mode reads `dist/.vite/manifest.json` from the embedded dir and
252    /// looks up `manifest_key`. If the manifest is missing or the key is not found
253    /// a warning is logged and the dev-mode fallback is returned.
254    ///
255    /// For multi-page apps with multiple entry points use [`entry_assets_for`](Self::entry_assets_for)
256    /// to pass a different manifest key while reusing the same config.
257    ///
258    /// Requires `build: { manifest: true }` in `vite.config` so Vite writes
259    /// `dist/.vite/manifest.json` during `npm run build`.
260    pub fn entry_assets(&self) -> EntryAssets {
261        self.entry_assets_for(&self.manifest_key, &self.dev_script)
262    }
263
264    /// Like [`entry_assets`](Self::entry_assets) but explicitly specifies both the
265    /// manifest key (used in production) and the dev script path (used in dev mode).
266    ///
267    /// Use this for **secondary entry points** in multi-page apps where the manifest
268    /// key (an HTML file) differs from the dev-mode source path:
269    ///
270    /// ```rust,no_run
271    /// # use axum_vite::{ViteConfig, embedded_dir};
272    /// # let config = ViteConfig::from_env(None);
273    /// // Primary entry: convenience wrapper uses ViteConfig fields
274    /// let home = config.entry_assets();
275    ///
276    /// // Secondary entry: supply manifest key + dev source path explicitly
277    /// let editor = config.entry_assets_for(
278    ///     "templates/components/editor.html",  // manifest key (prod)
279    ///     "src/features/PostEditor/editor.tsx", // dev script path
280    /// );
281    /// ```
282    ///
283    /// In dev mode `dev_script` is prepended with [`ViteConfig::prefix`] and
284    /// returned directly — the manifest key is ignored. In production the
285    /// manifest key is looked up in `dist/.vite/manifest.json`.
286    #[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        // Dev: Vite serves the entry file directly; CSS is injected by the JS module.
308        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
357/// A handle to a running Vite dev server child process.
358///
359/// Dropping this handle **kills the child process**, preventing orphaned Node
360/// processes when the Rust dev server restarts (e.g. with `cargo watch`).
361/// Keep it alive for the full lifetime of your server:
362///
363/// ```rust,no_run
364/// # use axum_vite::{ViteConfig, spawn_dev_server};
365/// let config = ViteConfig::from_env(None);
366/// // Bind to a variable so it lives until the end of main.
367/// let _dev_server = spawn_dev_server(&config);
368/// ```
369pub 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        // Reap the process so it doesn't linger as a zombie on Unix.
383        let _ = self.child.wait();
384    }
385}
386
387/// Spawns the Vite dev server as a child process.
388///
389/// Returns a [`DevServerHandle`] that kills the process on drop.
390/// This should only be called in debug mode and when `auto_start` is enabled.
391pub 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        // Prefix the command with `exec` so the shell replaces itself with
407        // the Node process. This means `kill()` in Drop reaches Node directly
408        // with no intermediate shell left as an orphan.
409        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        // `cmd /C` does not forward signals to its child process, so killing
419        // the handle may leave the Node process running. If that matters,
420        // consider spawning node/npm directly instead of going through cmd,
421        // or use `taskkill /T /F /PID <pid>` in the Drop impl.
422        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    /// Spawns the Vite dev server when [`auto_start`](Self::auto_start) is `true`.
433    ///
434    /// This is the ergonomic alternative to calling [`spawn_dev_server`] directly.
435    /// It handles the `#[cfg(debug_assertions)]` guard, the `auto_start` check, and
436    /// error logging internally — no boilerplate needed at the call site:
437    ///
438    /// ```rust,no_run
439    /// use axum_vite::ViteConfig;
440    ///
441    /// let config = ViteConfig::from_env(None);
442    /// // Keep the handle alive — dropping it kills the child process.
443    /// let _dev_server = config.maybe_spawn_dev_server();
444    /// ```
445    ///
446    /// Returns `None` in **release builds** unconditionally. In debug builds, returns
447    /// `None` when `auto_start` is `false`, when `frontend_root` is not set, or when
448    /// spawning fails (a warning is logged in that case).
449    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            // Path<String> handles two things we need:
487            //   1. Strips the nest prefix (e.g. /static/) automatically.
488            //   2. Percent-decodes the segment so include_dir can match
489            //      actual filenames (e.g. "my image.png" not "my%20image.png").
490            // We re-attach the raw query string separately so Vite receives
491            // its cache-busting params (?vue&type=style, ?import, ?t=) intact.
492            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
501/// Convenience router for a single-page application.
502///
503/// Equivalent to wiring up `serve_index` on `/` and a catch-all `/{*path}`,
504/// nesting the Vite asset `router` under `/{static_prefix}`, and wrapping
505/// everything in [`hmr_injection_middleware`]. Use this when your Axum app
506/// has no server-side HTML rendering and just needs to serve the SPA shell:
507///
508/// ```rust,no_run
509/// use axum::{Router, routing::get};
510/// use axum_vite::{ViteConfig, spa_router};
511///
512/// let config = ViteConfig::from_env(None);
513/// let app = Router::<()>::new()
514///     .route("/api/hello", get(|| async { "hello" }))
515///     .merge(spa_router(config));
516/// ```
517///
518/// If you render HTML server-side (e.g. with Askama), prefer calling
519/// `config.hmr_scripts()` in your template and skipping this helper — see the
520/// README for details.
521pub 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    // In dev mode the catch-all tries to proxy the path to Vite first (handles
533    // root-relative asset requests like /src/main.tsx that the browser produces
534    // from relative URLs in the HTML), then falls back to index.html for real
535    // SPA navigation routes (which Vite would 404).
536    //
537    // In release mode the catch-all always serves index.html; real assets are
538    // handled by the more-specific prefix-mounted router below.
539    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                    // Path<String> decodes percent-encoding and strips the nest
557                    // prefix. Re-attach the raw query string so Vite's module
558                    // protocol (?vue&type=style, ?import, ?t= HMR busters) works.
559                    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    // Only nest the static asset router when there is a real prefix.
569    // If the prefix is just "/" there is no distinct mount point and the
570    // catch-all handles everything.
571    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
581/// Serves the SPA shell (`index.html`).
582///
583/// Use this as the handler for your root route and any catch-all route so the
584/// single-page app can handle client-side navigation:
585///
586/// ```rust,no_run
587/// use axum::{Router, routing::get};
588/// use axum_vite::{ViteConfig, serve_index};
589///
590/// let config = ViteConfig::from_env(None);
591/// let app = Router::<()>::new()
592///     .route("/", get({
593///         let c = config.clone();
594///         move || serve_index(c.clone())
595///     }))
596///     .nest("/static", axum_vite::router(config));
597/// ```
598///
599/// In **dev** builds this proxies `GET /` to the Vite dev server so that HMR
600/// and the React/Vue/Svelte preamble are injected by Vite itself.
601///
602/// In **release** builds this reads `index.html` from the embedded asset
603/// directory and serves it with `Cache-Control: no-store`.
604pub 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    // The root is always fetched with GET by the browser.
612    proxy_to_vite("", &config, &headers, &Method::GET, Bytes::new()).await
613}
614
615/// SPA catch-all: tries to proxy the path to Vite first.
616/// On Vite 404 (client-side navigation route) falls back to `index.html`.
617/// In release builds always returns `index.html` — assets are handled by
618/// the prefix-mounted `router()` which is more specific and wins first.
619#[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    // Only fall back to the SPA shell for browser page-navigation requests.
629    // API fetches, missing images, and non-GET requests must receive the real
630    // status code — not index.html — or `fetch()` callers get back HTML and
631    // crash with "Unexpected token '<'" when they try to parse it as JSON.
632    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    // Respect excluded_prefixes consistently with serve_asset so a catchall
656    // request cannot bypass a configured exclusion.
657    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    // Only fall back to the SPA shell for browser page-navigation requests.
672    // API fetches, missing images, and non-GET requests must receive 404 —
673    // not index.html — or `fetch()` callers get back HTML and crash with
674    // "Unexpected token '<'" when they try to parse it as JSON.
675    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/// Core proxy logic shared by the `/static/` router and the dev fallback.
713///
714/// Always prepends the Vite `base` prefix from `config.prefix` — this is the
715/// single source of truth for the asset path namespace. When `base` is `/`
716/// (no prefix) this effectively becomes a no-op; when it's `/static/` the
717/// prefix is added automatically.
718#[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        // Strip Host (we target a known localhost URL) and Accept-Encoding:
732        // reqwest decompresses transparently but still forwards the upstream
733        // Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED in the
734        // browser. Forcing an uncompressed response is fine on localhost.
735        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                // Drop hop-by-hop and size headers: we buffer the full body
745                // into memory, so hyper will compute the correct Content-Length
746                // from the actual Bytes. Keeping the original could cause a
747                // mismatch (e.g. chunked response with an incorrect length, or
748                // a body that was decompressed in transit).
749                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/// Proxies `path_str` to Vite under the configured static prefix.
768/// Used by the asset `router()` where Axum has already stripped the prefix
769/// from the path segment.
770#[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    // Do not strip "public/" here. Vite serves files from the `public/`
780    // folder at the dist root (e.g. `public/logo.png` → `/logo.png`), so the
781    // developer must reference them without the `public/` prefix in both dev
782    // and release. Silently stripping it in dev mode only would create a
783    // dev/release inconsistency where broken URLs work locally but 404 in
784    // production.
785    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/// Proxies `path_str` to Vite at the root — NO prefix added.
798/// Used for paths that browsers request relative to `/` (e.g. `/src/main.tsx`,
799/// `/@vite/client`) which Vite serves at its own root regardless of the
800/// static asset prefix configured in this crate.
801#[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/// Middleware that injects Vite HMR scripts into HTML responses in dev mode.
832///
833/// Registered automatically by [`spa_router`] via `from_fn_with_state`.
834/// The middleware receives the `ViteConfig` it was built with, so custom
835/// `prefix` and `framework` settings are always respected.
836///
837/// Scans `text/html` responses and injects the preamble + `@vite/client`
838/// script immediately before `</head>`. In release builds this is a no-op.
839///
840/// **Note:** the entire HTML body is buffered in memory for the injection.
841/// This is only active in debug builds and is fine for typical HTML pages;
842/// do not route large non-HTML downloads through an HTML route in dev mode.
843#[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            // Vite already injected its own preamble (e.g. when proxying /),
866            // so skip injection to avoid duplicating the scripts.
867            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/// Computes a quoted ETag value from file bytes using a fast non-crypto hash.
891/// Stable within a single binary — a new deploy produces new hashes, which is
892/// correct: clients must re-fetch after any release regardless of content.
893#[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    // ETag values must be quoted strings per RFC 7232 §2.3.
900    format!("\"{}\"", h.finish())
901}
902
903/// Builds a response for a single embedded file with correct MIME type,
904/// cache headers, and ETag. Returns 304 Not Modified when the client's
905/// `If-None-Match` matches. Used by `serve_asset` and `_serve_spa_catchall`.
906#[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    // Cache-Control strategy:
920    //   - HTML: no-store (entry point, always revalidate)
921    //   - Service workers: no-store (stale SW can strand users for the cache TTL)
922    //   - Web manifests: 1 day (not content-hashed by Vite, changes infrequently)
923    //   - Everything else: 1 year (Vite content-hashes all JS/CSS filenames)
924    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        // Vite content-hashes filenames only for files inside the `assets/`
941        // directory (the output of Rollup bundling). Everything else — favicon,
942        // robots.txt, translation JSON, images from `public/` — lands in the
943        // dist root without a hash and must be revalidated on every request.
944        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    // Return 304 if the client already has this exact version.
952    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        // file.contents() is &'static [u8] because include_dir embeds the
967        // data directly in the binary. Body::from(&'static [u8]) is zero-copy.
968        .body(Body::from(file.contents()))
969        .unwrap()
970}
971
972/// Release-mode stub — passes the request through unmodified.
973#[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                // Use empty headers: 404 page is no-store so clients won't
995                // send If-None-Match, and we must return 404, not 304.
996                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            // Trim the leading slash and query before *all* checks so that a
1011            // request to "/templates/secret.html" cannot bypass an
1012            // `excluded_prefixes = ["templates/"]` rule.
1013            let clean = path_str.trim_start_matches('/');
1014            // Strip query string — Vite appends cache-busting params
1015            // (?v=, ?import, ?t=) that are not part of the embedded file
1016            // path stored by include_dir.
1017            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// ---------------------------------------------------------------------------
1041// Tests
1042// ---------------------------------------------------------------------------
1043#[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; // for `oneshot`
1054
1055    // -----------------------------------------------------------------------
1056    // ViteConfig helpers
1057    // -----------------------------------------------------------------------
1058
1059    #[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    // -----------------------------------------------------------------------
1136    // excluded_prefixes
1137    // -----------------------------------------------------------------------
1138
1139    #[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    // -----------------------------------------------------------------------
1155    // spawn_dev_server: returns an error when frontend_root is not set
1156    // -----------------------------------------------------------------------
1157
1158    #[test]
1159    fn spawn_dev_server_errors_without_root() {
1160        let config = ViteConfig::default(); // frontend_root: None
1161        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    // -----------------------------------------------------------------------
1166    // maybe_spawn_dev_server
1167    // -----------------------------------------------------------------------
1168
1169    #[test]
1170    fn maybe_spawn_dev_server_returns_none_when_auto_start_false() {
1171        // auto_start defaults to false → always None, regardless of root
1172        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        // auto_start true but no root → spawn_dev_server errors → None
1183        let config = ViteConfig {
1184            auto_start: true,
1185            ..Default::default() // frontend_root: None
1186        };
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        // Even with auto_start + root set, release builds must be a no-op.
1194        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    // -----------------------------------------------------------------------
1203    // router — proxy path (dev only, no real Vite needed: port 1 → refused)
1204    // -----------------------------------------------------------------------
1205
1206    #[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    // router() with no prefix — used standalone, no nest(). The path reaches
1228    // Vite at the root so we still expect SERVICE_UNAVAILABLE (port 1), not 404.
1229    #[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    // spa_router catchall proxies unknown paths to Vite and passes the
1251    // response through unmodified — it must NOT silently replace it with
1252    // index.html when the Vite response is not a 404 (here: 503 because
1253    // Vite is unreachable on port 1).
1254    #[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        // Vite is unreachable → SERVICE_UNAVAILABLE, not 200 with index.html.
1273        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1274    }
1275
1276    // The API-404-swallow guard: a POST to an unknown path must pass through
1277    // Vite's response rather than falling back to index.html. In dev mode with
1278    // Vite unreachable the proxy returns SERVICE_UNAVAILABLE; the important
1279    // thing is the catchall does not short-circuit to 200/index.html.
1280    #[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    // -----------------------------------------------------------------------
1307    // serve_index (dev only)
1308    // -----------------------------------------------------------------------
1309
1310    #[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    // -----------------------------------------------------------------------
1343    // HMR middleware — tested through a real Axum router so the middleware
1344    // signature (State + Next) is exercised correctly.
1345    // -----------------------------------------------------------------------
1346
1347    #[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        // A simple handler that returns a plain HTML page.
1355        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        // Build a router whose HTML already contains @vite/client.
1407        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        // Verify the middleware uses the config it was built with, not a fresh env parse.
1467        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    // Helper — typo-fix alias used in tests above.
1484    #[cfg(debug_assertions)]
1485    fn make_hmm_app(framework: frameworks::Framework, prefix: &str) -> Router {
1486        make_hmr_app(framework, prefix)
1487    }
1488
1489    // -----------------------------------------------------------------------
1490    // embedded_dir! macro
1491    // -----------------------------------------------------------------------
1492
1493    #[test]
1494    fn embedded_dir_returns_none_in_debug_mode() {
1495        // In debug builds the include_dir! branch is not compiled at all, so the
1496        // path doesn't need to exist on disk.  The macro must unconditionally
1497        // produce None.
1498        #[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        // Release-mode coverage is provided by the examples/basic.rs build which
1508        // actually embeds `examples/frontend/dist` and must compile + serve files.
1509        #[cfg(not(debug_assertions))]
1510        {}
1511    }
1512
1513    #[test]
1514    fn embedded_dir_type_is_option_dir() {
1515        // Confirm the macro produces the correct type so ViteConfig::from_env
1516        // can accept it without an explicit cast.
1517        #[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    // -----------------------------------------------------------------------
1525    // EntryAssets / entry_assets()
1526    // -----------------------------------------------------------------------
1527
1528    // In debug builds entry_assets must always return the dev-mode fallback
1529    // using the config's dev_script (manifest is never read in dev).
1530    #[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"); // default dev_script
1539        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    // entry_assets_for must use the caller-supplied dev_script, not self.dev_script.
1558    // This is the key invariant for secondary MPA entries (e.g. editor.tsx).
1559    #[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(), // primary entry — must not bleed through
1565            ..Default::default()
1566        };
1567        let entry = config.entry_assets_for(
1568            "templates/components/editor.html", // manifest key (ignored in dev)
1569            "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    // entry_assets() is still sugar for entry_assets_for(manifest_key, dev_script).
1576    #[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        // prefix is stored as "/static/" — the trailing slash must not produce
1595        // a double-slash like "/static//src/main.tsx".
1596        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    // from_manifest is only compiled in release — test it directly via a helper
1609    // that mirrors the same logic so we can exercise it in debug test runs too.
1610    #[test]
1611    fn entry_assets_from_manifest_secondary_entry_no_css() {
1612        // Real-world pattern: an HTML entry that only produces a JS chunk (no CSS).
1613        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        // Vite omits "css" when there are no stylesheets for the entry.
1663        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        // Verify the helper strips trailing slash from base the same way
1690        // entry_assets() does.
1691        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    /// Mirror of `EntryAssets::from_manifest` that is always compiled (not
1706    /// gated on `#[cfg(not(debug_assertions))]`) so the manifest-parsing logic
1707    /// can be unit-tested in both debug and release runs.
1708    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    // -----------------------------------------------------------------------
1739    // ETag / 304 — tested via the always-compiled helpers below so they run
1740    // in both debug and release test runs.
1741    // -----------------------------------------------------------------------
1742
1743    /// Mirrors `file_etag` for test use (always compiled).
1744    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    // The 304 path is release-only (gated on serve_embedded_file existing), so
1775    // we test it only in release mode.
1776    #[cfg(not(debug_assertions))]
1777    #[test]
1778    fn serve_embedded_file_returns_etag_header() {
1779        use include_dir::{Dir, DirEntry, File};
1780        // include_dir doesn't expose a constructor for tests; use the real
1781        // serve_embedded_file via a static fixture declared inline.
1782        static BYTES: &[u8] = b"console.log('hi')";
1783        // We can't construct a File directly — test via parse_etag round-trip.
1784        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        // Construct a minimal include_dir::File-like scenario by calling the
1793        // private helper indirectly: build a request with the ETag we expect,
1794        // then verify the 304 branch fires.
1795        //
1796        // Because File::new is not pub we test the logic of compute_etag_for_test
1797        // (which mirrors file_etag) and assert the branching condition.
1798        let bytes = b"some asset content";
1799        let etag = compute_etag_for_test(bytes);
1800
1801        // Simulate: client sends If-None-Match equal to the ETag.
1802        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        // Edge case: empty file should still produce a valid quoted ETag.
1821        let etag = compute_etag_for_test(b"");
1822        assert!(etag.starts_with('"'));
1823        assert!(etag.ends_with('"'));
1824    }
1825}