Selaa lähdekoodia

Merge pull request #1182 from DogeDark/cli-https

CLI HTTPS
Jonathan Kelley 1 vuosi sitten
vanhempi
commit
645706c7f4

+ 1 - 0
packages/cli/Cargo.toml

@@ -39,6 +39,7 @@ indicatif = "0.17.0-rc.11"
 subprocess = "0.2.9"
 
 axum = { version = "0.5.1", features = ["ws", "headers"] }
+axum-server = { version = "0.5.1", features = ["tls-rustls"] }
 tower-http = { version = "0.2.2", features = ["full"] }
 headers = "0.3.7"
 

+ 16 - 0
packages/cli/src/config.rs

@@ -77,6 +77,12 @@ impl Default for DioxusConfig {
                     style: Some(vec![]),
                     script: Some(vec![]),
                 },
+                https: WebHttpsConfig {
+                    enabled: None,
+                    mkcert: None,
+                    key_path: None,
+                    cert_path: None,
+                },
             },
             plugin: toml::Value::Table(toml::map::Map::new()),
         }
@@ -101,6 +107,8 @@ pub struct WebConfig {
     pub proxy: Option<Vec<WebProxyConfig>>,
     pub watcher: WebWatcherConfig,
     pub resource: WebResourceConfig,
+    #[serde(default)]
+    pub https: WebHttpsConfig,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -134,6 +142,14 @@ pub struct WebDevResourceConfig {
     pub script: Option<Vec<PathBuf>>,
 }
 
+#[derive(Debug, Default, Clone, Serialize, Deserialize)]
+pub struct WebHttpsConfig {
+    pub enabled: Option<bool>,
+    pub mkcert: Option<bool>,
+    pub key_path: Option<String>,
+    pub cert_path: Option<String>,
+}
+
 #[derive(Debug, Clone)]
 pub struct CrateConfig {
     pub out_dir: PathBuf,

+ 70 - 0
packages/cli/src/server/hot_reload.rs

@@ -0,0 +1,70 @@
+use std::sync::{Arc, Mutex};
+
+use axum::{
+    extract::{ws::Message, WebSocketUpgrade},
+    response::IntoResponse,
+    Extension, TypedHeader,
+};
+use dioxus_core::Template;
+use dioxus_html::HtmlCtx;
+use dioxus_rsx::hot_reload::FileMap;
+use tokio::sync::broadcast;
+
+use super::BuildManager;
+use crate::CrateConfig;
+
+pub struct HotReloadState {
+    pub messages: broadcast::Sender<Template<'static>>,
+    pub build_manager: Arc<BuildManager>,
+    pub file_map: Arc<Mutex<FileMap<HtmlCtx>>>,
+    pub watcher_config: CrateConfig,
+}
+
+pub async fn hot_reload_handler(
+    ws: WebSocketUpgrade,
+    _: Option<TypedHeader<headers::UserAgent>>,
+    Extension(state): Extension<Arc<HotReloadState>>,
+) -> impl IntoResponse {
+    ws.on_upgrade(|mut socket| async move {
+        log::info!("🔥 Hot Reload WebSocket connected");
+        {
+            // update any rsx calls that changed before the websocket connected.
+            {
+                log::info!("🔮 Finding updates since last compile...");
+                let templates: Vec<_> = {
+                    state
+                        .file_map
+                        .lock()
+                        .unwrap()
+                        .map
+                        .values()
+                        .filter_map(|(_, template_slot)| *template_slot)
+                        .collect()
+                };
+                for template in templates {
+                    if socket
+                        .send(Message::Text(serde_json::to_string(&template).unwrap()))
+                        .await
+                        .is_err()
+                    {
+                        return;
+                    }
+                }
+            }
+            log::info!("finished");
+        }
+
+        let mut rx = state.messages.subscribe();
+        loop {
+            if let Ok(rsx) = rx.recv().await {
+                if socket
+                    .send(Message::Text(serde_json::to_string(&rsx).unwrap()))
+                    .await
+                    .is_err()
+                {
+                    break;
+                };
+            }
+        }
+    })
+}

+ 324 - 432
packages/cli/src/server/mod.rs

@@ -10,8 +10,8 @@ use axum::{
     routing::{get, get_service},
     Router,
 };
+use axum_server::tls_rustls::RustlsConfig;
 use cargo_metadata::diagnostic::Diagnostic;
-use colored::Colorize;
 use dioxus_core::Template;
 use dioxus_html::HtmlCtx;
 use dioxus_rsx::hot_reload::*;
@@ -22,7 +22,7 @@ use std::{
     process::Command,
     sync::{Arc, Mutex},
 };
-use tokio::sync::broadcast;
+use tokio::sync::broadcast::{self, Sender};
 use tower::ServiceBuilder;
 use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};
 use tower_http::{
@@ -35,6 +35,12 @@ use plugin::PluginManager;
 
 mod proxy;
 
+mod hot_reload;
+use hot_reload::*;
+
+mod output;
+use output::*;
+
 pub struct BuildManager {
     config: CrateConfig,
     reload_tx: broadcast::Sender<()>,
@@ -76,72 +82,63 @@ pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Res
 
     let ip = get_ip().unwrap_or(String::from("0.0.0.0"));
 
-    if config.hot_reload {
-        startup_hot_reload(ip, port, config, start_browser).await?
-    } else {
-        startup_default(ip, port, config, start_browser).await?
+    match config.hot_reload {
+        true => serve_hot_reload(ip, port, config, start_browser).await?,
+        false => serve_default(ip, port, config, start_browser).await?,
     }
+
     Ok(())
 }
 
-pub struct HotReloadState {
-    pub messages: broadcast::Sender<Template<'static>>,
-    pub build_manager: Arc<BuildManager>,
-    pub file_map: Arc<Mutex<FileMap<HtmlCtx>>>,
-    pub watcher_config: CrateConfig,
-}
+/// 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)?;
 
-pub async fn hot_reload_handler(
-    ws: WebSocketUpgrade,
-    _: Option<TypedHeader<headers::UserAgent>>,
-    Extension(state): Extension<Arc<HotReloadState>>,
-) -> impl IntoResponse {
-    ws.on_upgrade(|mut socket| async move {
-        log::info!("🔥 Hot Reload WebSocket connected");
-        {
-            // update any rsx calls that changed before the websocket connected.
-            {
-                log::info!("🔮 Finding updates since last compile...");
-                let templates: Vec<_> = {
-                    state
-                        .file_map
-                        .lock()
-                        .unwrap()
-                        .map
-                        .values()
-                        .filter_map(|(_, template_slot)| *template_slot)
-                        .collect()
-                };
-                for template in templates {
-                    if socket
-                        .send(Message::Text(serde_json::to_string(&template).unwrap()))
-                        .await
-                        .is_err()
-                    {
-                        return;
-                    }
-                }
-            }
-            log::info!("finished");
-        }
+    log::info!("🚀 Starting development server...");
 
-        let mut rx = state.messages.subscribe();
-        loop {
-            if let Ok(rsx) = rx.recv().await {
-                if socket
-                    .send(Message::Text(serde_json::to_string(&rsx).unwrap()))
-                    .await
-                    .is_err()
-                {
-                    break;
-                };
-            }
-        }
-    })
+    // 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(())
 }
 
-#[allow(unused_assignments)]
-pub async fn startup_hot_reload(
+/// Start dx serve with hot reload
+pub async fn serve_hot_reload(
     ip: String,
     port: u16,
     config: CrateConfig,
@@ -151,22 +148,24 @@ pub async fn startup_hot_reload(
 
     log::info!("🚀 Starting development server...");
 
-    #[cfg(feature = "plugin")]
-    PluginManager::on_serve_start(&config)?;
-
-    let dist_path = config.out_dir.clone();
+    // Setup hot reload
     let (reload_tx, _) = broadcast::channel(100);
     let FileMapBuildResult { map, errors } =
         FileMap::<HtmlCtx>::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(),
@@ -174,107 +173,28 @@ pub async fn startup_hot_reload(
         watcher_config: config.clone(),
     });
 
-    let crate_dir = config.crate_dir.clone();
     let ws_reload_state = Arc::new(WsReloadState {
         update: reload_tx.clone(),
     });
 
-    // file watcher: check file change
-    let allow_watch_path = config
-        .dioxus_config
-        .web
-        .watcher
-        .watch_path
-        .clone()
-        .unwrap_or_else(|| vec![PathBuf::from("src")]);
-
-    let watcher_config = config.clone();
-    let watcher_ip = ip.clone();
-    let mut last_update_time = chrono::Local::now().timestamp();
-
-    let mut watcher = RecommendedWatcher::new(
-        move |evt: notify::Result<notify::Event>| {
-            let config = watcher_config.clone();
-            // Give time for the change to take effect before reading the file
-            std::thread::sleep(std::time::Duration::from_millis(100));
-            if chrono::Local::now().timestamp() > last_update_time {
-                if let Ok(evt) = evt {
-                    let mut messages: Vec<Template<'static>> = Vec::new();
-                    for path in evt.paths.clone() {
-                        // if this is not a rust file, rebuild the whole project
-                        if path.extension().and_then(|p| p.to_str()) != Some("rs") {
-                            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,
-                                        },
-                                    );
-                                }
-                                Err(err) => {
-                                    log::error!("{}", err);
-                                }
-                            }
-                            return;
-                        }
-                        // find changes to the rsx in the file
-                        let mut map = file_map.lock().unwrap();
-
-                        match map.update_rsx(&path, &crate_dir) {
-                            Ok(UpdateResult::UpdatedRsx(msgs)) => {
-                                messages.extend(msgs);
-                            }
-                            Ok(UpdateResult::NeedsRebuild) => {
-                                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,
-                                            },
-                                        );
-                                    }
-                                    Err(err) => {
-                                        log::error!("{}", err);
-                                    }
-                                }
-                                return;
-                            }
-                            Err(err) => {
-                                log::error!("{}", err);
-                            }
-                        }
-                    }
-                    for msg in messages {
-                        let _ = hot_reload_tx.send(msg);
-                    }
-                }
-                last_update_time = chrono::Local::now().timestamp();
-            }
-        },
-        notify::Config::default(),
+    // 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,
     )
