Skip to main content

mini_static/
server.rs

1use std::convert::Infallible;
2use std::fs;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8use std::time::{Duration, SystemTime};
9
10use bytes::Bytes;
11use http_body_util::Full;
12use hyper::body::Incoming;
13use hyper::http::response::Builder;
14use hyper::service::service_fn;
15use hyper::{HeaderMap, Method, Request, Response, StatusCode};
16use hyper_util::rt::TokioExecutor;
17use hyper_util::rt::TokioIo;
18use hyper_util::server::conn::auto::Builder as AutoBuilder;
19use tokio::fs::File;
20use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, ReadBuf};
21use tokio::net::{TcpListener, TcpStream};
22use tokio::sync::{OwnedSemaphorePermit, Semaphore};
23use tokio::time::timeout;
24
25use crate::css::{CssOptions, CssTool};
26use crate::error::StaticError;
27use crate::handler::{FileBody, ResponseBody};
28use crate::js::{JsOptions, JsTool};
29use crate::reload::{self, SseBody};
30use crate::resolve;
31use crate::source::SourcePipeline;
32use crate::spa::{self, SpaTransition};
33use crate::tool;
34use crate::watcher::{start_watching, Broadcaster};
35
36const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
37const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
38
39/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
40const DEFAULT_MAX_CONNECTIONS: usize = 1024;
41
42/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
43/// can be exercised against a listener that fails on demand, without needing to provoke
44/// real OS-level accept errors (e.g. EMFILE) in tests.
45trait TcpAccept {
46    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
47}
48
49impl TcpAccept for TcpListener {
50    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
51        TcpListener::accept(self).await
52    }
53}
54
55/// Accept a connection and reserve it a connection-limit permit.
56///
57/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
58/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
59/// so a sustained failure — the process being out of file descriptors, say — degrades
60/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
61///
62/// Returns `None` only if the semaphore itself has been closed (never happens in normal
63/// operation, since nothing ever calls `close()` on it — handled so a caller can still
64/// fail safely rather than panic).
65async fn accept_and_permit<L: TcpAccept>(
66    listener: &L,
67    backoff: &mut Duration,
68    semaphore: &Arc<Semaphore>,
69) -> Option<(TcpStream, OwnedSemaphorePermit)> {
70    loop {
71        let stream = match listener.accept().await {
72            Ok((stream, _)) => {
73                *backoff = ACCEPT_BACKOFF_INITIAL;
74                stream
75            }
76            Err(_) => {
77                tokio::time::sleep(*backoff).await;
78                *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
79                continue;
80            }
81        };
82        return semaphore
83            .clone()
84            .acquire_owned()
85            .await
86            .ok()
87            .map(|permit| (stream, permit));
88    }
89}
90
91/// A predicate deciding whether a resolved file path should get an immutable cache
92/// policy; see [`Server::with_immutable_assets`].
93type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
94
95/// A static file server for serving files securely from a root directory.
96///
97/// `Server` canonicalizes the root directory once at creation time and uses the
98/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
99///
100/// # Security
101///
102/// The server protects against:
103/// - Path traversal attacks (e.g., `../../etc/passwd`)
104/// - Accessing files outside the root via symlinks
105/// - Disclosing filesystem structure (traversal and missing files both return 404)
106///
107/// # Cloning
108///
109/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
110/// predicate closure. Multiple clones can be used concurrently in async tasks without
111/// synchronization overhead.
112///
113/// # Example
114///
115/// ```no_run
116/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
117/// use mini_static::Server;
118/// use std::path::Path;
119/// use std::time::Duration;
120///
121/// let server = Server::new(Path::new("./public"))?;
122/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
123/// println!("Server running on port {}", port);
124/// # Ok(())
125/// # }
126/// ```
127#[derive(Clone)]
128pub struct Server {
129    root_canon: PathBuf,
130    bundle_roots: Vec<PathBuf>,
131    max_connections: usize,
132    live_reload: bool,
133    broadcaster: Option<Broadcaster>,
134    spa_mode: bool,
135    spa_root: Option<String>,
136    spa_transition: SpaTransition,
137    not_found_page: Option<PathBuf>,
138    immutable_predicate: Option<ImmutablePredicate>,
139    source_folders: Vec<PathBuf>,
140    asset_folders: Vec<PathBuf>,
141    output_dir: PathBuf,
142    css_tool: Option<(CssTool, CssOptions)>,
143    js_tool: Option<(JsTool, JsOptions)>,
144    prune_output: bool,
145}
146
147/// True when two canonical paths are the same path or one contains the other.
148///
149/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
150/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
151/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
152/// cannot drift apart.
153fn paths_overlap(a: &Path, b: &Path) -> bool {
154    a.starts_with(b) || b.starts_with(a)
155}
156
157impl Server {
158    /// Create a new server with the given root directory.
159    ///
160    /// Canonicalizes the root once at startup. All subsequent requests use the
161    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
162    ///
163    /// # Errors
164    ///
165    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
166    /// no read permissions).
167    pub fn new(root: &Path) -> Result<Self, StaticError> {
168        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
169        let output_dir = root_canon.clone();
170        Ok(Server {
171            root_canon,
172            bundle_roots: Vec::new(),
173            max_connections: DEFAULT_MAX_CONNECTIONS,
174            live_reload: false,
175            broadcaster: None,
176            spa_mode: false,
177            spa_root: None,
178            spa_transition: SpaTransition::default(),
179            not_found_page: None,
180            immutable_predicate: None,
181            source_folders: Vec::new(),
182            asset_folders: Vec::new(),
183            output_dir,
184            css_tool: None,
185            js_tool: None,
186            prune_output: false,
187        })
188    }
189
190    /// Set the maximum number of connections served concurrently (default 1024).
191    ///
192    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
193    /// new ones — without pausing the accept loop, a client that opens a connection and
194    /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
195    /// otherwise be used, in enough parallel copies, to exhaust the process's file
196    /// descriptors or memory with no bound at all.
197    pub fn with_max_connections(mut self, max: usize) -> Self {
198        self.max_connections = max;
199        self
200    }
201
202    /// Enable live-reload for this server (disabled by default).
203    ///
204    /// Once enabled, the `run*` methods start a background watcher (mtime polling,
205    /// bounded 500ms interval — see [`crate::start_watching`]) over the server's root
206    /// the first time the server actually starts accepting connections, and:
207    ///
208    /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
209    ///   change event (with [`crate::ChangeType`]) whenever a served file is added,
210    ///   modified, or removed;
211    /// - inject a small `<script>` into every served `text/html` response that connects
212    ///   to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
213    ///   changes) — no manual client wiring required.
214    ///
215    /// This is meant for local development, not production: leave it disabled (the
216    /// default) for any server serving real traffic. A typical call site gates it behind
217    /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
218    /// injected script.
219    ///
220    /// # Example
221    ///
222    /// ```no_run
223    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
224    /// use mini_static::Server;
225    /// use std::path::Path;
226    ///
227    /// let server = Server::new(Path::new("./public"))?;
228    /// #[cfg(debug_assertions)]
229    /// let server = server.with_live_reload();
230    /// # Ok(())
231    /// # }
232    /// ```
233    pub fn with_live_reload(mut self) -> Self {
234        self.live_reload = true;
235        self
236    }
237
238    /// Enable spa-mode navigation for this server, swapping `document.body` on
239    /// each navigation (disabled by default).
240    ///
241    /// Once enabled, every served `text/html` response gets a small `<script>`
242    /// injected (see [`Server::with_spa_root`] for what it does) that treats
243    /// `document.body` as the swap target. Calling this after
244    /// [`Server::with_spa_root`] does not clear a previously configured root
245    /// selector — the two methods set independent fields, so
246    /// `.with_spa_root(sel).with_spa_mode()` and
247    /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
248    /// root `sel`. Use this one alone when there's no persistent chrome to
249    /// preserve across navigations.
250    ///
251    /// # Example
252    ///
253    /// ```no_run
254    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
255    /// use mini_static::Server;
256    /// use std::path::Path;
257    ///
258    /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
259    /// # Ok(())
260    /// # }
261    /// ```
262    pub fn with_spa_mode(mut self) -> Self {
263        self.spa_mode = true;
264        self
265    }
266
267    /// Enable spa-mode navigation for this server, swapping only the element
268    /// matched by the CSS `selector` on each navigation (disabled by default;
269    /// also enables spa-mode the same as [`Server::with_spa_mode`]).
270    ///
271    /// Once enabled, every served `text/html` response gets a small `<script>`
272    /// injected that intercepts left-clicks on same-origin `<a href>`
273    /// elements (skipping links with a non-`_self` `target`, a `download`
274    /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
275    /// hash-only href) and, instead of a normal navigation:
276    ///
277    /// - fetches the target URL;
278    /// - on a non-OK or non-`text/html` response (or a fetch error), falls
279    ///   back to a real `location.href` navigation — spa-mode never renders a
280    ///   broken page;
281    /// - otherwise replaces the matched element's `innerHTML` with the
282    ///   corresponding content from the fetched document, updates the page
283    ///   title, and pushes the new URL via `history.pushState`, animating the
284    ///   swap with `document.startViewTransition()` where supported;
285    /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
286    ///   every client-side navigation, so page scripts can re-run any
287    ///   per-page initialization that would otherwise only execute once
288    ///   (content swapped in via `innerHTML` never executes its own
289    ///   `<script>` tags);
290    /// - handles browser back/forward by re-fetching and swapping to the new
291    ///   `location.href`.
292    ///
293    /// `selector` is matched against both the current page and the fetched
294    /// page; a link click where the selector matches neither falls back to a
295    /// real navigation, same as a fetch failure. Choose a `selector` that
296    /// wraps only the content that varies between pages, leaving persistent
297    /// chrome (nav/header/footer) outside it so it survives navigation
298    /// untouched.
299    ///
300    /// This is meant to be usable in production, not just local development
301    /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
302    /// doesn't intercept, or on a browser without JS or View Transitions
303    /// support, still works as a normal navigation.
304    ///
305    /// # Example
306    ///
307    /// ```no_run
308    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
309    /// use mini_static::Server;
310    /// use std::path::Path;
311    ///
312    /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
313    /// # Ok(())
314    /// # }
315    /// ```
316    pub fn with_spa_root(mut self, selector: &str) -> Self {
317        self.spa_mode = true;
318        self.spa_root = Some(selector.to_string());
319        self
320    }
321
322    /// Set how spa-mode animates the swap between pages (also enables
323    /// spa-mode the same as [`Server::with_spa_mode`]; default
324    /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
325    ///
326    /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
327    /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
328    /// and [`SlideOptions`] for what each variant does and how to
329    /// configure the slide's duration, direction, and easing.
330    ///
331    /// # Example
332    ///
333    /// ```no_run
334    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
335    /// use mini_static::{Server, SlideOptions, SpaTransition};
336    /// use std::path::Path;
337    ///
338    /// let server = Server::new(Path::new("./public"))?
339    ///     .with_spa_root("#app")
340    ///     .with_spa_transition(SpaTransition::Slide(
341    ///         SlideOptions::default().duration_ms(500),
342    ///     ));
343    /// # Ok(())
344    /// # }
345    /// ```
346    /// Serves `path` as the body of every `404`, instead of the default plain-text
347    /// `not found`.
348    ///
349    /// `path` is resolved relative to the served root and must exist when this is
350    /// called: a missing 404 page is a deployment mistake, and finding out on the first
351    /// broken link — the one moment the page exists to handle — is too late. It is read
352    /// from disk per response rather than cached, so editing it during a live-reload
353    /// session takes effect without a restart.
354    ///
355    /// The response keeps its `404` status. Serving a custom page with `200` is a soft
356    /// 404: search engines index it, and monitoring stops seeing the failures. It also
357    /// carries `Cache-Control: no-store`, so a client never holds this page as though it
358    /// were the resource that was actually requested.
359    ///
360    /// Nothing about the failed request reaches the page — no path, no reason. A
361    /// traversal attempt and an ordinary miss are deliberately indistinguishable
362    /// (`StaticError::user_message`), and templating the requested path into the
363    /// response would undo that and hand back a reflected-content vector besides.
364    ///
365    /// # Errors
366    ///
367    /// Returns `Err(StaticError::Io)` if `path` cannot be canonicalized (typically:
368    /// it does not exist), or `Err(StaticError::Traversal)` if it lies outside the
369    /// served root.
370    pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
371        let joined = self.root_canon.join(path);
372        let canon = joined.canonicalize().map_err(StaticError::Io)?;
373
374        if !canon.starts_with(&self.root_canon) {
375            return Err(StaticError::Traversal(format!(
376                "404 page {} lies outside the served root {}",
377                canon.display(),
378                self.root_canon.display()
379            )));
380        }
381
382        self.not_found_page = Some(canon);
383        Ok(self)
384    }
385
386    pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
387        self.spa_mode = true;
388        self.spa_transition = transition;
389        self
390    }
391
392    /// Serve files matching `predicate` with a long-lived, immutable cache policy
393    /// instead of the default `Cache-Control: no-cache`.
394    ///
395    /// `predicate` is evaluated against each resolved file's path; a match sends
396    /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
397    /// responses. This is correct only for fingerprinted assets (e.g.
398    /// `main.a1b2c3.js`) where a content change always produces a new filename —
399    /// caching a mutable filename indefinitely would serve stale content to every
400    /// client that already has it cached.
401    ///
402    /// # Example
403    ///
404    /// ```no_run
405    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
406    /// use mini_static::Server;
407    /// use std::path::Path;
408    ///
409    /// let server = Server::new(Path::new("./public"))?
410    ///     .with_immutable_assets(|path| {
411    ///         path.file_name()
412    ///             .and_then(|name| name.to_str())
413    ///             .is_some_and(|name| name.contains(".fingerprint."))
414    ///     });
415    /// # Ok(())
416    /// # }
417    /// ```
418    pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
419    where
420        F: Fn(&Path) -> bool + Send + Sync + 'static,
421    {
422        self.immutable_predicate = Some(Arc::new(predicate));
423        self
424    }
425
426    /// The `Cache-Control` header value for a resolved file path: the immutable policy
427    /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
428    fn cache_control_for(&self, path: &Path) -> &'static str {
429        match &self.immutable_predicate {
430            Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
431            _ => "no-cache",
432        }
433    }
434
435    /// Register `path` as an additional directory whose changes should trigger a CSS
436    /// bundle rebuild, alongside the registered source folders.
437    ///
438    /// Useful for build pipelines where CSS partials referenced via `@import` live in a
439    /// separate directory tree from the source folders proper: without registering that
440    /// tree here, editing a partial wouldn't be noticed by the watcher and the bundle
441    /// would go stale until something else touched it.
442    ///
443    /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
444    /// request-handling path never consult bundle roots. This is purely a watch target,
445    /// not a second served root, and — since `@import` resolution is delegated entirely
446    /// to the configured [`CssTool`] (see [`Server::with_css_tool`]) — not an `@import`
447    /// traversal boundary either; the external tool resolves its own imports with no
448    /// root mini-static can enforce.
449    ///
450    /// This method is fallible and canonicalizes the path once at call time, matching
451    /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
452    /// more than one external source tree.
453    ///
454    /// # Errors
455    ///
456    /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
457    pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
458        let canon = path.canonicalize().map_err(StaticError::Io)?;
459        self.bundle_roots.push(canon);
460        Ok(self)
461    }
462
463    /// Designate `dir` as a source folder whose changes drive the build pipelines.
464    ///
465    /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
466    /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
467    ///
468    /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
469    /// source folder that is also the output would feed every pipeline its own output — the
470    /// feedback loop this layering exists to prevent.
471    ///
472    /// # Errors
473    ///
474    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
475    /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
476    pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
477        let canon = dir.canonicalize().map_err(StaticError::Io)?;
478
479        if paths_overlap(&canon, &self.output_dir) {
480            return Err(StaticError::Traversal(format!(
481                "source folder {} overlaps the output dir {}",
482                canon.display(),
483                self.output_dir.display()
484            )));
485        }
486        if self
487            .source_folders
488            .iter()
489            .chain(self.asset_folders.iter())
490            .any(|existing| paths_overlap(&canon, existing))
491        {
492            return Err(StaticError::Traversal(format!(
493                "source folder {} overlaps an already-registered source/asset folder",
494                canon.display()
495            )));
496        }
497
498        self.source_folders.push(canon);
499        Ok(self)
500    }
501
502    /// Designate `dir` as an asset source folder: every file under it (any extension)
503    /// is mirrored byte-identical into the output dir at server startup and on every
504    /// live-reload change — no CSS/JS transformation, just a flat copy preserving each
505    /// file's path relative to `dir`. Use this for hand-authored static files
506    /// (`index.html`, images) that should live outside the served/output dir as
507    /// source, the same source/output separation `with_source_folder`'s CSS/JS
508    /// pipelines already have.
509    ///
510    /// Rejected if `dir` overlaps the output dir or an already-registered
511    /// source/asset folder, for the same reason `with_source_folder` rejects it: a
512    /// folder that is also the output would feed the pipeline its own output.
513    ///
514    /// # Errors
515    ///
516    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
517    /// `Err(StaticError::Traversal)` if it overlaps the output dir or another
518    /// registered source/asset folder.
519    pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
520        let canon = dir.canonicalize().map_err(StaticError::Io)?;
521
522        if paths_overlap(&canon, &self.output_dir) {
523            return Err(StaticError::Traversal(format!(
524                "asset folder {} overlaps the output dir {}",
525                canon.display(),
526                self.output_dir.display()
527            )));
528        }
529        if self
530            .source_folders
531            .iter()
532            .chain(self.asset_folders.iter())
533            .any(|existing| paths_overlap(&canon, existing))
534        {
535            return Err(StaticError::Traversal(format!(
536                "asset folder {} overlaps an already-registered source/asset folder",
537                canon.display()
538            )));
539        }
540
541        self.asset_folders.push(canon);
542        Ok(self)
543    }
544
545    /// Designate `dir` as the output directory processed outputs are written to.
546    ///
547    /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
548    /// react to source folders only, so a pipeline's own output can never re-trigger it.
549    /// Call this before `with_css_tool` so a bundle output path reflects the override.
550    ///
551    /// # Errors
552    ///
553    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
554    /// `Err(StaticError::Traversal)` if it overlaps a registered source or asset folder.
555    pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
556        let canon = dir.canonicalize().map_err(StaticError::Io)?;
557
558        if self
559            .source_folders
560            .iter()
561            .chain(self.asset_folders.iter())
562            .any(|existing| paths_overlap(&canon, existing))
563        {
564            return Err(StaticError::Traversal(format!(
565                "output dir {} overlaps a registered source/asset folder",
566                canon.display()
567            )));
568        }
569
570        self.output_dir = canon;
571        Ok(self)
572    }
573
574    /// Configure CSS bundling/minification via an external tool (disabled by default).
575    ///
576    /// `tool` is a preset naming the CLI mini-static invokes (see [`CssTool`]) —
577    /// mini-static does not install or manage the binary, only looks it up on `PATH`;
578    /// [`Server::run_on`] fails fast at startup if it's missing. `options` selects
579    /// `bundle`/`minify` independently (see [`CssOptions`]):
580    ///
581    /// - Neither: every `.css` under the source folders is copied through unchanged,
582    ///   mirrored into the output dir.
583    /// - `minify` only: each file is minified independently and mirrored (no `@import`
584    ///   following).
585    /// - `bundle` only: every `.css` under the source folders is discovered,
586    ///   `@import`-resolved, and concatenated into one output file, unminified.
587    /// - Both: the bundle above, minified.
588    ///
589    /// # Example
590    ///
591    /// ```no_run
592    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
593    /// use mini_static::{CssOptions, CssTool, Server};
594    /// use std::path::Path;
595    ///
596    /// let server = Server::new(Path::new("./public"))?
597    ///     .with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true));
598    /// # Ok(())
599    /// # }
600    /// ```
601    pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
602        self.css_tool = Some((tool, options));
603        self
604    }
605
606    /// Configure JS bundling/minification via an external tool (disabled by default).
607    ///
608    /// `tool` is a preset naming the CLI mini-static invokes (see [`JsTool`]) —
609    /// mini-static does not install or manage the binary, only looks it up on `PATH`;
610    /// [`Server::run_on`] fails fast at startup if it's missing. Unlike CSS, JS bundling
611    /// requires an explicit entry point ([`JsOptions::bundle_entry`]) since a JS module
612    /// graph has no well-defined "concatenate everything" meaning; without it, `options`
613    /// runs in per-file mode (every `.js`/`.mjs` under the source folders processed and
614    /// mirrored independently).
615    ///
616    /// # Errors
617    ///
618    /// Returns `Err(StaticError::Io)` if `options` specifies a bundle entry that cannot
619    /// be canonicalized, or `Err(StaticError::Traversal)` if it doesn't lie under a
620    /// registered source folder — checked eagerly here so a bad entry path fails at
621    /// configuration time, not on the first rebuild.
622    ///
623    /// # Example
624    ///
625    /// ```no_run
626    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
627    /// use mini_static::{JsOptions, JsTool, Server};
628    /// use std::path::Path;
629    ///
630    /// let server = Server::new(Path::new("./public"))?
631    ///     .with_source_folder(Path::new("./js-src"))?
632    ///     .with_js_tool(
633    ///         JsTool::Esbuild,
634    ///         JsOptions::new()
635    ///             .bundle_entry(Path::new("./js-src/main.js"), "bundle.js")
636    ///             .minify(true),
637    ///     )?;
638    /// # Ok(())
639    /// # }
640    /// ```
641    pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
642        if let Some(entry) = options.entry() {
643            let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
644            let under_source_folder = self
645                .source_folders
646                .iter()
647                .any(|folder| entry_canon.starts_with(folder));
648            if !under_source_folder {
649                return Err(StaticError::Traversal(format!(
650                    "js bundle entry {} is not under any registered source folder",
651                    entry_canon.display()
652                )));
653            }
654        }
655
656        self.js_tool = Some((tool, options));
657        Ok(self)
658    }
659
660    /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
661    /// when no CSS sources remain, rather than serving an orphan. Applies only to the
662    /// one-shot startup build, never during live-reload.
663    pub fn with_prune_output(mut self) -> Self {
664        self.prune_output = true;
665        self
666    }
667
668    /// True when any build pipeline is configured (a CSS/JS tool and/or a source
669    /// folder), i.e. the server should run a startup build.
670    fn has_pipeline(&self) -> bool {
671        self.css_tool.is_some()
672            || self.js_tool.is_some()
673            || !self.source_folders.is_empty()
674            || !self.asset_folders.is_empty()
675    }
676
677    /// Every external tool binary this configuration actually needs at some point
678    /// (bundle and/or minify enabled — a pure passthrough config never spawns its
679    /// configured tool, so it has nothing to fail-fast on), paired with its
680    /// human-readable install hint for a fail-fast startup error.
681    fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
682        let mut required = Vec::new();
683        if let Some((css_tool, options)) = &self.css_tool {
684            if options.is_bundle() || options.is_minify() {
685                required.push((css_tool.binary_name(), css_tool.install_hint()));
686            }
687        }
688        if let Some((js_tool, options)) = &self.js_tool {
689            if options.is_bundle() || options.is_minify() {
690                required.push((js_tool.binary_name(), js_tool.install_hint()));
691            }
692        }
693        required
694    }
695
696    /// Every directory to watch for source changes: the source folders, the CSS
697    /// `@import` roots, and the asset folders, deduplicated so a directory registered
698    /// under more than one role is watched once.
699    fn watch_targets(&self) -> Vec<PathBuf> {
700        let mut targets = Vec::new();
701        for dir in self
702            .source_folders
703            .iter()
704            .chain(self.bundle_roots.iter())
705            .chain(self.asset_folders.iter())
706        {
707            if !targets.contains(dir) {
708                targets.push(dir.clone());
709            }
710        }
711        targets
712    }
713
714    /// Run every configured build pipeline (CSS/JS tools, asset folders) once and
715    /// return, without starting the HTTP server. A one-shot equivalent of the
716    /// startup build `run*` does automatically — for deploy tooling that wants to
717    /// populate the output dir ahead of time (e.g. a `cargo run --bin build_static`
718    /// step before baking a Docker image), mirroring a one-shot content
719    /// builder's `build()` (e.g. `mini_docs::Builder::build()`).
720    ///
721    /// # Errors
722    ///
723    /// - `Err(StaticError::PipelineSetup)` if a configured tool's binary that's
724    ///   actually needed (bundle or minify enabled) is missing from `PATH` — checked
725    ///   before anything runs, same as [`Server::run_on`].
726    /// - `Err(StaticError::Build)` if a configured pipeline step fails (a tool
727    ///   invocation error, a filesystem error writing output, etc.).
728    ///
729    /// # Example
730    ///
731    /// ```no_run
732    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
733    /// use mini_static::Server;
734    /// use std::path::Path;
735    ///
736    /// let server = Server::new(Path::new("./public"))?;
737    /// server.build().await?;
738    /// # Ok(())
739    /// # }
740    /// ```
741    pub async fn build(&self) -> Result<(), StaticError> {
742        for (binary, install_hint) in self.required_tool_binaries() {
743            if !tool::locate_on_path(binary) {
744                return Err(StaticError::PipelineSetup(format!(
745                    "{binary} not found on PATH ({install_hint})"
746                )));
747            }
748        }
749
750        let pipeline = SourcePipeline::new(
751            self.source_folders.clone(),
752            self.bundle_roots.clone(),
753            self.asset_folders.clone(),
754            self.output_dir.clone(),
755            self.css_tool.clone(),
756            self.js_tool.clone(),
757            self.prune_output,
758            Broadcaster::new(),
759        );
760        pipeline
761            .full_build()
762            .await
763            .map_err(|e| StaticError::Build(e.to_string()))
764    }
765
766    /// Resolve a request path under the server's root.
767    ///
768    /// This is a lower-level API for resolving paths without generating HTTP responses.
769    /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
770    ///
771    /// # Returns
772    ///
773    /// - `Ok(PathBuf)` if the path resolves to a file within root.
774    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
775    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
776        resolve::resolve_with_canonical_root(&self.root_canon, request_path)
777    }
778
779    /// Builds the `404` response: the configured page when there is one and it can be
780    /// read, and `fallback` as plain text otherwise.
781    ///
782    /// `fallback` is the caller's already-sanitized message (see
783    /// `StaticError::user_message`) — never the requested path, so an ordinary miss and
784    /// a rejected traversal stay indistinguishable to whoever is probing.
785    ///
786    /// A page that vanished after `with_not_found_page` validated it degrades to that
787    /// text rather than to a `500`: the request was still a miss, and answering a
788    /// missing page with the wrong status would be a second bug wearing the first one's
789    /// clothes.
790    fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
791        let builder = response(StatusCode::NOT_FOUND).header("Cache-Control", "no-store");
792
793        let Some(page) = &self.not_found_page else {
794            return text(builder, format!("{fallback}\n"));
795        };
796        let Ok(body) = std::fs::read(page) else {
797            return text(builder, format!("{fallback}\n"));
798        };
799
800        text(
801            builder.header("Content-Type", "text/html; charset=utf-8"),
802            body,
803        )
804    }
805
806    /// Run the server on a specific address with a configurable header-read timeout.
807    ///
808    /// Spawns the server in a background Tokio task and returns immediately with the
809    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
810    /// stop accepting new connections and wait for in-flight connections to finish.
811    /// Dropping the handle instead leaves the server running for the life of the process.
812    ///
813    /// # Header-Read Timeout
814    ///
815    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
816    /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
817    /// timeout applies only to the header-read phase — once a complete header block has been
818    /// read, the connection is handed off with no further time bound, so long-lived response
819    /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
820    /// off mid-stream.
821    ///
822    /// # Precompressed Sidecars
823    ///
824    /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
825    /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
826    /// served instead with a matching `Content-Encoding`. Every file response carries
827    /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
828    /// differently-capable client.
829    ///
830    /// # Arguments
831    ///
832    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
833    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
834    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
835    ///
836    /// # Returns
837    ///
838    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
839    /// - `Err(StaticError::Io)` if binding to the socket fails.
840    /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
841    ///   is not found on `PATH`. Checked before the listener binds: a deployment whose
842    ///   configured pipeline can never run should fail visibly at boot, not be discovered
843    ///   later as a missing/stale asset.
844    pub async fn run_on(
845        &self,
846        addr: SocketAddr,
847        header_timeout: Duration,
848    ) -> Result<(u16, ServerHandle), StaticError> {
849        for (binary, install_hint) in self.required_tool_binaries() {
850            if !tool::locate_on_path(binary) {
851                return Err(StaticError::PipelineSetup(format!(
852                    "{binary} not found on PATH ({install_hint})"
853                )));
854            }
855        }
856
857        let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
858        let port = listener.local_addr().map_err(StaticError::Io)?.port();
859
860        let mut server = self.clone();
861        if server.live_reload {
862            let broadcaster = Broadcaster::new();
863
864            // The build pipelines react to SOURCE folders only; the output dir is never
865            // watched. Watching the output would feed each pipeline its own writes back
866            // into its trigger — the feedback loop this layering exists to prevent.
867            if server.has_pipeline() {
868                let pipeline = Arc::new(SourcePipeline::new(
869                    server.source_folders.clone(),
870                    server.bundle_roots.clone(),
871                    server.asset_folders.clone(),
872                    server.output_dir.clone(),
873                    server.css_tool.clone(),
874                    server.js_tool.clone(),
875                    server.prune_output,
876                    broadcaster.clone(),
877                ));
878                let mut rx = broadcaster.subscribe();
879                tokio::spawn(async move {
880                    // One-shot startup build (and optional prune) first, so the earliest
881                    // request already sees fresh output rather than yesterday's.
882                    if let Err(e) = pipeline.full_build().await {
883                        eprintln!("source pipeline build error: {e}");
884                    }
885                    while let Some(event) = rx.recv().await {
886                        if let Err(e) = pipeline
887                            .process_change(&event.path, &event.change_type)
888                            .await
889                        {
890                            eprintln!("source pipeline error: {e}");
891                        }
892                    }
893                });
894            }
895
896            for dir in server.watch_targets() {
897                start_watching(Arc::new(dir), broadcaster.clone());
898            }
899
900            server.broadcaster = Some(broadcaster);
901        } else if server.has_pipeline() {
902            // No live-reload: still run the one-shot build so a release boot reflects the
903            // current sources. The broadcaster is a throwaway — there is no browser to
904            // notify, so broadcasting into it is a no-op.
905            let pipeline = Arc::new(SourcePipeline::new(
906                server.source_folders.clone(),
907                server.bundle_roots.clone(),
908                server.asset_folders.clone(),
909                server.output_dir.clone(),
910                server.css_tool.clone(),
911                server.js_tool.clone(),
912                server.prune_output,
913                Broadcaster::new(),
914            ));
915            tokio::spawn(async move {
916                if let Err(e) = pipeline.full_build().await {
917                    eprintln!("source pipeline build error: {e}");
918                }
919            });
920        }
921        let semaphore = Arc::new(Semaphore::new(server.max_connections));
922        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
923
924        let accept_task = tokio::spawn(async move {
925            let mut backoff = ACCEPT_BACKOFF_INITIAL;
926            let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
927            let mut shutdown_pin = std::pin::pin!(shutdown_rx);
928            let mut shutting_down = false;
929
930            loop {
931                if !shutting_down {
932                    // The accept-and-permit step and the shutdown signal race in a single
933                    // `select!` so shutdown can preempt a pending accept or a permit wait
934                    // cleanly, at any point — not just between loop iterations.
935                    tokio::select! {
936                        accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
937                            match accepted {
938                                Some((stream, permit)) => {
939                                    let server = server.clone();
940                                    join_set.spawn(async move {
941                                        let _permit = permit;
942                                        serve_connection(stream, server, header_timeout).await;
943                                    });
944                                }
945                                None => shutting_down = true,
946                            }
947                        }
948                        _ = shutdown_pin.as_mut() => {
949                            shutting_down = true;
950                        }
951                    }
952                    continue;
953                }
954
955                // Stop accepting; drain already-spawned connections before returning.
956                match join_set.join_next().await {
957                    Some(_) => continue,
958                    None => break,
959                }
960            }
961        });
962
963        Ok((
964            port,
965            ServerHandle {
966                shutdown_tx: Some(shutdown_tx),
967                accept_task,
968            },
969        ))
970    }
971
972    /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
973    ///
974    /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
975    /// sidecar semantics, and for what the returned [`ServerHandle`] does.
976    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
977        self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
978            .await
979    }
980
981    /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
982    ///
983    /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
984    /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
985    /// semantics, and for what the returned [`ServerHandle`] does.
986    pub async fn run_all(
987        &self,
988        port: u16,
989        header_timeout: Duration,
990    ) -> Result<(u16, ServerHandle), StaticError> {
991        self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
992            .await
993    }
994
995    /// Run the server on loopback with the default 30-second header-read timeout.
996    ///
997    /// The recommended entry point for tests and lightweight services that don't need a
998    /// custom timeout. Thin wrapper around [`Server::run`].
999    ///
1000    /// # Example
1001    ///
1002    /// ```no_run
1003    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1004    /// use mini_static::Server;
1005    /// use std::path::Path;
1006    ///
1007    /// let server = Server::new(Path::new("./public"))?;
1008    /// let (port, handle) = server.run_ephemeral().await?;
1009    /// println!("Server ready on http://127.0.0.1:{}", port);
1010    /// handle.shutdown().await;
1011    /// # Ok(())
1012    /// # }
1013    /// ```
1014    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
1015        self.run(DEFAULT_HEADER_TIMEOUT).await
1016    }
1017
1018    /// Produce the HTTP response for a request, streaming file bodies to the client.
1019    ///
1020    /// This is the crate's single request-handling path: the `run*` accept loop calls it,
1021    /// and so should any async server embedding `mini-static` as a fallback route (e.g.
1022    /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
1023    /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
1024    ///
1025    /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
1026    /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
1027    /// response regardless of file size.
1028    ///
1029    /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
1030    /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
1031    /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
1032    /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
1033    /// response never discloses whether a path exists outside the root.
1034    pub async fn handle_request(
1035        &self,
1036        method: &Method,
1037        request_path: &str,
1038        headers: &HeaderMap,
1039    ) -> Response<ResponseBody> {
1040        if method != Method::GET && method != Method::HEAD {
1041            return text(
1042                response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
1043                "method not allowed\n",
1044            );
1045        }
1046
1047        // Live-reload SSE stream — only reachable when `with_live_reload()` was called
1048        // and the server was started via a `run*` method (those are the only paths that
1049        // populate `broadcaster`).
1050        if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
1051            if let Some(broadcaster) = &self.broadcaster {
1052                return finish(
1053                    response(StatusCode::OK)
1054                        .header("Content-Type", "text/event-stream")
1055                        .header("Cache-Control", "no-cache")
1056                        .header("Connection", "keep-alive")
1057                        .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
1058                );
1059            }
1060        }
1061
1062        // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
1063        // request). Running those directly in this `async fn` would block whichever
1064        // Tokio worker thread happens to be driving it, stalling every other task
1065        // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
1066        // moves the work onto Tokio's dedicated blocking thread pool instead.
1067        let server = self.clone();
1068        let owned_request_path = request_path.to_string();
1069        let resolved =
1070            tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
1071        let path = match resolved {
1072            Err(_) => return internal_error_response(),
1073            Ok(Err(e)) => return self.not_found_response(e.user_message()),
1074            Ok(Ok(path)) => path,
1075        };
1076
1077        // A directory served via its `index.html` needs a trailing slash to establish the
1078        // correct base for the page's relative links. Compare against the *decoded*
1079        // request path so a percent-encoded explicit request for index.html (e.g.
1080        // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
1081        // still-encoded, broken Location.
1082        let decoded_request_path = resolve::decode_request_path(request_path);
1083        if path.file_name().is_some_and(|name| name == "index.html")
1084            && !decoded_request_path.ends_with('/')
1085            && !decoded_request_path.ends_with("index.html")
1086        {
1087            // `location` is built from the (attacker-controlled) request path; `finish()`
1088            // degrades to 400 instead of panicking if it ever contains bytes invalid in a
1089            // header value.
1090            let location = format!("{}/", request_path.trim_end_matches('/'));
1091            return text(
1092                response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
1093                "moved\n",
1094            );
1095        }
1096
1097        let Ok(file) = File::open(&path).await else {
1098            return internal_error_response();
1099        };
1100        let Ok(metadata) = file.metadata().await else {
1101            return internal_error_response();
1102        };
1103
1104        let content_type = mime_type_for_path(&path);
1105        // Live-reload and spa-mode HTML injection both need the original, uncompressed
1106        // bytes to splice their script into — never substitute a precompressed sidecar on
1107        // this path. `broadcaster` is only `Some` when live-reload is enabled (see
1108        // `Server::with_live_reload`); `spa_mode` is independent of it (see
1109        // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
1110        // injection.
1111        let html_injection =
1112            (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1113
1114        let range_header = header_str(headers, "range");
1115        let if_range_header = header_str(headers, "if-range");
1116
1117        let accept_encoding = header_str(headers, "accept-encoding");
1118        // Skip precompressed sidecars when Range is requested (serve original file instead).
1119        let sidecar = if html_injection || range_header.is_some() {
1120            None
1121        } else {
1122            select_precompressed_sidecar(&path, accept_encoding).await
1123        };
1124        let (mut file, metadata, content_encoding) = match sidecar {
1125            Some((sidecar_file, sidecar_metadata, encoding)) => {
1126                (sidecar_file, sidecar_metadata, Some(encoding))
1127            }
1128            None => (file, metadata, None),
1129        };
1130
1131        // HTML injection is skipped for a served precompressed sidecar (already final
1132        // bytes from a build step) — see `html_injection`'s definition above.
1133        let etag = generate_etag(&metadata);
1134        let cache_control = self.cache_control_for(&path);
1135
1136        if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1137            return finish(
1138                Response::builder()
1139                    .status(StatusCode::NOT_MODIFIED)
1140                    .header("Cache-Control", cache_control)
1141                    .header("Vary", "Accept-Encoding")
1142                    .header("ETag", etag)
1143                    .header("Accept-Ranges", "bytes")
1144                    .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1145            );
1146        }
1147
1148        // `Some` when the served representation differs from the file's raw bytes and had
1149        // to be built in memory; `None` means stream the open file as-is. Computed before
1150        // the HEAD check below because RFC 9110 requires a HEAD response's headers —
1151        // `Content-Length` included — to match what a GET would send, even though the body
1152        // itself is dropped.
1153        let transformed: Option<Bytes> = if html_injection {
1154            let mut html = Vec::with_capacity(metadata.len() as usize);
1155            if file.read_to_end(&mut html).await.is_err() {
1156                return internal_error_response();
1157            }
1158            if self.broadcaster.is_some() {
1159                reload::inject_reload_script(&mut html);
1160            }
1161            if self.spa_mode {
1162                spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
1163            }
1164            Some(Bytes::from(html))
1165        } else {
1166            None
1167        };
1168
1169        let file_size = transformed
1170            .as_ref()
1171            .map_or(metadata.len(), |bytes| bytes.len() as u64);
1172
1173        // Handle Range requests.
1174        let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1175        let range_check = if let Some(outcome) = &range_outcome {
1176            match outcome {
1177                RangeOutcome::Satisfiable(start, end) => {
1178                    // If-Range validation: stale If-Range ignores Range, serves full 200.
1179                    if let Some(if_range) = if_range_header {
1180                        if !if_range_valid(if_range, &etag) {
1181                            RangeCheck::IgnoreRange
1182                        } else {
1183                            RangeCheck::Satisfiable(*start, *end)
1184                        }
1185                    } else {
1186                        RangeCheck::Satisfiable(*start, *end)
1187                    }
1188                }
1189                RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1190                RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1191                RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1192            }
1193        } else {
1194            RangeCheck::IgnoreRange
1195        };
1196
1197        match &range_check {
1198            RangeCheck::Unsatisfiable => {
1199                return finish(
1200                    Response::builder()
1201                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
1202                        .header("Content-Range", format!("bytes */{}", file_size))
1203                        .header("Accept-Ranges", "bytes")
1204                        .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1205                );
1206            }
1207            RangeCheck::Satisfiable(start, end) => {
1208                let range_len = end - start + 1;
1209
1210                // Seek to start position; if sidecar, we already skipped it above.
1211                if transformed.is_none()
1212                    && file.seek(std::io::SeekFrom::Start(*start)).await.is_err()
1213                {
1214                    return internal_error_response();
1215                }
1216
1217                // HEAD must not return a body (RFC 9110).
1218                let body = if *method == Method::HEAD {
1219                    ResponseBody::Buffered(Full::new(Bytes::new()))
1220                } else {
1221                    match transformed {
1222                        Some(ref bytes) => ResponseBody::Buffered(Full::new(
1223                            bytes.slice(*start as usize..(*end as usize + 1)),
1224                        )),
1225                        None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1226                    }
1227                };
1228
1229                let mut builder = Response::builder()
1230                    .status(StatusCode::PARTIAL_CONTENT)
1231                    .header("Content-Type", content_type)
1232                    .header("Content-Length", range_len.to_string())
1233                    .header(
1234                        "Content-Range",
1235                        format!("bytes {}-{}/{}", start, end, file_size),
1236                    )
1237                    .header("Cache-Control", cache_control)
1238                    .header("Vary", "Accept-Encoding")
1239                    .header("ETag", etag)
1240                    .header("Accept-Ranges", "bytes");
1241                if let Some(encoding) = content_encoding {
1242                    builder = builder.header("Content-Encoding", encoding);
1243                }
1244                return finish(builder.body(body));
1245            }
1246            RangeCheck::IgnoreRange => {}
1247        }
1248
1249        // HEAD must not return a body (RFC 9110).
1250        let body = if *method == Method::HEAD {
1251            ResponseBody::Buffered(Full::new(Bytes::new()))
1252        } else {
1253            match transformed {
1254                Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1255                None => ResponseBody::Streamed(FileBody::new(file)),
1256            }
1257        };
1258
1259        let mut builder = response(StatusCode::OK)
1260            .header("Content-Type", content_type)
1261            .header("Content-Length", file_size.to_string())
1262            .header("Cache-Control", cache_control)
1263            .header("Vary", "Accept-Encoding")
1264            .header("ETag", etag)
1265            .header("Accept-Ranges", "bytes");
1266        if let Some(encoding) = content_encoding {
1267            builder = builder.header("Content-Encoding", encoding);
1268        }
1269        finish(builder.body(body))
1270    }
1271}
1272
1273/// Ceiling on how many bytes `read_header_prefix` buffers before giving up. Without this,
1274/// a client that trickles bytes forever without ever sending the terminating blank line
1275/// could grow the buffer without limit — the header-read timeout alone doesn't bound
1276/// memory, only wall-clock time, and a sufficiently patient sender could still send
1277/// unbounded data before the deadline fires.
1278const MAX_HEADER_BYTES: usize = 64 * 1024;
1279
1280/// Why `read_header_prefix` gave up before seeing a complete header block. Every variant
1281/// is a legitimate reason to drop the connection — none is treated specially by the
1282/// caller today, but the distinction is worth preserving for anyone debugging this later.
1283#[derive(Debug)]
1284enum HeaderReadError {
1285    /// The client closed the connection (or shut down its write half) before sending a
1286    /// complete header block.
1287    ConnectionClosed,
1288    /// More than `MAX_HEADER_BYTES` were buffered without seeing `\r\n\r\n`.
1289    TooLarge,
1290    /// The underlying socket read failed. Kept rather than discarded so a future `log`
1291    /// feature has the real I/O error to report instead of an opaque unit variant.
1292    #[allow(dead_code)]
1293    Io(std::io::Error),
1294}
1295
1296/// Reads from `stream` until a complete HTTP header block (`\r\n\r\n`) has been buffered,
1297/// returning every byte read so far — which may include bytes past the header block
1298/// (request body, or a second pipelined request) if the client sent them in the same
1299/// read. Callers pair this with `tokio::time::timeout` to bound how long the header phase
1300/// itself may take; this function has no timeout of its own, only the size ceiling in
1301/// `MAX_HEADER_BYTES`.
1302async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1303    let mut buf = Vec::new();
1304    let mut chunk = [0u8; 4096];
1305
1306    loop {
1307        let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1308        if n == 0 {
1309            return Err(HeaderReadError::ConnectionClosed);
1310        }
1311        buf.extend_from_slice(&chunk[..n]);
1312
1313        if buf.len() > MAX_HEADER_BYTES {
1314            return Err(HeaderReadError::TooLarge);
1315        }
1316        // Only the tail can hold a terminator this read completed: the `n` new bytes plus
1317        // the 3 before them. Rescanning the whole buffer every time would make the header
1318        // read quadratic in the bytes received.
1319        let scan_from = buf.len().saturating_sub(n + 3);
1320        if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1321            return Ok(buf);
1322        }
1323    }
1324}
1325
1326/// Wraps an accepted `TcpStream` whose header block has already been drained into
1327/// `prefix` (by `read_header_prefix`, ahead of the connection being handed to hyper).
1328/// Reads replay `prefix` first, then fall through to the live socket — so hyper sees
1329/// exactly the byte stream it would have seen without the pre-read, just sourced from two
1330/// buffers back-to-back instead of one continuous one. Writes pass straight through.
1331struct PrefixedIo {
1332    prefix: Bytes,
1333    prefix_pos: usize,
1334    inner: TcpStream,
1335}
1336
1337impl PrefixedIo {
1338    fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1339        PrefixedIo {
1340            prefix: Bytes::from(prefix),
1341            prefix_pos: 0,
1342            inner,
1343        }
1344    }
1345}
1346
1347impl AsyncRead for PrefixedIo {
1348    fn poll_read(
1349        self: Pin<&mut Self>,
1350        cx: &mut Context<'_>,
1351        buf: &mut ReadBuf<'_>,
1352    ) -> Poll<std::io::Result<()>> {
1353        let this = self.get_mut();
1354        if this.prefix_pos < this.prefix.len() {
1355            let remaining = &this.prefix[this.prefix_pos..];
1356            let n = remaining.len().min(buf.remaining());
1357            buf.put_slice(&remaining[..n]);
1358            this.prefix_pos += n;
1359            return Poll::Ready(Ok(()));
1360        }
1361        Pin::new(&mut this.inner).poll_read(cx, buf)
1362    }
1363}
1364
1365impl AsyncWrite for PrefixedIo {
1366    fn poll_write(
1367        self: Pin<&mut Self>,
1368        cx: &mut Context<'_>,
1369        buf: &[u8],
1370    ) -> Poll<std::io::Result<usize>> {
1371        Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1372    }
1373
1374    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1375        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1376    }
1377
1378    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1379        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1380    }
1381}
1382
1383/// Wires an accepted connection up to the hyper HTTP/1 service.
1384///
1385/// `header_timeout` bounds only the header-read phase (`read_header_prefix`, run before
1386/// hyper ever sees the connection). Once a complete header block has been read, the
1387/// connection is handed to hyper with no further time bound — deliberately, since a
1388/// response body may legitimately outlive `header_timeout` by design (the live-reload SSE
1389/// stream is the motivating case: it stays open until a watched file changes, which may
1390/// be minutes or hours after the request). Wrapping the whole connection lifetime in
1391/// `header_timeout` — the prior implementation — silently truncated exactly that stream
1392/// once `header_timeout` elapsed, aborting the response mid-write after headers had
1393/// already been sent (the client observes this as a chunked-encoding error, not a clean
1394/// close). The connection-count ceiling (`Server::with_max_connections`) is what bounds
1395/// resource use from connections held open indefinitely, not this timeout.
1396async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1397    let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1398        Ok(Ok(prefix)) => prefix,
1399        Ok(Err(_)) | Err(_) => return,
1400    };
1401
1402    let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1403    let svc = service_fn(move |req: Request<Incoming>| {
1404        let server = server.clone();
1405        async move {
1406            let resp = server
1407                .handle_request(req.method(), req.uri().path(), req.headers())
1408                .await;
1409            Ok::<_, Infallible>(resp)
1410        }
1411    });
1412    let _ = AutoBuilder::new(TokioExecutor::new())
1413        .serve_connection(io, svc)
1414        .await;
1415}
1416
1417/// Default header-read timeout used by [`Server::run_ephemeral`].
1418const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1419
1420/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1421/// finish on their own before aborting whatever is left. A connection with no
1422/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1423/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1424/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1425/// shutdown is no exception.
1426const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1427
1428/// A handle to a server started by one of the `Server::run*` methods.
1429///
1430/// Dropping this handle without calling `shutdown()` leaves the server running in the
1431/// background for the life of the process. Call `shutdown()` to stop accepting new
1432/// connections and wait for already-accepted connections to finish before returning.
1433pub struct ServerHandle {
1434    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1435    accept_task: tokio::task::JoinHandle<()>,
1436}
1437
1438impl ServerHandle {
1439    /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1440    /// (5s) for in-flight connections to finish on their own. Equivalent to
1441    /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1442    /// happens to connections still open once the grace period elapses.
1443    pub async fn shutdown(self) {
1444        self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1445            .await;
1446    }
1447
1448    /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1449    /// connections to finish on their own.
1450    ///
1451    /// Connections still open once `drain_timeout` elapses are aborted rather than
1452    /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1453    /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1454    /// which in turn drops each connection's socket, closing it. This is what bounds
1455    /// shutdown when a connection has no natural end of its own (the live-reload SSE
1456    /// stream is the motivating case: it stays open until a watched file changes, which
1457    /// may never happen before the process needs to exit).
1458    pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1459        if let Some(tx) = self.shutdown_tx.take() {
1460            let _ = tx.send(());
1461        }
1462        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1463            self.accept_task.abort();
1464        }
1465    }
1466}
1467
1468/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1469fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1470    headers.get(name).and_then(|value| value.to_str().ok())
1471}
1472
1473/// Start a response carrying the baseline security header every response in this crate
1474/// sends. The 304 path is the one exception and builds its own — a 304 repeats only the
1475/// caching validators, not the full header set.
1476fn response(status: StatusCode) -> Builder {
1477    Response::builder()
1478        .status(status)
1479        .header("X-Content-Type-Options", "nosniff")
1480}
1481
1482/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1483/// allocate; owned bodies are moved in.
1484fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1485    finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1486}
1487
1488/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1489/// header value turns out to be invalid for use as an HTTP header value.
1490///
1491/// Every header value that reaches `Response::builder()` in this module is either a
1492/// static string or formatted from internal, already-validated data (a byte count, an
1493/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1494/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1495/// production panic the day someone adds a header built from new input without
1496/// re-deriving that guarantee. Routing every response through this one fallible path
1497/// means that mistake fails safe instead of panicking.
1498fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1499    built.unwrap_or_else(|_| bad_request_response())
1500}
1501
1502// `internal_error_response()` and `bad_request_response()` are the fallback responses
1503// `finish()` itself degrades to — every header and body here is a fixed string with no
1504// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1505// without it degrading to itself on failure.
1506fn internal_error_response() -> Response<ResponseBody> {
1507    response(StatusCode::INTERNAL_SERVER_ERROR)
1508        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1509            b"internal server error\n",
1510        ))))
1511        .unwrap()
1512}
1513
1514fn bad_request_response() -> Response<ResponseBody> {
1515    response(StatusCode::BAD_REQUEST)
1516        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1517            b"bad request\n",
1518        ))))
1519        .unwrap()
1520}
1521
1522/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1523/// variant, in preference order — brotli wins when a client accepts both and both
1524/// sidecars exist.
1525const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1526
1527/// Whether `accept_encoding` allows `encoding`.
1528///
1529/// Matches by substring rather than parsing `q`-value weights or the `identity`/`*`
1530/// directives — a lighter-weight negotiation than a general HTTP client would need,
1531/// sufficient for deciding between two static sidecar files.
1532fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1533    accept_encoding.is_some_and(|header| header.contains(encoding))
1534}
1535
1536/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1537/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1538///
1539/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1540/// The sidecar path is built by appending an extension to it — never by re-resolving a
1541/// modified request path — so this lookup can't become a second traversal surface: any
1542/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1543async fn select_precompressed_sidecar(
1544    path: &Path,
1545    accept_encoding: Option<&str>,
1546) -> Option<(File, fs::Metadata, &'static str)> {
1547    for (encoding, ext) in SIDECAR_ENCODINGS {
1548        if !accepts_encoding(accept_encoding, encoding) {
1549            continue;
1550        }
1551        let mut sidecar = path.as_os_str().to_os_string();
1552        sidecar.push(ext);
1553        let sidecar_path = PathBuf::from(sidecar);
1554
1555        // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1556        // must stay in the same directory as `path` (which `resolve()` already proved is
1557        // inside root). `ext` is always one of the two static literals in
1558        // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1559        // a future change starts deriving `sidecar` some other way.
1560        debug_assert_eq!(
1561            sidecar_path.parent(),
1562            path.parent(),
1563            "sidecar path must stay in the same directory as the already-resolved path"
1564        );
1565
1566        if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1567            if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1568                return Some((sidecar_file, sidecar_metadata, encoding));
1569            }
1570        }
1571    }
1572    None
1573}
1574
1575/// Generate an ETag for a file based on modification time and size.
1576///
1577/// Format: `"<size>-<mtime_secs>"`
1578fn generate_etag(metadata: &fs::Metadata) -> String {
1579    let mtime = metadata
1580        .modified()
1581        .ok()
1582        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1583        .map(|d| d.as_secs())
1584        .unwrap_or(0);
1585    format!("\"{}-{}\"", metadata.len(), mtime)
1586}
1587
1588/// Determine MIME type from file path extension.
1589fn mime_type_for_path(path: &Path) -> &'static str {
1590    let ext = path
1591        .extension()
1592        .and_then(|ext| ext.to_str())
1593        .unwrap_or_default()
1594        .to_lowercase();
1595
1596    match ext.as_str() {
1597        "html" | "htm" => "text/html; charset=utf-8",
1598        "css" => "text/css; charset=utf-8",
1599        "js" => "application/javascript; charset=utf-8",
1600        "json" => "application/json; charset=utf-8",
1601        "svg" => "image/svg+xml",
1602        "png" => "image/png",
1603        "jpg" | "jpeg" => "image/jpeg",
1604        "gif" => "image/gif",
1605        "webp" => "image/webp",
1606        "ico" => "image/x-icon",
1607        "woff" => "font/woff",
1608        "woff2" => "font/woff2",
1609        "ttf" => "font/ttf",
1610        "md" | "markdown" => "text/markdown; charset=utf-8",
1611        "txt" => "text/plain; charset=utf-8",
1612        "xml" => "application/xml",
1613        "pdf" => "application/pdf",
1614        "zip" => "application/zip",
1615        _ => "application/octet-stream",
1616    }
1617}
1618
1619/// Check if the If-None-Match header matches the current ETag.
1620/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1621fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1622    if if_none_match == "*" {
1623        return true;
1624    }
1625    if_none_match.split(',').any(|tag| tag.trim() == etag)
1626}
1627
1628#[derive(Debug)]
1629enum RangeOutcome {
1630    NoRange,
1631    Satisfiable(u64, u64),
1632    Unsatisfiable,
1633    MultiRangeIgnored,
1634}
1635
1636enum RangeCheck {
1637    IgnoreRange,
1638    Satisfiable(u64, u64),
1639    Unsatisfiable,
1640}
1641
1642fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1643    let header = header.trim();
1644    if !header.starts_with("bytes=") {
1645        return RangeOutcome::NoRange;
1646    }
1647
1648    let range_spec = &header[6..];
1649
1650    if range_spec.contains(',') {
1651        return RangeOutcome::MultiRangeIgnored;
1652    }
1653
1654    if let Some(suffix_pos) = range_spec.find('-') {
1655        if suffix_pos == 0 {
1656            let suffix_len_str = &range_spec[1..];
1657            if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1658                if suffix_len == 0 {
1659                    return RangeOutcome::Unsatisfiable;
1660                }
1661                if suffix_len >= file_size {
1662                    return RangeOutcome::Satisfiable(0, file_size - 1);
1663                }
1664                return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1665            }
1666            return RangeOutcome::Unsatisfiable;
1667        }
1668
1669        let start_str = &range_spec[..suffix_pos];
1670        let end_str = &range_spec[suffix_pos + 1..];
1671
1672        if let Ok(start) = start_str.parse::<u64>() {
1673            if start >= file_size {
1674                return RangeOutcome::Unsatisfiable;
1675            }
1676
1677            if end_str.is_empty() {
1678                return RangeOutcome::Satisfiable(start, file_size - 1);
1679            }
1680
1681            if let Ok(end) = end_str.parse::<u64>() {
1682                if end < start {
1683                    return RangeOutcome::Unsatisfiable;
1684                }
1685                let clamped_end = (end + 1).min(file_size) - 1;
1686                if start > clamped_end {
1687                    return RangeOutcome::Unsatisfiable;
1688                }
1689                return RangeOutcome::Satisfiable(start, clamped_end);
1690            }
1691        }
1692    }
1693
1694    RangeOutcome::Unsatisfiable
1695}
1696
1697fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1698    if_range_header.trim() == current_etag
1699}
1700
1701#[cfg(test)]
1702#[path = "../tests/unit/server/precompressed_sidecar.rs"]
1703mod precompressed_sidecar_tests;
1704
1705#[cfg(test)]
1706#[path = "../tests/unit/server/file_body.rs"]
1707mod file_body_tests;
1708
1709#[cfg(test)]
1710#[path = "../tests/unit/server/accept.rs"]
1711mod accept_tests;
1712
1713#[cfg(test)]
1714#[path = "../tests/unit/server/finish.rs"]
1715mod finish_tests;
1716
1717#[cfg(test)]
1718#[path = "../tests/unit/server/header_prefix.rs"]
1719mod header_prefix_tests;
1720
1721#[cfg(test)]
1722#[path = "../tests/unit/server/css_bundle.rs"]
1723mod css_bundle_tests;
1724
1725#[cfg(test)]
1726#[path = "../tests/unit/server/asset_folder.rs"]
1727mod asset_folder_tests;
1728
1729#[cfg(test)]
1730#[path = "../tests/unit/server/build_once.rs"]
1731mod build_once_tests;
1732
1733#[cfg(test)]
1734#[path = "../tests/unit/server/range_header.rs"]
1735mod range_header_tests;