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            // Read the request header *before* acquiring the watch lock so that connections
469            // can be accepted and parsed while a rebuild is in progress. This reduces
470            // perceived latency: the response is dispatched immediately once the build
471            // finishes rather than having to re-parse the header afterward.
472            let header = warn_not_fail!(read_header(&stream));
473            let _guard = watch_lock.acquire();
474            let request = Request {
475                stream: &mut stream,
476                header: header.as_ref(),
477                path: warn_not_fail!(parse_request_path(&header)),
478                dist_dir: dist_dir.as_ref(),
479                not_found_path: not_found_path.as_deref(),
480            };
481
482            (handler)(request).unwrap_or_else(|e| {
483                let _ = stream.write("HTTP/1.1 500 INTERNAL SERVER ERROR\r\n\r\n".as_bytes());
484                log::error!("an error occurred: {e}");
485            });
486        });
487    }
488
489    Ok(())
490}
491
492fn read_header(mut stream: &TcpStream) -> Result<String> {
493    let mut header = Vec::with_capacity(64 * 1024);
494    let mut peek_buffer = [0u8; 4096];
495
496    loop {
497        let n = stream.peek(&mut peek_buffer)?;
498        ensure!(n > 0, "Unexpected EOF");
499
500        let data = &mut peek_buffer[..n];
501        if let Some(i) = data.windows(4).position(|x| x == b"\r\n\r\n") {
502            let data = &mut peek_buffer[..(i + 4)];
503            stream.read_exact(data)?;
504            header.extend(&*data);
505            break;
506        } else {
507            stream.read_exact(data)?;
508            header.extend(&*data);
509        }
510    }
511
512    Ok(String::from_utf8(header)?)
513}
514
515fn parse_request_path(header: &str) -> Result<&str> {
516    let content = header.split('\r').next().unwrap();
517    let requested_path = content
518        .split_whitespace()
519        .nth(1)
520        .context("could not find path in request")?;
521    Ok(requested_path
522        .split_once('?')
523        .map(|(prefix, _suffix)| prefix)
524        .unwrap_or(requested_path))
525}
526
527/// Default request handler
528pub fn default_request_handler(request: Request) -> Result<()> {
529    let requested_path = request.path;
530
531    log::debug!("<-- {requested_path}");
532
533    let rel_path = Path::new(requested_path.trim_matches('/'));
534    let mut full_path = request.dist_dir.join(rel_path);
535
536    if full_path.is_dir() {
537        if full_path.join("index.html").exists() {
538            full_path = full_path.join("index.html")
539        } else if full_path.join("index.htm").exists() {
540            full_path = full_path.join("index.htm")
541        } else {
542            bail!("no index.html in {}", full_path.display());
543        }
544    }
545
546    if let Some(path) = request.not_found_path {
547        if !full_path.is_file() {
548            full_path = request.dist_dir.join(path);
549        }
550    }
551
552    if full_path.is_file() {
553        log::debug!("--> {}", full_path.display());
554        let full_path_extension = Utf8Path::from_path(&full_path)
555            .context("request path contains non-utf8 characters")?
556            .extension();
557
558        let content_type = match full_path_extension {
559            Some("html") => "text/html;charset=utf-8",
560            Some("css") => "text/css;charset=utf-8",
561            Some("js") => "application/javascript",
562            Some("wasm") => "application/wasm",
563            _ => "application/octet-stream",
564        };
565
566        request
567            .stream
568            .write(
569                format!(
570                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\n\r\n",
571                    full_path.metadata()?.len(),
572                    content_type,
573                )
574                .as_bytes(),
575            )
576            .context("cannot write response")?;
577
578        std::io::copy(&mut fs::File::open(&full_path)?, request.stream)?;
579    } else {
580        log::error!("--> {} (404 NOT FOUND)", full_path.display());
581        request
582            .stream
583            .write("HTTP/1.1 404 NOT FOUND\r\n\r\n".as_bytes())
584            .context("cannot write response")?;
585    }
586
587    Ok(())
588}