Skip to content

Commit 1f37700

Browse files
committed
feat: added reloading server
Anything can listen to this to know when to reload.
1 parent 6e32c8f commit 1f37700

File tree

5 files changed

+135
-4
lines changed

5 files changed

+135
-4
lines changed

packages/perseus-cli/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ warp = "0.3"
3434
command-group = "1"
3535
ctrlc = { version = "3.0", features = ["termination"] }
3636
notify = "=5.0.0-pre.13"
37+
futures = "0.3"
38+
tokio-stream = "0.1"
39+
ureq = "2"
3740

3841
[lib]
3942
name = "perseus_cli"

packages/perseus-cli/src/bin/main.rs

+17-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use perseus_cli::{
88
parse::{Opts, Subcommand},
99
prepare, serve, serve_exported, tinker,
1010
};
11-
use perseus_cli::{errors::*, export_error_page, snoop_build, snoop_server, snoop_wasm_build};
11+
use perseus_cli::{
12+
errors::*, export_error_page, order_reload, run_reload_server, snoop_build, snoop_server,
13+
snoop_wasm_build,
14+
};
1215
use std::env;
1316
use std::io::Write;
1417
use std::path::PathBuf;
@@ -28,8 +31,6 @@ async fn main() {
2831
std::process::exit(exit_code)
2932
}
3033

31-
// IDEA Watch files at the `core()` level and then panic, catching the unwind in the watcher loop
32-
3334
// This manages error handling and returns a definite exit code to terminate with
3435
async fn real_main() -> i32 {
3536
// Get the working directory
@@ -113,6 +114,14 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
113114
})
114115
.expect("couldn't set handlers to gracefully terminate process");
115116

117+
// Set up a browser reloading server
118+
// We provide an option for the user to disable this
119+
if env::var("PERSEUS_NO_BROWSER_RELOAD").is_err() {
120+
tokio::task::spawn(async move {
121+
run_reload_server().await;
122+
});
123+
}
124+
116125
// Find out where this binary is
117126
// SECURITY: If the CLI were installed with root privileges, it would be possible to create a hard link to the
118127
// binary, execute through that, and then replace it with a malicious binary before we got here which would
@@ -156,6 +165,7 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
156165
let mut child = Command::new(&bin_name)
157166
.args(&args)
158167
.env("PERSEUS_WATCHING_PROHIBITED", "true")
168+
.env("PERSEUS_USE_RELOAD_SERVER", "true") // This is for internal use ONLY
159169
.group_spawn()
160170
.map_err(|err| WatchError::SpawnSelfFailed { source: err })?;
161171

@@ -170,6 +180,7 @@ async fn core(dir: PathBuf) -> Result<i32, Error> {
170180
child = Command::new(&bin_name)
171181
.args(&args)
172182
.env("PERSEUS_WATCHING_PROHIBITED", "true")
183+
.env("PERSEUS_USE_RELOAD_SERVER", "true") // This is for internal use ONLY
173184
.group_spawn()
174185
.map_err(|err| WatchError::SpawnSelfFailed { source: err })?;
175186
}
@@ -211,6 +222,8 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> {
211222
return Ok(exit_code);
212223
}
213224
if export_opts.serve {
225+
// Tell any connected browsers to reload
226+
order_reload();
214227
serve_exported(dir, export_opts.host, export_opts.port).await;
215228
}
216229
0
@@ -219,6 +232,7 @@ async fn core_watch(dir: PathBuf, opts: Opts) -> Result<i32, Error> {
219232
if !serve_opts.no_build {
220233
delete_artifacts(dir.clone(), "static")?;
221234
}
235+
// This orders reloads internally
222236
let (exit_code, _server_path) = serve(dir, serve_opts)?;
223237
exit_code
224238
}

