| 1 | module veb |
| 2 | |
| 3 | import compress.gzip |
| 4 | import compress.zstd |
| 5 | import hash |
| 6 | import json |
| 7 | import net |
| 8 | import net.http |
| 9 | import os |
| 10 | import time |
| 11 | |
| 12 | enum ContextReturnType { |
| 13 | normal |
| 14 | file |
| 15 | } |
| 16 | |
| 17 | enum ContextTakeoverMode { |
| 18 | none |
| 19 | manual |
| 20 | reusable |
| 21 | } |
| 22 | |
| 23 | pub enum RedirectType { |
| 24 | found = int(http.Status.found) |
| 25 | moved_permanently = int(http.Status.moved_permanently) |
| 26 | see_other = int(http.Status.see_other) |
| 27 | temporary_redirect = int(http.Status.temporary_redirect) |
| 28 | permanent_redirect = int(http.Status.permanent_redirect) |
| 29 | } |
| 30 | |
| 31 | // The Context struct represents the Context which holds the HTTP request and response. |
| 32 | // It has fields for the query, form, files and methods for handling the request and response |
| 33 | @[heap] |
| 34 | pub struct Context { |
| 35 | mut: |
| 36 | // veb will try to infer the content type base on file extension, |
| 37 | // and if `content_type` is not empty the `Content-Type` header will always be |
| 38 | // set to this value |
| 39 | content_type string |
| 40 | // done is set to true when a response can be sent over `conn` |
| 41 | done bool |
| 42 | // If the `Connection: close` header is present the connection should always be closed |
| 43 | client_wants_to_close bool |
| 44 | // Configuration for static file compression (set by serve_if_static) |
| 45 | enable_static_gzip bool |
| 46 | enable_static_zstd bool |
| 47 | enable_static_compression bool |
| 48 | static_compression_max_size int |
| 49 | static_compression_mime_types []string |
| 50 | // controls whether veb should automatically send the response or whether the handler |
| 51 | // takes over response writing. |
| 52 | takeover_mode ContextTakeoverMode |
| 53 | return_file string |
| 54 | custom_mime_types_ref &map[string]string = unsafe { nil } |
| 55 | // raw client file descriptor, used by the fasthttp backend to create a TcpConn |
| 56 | // on demand when takeover_conn() is called |
| 57 | client_fd int = -1 |
| 58 | // already_compressed indicates that the response body is already compressed (zstd/gzip) |
| 59 | // and the compression middlewares should skip it |
| 60 | already_compressed bool |
| 61 | pub: |
| 62 | // TODO: move this to `handle_request` |
| 63 | // time.ticks() from start of veb connection handle. |
| 64 | // You can use it to determine how much time is spent on your request. |
| 65 | page_gen_start i64 |
| 66 | pub mut: |
| 67 | // how the http response should be handled by veb's backend |
| 68 | return_type ContextReturnType = .normal |
| 69 | req http.Request @[skip] |
| 70 | custom_mime_types map[string]string |
| 71 | // TCP connection to client. Only for advanced usage! |
| 72 | conn &net.TcpConn = unsafe { nil } |
| 73 | // Map containing query params for the route. |
| 74 | // http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' } |
| 75 | query map[string]string |
| 76 | // Multipart-form fields. |
| 77 | form map[string]string |
| 78 | // Files from multipart-form. |
| 79 | files map[string][]http.FileData |
| 80 | res http.Response |
| 81 | // use form_error to pass errors from the context to your frontend |
| 82 | form_error string |
| 83 | livereload_poll_interval_ms int = 250 |
| 84 | } |
| 85 | |
| 86 | // returns the request header data from the key |
| 87 | pub fn (ctx &Context) get_header(key http.CommonHeader) !string { |
| 88 | return ctx.req.header.get(key)! |
| 89 | } |
| 90 | |
| 91 | // returns the request header data from the key |
| 92 | pub fn (ctx &Context) get_custom_header(key string) !string { |
| 93 | return ctx.req.header.get_custom(key)! |
| 94 | } |
| 95 | |
| 96 | // set a header on the response object |
| 97 | pub fn (mut ctx Context) set_header(key http.CommonHeader, value string) { |
| 98 | ctx.res.header.set(key, value) |
| 99 | } |
| 100 | |
| 101 | // set a custom header on the response object |
| 102 | pub fn (mut ctx Context) set_custom_header(key string, value string) ! { |
| 103 | ctx.res.header.set_custom(key, value)! |
| 104 | } |
| 105 | |
| 106 | // send_response_to_client finalizes the response headers and sets Content-Type to `mimetype` |
| 107 | // and the response body to `response` |
| 108 | pub fn (mut ctx Context) send_response_to_client(mimetype string, response string) Result { |
| 109 | if ctx.done && ctx.takeover_mode == .none { |
| 110 | eprintln('[veb] a response cannot be sent twice over one connection') |
| 111 | return Result{} |
| 112 | } |
| 113 | // ctx.done is only set in this function, so in order to sent a response over the connection |
| 114 | // this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly. |
| 115 | ctx.done = true |
| 116 | if ctx.res.body.len > 0 { |
| 117 | unsafe { ctx.res.body.free() } |
| 118 | ctx.res.body = '' |
| 119 | } |
| 120 | $if veb_livereload ? { |
| 121 | if mimetype == 'text/html' { |
| 122 | ctx.res.body = response.replace('</html>', |
| 123 | '<script src="/veb_livereload/${veb_livereload_server_start}/script.js"></script>\n</html>') |
| 124 | } else { |
| 125 | ctx.res.body = response.clone() |
| 126 | } |
| 127 | } $else { |
| 128 | ctx.res.body = response.clone() |
| 129 | } |
| 130 | // Prefer explicit overrides from Context state or a pre-set response header. |
| 131 | mut custom_mimetype := ctx.content_type |
| 132 | if custom_mimetype.len == 0 { |
| 133 | custom_mimetype = ctx.res.header.get(.content_type) or { mimetype } |
| 134 | } |
| 135 | if custom_mimetype != '' { |
| 136 | ctx.res.header.set(.content_type, custom_mimetype) |
| 137 | } |
| 138 | if !ctx.res.header.contains(.content_length) { |
| 139 | ctx.res.header.set(.content_length, ctx.res.body.len.str()) |
| 140 | } |
| 141 | // send veb's closing headers |
| 142 | ctx.res.header.set(.server, 'veb') |
| 143 | if ctx.takeover_mode == .none && ctx.client_wants_to_close { |
| 144 | // Only sent the `Connection: close` header when the client wants to close |
| 145 | // the connection. This typically happens when the client only supports HTTP 1.0 |
| 146 | ctx.res.header.set(.connection, 'close') |
| 147 | } |
| 148 | // set the http version |
| 149 | ctx.res.set_version(.v1_1) |
| 150 | if ctx.res.status_code == 0 { |
| 151 | ctx.res.set_status(.ok) |
| 152 | } |
| 153 | if ctx.takeover_mode != .none && ctx.conn != unsafe { nil } { |
| 154 | fast_send_resp(mut ctx.conn, ctx.res) or {} |
| 155 | unsafe { ctx.res.body.free() } |
| 156 | ctx.res.body = '' |
| 157 | } |
| 158 | // result is send in `veb.v`, `handle_route` |
| 159 | return Result{} |
| 160 | } |
| 161 | |
| 162 | // Response with payload and content-type `text/html` |
| 163 | pub fn (mut ctx Context) html(s string) Result { |
| 164 | return ctx.send_response_to_client('text/html', s) |
| 165 | } |
| 166 | |
| 167 | // Response with `s` as payload and content-type `text/plain` |
| 168 | pub fn (mut ctx Context) text(s string) Result { |
| 169 | return ctx.send_response_to_client('text/plain', s) |
| 170 | } |
| 171 | |
| 172 | fn (mut ctx Context) set_static_compression_config(enable_gzip bool, enable_zstd bool, enable_compression bool, max_size int, mime_types []string) { |
| 173 | ctx.enable_static_gzip = enable_gzip |
| 174 | ctx.enable_static_zstd = enable_zstd |
| 175 | ctx.enable_static_compression = enable_compression |
| 176 | ctx.static_compression_max_size = max_size |
| 177 | ctx.static_compression_mime_types = mime_types |
| 178 | } |
| 179 | |
| 180 | // Response with json_s as payload and content-type `application/json` |
| 181 | pub fn (mut ctx Context) json[T](j T) Result { |
| 182 | json_s := json.encode(j) |
| 183 | return ctx.send_response_to_client('application/json', json_s) |
| 184 | } |
| 185 | |
| 186 | // Response with a pretty-printed JSON result |
| 187 | pub fn (mut ctx Context) json_pretty[T](j T) Result { |
| 188 | json_s := json.encode_pretty(j) |
| 189 | return ctx.send_response_to_client('application/json', json_s) |
| 190 | } |
| 191 | |
| 192 | // Response HTTP_OK with file as payload |
| 193 | fn (ctx &Context) custom_mime_type(ext string) ?string { |
| 194 | if ct := ctx.custom_mime_types[ext] { |
| 195 | return ct |
| 196 | } |
| 197 | if unsafe { ctx.custom_mime_types_ref != nil } { |
| 198 | custom_mime_types := unsafe { *ctx.custom_mime_types_ref } |
| 199 | return custom_mime_types[ext] |
| 200 | } |
| 201 | return none |
| 202 | } |
| 203 | |
| 204 | pub fn (mut ctx Context) file(file_path string) Result { |
| 205 | if !os.exists(file_path) { |
| 206 | eprintln('[veb] file "${file_path}" does not exist') |
| 207 | return ctx.not_found() |
| 208 | } |
| 209 | ext := os.file_ext(file_path) |
| 210 | mut content_type := ctx.content_type |
| 211 | if content_type.len == 0 { |
| 212 | if ct := ctx.custom_mime_type(ext) { |
| 213 | content_type = ct |
| 214 | } else { |
| 215 | content_type = mime_types[ext] |
| 216 | } |
| 217 | } |
| 218 | if content_type.len == 0 { |
| 219 | eprintln('[veb] no MIME type found for extension "${ext}"') |
| 220 | return ctx.server_error('') |
| 221 | } |
| 222 | return ctx.send_file(content_type, file_path) |
| 223 | } |
| 224 | |
| 225 | fn (mut ctx Context) send_file(content_type string, file_path string) Result { |
| 226 | mut file := os.open(file_path) or { |
| 227 | eprint('[veb] error while trying to open file: ${err.msg()}') |
| 228 | ctx.res.set_status(.not_found) |
| 229 | return ctx.text('resource does not exist') |
| 230 | } |
| 231 | // seek from file end to get the file size |
| 232 | file.seek(0, .end) or { |
| 233 | eprintln('[veb] error while trying to read file: ${err.msg()}') |
| 234 | return ctx.server_error('could not read resource') |
| 235 | } |
| 236 | file_size := file.tell() or { |
| 237 | eprintln('[veb] error while trying to read file: ${err.msg()}') |
| 238 | return ctx.server_error('could not read resource') |
| 239 | } |
| 240 | file.close() |
| 241 | // Check which encodings the client accepts |
| 242 | accept_encoding := ctx.req.header.get(.accept_encoding) or { '' } |
| 243 | client_accepts_zstd := accept_encoding.contains('zstd') |
| 244 | client_accepts_gzip := accept_encoding.contains('gzip') |
| 245 | max_size_bytes := ctx.static_compression_max_size |
| 246 | should_use_static_compression := ctx.should_use_static_compression_for_mime(content_type) |
| 247 | // Determine which compression modes are enabled |
| 248 | use_zstd := should_use_static_compression && ((ctx.enable_static_zstd && client_accepts_zstd) |
| 249 | || (ctx.enable_static_compression && client_accepts_zstd)) |
| 250 | use_gzip := should_use_static_compression && ((ctx.enable_static_gzip && client_accepts_gzip) |
| 251 | || (ctx.enable_static_compression && client_accepts_gzip)) |
| 252 | // Try to serve pre-compressed files if any compression is enabled |
| 253 | if use_zstd || use_gzip { |
| 254 | orig_mtime := os.file_last_mod_unix(file_path) |
| 255 | // Try zstd first if enabled (better compression), then gzip |
| 256 | if use_zstd { |
| 257 | if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) { |
| 258 | return Result{} |
| 259 | } |
| 260 | } |
| 261 | if use_gzip { |
| 262 | if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) { |
| 263 | return Result{} |
| 264 | } |
| 265 | } |
| 266 | // No pre-compressed file available: create one if file is small enough |
| 267 | if file_size < max_size_bytes { |
| 268 | // Load, compress, save, and serve |
| 269 | data := os.read_file(file_path) or { |
| 270 | eprintln('[veb] error while trying to read file: ${err.msg()}') |
| 271 | return ctx.server_error('could not read resource') |
| 272 | } |
| 273 | // Try zstd first if enabled, then gzip |
| 274 | if use_zstd { |
| 275 | if result := ctx.serve_compressed_static(content_type, file_path, data, .zstd) { |
| 276 | return result |
| 277 | } |
| 278 | } |
| 279 | if use_gzip { |
| 280 | if result := ctx.serve_compressed_static(content_type, file_path, data, .gzip) { |
| 281 | return result |
| 282 | } |
| 283 | } |
| 284 | // Compression failed: serve uncompressed in streaming mode |
| 285 | ctx.return_type = .file |
| 286 | ctx.return_file = file_path |
| 287 | ctx.res.header.set(.content_length, file_size.str()) |
| 288 | return ctx.send_response_to_client(content_type, '') |
| 289 | } |
| 290 | } |
| 291 | // Takeover mode: load file in memory (backward compatibility) |
| 292 | if ctx.takeover_mode != .none { |
| 293 | data := os.read_file(file_path) or { |
| 294 | eprintln('[veb] error while trying to read file: ${err.msg()}') |
| 295 | return ctx.server_error('could not read resource') |
| 296 | } |
| 297 | return ctx.send_response_to_client(content_type, data) |
| 298 | } |
| 299 | // Default: serve uncompressed file in streaming mode (zero-copy sendfile) |
| 300 | ctx.return_type = .file |
| 301 | ctx.return_file = file_path |
| 302 | ctx.res.header.set(.content_length, file_size.str()) |
| 303 | return ctx.send_response_to_client(content_type, '') |
| 304 | } |
| 305 | |
| 306 | fn normalize_static_compression_mime_type(mime_type string) string { |
| 307 | return mime_type.all_before(';').trim_space().to_lower() |
| 308 | } |
| 309 | |
| 310 | fn (ctx &Context) should_use_static_compression_for_mime(content_type string) bool { |
| 311 | if ctx.static_compression_mime_types.len == 0 { |
| 312 | return true |
| 313 | } |
| 314 | normalized_content_type := normalize_static_compression_mime_type(content_type) |
| 315 | if normalized_content_type == '' { |
| 316 | return false |
| 317 | } |
| 318 | for mime_type in ctx.static_compression_mime_types { |
| 319 | if normalize_static_compression_mime_type(mime_type) == normalized_content_type { |
| 320 | return true |
| 321 | } |
| 322 | } |
| 323 | return false |
| 324 | } |
| 325 | |
| 326 | fn sanitize_cache_path_component(component string) string { |
| 327 | mut sanitized := component.trim_space() |
| 328 | if sanitized == '' { |
| 329 | return 'unknown' |
| 330 | } |
| 331 | for invalid_char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] { |
| 332 | sanitized = sanitized.replace(invalid_char, '_') |
| 333 | } |
| 334 | return sanitized |
| 335 | } |
| 336 | |
| 337 | fn static_compression_cache_path(file_path string, ext string) string { |
| 338 | real_file_path := os.real_path(file_path) |
| 339 | path_hash := hash.sum64_string(real_file_path, 0).hex_full() |
| 340 | app_dir_name := sanitize_cache_path_component(os.base(os.getwd())) |
| 341 | static_dir_name := sanitize_cache_path_component(os.base(os.dir(real_file_path))) |
| 342 | file_name := sanitize_cache_path_component(os.file_name(real_file_path)) |
| 343 | return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name, static_dir_name, |
| 344 | '${file_name}.${path_hash}${ext}') |
| 345 | } |
| 346 | |
| 347 | fn (mut ctx Context) serve_compressed_path_if_fresh(content_type string, compressed_path string, encoding_name string, orig_mtime i64) bool { |
| 348 | if !os.exists(compressed_path) { |
| 349 | return false |
| 350 | } |
| 351 | compressed_mtime := os.file_last_mod_unix(compressed_path) |
| 352 | if compressed_mtime < orig_mtime { |
| 353 | return false |
| 354 | } |
| 355 | // Serve existing compressed file in streaming mode (zero-copy) |
| 356 | ctx.return_type = .file |
| 357 | ctx.return_file = compressed_path |
| 358 | ctx.res.header.set(.content_encoding, encoding_name) |
| 359 | ctx.res.header.set(.vary, 'Accept-Encoding') |
| 360 | compressed_size := os.file_size(compressed_path) |
| 361 | ctx.res.header.set(.content_length, compressed_size.str()) |
| 362 | ctx.already_compressed = true |
| 363 | ctx.send_response_to_client(content_type, '') |
| 364 | return true |
| 365 | } |
| 366 | |
| 367 | // serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh. |
| 368 | // Returns true if the file was served, false otherwise. |
| 369 | fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool { |
| 370 | // First prefer manually pre-compressed files beside the original static file. |
| 371 | side_by_side_path := '${file_path}${ext}' |
| 372 | if ctx.serve_compressed_path_if_fresh(content_type, side_by_side_path, encoding_name, |
| 373 | orig_mtime) |
| 374 | { |
| 375 | return true |
| 376 | } |
| 377 | // Then try veb-managed cache files under os.cache_dir(). |
| 378 | cached_path := static_compression_cache_path(file_path, ext) |
| 379 | return ctx.serve_compressed_path_if_fresh(content_type, cached_path, encoding_name, orig_mtime) |
| 380 | } |
| 381 | |
| 382 | // serve_compressed_static compresses data and serves it, optionally caching to disk. |
| 383 | // Returns Result on success, none on compression failure. |
| 384 | fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result { |
| 385 | compressed, ext, encoding_name := match encoding { |
| 386 | .zstd { |
| 387 | c := zstd.compress(data.bytes()) or { return none } |
| 388 | c, '.zst', 'zstd' |
| 389 | } |
| 390 | .gzip { |
| 391 | c := gzip.compress(data.bytes()) or { return none } |
| 392 | c, '.gz', 'gzip' |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | compressed_path := static_compression_cache_path(file_path, ext) |
| 397 | // Try to save compressed version for future requests |
| 398 | mut write_success := true |
| 399 | cache_dir := os.dir(compressed_path) |
| 400 | os.mkdir_all(cache_dir) or { |
| 401 | eprintln('[veb] warning: could not create static compression cache dir `${cache_dir}`: ${err.msg()}') |
| 402 | write_success = false |
| 403 | } |
| 404 | if write_success { |
| 405 | os.write_file(compressed_path, compressed.bytestr()) or { |
| 406 | eprintln('[veb] warning: could not save ${ext} file in static compression cache: ${err.msg()}') |
| 407 | write_success = false |
| 408 | } |
| 409 | } |
| 410 | ctx.already_compressed = true |
| 411 | if write_success { |
| 412 | // Serve the newly cached file in streaming mode (zero-copy) |
| 413 | ctx.return_type = .file |
| 414 | ctx.return_file = compressed_path |
| 415 | ctx.res.header.set(.content_encoding, encoding_name) |
| 416 | ctx.res.header.set(.vary, 'Accept-Encoding') |
| 417 | ctx.res.header.set(.content_length, compressed.len.str()) |
| 418 | return ctx.send_response_to_client(content_type, '') |
| 419 | } else { |
| 420 | // Fallback: serve compressed content from memory (no caching) |
| 421 | ctx.res.header.set(.content_encoding, encoding_name) |
| 422 | ctx.res.header.set(.vary, 'Accept-Encoding') |
| 423 | return ctx.send_response_to_client(content_type, compressed.bytestr()) |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | // Response HTTP_OK with s as payload |
| 428 | pub fn (mut ctx Context) ok(s string) Result { |
| 429 | mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type } |
| 430 | return ctx.send_response_to_client(mime, s) |
| 431 | } |
| 432 | |
| 433 | // send an error 400 with a message |
| 434 | pub fn (mut ctx Context) request_error(msg string) Result { |
| 435 | ctx.res.set_status(.bad_request) |
| 436 | return ctx.send_response_to_client('text/plain', msg) |
| 437 | } |
| 438 | |
| 439 | // send an error 500 with a message |
| 440 | pub fn (mut ctx Context) server_error(msg string) Result { |
| 441 | ctx.res.set_status(.internal_server_error) |
| 442 | return ctx.send_response_to_client('text/plain', msg) |
| 443 | } |
| 444 | |
| 445 | // send an error with a custom status |
| 446 | pub fn (mut ctx Context) server_error_with_status(s http.Status) Result { |
| 447 | ctx.res.set_status(s) |
| 448 | return ctx.send_response_to_client('text/plain', 'Server error') |
| 449 | } |
| 450 | |
| 451 | // send a 204 No Content response without body and content-type |
| 452 | pub fn (mut ctx Context) no_content() Result { |
| 453 | ctx.res.set_status(.no_content) |
| 454 | return ctx.send_response_to_client('', '') |
| 455 | } |
| 456 | |
| 457 | @[params] |
| 458 | pub struct RedirectParams { |
| 459 | pub: |
| 460 | typ RedirectType |
| 461 | } |
| 462 | |
| 463 | // Redirect to an url |
| 464 | pub fn (mut ctx Context) redirect(url string, params RedirectParams) Result { |
| 465 | status := http.Status(params.typ) |
| 466 | ctx.res.set_status(status) |
| 467 | ctx.res.header.add(.location, url) |
| 468 | return ctx.send_response_to_client('text/plain', status.str()) |
| 469 | } |
| 470 | |
| 471 | // before_request is *always* the first function that is executed. |
| 472 | // It can be overriden on your custom context. You can use it to |
| 473 | // log information about the current request, like its target url, |
| 474 | // client IP etc, or to enrich the request with common information, |
| 475 | // by setting session cookies, adding custom headers etc. |
| 476 | // For more control over the request, use the explicit Middleware |
| 477 | // support described in https://modules.vlang.io/veb.html#middleware . |
| 478 | pub fn (mut ctx Context) before_request() { |
| 479 | } |
| 480 | |
| 481 | // returns a HTTP 404 response |
| 482 | pub fn (mut ctx Context) not_found() Result { |
| 483 | ctx.res.set_status(.not_found) |
| 484 | return ctx.send_response_to_client('text/plain', '404 Not Found') |
| 485 | } |
| 486 | |
| 487 | // Gets a cookie by a key |
| 488 | pub fn (ctx &Context) get_cookie(key string) ?string { |
| 489 | if cookie := ctx.req.cookie(key) { |
| 490 | return cookie.value |
| 491 | } else { |
| 492 | return none |
| 493 | } |
| 494 | } |
| 495 | |
| 496 | // Sets a cookie |
| 497 | pub fn (mut ctx Context) set_cookie(cookie http.Cookie) { |
| 498 | cookie_raw := cookie.str() |
| 499 | if cookie_raw == '' { |
| 500 | eprintln('[veb] error setting cookie: name of cookie is invalid.\n${cookie}') |
| 501 | return |
| 502 | } |
| 503 | ctx.res.header.add(.set_cookie, cookie_raw) |
| 504 | } |
| 505 | |
| 506 | // set_content_type sets the Content-Type header to `mime` |
| 507 | pub fn (mut ctx Context) set_content_type(mime string) { |
| 508 | ctx.content_type = mime |
| 509 | } |
| 510 | |
| 511 | // takeover_conn prevents veb from automatically sending a response and closing |
| 512 | // the connection. You are responsible for closing the connection. |
| 513 | // In takeover mode if you call a Context method the response will be directly |
| 514 | // send over the connection and you can send multiple responses. |
| 515 | // This function is useful when you want to keep the connection alive and/or |
| 516 | // send multiple responses. Like with the SSE. |
| 517 | pub fn (mut ctx Context) takeover_conn() { |
| 518 | ctx.takeover_mode = .manual |
| 519 | ctx.prepare_takeover_conn() |
| 520 | } |
| 521 | |
| 522 | fn (mut ctx Context) prepare_takeover_conn() { |
| 523 | if ctx.conn == unsafe { nil } && ctx.client_fd >= 0 { |
| 524 | // For the fasthttp backend: create a TcpConn from the raw fd on demand. |
| 525 | // Set the fd to blocking mode. fasthttp uses non-blocking sockets, |
| 526 | // but TcpConn.write() expects blocking behavior for reliable writes. |
| 527 | $if !windows { |
| 528 | flags := C.fcntl(ctx.client_fd, C.F_GETFL, 0) |
| 529 | if flags != -1 { |
| 530 | C.fcntl(ctx.client_fd, C.F_SETFL, flags & ~C.O_NONBLOCK) |
| 531 | } |
| 532 | } |
| 533 | ctx.conn = &net.TcpConn{ |
| 534 | sock: net.TcpSocket{ |
| 535 | Socket: net.Socket{ |
| 536 | handle: ctx.client_fd |
| 537 | } |
| 538 | } |
| 539 | handle: ctx.client_fd |
| 540 | is_blocking: true |
| 541 | read_timeout: 30 * time.second |
| 542 | write_timeout: 30 * time.second |
| 543 | } |
| 544 | } else if ctx.conn != unsafe { nil } { |
| 545 | // The connection exists but uses non-blocking I/O. |
| 546 | // Switch to blocking mode for reliable SSE writes. |
| 547 | fd := ctx.conn.handle |
| 548 | $if !windows { |
| 549 | flags := C.fcntl(fd, C.F_GETFL, 0) |
| 550 | if flags != -1 { |
| 551 | C.fcntl(fd, C.F_SETFL, flags & ~C.O_NONBLOCK) |
| 552 | } |
| 553 | } |
| 554 | ctx.conn.is_blocking = true |
| 555 | ctx.conn.set_read_timeout(30 * time.second) |
| 556 | ctx.conn.set_write_timeout(30 * time.second) |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | // takeover_conn_reusable prevents veb from automatically sending a response, |
| 561 | // but lets veb keep the connection in the read loop after the handler returns. |
| 562 | // The handler must write exactly one complete HTTP response with a clear body |
| 563 | // boundary, such as Content-Length or a final chunk for Transfer-Encoding: |
| 564 | // chunked. If the client asked to close the connection, veb will still close it. |
| 565 | pub fn (mut ctx Context) takeover_conn_reusable() { |
| 566 | ctx.takeover_mode = .reusable |
| 567 | ctx.prepare_takeover_conn() |
| 568 | } |
| 569 | |
| 570 | // user_agent returns the user-agent header for the current client |
| 571 | pub fn (ctx &Context) user_agent() string { |
| 572 | return ctx.req.header.get(.user_agent) or { '' } |
| 573 | } |
| 574 | |
| 575 | // Returns the ip address from the current user |
| 576 | pub fn (ctx &Context) ip() string { |
| 577 | mut ip := ctx.req.header.get_custom('CF-Connecting-IP') or { '' } |
| 578 | if ip == '' { |
| 579 | ip = ctx.req.header.get(.x_forwarded_for) or { '' } |
| 580 | } |
| 581 | if ip == '' { |
| 582 | ip = ctx.req.header.get_custom('X-Forwarded-For') or { '' } |
| 583 | } |
| 584 | if ip == '' { |
| 585 | ip = ctx.req.header.get_custom('X-Real-Ip') or { '' } |
| 586 | } |
| 587 | if ip.contains(',') { |
| 588 | ip = ip.all_before(',') |
| 589 | } |
| 590 | if ip == '' && ctx.conn != unsafe { nil } { |
| 591 | ip = ctx.conn.peer_ip() or { '' } |
| 592 | } |
| 593 | return ip |
| 594 | } |
| 595 | |
| 596 | // time_to_render returns the time in milliseconds that it took to render the page |
| 597 | pub fn (ctx &Context) time_to_render() i64 { |
| 598 | if ctx.page_gen_start == 0 { |
| 599 | return 0 |
| 600 | } |
| 601 | return time.ticks() - ctx.page_gen_start |
| 602 | } |
| 603 | |