Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build run test bench lint precompress clean release install commit bump changelog benchmark benchmark-keep benchmark-down benchmark-baremetal
.PHONY: build run test bench lint precompress clean release install commit bump changelog benchmark benchmark-keep benchmark-down benchmark-baremetal benchmark-compress benchmark-compress-keep benchmark-compress-down

# Binary output path and name
BIN := bin/static-web
Expand Down Expand Up @@ -41,18 +41,21 @@ bench:
lint:
go vet ./...

## precompress: gzip and brotli compress all files in ./public
## precompress: gzip, brotli, and zstd compress all files in ./public
precompress:
@echo "Pre-compressing files in ./public ..."
@find ./public -type f \
! -name "*.gz" ! -name "*.br" \
! -name "*.gz" ! -name "*.br" ! -name "*.zst" \
| while read f; do \
if command -v gzip >/dev/null 2>&1; then \
gzip -k -f "$$f" && echo " gzip: $$f.gz"; \
fi; \
if command -v brotli >/dev/null 2>&1; then \
brotli -f "$$f" -o "$$f.br" && echo " brotli: $$f.br"; \
fi; \
if command -v zstd >/dev/null 2>&1; then \
zstd -k -f "$$f" && echo " zstd: $$f.zst"; \
fi; \
done
@echo "Done."

Expand Down Expand Up @@ -87,3 +90,15 @@ benchmark-down:
## benchmark-baremetal: run bare-metal benchmark (static-web production vs Bun, no Docker)
benchmark-baremetal:
@bash benchmark/baremetal.sh

## benchmark-compress: run compression-specific benchmark suite (tears down when done)
benchmark-compress:
@bash benchmark/compress-bench.sh

## benchmark-compress-keep: same as benchmark-compress but leaves containers running afterwards
benchmark-compress-keep:
@bash benchmark/compress-bench.sh -k

## benchmark-compress-down: tear down any running compression benchmark containers
benchmark-compress-down:
docker compose -f benchmark/docker-compose.compression.yml down --remove-orphans
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ static-web --help
| Feature | Detail |
|---------|--------|
| **In-memory LRU cache** | Size-bounded, byte-accurate; ~28 ns/op lookup with 0 allocations. Optional startup preload for instant cache hits. |
| **gzip compression** | On-the-fly via pooled `gzip.Writer`; pre-compressed `.gz`/`.br` sidecar support |
| **Compression** | On-the-fly gzip; pre-compressed `.gz`/`.br`/`.zst` sidecar support; priority: br > zstd > gzip |
| **HTTP/2** | Automatic ALPN negotiation when TLS is configured |
| **Conditional requests** | ETag, `304 Not Modified`, `If-Modified-Since`, `If-None-Match` |
| **Range requests** | Byte ranges via custom `parseRange`/`serveRange` implementation for video and large files |
Expand Down Expand Up @@ -103,7 +103,7 @@ HTTP request
│ • Range/conditional → custom serveRange() │
│ • Cache miss → os.Stat → disk read → cache put │
│ • Large files (> max_file_size) bypass cache │
│ • Encoding negotiation: brotli > gzip > plain │
│ • Encoding negotiation: brotli > zstd > gzip > plain │
│ • Preloaded files served instantly on startup │
│ • Custom 404 page (path-validated) │
└─────────────────────────────────────────────────┘
Expand Down Expand Up @@ -262,7 +262,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start
| `enabled` | bool | `true` | Enable compression |
| `min_size` | int | `1024` | Minimum bytes to compress |
| `level` | int | `5` | gzip level (1–9) |
| `precompressed` | bool | `true` | Serve `.gz`/`.br` sidecar files |
| `precompressed` | bool | `true` | Serve `.gz`/`.br`/`.zst` sidecar files |

### `[headers]`

Expand Down Expand Up @@ -345,24 +345,27 @@ When TLS is configured:

## Pre-compressed Files

Place `.gz` and `.br` sidecar files alongside originals. The server serves them automatically when the client signals support:
Place `.gz`, `.br`, and `.zst` sidecar files alongside originals. The server serves them automatically when the client signals support:

```
public/
app.js
app.js.gz ← served for Accept-Encoding: gzip
app.js.br ← served for Accept-Encoding: br (preferred over gzip)
app.js.br ← served for Accept-Encoding: br (preferred)
app.js.zst ← served for Accept-Encoding: zstd (fastest decompress)
style.css
style.css.gz
style.css.br
style.css.zst
```

Generate sidecars from the `Makefile`:

