wsv

wsv is a zero-dependency static preview server for Ruby projects.

It has no runtime dependencies outside Ruby's standard library. Run wsv in a directory and it serves that directory over HTTP.

Requires Ruby 3.2 or later.

Installation

gem install wsv

For local development:

gem build wsv.gemspec
gem install ./wsv-*.gem

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:

  1. Ephemeral self-signedwsv --tls with no cert configured: wsv generates an in-memory self-signed certificate. Browsers will show a security warning; click through "Advanced → Proceed" once per session.
  2. ~/.config/wsv/ auto-detection (recommended) — if both ~/.config/wsv/cert.pem and ~/.config/wsv/key.pem exist (resolved via $XDG_CONFIG_HOME if set), --tls uses 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
  1. Explicit cert/key fileswsv --cert path/to/cert.pem --key path/to/key.pem for project-specific certificates. Both flags must be provided together.

Behavior

  • Serves files from the selected directory.
  • Serves index.html for directories that contain it.
  • Does not render directory listings.
  • Supports GET and HEAD.
  • Supports Range requests (206 Partial Content with Content-Range).
  • Honours If-Modified-Since and returns 304 Not Modified when applicable.
  • Rejects paths that resolve outside the served directory.
  • Sends Cache-Control: no-cache so the browser revalidates each request.
  • With --spa, serves the root index.html instead of 404 when a path resolves to "not found" (so client-side routers like React Router or Vue Router work). 403 and other errors are unaffected, so dotfile and traversal blocks still apply.
  • If the served directory contains a 404.html file, it is served as the body of every 404 Not Found response (with Content-Type: text/html) instead of the built-in plain text. Matches the convention of Jekyll, Hugo, and many static hosts.
  • With --cors, every response carries Access-Control-Allow-Origin: * (and Vary: Origin), and OPTIONS preflight requests get 204 No Content with the matching CORS headers. Lets a frontend on a different port (or a Service Worker) fetch assets from wsv during local development.

Security model

wsv is 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 a WARNING to 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 414 or 431 and 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: nosniff so browsers honour the declared Content-Type rather than guessing from body contents.
  • Single-client monopolisation — connections are handled by a thread pool capped at max_connections (default 8). Excess clients receive 503 (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 wsv against 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 above (-h / --host, -p / --port, --help, --version) and their meanings.
  • The directory argument and the default behaviour when it is omitted.
  • Process exit codes (0 for success, 1 for 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