Skip to content

Commit 6abe873

Browse files
committed
feat: add zstd compression support
- Add Zstandard (zstd) on-the-fly compression via klauspost/compress - Support pre-compressed .zst sidecar files alongside .gz and .br - Encoding priority: brotli > zstd > gzip (best ratio → fastest decompress) - Add 5 zstd unit tests + cache-hit benchmark - Update Makefile precompress target for zstd generation - Update documentation across README, USER_GUIDE, and website
1 parent b6e2f1d commit 6abe873

File tree

12 files changed

+322
-55
lines changed

12 files changed

+322
-55
lines changed

Makefile

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build run test bench lint precompress clean release install commit bump changelog benchmark benchmark-keep benchmark-down benchmark-baremetal
1+
.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
22

33
# Binary output path and name
44
BIN := bin/static-web
@@ -41,18 +41,21 @@ bench:
4141
lint:
4242
go vet ./...
4343

44-
## precompress: gzip and brotli compress all files in ./public
44+
## precompress: gzip, brotli, and zstd compress all files in ./public
4545
precompress:
4646
@echo "Pre-compressing files in ./public ..."
4747
@find ./public -type f \
48-
! -name "*.gz" ! -name "*.br" \
48+
! -name "*.gz" ! -name "*.br" ! -name "*.zst" \
4949
| while read f; do \
5050
if command -v gzip >/dev/null 2>&1; then \
5151
gzip -k -f "$$f" && echo " gzip: $$f.gz"; \
5252
fi; \
5353
if command -v brotli >/dev/null 2>&1; then \
5454
brotli -f "$$f" -o "$$f.br" && echo " brotli: $$f.br"; \
5555
fi; \
56+
if command -v zstd >/dev/null 2>&1; then \
57+
zstd -k -f "$$f" && echo " zstd: $$f.zst"; \
58+
fi; \
5659
done
5760
@echo "Done."
5861

@@ -87,3 +90,15 @@ benchmark-down:
8790
## benchmark-baremetal: run bare-metal benchmark (static-web production vs Bun, no Docker)
8891
benchmark-baremetal:
8992
@bash benchmark/baremetal.sh
93+
94+
## benchmark-compress: run compression-specific benchmark suite (tears down when done)
95+
benchmark-compress:
96+
@bash benchmark/compress-bench.sh
97+
98+
## benchmark-compress-keep: same as benchmark-compress but leaves containers running afterwards
99+
benchmark-compress-keep:
100+
@bash benchmark/compress-bench.sh -k
101+
102+
## benchmark-compress-down: tear down any running compression benchmark containers
103+
benchmark-compress-down:
104+
docker compose -f benchmark/docker-compose.compression.yml down --remove-orphans

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ static-web --help
5454
| Feature | Detail |
5555
|---------|--------|
5656
| **In-memory LRU cache** | Size-bounded, byte-accurate; ~28 ns/op lookup with 0 allocations. Optional startup preload for instant cache hits. |
57-
| **gzip compression** | On-the-fly via pooled `gzip.Writer`; pre-compressed `.gz`/`.br` sidecar support |
57+
| **Compression** | On-the-fly gzip; pre-compressed `.gz`/`.br`/`.zst` sidecar support; priority: br > zstd > gzip |
5858
| **HTTP/2** | Automatic ALPN negotiation when TLS is configured |
5959
| **Conditional requests** | ETag, `304 Not Modified`, `If-Modified-Since`, `If-None-Match` |
6060
| **Range requests** | Byte ranges via custom `parseRange`/`serveRange` implementation for video and large files |
@@ -103,7 +103,7 @@ HTTP request
103103
│ • Range/conditional → custom serveRange() │
104104
│ • Cache miss → os.Stat → disk read → cache put │
105105
│ • Large files (> max_file_size) bypass cache │
106-
│ • Encoding negotiation: brotli > gzip > plain │
106+
│ • Encoding negotiation: brotli > zstd > gzip > plain │
107107
│ • Preloaded files served instantly on startup │
108108
│ • Custom 404 page (path-validated) │
109109
└─────────────────────────────────────────────────┘
@@ -262,7 +262,7 @@ Copy `config.toml.example` to `config.toml` and edit as needed. The server start
262262
| `enabled` | bool | `true` | Enable compression |
263263
| `min_size` | int | `1024` | Minimum bytes to compress |
264264
| `level` | int | `5` | gzip level (1–9) |
265-
| `precompressed` | bool | `true` | Serve `.gz`/`.br` sidecar files |
265+
| `precompressed` | bool | `true` | Serve `.gz`/`.br`/`.zst` sidecar files |
266266

