From 0c5025ffa0c1a094fe33345e95b7a2694516297e Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 18 Jul 2023 10:24:29 -0700 Subject: [PATCH] WIP desktop serve hot reload --- packages/cli/Cargo.toml | 2 + packages/cli/src/builder.rs | 7 +- packages/cli/src/cli/autoformat.rs | 8 +- packages/cli/src/cli/build.rs | 10 +- packages/cli/src/cli/cfg.rs | 21 +- packages/cli/src/cli/serve.rs | 44 +- packages/cli/src/config.rs | 6 +- packages/cli/src/server/desktop/mod.rs | 255 +++++++++ packages/cli/src/server/mod.rs | 498 ++---------------- packages/cli/src/server/output.rs | 54 +- .../cli/src/server/{ => web}/hot_reload.rs | 0 packages/cli/src/server/web/mod.rs | 475 +++++++++++++++++ packages/cli/src/server/{ => web}/proxy.rs | 0 13 files changed, 865 insertions(+), 515 deletions(-) create mode 100644 packages/cli/src/server/desktop/mod.rs rename packages/cli/src/server/{ => web}/hot_reload.rs (100%) create mode 100644 packages/cli/src/server/web/mod.rs rename packages/cli/src/server/{ => web}/proxy.rs (100%) diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index c947baa63..cc78a5d28 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -81,6 +81,8 @@ rsx-rosetta = { workspace = true } dioxus-rsx = { workspace = true } dioxus-html = { workspace = true, features = ["hot-reload-context"] } dioxus-core = { workspace = true, features = ["serialize"] } +dioxus-hot-reload = { workspace = true } +interprocess-docfix = { version = "1.2.2" } [features] default = [] diff --git a/packages/cli/src/builder.rs b/packages/cli/src/builder.rs index 6f50fc49d..57794b728 100644 --- a/packages/cli/src/builder.rs +++ b/packages/cli/src/builder.rs @@ -244,7 +244,7 @@ pub fn build(config: &CrateConfig, quiet: bool) -> Result { }) } -pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<()> { +pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result { log::info!("🚅 Running build [Desktop] command..."); let ignore_files = build_assets(config)?; @@ -365,7 +365,10 @@ pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<()> { ); } - Ok(()) + Ok(BuildResult { + warnings: vec![], + elapsed_time: 0, + }) } fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result> { diff --git a/packages/cli/src/cli/autoformat.rs b/packages/cli/src/cli/autoformat.rs index fbc34402b..ea2c27148 100644 --- a/packages/cli/src/cli/autoformat.rs +++ b/packages/cli/src/cli/autoformat.rs @@ -136,12 +136,16 @@ async fn autoformat_project(check: bool) -> Result<()> { } fn collect_rs_files(folder: &Path, files: &mut Vec) { - let Ok(folder) = folder.read_dir() else { return }; + let Ok(folder) = folder.read_dir() else { + return; + }; // load the gitignore for entry in folder { - let Ok(entry) = entry else { continue; }; + let Ok(entry) = entry else { + continue; + }; let path = entry.path(); diff --git a/packages/cli/src/cli/build.rs b/packages/cli/src/cli/build.rs index 148a4ab76..29edf301b 100644 --- a/packages/cli/src/cli/build.rs +++ b/packages/cli/src/cli/build.rs @@ -1,3 +1,4 @@ +use crate::cfg::Platform; #[cfg(feature = "plugin")] use crate::plugin::PluginManager; @@ -42,16 +43,13 @@ impl Build { #[cfg(feature = "plugin")] let _ = PluginManager::on_build_start(&crate_config, &platform); - match platform.as_str() { - "web" => { + match platform { + Platform::Web => { crate::builder::build(&crate_config, false)?; } - "desktop" => { + Platform::Desktop => { crate::builder::build_desktop(&crate_config, false)?; } - _ => { - return custom_error!("Unsupported platform target."); - } } let temp = gen_page(&crate_config.dioxus_config, false); diff --git a/packages/cli/src/cli/cfg.rs b/packages/cli/src/cli/cfg.rs index 30ca89ce9..edc32f346 100644 --- a/packages/cli/src/cli/cfg.rs +++ b/packages/cli/src/cli/cfg.rs @@ -1,3 +1,6 @@ +use clap::ValueEnum; +use serde::Serialize; + use super::*; /// Config options for the build system. @@ -26,8 +29,8 @@ pub struct ConfigOptsBuild { pub profile: Option, /// Build platform: support Web & Desktop [default: "default_platform"] - #[clap(long)] - pub platform: Option, + #[clap(long, value_enum)] + pub platform: Option, /// Space separated list of features to activate #[clap(long)] @@ -69,8 +72,8 @@ pub struct ConfigOptsServe { pub profile: Option, /// Build platform: support Web & Desktop [default: "default_platform"] - #[clap(long)] - pub platform: Option, + #[clap(long, value_enum)] + pub platform: Option, /// Build with hot reloading rsx [default: false] #[clap(long)] @@ -88,6 +91,16 @@ pub struct ConfigOptsServe { pub features: Option>, } +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize, Debug)] +pub enum Platform { + #[clap(name = "web")] + #[serde(rename = "web")] + Web, + #[clap(name = "desktop")] + #[serde(rename = "desktop")] + Desktop, +} + /// Ensure the given value for `--public-url` is formatted correctly. pub fn parse_public_url(val: &str) -> String { let prefix = if !val.starts_with('/') { "/" } else { "" }; diff --git a/packages/cli/src/cli/serve.rs b/packages/cli/src/cli/serve.rs index e50eb0f38..89b9eaaa0 100644 --- a/packages/cli/src/cli/serve.rs +++ b/packages/cli/src/cli/serve.rs @@ -47,33 +47,33 @@ impl Serve { .clone() }); - if platform.as_str() == "desktop" { - crate::builder::build_desktop(&crate_config, true)?; + match platform { + cfg::Platform::Web => { + // generate dev-index page + Serve::regen_dev_page(&crate_config)?; - match &crate_config.executable { - crate::ExecutableType::Binary(name) - | crate::ExecutableType::Lib(name) - | crate::ExecutableType::Example(name) => { - let mut file = crate_config.out_dir.join(name); - if cfg!(windows) { - file.set_extension("exe"); + // start the develop server + server::web::startup(self.serve.port, crate_config.clone(), self.serve.open) + .await?; + } + cfg::Platform::Desktop => { + crate::builder::build_desktop(&crate_config, true)?; + + match &crate_config.executable { + crate::ExecutableType::Binary(name) + | crate::ExecutableType::Lib(name) + | crate::ExecutableType::Example(name) => { + let mut file = crate_config.out_dir.join(name); + if cfg!(windows) { + file.set_extension("exe"); + } + Command::new(file.to_str().unwrap()) + .stdout(Stdio::inherit()) + .output()?; } - Command::new(file.to_str().unwrap()) - .stdout(Stdio::inherit()) - .output()?; } } - return Ok(()); - } else if platform != "web" { - return custom_error!("Unsupported platform target."); } - - // generate dev-index page - Serve::regen_dev_page(&crate_config)?; - - // start the develop server - server::startup(self.serve.port, crate_config.clone(), self.serve.open).await?; - Ok(()) } diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index 05aca0d35..e46b4f0fe 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -1,4 +1,4 @@ -use crate::error::Result; +use crate::{cfg::Platform, error::Result}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, @@ -73,7 +73,7 @@ impl Default for DioxusConfig { Self { application: ApplicationConfig { name: "dioxus".into(), - default_platform: "web".to_string(), + default_platform: Platform::Web, out_dir: Some(PathBuf::from("dist")), asset_dir: Some(PathBuf::from("public")), @@ -115,7 +115,7 @@ impl Default for DioxusConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApplicationConfig { pub name: String, - pub default_platform: String, + pub default_platform: Platform, pub out_dir: Option, pub asset_dir: Option, diff --git a/packages/cli/src/server/desktop/mod.rs b/packages/cli/src/server/desktop/mod.rs new file mode 100644 index 000000000..0a0c0f35f --- /dev/null +++ b/packages/cli/src/server/desktop/mod.rs @@ -0,0 +1,255 @@ +use crate::{ + builder, + serve::Serve, + server::{ + output::{print_console_info, PrettierOptions, WebServerInfo}, + setup_file_watcher, setup_file_watcher_hot_reload, BuildManager, + }, + BuildResult, CrateConfig, Result, +}; +use axum::{ + body::{Full, HttpBody}, + extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade}, + http::{ + header::{HeaderName, HeaderValue}, + Method, Response, StatusCode, + }, + response::IntoResponse, + routing::{get, get_service}, + Router, +}; +use axum_server::tls_rustls::RustlsConfig; +use cargo_metadata::diagnostic::Diagnostic; +use dioxus_core::Template; +use dioxus_hot_reload::HotReloadMsg; +use dioxus_html::HtmlCtx; +use dioxus_rsx::hot_reload::*; +use interprocess_docfix::local_socket::LocalSocketListener; +use notify::{RecommendedWatcher, Watcher}; +use std::{ + net::UdpSocket, + path::PathBuf, + process::{Command, Stdio}, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast::{self, Sender}; +use tower::ServiceBuilder; +use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; +use tower_http::{ + cors::{Any, CorsLayer}, + ServiceBuilderExt, +}; + +#[cfg(feature = "plugin")] +use plugin::PluginManager; + +struct WsReloadState { + update: broadcast::Sender<()>, +} + +pub async fn startup(config: CrateConfig) -> Result<()> { + // ctrl-c shutdown checker + let _crate_config = config.clone(); + let _ = ctrlc::set_handler(move || { + #[cfg(feature = "plugin")] + let _ = PluginManager::on_serve_shutdown(&_crate_config); + std::process::exit(0); + }); + + match config.hot_reload { + true => serve_hot_reload(config).await?, + false => serve_default(config).await?, + } + + Ok(()) +} + +/// Start the server without hot reload +pub async fn serve_default(config: CrateConfig) -> Result<()> { + let first_build_result = start_desktop(&config)?; + + log::info!("🚀 Starting development server..."); + + // We got to own watcher so that it exists for the duration of serve + // Otherwise full reload won't work. + let _watcher = setup_file_watcher(crate::cfg::Platform::Desktop, &config, None, None).await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + None, + ); + + Ok(()) +} + +/// Start the server without hot reload + +/// Start dx serve with hot reload +pub async fn serve_hot_reload(config: CrateConfig) -> Result<()> { + let first_build_result = start_desktop(&config)?; + + println!("🚀 Starting development server..."); + + // Setup hot reload + let FileMapBuildResult { map, errors } = + FileMap::::create(config.crate_dir.clone()).unwrap(); + + println!("🚀 Starting development server..."); + + for err in errors { + log::error!("{}", err); + } + + let file_map = Arc::new(Mutex::new(map)); + let build_manager = Arc::new(BuildManager { + platform: crate::cfg::Platform::Web, + config: config.clone(), + reload_tx: None, + }); + + let (hot_reload_tx, mut hot_reload_rx) = broadcast::channel(100); + + // States + // Setup file watcher + // We got to own watcher so that it exists for the duration of serve + // Otherwise hot reload won't work. + let _watcher = setup_file_watcher_hot_reload( + &config, + hot_reload_tx, + file_map.clone(), + build_manager, + None, + ) + .await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + None, + ); + + #[cfg(target_os = "macos")] + { + // On unix, if you force quit the application, it can leave the file socket open + // This will cause the local socket listener to fail to open + // We check if the file socket is already open from an old session and then delete it + let paths = ["./dioxusin", "./@dioxusin"]; + for path in paths { + let path = PathBuf::from(path); + if path.exists() { + let _ = std::fs::remove_file(path); + } + } + } + + let channels = Arc::new(Mutex::new(Vec::new())); + + match LocalSocketListener::bind("@dioxusin") { + Ok(local_socket_stream) => { + let aborted = Arc::new(Mutex::new(false)); + + // listen for connections + std::thread::spawn({ + let file_map = file_map.clone(); + let channels = channels.clone(); + let aborted = aborted.clone(); + let _ = local_socket_stream.set_nonblocking(true); + move || { + loop { + if let Ok(mut connection) = local_socket_stream.accept() { + // send any templates than have changed before the socket connected + let templates: Vec<_> = { + file_map + .lock() + .unwrap() + .map + .values() + .filter_map(|(_, template_slot)| *template_slot) + .collect() + }; + for template in templates { + if !send_msg( + HotReloadMsg::UpdateTemplate(template), + &mut connection, + ) { + continue; + } + } + channels.lock().unwrap().push(connection); + println!("Connected to hot reloading 🚀"); + } + if *aborted.lock().unwrap() { + break; + } + } + } + }); + + // for channel in &mut *channels.lock().unwrap() { + // send_msg(HotReloadMsg::Shutdown, channel); + // } + + while let Ok(template) = hot_reload_rx.recv().await { + let channels = &mut *channels.lock().unwrap(); + let mut i = 0; + while i < channels.len() { + let channel = &mut channels[i]; + if send_msg(HotReloadMsg::UpdateTemplate(template), channel) { + i += 1; + } else { + channels.remove(i); + } + } + } + } + Err(error) => println!("failed to connect to hot reloading\n{error}"), + } + + Ok(()) +} + +fn send_msg(msg: HotReloadMsg, channel: &mut impl std::io::Write) -> bool { + if let Ok(msg) = serde_json::to_string(&msg) { + if channel.write_all(msg.as_bytes()).is_err() { + return false; + } + if channel.write_all(&[b'\n']).is_err() { + return false; + } + true + } else { + false + } +} + +pub fn start_desktop(config: &CrateConfig) -> Result { + // Run the desktop application + let result = crate::builder::build_desktop(config, true)?; + + match &config.executable { + crate::ExecutableType::Binary(name) + | crate::ExecutableType::Lib(name) + | crate::ExecutableType::Example(name) => { + let mut file = config.out_dir.join(name); + if cfg!(windows) { + file.set_extension("exe"); + } + Command::new(file.to_str().unwrap()) + .stdout(Stdio::inherit()) + .spawn()?; + } + } + + Ok(result) +} diff --git a/packages/cli/src/server/mod.rs b/packages/cli/src/server/mod.rs index 3bd434005..389f5000b 100644 --- a/packages/cli/src/server/mod.rs +++ b/packages/cli/src/server/mod.rs @@ -1,4 +1,7 @@ -use crate::{builder, serve::Serve, BuildResult, CrateConfig, Result}; +use crate::{ + builder, cfg::Platform, serve::Serve, server::desktop::start_desktop, BuildResult, CrateConfig, + Result, +}; use axum::{ body::{Full, HttpBody}, extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade}, @@ -19,7 +22,7 @@ use notify::{RecommendedWatcher, Watcher}; use std::{ net::UdpSocket, path::PathBuf, - process::Command, + process::{Command, Stdio}, sync::{Arc, Mutex}, }; use tokio::sync::broadcast::{self, Sender}; @@ -30,416 +33,20 @@ use tower_http::{ ServiceBuilderExt, }; -#[cfg(feature = "plugin")] -use plugin::PluginManager; - -mod proxy; - -mod hot_reload; -use hot_reload::*; - mod output; use output::*; - -pub struct BuildManager { - config: CrateConfig, - reload_tx: broadcast::Sender<()>, -} - -impl BuildManager { - fn rebuild(&self) -> Result { - log::info!("🪁 Rebuild project"); - let result = builder::build(&self.config, true)?; - // change the websocket reload state to true; - // the page will auto-reload. - if self - .config - .dioxus_config - .web - .watcher - .reload_html - .unwrap_or(false) - { - let _ = Serve::regen_dev_page(&self.config); - } - let _ = self.reload_tx.send(()); - Ok(result) - } -} - -struct WsReloadState { - update: broadcast::Sender<()>, -} - -pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> { - // ctrl-c shutdown checker - let _crate_config = config.clone(); - let _ = ctrlc::set_handler(move || { - #[cfg(feature = "plugin")] - let _ = PluginManager::on_serve_shutdown(&_crate_config); - std::process::exit(0); - }); - - let ip = get_ip().unwrap_or(String::from("0.0.0.0")); - - match config.hot_reload { - true => serve_hot_reload(ip, port, config, start_browser).await?, - false => serve_default(ip, port, config, start_browser).await?, - } - - Ok(()) -} - -/// Start the server without hot reload -pub async fn serve_default( - ip: String, - port: u16, - config: CrateConfig, - start_browser: bool, -) -> Result<()> { - let first_build_result = crate::builder::build(&config, false)?; - - log::info!("🚀 Starting development server..."); - - // WS Reload Watching - let (reload_tx, _) = broadcast::channel(100); - - // We got to own watcher so that it exists for the duration of serve - // Otherwise full reload won't work. - let _watcher = setup_file_watcher(&config, port, ip.clone(), reload_tx.clone()).await?; - - let ws_reload_state = Arc::new(WsReloadState { - update: reload_tx.clone(), - }); - - // HTTPS - // Before console info so it can stop if mkcert isn't installed or fails - let rustls_config = get_rustls(&config).await?; - - // Print serve info - print_console_info( - &ip, - port, - &config, - PrettierOptions { - changed: vec![], - warnings: first_build_result.warnings, - elapsed_time: first_build_result.elapsed_time, - }, - ); - - // Router - let router = setup_router(config, ws_reload_state, None).await?; - - // Start server - start_server(port, router, start_browser, rustls_config).await?; - - Ok(()) -} - -/// Start dx serve with hot reload -pub async fn serve_hot_reload( - ip: String, - port: u16, - config: CrateConfig, - start_browser: bool, -) -> Result<()> { - let first_build_result = crate::builder::build(&config, false)?; - - log::info!("🚀 Starting development server..."); - - // Setup hot reload - let (reload_tx, _) = broadcast::channel(100); - let FileMapBuildResult { map, errors } = - FileMap::::create(config.crate_dir.clone()).unwrap(); - - for err in errors { - log::error!("{}", err); - } - - let file_map = Arc::new(Mutex::new(map)); - let build_manager = Arc::new(BuildManager { - config: config.clone(), - reload_tx: reload_tx.clone(), - }); - - let hot_reload_tx = broadcast::channel(100).0; - - // States - let hot_reload_state = Arc::new(HotReloadState { - messages: hot_reload_tx.clone(), - build_manager: build_manager.clone(), - file_map: file_map.clone(), - watcher_config: config.clone(), - }); - - let ws_reload_state = Arc::new(WsReloadState { - update: reload_tx.clone(), - }); - - // Setup file watcher - // We got to own watcher so that it exists for the duration of serve - // Otherwise hot reload won't work. - let _watcher = setup_file_watcher_hot_reload( - &config, - port, - ip.clone(), - hot_reload_tx, - file_map, - build_manager, - ) - .await?; - - // HTTPS - // Before console info so it can stop if mkcert isn't installed or fails - let rustls_config = get_rustls(&config).await?; - - // Print serve info - print_console_info( - &ip, - port, - &config, - PrettierOptions { - changed: vec![], - warnings: first_build_result.warnings, - elapsed_time: first_build_result.elapsed_time, - }, - ); - - // Router - let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?; - - // Start server - start_server(port, router, start_browser, rustls_config).await?; - - Ok(()) -} - -const DEFAULT_KEY_PATH: &str = "ssl/key.pem"; -const DEFAULT_CERT_PATH: &str = "ssl/cert.pem"; - -/// Returns an enum of rustls config and a bool if mkcert isn't installed -async fn get_rustls(config: &CrateConfig) -> Result> { - let web_config = &config.dioxus_config.web.https; - if web_config.enabled != Some(true) { - return Ok(None); - } - - let (cert_path, key_path) = match web_config.mkcert { - // mkcert, use it - Some(true) => { - // 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 => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."), - e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()), - }; - return Err("failed to generate mkcert certificates".into()); - } - Ok(mut cmd) => { - cmd.wait()?; - } - } - - (cert_path, key_path) - } - // not mkcert - Some(false) => { - // get paths to cert & key - if let (Some(key), Some(cert)) = - (web_config.key_path.clone(), web_config.cert_path.clone()) - { - (cert, key) - } else { - // missing cert or key - return Err("https is enabled but cert or key path is missing".into()); - } - } - // other - _ => return Ok(None), - }; - - Ok(Some( - RustlsConfig::from_pem_file(cert_path, key_path).await?, - )) -} - -/// Sets up and returns a router -async fn setup_router( - config: CrateConfig, - ws_reload: Arc, - hot_reload: Option>, -) -> Result { - // Setup cors - let cors = 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); - - let (coep, coop) = if config.cross_origin_policy { - ( - HeaderValue::from_static("require-corp"), - HeaderValue::from_static("same-origin"), - ) - } else { - ( - HeaderValue::from_static("unsafe-none"), - HeaderValue::from_static("unsafe-none"), - ) - }; - - // Create file service - let file_service_config = config.clone(); - let file_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( - move |response: Response| async move { - let response = if file_service_config - .dioxus_config - .web - .watcher - .index_on_404 - .unwrap_or(false) - && response.status() == StatusCode::NOT_FOUND - { - let body = Full::from( - // TODO: Cache/memoize this. - std::fs::read_to_string( - file_service_config - .crate_dir - .join(file_service_config.out_dir) - .join("index.html"), - ) - .ok() - .unwrap(), - ) - .map_err(|err| match err {}) - .boxed(); - Response::builder() - .status(StatusCode::OK) - .body(body) - .unwrap() - } else { - response.map(|body| body.boxed()) - }; - Ok(response) - }, - ) - .service(ServeDir::new(config.crate_dir.join(&config.out_dir))); - - // Setup websocket - let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); - - // Setup proxy - for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() { - router = proxy::add_proxy(router, &proxy_config)?; - } - - // Route file service - router = router.fallback(get_service(file_service).handle_error( - |error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }, - )); - - // Setup routes - router = router - .route("/_dioxus/hot_reload", get(hot_reload_handler)) - .layer(cors) - .layer(Extension(ws_reload)); - - if let Some(hot_reload) = hot_reload { - router = router.layer(Extension(hot_reload)) - } - - Ok(router) -} - -/// Starts dx serve with no hot reload -async fn start_server( - port: u16, - router: Router, - start_browser: bool, - rustls: Option, -) -> Result<()> { - // If plugins, call on_serve_start event - #[cfg(feature = "plugin")] - PluginManager::on_serve_start(&config)?; - - // Parse address - let addr = format!("0.0.0.0:{}", port).parse().unwrap(); - - // Open the browser - if start_browser { - match rustls { - Some(_) => _ = open::that(format!("https://{}", addr)), - None => _ = open::that(format!("http://{}", addr)), - } - } - - // Start the server with or without rustls - match rustls { - Some(rustls) => { - axum_server::bind_rustls(addr, rustls) - .serve(router.into_make_service()) - .await? - } - None => { - axum::Server::bind(&addr) - .serve(router.into_make_service()) - .await? - } - } - - Ok(()) -} +pub mod desktop; +pub mod web; /// Sets up a file watcher async fn setup_file_watcher( + platform: Platform, config: &CrateConfig, - port: u16, - watcher_ip: String, - reload_tx: Sender<()>, + reload_tx: Option>, + web_info: Option, ) -> Result { let build_manager = BuildManager { + platform, config: config.clone(), reload_tx, }; @@ -466,14 +73,13 @@ async fn setup_file_watcher( #[allow(clippy::redundant_clone)] print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: e.paths.clone(), warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); #[cfg(feature = "plugin")] @@ -504,11 +110,10 @@ async fn setup_file_watcher( /// Sets up a file watcher with hot reload async fn setup_file_watcher_hot_reload( config: &CrateConfig, - port: u16, - watcher_ip: String, hot_reload_tx: Sender>, file_map: Arc>>, build_manager: Arc, + web_info: Option, ) -> Result { // file watcher: check file change let allow_watch_path = config @@ -536,14 +141,13 @@ async fn setup_file_watcher_hot_reload( match build_manager.rebuild() { Ok(res) => { print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: evt.paths, warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); } Err(err) => { @@ -563,14 +167,13 @@ async fn setup_file_watcher_hot_reload( match build_manager.rebuild() { Ok(res) => { print_console_info( - &watcher_ip, - port, &config, PrettierOptions { changed: evt.paths, warnings: res.warnings, elapsed_time: res.elapsed_time, }, + web_info.clone(), ); } Err(err) => { @@ -607,49 +210,34 @@ async fn setup_file_watcher_hot_reload( Ok(watcher) } -/// Get the network ip -fn get_ip() -> Option { - let socket = match UdpSocket::bind("0.0.0.0:0") { - Ok(s) => s, - Err(_) => return None, - }; +pub struct BuildManager { + platform: Platform, + config: CrateConfig, + reload_tx: Option>, +} - match socket.connect("8.8.8.8:80") { - Ok(()) => (), - Err(_) => return None, - }; - - match socket.local_addr() { - Ok(addr) => Some(addr.ip().to_string()), - Err(_) => None, +impl BuildManager { + fn rebuild(&self) -> Result { + log::info!("🪁 Rebuild project"); + match self.platform { + Platform::Web => { + let result = builder::build(&self.config, true)?; + // change the websocket reload state to true; + // the page will auto-reload. + if self + .config + .dioxus_config + .web + .watcher + .reload_html + .unwrap_or(false) + { + let _ = Serve::regen_dev_page(&self.config); + } + let _ = self.reload_tx.as_ref().map(|tx| tx.send(())); + Ok(result) + } + Platform::Desktop => start_desktop(&self.config), + } } } - -/// Handle websockets -async fn ws_handler( - ws: WebSocketUpgrade, - _: Option>, - Extension(state): Extension>, -) -> impl IntoResponse { - ws.on_upgrade(|mut socket| async move { - let mut rx = state.update.subscribe(); - let reload_watcher = tokio::spawn(async move { - loop { - rx.recv().await.unwrap(); - // ignore the error - if socket - .send(Message::Text(String::from("reload"))) - .await - .is_err() - { - break; - } - - // flush the errors after recompling - rx = rx.resubscribe(); - } - }); - - reload_watcher.await.unwrap(); - }) -} diff --git a/packages/cli/src/server/output.rs b/packages/cli/src/server/output.rs index 4c6e1f3b2..ce0f55064 100644 --- a/packages/cli/src/server/output.rs +++ b/packages/cli/src/server/output.rs @@ -11,7 +11,17 @@ pub struct PrettierOptions { pub elapsed_time: u128, } -pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: PrettierOptions) { +#[derive(Debug, Clone)] +pub struct WebServerInfo { + pub ip: String, + pub port: u16, +} + +pub fn print_console_info( + config: &CrateConfig, + options: PrettierOptions, + web_info: Option, +) { if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") { "cls" } else { @@ -70,26 +80,28 @@ pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: ); } - if config.dioxus_config.web.https.enabled == Some(true) { - println!( - "\t> Local : {}", - format!("https://localhost:{}/", port).blue() - ); - println!( - "\t> Network : {}", - format!("https://{}:{}/", ip, port).blue() - ); - println!("\t> HTTPS : {}", "Enabled".to_string().green()); - } else { - println!( - "\t> Local : {}", - format!("http://localhost:{}/", port).blue() - ); - println!( - "\t> Network : {}", - format!("http://{}:{}/", ip, port).blue() - ); - println!("\t> HTTPS : {}", "Disabled".to_string().red()); + if let Some(WebServerInfo { ip, port }) = web_info { + if config.dioxus_config.web.https.enabled == Some(true) { + println!( + "\t> Local : {}", + format!("https://localhost:{}/", port).blue() + ); + println!( + "\t> Network : {}", + format!("https://{}:{}/", ip, port).blue() + ); + println!("\t> HTTPS : {}", "Enabled".to_string().green()); + } else { + println!( + "\t> Local : {}", + format!("http://localhost:{}/", port).blue() + ); + println!( + "\t> Network : {}", + format!("http://{}:{}/", ip, port).blue() + ); + println!("\t> HTTPS : {}", "Disabled".to_string().red()); + } } println!(); println!("\t> Profile : {}", profile.green()); diff --git a/packages/cli/src/server/hot_reload.rs b/packages/cli/src/server/web/hot_reload.rs similarity index 100% rename from packages/cli/src/server/hot_reload.rs rename to packages/cli/src/server/web/hot_reload.rs diff --git a/packages/cli/src/server/web/mod.rs b/packages/cli/src/server/web/mod.rs new file mode 100644 index 000000000..c7661f028 --- /dev/null +++ b/packages/cli/src/server/web/mod.rs @@ -0,0 +1,475 @@ +use crate::{ + builder, + serve::Serve, + server::{ + output::{print_console_info, PrettierOptions, WebServerInfo}, + setup_file_watcher, setup_file_watcher_hot_reload, BuildManager, + }, + BuildResult, CrateConfig, Result, +}; +use axum::{ + body::{Full, HttpBody}, + extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade}, + http::{ + header::{HeaderName, HeaderValue}, + Method, Response, StatusCode, + }, + response::IntoResponse, + routing::{get, get_service}, + Router, +}; +use axum_server::tls_rustls::RustlsConfig; +use cargo_metadata::diagnostic::Diagnostic; +use dioxus_core::Template; +use dioxus_html::HtmlCtx; +use dioxus_rsx::hot_reload::*; +use notify::{RecommendedWatcher, Watcher}; +use std::{ + net::UdpSocket, + path::PathBuf, + process::Command, + sync::{Arc, Mutex}, +}; +use tokio::sync::broadcast::{self, Sender}; +use tower::ServiceBuilder; +use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; +use tower_http::{ + cors::{Any, CorsLayer}, + ServiceBuilderExt, +}; + +#[cfg(feature = "plugin")] +use plugin::PluginManager; + +mod proxy; + +mod hot_reload; +use hot_reload::*; + +struct WsReloadState { + update: broadcast::Sender<()>, +} + +pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> { + // ctrl-c shutdown checker + let _crate_config = config.clone(); + let _ = ctrlc::set_handler(move || { + #[cfg(feature = "plugin")] + let _ = PluginManager::on_serve_shutdown(&_crate_config); + std::process::exit(0); + }); + + let ip = get_ip().unwrap_or(String::from("0.0.0.0")); + + match config.hot_reload { + true => serve_hot_reload(ip, port, config, start_browser).await?, + false => serve_default(ip, port, config, start_browser).await?, + } + + Ok(()) +} + +/// Start the server without hot reload +pub async fn serve_default( + ip: String, + port: u16, + config: CrateConfig, + start_browser: bool, +) -> Result<()> { + let first_build_result = crate::builder::build(&config, false)?; + + log::info!("🚀 Starting development server..."); + + // WS Reload Watching + let (reload_tx, _) = broadcast::channel(100); + + // We got to own watcher so that it exists for the duration of serve + // Otherwise full reload won't work. + let _watcher = setup_file_watcher( + crate::cfg::Platform::Web, + &config, + Some(reload_tx.clone()), + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ) + .await?; + + let ws_reload_state = Arc::new(WsReloadState { + update: reload_tx.clone(), + }); + + // HTTPS + // Before console info so it can stop if mkcert isn't installed or fails + let rustls_config = get_rustls(&config).await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + Some(crate::server::output::WebServerInfo { + ip: ip.clone(), + port, + }), + ); + + // Router + let router = setup_router(config, ws_reload_state, None).await?; + + // Start server + start_server(port, router, start_browser, rustls_config).await?; + + Ok(()) +} + +/// Start dx serve with hot reload +pub async fn serve_hot_reload( + ip: String, + port: u16, + config: CrateConfig, + start_browser: bool, +) -> Result<()> { + let first_build_result = crate::builder::build(&config, false)?; + + log::info!("🚀 Starting development server..."); + + // Setup hot reload + let (reload_tx, _) = broadcast::channel(100); + let FileMapBuildResult { map, errors } = + FileMap::::create(config.crate_dir.clone()).unwrap(); + + for err in errors { + log::error!("{}", err); + } + + let file_map = Arc::new(Mutex::new(map)); + let build_manager = Arc::new(BuildManager { + platform: crate::cfg::Platform::Web, + config: config.clone(), + reload_tx: Some(reload_tx.clone()), + }); + + let hot_reload_tx = broadcast::channel(100).0; + + // States + let hot_reload_state = Arc::new(HotReloadState { + messages: hot_reload_tx.clone(), + build_manager: build_manager.clone(), + file_map: file_map.clone(), + watcher_config: config.clone(), + }); + + let ws_reload_state = Arc::new(WsReloadState { + update: reload_tx.clone(), + }); + + // Setup file watcher + // We got to own watcher so that it exists for the duration of serve + // Otherwise hot reload won't work. + let _watcher = setup_file_watcher_hot_reload( + &config, + hot_reload_tx, + file_map, + build_manager, + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ) + .await?; + + // HTTPS + // Before console info so it can stop if mkcert isn't installed or fails + let rustls_config = get_rustls(&config).await?; + + // Print serve info + print_console_info( + &config, + PrettierOptions { + changed: vec![], + warnings: first_build_result.warnings, + elapsed_time: first_build_result.elapsed_time, + }, + Some(WebServerInfo { + ip: ip.clone(), + port, + }), + ); + + // Router + let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?; + + // Start server + start_server(port, router, start_browser, rustls_config).await?; + + Ok(()) +} + +const DEFAULT_KEY_PATH: &str = "ssl/key.pem"; +const DEFAULT_CERT_PATH: &str = "ssl/cert.pem"; + +/// Returns an enum of rustls config and a bool if mkcert isn't installed +async fn get_rustls(config: &CrateConfig) -> Result> { + let web_config = &config.dioxus_config.web.https; + if web_config.enabled != Some(true) { + return Ok(None); + } + + let (cert_path, key_path) = match web_config.mkcert { + // mkcert, use it + Some(true) => { + // 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 => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."), + e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()), + }; + return Err("failed to generate mkcert certificates".into()); + } + Ok(mut cmd) => { + cmd.wait()?; + } + } + + (cert_path, key_path) + } + // not mkcert + Some(false) => { + // get paths to cert & key + if let (Some(key), Some(cert)) = + (web_config.key_path.clone(), web_config.cert_path.clone()) + { + (cert, key) + } else { + // missing cert or key + return Err("https is enabled but cert or key path is missing".into()); + } + } + // other + _ => return Ok(None), + }; + + Ok(Some( + RustlsConfig::from_pem_file(cert_path, key_path).await?, + )) +} + +/// Sets up and returns a router +async fn setup_router( + config: CrateConfig, + ws_reload: Arc, + hot_reload: Option>, +) -> Result { + // Setup cors + let cors = 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); + + let (coep, coop) = if config.cross_origin_policy { + ( + HeaderValue::from_static("require-corp"), + HeaderValue::from_static("same-origin"), + ) + } else { + ( + HeaderValue::from_static("unsafe-none"), + HeaderValue::from_static("unsafe-none"), + ) + }; + + // Create file service + let file_service_config = config.clone(); + let file_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( + move |response: Response| async move { + let response = if file_service_config + .dioxus_config + .web + .watcher + .index_on_404 + .unwrap_or(false) + && response.status() == StatusCode::NOT_FOUND + { + let body = Full::from( + // TODO: Cache/memoize this. + std::fs::read_to_string( + file_service_config + .crate_dir + .join(file_service_config.out_dir) + .join("index.html"), + ) + .ok() + .unwrap(), + ) + .map_err(|err| match err {}) + .boxed(); + Response::builder() + .status(StatusCode::OK) + .body(body) + .unwrap() + } else { + response.map(|body| body.boxed()) + }; + Ok(response) + }, + ) + .service(ServeDir::new(config.crate_dir.join(&config.out_dir))); + + // Setup websocket + let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); + + // Setup proxy + for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() { + router = proxy::add_proxy(router, &proxy_config)?; + } + + // Route file service + router = router.fallback(get_service(file_service).handle_error( + |error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }, + )); + + // Setup routes + router = router + .route("/_dioxus/hot_reload", get(hot_reload_handler)) + .layer(cors) + .layer(Extension(ws_reload)); + + if let Some(hot_reload) = hot_reload { + router = router.layer(Extension(hot_reload)) + } + + Ok(router) +} + +/// Starts dx serve with no hot reload +async fn start_server( + port: u16, + router: Router, + start_browser: bool, + rustls: Option, +) -> Result<()> { + // If plugins, call on_serve_start event + #[cfg(feature = "plugin")] + PluginManager::on_serve_start(&config)?; + + // Parse address + let addr = format!("0.0.0.0:{}", port).parse().unwrap(); + + // Open the browser + if start_browser { + match rustls { + Some(_) => _ = open::that(format!("https://{}", addr)), + None => _ = open::that(format!("http://{}", addr)), + } + } + + // Start the server with or without rustls + match rustls { + Some(rustls) => { + axum_server::bind_rustls(addr, rustls) + .serve(router.into_make_service()) + .await? + } + None => { + axum::Server::bind(&addr) + .serve(router.into_make_service()) + .await? + } + } + + Ok(()) +} + +/// Get the network ip +fn get_ip() -> Option { + let socket = match UdpSocket::bind("0.0.0.0:0") { + Ok(s) => s, + Err(_) => return None, + }; + + match socket.connect("8.8.8.8:80") { + Ok(()) => (), + Err(_) => return None, + }; + + match socket.local_addr() { + Ok(addr) => Some(addr.ip().to_string()), + Err(_) => None, + } +} + +/// Handle websockets +async fn ws_handler( + ws: WebSocketUpgrade, + _: Option>, + Extension(state): Extension>, +) -> impl IntoResponse { + ws.on_upgrade(|mut socket| async move { + let mut rx = state.update.subscribe(); + let reload_watcher = tokio::spawn(async move { + loop { + rx.recv().await.unwrap(); + // ignore the error + if socket + .send(Message::Text(String::from("reload"))) + .await + .is_err() + { + break; + } + + // flush the errors after recompling + rx = rx.resubscribe(); + } + }); + + reload_watcher.await.unwrap(); + }) +} diff --git a/packages/cli/src/server/proxy.rs b/packages/cli/src/server/web/proxy.rs similarity index 100% rename from packages/cli/src/server/proxy.rs rename to packages/cli/src/server/web/proxy.rs