use std::sync::Arc;
use crate::axum::body::Body;
use crate::axum::extract::Request;
#[derive(Clone, Debug)]
pub(crate) struct ViteRoutes {
asset_roots: Arc<[Box<str>]>,
}
impl ViteRoutes {
pub(crate) const BUILT_IN: &'static [&'static str] = &["/@", "/node_modules/.vite/"];
pub(crate) const DEFAULT_ASSET_ROOTS: &'static [&'static str] = &["/resources/", "/src/"];
pub(crate) fn new<I, S>(asset_roots: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let asset_roots: Vec<Box<str>> = asset_roots
.into_iter()
.filter_map(|root| normalise_root(root.as_ref()))
.collect();
Self {
asset_roots: asset_roots.into(),
}
}
pub(crate) fn defaults() -> Self {
Self::new(Self::DEFAULT_ASSET_ROOTS)
}
pub(crate) fn asset_roots(&self) -> &[Box<str>] {
&self.asset_roots
}
pub(crate) fn matches_path(&self, path: &str) -> bool {
Self::BUILT_IN.iter().any(|p| path.starts_with(p))
|| self
.asset_roots
.iter()
.any(|root| path.starts_with(root.as_ref()))
}
pub(crate) fn matches_request(&self, req: &Request<Body>) -> bool {
self.matches_path(req.uri().path()) || is_vite_ws_upgrade(req)
}
}
fn normalise_root(raw: &str) -> Option<Box<str>> {
let trimmed = raw.trim().trim_matches('/');
if trimmed.is_empty() {
return None;
}
Some(format!("/{trimmed}/").into_boxed_str())
}
fn is_vite_ws_upgrade(req: &Request<Body>) -> bool {
let headers = req.headers();
let connection_upgrade = headers
.get("connection")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("upgrade"));
let upgrade_websocket = headers
.get("upgrade")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"));
let vite_protocol = headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("vite-hmr") || v.contains("vite-ping"));
connection_upgrade && upgrade_websocket && vite_protocol
}
#[cfg(test)]
mod tests {
use super::*;
use crate::axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Uri};
use std::str::FromStr as _;
fn request(path: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(Uri::from_str(path).expect("test path should parse as URI"))
.body(Body::empty())
.expect("test request should build")
}
fn ws_request(protocol: &str) -> Request<Body> {
let mut headers = HeaderMap::new();
headers.insert("connection", HeaderValue::from_static("upgrade"));
headers.insert("upgrade", HeaderValue::from_static("websocket"));
headers.insert(
HeaderName::from_static("sec-websocket-protocol"),
HeaderValue::from_str(protocol).expect("valid header value"),
);
Request::builder()
.method(Method::GET)
.uri(Uri::from_static("/"))
.body(Body::empty())
.map(|mut req| {
*req.headers_mut() = headers;
req
})
.expect("test ws request should build")
}
#[test]
fn vite_internal_paths_are_forwarded() {
let routes = ViteRoutes::defaults();
assert!(routes.matches_request(&request("/@vite/client")));
assert!(routes.matches_request(&request("/@react-refresh")));
assert!(routes.matches_request(&request("/@fs/src/app.tsx")));
assert!(routes.matches_request(&request("/@id/react")));
}
#[test]
fn optimized_deps_are_forwarded() {
let routes = ViteRoutes::defaults();
assert!(routes.matches_request(&request("/node_modules/.vite/deps/react.js")));
}
#[test]
fn the_template_asset_root_is_forwarded_by_default() {
let routes = ViteRoutes::defaults();
assert!(routes.matches_request(&request("/resources/js/app.tsx")));
assert!(routes.matches_request(&request("/resources/js/pages/home.tsx")));
assert!(routes.matches_request(&request("/resources/css/app.css")));
}
#[test]
fn the_plain_vite_asset_root_is_still_forwarded_by_default() {
let routes = ViteRoutes::defaults();
assert!(routes.matches_request(&request("/src/app.tsx")));
assert!(routes.matches_request(&request("/src/main.ts")));
}
#[test]
fn a_configured_root_replaces_the_defaults() {
let routes = ViteRoutes::new(["assets"]);
assert!(routes.matches_request(&request("/assets/app.tsx")));
assert!(!routes.matches_request(&request("/resources/js/app.tsx")));
assert!(routes.matches_request(&request("/@vite/client")));
}
#[test]
fn a_root_matches_only_whole_path_segments() {
let routes = ViteRoutes::new(["src"]);
assert!(routes.matches_request(&request("/src/app.tsx")));
assert!(!routes.matches_request(&request("/srcmap.json")));
}
#[test]
fn roots_are_normalised_however_they_are_written() {
for spelling in ["resources", "/resources", "resources/", "/resources/"] {
let routes = ViteRoutes::new([spelling]);
assert!(
routes.matches_request(&request("/resources/js/app.tsx")),
"spelling {spelling:?} should normalise"
);
}
}
#[test]
fn a_root_that_would_swallow_everything_is_dropped() {
let routes = ViteRoutes::new(["/", "", " "]);
assert!(routes.asset_roots().is_empty());
assert!(!routes.matches_request(&request("/")));
assert!(!routes.matches_request(&request("/api/users")));
assert!(routes.matches_request(&request("/@vite/client")));
}
#[test]
fn application_paths_are_not_forwarded() {
let routes = ViteRoutes::defaults();
assert!(!routes.matches_request(&request("/")));
assert!(!routes.matches_request(&request("/api/users")));
assert!(!routes.matches_request(&request("/dashboard")));
assert!(!routes.matches_request(&request("/favicon.ico")));
}
#[test]
fn vite_hmr_websocket_is_forwarded() {
let routes = ViteRoutes::defaults();
assert!(routes.matches_request(&ws_request("vite-hmr")));
assert!(routes.matches_request(&ws_request("vite-ping")));
}
#[test]
fn non_vite_websocket_is_not_forwarded() {
let routes = ViteRoutes::defaults();
assert!(!routes.matches_request(&ws_request("custom-app-protocol")));
}
#[test]
fn websocket_without_vite_protocol_is_not_forwarded() {
let mut headers = HeaderMap::new();
headers.insert("connection", HeaderValue::from_static("Upgrade"));
headers.insert("upgrade", HeaderValue::from_static("websocket"));
let req = Request::builder()
.method(Method::GET)
.uri(Uri::from_static("/"))
.body(Body::empty())
.map(|mut req| {
*req.headers_mut() = headers;
req
})
.expect("request should build");
assert!(!ViteRoutes::defaults().matches_request(&req));
}
}