packages/perseus-cli/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod extraction;
3838
/// Parsing utilities for arguments.
3939
pub mod parse;
4040
mod prepare;
41+
mod reload_server;
4142
mod serve;
4243
mod serve_exported;
4344
mod snoop;
@@ -56,6 +57,7 @@ pub use eject::{eject, has_ejected};
5657
pub use export::export;
5758
pub use export_error_page::export_error_page;
5859
pub use prepare::{check_env, prepare};
60+
pub use reload_server::{order_reload, run_reload_server};
5961
pub use serve::serve;
6062
pub use serve_exported::serve_exported;
6163
pub use snoop::{snoop_build, snoop_server, snoop_wasm_build};
+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use futures::{SinkExt, StreamExt};
2+
use std::net::SocketAddr;
3+
use std::sync::atomic::AtomicUsize;
4+
use std::sync::Arc;
5+
use std::{collections::HashMap, env};
6+
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
7+
use tokio::sync::RwLock;
8+
use tokio_stream::wrappers::UnboundedReceiverStream;
9+
use warp::ws::Message;
10+
use warp::Filter;
11+
12+
/// A representation of the clients to the server. These are only the clients that will be told about reloads, any user
13+
/// can command a reload over the HTTP endpoint (unauthenticated because this is a development server).
14+
type Clients = Arc<RwLock<HashMap<usize, UnboundedSender<Message>>>>;
15+
16+
/// A simple counter that can be incremented from anywhere. This will be used as the source of the next user ID. This is an atomic
17+
/// `usize` for maximum platofrm portability (see the Rust docs on atomic primtives).
18+
static NEXT_UID: AtomicUsize = AtomicUsize::new(0);
19+
20+
/// Runs the reload server, which is used to instruct the browser on when to reload for updates.
21+
pub async fn run_reload_server() {
22+
let (host, port) = get_reload_server_host_and_port();
23+
24+
// Parse `localhost` into `127.0.0.1` (picky Rust `std`)
25+
let host = if host == "localhost" {
26+
"127.0.0.1".to_string()
27+
} else {
28+
host
29+
};
30+
// Parse the host and port into an address
31+
let addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
32+
33+
let clients = Clients::default();
34+
let clients = warp::any().map(move || clients.clone());
35+
36+
// This will be used by the CLI to order reloads
37+
let command = warp::path("send")
38+
.and(clients.clone())
39+
.then(|clients: Clients| async move {
40+
// Iterate through all the clients and tell them all to reload
41+
for (_id, tx) in clients.read().await.iter() {
42+
// We don't care if this fails, that means the client has disconnected and the disconnection code will be running
43+
let _ = tx.send(Message::text("reload"));
44+
}
45+
46+
"sent".to_string()
47+
});
48+
// This will be used by the browser to listen for reload orders
49+
let receive = warp::path("receive").and(warp::ws()).and(clients).map(
50+
|ws: warp::ws::Ws, clients: Clients| {
51+
// This code will run once the WS handshake completes
52+
ws.on_upgrade(|ws| async move {
53+
// Assign a new ID to this user
54+
// This nifty operation just gets the current value and then increments
55+
let id = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
56+
// Split out their sender/receiver
57+
let (mut ws_tx, mut ws_rx) = ws.split();
58+
// Use an unbounded channel as an intermediary to the WebSocket
59+
let (tx, rx) = unbounded_channel();
60+
let mut rx = UnboundedReceiverStream::new(rx);
61+
tokio::task::spawn(async move {
62+
// Whenever a message come sin on that intermediary channel, we'll just relay it to the client
63+
while let Some(message) = rx.next().await {
64+
let _ = ws_tx.send(message).await;
65+
}
66+
});
67+
68+
// Save the sender and their intermediary channel
69+
clients.write().await.insert(id, tx);
70+
71+
// Because we don't accept messages from listening clients, we'll just hold a loop until the client disconnects
72+
// Then, this will become `None` and we'll move on
73+
while ws_rx.next().await.is_some() {
74+
continue;
75+
}
76+
77+
// Once we're here, the client has disconnected
78+
clients.write().await.remove(&id);
79+
})
80+
},
81+
);
82+
83+
let routes = command.or(receive);
84+
warp::serve(routes).run(addr).await
85+
}
86+
87+
/// Orders all connected browsers to reload themselves. This spawns a blocking task through Tokio under the hood. Note that
88+
/// this will only do anything if `PERSEUS_USE_RELOAD_SERVER` is set to `true`.
89+
pub fn order_reload() {
90+
if env::var("PERSEUS_USE_RELOAD_SERVER").is_ok() {
91+
let (host, port) = get_reload_server_host_and_port();
92+
93+
tokio::task::spawn_blocking(move || {
94+
// We don't care if this fails because we have no guarnatees that the server is actually up
95+
let _ = ureq::get(&format!("http://{}:{}/send", host, port)).call();
96+
});
97+
}
98+
}
99+
100+
/// Gets the host and port to run the reload server on.
101+
fn get_reload_server_host_and_port() -> (String, u16) {
102+
let host = env::var("PERSEUS_RELOAD_SERVER_HOST").unwrap_or_else(|_| "localhost".to_string());
103+
let port = env::var("PERSEUS_RELOAD_SERVER_PORT").unwrap_or_else(|_| "8090".to_string());
104+
let port = port
105+
.parse::<u16>()
106+
.expect("reload server port must be a number");
107+
108+
(host, port)
109+
}

packages/perseus-cli/src/serve.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::build::{build_internal, finalize};
22
use crate::cmd::{cfg_spinner, run_stage};
3-
use crate::errors::*;
43
use crate::parse::{Integration, ServeOpts};
54
use crate::thread::{spawn_thread, ThreadHandle};
5+
use crate::{errors::*, order_reload};
66
use console::{style, Emoji};
77
use indicatif::{MultiProgress, ProgressBar};
88
use std::env;
@@ -244,6 +244,9 @@ pub fn serve(dir: PathBuf, opts: ServeOpts) -> Result<(i32, Option<String>), Exe
244244
finalize(&dir.join(".perseus"))?;
245245
}
246246

247+
// Order any connected browsers to reload
248+
order_reload();
249+
247250
// Now actually run that executable path if we should
248251
if should_run {
249252
let exit_code = run_server(Arc::clone(&exec), dir, did_build)?;

0 commit comments

Comments
 (0)