Skip to main content

mini_static/
server.rs

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