diff --git a/docker-compose.dev-sx-native.yml b/docker-compose.dev-sx-native.yml index 7e5b4959..dddfe124 100644 --- a/docker-compose.dev-sx-native.yml +++ b/docker-compose.dev-sx-native.yml @@ -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 diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 76bac756..ffa7056e 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -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 "

Not Found

") + else if String.length path > 8 && String.sub path 0 8 = "/static/" then + serve_static_file static_dir path else http_response ~status:404 "

Not Found

" end