Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,321 changes: 685 additions & 636 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ slotmap = { version = "1.0.7", features = ["serde"] }
futures = "0.3.32"
futures-channel = "0.3.32"
futures-util = { version = "0.3.32", default-features = false }
rust-embed = { version = "8", features = ["interpolate-folder-path"] }
rustc-hash = "2.1.1"
wasm-bindgen = "0.2.121"
wasm-bindgen-futures = "0.4.71"
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/build/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ pub(crate) struct BuildRequest {
pub(crate) session_cache_dir: PathBuf,
pub(crate) raw_json_diagnostics: bool,
pub(crate) windows_subsystem: Option<String>,
/// Embed public assets into the server binary via rust-embed
pub(crate) embed_assets: bool,
/// Path to the public directory to embed (set from client's root_dir)
pub(crate) embed_dir: Option<PathBuf>,
}

/// dx can produce different "modes" of a build. A "regular" build is a "base" build. The Fat and Thin
Expand Down Expand Up @@ -921,6 +925,8 @@ impl BuildRequest {
apple_team_id: args.apple_team_id.clone(),
raw_json_diagnostics: args.raw_json_diagnostics,
windows_subsystem: args.windows_subsystem.clone(),
embed_assets: false,
embed_dir: None,
})
}

Expand Down Expand Up @@ -1927,6 +1933,11 @@ impl BuildRequest {
));
}

// Set DIOXUS_EMBED_DIR so rust-embed picks up the public assets at compile time
if let Some(ref embed_dir) = self.embed_dir {
env_vars.push(("DIOXUS_EMBED_DIR".into(), embed_dir.as_os_str().into()));
}

// Assemble the rustflags by peering into the `.cargo/config.toml` file
let rust_flags = self.rustflags.clone();

Expand Down
30 changes: 28 additions & 2 deletions packages/cli/src/cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ pub struct BuildArgs {
#[clap(long)]
pub(crate) fat_binary: bool,

/// Embed all public assets into the server binary using rust-embed [default: false]
///
/// Produces a single self-contained server executable with no need for a separate public/ directory.
/// Only applies to fullstack builds. Forces sequential build (client must build first).
#[clap(long)]
pub(crate) embed: bool,

/// This flag only applies to fullstack builds. By default fullstack builds will run the server
/// and client builds in parallel. This flag will force the build to run the server build first, then the client build. [default: false]
///
Expand All @@ -47,8 +54,10 @@ pub struct BuildArgs {

impl BuildArgs {
pub(crate) fn force_sequential_build(&self) -> bool {
self.force_sequential
.unwrap_or_else(|| std::env::var("CI").is_ok())
self.embed
|| self
.force_sequential
.unwrap_or_else(|| std::env::var("CI").is_ok())
}
}

Expand All @@ -57,6 +66,7 @@ impl Anonymized for BuildArgs {
json! {{
"fullstack": self.fullstack,
"ssg": self.ssg,
"embed": self.embed,
"build_arguments": self.build_arguments.anonymized(),
}}
}
Expand Down Expand Up @@ -143,6 +153,22 @@ impl CommandWithPlatformOverrides<BuildArgs> {
}
}

// Validate and wire up --embed: requires fullstack, sets embed_dir on server request
if self.shared.embed {
match server.as_mut() {
Some(server_req) => {
server_req.embed_assets = true;
server_req.embed_dir = Some(client.root_dir());
server_req.features.push("embed".into());
}
None => {
anyhow::bail!(
"--embed requires a fullstack build (use --fullstack or enable the fullstack feature)"
);
}
}
}

Ok(BuildTargets { client, server })
}

Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/test_harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,26 @@ async fn test_harnesses() {
assert_eq!(t.client.features.iter().map(|s| s.as_str()).collect::<HashSet<_>>(), ["dioxus/web", "other"].into_iter().collect::<HashSet<_>>());
assert!(t.server.is_none());
}),
TestHarnessBuilder::new("harness-fullstack-embed")
.deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#)
.fetr(r#"web=["dioxus/web"]"#)
.fetr(r#"server=["dioxus/server"]"#)
.asrt(r#"dx build --embed"#, |targets| async move {
let t = targets.unwrap();
assert_eq!(t.client.bundle, BundleFormat::Web);
let client_root = t.client.root_dir();
let server = t.server.unwrap();
assert_eq!(server.bundle, BundleFormat::Server);
assert!(server.embed_assets, "server should have embed_assets set");
assert!(server.embed_dir.is_some(), "server should have embed_dir set");
assert!(server.features.contains(&"embed".to_string()), "server should have embed feature");
assert_eq!(server.embed_dir.unwrap(), client_root, "embed_dir should match client root_dir");
}),
TestHarnessBuilder::new("harness-web-embed-no-fullstack")
.deps(r#"dioxus = { workspace = true, features = ["web"] }"#)
.asrt(r#"dx build --embed"#, |targets| async move {
assert!(targets.is_err(), "--embed without fullstack should fail");
}),
])
.await;
}
Expand Down
1 change: 1 addition & 0 deletions packages/dioxus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ server = [
"ssr",
"dioxus-liveview?/axum",
]
embed = ["dioxus-server?/embed"]

