pub struct Server { /* private fields */ }Implementations§
Source§impl Server
impl Server
Sourcepub fn new(root: &Path) -> Result<Self, StaticError>
pub fn new(root: &Path) -> Result<Self, StaticError>
Create a new server with the given root directory.
Canonicalizes the root once at startup. All subsequent requests use the canonical root without re-canonicalizing it, making this suitable for long-lived servers.
§Errors
Returns Err(StaticError::Io) if the root cannot be canonicalized (e.g., doesn’t exist,
no read permissions).
Sourcepub fn with_max_connections(self, max: usize) -> Self
pub fn with_max_connections(self, max: usize) -> Self
Set the maximum number of connections served concurrently (default 1024).
Once this many connections are in flight, run()’s accept loop stops accepting
new ones — without pausing the accept loop, a client that opens a connection and
sends nothing (see the header-read timeout docs on Server::run_on) could
otherwise be used, in enough parallel copies, to exhaust the process’s file
descriptors or memory with no bound at all.
Sourcepub fn with_live_reload(self) -> Self
pub fn with_live_reload(self) -> Self
Enable live-reload for this server (disabled by default).
Once enabled, the run* methods start a background watcher (mtime polling,
bounded 500ms interval — see crate::start_watching) the first time the server
actually starts accepting connections. It watches the served root; when a build
pipeline is configured it watches that pipeline’s source folders instead, because
the pipeline broadcasts its own outputs once they are written. Then it will:
- serve a live-reload SSE stream at
crate::LIVE_RELOAD_PATH, broadcasting a change event (withcrate::ChangeType) whenever a served file is added, modified, or removed; - inject a small
<script>into every servedtext/htmlresponse that connects to that stream and reloads the page (or hot-swaps stylesheet<link>s, for CSS changes) — no manual client wiring required.
This is meant for local development, not production: leave it disabled (the
default) for any server serving real traffic. A typical call site gates it behind
#[cfg(debug_assertions)] so a release build never pays for the watcher or the
injected script.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?;
#[cfg(debug_assertions)]
let server = server.with_live_reload();Sourcepub fn with_spa_mode(self) -> Self
pub fn with_spa_mode(self) -> Self
Enable spa-mode navigation for this server, swapping document.body on
each navigation (disabled by default).
Once enabled, every served text/html response gets a small <script>
injected (see Server::with_spa_root for what it does) that treats
document.body as the swap target. Calling this after
Server::with_spa_root does not clear a previously configured root
selector — the two methods set independent fields, so
.with_spa_root(sel).with_spa_mode() and
.with_spa_mode().with_spa_root(sel) both end up with spa-mode on and
root sel. Use this one alone when there’s no persistent chrome to
preserve across navigations.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?.with_spa_mode();Sourcepub fn with_spa_root(self, selector: &str) -> Self
pub fn with_spa_root(self, selector: &str) -> Self
Enable spa-mode navigation for this server, swapping only the element
matched by the CSS selector on each navigation (disabled by default;
also enables spa-mode the same as Server::with_spa_mode).
Once enabled, every served text/html response gets a small <script>
injected that intercepts left-clicks on same-origin <a href>
elements (skipping links with a non-_self target, a download
attribute, rel="external", a data-no-spa attribute, or a same-page
hash-only href) and, instead of a normal navigation:
- fetches the target URL;
- on a non-OK or non-
text/htmlresponse (or a fetch error), falls back to a reallocation.hrefnavigation — spa-mode never renders a broken page; - otherwise replaces the matched element’s
innerHTMLwith the corresponding content from the fetched document, updates the page title, and pushes the new URL viahistory.pushState, animating the swap withdocument.startViewTransition()where supported; - dispatches a
mini-static:navigateCustomEventonwindowafter every client-side navigation, so page scripts can re-run any per-page initialization that would otherwise only execute once (content swapped in viainnerHTMLnever executes its own<script>tags); - handles browser back/forward by re-fetching and swapping to the new
location.href.
selector is matched against both the current page and the fetched
page; a link click where the selector matches neither falls back to a
real navigation, same as a fetch failure. Choose a selector that
wraps only the content that varies between pages, leaving persistent
chrome (nav/header/footer) outside it so it survives navigation
untouched.
This is meant to be usable in production, not just local development
(unlike Server::with_live_reload): a click on a link mini-static
doesn’t intercept, or on a browser without JS or View Transitions
support, still works as a normal navigation.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?.with_spa_root("#app");Sourcepub fn with_spa_transition(self, transition: SpaTransition) -> Self
pub fn with_spa_transition(self, transition: SpaTransition) -> Self
Set how spa-mode animates the swap between pages (also enables
spa-mode the same as Server::with_spa_mode; default
SpaTransition::Fade when spa-mode is enabled without calling this).
SpaTransition::Slide injects its own <style> tag alongside the
spa-mode <script> — no site CSS is required. See SpaTransition
and [SlideOptions] for what each variant does and how to
configure the slide’s duration, direction, and easing.
§Example
use mini_static::{Server, SlideOptions, SpaTransition};
use std::path::Path;
let server = Server::new(Path::new("./public"))?
.with_spa_root("#app")
.with_spa_transition(SpaTransition::Slide(
SlideOptions::default().duration_ms(500),
));Sourcepub fn with_not_found_page(self, path: &Path) -> Result<Self, StaticError>
pub fn with_not_found_page(self, path: &Path) -> Result<Self, StaticError>
Serves path as the body of every 404, instead of the default plain-text
not found.
path is resolved relative to the served root and must exist when this is
called: a missing 404 page is a deployment mistake, and finding out on the first
broken link — the one moment the page exists to handle — is too late. It is read
from disk per response rather than cached, so editing it during a live-reload
session takes effect without a restart.
The response keeps its 404 status. Serving a custom page with 200 is a soft
404: search engines index it, and monitoring stops seeing the failures. It also
carries Cache-Control: no-store, so a client never holds this page as though it
were the resource that was actually requested.
Nothing about the failed request reaches the page — no path, no reason. A
traversal attempt and an ordinary miss are deliberately indistinguishable
(StaticError::user_message), and templating the requested path into the
response would undo that and hand back a reflected-content vector besides.
§Errors
Returns Err(StaticError::Io) if path cannot be canonicalized (typically:
it does not exist), or Err(StaticError::Traversal) if it lies outside the
served root.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?
.with_not_found_page(Path::new("404.html"))?;Serve dot-prefixed paths (.env, .git/config) instead of answering them as a
miss.
Hidden files are denied by default. A served root is routinely a build output
directory, a repository working copy, or a folder someone dropped a .env into,
and the traversal guard cannot help: those files are legitimately inside the
root, so anyone who guesses the name gets them. The default trades a rarely-wanted
capability for not leaking credentials by accident.
/.well-known/ is served either way — it is where the web puts resources that
are meant to be fetched (ACME challenges for certificate issuance,
security.txt), and denying it would break certificate renewal. The exception is
the first segment only: /.well-known/.hidden is still denied.
Call this when the served root is a curated directory whose dotfiles are content
— a static site that publishes a .htaccess for a downstream server, say.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?.with_hidden_files();Sourcepub fn with_response_header(
self,
name: &str,
value: &str,
) -> Result<Self, StaticError>
pub fn with_response_header( self, name: &str, value: &str, ) -> Result<Self, StaticError>
Log one line per request to stderr, plus connection-level errors.
Off by default: a library that writes to a process’s stderr uninvited is a
surprise, and an embedder with its own logging wants the lines somewhere else.
See Server::with_request_logging_to to choose the destination.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?.with_request_logging();Send name: value on every response.
Call repeatedly to add several. Intended for the policy headers a static site
wants applied uniformly — Strict-Transport-Security, Content-Security-Policy,
Referrer-Policy — which this crate has no business choosing on an embedder’s
behalf but every business making expressible.
Both name and value are validated here, at configuration time, so a malformed header fails when the server is built rather than on a request months later.
§Errors
StaticError::Configifnameorvalueis not a valid HTTP header.StaticError::Configifnameis one this server computes per response (Content-Length,Content-Type,Content-Encoding,Content-Range,ETag,Cache-Control,Vary,Accept-Ranges,Allow,Location,Connection,Transfer-Encoding,X-Content-Type-Options). A fixed value would either be silently overridden or silently duplicated depending on the response — a configuration mistake worth surfacing at startup rather than a behavior worth supporting. UseServer::with_immutable_assetsfor cache policy.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?
.with_response_header("Strict-Transport-Security", "max-age=63072000")?
.with_response_header("Referrer-Policy", "strict-origin-when-cross-origin")?;pub fn with_request_logging(self) -> Self
Sourcepub fn with_request_logging_to(self, writer: Box<dyn Write + Send>) -> Self
pub fn with_request_logging_to(self, writer: Box<dyn Write + Send>) -> Self
Log one line per request to writer, plus connection-level errors.
Each served request writes one line:
GET /index.html 200 512 0.421ms— method, requested path exactly as received, status, response body bytes (-
when the length isn’t known, as on a live-reload SSE stream), and how long
handling took. Connection-level failures — a malformed request, a client
vanishing mid-response — write connection error: <cause>; before this they were
discarded entirely, so a server that was refusing every request looked exactly
like one nobody was talking to.
The path is logged as received, not decoded: it is attacker-controlled input, and a log reader deserves to see the bytes that actually arrived rather than a normalized rendering of them.
Writes are serialized across connections and write errors are ignored — a failing log sink must not take down request serving.
§Example
use mini_static::Server;
use std::fs::File;
use std::path::Path;
let log = File::create("access.log")?;
let server = Server::new(Path::new("./public"))?.with_request_logging_to(Box::new(log));Sourcepub fn with_immutable_assets<F>(self, predicate: F) -> Self
pub fn with_immutable_assets<F>(self, predicate: F) -> Self
Serve files matching predicate with a long-lived, immutable cache policy
instead of the default Cache-Control: no-cache.
predicate is evaluated against each resolved file’s path; a match sends
Cache-Control: public, max-age=31536000, immutable on that file’s 200 and 304
responses. This is correct only for fingerprinted assets (e.g.
main.a1b2c3.js) where a content change always produces a new filename —
caching a mutable filename indefinitely would serve stale content to every
client that already has it cached.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?
.with_immutable_assets(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(".fingerprint."))
});Sourcepub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError>
pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError>
Resolve a request path under the server’s root.
This is a lower-level API for resolving paths without generating HTTP responses.
For most use cases, prefer Server::handle_request or the run* methods.
§Returns
Ok(PathBuf)if the path resolves to a file within root.Err(StaticError)if the path is invalid, missing, or attempts traversal.
Sourcepub async fn run_on(
&self,
addr: SocketAddr,
header_timeout: Duration,
) -> Result<(u16, ServerHandle), StaticError>
pub async fn run_on( &self, addr: SocketAddr, header_timeout: Duration, ) -> Result<(u16, ServerHandle), StaticError>
Run the server on a specific address with a configurable header-read timeout.
Spawns the server in a background Tokio task and returns immediately with the
assigned port number and a ServerHandle. Call handle.shutdown().await to
stop accepting new connections and wait for in-flight connections to finish.
Dropping the handle instead leaves the server running for the life of the process.
§Header-Read Timeout
Connections that don’t send complete HTTP headers within header_timeout are closed.
This prevents slowloris attacks and resource exhaustion from incomplete requests. The
timeout applies only to the header-read phase — once a complete header block has been
read, the connection is handed off with no further time bound, so long-lived response
bodies (e.g. the live-reload SSE stream from Server::with_live_reload) are not cut
off mid-stream.
§Precompressed Sidecars
If a request’s Accept-Encoding allows br or gzip (preferring br) and a
sibling <path>.br/<path>.gz exists next to the resolved file, its bytes are
served instead with a matching Content-Encoding. Every file response carries
Vary: Accept-Encoding so intermediate caches don’t serve the wrong variant to a
differently-capable client.
§Arguments
addr- Socket address to bind to (e.g.,127.0.0.1:0for loopback ephemeral, or0.0.0.0:8080to bind all interfaces on a fixed port).header_timeout- Maximum time to wait for complete HTTP headers on each connection.
§Returns
Ok((u16, ServerHandle))with the assigned port number and a handle for graceful shutdown.Err(StaticError::Io)if binding to the socket fails.Err(StaticError::PipelineSetup)if a configured [CssTool]/[JsTool]’s binary is not found onPATH. Checked before the listener binds: a deployment whose configured pipeline can never run should fail visibly at boot, not be discovered later as a missing/stale asset.
Sourcepub async fn run(
&self,
header_timeout: Duration,
) -> Result<(u16, ServerHandle), StaticError>
pub async fn run( &self, header_timeout: Duration, ) -> Result<(u16, ServerHandle), StaticError>
Run the server on loopback (127.0.0.1), binding an ephemeral port.
Thin wrapper around Server::run_on — see it for the header-read timeout and
sidecar semantics, and for what the returned ServerHandle does.
Sourcepub async fn run_all(
&self,
port: u16,
header_timeout: Duration,
) -> Result<(u16, ServerHandle), StaticError>
pub async fn run_all( &self, port: u16, header_timeout: Duration, ) -> Result<(u16, ServerHandle), StaticError>
Run the server on all interfaces (0.0.0.0) at port (0 for an ephemeral port).
Useful for containerized deployments and reverse-proxy setups. Thin wrapper
around Server::run_on — see it for the header-read timeout and sidecar
semantics, and for what the returned ServerHandle does.
Sourcepub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError>
pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError>
Run the server on loopback with the default 30-second header-read timeout.
The recommended entry point for tests and lightweight services that don’t need a
custom timeout. Thin wrapper around Server::run.
§Example
use mini_static::Server;
use std::path::Path;
let server = Server::new(Path::new("./public"))?;
let (port, handle) = server.run_ephemeral().await?;
println!("Server ready on http://127.0.0.1:{}", port);
handle.shutdown().await;Sourcepub async fn handle_request(
&self,
method: &Method,
request_path: &str,
headers: &HeaderMap,
) -> Response<ResponseBody>
pub async fn handle_request( &self, method: &Method, request_path: &str, headers: &HeaderMap, ) -> Response<ResponseBody>
Produce the HTTP response for a request, streaming file bodies to the client.
This is the crate’s single request-handling path: the run* accept loop calls it,
and so should any async server embedding mini-static as a fallback route (e.g.
mini-unified).
Filesystem metadata work (path resolution, open, stat) runs inline on the
calling task, deliberately. Until 0.30.0 it was dispatched to Tokio’s blocking
pool so a slow filesystem could not stall co-scheduled tasks — measured under
load, that dispatch cost roughly three times the syscalls it sheltered, and a
one-worker server burned nearly four cores on pool handoff. On the local-disk
deployments this crate targets these calls are single-digit microseconds; an
embedder serving from a filesystem with unbounded latency (a network mount)
should use a multi-threaded runtime, which bounds the blast radius of a stall
to one worker.
File responses are backed by FileBody, which hands hyper one 64 KB chunk at a
time as poll_frame is driven: memory use stays bounded to one chunk per in-flight
response regardless of file size.
headers are the request’s headers; If-None-Match (304 on a matching ETag) and
Accept-Encoding (precompressed sidecar selection, see Server::run_on) are the
ones read today. Only GET and HEAD are allowed; anything else gets a 405 with an
Allow header. Missing files and traversal attempts both get an identical 404, so a
response never discloses whether a path exists outside the root.