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