Skip to content

Commit

Permalink
feat: collect all options in the Options struct (#114)
Browse files Browse the repository at this point in the history
  • Loading branch information
lomirus authored Nov 4, 2024
1 parent b00be5f commit 6d3cb84
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 117 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ $ cargo install live-server
$ live-server --help
Launch a local network server with live reload feature for static pages

Usage: live-server.exe [OPTIONS] [ROOT]
Usage: live-server [OPTIONS] [ROOT]

Arguments:
[ROOT] Set the root path of the static assets [default: .]

Options:
--index Whether to show directory listings if there is no index.html
-H, --host <HOST> Set the listener host [default: 0.0.0.0]
-p, --port <PORT> Set the listener port [default: 0]
-o, --open [<PAGE>] Open the page in browser automatically
Expand Down
1 change: 1 addition & 0 deletions src/file_layer/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub(crate) mod watcher;
File renamed without changes.
42 changes: 42 additions & 0 deletions src/http_layer/listener.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
use std::net::IpAddr;

use local_ip_address::local_ip;
use tokio::net::TcpListener;

use crate::ADDR;

pub(crate) async fn create_listener(addr: String) -> Result<TcpListener, String> {
match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => {
let port = listener.local_addr().unwrap().port();
let host = listener.local_addr().unwrap().ip();
let host = match host.is_unspecified() {
true => match local_ip() {
Ok(addr) => addr,
Err(err) => {
log::warn!("Failed to get local IP address: {}", err);
host
}
},
false => host,
};

let addr = match host {
IpAddr::V4(host) => format!("{host}:{port}"),
IpAddr::V6(host) => format!("[{host}]:{port}"),
};
log::info!("Listening on http://{addr}/");
ADDR.set(addr).unwrap();
Ok(listener)
}
Err(err) => {
let err_msg = if let std::io::ErrorKind::AddrInUse = err.kind() {
format!("Address {} is already in use", &addr)
} else {
format!("Failed to listen on {}: {}", addr, err)
};
log::error!("{err_msg}");
Err(err_msg)
}
}
}
2 changes: 2 additions & 0 deletions src/http_layer/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub(crate) mod listener;
pub(crate) mod server;
100 changes: 39 additions & 61 deletions src/server.rs → src/http_layer/server.rs
Original file line number Diff line number Diff line change
@@ -1,61 +1,47 @@
use std::io::ErrorKind;
use std::{fs, net::IpAddr};

use axum::extract::ws::WebSocket;
use axum::{
body::Body,
extract::{ws::Message, Request, WebSocketUpgrade},
extract::{
ws::{Message, WebSocket},
Request, State, WebSocketUpgrade,
},
http::{header, HeaderMap, HeaderValue, StatusCode},
routing::get,
Router,
};
use futures::{sink::SinkExt, stream::StreamExt};
use local_ip_address::local_ip;
use std::{fs, io::ErrorKind, sync::Arc};
use tokio::net::TcpListener;

use crate::{ADDR, HARD, INDEX, ROOT, TX};
use crate::{ADDR, ROOT, TX};

/// JS script containing a function that takes in the address and connects to the websocket.
const WEBSOCKET_FUNCTION: &str = include_str!("../templates/websocket.js");

/// JS script to inject to the HTML on reload so the client
/// knows it's a successful reload.
const RELOAD_PAYLOAD: &str = include_str!("../templates/reload.js");

pub(crate) async fn serve(tcp_listener: TcpListener, router: Router) {
axum::serve(tcp_listener, router).await.unwrap();
}

