//! Handler code for hotreloading. //! //! This sets up a websocket connection to the devserver and handles messages from it. //! We also set up a little recursive timer that will attempt to reconnect if the connection is lost. use dioxus_devtools::{DevserverMsg, HotReloadMsg}; use futures_channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; use js_sys::JsString; use std::fmt::Display; use std::time::Duration; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::{closure::Closure, JsValue}; use web_sys::{window, CloseEvent, MessageEvent, WebSocket}; const POLL_INTERVAL_MIN: i32 = 250; const POLL_INTERVAL_MAX: i32 = 4000; const POLL_INTERVAL_SCALE_FACTOR: i32 = 2; /// Amount of time that toats should be displayed. const TOAST_TIMEOUT: Duration = Duration::from_secs(5); const TOAST_TIMEOUT_LONG: Duration = Duration::from_secs(3600); // Duration::MAX is too long for JS. pub(crate) fn init(config: &crate::Config) -> UnboundedReceiver { // Create the tx/rx pair that we'll use for the top-level future in the dioxus loop let (tx, rx) = unbounded(); // Wire up the websocket to the devserver make_ws(tx.clone(), POLL_INTERVAL_MIN, false); // Set up the playground playground(tx); // Set up the panic hook if config.panic_hook { std::panic::set_hook(Box::new(|info| { hook_impl(info); })); } rx } fn make_ws(tx: UnboundedSender, poll_interval: i32, reload: bool) { // Get the location of the devserver, using the current location plus the /_dioxus path // The idea here being that the devserver is always located on the /_dioxus behind a proxy let location = web_sys::window().unwrap().location(); let url = format!( "{protocol}//{host}/_dioxus?build_id={build_id}", protocol = match location.protocol().unwrap() { prot if prot == "https:" => "wss:", _ => "ws:", }, host = location.host().unwrap(), build_id = dioxus_cli_config::build_id(), ); let ws = WebSocket::new(&url).unwrap(); // Set the onmessage handler to bounce messages off to the main dioxus loop let tx_ = tx.clone(); ws.set_onmessage(Some( Closure::::new(move |e: MessageEvent| { let Ok(text) = e.data().dyn_into::() else { return; }; // The devserver messages have some &'static strs in them, so we need to leak the source string let string: String = text.into(); let string = Box::leak(string.into_boxed_str()); match serde_json::from_str::(string) { Ok(DevserverMsg::HotReload(hr)) => _ = tx_.unbounded_send(hr), // todo: we want to throw a screen here that shows the user that the devserver has disconnected // Would be nice to do that with dioxus itself or some html/css // But if the dev server shutsdown we don't want to be super aggressive about it... let's // play with other devservers to see how they handle this Ok(DevserverMsg::Shutdown) => { web_sys::console::error_1(&"Connection to the devserver was closed".into()) } // The devserver is telling us that it started a full rebuild. This does not mean that it is ready. Ok(DevserverMsg::FullReloadStart) => show_toast( "Your app is being rebuilt.", "A non-hot-reloadable change occurred and we must rebuild.", ToastLevel::Info, TOAST_TIMEOUT_LONG, false, ), // The devserver is telling us that it started a full rebuild. This does not mean that it is ready. Ok(DevserverMsg::HotPatchStart) => show_toast( "Hot-patching app...", "Hot-patching modified Rust code.", ToastLevel::Info, TOAST_TIMEOUT_LONG, false, ), // The devserver is telling us that the full rebuild failed. Ok(DevserverMsg::FullReloadFailed) => show_toast( "Oops! The build failed.", "We tried to rebuild your app, but something went wrong.", ToastLevel::Error, TOAST_TIMEOUT_LONG, false, ), // The devserver is telling us to reload the whole page Ok(DevserverMsg::FullReloadCommand) => { show_toast( "Successfully rebuilt.", "Your app was rebuilt successfully and without error.", ToastLevel::Success, TOAST_TIMEOUT, true, ); window().unwrap().location().reload().unwrap() } Err(e) => web_sys::console::error_1( &format!("Error parsing devserver message: {}", e).into(), ), e => { web_sys::console::error_1( &format!("Error parsing devserver message: {:?}", e).into(), ); } } }) .into_js_value() .as_ref() .unchecked_ref(), )); // Set the onclose handler to reload the page if the connection is closed ws.set_onclose(Some( Closure::::new(move |e: CloseEvent| { // Firefox will send a 1001 code when the connection is closed because the page is reloaded // Only firefox will trigger the onclose event when the page is reloaded manually: https://stackoverflow.com/questions/10965720/should-websocket-onclose-be-triggered-by-user-navigation-or-refresh // We should not reload the page in this case if e.code() == 1001 { return; } // set timeout to reload the page in timeout_ms let tx = tx.clone(); web_sys::window() .unwrap() .set_timeout_with_callback_and_timeout_and_arguments_0( Closure::::new(move || { make_ws( tx.clone(), POLL_INTERVAL_MAX.min(poll_interval * POLL_INTERVAL_SCALE_FACTOR), true, ); }) .into_js_value() .as_ref() .unchecked_ref(), poll_interval, ) .unwrap(); }) .into_js_value() .as_ref() .unchecked_ref(), )); // Set the onopen handler to reload the page if the connection is closed ws.set_onopen(Some( Closure::::new(move |_evt| { if reload { window().unwrap().location().reload().unwrap(); } }) .into_js_value() .as_ref() .unchecked_ref(), )); // monkey patch our console.log / console.error to send the logs to the websocket // this will let us see the logs in the devserver! // We only do this if we're not reloading the page, since that will cause duplicate monkey patches if !reload { // the method we need to patch: // https://developer.mozilla.org/en-US/docs/Web/API/Console/log // log, info, warn, error, debug let ws: &JsValue = ws.as_ref(); dioxus_interpreter_js::minimal_bindings::monkeyPatchConsole(ws.clone()); } } /// Represents what color the toast should have. pub(crate) enum ToastLevel { /// Green Success, /// Blue Info, /// Red Error, } impl Display for ToastLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ToastLevel::Success => write!(f, "success"), ToastLevel::Info => write!(f, "info"), ToastLevel::Error => write!(f, "error"), } } } #[wasm_bindgen(inline_js = r#" export function js_show_toast(header_text, message, level, as_ms) { if (typeof showDXToast !== "undefined") {{ window.showDXToast(header_text, message, level, as_ms); }} } export function js_schedule_toast(header_text, message, level, as_ms) { if (typeof scheduleDXToast !== "undefined") {{ window.scheduleDXToast(header_text, message, level, as_ms); }} } "#)] extern "C" { fn js_schedule_toast(header_text: &str, message: &str, level: String, as_ms: u32); fn js_show_toast(header_text: &str, message: &str, level: String, as_ms: u32); } /// Displays a toast to the developer. pub(crate) fn show_toast( header_text: &str, message: &str, level: ToastLevel, duration: Duration, after_reload: bool, ) { let as_ms: u32 = duration.as_millis().try_into().unwrap(); match after_reload { true => js_schedule_toast(header_text, message, level.to_string(), as_ms), false => js_show_toast(header_text, message, level.to_string(), as_ms), } } /// Force a hotreload of the assets on this page by walking them and changing their URLs to include /// some extra entropy. /// /// This should... mostly work. pub(crate) fn invalidate_browser_asset_cache() { // it might be triggering a reload of assets // invalidate all the stylesheets on the page let links = web_sys::window() .unwrap() .document() .unwrap() .query_selector_all("link[rel=stylesheet]") .unwrap(); let noise = js_sys::Math::random(); for x in 0..links.length() { use wasm_bindgen::JsCast; let link: web_sys::Element = links.get(x).unwrap().unchecked_into(); if let Some(href) = link.get_attribute("href") { let (url, query) = href.split_once('?').unwrap_or((&href, "")); let mut query_params: Vec<&str> = query.split('&').collect(); // Remove the old force reload param query_params.retain(|param| !param.starts_with("dx_force_reload=")); // Add the new force reload param let force_reload = format!("dx_force_reload={noise}"); query_params.push(&force_reload); // Rejoin the query let query = query_params.join("&"); _ = link.set_attribute("href", &format!("{url}?{query}")); } } } /// Initialize required devtools for dioxus-playground. /// /// This listens for window message events from other Windows (such as window.top when this is running in an iframe). fn playground(tx: UnboundedSender) { let window = web_sys::window().expect("this code should be running in a web context"); let binding = Closure::::new(move |e: MessageEvent| { let Ok(text) = e.data().dyn_into::() else { return; }; let string: String = text.into(); let Ok(hr_msg) = serde_json::from_str::(&string) else { return; }; _ = tx.unbounded_send(hr_msg); }); let callback = binding.as_ref().unchecked_ref(); window .add_event_listener_with_callback("message", callback) .expect("event listener should be added successfully"); binding.forget(); } fn hook_impl(info: &std::panic::PanicHookInfo) { #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = console)] fn error(msg: String); type Error; #[wasm_bindgen(constructor)] fn new() -> Error; #[wasm_bindgen(structural, method, getter)] fn stack(error: &Error) -> String; } let mut msg = info.to_string(); // Add the error stack to our message. // // This ensures that even if the `console` implementation doesn't // include stacks for `console.error`, the stack is still available // for the user. Additionally, Firefox's console tries to clean up // stack traces, and ruins Rust symbols in the process // (https://bugzilla.mozilla.org/show_bug.cgi?id=1519569) but since // it only touches the logged message's associated stack, and not // the message's contents, by including the stack in the message // contents we make sure it is available to the user. msg.push_str("\n\nStack:\n\n"); let e = Error::new(); let stack = e.stack(); msg.push_str(&stack); // Safari's devtools, on the other hand, _do_ mess with logged // messages' contents, so we attempt to break their heuristics for // doing that by appending some whitespace. // https://github.com/rustwasm/console_error_panic_hook/issues/7 msg.push_str("\n\n"); // Log the panic with `console.error`! error(msg.clone()); show_toast( "App panicked! See console for details.", &msg, ToastLevel::Error, TOAST_TIMEOUT_LONG, false, ) }