Isomorphic SSR: server renders HTML body, client takes over with SX

Server now renders page content as HTML inside <div id="sx-root">,
visible immediately before JavaScript loads. The SX source is still
included in a <script data-mount="#sx-root"> tag for client hydration.

SSR pipeline: after aser produces the SX wire format, parse and
render-to-html it (~17ms for a 22KB page). Islands with reactive
state gracefully fall back to empty — client hydrates them.

Supporting changes:
- Load signals.sx into OCaml kernel (reactive primitives for island SSR)
- Add cek-call and context to kernel env (needed by signals/deref)
- Island-aware component accessors in sx_types.ml
- render-to-html handles Island values (renders as component with fallback)
- Fix 431 (Request Header Fields Too Large): replace SX-Components
  header (full component name list) with SX-Components-Hash (12 chars)
- CORS allow SX-Components-Hash header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 14:01:41 +00:00
parent 9bd4863ce1
commit 894321db18
9 changed files with 103 additions and 28 deletions

View File

@@ -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"
)

View File

@@ -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;
}

View File

@@ -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():

View File

@@ -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)

View File

@@ -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