Skip to main content

xtask_wasm/
dev_server.rs

1use crate::{
2    anyhow::{bail, ensure, Context, Result},
3    camino::Utf8Path,
4    clap, xtask_command, Dist, 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};
16use xtask_watch::WatchLock;
17
18type RequestHandler = Arc<dyn Fn(Request) -> Result<()> + Send + Sync + 'static>;
19
20/// A type that can produce a [`process::Command`] given the final [`DevServer`] configuration.
21///
22/// Implement this trait to build a command whose arguments or environment depend on the server's
23/// configuration — for example to pass `--dist-dir`, `--port`, or other runtime values.
24///
25/// A blanket implementation is provided for [`process::Command`] itself, so existing call sites
26/// that pass a plain command continue to work without any changes.
27///
28/// # Examples
29///
30/// ```rust,no_run
31/// use std::process;
32/// use xtask_wasm::{anyhow::Result, clap, DevServer, Hook};
33///
34/// struct NotifyOnPort;
35///
36/// impl Hook for NotifyOnPort {
37///     fn build_command(self: Box<Self>, server: &DevServer) -> process::Command {
38///         let mut cmd = process::Command::new("notify-send");
39///         cmd.arg(format!("dev server on port {}", server.port));
40///         cmd
41///     }
42/// }
43///
44/// #[derive(clap::Parser)]
45/// enum Opt {
46///     Start(xtask_wasm::DevServer),
47/// }
48///
49/// fn main() -> Result<()> {
50///     let opt: Opt = clap::Parser::parse();
51///
52///     match opt {
53///         Opt::Start(dev_server) => {
54///             dev_server
55///                 .xtask("dist")
56///                 .post(NotifyOnPort)
57///                 .start()?;
58///         }
59///     }
60///
61///     Ok(())
62/// }
63/// ```
64pub trait Hook {
65    /// Construct the [`process::Command`] to run, using `server` as context.
66    fn build_command(self: Box<Self>, server: &DevServer) -> process::Command;
67}
68
69impl Hook for process::Command {
70    fn build_command(self: Box<Self>, _server: &DevServer) -> process::Command {
71        *self
72    }
73}
74
75/// Abstraction over an HTTP request.
76#[derive(Debug)]
77#[non_exhaustive]
78pub struct Request<'a> {
79    /// TCP stream of the request.
80    pub stream: &'a mut TcpStream,
81    /// Path of the request.
82    pub path: &'a str,
83    /// Request header.
84    pub header: &'a str,
85    /// Path to the distributed directory.
86    pub dist_dir: &'a Path,
87    /// Path to the file used when the requested file cannot be found for the default request
88    /// handler.
89    pub not_found_path: Option<&'a Path>,
90}
91
92/// A simple HTTP server useful during development.
93///
94/// It can watch the source code for changes and restart a provided [`command`](Self::command).
95///
96/// Serve the file from the provided [`dist_dir`](Self::dist_dir) at a given IP address
97/// (127.0.0.1:8000 by default). An optional command can be provided to restart the build when
98/// changes are detected using [`command`](Self::command), [`xtask`](Self::xtask) or
99/// [`cargo`](Self::cargo).
100///
101/// # Usage
102///
103/// ```rust,no_run
104/// use std::process;
105/// use xtask_wasm::{
106///     anyhow::Result,
107///     clap,
108/// };
109///
110/// #[derive(clap::Parser)]
111/// enum Opt {
112///     Start(xtask_wasm::DevServer),
113///     Dist,
114/// }
115///
116/// fn main() -> Result<()> {
117///     let opt: Opt = clap::Parser::parse();
118///
119///     match opt {
120///         Opt::Dist => todo!("build project"),
121///         Opt::Start(dev_server) => {
122///             log::info!("Starting the development server...");
123///             dev_server
124///                 .xtask("dist")
125///                 .start()?;
126///         }
127///     }
128///
129///     Ok(())
130/// }
131/// ```
132///
133/// This adds a `start` subcommand that will run `cargo xtask dist`, watching for
134/// changes in the workspace and serve the files in the default dist directory
135/// (`target/debug/dist`) at the default IP address.
136#[non_exhaustive]
137#[derive(Debug, clap::Parser)]
138#[clap(
139    about = "A simple HTTP server useful during development.",
140    long_about = "A simple HTTP server useful during development.\n\
141        It can watch the source code for changes."
142)]
143pub struct DevServer {
144    /// IP address to bind. Default to `127.0.0.1`.
145    #[clap(long, default_value = "127.0.0.1")]
146    pub ip: IpAddr,
147    /// Port number. Default to `8000`.
148    #[clap(long, default_value = "8000")]
149    pub port: u16,
150
151    /// Watch configuration for detecting file-system changes.
152    ///
153    /// Controls which paths are watched, debounce timing, and other watch
154    /// behaviour. Watching is only active when at least one of `pre_hooks`,
155    /// `command`, or `post_hooks` is set; if none are provided the watch
156    /// thread is not started.
157    #[clap(flatten)]
158    pub watch: Watch,
159
160    /// Directory of all generated artifacts.
161    #[clap(skip)]
162    pub dist_dir: Option<PathBuf>,
163
164    /// Commands executed before the main command when a change is detected.
165    #[clap(skip)]
166    #[debug(skip)]
167    pub pre_hooks: Vec<Box<dyn Hook>>,
168
169    /// Main command executed when a change is detected.
170    #[clap(skip)]
171    pub command: Option<process::Command>,
172
173    /// Commands executed after the main command when a change is detected.
174    #[clap(skip)]
175    #[debug(skip)]
176    pub post_hooks: Vec<Box<dyn Hook>>,
177
178    /// Use another file path when the URL is not found.
179    #[clap(skip)]
180    pub not_found_path: Option<PathBuf>,
181
182    /// Pass a custom request handler.
183    #[clap(skip)]
184    #[debug(skip)]
185    request_handler: Option<RequestHandler>,
186}
187
188impl DevServer {
189    /// Set the dev-server binding address.
190    pub fn address(mut self, ip: IpAddr, port: u16) -> Self {
191        self.ip = ip;
192        self.port = port;
193
194        self
195    }
196
197    /// Set the directory for the generated artifacts.
198    ///
199    /// The default is `target/debug/dist`.
200    pub fn dist_dir(mut self, path: impl Into<PathBuf>) -> Self {
201        self.dist_dir = Some(path.into());
202        self
203    }
204
205    /// Add a command to execute before the main command when a change is detected.
206    pub fn pre(mut self, command: impl Hook + 'static) -> Self {
207        self.pre_hooks.push(Box::new(command));
208        self
209    }
210
211    /// Add multiple commands to execute before the main command when a change is detected.
212    pub fn pres(mut self, commands: impl IntoIterator<Item = impl Hook + 'static>) -> Self {
213        self.pre_hooks
214            .extend(commands.into_iter().map(|c| Box::new(c) as Box<dyn Hook>));
215        self
216    }
217
218    /// Add a command to execute after the main command when a change is detected.
219    pub fn post(mut self, command: impl Hook + 'static) -> Self {
220        self.post_hooks.push(Box::new(command));
221        self
222    }
223
224    /// Add multiple commands to execute after the main command when a change is detected.
225    pub fn posts(mut self, commands: impl IntoIterator<Item = impl Hook + 'static>) -> Self {
226        self.post_hooks
227            .extend(commands.into_iter().map(|c| Box::new(c) as Box<dyn Hook>));
228        self
229    }
230
231    /// Main command executed when a change is detected.
232    ///
233    /// See [`xtask`](Self::xtask) if you want to use an `xtask` command.
234    pub fn command(mut self, command: process::Command) -> Self {
235        self.command = Some(command);
236        self
237    }
238
239    /// Name of the main xtask command that is executed when a change is detected.
240    ///
241    /// See [`command`](Self::command) to use an arbitrary command.
242    pub fn xtask(mut self, name: impl AsRef<str>) -> Self {
243        let mut command = xtask_command();
244        command.arg(name.as_ref());
245        self.command = Some(command);
246        self
247    }
248
249    /// Cargo subcommand executed as the main command when a change is detected.
250    ///
251    /// See [`xtask`](Self::xtask) for xtask commands or [`command`](Self::command) for arbitrary
252    /// commands.
253    pub fn cargo(mut self, subcommand: impl AsRef<str>) -> Self {
254        let mut command = process::Command::new("cargo");
255        command.arg(subcommand.as_ref());
256        self.command = Some(command);
257        self
258    }
259
260    /// Adds an argument to the main command executed when changes are detected.
261    ///
262    /// # Panics
263    ///
264    /// Panics if called before [`command`](Self::command), [`xtask`](Self::xtask) or
265    /// [`cargo`](Self::cargo).
266    pub fn arg<S: AsRef<ffi::OsStr>>(mut self, arg: S) -> Self {
267        self.command
268            .as_mut()
269            .expect("`arg` called without a command set; call `command`, `xtask` or `cargo` first")
270            .arg(arg);
271        self
272    }
273
274    /// Adds multiple arguments to the main command executed when changes are detected.
275    ///
276    /// # Panics
277    ///
278    /// Panics if called before [`command`](Self::command), [`xtask`](Self::xtask) or
279    /// [`cargo`](Self::cargo).
280    pub fn args<I, S>(mut self, args: I) -> Self
281    where
282        I: IntoIterator<Item = S>,
283        S: AsRef<ffi::OsStr>,
284    {
285        self.command
286            .as_mut()
287            .expect("`args` called without a command set; call `command`, `xtask` or `cargo` first")
288            .args(args);
289        self
290    }
291
292    /// Inserts or updates an environment variable for the main command executed when changes are
293    /// detected.
294    ///
295    /// # Panics
296    ///
297    /// Panics if called before [`command`](Self::command), [`xtask`](Self::xtask) or
298    /// [`cargo`](Self::cargo).
299    pub fn env<K, V>(mut self, key: K, val: V) -> Self
300    where
301        K: AsRef<ffi::OsStr>,
302        V: AsRef<ffi::OsStr>,
303    {
304        self.command
305            .as_mut()
306            .expect("`env` called without a command set; call `command`, `xtask` or `cargo` first")
307            .env(key, val);
308        self
309    }
310
311    /// Inserts or updates multiple environment variables for the main command executed when
312    /// changes are detected.
313    ///
314    /// # Panics
315    ///
316    /// Panics if called before [`command`](Self::command), [`xtask`](Self::xtask) or
317    /// [`cargo`](Self::cargo).
318    pub fn envs<I, K, V>(mut self, vars: I) -> Self
319    where
320        I: IntoIterator<Item = (K, V)>,
321        K: AsRef<ffi::OsStr>,
322        V: AsRef<ffi::OsStr>,
323    {
324        self.command
325            .as_mut()
326            .expect("`envs` called without a command set; call `command`, `xtask` or `cargo` first")
327            .envs(vars);
328        self
329    }
330
331    /// Use another file path when the URL is not found.
332    pub fn not_found_path(mut self, path: impl Into<PathBuf>) -> Self {
333        self.not_found_path.replace(path.into());
334        self
335    }
336
337    /// Pass a custom request handler to the dev server.
338    pub fn request_handler<F>(mut self, handler: F) -> Self
339    where
340        F: Fn(Request) -> Result<()> + Send + Sync + 'static,
341    {
342        self.request_handler.replace(Arc::new(handler));
343        self
344    }
345
346    /// Start the server, serving the files at [`dist_dir`](Self::dist_dir).
347    ///
348    /// If `dist_dir` has not been provided, [`Dist::default_debug_dir`] will be used.
349    pub fn start(mut self) -> Result<()> {
350        // Resolve dist_dir early so Hooks can observe the final value via &self.
351        if self.dist_dir.is_none() {
352            self.dist_dir = Some(Dist::default_debug_dir().into());
353        }
354        let dist_dir = self.dist_dir.clone().unwrap();
355
356        let watch_lock = self.watch.lock();
357
358        let watch_process = {
359            // mem::take so we can pass &self to build_command while the fields are empty.
360            let pre_hooks = std::mem::take(&mut self.pre_hooks);
361            let post_hooks = std::mem::take(&mut self.post_hooks);
362            let main_command = self.command.take();
363
364            let mut commands: Vec<process::Command> = pre_hooks
365                .into_iter()
366                .map(|p| p.build_command(&self))
367                .collect();
368            if let Some(command) = main_command {
369                commands.push(command);
370            }
371            commands.extend(post_hooks.into_iter().map(|p| p.build_command(&self)));
372
373            if !commands.is_empty() {
374                // NOTE: the path needs to exists in order to be excluded because it is canonicalize
375                std::fs::create_dir_all(&dist_dir).with_context(|| {
376                    format!("cannot create dist directory `{}`", dist_dir.display())
377                })?;
378                let watch = self.watch.exclude_path(&dist_dir);
379
380                let handle = std::thread::spawn(move || match watch.run(commands) {
381                    Ok(()) => log::trace!("Starting to watch"),
382                    Err(err) => log::error!("an error occurred when starting to watch: {err}"),
383                });
384
385                Some(handle)
386            } else {
387                None
388            }
389        };
390
391        if let Some(handler) = self.request_handler {
392            serve(
393                self.ip,
394                self.port,
395                dist_dir,
396                self.not_found_path,
397                handler,
398                watch_lock,
399            )
400            .context("an error occurred when starting to serve")?;
401        } else {
402            serve(
403                self.ip,
404                self.port,
405                dist_dir,
406                self.not_found_path,
407                Arc::new(default_request_handler),
408                watch_lock,
409            )
410            .context("an error occurred when starting to serve")?;
411        }
412
413        if let Some(handle) = watch_process {
414            handle.join().expect("an error occurred when exiting watch");
415        }
416
417        Ok(())
418    }
419}
420
421impl Default for DevServer {
422    fn default() -> DevServer {
423        DevServer {
424            ip: IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
425            port: 8000,
426            watch: Default::default(),
427            dist_dir: None,
428            pre_hooks: Default::default(),
429            command: None,
430            post_hooks: Default::default(),
431            not_found_path: None,
432            request_handler: None,
433        }
434    }
435}
436
437fn serve(
438    ip: IpAddr,
439    port: u16,
440    dist_dir: PathBuf,
441    not_found_path: Option<PathBuf>,
442    handler: RequestHandler,
443    watch_lock: WatchLock,
444) -> Result<()> {
445    let address = SocketAddr::new(ip, port);
446    let listener = TcpListener::bind(address).context("cannot bind to the given address")?;
447
448    log::info!("Development server running at: http://{}", &address);
449
450    macro_rules! warn_not_fail {
451        ($expr:expr) => {{
452            match $expr {
453                Ok(res) => res,
454                Err(err) => {
455                    log::warn!("Malformed request's header: {}", err);
456                    return;
457                }
458            }
459        }};
460    }
461
462    for mut stream in listener.incoming().filter_map(Result::ok) {
463        let handler = handler.clone();
464        let dist_dir = dist_dir.clone();
465        let not_found_path = not_found_path.clone();
466        let watch_lock = watch_lock.clone();
467        thread::spawn(move || {
468            // Detect TLS/HTTPS connections and emit an actionable warning instead of a
469            // cryptic parse error.
470            let mut tls_probe = [0u8; 2];
471            if stream.peek(&mut tls_probe).is_ok() && tls_probe[0] == 0x16 && tls_probe[1] == 0x03 {
472                log::warn!(
473                    "Received a TLS/HTTPS connection on a plain HTTP server. \
474                     Use http:// instead of https:// to connect."
475                );
476                return;
477            }
478
479            // Read the request header *before* acquiring the watch lock so that connections
480            // can be accepted and parsed while a rebuild is in progress. This reduces
481            // perceived latency: the response is dispatched immediately once the build
482            // finishes rather than having to re-parse the header afterward.
483            let header = warn_not_fail!(read_header(&stream));
484            let _guard = watch_lock.acquire();
485            let request = Request {
486                stream: &mut stream,
487                header: header.as_ref(),
488                path: warn_not_fail!(parse_request_path(&header)),
489                dist_dir: dist_dir.as_ref(),
490                not_found_path: not_found_path.as_deref(),
491            };
492
493            (handler)(request).unwrap_or_else(|e| {
494                let _ = stream.write(
495                    "HTTP/1.1 500 INTERNAL SERVER ERROR\r\nConnection: close\r\n\r\n".as_bytes(),
496                );
497                log::error!("an error occurred: {e}");
498            });
499        });
500    }
501
502    Ok(())
503}
504
505fn read_header(mut stream: &TcpStream) -> Result<String> {
506    let mut header = Vec::with_capacity(64 * 1024);
507    let mut peek_buffer = [0u8; 4096];
508
509    loop {
510        let n = stream.peek(&mut peek_buffer)?;
511        ensure!(n > 0, "Unexpected EOF");
512
513        let data = &mut peek_buffer[..n];
514        if let Some(i) = data.windows(4).position(|x| x == b"\r\n\r\n") {
515            let data = &mut peek_buffer[..(i + 4)];
516            stream.read_exact(data)?;
517            header.extend(&*data);
518            break;
519        } else {
520            stream.read_exact(data)?;
521            header.extend(&*data);
522        }
523    }
524
525    Ok(String::from_utf8(header)?)
526}
527
528fn parse_request_path(header: &str) -> Result<&str> {
529    let content = header.split('\r').next().unwrap();
530    let requested_path = content
531        .split_whitespace()
532        .nth(1)
533        .context("could not find path in request")?;
534    Ok(requested_path
535        .split_once('?')
536        .map(|(prefix, _suffix)| prefix)
537        .unwrap_or(requested_path))
538}
539
540/// Default request handler
541pub fn default_request_handler(request: Request) -> Result<()> {
542    let requested_path = request.path;
543
544    log::debug!("<-- {requested_path}");
545
546    let rel_path = Path::new(requested_path.trim_matches('/'));
547    let mut full_path = request.dist_dir.join(rel_path);
548
549    if full_path.is_dir() {
550        if full_path.join("index.html").exists() {
551            full_path = full_path.join("index.html")
552        } else if full_path.join("index.htm").exists() {
553            full_path = full_path.join("index.htm")
554        } else {
555            bail!("no index.html in {}", full_path.display());
556        }
557    }
558
559    if let Some(path) = request.not_found_path {
560        if !full_path.is_file() {
561            full_path = request.dist_dir.join(path);
562        }
563    }
564
565    if full_path.is_file() {
566        log::debug!("--> {}", full_path.display());
567        let full_path_extension = Utf8Path::from_path(&full_path)
568            .context("request path contains non-utf8 characters")?
569            .extension();
570
571        let content_type = match full_path_extension {
572            Some("html") => "text/html;charset=utf-8",
573            Some("css") => "text/css;charset=utf-8",
574            Some("js") => "application/javascript",
575            Some("wasm") => "application/wasm",
576            _ => "application/octet-stream",
577        };
578
579        request
580            .stream
581            .write(
582                format!(
583                    "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\nContent-Type: {}\r\n\r\n",
584                    full_path.metadata()?.len(),
585                    content_type,
586                )
587                .as_bytes(),
588            )
589            .context("cannot write response")?;
590
591        std::io::copy(&mut fs::File::open(&full_path)?, request.stream)?;
592    } else {
593        log::error!("--> {} (404 NOT FOUND)", full_path.display());
594        request
595            .stream
596            .write("HTTP/1.1 404 NOT FOUND\r\nConnection: close\r\n\r\n".as_bytes())
597            .context("cannot write response")?;
598    }
599
600    Ok(())
601}