Server cleanup: extract app-specific config into SX
Phase 1 Step 2 of architecture roadmap. The OCaml HTTP server is now generic — all sx_docs-specific values (layout components, path prefix, title, warmup paths, handler prefixes, CSS/JS, client libs) move into sx/sx/app-config.sx as a __app-config dict. Server reads config at startup with hardcoded defaults as fallback, so it works with no config, partial config, or full config. Removed: 9 demo data stubs, stepper cookie cache logic, page-functions.sx directory heuristic. Added: 29-test server config test suite covering standard, custom, no-config, and minimal-config scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,16 +146,35 @@ let io_counter = ref 0
|
||||
let _request_cookies = Sx_scope.request_cookies
|
||||
let _scope_stacks = Sx_scope.scope_stacks
|
||||
|
||||
(* ── App config ─────────────────────────────────────────────────────── *)
|
||||
(* 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 []
|
||||
|
||||
let get_app_config key default =
|
||||
match !_app_config with
|
||||
| Some d -> (match Hashtbl.find_opt d key with Some v -> v | None -> default)
|
||||
| None -> default
|
||||
|
||||
let get_app_str key default =
|
||||
match get_app_config key (String default) with String s -> s | _ -> default
|
||||
|
||||
let get_app_list key default =
|
||||
match get_app_config key Nil with
|
||||
| List l | ListRef { contents = l } ->
|
||||
List.filter_map (function String s -> Some s | _ -> None) l
|
||||
| _ -> default
|
||||
|
||||
(** Helpers safe to defer — pure functions whose results are only used
|
||||
as rendering output (inlined into SX wire format), not in control flow. *)
|
||||
let batchable_helpers = [
|
||||
let batchable_helpers = ref [
|
||||
"highlight"; "component-source"
|
||||
]
|
||||
|
||||
let is_batchable name args =
|
||||
name = "helper" &&
|
||||
match args with
|
||||
| String h :: _ -> List.mem h batchable_helpers
|
||||
| String h :: _ -> List.mem h !batchable_helpers
|
||||
| _ -> false
|
||||
|
||||
(** Read an io-response from stdin, discarding stale messages from old epochs. *)
|
||||
@@ -1615,7 +1634,8 @@ let http_render_page env path headers =
|
||||
let page_ast = match Hashtbl.find_opt d "page-ast" with Some v -> v | _ -> Nil in
|
||||
let page_ast = if page_ast = Nil then error_page_ast "Page returned empty content" else page_ast in
|
||||
begin
|
||||
let wrapped = List [Symbol "~layouts/doc"; Keyword "path"; String nav_path; page_ast] in
|
||||
let inner_layout = get_app_str "inner-layout" "~layouts/doc" in
|
||||
let wrapped = List [Symbol inner_layout; Keyword "path"; String nav_path; page_ast] in
|
||||
if is_ajax then begin
|
||||
(* AJAX: return SX wire format (aser output) with text/sx content type *)
|
||||
let body_result =
|
||||
@@ -1628,7 +1648,8 @@ let http_render_page env path headers =
|
||||
Some body_str
|
||||
end else begin
|
||||
(* Full page: aser → SSR → shell *)
|
||||
let full_ast = List [Symbol "~shared:layout/app-body"; Keyword "content"; wrapped] in
|
||||
let outer_layout = get_app_str "outer-layout" "~shared:layout/app-body" in
|
||||
let full_ast = List [Symbol outer_layout; Keyword "content"; wrapped] in
|
||||
let page_source = serialize_value full_ast in
|
||||
let t1 = Unix.gettimeofday () in
|
||||
let body_result =
|
||||
@@ -1650,7 +1671,7 @@ let http_render_page env path headers =
|
||||
let t3 = Unix.gettimeofday () in
|
||||
let get_shell name = try env_get env ("__shell-" ^ name) with _ -> Nil in
|
||||
let shell_args = [
|
||||
Keyword "title"; String "SX"; Keyword "csrf"; String "";
|
||||
Keyword "title"; String (get_app_str "title" "SX"); Keyword "csrf"; String "";
|
||||
Keyword "page-sx"; String page_source;
|
||||
Keyword "body-html"; String body_html;
|
||||
Keyword "component-defs"; get_shell "component-defs";
|
||||
@@ -1666,7 +1687,8 @@ let http_render_page env path headers =
|
||||
Keyword "init-sx"; get_shell "init-sx";
|
||||
Keyword "meta-html"; String "";
|
||||
] in
|
||||
let shell_call = List (Symbol "~shared:shell/sx-page-shell" :: shell_args) in
|
||||
let shell_sym = get_app_str "shell" "~shared:shell/sx-page-shell" in
|
||||
let shell_call = List (Symbol shell_sym :: shell_args) in
|
||||
let html =
|
||||
if env_has env "render-to-html" then
|
||||
let render_call = List [Symbol "render-to-html";
|
||||
@@ -1769,11 +1791,8 @@ let http_inject_shell_statics env static_dir sx_sxc =
|
||||
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
||||
Filename.dirname (Filename.dirname static_dir) in
|
||||
let templates_dir = project_dir ^ "/shared/sx/templates" in
|
||||
let client_libs = [
|
||||
templates_dir ^ "/tw-layout.sx";
|
||||
templates_dir ^ "/tw-type.sx";
|
||||
templates_dir ^ "/tw.sx";
|
||||
] in
|
||||
let client_lib_names = get_app_list "client-libs" ["tw-layout.sx"; "tw-type.sx"; "tw.sx"] in
|
||||
let client_libs = List.map (fun name -> templates_dir ^ "/" ^ name) client_lib_names in
|
||||
List.iter (fun path ->
|
||||
if Sys.file_exists path then begin
|
||||
let src = In_channel.with_open_text path In_channel.input_all in
|
||||
@@ -1809,9 +1828,9 @@ let http_inject_shell_statics env static_dir sx_sxc =
|
||||
let platform_hash = file_hash (static_dir ^ "/wasm/sx-platform-2.js") in
|
||||
let sxbc_hash = sxbc_combined_hash (static_dir ^ "/wasm") 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
|
||||
let css_file_names = get_app_list "css-files" ["basics.css"; "tw.css"] in
|
||||
let sx_css = String.concat "\n" (List.map (fun name ->
|
||||
read_css_file (static_dir ^ "/styles/" ^ name)) css_file_names) in
|
||||
ignore (env_bind env "__shell-component-defs" (String component_defs));
|
||||
ignore (env_bind env "__shell-component-hash" (String component_hash));
|
||||
(* Build minimal pages-sx from defpage definitions in loaded .sx files.
|
||||
@@ -1840,11 +1859,13 @@ let http_inject_shell_statics env static_dir sx_sxc =
|
||||
| Some v -> serialize_value v | _ -> "" in
|
||||
let has_data = match extract_kw "data" rest with
|
||||
| Some _ -> true | None -> false in
|
||||
if path_val <> "" then
|
||||
if path_val <> "" then begin
|
||||
_defpage_paths := path_val :: !_defpage_paths;
|
||||
Buffer.add_string pages_buf
|
||||
(Printf.sprintf "{:name \"%s\" :path \"%s\" :auth \"public\" :has-data %s :content \"%s\"}\n"
|
||||
name path_val (if has_data then "true" else "false")
|
||||
(escape_sx_string content_val))
|
||||
end
|
||||
| _ -> ()
|
||||
) exprs
|
||||
with _ -> ()
|
||||
@@ -1864,16 +1885,17 @@ let http_inject_shell_statics env static_dir sx_sxc =
|
||||
ignore (env_bind env "__shell-sxbc-hash" (String sxbc_hash));
|
||||
ignore (env_bind env "__shell-inline-css" Nil);
|
||||
ignore (env_bind env "__shell-inline-head-js" Nil);
|
||||
(* init-sx: trigger client-side render when sx-root is empty (SSR failed).
|
||||
The boot code hydrates existing islands but doesn't do fresh render.
|
||||
This script forces a render from page-sx after boot completes. *)
|
||||
ignore (env_bind env "__shell-init-sx" (String
|
||||
(* init-sx: trigger client-side render when sx-root is empty (SSR failed). *)
|
||||
let default_init_sx =
|
||||
"document.addEventListener('sx:boot-done', function() { \
|
||||
var root = document.getElementById('sx-root'); \
|
||||
if (root && !root.innerHTML.trim() && typeof SX !== 'undefined' && SX.renderPage) { \
|
||||
SX.renderPage(); \
|
||||
} \
|
||||
});"));
|
||||
});" in
|
||||
let init_sx = match get_app_config "init-script" (Keyword "default") with
|
||||
| String s -> s | _ -> default_init_sx in
|
||||
ignore (env_bind env "__shell-init-sx" (String init_sx));
|
||||
Printf.eprintf "[sx-http] Shell statics: defs=%d hash=%s css=%d wasm=%s platform=%s sxbc=%s\n%!"
|
||||
(String.length component_defs) component_hash (String.length sx_css) wasm_hash platform_hash sxbc_hash
|
||||
|
||||
@@ -2292,17 +2314,7 @@ let http_setup_page_helpers env =
|
||||
with _ -> String (";; component " ^ name ^ " not found"))
|
||||
| _ -> raise (Eval_error "component-source: expected (name)"));
|
||||
|
||||
(* Stub remaining demo/action helpers that need real IO *)
|
||||
let stub name = bind name (fun _args -> Nil) in
|
||||
stub "run-spec-tests";
|
||||
stub "run-modular-tests";
|
||||
stub "streaming-demo-data";
|
||||
stub "affinity-demo-data";
|
||||
stub "optimistic-demo-data";
|
||||
stub "action:add-demo-item";
|
||||
stub "offline-demo-data";
|
||||
stub "prove-data";
|
||||
stub "page-helpers-demo-data"
|
||||
ignore bind (* suppress unused warning *)
|
||||
|
||||
let http_mode port =
|
||||
let env = make_server_env () in
|
||||
@@ -2325,11 +2337,16 @@ let http_mode port =
|
||||
let shared_sx = try Sys.getenv "SX_SHARED_DIR" with Not_found ->
|
||||
project_dir ^ "/shared/sx/templates" in
|
||||
let sx_sx = try Sys.getenv "SX_COMPONENTS_DIR" with Not_found ->
|
||||
(* Docker: /app/sx, dev: /project/sx/sx *)
|
||||
let docker_path = project_dir ^ "/sx" in
|
||||
let dev_path = project_dir ^ "/sx/sx" in
|
||||
if Sys.file_exists (docker_path ^ "/page-functions.sx") then docker_path
|
||||
else dev_path in
|
||||
if Sys.file_exists docker_path && Sys.is_directory docker_path
|
||||
&& not (Sys.file_exists (docker_path ^ "/sx")) (* avoid matching parent of sx/sx *)
|
||||
then docker_path
|
||||
else if Sys.file_exists dev_path && Sys.is_directory dev_path then dev_path
|
||||
else begin
|
||||
Printf.eprintf "[sx-http] WARNING: no components dir at %s or %s\n%!" docker_path dev_path;
|
||||
docker_path
|
||||
end in
|
||||
(* Expose project paths to SX helpers *)
|
||||
ignore (env_bind env "_project-dir" (String project_dir));
|
||||
ignore (env_bind env "_spec-dir" (String spec_base));
|
||||
@@ -2387,6 +2404,15 @@ let http_mode port =
|
||||
load_dir sx_sx;
|
||||
let t1 = Unix.gettimeofday () in
|
||||
Printf.eprintf "[sx-http] All files loaded in %.3fs\n%!" (t1 -. t0);
|
||||
(* Extract app config from __app-config dict *)
|
||||
(try match env_get env "__app-config" with
|
||||
| Dict d ->
|
||||
_app_config := Some d;
|
||||
batchable_helpers := get_app_list "batchable-helpers" ["highlight"; "component-source"];
|
||||
Printf.eprintf "[sx-http] App config loaded: title=%s prefix=%s\n%!"
|
||||
(get_app_str "title" "?") (get_app_str "path-prefix" "?")
|
||||
| _ -> Printf.eprintf "[sx-http] WARNING: __app-config is not a dict\n%!"
|
||||
with _ -> Printf.eprintf "[sx-http] No __app-config found, using defaults\n%!");
|
||||
(* SSR overrides — rebind browser-only functions AFTER .sx files load.
|
||||
effect and register-in-scope are no-ops on the server; the SX definitions
|
||||
from signals.sx are replaced so effect bodies never execute during SSR. *)
|
||||
@@ -2497,14 +2523,15 @@ let http_mode port =
|
||||
Printf.eprintf "[cache] %s → not found\n%!" path
|
||||
in
|
||||
|
||||
(* Pre-warm + cache all key pages *)
|
||||
let _warmup_paths = ["/sx/"; "/sx/(geography)"; "/sx/(language)"; "/sx/(applications)";
|
||||
"/sx/(geography.(reactive.(examples)))";
|
||||
"/sx/(applications.(sxtp))"; "/sx/(geography.(cek))";
|
||||
"/sx/(geography.(reactive))"; "/sx/(geography.(hypermedia))";
|
||||
] in
|
||||
(* Pre-warm + cache key pages — from config or just homepage *)
|
||||
let warmup_paths = match get_app_config "warmup-paths" (Keyword "auto") with
|
||||
| Keyword "auto" -> [get_app_str "path-prefix" "/sx/"]
|
||||
| List l | ListRef { contents = l } ->
|
||||
List.filter_map (function String s -> Some s | _ -> None) l
|
||||
| _ -> [get_app_str "path-prefix" "/sx/"]
|
||||
in
|
||||
let t_warm = Unix.gettimeofday () in
|
||||
List.iter cache_response _warmup_paths;
|
||||
List.iter cache_response warmup_paths;
|
||||
let n_cached = Hashtbl.length response_cache in
|
||||
Printf.eprintf "[sx-http] Pre-warmed %d pages in %.3fs\n%!"
|
||||
n_cached (Unix.gettimeofday () -. t_warm);
|
||||
@@ -2619,13 +2646,21 @@ let http_mode port =
|
||||
| None -> ())
|
||||
) (String.split_on_char ';' cookie_str)
|
||||
| None -> ());
|
||||
let app_prefix = get_app_str "path-prefix" "/sx/" in
|
||||
let app_prefix_bare = if String.length app_prefix > 1
|
||||
&& app_prefix.[String.length app_prefix - 1] = '/'
|
||||
then String.sub app_prefix 0 (String.length app_prefix - 1) else app_prefix in
|
||||
let app_home = get_app_str "home-path" app_prefix in
|
||||
let debug_prefix = app_prefix ^ "_debug/" in
|
||||
let debug_prefix_len = String.length debug_prefix in
|
||||
if path = "/" then begin
|
||||
write_response fd (http_redirect "/sx/"); true
|
||||
write_response fd (http_redirect app_home); true
|
||||
end else
|
||||
(* Debug endpoint — runs on main thread, no render worker *)
|
||||
let raw_decoded = url_decode raw_path in
|
||||
if String.length path > 11 && String.sub path 0 11 = "/sx/_debug/" then begin
|
||||
let cmd = String.sub raw_decoded 11 (String.length raw_decoded - 11) in
|
||||
if String.length path > debug_prefix_len
|
||||
&& String.sub path 0 debug_prefix_len = debug_prefix then begin
|
||||
let cmd = String.sub raw_decoded debug_prefix_len (String.length raw_decoded - debug_prefix_len) in
|
||||
let query_start = try String.index cmd '?' with Not_found -> String.length cmd in
|
||||
let action = String.sub cmd 0 query_start in
|
||||
let query = if query_start < String.length cmd - 1
|
||||
@@ -2676,8 +2711,10 @@ let http_mode port =
|
||||
else if String.sub s i (String.length sub) = sub then true
|
||||
else has_sub s sub (i + 1) in
|
||||
has_sub path "(api." 0 in
|
||||
let is_sx = path = "/sx/" || path = "/sx"
|
||||
|| (String.length path > 4 && String.sub path 0 4 = "/sx/") in
|
||||
let app_prefix_len = String.length app_prefix in
|
||||
let is_sx = path = app_prefix || path = app_prefix_bare
|
||||
|| (String.length path > app_prefix_len
|
||||
&& String.sub path 0 app_prefix_len = app_prefix) in
|
||||
if is_sx && is_handler_path then begin
|
||||
(* Handler dispatch — slug + path param extraction, method-based lookup, param binding *)
|
||||
let response =
|
||||
@@ -2706,8 +2743,9 @@ let http_mode port =
|
||||
let req_method = String.uppercase_ascii !_req_method in
|
||||
let try_key k = try let v = env_get env k in
|
||||
if v <> Nil then Some (k, v) else None with _ -> None in
|
||||
(* Try multiple handler name patterns: ex-slug, reactive-slug, slug *)
|
||||
let prefixes = ["handler:ex-" ^ slug; "handler:reactive-" ^ slug; "handler:" ^ slug] in
|
||||
let handler_prefix_list = get_app_list "handler-prefixes"
|
||||
["handler:ex-"; "handler:reactive-"; "handler:"] in
|
||||
let prefixes = List.map (fun p -> p ^ slug) handler_prefix_list in
|
||||
let suffixes = match req_method with
|
||||
| "POST" -> List.concat_map (fun base -> [base; base ^ "-save"; base ^ "-submit"]) prefixes
|
||||
| "PUT" | "PATCH" -> List.concat_map (fun base -> [base; base ^ "-put"; base ^ "-save"]) prefixes
|
||||
@@ -2763,10 +2801,7 @@ let http_mode port =
|
||||
in
|
||||
write_response fd response; true
|
||||
end else if is_sx then begin
|
||||
let has_stepper_cookie = Hashtbl.mem _request_cookies "sx-home-stepper" in
|
||||
let cache_key = if is_ajax then "ajax:" ^ path
|
||||
else if has_stepper_cookie then path ^ ":step=" ^ (try Hashtbl.find _request_cookies "sx-home-stepper" with Not_found -> "")
|
||||
else path in
|
||||
let cache_key = if is_ajax then "ajax:" ^ path else path in
|
||||
match Hashtbl.find_opt response_cache cache_key with
|
||||
| Some cached -> write_response fd cached; true
|
||||
| None ->
|
||||
|
||||
Reference in New Issue
Block a user