pub(crate) async fn create_listener(addr: String) -> Result<TcpListener, String> {
match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => {
let port = listener.local_addr().unwrap().port();
let host = listener.local_addr().unwrap().ip();
let host = match host.is_unspecified() {
true => match local_ip() {
Ok(addr) => addr,
Err(err) => {
log::warn!("Failed to get local IP address: {}", err);
host
}
},
false => host,
};
pub struct Options {
/// Always hard reload the page instead of hot-reload
pub hard_reload: bool,
/// Show page list of the current URL if `index.html` does not exist
pub index_listing: bool,
}

let addr = match host {
IpAddr::V4(host) => format!("{host}:{port}"),
IpAddr::V6(host) => format!("[{host}]:{port}"),
};
log::info!("Listening on http://{addr}/");
ADDR.set(addr).unwrap();
Ok(listener)
}
Err(err) => {
let err_msg = if let std::io::ErrorKind::AddrInUse = err.kind() {
format!("Address {} is already in use", &addr)
} else {
format!("Failed to listen on {}: {}", addr, err)
};
log::error!("{err_msg}");
Err(err_msg)
impl Default for Options {
fn default() -> Self {
Self {
hard_reload: false,
index_listing: true,
}
}
}

pub(crate) fn create_server() -> Router {
pub(crate) fn create_server(options: Options) -> Router {
Router::new()
.route("/", get(static_assets))
.route("/*path", get(static_assets))
Expand All @@ -68,6 +54,7 @@ pub(crate) fn create_server() -> Router {
.on_upgrade(on_websocket_upgrade)
}),
)
.with_state(Arc::new(options))
}

async fn on_websocket_upgrade(socket: WebSocket) {
Expand Down Expand Up @@ -115,10 +102,12 @@ fn get_index_listing(uri_path: &str) -> String {
.join("\n")
}

async fn static_assets(req: Request<Body>) -> (StatusCode, HeaderMap, Body) {
async fn static_assets(
state: State<Arc<Options>>,
req: Request<Body>,
) -> (StatusCode, HeaderMap, Body) {
let addr = ADDR.get().unwrap();
let root = ROOT.get().unwrap();
let index = INDEX.get().unwrap();

let is_reload = req.uri().query().is_some_and(|x| x == "reload");

Expand Down Expand Up @@ -155,10 +144,10 @@ async fn static_assets(req: Request<Body>) -> (StatusCode, HeaderMap, Body) {
}
let status_code = match err.kind() {
ErrorKind::NotFound => {
if *index && reading_index {
let script = format_script(addr, is_reload, false);
if state.index_listing && reading_index {
let script = format_script(addr, state.hard_reload, is_reload, false);
let html = format!(
include_str!("templates/index.html"),
include_str!("../templates/index.html"),
uri_path,
script,
get_index_listing(uri_path)
Expand All @@ -171,8 +160,8 @@ async fn static_assets(req: Request<Body>) -> (StatusCode, HeaderMap, Body) {
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
if mime == "text/html" {
let script = format_script(addr, is_reload, true);
let html = format!(include_str!("templates/error.html"), script, err);
let script = format_script(addr, state.hard_reload, is_reload, true);
let html = format!(include_str!("../templates/error.html"), script, err);
let body = Body::from(html);

return (status_code, headers, body);
Expand All @@ -191,9 +180,9 @@ async fn static_assets(req: Request<Body>) -> (StatusCode, HeaderMap, Body) {
return (StatusCode::INTERNAL_SERVER_ERROR, headers, body);
}
};
let script = format_script(addr, is_reload, false);
let script = format_script(addr, state.hard_reload, is_reload, false);
file = format!("{text}{script}").into_bytes();
} else if !HARD.get().copied().unwrap_or(false) {
} else if state.hard_reload {
// allow client to cache assets for a smoother reload.
// client handles preloading to refresh cache before reloading.
headers.append(
Expand All @@ -205,27 +194,16 @@ async fn static_assets(req: Request<Body>) -> (StatusCode, HeaderMap, Body) {
(StatusCode::OK, headers, Body::from(file))
}

/// JS script containing a function that takes in the address and connects to the websocket.
const WEBSOCKET_FUNCTION: &str = include_str!("templates/websocket.js");

/// JS script to inject to the HTML on reload so the client
/// knows it's a successful reload.
const RELOAD_PAYLOAD: &str = include_str!("templates/reload.js");

/// Inject the address into the websocket script and wrap it in a script tag
fn format_script(addr: &str, is_reload: bool, is_error: bool) -> String {
fn format_script(addr: &str, hard_reload: bool, is_reload: bool, is_error: bool) -> String {
match (is_reload, is_error) {
// successful reload, inject the reload payload
(true, false) => format!("<script>{}</script>", RELOAD_PAYLOAD),
// failed reload, don't inject anything so the client polls again
(true, true) => String::new(),
// normal connection, inject the websocket client
_ => {
let hard = if HARD.get().copied().unwrap_or(false) {
"true"
} else {
"false"
};
let hard = if hard_reload { "true" } else { "false" };
format!(
r#"<script>{}("{}", {})</script>"#,
WEBSOCKET_FUNCTION, addr, hard
Expand Down
64 changes: 21 additions & 43 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
//!
//! ## Create live server
//! ```
//! use live_server::listen;
//! use live_server::{listen, Options};
//!
//! async fn serve() -> Result<(), Box<dyn std::error::Error>> {
//! listen("127.0.0.1:8080", "./", false).await?.start().await
//! listen("127.0.0.1:8080", "./").await?.start(Options::default()).await
//! }
//! ```
//!
Expand All @@ -14,83 +14,66 @@
//! env_logger::init();
//! ```

mod server;
mod watcher;
mod file_layer;
mod http_layer;

use std::{error::Error, net::IpAddr, path::PathBuf};
pub use http_layer::server::Options;

use axum::Router;
use file_layer::watcher::{create_watcher, watch};
use http_layer::{
listener::create_listener,
server::{create_server, serve},
};
use local_ip_address::local_ip;
use notify::RecommendedWatcher;
use notify_debouncer_full::{DebouncedEvent, Debouncer, FileIdMap};
use server::{create_listener, create_server};
use std::{error::Error, net::IpAddr, path::PathBuf};
use tokio::{
net::TcpListener,
sync::{broadcast, mpsc::Receiver, OnceCell},
};
use watcher::create_watcher;

static ADDR: OnceCell<String> = OnceCell::const_new();
static ROOT: OnceCell<PathBuf> = OnceCell::const_new();
static INDEX: OnceCell<bool> = OnceCell::const_new();
static HARD: OnceCell<bool> = OnceCell::const_new();
static TX: OnceCell<broadcast::Sender<()>> = OnceCell::const_new();

pub struct Listener {
tcp_listener: TcpListener,
router: Router,
root_path: PathBuf,
debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
rx: Receiver<Result<Vec<DebouncedEvent>, Vec<notify::Error>>>,
hard: bool,
index: bool,
}

impl Listener {
/// Start live-server.
///
/// ```
/// use live_server::listen;
/// use live_server::{listen, Options};
///
/// async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// listen("127.0.0.1:8080", "./", false).await?.start().await
/// listen("127.0.0.1:8080", "./").await?.start(Options::default()).await
/// }
/// ```
pub async fn start(self) -> Result<(), Box<dyn Error>> {
HARD.set(self.hard)?;
pub async fn start(self, options: Options) -> Result<(), Box<dyn Error>> {
ROOT.set(self.root_path.clone())?;
INDEX.set(self.index)?;
let (tx, _) = broadcast::channel(16);
TX.set(tx)?;

let watcher_future = tokio::spawn(watcher::watch(self.root_path, self.debouncer, self.rx));
let server_future = tokio::spawn(server::serve(self.tcp_listener, self.router));
let watcher_future = tokio::spawn(watch(self.root_path, self.debouncer, self.rx));
let server_future = tokio::spawn(serve(self.tcp_listener, create_server(options)));

tokio::try_join!(watcher_future, server_future)?;

Ok(())
}

/// Always hard reload the page instead of hot-reload
/// ```
/// use live_server::listen;
///
/// async fn serve_hard() -> Result<(), Box<dyn std::error::Error>> {
/// listen("127.0.0.1:8080", "./", false).await?.hard_reload().start().await
/// }
/// ```
pub fn hard_reload(mut self) -> Self {
self.hard = true;
self
}

/// Return the link of the server, like `http://127.0.0.1:8080`.
///
/// ```
/// use live_server::listen;
/// use live_server::{listen, Options};
///
/// async fn serve() {
/// let listener = listen("127.0.0.1:8080", "./", false).await.unwrap();
/// let listener = listen("127.0.0.1:8080", "./").await.unwrap();
/// let link = listener.link().unwrap();
/// assert_eq!(link, "http://127.0.0.1:8080");
/// }
Expand All @@ -117,28 +100,23 @@ impl Listener {
/// Create live-server listener
///
/// ```
/// use live_server::listen;
/// use live_server::{listen, Options};
///
/// async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// listen("127.0.0.1:8080", "./", false).await?.start().await
/// listen("127.0.0.1:8080", "./").await?.start(Options::default()).await
/// }
/// ```
pub async fn listen<A: Into<String>, R: Into<PathBuf>, I: Into<bool>>(
pub async fn listen<A: Into<String>, R: Into<PathBuf>>(
addr: A,
root: R,
index: I,
) -> Result<Listener, String> {
let tcp_listener = create_listener(addr.into()).await?;
let router = create_server();
let (debouncer, root_path, rx) = create_watcher(root.into()).await?;

Ok(Listener {
tcp_listener,
router,
debouncer,
root_path,
index: index.into(),
rx,
hard: false,
})
}
Loading

0 comments on commit 6d3cb84

Please sign in to comment.