Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/cli-opt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ impl AssetManifest {
self.assets.get(path)
}

/// Get the first asset that matches the given source path
pub fn get_first_asset_for_source(&self, path: &Path) -> Option<&BundledAsset> {
self.assets
.get(path)
.and_then(|assets| assets.iter().next())
}

/// Check if the manifest contains a specific asset
pub fn contains(&self, asset: &BundledAsset) -> bool {
self.assets
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ built = { version = "0.7.5", features = ["git2"] }
[features]
default = []
plugin = []
tokio-console = ["dep:console-subscriber"]
tokio-console = ["dep:console-subscriber", "tokio/tracing"]
bundle = []
no-downloads = []

Expand Down
51 changes: 36 additions & 15 deletions packages/cli/src/build/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,31 +417,20 @@ impl AppBuilder {
}
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn open(
/// Create a list of environment variables that the child process will use
pub(crate) fn child_environment_variables(
&mut self,
devserver_ip: SocketAddr,
open_address: Option<SocketAddr>,
devserver_ip: Option<SocketAddr>,
start_fullstack_on_address: Option<SocketAddr>,
open_browser: bool,
always_on_top: bool,
build_id: BuildId,
args: &[String],
) -> Result<()> {
) -> Vec<(&'static str, String)> {
let krate = &self.build;

// Set the env vars that the clients will expect
// These need to be stable within a release version (ie 0.6.0)
let mut envs = vec![
(dioxus_cli_config::CLI_ENABLED_ENV, "true".to_string()),
(
dioxus_cli_config::DEVSERVER_IP_ENV,
devserver_ip.ip().to_string(),
),
(
dioxus_cli_config::DEVSERVER_PORT_ENV,
devserver_ip.port().to_string(),
),
(
dioxus_cli_config::APP_TITLE_ENV,
krate.config.web.app.title.clone(),
Expand All @@ -457,6 +446,17 @@ impl AppBuilder {
),
];

if let Some(devserver_ip) = devserver_ip {
envs.push((
dioxus_cli_config::DEVSERVER_IP_ENV,
devserver_ip.ip().to_string(),
));
envs.push((
dioxus_cli_config::DEVSERVER_PORT_ENV,
devserver_ip.port().to_string(),
));
}

if crate::VERBOSITY
.get()
.map(|f| f.verbose)
Expand All @@ -480,6 +480,27 @@ impl AppBuilder {
envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
}

envs
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn open(
&mut self,
devserver_ip: SocketAddr,
open_address: Option<SocketAddr>,
start_fullstack_on_address: Option<SocketAddr>,
open_browser: bool,
always_on_top: bool,
build_id: BuildId,
args: &[String],
) -> Result<()> {
let envs = self.child_environment_variables(
Some(devserver_ip),
start_fullstack_on_address,
always_on_top,
build_id,
);

// We try to use stdin/stdout to communicate with the app
match self.build.platform {
// Unfortunately web won't let us get a proc handle to it (to read its stdout/stderr) so instead
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ mod assets;
mod builder;
mod context;
mod patch;
mod pre_render;
mod request;
mod tools;

pub(crate) use assets::*;
pub(crate) use builder::*;
pub(crate) use context::*;
pub(crate) use patch::*;
pub(crate) use pre_render::*;
pub(crate) use request::*;
pub(crate) use tools::*;
148 changes: 148 additions & 0 deletions packages/cli/src/build/pre_render.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use anyhow::Context;
use dioxus_cli_config::{server_ip, server_port};
use dioxus_dx_wire_format::BuildStage;
use futures_util::{stream::FuturesUnordered, StreamExt};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::Duration,
};
use tokio::process::Command;

use crate::BuildId;

use super::{AppBuilder, BuilderUpdate};

/// Pre-render the static routes, performing static-site generation
pub(crate) async fn pre_render_static_routes(
devserver_ip: Option<SocketAddr>,
builder: &mut AppBuilder,
updates: Option<&futures_channel::mpsc::UnboundedSender<BuilderUpdate>>,
) -> anyhow::Result<()> {
if let Some(updates) = updates {
updates
.unbounded_send(BuilderUpdate::Progress {
stage: BuildStage::Prerendering,
})
.unwrap();
}
let server_exe = builder.build.main_exe();

// Use the address passed in through environment variables or default to localhost:9999. We need
// to default to a value that is different than the CLI default address to avoid conflicts
let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let port = server_port().unwrap_or(9999);
let fullstack_address = SocketAddr::new(ip, port);
let address = fullstack_address.ip().to_string();
let port = fullstack_address.port().to_string();

// Borrow port and address so we can easily move them into multiple tasks below
let address = &address;
let port = &port;

tracing::info!("Running SSG at http://{address}:{port} for {server_exe:?}");

let vars = builder.child_environment_variables(
devserver_ip,
Some(fullstack_address),
false,
BuildId::SERVER,
);
// Run the server executable
let _child = Command::new(&server_exe)
.envs(vars)
.current_dir(server_exe.parent().unwrap())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;

// Borrow reqwest_client so we only move the reference into the futures
let reqwest_client = reqwest::Client::new();
let reqwest_client = &reqwest_client;

// Get the routes from the `/static_routes` endpoint
let mut routes = None;

// The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay
const RETRY_ATTEMPTS: usize = 5;
for i in 0..=RETRY_ATTEMPTS {
tracing::debug!(
"Attempting to get static routes from server. Attempt {i} of {RETRY_ATTEMPTS}"
);

let request = reqwest_client
.post(format!("http://{address}:{port}/api/static_routes"))
.body("{}".to_string())
.send()
.await;
match request {
Ok(request) => {
routes = Some(request
.json::<Vec<String>>()
.await
.inspect(|text| tracing::debug!("Got static routes: {text:?}"))
.context("Failed to parse static routes from the server. Make sure your server function returns Vec<String> with the (default) json encoding")?);
break;
}
Err(err) => {
// If the request fails, try up to 5 times with a one second delay
// If it fails 5 times, return the error
if i == RETRY_ATTEMPTS {
return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec<String> of static routes.");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}

let routes = routes.expect(
"static routes should exist or an error should have been returned on the last attempt",
);

// Create a pool of futures that cache each route
let mut resolved_routes = routes
.into_iter()
.map(|route| async move {
tracing::info!("Rendering {route} for SSG");

// For each route, ping the server to force it to cache the response for ssg
let request = reqwest_client
.get(format!("http://{address}:{port}{route}"))
.header("Accept", "text/html")
.send()
.await?;

// If it takes longer than 30 seconds to resolve the route, log a warning
let warning_task = tokio::spawn({
let route = route.clone();
async move {
tokio::time::sleep(Duration::from_secs(30)).await;
tracing::warn!("Route {route} has been rendering for 30 seconds");
}
});

// Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly
// because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write
// the final clean HTML to the disk automatically after the request completes.
let _html = request.text().await?;

// Cancel the warning task if it hasn't already run
warning_task.abort();

Ok::<_, reqwest::Error>(route)
})
.collect::<FuturesUnordered<_>>();

while let Some(route) = resolved_routes.next().await {
match route {
Ok(route) => tracing::debug!("ssg success: {route:?}"),
Err(err) => tracing::error!("ssg error: {err:?}"),
}
}

tracing::info!("SSG complete");

drop(_child);

Ok(())
}
58 changes: 44 additions & 14 deletions packages/cli/src/build/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,10 @@ impl BuildRequest {
// Now extract the assets from the fat binary
self.collect_assets(&self.patch_exe(artifacts.time_start), ctx)?;

// If this is a web build, reset the index.html file in case it was modified by SSG
self.write_index_html(&artifacts.assets)
.context("Failed to write index.html")?;

// Clean up the temps manually
// todo: we might want to keep them around for debugging purposes
for file in object_files {
Expand Down Expand Up @@ -3202,7 +3206,6 @@ impl BuildRequest {
|| will_wasm_opt
|| ctx.mode == BuildMode::Fat;
let keep_names = will_wasm_opt || ctx.mode == BuildMode::Fat;
let package_to_asset = self.release && !should_bundle_split;
let demangle = false;
let wasm_opt_options = WasmOptConfig {
memory_packing: self.wasm_split,
Expand Down Expand Up @@ -3345,33 +3348,60 @@ impl BuildRequest {

// In release mode, we make the wasm and bindgen files into assets so they get bundled with max
// optimizations.
let wasm_path = if package_to_asset {
if self.should_bundle_to_asset() {
// Register the main.js with the asset system so it bundles in the snippets and optimizes
assets.register_asset(
&self.wasm_bindgen_js_output_file(),
AssetOptions::Js(JsAssetOptions::new().with_minify(true).with_preload(true)),
)?;
}

if self.should_bundle_to_asset() {
// Make sure to register the main wasm file with the asset system
let name = assets.register_asset(&post_bindgen_wasm, AssetOptions::Unknown)?;
assets.register_asset(&post_bindgen_wasm, AssetOptions::Unknown)?;
}

// Write the index.html file with the pre-configured contents we got from pre-rendering
self.write_index_html(assets)?;

Ok(())
}

/// Write the index.html file to the output directory. This must be called after the wasm and js
/// assets are registered with the asset system if this is a release build.
pub(crate) fn write_index_html(&self, assets: &AssetManifest) -> Result<()> {
// Get the path to the wasm-bindgen output files. Either the direct file or the opitmized one depending on the build mode
let wasm_bindgen_wasm_out = self.wasm_bindgen_wasm_output_file();
let wasm_path = if self.should_bundle_to_asset() {
let name = assets
.get_first_asset_for_source(&wasm_bindgen_wasm_out)
.expect("The wasm source must exist before creating index.html");
format!("assets/{}", name.bundled_path())
} else {
let asset = self.wasm_bindgen_wasm_output_file();
format!("wasm/{}", asset.file_name().unwrap().to_str().unwrap())
format!(
"wasm/{}",
wasm_bindgen_wasm_out.file_name().unwrap().to_str().unwrap()
)
};

let js_path = if package_to_asset {
// Register the main.js with the asset system so it bundles in the snippets and optimizes
let name = assets.register_asset(
&self.wasm_bindgen_js_output_file(),
AssetOptions::Js(JsAssetOptions::new().with_minify(true).with_preload(true)),
)?;
let wasm_bindgen_js_out = self.wasm_bindgen_js_output_file();
let js_path = if self.should_bundle_to_asset() {
let name = assets
.get_first_asset_for_source(&wasm_bindgen_js_out)
.expect("The js source must exist before creating index.html");
format!("assets/{}", name.bundled_path())
} else {
let asset = self.wasm_bindgen_js_output_file();
format!("wasm/{}", asset.file_name().unwrap().to_str().unwrap())
format!(
"wasm/{}",
wasm_bindgen_js_out.file_name().unwrap().to_str().unwrap()
)
};

// Write the index.html file with the pre-configured contents we got from pre-rendering
std::fs::write(
self.root_dir().join("index.html"),
self.prepare_html(assets, &wasm_path, &js_path).unwrap(),
)?;

Ok(())
}

Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ pub struct BuildArgs {
#[clap(long)]
pub(crate) fullstack: Option<bool>,

/// Pre-render all routes returned from the app's `/static_routes` endpoint [default: false]
#[clap(long)]
pub(crate) ssg: bool,

/// Arguments for the build itself
#[clap(flatten)]
pub(crate) build_arguments: TargetArgs,
Expand Down Expand Up @@ -63,6 +67,7 @@ impl CommandWithPlatformOverrides<BuildArgs> {
pub async fn build(self) -> Result<StructuredOutput> {
tracing::info!("Building project...");

let ssg = self.shared.ssg;
let targets = self.into_targets().await?;

AppBuilder::start(&targets.client, BuildMode::Base)?
Expand All @@ -73,9 +78,13 @@ impl CommandWithPlatformOverrides<BuildArgs> {

if let Some(server) = targets.server.as_ref() {
// If the server is present, we need to build it as well
AppBuilder::start(server, BuildMode::Base)?
.finish_build()
.await?;
let mut server_build = AppBuilder::start(server, BuildMode::Base)?;
server_build.finish_build().await?;

// Run SSG and cache static routes
if ssg {
crate::pre_render_static_routes(None, &mut server_build, None).await?;
}

tracing::info!(path = ?targets.client.root_dir(), "Server build completed successfully! 🚀");
}
Expand Down
Loading
Loading