Skip to content

Commit cf1257f

Browse files
authored
veb: add zstd compression support (#25816)
1 parent 58941fe commit cf1257f

File tree

9 files changed

+972
-435
lines changed

9 files changed

+972
-435
lines changed

vlib/veb/README.md

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -419,22 +419,24 @@ app.static_mime_types['.what'] = 'txt/plain'
419419
app.handle_static('static', true)!
420420
```
421421

422-
### Gzip compression for static files
422+
### Compression for static files (zstd/gzip)
423423

424-
veb provides automatic gzip compression for static files with smart caching. When enabled,
425-
veb will serve compressed versions of your static files to clients that support gzip encoding,
426-
reducing bandwidth usage and improving load times.
424+
veb provides automatic compression (zstd and gzip) for static files with smart caching.
425+
When enabled, veb will serve compressed versions of your static files to clients that
426+
support compression, reducing bandwidth usage and improving load times. Zstd is preferred
427+
over gzip when the client supports both.
427428

428429
**How it works:**
429430

430-
1. **Manual pre-compression**: If you create `.gz` files manually, veb will serve them in
431-
zero-copy streaming mode for maximum performance.
431+
1. **Manual pre-compression**: If you create `.zst` or `.gz` files manually, veb will serve
432+
them in zero-copy streaming mode for maximum performance.
432433
2. **Lazy compression cache**: Files smaller than the threshold are automatically compressed
433-
on first request and cached as `.gz` files on disk.
434-
3. **Cache validation**: If the original file is modified, the `.gz` cache is automatically
435-
regenerated on the next request.
434+
on first request and cached as `.zst` or `.gz` files on disk (zstd preferred when client
435+
supports it).
436+
3. **Cache validation**: If the original file is modified, the compressed cache is
437+
automatically regenerated on the next request.
436438
4. **Streaming for large files**: Files larger than the threshold are served uncompressed in
437-
streaming mode (unless a manual `.gz` file exists).
439+
streaming mode (unless a manual `.zst` or `.gz` file exists).
438440

439441
**Example:**
440442

@@ -453,21 +455,26 @@ pub struct App {
453455
}
454456
455457
pub fn (mut app App) index(mut ctx Context) veb.Result {
456-
return ctx.html('<h1>Gzip compression demo</h1><p>Visit <a href="/app.js">/app.js</a> or <a href="/style.css">/style.css</a></p>')
458+
return ctx.html('<h1>Compression demo</h1>
459+
<p>Visit <a href="/app.js">/app.js</a> or <a href="/style.css">/style.css</a>
460+
</p>')
457461
}
458462
459463
fn main() {
460464
mut app := &App{}
461465
462-
// Enable static file gzip compression (disabled by default)
463-
app.enable_static_gzip = true
464-
app.static_gzip_max_size = 524288 // Maximum file size for auto-compression is 512 KB (default: 1MB)
466+
// Enable static file compression (zstd/gzip, disabled by default)
467+
// Use enable_static_zstd and enable_static_gzip for specific compression
468+
app.enable_static_compression = true
469+
app.static_compression_max_size = 524288 // Maximum file size for auto-compression is 512 KB (default: 1MB)
465470
466471
// Serve files from the 'static' directory
467472
app.handle_static('static', true)!
468473
469-
// Add the gzip middleware to compress dynamic routes as well
470-
app.use(veb.encode_gzip[Context]())
474+
// Add the content encoding middleware to compress dynamic routes as well
475+
// This will use zstd if the client supports it, otherwise gzip
476+
// Use encode_gzip or encode_zstd for specific compression
477+
app.use(veb.encode_auto[Context]())
471478
472479
veb.run[App, Context](mut app, 8080)
473480
}
@@ -480,59 +487,68 @@ Create test files in the `static` directory:
480487
mkdir -p static
481488
echo "console.log('Hello from V web!');" > static/app.js
482489
echo "body { margin: 0; }" > static/style.css
483-
# Pre-compress style.css manually for zero-copy streaming
484-
gzip -k static/style.css
490+
# Pre-compress style.css manually for zero-copy streaming (zstd or gzip)
491+
zstd -k static/style.css # creates style.css.zst
492+
# or: gzip -k static/style.css # creates style.css.gz
485493
```
486494

487495
Run the server, it will listen on port 8080:
488496
```bash
489497
v run server.v
490498
```
491499

492-
Test gzip compression with cURL:
500+
Test compression with cURL:
493501
```bash
494-
# Test lazy compression cache - app.js will be compressed on first request
495-
curl -H "Accept-Encoding: gzip" -i http://localhost:8080/app.js
502+
# Test zstd compression (preferred when client supports it)
503+
curl -H "Accept-Encoding: zstd, gzip" -i http://localhost:8080/app.js
504+
# Expected headers:
505+
# Content-Encoding: zstd
506+
# Vary: Accept-Encoding
496507

508+
# Test gzip fallback (when client doesn't support zstd)
509+
curl -H "Accept-Encoding: gzip" -i http://localhost:8080/app.js
497510
# Expected headers:
498511
# Content-Encoding: gzip
499512
# Vary: Accept-Encoding
500513

501514
# Request with automatic decompression
502-
curl -H "Accept-Encoding: gzip" --compressed http://localhost:8080/app.js
515+
curl -H "Accept-Encoding: zstd, gzip" --compressed http://localhost:8080/app.js
503516

504-
# Request without gzip encoding - should return uncompressed content
517+
# Request without encoding - should return uncompressed content
505518
curl -i http://localhost:8080/app.js
506519

507-
# Verify that .gz cache file was created
508-
ls -lh static/app.js.gz
520+
# Verify that compressed cache file was created
521+
ls -lh static/app.js.zst static/app.js.gz 2>/dev/null
509522

510-
# Test manual pre-compression - style.css.gz is served directly (zero-copy)
511-
# The pre-compressed .gz file is served without loading into memory
512-
curl -H "Accept-Encoding: gzip" -i http://localhost:8080/style.css
523+
# Test manual pre-compression - style.css.zst is served directly (zero-copy)
524+
curl -H "Accept-Encoding: zstd" -i http://localhost:8080/style.css
513525
```
514526

515527
**Performance tips:**
516528

517-
- For production, you can pre-compress your static files (e.g., `gzip -k static/app.js`)
518-
and veb will serve them directly without loading into memory.
529+
- For production, you can pre-compress your static files with zstd (`zstd -k static/app.js`)
530+
or gzip (`gzip -k static/app.js`) and veb will serve them directly without loading into memory.
531+
- Zstd offers better compression ratio and speed than gzip - use it when possible.
532+
533+
**Priority order**: When both `.zst` and `.gz` files exist for the same source file, veb will
534+
serve `.zst` if the client supports zstd, otherwise `.gz` if gzip is supported.
535+
519536
- The lazy cache is created on first request, so the first visitor pays a small
520537
compression cost, but all subsequent requests are served at zero-copy speed.
521-
- Large files (> threshold) are always streamed, ensuring low memory usage even for
522-
large assets.
523-
- The `encode_gzip` middleware compresses dynamic routes and small files loaded in
524-
memory (takeover mode).
525-
- If `.gz` caching fails (e.g., on read-only filesystems), veb automatically falls
526-
back to serving compressed content from memory. You can set `static_gzip_max_size = 0`
538+
- Large files (> threshold) are always streamed, ensuring low memory usage even for large assets.
539+
- The `encode_auto` middleware automatically chooses zstd or gzip based on client support. You can
540+
also use `encode_zstd` or `encode_gzip` for specific compression.
541+
- If caching fails (e.g., on read-only filesystems), veb automatically falls
542+
back to serving compressed content from memory. You can set `static_compression_max_size = 0`
527543
to disable auto-compression completely. For optimal performance on read-only systems,
528-
pre-compress all files with `gzip -k`.
544+
pre-compress all files with `zstd -k` or `gzip -k`.
529545

530546
### Markdown content negotiation
531547

532548
veb can provide automatic content negotiation for markdown files, allowing you to serve
533549
markdown content when the client explicitly requests it via the `Accept` header.
534-
This is compliant to [llms.txt](https://llmstxt.org/) proposal and useful for documentations that can serve
535-
the same content in multiple formats, more efficiently to AI services using it.
550+
This is compliant to [llms.txt](https://llmstxt.org/) proposal and useful for documentations that
551+
can serve the same content in multiple formats, more efficiently to AI services using it.
536552

537553
**How it works:**
538554

vlib/veb/consts.v

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ pub const mime_types = {
142142
'.xml': 'application/xml'
143143
'.xul': 'application/vnd.mozilla.xul+xml'
144144
'.zip': 'application/zip'
145+
'.zst': 'application/zstd'
145146
'.3gp': 'video/3gpp'
146147
'.3g2': 'video/3gpp2'
147148
'.7z': 'application/x-7z-compressed'

vlib/veb/context.v

Lines changed: 111 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module veb
22

33
import compress.gzip
4+
import compress.zstd
45
import json
56
import net
67
import net.http
@@ -32,15 +33,17 @@ mut:
3233
done bool
3334
// If the `Connection: close` header is present the connection should always be closed
3435
client_wants_to_close bool
35-
// Configuration for static file gzip compression (set by serve_if_static)
36-
enable_static_gzip bool
37-
static_gzip_max_size int
36+
// Configuration for static file compression (set by serve_if_static)
37+
enable_static_gzip bool
38+
enable_static_zstd bool
39+
enable_static_compression bool
40+
static_compression_max_size int
3841
// if true the response should not be sent and the connection should be closed
3942
// manually.
4043
takeover bool
4144
return_file string
42-
// already_compressed indicates that the response body is already gzip-compressed
43-
// and the encode_gzip middleware should skip it
45+
// already_compressed indicates that the response body is already compressed (zstd/gzip)
46+
// and the compression middlewares should skip it
4447
already_compressed bool
4548
pub:
4649
// TODO: move this to `handle_request`
@@ -207,80 +210,64 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result {
207210
}
208211
file.close()
209212

210-
// Check if client accepts gzip encoding
213+
// Check which encodings the client accepts
211214
accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
215+
client_accepts_zstd := accept_encoding.contains('zstd')
212216
client_accepts_gzip := accept_encoding.contains('gzip')
213217

214-
max_size_bytes := ctx.static_gzip_max_size
218+
max_size_bytes := ctx.static_compression_max_size
215219

216-
// Try to serve pre-compressed .gz file if static gzip is enabled and client accepts it
217-
if ctx.enable_static_gzip && client_accepts_gzip {
218-
gz_path := '${file_path}.gz'
220+
// Determine which compression modes are enabled
221+
use_zstd := (ctx.enable_static_zstd && client_accepts_zstd)
222+
|| (ctx.enable_static_compression && client_accepts_zstd)
223+
use_gzip := (ctx.enable_static_gzip && client_accepts_gzip)
224+
|| (ctx.enable_static_compression && client_accepts_gzip)
219225

220-
// Check if .gz file exists and is up-to-date (newer or same age as original)
221-
if os.exists(gz_path) {
222-
gz_mtime := os.file_last_mod_unix(gz_path)
223-
orig_mtime := os.file_last_mod_unix(file_path)
226+
// Try to serve pre-compressed files if any compression is enabled
227+
if use_zstd || use_gzip {
228+
orig_mtime := os.file_last_mod_unix(file_path)
224229

225-
if gz_mtime >= orig_mtime {
226-
// Serve existing .gz file in streaming mode (zero-copy)
227-
ctx.return_type = .file
228-
ctx.return_file = gz_path
229-
ctx.res.header.set(.content_encoding, 'gzip')
230-
ctx.res.header.set(.vary, 'Accept-Encoding')
231-
232-
// Get .gz file size for Content-Length header
233-
gz_size := os.file_size(gz_path)
234-
ctx.res.header.set(.content_length, gz_size.str())
235-
236-
ctx.send_response_to_client(content_type, '')
237-
ctx.already_compressed = true
230+
// Try zstd first if enabled (better compression), then gzip
231+
if use_zstd {
232+
if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) {
233+
return Result{}
234+
}
235+
}
236+
if use_gzip {
237+
if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) {
238238
return Result{}
239239
}
240240
}
241241

242-
// .gz doesn't exist or is outdated: create it if file is small enough
242+
// No pre-compressed file available: create one if file is small enough
243243
if file_size < max_size_bytes {
244244
// Load, compress, save, and serve
245245
data := os.read_file(file_path) or {
246246
eprintln('[veb] error while trying to read file: ${err.msg()}')
247247
return ctx.server_error('could not read resource')
248248
}
249249

250-
compressed := gzip.compress(data.bytes()) or {
251-
// Fallback: serve uncompressed in streaming mode
252-
ctx.return_type = .file
253-
ctx.return_file = file_path
254-
ctx.res.header.set(.content_length, file_size.str())
255-
ctx.send_response_to_client(content_type, '')
256-
return Result{}
250+
// Try zstd first if enabled, then gzip
251+
if use_zstd {
252+
if result := ctx.serve_compressed_static(content_type, file_path, data,
253+
.zstd)
254+
{
255+
return result
256+
}
257257
}
258-
259-
// Try to save compressed version for future requests
260-
mut write_success := true
261-
os.write_file(gz_path, compressed.bytestr()) or {
262-
eprintln('[veb] warning: could not save .gz file (readonly filesystem?): ${err.msg()}')
263-
write_success = false
258+
if use_gzip {
259+
if result := ctx.serve_compressed_static(content_type, file_path, data,
260+
.gzip)
261+
{
262+
return result
263+
}
264264
}
265265

266-
if write_success {
267-
// Serve the newly cached .gz file in streaming mode (zero-copy)
268-
ctx.return_type = .file
269-
ctx.return_file = gz_path
270-
ctx.res.header.set(.content_encoding, 'gzip')
271-
ctx.res.header.set(.vary, 'Accept-Encoding')
272-
ctx.res.header.set(.content_length, compressed.len.str())
273-
ctx.send_response_to_client(content_type, '')
274-
ctx.already_compressed = true
275-
} else {
276-
// Fallback: serve compressed content from memory (no caching)
277-
// This happens on readonly filesystems or when write permissions are missing
278-
ctx.res.header.set(.content_encoding, 'gzip')
279-
ctx.res.header.set(.vary, 'Accept-Encoding')
280-
ctx.send_response_to_client(content_type, compressed.bytestr())
281-
ctx.already_compressed = true
282-
}
283-
return Result{}
266+
// Compression failed: serve uncompressed in streaming mode
267+
ctx.return_type = .file
268+
ctx.return_file = file_path
269+
ctx.res.header.set(.content_length, file_size.str())
270+
return ctx.send_response_to_client(content_type, '')
284271
}
285272
}
286273

@@ -301,6 +288,71 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result {
301288
return Result{}
302289
}
303290

291+
// serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh.
292+
// Returns true if the file was served, false otherwise.
293+
fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool {
294+
compressed_path := '${file_path}${ext}'
295+
if !os.exists(compressed_path) {
296+
return false
297+
}
298+
compressed_mtime := os.file_last_mod_unix(compressed_path)
299+
if compressed_mtime < orig_mtime {
300+
return false
301+
}
302+
// Serve existing compressed file in streaming mode (zero-copy)
303+
ctx.return_type = .file
304+
ctx.return_file = compressed_path
305+
ctx.res.header.set(.content_encoding, encoding_name)
306+
ctx.res.header.set(.vary, 'Accept-Encoding')
307+
compressed_size := os.file_size(compressed_path)
308+
ctx.res.header.set(.content_length, compressed_size.str())
309+
ctx.send_response_to_client(content_type, '')
310+
ctx.already_compressed = true
311+
return true
312+
}
313+
314+
// serve_compressed_static compresses data and serves it, optionally caching to disk.
315+
// Returns Result on success, none on compression failure.
316+
fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result {
317+
compressed, ext, encoding_name := match encoding {
318+
.zstd {
319+
c := zstd.compress(data.bytes()) or { return none }
320+
c, '.zst', 'zstd'
321+
}
322+
.gzip {
323+
c := gzip.compress(data.bytes()) or { return none }
324+
c, '.gz', 'gzip'
325+
}
326+
}
327+
328+
compressed_path := '${file_path}${ext}'
329+
330+
// Try to save compressed version for future requests
331+
mut write_success := true
332+
os.write_file(compressed_path, compressed.bytestr()) or {
333+
eprintln('[veb] warning: could not save ${ext} file (readonly filesystem?): ${err.msg()}')
334+
write_success = false
335+
}
336+
337+
if write_success {
338+
// Serve the newly cached file in streaming mode (zero-copy)
339+
ctx.return_type = .file
340+
ctx.return_file = compressed_path
341+
ctx.res.header.set(.content_encoding, encoding_name)
342+
ctx.res.header.set(.vary, 'Accept-Encoding')
343+
ctx.res.header.set(.content_length, compressed.len.str())
344+
ctx.send_response_to_client(content_type, '')
345+
ctx.already_compressed = true
346+
} else {
347+
// Fallback: serve compressed content from memory (no caching)
348+
ctx.res.header.set(.content_encoding, encoding_name)
349+
ctx.res.header.set(.vary, 'Accept-Encoding')
350+
ctx.send_response_to_client(content_type, compressed.bytestr())
351+
ctx.already_compressed = true
352+
}
353+
return Result{}
354+
}
355+
304356
// Response HTTP_OK with s as payload
305357
pub fn (mut ctx Context) ok(s string) Result {
306358
mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type }

0 commit comments

Comments
 (0)