xtask_wasm/
dev_server.rs

1use crate::{
2    anyhow::{bail, ensure, Context, Result},
3    camino::Utf8Path,
4    clap, Watch,
5};
6use derive_more::Debug;
7use std::{
8    ffi, fs,
9    io::prelude::*,
10    net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream},
11    path::{Path, PathBuf},
12    process,
13    sync::Arc,
14    thread,
15};
16
17type RequestHandler = Arc<dyn Fn(Request) -> Result<()> + Send + Sync + 'static>;
18
19/// Abstraction over an HTTP request.
20#[non_exhaustive]
21pub struct Request<'a> {
22    /// TCP stream of the request.
23    pub stream: &'a mut TcpStream,
24    /// Path of the request.
25    pub path: &'a str,
26    /// Request header.
27    pub header: &'a str,
28    /// Path to the distributed directory.
29    pub dist_dir_path: &'a Path,
30    /// Path to the file used when the requested file cannot be found for the default request
31    /// handler.
32    pub not_found_path: Option<&'a Path>,
33}
34
35/// A simple HTTP server useful during development.
36///
37/// It can watch the source code for changes and restart a provided command.
38///
39/// Get the files at `watch_path` and serve them at a given IP address
40/// (127.0.0.1:8000 by default). An optional command can be provided to restart
41/// the build when changes are detected.
42///
43/// # Usage
44///
45/// ```rust,no_run
46/// use std::process;
47/// use xtask_wasm::{
48///     anyhow::Result,
49///     clap,
50///     default_dist_dir,
51/// };
52///
53/// #[derive(clap::Parser)]
54/// enum Opt {
55///     Start(xtask_wasm::DevServer),
56///     Dist,
57/// }
58///
59/// fn main() -> Result<()> {
60///     let opt: Opt = clap::Parser::parse();
61///
62///     match opt {
63///         Opt::Start(mut dev_server) => {
64///             log::info!("Starting the development server...");
65///             dev_server.arg("dist").start(default_dist_dir(false))?;
66///         }
67///         Opt::Dist => todo!("build project"),
68///     }
69///
70///     Ok(())
71/// }
72/// ```
73///
74/// Add a `start` subcommand that will run `cargo xtask dist`, watching for
75/// changes in the workspace and serve the files in the default dist directory
76/// (`target/debug/dist` for non-release) at a given IP address.
77#[non_exhaustive]
78#[derive(Debug, clap::Parser)]
79#[clap(
80    about = "A simple HTTP server useful during development.",
81    long_about = "A simple HTTP server useful during development.\n\
82        It can watch the source code for changes."
83)]
84pub struct DevServer {
85    /// IP address to bind. Default to `127.0.0.1`.
86    #[clap(long, default_value = "127.0.0.1")]
87    pub ip: IpAddr,
88    /// Port number. Default to `8000`.
89    #[clap(long, default_value = "8000")]
90    pub port: u16,
91
92    /// Watch object for detecting changes.
93    ///
94    /// # Note
95    ///
96    /// Used only if `command` is set.
97    #[clap(flatten)]
98    pub watch: Watch,
99
100    /// Command executed when a change is detected.
101    #[clap(skip)]
102    pub command: Option<process::Command>,
103
104    /// Use another file path when the URL is not found.
105    #[clap(skip)]
106    pub not_found_path: Option<PathBuf>,
107
108    /// Pass a custom request handler.
109    #[clap(skip)]
110    #[debug(skip)]
111    request_handler: Option<RequestHandler>,
112}
113
114impl DevServer {
115    /// Set the dev-server binding address.
116    pub fn address(mut self, ip: IpAddr, port: u16) -> Self {
117        self.ip = ip;
118        self.port = port;
119
120        self
121    }
122
123    /// Set the command that is executed when a change is detected.
124    pub fn command(mut self, command: process::Command) -> Self {
125        self.command = Some(command);
126        self
127    }
128
129    /// Adds an argument to pass to the command executed when changes are
130    /// detected.
131    ///
132    /// This will use the xtask command by default.
133    pub fn arg<S: AsRef<ffi::OsStr>>(mut self, arg: S) -> Self {
134        self.set_xtask_command().arg(arg);
135        self
136    }
137
138    /// Adds multiple arguments to pass to the command executed when changes are
139    /// detected.
140    ///
141    /// This will use the xtask command by default.
142    pub fn args<I, S>(mut self, args: I) -> Self
143    where
144        I: IntoIterator<Item = S>,
145        S: AsRef<ffi::OsStr>,
146    {
147        self.set_xtask_command().args(args);
148        self
149    }
150
151    /// Use another file path when the URL is not found.
152    pub fn not_found(mut self, path: impl Into<PathBuf>) -> Self {
153        self.not_found_path.replace(path.into());
154        self
155    }
156
157    /// Pass a custom request handler to the dev server.
158    pub fn request_handler<F>(mut self, handler: F) -> Self
159    where
160        F: Fn(Request) -> Result<()> + Send + Sync + 'static,
161    {
162        self.request_handler.replace(Arc::new(handler));
163        self
164    }
165
166    /// Start the server, serving the files at `dist_dir_path`.
167    ///
168    /// [`crate::default_dist_dir`] should be used to get the dist directory
169    /// that needs to be served.
170    pub fn start(self, dist_dir_path: impl Into<PathBuf>) -> Result<()> {
171        let dist_dir_path = dist_dir_path.into();
172
173        let watch_process = if let Some(command) = self.command {
174            // NOTE: the path needs to exists in order to be excluded because it is canonicalize
175            let _ = std::fs::create_dir_all(&dist_dir_path);
176            let watch = self.watch.exclude_path(&dist_dir_path);
177            let handle = std::thread::spawn(|| match watch.run(command) {
178                Ok(()) => log::trace!("Starting to watch"),
179                Err(err) => log::error!("an error occurred when starting to watch: {}", err),
180            });
181
182            Some(handle)
183        } else {
184            None
185        };
186
187        if let Some(handler) = self.request_handler {
188            serve(
189                self.ip,
190                self.port,
191                dist_dir_path,
192                self.not_found_path,
193                handler,
194            )
195            .context("an error occurred when starting to serve")?;
196        } else {
197            serve(
198                self.ip,
199                self.port,
200                dist_dir_path,
201                self.not_found_path,
202                Arc::new(default_request_handler),
203            )
204            .context("an error occurred when starting to serve")?;
205        }
206
207        if let Some(handle) = watch_process {
208            handle.join().expect("an error occurred when exiting watch");
209        }
210
211        Ok(())
212    }
213
214    fn set_xtask_command(&mut self) -> &mut process::Command {
215        if self.command.is_none() {
216            self.command = Some(crate::xtask_command());
217        }
218        self.command.as_mut().unwrap()
219    }
220}
221
222impl Default for DevServer {
223    fn default() -> DevServer {
224        DevServer {
225            ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
226            port: 8000,
227            watch: Default::default(),
228            command: None,
229            not_found_path: None,
230            request_handler: None,
231        }
232    }
233}
234
235fn serve(
236    ip: IpAddr,
237    port: u16,
238    dist_dir_path: PathBuf,
239    not_found_path: Option<PathBuf>,
240    handler: RequestHandler,
241) -> Result<()> {
242    let address = SocketAddr::new(ip, port);
243    let listener = TcpListener::bind(address).context("cannot bind to the given address")?;
244
245    log::info!("Development server running at: http://{}", &address);
246
247    macro_rules! warn_not_fail {
248        ($expr:expr) => {{
249            match $expr {
250                Ok(res) => res,
251                Err(err) => {
252                    log::warn!("Malformed request's header: {}", err);
253                    return;
254                }
255            }
256        }};
257    }
258
259    for mut stream in listener.incoming().filter_map(Result::ok) {
260        let handler = handler.clone();
261        let dist_dir_path = dist_dir_path.clone();
262        let not_found_path = not_found_path.clone();
263        thread::spawn(move || {
264            let header = warn_not_fail!(read_header(&stream));
265            let request = Request {
266                stream: &mut stream,
267                header: header.as_ref(),
268                path: warn_not_fail!(parse_request_path(&header)),
269                dist_dir_path: dist_dir_path.as_ref(),
270                not_found_path: not_found_path.as_deref(),
271            };
272
273            (handler)(request).unwrap_or_else(|e| {
274                let _ = stream.write("HTTP/1.1 500 INTERNAL SERVER ERROR\r\n\r\n".as_bytes());
275                log::error!("an error occurred: {}", e);
276            });
277        });
278    }
279
280    Ok(())
281}
282
283fn read_header(mut stream: &TcpStream) -> Result<String> {
284    let mut header = Vec::with_capacity(64 * 1024);
285    let mut peek_buffer = [0u8; 4096];
286
287    loop {
288        let n = stream.peek(&mut peek_buffer)?;
289        ensure!(n > 0, "Unexpected EOF");
290
291        let data = &mut peek_buffer[..n];
292        if let Some(i) = data.windows(4).position(|x| x == b"\r\n\r\n") {
293            let data = &mut peek_buffer[..(i + 4)];
294            stream.read_exact(data)?;
295            header.extend(&*data);
296            break;
297        } else {
298            stream.read_exact(data)?;
299            header.extend(&*data);
300        }
301    }
302
303    Ok(String::from_utf8(header)?)
304}
305
306fn parse_request_path(header: &str) -> Result<&str> {
307    let content = header.split('\r').next().unwrap();
308    let requested_path = content
309        .split_whitespace()
310        .nth(1)
311        .context("could not find path in request")?;
312    Ok(requested_path
313        .split_once('?')
314        .map(|(prefix, _suffix)| prefix)
315        .unwrap_or(requested_path))
316}
317
318/// Default request handler
319pub fn default_request_handler(request: Request) -> Result<()> {
320    let requested_path = request.path;
321
322    log::debug!("<-- {}", requested_path);
323
324    let rel_path = Path::new(requested_path.trim_matches('/'));
325    let mut full_path = request.dist_dir_path.join(rel_path);
326
327    if full_path.is_dir() {
328        if full_path.join("index.html").exists() {
329            full_path = full_path.join("index.html")
330        } else if full_path.join("index.htm").exists() {
331            full_path = full_path.join("index.htm")
332        } else {
333            bail!("no index.html in {}", full_path.display());
334        }
335    }
336
337    if let Some(path) = request.not_found_path {
338        if !full_path.is_file() {
339            full_path = request.dist_dir_path.join(path);
340        }
341    }
342
343    if full_path.is_file() {
344        log::debug!("--> {}", full_path.display());
345        let full_path_extension = Utf8Path::from_path(&full_path)
346            .context("request path contains non-utf8 characters")?
347            .extension();
348
349        let content_type = match full_path_extension {
350            Some("html") => "text/html;charset=utf-8",
351            Some("css") => "text/css;charset=utf-8",
352            Some("js") => "application/javascript",
353            Some("wasm") => "application/wasm",
354            _ => "application/octet-stream",
355        };
356
357        request
358            .stream
359            .write(
360                format!(
361                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\n\r\n",
362                    full_path.metadata()?.len(),
363                    content_type,
364                )
365                .as_bytes(),
366            )
367            .context("cannot write response")?;
368
369        std::io::copy(&mut fs::File::open(&full_path)?, request.stream)?;
370    } else {
371        log::error!("--> {} (404 NOT FOUND)", full_path.display());
372        request
373            .stream
374            .write("HTTP/1.1 404 NOT FOUND\r\n\r\n".as_bytes())
375            .context("cannot write response")?;
376    }
377
378    Ok(())
379}