Hotreloading of for/if/body, formatted strings, literals, component props, nested rsx, light CLI rewrite, cli TUI (#2258)

Hotreload the contents of for loops, if chains, component bodies, props, attributes, and any literals discovered in rsx!
Add a TUI renderer to the CLI.
Improve the CLI build system to be async and parallel.
Refactor RSX to allow partial expansion of expressions.
Merge autofmt implementations for consistency.
Merge the representation of elements and components under the hood.
Add a diagnostics system for rsx for improved error messages.
Drop interprocess and move to websockets for communication between the CLI and the server.
Assign IDs to nodes and attributes in a stable way to be used in non compiler contexts.
Add hotreloading to any body of component/for loop/if chain/etc.

---------

Co-authored-by: Evan Almloff <evanalmloff@gmail.com>
Co-authored-by: Liam Mitchell <liamkarlmitchell@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-07-17 19:11:18 -07:00
1 parent 176e67e5b7
commit 88af3e7eff
181 files changed
+12755 -10806

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
use crate::builder::BuildRequest;
use crate::builder::BuildResult;
use crate::builder::UpdateBuildProgress;
use crate::dioxus_crate::DioxusCrate;
use crate::serve::Serve;
use crate::Result;
use dioxus_cli_config::Platform;
use futures_channel::mpsc::UnboundedReceiver;
use futures_util::future::OptionFuture;
use futures_util::stream::select_all;
use futures_util::StreamExt;
use std::process::Stdio;
use tokio::{
process::{Child, Command},
task::JoinHandle,
};
/// A handle to ongoing builds and then the spawned tasks themselves
pub struct Builder {
/// The results of the build
build_results: Option<JoinHandle<Result<Vec<BuildResult>>>>,
/// The progress of the builds
build_progress: Vec<(Platform, UnboundedReceiver<UpdateBuildProgress>)>,
/// The application we are building
config: DioxusCrate,
/// The arguments for the build
serve: Serve,
/// The children of the build process
pub children: Vec<(Platform, Child)>,
}
impl Builder {
/// Create a new builder
pub fn new(config: &DioxusCrate, serve: &Serve) -> Self {
let serve = serve.clone();
let config = config.clone();
Self {
build_results: None,
build_progress: Vec::new(),
config: config.clone(),
serve,
children: Vec::new(),
}
}
/// Start a new build - killing the current one if it exists
pub fn build(&mut self) {
self.shutdown();
let build_requests =
BuildRequest::create(true, &self.config, self.serve.build_arguments.clone());
let mut set = tokio::task::JoinSet::new();
for build_request in build_requests {
let (mut tx, rx) = futures_channel::mpsc::unbounded();
self.build_progress
.push((build_request.build_arguments.platform(), rx));
set.spawn(async move {
let res = build_request.build(tx.clone()).await;
if let Err(err) = &res {
let _ = tx.start_send(UpdateBuildProgress {
stage: crate::builder::Stage::Finished,
update: crate::builder::UpdateStage::Failed(err.to_string()),
});
}
res
});
}
self.build_results = Some(tokio::spawn(async move {
let mut all_results = Vec::new();
while let Some(result) = set.join_next().await {
let res = result.map_err(|err| {
crate::Error::Unique(format!("Panic while building project: {err:?}"))
})??;
all_results.push(res);
}
Ok(all_results)
}));
}
/// Wait for any new updates to the builder - either it completed or gave us a message etc
pub async fn wait(&mut self) -> Result<BuilderUpdate> {
// Wait for build progress
let mut next = select_all(
self.build_progress
.iter_mut()
.map(|(platform, rx)| rx.map(move |update| (*platform, update))),
);
let results: OptionFuture<_> = self.build_results.as_mut().into();
// Wait for the next build result
tokio::select! {
Some(build_results) = results => {
self.build_results = None;
// If we have a build result, bubble it up to the main loop
let build_results = build_results.map_err(|_| crate::Error::Unique("Build join failed".to_string()))??;
Ok(BuilderUpdate::Ready { results: build_results })
}
Some((platform, update)) = next.next() => {
// If we have a build progress, send it to the screen
Ok(BuilderUpdate::Progress { platform, update })
}
else => {
std::future::pending::<()>().await;
unreachable!("Pending cannot resolve")
},
}
}
/// Shutdown the current build process
pub(crate) fn shutdown(&mut self) {
for (_, mut child) in self.children.drain(..) {
// Gracefully shtudown the desktop app
// It might have a receiver to do some cleanup stuff
if let Some(pid) = child.id() {
// on unix, we can send a signal to the process to shut down
#[cfg(unix)]
{
_ = Command::new("kill")
.args(["-s", "TERM", &pid.to_string()])
.stderr(Stdio::null())
.stdout(Stdio::null())
.spawn();
}
// on windows, use the `taskkill` command
#[cfg(windows)]
{
_ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.stderr(Stdio::null())
.stdout(Stdio::null())
.spawn();
}
}
// Todo: add a timeout here to kill the process if it doesn't shut down within a reasonable time
_ = child.start_kill();
}
if let Some(tasks) = self.build_results.take() {
tasks.abort();
}
self.build_progress.clear();
}
}
pub enum BuilderUpdate {
Progress {
platform: Platform,
update: UpdateBuildProgress,
},
Ready {
results: Vec<BuildResult>,
},
}
@@ -0,0 +1,313 @@
use dioxus_core::{internal::HotReloadLiteral, Template};
use dioxus_rsx::{
hot_reload::{diff_rsx, ChangedRsx},
CallBody, HotReloadedTemplate, HotReloadingContext,
};
use krates::cm::MetadataCommand;
use krates::Cmd;
pub use std::collections::HashMap;
use std::{ffi::OsStr, path::PathBuf};
pub use std::{fs, io, path::Path};
pub use std::{fs::File, io::Read};
use syn::spanned::Spanned;
pub struct FileMap {
pub map: HashMap<PathBuf, CachedSynFile>,
/// Any errors that occurred while building the FileMap that were not fatal
pub errors: Vec<io::Error>,
pub in_workspace: HashMap<PathBuf, Option<PathBuf>>,
pub changed_lits: HashMap<String, HotReloadLiteral>,
}
/// A cached file that has been parsed
///
/// We store the templates found in this file
pub struct CachedSynFile {
pub raw: String,
pub templates: HashMap<&'static str, Template>,
}
impl FileMap {
/// Create a new FileMap from a crate directory
///
/// TODO: this should be created with a gitignore filter
pub fn create<Ctx: HotReloadingContext>(path: PathBuf) -> io::Result<FileMap> {
Self::create_with_filter::<Ctx>(path, |p| {
// skip some stuff we know is large by default
p.file_name() == Some(OsStr::new("target"))
|| p.file_name() == Some(OsStr::new("node_modules"))
})
}
/// Create a new FileMap from a crate directory
///
/// Takes a filter that when returns true, the file will be filtered out (ie not tracked)
/// Note that this is inverted from a typical .filter() method.
pub fn create_with_filter<Ctx: HotReloadingContext>(
crate_dir: PathBuf,
mut filter: impl FnMut(&Path) -> bool,
) -> io::Result<FileMap> {
let FileMapSearchResult { map, errors } = find_rs_files(crate_dir.clone(), &mut filter);
let mut map = Self {
map,
errors,
in_workspace: HashMap::new(),
changed_lits: HashMap::new(),
};
map.load_assets::<Ctx>(crate_dir.as_path());
Ok(map)
}
/// Start watching assets for changes
///
/// This just diffs every file against itself and populates the tracked assets as it goes
pub fn load_assets<Ctx: HotReloadingContext>(&mut self, crate_dir: &Path) {
let keys = self.map.keys().cloned().collect::<Vec<_>>();
for file in keys {
_ = self.update_rsx::<Ctx>(file.as_path(), crate_dir);
}
}
/// Try to update the rsx in a file
pub fn update_rsx<Ctx: HotReloadingContext>(
&mut self,
file_path: &Path,
crate_dir: &Path,
) -> Result<Vec<HotReloadedTemplate>, HotreloadError> {
let src = std::fs::read_to_string(file_path)?;
// If we can't parse the contents we want to pass it off to the build system to tell the user that there's a syntax error
let syntax = syn::parse_file(&src).map_err(|_err| HotreloadError::Parse)?;
let in_workspace = self.child_in_workspace(crate_dir)?;
// Get the cached file if it exists, otherwise try to create it
let Some(old_cached) = self.map.get_mut(file_path) else {
// if this is a new file, rebuild the project
let mut map = FileMap::create::<Ctx>(crate_dir.to_path_buf())?;
if let Some(err) = map.errors.pop() {
return Err(HotreloadError::Failure(err));
}
// merge the new map into the old map
self.map.extend(map.map);
return Err(HotreloadError::Notreloadable);
};
// If the cached file is not a valid rsx file, rebuild the project, forcing errors
// TODO: in theory the error is simply in the RsxCallbody. We could attempt to parse it using partial expansion
// And collect out its errors instead of giving up to a full rebuild
let old = syn::parse_file(&old_cached.raw).map_err(|_e| HotreloadError::Parse)?;
let instances = match diff_rsx(&syntax, &old) {
// If the changes were just some rsx, we can just update the template
//
// However... if the changes involved code in the rsx itself, this should actually be a CodeChanged
Some(rsx_calls) => rsx_calls,
// If the changes were some code, we should insert the file into the map and rebuild
// todo: not sure we even need to put the cached file into the map, but whatever
None => {
let cached_file = CachedSynFile {
raw: src.clone(),
templates: HashMap::new(),
};
self.map.insert(file_path.to_path_buf(), cached_file);
return Err(HotreloadError::Notreloadable);
}
};
let mut out_templates = vec![];
for calls in instances.into_iter() {
let ChangedRsx { old, new } = calls;
let old_start = old.span().start();
let old_parsed = syn::parse2::<CallBody>(old.tokens);
let new_parsed = syn::parse2::<CallBody>(new.tokens);
let (Ok(old_call_body), Ok(new_call_body)) = (old_parsed, new_parsed) else {
continue;
};
// if the file!() macro is invoked in a workspace, the path is relative to the workspace root, otherwise it's relative to the crate root
// we need to check if the file is in a workspace or not and strip the prefix accordingly
let prefix = match in_workspace {
Some(ref workspace) => workspace,
_ => crate_dir,
};
let Ok(file) = file_path.strip_prefix(prefix) else {
continue;
};
// We leak the template since templates are a compiletime value
// This is not ideal, but also not a huge deal for hot reloading
// TODO: we could consider arena allocating the templates and dropping them when the connection is closed
let leaked_location = Box::leak(template_location(old_start, file).into_boxed_str());
// Returns a list of templates that are hotreloadable
let hotreload_result = dioxus_rsx::hotreload::HotReloadedTemplate::new::<Ctx>(
&old_call_body,
&new_call_body,
leaked_location,
self.changed_lits.clone(),
);
// if the template is not hotreloadable, we need to do a full rebuild
let Some(mut results) = hotreload_result else {
return Err(HotreloadError::Notreloadable);
};
// self.changed_lits
// .extend(std::mem::take(&mut results.changed_lits));
// Be careful to not send the bad templates
results.templates.retain(|template| {
// dioxus cannot handle empty templates...
if template.roots.is_empty() {
return false;
}
// if the template is the same, don't send it
if old_cached.templates.get(template.name) == Some(template) {
return false;
};
// Update the most recent idea of the template
// This lets us know if the template has changed so we don't need to send it
old_cached.templates.insert(template.name, *template);
true
});
out_templates.push(results);
}
Ok(out_templates)
}
fn child_in_workspace(&mut self, crate_dir: &Path) -> io::Result<Option<PathBuf>> {
if let Some(in_workspace) = self.in_workspace.get(crate_dir) {
return Ok(in_workspace.clone());
}
let mut cmd = Cmd::new();
let manafest_path = crate_dir.join("Cargo.toml");
cmd.manifest_path(&manafest_path);
let cmd: MetadataCommand = cmd.into();
let metadata = cmd
.exec()
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
let in_workspace = metadata.workspace_root != crate_dir;
let workspace_path = in_workspace.then(|| metadata.workspace_root.into());
self.in_workspace
.insert(crate_dir.to_path_buf(), workspace_path.clone());
Ok(workspace_path)
}
}
pub fn template_location(old_start: proc_macro2::LineColumn, file: &Path) -> String {
let line = old_start.line;
let column = old_start.column + 1;
// Always ensure the path components are separated by `/`.
let path = file
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
path
+ ":"
+ line.to_string().as_str()
+ ":"
+ column.to_string().as_str()
// the byte index doesn't matter, but dioxus needs it
+ ":0"
}
struct FileMapSearchResult {
map: HashMap<PathBuf, CachedSynFile>,
errors: Vec<io::Error>,
}
// todo: we could just steal the mod logic from rustc itself
fn find_rs_files(root: PathBuf, filter: &mut impl FnMut(&Path) -> bool) -> FileMapSearchResult {
let mut files = HashMap::new();
let mut errors = Vec::new();
if root.is_dir() {
let read_dir = match fs::read_dir(root) {
Ok(read_dir) => read_dir,
Err(err) => {
errors.push(err);
return FileMapSearchResult { map: files, errors };
}
};
for entry in read_dir.flatten() {
let path = entry.path();
if !filter(&path) {
let FileMapSearchResult {
map,
errors: child_errors,
} = find_rs_files(path, filter);
errors.extend(child_errors);
files.extend(map);
}
}
} else if root.extension().and_then(|s| s.to_str()) == Some("rs") {
if let Ok(mut file) = File::open(root.clone()) {
let mut src = String::new();
match file.read_to_string(&mut src) {
Ok(_) => {
let cached_file = CachedSynFile {
raw: src.clone(),
templates: HashMap::new(),
};
// track assets while we're here
files.insert(root, cached_file);
}
Err(err) => {
errors.push(err);
}
}
}
}
FileMapSearchResult { map: files, errors }
}
#[derive(Debug)]
pub enum HotreloadError {
Failure(io::Error),
Parse,
Notreloadable,
}
impl std::fmt::Display for HotreloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failure(err) => write!(f, "Failed to parse file: {}", err),
Self::Parse => write!(f, "Failed to parse file"),
Self::Notreloadable => write!(f, "Template is not hotreloadable"),
}
}
}
impl From<io::Error> for HotreloadError {
fn from(err: io::Error) -> Self {
HotreloadError::Failure(err)
}
}
+1
View File
@@ -0,0 +1 @@
+157
View File
@@ -0,0 +1,157 @@
use crate::cli::serve::Serve;
use crate::dioxus_crate::DioxusCrate;
use crate::Result;
use dioxus_cli_config::Platform;
use tokio::task::yield_now;
mod builder;
mod hot_reloading_file_map;
mod logs_tab;
mod output;
mod proxy;
mod server;
mod watcher;
use builder::*;
use output::*;
use server::*;
use watcher::*;
/// For *all* builds the CLI spins up a dedicated webserver, file watcher, and build infrastructure to serve the project.
///
/// This includes web, desktop, mobile, fullstack, etc.
///
/// Platform specifics:
/// - Web: we need to attach a filesystem server to our devtools webserver to serve the project. We
/// want to emulate GithubPages here since most folks are deploying there and expect things like
/// basepath to match.
/// - Fullstack: We spin up the same dev server but in this case the fullstack server itself needs to
/// proxy all dev requests to our dev server
/// - Desktop: We spin up the dev server but without a filesystem server.
/// - Mobile: Basically the same as desktop.
///
/// Notes:
/// - All filesystem changes are tracked here
/// - We send all updates to connected websocket connections. Even desktop connects via the websocket
/// - Right now desktop compiles tokio-tungstenite to do the connection but we could in theory reuse
/// the websocket logic from the webview for thinner builds.
///
/// Todos(Jon):
/// - I'd love to be able to configure the CLI while it's running so we can change settingaon the fly.
/// This would require some light refactoring and potentially pulling in something like ratatui.
/// - Build a custom subscriber for logs by tools within this
/// - Handle logs from the build engine separately?
/// - Consume logs from the wasm for web/fullstack
/// - I want us to be able to detect a `server_fn` in the project and then upgrade from a static server
/// to a dynamic one on the fly.
pub async fn serve_all(serve: Serve, dioxus_crate: DioxusCrate) -> Result<()> {
let mut server = Server::start(&serve, &dioxus_crate);
let mut watcher = Watcher::start(&dioxus_crate);
let mut screen = Output::start(&serve)
.await
.expect("Failed to open terminal logger");
let mut builder = Builder::new(&dioxus_crate, &serve);
// Start the first build
builder.build();
loop {
// Make sure we don't hog the CPU: these loop { select! {} } blocks can starve the executor
yield_now().await;
// Draw the state of the server to the screen
screen.render(&serve, &dioxus_crate, &builder, &server, &watcher);
// And then wait for any updates before redrawing
tokio::select! {
// rebuild the project or hotreload it
_ = watcher.wait() => {
if !watcher.pending_changes() {
continue;
}
let changed_files = watcher.dequeue_changed_files(&dioxus_crate);
// if change is hotreloadable, hotreload it
// and then send that update to all connected clients
if let Some(hr) = watcher.attempt_hot_reload(&dioxus_crate, changed_files) {
// Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild
if hr.templates.is_empty() && hr.assets.is_empty() {
continue
}
server.send_hotreload(hr).await;
} else {
// If the change is not binary patchable, rebuild the project
// We're going to kick off a new build, interrupting the current build if it's ongoing
builder.build();
// Tell the server to show a loading page for any new requests
server.start_build().await;
}
}
// reload the page
msg = server.wait() => {
// Run the server in the background
// Waiting for updates here lets us tap into when clients are added/removed
if let Some(msg) = msg {
screen.new_ws_message(Platform::Web, msg);
}
}
// Handle updates from the build engine
application = builder.wait() => {
// Wait for logs from the build engine
// These will cause us to update the screen
// We also can check the status of the builds here in case we have multiple ongoing builds
match application {
Ok(BuilderUpdate::Progress { platform, update }) => {
screen.new_build_logs(platform, update);
server.update_build_status(screen.build_progress.progress()).await;
}
Ok(BuilderUpdate::Ready { results }) => {
if !results.is_empty() {
builder.children.clear();
}
// If we have a build result, open it
for build_result in results.iter() {
let child = build_result.open(&serve.server_arguments, server.fullstack_address());
match child {
Ok(Some(child_proc)) => builder.children.push((build_result.platform,child_proc)),
Err(_e) => break,
_ => {}
}
}
// Make sure we immediately capture the stdout/stderr of the executable -
// otherwise it'll clobber our terminal output
screen.new_ready_app(&mut builder, results);
// And then finally tell the server to reload
server.send_reload().await;
},
Err(err) => {
server.send_build_error(err).await;
}
}
}
// Handle input from the user using our settings
res = screen.wait() => {
if res.is_err() {
break;
}
}
}
}
// Run our cleanup logic here - maybe printing as we go?
// todo: more printing, logging, error handling in this phase
_ = screen.shutdown();
_ = server.shutdown().await;
builder.shutdown();
Ok(())
}
+841
View File
@@ -0,0 +1,841 @@
use crate::{
builder::{BuildMessage, MessageType, Stage, UpdateBuildProgress},
dioxus_crate::DioxusCrate,
};
use crate::{
builder::{BuildResult, UpdateStage},
serve::Serve,
};
use core::panic;
use crossterm::{
event::{Event, EventStream, KeyCode, KeyModifiers, MouseEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
tty::IsTty,
ExecutableCommand,
};
use dioxus_cli_config::Platform;
use dioxus_hot_reload::ClientMsg;
use futures_util::{future::select_all, Future, StreamExt};
use ratatui::{prelude::*, widgets::*, TerminalOptions, Viewport};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
io::{self, stdout},
pin::Pin,
rc::Rc,
time::{Duration, Instant},
};
use tokio::{
io::{AsyncBufReadExt, BufReader, Lines},
process::{ChildStderr, ChildStdout},
};
use tracing::Level;
use super::{Builder, Server, Watcher};
#[derive(Default)]
pub struct BuildProgress {
build_logs: HashMap<Platform, ActiveBuild>,
}
impl BuildProgress {
pub fn progress(&self) -> f64 {
self.build_logs
.values()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|build| match build.stage {
Stage::Initializing => 0.0,
Stage::InstallingWasmTooling => 0.0,
Stage::Compiling => build.progress,
Stage::OptimizingWasm | Stage::OptimizingAssets | Stage::Finished => 1.0,
})
.unwrap_or_default()
}
}
pub struct Output {
term: Rc<RefCell<Option<TerminalBackend>>>,
// optional since when there's no tty there's no eventstream to read from - just stdin
events: Option<EventStream>,
_rustc_version: String,
_rustc_nightly: bool,
_dx_version: String,
interactive: bool,
pub(crate) build_progress: BuildProgress,
running_apps: HashMap<Platform, RunningApp>,
is_cli_release: bool,
platform: Platform,
num_lines_with_wrapping: u16,
term_height: u16,
scroll: u16,
fly_modal_open: bool,
anim_start: Instant,
tab: Tab,
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum Tab {
Console,
BuildLog,
}
type TerminalBackend = Terminal<CrosstermBackend<io::Stdout>>;
impl Output {
pub async fn start(cfg: &Serve) -> io::Result<Self> {
let interactive = std::io::stdout().is_tty() && cfg.interactive.unwrap_or(true);
let mut events = None;
if interactive {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
// workaround for ci where the terminal is not fully initialized
// this stupid bug
// https://github.com/crossterm-rs/crossterm/issues/659
events = Some(EventStream::new());
};
// set the panic hook to fix the terminal
set_fix_term_hook();
let term: Option<TerminalBackend> = Terminal::with_options(
CrosstermBackend::new(stdout()),
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)
.ok();
// todo: re-enable rustc version
// let rustc_version = rustc_version().await;
// let rustc_nightly = rustc_version.contains("nightly") || cfg.target_args.nightly;
let _rustc_version = String::from("1.0.0");
let _rustc_nightly = false;
let mut dx_version = String::new();
dx_version.push_str(env!("CARGO_PKG_VERSION"));
let is_cli_release = crate::dx_build_info::PROFILE == "release";
if !is_cli_release {
if let Some(hash) = crate::dx_build_info::GIT_COMMIT_HASH_SHORT {
let hash = &hash.trim_start_matches('g')[..4];
dx_version.push('-');
dx_version.push_str(hash);
}
}
let platform = cfg.build_arguments.platform.expect("To be resolved by now");
Ok(Self {
term: Rc::new(RefCell::new(term)),
events,
_rustc_version,
_rustc_nightly,
_dx_version: dx_version,
interactive,
is_cli_release,
platform,
fly_modal_open: false,
build_progress: Default::default(),
running_apps: HashMap::new(),
scroll: 0,
term_height: 0,
num_lines_with_wrapping: 0,
anim_start: Instant::now(),
tab: Tab::BuildLog,
})
}
/// Wait for either the ctrl_c handler or the next event
///
/// Why is the ctrl_c handler here?
///
/// Also tick animations every few ms
pub async fn wait(&mut self) -> io::Result<()> {
// sorry lord
let user_input = match self.events.as_mut() {
Some(events) => {
let pinned: Pin<Box<dyn Future<Output = Option<Result<Event, _>>>>> =
Box::pin(events.next());
pinned
}
None => Box::pin(futures_util::future::pending()) as Pin<Box<dyn Future<Output = _>>>,
};
let has_running_apps = !self.running_apps.is_empty();
let next_stdout = self.running_apps.values_mut().map(|app| {
let future = async move {
let (stdout, stderr) = match &mut app.stdout {
Some(stdout) => (stdout.stdout.next_line(), stdout.stderr.next_line()),
None => return futures_util::future::pending().await,
};
tokio::select! {
Ok(Some(line)) = stdout => (app.result.platform, Some(line), None),
Ok(Some(line)) = stderr => (app.result.platform, None, Some(line)),
else => futures_util::future::pending().await,
}
};
Box::pin(future)
});
let next_stdout = async {
if has_running_apps {
select_all(next_stdout).await.0
} else {
futures_util::future::pending().await
}
};
let animation_timeout = tokio::time::sleep(Duration::from_millis(300));
tokio::select! {
(platform, stdout, stderr) = next_stdout => {
if let Some(stdout) = stdout {
self.running_apps.get_mut(&platform).unwrap().stdout.as_mut().unwrap().stdout_line.push_str(&stdout);
self.push_log(platform, BuildMessage {
level: Level::INFO,
message: MessageType::Text(stdout),
source: Some("app".to_string()),
})
}
if let Some(stderr) = stderr {
self.running_apps.get_mut(&platform).unwrap().stdout.as_mut().unwrap().stderr_line.push_str(&stderr);
self.push_log(platform, BuildMessage {
level: Level::ERROR,
message: MessageType::Text(stderr),
source: Some("app".to_string()),
})
}
},
event = user_input => {
self.handle_events(event.unwrap().unwrap()).await?;
// self.handle_input(event.unwrap().unwrap())?;
}
_ = animation_timeout => {}
}
Ok(())
}
pub fn shutdown(&mut self) -> io::Result<()> {
// if we're a tty then we need to disable the raw mode
if self.interactive {
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
self.drain_print_logs();
}
Ok(())
}
/// Emit the build logs as println! statements such that the terminal has the same output as cargo
///
/// This is used when the terminal is shutdown and we want the build logs in the terminal. Old
/// versions of the cli would just eat build logs making debugging issues harder than they needed
/// to be.
fn drain_print_logs(&mut self) {
// todo: print the build info here for the most recent build, and then the logs of the most recent build
for (platform, build) in self.build_progress.build_logs.iter() {
if build.messages.is_empty() {
continue;
}
for message in build.messages.iter() {
match &message.message {
MessageType::Cargo(diagnostic) => {
println!(
"{platform}: {}",
diagnostic.rendered.as_deref().unwrap_or_default()
)
}
MessageType::Text(t) => println!("{platform}: {t}"),
}
}
}
}
pub fn handle_input(&mut self, input: Event) -> io::Result<()> {
// handle ctrlc
if let Event::Key(key) = input {
if let KeyCode::Char('c') = key.code {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return Err(io::Error::new(io::ErrorKind::Interrupted, "Ctrl-C"));
}
}
}
if let Event::Key(key) = input {
if let KeyCode::Char('/') = key.code {
self.fly_modal_open = !self.fly_modal_open;
}
}
match input {
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
self.scroll = self.scroll.saturating_sub(1);
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
self.scroll += 1;
}
Event::Key(key) if key.code == KeyCode::Up => {
self.scroll = self.scroll.saturating_sub(1);
}
Event::Key(key) if key.code == KeyCode::Down => {
self.scroll += 1;
}
Event::Key(key) if key.code == KeyCode::Char('r') => {}
Event::Key(key) if key.code == KeyCode::Char('o') => {
// todo: open the app
}
Event::Key(key) if key.code == KeyCode::Char('c') => {
// clear
}
Event::Key(key) if key.code == KeyCode::Char('0') => {
self.tab = Tab::Console;
self.scroll = 0;
}
Event::Key(key) if key.code == KeyCode::Char('1') => {
self.tab = Tab::BuildLog;
self.scroll = 0;
}
Event::Resize(_width, _height) => {
// nothing, it should take care of itself
}
_ => {}
}
if self.scroll
> self
.num_lines_with_wrapping
.saturating_sub(self.term_height + 1)
{
self.scroll = self
.num_lines_with_wrapping
.saturating_sub(self.term_height + 1);
}
Ok(())
}
pub fn new_ws_message(&mut self, platform: Platform, message: axum::extract::ws::Message) {
if let axum::extract::ws::Message::Text(text) = message {
let msg = serde_json::from_str::<ClientMsg>(text.as_str());
match msg {
Ok(ClientMsg::Log { level, messages }) => {
self.push_log(
platform,
BuildMessage {
level: match level.as_str() {
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
"debug" => Level::DEBUG,
_ => Level::INFO,
},
message: MessageType::Text(
// todo: the js console is giving us a list of params, not formatted text
// we need to translate its styling into our own
messages.first().unwrap_or(&String::new()).clone(),
),
source: Some("app".to_string()),
},
);
}
Err(err) => {
self.push_log(
platform,
BuildMessage {
level: Level::ERROR,
source: Some("app".to_string()),
message: MessageType::Text(format!("Error parsing message: {err}")),
},
);
}
}
}
}
// todo: re-enable
#[allow(unused)]
fn is_snapped(&self, _platform: Platform) -> bool {
true
// let prev_scrol = self
// .num_lines_with_wrapping
// .saturating_sub(self.term_height);
// prev_scrol == self.scroll
}
pub fn scroll_to_bottom(&mut self) {
self.scroll = (self.num_lines_with_wrapping).saturating_sub(self.term_height);
}
pub fn push_log(&mut self, platform: Platform, message: BuildMessage) {
let snapped = self.is_snapped(platform);
if let Some(build) = self.build_progress.build_logs.get_mut(&platform) {
build.stdout_logs.push(message);
}
if snapped {
self.scroll_to_bottom();
}
}
pub fn new_build_logs(&mut self, platform: Platform, update: UpdateBuildProgress) {
let snapped = self.is_snapped(platform);
// when the build is finished, switch to the console
if update.stage == Stage::Finished {
self.tab = Tab::Console;
}
self.build_progress
.build_logs
.entry(platform)
.or_default()
.update(update);
if snapped {
self.scroll_to_bottom();
}
}
pub fn new_ready_app(&mut self, build_engine: &mut Builder, results: Vec<BuildResult>) {
for result in results {
let out = build_engine
.children
.iter_mut()
.find_map(|(platform, child)| {
if platform == &result.platform {
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
Some((stdout, stderr))
} else {
None
}
});
let platform = result.platform;
let stdout = out.map(|(stdout, stderr)| RunningAppOutput {
stdout: BufReader::new(stdout).lines(),
stderr: BufReader::new(stderr).lines(),
stdout_line: String::new(),
stderr_line: String::new(),
});
let app = RunningApp { result, stdout };
self.running_apps.insert(platform, app);
// Finish the build progress for the platform that just finished building
if let Some(build) = self.build_progress.build_logs.get_mut(&platform) {
build.stage = Stage::Finished;
}
}
}
pub fn render(
&mut self,
_opts: &Serve,
_config: &DioxusCrate,
_build_engine: &Builder,
server: &Server,
_watcher: &Watcher,
) {
// just drain the build logs
if !self.interactive {
self.drain_print_logs();
return;
}
// Keep the animation track in terms of 100ms frames - the frame should be a number between 0 and 10
// todo: we want to use this somehow to animate things...
let elapsed = self.anim_start.elapsed().as_millis() as f32;
let num_frames = elapsed / 100.0;
let _frame_step = (num_frames % 10.0) as usize;
_ = self
.term
.clone()
.borrow_mut()
.as_mut()
.unwrap()
.draw(|frame| {
// a layout that has a title with stats about the program and then the actual console itself
let body = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
// Title
Constraint::Length(1),
// Body
Constraint::Min(0),
]
.as_ref(),
)
.split(frame.size());
// Split the body into a left and a right
let console = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(14)].as_ref())
.split(body[1]);
let listening_len = "listening at http://127.0.0.1:8080".len() + 3;
let listening_len = if listening_len > body[0].width as usize {
0
} else {
listening_len
};
let header = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Fill(1),
Constraint::Length(listening_len as u16),
]
.as_ref(),
)
.split(body[0]);
// // Render a border for the header
// frame.render_widget(Block::default().borders(Borders::BOTTOM), body[0]);
// Render the metadata
let mut spans = vec![
Span::from(if self.is_cli_release { "dx" } else { "dx-dev" }).green(),
Span::from(" ").green(),
Span::from("serve").green(),
Span::from(" | ").white(),
Span::from(self.platform.to_string()).green(),
Span::from(" | ").white(),
];
// If there is build progress, display that next to the platform
if !self.build_progress.build_logs.is_empty() {
if self
.build_progress
.build_logs
.values()
.any(|b| b.failed.is_some())
{
spans.push(Span::from("build failed ❌").red());
} else {
spans.push(Span::from("status: ").green());
let build = self
.build_progress
.build_logs
.values()
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap();
spans.extend_from_slice(&build.spans(Rect::new(
0,
0,
build.max_layout_size(),
1,
)));
}
}
frame.render_widget(Paragraph::new(Line::from(spans)).left_aligned(), header[0]);
// Split apart the body into a center and a right side
// We only want to show the sidebar if there's enough space
if listening_len > 0 {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::from("listening at ").dark_gray(),
Span::from(format!("http://{}", server.ip).as_str()).gray(),
])),
header[1],
);
}
// Draw the tabs in the right region of the console
// First draw the left border
frame.render_widget(
Paragraph::new(vec![
{
let mut line = Line::from(" [0] console").dark_gray();
if self.tab == Tab::Console {
line.style = Style::default().fg(Color::LightYellow);
}
line
},
{
let mut line = Line::from(" [1] build").dark_gray();
if self.tab == Tab::BuildLog {
line.style = Style::default().fg(Color::LightYellow);
}
line
},
Line::from(" ").gray(),
Line::from(" [/] more").gray(),
Line::from(" [r] reload").gray(),
Line::from(" [r] clear").gray(),
Line::from(" [o] open").gray(),
Line::from(" [h] hide").gray(),
])
.left_aligned()
.block(
Block::default()
.borders(Borders::LEFT | Borders::TOP)
.border_set(symbols::border::Set {
top_left: symbols::line::NORMAL.horizontal_down,
..symbols::border::PLAIN
}),
),
console[1],
);
// We're going to assemble a text buffer directly and then let the paragraph widgets
// handle the wrapping and scrolling
let mut paragraph_text: Text<'_> = Text::default();
for platform in self.build_progress.build_logs.keys() {
let build = self.build_progress.build_logs.get(platform).unwrap();
let msgs = match self.tab {
Tab::Console => &build.stdout_logs,
Tab::BuildLog => &build.messages,
};
for span in msgs.iter() {
use ansi_to_tui::IntoText;
match &span.message {
MessageType::Text(line) => {
for line in line.lines() {
let text = line.into_text().unwrap_or_default();
for line in text.lines {
let mut out_line = vec![Span::from("[app] ").dark_gray()];
for span in line.spans {
out_line.push(span);
}
let newline = Line::from(out_line);
paragraph_text.push_line(newline);
}
}
}
MessageType::Cargo(diagnostic) => {
let diagnostic = diagnostic.rendered.as_deref().unwrap_or_default();
for line in diagnostic.lines() {
paragraph_text.extend(line.into_text().unwrap_or_default());
}
}
};
}
}
let paragraph = Paragraph::new(paragraph_text)
.left_aligned()
.wrap(Wrap { trim: false });
self.term_height = console[0].height;
self.num_lines_with_wrapping = paragraph.line_count(console[0].width) as u16;
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(None)
.thumb_symbol("▐");
let mut scrollbar_state = ScrollbarState::new(
self.num_lines_with_wrapping
.saturating_sub(self.term_height) as usize,
)
.position(self.scroll as usize);
let paragraph = paragraph.scroll((self.scroll, 0));
paragraph
.block(Block::new().borders(Borders::TOP))
.render(console[0], frame.buffer_mut());
// and the scrollbar, those are separate widgets
frame.render_stateful_widget(
scrollbar,
console[0].inner(Margin {
// todo: dont use margin - just push down the body based on its top border
// using an inner vertical margin of 1 unit makes the scrollbar inside the block
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
// render the fly modal
self.render_fly_modal(frame, console[0]);
});
}
async fn handle_events(&mut self, event: Event) -> io::Result<()> {
let mut events = vec![event];
// Collect all the events within the next 10ms in one stream
loop {
let next = self.events.as_mut().unwrap().next();
tokio::select! {
msg = next => events.push(msg.unwrap().unwrap()),
_ = tokio::time::sleep(Duration::from_millis(1)) => break
}
}
// Debounce events within the same frame
let mut handled = HashSet::new();
for event in events {
if !handled.contains(&event) {
self.handle_input(event.clone())?;
handled.insert(event);
}
}
Ok(())
}
fn render_fly_modal(&mut self, frame: &mut Frame, area: Rect) {
if !self.fly_modal_open {
return;
}
// Create a frame slightly smaller than the area
let panel = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1)].as_ref())
.split(area)[0];
// Wipe the panel
frame.render_widget(Clear, panel);
frame.render_widget(Block::default().borders(Borders::ALL), panel);
let modal = Paragraph::new(
"Hello world!\nHello world!\nHello world!\nHello world!\nHello world!\n",
)
.alignment(Alignment::Center);
frame.render_widget(modal, panel);
}
}
#[derive(Default, Debug, PartialEq)]
pub struct ActiveBuild {
stage: Stage,
messages: Vec<BuildMessage>,
stdout_logs: Vec<BuildMessage>,
progress: f64,
failed: Option<String>,
}
impl ActiveBuild {
fn update(&mut self, update: UpdateBuildProgress) {
match update.update {
UpdateStage::Start => {
self.stage = update.stage;
self.progress = 0.0;
self.failed = None;
}
UpdateStage::AddMessage(message) => {
self.messages.push(message);
}
UpdateStage::SetProgress(progress) => {
self.progress = progress;
}
UpdateStage::Failed(failed) => {
self.stage = Stage::Finished;
self.failed = Some(failed.clone());
}
}
}
fn spans(&self, area: Rect) -> Vec<Span> {
let mut spans = Vec::new();
let message = match self.stage {
Stage::Initializing => "initializing... ",
Stage::InstallingWasmTooling => "installing wasm tools... ",
Stage::Compiling => "compiling... ",
Stage::OptimizingWasm => "optimizing wasm... ",
Stage::OptimizingAssets => "optimizing assets... ",
Stage::Finished => "finished! 🎉 ",
};
let progress = format!("{}%", (self.progress * 100.0) as u8);
if area.width >= self.max_layout_size() {
spans.push(Span::from(message).light_yellow());
if self.stage != Stage::Finished {
spans.push(Span::from(progress).white());
}
} else {
spans.push(Span::from(progress).white());
}
spans
}
fn max_layout_size(&self) -> u16 {
let progress_size = 4;
let stage_size = self.stage.to_string().len() as u16;
let brace_size = 2;
progress_size + stage_size + brace_size
}
}
impl PartialOrd for ActiveBuild {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(
self.stage
.cmp(&other.stage)
.then(self.progress.partial_cmp(&other.progress).unwrap()),
)
}
}
fn set_fix_term_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
_ = disable_raw_mode();
_ = stdout().execute(LeaveAlternateScreen);
original_hook(info);
}));
}
// todo: re-enable
#[allow(unused)]
async fn rustc_version() -> String {
tokio::process::Command::new("rustc")
.arg("--version")
.output()
.await
.ok()
.map(|o| o.stdout)
.and_then(|o| {
let out = String::from_utf8(o).unwrap();
out.split_ascii_whitespace().nth(1).map(|v| v.to_string())
})
.unwrap_or_else(|| "<unknown>".to_string())
}
pub struct RunningApp {
result: BuildResult,
stdout: Option<RunningAppOutput>,
}
struct RunningAppOutput {
stdout: Lines<BufReader<ChildStdout>>,
stderr: Lines<BufReader<ChildStderr>>,
stdout_line: String,
stderr_line: String,
}
+252
View File
@@ -0,0 +1,252 @@
use crate::{Error, Result};
use dioxus_cli_config::WebProxyConfig;
use anyhow::{anyhow, Context};
use axum::body::Body as MyBody;
use axum::body::Body;
use axum::{
http::StatusCode,
routing::{any, MethodRouter},
Router,
};
use hyper::{Request, Response, Uri};
use hyper_util::{
client::legacy::{self, connect::HttpConnector},
rt::TokioExecutor,
};
#[derive(Debug, Clone)]
struct ProxyClient {
inner: legacy::Client<hyper_rustls::HttpsConnector<HttpConnector>, MyBody>,
url: Uri,
}
impl ProxyClient {
fn new(url: Uri) -> Self {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.unwrap()
.https_or_http()
.enable_http1()
.build();
Self {
inner: legacy::Client::builder(TokioExecutor::new()).build(https),
url,
}
}
async fn send(&self, mut req: Request<MyBody>) -> Result<Response<hyper::body::Incoming>> {
let mut uri_parts = req.uri().clone().into_parts();
uri_parts.authority = self.url.authority().cloned();
uri_parts.scheme = self.url.scheme().cloned();
*req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?;
self.inner
.request(req)
.await
.map_err(|err| crate::error::Error::Other(anyhow!(err)))
}
}
/// Add routes to the router handling the specified proxy config.
///
/// We will proxy requests directed at either:
///
/// - the exact path of the proxy config's backend URL, e.g. /api
/// - the exact path with a trailing slash, e.g. /api/
/// - any subpath of the backend URL, e.g. /api/foo/bar
pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
let url: Uri = proxy.backend.parse()?;
let path = url.path().to_string();
let trimmed_path = path.trim_start_matches('/');
if trimmed_path.is_empty() {
return Err(crate::Error::ProxySetupError(format!(
"Proxy backend URL must have a non-empty path, e.g. {}/api instead of {}",
proxy.backend.trim_end_matches('/'),
proxy.backend
)));
}
let method_router = proxy_to(url, false, handle_proxy_error);
// api/*path
router = router.route(
&format!("/{}/*path", trimmed_path.trim_end_matches('/')),
method_router.clone(),
);
// /api/
router = router.route(
&format!("/{}/", trimmed_path.trim_end_matches('/')),
method_router.clone(),
);
// /api
router = router.route(
&format!("/{}", trimmed_path.trim_end_matches('/')),
method_router,
);
Ok(router)
}
pub(crate) fn proxy_to(
url: Uri,
nocache: bool,
handle_error: fn(Error) -> Response<Body>,
) -> MethodRouter {
let client = ProxyClient::new(url);
any(move |mut req: Request<MyBody>| async move {
// Prevent request loops
if req.headers().get("x-proxied-by-dioxus").is_some() {
return Err(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(
"API is sharing a loopback with the dev server. Try setting a different port on the API config.",
))
.unwrap());
}
req.headers_mut().insert(
"x-proxied-by-dioxus",
"true".parse().expect("header value is valid"),
);
if nocache {
crate::serve::insert_no_cache_headers(req.headers_mut());
}
client.send(req).await.map_err(handle_error)
})
}
fn handle_proxy_error(e: Error) -> axum::http::Response<axum::body::Body> {
tracing::error!("Proxy error: {}", e);
axum::http::Response::builder()
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.body(axum::body::Body::from(format!(
"Proxy connection failed: {:#?}",
e
)))
.unwrap()
}
#[cfg(test)]
mod test {
use super::*;
use axum_server::{Handle, Server};
async fn setup_servers(mut config: WebProxyConfig) -> String {
let backend_router =
Router::new().route(
"/*path",
any(|request: axum::extract::Request| async move {
format!("backend: {}", request.uri())
}),
);
// The API backend server
let backend_handle_handle = Handle::new();
let backend_handle_handle_ = backend_handle_handle.clone();
tokio::spawn(async move {
Server::bind("127.0.0.1:0".parse().unwrap())
.handle(backend_handle_handle_)
.serve(backend_router.into_make_service())
.await
.unwrap();
});
// Set the user's config to this dummy API we just built so we can test it
let backend_addr = backend_handle_handle.listening().await.unwrap();
config.backend = format!("http://{}{}", backend_addr, config.backend);
// Now set up our actual filesystem server
let router = super::add_proxy(Router::new(), &config);
let server_handle_handle = Handle::new();
let server_handle_handle_ = server_handle_handle.clone();
tokio::spawn(async move {
Server::bind("127.0.0.1:0".parse().unwrap())
.handle(server_handle_handle_)
.serve(router.unwrap().into_make_service())
.await
.unwrap();
});
// Expose *just* the fileystem web server's address
server_handle_handle.listening().await.unwrap().to_string()
}
async fn test_proxy_requests(path: String) {
let config = WebProxyConfig {
// Normally this would be an absolute URL including scheme/host/port,
// but in these tests we need to let the OS choose the port so tests
// don't conflict, so we'll concatenate the final address and this
// path together.
// So in day to day usage, use `http://localhost:8000/api` instead!
backend: path,
};
let server_addr = setup_servers(config).await;
assert_eq!(
reqwest::get(format!("http://{}/api", server_addr))
.await
.unwrap()
.text()
.await
.unwrap(),
"backend: /api"
);
assert_eq!(
reqwest::get(format!("http://{}/api/", server_addr))
.await
.unwrap()
.text()
.await
.unwrap(),
"backend: /api/"
);
assert_eq!(
reqwest::get(format!("http://{server_addr}/api/subpath"))
.await
.unwrap()
.text()
.await
.unwrap(),
"backend: /api/subpath"
);
}
#[tokio::test]
async fn add_proxy() {
test_proxy_requests("/api".to_string()).await;
}
#[tokio::test]
async fn add_proxy_trailing_slash() {
test_proxy_requests("/api/".to_string()).await;
}
#[test]
fn add_proxy_empty_path() {
let config = WebProxyConfig {
backend: "http://localhost:8000".to_string(),
};
let router = super::add_proxy(Router::new(), &config);
match router.unwrap_err() {
crate::Error::ProxySetupError(e) => {
assert_eq!(
e,
"Proxy backend URL must have a non-empty path, e.g. http://localhost:8000/api instead of http://localhost:8000"
);
}
e => panic!("Unexpected error type: {}", e),
}
}
}
+587
View File
@@ -0,0 +1,587 @@
use crate::dioxus_crate::DioxusCrate;
use crate::serve::Serve;
use crate::{Error, Result};
use axum::extract::{Request, State};
use axum::middleware::{self, Next};
use axum::{
body::Body,
extract::{
ws::{Message, WebSocket},
Extension, WebSocketUpgrade,
},
http::{
header::{HeaderName, HeaderValue, CACHE_CONTROL, EXPIRES, PRAGMA},
Method, Response, StatusCode,
},
response::IntoResponse,
routing::{get, get_service},
Router,
};
use axum_server::tls_rustls::RustlsConfig;
use dioxus_cli_config::{Platform, WebHttpsConfig};
use dioxus_hot_reload::{DevserverMsg, HotReloadMsg};
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
use futures_util::{stream::FuturesUnordered, StreamExt};
use hyper::header::ACCEPT;
use hyper::HeaderMap;
use serde::{Deserialize, Serialize};
use std::net::TcpListener;
use std::path::Path;
use std::sync::Arc;
use std::sync::RwLock;
use std::{
convert::Infallible,
fs, io,
net::{IpAddr, SocketAddr},
process::Command,
};
use tokio::task::JoinHandle;
use tower::ServiceBuilder;
use tower_http::{
cors::{Any, CorsLayer},
services::fs::{ServeDir, ServeFileSystemResponseBody},
ServiceBuilderExt,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Status {
Building { progress: f64 },
BuildError { error: String },
Ready,
}
#[derive(Debug, Clone)]
struct SharedStatus(Arc<RwLock<Status>>);
impl SharedStatus {
fn new(status: Status) -> Self {
Self(Arc::new(RwLock::new(status)))
}
fn set(&self, status: Status) {
*self.0.write().unwrap() = status;
}
fn get(&self) -> Status {
self.0.read().unwrap().clone()
}
}
pub(crate) struct Server {
pub hot_reload_sockets: Vec<WebSocket>,
pub build_status_sockets: Vec<WebSocket>,
pub ip: SocketAddr,
pub new_hot_reload_sockets: UnboundedReceiver<WebSocket>,
pub new_build_status_sockets: UnboundedReceiver<WebSocket>,
_server_task: JoinHandle<Result<()>>,
/// We proxy (not hot reloading) fullstack requests to this port
pub fullstack_port: Option<u16>,
build_status: SharedStatus,
}
impl Server {
pub fn start(serve: &Serve, cfg: &DioxusCrate) -> Self {
let (hot_reload_sockets_tx, hot_reload_sockets_rx) = futures_channel::mpsc::unbounded();
let (build_status_sockets_tx, build_status_sockets_rx) = futures_channel::mpsc::unbounded();
let build_status = SharedStatus::new(Status::Building { progress: 0.0 });
let addr = serve.server_arguments.address.address();
let start_browser = serve.server_arguments.open.unwrap_or_default();
// If we're serving a fullstack app, we need to find a port to proxy to
let fullstack_port = if matches!(
serve.build_arguments.platform(),
Platform::Fullstack | Platform::StaticGeneration
) {
get_available_port(addr.ip())
} else {
None
};
let fullstack_address = fullstack_port.map(|port| SocketAddr::new(addr.ip(), port));
let router = setup_router(
serve,
cfg,
hot_reload_sockets_tx,
build_status_sockets_tx,
fullstack_address,
build_status.clone(),
)
.unwrap();
// Actually just start the server, cloning in a few bits of config
let web_config = cfg.dioxus_config.web.https.clone();
let base_path = cfg.dioxus_config.web.app.base_path.clone();
let _server_task = tokio::spawn(async move {
let web_config = web_config.clone();
// HTTPS
// Before console info so it can stop if mkcert isn't installed or fails
// todo: this is the only async thing here - might be nice to
let rustls: Option<RustlsConfig> = get_rustls(&web_config).await.unwrap();
// Open the browser
if start_browser {
open_browser(base_path, addr, rustls.is_some());
}
// Start the server with or without rustls
if let Some(rustls) = rustls {
axum_server::bind_rustls(addr, rustls)
.serve(router.into_make_service())
.await?
} else {
// Create a TCP listener bound to the address
axum::serve(
tokio::net::TcpListener::bind(&addr).await?,
router.into_make_service(),
)
.await?
}
Ok(())
});
Self {
hot_reload_sockets: Default::default(),
build_status_sockets: Default::default(),
new_hot_reload_sockets: hot_reload_sockets_rx,
new_build_status_sockets: build_status_sockets_rx,
_server_task,
ip: addr,
fullstack_port,
build_status,
}
}
async fn send_build_status(&mut self) {
let mut i = 0;
while i < self.build_status_sockets.len() {
let socket = &mut self.build_status_sockets[i];
if send_build_status_to(&self.build_status, socket)
.await
.is_err()
{
self.build_status_sockets.remove(i);
} else {
i += 1;
}
}
}
pub async fn start_build(&mut self) {
self.build_status.set(Status::Building { progress: 0.0 });
self.send_build_status().await;
}
pub async fn update_build_status(&mut self, progress: f64) {
if !matches!(self.build_status.get(), Status::Building { .. }) {
return;
}
self.build_status.set(Status::Building { progress });
self.send_build_status().await;
}
pub async fn send_hotreload(&mut self, reload: HotReloadMsg) {
let msg = DevserverMsg::HotReload(reload);
let msg = serde_json::to_string(&msg).unwrap();
// Send the changes to any connected clients
let mut i = 0;
while i < self.hot_reload_sockets.len() {
let socket = &mut self.hot_reload_sockets[i];
if socket.send(Message::Text(msg.clone())).await.is_err() {
self.hot_reload_sockets.remove(i);
} else {
i += 1;
}
}
}
/// Wait for new clients to be connected and then save them
pub async fn wait(&mut self) -> Option<Message> {
let mut new_hot_reload_socket = self.new_hot_reload_sockets.next();
let mut new_build_status_socket = self.new_build_status_sockets.next();
let mut new_message = self
.hot_reload_sockets
.iter_mut()
.enumerate()
.map(|(idx, socket)| async move { (idx, socket.next().await) })
.collect::<FuturesUnordered<_>>();
tokio::select! {
new_hot_reload_socket = &mut new_hot_reload_socket => {
if let Some(new_socket) = new_hot_reload_socket {
drop(new_message);
self.hot_reload_sockets.push(new_socket);
return None;
} else {
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
}
}
new_build_status_socket = &mut new_build_status_socket => {
if let Some(mut new_socket) = new_build_status_socket {
drop(new_message);
// Update the socket with the current status
if send_build_status_to(&self.build_status, &mut new_socket).await.is_ok() {
self.build_status_sockets.push(new_socket);
}
return None;
} else {
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
}
}
Some((idx, message)) = new_message.next() => {
match message {
Some(Ok(message)) => return Some(message),
_ => {
drop(new_message);
_ = self.hot_reload_sockets.remove(idx);
}
}
}
}
None
}
pub async fn send_build_error(&mut self, error: Error) {
let error = error.to_string();
self.build_status.set(Status::BuildError {
error: ansi_to_html::convert(&error).unwrap_or(error),
});
self.send_build_status().await;
}
pub async fn send_reload(&mut self) {
self.build_status.set(Status::Ready);
self.send_build_status().await;
for socket in self.hot_reload_sockets.iter_mut() {
_ = socket
.send(Message::Text(
serde_json::to_string(&DevserverMsg::FullReload).unwrap(),
))
.await;
}
}
/// Send a shutdown message to all connected clients
pub async fn send_shutdown(&mut self) {
for socket in self.hot_reload_sockets.iter_mut() {
_ = socket
.send(Message::Text(
serde_json::to_string(&DevserverMsg::Shutdown).unwrap(),
))
.await;
}
}
pub async fn shutdown(&mut self) {
self.send_shutdown().await;
for socket in self.hot_reload_sockets.drain(..) {
_ = socket.close().await;
}
}
/// Get the address the fullstack server should run on if we're serving a fullstack app
pub fn fullstack_address(&self) -> Option<SocketAddr> {
self.fullstack_port
.map(|port| SocketAddr::new(self.ip.ip(), port))
}
}
/// Sets up and returns a router
///
/// Steps include:
/// - Setting up cors
/// - Setting up the proxy to the endpoint specified in the config
/// - Setting up the file serve service
/// - Setting up the websocket endpoint for devtools
fn setup_router(
serve: &Serve,
config: &DioxusCrate,
hot_reload_sockets: UnboundedSender<WebSocket>,
build_status_sockets: UnboundedSender<WebSocket>,
fullstack_address: Option<SocketAddr>,
build_status: SharedStatus,
) -> Result<Router> {
let mut router = Router::new();
let platform = serve.build_arguments.platform();
// Setup proxy for the endpoint specified in the config
for proxy_config in config.dioxus_config.web.proxy.iter() {
router = super::proxy::add_proxy(router, proxy_config)?;
}
// Setup base path redirection
if let Some(base_path) = config.dioxus_config.web.app.base_path.clone() {
let base_path = format!("/{}", base_path.trim_matches('/'));
router = Router::new()
.nest(&base_path, router)
.fallback(get(move || async move {
format!("Outside of the base path: {}", base_path)
}));
}
// server the dir if it's web, otherwise let the fullstack server itself handle it
match platform {
Platform::Web => {
// Route file service to output the .wasm and assets if this is a web build
router = router.fallback(build_serve_dir(serve, config));
}
Platform::Fullstack | Platform::StaticGeneration => {
// For fullstack and static generation, forward all requests to the server
let address = fullstack_address.unwrap();
router = router.fallback(super::proxy::proxy_to(
format!("http://{address}").parse().unwrap(),
true,
|error| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(format!(
"Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}",
error
)))
.unwrap()
},
));
}
_ => {}
}
// Setup middleware to intercept html requests if the build status is "Building"
router = router.layer(middleware::from_fn_with_state(
build_status,
build_status_middleware,
));
// Setup websocket endpoint - and pass in the extension layer immediately after
router = router.nest(
"/_dioxus",
Router::new()
.route(
"/",
get(
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
},
),
)
.layer(Extension(hot_reload_sockets))
.route(
"/build_status",
get(
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
},
),
)
.layer(Extension(build_status_sockets)),
);
// Setup cors
router = router.layer(
CorsLayer::new()
// allow `GET` and `POST` when accessing the resource
.allow_methods([Method::GET, Method::POST])
// allow requests from any origin
.allow_origin(Any)
.allow_headers(Any),
);
Ok(router)
}
fn build_serve_dir(serve: &Serve, cfg: &DioxusCrate) -> axum::routing::MethodRouter {
static CORS_UNSAFE: (HeaderValue, HeaderValue) = (
HeaderValue::from_static("unsafe-none"),
HeaderValue::from_static("unsafe-none"),
);
static CORS_REQUIRE: (HeaderValue, HeaderValue) = (
HeaderValue::from_static("require-corp"),
HeaderValue::from_static("same-origin"),
);
let (coep, coop) = match serve.server_arguments.cross_origin_policy {
true => CORS_REQUIRE.clone(),
false => CORS_UNSAFE.clone(),
};
let out_dir = cfg.out_dir();
let index_on_404 = cfg.dioxus_config.web.watcher.index_on_404;
get_service(
ServiceBuilder::new()
.override_response_header(
HeaderName::from_static("cross-origin-embedder-policy"),
coep,
)
.override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop)
.and_then({
let out_dir = out_dir.clone();
move |response| async move { Ok(no_cache(index_on_404, &out_dir, response)) }
})
.service(ServeDir::new(out_dir)),
)
.handle_error(|error: Infallible| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
})
}
fn no_cache(
index_on_404: bool,
out_dir: &Path,
response: Response<ServeFileSystemResponseBody>,
) -> Response<Body> {
// By default we just decompose into the response
let mut response = response.into_response();
// If there's a 404 and we're supposed to index on 404, upgrade that failed request to the index.html
// We migth want to isnert a header here saying we *did* that but oh well
if response.status() == StatusCode::NOT_FOUND && index_on_404 {
let body = Body::from(std::fs::read_to_string(out_dir.join("index.html")).unwrap());
response = Response::builder()
.status(StatusCode::OK)
.body(body)
.unwrap();
};
insert_no_cache_headers(response.headers_mut());
response
}
pub fn insert_no_cache_headers(headers: &mut HeaderMap) {
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(EXPIRES, HeaderValue::from_static("0"));
}
/// Returns an enum of rustls config
pub async fn get_rustls(web_config: &WebHttpsConfig) -> Result<Option<RustlsConfig>> {
if web_config.enabled != Some(true) {
return Ok(None);
}
let (cert_path, key_path) = match web_config.mkcert {
Some(true) => get_rustls_with_mkcert(web_config)?,
_ => get_rustls_without_mkcert(web_config)?,
};
Ok(Some(
RustlsConfig::from_pem_file(cert_path, key_path).await?,
))
}
pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
const DEFAULT_KEY_PATH: &str = "ssl/key.pem";
const DEFAULT_CERT_PATH: &str = "ssl/cert.pem";
// Get paths to store certs, otherwise use ssl/item.pem
let key_path = web_config
.key_path
.clone()
.unwrap_or(DEFAULT_KEY_PATH.to_string());
let cert_path = web_config
.cert_path
.clone()
.unwrap_or(DEFAULT_CERT_PATH.to_string());
// Create ssl directory if using defaults
if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH {
_ = fs::create_dir("ssl");
}
let cmd = Command::new("mkcert")
.args([
"-install",
"-key-file",
&key_path,
"-cert-file",
&cert_path,
"localhost",
"::1",
"127.0.0.1",
])
.spawn();
match cmd {
Err(e) => {
match e.kind() {
io::ErrorKind::NotFound => tracing::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."),
e => tracing::error!("an error occurred while generating mkcert certificates: {}", e.to_string()),
};
return Err("failed to generate mkcert certificates".into());
}
Ok(mut cmd) => {
cmd.wait()?;
}
}
Ok((cert_path, key_path))
}
pub fn get_rustls_without_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
// get paths to cert & key
if let (Some(key), Some(cert)) = (web_config.key_path.clone(), web_config.cert_path.clone()) {
Ok((cert, key))
} else {
// missing cert or key
Err("https is enabled but cert or key path is missing".into())
}
}
/// Open the browser to the address
pub(crate) fn open_browser(base_path: Option<String>, address: SocketAddr, https: bool) {
let protocol = if https { "https" } else { "http" };
let base_path = match base_path.as_deref() {
Some(base_path) => format!("/{}", base_path.trim_matches('/')),
None => "".to_owned(),
};
_ = open::that(format!("{protocol}://{address}{base_path}"));
}
fn get_available_port(address: IpAddr) -> Option<u16> {
(8000..9000).find(|port| TcpListener::bind((address, *port)).is_ok())
}
/// Middleware that intercepts html requests if the status is "Building" and returns a loading page instead
async fn build_status_middleware(
state: State<SharedStatus>,
request: Request,
next: Next,
) -> axum::response::Response {
// If the request is for html, and the status is "Building", return the loading page instead of the contents of the response
let accepts = request.headers().get(ACCEPT);
let accepts_html = accepts
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("text/html"));
if let Some(true) = accepts_html {
let status = state.get();
if status != Status::Ready {
let html = include_str!("../../assets/loading.html");
return axum::response::Response::builder()
.status(StatusCode::OK)
.body(Body::from(html))
.unwrap();
}
}
next.run(request).await
}
async fn send_build_status_to(
build_status: &SharedStatus,
socket: &mut WebSocket,
) -> Result<(), axum::Error> {
let msg = serde_json::to_string(&build_status.get()).unwrap();
socket.send(Message::Text(msg)).await
}
+283
View File
@@ -0,0 +1,283 @@
use std::path::PathBuf;
use crate::dioxus_crate::DioxusCrate;
use crate::serve::hot_reloading_file_map::FileMap;
use dioxus_hot_reload::HotReloadMsg;
use dioxus_html::HtmlCtx;
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
use futures_util::StreamExt;
use ignore::gitignore::Gitignore;
use notify::{event::ModifyKind, EventKind, RecommendedWatcher};
/// This struct stores the file watcher and the filemap for the project.
///
/// This is where we do workspace discovery and recursively listen for changes in Rust files and asset
/// directories.
pub struct Watcher {
_tx: UnboundedSender<notify::Event>,
rx: UnboundedReceiver<notify::Event>,
_last_update_time: i64,
_watcher: RecommendedWatcher,
queued_events: Vec<notify::Event>,
file_map: FileMap,
ignore: Gitignore,
}
impl Watcher {
pub fn start(config: &DioxusCrate) -> Self {
let (tx, rx) = futures_channel::mpsc::unbounded();
// 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
let mut allow_watch_path = config.dioxus_config.web.watcher.watch_path.clone();
allow_watch_path.push(config.dioxus_config.application.asset_dir.clone());
allow_watch_path.push("Cargo.toml".to_string().into());
allow_watch_path.push("Dioxus.toml".to_string().into());
allow_watch_path.dedup();
let crate_dir = config.crate_dir();
let gitignore_file_path = crate_dir.join(".gitignore");
let mut builder = ignore::gitignore::GitignoreBuilder::new(gitignore_file_path);
let excluded_paths = vec![
".git",
".github",
".vscode",
"target",
"node_modules",
"dist",
".dioxus",
];
for path in excluded_paths {
builder.add(path);
}
let ignore = builder.build().unwrap();
// Create the file watcher
let mut watcher = notify::recommended_watcher({
let tx = tx.clone();
move |info: notify::Result<notify::Event>| {
if let Ok(e) = info {
match e.kind {
// An event emitted when the metadata of a file or folder is changed.
EventKind::Modify(ModifyKind::Data(_)) |
EventKind::Create(_) |
EventKind::Remove(_) => {
_ = tx.unbounded_send(e);
},
_ => {}
}
}
}
})
.expect("Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.");
// Watch the specified paths
// todo: make sure we don't double-watch paths if they're nested
for sub_path in allow_watch_path {
let path = &config.crate_dir().join(sub_path);
// If the path is ignored, don't watch it
if ignore.matched(path, path.is_dir()).is_ignore() {
continue;
}
let mode = notify::RecursiveMode::Recursive;
use notify::Watcher;
if let Err(err) = watcher.watch(path, mode) {
tracing::warn!("Failed to watch path: {}", err);
}
}
// Probe the entire project looking for our rsx calls
// Whenever we get an update from the file watcher, we'll try to hotreload against this file map
let file_map = FileMap::create_with_filter::<HtmlCtx>(config.crate_dir(), |path| {
ignore.matched(path, path.is_dir()).is_ignore()
})
.unwrap();
Self {
_tx: tx,
rx,
_watcher: watcher,
file_map,
ignore,
queued_events: Vec::new(),
_last_update_time: chrono::Local::now().timestamp(),
}
}
/// A cancel safe handle to the file watcher
///
/// todo: this should be simpler logic?
pub async fn wait(&mut self) {
// Pull off any queued events in succession
while let Ok(Some(event)) = self.rx.try_next() {
self.queued_events.push(event);
}
if !self.queued_events.is_empty() {
return;
}
// If there are no queued events, wait for the next event
if let Some(event) = self.rx.next().await {
self.queued_events.push(event);
}
}
/// Deques changed files from the event queue, doing the proper intelligent filtering
pub fn dequeue_changed_files(&mut self, config: &DioxusCrate) -> Vec<PathBuf> {
let mut all_mods: Vec<PathBuf> = vec![];
// Decompose the events into a list of all the files that have changed
for event in self.queued_events.drain(..) {
// We only care about modify/crate/delete events
match event.kind {
EventKind::Modify(ModifyKind::Data(_)) => {}
EventKind::Modify(ModifyKind::Name(_)) => {}
EventKind::Create(_) => {}
EventKind::Remove(_) => {}
_ => continue,
}
for path in event.paths {
all_mods.push(path.clone());
}
}
let mut modified_files = vec![];
// For the non-rust files, we want to check if it's an asset file
// This would mean the asset lives somewhere under the /assets directory or is referenced by magnanis in the linker
// todo: mg integration here
let _asset_dir = config
.dioxus_config
.application
.asset_dir
.canonicalize()
.ok();
for path in all_mods.iter() {
if path.extension().is_none() {
continue;
}
// 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;
}
}
// If the extension is a backup file, or a hidden file, ignore it completely (no rebuilds)
if is_backup_file(path.to_path_buf()) {
tracing::trace!("Ignoring backup file: {:?}", path);
continue;
}
// If the path is ignored, don't watch it
if self.ignore.matched(path, path.is_dir()).is_ignore() {
continue;
}
modified_files.push(path.clone());
}
modified_files
}
pub fn attempt_hot_reload(
&mut self,
config: &DioxusCrate,
modified_files: Vec<PathBuf>,
) -> Option<HotReloadMsg> {
// If we have any changes to the rust files, we need to update the file map
let crate_dir = config.crate_dir();
let mut templates = vec![];
// Prepare the hotreload message we need to send
let mut edited_rust_files = Vec::new();
let mut assets = Vec::new();
let mut unknown_files = Vec::new();
for path in modified_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 Some(ext) = path.extension().and_then(|v| v.to_str()) else {
continue;
};
match ext {
"rs" => edited_rust_files.push(path),
_ if path.starts_with("assets") => assets.push(path),
_ => unknown_files.push(path),
}
}
for rust_file in edited_rust_files {
let hotreloaded_templates = self
.file_map
.update_rsx::<HtmlCtx>(&rust_file, &crate_dir)
.ok()?;
templates.extend(hotreloaded_templates);
}
Some(HotReloadMsg {
templates,
assets,
unknown_files,
})
}
/// Ensure the changes we've received from the queue are actually legit changes to either assets or
/// rust code. We don't care about changes otherwise, unless we get a signle elsewhere to do a full rebuild
pub fn pending_changes(&mut self) -> bool {
!self.queued_events.is_empty()
}
}
fn is_backup_file(path: PathBuf) -> bool {
// If there's a tilde at the end of the file, it's a backup file
if let Some(name) = path.file_name() {
if let Some(name) = name.to_str() {
if name.ends_with('~') {
return true;
}
}
}
// if the file is hidden, it's a backup file
if let Some(name) = path.file_name() {
if let Some(name) = name.to_str() {
if name.starts_with('.') {
return true;
}
}
}
false
}
#[test]
fn test_is_backup_file() {
assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
assert!(is_backup_file(PathBuf::from("examples/.back")));
assert!(is_backup_file(PathBuf::from("test.rs~")));
assert!(is_backup_file(PathBuf::from(".back")));
assert!(!is_backup_file(PathBuf::from("val.rs")));
assert!(!is_backup_file(PathBuf::from(
"/Users/jonkelley/Development/Tinkering/basic_05_example/src/lib.rs"
)));
assert!(!is_backup_file(PathBuf::from("exmaples/val.rs")));
}