-    .unwrap();
+    .await?;
 
-    for sub_path in allow_watch_path {
-        if let Err(err) = watcher.watch(
-            &config.crate_dir.join(&sub_path),
-            notify::RecursiveMode::Recursive,
-        ) {
-            log::error!("error watching {sub_path:?}: \n{}", err);
-        }
-    }
+    // HTTPS
+    // Before console info so it can stop if mkcert isn't installed or fails
+    let rustls_config = get_rustls(&config).await?;
 
-    // start serve dev-server at 0.0.0.0:8080
+    // Print serve info
     print_console_info(
         &ip,
         port,
@@ -286,6 +206,100 @@ pub async fn startup_hot_reload(
         },
     );
 
+    // 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<Option<RustlsConfig>> {
+    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<WsReloadState>,
+    hot_reload: Option<Arc<HotReloadState>>,
+) -> Result<Router> {
+    // Setup cors
     let cors = CorsLayer::new()
         // allow `GET` and `POST` when accessing the resource
         .allow_methods([Method::GET, Method::POST])
@@ -305,6 +319,7 @@ pub async fn startup_hot_reload(
         )
     };
 
+    // Create file service
     let file_service_config = config.clone();
     let file_service = ServiceBuilder::new()
         .override_response_header(
@@ -345,12 +360,17 @@ pub async fn startup_hot_reload(
                 Ok(response)
             },
         )
