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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user