use super::{AppBuilder, ServeUpdate, WebServer}; use crate::{ platform_override::CommandWithPlatformOverrides, BuildArtifacts, BuildId, BuildMode, BuildTargets, BuilderUpdate, BundleFormat, HotpatchModuleCache, Result, ServeArgs, TailwindCli, TraceSrc, Workspace, }; use anyhow::{bail, Context}; use dioxus_core::internal::{ HotReloadTemplateWithLocation, HotReloadedTemplate, TemplateGlobalKey, }; use dioxus_devtools_types::HotReloadMsg; use dioxus_dx_wire_format::BuildStage; use dioxus_html::HtmlCtx; use dioxus_rsx::CallBody; use dioxus_rsx_hotreload::{ChangedRsx, HotReloadResult}; use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures_util::future::OptionFuture; use futures_util::StreamExt; use krates::NodeId; use notify::{ event::{MetadataKind, ModifyKind}, Config, EventKind, RecursiveMode, Watcher as NotifyWatcher, }; use std::{ collections::{HashMap, HashSet, VecDeque}, net::{IpAddr, TcpListener}, path::{Path, PathBuf}, sync::Arc, time::Duration, }; use syn::spanned::Spanned; use tokio::process::Command; /// This is the primary "state" object that holds the builds and handles for the running apps. /// /// It also holds the watcher which is used to watch for changes in the filesystem and trigger rebuilds, /// hotreloads, asset updates, etc. /// /// Since we resolve the build request before initializing the CLI, it also serves as a place to store /// resolved "serve" arguments, which is why it takes ServeArgs instead of BuildArgs. Simply wrap the /// BuildArgs in a default ServeArgs and pass it in. pub(crate) struct AppServer { /// the platform of the "primary" crate (ie the first) pub(crate) workspace: Arc, pub(crate) client: AppBuilder, pub(crate) server: Option, // Related to the filesystem watcher pub(crate) watcher: Box, pub(crate) _watcher_tx: UnboundedSender, pub(crate) watcher_rx: UnboundedReceiver, // Tracked state related to open builds and hot reloading pub(crate) applied_client_hot_reload_message: HotReloadMsg, pub(crate) file_map: HashMap, // Resolved args related to how we go about processing the rebuilds and logging pub(crate) use_hotpatch_engine: bool, pub(crate) automatic_rebuilds: bool, pub(crate) interactive: bool, pub(crate) _force_sequential: bool, pub(crate) hot_reload: bool, pub(crate) open_browser: bool, pub(crate) _wsl_file_poll_interval: u16, pub(crate) always_on_top: bool, pub(crate) fullstack: bool, pub(crate) ssg: bool, pub(crate) watch_fs: bool, // resolve args related to the webserver pub(crate) devserver_port: u16, pub(crate) devserver_bind_ip: IpAddr, pub(crate) proxied_port: Option, pub(crate) cross_origin_policy: bool, // The arguments that should be forwarded to the client app when it is opened pub(crate) client_args: Vec, // The arguments that should be forwarded to the server app when it is opened pub(crate) server_args: Vec, // Additional plugin-type tools pub(crate) tw_watcher: tokio::task::JoinHandle>, // File changes that arrived while a build was in progress, to be processed after build completes pub(crate) pending_file_changes: Vec, } pub(crate) struct CachedFile { contents: String, most_recent: Option, templates: HashMap, } impl AppServer { /// Create the AppRunner and then initialize the filemap with the crate directory. pub(crate) async fn new(args: ServeArgs) -> Result { let workspace = Workspace::current().await?; // Resolve the simpler args let interactive = args.is_interactive_tty(); let force_sequential = args.platform_args.shared.targets.force_sequential_build(); let cross_origin_policy = args.cross_origin_policy; // Find the launch args for the client and server let split_args = |args: &str| { args.split_whitespace() .map(|s| s.to_string()) .collect::>() }; let server_args = args.platform_args.with_server_or_shared(|c| &c.args); let server_args = split_args(server_args); let client_args = args.platform_args.with_client_or_shared(|c| &c.args); let client_args = split_args(client_args); // These come from the args but also might come from the workspace settings // We opt to use the manually specified args over the workspace settings let hot_reload = args .hot_reload .unwrap_or_else(|| workspace.settings.always_hot_reload.unwrap_or(true)); let open_browser = args .open .unwrap_or_else(|| workspace.settings.always_open_browser.unwrap_or(false)) && interactive; let wsl_file_poll_interval = args .wsl_file_poll_interval .unwrap_or_else(|| workspace.settings.wsl_file_poll_interval.unwrap_or(2)); let always_on_top = args .always_on_top .unwrap_or_else(|| workspace.settings.always_on_top.unwrap_or(true)); // Use 127.0.0.1 as the default address if none is specified. // If the user wants to export on the network, they can use `0.0.0.0` instead. let devserver_bind_ip = args.address.addr.unwrap_or(WebServer::SELF_IP); // If the user specified a port, use that, otherwise use any available port, preferring 8080 let devserver_port = args .address .port .unwrap_or_else(|| get_available_port(devserver_bind_ip, Some(8080)).unwrap_or(8080)); // Spin up the file watcher let (watcher_tx, watcher_rx) = futures_channel::mpsc::unbounded(); let watcher = create_notify_watcher(watcher_tx.clone(), wsl_file_poll_interval as u64); let ssg = args.platform_args.shared.targets.ssg; let target_args = CommandWithPlatformOverrides { shared: args.platform_args.shared.targets, server: args.platform_args.server.map(|s| s.targets), client: args.platform_args.client.map(|c| c.targets), }; let BuildTargets { client, server } = target_args.into_targets().await?; // All servers will end up behind us (the devserver) but on a different port // This is so we can serve a loading screen as well as devtools without anything particularly fancy let fullstack = server.is_some(); let should_proxy_port = match client.bundle { BundleFormat::Server => true, _ => fullstack && !ssg, }; let proxied_port = should_proxy_port .then(|| get_available_port(devserver_bind_ip, None)) .flatten(); let watch_fs = args.watch.unwrap_or(true); let use_hotpatch_engine = args.hot_patch; let client = AppBuilder::new(&client)?; let server = server.map(|server| AppBuilder::new(&server)).transpose()?; let tw_watcher = TailwindCli::serve( client.build.package_manifest_dir(), client.build.config.application.tailwind_input.clone(), client.build.config.application.tailwind_output.clone(), ); _ = client.build.start_simulators().await; // Encourage the user to update to a new dx version crate::update::log_if_cli_could_update(); // Create the runner let mut runner = Self { file_map: Default::default(), applied_client_hot_reload_message: Default::default(), automatic_rebuilds: true, watch_fs, use_hotpatch_engine, client, server, hot_reload, open_browser, _wsl_file_poll_interval: wsl_file_poll_interval, always_on_top, workspace, devserver_port, devserver_bind_ip, proxied_port, watcher, watcher_rx, _watcher_tx: watcher_tx, interactive, _force_sequential: force_sequential, cross_origin_policy, fullstack, ssg, tw_watcher, server_args, client_args, pending_file_changes: Vec::new(), }; // Only register the hot-reload stuff if we're watching the filesystem if runner.watch_fs { // Spin up the notify watcher // When builds load though, we're going to parse their depinfo and add the paths to the watcher runner.watch_filesystem(); // todo(jon): this might take a while so we should try and background it, or make it lazy somehow // we could spawn a thread to search the FS and then when it returns we can fill the filemap // in testing, if this hits a massive directory, it might take several seconds with no feedback. // really, we should be using depinfo to get the files that are actually used, but the depinfo file might not be around yet // todo(jon): see if we can just guess the depinfo file before it generates. might be stale but at least it catches most of the files runner.load_rsx_filemap(); } Ok(runner) } pub(crate) fn initialize(&mut self) { let build_mode = match self.use_hotpatch_engine { true => BuildMode::Fat, false => BuildMode::Base { run: true }, }; self.client.start(build_mode.clone(), BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { server.start(build_mode, BuildId::SECONDARY); } } /// Take any pending file changes that were queued while a build was in progress. /// Returns the files and clears the pending list. pub(crate) fn take_pending_file_changes(&mut self) -> Vec { std::mem::take(&mut self.pending_file_changes) } pub(crate) async fn rebuild_ssg(&mut self, devserver: &WebServer) { if self.client.stage != BuildStage::Success { return; } // Run SSG and cache static routes if the server build is done if let Some(server) = self.server.as_mut() { if !self.ssg || server.stage != BuildStage::Success { return; } if let Err(err) = crate::pre_render_static_routes( Some(devserver.devserver_address()), server, Some(&server.tx.clone()), ) .await { tracing::error!("Failed to pre-render static routes: {err}"); } } } pub(crate) async fn wait(&mut self) -> ServeUpdate { let client = &mut self.client; let server = self.server.as_mut(); let client_wait = client.wait(); let server_wait = OptionFuture::from(server.map(|s| s.wait())); let watcher_wait = self.watcher_rx.next(); tokio::select! { // Wait for the client to finish client_update = client_wait => { ServeUpdate::BuilderUpdate { id: BuildId::PRIMARY, update: client_update, } } Some(server_update) = server_wait => { ServeUpdate::BuilderUpdate { id: BuildId::SECONDARY, update: server_update, } } // Wait for the watcher to send us an event event = watcher_wait => { let mut changes: Vec<_> = event.into_iter().collect(); // Dequeue in bulk if we can, we might've received a lot of events in one go while let Some(event) = self.watcher_rx.try_next().ok().flatten() { changes.push(event); } // Filter the changes let mut files: Vec = vec![]; // Decompose the events into a list of all the files that have changed for event in changes.drain(..) { // Make sure we add new folders to the watch list, provided they're not matched by the ignore list // We'll only watch new folders that are found under the crate, and then update our watcher to watch them // This unfortunately won't pick up new krates added "at a distance" - IE krates not within the workspace. if let EventKind::Create(_create_kind) = event.kind { // If it's a new folder, watch it // If it's a new cargo.toml (ie dep on the fly), // todo(jon) support new folders on the fly } for path in event.paths { // Workaround for notify and vscode-like editor: // - when edit & save a file in vscode, there will be two notifications, // - the first one is a file with empty content. // - filter the empty file notification to avoid false rebuild during hot-reload if let Ok(metadata) = std::fs::metadata(&path) { if metadata.len() == 0 { continue; } } files.push(path); } } ServeUpdate::FilesChanged { files } } } } /// Handle an update from the builder pub(crate) async fn new_build_update(&mut self, update: &BuilderUpdate, devserver: &WebServer) { if let BuilderUpdate::BuildReady { .. } = update { // If the build is ready, we need to check if we need to pre-render with ssg self.rebuild_ssg(devserver).await; } } /// Handle the list of changed files from the file watcher, attempting to aggressively prevent /// full rebuilds by hot-reloading RSX and hot-patching Rust code. /// /// This will also handle any assets that are linked in the files, and copy them to the bundle /// and send them to the client. pub(crate) async fn handle_file_change(&mut self, files: &[PathBuf], server: &mut WebServer) { // We can attempt to hotpatch if the build is in a bad state, since this patch might be a recovery. if !matches!( self.client.stage, BuildStage::Failed | BuildStage::Aborted | BuildStage::Success ) { // Queue file changes that arrive during a build, so we can process them after the build completes. // This prevents losing changes from tools like stylance, tailwind, or sass that generate files // in response to source changes. tracing::debug!( "Queueing file change: client is not ready to receive hotreloads. Files: {:#?}", files ); self.pending_file_changes.extend(files.iter().cloned()); return; } // If we have any changes to the rust files, we need to update the file map let mut templates = vec![]; // Prepare the hotreload message we need to send let mut assets = Vec::new(); let mut needs_full_rebuild = false; // We attempt to hotreload rsx blocks without a full rebuild for path in files { // for various assets that might be linked in, we just try to hotreloading them forcefully // That is, unless they appear in an include! macro, in which case we need to a full rebuild.... let ext = path .extension() .and_then(|v| v.to_str()) .unwrap_or_default(); // If it's an asset, we want to hotreload it // todo(jon): don't hardcode this here if let Some(bundled_names) = self.client.hotreload_bundled_assets(path).await { for bundled_name in bundled_names { assets.push(PathBuf::from("/assets/").join(bundled_name)); } } // 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 let Ok(new_contents) = std::fs::read_to_string(path) else { tracing::debug!("Failed to read rust file while hotreloading: {:?}", path); continue; }; // Get the cached file if it exists - ignoring if it doesn't exist let Some(cached_file) = self.file_map.get_mut(path) else { tracing::debug!("No entry for file in filemap: {:?}", path); tracing::debug!("Filemap: {:#?}", self.file_map.keys()); continue; }; let Ok(local_path) = path.strip_prefix(self.workspace.workspace_root()) else { tracing::debug!("Skipping file outside workspace dir: {:?}", path); continue; }; // We assume we can parse the old file and the new file, ignoring untracked rust files let old_syn = syn::parse_file(&cached_file.contents); let new_syn = syn::parse_file(&new_contents); let (Ok(old_file), Ok(new_file)) = (old_syn, new_syn) else { tracing::debug!("Diff rsx returned not parseable"); continue; }; // Update the most recent version of the file, so when we force a rebuild, we keep operating on the most recent version cached_file.most_recent = Some(new_contents); // This assumes the two files are structured similarly. If they're not, we can't diff them let Some(changed_rsx) = dioxus_rsx_hotreload::diff_rsx(&new_file, &old_file) else { needs_full_rebuild = true; break; }; for ChangedRsx { old, new } in changed_rsx { let old_start = old.span().start(); let old_parsed = syn::parse2::(old.tokens); let new_parsed = syn::parse2::(new.tokens); let (Ok(old_call_body), Ok(new_call_body)) = (old_parsed, new_parsed) else { continue; }; // Format the template location, normalizing the path let file_name: String = local_path .components() .map(|c| c.as_os_str().to_string_lossy()) .collect::>() .join("/"); // Returns a list of templates that are hotreloadable let results = HotReloadResult::new::( &old_call_body.body, &new_call_body.body, file_name.clone(), ); // If no result is returned, we can't hotreload this file and need to keep the old file let Some(results) = results else { needs_full_rebuild = true; break; }; // Only send down templates that have roots, and ideally ones that have changed // todo(jon): maybe cache these and don't send them down if they're the same for (index, template) in results.templates { if template.roots.is_empty() { continue; } // Create the key we're going to use to identify this template let key = TemplateGlobalKey { file: file_name.clone(), line: old_start.line, column: old_start.column + 1, index, }; // if the template is the same, don't send its if cached_file.templates.get(&key) == Some(&template) { continue; }; cached_file.templates.insert(key.clone(), template.clone()); templates.push(HotReloadTemplateWithLocation { template, key }); } } } // If it's not a rust file, then it might be depended on via include! or similar if ext != "rs" { if let Some(artifacts) = self.client.artifacts.as_ref() { if artifacts.depinfo.files.contains(path) { needs_full_rebuild = true; break; } } } } // If the client is in a failed state, any changes to rsx should trigger a rebuild/hotpatch if self.client.stage == BuildStage::Failed && !templates.is_empty() { needs_full_rebuild = true } // todo - we need to distinguish between hotpatchable rebuilds and true full rebuilds. // A full rebuild is required when the user modifies static initializers which we haven't wired up yet. if needs_full_rebuild && self.automatic_rebuilds { if self.use_hotpatch_engine { let changed_crates = self.order_changed_crates(files); self.client .patch_rebuild(files.to_vec(), changed_crates.clone(), BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { server.patch_rebuild(files.to_vec(), changed_crates, BuildId::SECONDARY); } self.clear_hot_reload_changes(); self.clear_cached_rsx(); server.send_patch_start().await; } else { self.client .start_rebuild(BuildMode::Base { run: true }, BuildId::PRIMARY); if let Some(server) = self.server.as_mut() { server.start_rebuild(BuildMode::Base { run: true }, BuildId::SECONDARY); } self.clear_hot_reload_changes(); self.clear_cached_rsx(); server.send_reload_start().await; } } else { let msg = HotReloadMsg { templates, assets, ms_elapsed: 0, jump_table: Default::default(), for_build_id: None, for_pid: None, }; self.add_hot_reload_message(&msg); let file = files[0].display().to_string(); let file = file.trim_start_matches(&self.client.build.crate_dir().display().to_string()); if needs_full_rebuild && !self.automatic_rebuilds { use crate::styles::NOTE_STYLE; tracing::warn!( "Ignoring full rebuild for: {NOTE_STYLE}{}{NOTE_STYLE:#}", file ); } // Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild // // todo: move the android file uploading out of hotreload_bundled_asset and // // Also make sure the builder isn't busy since that might cause issues with hotreloads // https://github.com/DioxusLabs/dioxus/issues/3361 if !msg.is_empty() && self.client.can_receive_hotreloads() { use crate::styles::NOTE_STYLE; tracing::info!(dx_src = ?TraceSrc::Dev, "Hotreloading: {NOTE_STYLE}{}{NOTE_STYLE:#}", file); if !server.has_hotreload_sockets() && self.client.build.bundle != BundleFormat::Web { tracing::warn!("No clients to hotreload - try reloading the app!"); } server.send_hotreload(msg).await; } else { tracing::debug!(dx_src = ?TraceSrc::Dev, "Ignoring file change: {}", file); } } } /// Finally "bundle" this app and return a handle to it pub(crate) async fn open( &mut self, artifacts: &BuildArtifacts, devserver: &mut WebServer, ) -> Result<()> { // Make sure to save artifacts regardless of if we're opening the app or not match artifacts.build_id { BuildId::PRIMARY => self.client.artifacts = Some(artifacts.clone()), BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { server.artifacts = Some(artifacts.clone()); } } _ => {} } let should_open = self.client.stage == BuildStage::Success && (self.server.as_ref().map(|s| s.stage == BuildStage::Success)).unwrap_or(true); use crate::cli::styles::GLOW_STYLE; if should_open { let time_taken = artifacts .time_end .duration_since(artifacts.time_start) .unwrap(); if self.client.builds_opened == 0 { tracing::info!( "Build completed successfully in {GLOW_STYLE}{}{GLOW_STYLE:#}, launching app! 💫", format_duration_ms(time_taken) ); } else { tracing::info!( "Build completed in {GLOW_STYLE}{}{GLOW_STYLE:#}", format_duration_ms(time_taken) ); } let open_browser = self.client.builds_opened == 0 && self.open_browser; self.open_all(devserver, open_browser).await?; // Give a second for the server to boot tokio::time::sleep(Duration::from_millis(300)).await; // Update the screen + devserver with the new handle info devserver.send_reload_command().await } Ok(()) } /// Open an existing app bundle, if it exists /// /// Will attempt to open the server and client together, in a coordinated way such that the server /// opens first, initializes, and then the client opens. /// /// There's a number of issues we need to be careful to work around: /// - The server failing to boot or crashing on startup (and entering a boot loop) /// - pub(crate) async fn open_all( &mut self, devserver: &WebServer, open_browser: bool, ) -> Result<()> { let devserver_ip = devserver.devserver_address(); let fullstack_address = devserver.proxied_server_address(); let displayed_address = devserver.displayed_address(); // Always open the server first after the client has been built // Only open the server if it isn't prerendered and finished building if let Some(server) = self.server.as_mut().filter(|_| !self.ssg) { if server.stage < BuildStage::Success { tracing::trace!("Skipping server open: will open once build completes"); } else { tracing::debug!("Opening server build"); server.soft_kill().await; server .open( devserver_ip, displayed_address, fullstack_address, false, false, BuildId::SECONDARY, &self.server_args, ) .await?; } } // Skip opening native client if still building (web can open anytime) if self.client.build.bundle != BundleFormat::Web && self.client.stage < BuildStage::Success { tracing::trace!("Skipping client open: will open once build completes"); return Ok(()); } // Start the new app before we kill the old one to give it a little bit of time self.client.soft_kill().await; self.client .open( devserver_ip, displayed_address, fullstack_address, open_browser, self.always_on_top, BuildId::PRIMARY, &self.client_args, ) .await?; Ok(()) } /// Shutdown all the running processes pub(crate) async fn shutdown(&mut self) -> Result<()> { self.client.soft_kill().await; if let Some(server) = self.server.as_mut() { server.soft_kill().await; } // If the client is running on Android, we need to remove the port forwarding // todo: use the android tools "adb" if matches!(self.client.build.bundle, BundleFormat::Android) { if let Err(err) = Command::new(&self.workspace.android_tools()?.adb) .arg("reverse") .arg("--remove") .arg(format!("tcp:{}", self.devserver_port)) .output() .await { tracing::error!( "failed to remove forwarded port {}: {err}", self.devserver_port ); } } // force the tailwind watcher to stop - if we don't, it eats our stdin self.tw_watcher.abort(); Ok(()) } /// Perform a full rebuild of the app, equivalent to `cargo rustc` from scratch with no incremental /// hot-patch engine integration. pub(crate) async fn full_rebuild(&mut self) { let build_mode = match self.use_hotpatch_engine { true => BuildMode::Fat, false => BuildMode::Base { run: true }, }; self.client .start_rebuild(build_mode.clone(), BuildId::PRIMARY); if let Some(s) = self.server.as_mut() { s.start_rebuild(build_mode, BuildId::SECONDARY); } self.clear_hot_reload_changes(); self.clear_cached_rsx(); self.clear_patches(); } pub(crate) async fn hotpatch( &mut self, bundle: &BuildArtifacts, id: BuildId, cache: &HotpatchModuleCache, devserver: &mut WebServer, ) -> Result<()> { let elapsed = bundle .time_end .duration_since(bundle.time_start) .unwrap_or_default(); let jump_table = match id { BuildId::PRIMARY => self.client.hotpatch(bundle, cache).await, BuildId::SECONDARY => { self.server .as_mut() .context("Server not found")? .hotpatch(bundle, cache) .await } _ => bail!("Invalid build id"), }?; if id == BuildId::PRIMARY { self.applied_client_hot_reload_message.jump_table = self.client.patches.last().cloned(); } // If no server, just send the patch immediately let Some(server) = self.server.as_mut() else { devserver .send_patch(jump_table, elapsed, id, self.client.pid) .await; return Ok(()); }; // If we have a server, we need to wait until both the client and server are ready // Otherwise we end up with an annoying race condition where the client can't actually load the patch if self.client.stage == BuildStage::Success && server.stage == BuildStage::Success { let client_jump_table = self .client .patches .last() .cloned() .context("Missing client jump table")?; let server_jump_table = server .patches .last() .cloned() .context("Missing server jump table")?; devserver .send_patch(server_jump_table, elapsed, BuildId::SECONDARY, server.pid) .await; devserver .send_patch( client_jump_table, elapsed, BuildId::PRIMARY, self.client.pid, ) .await; } Ok(()) } pub(crate) fn get_build(&self, id: BuildId) -> Option<&AppBuilder> { match id { BuildId::PRIMARY => Some(&self.client), BuildId::SECONDARY => self.server.as_ref(), _ => None, } } pub(crate) fn client(&self) -> &AppBuilder { &self.client } /// The name of the app being served, to display pub(crate) fn app_name(&self) -> &str { self.client.build.executable_name() } /// Get any hot reload changes that have been applied since the last full rebuild pub(crate) fn applied_hot_reload_changes(&mut self, build: BuildId) -> HotReloadMsg { let mut msg = self.applied_client_hot_reload_message.clone(); if build == BuildId::PRIMARY { msg.jump_table = self.client.patches.last().cloned(); msg.for_build_id = Some(BuildId::PRIMARY.0 as _); if let Some(lib) = msg.jump_table.as_mut() { lib.lib = PathBuf::from("/").join(lib.lib.clone()); } } if build == BuildId::SECONDARY { if let Some(server) = self.server.as_mut() { msg.jump_table = server.patches.last().cloned(); msg.for_build_id = Some(BuildId::SECONDARY.0 as _); } } msg } /// Clear the hot reload changes. This should be called any time a new build is starting pub(crate) fn clear_hot_reload_changes(&mut self) { self.applied_client_hot_reload_message = Default::default(); } pub(crate) fn clear_patches(&mut self) { self.client.patches.clear(); if let Some(server) = self.server.as_mut() { server.patches.clear(); } } pub(crate) async fn client_connected( &mut self, build_id: BuildId, aslr_reference: Option, pid: Option, ) { match build_id { BuildId::PRIMARY => { // multiple tabs on web can cause this to be called incorrectly, and it doesn't // make any sense anyways if self.client.build.bundle != BundleFormat::Web { if let Some(aslr_reference) = aslr_reference { self.client.aslr_reference = Some(aslr_reference); } if let Some(pid) = pid { self.client.pid = Some(pid); } } } BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { server.aslr_reference = aslr_reference; } } _ => {} } // Assign the runtime asset dir to the runner if self.client.build.bundle == BundleFormat::Ios { // xcrun simctl get_app_container booted com.dioxuslabs let res = Command::new("xcrun") .arg("simctl") .arg("get_app_container") .arg("booted") .arg(self.client.build.bundle_identifier()) .output() .await; if let Ok(res) = res { tracing::trace!("Using runtime asset dir: {:?}", res); if let Ok(out) = String::from_utf8(res.stdout) { let out = out.trim(); tracing::trace!("Setting Runtime asset dir: {out:?}"); self.client.runtime_asset_dir = Some(PathBuf::from(out)); } } } } /// Store the hot reload changes for any future clients that connect fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) { let applied = &mut self.applied_client_hot_reload_message; // Merge the assets, unknown files, and templates // We keep the newer change if there is both a old and new change let mut templates: HashMap = std::mem::take(&mut applied.templates) .into_iter() .map(|template| (template.key.clone(), template)) .collect(); let mut assets: HashSet = std::mem::take(&mut applied.assets).into_iter().collect(); for template in &msg.templates { templates.insert(template.key.clone(), template.clone()); } assets.extend(msg.assets.iter().cloned()); applied.templates = templates.into_values().collect(); applied.assets = assets.into_iter().collect(); applied.jump_table = self.client.patches.last().cloned(); } /// Register the files from the workspace into our file watcher. /// /// This very simply looks for all Rust files in the workspace and adds them to the filemap. /// /// Once the builds complete we'll use the depinfo files to get the actual files that are used, /// making our watcher more accurate. Filling the filemap here is intended to catch any file changes /// in between the first build and the depinfo file being generated. /// /// We don't want watch any registry files since that generally causes a huge performance hit - /// we mostly just care about workspace files and local dependencies. /// /// Dep-info file background: /// fn load_rsx_filemap(&mut self) { self.fill_filemap_from_krate(self.client.build.crate_dir()); if let Some(server) = self.server.as_ref() { self.fill_filemap_from_krate(server.build.crate_dir()); } for krate in self.all_watched_crates() { self.fill_filemap_from_krate(krate); } } /// Fill the filemap with files from the filesystem, using the given filter to determine which files to include. /// /// You can use the filter with something like a gitignore to only include files that are relevant to your project. /// We'll walk the filesystem from the given path and recursively search for all files that match the filter. /// /// The filter function takes a path and returns true if the file should be included in the filemap. /// Generally this will only be .rs files /// /// If a file couldn't be parsed, we don't fail. Instead, we save the error. /// /// todo: There are known bugs here when handling gitignores. fn fill_filemap_from_krate(&mut self, crate_dir: PathBuf) { for entry in walkdir::WalkDir::new(crate_dir).into_iter().flatten() { if self .workspace .ignore .matched(entry.path(), entry.file_type().is_dir()) .is_ignore() { continue; } let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("rs") { if let Ok(contents) = std::fs::read_to_string(path) { self.file_map.insert( path.to_path_buf(), CachedFile { contents, most_recent: None, templates: Default::default(), }, ); } } } } /// Commit the changes to the filemap, overwriting the contents of the files /// /// Removes any cached templates and replaces the contents of the files with the most recent /// /// todo: we should-reparse the contents so we never send a new version, ever fn clear_cached_rsx(&mut self) { for cached_file in self.file_map.values_mut() { if let Some(most_recent) = cached_file.most_recent.take() { cached_file.contents = most_recent; } cached_file.templates.clear(); } } fn watch_filesystem(&mut self) { // Watch the folders of the crates that we're interested in for path in self.watch_paths( self.client.build.crate_dir(), self.client.build.crate_package, ) { tracing::trace!("Watching path {path:?}"); if let Err(err) = self.watcher.watch(&path, RecursiveMode::Recursive) { handle_notify_error(err); } } if let Some(server) = self.server.as_ref() { // Watch the server's crate directory as well for path in self.watch_paths(server.build.crate_dir(), server.build.crate_package) { tracing::trace!("Watching path {path:?}"); if let Err(err) = self.watcher.watch(&path, RecursiveMode::Recursive) { handle_notify_error(err); } } } // Also watch the crates themselves, but not recursively, such that we can pick up new folders for krate in self.all_watched_crates() { tracing::trace!("Watching path {krate:?}"); if let Err(err) = self.watcher.watch(&krate, RecursiveMode::NonRecursive) { handle_notify_error(err); } } // Also watch the workspace dir, non recursively, such that we can pick up new folders there too if let Err(err) = self.watcher.watch( self.workspace.krates.workspace_root().as_std_path(), RecursiveMode::NonRecursive, ) { handle_notify_error(err); } } /// Return the list of paths that we should watch for changes. fn watch_paths(&self, crate_dir: PathBuf, crate_package: NodeId) -> Vec { let mut watched_paths = vec![]; // Get a list of *all* the crates with Rust code that we need to watch. // This will end up being dependencies in the workspace and non-workspace dependencies on the user's computer. 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 let ignore = self.workspace.ignore_for_krate(&krate_root); for entry in krate_root.read_dir().into_iter().flatten() { let Ok(entry) = entry else { continue; }; if ignore .matched(entry.path(), entry.path().is_dir()) .is_ignore() { continue; } watched_paths.push(entry.path().to_path_buf()); } } watched_paths.dedup(); watched_paths } /// Get all the Manifest paths for dependencies that we should watch. Will not return anything /// in the `.cargo` folder - only local dependencies will be watched. /// /// This returns a list of manifest paths /// /// Extend the watch path to include: /// /// - the assets directory - this is so we can hotreload CSS and other assets by default /// - the Cargo.toml file - this is so we can hotreload the project if the user changes dependencies /// - the Dioxus.toml file - this is so we can hotreload the project if the user changes the Dioxus config fn local_dependencies(&self, crate_package: NodeId) -> Vec { let mut paths = vec![]; for (dependency, _edge) in self.workspace.krates.get_deps(crate_package) { let krate = match dependency { krates::Node::Krate { krate, .. } => krate, krates::Node::Feature { krate_index, .. } => { &self.workspace.krates[krate_index.index()] } }; if krate .manifest_path .components() .any(|c| c.as_str() == ".cargo") { continue; } paths.push( krate .manifest_path .parent() .unwrap() .to_path_buf() .into_std_path_buf(), ); } paths } // todo: we need to make sure we merge this for all the running packages fn all_watched_crates(&self) -> Vec { let crate_package = self.client().build.crate_package; let crate_dir = self.client().build.crate_dir(); let mut krates: Vec = self .local_dependencies(crate_package) .into_iter() .map(|p| { p.parent() .expect("Local manifest to exist and have a parent") .to_path_buf() }) .chain(Some(crate_dir)) .collect(); if let Some(server) = self.server.as_ref() { let server_crate_package = server.build.crate_package; let server_crate_dir = server.build.crate_dir(); let server_krates: Vec = self .local_dependencies(server_crate_package) .into_iter() .map(|p| { p.parent() .expect("Server manifest to exist and have a parent") .to_path_buf() }) .chain(Some(server_crate_dir)) .collect(); krates.extend(server_krates); } krates.dedup(); krates } /// Compute the ordered compilation chain from a changed workspace crate to the tip crate. /// /// Returns crate names (underscore-normalized) in compilation order: the changed crate first, /// then each intermediate workspace crate that depends on it, ending with the tip crate. /// /// Uses BFS from the tip crate through its workspace dependencies to find the path. /// If the changed crate IS the tip crate, returns just `[tip]`. pub(crate) fn workspace_dep_chain(&self, changed_crate: &str) -> Vec { let tip_name = self.client.build.main_target.replace('-', "_"); // If the changed crate is the tip, no chain needed if changed_crate == tip_name { return vec![tip_name]; } // Build a map of workspace crate names to their krates NodeIds let mut name_to_node: HashMap = HashMap::new(); for member in self.workspace.krates.workspace_members() { if let krates::Node::Krate { id, krate, .. } = member { let normalized = krate.name.replace('-', "_"); name_to_node.insert(normalized, self.workspace.krates.nid_for_kid(id).unwrap()); } } // BFS/DFS from tip through workspace deps to find path to changed crate. // We walk the dependency edges (tip → its deps → their deps → ...) looking for changed_crate. let Some(&tip_node) = name_to_node.get(&tip_name) else { return vec![changed_crate.to_string()]; }; // parent[node] = the workspace crate that depends on it (closer to tip) let mut parent: HashMap> = HashMap::new(); parent.insert(tip_node, None); let mut queue = VecDeque::new(); queue.push_back(tip_node); let mut target_node = None; while let Some(current) = queue.pop_front() { for (dep, _edge) in self.workspace.krates.get_deps(current) { let (dep_name, dep_nid) = match dep { krates::Node::Krate { id, krate, .. } => { let normalized = krate.name.replace('-', "_"); let nid = self.workspace.krates.nid_for_kid(id).unwrap(); (normalized, nid) } _ => continue, }; // Only traverse workspace members if !name_to_node.contains_key(&dep_name) { continue; } if parent.contains_key(&dep_nid) { continue; // already visited } parent.insert(dep_nid, Some(current)); if dep_name == changed_crate { target_node = Some(dep_nid); break; } queue.push_back(dep_nid); } if target_node.is_some() { break; } } // Reconstruct the path from changed_crate → ... → tip let Some(target) = target_node else { // Changed crate not found in workspace dep graph — just compile it alone return vec![changed_crate.to_string()]; }; let mut chain = vec![]; let mut node = target; loop { // Find the crate name for this node let krate = &self.workspace.krates[node]; chain.push(krate.name.replace('-', "_")); match parent.get(&node) { Some(Some(parent_node)) => node = *parent_node, _ => break, } } chain } /// Order a set of changed workspace crates so that deeper dependencies compile first. /// /// Uses `workspace_dep_chain` to determine the depth of each crate in the dependency graph, /// then sorts so that leaves (deepest deps) compile before crates closer to the tip. fn order_changed_crates(&self, files: &[PathBuf]) -> Vec { // Determine which workspace crates changed based on the file paths. // Order them so deeper deps compile first (leaves before dependents). let changed_set: HashSet = files .iter() .filter_map(|f| self.file_to_workspace_crate(f)) .collect(); let mut crates_with_depth: Vec<_> = changed_set .iter() .map(|c| (c.clone(), self.workspace_dep_chain(c).len())) .collect(); // Longer chain = deeper in dep tree = should compile first crates_with_depth.sort_by(|a, b| b.1.cmp(&a.1)); crates_with_depth.into_iter().map(|(c, _)| c).collect() } /// Map a changed file path to the workspace crate it belongs to. /// /// Returns the crate name in rustc convention (hyphens → underscores), matching the /// `--crate-name` arg used by rustc and the keys in `workspace_rustc_args`. /// /// Finds the workspace member whose crate directory is the longest prefix of the file path. fn file_to_workspace_crate(&self, file: &Path) -> Option { let mut best_match: Option<(String, usize)> = None; for member in self.workspace.krates.workspace_members() { if let krates::Node::Krate { krate, .. } = member { let Some(crate_dir) = krate.manifest_path.parent() else { continue; }; if let Ok(relative) = file.strip_prefix(crate_dir.as_std_path()) { let depth = relative.components().count(); let is_better = best_match .as_ref() .map_or(true, |(_, best_depth)| depth < *best_depth); if is_better { best_match = Some((krate.name.replace('-', "_"), depth)); } } } } best_match.map(|(name, _)| name) } /// Check if this is a fullstack build. This means that there is an additional build with the `server` platform. pub(crate) fn is_fullstack(&self) -> bool { self.fullstack } /// Return a number between 0 and 1 representing the progress of the server build pub(crate) fn server_compile_progress(&self) -> f64 { let Some(server) = self.server.as_ref() else { return 0.0; }; server.compiled_crates as f64 / server.expected_crates as f64 } pub(crate) async fn open_debugger(&mut self, dev: &WebServer, build: BuildId) { if self.use_hotpatch_engine { tracing::warn!("Debugging symbols might not work properly with hotpatching enabled. Consider disabling hotpatching for debugging."); } match build { BuildId::PRIMARY => { _ = self.client.open_debugger(dev).await; } BuildId::SECONDARY => { if let Some(server) = self.server.as_mut() { _ = server.open_debugger(dev).await; } } _ => {} } } } /// Bind a listener to any point and return it /// When the listener is dropped, the socket will be closed, but we'll still have a port that we /// can bind our proxy to. /// /// Todo: we might want to do this on every new build in case the OS tries to bind things to this port /// and we don't already have something bound to it. There's no great way of "reserving" a port. fn get_available_port(address: IpAddr, prefer: Option) -> Option { // First, try to bind to the preferred port if let Some(port) = prefer { if let Ok(_listener) = TcpListener::bind((address, port)) { return Some(port); } } // Otherwise, try to bind to any port and return the first one we can TcpListener::bind((address, 0)) .and_then(|listener| listener.local_addr().map(|f| f.port())) .ok() } fn create_notify_watcher( tx: UnboundedSender, wsl_poll_interval: u64, ) -> Box { // Build the event handler for notify. // This has been known to be a source of many problems, unfortunately, since notify handling seems to be flakey across platforms let handler = move |info: notify::Result| { let Ok(event) = info else { return; }; let is_allowed_notify_event = match event.kind { EventKind::Modify(ModifyKind::Data(_)) => true, EventKind::Modify(ModifyKind::Name(_)) => true, // The primary modification event on WSL's poll watcher. EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true, // Catch-all for unknown event types (windows) EventKind::Modify(ModifyKind::Any) => true, EventKind::Modify(ModifyKind::Metadata(_)) => false, // Don't care about anything else. EventKind::Create(_) => true, EventKind::Remove(_) => true, _ => false, }; if is_allowed_notify_event { _ = tx.unbounded_send(event); } }; const NOTIFY_ERROR_MSG: &str = "Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories."; // On wsl, we need to poll the filesystem for changes if is_wsl() { return Box::new( notify::PollWatcher::new( handler, Config::default().with_poll_interval(Duration::from_secs(wsl_poll_interval)), ) .expect(NOTIFY_ERROR_MSG), ); } // Otherwise we can use the recommended watcher Box::new(notify::recommended_watcher(handler).expect(NOTIFY_ERROR_MSG)) } fn handle_notify_error(err: notify::Error) { tracing::debug!("Failed to watch path: {}", err); match err.kind { notify::ErrorKind::Io(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { tracing::error!("Failed to watch path: permission denied. {:?}", err.paths) } notify::ErrorKind::MaxFilesWatch => { tracing::error!("Failed to set up file watcher: too many files to watch") } _ => {} } } /// Detects if `dx` is being ran in a WSL environment. /// /// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the `WSL_1` or `WSL_2` files. /// This may fail in the future as it isn't guaranteed by Microsoft. /// See fn is_wsl() -> bool { const WSL_1: &str = "/proc/sys/kernel/osrelease"; const WSL_2: &str = "/proc/version"; const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"]; // Test 1st File if let Ok(content) = std::fs::read_to_string(WSL_1) { let lowercase = content.to_lowercase(); for keyword in WSL_KEYWORDS { if lowercase.contains(keyword) { return true; } } } // Test 2nd File if let Ok(content) = std::fs::read_to_string(WSL_2) { let lowercase = content.to_lowercase(); for keyword in WSL_KEYWORDS { if lowercase.contains(keyword) { return true; } } } false } /// Format a Duration for human-readable output. fn format_duration_ms(d: Duration) -> String { let total_ms = d.as_millis() as u64; if total_ms < 1000 { format!("{total_ms}ms") } else { let secs = total_ms as f64 / 1000.0; format!("{secs:.2}s") } }