-        .service(ServeDir::new(config.crate_dir.join(&dist_path)));
+        .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 {
             (
@@ -360,48 +380,70 @@ pub async fn startup_hot_reload(
         },
     ));
 
-    let router = router
+    // Setup routes
+    router = router
         .route("/_dioxus/hot_reload", get(hot_reload_handler))
         .layer(cors)
-        .layer(Extension(ws_reload_state))
-        .layer(Extension(hot_reload_state));
-
-    let addr = format!("0.0.0.0:{}", port).parse().unwrap();
+        .layer(Extension(ws_reload));
 
-    let server = axum::Server::bind(&addr).serve(router.into_make_service());
-
-    if start_browser {
-        let _ = open::that(format!("http://{}", addr));
+    if let Some(hot_reload) = hot_reload {
+        router = router.layer(Extension(hot_reload))
     }
 
-    server.await?;
-
-    Ok(())
+    Ok(router)
 }
 
-pub async fn startup_default(
-    ip: String,
+/// Starts dx serve with no hot reload
+async fn start_server(
     port: u16,
-    config: CrateConfig,
+    router: Router,
     start_browser: bool,
+    rustls: Option<RustlsConfig>,
 ) -> Result<()> {
-    let first_build_result = crate::builder::build(&config, false)?;
+    // If plugins, call on_serve_start event
+    #[cfg(feature = "plugin")]
+    PluginManager::on_serve_start(&config)?;
 
-    log::info!("🚀 Starting development server...");
+    // Parse address
+    let addr = format!("0.0.0.0:{}", port).parse().unwrap();
 
-    let dist_path = config.out_dir.clone();
+    // Open the browser
+    if start_browser {
+        match rustls {
+            Some(_) => _ = open::that(format!("https://{}", addr)),
+            None => _ = open::that(format!("http://{}", addr)),
+        }
+    }
 
-    let (reload_tx, _) = broadcast::channel(100);
+    // 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(())
+}
 
+/// Sets up a file watcher
+async fn setup_file_watcher(
+    config: &CrateConfig,
+    port: u16,
+    watcher_ip: String,
+    reload_tx: Sender<()>,
+) -> Result<RecommendedWatcher> {
     let build_manager = BuildManager {
         config: config.clone(),
-        reload_tx: reload_tx.clone(),
+        reload_tx,
     };
 
-    let ws_reload_state = Arc::new(WsReloadState {
-        update: reload_tx.clone(),
-    });
-
     let mut last_update_time = chrono::Local::now().timestamp();
 
     // file watcher: check file change
@@ -414,7 +456,6 @@ pub async fn startup_default(
         .unwrap_or_else(|| vec![PathBuf::from("src")]);
 
     let watcher_config = config.clone();
-    let watcher_ip = ip.clone();
     let mut watcher = notify::recommended_watcher(move |info: notify::Result<notify::Event>| {
         let config = watcher_config.clone();
         if let Ok(e) = info {
@@ -456,267 +497,117 @@ pub async fn startup_default(
             )
             .unwrap();
     }
-
-    // start serve dev-server at 0.0.0.0
-    print_console_info(
-        &ip,
-        port,
-        &config,
-        PrettierOptions {
-            changed: vec![],
-            warnings: first_build_result.warnings,
-            elapsed_time: first_build_result.elapsed_time,
-        },
-    );
-
-    #[cfg(feature = "plugin")]
-    PluginManager::on_serve_start(&config)?;
-
-    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"),
-        )
-    };
-
-    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<ServeFileSystemResponseBody>| 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(&dist_path)));
-
-    let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
-    for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() {
-        router = proxy::add_proxy(router, &proxy_config)?;
-    }
-    router = router
-        .fallback(
-            get_service(file_service).handle_error(|error: std::io::Error| async move {
-                (
-                    StatusCode::INTERNAL_SERVER_ERROR,
-                    format!("Unhandled internal error: {}", error),
-                )
-            }),
-        )
-        .layer(cors)
-        .layer(Extension(ws_reload_state));
-
-    let addr = format!("0.0.0.0:{}", port).parse().unwrap();
-    let server = axum::Server::bind(&addr).serve(router.into_make_service());
-
-    if start_browser {
-        let _ = open::that(format!("http://{}", addr));
-    }
-
-    server.await?;
-
-    Ok(())
-}
-
-#[derive(Debug, Default)]
-pub struct PrettierOptions {
-    changed: Vec<PathBuf>,
-    warnings: Vec<Diagnostic>,
-    elapsed_time: u128,
+    Ok(watcher)
 }
 
-fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: PrettierOptions) {
-    if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") {
-        "cls"
-    } else {
-        "clear"
-    })
-    .output()
-    {
-        print!("{}", String::from_utf8_lossy(&native_clearseq.stdout));
-    } else {
-        // Try ANSI-Escape characters
-        print!("\x1b[2J\x1b[H");
-    }
-
-    // for path in &changed {
-    //     let path = path
-    //         .strip_prefix(crate::crate_root().unwrap())
-    //         .unwrap()
-    //         .to_path_buf();
-    //     log::info!("Updated {}", format!("{}", path.to_str().unwrap()).green());
-    // }
-
-    let mut profile = if config.release { "Release" } else { "Debug" }.to_string();
-    if config.custom_profile.is_some() {
-        profile = config.custom_profile.as_ref().unwrap().to_string();
-    }
-    let hot_reload = if config.hot_reload { "RSX" } else { "Normal" };
-    let crate_root = crate::cargo::crate_root().unwrap();
-    let custom_html_file = if crate_root.join("index.html").is_file() {
-        "Custom [index.html]"
-    } else {
-        "Default"
-    };
-    let url_rewrite = if config
+// Todo: reduce duplication and merge with 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<Template<'static>>,
+    file_map: Arc<Mutex<FileMap<HtmlCtx>>>,
+    build_manager: Arc<BuildManager>,
+) -> Result<RecommendedWatcher> {
+    // file watcher: check file change
+    let allow_watch_path = config
         .dioxus_config
         .web
         .watcher
-        .index_on_404
-        .unwrap_or(false)
-    {
-        "True"
-    } else {
-        "False"
-    };
+        .watch_path
+        .clone()
+        .unwrap_or_else(|| vec![PathBuf::from("src")]);
 
-    let proxies = config.dioxus_config.web.proxy.as_ref();
+    let watcher_config = config.clone();
+    let mut last_update_time = chrono::Local::now().timestamp();
 
-    if options.changed.is_empty() {
-        println!(
-            "{} @ v{} [{}] \n",
-            "Dioxus".bold().green(),
-            crate::DIOXUS_CLI_VERSION,
-            chrono::Local::now().format("%H:%M:%S").to_string().dimmed()
-        );
-    } else {
-        println!(
-            "Project Reloaded: {}\n",
-            format!(
-                "Changed {} files. [{}]",
-                options.changed.len(),
-                chrono::Local::now().format("%H:%M:%S").to_string().dimmed()
-            )
-            .purple()
-            .bold()
-        );
-    }
-    println!(
-        "\t> Local : {}",
-        format!("http://localhost:{}/", port).blue()
-    );
-    println!(
-        "\t> Network : {}",
-        format!("http://{}:{}/", ip, port).blue()
-    );
-    println!();
-    println!("\t> Profile : {}", profile.green());
-    println!("\t> Hot Reload : {}", hot_reload.cyan());
-    if let Some(proxies) = proxies {
-        if !proxies.is_empty() {
-            println!("\t> Proxies :");
-            for proxy in proxies {
-                println!("\t\t- {}", proxy.backend.blue());
+    let mut watcher = RecommendedWatcher::new(
+        move |evt: notify::Result<notify::Event>| {
+            let config = watcher_config.clone();
+            // Give time for the change to take effect before reading the file
+            std::thread::sleep(std::time::Duration::from_millis(100));
+            if chrono::Local::now().timestamp() > last_update_time {
+                if let Ok(evt) = evt {
+                    let mut messages: Vec<Template<'static>> = Vec::new();
+                    for path in evt.paths.clone() {
+                        // if this is not a rust file, rebuild the whole project
+                        if path.extension().and_then(|p| p.to_str()) != Some("rs") {
+                            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,
+                                        },
+                                    );
+                                }
+                                Err(err) => {
+                                    log::error!("{}", err);
+                                }
+                            }
+                            return;
+                        }
+                        // find changes to the rsx in the file
+                        let mut map = file_map.lock().unwrap();
+
+                        match map.update_rsx(&path, &config.crate_dir) {
+                            Ok(UpdateResult::UpdatedRsx(msgs)) => {
+                                messages.extend(msgs);
+                            }
+                            Ok(UpdateResult::NeedsRebuild) => {
+                                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,
+                                            },
+                                        );
+                                    }
+                                    Err(err) => {
+                                        log::error!("{}", err);
+                                    }
+                                }
+                                return;
+                            }
+                            Err(err) => {
+                                log::error!("{}", err);
+                            }
+                        }
+                    }
+                    for msg in messages {
+                        let _ = hot_reload_tx.send(msg);
+                    }
+                }
+                last_update_time = chrono::Local::now().timestamp();
             }
