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
20pub trait Hook {
65 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#[derive(Debug)]
77#[non_exhaustive]
78pub struct Request<'a> {
79 pub stream: &'a mut TcpStream,
81 pub path: &'a str,
83 pub header: &'a str,
85 pub dist_dir: &'a Path,
87 pub not_found_path: Option<&'a Path>,
90}
91
92#[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 #[clap(long, default_value = "127.0.0.1")]
146 pub ip: IpAddr,
147 #[clap(long, default_value = "8000")]
149 pub port: u16,
150
151 #[clap(flatten)]
158 pub watch: Watch,
159
160 #[clap(skip)]
162 pub dist_dir: Option<PathBuf>,
163
164 #[clap(skip)]
166 #[debug(skip)]
167 pub pre_hooks: Vec<Box<dyn Hook>>,
168
169 #[clap(skip)]
171 pub command: Option<process::Command>,
172
173 #[clap(skip)]
175 #[debug(skip)]
176 pub post_hooks: Vec<Box<dyn Hook>>,
177
178 #[clap(skip)]
180 pub not_found_path: Option<PathBuf>,
181
182 #[clap(skip)]
184 #[debug(skip)]
185 request_handler: Option<RequestHandler>,
186}
187
188impl DevServer {
189 pub fn address(mut self, ip: IpAddr, port: u16) -> Self {
191 self.ip = ip;
192 self.port = port;
193
194 self
195 }
196
197 pub fn dist_dir(mut self, path: impl Into<PathBuf>) -> Self {
201 self.dist_dir = Some(path.into());
202 self
203 }
204
205 pub fn pre(mut self, command: impl Hook + 'static) -> Self {
207 self.pre_hooks.push(Box::new(command));
208 self
209 }
210
211 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 pub fn post(mut self, command: impl Hook + 'static) -> Self {
220 self.post_hooks.push(Box::new(command));
221 self
222 }
223
224 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 pub fn command(mut self, command: process::Command) -> Self {
235 self.command = Some(command);
236 self
237 }
238
239 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 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 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 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 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 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 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 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 pub fn start(mut self) -> Result<()> {
350 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 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 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 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
527pub 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}