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
19pub trait Hook {
64 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#[derive(Debug)]
76#[non_exhaustive]
77pub struct Request<'a> {
78 pub stream: &'a mut TcpStream,
80 pub path: &'a str,
82 pub header: &'a str,
84 pub dist_dir: &'a Path,
86 pub not_found_path: Option<&'a Path>,
89}
90
91#[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 #[clap(long, default_value = "127.0.0.1")]
145 pub ip: IpAddr,
146 #[clap(long, default_value = "8000")]
148 pub port: u16,
149
150 #[clap(flatten)]
157 pub watch: Watch,
158
159 #[clap(skip)]
161 pub dist_dir: Option<PathBuf>,
162
163 #[clap(skip)]
165 #[debug(skip)]
166 pub pre_hooks: Vec<Box<dyn Hook>>,
167
168 #[clap(skip)]
170 pub command: Option<process::Command>,
171
172 #[clap(skip)]
174 #[debug(skip)]
175 pub post_hooks: Vec<Box<dyn Hook>>,
176
177 #[clap(skip)]
179 pub not_found_path: Option<PathBuf>,
180
181 #[clap(skip)]
183 #[debug(skip)]
184 request_handler: Option<RequestHandler>,
185}
186
187impl DevServer {
188 pub fn address(mut self, ip: IpAddr, port: u16) -> Self {
190 self.ip = ip;
191 self.port = port;
192
193 self
194 }
195
196 pub fn dist_dir(mut self, path: impl Into<PathBuf>) -> Self {
200 self.dist_dir = Some(path.into());
201 self
202 }
203
204 pub fn pre(mut self, command: impl Hook + 'static) -> Self {
206 self.pre_hooks.push(Box::new(command));
207 self
208 }
209
210 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 pub fn post(mut self, command: impl Hook + 'static) -> Self {
219 self.post_hooks.push(Box::new(command));
220 self
221 }
222
223 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 pub fn command(mut self, command: process::Command) -> Self {
234 self.command = Some(command);
235 self
236 }
237
238 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 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 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 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 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 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 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 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 pub fn start(mut self) -> Result<()> {
349 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 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 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
508pub 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}