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
@@ -6,8 +6,8 @@ name = "project_name"
# `build` & `serve` output path
out_dir = "dist"
# The static resource path
asset_dir = "public"
# Static files copied verbatim into the built app (set to enable, e.g. "public")
public_dir = "public"
[web.app]
+3 -1
View File
@@ -60,9 +60,11 @@ name = "project-name"
# Currently supported platforms: web, desktop
default_platform = "web"
# Optional: enable copying from a static directory (e.g. "public")
public_dir = "public"
[web.app]
title = "Hello"
[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 builder;
mod context;
mod manifest;
mod patch;
mod pre_render;
mod request;
@@ -19,6 +20,7 @@ mod tools;
pub(crate) use assets::*;
pub(crate) use builder::*;
pub(crate) use context::*;
pub(crate) use manifest::*;
pub(crate) use patch::*;
pub(crate) use pre_render::*;
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>
use crate::{
AndroidTools, BuildContext, BuildId, BundleFormat, DioxusConfig, Error, LinkAction,
LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen,
WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
AndroidTools, AppManifest, BuildContext, BuildId, BundleFormat, DioxusConfig, Error,
LinkAction, LinkerFlavor, Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc,
WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR,
};
use anyhow::{bail, Context};
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 itertools::Itertools;
use krates::{cm::TargetKind, NodeId};
use manganis::AssetOptions;
use manganis_core::AssetVariant;
use manganis::{AssetOptions, BundledAsset};
use manganis_core::{AssetOptionsBuilder, AssetVariant};
use rayon::prelude::{IntoParallelRefIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
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
/// hashed asset location.
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
if !self.skip_assets {
ctx.status_extracting_assets();
manifest = super::assets::extract_assets_from_file(exe).await?;
if self.skip_assets {
return Ok(AssetManifest::default());
}
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)
@@ -1494,14 +1521,13 @@ impl BuildRequest {
.map(|a| asset_dir.join(a.bundled_path()))
.collect();
// The CLI creates a .version file in the asset dir to keep track of what version of the optimizer
// the asset was processed. If that version doesn't match the CLI version, we need to re-optimize
// all assets.
let version_file = self.asset_optimizer_version_file();
let clear_cache = std::fs::read_to_string(&version_file)
.ok()
.filter(|s| s == crate::VERSION.as_str())
.is_none();
// The CLI creates a .manifest.json file in the asset dir to keep track of the assets and
// other build metadata. If we can't parse this file (or the CLI version changed), then we
// want to re-copy all the assets rather than trying to do an incremental update.
let clear_cache = self
.load_manifest()
.map(|manifest| manifest.cli_version != crate::VERSION.as_str())
.unwrap_or(true);
if clear_cache {
keep_bundled_output_paths.clear();
}
@@ -1511,20 +1537,6 @@ impl BuildRequest {
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
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
std::fs::write(self.asset_optimizer_version_file(), crate::VERSION.as_str())?;
self.write_app_manifest(assets).await?;
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 {
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 {
@@ -3416,20 +3438,6 @@ impl BuildRequest {
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/blah.app
/// target/dx/bundle/app/blah.exe
@@ -4422,12 +4430,13 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
.join("main")
.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 :)
BundleFormat::Web
| BundleFormat::Ios
| BundleFormat::Windows
| BundleFormat::Linux
| BundleFormat::Server => self.root_dir().join("assets"),
BundleFormat::Web | BundleFormat::Ios | BundleFormat::Windows | BundleFormat::Linux => {
self.root_dir().join("assets")
}
}
}
@@ -4482,9 +4491,21 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{
.with_extension("wasm")
}
/// Get the path to the asset optimizer version file
pub(crate) fn asset_optimizer_version_file(&self) -> PathBuf {
self.platform_dir().join(".cli-version")
/// Get the path to the app manifest file
///
/// 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.
@@ -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
pub(crate) fn base_path(&self) -> Option<&str> {
self.base_path
@@ -5289,4 +5338,17 @@ We checked the folders:
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)]
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)]
pub(crate) tailwind_input: Option<PathBuf>,
@@ -43,3 +47,19 @@ pub(crate) struct ApplicationConfig {
#[serde(default)]
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 {
application: ApplicationConfig {
out_dir: None,
public_dir: Some("public".into()),
tailwind_input: None,
tailwind_output: 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 ext == "rs" {
// And grabout the contents
@@ -1028,6 +1034,15 @@ impl AppServer {
let mut watched_crates = self.local_dependencies(crate_package);
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
for krate_root in watched_crates {
// Build the ignore builder for this crate, but with our default ignore list as well