use std::time::Duration;
pub const GUEST_ENV: &str = "LEPTOSKIT_DEV_GUEST";
pub const GUEST_EXTRA: &str = "leptoskit.dev_guest";
pub const WAIT: Duration = Duration::from_secs(25);
#[must_use]
pub fn guest_path(platform: &str) -> String {
format!("/_leptoskit/dev/{platform}/guest")
}
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
pub use client::DevGuests;
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
pub use client::follow_environment;
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
mod client {
use std::sync::mpsc;
use std::time::Duration;
use super::GUEST_ENV;
use super::WAIT;
use crate::CodePushError;
use crate::hash_bytes;
use crate::installed::MAX_BUNDLE_BYTES;
const RESPONSE_MARGIN: Duration = Duration::from_secs(10);
const RETRY: Duration = Duration::from_secs(1);
#[must_use]
pub fn follow_environment(running: &[u8]) -> Option<mpsc::Receiver<Vec<u8>>> {
follow(&std::env::var(GUEST_ENV).ok()?, running)
}
fn follow(url: &str, running: &[u8]) -> Option<mpsc::Receiver<Vec<u8>>> {
let mut guests = DevGuests::new(url, running)
.inspect_err(|error| eprintln!("leptoskit dev: {error}"))
.ok()?;
let (sender, receiver) = mpsc::channel();
std::thread::Builder::new()
.name("leptoskit-dev-guests".to_owned())
.spawn(move || {
loop {
match guests.next_guest() {
Ok(Some(wasm)) => {
if sender.send(wasm).is_err() {
return;
}
}
Ok(None) => {}
Err(error) => {
eprintln!("leptoskit dev: {error}");
std::thread::sleep(RETRY);
}
}
}
})
.inspect_err(|error| eprintln!("leptoskit dev: {error}"))
.ok()?;
Some(receiver)
}
pub struct DevGuests {
agent: ureq::Agent,
url: String,
running: String,
}
impl DevGuests {
pub fn new(url: &str, running: &[u8]) -> Result<Self, CodePushError> {
if !cfg!(debug_assertions) {
return Err(CodePushError::Activation(
"development guests are only accepted by debug builds".to_owned(),
));
}
let scheme = url.starts_with("http://") || url.starts_with("https://");
if !scheme || url.contains(['?', '#']) {
return Err(CodePushError::Transport(format!(
"invalid development guest URL {url:?}"
)));
}
let config = ureq::Agent::config_builder()
.max_redirects(0)
.http_status_as_error(false)
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_response(Some(WAIT + RESPONSE_MARGIN))
.build();
Ok(Self {
agent: config.into(),
url: url.to_owned(),
running: hash_bytes(running),
})
}
#[must_use]
pub fn running(&self) -> &str {
&self.running
}
pub fn next_guest(&mut self) -> Result<Option<Vec<u8>>, CodePushError> {
let url = format!("{}?after={}", self.url, self.running);
let mut response = self
.agent
.get(&url)
.call()
.map_err(|error| CodePushError::Transport(error.to_string()))?;
match response.status().as_u16() {
204 => return Ok(None),
200 => {}
status => {
return Err(CodePushError::Transport(format!(
"HTTP {status} from {}",
self.url
)));
}
}
let expected = response
.headers()
.get(ureq::http::header::ETAG)
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_owned())
.ok_or_else(|| {
CodePushError::Transport("the development guest has no ETag".to_owned())
})?;
let wasm = response
.body_mut()
.with_config()
.limit(MAX_BUNDLE_BYTES)
.read_to_vec()
.map_err(|error| CodePushError::Transport(error.to_string()))?;
let actual = hash_bytes(&wasm);
if actual != expected {
return Err(CodePushError::HashMismatch {
path: self.url.clone(),
expected,
actual,
});
}
self.running = actual;
Ok(Some(wasm))
}
}
#[cfg(test)]
mod tests {
use std::io::BufRead as _;
use std::io::BufReader;
use std::io::Write as _;
use std::net::TcpListener;
use std::net::TcpStream;
use std::thread::JoinHandle;
use super::*;
fn serve(responses: Vec<Vec<u8>>) -> (String, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!(
"http://{}/_leptoskit/dev/linux/guest",
listener.local_addr().unwrap()
);
let server = std::thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().unwrap();
read_head(&stream);
stream.write_all(&response).unwrap();
}
});
(url, server)
}
fn read_head(stream: &TcpStream) {
let mut reader = BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).unwrap() > 0 && line != "\r\n" {
line.clear();
}
}
fn respond(status: &str, etag: Option<&str>, body: &[u8]) -> Vec<u8> {
let etag = etag.map_or_else(String::new, |etag| format!("ETag: \"{etag}\"\r\n"));
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{etag}Connection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(body);
response
}
fn guest(wasm: &[u8]) -> Vec<u8> {
respond("200 OK", Some(&hash_bytes(wasm)), wasm)
}
#[test]
fn following_delivers_each_new_guest_and_retries_a_failing_server() {
let (url, server) = serve(vec![
respond("500 Internal Server Error", None, b""),
respond("204 No Content", None, b""),
guest(b"first"),
guest(b"second"),
]);
let guests = follow(&url, b"running").unwrap();
assert_eq!(
guests.recv_timeout(Duration::from_secs(10)).unwrap(),
b"first"
);
drop(guests);
server.join().unwrap();
}
#[test]
fn an_invalid_url_or_no_url_is_not_followed() {
assert!(follow("ftp://example.com/guest", b"").is_none());
assert!(follow("http://example.com/guest?after=x", b"").is_none());
if std::env::var_os(GUEST_ENV).is_none() {
assert!(follow_environment(b"").is_none());
}
}
#[test]
fn a_guest_must_carry_the_etag_it_hashes_to() {
let (url, server) = serve(vec![
respond("200 OK", None, b"guest"),
respond("200 OK", Some("0000"), b"guest"),
respond("204 No Content", None, b""),
guest(b"guest"),
]);
let mut guests = DevGuests::new(&url, b"running").unwrap();
assert_eq!(guests.running(), hash_bytes(b"running"));
let error = guests.next_guest().unwrap_err();
assert!(error.to_string().contains("no ETag"), "{error}");
let error = guests.next_guest().unwrap_err();
assert!(
matches!(error, CodePushError::HashMismatch { .. }),
"{error}"
);
assert_eq!(guests.next_guest().unwrap(), None);
assert_eq!(guests.next_guest().unwrap(), Some(b"guest".to_vec()));
assert_eq!(guests.running(), hash_bytes(b"guest"));
server.join().unwrap();
}
}
}