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:
181 files changed
+12755
-10806
No files matched your search
@@ -1,40 +0,0 @@
|
||||
(function () {
|
||||
const POLL_INTERVAL_MIN = 250;
|
||||
const POLL_INTERVAL_MAX = 4000;
|
||||
const POLL_INTERVAL_SCALE_FACTOR = 2;
|
||||
|
||||
var protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
var url = protocol + "//" + window.location.host + "/_dioxus/ws";
|
||||
|
||||
var reload_upon_connect = (event, poll_interval) => {
|
||||
// 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 (event.code === 1001) {
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
var ws = new WebSocket(url);
|
||||
ws.onopen = () => window.location.reload();
|
||||
ws.onclose = (event) => {
|
||||
reload_upon_connect(
|
||||
event,
|
||||
Math.min(
|
||||
POLL_INTERVAL_MAX,
|
||||
poll_interval * POLL_INTERVAL_SCALE_FACTOR
|
||||
)
|
||||
);
|
||||
};
|
||||
}, poll_interval);
|
||||
};
|
||||
|
||||
var ws = new WebSocket(url);
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (ev.data == "reload") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => reload_upon_connect(event, POLL_INTERVAL_MIN);
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::HotReloadMsg;
|
||||
use dioxus_core::{internal::HotReloadLiteral, ScopeId, VirtualDom};
|
||||
use dioxus_signals::Writable;
|
||||
|
||||
/// Applies template and literal changes to the VirtualDom
|
||||
///
|
||||
/// Assets need to be handled by the renderer.
|
||||
pub fn apply_changes(dom: &mut VirtualDom, msg: &HotReloadMsg) {
|
||||
for templates in &msg.templates {
|
||||
for template in &templates.templates {
|
||||
dom.replace_template(*template);
|
||||
}
|
||||
|
||||
dom.runtime().on_scope(ScopeId::ROOT, || {
|
||||
let ctx = dioxus_signals::get_global_context();
|
||||
|
||||
for (id, literal) in templates.changed_lits.iter() {
|
||||
match &literal {
|
||||
HotReloadLiteral::Fmted(f) => {
|
||||
if let Some(mut signal) = ctx.get_signal_with_key(id) {
|
||||
signal.set(f.clone());
|
||||
}
|
||||
}
|
||||
HotReloadLiteral::Float(f) => {
|
||||
if let Some(mut signal) = ctx.get_signal_with_key::<f64>(id) {
|
||||
signal.set(*f);
|
||||
}
|
||||
}
|
||||
HotReloadLiteral::Int(f) => {
|
||||
if let Some(mut signal) = ctx.get_signal_with_key::<i64>(id) {
|
||||
signal.set(*f);
|
||||
}
|
||||
}
|
||||
HotReloadLiteral::Bool(f) => {
|
||||
if let Some(mut signal) = ctx.get_signal_with_key::<bool>(id) {
|
||||
signal.set(*f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::HotReloadMsg;
|
||||
use dioxus_rsx::{
|
||||
hot_reload::{FileMap, FileMapBuildResult, UpdateResult},
|
||||
HotReloadingContext,
|
||||
};
|
||||
use interprocess::local_socket::LocalSocketListener;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
#[cfg(feature = "file_watcher")]
|
||||
use dioxus_html::HtmlCtx;
|
||||
|
||||
pub struct Config<Ctx: HotReloadingContext> {
|
||||
root_path: &'static str,
|
||||
listening_paths: &'static [&'static str],
|
||||
excluded_paths: &'static [&'static str],
|
||||
log: bool,
|
||||
rebuild_with: Option<Box<dyn FnMut() -> bool + Send + 'static>>,
|
||||
phantom: std::marker::PhantomData<Ctx>,
|
||||
}
|
||||
|
||||
impl<Ctx: HotReloadingContext> Default for Config<Ctx> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root_path: "",
|
||||
listening_paths: &[""],
|
||||
excluded_paths: &["./target"],
|
||||
log: true,
|
||||
rebuild_with: None,
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "file_watcher")]
|
||||
impl Config<HtmlCtx> {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
root_path: "",
|
||||
listening_paths: &[""],
|
||||
excluded_paths: &["./target"],
|
||||
log: true,
|
||||
rebuild_with: None,
|
||||
phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Ctx: HotReloadingContext> Config<Ctx> {
|
||||
/// Set the root path of the project (where the Cargo.toml file is). This is automatically set by the [`hot_reload_init`] macro.
|
||||
pub fn root(self, path: &'static str) -> Self {
|
||||
Self {
|
||||
root_path: path,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Set whether to enable logs
|
||||
pub fn with_logging(self, log: bool) -> Self {
|
||||
Self { log, ..self }
|
||||
}
|
||||
|
||||
/// Set the command to run to rebuild the project
|
||||
///
|
||||
/// For example to restart the application after a change is made, you could use `cargo run`
|
||||
pub fn with_rebuild_command(self, rebuild_command: &'static str) -> Self {
|
||||
self.with_rebuild_callback(move || {
|
||||
execute::shell(rebuild_command)
|
||||
.spawn()
|
||||
.expect("Failed to spawn the rebuild command");
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Set a callback to run to when the project needs to be rebuilt and returns if the server should shut down
|
||||
///
|
||||
/// For example a CLI application could rebuild the application when a change is made
|
||||
pub fn with_rebuild_callback(
|
||||
self,
|
||||
rebuild_callback: impl FnMut() -> bool + Send + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
rebuild_with: Some(Box::new(rebuild_callback)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the paths to listen for changes in to trigger hot reloading. If this is a directory it will listen for changes in all files in that directory recursively.
|
||||
pub fn with_paths(self, paths: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
listening_paths: paths,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets paths to ignore changes on. This will override any paths set in the [`Config::with_paths`] method in the case of conflicts.
|
||||
pub fn excluded_paths(self, paths: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
excluded_paths: paths,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the hot reloading listener
|
||||
///
|
||||
/// This is designed to be called by hot_reload_Init!() which will pass in information about the project
|
||||
///
|
||||
/// Notes:
|
||||
/// - We don't wannt to watch the
|
||||
pub fn init<Ctx: HotReloadingContext + Send + 'static>(cfg: Config<Ctx>) {
|
||||
let Config {
|
||||
mut rebuild_with,
|
||||
root_path,
|
||||
listening_paths,
|
||||
log,
|
||||
excluded_paths,
|
||||
..
|
||||
} = cfg;
|
||||
|
||||
let Ok(crate_dir) = PathBuf::from_str(root_path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// try to find the gitignore file
|
||||
let gitignore_file_path = crate_dir.join(".gitignore");
|
||||
let (gitignore, _) = ignore::gitignore::Gitignore::new(gitignore_file_path);
|
||||
|
||||
// convert the excluded paths to absolute paths
|
||||
let excluded_paths = excluded_paths
|
||||
.iter()
|
||||
.map(|path| crate_dir.join(PathBuf::from(path)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let channels = Arc::new(Mutex::new(Vec::new()));
|
||||
let FileMapBuildResult {
|
||||
map: file_map,
|
||||
errors,
|
||||
} = FileMap::<Ctx>::create_with_filter(crate_dir.clone(), |path| {
|
||||
// skip excluded paths
|
||||
excluded_paths.iter().any(|p| path.starts_with(p)) ||
|
||||
// respect .gitignore
|
||||
gitignore
|
||||
.matched_path_or_any_parents(path, path.is_dir())
|
||||
.is_ignore()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
for err in errors {
|
||||
if log {
|
||||
println!("hot reloading failed to initialize:\n{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let file_map = Arc::new(Mutex::new(file_map));
|
||||
|
||||
let target_dir = crate_dir.join("target");
|
||||
let hot_reload_socket_path = target_dir.join("dioxusin");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// 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
|
||||
if hot_reload_socket_path.exists() {
|
||||
let _ = std::fs::remove_file(hot_reload_socket_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let listener = if cfg!(windows) {
|
||||
LocalSocketListener::bind("@dioxusin")
|
||||
} else {
|
||||
LocalSocketListener::bind(hot_reload_socket_path)
|
||||
};
|
||||
|
||||
let local_socket_stream = match listener {
|
||||
Ok(local_socket_stream) => local_socket_stream,
|
||||
Err(err) => {
|
||||
println!("failed to connect to hot reloading\n{err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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()
|
||||
.flat_map(|v| v.templates.values().copied())
|
||||
.collect()
|
||||
};
|
||||
|
||||
for template in templates {
|
||||
if !send_msg(HotReloadMsg::UpdateTemplate(template), &mut connection) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
channels.lock().unwrap().push(connection);
|
||||
if log {
|
||||
println!("Connected to hot reloading 🚀");
|
||||
}
|
||||
}
|
||||
if *aborted.lock().unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// watch for changes
|
||||
std::thread::spawn(move || {
|
||||
let mut last_update_time = chrono::Local::now().timestamp();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()).unwrap();
|
||||
|
||||
let mut listening_pathbufs = vec![];
|
||||
|
||||
// We're attempting to watch the root path... which contains a target directory...
|
||||
// And on some platforms the target directory is really really large and can cause the watcher to crash
|
||||
// since it runs out of file handles
|
||||
// So we're going to iterate through its children and watch them instead of the root path, skipping the target
|
||||
// directory.
|
||||
//
|
||||
// In reality, this whole approach of doing embedded file watching is kinda hairy since you want full knowledge
|
||||
// of where rust code is. We could just use the filemap we generated above as an indication of where the rust
|
||||
// code is in this project and deduce the subfolders under the root path from that.
|
||||
//
|
||||
// FIXME: use a more robust system here for embedded discovery
|
||||
//
|
||||
// https://github.com/DioxusLabs/dioxus/issues/1914
|
||||
if listening_paths == [""] {
|
||||
for entry in std::fs::read_dir(&crate_dir)
|
||||
.expect("failed to read rust crate directory. Are you running with cargo?")
|
||||
{
|
||||
let entry = entry.expect("failed to read directory entry");
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if path == target_dir {
|
||||
continue;
|
||||
}
|
||||
listening_pathbufs.push(path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for path in listening_paths {
|
||||
let full_path = crate_dir.join(path);
|
||||
listening_pathbufs.push(full_path);
|
||||
}
|
||||
}
|
||||
|
||||
for full_path in listening_pathbufs {
|
||||
if let Err(err) = watcher.watch(&full_path, RecursiveMode::Recursive) {
|
||||
if log {
|
||||
println!("hot reloading failed to start watching {full_path:?}:\n{err:?}",);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rebuild = {
|
||||
let aborted = aborted.clone();
|
||||
let channels = channels.clone();
|
||||
move || {
|
||||
if let Some(rebuild_callback) = &mut rebuild_with {
|
||||
if log {
|
||||
println!("Rebuilding the application...");
|
||||
}
|
||||
let shutdown = rebuild_callback();
|
||||
|
||||
if shutdown {
|
||||
*aborted.lock().unwrap() = true;
|
||||
}
|
||||
|
||||
for channel in &mut *channels.lock().unwrap() {
|
||||
send_msg(HotReloadMsg::Shutdown, channel);
|
||||
}
|
||||
|
||||
return shutdown;
|
||||
} else if log {
|
||||
println!("Rebuild needed... shutting down hot reloading.\nManually rebuild the application to view further changes.");
|
||||
}
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
for evt in rx {
|
||||
if chrono::Local::now().timestamp_millis() < last_update_time {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(evt) = evt else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let real_paths = evt
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
// skip non rust files
|
||||
matches!(
|
||||
path.extension().and_then(|p| p.to_str()),
|
||||
Some("rs" | "toml" | "css" | "html" | "js")
|
||||
) &&
|
||||
// skip excluded paths
|
||||
!excluded_paths.iter().any(|p| path.starts_with(p)) &&
|
||||
// respect .gitignore
|
||||
!gitignore
|
||||
.matched_path_or_any_parents(path, false)
|
||||
.is_ignore()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Give time for the change to take effect before reading the file
|
||||
if !real_paths.is_empty() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let mut channels = channels.lock().unwrap();
|
||||
for path in real_paths {
|
||||
// if this file type cannot be hot reloaded, rebuild the application
|
||||
if path.extension().and_then(|p| p.to_str()) != Some("rs") && rebuild() {
|
||||
return;
|
||||
}
|
||||
// find changes to the rsx in the file
|
||||
let changes = file_map
|
||||
.lock()
|
||||
.unwrap()
|
||||
.update_rsx(path, crate_dir.as_path());
|
||||
|
||||
match changes {
|
||||
Ok(UpdateResult::UpdatedRsx(msgs)) => {
|
||||
for msg in msgs {
|
||||
let mut i = 0;
|
||||
while i < channels.len() {
|
||||
let channel = &mut channels[i];
|
||||
if send_msg(HotReloadMsg::UpdateTemplate(msg), channel) {
|
||||
i += 1;
|
||||
} else {
|
||||
channels.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UpdateResult::NeedsRebuild) => {
|
||||
drop(channels);
|
||||
if rebuild() {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if log {
|
||||
println!("hot reloading failed to update rsx:\n{err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_update_time = chrono::Local::now().timestamp_millis();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn send_msg(msg: HotReloadMsg, channel: &mut impl 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
|
||||
}
|
||||
}
|
||||
+43
-102
@@ -1,124 +1,65 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use dioxus_core::Template;
|
||||
#[cfg(feature = "file_watcher")]
|
||||
pub use dioxus_html::HtmlCtx;
|
||||
use interprocess::local_socket::LocalSocketStream;
|
||||
use dioxus_rsx::HotReloadedTemplate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(feature = "custom_file_watcher")]
|
||||
mod file_watcher;
|
||||
#[cfg(feature = "custom_file_watcher")]
|
||||
pub use file_watcher::*;
|
||||
#[cfg(feature = "client")]
|
||||
mod client;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
pub use client::*;
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
mod websocket;
|
||||
#[cfg(feature = "serve")]
|
||||
pub use websocket::*;
|
||||
mod ws_receiver;
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
/// The script to inject into the page to reconnect to server if the connection is lost
|
||||
pub const RECONNECT_SCRIPT: &str = include_str!("assets/autoreload.js");
|
||||
pub use ws_receiver::*;
|
||||
|
||||
/// A message the hot reloading server sends to the client
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(bound(deserialize = "'de: 'static"))]
|
||||
pub enum HotReloadMsg {
|
||||
/// A template has been updated
|
||||
UpdateTemplate(Template),
|
||||
pub enum DevserverMsg {
|
||||
/// Attempt a hotreload
|
||||
/// This includes all the templates/literals/assets/binary patches that have changed in one shot
|
||||
HotReload(HotReloadMsg),
|
||||
|
||||
/// An asset discovered by rsx! has been updated
|
||||
UpdateAsset(PathBuf),
|
||||
/// The app should reload completely if it can
|
||||
FullReload,
|
||||
|
||||
/// The program needs to be recompiled, and the client should shut down
|
||||
/// The program is shutting down completely - maybe toss up a splash screen or something?
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Connect to the hot reloading listener. The callback provided will be called every time a template change is detected
|
||||
pub fn connect(callback: impl FnMut(HotReloadMsg) + Send + 'static) {
|
||||
if cfg!(windows) {
|
||||
connect_at(PathBuf::from("@dioxusin"), callback);
|
||||
} else {
|
||||
// FIXME: this is falling back onto the current directory when not running under cargo, which is how the CLI runs this.
|
||||
// This needs to be fixed.
|
||||
let _manifest_dir = std::env::var("CARGO_MANIFEST_DIR");
|
||||
|
||||
// get the cargo manifest directory, where the target dir lives
|
||||
let mut path = match _manifest_dir {
|
||||
Ok(manifest_dir) => PathBuf::from(manifest_dir),
|
||||
Err(_) => std::env::current_dir().unwrap(),
|
||||
};
|
||||
|
||||
// walk the path until we a find a socket named `dioxusin` inside that folder's target directory
|
||||
loop {
|
||||
let maybe = path.join("target").join("dioxusin");
|
||||
|
||||
if maybe.exists() {
|
||||
path = maybe;
|
||||
break;
|
||||
}
|
||||
|
||||
// It's likely we're running under just cargo and not dx
|
||||
path = match path.parent() {
|
||||
Some(parent) => parent.to_path_buf(),
|
||||
None => return,
|
||||
};
|
||||
}
|
||||
|
||||
println!("connecting to {:?}", path);
|
||||
connect_at(path, callback);
|
||||
}
|
||||
/// A message the client sends from the frontend to the devserver
|
||||
///
|
||||
/// This is used to communicate with the devserver
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum ClientMsg {
|
||||
Log {
|
||||
level: String,
|
||||
messages: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn connect_at(socket: PathBuf, mut callback: impl FnMut(HotReloadMsg) + Send + 'static) {
|
||||
std::thread::spawn(move || {
|
||||
// There might be a socket since the we're not running under the hot reloading server
|
||||
let stream = if cfg!(windows) {
|
||||
LocalSocketStream::connect("@dioxusin")
|
||||
} else {
|
||||
LocalSocketStream::connect(socket.clone())
|
||||
};
|
||||
let Ok(socket) = stream else {
|
||||
println!(
|
||||
"could not find hot reloading server at {:?}, make sure it's running",
|
||||
socket
|
||||
);
|
||||
return;
|
||||
};
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(bound(deserialize = "'de: 'static"))]
|
||||
pub struct HotReloadMsg {
|
||||
pub templates: Vec<HotReloadedTemplate>,
|
||||
pub assets: Vec<PathBuf>,
|
||||
|
||||
let mut buf_reader = BufReader::new(socket);
|
||||
|
||||
loop {
|
||||
let mut buf = String::new();
|
||||
|
||||
if let Err(err) = buf_reader.read_line(&mut buf) {
|
||||
if err.kind() != std::io::ErrorKind::WouldBlock {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(template) = serde_json::from_str(Box::leak(buf.into_boxed_str())) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
callback(template);
|
||||
}
|
||||
});
|
||||
/// A file changed that's not an asset or a rust file - best of luck!
|
||||
pub unknown_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Start the hot reloading server with the current directory as the root
|
||||
#[macro_export]
|
||||
macro_rules! hot_reload_init {
|
||||
() => {
|
||||
#[cfg(debug_assertions)]
|
||||
dioxus_hot_reload::init(dioxus_hot_reload::Config::new().root(env!("CARGO_MANIFEST_DIR")));
|
||||
#[test]
|
||||
fn serialize_client_msg() {
|
||||
let msg = ClientMsg::Log {
|
||||
level: "info".to_string(),
|
||||
messages: vec!["hello world".to_string()],
|
||||
};
|
||||
|
||||
($cfg: expr) => {
|
||||
#[cfg(debug_assertions)]
|
||||
dioxus_hot_reload::init($cfg.root(env!("CARGO_MANIFEST_DIR")));
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
r#"{"Log":{"level":"info","messages":["hello world"]}}"#
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
use dioxus_core::Template;
|
||||
use futures_util::{pin_mut, FutureExt};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::HotReloadMsg;
|
||||
|
||||
/// A extension trait with utilities for integrating Dioxus hot reloading with your Axum router.
|
||||
pub trait HotReloadRouterExt<S> {
|
||||
/// Register the web RSX hot reloading endpoint. This will enable hot reloading for your application in debug mode when you call [`dioxus_hot_reload::hot_reload_init`].
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use dioxus_hot_reload::{HotReloadReceiver, HotReloadRouterExt};
|
||||
/// use axum::Extension;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let listener = tokio::net::TcpListener::bind("127.0.0.01:8080")
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// let ws_reload = HotReloadReceiver::default();
|
||||
/// axum::serve(
|
||||
/// listener,
|
||||
/// axum::Router::new()
|
||||
/// // Connect to hot reloading in debug mode
|
||||
/// .connect_hot_reload()
|
||||
/// .layer(Extension(ws_reload))
|
||||
/// .into_make_service(),
|
||||
/// )
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
fn connect_hot_reload(self) -> Self;
|
||||
|
||||
/// Like [`connect_hot_reload`] but connects to the hot reloading messages that the CLI sends in the desktop and fullstack platforms
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use dioxus_hot_reload::{HotReloadReceiver, HotReloadRouterExt};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let listener = tokio::net::TcpListener::bind("127.0.0.01:8080")
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// let ws_reload = HotReloadReceiver::default();
|
||||
/// axum::serve(
|
||||
/// listener,
|
||||
/// axum::Router::new()
|
||||
/// // Connect to hot reloading in debug mode
|
||||
/// .forward_cli_hot_reloading()
|
||||
/// .into_make_service(),
|
||||
/// )
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
fn forward_cli_hot_reloading(self) -> Self;
|
||||
}
|
||||
|
||||
impl<S> HotReloadRouterExt<S> for Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
fn connect_hot_reload(self) -> Self {
|
||||
self.nest(
|
||||
"/_dioxus",
|
||||
Router::new()
|
||||
.route("/ws", get(ws_handler))
|
||||
.route("/hot_reload", get(hot_reload_handler)),
|
||||
)
|
||||
}
|
||||
|
||||
fn forward_cli_hot_reloading(mut self) -> Self {
|
||||
static HOT_RELOAD_STATE: once_cell::sync::Lazy<HotReloadReceiver> =
|
||||
once_cell::sync::Lazy::new(forward_cli_hot_reload);
|
||||
let hot_reload_state: HotReloadReceiver = HOT_RELOAD_STATE.clone();
|
||||
|
||||
self = self.connect_hot_reload().layer(Extension(hot_reload_state));
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle websockets
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Extension(state): Extension<HotReloadReceiver>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| ws_reload_handler(socket, state))
|
||||
}
|
||||
|
||||
/// Handles full-reloads (e.g. page refresh).
|
||||
async fn ws_reload_handler(mut socket: WebSocket, mut state: HotReloadReceiver) {
|
||||
let mut rx = state.reload.subscribe();
|
||||
|
||||
loop {
|
||||
rx.recv().await.unwrap();
|
||||
|
||||
// We need to clear the cached templates because we are doing a "fresh" build
|
||||
// and the templates may force the page to a state before the full reload.
|
||||
state.clear_all_modified_templates();
|
||||
|
||||
// ignore the error
|
||||
let _ = socket.send(Message::Text(String::from("reload"))).await;
|
||||
tracing::info!("forcing reload");
|
||||
|
||||
// flush the errors after recompling
|
||||
rx = rx.resubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/// State that is shared between the websocket and the hot reloading watcher
|
||||
#[derive(Clone)]
|
||||
pub struct HotReloadReceiver {
|
||||
/// Hot reloading messages sent from the client
|
||||
// NOTE: We use a send broadcast channel to allow clones
|
||||
messages: broadcast::Sender<HotReloadMsg>,
|
||||
|
||||
/// Rebuilds sent from the client
|
||||
reload: broadcast::Sender<()>,
|
||||
|
||||
/// Any template updates that have happened since the last full render
|
||||
template_updates: SharedTemplateUpdates,
|
||||
}
|
||||
|
||||
impl HotReloadReceiver {
|
||||
/// Create a new [`HotReloadReceiver`]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HotReloadReceiver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
messages: broadcast::channel(100).0,
|
||||
reload: broadcast::channel(100).0,
|
||||
template_updates: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SharedTemplateUpdates = Arc<Mutex<HashMap<&'static str, Template>>>;
|
||||
|
||||
impl HotReloadReceiver {
|
||||
/// Find all templates that have been updated since the last full render
|
||||
pub fn all_modified_templates(&self) -> Vec<Template> {
|
||||
self.template_updates
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clears the cache of modified templates.
|
||||
pub fn clear_all_modified_templates(&mut self) {
|
||||
self.template_updates.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Send a hot reloading message to the client
|
||||
pub fn send_message(&self, msg: HotReloadMsg) {
|
||||
// Before we send the message, update the list of changed templates
|
||||
if let HotReloadMsg::UpdateTemplate(template) = msg {
|
||||
let mut template_updates = self.template_updates.lock().unwrap();
|
||||
template_updates.insert(template.name, template);
|
||||
}
|
||||
if let Err(err) = self.messages.send(msg) {
|
||||
tracing::error!("Failed to send hot reload message: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to hot reloading messages
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<HotReloadMsg> {
|
||||
self.messages.subscribe()
|
||||
}
|
||||
|
||||
/// Reload the website
|
||||
pub fn reload(&self) {
|
||||
self.reload.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn hot_reload_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Extension(state): Extension<HotReloadReceiver>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| async move {
|
||||
let err = hotreload_loop(socket, state).await;
|
||||
|
||||
if let Err(err) = err {
|
||||
tracing::error!("Hotreload receiver failed: {}", err);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn hotreload_loop(
|
||||
mut socket: WebSocket,
|
||||
state: HotReloadReceiver,
|
||||
) -> Result<(), axum::Error> {
|
||||
tracing::info!("🔥 Hot Reload WebSocket connected");
|
||||
|
||||
let mut rx = state.messages.subscribe();
|
||||
|
||||
// update any rsx calls that changed before the websocket connected.
|
||||
// These templates will be sent down immediately so the page is in sync with the hotreloaded version
|
||||
// The compiled version will be different from the one we actually want to present
|
||||
for template in state.all_modified_templates() {
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(&template).unwrap()))
|
||||
.await?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let msg = {
|
||||
// Poll both the receiver and the socket
|
||||
//
|
||||
// This shuts us down if the connection is closed.
|
||||
let mut _socket = socket.recv().fuse();
|
||||
let mut _rx = rx.recv().fuse();
|
||||
|
||||
pin_mut!(_socket, _rx);
|
||||
|
||||
let msg = futures_util::select! {
|
||||
msg = _rx => msg,
|
||||
e = _socket => {
|
||||
if let Some(Err(e)) = e {
|
||||
tracing::info!("🔥 Hot Reload WebSocket Error: {}", e);
|
||||
} else {
|
||||
tracing::info!("🔥 Hot Reload WebSocket Closed");
|
||||
}
|
||||
break;
|
||||
},
|
||||
};
|
||||
|
||||
let Ok(msg) = msg else { break };
|
||||
|
||||
match msg {
|
||||
HotReloadMsg::UpdateTemplate(template) => {
|
||||
Message::Text(serde_json::to_string(&template).unwrap())
|
||||
}
|
||||
HotReloadMsg::UpdateAsset(asset) => {
|
||||
Message::Text(format!("reload-asset: {}", asset.display()))
|
||||
}
|
||||
HotReloadMsg::Shutdown => {
|
||||
tracing::info!("🔥 Hot Reload WebSocket shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.send(msg).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn forward_cli_hot_reload() -> HotReloadReceiver {
|
||||
let hot_reload_state = HotReloadReceiver::default();
|
||||
|
||||
// Hot reloading can be expensive to start so we spawn a new thread
|
||||
std::thread::spawn({
|
||||
let hot_reload_state = hot_reload_state.clone();
|
||||
move || {
|
||||
crate::connect(move |msg| hot_reload_state.send_message(msg));
|
||||
}
|
||||
});
|
||||
|
||||
hot_reload_state
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use crate::DevserverMsg;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_tungstenite::{
|
||||
tungstenite::{Message, Result as TtResult},
|
||||
MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
|
||||
pub fn connect(mut callback: impl FnMut(DevserverMsg) + Send + 'static) {
|
||||
tokio::spawn(async move {
|
||||
let Some(Ok(mut recv)) = NativeReceiver::create_from_cli().await else {
|
||||
return;
|
||||
};
|
||||
while let Some(msg) = recv.next().await {
|
||||
match msg {
|
||||
Ok(msg) => callback(msg),
|
||||
Err(_e) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A receiver for messages from the devserver
|
||||
///
|
||||
/// Calling `next` will watch the channel for the next valid message from the devserver
|
||||
pub struct NativeReceiver {
|
||||
socket: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
}
|
||||
|
||||
impl NativeReceiver {
|
||||
/// Connect to the devserver
|
||||
async fn create(url: String) -> TtResult<Self> {
|
||||
let (socket, _ws) = tokio_tungstenite::connect_async(&url).await?;
|
||||
Ok(Self { socket })
|
||||
}
|
||||
|
||||
/// Connect to the devserver with an address from the CLI. Returns None if the current application was not run with the CLI
|
||||
pub async fn create_from_cli() -> Option<TtResult<Self>> {
|
||||
let cli_args = dioxus_cli_config::RuntimeCLIArguments::from_cli()?;
|
||||
let addr = cli_args.cli_address();
|
||||
Some(Self::create(format!("ws://{addr}/_dioxus")).await)
|
||||
}
|
||||
|
||||
/// Wait for the next message from the devserver
|
||||
///
|
||||
/// Returns None when the connection is closed or socket.next() returns None
|
||||
pub async fn next(&mut self) -> Option<TtResult<DevserverMsg>> {
|
||||
loop {
|
||||
let res = self.socket.next().await?;
|
||||
|
||||
match res {
|
||||
Ok(res) => match res {
|
||||
Message::Text(text) => {
|
||||
let leaked: &'static str = Box::leak(text.into_boxed_str());
|
||||
let msg = serde_json::from_str::<DevserverMsg>(leaked);
|
||||
if let Ok(msg) = msg {
|
||||
return Some(Ok(msg));
|
||||
}
|
||||
}
|
||||
// send a pong
|
||||
Message::Ping(_) => {
|
||||
let _ = self.socket.send(Message::Pong(vec![])).await;
|
||||
}
|
||||
Message::Close(_) => return None,
|
||||
Message::Binary(_) => {}
|
||||
Message::Pong(_) => {}
|
||||
Message::Frame(_) => {}
|
||||
},
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user