267267
### `[headers]`
268268

@@ -345,24 +345,27 @@ When TLS is configured:
345345

346346
## Pre-compressed Files
347347

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

350350
```
351351
public/
352352
app.js
353353
app.js.gz ← served for Accept-Encoding: gzip
354-
app.js.br ← served for Accept-Encoding: br (preferred over gzip)
354+
app.js.br ← served for Accept-Encoding: br (preferred)
355+
app.js.zst ← served for Accept-Encoding: zstd (fastest decompress)
355356
style.css
356357
style.css.gz
358+
style.css.br
359+
style.css.zst
357360
```
358361

359362
Generate sidecars from the `Makefile`:
360363

361364
```bash
362-
make precompress # runs gzip and brotli on all .js/.css/.html/.json/.svg
365+
make precompress # runs gzip, brotli, and zstd on all .js/.css/.html/.json/.svg
363366
```
364367

365-
> **Note**: On-the-fly brotli encoding is not implemented. Only `.br` sidecar files are served with brotli encoding.
368+
> **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.
366369
367370
---
368371

USER_GUIDE.md

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ make build # produces bin/static-web
4242

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

45-
| Default | Value |
46-
| ---------------------- | --------------------- |
47-
| Listen address | `:8080` |
48-
| Static files directory | `./public` |
49-
| In-memory cache | enabled, 256 MB |
50-
| Compression | enabled, gzip level 5 |
51-
| Dotfile protection | enabled |
52-
| Security headers | always set |
45+
| Default | Value |
46+
| ---------------------- | ------------------------------------- |
47+
| Listen address | `:8080` |
48+
| Static files directory | `./public` |
49+
| In-memory cache | enabled, 256 MB |
50+
| Compression | enabled, gzip level 5, br + zstd |
51+
| Dotfile protection | enabled |
52+
| Security headers | always set |
5353

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

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

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

292292
## Pre-compressing Assets
293293

294-
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:
294+
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:
295295

296296
```
297297
public/
298298
app.js
299299
app.js.gz ← served when client sends Accept-Encoding: gzip
300-
app.js.br ← served when client sends Accept-Encoding: br (preferred over gzip)
300+
app.js.br ← served when client sends Accept-Encoding: br (preferred)
301+
app.js.zst ← served when client sends Accept-Encoding: zstd (fastest decompress)
301302
style.css
302303
style.css.gz
304+
style.css.br
305+
style.css.zst
303306
```
304307

305308
Generate them with the bundled Makefile target:
@@ -308,14 +311,17 @@ Generate them with the bundled Makefile target:
308311
make precompress
309312
```
310313

311-
Or manually (requires `gzip` and `brotli` installed):
314+
Or manually (requires `gzip`, `brotli`, and `zstd` installed):
312315

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

317320
# brotli
318321
brotli -9 public/app.js -o public/app.js.br
322+
323+
# zstandard
324+
zstd -k public/app.js # keeps original, produces app.js.zst
319325
```
320326

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

328334
> **Note:** Brotli encoding is only available via pre-compressed `.br` sidecar files. On-the-fly brotli compression is not implemented.
329335
336+
### Encoding Priority
337+
338+
When a client sends multiple encodings in `Accept-Encoding`, the server selects in this order:
339+
340+
1. **Brotli** (`.br`) — best compression ratio
341+
2. **Zstandard** (`.zst`) — fastest decompression, good compression
342+
3. **Gzip** (`.gz`) — universally supported fallback
343+
344+
This ordering provides the best balance of compression ratio and decompression speed.
345+
330346
---
331347

