use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const DEFAULT_PUBLIC_DIR: &str = "public";
pub const DEFAULT_BUILD_DIR: &str = "build";
#[derive(Debug, Clone)]
pub struct AssetsConfig {
public_dir: PathBuf,
build_dir: String,
}
impl AssetsConfig {
#[must_use]
pub fn new() -> Self {
AssetsConfig {
public_dir: PathBuf::from(DEFAULT_PUBLIC_DIR),
build_dir: DEFAULT_BUILD_DIR.to_string(),
}
}
#[must_use]
pub fn public_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.public_dir = dir.into();
self
}
#[must_use]
pub fn build_dir(mut self, dir: impl Into<String>) -> Self {
self.build_dir = dir.into();
self
}
#[must_use]
pub fn public_path(&self) -> &Path {
&self.public_dir
}
#[must_use]
pub fn build_path(&self) -> PathBuf {
self.public_dir.join(&self.build_dir)
}
#[must_use]
pub fn manifest_path(&self) -> PathBuf {
self.build_path().join(".vite").join("manifest.json")
}
#[must_use]
pub fn url_prefix(&self) -> String {
format!("/{}", self.build_dir.trim_matches('/'))
}
}
impl Default for AssetsConfig {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
pub enum AssetsError {
#[error(
"no Vite manifest at {path}: run `arc build` (or `npx vite build`) before starting in production"
)]
ManifestMissing {
path: PathBuf,
},
#[error("could not read the Vite manifest at {path}: {source}")]
ManifestUnreadable {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the Vite manifest at {path} is not valid manifest JSON: {source}")]
ManifestMalformed {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
#[derive(Debug, Clone, serde::Deserialize)]
struct Chunk {
file: String,
#[serde(default)]
css: Vec<String>,
#[serde(default)]
imports: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EntryAssets {
pub js: String,
pub css: Vec<String>,
}
#[derive(Debug, Clone)]
enum Resolver {
Dev,
Manifest(BTreeMap<String, Chunk>),
}
#[derive(Debug)]
struct AssetsInner {
resolver: Resolver,
url_prefix: String,
}
#[derive(Debug, Clone)]
pub struct Assets {
inner: Arc<AssetsInner>,
}
fn warn_no_manifest(path: &Path) {
#[cfg(feature = "observe")]
tracing::warn!(
manifest = %path.display(),
"no Vite manifest; serving asset URLs as source paths. This is a debug \
build, so it starts anyway -- but nothing answers those paths until \
`arc dev` is running or `npx vite build` has been run."
);
#[cfg(not(feature = "observe"))]
eprintln!(
"warning: no Vite manifest at {}; serving asset URLs as source paths. \
This is a debug build, so it starts anyway -- but nothing answers \
those paths until `arc dev` is running or `npx vite build` has been \
run.",
path.display()
);
}
impl Assets {
#[must_use]
pub fn dev(config: &AssetsConfig) -> Self {
Assets {
inner: Arc::new(AssetsInner {
resolver: Resolver::Dev,
url_prefix: config.url_prefix(),
}),
}
}
pub fn from_manifest(config: &AssetsConfig) -> Result<Self, AssetsError> {
let path = config.manifest_path();
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(AssetsError::ManifestMissing { path });
}
Err(source) => return Err(AssetsError::ManifestUnreadable { path, source }),
};
let chunks: BTreeMap<String, Chunk> = serde_json::from_str(&raw)
.map_err(|source| AssetsError::ManifestMalformed { path, source })?;
Ok(Assets {
inner: Arc::new(AssetsInner {
resolver: Resolver::Manifest(chunks),
url_prefix: config.url_prefix(),
}),
})
}
pub fn detect(config: &AssetsConfig) -> Result<Self, AssetsError> {
let ipc = std::env::var(crate::config::VITE_IPC_ENV).is_ok_and(|value| !value.is_empty());
if ipc {
return Ok(Self::dev(config));
}
match Self::from_manifest(config) {
Ok(assets) => Ok(assets),
Err(AssetsError::ManifestMissing { path }) if cfg!(debug_assertions) => {
warn_no_manifest(&path);
Ok(Self::dev(config))
}
Err(other) => Err(other),
}
}
#[must_use]
pub fn is_dev(&self) -> bool {
matches!(self.inner.resolver, Resolver::Dev)
}
#[must_use]
pub fn resolve(&self, entry: &str) -> Option<EntryAssets> {
match &self.inner.resolver {
Resolver::Dev => Some(EntryAssets {
js: format!("/{}", entry.trim_start_matches('/')),
css: Vec::new(),
}),
Resolver::Manifest(chunks) => {
let chunk = chunks.get(entry)?;
Some(EntryAssets {
js: self.url(&chunk.file),
css: self.collect_css(chunks, entry),
})
}
}
}
fn collect_css(&self, chunks: &BTreeMap<String, Chunk>, entry: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let mut queue = vec![entry.to_string()];
while let Some(key) = queue.pop() {
if !seen.insert(key.clone()) {
continue;
}
let Some(chunk) = chunks.get(&key) else {
continue;
};
for css in &chunk.css {
let url = self.url(css);
if !out.contains(&url) {
out.push(url);
}
}
queue.extend(chunk.imports.iter().cloned());
}
out
}
fn url(&self, file: &str) -> String {
format!("{}/{}", self.inner.url_prefix, file.trim_start_matches('/'))
}
#[must_use]
pub fn head_tags(&self, entry: &str) -> String {
self.head_tags_with_nonce(entry, None)
}
#[must_use]
pub fn head_tags_with_nonce(&self, entry: &str, nonce: Option<&str>) -> String {
let Some(resolved) = self.resolve(entry) else {
return String::new();
};
style_tags(&resolved.css, nonce)
}
#[must_use]
pub fn body_tags(&self, entry: &str) -> String {
self.body_tags_with_nonce(entry, None)
}
#[must_use]
pub fn body_tags_with_nonce(&self, entry: &str, nonce: Option<&str>) -> String {
let Some(resolved) = self.resolve(entry) else {
return String::new();
};
script_tags(Some(&resolved.js), self.is_dev(), nonce)
}
}
fn nonce_attribute(nonce: Option<&str>) -> String {
nonce.map(|n| format!(" nonce=\"{n}\"")).unwrap_or_default()
}
pub(crate) fn style_tags(css: &[String], nonce: Option<&str>) -> String {
let attribute = nonce_attribute(nonce);
css.iter()
.map(|href| format!("<link{attribute} rel=\"stylesheet\" href=\"{href}\" />"))
.collect::<Vec<_>>()
.join("\n ")
}
pub(crate) fn script_tags(js: Option<&str>, dev: bool, nonce: Option<&str>) -> String {
let Some(js) = js else {
return String::new();
};
let attribute = nonce_attribute(nonce);
let script = format!("<script{attribute} type=\"module\" src=\"{js}\"></script>");
if dev {
format!("<script{attribute} type=\"module\" src=\"/@vite/client\"></script>\n {script}")
} else {
script
}
}
pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
pub const REVALIDATE_CACHE_CONTROL: &str = "no-cache";
fn cache_control_for(path: &str, immutable_prefix: &str) -> &'static str {
if path.starts_with(immutable_prefix) && looks_hashed(path) {
IMMUTABLE_CACHE_CONTROL
} else {
REVALIDATE_CACHE_CONTROL
}
}
fn looks_hashed(path: &str) -> bool {
let file = path.rsplit('/').next().unwrap_or_default();
let Some((stem, _extension)) = file.rsplit_once('.') else {
return false;
};
let Some((_name, hash)) = stem.rsplit_once('-') else {
return false;
};
hash.len() >= 8 && hash.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
#[derive(Clone, Debug)]
pub struct StaticFiles {
inner: tower_http::services::ServeDir,
immutable_prefix: Arc<str>,
}
impl StaticFiles {
#[must_use]
pub fn new(config: &AssetsConfig) -> Self {
StaticFiles {
inner: tower_http::services::ServeDir::new(config.public_path())
.append_index_html_on_directories(false),
immutable_prefix: Arc::from(format!("{}/", config.url_prefix())),
}
}
}
impl tower::Service<axum::extract::Request> for StaticFiles {
type Response = axum::response::Response;
type Error = std::convert::Infallible;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
<tower_http::services::ServeDir as tower::Service<axum::extract::Request>>::poll_ready(
&mut self.inner,
cx,
)
}
fn call(&mut self, request: axum::extract::Request) -> Self::Future {
use axum::response::IntoResponse as _;
let cache_control = cache_control_for(request.uri().path(), &self.immutable_prefix);
let future = tower::Service::call(&mut self.inner, request);
Box::pin(async move {
let mut response = match future.await {
Ok(response) => response.into_response(),
Err(never) => match never {},
};
if response.status().is_success() {
response.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static(cache_control),
);
}
Ok(response)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(json: &str) -> Assets {
Assets {
inner: Arc::new(AssetsInner {
resolver: Resolver::Manifest(serde_json::from_str(json).expect("manifest")),
url_prefix: "/build".to_string(),
}),
}
}
#[test]
fn the_default_config_matches_the_scaffold_layout() {
let config = AssetsConfig::new();
assert_eq!(config.public_path(), Path::new("public"));
assert_eq!(config.build_path(), Path::new("public").join("build"));
assert_eq!(config.url_prefix(), "/build");
assert!(
config
.manifest_path()
.ends_with(Path::new("build").join(".vite").join("manifest.json"))
);
}
#[test]
fn an_entry_resolves_to_its_hashed_file() {
let assets = manifest(
r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js","isEntry":true}}"#,
);
let resolved = assets.resolve("resources/js/app.tsx").expect("entry");
assert_eq!(resolved.js, "/build/assets/app-C7xk91Qa.js");
assert!(resolved.css.is_empty());
}
#[test]
fn css_is_collected_through_imports() {
let assets = manifest(
r#"{
"resources/js/app.tsx": {
"file": "assets/app-A1b2C3d4.js",
"imports": ["_shared-E5f6G7h8.js"],
"css": ["assets/app-I9j0K1l2.css"]
},
"_shared-E5f6G7h8.js": {
"file": "assets/shared-E5f6G7h8.js",
"css": ["assets/shared-M3n4O5p6.css"]
}
}"#,
);
let resolved = assets.resolve("resources/js/app.tsx").expect("entry");
assert_eq!(
resolved.css,
[
"/build/assets/app-I9j0K1l2.css",
"/build/assets/shared-M3n4O5p6.css"
]
);
}
#[test]
fn a_cyclic_manifest_does_not_hang() {
let assets = manifest(
r#"{
"a.js": {"file": "assets/a-A1b2C3d4.js", "imports": ["b.js"], "css": ["assets/a-Q7r8S9t0.css"]},
"b.js": {"file": "assets/b-U1v2W3x4.js", "imports": ["a.js"]}
}"#,
);
let resolved = assets.resolve("a.js").expect("entry");
assert_eq!(resolved.css, ["/build/assets/a-Q7r8S9t0.css"]);
}
#[test]
fn an_unknown_entry_does_not_resolve_in_production() {
let assets = manifest(r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js"}}"#);
assert!(assets.resolve("resources/js/missing.tsx").is_none());
assert_eq!(assets.body_tags("resources/js/missing.tsx"), "");
}
#[test]
fn dev_resolves_the_source_path_and_adds_the_hmr_client() {
let assets = Assets::dev(&AssetsConfig::new());
let resolved = assets.resolve("resources/js/app.tsx").expect("entry");
assert_eq!(resolved.js, "/resources/js/app.tsx");
assert!(resolved.css.is_empty(), "Vite injects styles over HMR");
assert!(assets.head_tags("resources/js/app.tsx").is_empty());
assert!(
assets
.body_tags("resources/js/app.tsx")
.contains("/@vite/client")
);
}
#[test]
fn production_tags_link_the_stylesheet_and_load_the_module() {
let assets = manifest(
r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js","css":["assets/app-Z9y8X7w6.css"]}}"#,
);
assert_eq!(
assets.head_tags("resources/js/app.tsx"),
"<link rel=\"stylesheet\" href=\"/build/assets/app-Z9y8X7w6.css\" />"
);
let body = assets.body_tags("resources/js/app.tsx");
assert_eq!(
body,
"<script type=\"module\" src=\"/build/assets/app-C7xk91Qa.js\"></script>"
);
assert!(!body.contains("@vite/client"), "not a dev build");
}
#[test]
fn the_nonce_lands_on_every_tag_a_manifest_entry_produces() {
let assets = manifest(
r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js","css":["assets/app-Z9y8X7w6.css"]}}"#,
);
assert_eq!(
assets.head_tags_with_nonce("resources/js/app.tsx", Some("r4nd0m")),
"<link nonce=\"r4nd0m\" rel=\"stylesheet\" href=\"/build/assets/app-Z9y8X7w6.css\" />"
);
assert_eq!(
assets.body_tags_with_nonce("resources/js/app.tsx", Some("r4nd0m")),
"<script nonce=\"r4nd0m\" type=\"module\" src=\"/build/assets/app-C7xk91Qa.js\"></script>"
);
}
#[test]
fn the_hmr_client_carries_the_nonce_too() {
let assets = Assets::dev(&AssetsConfig::new());
let body = assets.body_tags_with_nonce("resources/js/app.tsx", Some("r4nd0m"));
assert_eq!(body.matches("nonce=\"r4nd0m\"").count(), 2, "{body}");
}
#[test]
fn tags_without_a_nonce_are_byte_for_byte_what_they_always_were() {
let assets = manifest(
r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js","css":["assets/app-Z9y8X7w6.css"]}}"#,
);
assert_eq!(
assets.head_tags("resources/js/app.tsx"),
assets.head_tags_with_nonce("resources/js/app.tsx", None)
);
assert!(!assets.body_tags("resources/js/app.tsx").contains("nonce"));
}
#[test]
fn a_missing_manifest_is_a_startup_error_not_a_silent_dev_fallback() {
let config = AssetsConfig::new().public_dir("this-directory-does-not-exist");
let error = Assets::from_manifest(&config).expect_err("no manifest");
assert!(matches!(error, AssetsError::ManifestMissing { .. }));
}
#[test]
fn detect_falls_back_to_dev_when_a_debug_build_has_no_manifest() {
let config = AssetsConfig::new().public_dir("this-directory-does-not-exist");
let assets = Assets::detect(&config).expect("a debug build starts without a manifest");
assert!(assets.is_dev());
}
#[test]
fn detect_prefers_a_manifest_that_exists_over_the_debug_fallback() {
let dir = tempfile::tempdir().expect("tempdir");
let vite = dir.path().join("build").join(".vite");
std::fs::create_dir_all(&vite).expect("mkdir");
std::fs::write(
vite.join("manifest.json"),
r#"{"resources/js/app.tsx":{"file":"assets/app-C7xk91Qa.js"}}"#,
)
.expect("write manifest");
let config = AssetsConfig::new().public_dir(dir.path());
let assets = Assets::detect(&config).expect("the manifest is readable");
assert!(!assets.is_dev(), "a present manifest wins over the profile");
assert_eq!(
assets.resolve("resources/js/app.tsx").expect("entry").js,
"/build/assets/app-C7xk91Qa.js"
);
}
#[test]
fn detect_refuses_a_manifest_it_cannot_parse_even_in_a_debug_build() {
let dir = tempfile::tempdir().expect("tempdir");
let vite = dir.path().join("build").join(".vite");
std::fs::create_dir_all(&vite).expect("mkdir");
std::fs::write(vite.join("manifest.json"), "{ not json").expect("write manifest");
let config = AssetsConfig::new().public_dir(dir.path());
let error = Assets::detect(&config).expect_err("a broken build is not a dev build");
assert!(matches!(error, AssetsError::ManifestMalformed { .. }));
}
#[test]
fn hashed_files_under_the_build_prefix_are_immutable() {
assert_eq!(
cache_control_for("/build/assets/app-C7xk91Qa.js", "/build/"),
IMMUTABLE_CACHE_CONTROL
);
}
#[test]
fn unhashed_files_revalidate_even_under_the_build_prefix() {
assert_eq!(
cache_control_for("/build/.vite/manifest.json", "/build/"),
REVALIDATE_CACHE_CONTROL
);
}
#[test]
fn files_outside_the_build_prefix_revalidate_however_they_are_named() {
assert_eq!(
cache_control_for("/robots.txt", "/build/"),
REVALIDATE_CACHE_CONTROL
);
assert_eq!(
cache_control_for("/images/logo-C7xk91Qa.png", "/build/"),
REVALIDATE_CACHE_CONTROL
);
}
#[test]
fn short_suffixes_are_not_mistaken_for_hashes() {
assert!(!looks_hashed("/build/assets/app-v2.js"));
assert!(!looks_hashed("/build/assets/app.js"));
assert!(!looks_hashed("/build/assets/appC7xk91Qa"));
assert!(looks_hashed("/build/assets/app-C7xk91Qa.js"));
}
}