#[cfg(feature = "watch")] use super::watch; use crate::{get_book_dir, open}; use clap::{App, Arg, ArgMatches, SubCommand}; use futures_util::sink::SinkExt; use futures_util::StreamExt; use mdbook::errors::*; use mdbook::utils; use mdbook::MDBook; use std::net::{SocketAddr, ToSocketAddrs}; use std::path::PathBuf; use tokio::sync::broadcast; use warp::ws::Message; use warp::Filter; /// The HTTP endpoint for the websocket used to trigger reloads when a file changes. const LIVE_RELOAD_ENDPOINT: &str = "__livereload"; // Create clap subcommand arguments pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { SubCommand::with_name("serve") .about("Serves a book at http://localhost:3000, and rebuilds it on changes") .arg_from_usage( "-d, --dest-dir=[dest-dir] 'Output directory for the book{n}\ Relative paths are interpreted relative to the book's root directory.{n}\ If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.'", ) .arg_from_usage( "[dir] 'Root directory for the book{n}\ (Defaults to the Current Directory when omitted)'", ) .arg( Arg::with_name("hostname") .short("n") .long("hostname") .takes_value(true) .default_value("localhost") .empty_values(false) .help("Hostname to listen on for HTTP connections"), ) .arg( Arg::with_name("port") .short("p") .long("port") .takes_value(true) .default_value("3000") .empty_values(false) .help("Port to use for HTTP connections"), ) .arg_from_usage("-o, --open 'Opens the book server in a web browser'") } // Serve command implementation pub fn execute(args: &ArgMatches) -> Result<()> { let book_dir = get_book_dir(args); let mut book = MDBook::load(&book_dir)?; let port = args.value_of("port").unwrap(); let hostname = args.value_of("hostname").unwrap(); let open_browser = args.is_present("open"); let address = format!("{}:{}", hostname, port); let livereload_url = format!("ws://{}/{}", address, LIVE_RELOAD_ENDPOINT); book.config .set("output.html.livereload-url", &livereload_url)?; if let Some(dest_dir) = args.value_of("dest-dir") { book.config.build.build_dir = dest_dir.into(); } book.build()?; let sockaddr: SocketAddr = address .to_socket_addrs()? .next() .ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?; let build_dir = book.build_dir_for("html"); // A channel used to broadcast to any websockets to reload when a file changes. let (tx, _rx) = tokio::sync::broadcast::channel::(100); let reload_tx = tx.clone(); let thread_handle = std::thread::spawn(move || { serve(build_dir, sockaddr, reload_tx); }); let serving_url = format!("http://{}", address); info!("Serving on: {}", serving_url); if open_browser { open(serving_url); } #[cfg(feature = "watch")] watch::trigger_on_change(&book, move |paths, book_dir| { info!("Files changed: {:?}", paths); info!("Building book..."); // FIXME: This area is really ugly because we need to re-set livereload :( let result = MDBook::load(&book_dir) .and_then(|mut b| { b.config .set("output.html.livereload-url", &livereload_url)?; Ok(b) }) .and_then(|b| b.build()); if let Err(e) = result { error!("Unable to load the book"); utils::log_backtrace(&e); } else { let _ = tx.send(Message::text("reload")); } }); let _ = thread_handle.join(); Ok(()) } #[tokio::main] async fn serve(build_dir: PathBuf, address: SocketAddr, reload_tx: broadcast::Sender) { // A warp Filter which captures `reload_tx` and provides an `rx` copy to // receive reload messages. let sender = warp::any().map(move || reload_tx.subscribe()); // A warp Filter to handle the livereload endpoint. This upgrades to a // websocket, and then waits for any filesystem change notifications, and // relays them over the websocket. let livereload = warp::path(LIVE_RELOAD_ENDPOINT) .and(warp::ws()) .and(sender) .map(|ws: warp::ws::Ws, mut rx: broadcast::Receiver| { ws.on_upgrade(move |ws| async move { let (mut user_ws_tx, _user_ws_rx) = ws.split(); trace!("websocket got connection"); if let Ok(m) = rx.recv().await { trace!("notify of reload"); let _ = user_ws_tx.send(m).await; } }) }); // A warp Filter that serves from the filesystem. let book_route = warp::fs::dir(build_dir.clone()); // The fallback route for 404 errors let fallback_file = "404.html"; let fallback_route = warp::fs::file(build_dir.join(fallback_file)) .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NOT_FOUND)); let routes = livereload.or(book_route).or(fallback_route); warp::serve(routes).run(address).await; }