wsv
wsv is a zero-dependency static preview server for Ruby projects. Defensive by design: blocks dotfiles and binds to loopback by default.
It has no runtime dependencies outside Ruby's standard library. Run wsv in a directory and it serves that directory over HTTP/HTTPS.
Requires Ruby 3.2 or later.
Installation
Add to your Gemfile:
group :development do
gem "wsv"
end
Then run bundle install and start with bundle exec wsv.
Or install globally:
gem install wsv
Usage
wsv [options] [directory]
Examples:
wsv # serve current directory
wsv _site # Jekyll / Bridgetown output
wsv build # Astro / Hugo output
wsv --spa dist # Vite / esbuild / webpack SPA output
wsv --tls --open # HTTPS, open browser at startup
wsv -h 0.0.0.0 -p 3000 ./dist
Options:
-h, --host HOST Bind host (default: 127.0.0.1)
-p, --port PORT Bind port (default: 8000)
--tls Enable HTTPS (uses ~/.config/wsv/{cert,key}.pem if both present, else self-signed)
--cert PATH TLS certificate file (PEM); implies --tls
--key PATH TLS private key file (PEM); implies --tls
--spa Single-page-app mode: fall back to root index.html on 404
--open Open the served URL in the default browser at startup
--cors Send Access-Control-Allow-Origin: * on every response
--help Show help
--version Show version
TLS / HTTPS
--tls enables HTTPS on the chosen --port. Three modes:
- Ephemeral self-signed:
wsv --tlswith no cert configured: wsv generates an in-memory self-signed certificate. Browsers will show a security warning; click through "Advanced → Proceed" once per session. ~/.config/wsv/auto-detection (recommended): if both~/.config/wsv/cert.pemand~/.config/wsv/key.pemexist (resolved via$XDG_CONFIG_HOMEif set),--tlsuses them. If only one of the two files is present, wsv refuses to start so the misconfiguration does not silently fall back to a self-signed certificate. Combine with mkcert to skip browser warnings:
mkcert -install # one-time: register a local CA in your trust stores
mkdir -p ~/.config/wsv
mkcert -cert-file ~/.config/wsv/cert.pem \
-key-file ~/.config/wsv/key.pem \
localhost 127.0.0.1 ::1
chmod 600 ~/.config/wsv/key.pem
wsv --tls # → https://localhost:8000/ with no warning
- Explicit cert/key files:
wsv --cert path/to/cert.pem --key path/to/key.pemfor project-specific certificates. Both flags must be provided together.
Behavior
- Serves files from the selected directory.
- Serves
index.htmlfor directories that contain it. - Does not render directory listings.
- Supports
GETandHEAD. - Supports
Rangerequests (206 Partial ContentwithContent-Range). - Honours
If-Modified-Sinceand returns304 Not Modifiedwhen applicable. - Rejects paths that resolve outside the served directory.
- Sends
Cache-Control: no-cacheso the browser revalidates each request. - With
--spa, serves the rootindex.htmlinstead of404when a path resolves to "not found" (so client-side routers like React Router or Vue Router work).403and other errors are unaffected, so dotfile and traversal blocks still apply. - If the served directory contains a
404.htmlfile, it is served as the body of every404 Not Foundresponse (withContent-Type: text/html) instead of the built-in plain text. Matches the convention of Jekyll, Hugo, and many static hosts. - With
--cors, every response carriesAccess-Control-Allow-Origin: *(andVary: Origin), andOPTIONSpreflight requests get204 No Contentwith the matching CORS headers. Lets a frontend on a different port (or a Service Worker) fetch assets fromwsvduring local development.
Security model
[!WARNING]
wsvis intended for local development previews, not for production or internet-facing use.
Within that scope it tries to behave defensively:
What wsv protects against
- Path traversal —
.., absolute paths, and URL-encoded forms (%2e%2e) are resolved and rejected if they escape the served directory. - Symlink-based escape — symlinks pointing outside the served directory are rejected (403). Symlinks that resolve inside the directory are followed.
- Symlink-to-dotfile bypass — even if a non-dotfile name is requested, the
resolved real path is checked again so an internal symlink cannot smuggle
access to
.git/,.env, etc. - Dotfile exposure — any path segment beginning with
.is rejected (403), whether at the URL layer or after symlinks resolve. - Unintended LAN exposure — the default bind is
127.0.0.1. Passing--host 0.0.0.0(or any non-loopback address) prints aWARNINGto stderr so the choice is explicit. - Resource exhaustion from oversized requests — request line, header line,
total header bytes, and header count are bounded; offending clients receive
414or431and are disconnected. - Slow / idle clients — each request has a per-request read deadline
(default 10s, configurable). Stalled connections receive
408. - Header injection — CR/LF in response header values is rejected at construction time, so user-derived strings cannot inject extra headers.
- MIME sniffing — every response carries
X-Content-Type-Options: nosniffso browsers honour the declaredContent-Typerather than guessing from body contents. - Single-client monopolisation — connections are handled by a thread pool
capped at
max_connections(default 8). Excess clients receive503(or are closed without response in TLS mode, since writing plaintext over a half-handshaked TLS socket would corrupt the client's view of the protocol). - Transient
accept(2)errors — per-connection failures (ECONNABORTED,EMFILE, etc.) are logged and skipped instead of killing the server.
What wsv does NOT do
- Authentication, authorization, or rate limiting.
- HTTP keep-alive (each response sets
Connection: close). - HTTP/2. Use Caddy / nginx as a front proxy if you need it.
- ETags /
If-None-Match. - Production-grade DoS resistance under hostile network load.
- Defend against TOCTOU attacks from other local processes that can write to the served directory. Path resolution (canonicalisation, dotfile checks, within-root verification) happens before each file is opened; another process that can swap files in the served directory between resolution and read could redirect a request elsewhere on the same machine.
- Protect a directory you should not be sharing in the first place. The
bound is the directory you pass on the command line; if it contains
secrets, do not run
wsvagainst it.
If you need any of the above, use a real production server.
Public API and stability
wsv follows Semantic Versioning. The public API
that SemVer covers is the CLI:
- The flags listed in Options above and their meanings.
- The directory argument and the default behaviour when it is omitted.
- Process exit codes (
0for success,1for usage / setup errors).
Within a major version, wsv will not silently change the default bind
host, default port, the dotfile-blocking rule, or the security posture in
ways that would surprise an existing user.
The Ruby classes inside lib/wsv/ are implementation details. They may
change in any release, including patches. Pin the gem version if you
embed wsv as a library.
License
MIT