Support public dir (#4783)

* Add optional static_dir support

- add optional application.static_dir field defaulting to none
- copy configured static directory into web output during build/serve
- document config knob and add unit tests
- force full rebuild when static_dir contents change
- sync removes stale outputs and logs copy activity
- track static manifest next to platform dir

Tests: cargo fmt

* default to public dir to match vite

* static_dir -> public_dir

* wip

* deser as none...

* wip... use .manifest.json

* implement dumb form of asset manifest generation

* implement manifest using asset system directly

* cleanup

* dont create extra asset dir for server

---------

Co-authored-by: Evan Almloff <evanalmloff@gmail.com>
Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
authored and GitHub committed 2025-10-20 17:03:34 -04:00
1 parent 2f6be083ce
commit b3966c3509
10 files changed
+245 -78

No files matched your search

+2 -2
View File
@@ -3,7 +3,7 @@ use manganis::AssetOptions;
use manganis_core::BundledAsset; use manganis_core::BundledAsset;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@@ -25,7 +25,7 @@ pub use hash::add_hash_to_asset;
#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)]
pub struct AssetManifest { pub struct AssetManifest {
/// Map of bundled asset name to the asset itself /// Map of bundled asset name to the asset itself
assets: HashMap<PathBuf, HashSet<BundledAsset>>, assets: BTreeMap<PathBuf, HashSet<BundledAsset>>,
} }
impl AssetManifest { impl AssetManifest {
+2 -2
View File
@@ -6,8 +6,8 @@ name = "project_name"
# `build` & `serve` output path # `build` & `serve` output path
out_dir = "dist" out_dir = "dist"
# The static resource path # Static files copied verbatim into the built app (set to enable, e.g. "public")
asset_dir = "public" public_dir = "public"
[web.app] [web.app]
+3 -1
View File
@@ -60,9 +60,11 @@ name = "project-name"
# Currently supported platforms: web, desktop # Currently supported platforms: web, desktop
default_platform = "web" default_platform = "web"
# Optional: enable copying from a static directory (e.g. "public")
public_dir = "public"
[web.app] [web.app]
title = "Hello" title = "Hello"
[web.resource.dev] [web.resource.dev]
``` ```
+24
View File
@@ -0,0 +1,24 @@
//! The build manifest for `dx` applications, containing metadata about the build including
//! the CLI version, Rust version, and all bundled assets.
//!
//! We eventually plan to use this manifest to support tighter integration with deployment platforms
//! and CDNs.
//!
//! This manifest contains the list of assets, rust version, and cli version used to build the app.
//! Eventually, we might want to expand this to include more metadata about the build, including
//! build time, target platform, etc.
use dioxus_cli_opt::AssetManifest;
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize)]
pub struct AppManifest {
/// Stable since 0.7.0
pub cli_version: String,
/// Stable since 0.7.0
pub rust_version: String,
/// Stable since 0.7.0
pub assets: AssetManifest,
}
+2
View File
@@ -11,6 +11,7 @@
mod assets; mod assets;
mod builder; mod builder;
mod context; mod context;
mod manifest;
mod patch; mod patch;
mod pre_render; mod pre_render;
mod request; mod request;
@@ -19,6 +20,7 @@ mod tools;
pub(crate) use assets::*; pub(crate) use assets::*;
pub(crate) use builder::*; pub(crate) use builder::*;
pub(crate) use context::*; pub(crate) use context::*;
pub(crate) use manifest::*;
pub(crate) use patch::*; pub(crate) use patch::*;
pub(crate) use pre_render::*; pub(crate) use pre_render::*;
pub(crate) use request::*; pub(crate) use request::*;
+119 -57
View File
@@ -320,9 +320,9 @@
//! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs> //! - xbuild: <https://github.com/rust-mobile/xbuild/blob/master/xbuild/src/command/build.rs>
use crate::{ use crate::{
AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction, AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error,
LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, LinkAction, LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc,
WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
}; };
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use cargo_metadata::diagnostic::Diagnostic; use cargo_metadata::diagnostic::Diagnostic;
@@ -333,8 +333,8 @@ use dioxus_cli_config::{APP_TITLE_ENV, ASSET_ROOT_ENV};
use dioxus_cli_opt::{process_file_to, AssetManifest}; use dioxus_cli_opt::{process_file_to, AssetManifest};
use itertools::Itertools; use itertools::Itertools;
use krates::{cm::TargetKind, NodeId}; use krates::{cm::TargetKind, NodeId};
use manganis::AssetOptions; use manganis::{AssetOptions, BundledAsset};
use manganis_core::AssetVariant; use manganis_core::{AssetOptionsBuilder, AssetVariant};
use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{borrow::Cow, ffi::OsString}; use std::{borrow::Cow, ffi::OsString};
@@ -1295,13 +1295,40 @@ impl BuildRequest {
/// Collect the assets from the final executable and modify the binary in place to point to the right /// Collect the assets from the final executable and modify the binary in place to point to the right
/// hashed asset location. /// hashed asset location.
async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result<AssetManifest> { async fn collect_assets(&self, exe: &Path, ctx: &BuildContext) -> Result<AssetManifest> {
// walk every file in the incremental cache dir, reading and inserting items into the manifest.
let mut manifest = AssetManifest::default();
// And then add from the exe directly, just in case it's LTO compiled and has no incremental cache // And then add from the exe directly, just in case it's LTO compiled and has no incremental cache
if !self.skip_assets { if self.skip_assets {
ctx.status_extracting_assets(); return Ok(AssetManifest::default());
manifest = super::assets::extract_assets_from_file(exe).await?; }
ctx.status_extracting_assets();
let mut manifest = super::assets::extract_assets_from_file(exe).await?;
// If the user has a public dir, we submit all the entries there as assets too
//
// These don't receive a hash in their filename, since they're user-provided static assets
// We only do this for web builds
if matches!(self.bundle, BundleFormat::Web)
&& matches!(ctx.mode, BuildMode::Base { .. } | BuildMode::Fat)
{
if let Some(dir) = self.user_public_dir() {
for entry in walkdir::WalkDir::new(&dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let from = entry.path().to_path_buf();
let relative_path = from.strip_prefix(&dir).unwrap();
let to = format!("../{}", relative_path.display());
manifest.insert_asset(BundledAsset::new(
from.to_string_lossy().as_ref(),
to.as_str(),
AssetOptionsBuilder::new()
.with_hash_suffix(false)
.into_asset_options(),
));
}
}
} }
Ok(manifest) Ok(manifest)
@@ -1494,14 +1521,13 @@ impl BuildRequest {
.map(|a| asset_dir.join(a.bundled_path())) .map(|a| asset_dir.join(a.bundled_path()))
.collect(); .collect();
// The CLI creates a .version file in the asset dir to keep track of what version of the optimizer // The CLI creates a .manifest.json file in the asset dir to keep track of the assets and
// the asset was processed. If that version doesn't match the CLI version, we need to re-optimize // other build metadata. If we can't parse this file (or the CLI version changed), then we
// all assets. // want to re-copy all the assets rather than trying to do an incremental update.
let version_file = self.asset_optimizer_version_file(); let clear_cache = self
let clear_cache = std::fs::read_to_string(&version_file) .load_manifest()
.ok() .map(|manifest| manifest.cli_version != crate::VERSION.as_str())
.filter(|s| s == crate::VERSION.as_str()) .unwrap_or(true);
.is_none();
if clear_cache { if clear_cache {
keep_bundled_output_paths.clear(); keep_bundled_output_paths.clear();
} }
@@ -1511,20 +1537,6 @@ impl BuildRequest {
keep_bundled_output_paths keep_bundled_output_paths
); );
// use walkdir::WalkDir;
// for item in WalkDir::new(&asset_dir).into_iter().flatten() {
// // If this asset is in the manifest, we don't need to remove it
// let canonicalized = dunce::canonicalize(item.path())?;
// if !keep_bundled_output_paths.contains(canonicalized.as_path()) {
// // Remove empty dirs, remove files not in the manifest
// if item.file_type().is_dir() && item.path().read_dir()?.next().is_none() {
// std::fs::remove_dir(item.path())?;
// } else {
// std::fs::remove_file(item.path())?;
// }
// }
// }
// todo(jon): we also want to eventually include options for each asset's optimization and compression, which we currently aren't // todo(jon): we also want to eventually include options for each asset's optimization and compression, which we currently aren't
let mut assets_to_transfer = vec![]; let mut assets_to_transfer = vec![];
@@ -1589,7 +1601,7 @@ impl BuildRequest {
} }
// Write the version file so we know what version of the optimizer we used // Write the version file so we know what version of the optimizer we used
std::fs::write(self.asset_optimizer_version_file(), crate::VERSION.as_str())?; self.write_app_manifest(assets).await?;
Ok(()) Ok(())
} }
@@ -3116,8 +3128,18 @@ impl BuildRequest {
} }
} }
/// Create a workdir for the given platform
/// This can be used as a temporary directory for the build, but in an observable way such that
/// you can see the files in the directory via `target`
///
/// target/dx/build/app/web/
/// target/dx/build/app/web/public/
/// target/dx/build/app/web/server.exe
fn platform_dir(&self) -> PathBuf { fn platform_dir(&self) -> PathBuf {
self.build_dir(self.bundle, self.release) self.internal_out_dir()
.join(&self.main_target)
.join(if self.release { "release" } else { "debug" })
.join(self.bundle.build_folder_name())
} }
fn platform_exe_name(&self) -> String { fn platform_exe_name(&self) -> String {
@@ -3416,20 +3438,6 @@ impl BuildRequest {
dir dir
} }
/// Create a workdir for the given platform
/// This can be used as a temporary directory for the build, but in an observable way such that
/// you can see the files in the directory via `target`
///
/// target/dx/build/app/web/
/// target/dx/build/app/web/public/
/// target/dx/build/app/web/server.exe
pub(crate) fn build_dir(&self, bundle: BundleFormat, release: bool) -> PathBuf {
self.internal_out_dir()
.join(&self.main_target)
.join(if release { "release" } else { "debug" })
.join(bundle.build_folder_name())
}
/// target/dx/bundle/app/ /// target/dx/bundle/app/
/// target/dx/bundle/app/blah.app /// target/dx/bundle/app/blah.app
/// target/dx/bundle/app/blah.exe /// target/dx/bundle/app/blah.exe
@@ -4422,12 +4430,13 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
.join("main") .join("main")
.join("assets"), .join("assets"),
// We put assets in public/assets for server apps
BundleFormat::Server => self.root_dir().join("public").join("assets"),
// everyone else is soooo normal, just app/assets :) // everyone else is soooo normal, just app/assets :)
BundleFormat::Web BundleFormat::Web | BundleFormat::Ios | BundleFormat::Windows | BundleFormat::Linux => {
| BundleFormat::Ios self.root_dir().join("assets")
| BundleFormat::Windows }
| BundleFormat::Linux
| BundleFormat::Server => self.root_dir().join("assets"),
} }
} }
@@ -4482,9 +4491,21 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
.with_extension("wasm") .with_extension("wasm")
} }
/// Get the path to the asset optimizer version file /// Get the path to the app manifest file
pub(crate) fn asset_optimizer_version_file(&self) -> PathBuf { ///
self.platform_dir().join(".cli-version") /// This includes metadata about the build such as the bundle format, target triple, features, etc.
/// Manifests are only written by the `PRIMARY` build.
pub(crate) fn app_manifest(&self) -> PathBuf {
self.platform_dir().join(".manifest.json")
}
pub(crate) fn load_manifest(&self) -> Result<AppManifest> {
let manifest_path = self.app_manifest();
let manifest_data = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read manifest at {:?}", &manifest_path))?;
let manifest: AppManifest = serde_json::from_str(&manifest_data)
.with_context(|| format!("Failed to parse manifest at {:?}", &manifest_path))?;
Ok(manifest)
} }
/// Check for tooling that might be required for this build. /// Check for tooling that might be required for this build.
@@ -4882,6 +4903,34 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
} }
} }
/// Resolve the configured public directory relative to the crate, if any.
pub(crate) fn user_public_dir(&self) -> Option<PathBuf> {
let path = self.config.application.public_dir.as_ref()?;
if path.as_os_str().is_empty() {
return None;
}
Some(if path.is_absolute() {
path.clone()
} else {
self.crate_dir().join(path)
})
}
pub(crate) fn path_is_in_public_dir(&self, path: &Path) -> bool {
let Some(static_dir) = self.user_public_dir() else {
return false;
};
// Canonicalize when possible so we work with editors that use tmp files
let canonical_static =
dunce::canonicalize(&static_dir).unwrap_or_else(|_| static_dir.clone());
let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canonical_path.starts_with(&canonical_static)
}
/// Get the base path from the config or None if this is not a web or server build /// Get the base path from the config or None if this is not a web or server build
pub(crate) fn base_path(&self) -> Option<&str> { pub(crate) fn base_path(&self) -> Option<&str> {
self.base_path self.base_path
@@ -5289,4 +5338,17 @@ We checked the folders:
TEAM_IDENTIFIER = mbfile.team_identifier[0], TEAM_IDENTIFIER = mbfile.team_identifier[0],
)) ))
} }
async fn write_app_manifest(&self, assets: &AssetManifest) -> Result<()> {
let manifest = AppManifest {
assets: assets.clone(),
cli_version: crate::VERSION.to_string(),
rust_version: self.workspace.rustc_version.clone(),
};
let manifest_path = self.app_manifest();
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
Ok(())
}
} }
+20
View File
@@ -6,6 +6,10 @@ pub(crate) struct ApplicationConfig {
#[serde(default)] #[serde(default)]
pub(crate) out_dir: Option<PathBuf>, pub(crate) out_dir: Option<PathBuf>,
#[serde(default = "public_dir_default")]
#[serde(deserialize_with = "empty_string_is_none")]
pub(crate) public_dir: Option<PathBuf>,
#[serde(default)] #[serde(default)]
pub(crate) tailwind_input: Option<PathBuf>, pub(crate) tailwind_input: Option<PathBuf>,
@@ -43,3 +47,19 @@ pub(crate) struct ApplicationConfig {
#[serde(default)] #[serde(default)]
pub(crate) android_min_sdk_version: Option<u32>, pub(crate) android_min_sdk_version: Option<u32>,
} }
fn public_dir_default() -> Option<PathBuf> {
Some("public".into())
}
fn empty_string_is_none<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(s) if s.is_empty() => Ok(None),
Some(s) => Ok(Some(PathBuf::from(s))),
None => Ok(None),
}
}
+40
View File
@@ -17,6 +17,7 @@ impl Default for DioxusConfig {
Self { Self {
application: ApplicationConfig { application: ApplicationConfig {
out_dir: None, out_dir: None,
public_dir: Some("public".into()),
tailwind_input: None, tailwind_input: None,
tailwind_output: None, tailwind_output: None,
ios_info_plist: None, ios_info_plist: None,
@@ -55,3 +56,42 @@ impl Default for DioxusConfig {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_dir_defaults_to_public() {
let config = DioxusConfig::default();
assert_eq!(
config.application.public_dir,
Some(std::path::PathBuf::from("public"))
);
}
#[test]
fn static_dir_can_be_overridden() {
let source = r#"
[application]
public_dir = "public2"
"#;
let config: DioxusConfig = toml::from_str(source).expect("parse config");
assert_eq!(
config.application.public_dir.as_deref(),
Some(std::path::Path::new("public2"))
);
}
#[test]
fn static_dir_can_be_disabled() {
let source = r#"
[application]
public_dir = ""
"#;
let config: DioxusConfig = toml::from_str(source).expect("parse config");
assert_eq!(config.application.public_dir.as_deref(), None);
}
}
+15
View File
@@ -379,6 +379,12 @@ impl AppServer {
} }
} }
// If it's in the public dir, we sync it and trigger a full rebuild
if self.client.build.path_is_in_public_dir(path) {
needs_full_rebuild = true;
continue;
}
// If it's a rust file, we want to hotreload it using the filemap // If it's a rust file, we want to hotreload it using the filemap
if ext == "rs" { if ext == "rs" {
// And grabout the contents // And grabout the contents
@@ -1028,6 +1034,15 @@ impl AppServer {
let mut watched_crates = self.local_dependencies(crate_package); let mut watched_crates = self.local_dependencies(crate_package);
watched_crates.push(crate_dir); watched_crates.push(crate_dir);
// Watch the `public` directory if this is the client crate
if self.client.build.crate_package == crate_package {
if let Some(public_dir) = self.client.build.user_public_dir() {
if public_dir.exists() {
watched_paths.push(public_dir);
}
}
}
// Now, watch all the folders in the crates, but respecting their respective ignore files // Now, watch all the folders in the crates, but respecting their respective ignore files
for krate_root in watched_crates { for krate_root in watched_crates {
// Build the ignore builder for this crate, but with our default ignore list as well // Build the ignore builder for this crate, but with our default ignore list as well
+18 -16
View File
@@ -222,32 +222,34 @@ const MAX_STR_SIZE: usize = 256;
/// A string that is stored in a constant sized buffer that can be serialized and deserialized at compile time /// A string that is stored in a constant sized buffer that can be serialized and deserialized at compile time
#[derive(Eq, PartialEq, PartialOrd, Clone, Copy, Hash)] #[derive(Eq, PartialEq, PartialOrd, Clone, Copy, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConstStr { pub struct ConstStr {
#[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
bytes: [u8; MAX_STR_SIZE], bytes: [u8; MAX_STR_SIZE],
len: u32, len: u32,
} }
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
mod serde_bytes { mod serde_bytes {
use serde::{Deserialize, Serializer}; use serde::{Deserialize, Serialize, Serializer};
pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> use crate::ConstStr;
where
S: Serializer, impl Serialize for ConstStr {
{ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
serializer.serialize_bytes(bytes) where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
} }
pub fn deserialize<'de, D>(deserializer: D) -> Result<[u8; super::MAX_STR_SIZE], D::Error> impl<'de> Deserialize<'de> for ConstStr {
where fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
D: serde::Deserializer<'de>, where
{ D: serde::Deserializer<'de>,
let bytes = Vec::<u8>::deserialize(deserializer)?; {
bytes let s = String::deserialize(deserializer)?;
.try_into() Ok(ConstStr::new(&s))
.map_err(|_| serde::de::Error::custom("Failed to convert bytes to a fixed size array")) }
} }
} }