An OCaml webserver, but the allocating version (vs httpz which doesnt)
at main 427 lines 16 kB view raw
1(* server.ml - Eio static file server using httpzo *) 2 3open Eio.Std 4 5(* Response buffer size - 64KB for headers *) 6let response_buffer_size = 65536 7 8type config = { 9 port : int; 10 root : Eio.Fs.dir_ty Eio.Path.t; 11 limits : Buf_read.limits; 12} 13 14let default_config ~fs = { 15 port = 8080; 16 root = fs; 17 limits = Buf_read.default_limits; 18} 19 20(* Connection state - generic over flow type for accept_fork compatibility *) 21type 'a conn_state = 22 { flow : 'a 23 ; read_buf : Base_bigstring.t 24 ; write_buf : Base_bigstring.t 25 ; mutable read_len : int 26 ; mutable keep_alive : bool 27 (* Reusable arrays for range parsing *) 28 ; ranges : Range.byte_range array 29 ; resolved : Range.resolved array 30 } 31 32(* Create connection state *) 33let create_conn flow = 34 { flow 35 ; read_buf = Buf_read.create () 36 ; write_buf = 37 Bigarray.Array1.create Bigarray.char Bigarray.c_layout response_buffer_size 38 ; read_len = 0 39 ; keep_alive = true 40 ; ranges = Array.make Range.max_ranges Range.empty 41 ; resolved = Array.make Range.max_ranges Range.empty_resolved 42 } 43 44(* MIME type detection using magic-mime *) 45let mime_type_of_path path = Magic_mime.lookup path 46 47(* Generate weak ETag from file stats: W/"mtime-size" *) 48let generate_etag ~mtime ~size = 49 Printf.sprintf "W/\"%x-%Lx\"" (int_of_float (mtime *. 1000.0)) size 50 51(* Get current time *) 52let now () = Unix.gettimeofday () 53 54(* Write common response headers *) 55let write_common_headers buf ~off ~keep_alive = 56 let off = Date.write_date_header buf ~off (now ()) in 57 let off = Res.write_header_name buf ~off Header_name.Server "httpzo/0.1" in 58 Res.write_connection buf ~off ~keep_alive 59 60(* Write response headers for a full file response *) 61let write_file_headers conn ~off status content_type file_size etag mtime version = 62 let buf = conn.write_buf in 63 let off = Res.write_status_line buf ~off status version in 64 let off = Res.write_header_name buf ~off Header_name.Content_type content_type in 65 let off = Res.write_content_length buf ~off (Int64.to_int file_size) in 66 let off = Range.write_accept_ranges buf ~off in 67 let off = Res.write_header buf ~off "ETag" etag in 68 let off = Date.write_last_modified buf ~off mtime in 69 let off = write_common_headers buf ~off ~keep_alive:conn.keep_alive in 70 Res.write_crlf buf ~off 71 72(* Write response headers for a partial content (206) response *) 73let write_partial_headers conn ~off content_type ~start ~end_ ~total etag mtime version = 74 let buf = conn.write_buf in 75 let off = Res.write_status_line buf ~off Res.Partial_content version in 76 let off = Res.write_header_name buf ~off Header_name.Content_type content_type in 77 let content_length = Int64.(to_int (sub (add (sub end_ start) 1L) 0L)) in 78 let off = Res.write_content_length buf ~off content_length in 79 let off = Range.write_content_range buf ~off ~start ~end_ ~total in 80 let off = Res.write_header buf ~off "ETag" etag in 81 let off = Date.write_last_modified buf ~off mtime in 82 let off = write_common_headers buf ~off ~keep_alive:conn.keep_alive in 83 Res.write_crlf buf ~off 84 85(* Write 304 Not Modified response *) 86let write_not_modified_headers conn ~off etag mtime version = 87 let buf = conn.write_buf in 88 let off = Res.write_status_line buf ~off Res.Not_modified version in 89 let off = Res.write_header buf ~off "ETag" etag in 90 let off = Date.write_last_modified buf ~off mtime in 91 let off = write_common_headers buf ~off ~keep_alive:conn.keep_alive in 92 Res.write_crlf buf ~off 93 94(* Write 416 Range Not Satisfiable response *) 95let write_range_not_satisfiable conn ~off total version = 96 let buf = conn.write_buf in 97 let off = Res.write_status_line buf ~off Res.Range_not_satisfiable version in 98 let off = Range.write_content_range_unsatisfiable buf ~off ~total in 99 let off = write_common_headers buf ~off ~keep_alive:conn.keep_alive in 100 Res.write_crlf buf ~off 101 102(* Write buffer to flow *) 103let write_buf conn ~len = 104 let cs = Cstruct.of_bigarray conn.write_buf ~off:0 ~len in 105 Eio.Flow.write conn.flow [cs] 106 107(* Send error response *) 108let send_error conn status message version = 109 let buf = conn.write_buf in 110 let off = Res.write_status_line buf ~off:0 status version in 111 let off = Res.write_header_name buf ~off Header_name.Content_type "text/plain" in 112 let off = Res.write_content_length buf ~off (String.length message) in 113 let off = write_common_headers buf ~off ~keep_alive:conn.keep_alive in 114 let off = Res.write_crlf buf ~off in 115 write_buf conn ~len:off; 116 Eio.Flow.write conn.flow [Cstruct.of_string message] 117 118(* Normalize path - remove .. and resolve to absolute within root *) 119let normalize_path ~root request_path = 120 let decoded = request_path in 121 let parts = String.split_on_char '/' decoded in 122 let rec resolve acc = function 123 | [] -> List.rev acc 124 | "" :: rest | "." :: rest -> resolve acc rest 125 | ".." :: rest -> 126 (match acc with 127 | [] -> resolve [] rest 128 | _ :: acc' -> resolve acc' rest) 129 | part :: rest -> resolve (part :: acc) rest 130 in 131 let normalized = resolve [] parts in 132 let relative = String.concat "/" normalized in 133 Eio.Path.(root / relative) 134 135(* File metadata for caching decisions *) 136type file_meta = 137 { size : int64 138 ; mtime : float 139 ; etag : string 140 ; content_type : string 141 } 142 143(* Extracted/parsed header values for conditional requests and ranges *) 144type request_headers = 145 { if_none_match : string option 146 ; range_count : int 147 } 148 149(* Get file metadata *) 150let get_file_meta file_path = 151 let (_fs, path_str) = file_path in 152 let stats = Unix.stat path_str in 153 let size = stats.Unix.st_size |> Int64.of_int in 154 let mtime = stats.Unix.st_mtime in 155 let etag = generate_etag ~mtime ~size in 156 let content_type = mime_type_of_path path_str in 157 { size; mtime; etag; content_type } 158 159(* Check If-None-Match header for conditional GET *) 160let check_if_none_match etag if_none_match_str = 161 match if_none_match_str with 162 | None -> false 163 | Some value -> 164 if String.trim value = "*" then true 165 else 166 let normalize_etag s = 167 let s = String.trim s in 168 if String.length s >= 2 && String.sub s 0 2 = "W/" then 169 String.sub s 2 (String.length s - 2) 170 else s 171 in 172 let our_value = normalize_etag etag in 173 let tags = String.split_on_char ',' value in 174 List.exists (fun tag -> 175 let their_value = normalize_etag tag in 176 our_value = their_value 177 ) tags 178 179(* Send file with support for range requests and conditional GET *) 180let send_file_with_meta conn ~file_path ~meta ~req_headers ~version = 181 let { size; mtime; etag; content_type } = meta in 182 let (_fs, path_str) = file_path in 183 if check_if_none_match etag req_headers.if_none_match then ( 184 let off = write_not_modified_headers conn ~off:0 etag mtime version in 185 write_buf conn ~len:off 186 ) 187 else if req_headers.range_count = 0 then ( 188 (* Full content response *) 189 let off = write_file_headers conn ~off:0 Res.Success content_type size etag mtime version in 190 write_buf conn ~len:off; 191 (* Stream file contents *) 192 let fd = Unix.openfile path_str [Unix.O_RDONLY] 0 in 193 Fun.protect ~finally:(fun () -> Unix.close fd) (fun () -> 194 let buf = Bytes.create 65536 in 195 let rec copy () = 196 let n = Unix.read fd buf 0 65536 in 197 if n > 0 then ( 198 Eio.Flow.write conn.flow [Cstruct.of_bytes buf ~off:0 ~len:n]; 199 copy () 200 ) 201 in 202 copy () 203 ) 204 ) 205 else ( 206 (* Range request - evaluate ranges against file size *) 207 let (result, _resolved_count) = 208 Range.evaluate conn.ranges ~count:req_headers.range_count 209 ~resource_length:size conn.resolved 210 in 211 match result with 212 | Range.Full_content -> 213 let off = write_file_headers conn ~off:0 Res.Success content_type size etag mtime version in 214 write_buf conn ~len:off; 215 let fd = Unix.openfile path_str [Unix.O_RDONLY] 0 in 216 Fun.protect ~finally:(fun () -> Unix.close fd) (fun () -> 217 let buf = Bytes.create 65536 in 218 let rec copy () = 219 let n = Unix.read fd buf 0 65536 in 220 if n > 0 then ( 221 Eio.Flow.write conn.flow [Cstruct.of_bytes buf ~off:0 ~len:n]; 222 copy () 223 ) 224 in 225 copy () 226 ) 227 | Range.Not_satisfiable -> 228 conn.keep_alive <- false; 229 let off = write_range_not_satisfiable conn ~off:0 size version in 230 write_buf conn ~len:off 231 | Range.Single_range | Range.Multiple_ranges -> 232 let r = conn.resolved.(0) in 233 let start = r.start in 234 let end_ = r.end_ in 235 let range_len = Int64.(sub (add (sub end_ start) 1L) 0L) in 236 let len = Int64.to_int range_len in 237 let off = write_partial_headers conn ~off:0 content_type ~start ~end_ ~total:size etag mtime version in 238 write_buf conn ~len:off; 239 let fd = Unix.openfile path_str [Unix.O_RDONLY] 0 in 240 Fun.protect ~finally:(fun () -> Unix.close fd) (fun () -> 241 let _ = Unix.lseek fd (Int64.to_int start) Unix.SEEK_SET in 242 let buf = Bytes.create (min len 65536) in 243 let remaining = ref len in 244 while !remaining > 0 do 245 let to_read = min !remaining 65536 in 246 let n = Unix.read fd buf 0 to_read in 247 if n > 0 then ( 248 Eio.Flow.write conn.flow [Cstruct.of_bytes buf ~off:0 ~len:n]; 249 remaining := !remaining - n 250 ) else 251 remaining := 0 252 done 253 ) 254 ) 255 256(* Try to serve index.html from a directory *) 257let serve_directory conn ~file_path ~req_headers ~version = 258 let index_path = Eio.Path.(file_path / "index.html") in 259 let (_fs, index_str) = index_path in 260 if Sys.file_exists index_str then ( 261 let meta = get_file_meta index_path in 262 send_file_with_meta conn ~file_path:index_path ~meta ~req_headers ~version 263 ) else 264 send_error conn Res.Not_found "Not Found" version 265 266(* Try to serve a regular file, checking it's within root *) 267let serve_regular_file conn ~root ~file_path ~req_headers ~version = 268 let (_root_fs, root_str) = root in 269 let (_fs, path_str) = file_path in 270 try 271 let file_abs = Unix.realpath path_str in 272 let root_abs = Unix.realpath root_str in 273 if String.length file_abs >= String.length root_abs && 274 String.sub file_abs 0 (String.length root_abs) = root_abs 275 then ( 276 let meta = get_file_meta file_path in 277 send_file_with_meta conn ~file_path ~meta ~req_headers ~version 278 ) else 279 send_error conn Res.Forbidden "Forbidden" version 280 with _ -> 281 send_error conn Res.Not_found "Not Found" version 282 283(* Serve a file *) 284let serve_file conn ~root target_str req_headers version = 285 let path = match String.index_opt target_str '?' with 286 | Some idx -> String.sub target_str 0 idx 287 | None -> target_str 288 in 289 let file_path = normalize_path ~root path in 290 let (_fs, path_str) = file_path in 291 if Sys.file_exists path_str then ( 292 if Sys.is_directory path_str then 293 serve_directory conn ~file_path ~req_headers ~version 294 else 295 serve_regular_file conn ~root ~file_path ~req_headers ~version 296 ) else 297 send_error conn Res.Not_found "Not Found" version 298 299(* Read more data into buffer *) 300let read_more conn = 301 if conn.read_len >= Buf_read.buffer_size then 302 `Buffer_full 303 else ( 304 let available = Buf_read.buffer_size - conn.read_len in 305 let cs = Cstruct.of_bigarray conn.read_buf ~off:conn.read_len ~len:available in 306 match Eio.Flow.single_read conn.flow cs with 307 | n -> 308 conn.read_len <- conn.read_len + n; 309 `Ok n 310 | exception End_of_file -> `Eof 311 ) 312 313(* Shift buffer contents to remove processed data *) 314let shift_buffer conn consumed = 315 if consumed > 0 && consumed < conn.read_len then ( 316 for i = 0 to conn.read_len - consumed - 1 do 317 Bigarray.Array1.set conn.read_buf i 318 (Bigarray.Array1.get conn.read_buf (consumed + i)) 319 done; 320 conn.read_len <- conn.read_len - consumed 321 ) else if consumed >= conn.read_len then 322 conn.read_len <- 0 323 324(* Handle one request on connection *) 325let handle_request conn ~root ~limits = 326 let buf = conn.read_buf in 327 let len = conn.read_len in 328 let (status, req, headers) = Request_parse.parse buf ~len ~limits in 329 let body_off = req.Req.body_off in 330 let version = req.Req.version in 331 let target = req.Req.target in 332 match status with 333 | Buf_read.Complete -> 334 let target_str = Span.to_string buf target in 335 let if_none_match = 336 match Header.find headers Header_name.If_none_match with 337 | None -> None 338 | Some hdr -> Some (Span.to_string buf hdr.Header.value) 339 in 340 let range_count = 341 match Header.find headers Header_name.Range with 342 | None -> 0 343 | Some hdr -> 344 let (status, count) = Range.parse buf hdr.Header.value conn.ranges in 345 match status with 346 | Range.Invalid -> 0 347 | Range.Valid -> count 348 in 349 let req_headers = { if_none_match; range_count } in 350 let body_span = Req.body_span ~len req in 351 let body_span_len = Span.len body_span in 352 let body_span_off = Span.off body_span in 353 if body_span_len = -1 then 354 `Need_more 355 else ( 356 conn.keep_alive <- req.Req.keep_alive; 357 serve_file conn ~root target_str req_headers version; 358 let consumed = 359 if body_span_len > 0 then body_span_off + body_span_len else body_off 360 in 361 shift_buffer conn consumed; 362 if conn.keep_alive then `Continue else `Close 363 ) 364 | Buf_read.Partial -> `Need_more 365 | Buf_read.Headers_too_large 366 | Buf_read.Content_length_overflow -> 367 conn.keep_alive <- false; 368 send_error conn Res.Payload_too_large "Payload Too Large" Version.Http_1_1; 369 `Close 370 | Buf_read.Bare_cr_detected 371 | Buf_read.Ambiguous_framing -> 372 conn.keep_alive <- false; 373 send_error conn Res.Bad_request "Bad Request" Version.Http_1_1; 374 `Close 375 | Buf_read.Missing_host_header -> 376 conn.keep_alive <- false; 377 send_error conn Res.Bad_request "Missing Host Header" Version.Http_1_1; 378 `Close 379 | _ -> 380 conn.keep_alive <- false; 381 send_error conn Res.Bad_request "Bad Request" Version.Http_1_1; 382 `Close 383 384(* Send payload too large error and close connection *) 385let send_payload_too_large conn = 386 conn.keep_alive <- false; 387 send_error conn Res.Payload_too_large "Payload Too Large" Version.Http_1_1 388 389(* Handle connection loop *) 390let handle_connection conn ~root ~limits = 391 let handle_read_result ~continue = function 392 | `Eof -> () 393 | `Buffer_full -> send_payload_too_large conn 394 | `Ok _ -> continue () 395 in 396 let rec loop () = 397 if conn.read_len = 0 then 398 handle_read_result ~continue:loop (read_more conn) 399 else 400 match handle_request conn ~root ~limits with 401 | `Continue -> loop () 402 | `Close -> () 403 | `Need_more -> handle_read_result ~continue:loop (read_more conn) 404 in 405 loop () 406 407(* Handle a single client connection *) 408let handle_client ~root ~limits flow _addr = 409 let conn = create_conn flow in 410 try 411 handle_connection conn ~root ~limits 412 with exn -> 413 traceln "Error: %s" (Printexc.to_string exn) 414 415(* Run server *) 416let run ~net ~sw config = 417 traceln "httpzo serving on http://localhost:%d/" config.port; 418 traceln " Supports: Range requests, ETag, If-None-Match"; 419 let addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, config.port) in 420 let sock = Eio.Net.listen net ~sw ~backlog:128 ~reuse_addr:true addr in 421 let rec accept_loop () = 422 Eio.Net.accept_fork sock ~sw ~on_error:(fun exn -> 423 traceln "Connection error: %s" (Printexc.to_string exn) 424 ) (fun flow addr -> handle_client ~root:config.root ~limits:config.limits flow addr); 425 accept_loop () 426 in 427 accept_loop ()