```bash
make precompress # runs gzip and brotli on all .js/.css/.html/.json/.svg
make precompress # runs gzip, brotli, and zstd on all .js/.css/.html/.json/.svg
```

> **Note**: On-the-fly brotli encoding is not implemented. Only `.br` sidecar files are served with brotli encoding.
> **Note**: On-the-fly brotli encoding is not implemented. Only `.br` sidecar files are served with brotli encoding. Zstandard is available both as pre-compressed sidecar files and on-the-fly compression.

---

Expand Down
44 changes: 31 additions & 13 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ make build # produces bin/static-web

The server starts with sensible defaults even without a config file:

| Default | Value |
| ---------------------- | --------------------- |
| Listen address | `:8080` |
| Static files directory | `./public` |
| In-memory cache | enabled, 256 MB |
| Compression | enabled, gzip level 5 |
| Dotfile protection | enabled |
| Security headers | always set |
| Default | Value |
| ---------------------- | ------------------------------------- |
| Listen address | `:8080` |
| Static files directory | `./public` |
| In-memory cache | enabled, 256 MB |
| Compression | enabled, gzip level 5, br + zstd |
| Dotfile protection | enabled |
| Security headers | always set |

Point your browser at `http://localhost:8080`.

Expand Down Expand Up @@ -135,7 +135,7 @@ preload = false # true = load all files into RAM at startup
enabled = true
min_size = 1024 # don't compress responses smaller than 1 KB
level = 5 # gzip level 1 (fastest) – 9 (best)
precompressed = true # serve .gz / .br sidecar files when available
precompressed = true # serve .gz / .br / .zst sidecar files when available

