file_serve/
lib.rs

1//! > An HTTP Static File Server
2//!
3//! `file-serve` focuses on augmenting development of your site.  It prioritizes
4//! small size and compile times over speed, scalability, or security.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! let path = std::env::current_dir().unwrap();
10//! let server = file_serve::Server::new(&path);
11//!
12//! println!("Serving {}", path.display());
13//! println!("See http://{}", server.addr());
14//! println!("Hit CTRL-C to stop");
15//!
16//! server.serve().unwrap();
17//! ```
18
19#![cfg_attr(docsrs, feature(doc_auto_cfg))]
20#![warn(clippy::print_stderr)]
21#![warn(clippy::print_stdout)]
22
23use std::{
24    str::FromStr,
25    sync::{RwLock, TryLockError},
26};
27
28/// Custom server settings
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct ServerBuilder {
31    source: std::path::PathBuf,
32    hostname: Option<String>,
33    port: Option<u16>,
34}
35
36impl ServerBuilder {
37    pub fn new(source: impl Into<std::path::PathBuf>) -> Self {
38        Self {
39            source: source.into(),
40            hostname: None,
41            port: None,
42        }
43    }
44
45    /// Override the hostname
46    pub fn hostname(&mut self, hostname: impl Into<String>) -> &mut Self {
47        self.hostname = Some(hostname.into());
48        self
49    }
50
51    /// Override the port
52    ///
53    /// By default, the first available port is selected.
54    pub fn port(&mut self, port: u16) -> &mut Self {
55        self.port = Some(port);
56        self
57    }
58
59    /// Create a server
60    ///
61    /// This is needed for accessing the dynamically assigned pot
62    pub fn build(&self) -> Server {
63        let source = self.source.clone();
64        let hostname = self.hostname.as_deref().unwrap_or("localhost");
65        let port = self
66            .port
67            .or_else(|| get_available_port(hostname))
68            // Just have `serve` error out
69            .unwrap_or(3000);
70
71        Server {
72            source,
73            addr: format!("{hostname}:{port}"),
74            server: RwLock::new(None),
75        }
76    }
77
78    /// Start the webserver
79    pub fn serve(&self) -> Result<(), Error> {
80        self.build().serve()
81    }
82}
83
84pub struct Server {
85    source: std::path::PathBuf,
86    addr: String,
87    server: RwLock<Option<tiny_http::Server>>,
88}
89
90impl Server {
91    /// Serve on first available port on localhost
92    pub fn new(source: impl Into<std::path::PathBuf>) -> Self {
93        ServerBuilder::new(source).build()
94    }
95
96    /// The location being served
97    pub fn source(&self) -> &std::path::Path {
98        self.source.as_path()
99    }
100
101    /// The address the server is available at
102    ///
103    /// This is useful for telling users how to access the served up files since the port is
104    /// dynamically assigned by default.
105    pub fn addr(&self) -> &str {
106        self.addr.as_str()
107    }
108
109    /// Whether the server was running at the instant the call happened
110    pub fn is_running(&self) -> bool {
111        matches!(self.server.read().as_deref(), Ok(Some(_)))
112    }
113
114    /// Start the webserver
115    pub fn serve(&self) -> Result<(), Error> {
116        match self.server.try_write().as_deref_mut() {
117            Ok(server @ None) => {
118                // attempts to create a server
119                *server = Some(tiny_http::Server::http(self.addr()).map_err(Error::new)?);
120            }
121            Ok(Some(_)) | Err(TryLockError::WouldBlock) => {
122                return Err(Error::new("the server is running"));
123            }
124            Err(error @ TryLockError::Poisoned(_)) => return Err(Error::new(error)),
125        }
126
127        {
128            let server = self.server.read().map_err(Error::new)?;
129            // unwrap is safe here
130            for request in server.as_ref().unwrap().incoming_requests() {
131                // handles the request
132                if let Err(e) = static_file_handler(self.source(), request) {
133                    log::error!("{e}");
134                }
135            }
136        }
137
138        *self.server.write().map_err(Error::new)? = None;
139
140        Ok(())
141    }
142
143    /// Closes the server gracefully
144    pub fn close(&self) {
145        if let Ok(Some(server)) = self.server.read().as_deref() {
146            server.unblock();
147        }
148    }
149}
150
151/// Serve Error
152#[derive(Debug)]
153pub struct Error {
154    message: String,
155}
156
157impl Error {
158    fn new(message: impl ToString) -> Self {
159        Self {
160            message: message.to_string(),
161        }
162    }
163}
164
165impl std::fmt::Display for Error {
166    fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        self.message.fmt(fmt)
168    }
169}
170
171impl std::error::Error for Error {}
172
173fn static_file_handler(dest: &std::path::Path, req: tiny_http::Request) -> Result<(), Error> {
174    // grab the requested path
175    let mut req_path = req.url().to_string();
176
177    // strip off any querystrings so path.is_file() matches and doesn't stick index.html on the end
178    // of the path (querystrings often used for cachebusting)
179    if let Some(position) = req_path.rfind('?') {
180        req_path.truncate(position);
181    }
182
183    // find the path of the file in the local system
184    // (this gets rid of the '/' in `p`, so the `join()` will not replace the path)
185    let path = dest.to_path_buf().join(&req_path[1..]);
186
187    let serve_path = if path.is_file() {
188        // try to point the serve path to `path` if it corresponds to a file
189        path
190    } else {
191        // try to point the serve path into a "index.html" file in the requested
192        // path
193        path.join("index.html")
194    };
195
196    // if the request points to a file and it exists, read and serve it
197    if serve_path.exists() {
198        let file = std::fs::File::open(&serve_path).map_err(Error::new)?;
199        let mut response = tiny_http::Response::from_file(file);
200        if let Some(mime) = mime_guess::MimeGuess::from_path(&serve_path).first_raw() {
201            let content_type = format!("Content-Type:{mime}");
202            let content_type =
203                tiny_http::Header::from_str(&content_type).expect("formatted correctly");
204            response.add_header(content_type);
205        }
206        req.respond(response).map_err(Error::new)?;
207    } else {
208        // write a simple body for the 404 page
209        req.respond(
210            tiny_http::Response::from_string("<h1> <center> 404: Page not found </center> </h1>")
211                .with_status_code(404)
212                .with_header(
213                    tiny_http::Header::from_str("Content-Type: text/html")
214                        .expect("formatted correctly"),
215                ),
216        )
217        .map_err(Error::new)?;
218    }
219
220    Ok(())
221}
222
223fn get_available_port(host: &str) -> Option<u16> {
224    // Start after "well-known" ports (0–1023) as they require superuser
225    // privileges on UNIX-like operating systems.
226    (1024..9000).find(|port| port_is_available(host, *port))
227}
228
229fn port_is_available(host: &str, port: u16) -> bool {
230    std::net::TcpListener::bind((host, port)).is_ok()
231}