Restore SSG serve logic (#4111)

* Build ssg routes when both the client and server builds are finished

* Fix default server fn encoding

* fix tokio console

* fix ssg

* add prerendering message

* render under the same environment variables as normal

* fix check

* use should_bundle_to_asset

* enable ssg playwright test

* fix clippy
This commit is contained in:
Evan Almloff authored and GitHub committed 2025-06-23 13:35:46 -07:00
1 parent f610c6b8c1
commit fdd77d79c1
17 files changed
+357 -236

No files matched your search

+36 -15
View File
@@ -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(),
@@ -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)
@@ -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
+2
View File
@@ -12,6 +12,7 @@ mod assets;
mod builder;
mod context;
mod patch;
mod pre_render;
mod request;
mod tools;
@@ -19,5 +20,6 @@ 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
View File
@@ -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(())
}
+45 -15
View File
@@ -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 {
@@ -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,
@@ -3345,25 +3348,53 @@ 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 {
// Make sure to register the main wasm file with the asset system
let name = assets.register_asset(&post_bindgen_wasm, AssetOptions::Unknown)?;
format!("assets/{}", name.bundled_path())
} else {
let asset = self.wasm_bindgen_wasm_output_file();
format!("wasm/{}", asset.file_name().unwrap().to_str().unwrap())
};
let js_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
let name = assets.register_asset(
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
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_js_output_file();
format!("wasm/{}", asset.file_name().unwrap().to_str().unwrap())
format!(
"wasm/{}",
wasm_bindgen_wasm_out.file_name().unwrap().to_str().unwrap()
)
};
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 {
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
@@ -3371,7 +3402,6 @@ impl BuildRequest {
self.root_dir().join("index.html"),
self.prepare_html(assets, &wasm_path, &js_path).unwrap(),
)?;
Ok(())
}