//! # Liver
//!
//! > Quick and dirty live reloading server for web development.
//!
//! This library provides one function [`watch`] that does the following:
//!
//! * Creates a WebSocket server (with [`ws`]).
//! * Creates a file watcher (with [`hotwatch`]) that detects file changes and
//! sends a WebSocket message whenever one happens.
//! * Creates a small HTTP server (with [`rocket`]) that returns static files
//! from the path you passed through [`watch`]. And it injects some JavaScript
//! in any HTML files to reload the page when a WebSocket message is received.
//!
//! **Note**: this was developed in a very quick and dirty manner, please don't
//! use this in a production setting. It doesn't have proper error handling
//! (`unwrap`s everywhere) and isn't thoroughly tested. If you're interested in
//! improving the code and stability, I very much welcome it. I think I've
//! gotten about as far as I can with my limited Rust capabilities.
//!
//! To change the ports used for Rocket or the WebSocket server, you can set
//! their corresponding environment variables: `ROCKET_PORT` (8000 by default)
//! and `WS_PORT` (8001 by default).
#![feature(proc_macro_hygiene, decl_macro)]
use std::{
env, ffi::OsStr, fs::read, io::Cursor, path::PathBuf, thread, time::Duration,
};
#[macro_use]
extern crate rocket;
use anyhow::Result;
use hotwatch::{notify::DebouncedEvent, Hotwatch};
use rocket::{
http::{ContentType, Status},
response, Rocket, State,
};
/// The reload JavaScript that gets injected into HTML files so we can do
/// `location.reload()` when the server detects changes.
pub(crate) const RELOAD_SCRIPT: &str = r#""#;
/// The default websocket, I picked 8001 as the default Rocket port is 8000.
///
/// Both the Rocket and WS ports can be overridden with `ROCKET_PORT` and
/// `WS_PORT` environment variables.
pub(crate) const WS_PORT_DEFAULT: &str = "8001";
/// The watch function, see the [top-level module documentation](crate) for info.
pub fn watch(path: &str) -> Result<()> {
let new_path = path.to_string();
// Use a separate thread to run the websocket server and file watcher in.
thread::spawn(move || {
let mut watcher =
Hotwatch::new_with_custom_delay(Duration::from_millis(500)).unwrap();
// Start the websocket server.
ws::listen(ws_url(), move |out| {
// Then whenever we have a connection, start watching the source.
// I'm *pretty sure* this is fine, as far as I can tell Hotwatch just
// overrides any old watchers on the same path.
// I could be very wrong though!
watcher
.watch(&new_path, move |event| {
// Then, whenever Hotwatch notices an event, send the reload message.
if let DebouncedEvent::Write(_) = event {
out.send("Reload").unwrap();
}
})
.unwrap();
|_| Ok(())
})
.unwrap();
});
// Start Rocket, this will block the main thread.
Rocket::ignite()
.manage(path.to_string())
.mount("/", routes![index, static_files])
.launch();
Ok(())
}
/// Small convenience function to return the websocket URL.
pub(crate) fn ws_url() -> String {
format!(
"127.0.0.1:{}",
env::var("WS_PORT").unwrap_or_else(|_| WS_PORT_DEFAULT.into())
)
}
/// The regular index needs to be handled specifically, it just relays to
/// `static_files` though. *shrug*
#[get("/")]
pub(crate) fn index<'r>(source: State) -> response::Result<'r> {
static_files(None, source)
}
#[get("/")]
pub(crate) fn static_files<'r>(
path: Option,
source: State,
) -> response::Result<'r> {
// Grab the reload JavaScript and set the WS_PORT in it.
let mut reload_script = RELOAD_SCRIPT
.replace(
"${WS_PORT}",
&env::var("WS_PORT").unwrap_or_else(|_| WS_PORT_DEFAULT.to_string()),
)
.as_bytes()
.to_vec();
if path.is_none() {
// If `path` is None that means it was called from `index`, so we just return
// the `index.html` at the `source` root or a 404 if it doesn't exist.
let path = PathBuf::from(source.inner()).join("index.html");
if let Ok(mut file) = read(&path) {
// Insert the reload JavaScript since we're going to be returning HTML.
file.append(&mut reload_script);
return response::Response::build()
.header(ContentType::HTML)
.sized_body(Cursor::new(file))
.ok();
} else {
return Err(Status::NotFound);
}
}
// Join our `source` path with the URL `path` so we get the correct
// relative URL.
let mut path = PathBuf::from(source.inner()).join(path.unwrap());
// If it's pointing to a directory then join `index.html`.
if path.is_dir() {
path = path.join("index.html");
}
if let Ok(mut file) = read(&path) {
// Get the extension of the file, if any.
let file_extension = path.extension().and_then(OsStr::to_str).unwrap_or("");
// Get the content type and use plaintext if we can't find it.
let content_type =
ContentType::from_extension(file_extension).unwrap_or(ContentType::Plain);
if content_type == ContentType::HTML {
// If we're about to return HTML, insert the reload JavaScript.
file.append(&mut reload_script);
}
response::Response::build()
.header(content_type)
.sized_body(Cursor::new(file))
.ok()
} else {
Err(Status::NotFound)
}
}