Phase 4: Advanced Compression
Goal
Complete the compression story — add Zstandard, pre-compressed asset serving, per-route compression levels, and content-type-aware negotiation.
Current State
- Gzip compression:
bext-core/src/compress/gzip.rs - Brotli compression:
bext-core/src/compress/brotli.rs - Accept-Encoding negotiation in handler
- Streaming compression for SSR responses
- No Zstandard, no pre-compressed asset detection, no per-route tuning
What We're Adding
1. Zstandard (zstd) Compression
Zstd offers better compression ratio than gzip at similar CPU cost, and much faster decompression. Caddy already supports it; Chrome 123+ and Firefox 126+ support Accept-Encoding: zstd.
Compression ratio comparison (typical HTML):
gzip level 6: 3.2:1 @ 150 MB/s compress, 400 MB/s decompress
brotli level 4: 3.8:1 @ 100 MB/s compress, 500 MB/s decompress
zstd level 3: 3.5:1 @ 400 MB/s compress, 1500 MB/s decompress
Zstd's fast compression makes it ideal for dynamic SSR content where Brotli's higher ratio doesn't justify its CPU cost.
2. Pre-Compressed Asset Detection
For static assets, skip runtime compression entirely. During build or on first request, write .gz, .br, .zst sidecar files. On subsequent requests, serve the pre-compressed version directly.
public/
css/main.css (120KB)
css/main.css.br (28KB, pre-compressed Brotli)
css/main.css.gz (35KB, pre-compressed gzip)
css/main.css.zst (31KB, pre-compressed zstd)
Handler logic:
- Check if
public/css/main.css.brexists and client acceptsbr - If yes, serve pre-compressed file with
Content-Encoding: br— zero CPU - If no pre-compressed variant, fall back to runtime compression
3. Per-Route Compression Levels
Different content needs different compression strategies:
[[route_rules]]
pattern = "/api/**"
compression = "fast" # zstd level 1, low latency
[[route_rules]]
pattern = "/blog/*"
compression = "balanced" # brotli level 4, good ratio
[[route_rules]]
pattern = "/assets/**"
compression = "off" # Already compressed (images, video)
4. Content-Type-Aware Compression
Don't compress already-compressed content:
Compress: text/html, text/css, application/javascript, application/json,
image/svg+xml, application/xml, text/plain
Skip: image/jpeg, image/png, image/webp, video/*, audio/*,
application/zip, application/gzip, font/woff2
5. Negotiation Priority
When client sends Accept-Encoding: zstd, br, gzip:
| Content Type | Priority | Reason |
|---|---|---|
| Dynamic HTML (SSR) | zstd > gzip > br | zstd fastest compress; br too slow for dynamic |
| Static assets (pre-compressed) | br > zstd > gzip | br best ratio, no CPU cost |
| API JSON | zstd > gzip | Fast compress + decompress |
| Streaming SSR | gzip > zstd | Best streaming support |
Implementation
New Module: bext-core/src/compress/zstd.rs
use zstd::stream::encode_all;
use zstd::stream::Encoder;
pub struct ZstdCompressor {
level: i32, // 1-22, default 3
}
impl ZstdCompressor {
pub fn compress(&self, input: &[u8]) -> Result<Vec<u8>> {
encode_all(input, self.level)
}
pub fn stream_compress(&self) -> ZstdStreamEncoder {
// For streaming SSR responses
ZstdStreamEncoder::new(self.level)
}
}
Pre-Compression Module: bext-core/src/compress/precompressed.rs
/// Check for pre-compressed sidecar files
pub fn find_precompressed(
path: &Path,
accepted: &AcceptEncoding,
) -> Option<(PathBuf, ContentEncoding)> {
// Priority: brotli > zstd > gzip (best ratio first for static)
for (ext, encoding) in &[
(".br", ContentEncoding::Brotli),
(".zst", ContentEncoding::Zstd),
(".gz", ContentEncoding::Gzip),
] {
if accepted.supports(encoding) {
let compressed_path = path.with_extension(
format!("{}{}", path.extension().unwrap_or_default().to_str().unwrap(), ext)
);
if compressed_path.exists() {
return Some((compressed_path, *encoding));
}
}
}
None
}
CLI: Pre-Compress Static Assets
bext compress ./public # Pre-compress all static assets
bext compress ./public --format br # Only Brotli
bext compress ./public --min-size 1024 # Skip files < 1KB
Updated Negotiation in Handler
fn select_encoding(
accept: &AcceptEncoding,
content_type: &ContentType,
route_config: &RouteCompression,
is_streaming: bool,
) -> Option {
if route_config == RouteCompression::Off {
return None;
}
if !should_compress(content_type) {
return None;
}
match (is_streaming, route_config) {
// Streaming: prefer gzip (best flush behavior)
(true, _) => {
if accept.supports_gzip() { Some(Gzip) }
else if accept.supports_zstd() { Some(Zstd) }
else { None }
}
// Fast mode: prefer zstd (fastest compress)
(false, RouteCompression::Fast) => {
if accept.supports_zstd() { Some(Zstd) }
else if accept.supports_gzip() { Some(Gzip) }
else { None }
}
// Balanced: prefer brotli (best ratio)
(false, RouteCompression::Balanced | RouteCompression::Default) => {
if accept.supports_brotli() { Some(Brotli) }
else if accept.supports_zstd() { Some(Zstd) }
else if accept.supports_gzip() { Some(Gzip) }
else { None }
}
}
}
Config Reference
[server.compression]
enabled = true # Global toggle (default: true)
min_size = 256 # Don't compress below N bytes
level = "balanced" # Default: balanced | fast | max
precompressed = true # Serve .br/.gz/.zst sidecar files
# Per-route override (in route_rules)
[[route_rules]]
pattern = "/api/**"
compression = "fast"
[[route_rules]]
pattern = "/assets/**"
compression = "off"
Vary Header
All compressed responses include Vary: Accept-Encoding to prevent CDN/proxy cache poisoning:
Vary: Accept-Encoding
Content-Encoding: zstd
Testing Plan
| Test | Type | What it validates |
|---|---|---|
| Zstd compress/decompress | Unit | Round-trip correctness |
| Zstd streaming | Unit | Streaming encoder flushes correctly |
| Pre-compressed detection | Unit | Finds .br/.gz/.zst sidecar files |
| Pre-compressed priority | Unit | br > zstd > gz for static |
| Content-type filtering | Unit | Images/video not compressed |
| Per-route config | Unit | Route-specific compression overrides global |
| Accept-Encoding negotiation | Unit | Correct encoding selected per priority |
| Streaming fallback to gzip | Unit | Gzip preferred for streaming |
| Vary header present | Unit | Always set on compressed responses |
| Min size threshold | Unit | Small responses not compressed |
| CLI pre-compress | Integration | bext compress creates sidecar files |
Dependencies
| Crate | Purpose |
|---|---|
zstd |
Zstandard compression (C bindings, fast) |
Done Criteria
- Zstd compression supported (
Accept-Encoding: zstd) - Pre-compressed asset detection (
.br,.gz,.zstsidecar files) -
bext compressCLI command for pre-compressing static assets - Per-route compression config (
fast,balanced,off) - Content-type-aware skip list (don't compress images/video)
-
Vary: Accept-Encodingon all compressed responses - Streaming SSR prefers gzip for flush behavior
- All tests passing