WIP desktop serve hot reload

This commit is contained in:
Evan Almloff committed 2023-07-18 10:24:29 -07:00
1 parent 9d53b2b6d2
commit 0c5025ffa0
13 files changed
+865 -515

No files matched your search

+70
View File
@@ -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;
};
}
}
})
}
+475
View File
@@ -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::<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 {
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<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])
// 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<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(&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<RustlsConfig>,
) -> 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<String> {
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<TypedHeader<headers::UserAgent>>,
Extension(state): Extension<Arc<WsReloadState>>,
) -> 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();
})
}
+171
View File
@@ -0,0 +1,171 @@
use crate::{Result, WebProxyConfig};
use anyhow::Context;
use axum::{http::StatusCode, routing::any, Router};
use hyper::{Request, Response, Uri};
#[derive(Debug, Clone)]
struct ProxyClient {
inner: hyper::Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>>,
url: Uri,
}
impl ProxyClient {
fn new(url: Uri) -> Self {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.https_or_http()
.enable_http1()
.build();
Self {
inner: hyper::Client::builder().build(https),
url,
}
}
async fn send(
&self,
mut req: Request<hyper::body::Body>,
) -> Result<Response<hyper::body::Body>> {
let mut uri_parts = req.uri().clone().into_parts();
uri_parts.authority = self.url.authority().cloned();
uri_parts.scheme = self.url.scheme().cloned();
*req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?;
self.inner
.request(req)
.await
.map_err(crate::error::Error::ProxyRequestError)
}
}
/// Add routes to the router handling the specified proxy config.
///
/// We will proxy requests directed at either:
///
/// - the exact path of the proxy config's backend URL, e.g. /api
/// - the exact path with a trailing slash, e.g. /api/
/// - any subpath of the backend URL, e.g. /api/foo/bar
pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
let url: Uri = proxy.backend.parse()?;
let path = url.path().to_string();
let client = ProxyClient::new(url);
// We also match everything after the path using a wildcard matcher.
let wildcard_client = client.clone();
router = router.route(
// Always remove trailing /'s so that the exact route
// matches.
path.trim_end_matches('/'),
any(move |req| async move {
client
.send(req)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}),
);
// Wildcard match anything else _after_ the backend URL's path.
// Note that we know `path` ends with a trailing `/` in this branch,
// so `wildcard` will look like `http://localhost/api/*proxywildcard`.
let wildcard = format!("{}/*proxywildcard", path.trim_end_matches('/'));
router = router.route(
&wildcard,
any(move |req| async move {
wildcard_client
.send(req)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}),
);
Ok(router)
}
#[cfg(test)]
mod test {
use super::*;
use axum::{extract::Path, Router};
fn setup_servers(
mut config: WebProxyConfig,
) -> (
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
String,
) {
let backend_router = Router::new().route(
"/*path",
any(|path: Path<String>| async move { format!("backend: {}", path.0) }),
);
let backend_server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
.serve(backend_router.into_make_service());
let backend_addr = backend_server.local_addr();
let backend_handle = tokio::spawn(async move { backend_server.await.unwrap() });
config.backend = format!("http://{}{}", backend_addr, config.backend);
let router = super::add_proxy(Router::new(), &config);
let server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
.serve(router.unwrap().into_make_service());
let server_addr = server.local_addr();
let server_handle = tokio::spawn(async move { server.await.unwrap() });
(backend_handle, server_handle, server_addr.to_string())
}
async fn test_proxy_requests(path: String) {
let config = WebProxyConfig {
// Normally this would be an absolute URL including scheme/host/port,
// but in these tests we need to let the OS choose the port so tests
// don't conflict, so we'll concatenate the final address and this
// path together.
// So in day to day usage, use `http://localhost:8000/api` instead!
backend: path,
};
let (backend_handle, server_handle, server_addr) = setup_servers(config);
let resp = hyper::Client::new()
.get(format!("http://{}/api", server_addr).parse().unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api"
);
let resp = hyper::Client::new()
.get(format!("http://{}/api/", server_addr).parse().unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api/"
);
let resp = hyper::Client::new()
.get(
format!("http://{}/api/subpath", server_addr)
.parse()
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api/subpath"
);
backend_handle.abort();
server_handle.abort();
}
#[tokio::test]
async fn add_proxy() {
test_proxy_requests("/api".to_string()).await;
}
#[tokio::test]
async fn add_proxy_trailing_slash() {
test_proxy_requests("/api/".to_string()).await;
}
}