Skip to main content

mini_static/
server.rs

1use std::convert::Infallible;
2use std::fs;
3use std::io::Write;
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant, SystemTime};
8
9use bytes::Bytes;
10use http_body_util::Full;
11use hyper::body::Incoming;
12use hyper::header::{self, HeaderName, HeaderValue};
13use hyper::http::response::Builder;
14use hyper::server::conn::http1;
15use hyper::service::service_fn;
16use hyper::{HeaderMap, Method, Request, Response, StatusCode};
17use hyper_util::rt::{TokioIo, TokioTimer};
18use tokio::fs::File;
19use tokio::net::{TcpListener, TcpStream};
20use tokio::sync::{OwnedSemaphorePermit, Semaphore};
21use tokio::time::timeout;
22
23use crate::error::StaticError;
24use crate::handler::{FileBody, ResponseBody};
25use crate::reload::{self, SseBody};
26use crate::resolve;
27use crate::resolve::HiddenFiles;
28use crate::spa::{self, SpaTransition};
29use crate::watcher::{start_watching, Broadcaster};
30
31const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
32const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
33
34/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
35const DEFAULT_MAX_CONNECTIONS: usize = 1024;
36
37/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
38/// can be exercised against a listener that fails on demand, without needing to provoke
39/// real OS-level accept errors (e.g. EMFILE) in tests.
40trait TcpAccept {
41    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
42}
43
44impl TcpAccept for TcpListener {
45    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
46        TcpListener::accept(self).await
47    }
48}
49
50/// Accept a connection and reserve it a connection-limit permit.
51///
52/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
53/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
54/// so a sustained failure — the process being out of file descriptors, say — degrades
55/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
56///
57/// Returns `None` only if the semaphore itself has been closed (never happens in normal
58/// operation, since nothing ever calls `close()` on it — handled so a caller can still
59/// fail safely rather than panic).
60async fn accept_and_permit<L: TcpAccept>(
61    listener: &L,
62    backoff: &mut Duration,
63    semaphore: &Arc<Semaphore>,
64    log: Option<&Server>,
65) -> Option<(TcpStream, OwnedSemaphorePermit)> {
66    loop {
67        let stream = match listener.accept().await {
68            Ok((stream, _)) => {
69                *backoff = ACCEPT_BACKOFF_INITIAL;
70                stream
71            }
72            Err(error) => {
73                // Logged rather than swallowed: sustained accept failure (out of file
74                // descriptors, most often) degrades into an ever-slower retry loop that
75                // is otherwise indistinguishable from an idle server.
76                if let Some(server) = log {
77                    server.log(format_args!(
78                        "accept error: {error}; retrying in {backoff:?}"
79                    ));
80                }
81                tokio::time::sleep(*backoff).await;
82                *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
83                continue;
84            }
85        };
86        return semaphore
87            .clone()
88            .acquire_owned()
89            .await
90            .ok()
91            .map(|permit| (stream, permit));
92    }
93}
94
95/// A predicate deciding whether a resolved file path should get an immutable cache
96/// policy; see [`Server::with_immutable_assets`].
97type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
98
99/// A static file server for serving files securely from a root directory.
100///
101/// `Server` canonicalizes the root directory once at creation time and uses the
102/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
103///
104/// # Security
105///
106/// The server protects against:
107/// - Path traversal attacks (e.g., `../../etc/passwd`)
108/// - Accessing files outside the root via symlinks
109/// - Disclosing filesystem structure (traversal and missing files both return 404)
110///
111/// # Cloning
112///
113/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
114/// predicate closure. Multiple clones can be used concurrently in async tasks without
115/// synchronization overhead.
116///
117/// # Example
118///
119/// ```no_run
120/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
121/// use mini_static::Server;
122/// use std::path::Path;
123/// use std::time::Duration;
124///
125/// let server = Server::new(Path::new("./public"))?;
126/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
127/// println!("Server running on port {}", port);
128/// # Ok(())
129/// # }
130/// ```
131/// Headers this server derives from the response it is building, and therefore refuses
132/// as fixed values via [`Server::with_response_header`]. A fixed value would be either
133/// silently overridden or silently duplicated depending on the response — and a wrong
134/// `Content-Length` or `ETag` is a correctness bug, not a policy choice.
135const SERVER_COMPUTED_HEADERS: [HeaderName; 13] = [
136    header::CONTENT_LENGTH,
137    header::CONTENT_TYPE,
138    header::CONTENT_ENCODING,
139    header::CONTENT_RANGE,
140    header::ETAG,
141    header::CACHE_CONTROL,
142    header::VARY,
143    header::ACCEPT_RANGES,
144    header::ALLOW,
145    header::LOCATION,
146    header::CONNECTION,
147    header::TRANSFER_ENCODING,
148    header::X_CONTENT_TYPE_OPTIONS,
149];
150
151/// Where request and connection log lines go.
152///
153/// A `Server` is cloned per request, so the sink is shared rather than duplicated. The
154/// mutex serializes writes from concurrent connections — without it, two responses
155/// finishing at once would interleave mid-line and produce log entries belonging to
156/// neither request.
157type RequestLog = Arc<Mutex<Box<dyn Write + Send>>>;
158
159#[derive(Clone)]
160pub struct Server {
161    root_canon: PathBuf,
162    max_connections: usize,
163    live_reload: bool,
164    broadcaster: Option<Broadcaster>,
165    spa_mode: bool,
166    spa_root: Option<String>,
167    spa_transition: SpaTransition,
168    not_found_page: Option<PathBuf>,
169    hidden_files: HiddenFiles,
170    request_log: Option<RequestLog>,
171    extra_headers: Arc<Vec<(HeaderName, HeaderValue)>>,
172    immutable_predicate: Option<ImmutablePredicate>,
173}
174
175impl Server {
176    /// Create a new server with the given root directory.
177    ///
178    /// Canonicalizes the root once at startup. All subsequent requests use the
179    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
180    ///
181    /// # Errors
182    ///
183    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
184    /// no read permissions).
185    pub fn new(root: &Path) -> Result<Self, StaticError> {
186        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
187        Ok(Server {
188            root_canon,
189            max_connections: DEFAULT_MAX_CONNECTIONS,
190            live_reload: false,
191            broadcaster: None,
192            spa_mode: false,
193            spa_root: None,
194            spa_transition: SpaTransition::default(),
195            not_found_page: None,
196            hidden_files: HiddenFiles::Deny,
197            request_log: None,
198            extra_headers: Arc::new(Vec::new()),
199            immutable_predicate: None,
200        })
201    }
202
203    /// Set the maximum number of connections served concurrently (default 1024).
204    ///
205    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
206    /// new ones — without pausing the accept loop, a client that opens a connection and
207    /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
208    /// otherwise be used, in enough parallel copies, to exhaust the process's file
209    /// descriptors or memory with no bound at all.
210    pub fn with_max_connections(mut self, max: usize) -> Self {
211        self.max_connections = max;
212        self
213    }
214
215    /// Enable live-reload for this server (disabled by default).
216    ///
217    /// Once enabled, the `run*` methods start a background watcher (mtime polling,
218    /// bounded 500ms interval — see [`crate::start_watching`]) the first time the server
219    /// actually starts accepting connections. It watches the served root; when a build
220    /// pipeline is configured it watches that pipeline's source folders instead, because
221    /// the pipeline broadcasts its own outputs once they are written. Then it will:
222    ///
223    /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
224    ///   change event (with [`crate::ChangeType`]) whenever a served file is added,
225    ///   modified, or removed;
226    /// - inject a small `<script>` into every served `text/html` response that connects
227    ///   to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
228    ///   changes) — no manual client wiring required.
229    ///
230    /// This is meant for local development, not production: leave it disabled (the
231    /// default) for any server serving real traffic. A typical call site gates it behind
232    /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
233    /// injected script.
234    ///
235    /// # Example
236    ///
237    /// ```no_run
238    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
239    /// use mini_static::Server;
240    /// use std::path::Path;
241    ///
242    /// let server = Server::new(Path::new("./public"))?;
243    /// #[cfg(debug_assertions)]
244    /// let server = server.with_live_reload();
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub fn with_live_reload(mut self) -> Self {
249        self.live_reload = true;
250        self
251    }
252
253    /// Enable spa-mode navigation for this server, swapping `document.body` on
254    /// each navigation (disabled by default).
255    ///
256    /// Once enabled, every served `text/html` response gets a small `<script>`
257    /// injected (see [`Server::with_spa_root`] for what it does) that treats
258    /// `document.body` as the swap target. Calling this after
259    /// [`Server::with_spa_root`] does not clear a previously configured root
260    /// selector — the two methods set independent fields, so
261    /// `.with_spa_root(sel).with_spa_mode()` and
262    /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
263    /// root `sel`. Use this one alone when there's no persistent chrome to
264    /// preserve across navigations.
265    ///
266    /// # Example
267    ///
268    /// ```no_run
269    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
270    /// use mini_static::Server;
271    /// use std::path::Path;
272    ///
273    /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
274    /// # Ok(())
275    /// # }
276    /// ```
277    pub fn with_spa_mode(mut self) -> Self {
278        self.spa_mode = true;
279        self
280    }
281
282    /// Enable spa-mode navigation for this server, swapping only the element
283    /// matched by the CSS `selector` on each navigation (disabled by default;
284    /// also enables spa-mode the same as [`Server::with_spa_mode`]).
285    ///
286    /// Once enabled, every served `text/html` response gets a small `<script>`
287    /// injected that intercepts left-clicks on same-origin `<a href>`
288    /// elements (skipping links with a non-`_self` `target`, a `download`
289    /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
290    /// hash-only href) and, instead of a normal navigation:
291    ///
292    /// - fetches the target URL;
293    /// - on a non-OK or non-`text/html` response (or a fetch error), falls
294    ///   back to a real `location.href` navigation — spa-mode never renders a
295    ///   broken page;
296    /// - otherwise replaces the matched element's `innerHTML` with the
297    ///   corresponding content from the fetched document, updates the page
298    ///   title, and pushes the new URL via `history.pushState`, animating the
299    ///   swap with `document.startViewTransition()` where supported;
300    /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
301    ///   every client-side navigation, so page scripts can re-run any
302    ///   per-page initialization that would otherwise only execute once
303    ///   (content swapped in via `innerHTML` never executes its own
304    ///   `<script>` tags);
305    /// - handles browser back/forward by re-fetching and swapping to the new
306    ///   `location.href`.
307    ///
308    /// `selector` is matched against both the current page and the fetched
309    /// page; a link click where the selector matches neither falls back to a
310    /// real navigation, same as a fetch failure. Choose a `selector` that
311    /// wraps only the content that varies between pages, leaving persistent
312    /// chrome (nav/header/footer) outside it so it survives navigation
313    /// untouched.
314    ///
315    /// This is meant to be usable in production, not just local development
316    /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
317    /// doesn't intercept, or on a browser without JS or View Transitions
318    /// support, still works as a normal navigation.
319    ///
320    /// # Example
321    ///
322    /// ```no_run
323    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
324    /// use mini_static::Server;
325    /// use std::path::Path;
326    ///
327    /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
328    /// # Ok(())
329    /// # }
330    /// ```
331    pub fn with_spa_root(mut self, selector: &str) -> Self {
332        self.spa_mode = true;
333        self.spa_root = Some(selector.to_string());
334        self
335    }
336
337    /// Set how spa-mode animates the swap between pages (also enables
338    /// spa-mode the same as [`Server::with_spa_mode`]; default
339    /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
340    ///
341    /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
342    /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
343    /// and [`SlideOptions`] for what each variant does and how to
344    /// configure the slide's duration, direction, and easing.
345    ///
346    /// # Example
347    ///
348    /// ```no_run
349    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
350    /// use mini_static::{Server, SlideOptions, SpaTransition};
351    /// use std::path::Path;
352    ///
353    /// let server = Server::new(Path::new("./public"))?
354    ///     .with_spa_root("#app")
355    ///     .with_spa_transition(SpaTransition::Slide(
356    ///         SlideOptions::default().duration_ms(500),
357    ///     ));
358    /// # Ok(())
359    /// # }
360    /// ```
361    pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
362        self.spa_mode = true;
363        self.spa_transition = transition;
364        self
365    }
366
367    /// Serves `path` as the body of every `404`, instead of the default plain-text
368    /// `not found`.
369    ///
370    /// `path` is resolved relative to the served root and must exist when this is
371    /// called: a missing 404 page is a deployment mistake, and finding out on the first
372    /// broken link — the one moment the page exists to handle — is too late. It is read
373    /// from disk per response rather than cached, so editing it during a live-reload
374    /// session takes effect without a restart.
375    ///
376    /// The response keeps its `404` status. Serving a custom page with `200` is a soft
377    /// 404: search engines index it, and monitoring stops seeing the failures. It also
378    /// carries `Cache-Control: no-store`, so a client never holds this page as though it
379    /// were the resource that was actually requested.
380    ///
381    /// Nothing about the failed request reaches the page — no path, no reason. A
382    /// traversal attempt and an ordinary miss are deliberately indistinguishable
383    /// (`StaticError::user_message`), and templating the requested path into the
384    /// response would undo that and hand back a reflected-content vector besides.
385    ///
386    /// # Errors
387    ///
388    /// Returns `Err(StaticError::Io)` if `path` cannot be canonicalized (typically:
389    /// it does not exist), or `Err(StaticError::Traversal)` if it lies outside the
390    /// served root.
391    ///
392    /// # Example
393    ///
394    /// ```no_run
395    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
396    /// use mini_static::Server;
397    /// use std::path::Path;
398    ///
399    /// let server = Server::new(Path::new("./public"))?
400    ///     .with_not_found_page(Path::new("404.html"))?;
401    /// # Ok(())
402    /// # }
403    /// ```
404    pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
405        let joined = self.root_canon.join(path);
406        let canon = joined.canonicalize().map_err(StaticError::Io)?;
407
408        if !canon.starts_with(&self.root_canon) {
409            return Err(StaticError::Traversal(format!(
410                "404 page {} lies outside the served root {}",
411                canon.display(),
412                self.root_canon.display()
413            )));
414        }
415
416        self.not_found_page = Some(canon);
417        Ok(self)
418    }
419
420    /// Serve dot-prefixed paths (`.env`, `.git/config`) instead of answering them as a
421    /// miss.
422    ///
423    /// Hidden files are denied by default. A served root is routinely a build output
424    /// directory, a repository working copy, or a folder someone dropped a `.env` into,
425    /// and the traversal guard cannot help: those files are legitimately *inside* the
426    /// root, so anyone who guesses the name gets them. The default trades a rarely-wanted
427    /// capability for not leaking credentials by accident.
428    ///
429    /// `/.well-known/` is served either way — it is where the web puts resources that
430    /// are meant to be fetched (ACME challenges for certificate issuance,
431    /// `security.txt`), and denying it would break certificate renewal. The exception is
432    /// the first segment only: `/.well-known/.hidden` is still denied.
433    ///
434    /// Call this when the served root is a curated directory whose dotfiles are content
435    /// — a static site that publishes a `.htaccess` for a downstream server, say.
436    ///
437    /// # Example
438    ///
439    /// ```no_run
440    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
441    /// use mini_static::Server;
442    /// use std::path::Path;
443    ///
444    /// let server = Server::new(Path::new("./public"))?.with_hidden_files();
445    /// # Ok(())
446    /// # }
447    /// ```
448    pub fn with_hidden_files(mut self) -> Self {
449        self.hidden_files = HiddenFiles::Serve;
450        self
451    }
452
453    /// Log one line per request to stderr, plus connection-level errors.
454    ///
455    /// Off by default: a library that writes to a process's stderr uninvited is a
456    /// surprise, and an embedder with its own logging wants the lines somewhere else.
457    /// See [`Server::with_request_logging_to`] to choose the destination.
458    ///
459    /// # Example
460    ///
461    /// ```no_run
462    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
463    /// use mini_static::Server;
464    /// use std::path::Path;
465    ///
466    /// let server = Server::new(Path::new("./public"))?.with_request_logging();
467    /// # Ok(())
468    /// # }
469    /// ```
470    /// Send `name: value` on every response.
471    ///
472    /// Call repeatedly to add several. Intended for the policy headers a static site
473    /// wants applied uniformly — `Strict-Transport-Security`, `Content-Security-Policy`,
474    /// `Referrer-Policy` — which this crate has no business choosing on an embedder's
475    /// behalf but every business making expressible.
476    ///
477    /// Both name and value are validated here, at configuration time, so a malformed
478    /// header fails when the server is built rather than on a request months later.
479    ///
480    /// # Errors
481    ///
482    /// - `StaticError::Config` if `name` or `value` is not a valid HTTP header.
483    /// - `StaticError::Config` if `name` is one this server computes per response
484    ///   (`Content-Length`, `Content-Type`, `Content-Encoding`, `Content-Range`, `ETag`,
485    ///   `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`, `Connection`,
486    ///   `Transfer-Encoding`, `X-Content-Type-Options`). A fixed value would either be
487    ///   silently overridden or silently duplicated depending on the response — a
488    ///   configuration mistake worth surfacing at startup rather than a behavior worth
489    ///   supporting. Use [`Server::with_immutable_assets`] for cache policy.
490    ///
491    /// # Example
492    ///
493    /// ```no_run
494    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
495    /// use mini_static::Server;
496    /// use std::path::Path;
497    ///
498    /// let server = Server::new(Path::new("./public"))?
499    ///     .with_response_header("Strict-Transport-Security", "max-age=63072000")?
500    ///     .with_response_header("Referrer-Policy", "strict-origin-when-cross-origin")?;
501    /// # Ok(())
502    /// # }
503    /// ```
504    pub fn with_response_header(mut self, name: &str, value: &str) -> Result<Self, StaticError> {
505        let name = HeaderName::from_bytes(name.as_bytes())
506            .map_err(|_| StaticError::Config(format!("invalid header name: {name}")))?;
507        let value = HeaderValue::from_str(value).map_err(|_| {
508            StaticError::Config(format!("invalid value for header {name}: {value}"))
509        })?;
510
511        if SERVER_COMPUTED_HEADERS.contains(&name) {
512            return Err(StaticError::Config(format!(
513                "{name} is computed per response and cannot be set as a fixed header"
514            )));
515        }
516
517        Arc::make_mut(&mut self.extra_headers).push((name, value));
518        Ok(self)
519    }
520
521    pub fn with_request_logging(self) -> Self {
522        self.with_request_logging_to(Box::new(std::io::stderr()))
523    }
524
525    /// Log one line per request to `writer`, plus connection-level errors.
526    ///
527    /// Each served request writes one line:
528    ///
529    /// ```text
530    /// GET /index.html 200 512 0.421ms
531    /// ```
532    ///
533    /// — method, requested path exactly as received, status, response body bytes (`-`
534    /// when the length isn't known, as on a live-reload SSE stream), and how long
535    /// handling took. Connection-level failures — a malformed request, a client
536    /// vanishing mid-response — write `connection error: <cause>`; before this they were
537    /// discarded entirely, so a server that was refusing every request looked exactly
538    /// like one nobody was talking to.
539    ///
540    /// The path is logged as received, *not* decoded: it is attacker-controlled input,
541    /// and a log reader deserves to see the bytes that actually arrived rather than a
542    /// normalized rendering of them.
543    ///
544    /// Writes are serialized across connections and write errors are ignored — a
545    /// failing log sink must not take down request serving.
546    ///
547    /// # Example
548    ///
549    /// ```no_run
550    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
551    /// use mini_static::Server;
552    /// use std::fs::File;
553    /// use std::path::Path;
554    ///
555    /// let log = File::create("access.log")?;
556    /// let server = Server::new(Path::new("./public"))?.with_request_logging_to(Box::new(log));
557    /// # Ok(())
558    /// # }
559    /// ```
560    pub fn with_request_logging_to(mut self, writer: Box<dyn Write + Send>) -> Self {
561        self.request_log = Some(Arc::new(Mutex::new(writer)));
562        self
563    }
564
565    /// Start a response carrying the baseline security header plus every header the
566    /// embedder configured via [`Server::with_response_header`].
567    ///
568    /// Every response this server builds for a request goes through here, so a
569    /// configured policy header cannot be missing from one status and present on
570    /// another. The sole exception is the `400` that `finish` falls back to when a
571    /// builder produced an invalid header — no `Server` is in scope there, and a
572    /// response that exists only because header construction already failed is the wrong
573    /// place to add more headers.
574    fn response(&self, status: StatusCode) -> Builder {
575        let mut builder = response(status);
576        for (name, value) in self.extra_headers.iter() {
577            builder = builder.header(name, value);
578        }
579        builder
580    }
581
582    /// Write `line` to the configured log sink, if there is one.
583    ///
584    /// A poisoned mutex (some earlier writer panicked mid-write) and a failed write are
585    /// both ignored: neither is a reason to fail a request that was otherwise served
586    /// correctly.
587    fn log(&self, line: std::fmt::Arguments<'_>) {
588        let Some(log) = &self.request_log else {
589            return;
590        };
591        if let Ok(mut sink) = log.lock() {
592            let _ = writeln!(sink, "{line}");
593            let _ = sink.flush();
594        }
595    }
596
597    /// Serve files matching `predicate` with a long-lived, immutable cache policy
598    /// instead of the default `Cache-Control: no-cache`.
599    ///
600    /// `predicate` is evaluated against each resolved file's path; a match sends
601    /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
602    /// responses. This is correct only for fingerprinted assets (e.g.
603    /// `main.a1b2c3.js`) where a content change always produces a new filename —
604    /// caching a mutable filename indefinitely would serve stale content to every
605    /// client that already has it cached.
606    ///
607    /// # Example
608    ///
609    /// ```no_run
610    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
611    /// use mini_static::Server;
612    /// use std::path::Path;
613    ///
614    /// let server = Server::new(Path::new("./public"))?
615    ///     .with_immutable_assets(|path| {
616    ///         path.file_name()
617    ///             .and_then(|name| name.to_str())
618    ///             .is_some_and(|name| name.contains(".fingerprint."))
619    ///     });
620    /// # Ok(())
621    /// # }
622    /// ```
623    pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
624    where
625        F: Fn(&Path) -> bool + Send + Sync + 'static,
626    {
627        self.immutable_predicate = Some(Arc::new(predicate));
628        self
629    }
630
631    /// The `Cache-Control` header value for a resolved file path: the immutable policy
632    /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
633    fn cache_control_for(&self, path: &Path) -> &'static str {
634        match &self.immutable_predicate {
635            Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
636            _ => "no-cache",
637        }
638    }
639
640    /// The directories the live-reload watcher polls: the served root, and only that.
641    ///
642    /// Until 0.29.0 this also returned the build pipeline's source folders, and the
643    /// served root was excluded whenever a pipeline was configured — watching the output
644    /// dir would have fed each pipeline its own writes back into its own trigger. The
645    /// pipeline now lives in `mini-build`, in a separate process, so nothing this server
646    /// watches is written by this server and the exclusion has nothing left to prevent.
647    fn watch_targets(&self) -> Vec<PathBuf> {
648        vec![self.root_canon.clone()]
649    }
650
651    /// Resolve a request path under the server's root.
652    ///
653    /// This is a lower-level API for resolving paths without generating HTTP responses.
654    /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
655    ///
656    /// # Returns
657    ///
658    /// - `Ok(PathBuf)` if the path resolves to a file within root.
659    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
660    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
661        resolve::resolve_with_policy(&self.root_canon, request_path, self.hidden_files)
662    }
663
664    /// Builds the `404` response: the configured page when there is one and it can be
665    /// read, and `fallback` as plain text otherwise.
666    ///
667    /// `fallback` is the caller's already-sanitized message (see
668    /// `StaticError::user_message`) — never the requested path, so an ordinary miss and
669    /// a rejected traversal stay indistinguishable to whoever is probing.
670    ///
671    /// A page that vanished after `with_not_found_page` validated it degrades to that
672    /// text rather than to a `500`: the request was still a miss, and answering a
673    /// missing page with the wrong status would be a second bug wearing the first one's
674    /// clothes.
675    async fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
676        let builder = self
677            .response(StatusCode::NOT_FOUND)
678            .header("Cache-Control", "no-store");
679
680        let Some(page) = &self.not_found_page else {
681            return text(builder, format!("{fallback}\n"));
682        };
683        let Ok(body) = tokio::fs::read(page).await else {
684            return text(builder, format!("{fallback}\n"));
685        };
686
687        text(
688            builder.header("Content-Type", "text/html; charset=utf-8"),
689            body,
690        )
691    }
692
693    /// Run the server on a specific address with a configurable header-read timeout.
694    ///
695    /// Spawns the server in a background Tokio task and returns immediately with the
696    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
697    /// stop accepting new connections and wait for in-flight connections to finish.
698    /// Dropping the handle instead leaves the server running for the life of the process.
699    ///
700    /// # Header-Read Timeout
701    ///
702    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
703    /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
704    /// timeout applies only to the header-read phase — once a complete header block has been
705    /// read, the connection is handed off with no further time bound, so long-lived response
706    /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
707    /// off mid-stream.
708    ///
709    /// # Precompressed Sidecars
710    ///
711    /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
712    /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
713    /// served instead with a matching `Content-Encoding`. Every file response carries
714    /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
715    /// differently-capable client.
716    ///
717    /// # Arguments
718    ///
719    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
720    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
721    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
722    ///
723    /// # Returns
724    ///
725    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
726    /// - `Err(StaticError::Io)` if binding to the socket fails.
727    /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
728    ///   is not found on `PATH`. Checked before the listener binds: a deployment whose
729    ///   configured pipeline can never run should fail visibly at boot, not be discovered
730    ///   later as a missing/stale asset.
731    pub async fn run_on(
732        &self,
733        addr: SocketAddr,
734        header_timeout: Duration,
735    ) -> Result<(u16, ServerHandle), StaticError> {
736        let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
737        let port = listener.local_addr().map_err(StaticError::Io)?.port();
738
739        let mut server = self.clone();
740        if server.live_reload {
741            // The served root is the only watch target now that nothing writes into it.
742            // While the build pipeline lived here the output dir was deliberately never
743            // watched, because watching it fed each pipeline its own writes back into its
744            // trigger; with the builder in a separate process that loop cannot happen.
745            let broadcaster = Broadcaster::new();
746            for dir in server.watch_targets() {
747                start_watching(Arc::new(dir), broadcaster.clone());
748            }
749            server.broadcaster = Some(broadcaster);
750        }
751
752        let semaphore = Arc::new(Semaphore::new(server.max_connections));
753        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
754
755        let accept_task = tokio::spawn(async move {
756            let mut backoff = ACCEPT_BACKOFF_INITIAL;
757            let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
758            let mut shutdown_pin = std::pin::pin!(shutdown_rx);
759            let mut shutting_down = false;
760
761            loop {
762                if !shutting_down {
763                    // The accept-and-permit step and the shutdown signal race in a single
764                    // `select!` so shutdown can preempt a pending accept or a permit wait
765                    // cleanly, at any point — not just between loop iterations.
766                    tokio::select! {
767                        accepted = accept_and_permit(&listener, &mut backoff, &semaphore, Some(&server)) => {
768                            match accepted {
769                                Some((stream, permit)) => {
770                                    let server = server.clone();
771                                    join_set.spawn(async move {
772                                        let _permit = permit;
773                                        serve_connection(stream, server, header_timeout).await;
774                                    });
775                                }
776                                None => shutting_down = true,
777                            }
778                        }
779                        _ = shutdown_pin.as_mut() => {
780                            shutting_down = true;
781                        }
782                    }
783                    continue;
784                }
785
786                // Stop accepting; drain already-spawned connections before returning.
787                match join_set.join_next().await {
788                    Some(_) => continue,
789                    None => break,
790                }
791            }
792        });
793
794        Ok((
795            port,
796            ServerHandle {
797                shutdown_tx: Some(shutdown_tx),
798                accept_task,
799            },
800        ))
801    }
802
803    /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
804    ///
805    /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
806    /// sidecar semantics, and for what the returned [`ServerHandle`] does.
807    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
808        self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
809            .await
810    }
811
812    /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
813    ///
814    /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
815    /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
816    /// semantics, and for what the returned [`ServerHandle`] does.
817    pub async fn run_all(
818        &self,
819        port: u16,
820        header_timeout: Duration,
821    ) -> Result<(u16, ServerHandle), StaticError> {
822        self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
823            .await
824    }
825
826    /// Run the server on loopback with the default 30-second header-read timeout.
827    ///
828    /// The recommended entry point for tests and lightweight services that don't need a
829    /// custom timeout. Thin wrapper around [`Server::run`].
830    ///
831    /// # Example
832    ///
833    /// ```no_run
834    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
835    /// use mini_static::Server;
836    /// use std::path::Path;
837    ///
838    /// let server = Server::new(Path::new("./public"))?;
839    /// let (port, handle) = server.run_ephemeral().await?;
840    /// println!("Server ready on http://127.0.0.1:{}", port);
841    /// handle.shutdown().await;
842    /// # Ok(())
843    /// # }
844    /// ```
845    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
846        self.run(DEFAULT_HEADER_TIMEOUT).await
847    }
848
849    /// Produce the HTTP response for a request, streaming file bodies to the client.
850    ///
851    /// This is the crate's single request-handling path: the `run*` accept loop calls it,
852    /// and so should any async server embedding `mini-static` as a fallback route (e.g.
853    /// `mini-unified`).
854    ///
855    /// Filesystem metadata work (path resolution, `open`, `stat`) runs *inline* on the
856    /// calling task, deliberately. Until 0.30.0 it was dispatched to Tokio's blocking
857    /// pool so a slow filesystem could not stall co-scheduled tasks — measured under
858    /// load, that dispatch cost roughly three times the syscalls it sheltered, and a
859    /// one-worker server burned nearly four cores on pool handoff. On the local-disk
860    /// deployments this crate targets these calls are single-digit microseconds; an
861    /// embedder serving from a filesystem with unbounded latency (a network mount)
862    /// should use a multi-threaded runtime, which bounds the blast radius of a stall
863    /// to one worker.
864    ///
865    /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
866    /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
867    /// response regardless of file size.
868    ///
869    /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
870    /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
871    /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
872    /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
873    /// response never discloses whether a path exists outside the root.
874    pub async fn handle_request(
875        &self,
876        method: &Method,
877        request_path: &str,
878        headers: &HeaderMap,
879    ) -> Response<ResponseBody> {
880        if method != Method::GET && method != Method::HEAD {
881            return text(
882                self.response(StatusCode::METHOD_NOT_ALLOWED)
883                    .header("Allow", "GET, HEAD"),
884                "method not allowed\n",
885            );
886        }
887
888        // Live-reload SSE stream — only reachable when `with_live_reload()` was called
889        // and the server was started via a `run*` method (those are the only paths that
890        // populate `broadcaster`).
891        if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
892            if let Some(broadcaster) = &self.broadcaster {
893                return finish(
894                    self.response(StatusCode::OK)
895                        .header("Content-Type", "text/event-stream")
896                        .header("Cache-Control", "no-cache")
897                        .header("Connection", "keep-alive")
898                        .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
899                );
900            }
901        }
902
903        // Inline on purpose — see this function's doc comment for the measured case
904        // against the old `spawn_blocking` dispatch. One call opens the file and proves
905        // containment on the opened fd, so there is no separate open to fail later and
906        // no window between the check and the handle that gets served.
907        let resolved =
908            match resolve::open_with_policy(&self.root_canon, request_path, self.hidden_files) {
909                Err(e) => return self.not_found_response(e.user_message()).await,
910                Ok(resolved) => resolved,
911            };
912        let (std_file, metadata, path) = (resolved.file, resolved.metadata, resolved.path);
913
914        // A directory served via its `index.html` needs a trailing slash to establish the
915        // correct base for the page's relative links. Compare against the *decoded*
916        // request path so a percent-encoded explicit request for index.html (e.g.
917        // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
918        // still-encoded, broken Location.
919        let decoded_request_path = resolve::decode_request_path(request_path);
920        if path.file_name().is_some_and(|name| name == "index.html")
921            && !decoded_request_path.ends_with('/')
922            && !decoded_request_path.ends_with("index.html")
923        {
924            // `location` is built from the (attacker-controlled) request path; `finish()`
925            // degrades to 400 instead of panicking if it ever contains bytes invalid in a
926            // header value.
927            let location = format!("{}/", request_path.trim_end_matches('/'));
928            return text(
929                self.response(StatusCode::MOVED_PERMANENTLY)
930                    .header("Location", location),
931                "moved\n",
932            );
933        }
934
935        let content_type = mime_type_for_path(&path);
936        // Live-reload and spa-mode HTML injection both need the original, uncompressed
937        // bytes to splice their script into — never substitute a precompressed sidecar on
938        // this path. `broadcaster` is only `Some` when live-reload is enabled (see
939        // `Server::with_live_reload`); `spa_mode` is independent of it (see
940        // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
941        // injection.
942        // Injection reads the whole file into memory, so it is also gated on size. Every
943        // decision keyed off `html_injection` — the sidecar skip below, the range skip,
944        // the full read itself — inherits the cap from this one boolean, so an over-cap
945        // page takes the ordinary streamed path with no second decision point.
946        let wants_injection =
947            (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
948        let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;
949
950        if wants_injection && !html_injection {
951            self.log(format_args!(
952                "html injection skipped for {request_path}: {} bytes exceeds the \
953                 {MAX_INJECTABLE_HTML_BYTES}-byte limit; serving unmodified",
954                metadata.len(),
955            ));
956        }
957
958        let range_header = header_str(headers, "range");
959        let if_range_header = header_str(headers, "if-range");
960
961        let accept_encoding = header_str(headers, "accept-encoding");
962        // Skip precompressed sidecars when Range is requested (serve original file instead).
963        let sidecar = if html_injection || range_header.is_some() {
964            None
965        } else {
966            select_precompressed_sidecar(&path, accept_encoding)
967        };
968        let (mut std_file, metadata, content_encoding) = match sidecar {
969            Some((sidecar_file, sidecar_metadata, encoding)) => {
970                (sidecar_file, sidecar_metadata, Some(encoding))
971            }
972            None => (std_file, metadata, None),
973        };
974        // The handle stays synchronous until a body actually streams: every whole-file
975        // read below (HTML injection, small bodies) is cheaper inline than as a
976        // blocking-pool round trip, and only `FileBody` needs an async `File`.
977
978        // HTML injection is skipped for a served precompressed sidecar (already final
979        // bytes from a build step) — see `html_injection`'s definition above.
980        let etag = generate_etag(&metadata);
981        let cache_control = self.cache_control_for(&path);
982
983        if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
984            return finish(
985                self.response(StatusCode::NOT_MODIFIED)
986                    .header("Cache-Control", cache_control)
987                    .header("Vary", "Accept-Encoding")
988                    .header("ETag", etag)
989                    .header("Accept-Ranges", "bytes")
990                    .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
991            );
992        }
993
994        // `Some` when the served representation differs from the file's raw bytes and had
995        // to be built in memory; `None` means stream the open file as-is. Computed before
996        // the HEAD check below because RFC 9110 requires a HEAD response's headers —
997        // `Content-Length` included — to match what a GET would send, even though the body
998        // itself is dropped.
999        let transformed: Option<Bytes> = if html_injection {
1000            let mut html = Vec::with_capacity(metadata.len() as usize);
1001            use std::io::Read as _;
1002            if std_file.read_to_end(&mut html).is_err() {
1003                return internal_error_response();
1004            }
1005            if self.broadcaster.is_some() {
1006                reload::inject_reload_script(&mut html);
1007            }
1008            if self.spa_mode {
1009                spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
1010            }
1011            Some(Bytes::from(html))
1012        } else {
1013            None
1014        };
1015
1016        let file_size = transformed
1017            .as_ref()
1018            .map_or(metadata.len(), |bytes| bytes.len() as u64);
1019
1020        // Handle Range requests.
1021        let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1022        let range_check = if let Some(outcome) = &range_outcome {
1023            match outcome {
1024                RangeOutcome::Satisfiable(start, end) => {
1025                    // If-Range validation: stale If-Range ignores Range, serves full 200.
1026                    if let Some(if_range) = if_range_header {
1027                        if !if_range_valid(if_range, &etag) {
1028                            RangeCheck::IgnoreRange
1029                        } else {
1030                            RangeCheck::Satisfiable(*start, *end)
1031                        }
1032                    } else {
1033                        RangeCheck::Satisfiable(*start, *end)
1034                    }
1035                }
1036                RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1037                RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1038                RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1039            }
1040        } else {
1041            RangeCheck::IgnoreRange
1042        };
1043
1044        match &range_check {
1045            RangeCheck::Unsatisfiable => {
1046                return finish(
1047                    Response::builder()
1048                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
1049                        .header("Content-Range", format!("bytes */{}", file_size))
1050                        .header("Accept-Ranges", "bytes")
1051                        .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1052                );
1053            }
1054            RangeCheck::Satisfiable(start, end) => {
1055                let range_len = end - start + 1;
1056
1057                // Seek to start position; if sidecar, we already skipped it above.
1058                if transformed.is_none()
1059                    && std::io::Seek::seek(&mut std_file, std::io::SeekFrom::Start(*start)).is_err()
1060                {
1061                    return internal_error_response();
1062                }
1063
1064                // HEAD must not return a body (RFC 9110).
1065                let body = if *method == Method::HEAD {
1066                    ResponseBody::Buffered(Full::new(Bytes::new()))
1067                } else {
1068                    match transformed {
1069                        Some(ref bytes) => ResponseBody::Buffered(Full::new(
1070                            bytes.slice(*start as usize..(*end as usize + 1)),
1071                        )),
1072                        None => ResponseBody::Streamed(FileBody::new_ranged(
1073                            File::from_std(std_file),
1074                            range_len,
1075                        )),
1076                    }
1077                };
1078
1079                let mut builder = Response::builder()
1080                    .status(StatusCode::PARTIAL_CONTENT)
1081                    .header("Content-Type", content_type)
1082                    .header("Content-Length", range_len.to_string())
1083                    .header(
1084                        "Content-Range",
1085                        format!("bytes {}-{}/{}", start, end, file_size),
1086                    )
1087                    .header("Cache-Control", cache_control)
1088                    .header("Vary", "Accept-Encoding")
1089                    .header("ETag", etag)
1090                    .header("Accept-Ranges", "bytes");
1091                if let Some(encoding) = content_encoding {
1092                    builder = builder.header("Content-Encoding", encoding);
1093                }
1094                return finish(builder.body(body));
1095            }
1096            RangeCheck::IgnoreRange => {}
1097        }
1098
1099        // HEAD must not return a body (RFC 9110).
1100        let body = if *method == Method::HEAD {
1101            ResponseBody::Buffered(Full::new(Bytes::new()))
1102        } else {
1103            match transformed {
1104                Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1105                None if metadata.len() <= INLINE_BODY_BYTES => {
1106                    // From the handle opened above — never by re-opening the path — so
1107                    // the bytes served are provably the file that was probed and
1108                    // stat'd, sidecars included, with no reopen window in between.
1109                    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1110                    use std::io::Read as _;
1111                    if std_file.read_to_end(&mut bytes).is_err() {
1112                        return internal_error_response();
1113                    }
1114                    ResponseBody::Buffered(Full::new(Bytes::from(bytes)))
1115                }
1116                None => ResponseBody::Streamed(FileBody::new(File::from_std(std_file))),
1117            }
1118        };
1119
1120        let mut builder = self
1121            .response(StatusCode::OK)
1122            .header("Content-Type", content_type)
1123            .header("Content-Length", file_size.to_string())
1124            .header("Cache-Control", cache_control)
1125            .header("Vary", "Accept-Encoding")
1126            .header("ETag", etag)
1127            .header("Accept-Ranges", "bytes");
1128        if let Some(encoding) = content_encoding {
1129            builder = builder.header("Content-Encoding", encoding);
1130        }
1131        finish(builder.body(body))
1132    }
1133}
1134
1135/// Ceiling on how many bytes hyper buffers for a single request's header block before
1136/// rejecting it. Without this, a client that trickles bytes forever without ever sending
1137/// the terminating blank line could grow the buffer without limit — the header-read
1138/// timeout alone doesn't bound memory, only wall-clock time, and a sufficiently patient
1139/// sender could still send unbounded data before the deadline fires.
1140const MAX_HEADER_BYTES: usize = 64 * 1024;
1141
1142/// Ceiling on the size of an HTML file this server will buffer in memory to splice a
1143/// live-reload or spa-mode `<script>` into.
1144///
1145/// Injection is the one code path that reads a whole file into memory rather than
1146/// streaming it in bounded chunks, and it does so *per request* — so without a cap, a
1147/// single large HTML file turns every concurrent request for it into another full copy
1148/// in memory, and spa-mode is a production feature, not a development-only one. An
1149/// over-cap page is served unmodified (and streamed) instead: losing a client-side
1150/// navigation enhancement on an 8 MiB document is a far smaller failure than an
1151/// allocation proportional to file size times concurrency.
1152///
1153/// 8 MiB is comfortably above any hand-written HTML page and any realistic
1154/// static-site-generator output, so the cap should never fire on content this feature
1155/// was designed for.
1156const MAX_INJECTABLE_HTML_BYTES: u64 = 8 * 1024 * 1024;
1157
1158/// Bodies at or below this size are read synchronously and served from memory; larger
1159/// ones stream through `FileBody`. Equal to `FileBody`'s chunk size on purpose: at or
1160/// under one chunk the streaming path performed exactly one read anyway, so buffering
1161/// changes only *where* that read runs (inline, instead of a blocking-pool round trip
1162/// per chunk) — never how much memory a response can hold.
1163const INLINE_BODY_BYTES: u64 = 64 * 1024;
1164
1165/// Wires an accepted connection up to the hyper HTTP/1.1 service.
1166///
1167/// HTTP/1.1 is the whole surface, deliberately. An earlier implementation used
1168/// `hyper_util`'s auto builder, whose `server-auto` feature transitively enables
1169/// `hyper/http2` — so a prior-knowledge h2c client negotiated HTTP/2 against a server
1170/// that documented, tested, and tuned only HTTP/1: no stream-concurrency limit, no
1171/// frame-size bound, and a header pre-read whose `\r\n\r\n` scan the HTTP/2 preface
1172/// satisfies without being an HTTP/1 request at all. Serving a protocol nobody
1173/// configured is worse than not serving it; browsers reach h2 over TLS only, which this
1174/// crate does not terminate.
1175///
1176/// `header_timeout` and `MAX_HEADER_BYTES` are enforced by hyper itself, per request.
1177/// They were previously enforced by a hand-rolled pre-read that ran once, before the
1178/// connection was handed to hyper — which meant the first request on a connection was
1179/// bounded and every subsequent keep-alive request on that same connection was not:
1180/// a client could complete one cheap request and then trickle headers forever, or send
1181/// an unbounded header block, with neither the timeout nor the size ceiling in play.
1182/// Delegating to hyper applies both bounds to every request, and deletes ~100 lines of
1183/// socket plumbing (`read_header_prefix`, `PrefixedIo`) whose only job was to hand the
1184/// already-read bytes back to hyper.
1185///
1186/// Only the *header* phase is bounded. A response body may legitimately outlive
1187/// `header_timeout` by design — the live-reload SSE stream stays open until a watched
1188/// file changes, possibly hours later — and `header_read_timeout` does not apply once a
1189/// request's headers are complete. The connection-count ceiling
1190/// (`Server::with_max_connections`) is what bounds resource use from connections held
1191/// open indefinitely.
1192///
1193/// `.timer(TokioTimer::new())` is load-bearing, not boilerplate: hyper resolves
1194/// `header_read_timeout` against an installed timer and panics with "timeout set, but no
1195/// timer set" if there isn't one (`hyper::common::time`). The failure is loud rather than
1196/// silent, but it happens per connection inside a spawned task — where a panic takes out
1197/// the connection, not the server — so it is the keep-alive tests, not a startup check,
1198/// that hold this wiring in place.
1199async fn serve_connection(stream: TcpStream, server: Server, header_timeout: Duration) {
1200    let io = TokioIo::new(stream);
1201    let log_server = server.clone();
1202    let svc = service_fn(move |req: Request<Incoming>| {
1203        let server = server.clone();
1204        async move {
1205            let started = Instant::now();
1206            let method = req.method().clone();
1207            let path = req.uri().path().to_string();
1208
1209            let resp = server
1210                .handle_request(req.method(), req.uri().path(), req.headers())
1211                .await;
1212
1213            let bytes = header_str(resp.headers(), "content-length").unwrap_or("-");
1214            server.log(format_args!(
1215                "{method} {path} {} {bytes} {:.3}ms",
1216                resp.status().as_u16(),
1217                started.elapsed().as_secs_f64() * 1000.0,
1218            ));
1219            Ok::<_, Infallible>(resp)
1220        }
1221    });
1222    if let Err(error) = http1::Builder::new()
1223        .timer(TokioTimer::new())
1224        .header_read_timeout(header_timeout)
1225        .max_buf_size(MAX_HEADER_BYTES)
1226        .serve_connection(io, svc)
1227        .await
1228    {
1229        log_server.log(format_args!("connection error: {error}"));
1230    }
1231}
1232
1233/// Default header-read timeout used by [`Server::run_ephemeral`].
1234const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1235
1236/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1237/// finish on their own before aborting whatever is left. A connection with no
1238/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1239/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1240/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1241/// shutdown is no exception.
1242const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1243
1244/// A handle to a server started by one of the `Server::run*` methods.
1245///
1246/// Dropping this handle without calling `shutdown()` leaves the server running in the
1247/// background for the life of the process. Call `shutdown()` to stop accepting new
1248/// connections and wait for already-accepted connections to finish before returning.
1249pub struct ServerHandle {
1250    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1251    accept_task: tokio::task::JoinHandle<()>,
1252}
1253
1254impl ServerHandle {
1255    /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1256    /// (5s) for in-flight connections to finish on their own. Equivalent to
1257    /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1258    /// happens to connections still open once the grace period elapses.
1259    pub async fn shutdown(self) {
1260        self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1261            .await;
1262    }
1263
1264    /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1265    /// connections to finish on their own.
1266    ///
1267    /// Connections still open once `drain_timeout` elapses are aborted rather than
1268    /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1269    /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1270    /// which in turn drops each connection's socket, closing it. This is what bounds
1271    /// shutdown when a connection has no natural end of its own (the live-reload SSE
1272    /// stream is the motivating case: it stays open until a watched file changes, which
1273    /// may never happen before the process needs to exit).
1274    pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1275        if let Some(tx) = self.shutdown_tx.take() {
1276            let _ = tx.send(());
1277        }
1278        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1279            self.accept_task.abort();
1280        }
1281    }
1282}
1283
1284/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1285fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1286    headers.get(name).and_then(|value| value.to_str().ok())
1287}
1288
1289/// Start a response carrying the baseline security header every response in this crate
1290/// sends — 304s included. A 304 otherwise repeats only the caching validators, which is
1291/// why it once built its own builder and was the single response able to arrive without
1292/// `nosniff`; a client that caches the header set alongside the representation would
1293/// then hold a copy missing it.
1294///
1295/// Prefer [`Server::response`], which also applies the embedder's configured headers.
1296/// This bare form exists for `bad_request_response`, which is reachable from `finish`
1297/// where no `Server` is in scope.
1298fn response(status: StatusCode) -> Builder {
1299    Response::builder()
1300        .status(status)
1301        .header("X-Content-Type-Options", "nosniff")
1302}
1303
1304/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1305/// allocate; owned bodies are moved in.
1306fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1307    finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1308}
1309
1310/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1311/// header value turns out to be invalid for use as an HTTP header value.
1312///
1313/// Every header value that reaches `Response::builder()` in this module is either a
1314/// static string or formatted from internal, already-validated data (a byte count, an
1315/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1316/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1317/// production panic the day someone adds a header built from new input without
1318/// re-deriving that guarantee. Routing every response through this one fallible path
1319/// means that mistake fails safe instead of panicking.
1320fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1321    built.unwrap_or_else(|_| bad_request_response())
1322}
1323
1324// `internal_error_response()` and `bad_request_response()` are the fallback responses
1325// `finish()` itself degrades to — every header and body here is a fixed string with no
1326// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1327// without it degrading to itself on failure.
1328fn internal_error_response() -> Response<ResponseBody> {
1329    response(StatusCode::INTERNAL_SERVER_ERROR)
1330        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1331            b"internal server error\n",
1332        ))))
1333        .unwrap()
1334}
1335
1336fn bad_request_response() -> Response<ResponseBody> {
1337    response(StatusCode::BAD_REQUEST)
1338        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1339            b"bad request\n",
1340        ))))
1341        .unwrap()
1342}
1343
1344/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1345/// variant, in preference order — brotli wins when a client accepts both and both
1346/// sidecars exist.
1347const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1348
1349/// `q`-values are carried in thousandths — RFC 9110 allows at most three decimal places
1350/// — so weights compare exactly as integers instead of through float equality.
1351const QVALUE_SCALE: f32 = 1000.0;
1352
1353/// An `Accept-Encoding` entry with no explicit `q` parameter has weight 1.
1354const DEFAULT_QVALUE: u16 = 1000;
1355
1356/// The weight `accept_encoding` gives `encoding`, or `None` if it does not list it.
1357///
1358/// Entries are matched as whole tokens, case-insensitively, per RFC 9110 — not by
1359/// substring. The substring form this replaces got two things wrong that a client can
1360/// trigger: `Accept-Encoding: gzip;q=0` selected gzip, because the header *contains*
1361/// "gzip" while explicitly refusing it, and a token like `brotli` matched `br`.
1362///
1363/// `*` is deliberately not honored: treating the wildcard as matching nothing can only
1364/// cost a bandwidth optimization, while treating it as matching everything risks sending
1365/// an encoding the client did not ask for. The conservative reading is the safe one when
1366/// the payoff is choosing between two static files.
1367fn encoding_quality(accept_encoding: &str, encoding: &str) -> Option<u16> {
1368    accept_encoding.split(',').find_map(|entry| {
1369        let mut parts = entry.split(';');
1370        if !parts.next()?.trim().eq_ignore_ascii_case(encoding) {
1371            return None;
1372        }
1373
1374        let quality = parts
1375            .find_map(|parameter| {
1376                let (key, value) = parameter.split_once('=')?;
1377                key.trim().eq_ignore_ascii_case("q").then(|| value.trim())
1378            })
1379            .and_then(|value| value.parse::<f32>().ok())
1380            .map(|value| (value.clamp(0.0, 1.0) * QVALUE_SCALE).round() as u16)
1381            .unwrap_or(DEFAULT_QVALUE);
1382
1383        Some(quality)
1384    })
1385}
1386
1387/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1388/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1389///
1390/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1391/// The sidecar path is built by appending an extension to it — never by re-resolving a
1392/// modified request path — so this lookup can't become a second traversal surface: any
1393/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1394fn select_precompressed_sidecar(
1395    path: &Path,
1396    accept_encoding: Option<&str>,
1397) -> Option<(std::fs::File, fs::Metadata, &'static str)> {
1398    // Highest `q` first; ties keep `SIDECAR_ENCODINGS` order (brotli over gzip) because
1399    // the sort is stable. Without this, `br;q=0.5, gzip` would serve brotli purely
1400    // because it is listed first here, ignoring the preference the client stated.
1401    let mut candidates: Vec<(&'static str, &'static str, u16)> = SIDECAR_ENCODINGS
1402        .iter()
1403        .filter_map(|(encoding, ext)| {
1404            let quality = accept_encoding.and_then(|header| encoding_quality(header, encoding))?;
1405            (quality > 0).then_some((*encoding, *ext, quality))
1406        })
1407        .collect();
1408    candidates.sort_by_key(|(_, _, quality)| std::cmp::Reverse(*quality));
1409
1410    for (encoding, ext, _) in candidates {
1411        let mut sidecar = path.as_os_str().to_os_string();
1412        sidecar.push(ext);
1413        let sidecar_path = PathBuf::from(sidecar);
1414
1415        // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1416        // must stay in the same directory as `path` (which `resolve()` already proved is
1417        // inside root). `ext` is always one of the two static literals in
1418        // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1419        // a future change starts deriving `sidecar` some other way.
1420        debug_assert_eq!(
1421            sidecar_path.parent(),
1422            path.parent(),
1423            "sidecar path must stay in the same directory as the already-resolved path"
1424        );
1425
1426        // Synchronous, and it stays an *open* rather than a cheaper `stat`: handing back
1427        // an already-open file is what keeps there being no gap between probing the
1428        // sidecar and serving it. Browsers send `Accept-Encoding` on every request, so
1429        // this probe is the common path — as `tokio::fs` opens, two misses per request
1430        // kept the blocking pool hot for files that do not exist.
1431        if let Ok(sidecar_std) = std::fs::File::open(&sidecar_path) {
1432            if let Ok(sidecar_metadata) = sidecar_std.metadata() {
1433                return Some((sidecar_std, sidecar_metadata, encoding));
1434            }
1435        }
1436    }
1437    None
1438}
1439
1440/// Generate an ETag for a file based on modification time and size.
1441///
1442/// Format: `"<size>-<mtime_secs>.<mtime_nanos>"`.
1443///
1444/// The sub-second component is what makes this crate's choice to serve an ETag *instead*
1445/// of `Last-Modified`/`If-Modified-Since` sound. That choice rests on an ETag being able
1446/// to distinguish representations a whole-second timestamp cannot — two writes inside the
1447/// same second — which a whole-second ETag plainly cannot do either: rewriting a file
1448/// within a second of its last write, without changing its length, reproduced the
1449/// previous ETag exactly and every revalidating client was told `304 Not Modified` while
1450/// holding stale bytes. Build pipelines that rewrite generated assets are the realistic
1451/// way to hit that, and this crate ships one.
1452///
1453/// A filesystem whose timestamps are only second-granular gives `subsec_nanos() == 0`
1454/// and the same behavior as before — no worse, and no false confidence beyond what the
1455/// filesystem actually provides.
1456fn generate_etag(metadata: &fs::Metadata) -> String {
1457    let mtime = metadata
1458        .modified()
1459        .ok()
1460        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1461        .unwrap_or_default();
1462    format!(
1463        "\"{}-{}.{}\"",
1464        metadata.len(),
1465        mtime.as_secs(),
1466        mtime.subsec_nanos()
1467    )
1468}
1469
1470/// Determine MIME type from file path extension.
1471fn mime_type_for_path(path: &Path) -> &'static str {
1472    let ext = path
1473        .extension()
1474        .and_then(|ext| ext.to_str())
1475        .unwrap_or_default()
1476        .to_lowercase();
1477
1478    match ext.as_str() {
1479        "html" | "htm" => "text/html; charset=utf-8",
1480        "css" => "text/css; charset=utf-8",
1481        "js" => "application/javascript; charset=utf-8",
1482        "json" => "application/json; charset=utf-8",
1483        "svg" => "image/svg+xml",
1484        "png" => "image/png",
1485        "jpg" | "jpeg" => "image/jpeg",
1486        "gif" => "image/gif",
1487        "webp" => "image/webp",
1488        "ico" => "image/x-icon",
1489        "woff" => "font/woff",
1490        "woff2" => "font/woff2",
1491        "ttf" => "font/ttf",
1492        "md" | "markdown" => "text/markdown; charset=utf-8",
1493        "txt" => "text/plain; charset=utf-8",
1494        "xml" => "application/xml",
1495        "pdf" => "application/pdf",
1496        "zip" => "application/zip",
1497        _ => "application/octet-stream",
1498    }
1499}
1500
1501/// Check if the If-None-Match header matches the current ETag.
1502/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1503fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1504    if if_none_match == "*" {
1505        return true;
1506    }
1507    if_none_match.split(',').any(|tag| tag.trim() == etag)
1508}
1509
1510#[derive(Debug)]
1511enum RangeOutcome {
1512    NoRange,
1513    Satisfiable(u64, u64),
1514    Unsatisfiable,
1515    MultiRangeIgnored,
1516}
1517
1518enum RangeCheck {
1519    IgnoreRange,
1520    Satisfiable(u64, u64),
1521    Unsatisfiable,
1522}
1523
1524fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1525    let header = header.trim();
1526    if !header.starts_with("bytes=") {
1527        return RangeOutcome::NoRange;
1528    }
1529
1530    let range_spec = &header[6..];
1531
1532    if range_spec.contains(',') {
1533        return RangeOutcome::MultiRangeIgnored;
1534    }
1535
1536    if let Some(suffix_pos) = range_spec.find('-') {
1537        if suffix_pos == 0 {
1538            let suffix_len_str = &range_spec[1..];
1539            if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1540                if suffix_len == 0 {
1541                    return RangeOutcome::Unsatisfiable;
1542                }
1543                if suffix_len >= file_size {
1544                    return RangeOutcome::Satisfiable(0, file_size - 1);
1545                }
1546                return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1547            }
1548            return RangeOutcome::Unsatisfiable;
1549        }
1550
1551        let start_str = &range_spec[..suffix_pos];
1552        let end_str = &range_spec[suffix_pos + 1..];
1553
1554        if let Ok(start) = start_str.parse::<u64>() {
1555            if start >= file_size {
1556                return RangeOutcome::Unsatisfiable;
1557            }
1558
1559            if end_str.is_empty() {
1560                return RangeOutcome::Satisfiable(start, file_size - 1);
1561            }
1562
1563            if let Ok(end) = end_str.parse::<u64>() {
1564                if end < start {
1565                    return RangeOutcome::Unsatisfiable;
1566                }
1567                let clamped_end = (end + 1).min(file_size) - 1;
1568                if start > clamped_end {
1569                    return RangeOutcome::Unsatisfiable;
1570                }
1571                return RangeOutcome::Satisfiable(start, clamped_end);
1572            }
1573        }
1574    }
1575
1576    RangeOutcome::Unsatisfiable
1577}
1578
1579fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1580    if_range_header.trim() == current_etag
1581}
1582
1583#[cfg(test)]
1584#[path = "../tests/unit/server/precompressed_sidecar.rs"]
1585mod precompressed_sidecar_tests;
1586
1587#[cfg(test)]
1588#[path = "../tests/unit/server/file_body.rs"]
1589mod file_body_tests;
1590
1591#[cfg(test)]
1592#[path = "../tests/unit/server/accept.rs"]
1593mod accept_tests;
1594
1595#[cfg(test)]
1596#[path = "../tests/unit/server/finish.rs"]
1597mod finish_tests;
1598
1599#[cfg(test)]
1600#[path = "../tests/unit/server/etag.rs"]
1601mod etag_tests;
1602
1603#[cfg(test)]
1604#[path = "../tests/unit/server/range_header.rs"]
1605mod range_header_tests;