[headers]
immutable_pattern = "" # glob for fingerprinted assets → Cache-Control: immutable
Expand Down Expand Up @@ -291,15 +291,18 @@ server {

## Pre-compressing Assets

Serving pre-compressed files is far more efficient than on-the-fly gzip, especially for large JavaScript bundles. Place `.gz` and `.br` files alongside originals:
Serving pre-compressed files is far more efficient than on-the-fly compression, especially for large JavaScript bundles. Place `.gz`, `.br`, and `.zst` files alongside originals:

```
public/
app.js
app.js.gz ← served when client sends Accept-Encoding: gzip
app.js.br ← served when client sends Accept-Encoding: br (preferred over gzip)
app.js.br ← served when client sends Accept-Encoding: br (preferred)
app.js.zst ← served when client sends Accept-Encoding: zstd (fastest decompress)
style.css
style.css.gz
style.css.br
style.css.zst
```

Generate them with the bundled Makefile target:
Expand All @@ -308,14 +311,17 @@ Generate them with the bundled Makefile target:
make precompress
```

Or manually (requires `gzip` and `brotli` installed):
Or manually (requires `gzip`, `brotli`, and `zstd` installed):

```bash
# gzip
gzip -k -9 public/app.js # keeps original, produces app.js.gz

# brotli
brotli -9 public/app.js -o public/app.js.br

# zstandard
zstd -k public/app.js # keeps original, produces app.js.zst
```

Enable in config (on by default):
Expand All @@ -327,6 +333,16 @@ precompressed = true

> **Note:** Brotli encoding is only available via pre-compressed `.br` sidecar files. On-the-fly brotli compression is not implemented.

### Encoding Priority

When a client sends multiple encodings in `Accept-Encoding`, the server selects in this order:

1. **Brotli** (`.br`) — best compression ratio
2. **Zstandard** (`.zst`) — fastest decompression, good compression
3. **Gzip** (`.gz`) — universally supported fallback

This ordering provides the best balance of compression ratio and decompression speed.

---

## Docker Deployment
Expand Down Expand Up @@ -744,6 +760,8 @@ Directory listing is **disabled by default** (`directory_listing = false`). Enab
| **Brotli on-the-fly not implemented** | Brotli encoding requires pre-compressed `.br` files. | Run `make precompress` as part of your build pipeline. |
| **No hot config reload** | SIGHUP flushes the cache only; config changes require a restart. | Use a process manager (systemd, Docker restart policy) for zero-downtime restarts. |

> **Note:** Zstandard (`.zst`) compression is available both as pre-compressed sidecar files and on-the-fly compression.

---

## Troubleshooting
Expand Down Expand Up @@ -779,7 +797,7 @@ If `cache.ttl` is `0`, entries remain cached until eviction pressure or SIGHUP f

1. Verify `compression.enabled = true` in config.
2. Check that the response is larger than `compression.min_size` (default: 1024 bytes).
3. The client must send `Accept-Encoding: gzip`. Browsers do this automatically; `curl` does not by default — use `curl --compressed`.
3. The client must send `Accept-Encoding: gzip`, `br`, or `zstd`. Browsers do this automatically; `curl` does not by default — use `curl --compressed` (for gzip) or specify the encoding explicitly.
4. Some content types are not compressed (images, video, audio, pre-compressed archives). This is intentional — re-compressing already-compressed data makes files larger.

### HTTPS redirect loop
Expand Down
3 changes: 2 additions & 1 deletion config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ min_size = 1024
# gzip compression level (1=fastest, 9=best). Default 5 is a good balance.
level = 5

# Serve pre-compressed .gz and .br sidecar files when they exist alongside originals.
# Serve pre-compressed .gz, .br, and .zst sidecar files when they exist alongside originals.
# Encoding priority: br > zstd > gzip
precompressed = true

[headers]
Expand Down
52 changes: 26 additions & 26 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
<title>static-web — High-Performance Go Static File Server</title>
<meta
name="description"
content="Production-grade, blazing-fast static web file server written in Go. ~148k req/sec with fasthttp — 59% faster than Bun. In-memory LRU cache, HTTP/2, TLS 1.2+, gzip/brotli, security headers — built on fasthttp for maximum throughput."
content="Production-grade, blazing-fast static web file server written in Go. ~148k req/sec with fasthttp — 59% faster than Bun. In-memory LRU cache, HTTP/2, TLS 1.2+, gzip/brotli/zstd, security headers — built on fasthttp for maximum throughput."
/>
<meta
name="keywords"
content="static file server, go, golang, http2, tls, lru cache, gzip, brotli, performance, security, web server, static site hosting, docker"
content="static file server, go, golang, http2, tls, lru cache, gzip, brotli, zstd, performance, security, web server, static site hosting, docker"
/>
<meta name="author" content="21no.de" />
<meta name="robots" content="index, follow" />
Expand All @@ -20,7 +20,7 @@
<meta property="og:title" content="static-web — High-Performance Go Static File Server" />
<meta
property="og:description"
content="Production-grade static web file server in Go. ~148k req/sec with fasthttp — 59% faster than Bun. HTTP/2, TLS 1.2+, gzip/brotli, security hardened."
content="Production-grade static web file server in Go. ~148k req/sec with fasthttp — 59% faster than Bun. HTTP/2, TLS 1.2+, gzip/brotli/zstd, security hardened."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://static.21no.de" />
Expand All @@ -37,7 +37,7 @@
<meta name="twitter:title" content="static-web — High-Performance Go Static File Server" />
<meta
name="twitter:description"
content="Production-grade static file server in Go. ~148k req/sec with fasthttp — 59% faster than Bun. HTTP/2, TLS, gzip/brotli — security hardened."
content="Production-grade static file server in Go. ~148k req/sec with fasthttp — 59% faster than Bun. HTTP/2, TLS, gzip/brotli/zstd — security hardened."
/>
<meta name="twitter:image" content="https://static.21no.de/og-image.svg" />
<meta name="twitter:image:alt" content="static-web — High-Performance Go Static File Server" />
Expand Down Expand Up @@ -75,7 +75,7 @@
"programmingLanguage": "Go",
"license": "https://github.com/BackendStack21/static-web/blob/main/LICENSE",
"codeRepository": "https://github.com/BackendStack21/static-web",
"description": "A production-grade, blazing-fast static web file server written in Go. ~148k req/sec with fasthttp — 59% faster than Bun. Features in-memory LRU cache, TTL-aware cache expiry, HTTP/2, TLS 1.2+, gzip and brotli compression, and comprehensive security headers.",
"description": "A production-grade, blazing-fast static web file server written in Go. ~148k req/sec with fasthttp — 59% faster than Bun. Features in-memory LRU cache, TTL-aware cache expiry, HTTP/2, TLS 1.2+, gzip, brotli, and zstd compression, and comprehensive security headers.",
"author": {
"@type": "Person",
"name": "Rolando Santamaria Maso",
Expand All @@ -86,22 +86,22 @@
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"~148k req/sec — 59% faster than Bun's native static server",
"In-memory LRU cache with ~28 ns/op lookup",
"Startup preloading with path-safety cache pre-warming",
"TTL-aware cache expiry with optional automatic stale-entry eviction",
"Direct ctx.SetBody() fast path with pre-formatted headers for cache hits",
"HTTP/2 with TLS 1.2+ and HTTP→HTTPS redirect",
"TLS 1.2+ with AEAD cipher suites",
"gzip and brotli compression",
"6-step path traversal prevention",
"Security headers (CSP, HSTS, Permissions-Policy)",
"CORS with wildcard and per-origin modes",
"Directory listing with breadcrumb navigation",
"Docker and container ready",
"Graceful shutdown with signal handling"
]
"featureList": [
"~148k req/sec — 59% faster than Bun's native static server",
"In-memory LRU cache with ~28 ns/op lookup",
"Startup preloading with path-safety cache pre-warming",
"TTL-aware cache expiry with optional automatic stale-entry eviction",
"Direct ctx.SetBody() fast path with pre-formatted headers for cache hits",
"HTTP/2 with TLS 1.2+ and HTTP→HTTPS redirect",
"TLS 1.2+ with AEAD cipher suites",
"gzip, brotli, and zstd compression",
"6-step path traversal prevention",
"Security headers (CSP, HSTS, Permissions-Policy)",
"CORS with wildcard and per-origin modes",
"Directory listing with breadcrumb navigation",
"Docker and container ready",
"Graceful shutdown with signal handling"
]
},
{
"@type": "BreadcrumbList",
Expand Down Expand Up @@ -218,7 +218,7 @@
<h1 class="hero-title">static-web</h1>
<p class="hero-subtitle">Production-Grade Go Static File Server</p>
<p class="hero-description">
Blazing fast, lightweight static server with <strong>in-memory LRU cache</strong>, startup preloading, HTTP/2, TLS, gzip / brotli,
Blazing fast, lightweight static server with <strong>in-memory LRU cache</strong>, startup preloading, HTTP/2, TLS, gzip / brotli / zstd,
and security headers baked in.
</p>

Expand Down Expand Up @@ -301,10 +301,10 @@ <h3>Near-Zero Alloc Hot Path</h3>
</div>
<div class="feature-card">
<div class="feature-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22V4c0-.5.2-1 .6-1.4C5 2.2 5.5 2 6 2h8.5L20 7.5V20c0 .5-.2 1-.6 1.4-.4.4-.9.6-1.4.6h-2"/><path d="M14 2v6h6"/><path d="M10 20v-5"/><path d="M10 12v-1"/><path d="M10 8v-1"/></svg></div>
<h3>gzip + Brotli</h3>
<h3>gzip + Brotli + Zstd</h3>
<p>
On-the-fly gzip via pooled writers, plus pre-compressed <code>.gz</code>/<code>.br</code> sidecar file
support. Brotli preference over gzip.
On-the-fly gzip and zstd via pooled writers, plus pre-compressed <code>.gz</code>/<code>.br</code>/<code>.zst</code> sidecar file
support. Encoding priority: brotli &gt; zstd &gt; gzip.
</p>
</div>
<div class="feature-card">
Expand Down Expand Up @@ -603,7 +603,7 @@ <h3>Compress Middleware</h3>
<div class="pipeline-info">
<h3>File Handler</h3>
<p>
Preloaded or cached → direct ctx.SetBody() fast path · brotli/gzip sidecar negotiation · miss → stat → read →
Preloaded or cached → direct ctx.SetBody() fast path · brotli/zstd/gzip sidecar negotiation · miss → stat → read →
cache
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ go 1.26
require (
github.com/BurntSushi/toml v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/klauspost/compress v1.18.4
github.com/valyala/fasthttp v1.69.0
)

require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
)
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
4 changes: 3 additions & 1 deletion internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type CachedFile struct {
GzipData []byte
// BrData is the pre-compressed brotli content, or nil if unavailable.
BrData []byte
// ZstdData is the pre-compressed zstd content, or nil if unavailable.
ZstdData []byte
// ETag is the first 16 hex characters of sha256(Data), without quotes.
ETag string
// ETagFull is the pre-formatted weak ETag ready for use in HTTP headers,
Expand Down Expand Up @@ -150,7 +152,7 @@ func matchesImmutable(urlPath, pattern string) bool {

// totalSize returns the approximate byte footprint of the entry.
func (f *CachedFile) totalSize() int64 {
return int64(len(f.Data)+len(f.GzipData)+len(f.BrData)) + cacheOverhead
return int64(len(f.Data)+len(f.GzipData)+len(f.BrData)+len(f.ZstdData)) + cacheOverhead
}

// CacheStats holds runtime statistics for the cache.
Expand Down
Loading
Loading