sx-http: static file serving + inline CSS + cache-bust hashes

OCaml HTTP server now serves /static/* files with MIME types, immutable
cache headers, and in-memory caching. Computes MD5 hashes for JS/WASM
files and injects them as ?v= cache-busting query params.

Inline CSS: reads tw.css + basics.css at startup, injects into
<style id="sx-css"> tag. Pages now render with full Tailwind styling.

Shell statics now include real file hashes:
  sx-js-hash, body-js-hash, wasm-hash — 12-char MD5 hex

Docker compose: mounts shared/static as /app/static for the container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:34:18 +00:00
parent c794e33dda
commit f905ff287c
2 changed files with 82 additions and 9 deletions

View File

@@ -12,3 +12,6 @@ services:
SX_SPEC_DIR: /app/spec
SX_LIB_DIR: /app/lib
SX_WEB_DIR: /app/web
volumes:
# Static files (CSS, JS, WASM) — served by Caddy on externalnet
- ./shared/static:/app/static:ro

View File

@@ -1534,8 +1534,62 @@ let http_render_page env path =
Some html
end
(* ====================================================================== *)
(* Static file serving + file hashing *)
(* ====================================================================== *)
let mime_type_of path =
if Filename.check_suffix path ".css" then "text/css; charset=utf-8"
else if Filename.check_suffix path ".js" then "application/javascript; charset=utf-8"
else if Filename.check_suffix path ".wasm" then "application/wasm"
else if Filename.check_suffix path ".json" then "application/json"
else if Filename.check_suffix path ".svg" then "image/svg+xml"
else if Filename.check_suffix path ".png" then "image/png"
else if Filename.check_suffix path ".jpg" || Filename.check_suffix path ".jpeg" then "image/jpeg"
else if Filename.check_suffix path ".ico" then "image/x-icon"
else if Filename.check_suffix path ".map" then "application/json"
else if Filename.check_suffix path ".woff2" then "font/woff2"
else if Filename.check_suffix path ".woff" then "font/woff"
else if Filename.check_suffix path ".sx" then "text/sx; charset=utf-8"
else "application/octet-stream"
let static_cache : (string, string) Hashtbl.t = Hashtbl.create 256
let serve_static_file static_dir url_path =
match Hashtbl.find_opt static_cache url_path with
| Some cached -> cached
| None ->
let rel = String.sub url_path 8 (String.length url_path - 8) in
let rel = match String.index_opt rel '?' with
| Some i -> String.sub rel 0 i | None -> rel in
if String.contains rel '\x00' || (String.length rel > 1 && String.sub rel 0 2 = "..") then
http_response ~status:403 "Forbidden"
else
let file_path = static_dir ^ "/" ^ rel in
if Sys.file_exists file_path && not (Sys.is_directory file_path) then begin
let content_type = mime_type_of file_path in
let body = In_channel.with_open_bin file_path In_channel.input_all in
let resp = Printf.sprintf
"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nCache-Control: public, max-age=31536000, immutable\r\nConnection: keep-alive\r\n\r\n%s"
content_type (String.length body) body in
Hashtbl.replace static_cache url_path resp;
resp
end else
http_response ~status:404 "Not Found"
let file_hash path =
if Sys.file_exists path then
String.sub (Digest.string (In_channel.with_open_bin path In_channel.input_all) |> Digest.to_hex) 0 12
else ""
let read_css_file path =
if Sys.file_exists path then
In_channel.with_open_text path In_channel.input_all
else ""
(** Pre-compute shell statics and inject into env as __shell-* vars. *)
let http_inject_shell_statics env =
let http_inject_shell_statics env static_dir =
(* Component definitions for client *)
let buf = Buffer.create 65536 in
Hashtbl.iter (fun _sym v ->
@@ -1556,22 +1610,30 @@ let http_inject_shell_statics env =
) env.bindings;
let component_defs = Buffer.contents buf in
let component_hash = Digest.string component_defs |> Digest.to_hex in
(* Compute file hashes for cache busting *)
let sx_js_hash = file_hash (static_dir ^ "/scripts/sx-browser.js") in
let body_js_hash = file_hash (static_dir ^ "/scripts/body.js") in
let wasm_hash = file_hash (static_dir ^ "/wasm/sx_browser.bc.wasm.js") in
(* Read CSS for inline injection *)
let tw_css = read_css_file (static_dir ^ "/styles/tw.css") in
let basics_css = read_css_file (static_dir ^ "/styles/basics.css") in
let sx_css = basics_css ^ "\n" ^ tw_css in
ignore (env_bind env "__shell-component-defs" (String component_defs));
ignore (env_bind env "__shell-component-hash" (String component_hash));
ignore (env_bind env "__shell-pages-sx" (String ""));
ignore (env_bind env "__shell-sx-css" (String ""));
ignore (env_bind env "__shell-sx-css" (String sx_css));
ignore (env_bind env "__shell-sx-css-classes" (String ""));
ignore (env_bind env "__shell-asset-url" (String "/static"));
ignore (env_bind env "__shell-sx-js-hash" (String ""));
ignore (env_bind env "__shell-body-js-hash" (String ""));
ignore (env_bind env "__shell-wasm-hash" (String ""));
ignore (env_bind env "__shell-sx-js-hash" (String sx_js_hash));
ignore (env_bind env "__shell-body-js-hash" (String body_js_hash));
ignore (env_bind env "__shell-wasm-hash" (String wasm_hash));
ignore (env_bind env "__shell-head-scripts" Nil);
ignore (env_bind env "__shell-body-scripts" Nil);
ignore (env_bind env "__shell-inline-css" Nil);
ignore (env_bind env "__shell-inline-head-js" Nil);
ignore (env_bind env "__shell-init-sx" Nil);
Printf.eprintf "[sx-http] Shell statics injected (defs=%d hash=%s)\n%!"
(String.length component_defs) component_hash
Printf.eprintf "[sx-http] Shell statics: defs=%d hash=%s css=%d js=%s wasm=%s\n%!"
(String.length component_defs) component_hash (String.length sx_css) sx_js_hash wasm_hash
let http_setup_declarative_stubs env =
(* Stub declarative forms that are metadata-only — no-ops at render time. *)
@@ -1804,10 +1866,16 @@ let http_mode port =
match args with
| String code :: _ -> SxExpr (Printf.sprintf "(pre :class \"text-sm overflow-x-auto\" (code \"%s\"))" (escape_sx_string code))
| _ -> Nil))));
(* Static file directory *)
let static_dir = try Sys.getenv "SX_STATIC_DIR" with Not_found ->
let docker_path = project_dir ^ "/static" in
let dev_path = project_dir ^ "/shared/static" in
if Sys.file_exists docker_path then docker_path else dev_path in
Printf.eprintf "[sx-http] static_dir=%s\n%!" static_dir;
(* HTTP mode always expands components — bind once, shared across domains *)
ignore (env_bind env "expand-components?" (NativeFn ("expand-components?", fun _args -> Bool true)));
(* Inject shell statics *)
http_inject_shell_statics env;
(* Inject shell statics with real file hashes and CSS *)
http_inject_shell_statics env static_dir;
(* Response cache — path → full HTTP response string.
Populated during pre-warm, serves cached responses in <0.1ms.
Thread-safe: reads are lock-free (Hashtbl.find_opt is atomic for
@@ -1879,6 +1947,8 @@ let http_mode port =
Hashtbl.replace response_cache path resp;
resp
| None -> http_response ~status:404 "<h1>Not Found</h1>")
else if String.length path > 8 && String.sub path 0 8 = "/static/" then
serve_static_file static_dir path
else
http_response ~status:404 "<h1>Not Found</h1>"
end