# This feature just disables the no-renderer-enabled warning
third-party-renderer = []
Expand Down
3 changes: 3 additions & 0 deletions packages/fullstack-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ chrono = { workspace = true }
rustc-hash = { workspace = true }
lru = { workspace = true }
walkdir = { workspace = true }
rust-embed = { workspace = true, optional = true }
mime_guess = { version = "2", optional = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { workspace = true, features = ["rt", "sync", "macros"], optional = true }
Expand All @@ -99,6 +101,7 @@ rustls = ["dep:rustls", "dep:hyper-rustls"]
axum-no-default = []
rkyv = ["dep:rkyv"]
server = []
embed = ["dep:rust-embed", "dep:mime_guess"]

[package.metadata.docs.rs]
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
Expand Down
12 changes: 12 additions & 0 deletions packages/fullstack-server/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
fn main() {
// When the `embed` feature is enabled, rust-embed's derive macro needs DIOXUS_EMBED_DIR
// to point to a folder. The CLI sets this to the client's public output directory during
// `dx build --embed`. For regular development (clippy, cargo check --all-features, IDE),
// provide an empty fallback so the derive compiles with zero embedded assets.
if cfg!(feature = "embed") && std::env::var("DIOXUS_EMBED_DIR").is_err() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let fallback = format!("{out_dir}/empty_embed");
std::fs::create_dir_all(&fallback).unwrap();
println!("cargo:rustc-env=DIOXUS_EMBED_DIR={fallback}");
}
}
48 changes: 30 additions & 18 deletions packages/fullstack-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,7 @@ impl ServeConfig {
///
/// To provide an alternate `index.html`, you can use `with_index_html` method instead.
pub fn new() -> Self {
let index = if let Some(public_path) = crate::public_path() {
let index_html_path = public_path.join("index.html");

if index_html_path.exists() {
let index_html = std::fs::read_to_string(index_html_path)
.expect("Failed to read index.html from public directory");

IndexHtml::new(&index_html, "main")
.expect("Failed to parse index.html from public directory")
} else {
IndexHtml::ssr_only()
}
} else {
tracing::warn!(
"Cannot identify public directory, using default index.html. If you need client-side scripts (like JS + WASM), please provide an explicit public directory."
);
IndexHtml::ssr_only()
};
let index = Self::load_index_html();

Self {
index,
Expand All @@ -93,6 +76,35 @@ impl ServeConfig {
}
}

/// Load index.html from the appropriate source.
///
/// When the `embed` feature is active, reads from the embedded assets first.
/// Otherwise, looks for it on the filesystem next to the executable.
fn load_index_html() -> IndexHtml {
// When assets are embedded, read index.html from the bundle
#[cfg(feature = "embed")]
if let Some(index_html) = crate::embedded::embedded_index_html() {
return IndexHtml::new(&index_html, "main")
.expect("Failed to parse embedded index.html");
}

if let Some(public_path) = crate::public_path() {
let index_html_path = public_path.join("index.html");
if index_html_path.exists() {
let index_html = std::fs::read_to_string(index_html_path)
.expect("Failed to read index.html from public directory");
return IndexHtml::new(&index_html, "main")
.expect("Failed to parse index.html from public directory");
}
} else {
tracing::warn!(
"Cannot identify public directory, using default index.html. If you need client-side scripts (like JS + WASM), please provide an explicit public directory."
);
}

IndexHtml::ssr_only()
}

/// Enable incremental static generation. Incremental static generation caches the
/// rendered html in memory and/or the file system. It can be used to improve performance of heavy routes.
///
Expand Down
93 changes: 93 additions & 0 deletions packages/fullstack-server/src/embedded.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
use axum::{
Router,
body::Body,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE};

use rust_embed::RustEmbed;

use crate::server::file_name_looks_immutable;

#[derive(RustEmbed)]
#[folder = "$DIOXUS_EMBED_DIR"]
struct PublicAssets;

/// Read the embedded index.html contents, if present.
pub(crate) fn embedded_index_html() -> Option<String> {
let file = PublicAssets::get("index.html")?;
String::from_utf8(file.data.into_owned()).ok()
}

pub(crate) fn serve_embedded_assets<S>(mut router: Router<S>) -> Router<S>
where
S: Send + Sync + Clone + 'static,
{
for file_path in PublicAssets::iter() {
let path = file_path.to_string();

// Don't serve index.html — SSR generates it
if path == "index.html" {
continue;
}

let route = format!("/{path}");
let immutable = file_name_looks_immutable(&route);

// Check if a brotli-compressed variant exists
let has_br = PublicAssets::get(&format!("{path}.br")).is_some();

// Don't register the .br files as their own routes
if path.ends_with(".br") {
continue;
}

let mime = mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string();

router = router.route(
&route,
get(move |headers: http::HeaderMap| async move {
// Serve brotli variant if client supports it
let accepts_br = headers
.get(http::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("br"));

let (body, is_br) = if has_br && accepts_br {
match PublicAssets::get(&format!("{path}.br")) {
Some(file) => (file.data.into_owned(), true),
None => match PublicAssets::get(&path) {
Some(file) => (file.data.into_owned(), false),
None => return StatusCode::NOT_FOUND.into_response(),
},
}
} else {
match PublicAssets::get(&path) {
Some(file) => (file.data.into_owned(), false),
None => return StatusCode::NOT_FOUND.into_response(),
}
};

let mut builder = Response::builder().header(CONTENT_TYPE, &mime);

if is_br {
builder = builder.header(CONTENT_ENCODING, "br");
}

if immutable {
builder = builder.header(CACHE_CONTROL, "public, max-age=31536000, immutable");
}

builder
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}),
);
}

router
}
3 changes: 3 additions & 0 deletions packages/fullstack-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ pub use isrg::*;

mod index_html;
pub(crate) use index_html::IndexHtml;

#[cfg(feature = "embed")]
mod embedded;
Loading