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