From cf1257f8a2fe12e1585ee6e1b18373ab25f7d0b8 Mon Sep 17 00:00:00 2001 From: David Legrand <1110600+davlgd@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:01:56 +0100 Subject: [PATCH] veb: add zstd compression support (#25816) --- vlib/veb/README.md | 94 ++-- vlib/veb/consts.v | 1 + vlib/veb/context.v | 170 ++++--- vlib/veb/middleware.v | 144 +++++- vlib/veb/static_handler.v | 42 +- vlib/veb/tests/middleware_test.v | 83 ++++ vlib/veb/tests/static_compression_test.v | 569 +++++++++++++++++++++++ vlib/veb/tests/static_gzip_test.v | 296 ------------ vlib/veb/veb.v | 8 +- 9 files changed, 972 insertions(+), 435 deletions(-) create mode 100644 vlib/veb/tests/static_compression_test.v delete mode 100644 vlib/veb/tests/static_gzip_test.v diff --git a/vlib/veb/README.md b/vlib/veb/README.md index 4e193d6e7..12da41571 100644 --- a/vlib/veb/README.md +++ b/vlib/veb/README.md @@ -419,22 +419,24 @@ app.static_mime_types['.what'] = 'txt/plain' app.handle_static('static', true)! ``` -### Gzip compression for static files +### Compression for static files (zstd/gzip) -veb provides automatic gzip compression for static files with smart caching. When enabled, -veb will serve compressed versions of your static files to clients that support gzip encoding, -reducing bandwidth usage and improving load times. +veb provides automatic compression (zstd and gzip) for static files with smart caching. +When enabled, veb will serve compressed versions of your static files to clients that +support compression, reducing bandwidth usage and improving load times. Zstd is preferred +over gzip when the client supports both. **How it works:** -1. **Manual pre-compression**: If you create `.gz` files manually, veb will serve them in - zero-copy streaming mode for maximum performance. +1. **Manual pre-compression**: If you create `.zst` or `.gz` files manually, veb will serve + them in zero-copy streaming mode for maximum performance. 2. **Lazy compression cache**: Files smaller than the threshold are automatically compressed - on first request and cached as `.gz` files on disk. -3. **Cache validation**: If the original file is modified, the `.gz` cache is automatically - regenerated on the next request. + on first request and cached as `.zst` or `.gz` files on disk (zstd preferred when client + supports it). +3. **Cache validation**: If the original file is modified, the compressed cache is + automatically regenerated on the next request. 4. **Streaming for large files**: Files larger than the threshold are served uncompressed in - streaming mode (unless a manual `.gz` file exists). + streaming mode (unless a manual `.zst` or `.gz` file exists). **Example:** @@ -453,21 +455,26 @@ pub struct App { } pub fn (mut app App) index(mut ctx Context) veb.Result { - return ctx.html('

Gzip compression demo

Visit /app.js or /style.css

') + return ctx.html('

Compression demo

+

Visit /app.js or /style.css +

') } fn main() { mut app := &App{} - // Enable static file gzip compression (disabled by default) - app.enable_static_gzip = true - app.static_gzip_max_size = 524288 // Maximum file size for auto-compression is 512 KB (default: 1MB) + // Enable static file compression (zstd/gzip, disabled by default) + // Use enable_static_zstd and enable_static_gzip for specific compression + app.enable_static_compression = true + app.static_compression_max_size = 524288 // Maximum file size for auto-compression is 512 KB (default: 1MB) // Serve files from the 'static' directory app.handle_static('static', true)! - // Add the gzip middleware to compress dynamic routes as well - app.use(veb.encode_gzip[Context]()) + // Add the content encoding middleware to compress dynamic routes as well + // This will use zstd if the client supports it, otherwise gzip + // Use encode_gzip or encode_zstd for specific compression + app.use(veb.encode_auto[Context]()) veb.run[App, Context](mut app, 8080) } @@ -480,8 +487,9 @@ Create test files in the `static` directory: mkdir -p static echo "console.log('Hello from V web!');" > static/app.js echo "body { margin: 0; }" > static/style.css -# Pre-compress style.css manually for zero-copy streaming -gzip -k static/style.css +# Pre-compress style.css manually for zero-copy streaming (zstd or gzip) +zstd -k static/style.css # creates style.css.zst +# or: gzip -k static/style.css # creates style.css.gz ``` Run the server, it will listen on port 8080: @@ -489,50 +497,58 @@ Run the server, it will listen on port 8080: v run server.v ``` -Test gzip compression with cURL: +Test compression with cURL: ```bash -# Test lazy compression cache - app.js will be compressed on first request -curl -H "Accept-Encoding: gzip" -i http://localhost:8080/app.js +# Test zstd compression (preferred when client supports it) +curl -H "Accept-Encoding: zstd, gzip" -i http://localhost:8080/app.js +# Expected headers: +# Content-Encoding: zstd +# Vary: Accept-Encoding +# Test gzip fallback (when client doesn't support zstd) +curl -H "Accept-Encoding: gzip" -i http://localhost:8080/app.js # Expected headers: # Content-Encoding: gzip # Vary: Accept-Encoding # Request with automatic decompression -curl -H "Accept-Encoding: gzip" --compressed http://localhost:8080/app.js +curl -H "Accept-Encoding: zstd, gzip" --compressed http://localhost:8080/app.js -# Request without gzip encoding - should return uncompressed content +# Request without encoding - should return uncompressed content curl -i http://localhost:8080/app.js -# Verify that .gz cache file was created -ls -lh static/app.js.gz +# Verify that compressed cache file was created +ls -lh static/app.js.zst static/app.js.gz 2>/dev/null -# Test manual pre-compression - style.css.gz is served directly (zero-copy) -# The pre-compressed .gz file is served without loading into memory -curl -H "Accept-Encoding: gzip" -i http://localhost:8080/style.css +# Test manual pre-compression - style.css.zst is served directly (zero-copy) +curl -H "Accept-Encoding: zstd" -i http://localhost:8080/style.css ``` **Performance tips:** -- For production, you can pre-compress your static files (e.g., `gzip -k static/app.js`) - and veb will serve them directly without loading into memory. +- For production, you can pre-compress your static files with zstd (`zstd -k static/app.js`) + or gzip (`gzip -k static/app.js`) and veb will serve them directly without loading into memory. +- Zstd offers better compression ratio and speed than gzip - use it when possible. + +**Priority order**: When both `.zst` and `.gz` files exist for the same source file, veb will +serve `.zst` if the client supports zstd, otherwise `.gz` if gzip is supported. + - The lazy cache is created on first request, so the first visitor pays a small compression cost, but all subsequent requests are served at zero-copy speed. -- Large files (> threshold) are always streamed, ensuring low memory usage even for - large assets. -- The `encode_gzip` middleware compresses dynamic routes and small files loaded in - memory (takeover mode). -- If `.gz` caching fails (e.g., on read-only filesystems), veb automatically falls - back to serving compressed content from memory. You can set `static_gzip_max_size = 0` +- Large files (> threshold) are always streamed, ensuring low memory usage even for large assets. +- The `encode_auto` middleware automatically chooses zstd or gzip based on client support. You can + also use `encode_zstd` or `encode_gzip` for specific compression. +- If caching fails (e.g., on read-only filesystems), veb automatically falls + back to serving compressed content from memory. You can set `static_compression_max_size = 0` to disable auto-compression completely. For optimal performance on read-only systems, - pre-compress all files with `gzip -k`. + pre-compress all files with `zstd -k` or `gzip -k`. ### Markdown content negotiation veb can provide automatic content negotiation for markdown files, allowing you to serve markdown content when the client explicitly requests it via the `Accept` header. -This is compliant to [llms.txt](https://llmstxt.org/) proposal and useful for documentations that can serve -the same content in multiple formats, more efficiently to AI services using it. +This is compliant to [llms.txt](https://llmstxt.org/) proposal and useful for documentations that +can serve the same content in multiple formats, more efficiently to AI services using it. **How it works:** diff --git a/vlib/veb/consts.v b/vlib/veb/consts.v index 08b376d46..8bb33bfd2 100644 --- a/vlib/veb/consts.v +++ b/vlib/veb/consts.v @@ -142,6 +142,7 @@ pub const mime_types = { '.xml': 'application/xml' '.xul': 'application/vnd.mozilla.xul+xml' '.zip': 'application/zip' + '.zst': 'application/zstd' '.3gp': 'video/3gpp' '.3g2': 'video/3gpp2' '.7z': 'application/x-7z-compressed' diff --git a/vlib/veb/context.v b/vlib/veb/context.v index 944b13a27..0c09e0c25 100644 --- a/vlib/veb/context.v +++ b/vlib/veb/context.v @@ -1,6 +1,7 @@ module veb import compress.gzip +import compress.zstd import json import net import net.http @@ -32,15 +33,17 @@ mut: done bool // If the `Connection: close` header is present the connection should always be closed client_wants_to_close bool - // Configuration for static file gzip compression (set by serve_if_static) - enable_static_gzip bool - static_gzip_max_size int + // Configuration for static file compression (set by serve_if_static) + enable_static_gzip bool + enable_static_zstd bool + enable_static_compression bool + static_compression_max_size int // if true the response should not be sent and the connection should be closed // manually. takeover bool return_file string - // already_compressed indicates that the response body is already gzip-compressed - // and the encode_gzip middleware should skip it + // already_compressed indicates that the response body is already compressed (zstd/gzip) + // and the compression middlewares should skip it already_compressed bool pub: // TODO: move this to `handle_request` @@ -207,39 +210,36 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result { } file.close() - // Check if client accepts gzip encoding + // Check which encodings the client accepts accept_encoding := ctx.req.header.get(.accept_encoding) or { '' } + client_accepts_zstd := accept_encoding.contains('zstd') client_accepts_gzip := accept_encoding.contains('gzip') - max_size_bytes := ctx.static_gzip_max_size + max_size_bytes := ctx.static_compression_max_size - // Try to serve pre-compressed .gz file if static gzip is enabled and client accepts it - if ctx.enable_static_gzip && client_accepts_gzip { - gz_path := '${file_path}.gz' + // Determine which compression modes are enabled + use_zstd := (ctx.enable_static_zstd && client_accepts_zstd) + || (ctx.enable_static_compression && client_accepts_zstd) + use_gzip := (ctx.enable_static_gzip && client_accepts_gzip) + || (ctx.enable_static_compression && client_accepts_gzip) - // Check if .gz file exists and is up-to-date (newer or same age as original) - if os.exists(gz_path) { - gz_mtime := os.file_last_mod_unix(gz_path) - orig_mtime := os.file_last_mod_unix(file_path) + // Try to serve pre-compressed files if any compression is enabled + if use_zstd || use_gzip { + orig_mtime := os.file_last_mod_unix(file_path) - if gz_mtime >= orig_mtime { - // Serve existing .gz file in streaming mode (zero-copy) - ctx.return_type = .file - ctx.return_file = gz_path - ctx.res.header.set(.content_encoding, 'gzip') - ctx.res.header.set(.vary, 'Accept-Encoding') - - // Get .gz file size for Content-Length header - gz_size := os.file_size(gz_path) - ctx.res.header.set(.content_length, gz_size.str()) - - ctx.send_response_to_client(content_type, '') - ctx.already_compressed = true + // Try zstd first if enabled (better compression), then gzip + if use_zstd { + if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) { + return Result{} + } + } + if use_gzip { + if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) { return Result{} } } - // .gz doesn't exist or is outdated: create it if file is small enough + // No pre-compressed file available: create one if file is small enough if file_size < max_size_bytes { // Load, compress, save, and serve data := os.read_file(file_path) or { @@ -247,40 +247,27 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result { return ctx.server_error('could not read resource') } - compressed := gzip.compress(data.bytes()) or { - // Fallback: serve uncompressed in streaming mode - ctx.return_type = .file - ctx.return_file = file_path - ctx.res.header.set(.content_length, file_size.str()) - ctx.send_response_to_client(content_type, '') - return Result{} + // Try zstd first if enabled, then gzip + if use_zstd { + if result := ctx.serve_compressed_static(content_type, file_path, data, + .zstd) + { + return result + } } - - // Try to save compressed version for future requests - mut write_success := true - os.write_file(gz_path, compressed.bytestr()) or { - eprintln('[veb] warning: could not save .gz file (readonly filesystem?): ${err.msg()}') - write_success = false + if use_gzip { + if result := ctx.serve_compressed_static(content_type, file_path, data, + .gzip) + { + return result + } } - if write_success { - // Serve the newly cached .gz file in streaming mode (zero-copy) - ctx.return_type = .file - ctx.return_file = gz_path - ctx.res.header.set(.content_encoding, 'gzip') - ctx.res.header.set(.vary, 'Accept-Encoding') - ctx.res.header.set(.content_length, compressed.len.str()) - ctx.send_response_to_client(content_type, '') - ctx.already_compressed = true - } else { - // Fallback: serve compressed content from memory (no caching) - // This happens on readonly filesystems or when write permissions are missing - ctx.res.header.set(.content_encoding, 'gzip') - ctx.res.header.set(.vary, 'Accept-Encoding') - ctx.send_response_to_client(content_type, compressed.bytestr()) - ctx.already_compressed = true - } - return Result{} + // Compression failed: serve uncompressed in streaming mode + ctx.return_type = .file + ctx.return_file = file_path + ctx.res.header.set(.content_length, file_size.str()) + return ctx.send_response_to_client(content_type, '') } } @@ -301,6 +288,71 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result { return Result{} } +// serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh. +// Returns true if the file was served, false otherwise. +fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool { + compressed_path := '${file_path}${ext}' + if !os.exists(compressed_path) { + return false + } + compressed_mtime := os.file_last_mod_unix(compressed_path) + if compressed_mtime < orig_mtime { + return false + } + // Serve existing compressed file in streaming mode (zero-copy) + ctx.return_type = .file + ctx.return_file = compressed_path + ctx.res.header.set(.content_encoding, encoding_name) + ctx.res.header.set(.vary, 'Accept-Encoding') + compressed_size := os.file_size(compressed_path) + ctx.res.header.set(.content_length, compressed_size.str()) + ctx.send_response_to_client(content_type, '') + ctx.already_compressed = true + return true +} + +// serve_compressed_static compresses data and serves it, optionally caching to disk. +// Returns Result on success, none on compression failure. +fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result { + compressed, ext, encoding_name := match encoding { + .zstd { + c := zstd.compress(data.bytes()) or { return none } + c, '.zst', 'zstd' + } + .gzip { + c := gzip.compress(data.bytes()) or { return none } + c, '.gz', 'gzip' + } + } + + compressed_path := '${file_path}${ext}' + + // Try to save compressed version for future requests + mut write_success := true + os.write_file(compressed_path, compressed.bytestr()) or { + eprintln('[veb] warning: could not save ${ext} file (readonly filesystem?): ${err.msg()}') + write_success = false + } + + if write_success { + // Serve the newly cached file in streaming mode (zero-copy) + ctx.return_type = .file + ctx.return_file = compressed_path + ctx.res.header.set(.content_encoding, encoding_name) + ctx.res.header.set(.vary, 'Accept-Encoding') + ctx.res.header.set(.content_length, compressed.len.str()) + ctx.send_response_to_client(content_type, '') + ctx.already_compressed = true + } else { + // Fallback: serve compressed content from memory (no caching) + ctx.res.header.set(.content_encoding, encoding_name) + ctx.res.header.set(.vary, 'Accept-Encoding') + ctx.send_response_to_client(content_type, compressed.bytestr()) + ctx.already_compressed = true + } + return Result{} +} + // Response HTTP_OK with s as payload pub fn (mut ctx Context) ok(s string) Result { mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type } diff --git a/vlib/veb/middleware.v b/vlib/veb/middleware.v index b15bd146b..1f63c6743 100644 --- a/vlib/veb/middleware.v +++ b/vlib/veb/middleware.v @@ -1,6 +1,7 @@ module veb import compress.gzip +import compress.zstd import net.http pub type MiddlewareHandler[T] = fn (mut T) bool @@ -117,9 +118,64 @@ fn validate_middleware[T](mut ctx T, raw_handlers []voidptr) bool { return true } +// Compression encoding types for HTTP responses +enum ContentEncoding { + gzip + zstd +} + +// send_compressed_response compresses the response body and sends it to the client. +// Returns true if compression should be skipped, false if compression was applied. +fn send_compressed_response(mut ctx Context, encoding ContentEncoding) bool { + compressed, encoding_name := match encoding { + .zstd { + data := zstd.compress(ctx.res.body.bytes()) or { + eprintln('[veb] error while compressing with zstd: ${err.msg()}') + return true + } + data, 'zstd' + } + .gzip { + data := gzip.compress(ctx.res.body.bytes()) or { + eprintln('[veb] error while compressing with gzip: ${err.msg()}') + return true + } + data, 'gzip' + } + } + + // Take over the connection to have full control over the response + ctx.takeover_conn() + + // Set HTTP headers for compressed content + ctx.res.header.add(.content_encoding, encoding_name) + ctx.res.header.set(.vary, 'Accept-Encoding') + ctx.res.header.set(.content_length, compressed.len.str()) + + fast_send_resp_header(mut ctx.conn, ctx.res) or {} + ctx.conn.write_ptr(&u8(compressed.data), compressed.len) or {} + ctx.conn.close() or {} + + return false +} + +// should_skip_compression checks if compression should be skipped for this context. +fn should_skip_compression(ctx Context) bool { + // Skip if already compressed (optimization for static files compressed in send_file) + if ctx.already_compressed { + return true + } + // Skip compression for files in streaming mode (takeover == false) + // Files in takeover mode (small files loaded in memory) are compressed + if ctx.return_type == .file && !ctx.takeover { + return true + } + return false +} + // encode_gzip adds gzip encoding to the HTTP Response body. // This middleware compresses dynamic routes and static files loaded in memory (takeover mode). -// Static files in streaming mode are compressed by send_file() when enable_static_gzip is enabled, +// Static files in streaming mode are compressed by send_file() when static compression is enabled, // and this middleware skips them to avoid double compression (via the already_compressed flag). // Register this middleware as last! // Usage example: app.use(veb.encode_gzip[Context]()) @@ -127,35 +183,61 @@ pub fn encode_gzip[T]() MiddlewareOptions[T] { return MiddlewareOptions[T]{ after: true handler: fn [T](mut ctx T) bool { - // Skip if already compressed (optimization for static files compressed in send_file) - if ctx.Context.already_compressed { + if should_skip_compression(ctx.Context) { return true } - // Skip compression for files in streaming mode (takeover == false) - // Files in takeover mode (small files loaded in memory) are compressed - if ctx.Context.return_type == .file && !ctx.Context.takeover { + return send_compressed_response(mut ctx.Context, .gzip) + } + } +} + +// encode_zstd adds zstd encoding to the HTTP Response body. +// This middleware compresses dynamic routes and static files loaded in memory (takeover mode). +// Static files in streaming mode are compressed by send_file() when static compression is enabled, +// and this middleware skips them to avoid double compression (via the already_compressed flag). +// Register this middleware as last! +// Usage example: app.route_use('/api', veb.encode_zstd[Context]()) +pub fn encode_zstd[T]() MiddlewareOptions[T] { + return MiddlewareOptions[T]{ + after: true + handler: fn [T](mut ctx T) bool { + if should_skip_compression(ctx.Context) { return true } - // first try compressions, because if it fails we can still send a response - // before taking over the connection - compressed := gzip.compress(ctx.res.body.bytes()) or { - eprintln('[veb] error while compressing with gzip: ${err.msg()}') + return send_compressed_response(mut ctx.Context, .zstd) + } + } +} + +// encode_auto adds automatic content encoding (zstd or gzip) based on the client's Accept-Encoding header. +// This middleware checks the Accept-Encoding header and compresses with zstd if supported, otherwise gzip. +// Static files in streaming mode are compressed by send_file() when static compression is enabled, +// and this middleware skips them to avoid double compression (via the already_compressed flag). +// Register this middleware as last! +// Usage example: app.use(veb.encode_auto[Context]()) +pub fn encode_auto[T]() MiddlewareOptions[T] { + return MiddlewareOptions[T]{ + after: true + handler: fn [T](mut ctx T) bool { + if should_skip_compression(ctx.Context) { return true } - // enables us to have full control over what response is send over the connection - // and how. - ctx.takeover_conn() - // set HTTP headers for gzip - ctx.res.header.add(.content_encoding, 'gzip') - ctx.res.header.set(.vary, 'Accept-Encoding') - ctx.res.header.set(.content_length, compressed.len.str()) + // Check Accept-Encoding header to determine best compression + accept_encoding := ctx.req.header.get(.accept_encoding) or { '' } + supports_zstd := accept_encoding.contains('zstd') + supports_gzip := accept_encoding.contains('gzip') - fast_send_resp_header(mut ctx.Context.conn, ctx.res) or {} - ctx.Context.conn.write_ptr(&u8(compressed.data), compressed.len) or {} - ctx.Context.conn.close() or {} + // Try zstd first (better compression ratio), fallback to gzip + if supports_zstd { + return send_compressed_response(mut ctx.Context, .zstd) + } + if supports_gzip { + return send_compressed_response(mut ctx.Context, .gzip) + } - return false + // No supported compression + return true } } } @@ -180,6 +262,26 @@ pub fn decode_gzip[T]() MiddlewareOptions[T] { } } +// decode_zstd decodes the body of a zstd-compressed HTTP request. +// Register this middleware before you do anything with the request body! +// Usage example: app.use(veb.decode_zstd[Context]()) +pub fn decode_zstd[T]() MiddlewareOptions[T] { + return MiddlewareOptions[T]{ + handler: fn [T](mut ctx T) bool { + if encoding := ctx.req.header.get(.content_encoding) { + if encoding == 'zstd' { + decompressed := zstd.decompress(ctx.req.data.bytes()) or { + ctx.request_error('invalid zstd encoding') + return false + } + ctx.req.data = decompressed.bytestr() + } + } + return true + } + } +} + interface HasBeforeRequest { before_request() } diff --git a/vlib/veb/static_handler.v b/vlib/veb/static_handler.v index ae98285aa..6995925cd 100644 --- a/vlib/veb/static_handler.v +++ b/vlib/veb/static_handler.v @@ -4,11 +4,14 @@ import os pub interface StaticApp { mut: - static_files map[string]string - static_mime_types map[string]string - static_hosts map[string]string - enable_static_gzip bool - static_gzip_max_size int + static_files map[string]string + static_mime_types map[string]string + static_hosts map[string]string + enable_static_gzip bool + enable_static_zstd bool + enable_static_compression bool + static_compression_max_size int + enable_markdown_negotiation bool } // StaticHandler provides methods to handle static files in your veb App @@ -17,19 +20,24 @@ pub mut: static_files map[string]string static_mime_types map[string]string static_hosts map[string]string - // enable_static_gzip enables automatic gzip compression for static files. - // When enabled, Veb will: - // 1. Serve existing .gz files in zero-copy streaming mode (manual pre-compression) - // 2. Auto-generate .gz files for files < static_gzip_max_size (lazy compression cache) - // 3. Validate .gz freshness (regenerate if source file is newer) - // Files larger than the threshold are served uncompressed in streaming mode. - // Default: false (for backward compatibility) + // enable_static_gzip enables gzip compression for static files. + // Use this for gzip-only compression. For automatic zstd/gzip selection, use enable_static_compression. + // Default: false enable_static_gzip bool - // static_gzip_max_size sets the maximum file size in bytes for auto-compression. - // Files larger than this threshold will not be auto-compressed (but manual .gz files are still served). - // Default: 1MB (1024*1024 bytes). Set to 0 to disable auto-compression completely (only pre-compressed .gz files will be served). - // Note: On readonly filesystems, if .gz caching fails, compressed content is served from memory as fallback. - static_gzip_max_size int = 1048576 + // enable_static_zstd enables zstd compression for static files. + // Use this for zstd-only compression. For automatic zstd/gzip selection, use enable_static_compression. + // Default: false + enable_static_zstd bool + // enable_static_compression enables automatic compression (zstd/gzip) for static files. + // When enabled, Veb will choose zstd over gzip when client supports both (better compression ratio). + // For gzip-only or zstd-only compression, use enable_static_gzip or enable_static_zstd instead. + // Default: false + enable_static_compression bool + // static_compression_max_size sets the maximum file size in bytes for auto-compression. + // Files larger than this threshold will not be auto-compressed (but manual .zst/.gz files are still served). + // Default: 1MB (1024*1024 bytes). Set to 0 to disable auto-compression completely. + // Note: On readonly filesystems, if caching fails, compressed content is served from memory as fallback. + static_compression_max_size int = 1048576 // enable_markdown_negotiation allows the client sends Accept: text/markdown, then the server will serve .md files, if any. // Default: false (for backward compatibility) enable_markdown_negotiation bool diff --git a/vlib/veb/tests/middleware_test.v b/vlib/veb/tests/middleware_test.v index b8fcd47c3..8e5cfced7 100644 --- a/vlib/veb/tests/middleware_test.v +++ b/vlib/veb/tests/middleware_test.v @@ -3,6 +3,7 @@ import net.http import os import time import compress.gzip +import compress.zstd const port = 13001 @@ -59,6 +60,21 @@ pub fn (app &App) decode_gzip_test(mut ctx Context) veb.Result { return ctx.text('received: ${ctx.req.data}') } +@['/zstd'] +pub fn (app &App) zstd_test(mut ctx Context) veb.Result { + return ctx.text('zstd response, ${ctx.counter}') +} + +@['/decode_zstd'; post] +pub fn (app &App) decode_zstd_test(mut ctx Context) veb.Result { + return ctx.text('received: ${ctx.req.data}') +} + +@['/content'] +pub fn (app &App) content_test(mut ctx Context) veb.Result { + return ctx.text('content response, ${ctx.counter}') +} + pub fn (app &App) app_middleware(mut ctx Context) bool { ctx.counter++ return true @@ -102,6 +118,13 @@ fn testsuite_begin() { app.Middleware.use(veb.decode_gzip[Context]()) app.Middleware.route_use('/gzip', veb.encode_gzip[Context]()) + // Zstd middleware tests + app.Middleware.use(veb.decode_zstd[Context]()) + app.Middleware.route_use('/zstd', veb.encode_zstd[Context]()) + + // Auto content encoding middleware (zstd/gzip based on Accept-Encoding) + app.Middleware.route_use('/content', veb.encode_auto[Context]()) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) // app startup time _ := <-app.started @@ -163,3 +186,63 @@ fn test_decode_gzip_middleware() { assert x.body == 'received: ${original_text}' } + +// Verifies that encode_zstd compresses responses +fn test_encode_zstd_middleware() { + x := http.get('${localserver}/zstd')! + + encoding := x.header.get(.content_encoding) or { '' } + assert encoding == 'zstd', 'Expected zstd encoding, got: ${encoding}' + + decompressed := zstd.decompress(x.body.bytes())! + assert decompressed.bytestr() == 'zstd response, 2' +} + +// Verifies that decode_zstd middleware decompresses request bodies +fn test_decode_zstd_middleware() { + original_text := 'Hello from zstd compressed body!' + compressed := zstd.compress(original_text.bytes())! + + mut req := http.new_request(.post, '${localserver}/decode_zstd', compressed.bytestr()) + req.header.add(.content_encoding, 'zstd') + x := req.do()! + + assert x.body == 'received: ${original_text}' +} + +// Verifies that encode_auto uses zstd when client supports it +fn test_encode_auto_zstd() { + mut req := http.new_request(.get, '${localserver}/content', '') + req.header.add(.accept_encoding, 'gzip, zstd, br') + x := req.do()! + + encoding := x.header.get(.content_encoding) or { '' } + assert encoding == 'zstd', 'Expected zstd encoding when zstd is in Accept-Encoding, got: ${encoding}' + + decompressed := zstd.decompress(x.body.bytes())! + assert decompressed.bytestr() == 'content response, 2' +} + +// Verifies that encode_auto falls back to gzip when client doesn't support zstd +fn test_encode_auto_gzip_fallback() { + mut req := http.new_request(.get, '${localserver}/content', '') + req.header.add(.accept_encoding, 'gzip, br') + x := req.do()! + + encoding := x.header.get(.content_encoding) or { '' } + assert encoding == 'gzip', 'Expected gzip encoding when zstd is not in Accept-Encoding, got: ${encoding}' + + decompressed := gzip.decompress(x.body.bytes())! + assert decompressed.bytestr() == 'content response, 2' +} + +// Verifies that encode_auto sends uncompressed when no encoding is supported +fn test_encode_auto_no_compression() { + mut req := http.new_request(.get, '${localserver}/content', '') + req.header.add(.accept_encoding, 'br') + x := req.do()! + + encoding := x.header.get(.content_encoding) or { '' } + assert encoding == '', 'Expected no encoding when neither gzip nor zstd is in Accept-Encoding, got: ${encoding}' + assert x.body == 'content response, 2' +} diff --git a/vlib/veb/tests/static_compression_test.v b/vlib/veb/tests/static_compression_test.v new file mode 100644 index 000000000..6e27a356b --- /dev/null +++ b/vlib/veb/tests/static_compression_test.v @@ -0,0 +1,569 @@ +// vtest build: !docker-ubuntu-musl // failing assert static_compression_test.v:234 -> `.gz cache file should not be created on readonly filesystem`, because of the Docker container +import veb +import net.http +import os +import time +import compress.gzip +import compress.zstd + +const port = 14013 +const port_no_auto = 14014 // Port for static_compression_max_size = 0 test +const port_gzip_only = 14015 // Port for enable_static_gzip only test +const port_zstd_only = 14016 // Port for enable_static_zstd only test + +const localserver = 'http://127.0.0.1:${port}' +const localserver_no_auto = 'http://127.0.0.1:${port_no_auto}' +const localserver_gzip_only = 'http://127.0.0.1:${port_gzip_only}' +const localserver_zstd_only = 'http://127.0.0.1:${port_zstd_only}' + +const exit_after = time.second * 30 + +const test_file_content = 'This is a test file for gzip compression. It contains enough text to make compression worthwhile. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' + +pub struct App { + veb.StaticHandler + veb.Middleware[Context] +mut: + started chan bool +} + +pub fn (mut app App) before_accept_loop() { + app.started <- true +} + +pub fn (mut app App) index(mut ctx Context) veb.Result { + return ctx.text('Hello V!') +} + +pub struct Context { + veb.Context +} + +fn testsuite_begin() { + os.chdir(os.dir(@FILE))! + + // Create test directory and files + os.mkdir_all('testdata_compression')! + os.write_file('testdata_compression/test.txt', test_file_content)! + os.write_file('testdata_compression/large.txt', test_file_content.repeat(100))! + + // Create readonly directory and file for readonly filesystem test + os.mkdir_all('testdata_compression/readonly')! + os.write_file('testdata_compression/readonly/readonly.txt', 'This is a readonly file test')! + + // Create pre-compressed file for manual .gz test + large_content := 'X'.repeat(2000) + os.write_file('testdata_compression/precompressed.txt', large_content)! + compressed_gz := gzip.compress(large_content.bytes()) or { panic(err) } + os.write_file('testdata_compression/precompressed.txt.gz', compressed_gz.bytestr())! + + // Create pre-compressed file for manual .zst test + os.write_file('testdata_compression/precompressed_zstd.txt', large_content)! + compressed_zst := zstd.compress(large_content.bytes()) or { panic(err) } + os.write_file('testdata_compression/precompressed_zstd.txt.zst', compressed_zst.bytestr())! + + // Create test file for zstd auto-compression + os.write_file('testdata_compression/zstd_test.txt', test_file_content)! + + // Create file for testing max_size = 0 (no auto-compression) + os.write_file('testdata_compression/no_auto.txt', 'This file should not be auto-compressed')! + + // Create files for gzip-only and zstd-only tests + os.write_file('testdata_compression/gzip_only_test.txt', test_file_content)! + os.write_file('testdata_compression/zstd_only_test.txt', test_file_content)! + + spawn fn () { + time.sleep(exit_after) + assert true == false, 'timeout reached!' + exit(1) + }() + + run_app_test() + run_no_auto_compression_test() + run_gzip_only_test() + run_zstd_only_test() +} + +fn testsuite_end() { + // Clean up test files + os.rmdir_all('testdata_compression') or {} +} + +fn run_app_test() { + mut app := &App{} + + // Enable static compression (zstd/gzip) + app.enable_static_compression = true + app.static_compression_max_size = 1048576 // 1MB + + app.handle_static('testdata_compression', true) or { panic(err) } + + // Add compression middleware (gzip for this test app) + app.use(veb.encode_gzip[Context]()) + + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 25, family: .ip) + _ := <-app.started +} + +fn run_no_auto_compression_test() { + mut app := &App{} + + // Enable static compression but disable auto-compression (max_size = 0) + app.enable_static_compression = true + app.static_compression_max_size = 0 // Disable auto-compression + + app.handle_static('testdata_compression', true) or { panic(err) } + + // Add compression middleware (gzip for this test app) + app.use(veb.encode_gzip[Context]()) + + spawn veb.run_at[App, Context](mut app, + port: port_no_auto + timeout_in_seconds: 25 + family: .ip + ) + _ := <-app.started +} + +fn run_gzip_only_test() { + mut app := &App{} + + // Enable ONLY gzip compression (not zstd, not auto) + app.enable_static_gzip = true + app.static_compression_max_size = 1048576 // 1MB + + app.handle_static('testdata_compression', true) or { panic(err) } + + spawn veb.run_at[App, Context](mut app, + port: port_gzip_only + timeout_in_seconds: 25 + family: .ip + ) + _ := <-app.started +} + +fn run_zstd_only_test() { + mut app := &App{} + + // Enable ONLY zstd compression (not gzip, not auto) + app.enable_static_zstd = true + app.static_compression_max_size = 1048576 // 1MB + + app.handle_static('testdata_compression', true) or { panic(err) } + + spawn veb.run_at[App, Context](mut app, + port: port_zstd_only + timeout_in_seconds: 25 + family: .ip + ) + _ := <-app.started +} + +fn test_gzip_compression_with_accept_encoding() { + // Request with Accept-Encoding: gzip + mut req := http.new_request(.get, '${localserver}/test.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'gzip' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify Content-Length header matches actual body size + content_length := x.header.get(.content_length)!.int() + assert content_length == x.body.len, 'Content-Length should match actual body size' + + // Verify the body is compressed + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'failed to decompress response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_no_compression_without_accept_encoding() { + // Request without Accept-Encoding header + x := http.get('${localserver}/test.txt')! + + assert x.status() == .ok + // Should not have content-encoding header when client doesn't accept gzip + _ := x.header.get(.content_encoding) or { + // Expected: no content-encoding header + assert x.body == test_file_content + return + } + assert false, 'should not compress without Accept-Encoding: gzip' +} + +fn test_gz_file_cache_creation() { + // First request creates .gz cache file + mut req := http.new_request(.get, '${localserver}/test.txt', '') + req.add_header(.accept_encoding, 'gzip') + _ := req.do()! + + // Check that .gz file was created + gz_path := 'testdata_compression/test.txt.gz' + assert os.exists(gz_path), '.gz cache file should be created' + + // Second request should use cached .gz file + y := req.do()! + assert y.status() == .ok + assert y.header.get(.content_encoding)! == 'gzip' + + // Verify Content-Length matches .gz file size (tests os.file_size() code path) + gz_file_size := os.file_size(gz_path) + content_length := y.header.get(.content_length)!.u64() + assert content_length == gz_file_size, 'Content-Length should match .gz file size' +} + +fn test_large_file_not_auto_compressed() { + // Configure app with very small max size to test threshold + // The large.txt file is ~20KB (200 chars * 100), which exceeds a 1KB threshold + // But we set it to 1MB, so it should still be compressed + // Let's test by checking if it gets compressed + + mut req := http.new_request(.get, '${localserver}/large.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + // File should be compressed as it's under 1MB threshold + assert x.header.get(.content_encoding)! == 'gzip' +} + +fn test_already_compressed_flag() { + // Request a file that will be compressed and cached + mut req := http.new_request(.get, '${localserver}/test.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + // The file should be compressed only once (in send_file, not by middleware) + // We can't directly test the already_compressed flag, but we can verify + // that the response is valid gzip + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'response should be valid gzip: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_readonly_filesystem_fallback() { + // Test that compression works even on readonly filesystems (fallback to memory) + // Skip on Windows as readonly permissions work differently (ACL vs chmod) + $if windows { + eprintln('Skipping readonly filesystem test on Windows') + return + } + + // Make readonly directory readonly (no write permissions) + readonly_dir := 'testdata_compression/readonly' + readonly_file := '${readonly_dir}/readonly.txt' + + os.chmod(readonly_dir, 0o555)! // r-xr-xr-x + + mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + // Restore permissions before assertions (for cleanup) + os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x + + assert x.status() == .ok + // Should be compressed (served from memory as fallback) + assert x.header.get(.content_encoding)! == 'gzip' + + // Verify that .gz file was NOT created (readonly filesystem) + gz_path := '${readonly_file}.gz' + assert !os.exists(gz_path), '.gz cache file should not be created on readonly filesystem' + + // Verify content is valid gzip + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'response should be valid gzip even on readonly fs: ${err}' + return + } + assert decompressed.bytestr() == 'This is a readonly file test' +} + +fn test_readonly_filesystem_fallback_zstd() { + // Test that zstd compression works even on readonly filesystems (fallback to memory) + // Skip on Windows as readonly permissions work differently (ACL vs chmod) + $if windows { + eprintln('Skipping readonly filesystem test on Windows') + return + } + + // Make readonly directory readonly (no write permissions) + readonly_dir := 'testdata_compression/readonly' + readonly_file := '${readonly_dir}/readonly.txt' + + os.chmod(readonly_dir, 0o555)! // r-xr-xr-x + + mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '') + req.add_header(.accept_encoding, 'zstd') + x := req.do()! + + // Restore permissions before assertions (for cleanup) + os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x + + assert x.status() == .ok + // Should be compressed (served from memory as fallback) + assert x.header.get(.content_encoding)! == 'zstd' + + // Verify that .zst file was NOT created (readonly filesystem) + zst_path := '${readonly_file}.zst' + assert !os.exists(zst_path), '.zst cache file should not be created on readonly filesystem' + + // Verify content is valid zstd + decompressed := zstd.decompress(x.body.bytes()) or { + assert false, 'response should be valid zstd even on readonly fs: ${err}' + return + } + assert decompressed.bytestr() == 'This is a readonly file test' +} + +fn test_precompressed_gz_file_served() { + // Test that manually pre-compressed .gz files are always served + // This validates the manual pre-compression workflow (useful with static_compression_max_size = 0) + + // Request the pre-compressed file + mut req := http.new_request(.get, '${localserver}/precompressed.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + // Should serve the manually pre-compressed .gz file + assert x.header.get(.content_encoding)! == 'gzip' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify it's the pre-compressed content + large_content := 'X'.repeat(2000) + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'manual .gz should be valid: ${err}' + return + } + assert decompressed.bytestr() == large_content +} + +fn test_no_auto_compression_with_max_size_zero() { + // Test that static_compression_max_size = 0 disables auto-compression + // but still serves manually pre-compressed .gz files + + // 1. Verify manually pre-compressed .gz files are still served + mut req1 := http.new_request(.get, '${localserver_no_auto}/precompressed.txt', '') + req1.add_header(.accept_encoding, 'gzip') + x := req1.do()! + + assert x.status() == .ok + // Should serve the manually pre-compressed .gz file + assert x.header.get(.content_encoding)! == 'gzip' + assert x.header.get(.vary)! == 'Accept-Encoding' + + large_content := 'X'.repeat(2000) + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'manual .gz should be valid with max_size=0: ${err}' + return + } + assert decompressed.bytestr() == large_content + + // 2. Verify auto-compression is disabled for files without .gz + mut req2 := http.new_request(.get, '${localserver_no_auto}/no_auto.txt', '') + req2.add_header(.accept_encoding, 'gzip') + y := req2.do()! + + assert y.status() == .ok + // Should NOT have content-encoding header (no auto-compression) + _ := y.header.get(.content_encoding) or { + // Expected: no content-encoding header + assert y.body == 'This file should not be auto-compressed' + return + } + assert false, 'should not auto-compress with static_compression_max_size = 0' + + // 3. Verify that .gz file was NOT created + gz_path := 'testdata_compression/no_auto.txt.gz' + assert !os.exists(gz_path), '.gz cache file should not be created with max_size = 0' +} + +// Zstd tests + +fn test_zstd_preferred_over_gzip() { + // When client supports both zstd and gzip, zstd should be preferred + mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '') + req.add_header(.accept_encoding, 'gzip, zstd, br') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'zstd', 'zstd should be preferred over gzip' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify the body is valid zstd + decompressed := zstd.decompress(x.body.bytes()) or { + assert false, 'failed to decompress zstd response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_zst_file_cache_creation() { + // First request should create .zst cache file + mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '') + req.add_header(.accept_encoding, 'zstd') + _ := req.do()! + + // Check that .zst file was created + zst_path := 'testdata_compression/zstd_test.txt.zst' + assert os.exists(zst_path), '.zst cache file should be created' + + // Second request should use cached .zst file + y := req.do()! + assert y.status() == .ok + assert y.header.get(.content_encoding)! == 'zstd' + + // Verify Content-Length matches .zst file size + zst_file_size := os.file_size(zst_path) + content_length := y.header.get(.content_length)!.u64() + assert content_length == zst_file_size, 'Content-Length should match .zst file size' +} + +fn test_precompressed_zst_file_served() { + // Test that manually pre-compressed .zst files are served + mut req := http.new_request(.get, '${localserver}/precompressed_zstd.txt', '') + req.add_header(.accept_encoding, 'zstd') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'zstd' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify it's the pre-compressed content + large_content := 'X'.repeat(2000) + decompressed := zstd.decompress(x.body.bytes()) or { + assert false, 'manual .zst should be valid: ${err}' + return + } + assert decompressed.bytestr() == large_content +} + +fn test_gzip_fallback_when_zstd_not_supported() { + // When client only supports gzip, gzip should be used + mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'gzip', 'should fallback to gzip when zstd not supported' + + // Verify the body is valid gzip + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'failed to decompress gzip response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +// Tests for enable_static_gzip only (backward compatibility) + +fn test_gzip_only_serves_gzip() { + // Test that enable_static_gzip alone works (backward compatibility) + mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '') + req.add_header(.accept_encoding, 'gzip') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'gzip', 'gzip-only mode should serve gzip' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify the body is valid gzip + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'failed to decompress gzip response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_gzip_only_ignores_zstd_request() { + // Test that enable_static_gzip does NOT serve zstd even if client supports it + mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '') + req.add_header(.accept_encoding, 'zstd, gzip') + x := req.do()! + + assert x.status() == .ok + // Should serve gzip, NOT zstd (because only enable_static_gzip is set) + assert x.header.get(.content_encoding)! == 'gzip', 'gzip-only mode should serve gzip even when client supports zstd' + + decompressed := gzip.decompress(x.body.bytes()) or { + assert false, 'failed to decompress gzip response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_gzip_only_no_compression_without_gzip_header() { + // Test that enable_static_gzip does not compress when client doesn't accept gzip + mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '') + req.add_header(.accept_encoding, 'zstd') // Only zstd, no gzip + x := req.do()! + + assert x.status() == .ok + // Should not have content-encoding header (no compression) + _ := x.header.get(.content_encoding) or { + // Expected: no content-encoding header + assert x.body == test_file_content + return + } + assert false, 'gzip-only mode should not compress when client only accepts zstd' +} + +// Tests for enable_static_zstd only + +fn test_zstd_only_serves_zstd() { + // Test that enable_static_zstd alone works + mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '') + req.add_header(.accept_encoding, 'zstd') + x := req.do()! + + assert x.status() == .ok + assert x.header.get(.content_encoding)! == 'zstd', 'zstd-only mode should serve zstd' + assert x.header.get(.vary)! == 'Accept-Encoding' + + // Verify the body is valid zstd + decompressed := zstd.decompress(x.body.bytes()) or { + assert false, 'failed to decompress zstd response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_zstd_only_ignores_gzip_request() { + // Test that enable_static_zstd does not serve gzip even if client supports it + mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '') + req.add_header(.accept_encoding, 'gzip, zstd') + x := req.do()! + + assert x.status() == .ok + // Should serve zstd, not gzip (because only enable_static_zstd is set) + assert x.header.get(.content_encoding)! == 'zstd', 'zstd-only mode should serve zstd even when client supports gzip' + + decompressed := zstd.decompress(x.body.bytes()) or { + assert false, 'failed to decompress zstd response: ${err}' + return + } + assert decompressed.bytestr() == test_file_content +} + +fn test_zstd_only_no_compression_without_zstd_header() { + // Test that enable_static_zstd does not compress when client doesn't accept zstd + mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '') + req.add_header(.accept_encoding, 'gzip') // Only gzip, no zstd + x := req.do()! + + assert x.status() == .ok + // Should not have content-encoding header (no compression) + _ := x.header.get(.content_encoding) or { + // Expected: no content-encoding header + assert x.body == test_file_content + return + } + assert false, 'zstd-only mode should not compress when client only accepts gzip' +} diff --git a/vlib/veb/tests/static_gzip_test.v b/vlib/veb/tests/static_gzip_test.v deleted file mode 100644 index 92e8891e6..000000000 --- a/vlib/veb/tests/static_gzip_test.v +++ /dev/null @@ -1,296 +0,0 @@ -// vtest build: !docker-ubuntu-musl // failing assert static_gzip_test.v:224 -> `.gz cache file should not be created on readonly filesystem`, because of the Docker container -import veb -import net.http -import os -import time -import compress.gzip - -const port = 14013 -const port_no_auto = 14014 // Port for static_gzip_max_size = 0 test - -const localserver = 'http://127.0.0.1:${port}' -const localserver_no_auto = 'http://127.0.0.1:${port_no_auto}' - -const exit_after = time.second * 30 - -const test_file_content = 'This is a test file for gzip compression. It contains enough text to make compression worthwhile. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' - -pub struct App { - veb.StaticHandler - veb.Middleware[Context] -mut: - started chan bool -} - -pub fn (mut app App) before_accept_loop() { - app.started <- true -} - -pub fn (mut app App) index(mut ctx Context) veb.Result { - return ctx.text('Hello V!') -} - -pub struct Context { - veb.Context -} - -fn testsuite_begin() { - os.chdir(os.dir(@FILE))! - - // Create test directory and files - os.mkdir_all('testdata_gzip')! - os.write_file('testdata_gzip/test.txt', test_file_content)! - os.write_file('testdata_gzip/large.txt', test_file_content.repeat(100))! - - // Create readonly directory and file for readonly filesystem test - os.mkdir_all('testdata_gzip/readonly')! - os.write_file('testdata_gzip/readonly/readonly.txt', 'This is a readonly file test')! - - // Create pre-compressed file for manual .gz test - large_content := 'X'.repeat(2000) - os.write_file('testdata_gzip/precompressed.txt', large_content)! - compressed := gzip.compress(large_content.bytes()) or { panic(err) } - os.write_file('testdata_gzip/precompressed.txt.gz', compressed.bytestr())! - - // Create file for testing max_size = 0 (no auto-compression) - os.write_file('testdata_gzip/no_auto.txt', 'This file should not be auto-compressed')! - - spawn fn () { - time.sleep(exit_after) - assert true == false, 'timeout reached!' - exit(1) - }() - - run_app_test() - run_no_auto_compression_test() -} - -fn testsuite_end() { - // Clean up test files - os.rmdir_all('testdata_gzip') or {} -} - -fn run_app_test() { - mut app := &App{} - - // Enable static gzip compression - app.enable_static_gzip = true - app.static_gzip_max_size = 1048576 // 1MB - - app.handle_static('testdata_gzip', true) or { panic(err) } - - // Add gzip middleware - app.use(veb.encode_gzip[Context]()) - - spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 25, family: .ip) - _ := <-app.started -} - -fn run_no_auto_compression_test() { - mut app := &App{} - - // Enable static gzip but disable auto-compression (max_size = 0) - app.enable_static_gzip = true - app.static_gzip_max_size = 0 // Disable auto-compression - - app.handle_static('testdata_gzip', true) or { panic(err) } - - // Add gzip middleware - app.use(veb.encode_gzip[Context]()) - - spawn veb.run_at[App, Context](mut app, - port: port_no_auto - timeout_in_seconds: 25 - family: .ip - ) - _ := <-app.started -} - -fn test_gzip_compression_with_accept_encoding() { - // Request with Accept-Encoding: gzip - mut req := http.new_request(.get, '${localserver}/test.txt', '') - req.add_header(.accept_encoding, 'gzip') - x := req.do()! - - assert x.status() == .ok - assert x.header.get(.content_encoding)! == 'gzip' - assert x.header.get(.vary)! == 'Accept-Encoding' - - // Verify Content-Length header matches actual body size - content_length := x.header.get(.content_length)!.int() - assert content_length == x.body.len, 'Content-Length should match actual body size' - - // Verify the body is compressed - decompressed := gzip.decompress(x.body.bytes()) or { - assert false, 'failed to decompress response: ${err}' - return - } - assert decompressed.bytestr() == test_file_content -} - -fn test_no_compression_without_accept_encoding() { - // Request without Accept-Encoding header - x := http.get('${localserver}/test.txt')! - - assert x.status() == .ok - // Should not have content-encoding header when client doesn't accept gzip - _ := x.header.get(.content_encoding) or { - // Expected: no content-encoding header - assert x.body == test_file_content - return - } - assert false, 'should not compress without Accept-Encoding: gzip' -} - -fn test_gz_file_cache_creation() { - // First request creates .gz cache file - mut req := http.new_request(.get, '${localserver}/test.txt', '') - req.add_header(.accept_encoding, 'gzip') - _ := req.do()! - - // Check that .gz file was created - gz_path := 'testdata_gzip/test.txt.gz' - assert os.exists(gz_path), '.gz cache file should be created' - - // Second request should use cached .gz file - y := req.do()! - assert y.status() == .ok - assert y.header.get(.content_encoding)! == 'gzip' - - // Verify Content-Length matches .gz file size (tests os.file_size() code path) - gz_file_size := os.file_size(gz_path) - content_length := y.header.get(.content_length)!.u64() - assert content_length == gz_file_size, 'Content-Length should match .gz file size' -} - -fn test_large_file_not_auto_compressed() { - // Configure app with very small max size to test threshold - // The large.txt file is ~20KB (200 chars * 100), which exceeds a 1KB threshold - // But we set it to 1MB, so it should still be compressed - // Let's test by checking if it gets compressed - - mut req := http.new_request(.get, '${localserver}/large.txt', '') - req.add_header(.accept_encoding, 'gzip') - x := req.do()! - - assert x.status() == .ok - // File should be compressed as it's under 1MB threshold - assert x.header.get(.content_encoding)! == 'gzip' -} - -fn test_already_compressed_flag() { - // Request a file that will be compressed and cached - mut req := http.new_request(.get, '${localserver}/test.txt', '') - req.add_header(.accept_encoding, 'gzip') - x := req.do()! - - assert x.status() == .ok - // The file should be compressed only once (in send_file, not by middleware) - // We can't directly test the already_compressed flag, but we can verify - // that the response is valid gzip - decompressed := gzip.decompress(x.body.bytes()) or { - assert false, 'response should be valid gzip: ${err}' - return - } - assert decompressed.bytestr() == test_file_content -} - -fn test_readonly_filesystem_fallback() { - // Test that compression works even on readonly filesystems (fallback to memory) - // Skip on Windows as readonly permissions work differently (ACL vs chmod) - $if windows { - eprintln('Skipping readonly filesystem test on Windows') - return - } - - // Make readonly directory readonly (no write permissions) - readonly_dir := 'testdata_gzip/readonly' - readonly_file := '${readonly_dir}/readonly.txt' - - os.chmod(readonly_dir, 0o555)! // r-xr-xr-x - - mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '') - req.add_header(.accept_encoding, 'gzip') - x := req.do()! - - // Restore permissions before assertions (for cleanup) - os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x - - assert x.status() == .ok - // Should be compressed (served from memory as fallback) - assert x.header.get(.content_encoding)! == 'gzip' - - // Verify that .gz file was NOT created (readonly filesystem) - gz_path := '${readonly_file}.gz' - assert !os.exists(gz_path), '.gz cache file should not be created on readonly filesystem' - - // Verify content is valid gzip - decompressed := gzip.decompress(x.body.bytes()) or { - assert false, 'response should be valid gzip even on readonly fs: ${err}' - return - } - assert decompressed.bytestr() == 'This is a readonly file test' -} - -fn test_precompressed_gz_file_served() { - // Test that manually pre-compressed .gz files are always served - // This validates the manual pre-compression workflow (useful with static_gzip_max_size = 0) - - // Request the pre-compressed file - mut req := http.new_request(.get, '${localserver}/precompressed.txt', '') - req.add_header(.accept_encoding, 'gzip') - x := req.do()! - - assert x.status() == .ok - // Should serve the manually pre-compressed .gz file - assert x.header.get(.content_encoding)! == 'gzip' - assert x.header.get(.vary)! == 'Accept-Encoding' - - // Verify it's the pre-compressed content - large_content := 'X'.repeat(2000) - decompressed := gzip.decompress(x.body.bytes()) or { - assert false, 'manual .gz should be valid: ${err}' - return - } - assert decompressed.bytestr() == large_content -} - -fn test_no_auto_compression_with_max_size_zero() { - // Test that static_gzip_max_size = 0 disables auto-compression - // but still serves manually pre-compressed .gz files - - // 1. Verify manually pre-compressed .gz files are still served - mut req1 := http.new_request(.get, '${localserver_no_auto}/precompressed.txt', '') - req1.add_header(.accept_encoding, 'gzip') - x := req1.do()! - - assert x.status() == .ok - // Should serve the manually pre-compressed .gz file - assert x.header.get(.content_encoding)! == 'gzip' - assert x.header.get(.vary)! == 'Accept-Encoding' - - large_content := 'X'.repeat(2000) - decompressed := gzip.decompress(x.body.bytes()) or { - assert false, 'manual .gz should be valid with max_size=0: ${err}' - return - } - assert decompressed.bytestr() == large_content - - // 2. Verify auto-compression is disabled for files without .gz - mut req2 := http.new_request(.get, '${localserver_no_auto}/no_auto.txt', '') - req2.add_header(.accept_encoding, 'gzip') - y := req2.do()! - - assert y.status() == .ok - // Should NOT have content-encoding header (no auto-compression) - _ := y.header.get(.content_encoding) or { - // Expected: no content-encoding header - assert y.body == 'This file should not be auto-compressed' - return - } - assert false, 'should not auto-compress with static_gzip_max_size = 0' - - // 3. Verify that .gz file was NOT created - gz_path := 'testdata_gzip/no_auto.txt.gz' - assert !os.exists(gz_path), '.gz cache file should not be created with max_size = 0' -} diff --git a/vlib/veb/veb.v b/vlib/veb/veb.v index c342f3549..532fd1d79 100644 --- a/vlib/veb/veb.v +++ b/vlib/veb/veb.v @@ -441,10 +441,12 @@ fn serve_if_static[A, X](app &A, mut user_context X, url urllib.URL, host string return false } - // Configure static file gzip compression settings + // Configure static file compression settings user_context.enable_static_gzip = app.enable_static_gzip - user_context.static_gzip_max_size = if app.static_gzip_max_size >= 0 { - app.static_gzip_max_size + user_context.enable_static_zstd = app.enable_static_zstd + user_context.enable_static_compression = app.enable_static_compression + user_context.static_compression_max_size = if app.static_compression_max_size >= 0 { + app.static_compression_max_size } else { 1048576 // Default: 1MB } -- 2.39.5