332348
## Docker Deployment
@@ -744,6 +760,8 @@ Directory listing is **disabled by default** (`directory_listing = false`). Enab
744760
| **Brotli on-the-fly not implemented** | Brotli encoding requires pre-compressed `.br` files. | Run `make precompress` as part of your build pipeline. |
745761
| **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. |
746762
763+
> **Note:** Zstandard (`.zst`) compression is available both as pre-compressed sidecar files and on-the-fly compression.
764+
747765
---
748766
749767
## Troubleshooting
@@ -779,7 +797,7 @@ If `cache.ttl` is `0`, entries remain cached until eviction pressure or SIGHUP f
779797
780798
1. Verify `compression.enabled = true` in config.
781799
2. Check that the response is larger than `compression.min_size` (default: 1024 bytes).
782-
3. The client must send `Accept-Encoding: gzip`. Browsers do this automatically; `curl` does not by default — use `curl --compressed`.
800+
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.
783801
4. Some content types are not compressed (images, video, audio, pre-compressed archives). This is intentional — re-compressing already-compressed data makes files larger.
784802
785803
### HTTPS redirect loop

config.toml.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ min_size = 1024
6565
# gzip compression level (1=fastest, 9=best). Default 5 is a good balance.
6666
level = 5
6767

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

7172
[headers]