+        },
+        notify::Config::default(),
+    )
+    .unwrap();
+
+    for sub_path in allow_watch_path {
+        if let Err(err) = watcher.watch(
+            &config.crate_dir.join(&sub_path),
+            notify::RecursiveMode::Recursive,
+        ) {
+            log::error!("error watching {sub_path:?}: \n{}", err);
         }
     }
-    println!("\t> Index Template : {}", custom_html_file.green());
-    println!("\t> URL Rewrite [index_on_404] : {}", url_rewrite.purple());
-    println!();
-    println!(
-        "\t> Build Time Use : {} millis",
-        options.elapsed_time.to_string().green().bold()
-    );
-    println!();
 
-    if options.warnings.is_empty() {
-        log::info!("{}\n", "A perfect compilation!".green().bold());
-    } else {
-        log::warn!(
-            "{}",
-            format!(
-                "There were {} warning messages during the build.",
-                options.warnings.len() - 1
-            )
-            .yellow()
-            .bold()
-        );
-        // for info in &options.warnings {
-        //     let message = info.message.clone();
-        //     if message == format!("{} warnings emitted", options.warnings.len() - 1) {
-        //         continue;
-        //     }
-        //     let mut console = String::new();
-        //     for span in &info.spans {
-        //         let file = &span.file_name;
-        //         let line = (span.line_start, span.line_end);
-        //         let line_str = if line.0 == line.1 {
-        //             line.0.to_string()
-        //         } else {
-        //             format!("{}~{}", line.0, line.1)
-        //         };
-        //         let code = span.text.clone();
-        //         let span_info = if code.len() == 1 {
-        //             let code = code.get(0).unwrap().text.trim().blue().bold().to_string();
-        //             format!(
-        //                 "[{}: {}]: '{}' --> {}",
-        //                 file,
-        //                 line_str,
-        //                 code,
-        //                 message.yellow().bold()
-        //             )
-        //         } else {
-        //             let code = code
-        //                 .iter()
-        //                 .enumerate()
-        //                 .map(|(_i, s)| format!("\t{}\n", s.text).blue().bold().to_string())
-        //                 .collect::<String>();
-        //             format!("[{}: {}]:\n{}\n#:{}", file, line_str, code, message)
-        //         };
-        //         console = format!("{console}\n\t{span_info}");
-        //     }
-        //     println!("{console}");
-        // }
-        // println!(
-        //     "\n{}\n",
-        //     "Resolving all warnings will help your code run better!".yellow()
-        // );
-    }
+    Ok(watcher)
 }
 
