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