docs/index.html

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
<title>static-web — High-Performance Go Static File Server</title>
77
<meta
88
name="description"
9-
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."
9+
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."
1010
/>
1111
<meta
1212
name="keywords"
13-
content="static file server, go, golang, http2, tls, lru cache, gzip, brotli, performance, security, web server, static site hosting, docker"
13+
content="static file server, go, golang, http2, tls, lru cache, gzip, brotli, zstd, performance, security, web server, static site hosting, docker"
1414
/>
1515
<meta name="author" content="21no.de" />
1616
<meta name="robots" content="index, follow" />
@@ -20,7 +20,7 @@
2020
<meta property="og:title" content="static-web — High-Performance Go Static File Server" />
2121
<meta
2222
property="og:description"
23-
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."
23+
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."
2424
/>
2525
<meta property="og:type" content="website" />
2626
<meta property="og:url" content="https://static.21no.de" />
@@ -37,7 +37,7 @@
3737
<meta name="twitter:title" content="static-web — High-Performance Go Static File Server" />
3838
<meta
3939
name="twitter:description"
40-
content="Production-grade static file server in Go. ~148k req/sec with fasthttp — 59% faster than Bun. HTTP/2, TLS, gzip/brotli — security hardened."
40+
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."
4141
/>
4242
<meta name="twitter:image" content="https://static.21no.de/og-image.svg" />
4343
<meta name="twitter:image:alt" content="static-web — High-Performance Go Static File Server" />
@@ -75,7 +75,7 @@
7575
"programmingLanguage": "Go",
7676
"license": "https://github.com/BackendStack21/static-web/blob/main/LICENSE",
7777
"codeRepository": "https://github.com/BackendStack21/static-web",
78-
"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.",
78+
"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.",
7979
"author": {
8080
"@type": "Person",
8181
"name": "Rolando Santamaria Maso",
@@ -86,22 +86,22 @@
8686
"price": "0",
8787
"priceCurrency": "USD"
8888
},
89-
"featureList": [
90-
"~148k req/sec — 59% faster than Bun's native static server",
91-
"In-memory LRU cache with ~28 ns/op lookup",
92-
"Startup preloading with path-safety cache pre-warming",
93-
"TTL-aware cache expiry with optional automatic stale-entry eviction",
94-
"Direct ctx.SetBody() fast path with pre-formatted headers for cache hits",
95-
"HTTP/2 with TLS 1.2+ and HTTP→HTTPS redirect",
96-
"TLS 1.2+ with AEAD cipher suites",
97-
"gzip and brotli compression",
98-
"6-step path traversal prevention",
99-
"Security headers (CSP, HSTS, Permissions-Policy)",
100-
"CORS with wildcard and per-origin modes",
101-
"Directory listing with breadcrumb navigation",
102-
"Docker and container ready",
103-
"Graceful shutdown with signal handling"
104-
]
89+
"featureList": [
90+
"~148k req/sec — 59% faster than Bun's native static server",
91+
"In-memory LRU cache with ~28 ns/op lookup",
92+
"Startup preloading with path-safety cache pre-warming",
93+
"TTL-aware cache expiry with optional automatic stale-entry eviction",
94+
"Direct ctx.SetBody() fast path with pre-formatted headers for cache hits",
95+
"HTTP/2 with TLS 1.2+ and HTTP→HTTPS redirect",
96+
"TLS 1.2+ with AEAD cipher suites",
97+
"gzip, brotli, and zstd compression",
98+
"6-step path traversal prevention",
99+
"Security headers (CSP, HSTS, Permissions-Policy)",
100+
"CORS with wildcard and per-origin modes",
101+
"Directory listing with breadcrumb navigation",
102+
"Docker and container ready",
103+
"Graceful shutdown with signal handling"
104+
]
105105
},
106106
{
107107
"@type": "BreadcrumbList",
@@ -218,7 +218,7 @@
218218
<h1 class="hero-title">static-web</h1>
219219
<p class="hero-subtitle">Production-Grade Go Static File Server</p>
220220
<p class="hero-description">
221-
Blazing fast, lightweight static server with <strong>in-memory LRU cache</strong>, startup preloading, HTTP/2, TLS, gzip / brotli,
221+
Blazing fast, lightweight static server with <strong>in-memory LRU cache</strong>, startup preloading, HTTP/2, TLS, gzip / brotli / zstd,
222222
and security headers baked in.
223223
</p>
224224

@@ -301,10 +301,10 @@ <h3>Near-Zero Alloc Hot Path</h3>
301301
</div>
302302
<div class="feature-card">
303303
<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>
304-
<h3>gzip + Brotli</h3>
304+
<h3>gzip + Brotli + Zstd</h3>
305305
<p>
306-
On-the-fly gzip via pooled writers, plus pre-compressed <code>.gz</code>/<code>.br</code> sidecar file
307-
support. Brotli preference over gzip.
306+
On-the-fly gzip and zstd via pooled writers, plus pre-compressed <code>.gz</code>/<code>.br</code>/<code>.zst</code> sidecar file
307+
support. Encoding priority: brotli &gt; zstd &gt; gzip.
308308
</p>
309309
</div>
310310
<div class="feature-card">
@@ -603,7 +603,7 @@ <h3>Compress Middleware</h3>
603603
<div class="pipeline-info">
604604
<h3>File Handler</h3>
605605
<p>
606-
Preloaded or cached → direct ctx.SetBody() fast path · brotli/gzip sidecar negotiation · miss → stat → read →
606+
Preloaded or cached → direct ctx.SetBody() fast path · brotli/zstd/gzip sidecar negotiation · miss → stat → read →
607607
cache
608608
</p>
609609
</div>

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ go 1.26
55
require (
66
github.com/BurntSushi/toml v1.6.0
77
github.com/hashicorp/golang-lru/v2 v2.0.7
8+
github.com/klauspost/compress v1.18.4
89
github.com/valyala/fasthttp v1.69.0
910
)
1011

1112
require (
1213
github.com/andybalholm/brotli v1.2.0 // indirect
13-
github.com/klauspost/compress v1.18.2 // indirect
1414
github.com/valyala/bytebufferpool v1.0.0 // indirect
1515
)

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
44
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
55
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
66
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
7-
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
8-
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
7+
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
8+
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
99
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
1010
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
1111
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
1212
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
13+
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
14+
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=

internal/cache/cache.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ type CachedFile struct {
3434
GzipData []byte
3535
// BrData is the pre-compressed brotli content, or nil if unavailable.
3636
BrData []byte
37+
// ZstdData is the pre-compressed zstd content, or nil if unavailable.
38+
ZstdData []byte
3739
// ETag is the first 16 hex characters of sha256(Data), without quotes.
3840
ETag string
3941
// ETagFull is the pre-formatted weak ETag ready for use in HTTP headers,
@@ -150,7 +152,7 @@ func matchesImmutable(urlPath, pattern string) bool {
150152

151153
// totalSize returns the approximate byte footprint of the entry.
152154
func (f *CachedFile) totalSize() int64 {
153-
return int64(len(f.Data)+len(f.GzipData)+len(f.BrData)) + cacheOverhead
155+
return int64(len(f.Data)+len(f.GzipData)+len(f.BrData)+len(f.ZstdData)) + cacheOverhead
154156
}
155157

156158
// CacheStats holds runtime statistics for the cache.

0 commit comments

Comments
 (0)