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, 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::tool;
33use crate::watcher::{start_watching, Broadcaster};
34
35const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
36const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
37
38/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
39const DEFAULT_MAX_CONNECTIONS: usize = 1024;
40
41/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
42/// can be exercised against a listener that fails on demand, without needing to provoke
43/// real OS-level accept errors (e.g. EMFILE) in tests.
44trait TcpAccept {
45 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
46}
47
48impl TcpAccept for TcpListener {
49 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
50 TcpListener::accept(self).await
51 }
52}
53
54/// Accept a connection and reserve it a connection-limit permit.
55///
56/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
57/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
58/// so a sustained failure — the process being out of file descriptors, say — degrades
59/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
60///
61/// Returns `None` only if the semaphore itself has been closed (never happens in normal
62/// operation, since nothing ever calls `close()` on it — handled so a caller can still
63/// fail safely rather than panic).
64async fn accept_and_permit<L: TcpAccept>(
65 listener: &L,
66 backoff: &mut Duration,
67 semaphore: &Arc<Semaphore>,
68) -> Option<(TcpStream, OwnedSemaphorePermit)> {
69 loop {
70 let stream = match listener.accept().await {
71 Ok((stream, _)) => {
72 *backoff = ACCEPT_BACKOFF_INITIAL;
73 stream
74 }
75 Err(_) => {
76 tokio::time::sleep(*backoff).await;
77 *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
78 continue;
79 }
80 };
81 return semaphore
82 .clone()
83 .acquire_owned()
84 .await
85 .ok()
86 .map(|permit| (stream, permit));
87 }
88}
89
90/// A predicate deciding whether a resolved file path should get an immutable cache
91/// policy; see [`Server::with_immutable_assets`].
92type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
93
94/// A static file server for serving files securely from a root directory.
95///
96/// `Server` canonicalizes the root directory once at creation time and uses the
97/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
98///
99/// # Security
100///
101/// The server protects against:
102/// - Path traversal attacks (e.g., `../../etc/passwd`)
103/// - Accessing files outside the root via symlinks
104/// - Disclosing filesystem structure (traversal and missing files both return 404)
105///
106/// # Cloning
107///
108/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
109/// predicate closure. Multiple clones can be used concurrently in async tasks without
110/// synchronization overhead.
111///
112/// # Example
113///
114/// ```no_run
115/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
116/// use mini_static::Server;
117/// use std::path::Path;
118/// use std::time::Duration;
119///
120/// let server = Server::new(Path::new("./public"))?;
121/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
122/// println!("Server running on port {}", port);
123/// # Ok(())
124/// # }
125/// ```
126#[derive(Clone)]
127pub struct Server {
128 root_canon: PathBuf,
129 bundle_roots: Vec<PathBuf>,
130 max_connections: usize,
131 live_reload: bool,
132 broadcaster: Option<Broadcaster>,
133 immutable_predicate: Option<ImmutablePredicate>,
134 source_folders: Vec<PathBuf>,
135 asset_folders: Vec<PathBuf>,
136 output_dir: PathBuf,
137 css_tool: Option<(CssTool, CssOptions)>,
138 js_tool: Option<(JsTool, JsOptions)>,
139 prune_output: bool,
140}
141
142/// True when two canonical paths are the same path or one contains the other.
143///
144/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
145/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
146/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
147/// cannot drift apart.
148fn paths_overlap(a: &Path, b: &Path) -> bool {
149 a.starts_with(b) || b.starts_with(a)
150}
151
152impl Server {
153 /// Create a new server with the given root directory.
154 ///
155 /// Canonicalizes the root once at startup. All subsequent requests use the
156 /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
157 ///
158 /// # Errors
159 ///
160 /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
161 /// no read permissions).
162 pub fn new(root: &Path) -> Result<Self, StaticError> {
163 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
164 let output_dir = root_canon.clone();
165 Ok(Server {
166 root_canon,
167 bundle_roots: Vec::new(),
168 max_connections: DEFAULT_MAX_CONNECTIONS,
169 live_reload: false,
170 broadcaster: None,
171 immutable_predicate: None,
172 source_folders: Vec::new(),
173 asset_folders: Vec::new(),
174 output_dir,
175 css_tool: None,
176 js_tool: None,
177 prune_output: false,
178 })
179 }
180
181 /// Set the maximum number of connections served concurrently (default 1024).
182 ///
183 /// Once this many connections are in flight, `run()`'s accept loop stops accepting
184 /// new ones — without pausing the accept loop, a client that opens a connection and
185 /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
186 /// otherwise be used, in enough parallel copies, to exhaust the process's file
187 /// descriptors or memory with no bound at all.
188 pub fn with_max_connections(mut self, max: usize) -> Self {
189 self.max_connections = max;
190 self
191 }
192
193 /// Enable live-reload for this server (disabled by default).
194 ///
195 /// Once enabled, the `run*` methods start a background watcher (mtime polling,
196 /// bounded 500ms interval — see [`crate::start_watching`]) over the server's root
197 /// the first time the server actually starts accepting connections, and:
198 ///
199 /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
200 /// change event (with [`crate::ChangeType`]) whenever a served file is added,
201 /// modified, or removed;
202 /// - inject a small `<script>` into every served `text/html` response that connects
203 /// to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
204 /// changes) — no manual client wiring required.
205 ///
206 /// This is meant for local development, not production: leave it disabled (the
207 /// default) for any server serving real traffic. A typical call site gates it behind
208 /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
209 /// injected script.
210 ///
211 /// # Example
212 ///
213 /// ```no_run
214 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
215 /// use mini_static::Server;
216 /// use std::path::Path;
217 ///
218 /// let server = Server::new(Path::new("./public"))?;
219 /// #[cfg(debug_assertions)]
220 /// let server = server.with_live_reload();
221 /// # Ok(())
222 /// # }
223 /// ```
224 pub fn with_live_reload(mut self) -> Self {
225 self.live_reload = true;
226 self
227 }
228
229 /// Serve files matching `predicate` with a long-lived, immutable cache policy
230 /// instead of the default `Cache-Control: no-cache`.
231 ///
232 /// `predicate` is evaluated against each resolved file's path; a match sends
233 /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
234 /// responses. This is correct only for fingerprinted assets (e.g.
235 /// `main.a1b2c3.js`) where a content change always produces a new filename —
236 /// caching a mutable filename indefinitely would serve stale content to every
237 /// client that already has it cached.
238 ///
239 /// # Example
240 ///
241 /// ```no_run
242 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
243 /// use mini_static::Server;
244 /// use std::path::Path;
245 ///
246 /// let server = Server::new(Path::new("./public"))?
247 /// .with_immutable_assets(|path| {
248 /// path.file_name()
249 /// .and_then(|name| name.to_str())
250 /// .is_some_and(|name| name.contains(".fingerprint."))
251 /// });
252 /// # Ok(())
253 /// # }
254 /// ```
255 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
256 where
257 F: Fn(&Path) -> bool + Send + Sync + 'static,
258 {
259 self.immutable_predicate = Some(Arc::new(predicate));
260 self
261 }
262
263 /// The `Cache-Control` header value for a resolved file path: the immutable policy
264 /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
265 fn cache_control_for(&self, path: &Path) -> &'static str {
266 match &self.immutable_predicate {
267 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
268 _ => "no-cache",
269 }
270 }
271
272 /// Register `path` as an additional directory whose changes should trigger a CSS
273 /// bundle rebuild, alongside the registered source folders.
274 ///
275 /// Useful for build pipelines where CSS partials referenced via `@import` live in a
276 /// separate directory tree from the source folders proper: without registering that
277 /// tree here, editing a partial wouldn't be noticed by the watcher and the bundle
278 /// would go stale until something else touched it.
279 ///
280 /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
281 /// request-handling path never consult bundle roots. This is purely a watch target,
282 /// not a second served root, and — since `@import` resolution is delegated entirely
283 /// to the configured [`CssTool`] (see [`Server::with_css_tool`]) — not an `@import`
284 /// traversal boundary either; the external tool resolves its own imports with no
285 /// root mini-static can enforce.
286 ///
287 /// This method is fallible and canonicalizes the path once at call time, matching
288 /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
289 /// more than one external source tree.
290 ///
291 /// # Errors
292 ///
293 /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
294 pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
295 let canon = path.canonicalize().map_err(StaticError::Io)?;
296 self.bundle_roots.push(canon);
297 Ok(self)
298 }
299
300 /// Designate `dir` as a source folder whose changes drive the build pipelines.
301 ///
302 /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
303 /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
304 ///
305 /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
306 /// source folder that is also the output would feed every pipeline its own output — the
307 /// feedback loop this layering exists to prevent.
308 ///
309 /// # Errors
310 ///
311 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
312 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
313 pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
314 let canon = dir.canonicalize().map_err(StaticError::Io)?;
315
316 if paths_overlap(&canon, &self.output_dir) {
317 return Err(StaticError::Traversal(format!(
318 "source folder {} overlaps the output dir {}",
319 canon.display(),
320 self.output_dir.display()
321 )));
322 }
323 if self
324 .source_folders
325 .iter()
326 .chain(self.asset_folders.iter())
327 .any(|existing| paths_overlap(&canon, existing))
328 {
329 return Err(StaticError::Traversal(format!(
330 "source folder {} overlaps an already-registered source/asset folder",
331 canon.display()
332 )));
333 }
334
335 self.source_folders.push(canon);
336 Ok(self)
337 }
338
339 /// Designate `dir` as an asset source folder: every file under it (any extension)
340 /// is mirrored byte-identical into the output dir at server startup and on every
341 /// live-reload change — no CSS/JS transformation, just a flat copy preserving each
342 /// file's path relative to `dir`. Use this for hand-authored static files
343 /// (`index.html`, images) that should live outside the served/output dir as
344 /// source, the same source/output separation `with_source_folder`'s CSS/JS
345 /// pipelines already have.
346 ///
347 /// Rejected if `dir` overlaps the output dir or an already-registered
348 /// source/asset folder, for the same reason `with_source_folder` rejects it: a
349 /// folder that is also the output would feed the pipeline its own output.
350 ///
351 /// # Errors
352 ///
353 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
354 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another
355 /// registered source/asset folder.
356 pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
357 let canon = dir.canonicalize().map_err(StaticError::Io)?;
358
359 if paths_overlap(&canon, &self.output_dir) {
360 return Err(StaticError::Traversal(format!(
361 "asset folder {} overlaps the output dir {}",
362 canon.display(),
363 self.output_dir.display()
364 )));
365 }
366 if self
367 .source_folders
368 .iter()
369 .chain(self.asset_folders.iter())
370 .any(|existing| paths_overlap(&canon, existing))
371 {
372 return Err(StaticError::Traversal(format!(
373 "asset folder {} overlaps an already-registered source/asset folder",
374 canon.display()
375 )));
376 }
377
378 self.asset_folders.push(canon);
379 Ok(self)
380 }
381
382 /// Designate `dir` as the output directory processed outputs are written to.
383 ///
384 /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
385 /// react to source folders only, so a pipeline's own output can never re-trigger it.
386 /// Call this before `with_css_tool` so a bundle output path reflects the override.
387 ///
388 /// # Errors
389 ///
390 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
391 /// `Err(StaticError::Traversal)` if it overlaps a registered source or asset folder.
392 pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
393 let canon = dir.canonicalize().map_err(StaticError::Io)?;
394
395 if self
396 .source_folders
397 .iter()
398 .chain(self.asset_folders.iter())
399 .any(|existing| paths_overlap(&canon, existing))
400 {
401 return Err(StaticError::Traversal(format!(
402 "output dir {} overlaps a registered source/asset folder",
403 canon.display()
404 )));
405 }
406
407 self.output_dir = canon;
408 Ok(self)
409 }
410
411 /// Configure CSS bundling/minification via an external tool (disabled by default).
412 ///
413 /// `tool` is a preset naming the CLI mini-static invokes (see [`CssTool`]) —
414 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
415 /// [`Server::run_on`] fails fast at startup if it's missing. `options` selects
416 /// `bundle`/`minify` independently (see [`CssOptions`]):
417 ///
418 /// - Neither: every `.css` under the source folders is copied through unchanged,
419 /// mirrored into the output dir.
420 /// - `minify` only: each file is minified independently and mirrored (no `@import`
421 /// following).
422 /// - `bundle` only: every `.css` under the source folders is discovered,
423 /// `@import`-resolved, and concatenated into one output file, unminified.
424 /// - Both: the bundle above, minified.
425 ///
426 /// # Example
427 ///
428 /// ```no_run
429 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
430 /// use mini_static::{CssOptions, CssTool, Server};
431 /// use std::path::Path;
432 ///
433 /// let server = Server::new(Path::new("./public"))?
434 /// .with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true));
435 /// # Ok(())
436 /// # }
437 /// ```
438 pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
439 self.css_tool = Some((tool, options));
440 self
441 }
442
443 /// Configure JS bundling/minification via an external tool (disabled by default).
444 ///
445 /// `tool` is a preset naming the CLI mini-static invokes (see [`JsTool`]) —
446 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
447 /// [`Server::run_on`] fails fast at startup if it's missing. Unlike CSS, JS bundling
448 /// requires an explicit entry point ([`JsOptions::bundle_entry`]) since a JS module
449 /// graph has no well-defined "concatenate everything" meaning; without it, `options`
450 /// runs in per-file mode (every `.js`/`.mjs` under the source folders processed and
451 /// mirrored independently).
452 ///
453 /// # Errors
454 ///
455 /// Returns `Err(StaticError::Io)` if `options` specifies a bundle entry that cannot
456 /// be canonicalized, or `Err(StaticError::Traversal)` if it doesn't lie under a
457 /// registered source folder — checked eagerly here so a bad entry path fails at
458 /// configuration time, not on the first rebuild.
459 ///
460 /// # Example
461 ///
462 /// ```no_run
463 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
464 /// use mini_static::{JsOptions, JsTool, Server};
465 /// use std::path::Path;
466 ///
467 /// let server = Server::new(Path::new("./public"))?
468 /// .with_source_folder(Path::new("./js-src"))?
469 /// .with_js_tool(
470 /// JsTool::Esbuild,
471 /// JsOptions::new()
472 /// .bundle_entry(Path::new("./js-src/main.js"), "bundle.js")
473 /// .minify(true),
474 /// )?;
475 /// # Ok(())
476 /// # }
477 /// ```
478 pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
479 if let Some(entry) = options.entry() {
480 let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
481 let under_source_folder = self
482 .source_folders
483 .iter()
484 .any(|folder| entry_canon.starts_with(folder));
485 if !under_source_folder {
486 return Err(StaticError::Traversal(format!(
487 "js bundle entry {} is not under any registered source folder",
488 entry_canon.display()
489 )));
490 }
491 }
492
493 self.js_tool = Some((tool, options));
494 Ok(self)
495 }
496
497 /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
498 /// when no CSS sources remain, rather than serving an orphan. Applies only to the
499 /// one-shot startup build, never during live-reload.
500 pub fn with_prune_output(mut self) -> Self {
501 self.prune_output = true;
502 self
503 }
504
505 /// True when any build pipeline is configured (a CSS/JS tool and/or a source
506 /// folder), i.e. the server should run a startup build.
507 fn has_pipeline(&self) -> bool {
508 self.css_tool.is_some()
509 || self.js_tool.is_some()
510 || !self.source_folders.is_empty()
511 || !self.asset_folders.is_empty()
512 }
513
514 /// Every external tool binary this configuration actually needs at some point
515 /// (bundle and/or minify enabled — a pure passthrough config never spawns its
516 /// configured tool, so it has nothing to fail-fast on), paired with its
517 /// human-readable install hint for a fail-fast startup error.
518 fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
519 let mut required = Vec::new();
520 if let Some((css_tool, options)) = &self.css_tool {
521 if options.is_bundle() || options.is_minify() {
522 required.push((css_tool.binary_name(), css_tool.install_hint()));
523 }
524 }
525 if let Some((js_tool, options)) = &self.js_tool {
526 if options.is_bundle() || options.is_minify() {
527 required.push((js_tool.binary_name(), js_tool.install_hint()));
528 }
529 }
530 required
531 }
532
533 /// Every directory to watch for source changes: the source folders, the CSS
534 /// `@import` roots, and the asset folders, deduplicated so a directory registered
535 /// under more than one role is watched once.
536 fn watch_targets(&self) -> Vec<PathBuf> {
537 let mut targets = Vec::new();
538 for dir in self
539 .source_folders
540 .iter()
541 .chain(self.bundle_roots.iter())
542 .chain(self.asset_folders.iter())
543 {
544 if !targets.contains(dir) {
545 targets.push(dir.clone());
546 }
547 }
548 targets
549 }
550
551 /// Run every configured build pipeline (CSS/JS tools, asset folders) once and
552 /// return, without starting the HTTP server. A one-shot equivalent of the
553 /// startup build `run*` does automatically — for deploy tooling that wants to
554 /// populate the output dir ahead of time (e.g. a `cargo run --bin build_static`
555 /// step before baking a Docker image), mirroring a one-shot content
556 /// builder's `build()` (e.g. `mini_docs::Builder::build()`).
557 ///
558 /// # Errors
559 ///
560 /// - `Err(StaticError::PipelineSetup)` if a configured tool's binary that's
561 /// actually needed (bundle or minify enabled) is missing from `PATH` — checked
562 /// before anything runs, same as [`Server::run_on`].
563 /// - `Err(StaticError::Build)` if a configured pipeline step fails (a tool
564 /// invocation error, a filesystem error writing output, etc.).
565 ///
566 /// # Example
567 ///
568 /// ```no_run
569 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
570 /// use mini_static::Server;
571 /// use std::path::Path;
572 ///
573 /// let server = Server::new(Path::new("./public"))?;
574 /// server.build().await?;
575 /// # Ok(())
576 /// # }
577 /// ```
578 pub async fn build(&self) -> Result<(), StaticError> {
579 for (binary, install_hint) in self.required_tool_binaries() {
580 if !tool::locate_on_path(binary) {
581 return Err(StaticError::PipelineSetup(format!(
582 "{binary} not found on PATH ({install_hint})"
583 )));
584 }
585 }
586
587 let pipeline = SourcePipeline::new(
588 self.source_folders.clone(),
589 self.bundle_roots.clone(),
590 self.asset_folders.clone(),
591 self.output_dir.clone(),
592 self.css_tool.clone(),
593 self.js_tool.clone(),
594 self.prune_output,
595 Broadcaster::new(),
596 );
597 pipeline
598 .full_build()
599 .await
600 .map_err(|e| StaticError::Build(e.to_string()))
601 }
602
603 /// Resolve a request path under the server's root.
604 ///
605 /// This is a lower-level API for resolving paths without generating HTTP responses.
606 /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
607 ///
608 /// # Returns
609 ///
610 /// - `Ok(PathBuf)` if the path resolves to a file within root.
611 /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
612 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
613 resolve::resolve_with_canonical_root(&self.root_canon, request_path)
614 }
615
616 /// Run the server on a specific address with a configurable header-read timeout.
617 ///
618 /// Spawns the server in a background Tokio task and returns immediately with the
619 /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
620 /// stop accepting new connections and wait for in-flight connections to finish.
621 /// Dropping the handle instead leaves the server running for the life of the process.
622 ///
623 /// # Header-Read Timeout
624 ///
625 /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
626 /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
627 /// timeout applies only to the header-read phase — once a complete header block has been
628 /// read, the connection is handed off with no further time bound, so long-lived response
629 /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
630 /// off mid-stream.
631 ///
632 /// # Precompressed Sidecars
633 ///
634 /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
635 /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
636 /// served instead with a matching `Content-Encoding`. Every file response carries
637 /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
638 /// differently-capable client.
639 ///
640 /// # Arguments
641 ///
642 /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
643 /// or `0.0.0.0:8080` to bind all interfaces on a fixed port).
644 /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
645 ///
646 /// # Returns
647 ///
648 /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
649 /// - `Err(StaticError::Io)` if binding to the socket fails.
650 /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
651 /// is not found on `PATH`. Checked before the listener binds: a deployment whose
652 /// configured pipeline can never run should fail visibly at boot, not be discovered
653 /// later as a missing/stale asset.
654 pub async fn run_on(
655 &self,
656 addr: SocketAddr,
657 header_timeout: Duration,
658 ) -> Result<(u16, ServerHandle), StaticError> {
659 for (binary, install_hint) in self.required_tool_binaries() {
660 if !tool::locate_on_path(binary) {
661 return Err(StaticError::PipelineSetup(format!(
662 "{binary} not found on PATH ({install_hint})"
663 )));
664 }
665 }
666
667 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
668 let port = listener.local_addr().map_err(StaticError::Io)?.port();
669
670 let mut server = self.clone();
671 if server.live_reload {
672 let broadcaster = Broadcaster::new();
673
674 // The build pipelines react to SOURCE folders only; the output dir is never
675 // watched. Watching the output would feed each pipeline its own writes back
676 // into its trigger — the feedback loop this layering exists to prevent.
677 if server.has_pipeline() {
678 let pipeline = Arc::new(SourcePipeline::new(
679 server.source_folders.clone(),
680 server.bundle_roots.clone(),
681 server.asset_folders.clone(),
682 server.output_dir.clone(),
683 server.css_tool.clone(),
684 server.js_tool.clone(),
685 server.prune_output,
686 broadcaster.clone(),
687 ));
688 let mut rx = broadcaster.subscribe();
689 tokio::spawn(async move {
690 // One-shot startup build (and optional prune) first, so the earliest
691 // request already sees fresh output rather than yesterday's.
692 if let Err(e) = pipeline.full_build().await {
693 eprintln!("source pipeline build error: {e}");
694 }
695 while let Some(event) = rx.recv().await {
696 if let Err(e) = pipeline
697 .process_change(&event.path, &event.change_type)
698 .await
699 {
700 eprintln!("source pipeline error: {e}");
701 }
702 }
703 });
704 }
705
706 for dir in server.watch_targets() {
707 start_watching(Arc::new(dir), broadcaster.clone());
708 }
709
710 server.broadcaster = Some(broadcaster);
711 } else if server.has_pipeline() {
712 // No live-reload: still run the one-shot build so a release boot reflects the
713 // current sources. The broadcaster is a throwaway — there is no browser to
714 // notify, so broadcasting into it is a no-op.
715 let pipeline = Arc::new(SourcePipeline::new(
716 server.source_folders.clone(),
717 server.bundle_roots.clone(),
718 server.asset_folders.clone(),
719 server.output_dir.clone(),
720 server.css_tool.clone(),
721 server.js_tool.clone(),
722 server.prune_output,
723 Broadcaster::new(),
724 ));
725 tokio::spawn(async move {
726 if let Err(e) = pipeline.full_build().await {
727 eprintln!("source pipeline build error: {e}");
728 }
729 });
730 }
731 let semaphore = Arc::new(Semaphore::new(server.max_connections));
732 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
733
734 let accept_task = tokio::spawn(async move {
735 let mut backoff = ACCEPT_BACKOFF_INITIAL;
736 let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
737 let mut shutdown_pin = std::pin::pin!(shutdown_rx);
738 let mut shutting_down = false;
739
740 loop {
741 if !shutting_down {
742 // The accept-and-permit step and the shutdown signal race in a single
743 // `select!` so shutdown can preempt a pending accept or a permit wait
744 // cleanly, at any point — not just between loop iterations.
745 tokio::select! {
746 accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
747 match accepted {
748 Some((stream, permit)) => {
749 let server = server.clone();
750 join_set.spawn(async move {
751 let _permit = permit;
752 serve_connection(stream, server, header_timeout).await;
753 });
754 }
755 None => shutting_down = true,
756 }
757 }
758 _ = shutdown_pin.as_mut() => {
759 shutting_down = true;
760 }
761 }
762 continue;
763 }
764
765 // Stop accepting; drain already-spawned connections before returning.
766 match join_set.join_next().await {
767 Some(_) => continue,
768 None => break,
769 }
770 }
771 });
772
773 Ok((
774 port,
775 ServerHandle {
776 shutdown_tx: Some(shutdown_tx),
777 accept_task,
778 },
779 ))
780 }
781
782 /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
783 ///
784 /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
785 /// sidecar semantics, and for what the returned [`ServerHandle`] does.
786 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
787 self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
788 .await
789 }
790
791 /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
792 ///
793 /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
794 /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
795 /// semantics, and for what the returned [`ServerHandle`] does.
796 pub async fn run_all(
797 &self,
798 port: u16,
799 header_timeout: Duration,
800 ) -> Result<(u16, ServerHandle), StaticError> {
801 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
802 .await
803 }
804
805 /// Run the server on loopback with the default 30-second header-read timeout.
806 ///
807 /// The recommended entry point for tests and lightweight services that don't need a
808 /// custom timeout. Thin wrapper around [`Server::run`].
809 ///
810 /// # Example
811 ///
812 /// ```no_run
813 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
814 /// use mini_static::Server;
815 /// use std::path::Path;
816 ///
817 /// let server = Server::new(Path::new("./public"))?;
818 /// let (port, handle) = server.run_ephemeral().await?;
819 /// println!("Server ready on http://127.0.0.1:{}", port);
820 /// handle.shutdown().await;
821 /// # Ok(())
822 /// # }
823 /// ```
824 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
825 self.run(DEFAULT_HEADER_TIMEOUT).await
826 }
827
828 /// Produce the HTTP response for a request, streaming file bodies to the client.
829 ///
830 /// This is the crate's single request-handling path: the `run*` accept loop calls it,
831 /// and so should any async server embedding `mini-static` as a fallback route (e.g.
832 /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
833 /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
834 ///
835 /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
836 /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
837 /// response regardless of file size.
838 ///
839 /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
840 /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
841 /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
842 /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
843 /// response never discloses whether a path exists outside the root.
844 pub async fn handle_request(
845 &self,
846 method: &Method,
847 request_path: &str,
848 headers: &HeaderMap,
849 ) -> Response<ResponseBody> {
850 if method != Method::GET && method != Method::HEAD {
851 return text(
852 response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
853 "method not allowed\n",
854 );
855 }
856
857 // Live-reload SSE stream — only reachable when `with_live_reload()` was called
858 // and the server was started via a `run*` method (those are the only paths that
859 // populate `broadcaster`).
860 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
861 if let Some(broadcaster) = &self.broadcaster {
862 return finish(
863 response(StatusCode::OK)
864 .header("Content-Type", "text/event-stream")
865 .header("Cache-Control", "no-cache")
866 .header("Connection", "keep-alive")
867 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
868 );
869 }
870 }
871
872 // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
873 // request). Running those directly in this `async fn` would block whichever
874 // Tokio worker thread happens to be driving it, stalling every other task
875 // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
876 // moves the work onto Tokio's dedicated blocking thread pool instead.
877 let server = self.clone();
878 let owned_request_path = request_path.to_string();
879 let resolved =
880 tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
881 let path = match resolved {
882 Err(_) => return internal_error_response(),
883 Ok(Err(e)) => {
884 return text(
885 response(StatusCode::NOT_FOUND),
886 format!("{}\n", e.user_message()),
887 )
888 }
889 Ok(Ok(path)) => path,
890 };
891
892 // A directory served via its `index.html` needs a trailing slash to establish the
893 // correct base for the page's relative links. Compare against the *decoded*
894 // request path so a percent-encoded explicit request for index.html (e.g.
895 // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
896 // still-encoded, broken Location.
897 let decoded_request_path = resolve::decode_request_path(request_path);
898 if path.file_name().is_some_and(|name| name == "index.html")
899 && !decoded_request_path.ends_with('/')
900 && !decoded_request_path.ends_with("index.html")
901 {
902 // `location` is built from the (attacker-controlled) request path; `finish()`
903 // degrades to 400 instead of panicking if it ever contains bytes invalid in a
904 // header value.
905 let location = format!("{}/", request_path.trim_end_matches('/'));
906 return text(
907 response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
908 "moved\n",
909 );
910 }
911
912 let Ok(file) = File::open(&path).await else {
913 return internal_error_response();
914 };
915 let Ok(metadata) = file.metadata().await else {
916 return internal_error_response();
917 };
918
919 let content_type = mime_type_for_path(&path);
920 // Live-reload HTML injection needs the original, uncompressed bytes to splice the
921 // reload script into — never substitute a precompressed sidecar on this path.
922 let html_injection = self.broadcaster.is_some() && content_type.starts_with("text/html");
923
924 let accept_encoding = header_str(headers, "accept-encoding");
925 let sidecar = if html_injection {
926 None
927 } else {
928 select_precompressed_sidecar(&path, accept_encoding).await
929 };
930 let (mut file, metadata, content_encoding) = match sidecar {
931 Some((sidecar_file, sidecar_metadata, encoding)) => {
932 (sidecar_file, sidecar_metadata, Some(encoding))
933 }
934 None => (file, metadata, None),
935 };
936
937 // HTML injection is skipped for a served precompressed sidecar (already final
938 // bytes from a build step) — see `html_injection`'s definition above.
939 let etag = generate_etag(&metadata);
940 let cache_control = self.cache_control_for(&path);
941
942 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
943 return finish(
944 Response::builder()
945 .status(StatusCode::NOT_MODIFIED)
946 .header("Cache-Control", cache_control)
947 .header("Vary", "Accept-Encoding")
948 .header("ETag", etag)
949 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
950 );
951 }
952
953 // `Some` when the served representation differs from the file's raw bytes and had
954 // to be built in memory; `None` means stream the open file as-is. Computed before
955 // the HEAD check below because RFC 9110 requires a HEAD response's headers —
956 // `Content-Length` included — to match what a GET would send, even though the body
957 // itself is dropped.
958 let transformed: Option<Bytes> = if html_injection {
959 let mut html = Vec::with_capacity(metadata.len() as usize);
960 if file.read_to_end(&mut html).await.is_err() {
961 return internal_error_response();
962 }
963 reload::inject_reload_script(&mut html);
964 Some(Bytes::from(html))
965 } else {
966 None
967 };
968
969 let file_size = transformed
970 .as_ref()
971 .map_or(metadata.len(), |bytes| bytes.len() as u64);
972
973 // HEAD must not return a body (RFC 9110).
974 let body = if *method == Method::HEAD {
975 ResponseBody::Buffered(Full::new(Bytes::new()))
976 } else {
977 match transformed {
978 Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
979 None => ResponseBody::Streamed(FileBody::new(file)),
980 }
981 };
982
983 let mut builder = response(StatusCode::OK)
984 .header("Content-Type", content_type)
985 .header("Content-Length", file_size.to_string())
986 .header("Cache-Control", cache_control)
987 .header("Vary", "Accept-Encoding")
988 .header("ETag", etag);
989 if let Some(encoding) = content_encoding {
990 builder = builder.header("Content-Encoding", encoding);
991 }
992 finish(builder.body(body))
993 }
994}
995
996/// Ceiling on how many bytes `read_header_prefix` buffers before giving up. Without this,
997/// a client that trickles bytes forever without ever sending the terminating blank line
998/// could grow the buffer without limit — the header-read timeout alone doesn't bound
999/// memory, only wall-clock time, and a sufficiently patient sender could still send
1000/// unbounded data before the deadline fires.
1001const MAX_HEADER_BYTES: usize = 64 * 1024;
1002
1003/// Why `read_header_prefix` gave up before seeing a complete header block. Every variant
1004/// is a legitimate reason to drop the connection — none is treated specially by the
1005/// caller today, but the distinction is worth preserving for anyone debugging this later.
1006#[derive(Debug)]
1007enum HeaderReadError {
1008 /// The client closed the connection (or shut down its write half) before sending a
1009 /// complete header block.
1010 ConnectionClosed,
1011 /// More than `MAX_HEADER_BYTES` were buffered without seeing `\r\n\r\n`.
1012 TooLarge,
1013 /// The underlying socket read failed. Kept rather than discarded so a future `log`
1014 /// feature has the real I/O error to report instead of an opaque unit variant.
1015 #[allow(dead_code)]
1016 Io(std::io::Error),
1017}
1018
1019/// Reads from `stream` until a complete HTTP header block (`\r\n\r\n`) has been buffered,
1020/// returning every byte read so far — which may include bytes past the header block
1021/// (request body, or a second pipelined request) if the client sent them in the same
1022/// read. Callers pair this with `tokio::time::timeout` to bound how long the header phase
1023/// itself may take; this function has no timeout of its own, only the size ceiling in
1024/// `MAX_HEADER_BYTES`.
1025async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1026 let mut buf = Vec::new();
1027 let mut chunk = [0u8; 4096];
1028
1029 loop {
1030 let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1031 if n == 0 {
1032 return Err(HeaderReadError::ConnectionClosed);
1033 }
1034 buf.extend_from_slice(&chunk[..n]);
1035
1036 if buf.len() > MAX_HEADER_BYTES {
1037 return Err(HeaderReadError::TooLarge);
1038 }
1039 // Only the tail can hold a terminator this read completed: the `n` new bytes plus
1040 // the 3 before them. Rescanning the whole buffer every time would make the header
1041 // read quadratic in the bytes received.
1042 let scan_from = buf.len().saturating_sub(n + 3);
1043 if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1044 return Ok(buf);
1045 }
1046 }
1047}
1048
1049/// Wraps an accepted `TcpStream` whose header block has already been drained into
1050/// `prefix` (by `read_header_prefix`, ahead of the connection being handed to hyper).
1051/// Reads replay `prefix` first, then fall through to the live socket — so hyper sees
1052/// exactly the byte stream it would have seen without the pre-read, just sourced from two
1053/// buffers back-to-back instead of one continuous one. Writes pass straight through.
1054struct PrefixedIo {
1055 prefix: Bytes,
1056 prefix_pos: usize,
1057 inner: TcpStream,
1058}
1059
1060impl PrefixedIo {
1061 fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1062 PrefixedIo {
1063 prefix: Bytes::from(prefix),
1064 prefix_pos: 0,
1065 inner,
1066 }
1067 }
1068}
1069
1070impl AsyncRead for PrefixedIo {
1071 fn poll_read(
1072 self: Pin<&mut Self>,
1073 cx: &mut Context<'_>,
1074 buf: &mut ReadBuf<'_>,
1075 ) -> Poll<std::io::Result<()>> {
1076 let this = self.get_mut();
1077 if this.prefix_pos < this.prefix.len() {
1078 let remaining = &this.prefix[this.prefix_pos..];
1079 let n = remaining.len().min(buf.remaining());
1080 buf.put_slice(&remaining[..n]);
1081 this.prefix_pos += n;
1082 return Poll::Ready(Ok(()));
1083 }
1084 Pin::new(&mut this.inner).poll_read(cx, buf)
1085 }
1086}
1087
1088impl AsyncWrite for PrefixedIo {
1089 fn poll_write(
1090 self: Pin<&mut Self>,
1091 cx: &mut Context<'_>,
1092 buf: &[u8],
1093 ) -> Poll<std::io::Result<usize>> {
1094 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1095 }
1096
1097 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1098 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1099 }
1100
1101 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1102 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1103 }
1104}
1105
1106/// Wires an accepted connection up to the hyper HTTP/1 service.
1107///
1108/// `header_timeout` bounds only the header-read phase (`read_header_prefix`, run before
1109/// hyper ever sees the connection). Once a complete header block has been read, the
1110/// connection is handed to hyper with no further time bound — deliberately, since a
1111/// response body may legitimately outlive `header_timeout` by design (the live-reload SSE
1112/// stream is the motivating case: it stays open until a watched file changes, which may
1113/// be minutes or hours after the request). Wrapping the whole connection lifetime in
1114/// `header_timeout` — the prior implementation — silently truncated exactly that stream
1115/// once `header_timeout` elapsed, aborting the response mid-write after headers had
1116/// already been sent (the client observes this as a chunked-encoding error, not a clean
1117/// close). The connection-count ceiling (`Server::with_max_connections`) is what bounds
1118/// resource use from connections held open indefinitely, not this timeout.
1119async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1120 let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1121 Ok(Ok(prefix)) => prefix,
1122 Ok(Err(_)) | Err(_) => return,
1123 };
1124
1125 let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1126 let svc = service_fn(move |req: Request<Incoming>| {
1127 let server = server.clone();
1128 async move {
1129 let resp = server
1130 .handle_request(req.method(), req.uri().path(), req.headers())
1131 .await;
1132 Ok::<_, Infallible>(resp)
1133 }
1134 });
1135 let _ = AutoBuilder::new(TokioExecutor::new())
1136 .serve_connection(io, svc)
1137 .await;
1138}
1139
1140/// Default header-read timeout used by [`Server::run_ephemeral`].
1141const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1142
1143/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1144/// finish on their own before aborting whatever is left. A connection with no
1145/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1146/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1147/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1148/// shutdown is no exception.
1149const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1150
1151/// A handle to a server started by one of the `Server::run*` methods.
1152///
1153/// Dropping this handle without calling `shutdown()` leaves the server running in the
1154/// background for the life of the process. Call `shutdown()` to stop accepting new
1155/// connections and wait for already-accepted connections to finish before returning.
1156pub struct ServerHandle {
1157 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1158 accept_task: tokio::task::JoinHandle<()>,
1159}
1160
1161impl ServerHandle {
1162 /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1163 /// (5s) for in-flight connections to finish on their own. Equivalent to
1164 /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1165 /// happens to connections still open once the grace period elapses.
1166 pub async fn shutdown(self) {
1167 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1168 .await;
1169 }
1170
1171 /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1172 /// connections to finish on their own.
1173 ///
1174 /// Connections still open once `drain_timeout` elapses are aborted rather than
1175 /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1176 /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1177 /// which in turn drops each connection's socket, closing it. This is what bounds
1178 /// shutdown when a connection has no natural end of its own (the live-reload SSE
1179 /// stream is the motivating case: it stays open until a watched file changes, which
1180 /// may never happen before the process needs to exit).
1181 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1182 if let Some(tx) = self.shutdown_tx.take() {
1183 let _ = tx.send(());
1184 }
1185 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1186 self.accept_task.abort();
1187 }
1188 }
1189}
1190
1191/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1192fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1193 headers.get(name).and_then(|value| value.to_str().ok())
1194}
1195
1196/// Start a response carrying the baseline security header every response in this crate
1197/// sends. The 304 path is the one exception and builds its own — a 304 repeats only the
1198/// caching validators, not the full header set.
1199fn response(status: StatusCode) -> Builder {
1200 Response::builder()
1201 .status(status)
1202 .header("X-Content-Type-Options", "nosniff")
1203}
1204
1205/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1206/// allocate; `String` bodies (the 404 message) are moved in.
1207fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1208 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1209}
1210
1211/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1212/// header value turns out to be invalid for use as an HTTP header value.
1213///
1214/// Every header value that reaches `Response::builder()` in this module is either a
1215/// static string or formatted from internal, already-validated data (a byte count, an
1216/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1217/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1218/// production panic the day someone adds a header built from new input without
1219/// re-deriving that guarantee. Routing every response through this one fallible path
1220/// means that mistake fails safe instead of panicking.
1221fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1222 built.unwrap_or_else(|_| bad_request_response())
1223}
1224
1225// `internal_error_response()` and `bad_request_response()` are the fallback responses
1226// `finish()` itself degrades to — every header and body here is a fixed string with no
1227// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1228// without it degrading to itself on failure.
1229fn internal_error_response() -> Response<ResponseBody> {
1230 response(StatusCode::INTERNAL_SERVER_ERROR)
1231 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1232 b"internal server error\n",
1233 ))))
1234 .unwrap()
1235}
1236
1237fn bad_request_response() -> Response<ResponseBody> {
1238 response(StatusCode::BAD_REQUEST)
1239 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1240 b"bad request\n",
1241 ))))
1242 .unwrap()
1243}
1244
1245/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1246/// variant, in preference order — brotli wins when a client accepts both and both
1247/// sidecars exist.
1248const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1249
1250/// Whether `accept_encoding` allows `encoding`.
1251///
1252/// Matches by substring rather than parsing `q`-value weights or the `identity`/`*`
1253/// directives — a lighter-weight negotiation than a general HTTP client would need,
1254/// sufficient for deciding between two static sidecar files.
1255fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1256 accept_encoding.is_some_and(|header| header.contains(encoding))
1257}
1258
1259/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1260/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1261///
1262/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1263/// The sidecar path is built by appending an extension to it — never by re-resolving a
1264/// modified request path — so this lookup can't become a second traversal surface: any
1265/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1266async fn select_precompressed_sidecar(
1267 path: &Path,
1268 accept_encoding: Option<&str>,
1269) -> Option<(File, fs::Metadata, &'static str)> {
1270 for (encoding, ext) in SIDECAR_ENCODINGS {
1271 if !accepts_encoding(accept_encoding, encoding) {
1272 continue;
1273 }
1274 let mut sidecar = path.as_os_str().to_os_string();
1275 sidecar.push(ext);
1276 let sidecar_path = PathBuf::from(sidecar);
1277
1278 // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1279 // must stay in the same directory as `path` (which `resolve()` already proved is
1280 // inside root). `ext` is always one of the two static literals in
1281 // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1282 // a future change starts deriving `sidecar` some other way.
1283 debug_assert_eq!(
1284 sidecar_path.parent(),
1285 path.parent(),
1286 "sidecar path must stay in the same directory as the already-resolved path"
1287 );
1288
1289 if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1290 if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1291 return Some((sidecar_file, sidecar_metadata, encoding));
1292 }
1293 }
1294 }
1295 None
1296}
1297
1298/// Generate an ETag for a file based on modification time and size.
1299///
1300/// Format: `"<size>-<mtime_secs>"`
1301fn generate_etag(metadata: &fs::Metadata) -> String {
1302 let mtime = metadata
1303 .modified()
1304 .ok()
1305 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1306 .map(|d| d.as_secs())
1307 .unwrap_or(0);
1308 format!("\"{}-{}\"", metadata.len(), mtime)
1309}
1310
1311/// Determine MIME type from file path extension.
1312fn mime_type_for_path(path: &Path) -> &'static str {
1313 let ext = path
1314 .extension()
1315 .and_then(|ext| ext.to_str())
1316 .unwrap_or_default()
1317 .to_lowercase();
1318
1319 match ext.as_str() {
1320 "html" | "htm" => "text/html; charset=utf-8",
1321 "css" => "text/css; charset=utf-8",
1322 "js" => "application/javascript; charset=utf-8",
1323 "json" => "application/json; charset=utf-8",
1324 "svg" => "image/svg+xml",
1325 "png" => "image/png",
1326 "jpg" | "jpeg" => "image/jpeg",
1327 "gif" => "image/gif",
1328 "webp" => "image/webp",
1329 "ico" => "image/x-icon",
1330 "woff" => "font/woff",
1331 "woff2" => "font/woff2",
1332 "ttf" => "font/ttf",
1333 "md" | "markdown" => "text/markdown; charset=utf-8",
1334 "txt" => "text/plain; charset=utf-8",
1335 "xml" => "application/xml",
1336 "pdf" => "application/pdf",
1337 "zip" => "application/zip",
1338 _ => "application/octet-stream",
1339 }
1340}
1341
1342/// Check if the If-None-Match header matches the current ETag.
1343/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1344fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1345 if if_none_match == "*" {
1346 return true;
1347 }
1348 if_none_match.split(',').any(|tag| tag.trim() == etag)
1349}
1350
1351#[cfg(test)]
1352mod precompressed_sidecar_tests {
1353 use super::*;
1354
1355 // `select_precompressed_sidecar` only ever appends a static extension literal
1356 // (".br"/".gz") to the `path` it's given — it never re-joins against `root` or
1357 // re-parses a request-path string, so it structurally cannot become a second
1358 // traversal surface the way re-running `resolve()` on modified input could. This
1359 // test locks that in by construction: the sidecar it finds must live in exactly
1360 // the same directory as the resolved file, for every encoding preference branch.
1361 #[tokio::test]
1362 async fn sidecar_never_leaves_the_resolved_files_directory() {
1363 let root = tempfile::TempDir::new().unwrap();
1364 let sub = root.path().join("assets");
1365 fs::create_dir(&sub).unwrap();
1366 let resolved = sub.join("app.js");
1367 fs::write(&resolved, b"plain").unwrap();
1368 fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1369 fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1370
1371 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1372 .await
1373 .expect("both sidecars present, br should be preferred");
1374 assert_eq!(
1375 encoding, "br",
1376 "br must be preferred over gzip when both are accepted"
1377 );
1378
1379 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1380 .await
1381 .expect("gzip sidecar present");
1382 assert_eq!(encoding, "gzip");
1383
1384 assert!(
1385 select_precompressed_sidecar(&resolved, None)
1386 .await
1387 .is_none(),
1388 "no Accept-Encoding header should never select a sidecar"
1389 );
1390 }
1391
1392 #[test]
1393 fn accepts_encoding_matches_only_listed_directives() {
1394 assert!(!accepts_encoding(None, "br"));
1395 assert!(!accepts_encoding(Some("identity"), "br"));
1396 assert!(!accepts_encoding(Some("identity"), "gzip"));
1397 assert!(accepts_encoding(Some("gzip, br"), "br"));
1398 assert!(accepts_encoding(Some("gzip"), "gzip"));
1399 assert!(!accepts_encoding(Some("gzip"), "br"));
1400 }
1401}
1402
1403#[cfg(test)]
1404mod file_body_tests {
1405 use super::*;
1406 use crate::handler::FILE_CHUNK_SIZE;
1407 use http_body_util::BodyExt;
1408
1409 // Disproves the prior implementation, which read every chunk into a `Vec` and
1410 // only wrapped the whole result in a single `Full` frame at the end — that
1411 // implementation would fail this test with `frame_count == 1` and
1412 // `max_frame_len == file size`, regardless of `FILE_CHUNK_SIZE`.
1413 #[tokio::test]
1414 async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1415 let dir = tempfile::TempDir::new().unwrap();
1416 let path = dir.path().join("big.bin");
1417 let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1418 fs::write(&path, &content).unwrap();
1419
1420 let file = File::open(&path).await.unwrap();
1421 let mut body = FileBody::new(file);
1422
1423 let mut frame_count = 0usize;
1424 let mut max_frame_len = 0usize;
1425 let mut reassembled = Vec::new();
1426
1427 while let Some(frame) = body.frame().await {
1428 let frame = frame.unwrap();
1429 let data = frame.into_data().unwrap();
1430 frame_count += 1;
1431 max_frame_len = max_frame_len.max(data.len());
1432 reassembled.extend_from_slice(&data);
1433 }
1434
1435 assert!(
1436 frame_count > 1,
1437 "expected the file to be delivered as multiple frames, got {frame_count}"
1438 );
1439 assert!(
1440 max_frame_len <= FILE_CHUNK_SIZE,
1441 "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1442 );
1443 assert_eq!(
1444 reassembled, content,
1445 "reassembled chunks must match original file content exactly"
1446 );
1447 }
1448}
1449
1450#[cfg(test)]
1451mod accept_tests {
1452 use super::*;
1453 use std::sync::atomic::{AtomicUsize, Ordering};
1454 use std::sync::Mutex;
1455
1456 /// Fails `accept()` a fixed number of times, recording the (paused, virtual)
1457 /// instant of each attempt, before delegating to a real listener so the caller can
1458 /// eventually succeed.
1459 struct FlakyListener {
1460 inner: TcpListener,
1461 remaining_failures: AtomicUsize,
1462 attempts: Mutex<Vec<tokio::time::Instant>>,
1463 }
1464
1465 impl TcpAccept for FlakyListener {
1466 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1467 self.attempts
1468 .lock()
1469 .unwrap()
1470 .push(tokio::time::Instant::now());
1471 if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1472 Err(std::io::Error::other("simulated accept error"))
1473 } else {
1474 TcpAccept::accept(&self.inner).await
1475 }
1476 }
1477 }
1478
1479 // Disproves the prior implementation, which broke out of the accept loop entirely
1480 // on the first `accept()` error — permanently ending the server. This test would
1481 // also fail against a naive `continue`-only fix (no backoff): the recorded gaps
1482 // between attempts would collapse to ~0 (a busy spin) instead of the expected
1483 // exponentially growing delays.
1484 #[tokio::test(start_paused = true)]
1485 async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1486 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1487 let addr = inner.local_addr().unwrap();
1488
1489 let flaky = FlakyListener {
1490 inner,
1491 remaining_failures: AtomicUsize::new(5),
1492 attempts: Mutex::new(Vec::new()),
1493 };
1494
1495 tokio::spawn(async move {
1496 let _ = TcpStream::connect(addr).await;
1497 });
1498
1499 let semaphore = Arc::new(Semaphore::new(1));
1500 let mut backoff = ACCEPT_BACKOFF_INITIAL;
1501 let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1502 assert!(
1503 result.is_some(),
1504 "accept should eventually succeed once the flaky listener stops failing"
1505 );
1506
1507 let recorded = flaky.attempts.lock().unwrap();
1508 assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1509
1510 let expected_gaps = [
1511 ACCEPT_BACKOFF_INITIAL,
1512 ACCEPT_BACKOFF_INITIAL * 2,
1513 ACCEPT_BACKOFF_INITIAL * 4,
1514 ACCEPT_BACKOFF_INITIAL * 8,
1515 ACCEPT_BACKOFF_INITIAL * 16,
1516 ];
1517 for (i, expected) in expected_gaps.iter().enumerate() {
1518 let gap = recorded[i + 1] - recorded[i];
1519 assert_eq!(
1520 gap,
1521 *expected,
1522 "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1523 i + 1
1524 );
1525 }
1526
1527 // The delay must stop doubling at the cap rather than growing without bound.
1528 let mut capped = ACCEPT_BACKOFF_MAX;
1529 capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1530 assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1531 }
1532
1533 // A successful accept must clear the accumulated delay, so an isolated error later
1534 // on doesn't inherit a second-long wait from an unrelated earlier failure.
1535 #[tokio::test(start_paused = true)]
1536 async fn a_successful_accept_resets_the_backoff() {
1537 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1538 let addr = inner.local_addr().unwrap();
1539 let flaky = FlakyListener {
1540 inner,
1541 remaining_failures: AtomicUsize::new(3),
1542 attempts: Mutex::new(Vec::new()),
1543 };
1544 tokio::spawn(async move {
1545 let _ = TcpStream::connect(addr).await;
1546 });
1547
1548 let semaphore = Arc::new(Semaphore::new(1));
1549 let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1550 accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1551
1552 assert_eq!(
1553 backoff, ACCEPT_BACKOFF_INITIAL,
1554 "the delay must return to its initial value once an accept succeeds"
1555 );
1556 }
1557}
1558
1559#[cfg(test)]
1560mod finish_tests {
1561 use super::*;
1562
1563 // Disproves a bare `.unwrap()` on the same builder: CR/LF is not a legal header
1564 // value byte (it would enable header/response splitting), so this construction is
1565 // guaranteed to make `.body(...)` return `Err`. Every real call site in this module
1566 // only ever builds header values from static strings or internally-formatted
1567 // numbers, so this test can't happen through normal use — it exists to prove
1568 // `finish()`'s fallback path actually works, not to exercise a reachable case.
1569 #[test]
1570 fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1571 let built = Response::builder()
1572 .status(StatusCode::OK)
1573 .header("X-Test", "invalid\r\nvalue")
1574 .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1575 assert!(
1576 built.is_err(),
1577 "CR/LF in a header value should be rejected by the builder"
1578 );
1579
1580 let response = finish(built);
1581 assert_eq!(
1582 response.status(),
1583 StatusCode::BAD_REQUEST,
1584 "finish() should degrade to 400 rather than panicking on an invalid header value"
1585 );
1586 }
1587}
1588
1589#[cfg(test)]
1590mod header_prefix_tests {
1591 use super::*;
1592 use tokio::io::AsyncWriteExt;
1593
1594 /// Binds an ephemeral listener, connects a client to it, and returns both ends —
1595 /// `(server_side, client_side)` — so a test can drive `read_header_prefix` against a
1596 /// real socket without a full `Server`/`serve_connection` in the loop.
1597 async fn connected_pair() -> (TcpStream, TcpStream) {
1598 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1599 let addr = listener.local_addr().unwrap();
1600 let client = TcpStream::connect(addr).await.unwrap();
1601 let (server_side, _) = listener.accept().await.unwrap();
1602 (server_side, client)
1603 }
1604
1605 #[tokio::test]
1606 async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1607 let (mut server_side, mut client) = connected_pair().await;
1608
1609 client
1610 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1611 .await
1612 .unwrap();
1613
1614 let prefix = read_header_prefix(&mut server_side)
1615 .await
1616 .unwrap_or_else(|_| {
1617 panic!("expected a complete header block to be read");
1618 });
1619
1620 assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1621 }
1622
1623 // Disproves a version that only inspects the newest chunk for `\r\n\r\n`: writing the
1624 // blank line in a separate write (and thus, almost always, a separate read) after the
1625 // rest of the headers would make that version wait forever, since the terminator
1626 // never appears within a single chunk. Also pins the tail-only scan in
1627 // `read_header_prefix` — a terminator straddling two reads must still be seen.
1628 #[tokio::test]
1629 async fn assembles_a_header_block_split_across_multiple_writes() {
1630 let (mut server_side, mut client) = connected_pair().await;
1631
1632 client
1633 .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1634 .await
1635 .unwrap();
1636 client.write_all(b"\n\r\n").await.unwrap();
1637
1638 let prefix = read_header_prefix(&mut server_side)
1639 .await
1640 .unwrap_or_else(|_| {
1641 panic!("expected a complete header block to be read across multiple writes");
1642 });
1643
1644 assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1645 }
1646
1647 // Bytes past the header block (a pipelined second request, here) must be preserved
1648 // verbatim in the returned prefix — `PrefixedIo` depends on this to replay them to
1649 // hyper untouched.
1650 #[tokio::test]
1651 async fn preserves_bytes_sent_past_the_header_block() {
1652 let (mut server_side, mut client) = connected_pair().await;
1653
1654 let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1655 let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1656 let mut sent = Vec::new();
1657 sent.extend_from_slice(first);
1658 sent.extend_from_slice(second);
1659 client.write_all(&sent).await.unwrap();
1660
1661 let prefix = read_header_prefix(&mut server_side)
1662 .await
1663 .unwrap_or_else(|_| {
1664 panic!("expected a complete header block to be read");
1665 });
1666
1667 assert_eq!(
1668 &prefix, &sent,
1669 "pipelined bytes past the first header block must survive intact"
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1675 let (mut server_side, client) = connected_pair().await;
1676 drop(client);
1677
1678 match read_header_prefix(&mut server_side).await {
1679 Err(HeaderReadError::ConnectionClosed) => {}
1680 Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1681 Ok(_) => {
1682 panic!("expected an error, got a complete header block from a closed connection")
1683 }
1684 }
1685 }
1686
1687 // Disproves an unbounded buffer: without the `MAX_HEADER_BYTES` check, this would
1688 // hang consuming memory forever instead of erroring, since the client never sends the
1689 // terminating blank line.
1690 #[tokio::test]
1691 async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1692 let (mut server_side, mut client) = connected_pair().await;
1693
1694 let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1695 client.write_all(&garbage).await.unwrap();
1696
1697 match read_header_prefix(&mut server_side).await {
1698 Err(HeaderReadError::TooLarge) => {}
1699 Err(_) => panic!("expected TooLarge, got a different error variant"),
1700 Ok(_) => {
1701 panic!("expected an error, got a complete header block from unterminated garbage")
1702 }
1703 }
1704 }
1705
1706 #[tokio::test]
1707 async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1708 let (server_side, mut client) = connected_pair().await;
1709 let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1710
1711 client.write_all(b"-live-bytes").await.unwrap();
1712
1713 let mut collected = Vec::new();
1714 let mut chunk = [0u8; 8];
1715 while collected.len() < b"buffered-prefix-live-bytes".len() {
1716 let n = io.read(&mut chunk).await.unwrap();
1717 assert!(n > 0, "read returned 0 before all expected bytes arrived");
1718 collected.extend_from_slice(&chunk[..n]);
1719 }
1720
1721 assert_eq!(collected, b"buffered-prefix-live-bytes");
1722 }
1723}
1724
1725#[cfg(test)]
1726mod css_bundle_tests {
1727 use super::*;
1728 use std::fs;
1729 use std::time::Duration;
1730 use tempfile::TempDir;
1731 use tokio::time::sleep;
1732
1733 #[tokio::test]
1734 async fn source_folder_overlapping_output_dir_is_rejected() {
1735 let root = TempDir::new().unwrap();
1736
1737 // The output dir defaults to the served root, so registering that root as a source
1738 // folder must be refused: watching the output would feed every pipeline its own
1739 // writes back into its trigger.
1740 let result = Server::new(root.path())
1741 .unwrap()
1742 .with_source_folder(root.path());
1743 assert!(
1744 result.is_err(),
1745 "a source folder equal to the output dir must be rejected"
1746 );
1747 }
1748
1749 #[tokio::test]
1750 async fn source_folder_inside_output_dir_is_rejected() {
1751 let root = TempDir::new().unwrap();
1752 let nested = root.path().join("nested");
1753 fs::create_dir(&nested).unwrap();
1754
1755 let result = Server::new(root.path())
1756 .unwrap()
1757 .with_source_folder(&nested);
1758 assert!(
1759 result.is_err(),
1760 "a source folder nested in the output dir must be rejected"
1761 );
1762 }
1763
1764 #[tokio::test]
1765 async fn output_dir_overlapping_source_folder_is_rejected() {
1766 let root = TempDir::new().unwrap();
1767 let source = TempDir::new().unwrap();
1768
1769 let server = Server::new(root.path())
1770 .unwrap()
1771 .with_source_folder(source.path())
1772 .unwrap();
1773
1774 let result = server.with_output_dir(source.path());
1775 assert!(
1776 result.is_err(),
1777 "an output dir equal to a source folder must be rejected"
1778 );
1779 }
1780
1781 #[tokio::test]
1782 async fn css_bundle_creates_output_on_startup_with_live_reload() {
1783 let src = TempDir::new().unwrap();
1784 let out = TempDir::new().unwrap();
1785
1786 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
1787
1788 let server = Server::new(out.path())
1789 .unwrap()
1790 .with_live_reload()
1791 .with_source_folder(src.path())
1792 .unwrap()
1793 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1794
1795 let (_port, handle) = server.run_ephemeral().await.unwrap();
1796
1797 sleep(Duration::from_millis(800)).await;
1798
1799 let bundle = out.path().join("styles.css");
1800 assert!(
1801 bundle.exists(),
1802 "bundle should be written to the default <output>/styles.css"
1803 );
1804 let content = fs::read_to_string(&bundle).unwrap();
1805 assert!(!content.is_empty(), "bundle should contain CSS");
1806
1807 handle.shutdown().await;
1808 }
1809
1810 #[tokio::test]
1811 async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
1812 let src = TempDir::new().unwrap();
1813 let out = TempDir::new().unwrap();
1814 let src_path = src.path();
1815 let bundle = out.path().join("styles.css");
1816
1817 fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
1818
1819 let server = Server::new(out.path())
1820 .unwrap()
1821 .with_live_reload()
1822 .with_source_folder(src_path)
1823 .unwrap()
1824 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1825
1826 let (_port, handle) = server.run_ephemeral().await.unwrap();
1827
1828 // Let the startup build and the watcher's first poll pass (500ms) complete.
1829 sleep(Duration::from_millis(800)).await;
1830 assert!(bundle.exists());
1831
1832 fs::write(
1833 src_path.join("style.css"),
1834 "body { margin: 0; color: blue; }",
1835 )
1836 .unwrap();
1837
1838 // Wait long enough for the watcher poll + rebundle to land at least once.
1839 sleep(Duration::from_millis(1500)).await;
1840 let content_v2 = fs::read_to_string(&bundle).unwrap();
1841 assert!(
1842 content_v2.contains("color"),
1843 "rebundle should contain the new color rule"
1844 );
1845
1846 let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
1847 sleep(Duration::from_millis(1200)).await;
1848 let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
1849
1850 // The regression this guards: the output write must NOT re-trigger another rebuild
1851 // (the feedback loop would keep mutating the bundle's mtime here). A settled mtime
1852 // over a full poll interval proves a single rebuild, not a loop.
1853 assert_eq!(
1854 mtime_after, mtime_later,
1855 "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
1856 );
1857
1858 handle.shutdown().await;
1859 }
1860
1861 #[tokio::test]
1862 async fn css_bundle_creates_output_on_startup_without_live_reload() {
1863 let src = TempDir::new().unwrap();
1864 let out = TempDir::new().unwrap();
1865
1866 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
1867
1868 let server = Server::new(out.path())
1869 .unwrap()
1870 .with_source_folder(src.path())
1871 .unwrap()
1872 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1873
1874 let (_port, handle) = server.run_ephemeral().await.unwrap();
1875
1876 sleep(Duration::from_millis(200)).await;
1877
1878 let bundle = out.path().join("styles.css");
1879 assert!(
1880 bundle.exists(),
1881 "bundle should be created even without live_reload"
1882 );
1883 let content = fs::read_to_string(&bundle).unwrap();
1884 assert!(!content.is_empty(), "bundle should contain CSS");
1885
1886 handle.shutdown().await;
1887 }
1888
1889 #[tokio::test]
1890 async fn css_bundle_concatenates_multiple_source_css_files() {
1891 let src = TempDir::new().unwrap();
1892 let out = TempDir::new().unwrap();
1893
1894 fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
1895 fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
1896
1897 let server = Server::new(out.path())
1898 .unwrap()
1899 .with_live_reload()
1900 .with_source_folder(src.path())
1901 .unwrap()
1902 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
1903
1904 let (_port, handle) = server.run_ephemeral().await.unwrap();
1905
1906 sleep(Duration::from_millis(800)).await;
1907
1908 let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
1909 assert!(
1910 content.contains("margin"),
1911 "output should contain reset CSS"
1912 );
1913 assert!(
1914 content.contains("background"),
1915 "output should contain theme CSS"
1916 );
1917
1918 handle.shutdown().await;
1919 }
1920}
1921
1922#[cfg(test)]
1923mod asset_folder_tests {
1924 use super::*;
1925 use std::fs;
1926 use std::time::Duration;
1927 use tempfile::TempDir;
1928 use tokio::time::sleep;
1929
1930 #[tokio::test]
1931 async fn asset_folder_overlapping_output_dir_is_rejected() {
1932 let root = TempDir::new().unwrap();
1933 let result = Server::new(root.path())
1934 .unwrap()
1935 .with_asset_folder(root.path());
1936 assert!(
1937 result.is_err(),
1938 "an asset folder equal to the output dir must be rejected"
1939 );
1940 }
1941
1942 #[tokio::test]
1943 async fn asset_folder_overlapping_an_existing_asset_folder_is_rejected() {
1944 let root = TempDir::new().unwrap();
1945 let assets = TempDir::new().unwrap();
1946
1947 let result = Server::new(root.path())
1948 .unwrap()
1949 .with_asset_folder(assets.path())
1950 .unwrap()
1951 .with_asset_folder(assets.path());
1952 assert!(
1953 result.is_err(),
1954 "registering the same asset folder twice must be rejected"
1955 );
1956 }
1957
1958 #[tokio::test]
1959 async fn asset_folder_overlapping_a_source_folder_is_rejected_both_ways() {
1960 let root = TempDir::new().unwrap();
1961 let shared = TempDir::new().unwrap();
1962
1963 let via_asset_then_source = Server::new(root.path())
1964 .unwrap()
1965 .with_asset_folder(shared.path())
1966 .unwrap()
1967 .with_source_folder(shared.path());
1968 assert!(
1969 via_asset_then_source.is_err(),
1970 "a source folder overlapping an already-registered asset folder must be rejected"
1971 );
1972
1973 let via_source_then_asset = Server::new(root.path())
1974 .unwrap()
1975 .with_source_folder(shared.path())
1976 .unwrap()
1977 .with_asset_folder(shared.path());
1978 assert!(
1979 via_source_then_asset.is_err(),
1980 "an asset folder overlapping an already-registered source folder must be rejected"
1981 );
1982 }
1983
1984 #[tokio::test]
1985 async fn asset_folder_files_are_served_after_startup_build() {
1986 let assets = TempDir::new().unwrap();
1987 let out = TempDir::new().unwrap();
1988 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
1989 fs::create_dir(assets.path().join("images")).unwrap();
1990 fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();
1991
1992 let server = Server::new(out.path())
1993 .unwrap()
1994 .with_asset_folder(assets.path())
1995 .unwrap();
1996 let (port, handle) = server.run_ephemeral().await.unwrap();
1997
1998 sleep(Duration::from_millis(200)).await;
1999
2000 let index = fs::read_to_string(out.path().join("index.html")).unwrap();
2001 assert_eq!(index, "<html>hi</html>");
2002 let logo = fs::read_to_string(out.path().join("images/logo.svg")).unwrap();
2003 assert_eq!(logo, "<svg></svg>");
2004
2005 let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
2006 .await
2007 .unwrap();
2008 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2009 conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2010 .await
2011 .unwrap();
2012 let mut response = Vec::new();
2013 conn.read_to_end(&mut response).await.unwrap();
2014 let response = String::from_utf8_lossy(&response);
2015 assert!(response.contains("HTTP/1.1 200"), "got: {response}");
2016 assert!(response.contains("<html>hi</html>"), "got: {response}");
2017
2018 handle.shutdown().await;
2019 }
2020
2021 #[tokio::test]
2022 async fn asset_folder_change_rebuilds_and_live_reloads() {
2023 let assets = TempDir::new().unwrap();
2024 let out = TempDir::new().unwrap();
2025 fs::write(assets.path().join("index.html"), "v1").unwrap();
2026
2027 let server = Server::new(out.path())
2028 .unwrap()
2029 .with_live_reload()
2030 .with_asset_folder(assets.path())
2031 .unwrap();
2032 let (_port, handle) = server.run_ephemeral().await.unwrap();
2033
2034 sleep(Duration::from_millis(800)).await;
2035 assert_eq!(
2036 fs::read_to_string(out.path().join("index.html")).unwrap(),
2037 "v1"
2038 );
2039
2040 fs::write(assets.path().join("index.html"), "v2").unwrap();
2041 sleep(Duration::from_millis(1500)).await;
2042
2043 assert_eq!(
2044 fs::read_to_string(out.path().join("index.html")).unwrap(),
2045 "v2",
2046 "editing the source asset must re-copy it into the output dir"
2047 );
2048
2049 handle.shutdown().await;
2050 }
2051}
2052
2053#[cfg(test)]
2054mod build_once_tests {
2055 use super::*;
2056 use std::fs;
2057 use tempfile::TempDir;
2058
2059 #[tokio::test]
2060 async fn build_populates_the_output_dir_without_starting_a_server() {
2061 let assets = TempDir::new().unwrap();
2062 let out = TempDir::new().unwrap();
2063 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2064
2065 let server = Server::new(out.path())
2066 .unwrap()
2067 .with_asset_folder(assets.path())
2068 .unwrap();
2069
2070 server.build().await.unwrap();
2071
2072 assert_eq!(
2073 fs::read_to_string(out.path().join("index.html")).unwrap(),
2074 "<html>hi</html>",
2075 "build() must populate the output dir synchronously, no server needed"
2076 );
2077 }
2078
2079 #[tokio::test]
2080 async fn build_with_no_pipeline_configured_is_a_harmless_no_op() {
2081 let out = TempDir::new().unwrap();
2082 let server = Server::new(out.path()).unwrap();
2083
2084 server
2085 .build()
2086 .await
2087 .expect("build() with nothing configured must succeed trivially");
2088 }
2089
2090 #[tokio::test]
2091 async fn build_fails_fast_when_a_required_tool_binary_is_missing() {
2092 let src = TempDir::new().unwrap();
2093 let out = TempDir::new().unwrap();
2094 fs::write(src.path().join("a.css"), "body{}").unwrap();
2095
2096 let server = Server::new(out.path())
2097 .unwrap()
2098 .with_source_folder(src.path())
2099 .unwrap()
2100 .with_css_tool(CssTool::TestMissing, CssOptions::new().minify(true));
2101
2102 let result = server.build().await;
2103
2104 assert!(
2105 matches!(result, Err(StaticError::PipelineSetup(_))),
2106 "expected PipelineSetup, got {result:?}"
2107 );
2108 }
2109}