+/// Get the network ip
 fn get_ip() -> Option<String> {
     let socket = match UdpSocket::bind("0.0.0.0:0") {
         Ok(s) => s,
@@ -734,6 +625,7 @@ fn get_ip() -> Option<String> {
     }
 }
 
+/// Handle websockets
 async fn ws_handler(
     ws: WebSocketUpgrade,
     _: Option<TypedHeader<headers::UserAgent>>,

+ 127 - 0
packages/cli/src/server/output.rs

@@ -0,0 +1,127 @@
+use crate::server::Diagnostic;
+use crate::CrateConfig;
+use colored::Colorize;
+use std::path::PathBuf;
+use std::process::Command;
+
+#[derive(Debug, Default)]
+pub struct PrettierOptions {
+    pub changed: Vec<PathBuf>,
+    pub warnings: Vec<Diagnostic>,
+    pub elapsed_time: u128,
+}
+
+pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: PrettierOptions) {
+    if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") {
+        "cls"
+    } else {
+        "clear"
+    })
+    .output()
+    {
+        print!("{}", String::from_utf8_lossy(&native_clearseq.stdout));
+    } else {
+        // Try ANSI-Escape characters
+        print!("\x1b[2J\x1b[H");
+    }
+
+    let mut profile = if config.release { "Release" } else { "Debug" }.to_string();
+    if config.custom_profile.is_some() {
+        profile = config.custom_profile.as_ref().unwrap().to_string();
+    }
+    let hot_reload = if config.hot_reload { "RSX" } else { "Normal" };
+    let crate_root = crate::cargo::crate_root().unwrap();
+    let custom_html_file = if crate_root.join("index.html").is_file() {
+        "Custom [index.html]"
+    } else {
+        "Default"
+    };
+    let url_rewrite = if config
+        .dioxus_config
+        .web
+        .watcher
+        .index_on_404
+        .unwrap_or(false)
+    {
+        "True"
+    } else {
+        "False"
+    };
+
+    let proxies = config.dioxus_config.web.proxy.as_ref();
+
+    if options.changed.is_empty() {
+        println!(
+            "{} @ v{} [{}] \n",
+            "Dioxus".bold().green(),
+            crate::DIOXUS_CLI_VERSION,
+            chrono::Local::now().format("%H:%M:%S").to_string().dimmed()
+        );
+    } else {
+        println!(
+            "Project Reloaded: {}\n",
+            format!(
+                "Changed {} files. [{}]",
+                options.changed.len(),
+                chrono::Local::now().format("%H:%M:%S").to_string().dimmed()
+            )
+            .purple()
+            .bold()
+        );
+    }
+
+    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());
+    println!("\t> Hot Reload : {}", hot_reload.cyan());
+    if let Some(proxies) = proxies {
+        if !proxies.is_empty() {
+            println!("\t> Proxies :");
+            for proxy in proxies {
+                println!("\t\t- {}", proxy.backend.blue());
+            }
+        }
+    }
+    println!("\t> Index Template : {}", custom_html_file.green());
+    println!("\t> URL Rewrite [index_on_404] : {}", url_rewrite.purple());
+    println!();
+    println!(
+        "\t> Build Time Use : {} millis",
+        options.elapsed_time.to_string().green().bold()
+    );
+    println!();
+
+    if options.warnings.is_empty() {
+        log::info!("{}\n", "A perfect compilation!".green().bold());
+    } else {
+        log::warn!(
+            "{}",
+            format!(
+                "There were {} warning messages during the build.",
+                options.warnings.len() - 1
+            )
+            .yellow()
+            .bold()
+        );
+    }
+}