diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index cbf79a60..3ca70881 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -18,6 +18,123 @@ open Sx_types +(* ====================================================================== *) +(* Font measurement via otfm — reads OpenType/TrueType font tables *) +(* ====================================================================== *) + +(* Font cache: font-family → (units_per_em, glyph_id_map, advance_width_map) *) +let _font_cache : (string, (int * (int, int) Hashtbl.t * (int, int) Hashtbl.t)) Hashtbl.t = Hashtbl.create 4 + +(* Map font-family names to file paths *) +let _font_base = ref "shared/static/fonts" + +let font_path_for family = + let base = !_font_base in + let try_paths paths = + List.find_opt Sys.file_exists paths in + match String.lowercase_ascii family with + | "serif" | "times" | "times new roman" -> + try_paths [base ^ "/DejaVuSerif.ttf"; + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"] + | "sans-serif" | "sans" | "arial" | "helvetica" -> + try_paths [base ^ "/DejaVuSans.ttf"; + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] + | "monospace" | "mono" | "courier" -> + try_paths [base ^ "/DejaVuSansMono-Bold.ttf"; + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"] + | path when Sys.file_exists path -> Some path + | _ -> try_paths [base ^ "/DejaVuSerif.ttf"; + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"] + +(* Load a font file and extract cmap + hmtx tables *) +let load_font family = + match Hashtbl.find_opt _font_cache family with + | Some cached -> Some cached + | None -> + match font_path_for family with + | None -> None + | Some path -> + try + let ic = open_in_bin path in + let n = in_channel_length ic in + let buf = Bytes.create n in + really_input ic buf 0 n; + close_in ic; + let s = Bytes.to_string buf in + let d = Otfm.decoder (`String s) in + (* Get units_per_em from head table *) + let upm = match Otfm.head d with + | Error _ -> 2048 + | Ok h -> h.Otfm.head_units_per_em in + (* Build char→glyph_id map from cmap. + cmap folds: (acc -> map_kind -> (u0,u1) -> glyph_id -> acc) *) + let cmap_tbl = Hashtbl.create 256 in + let _ = Otfm.cmap d (fun acc kind (u0, u1) gid -> + (match kind with + | `Glyph -> + (* Single mapping: u0 -> gid (u0 = u1) *) + Hashtbl.replace cmap_tbl u0 gid + | `Glyph_range -> + (* Range: u0..u1 -> gid..(gid + u1 - u0) *) + for i = 0 to u1 - u0 do + Hashtbl.replace cmap_tbl (u0 + i) (gid + i) + done); + acc) () in + (* Build glyph_id→advance_width map from hmtx. + hmtx folds: (acc -> glyph_id -> adv_width -> lsb -> acc) *) + let hmtx_tbl = Hashtbl.create 256 in + let _ = Otfm.hmtx d (fun acc gid adv _lsb -> + Hashtbl.replace hmtx_tbl gid adv; acc) () in + let result = (upm, cmap_tbl, hmtx_tbl) in + Printf.eprintf "[font] loaded %s: %d glyphs, %d metrics, upm=%d\n%!" + (Filename.basename path) (Hashtbl.length cmap_tbl) (Hashtbl.length hmtx_tbl) upm; + Hashtbl.replace _font_cache family result; + Some result + with e -> + Printf.eprintf "[font] error loading %s: %s\n%!" path (Printexc.to_string e); + None + +(* Measure text width using font tables *) +let measure_text_otfm family size text = + match load_font family with + | None -> + (* Fallback to monospace approximation *) + let w = size *. 0.6 *. (float_of_int (String.length text)) in + (w, size, size *. 0.8, size *. 0.2) + | Some (upm, cmap_tbl, hmtx_tbl) -> + let scale = size /. (float_of_int upm) in + let width = ref 0.0 in + (* Iterate over UTF-8 codepoints *) + let i = ref 0 in + let len = String.length text in + while !i < len do + let byte = Char.code text.[!i] in + let cp, advance = + if byte < 0x80 then (byte, 1) + else if byte < 0xC0 then (byte, 1) (* continuation — skip *) + else if byte < 0xE0 then + ((byte land 0x1F) lsl 6 lor (Char.code text.[min (!i+1) (len-1)] land 0x3F), 2) + else if byte < 0xF0 then + ((byte land 0x0F) lsl 12 + lor (Char.code text.[min (!i+1) (len-1)] land 0x3F) lsl 6 + lor (Char.code text.[min (!i+2) (len-1)] land 0x3F), 3) + else + ((byte land 0x07) lsl 18 + lor (Char.code text.[min (!i+1) (len-1)] land 0x3F) lsl 12 + lor (Char.code text.[min (!i+2) (len-1)] land 0x3F) lsl 6 + lor (Char.code text.[min (!i+3) (len-1)] land 0x3F), 4) + in + let gid = match Hashtbl.find_opt cmap_tbl cp with + | Some g -> g | None -> 0 in + let adv = match Hashtbl.find_opt hmtx_tbl gid with + | Some a -> a | None -> upm / 2 in + width := !width +. (float_of_int adv) *. scale; + i := !i + advance + done; + let ascent = size *. 0.8 in (* approximate — could read OS/2 table *) + let descent = size *. 0.2 in + (!width, size, ascent, descent) + (* ====================================================================== *) (* Output helpers *) @@ -71,6 +188,56 @@ let rec serialize_value = function "(make-spread {" ^ String.concat " " items ^ "})" | _ -> "nil" +(** Collect all ~-prefixed symbol references from an AST value. + Walks the tree recursively, returns a deduplicated list of symbol names + like ["~card"; "~layout/base"]. Used for dependency analysis. *) +let collect_tilde_refs body = + let seen = Hashtbl.create 16 in + let rec walk = function + | Symbol s when String.length s > 0 && s.[0] = '~' -> + if not (Hashtbl.mem seen s) then Hashtbl.replace seen s () + | List items | ListRef { contents = items } -> + List.iter walk items + | Dict d -> + Hashtbl.iter (fun _k v -> walk v) d + | Spread pairs -> + List.iter (fun (_k, v) -> walk v) pairs + | _ -> () + in + walk body; + Hashtbl.fold (fun k () acc -> k :: acc) seen [] + +(** Serialize a value to SX text, replacing ~-prefixed symbol references + with their content hashes from the index. Symbols not in the index + are emitted verbatim (unknown ref or non-component symbol). *) +let rec serialize_value_hashed (index : (string, string) Hashtbl.t) = function + | Nil -> "nil" + | Bool true -> "true" + | Bool false -> "false" + | Number n -> + if Float.is_integer n then string_of_int (int_of_float n) + else Printf.sprintf "%g" n + | String s -> "\"" ^ escape_sx_string s ^ "\"" + | Symbol s when String.length s > 0 && s.[0] = '~' -> + (match Hashtbl.find_opt index s with + | Some h -> "@h:" ^ h + | None -> s) + | Symbol s -> s + | Keyword k -> ":" ^ k + | List items | ListRef { contents = items } -> + "(" ^ String.concat " " (List.map (serialize_value_hashed index) items) ^ ")" + | Dict d -> + let pairs = Hashtbl.fold (fun k v acc -> + (Printf.sprintf ":%s %s" k (serialize_value_hashed index v)) :: acc) d [] in + "{" ^ String.concat " " (List.sort String.compare pairs) ^ "}" + | RawHTML s -> "\"" ^ escape_sx_string s ^ "\"" + | SxExpr s -> s + | Spread pairs -> + let items = List.map (fun (k, v) -> + Printf.sprintf ":%s %s" k (serialize_value_hashed index v)) pairs in + "(make-spread {" ^ String.concat " " items ^ "})" + | _ -> "nil" + (** Request epoch — monotonically increasing, set by (epoch N) from Python. All responses are tagged with the current epoch so Python can discard stale messages from previous requests. Makes pipe desync impossible. *) @@ -149,6 +316,11 @@ let _scope_stacks = Sx_primitives._scope_stacks (* Populated from __app-config dict after SX files load. *) let _app_config : (string, value) Hashtbl.t option ref = ref None let _defpage_paths : string list ref = ref [] +(* Streaming pages: path → page name, for pages with :stream true *) +let _streaming_pages : (string, string) Hashtbl.t = Hashtbl.create 8 +(* Mutex to serialize streaming renders — OCaml threads share the runtime + lock, and concurrent CEK evaluations corrupt shared state. *) +let _stream_mutex = Mutex.create () let get_app_config key default = match !_app_config with @@ -315,12 +487,12 @@ let resolve_library_path lib_spec = The file should contain a define-library form that registers itself. *) let _import_env : env option ref = ref None -let load_library_file path = - (* Use eval_expr which has the cek_run import patch — handles nested imports *) +let rec load_library_file path = + (* Use eval_expr_io for IO-aware loading (handles nested imports) *) let env = match !_import_env with Some e -> e | None -> Sx_types.make_env () in let exprs = Sx_parser.parse_file path in List.iter (fun expr -> - try ignore (Sx_ref.eval_expr expr (Env env)) + try ignore (eval_expr_io expr (Env env)) with Eval_error msg -> Printf.eprintf "[load-library] %s: %s\n%!" (Filename.basename path) msg ) exprs @@ -328,7 +500,7 @@ let load_library_file path = (** IO-aware CEK run — handles suspension by dispatching IO requests. Import requests are handled locally (load .sx file). Other IO requests are sent to the Python bridge. *) -let cek_run_with_io state = +and cek_run_with_io state = let s = ref state in let is_terminal s = match Sx_ref.cek_terminal_p s with Bool true -> true | _ -> false in let is_suspended s = match Sx_runtime.get_val s (String "phase") with String "io-suspended" -> true | _ -> false in @@ -355,6 +527,24 @@ let cek_run_with_io state = (Sx_runtime.value_to_str lib_spec)); Nil end + | "text-measure" -> + let args = let a = Sx_runtime.get_val request (String "args") in + (match a with List l -> l | _ -> [a]) in + let font = match args with String f :: _ -> f | _ -> "serif" in + let size = match args with + | [_font; Number sz; _text] -> sz + | [_font; Number sz] -> sz + | _ -> 16.0 in + let text = match args with + | [_font; _sz; String t] -> t + | _ -> "" in + let (w, h, asc, desc) = measure_text_otfm font size text in + let d = Hashtbl.create 4 in + Hashtbl.replace d "width" (Number w); + Hashtbl.replace d "height" (Number h); + Hashtbl.replace d "ascent" (Number asc); + Hashtbl.replace d "descent" (Number desc); + Dict d | _ -> let args = let a = Sx_runtime.get_val request (String "args") in (match a with List l -> l | _ -> [a]) in @@ -368,10 +558,7 @@ let cek_run_with_io state = loop () (** IO-aware eval_expr — like eval_expr but handles IO suspension. *) -(* IO-aware eval — used by site_mode. The regular file loading path - uses Sx_ref.eval_expr which delegates IO suspension to the - _cek_io_suspend_hook → _vm_suspension_to_dict chain. *) -let _eval_expr_io expr env = +and eval_expr_io expr env = let state = Sx_ref.make_cek_state expr env (List []) in cek_run_with_io state @@ -467,7 +654,22 @@ let setup_browser_stubs env = bind "create-text-node" (fun args -> match args with [String s] -> String s | [v] -> String (value_to_string v) | _ -> Nil); bind "render-to-dom" (fun _args -> Nil); bind "set-render-active!" (fun _args -> Nil); - bind "render-active?" (fun _args -> Bool true) + bind "render-active?" (fun _args -> Bool true); + (* host-* platform primitives — browser-only, but boot-helpers.sx + references them so they need server-side stubs *) + bind "host-get" (fun args -> + match args with + | [Dict d; String key] -> (match Hashtbl.find_opt d key with Some v -> v | None -> Nil) + | _ -> Nil); + bind "host-set!" (fun _args -> Nil); + bind "host-call" (fun _args -> Nil); + bind "host-typeof" (fun _args -> String "nil"); + bind "host-callback" (fun args -> + match args with [fn] -> fn | _ -> NativeFn ("noop", fun _ -> Nil)); + bind "host-await" (fun _args -> Nil); + bind "host-new" (fun _args -> Nil); + bind "host-global" (fun _args -> Nil); + bind "host-object" (fun _args -> Dict (Hashtbl.create 0)) (* ---- Scope primitives: bind into env for VM visibility ---- *) let setup_scope_env env = @@ -946,7 +1148,16 @@ let register_jit_hook env = (try Some (Sx_vm.call_closure cl args cl.vm_env_ref) with | Sx_vm.VmSuspended (request, saved_vm) -> - Some (make_vm_suspend_marker request saved_vm) + (* Try inline IO resolution; fall back to suspend marker *) + (match !Sx_types._cek_io_resolver with + | Some resolver -> + let rec resolve_loop req vm = + let result = resolver req (Nil) in + (try Some (Sx_vm.resume_vm vm result) + with Sx_vm.VmSuspended (req2, vm2) -> resolve_loop req2 vm2) + in + resolve_loop request saved_vm + | None -> Some (make_vm_suspend_marker request saved_vm)) | e -> let fn_name = match l.l_name with Some n -> n | None -> "?" in if not (Hashtbl.mem _jit_warned fn_name) then begin @@ -971,7 +1182,15 @@ let register_jit_hook env = (try Some (Sx_vm.call_closure cl args cl.vm_env_ref) with | Sx_vm.VmSuspended (request, saved_vm) -> - Some (make_vm_suspend_marker request saved_vm) + (match !Sx_types._cek_io_resolver with + | Some resolver -> + let rec resolve_loop req vm = + let result = resolver req (Nil) in + (try Some (Sx_vm.resume_vm vm result) + with Sx_vm.VmSuspended (req2, vm2) -> resolve_loop req2 vm2) + in + resolve_loop request saved_vm + | None -> Some (make_vm_suspend_marker request saved_vm)) | e -> Printf.eprintf "[jit] %s first-call fallback to CEK: %s\n%!" fn_name (Printexc.to_string e); Hashtbl.replace _jit_warned fn_name true; @@ -997,6 +1216,41 @@ let rebind_host_extensions env = | _ -> raise (Eval_error "register-special-form!: expected (name handler)"))); ignore (env_bind env "*custom-special-forms*" Sx_ref.custom_special_forms) +(* Path-based naming for unnamed definitions *) +(* ====================================================================== *) + +let def_keywords = ["defcomp"; "defisland"; "defmacro"; "define"; + "defhandler"; "defstyle"; "deftype"; "defeffect"; + "defrelation"; "deftest"; "defpage"] + +(* Inject path-derived name into unnamed definitions. + (defcomp (params) body) -> (defcomp ~path/name (params) body) + Only applied when base_dir is provided (service components). *) +let inject_path_name expr path base_dir = + match expr with + | List (Symbol kw :: rest) when List.mem kw def_keywords -> + (match rest with + | Symbol _ :: _ -> expr (* Already named *) + | _ -> + (* Unnamed — derive name from file path relative to base_dir *) + let rel = if String.length path > String.length base_dir + 1 + then String.sub path (String.length base_dir + 1) + (String.length path - String.length base_dir - 1) + else Filename.basename path in + let stem = if Filename.check_suffix rel ".sx" + then String.sub rel 0 (String.length rel - 3) + else rel in + (* index files are known by their directory *) + let name = if Filename.basename stem = "index" + then let d = Filename.dirname stem in + if d = "." then "index" else d + else stem in + (* Components/islands get ~ prefix *) + let prefixed = if kw = "defcomp" || kw = "defisland" + then "~" ^ name else name in + List (Symbol kw :: Symbol prefixed :: rest)) + | _ -> expr + (* Command dispatch *) (* ====================================================================== *) @@ -1005,14 +1259,20 @@ let rec dispatch env cmd = | List [Symbol "ping"] -> send_ok_string "ocaml-cek" - | List [Symbol "load"; String path] -> + | List [Symbol "load"; String path] + | List [Symbol "load"; String path; String _] -> + let base_dir = match cmd with + | List [_; _; String b] -> b | _ -> "" in (try let exprs = Sx_parser.parse_file path in let prev_file = if Sx_types.env_has env "*current-file*" then Some (Sx_types.env_get env "*current-file*") else None in ignore (Sx_types.env_bind env "*current-file*" (String path)); let count = ref 0 in List.iter (fun expr -> - ignore (Sx_ref.eval_expr expr (Env env)); + let expr' = if base_dir <> "" then inject_path_name expr path base_dir else expr in + (try ignore (eval_expr_io expr' (Env env)) + with Eval_error msg -> + Printf.eprintf "[load] %s: %s\n%!" (Filename.basename path) msg); incr count ) exprs; (* Rebind host extension points after .sx load — evaluator.sx @@ -1743,6 +2003,32 @@ let http_response ?(status=200) ?(content_type="text/html; charset=utf-8") body let http_redirect url = Printf.sprintf "HTTP/1.1 301 Moved Permanently\r\nLocation: %s\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n" url +(* Chunked transfer encoding helpers for streaming responses *) +let http_chunked_header ?(status=200) ?(content_type="text/html; charset=utf-8") () = + let status_text = match status with + | 200 -> "OK" | 404 -> "Not Found" | 500 -> "Internal Server Error" | _ -> "Unknown" in + Printf.sprintf "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\nCache-Control: no-cache, no-transform\r\n\r\n" + status status_text content_type + +let write_chunk fd data = + if String.length data > 0 then begin + let chunk = Printf.sprintf "%x\r\n%s\r\n" (String.length data) data in + let bytes = Bytes.of_string chunk in + let total = Bytes.length bytes in + let written = ref 0 in + try + while !written < total do + let n = Unix.write fd bytes !written (total - !written) in + written := !written + n + done; + true + with Unix.Unix_error _ -> false + end else true + +let end_chunked fd = + (try ignore (Unix.write_substring fd "0\r\n\r\n" 0 5) with Unix.Unix_error _ -> ()); + (try Unix.close fd with _ -> ()) + let parse_http_request data = match String.index_opt data '\r' with | None -> (match String.index_opt data '\n' with @@ -1789,6 +2075,48 @@ let parse_http_headers data = ) (match lines with _ :: rest -> rest | [] -> []); !headers +(* IO-aware eval for rendering — handles perform (text-measure, sleep, import). + Used by aser and SSR so components can call measure-text via perform. *) +let eval_with_io_render expr env = + let state = ref (Sx_ref.cek_step_loop (Sx_ref.make_cek_state expr (Env env) Nil)) in + while sx_truthy (Sx_ref.cek_suspended_p !state) do + let request = Sx_ref.cek_io_request !state in + let op = match request with + | Dict d -> (match Hashtbl.find_opt d "op" with Some (String s) -> s | Some (Symbol s) -> s | _ -> "") + | _ -> "" in + let args = match request with + | Dict d -> (match Hashtbl.find_opt d "args" with Some v -> v | None -> Nil) + | _ -> Nil in + let result = match op with + | "text-measure" -> + let font = match args with + | List (String f :: _) -> f | _ -> "serif" in + let size = match args with + | List [_font; Number sz; _text] -> sz + | List [_font; Number sz] -> sz + | _ -> 16.0 in + let text = match args with + | List [_font; _sz; String t] -> t + | _ -> "" in + let (w, h, asc, desc) = measure_text_otfm font size text in + let d = Hashtbl.create 4 in + Hashtbl.replace d "width" (Number w); + Hashtbl.replace d "height" (Number h); + Hashtbl.replace d "ascent" (Number asc); + Hashtbl.replace d "descent" (Number desc); + Dict d + | "io-sleep" | "sleep" -> + let ms = match args with + | List (Number n :: _) -> n | Number n -> n | _ -> 0.0 in + Unix.sleepf (ms /. 1000.0); Nil + | "import" -> Nil + | _ -> Nil + in + state := Sx_ref.cek_step_loop (Sx_ref.cek_resume !state result) + done; + if sx_truthy (Sx_ref.cek_terminal_p !state) then Sx_ref.cek_value !state + else Nil + (** Render a page. Routing + AJAX detection in SX (request-handler.sx), render pipeline (aser → SSR → shell) in OCaml for reliable env access. *) let http_render_page env path headers = @@ -1840,7 +2168,7 @@ let http_render_page env path headers = (* AJAX: return SX wire format (aser output) with text/sx content type *) let body_result = let call = List [Symbol "aser"; List [Symbol "quote"; wrapped]; Env env] in - Sx_ref.eval_expr call (Env env) in + eval_with_io_render call env in let body_str = match body_result with | String s | SxExpr s -> s | _ -> serialize_value body_result in let t1 = Unix.gettimeofday () in @@ -1854,7 +2182,7 @@ let http_render_page env path headers = let t1 = Unix.gettimeofday () in let body_result = let call = List [Symbol "aser"; List [Symbol "quote"; full_ast]; Env env] in - Sx_ref.eval_expr call (Env env) in + eval_with_io_render call env in let body_str = match body_result with | String s | SxExpr s -> s | _ -> serialize_value body_result in let t2 = Unix.gettimeofday () in @@ -1864,7 +2192,7 @@ let http_render_page env path headers = if env_has env "render-to-html" then let render_call = List [Symbol "render-to-html"; List [Symbol "quote"; body_expr]; Env env] in - (match Sx_ref.eval_expr render_call (Env env) with + (match eval_with_io_render render_call env with | String s | RawHTML s -> s | v -> Sx_runtime.value_to_str v) else Sx_render.sx_render_to_html env body_expr env with e -> Printf.eprintf "[http-ssr] failed for %s: %s\n%!" path (Printexc.to_string e); "" in @@ -1876,6 +2204,7 @@ let http_render_page env path headers = Keyword "body-html"; String body_html; Keyword "component-defs"; get_shell "component-defs"; Keyword "component-hash"; get_shell "component-hash"; + Keyword "component-manifest"; get_shell "component-manifest"; Keyword "pages-sx"; get_shell "pages-sx"; Keyword "sx-css"; get_shell "sx-css"; Keyword "asset-url"; get_shell "asset-url"; @@ -1903,6 +2232,297 @@ let http_render_page env path headers = end end +(* JSON-encode a string for use in __sxResolve script tags *) +let json_encode_string s = + let buf = Buffer.create (String.length s + 16) in + Buffer.add_char buf '"'; + String.iter (fun c -> match c with + | '"' -> Buffer.add_string buf "\\\"" + | '\\' -> Buffer.add_string buf "\\\\" + | '\n' -> Buffer.add_string buf "\\n" + | '\r' -> Buffer.add_string buf "\\r" + | '\t' -> Buffer.add_string buf "\\t" + | c when Char.code c < 0x20 -> + Buffer.add_string buf (Printf.sprintf "\\u%04x" (Char.code c)) + | c -> Buffer.add_char buf c + ) s; + Buffer.add_char buf '"'; + Buffer.contents buf + +(* Bootstrap script that queues resolves arriving before sx.js loads. + Must match _SX_STREAMING_BOOTSTRAP in shared/sx/helpers.py *) +let _sx_streaming_bootstrap = + "" + +(* Build a resolve script tag. Must match _SX_STREAMING_RESOLVE in helpers.py *) +let sx_streaming_resolve_script id sx_source = + Printf.sprintf "" + (json_encode_string id) (json_encode_string sx_source) + +(* ====================================================================== *) +(* IO-resolving evaluator for streaming render *) +(* ====================================================================== *) + +(* Evaluate an expression with IO suspension handling. + Steps the CEK machine; when it suspends on an IO request, resolves + the request locally and resumes. Supports: io-sleep, import, helper. *) +let eval_with_io expr env = + let state = ref (Sx_ref.cek_step_loop (Sx_ref.make_cek_state expr (Env env) Nil)) in + while sx_truthy (Sx_ref.cek_suspended_p !state) do + let request = Sx_ref.cek_io_request !state in + let op = match request with + | Dict d -> (match Hashtbl.find_opt d "op" with Some (String s) -> s | Some (Symbol s) -> s | _ -> "") + | _ -> "" in + let args = match request with + | Dict d -> (match Hashtbl.find_opt d "args" with Some v -> v | None -> Nil) + | _ -> Nil in + let result = match op with + | "io-sleep" | "sleep" -> + let ms = match args with + | List (Number n :: _) -> n + | Number n -> n + | _ -> 0.0 in + Unix.sleepf (ms /. 1000.0); + Nil + | "import" -> + (* Library import — delegate to existing import hook *) + (try + let lib_name = match request with + | Dict d -> (match Hashtbl.find_opt d "library" with Some v -> v | _ -> args) + | _ -> args in + ignore lib_name; (* import handled by _import_hook if registered *) + Nil + with _ -> Nil) + | "text-measure" -> + let font = match args with + | List (String f :: _) -> f | _ -> "serif" in + let size = match args with + | List [_font; Number sz; _text] -> sz + | List [_font; Number sz] -> sz + | _ -> 16.0 in + let text = match args with + | List [_font; _sz; String t] -> t + | _ -> "" in + let (w, h, asc, desc) = measure_text_otfm font size text in + let d = Hashtbl.create 4 in + Hashtbl.replace d "width" (Number w); + Hashtbl.replace d "height" (Number h); + Hashtbl.replace d "ascent" (Number asc); + Hashtbl.replace d "descent" (Number desc); + Dict d + | _ -> + Printf.eprintf "[io] unhandled IO op: %s\n%!" op; + Nil + in + state := Sx_ref.cek_step_loop (Sx_ref.cek_resume !state result) + done; + if sx_truthy (Sx_ref.cek_terminal_p !state) then Sx_ref.cek_value !state + else Nil + +(* ====================================================================== *) +(* Streaming page render — shell-first with chunked transfer encoding *) +(* ====================================================================== *) + +let http_render_page_streaming env path _headers fd page_name = + (* No send timeout for streaming — the alive check in write_chunk handles + broken pipe. Streaming clients may be slow to receive large shell chunks + while busy parsing/downloading other resources. *) + (try Unix.setsockopt_float fd Unix.SO_SNDTIMEO 30.0 with _ -> ()); + let t0 = Unix.gettimeofday () in + let page_def = try + match env_get env ("page:" ^ page_name) with Dict d -> d | _ -> raise Not_found + with _ -> + Printf.eprintf "[sx-stream] page def not found: page:%s\n%!" page_name; + let err = http_response ~status:500 "