A single-file Java 25 shebang script that serves a directory over HTTP.
Built on the JDK's built-in com.sun.net.httpserver with virtual threads.
No build step, no dependencies — just drop httpserv on your $PATH and run.
- Single executable file (
httpserv) — runs viajava --source 25 - Virtual-thread-per-request executor
- RFC 7233 range requests: single ranges, multi-range
multipart/byteranges, andIf-Range— useful for COGs, video, resumable downloads - Directory listing with sorted entries and human-readable sizes
- Read-only:
GET,HEAD,OPTIONS,TRACE - Optional
ETagheader (value = file'slastModifiedtimestamp in millis) - Optional authentication: Basic, Bearer, API Key, Custom Header (repeatable, any match)
- Network conditioning:
--latencyand--bandwidthto simulate a distant, throttled bucket - Path-traversal protection
- Access log includes the
Rangeheader when present
- JDK 25 or newer on
$PATH(tested with Temurin 25)
The installed binary is named httpserv.sh to avoid colliding with the NSS
test server that Homebrew ships as /opt/homebrew/bin/httpserv.
Clone and use the provided Makefile:
git clone https://github.com/multiversio/httpserv.sh.git
cd httpserv.sh
sudo make install # installs to /usr/local/bin/httpserv.shThe install target respects the standard PREFIX and DESTDIR variables, so
you can install without sudo into a user-writable location:
make install PREFIX=$HOME/.local # -> ~/.local/bin/httpserv.shTo uninstall:
sudo make uninstall # or: make uninstall PREFIX=$HOME/.localAlternatively, download the raw script directly:
curl -fsSL https://raw.githubusercontent.com/multiversio/httpserv.sh/main/httpserv.sh \
-o ~/.local/bin/httpserv.sh && chmod +x ~/.local/bin/httpserv.shmake test# runs test/run.sh (curl against a live server)Or drive the scripts directly:
./test/smoke.sh # open-server tests
./test/ranges.sh # RFC 7233 range request compliance
./test/auth.sh # auth + range-with-auth tests
./test/latency.sh # --latency
./test/bandwidth.sh # --bandwidthThey all honor PORT=... and HTTPSERV=... env overrides. CI runs the exact same
scripts — no duplicated logic.
httpserv.sh [options] [directory]
-d, --dir DIR directory to serve (default: .)
-p, --port PORT listen port (default: 8080)
-s, --silent suppress access logging
-e, --etag send ETag header (value = lastModified millis)
--latency DUR fixed delay before each file response
(e.g. 150ms, 2s, 1500us; bare number = ms)
--bandwidth RATE throttle response body throughput
(e.g. 10MB/s, 500KB/s; /s optional, 1024-based)
-a, --auth SPEC require authentication (repeatable; any match passes)
basic:USER:PASS
bearer:TOKEN
api-key:HEADER:VALUE
header:H1=V1,H2=V2,... (all headers required)
-h, --help show this help
Examples:
httpserv.sh # serve current dir on :8080
httpserv.sh -p 9000 ./public # serve ./public on :9000
httpserv.sh --etag --silent /data # ETags on, no access logsPass --auth one or more times. The server accepts a request as authorized if any of the configured credentials matches. With no --auth flags, the server is open.
httpserv.sh --auth basic:alice:s3cret # HTTP Basic
httpserv.sh --auth bearer:eyJhbGciOi... # OAuth/JWT Bearer
httpserv.sh --auth api-key:X-API-Key:abc123 # single-header API key
httpserv.sh --auth header:X-Tenant=acme,X-Env=prod # multi-header (all required)Multiple schemes can be combined — handy for testing clients that try different auth strategies:
httpserv.sh \
--auth basic:alice:s3cret \
--auth bearer:tok123 \
--auth api-key:X-API-Key:abcA request that fails authorization gets 401 Unauthorized. WWW-Authenticate challenges are emitted for Basic and Bearer when those schemes are configured.
Schemes map to io.tileverse.rangereader.http.*Authentication classes: BasicAuthentication, BearerTokenAuthentication, ApiKeyAuthentication, CustomHeaderAuthentication. Digest is intentionally unsupported for now.
Range handling follows RFC 7233.
Every file response advertises Accept-Ranges: bytes.
- Single range —
206 Partial Contentwith aContent-Rangeheader and the requested bytes as the body. - Multiple ranges —
206with amultipart/byterangespayload. Each part repeats the representation'sContent-Typeand states its ownContent-Range, and the parts keep the order the client listed them in. Ranges are never coalesced or reordered, and there is no cap on how many a client may ask for. - Suffix ranges —
bytes=-Nreturns the last N bytes; an N larger than the file returns the whole file. - Clamping — a
last-byte-pospast the end of the file is clamped to the last byte. If-Range— evaluated against theETag(with--etag) and againstLast-Modified, using strong comparison. A validator that no longer matches makes the server ignoreRangeand answer200with the whole file, which is what lets a client resume a download without splicing stale bytes.
416 Range Not Satisfiable, with Content-Range: bytes */<length>, comes back
when no requested range overlaps the file, when a suffix length is zero, or when
any spec is invalid (last-byte-pos before first-byte-pos). One invalid spec
rejects the whole set.
Range is ignored, and the whole file returned, when the range unit is not
bytes or when the header value does not parse as a byte-range-set.
HEAD is answered from the same evaluation as GET: same status, same
Content-Range, same Content-Length, no body. RFC 7233 section 3.1 asks
servers to ignore Range on any method other than GET, but Apache, nginx,
Caddy and S3 all answer 206 here, and RFC 7231 section 4.3.2 asks a HEAD
response to mirror the header fields of the matching GET. Standing in for
those object stores matters more here than the letter of section 3.1, so a
client probing range support with HEAD sees what they would send.
These flags reproduce the quirks of a remote object store so clients can be tested against realistic conditions.
Adds a fixed delay before each file response is sent, simulating the round-trip
time to a distant bucket. Virtual threads make the sleep cheap.
httpserv.sh --latency 150ms # 150 ms before every file response
httpserv.sh --latency 2s # a painfully distant region
httpserv.sh --latency 1500us # sub-millisecond precisionValues accept a us, ms, or s suffix; a bare number is milliseconds. The
delay applies only to file responses (200/206); error responses (404,
403, 401), redirects, and directory listings stay instant.
Throttles response body throughput, metering bytes as they are written so the
sender holds back to the configured rate. Pairs with --latency to model a
high-latency, fat-pipe object store.
httpserv.sh --bandwidth 10MB/s # cap every response body at 10 MB/s
httpserv.sh --bandwidth 500KB/s # a slow link
httpserv.sh --latency 150ms --bandwidth 5MB/s # both at onceValues accept B, KB, MB, or GB units (1024-based, case-insensitive); a
bare number is bytes. The trailing /s is optional. The throttle covers every
response body, including single-range and multipart/byteranges reads.
[2026-04-18T20:05:21.349Z] "GET /opendata/file.tif" "okhttp/5.3.2" Range: bytes=0-16383
httpserv primarily exists to test other components — imageio-ext, tileverse,
GeoTools, GeoServer — against cloud-native formats like COG, PMTiles, and GeoParquet.
Planned enhancements are geared at reproducing the quirks of real object-storage
backends (S3, GCS, Azure Blob) rather than general-purpose static hosting.
--ttfb <duration>— separate "time-to-first-byte" from streaming rate so we can model high-latency-but-fat-pipe object stores independently of throughput.--jitter <pct>— randomize latency/bandwidth by ±pct to avoid lockstep clients.
--fail-rate <pct>— return503 Service Unavailableon a configurable fraction of requests. Exercises client retry/backoff logic.--fail-ranges-rate <pct>— only fail requests that carry aRangeheader (COG readers are especially sensitive to partial-read failures).--truncate-rate <pct>— close the connection mid-response. Tests how clients handle short reads on range requests.
- Conditional requests — honor
If-None-Match/If-Modified-Since→304 Not Modified. Pairs with the existing--etagflag to validate cache logic. --tls— serve over HTTPS with an on-the-fly self-signed certificate. Some libraries take different code paths on TLS (connection pooling, ALPN, etc.).
--log-jsonl <file>— structured access log: method, path, range, status, bytes sent, duration. Grep-able for perf regressions and request-pattern asserts.- Replay / whitelist mode — load a manifest of allowed URL+range pairs; anything
outside returns
403. Lets tests assert the exact request pattern a client made.
CORS is intentionally out of scope: consumers like GeoServer proxy requests
server-side, so the browser never talks to httpserv directly.
MIT — see LICENSE.