roadrunner_static (roadrunner v0.11.0)
View SourceBuilt-in static file handler.
A route serves either a directory or a single file. For a directory, give
dir and end the route's path in a *path wildcard, which carries the
file's path under dir:
{~"/static/*path", roadrunner_static, #{dir => ~"/var/www"}}For one file, give file. The route needs no wildcard, and every request
it answers gets that file:
{~"/favicon.ico", roadrunner_static, #{file => ~"/var/www/favicon.ico"}}Both read the file from disk, set Content-Type from its extension, and
answer 404 when the file is missing (or is not a regular file). A
directory route also answers 404 for any path that contains ... Every
other feature below works the same way in both modes.
Options
The route's state is a map of opts/0:
dirorfile(exactly one is required): a path, as a binary or a string, or{priv, App, SubPath}cache_control: aCache-Controlvalue (see below)symlink_policy:refuse_escapes,followorrefuse(directory routes only, see below)cache_ttl_ms: a non-negative integer orinfinity(see below)mime_types: extra or replacement content types (see below)
The options are checked once, when the listener starts (and again on
roadrunner_listener:reload_routes/2), by the handler's
init_route/1. An unknown key, a value of the wrong shape, both or
neither of dir and file, or an application code:priv_dir/1 does not
know stops the listener from starting with an error naming the option
(see init_route/1), instead of failing a request later.
A relative path is made absolute against the working directory at that
point. {priv, App, SubPath} is SubPath under the application's
priv directory, looked up once, so a release can serve the assets it
ships without building the path itself:
{~"/assets/*path", roadrunner_static, #{dir => {priv, my_app, ~"static"}}},
{~"/robots.txt", roadrunner_static, #{file => {priv, my_app, ~"robots.txt"}}}Content-Type
The extension is matched without regard to case, so logo.PNG is served
as image/png. The built-in table covers the common web types:
- Documents and code:
.htmland.htm(text/html),.css,.jsand.mjs(text/javascript),.jsonand.map(application/json),.webmanifest(application/manifest+json),.xml(application/xml),.txt,.csv,.md,.vtt,.wasm(application/wasm),.pdf,.zip - Images:
.png,.apng,.jpg,.jpeg,.gif,.webp,.avif,.bmp,.svg(image/svg+xml),.ico(image/x-icon) - Fonts:
.woff,.woff2,.ttf,.otf(font/*) - Audio and video:
.mp3(audio/mpeg),.m4a(audio/mp4),.ogg(audio/ogg),.wav(audio/wav),.mp4(video/mp4),.webm(video/webm)
The text/* types carry ; charset=utf-8. Any other extension, and a
file with none, is served as application/octet-stream.
#{mime_types => #{Ext => ContentType}} adds types to the table or
replaces built-in ones for this route. Ext is the extension with its
leading dot and ContentType is the header value, sent verbatim:
{~"/static/*path", roadrunner_static, #{
dir => ~"/var/www",
mime_types => #{
~".glb" => ~"model/gltf-binary",
~".txt" => ~"text/plain; charset=iso-8859-1"
}
}}Each key must start with . and each value must be a binary. Keys are
matched without regard to case, like the file's extension: ~".glb" and
~".GLB" both match scene.glb and scene.GLB. Two keys that differ
only in case are refused. The route's types are merged into the built-in
table when the listener starts, so a request does one lookup. A file
route looks its type up once, then.
Precompressed-sibling serving
When a request's Accept-Encoding opts into a compressed encoding
and the requested file has a matching precompressed sibling on disk,
the sibling is served verbatim with the corresponding
Content-Encoding plus Vary: Accept-Encoding. This matches nginx's
gzip_static on behaviour and lets operators pre-compress build
assets once instead of paying the compression cost per request.
Two encodings are negotiated, brotli first:
Accept-Encoding: brwith a<file>.brsibling on disk →Content-Encoding: brAccept-Encoding: gzipwith a<file>.gzsibling on disk →Content-Encoding: gzip
Brotli wins when the client accepts both and the .br sibling
exists; gzip is the fallback. With neither sibling on disk (or
neither encoding accepted) the raw file is served. Roadrunner never
compresses on the fly — it only sendfiles a sibling an operator
placed on disk, so there is no brotli or gzip codec dependency.
Accept-Encoding is matched via plain substring (br, gzip)
rather than full RFC 9110 §12.5.3 qvalue ranking. Browsers and
clients that reach the static path send these tokens plainly.
The original file's ETag is reused for every variant, so a follow-up
If-None-Match returns 304 regardless of which variant was first
served. A Range request disables the precompressed path on that
request — byte offsets over a compressed representation have subtle
semantics and the simple "Range wins" rule matches what nginx does.
The Content-Type always reflects the original file's extension,
not the sibling's.
Cache-Control
#{cache_control => Value} sets a Cache-Control response header,
verbatim, on every cacheable response for the file — the 200 (full
file and any precompressed sibling), the 206 range, and the
304 Not Modified. A 404 or a 416 never carries it, so a missing
file or an unsatisfiable range is never marked cacheable.
The value is an opaque binary the operator controls in full —
roadrunner does not parse, validate, or merge directives. This mirrors
nginx's add_header Cache-Control, where the operator writes the exact
directive string.
The option is off by default. With no cache_control key the responses
are byte-for-byte what they are without it, carrying no Cache-Control
header at all. ETag and Last-Modified only enable conditional
revalidation — a follow-up request that may come back 304 — whereas
Cache-Control is what lets a browser or a CDN reuse the resource from
its own cache without contacting the server at all.
A typical value for content-hashed build assets:
{~"/static/*path", roadrunner_static, #{
dir => ~"/var/www",
cache_control => ~"public, max-age=31536000, immutable"
}}max-age caps how many seconds a cache may reuse the response,
immutable tells browsers not to revalidate during that window, and
public lets shared caches such as CDNs and proxies store it.
Symlink policy
#{symlink_policy => Policy} (default refuse_escapes) controls
how symlinks inside the docroot of a dir route are handled. The policy applies to
the leaf of the requested path — symlinks in intermediate
directories are still followed by the kernel.
refuse_escapes(default) — symlinks whose target resolves insidedirare followed; symlinks pointing outside (e.g. an absolute target like/etc/passwd, or a relative target with..segments) return 404. Every link of a chain is checked, so a link to an in-docroot link that points outside is refused too. Stricter than nginx/Apache defaults but matches what an operator typically wants for a public docroot.follow— every symlink is followed regardless of where it points (nginxdisable_symlinks offequivalent). Use only when the docroot's filesystem permissions prevent untrusted writes.refuse— every symlink returns 404, even safe in-docroot ones.
A file route always follows its file when it is a symlink, wherever the
link points: the operator named that path, and no request can choose
another one, so there is nothing to escape. Setting symlink_policy on a
file route is refused when the listener starts, since it would have no
effect.
Metadata cache
#{cache_ttl_ms => N} caches the stat result (size, mtime) for
each regular file in a node-global ETS table for N milliseconds. Hot
paths skip the per-request read_link_info syscall after the
first hit. Symlinks always bypass the cache because the policy
gate needs the un-followed lookup (a file route's symlinked file
too).
The default is 0 (disabled). Enabling the cache assumes files
are immutable during the TTL window: the cached size feeds
the Content-Length header while sendfile reads the file fresh
from disk, so a file replaced or resized mid-window produces a
length / body mismatch (truncated or short response). Safe for
deploy-then-restart workflows with versioned-by-hash assets;
unsafe for mutable files (user uploads, dev hot-reload).
Set to a positive integer to opt in (e.g., cache_ttl_ms => 1000),
or infinity for "cache for the lifetime of the listener; only a
listener restart re-stats". nginx's open_file_cache makes the same
trade-off and is also off by default.
Call roadrunner_static:cache_clear/0 to flush every cached entry
without a listener restart — useful after a deploy that swaps files
under an infinity (or long) TTL.
Summary
Types
The route state roadrunner_static takes, checked when the routes compile
(see the moduledoc for each option). Exactly one of dir and file is
required.
Where dir or file points: a path, as a binary or a string, or
{priv, App, SubPath} for SubPath under code:priv_dir(App). A
relative path is made absolute against the working directory when the
routes compile.
Precompressed siblings found on disk for a regular file, keyed by
encoding. A key is present only when that sibling is a regular file;
the value is its size in bytes. #{} means neither sibling exists.
A route's options once init_route/1 has checked them: what the handler
reads on each request. Treat as opaque.
Functions
Drop every cached static-file metadata entry. Pair with
cache_ttl_ms => infinity (or any TTL longer than your deploy
cycle) to flush stale metadata after replacing files in the
docroot, without restarting the listener.
Check the route's opts/0 when the routes compile (listener start and
roadrunner_listener:reload_routes/2) and build the state handle/1
serves from: the resolved dir or file, a {priv, App, SubPath} path
looked up once, the mime_types keys lowercased and merged into the
built-in table, and for file its Content-Type. A bad option raises,
which stops the listener from starting
Types
-type opts() :: #{dir => path(), file => path(), cache_control => binary(), symlink_policy => follow | refuse | refuse_escapes, cache_ttl_ms => non_neg_integer() | infinity, mime_types => #{binary() => binary()}}.
The route state roadrunner_static takes, checked when the routes compile
(see the moduledoc for each option). Exactly one of dir and file is
required.
-type path() :: file:name_all() | {priv, App :: atom(), SubPath :: file:name_all()}.
Where dir or file points: a path, as a binary or a string, or
{priv, App, SubPath} for SubPath under code:priv_dir(App). A
relative path is made absolute against the working directory when the
routes compile.
-type siblings() :: #{br => non_neg_integer(), gz => non_neg_integer()}.
Precompressed siblings found on disk for a regular file, keyed by
encoding. A key is present only when that sibling is a regular file;
the value is its size in bytes. #{} means neither sibling exists.
-opaque state()
A route's options once init_route/1 has checked them: what the handler
reads on each request. Treat as opaque.
Functions
-spec cache_clear() -> ok.
Drop every cached static-file metadata entry. Pair with
cache_ttl_ms => infinity (or any TTL longer than your deploy
cycle) to flush stale metadata after replacing files in the
docroot, without restarting the listener.
-spec handle(roadrunner_req:request()) -> roadrunner_handler:result().
Check the route's opts/0 when the routes compile (listener start and
roadrunner_listener:reload_routes/2) and build the state handle/1
serves from: the resolved dir or file, a {priv, App, SubPath} path
looked up once, the mime_types keys lowercased and merged into the
built-in table, and for file its Content-Type. A bad option raises,
which stops the listener from starting:
{unknown_static_opt, Key}for a key that is not an option{invalid_static_opt, Key, Value}for a value of the wrong shape (for a badmime_typesentry,Valueis that{Ext, ContentType}entry){static_app_not_found, Key, App}whencode:priv_dir(App)knows no such application{static_opt_conflict, dir, file}when both are set, and{static_opt_conflict, file, symlink_policy}for a policy on a file route{missing_static_opt, dir_or_file}when neither is set{invalid_static_opts, Opts}when the state is not a map