Server: render htmx (HX-Request) responses as HTML, not SX wire format

htmx sends HX-Request header on AJAX calls. The server now detects this
and renders the SX response to HTML via sx_render_to_html before sending.
SX-Request (from SX client navigation) still gets SX wire format.
Also skip response cache for htmx requests (they need fresh HTML renders).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 07:14:12 +00:00
parent b5387c069f
commit 444cd1ea70

View File

@@ -3740,18 +3740,23 @@ let http_mode port =
(try Unix.close client with _ -> ())
in
(* Check if request has SX-Request or HX-Request header (AJAX navigation) *)
(* Check if request has SX-Request header (SX AJAX navigation — return SX wire format)
or HX-Request header (htmx AJAX — return rendered HTML). *)
let has_substring s sub =
let slen = String.length s and sublen = String.length sub in
if sublen > slen then false
else let rec check i = if i > slen - sublen then false
else if String.sub s i sublen = sub then true else check (i + 1)
in check 0
in
let is_sx_request data =
let lower = String.lowercase_ascii data in
let has_substring s sub =
let slen = String.length s and sublen = String.length sub in
if sublen > slen then false
else let rec check i = if i > slen - sublen then false
else if String.sub s i sublen = sub then true else check (i + 1)
in check 0
in
has_substring lower "sx-request" || has_substring lower "hx-request"
in
let _is_hx_request data =
let lower = String.lowercase_ascii data in
has_substring lower "hx-request" && not (has_substring lower "sx-request")
in
(* Non-blocking event loop with render worker pool.
- Main loop: Unix.select on listen socket + all connected clients
@@ -3785,14 +3790,23 @@ let http_mode port =
match work with
| Some (fd, path, headers) ->
let is_ajax = headers <> [] in
let cache_key = if is_ajax then "ajax:" ^ path else path in
let is_htmx = List.exists (fun (k,_) -> String.lowercase_ascii k = "hx-request") headers in
let cache_key = if is_ajax then (if is_htmx then "htmx:" else "ajax:") ^ path else path in
let response =
try
match http_render_page env path headers with
| Some body ->
let ct = if is_ajax then "text/sx; charset=utf-8"
(* htmx requests get HTML; SX requests get SX wire format *)
let final_body = if is_htmx then
(try
let exprs = Sx_parser.parse_all body in
let expr = match exprs with [e] -> e | [] -> Nil | _ -> List (Symbol "<>" :: exprs) in
Sx_render.sx_render_to_html env expr env
with _ -> body)
else body in
let ct = if is_ajax && not is_htmx then "text/sx; charset=utf-8"
else "text/html; charset=utf-8" in
let resp = http_response ~content_type:ct body in
let resp = http_response ~content_type:ct final_body in
Hashtbl.replace response_cache cache_key resp;
resp
| None -> http_response ~status:404 "<h1>Not Found</h1>"
@@ -4120,20 +4134,35 @@ let http_mode port =
end
end else
let has_state_cookie = Hashtbl.mem _request_cookies "sx-home-stepper" in
let cache_key = if is_ajax then "ajax:" ^ path else path in
match (if has_state_cookie then None
let is_htmx_req = is_ajax && has_substring (String.lowercase_ascii data) "hx-request" in
let cache_key = if is_htmx_req then "htmx:" ^ path
else if is_ajax then "ajax:" ^ path else path in
match (if has_state_cookie || is_htmx_req then None
else Hashtbl.find_opt response_cache cache_key) with
| Some cached -> write_response fd cached; true
| None ->
if is_ajax then begin
(* AJAX: render on main thread — aser only, fast, no SSR.
Avoids queueing behind slow full-page renders. *)
Avoids queueing behind slow full-page renders.
HX-Request (htmx) gets HTML; SX-Request gets SX wire format. *)
let headers = parse_http_headers data in
let is_htmx = List.exists (fun (k,_) ->
String.lowercase_ascii k = "hx-request") headers in
let response =
try match http_render_page env path headers with
| Some body ->
let resp = http_response ~content_type:"text/sx; charset=utf-8" body in
Hashtbl.replace response_cache cache_key resp; resp
let final_body = if is_htmx then
(try
let exprs = Sx_parser.parse_all body in
let expr = match exprs with [e] -> e | [] -> Nil | _ -> List (Symbol "<>" :: exprs) in
Sx_render.sx_render_to_html env expr env
with _ -> body)
else body in
let ct = if is_htmx then "text/html; charset=utf-8"
else "text/sx; charset=utf-8" in
let resp = http_response ~content_type:ct final_body in
if not is_htmx then Hashtbl.replace response_cache cache_key resp;
resp
| None -> http_response ~status:404
"(div :class \"p-8\" (h2 :class \"text-rose-600 font-semibold\" \"Page not found\") (p :class \"text-stone-500\" \"No route matched this path\"))"
with e ->