diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 4808b27..6d6c6ff 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -425,7 +425,7 @@ let make_server_env () = (* context — scope lookup. The CEK handles this as a special form by walking continuation frames, but compiled VM code needs it as a function that reads from the scope_stacks hashtable. *) - bind "sx-context" (fun args -> + let context_impl = NativeFn ("context", fun args -> match args with | [String name] | [String name; _] -> let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in @@ -433,7 +433,9 @@ let make_server_env () = | v :: _, _ -> v | [], [_; default_val] -> default_val | [], _ -> Nil) - | _ -> Nil); + | _ -> Nil) in + ignore (env_bind env "sx-context" context_impl); + ignore (env_bind env "context" context_impl); (* qq-expand-runtime — quasiquote expansion at runtime. The bytecode compiler emits CALL_PRIM "qq-expand-runtime" for @@ -464,6 +466,12 @@ let make_server_env () = | [fn_val; List call_args] -> Sx_ref.eval_expr (List (fn_val :: call_args)) (Env env) | _ -> raise (Eval_error "call-lambda: expected (fn args env?)")); + bind "cek-call" (fun args -> + match args with + | [fn_val; List call_args] -> Sx_ref.cek_call fn_val (List call_args) + | [fn_val; Nil] -> Sx_ref.cek_call fn_val (List []) + | [fn_val] -> Sx_ref.cek_call fn_val (List []) + | _ -> Nil); bind "expand-macro" (fun args -> match args with | [Macro m; List macro_args; Env e] -> @@ -1072,6 +1080,22 @@ let rec dispatch env cmd = in let body_final = flush_batched_io body_str in let t2 = Unix.gettimeofday () in + (* Phase 1b: render the aser'd SX to HTML for isomorphic SSR. + The aser output is flat (all components expanded, just HTML tags), + so render-to-html is cheap — no component lookups needed. *) + let body_html = + try + let body_exprs = Sx_parser.parse_all body_final in + let body_expr = match body_exprs with + | [e] -> e | [] -> Nil | _ -> List (Symbol "<>" :: body_exprs) + in + Sx_render.render_to_html body_expr env + with e -> + Printf.eprintf "[ssr] render-to-html failed: %s\n%!" (Printexc.to_string e); + "" (* fallback: client renders from SX source. Islands with + reactive state may fail SSR — client hydrates them. *) + in + let t2b = Unix.gettimeofday () in (* Phase 2: render shell with body + all kwargs. Resolve symbol references (e.g. __shell-component-defs) to their values from the env — these were pre-injected by the bridge. *) @@ -1082,13 +1106,15 @@ let rec dispatch env cmd = with _ -> try Sx_primitives.get_primitive s with _ -> v) | _ -> v ) shell_kwargs in - let shell_args = Keyword "page-sx" :: String body_final :: resolved_kwargs in + let shell_args = Keyword "page-sx" :: String body_final + :: Keyword "body-html" :: String body_html + :: resolved_kwargs in let shell_call = List (Symbol "~shared:shell/sx-page-shell" :: shell_args) in let html = Sx_render.render_to_html shell_call env in let t3 = Unix.gettimeofday () in - Printf.eprintf "[sx-page-full] aser=%.3fs io=%.3fs shell=%.3fs total=%.3fs body=%d html=%d\n%!" - (t1 -. t0) (t2 -. t1) (t3 -. t2) (t3 -. t0) - (String.length body_final) (String.length html); + Printf.eprintf "[sx-page-full] aser=%.3fs io=%.3fs ssr=%.3fs shell=%.3fs total=%.3fs body=%d ssr=%d html=%d\n%!" + (t1 -. t0) (t2 -. t1) (t2b -. t2) (t3 -. t2b) (t3 -. t0) + (String.length body_final) (String.length body_html) (String.length html); send_ok_string html with | Eval_error msg -> diff --git a/hosts/ocaml/lib/sx_render.ml b/hosts/ocaml/lib/sx_render.ml index 79e5399..4b3c2e9 100644 --- a/hosts/ocaml/lib/sx_render.ml +++ b/hosts/ocaml/lib/sx_render.ml @@ -256,6 +256,18 @@ and render_list_to_html head args env = let v = env_get env name in (match v with | Component _ -> render_component v args env + | Island i -> + (* Islands: render initial HTML server-side (like React SSR). + Log failures so we can fix them. *) + (try + let c = { c_name = i.i_name; c_params = i.i_params; + c_has_children = i.i_has_children; c_body = i.i_body; + c_closure = i.i_closure; c_affinity = "client"; + c_compiled = None } in + render_component (Component c) args env + with e -> + Printf.eprintf "[ssr-island] ~%s FAILED: %s\n%!" i.i_name (Printexc.to_string e); + "") | Macro m -> let expanded = expand_macro m args env in do_render_to_html expanded env diff --git a/hosts/ocaml/lib/sx_types.ml b/hosts/ocaml/lib/sx_types.ml index 7ad022c..8425fd2 100644 --- a/hosts/ocaml/lib/sx_types.ml +++ b/hosts/ocaml/lib/sx_types.ml @@ -347,26 +347,32 @@ let set_lambda_name l n = match l with let component_name = function | Component c -> String c.c_name + | Island i -> String i.i_name | v -> raise (Eval_error ("Expected component, got " ^ type_of v)) let component_params = function | Component c -> List (List.map (fun s -> String s) c.c_params) + | Island i -> List (List.map (fun s -> String s) i.i_params) | v -> raise (Eval_error ("Expected component, got " ^ type_of v)) let component_body = function | Component c -> c.c_body + | Island i -> i.i_body | v -> raise (Eval_error ("Expected component, got " ^ type_of v)) let component_closure = function | Component c -> Env c.c_closure + | Island i -> Env i.i_closure | v -> raise (Eval_error ("Expected component, got " ^ type_of v)) let component_has_children = function | Component c -> Bool c.c_has_children + | Island i -> Bool i.i_has_children | v -> raise (Eval_error ("Expected component, got " ^ type_of v)) let component_affinity = function | Component c -> String c.c_affinity + | Island _ -> String "client" | _ -> String "auto" let macro_params = function diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 9512277..65e86c3 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -344,7 +344,7 @@ def create_base_app( response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Allow-Headers"] = ( - "SX-Request, SX-Target, SX-Current-URL, SX-Components, SX-Css, " + "SX-Request, SX-Target, SX-Current-URL, SX-Components, SX-Components-Hash, SX-Css, " "HX-Request, HX-Target, HX-Current-URL, HX-Trigger, " "Content-Type, X-CSRFToken" ) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 6b37254..6297351 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-23T08:59:15Z"; + var SX_VERSION = "2026-03-23T13:21:57Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1582,7 +1582,13 @@ PRIMITIVES["step-sf-lambda"] = stepSfLambda; var val = NIL; var body = NIL; (isSxTruthy((isSxTruthy((len(restArgs) >= 2)) && isSxTruthy((typeOf(first(restArgs)) == "keyword")) && (keywordName(first(restArgs)) == "value"))) ? ((val = trampoline(evalExpr(nth(restArgs, 1), env))), (body = slice(restArgs, 2))) : (body = restArgs)); - return (isSxTruthy(isEmpty(body)) ? makeCekValue(NIL, env, kont) : (isSxTruthy((len(body) == 1)) ? makeCekState(first(body), env, kontPush(makeScopeAccFrame(name, val, [], env), kont)) : makeCekState(first(body), env, kontPush(makeScopeAccFrame(name, val, rest(body), env), kont)))); + scopePush(name, val); + return (function() { + var result = NIL; + { var _c = body; for (var _i = 0; _i < _c.length; _i++) { var expr = _c[_i]; result = trampoline(evalExpr(expr, env)); } } + scopePop(name); + return makeCekValue(result, env, kont); +})(); })(); }; PRIMITIVES["step-sf-scope"] = stepSfScope; @@ -1591,7 +1597,13 @@ PRIMITIVES["step-sf-scope"] = stepSfScope; var name = trampoline(evalExpr(first(args), env)); var val = trampoline(evalExpr(nth(args, 1), env)); var body = slice(args, 2); - return (isSxTruthy(isEmpty(body)) ? makeCekValue(NIL, env, kont) : (isSxTruthy((len(body) == 1)) ? makeCekState(first(body), env, kontPush(makeProvideFrame(name, val, [], env), kont)) : makeCekState(first(body), env, kontPush(makeProvideFrame(name, val, rest(body), env), kont)))); + scopePush(name, val); + return (function() { + var result = NIL; + { var _c = body; for (var _i = 0; _i < _c.length; _i++) { var expr = _c[_i]; result = trampoline(evalExpr(expr, env)); } } + scopePop(name); + return makeCekValue(result, env, kont); +})(); })(); }; PRIMITIVES["step-sf-provide"] = stepSfProvide; @@ -1599,8 +1611,8 @@ PRIMITIVES["step-sf-provide"] = stepSfProvide; var stepSfContext = function(args, env, kont) { return (function() { var name = trampoline(evalExpr(first(args), env)); var defaultVal = (isSxTruthy((len(args) >= 2)) ? trampoline(evalExpr(nth(args, 1), env)) : NIL); - var frame = kontFindProvide(kont, name); - return (isSxTruthy(frame) ? makeCekValue(get(frame, "value"), env, kont) : (isSxTruthy((len(args) >= 2)) ? makeCekValue(defaultVal, env, kont) : error((String("No provider for: ") + String(name))))); + var val = scopePeek(name); + return makeCekValue((isSxTruthy(isNil(val)) ? defaultVal : val), env, kont); })(); }; PRIMITIVES["step-sf-context"] = stepSfContext; @@ -1608,16 +1620,16 @@ PRIMITIVES["step-sf-context"] = stepSfContext; var stepSfEmit = function(args, env, kont) { return (function() { var name = trampoline(evalExpr(first(args), env)); var val = trampoline(evalExpr(nth(args, 1), env)); - var frame = kontFindScopeAcc(kont, name); - return (isSxTruthy(frame) ? (append_b(get(frame, "emitted"), val), makeCekValue(NIL, env, kont)) : error((String("No scope for emit!: ") + String(name)))); + scopeEmit(name, val); + return makeCekValue(NIL, env, kont); })(); }; PRIMITIVES["step-sf-emit"] = stepSfEmit; // step-sf-emitted var stepSfEmitted = function(args, env, kont) { return (function() { var name = trampoline(evalExpr(first(args), env)); - var frame = kontFindScopeAcc(kont, name); - return (isSxTruthy(frame) ? makeCekValue(get(frame, "emitted"), env, kont) : error((String("No scope for emitted: ") + String(name)))); + var val = scopePeek(name); + return makeCekValue((isSxTruthy(isNil(val)) ? [] : val), env, kont); })(); }; PRIMITIVES["step-sf-emitted"] = stepSfEmitted; @@ -3573,9 +3585,10 @@ PRIMITIVES["get-verb-info"] = getVerbInfo; var targetSel = domGetAttr(el, "sx-target"); return (isSxTruthy(targetSel) ? dictSet(headers, "SX-Target", targetSel) : NIL); })(); - if (isSxTruthy(!isSxTruthy(isEmpty(loadedComponents)))) { - headers["SX-Components"] = join(",", loadedComponents); -} + (function() { + var compHash = domGetAttr(domQuery("script[data-components][data-hash]"), "data-hash"); + return (isSxTruthy(compHash) ? dictSet(headers, "SX-Components-Hash", compHash) : NIL); +})(); if (isSxTruthy(cssHash)) { headers["SX-Css"] = cssHash; } diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index c706d85..71d51b1 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -521,8 +521,18 @@ def components_for_request(source: str = "", elif extra_names: needed = extra_names - loaded_raw = request.headers.get("SX-Components", "") - loaded = set(loaded_raw.split(",")) if loaded_raw else set() + # Check hash first (new): if client hash matches current, skip all defs. + # Fall back to legacy name list (SX-Components) for backward compat. + comp_hash_header = request.headers.get("SX-Components-Hash", "") + if comp_hash_header: + from .jinja_bridge import components_for_page + _, current_hash = components_for_page("", service=None) + if comp_hash_header == current_hash: + return "" # client has everything + loaded = set() # hash mismatch — send all needed + else: + loaded_raw = request.headers.get("SX-Components", "") + loaded = set(loaded_raw.split(",")) if loaded_raw else set() parts = [] for key, val in _COMPONENT_ENV.items(): diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 9dc0bb7..2a611f8 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -397,10 +397,11 @@ class OcamlBridge: # All directories loaded into the Python env all_dirs = list(set(_watched_dirs) | _dirs_from_cache) - # Web adapters (aser lives in adapter-sx.sx) — only load specific files + # Web adapters + signals (aser lives in adapter-sx.sx, + # signals.sx provides reactive primitives for island SSR) web_dir = os.path.join(os.path.dirname(__file__), "../../web") if os.path.isdir(web_dir): - for web_file in ["adapter-sx.sx"]: + for web_file in ["signals.sx", "adapter-sx.sx"]: path = os.path.normpath(os.path.join(web_dir, web_file)) if os.path.isfile(path): all_files.append(path) diff --git a/shared/sx/templates/shell.sx b/shared/sx/templates/shell.sx index fdd6a31..3b0c406 100644 --- a/shared/sx/templates/shell.sx +++ b/shared/sx/templates/shell.sx @@ -15,6 +15,7 @@ (sx-css :as string?) (sx-css-classes :as string?) (component-hash :as string?) (component-defs :as string?) (pages-sx :as string?) (page-sx :as string?) + (body-html :as string?) (asset-url :as string) (sx-js-hash :as string) (body-js-hash :as string?) (head-scripts :as list?) (inline-css :as string?) (inline-head-js :as string?) (init-sx :as string?) (body-scripts :as list?)) @@ -65,6 +66,8 @@ details.group{overflow:hidden}details.group>summary{list-style:none}details.grou .sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex} .js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")))) (body :class "bg-stone-50 text-stone-900" + ;; Server-rendered HTML — visible immediately before JS loads + (div :id "sx-root" (raw! (or body-html ""))) (script :type "text/sx" :data-components true :data-hash component-hash (raw! (or component-defs ""))) (when init-sx @@ -72,7 +75,7 @@ details.group{overflow:hidden}details.group>summary{list-style:none}details.grou (raw! init-sx))) (script :type "text/sx-pages" (raw! (or pages-sx ""))) - (script :type "text/sx" :data-mount "body" + (script :type "text/sx" :data-mount "#sx-root" (raw! (or page-sx ""))) (script :src (str asset-url "/scripts/sx-browser.js?v=" sx-js-hash)) ;; Body scripts — configurable per app diff --git a/web/engine.sx b/web/engine.sx index 898777e..feab773 100644 --- a/web/engine.sx +++ b/web/engine.sx @@ -125,10 +125,14 @@ (when target-sel (dict-set! headers "SX-Target" target-sel))) - ;; Loaded component names - (when (not (empty? loaded-components)) - (dict-set! headers "SX-Components" - (join "," loaded-components))) + ;; Send component hash instead of full name list to avoid 431 + ;; (Request Header Fields Too Large) with many loaded components. + ;; Server uses hash to decide whether to send component definitions. + (let ((comp-hash (dom-get-attr + (dom-query "script[data-components][data-hash]") + "data-hash"))) + (when comp-hash + (dict-set! headers "SX-Components-Hash" comp-hash))) ;; CSS class hash (when css-hash