11module veb
22
33import compress.gzip
4+ import compress.zstd
45import json
56import net
67import net.http
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
4548pub :
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
305357pub 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