SxExpr aser wire format fix + Playwright test infrastructure + blob protocol
Aser serialization: aser-call/fragment now return SxExpr instead of String. serialize/inspect passes SxExpr through unquoted, preventing the double- escaping (\" → \\\" ) that broke client-side parsing when aser wire format was output via raw! into <script> tags. Added make-sx-expr + sx-expr-source primitives to OCaml and JS hosts. Binary blob protocol: eval, aser, aser-slot, and sx-page-full now send SX source as length-prefixed blobs instead of escaped strings. Eliminates pipe desync from concurrent requests and removes all string-escape round-trips between Python and OCaml. Bridge safety: re-entrancy guard (_in_io_handler) raises immediately if an IO handler tries to call the bridge, preventing silent deadlocks. Fetch error logging: orchestration.sx error callback now logs method + URL via log-warn. Platform catches (fetchAndRestore, fetchPreload, bindBoostForm) also log errors instead of silently swallowing them. Transpiler fixes: makeEnv, scopePeek, scopeEmit, makeSxExpr added as platform function definitions + transpiler mappings — were referenced in transpiled code but never defined as JS functions. Playwright test infrastructure: - nav() captures JS errors and fails fast with the actual error message - Checks for [object Object] rendering artifacts - New tests: delete-row interaction, full page refresh, back button, direct load with fresh context, code block content verification - Default base URL changed to localhost:8013 (standalone dev server) - docker-compose.dev-sx.yml: port 8013 exposed for local testing - test-sx-build.sh: build + unit tests + Playwright smoke tests Geography content: index page component written (sx/sx/geography/index.sx) describing OCaml evaluator, wire formats, rendering pipeline, and topic links. Wiring blocked by aser-expand-component children passing issue. Tests: 1080/1080 JS, 952/952 OCaml, 66/66 Playwright Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-19T14:05:23Z";
|
||||
var SX_VERSION = "2026-03-22T20:34:05Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -111,6 +111,7 @@
|
||||
if (x._spread) return "spread";
|
||||
if (x._macro) return "macro";
|
||||
if (x._raw) return "raw-html";
|
||||
if (x._sx_expr) return "sx-expr";
|
||||
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
|
||||
if (Array.isArray(x)) return "list";
|
||||
if (typeof x === "object") return "dict";
|
||||
@@ -510,8 +511,10 @@
|
||||
PRIMITIVES["emit!"] = sxEmit;
|
||||
PRIMITIVES["emitted"] = sxEmitted;
|
||||
// Aliases for aser adapter (avoids CEK special form conflict on server)
|
||||
PRIMITIVES["scope-emit!"] = sxEmit;
|
||||
PRIMITIVES["scope-peek"] = sxEmitted;
|
||||
var scopeEmit = sxEmit;
|
||||
var scopePeek = sxEmitted;
|
||||
PRIMITIVES["scope-emit!"] = scopeEmit;
|
||||
PRIMITIVES["scope-peek"] = scopePeek;
|
||||
|
||||
|
||||
function isPrimitive(name) { return name in PRIMITIVES; }
|
||||
@@ -592,6 +595,7 @@
|
||||
// escape-html and escape-attr are now library functions defined in render.sx
|
||||
function rawHtmlContent(r) { return r.html; }
|
||||
function makeRawHtml(s) { return { _raw: true, html: s }; }
|
||||
function makeSxExpr(s) { return { _sx_expr: true, source: s }; }
|
||||
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
|
||||
|
||||
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
|
||||
@@ -848,7 +852,7 @@
|
||||
function escapeString(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
||||
}
|
||||
function sxExprSource(e) { return typeof e === "string" ? e : String(e); }
|
||||
function sxExprSource(e) { return typeof e === "string" ? e : (e && e.source ? e.source : String(e)); }
|
||||
var charFromCode = PRIMITIVES["char-from-code"];
|
||||
|
||||
|
||||
@@ -1646,7 +1650,7 @@ PRIMITIVES["step-sf-deref"] = stepSfDeref;
|
||||
// cek-call
|
||||
var cekCall = function(f, args) { return (function() {
|
||||
var a = (isSxTruthy(isNil(args)) ? [] : args);
|
||||
return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, {}, a, [])) : NIL));
|
||||
return (isSxTruthy(isNil(f)) ? NIL : (isSxTruthy(sxOr(isLambda(f), isCallable(f))) ? cekRun(continueWithCall(f, a, makeEnv(), a, [])) : NIL));
|
||||
})(); };
|
||||
PRIMITIVES["cek-call"] = cekCall;
|
||||
|
||||
@@ -2655,7 +2659,7 @@ PRIMITIVES["serialize-island-state"] = serializeIslandState;
|
||||
// render-to-sx
|
||||
var renderToSx = function(expr, env) { return (function() {
|
||||
var result = aser(expr, env);
|
||||
return (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result));
|
||||
return (isSxTruthy((typeOf(result) == "sx-expr")) ? sxExprSource(result) : (isSxTruthy((typeOf(result) == "string")) ? result : serialize(result)));
|
||||
})(); };
|
||||
PRIMITIVES["render-to-sx"] = renderToSx;
|
||||
|
||||
@@ -2665,8 +2669,8 @@ return (function() {
|
||||
var result = (function() { var _m = typeOf(expr); if (_m == "number") return expr; if (_m == "string") return expr; if (_m == "boolean") return expr; if (_m == "nil") return NIL; if (_m == "symbol") return (function() {
|
||||
var name = symbolName(expr);
|
||||
return (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name))))))));
|
||||
})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (scopeEmit_b("element-attrs", spreadAttrs(expr)), NIL); return expr; })();
|
||||
return (isSxTruthy(isSpread(result)) ? (scopeEmit_b("element-attrs", spreadAttrs(result)), NIL) : result);
|
||||
})(); if (_m == "keyword") return keywordName(expr); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? [] : aserList(expr, env)); if (_m == "spread") return (scopeEmit("element-attrs", spreadAttrs(expr)), NIL); return expr; })();
|
||||
return (isSxTruthy(isSpread(result)) ? (scopeEmit("element-attrs", spreadAttrs(result)), NIL) : result);
|
||||
})(); };
|
||||
PRIMITIVES["aser"] = aser;
|
||||
|
||||
@@ -2694,9 +2698,9 @@ PRIMITIVES["aser-list"] = aserList;
|
||||
var parts = [];
|
||||
{ var _c = children; for (var _i = 0; _i < _c.length; _i++) { var c = _c[_i]; (function() {
|
||||
var result = aser(c, env);
|
||||
return (isSxTruthy(isNil(result)) ? NIL : (isSxTruthy((isSxTruthy((typeOf(result) == "string")) && isSxTruthy((stringLength(result) > 0)) && startsWith(result, "("))) ? append_b(parts, result) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(parts, item) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result)))));
|
||||
return (isSxTruthy(isNil(result)) ? NIL : (isSxTruthy((typeOf(result) == "sx-expr")) ? append_b(parts, sxExprSource(result)) : (isSxTruthy((typeOf(result) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(parts, sxExprSource(item)) : append_b(parts, serialize(item))) : NIL); }, result) : append_b(parts, serialize(result)))));
|
||||
})(); } }
|
||||
return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? first(parts) : (String("(<> ") + String(join(" ", parts)) + String(")"))));
|
||||
return (isSxTruthy(isEmpty(parts)) ? "" : (isSxTruthy((len(parts) == 1)) ? makeSxExpr(first(parts)) : makeSxExpr((String("(<> ") + String(join(" ", parts)) + String(")")))));
|
||||
})(); };
|
||||
PRIMITIVES["aser-fragment"] = aserFragment;
|
||||
|
||||
@@ -2711,14 +2715,14 @@ PRIMITIVES["aser-fragment"] = aserFragment;
|
||||
var val = aser(nth(args, (i + 1)), env);
|
||||
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
||||
attrParts.push((String(":") + String(keywordName(arg))));
|
||||
(isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(attrParts, val) : append_b(attrParts, serialize(val)));
|
||||
(isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(attrParts, sxExprSource(val)) : append_b(attrParts, serialize(val)));
|
||||
}
|
||||
skip = true;
|
||||
return (i = (i + 1));
|
||||
})() : (function() {
|
||||
var val = aser(arg, env);
|
||||
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
|
||||
(isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(childParts, val) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((isSxTruthy((typeOf(item) == "string")) && isSxTruthy((stringLength(item) > 0)) && startsWith(item, "("))) ? append_b(childParts, item) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val))));
|
||||
(isSxTruthy((typeOf(val) == "sx-expr")) ? append_b(childParts, sxExprSource(val)) : (isSxTruthy((typeOf(val) == "list")) ? forEach(function(item) { return (isSxTruthy(!isSxTruthy(isNil(item))) ? (isSxTruthy((typeOf(item) == "sx-expr")) ? append_b(childParts, sxExprSource(item)) : append_b(childParts, serialize(item))) : NIL); }, val) : append_b(childParts, serialize(val))));
|
||||
}
|
||||
return (i = (i + 1));
|
||||
})())); } }
|
||||
@@ -2730,7 +2734,7 @@ PRIMITIVES["aser-fragment"] = aserFragment;
|
||||
scopePop("element-attrs");
|
||||
return (function() {
|
||||
var parts = concat([name], attrParts, childParts);
|
||||
return (String("(") + String(join(" ", parts)) + String(")"));
|
||||
return makeSxExpr((String("(") + String(join(" ", parts)) + String(")")));
|
||||
})();
|
||||
})(); };
|
||||
PRIMITIVES["aser-call"] = aserCall;
|
||||
@@ -3952,7 +3956,7 @@ PRIMITIVES["execute-request"] = executeRequest;
|
||||
domAddClass(el, "sx-request");
|
||||
domSetAttr(el, "aria-busy", "true");
|
||||
domDispatch(el, "sx:beforeRequest", {["url"]: finalUrl, ["method"]: method});
|
||||
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? domDispatch(el, "sx:requestError", {["error"]: err}) : NIL)); });
|
||||
return fetchRequest({["url"]: finalUrl, ["method"]: method, ["headers"]: headers, ["body"]: body, ["signal"]: controllerSignal(ctrl), ["cross-origin"]: isCrossOrigin(finalUrl), ["preloaded"]: cached}, function(respOk, status, getHeader, text) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(respOk)) ? (domDispatch(el, "sx:responseError", {["status"]: status, ["text"]: text}), (isSxTruthy((isSxTruthy(text) && (len(text) > 0))) ? handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text) : handleRetry(el, verb, method, finalUrl, extraParams))) : (domDispatch(el, "sx:afterRequest", {["status"]: status}), handleFetchSuccess(el, finalUrl, verb, extraParams, getHeader, text)))); }, function(err) { return (clearLoadingState(el, indicator, disabledElts), revertOptimistic(optimisticState), (isSxTruthy(!isSxTruthy(isAbortError(err))) ? (logWarn((String("sx:fetch error ") + String(method) + String(" ") + String(finalUrl) + String(" — ") + String(err))), domDispatch(el, "sx:requestError", {["error"]: err})) : NIL)); });
|
||||
})();
|
||||
})();
|
||||
})();
|
||||
@@ -5866,7 +5870,8 @@ PRIMITIVES["resource"] = resource;
|
||||
PRIMITIVES["island?"] = isIsland;
|
||||
PRIMITIVES["make-symbol"] = function(n) { return new Symbol(n); };
|
||||
PRIMITIVES["is-html-tag?"] = function(n) { return HTML_TAGS.indexOf(n) >= 0; };
|
||||
PRIMITIVES["make-env"] = function() { return merge(componentEnv, PRIMITIVES); };
|
||||
function makeEnv() { return merge(componentEnv, PRIMITIVES); }
|
||||
PRIMITIVES["make-env"] = makeEnv;
|
||||
|
||||
// localStorage — defined here (before boot) so islands can use at hydration
|
||||
PRIMITIVES["local-storage-get"] = function(key) {
|
||||
@@ -6393,7 +6398,10 @@ PRIMITIVES["resource"] = resource;
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch(function() { location.reload(); });
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:popstate fetch error " + url + " — " + (err && err.message ? err.message : err));
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function fetchStreaming(target, url, headers) {
|
||||
@@ -6531,7 +6539,9 @@ PRIMITIVES["resource"] = resource;
|
||||
return resp.text().then(function(text) {
|
||||
preloadCacheSet(cache, url, text, ct);
|
||||
});
|
||||
}).catch(function() { /* ignore */ });
|
||||
}).catch(function(err) {
|
||||
logInfo("sx:preload error " + url + " — " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Request body building ---
|
||||
@@ -6820,6 +6830,8 @@ PRIMITIVES["resource"] = resource;
|
||||
var liveAction = form.getAttribute("action") || _action || location.href;
|
||||
executeRequest(form, { method: liveMethod, url: liveAction }).then(function() {
|
||||
try { history.pushState({ sxUrl: liveAction, scrollY: window.scrollY }, "", liveAction); } catch (err) {}
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:boost form error " + liveMethod + " " + liveAction + " — " + (err && err.message ? err.message : err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class OcamlBridge:
|
||||
self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN
|
||||
self._proc: asyncio.subprocess.Process | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._in_io_handler = False # re-entrancy guard
|
||||
self._started = False
|
||||
self._components_loaded = False
|
||||
self._helpers_injected = False
|
||||
@@ -123,7 +124,8 @@ class OcamlBridge:
|
||||
"""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
await self._send(f'(eval "{_escape(source)}")')
|
||||
await self._send('(eval-blob)')
|
||||
await self._send_blob(source)
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def render(
|
||||
@@ -141,7 +143,8 @@ class OcamlBridge:
|
||||
"""Evaluate SX and return SX wire format, handling io-requests."""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
await self._send(f'(aser "{_escape(source)}")')
|
||||
await self._send('(aser-blob)')
|
||||
await self._send_blob(source)
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
||||
@@ -156,7 +159,8 @@ class OcamlBridge:
|
||||
# a separate lock acquisition could let another coroutine
|
||||
# interleave commands between injection and aser-slot.
|
||||
await self._inject_helpers_locked()
|
||||
await self._send(f'(aser-slot "{_escape(source)}")')
|
||||
await self._send('(aser-slot-blob)')
|
||||
await self._send_blob(source)
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
_shell_statics_injected: bool = False
|
||||
@@ -227,7 +231,10 @@ class OcamlBridge:
|
||||
static_keys = {"component_hash", "sx_css_classes", "asset_url",
|
||||
"sx_js_hash", "body_js_hash",
|
||||
"head_scripts", "body_scripts"}
|
||||
parts = [f'(sx-page-full "{_escape(page_source)}"']
|
||||
# page_source is SX wire format that may contain \" escapes.
|
||||
# Send via binary blob protocol to avoid double-escaping
|
||||
# through the SX string parser round-trip.
|
||||
parts = ['(sx-page-full-blob']
|
||||
for key, val in shell_kwargs.items():
|
||||
k = key.replace("_", "-")
|
||||
if key in PLACEHOLDER_KEYS:
|
||||
@@ -248,6 +255,8 @@ class OcamlBridge:
|
||||
parts.append(")")
|
||||
cmd = "".join(parts)
|
||||
await self._send(cmd)
|
||||
# Send page source as binary blob (avoids string-escape issues)
|
||||
await self._send_blob(page_source)
|
||||
html = await self._read_until_ok(ctx)
|
||||
# Splice in large blobs
|
||||
for token, blob in placeholders.items():
|
||||
@@ -473,11 +482,30 @@ class OcamlBridge:
|
||||
|
||||
async def _send(self, line: str) -> None:
|
||||
"""Write a line to the subprocess stdin and flush."""
|
||||
if self._in_io_handler:
|
||||
raise OcamlBridgeError(
|
||||
f"Re-entrant bridge call from IO handler: {line[:80]}. "
|
||||
f"IO handlers must not call the bridge — use Python-only code."
|
||||
)
|
||||
assert self._proc and self._proc.stdin
|
||||
_logger.debug("SEND: %s", line[:120])
|
||||
self._proc.stdin.write((line + "\n").encode())
|
||||
await self._proc.stdin.drain()
|
||||
|
||||
async def _send_blob(self, data: str) -> None:
|
||||
"""Send a length-prefixed binary blob to the subprocess.
|
||||
|
||||
Protocol: sends "(blob N)\\n" followed by exactly N bytes, then "\\n".
|
||||
The OCaml side reads the length, then reads exactly N bytes.
|
||||
This avoids string-escape round-trip issues for SX wire format.
|
||||
"""
|
||||
assert self._proc and self._proc.stdin
|
||||
encoded = data.encode()
|
||||
self._proc.stdin.write(f"(blob {len(encoded)})\n".encode())
|
||||
self._proc.stdin.write(encoded)
|
||||
self._proc.stdin.write(b"\n")
|
||||
await self._proc.stdin.drain()
|
||||
|
||||
async def _readline(self) -> str:
|
||||
"""Read a line from the subprocess stdout."""
|
||||
assert self._proc and self._proc.stdout
|
||||
@@ -574,7 +602,24 @@ class OcamlBridge:
|
||||
line: str,
|
||||
ctx: dict[str, Any] | None,
|
||||
) -> Any:
|
||||
"""Dispatch an io-request to the appropriate Python handler."""
|
||||
"""Dispatch an io-request to the appropriate Python handler.
|
||||
|
||||
IO handlers MUST NOT call the bridge (eval/aser/render) — doing so
|
||||
would deadlock since the lock is already held. The _in_io_handler
|
||||
flag triggers an immediate error if this rule is violated.
|
||||
"""
|
||||
self._in_io_handler = True
|
||||
try:
|
||||
return await self._dispatch_io(line, ctx)
|
||||
finally:
|
||||
self._in_io_handler = False
|
||||
|
||||
async def _dispatch_io(
|
||||
self,
|
||||
line: str,
|
||||
ctx: dict[str, Any] | None,
|
||||
) -> Any:
|
||||
"""Inner dispatch for IO requests."""
|
||||
from .parser import parse_all
|
||||
|
||||
# Parse the io-request
|
||||
|
||||
@@ -181,6 +181,47 @@ def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
|
||||
# Page execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _wrap_with_env(expr: Any, env: dict) -> str:
|
||||
"""Serialize an expression wrapped with let-bindings from env.
|
||||
|
||||
Injects page env values (URL params, data results) as let-bindings
|
||||
so the OCaml kernel can evaluate the expression with those bindings.
|
||||
Only injects non-component, non-callable values that pages add dynamically.
|
||||
"""
|
||||
from .parser import serialize
|
||||
from .ocaml_bridge import _serialize_for_ocaml
|
||||
from .types import Symbol, Keyword, NIL
|
||||
|
||||
body = serialize(expr)
|
||||
bindings = []
|
||||
for k, v in env.items():
|
||||
# Skip component definitions — already loaded in kernel
|
||||
if k.startswith("~") or callable(v):
|
||||
continue
|
||||
# Skip env keys that are component-env infrastructure
|
||||
if isinstance(v, (type, type(None))) and v is not None:
|
||||
continue
|
||||
# Serialize the value
|
||||
if v is NIL or v is None:
|
||||
sv = "nil"
|
||||
elif isinstance(v, bool):
|
||||
sv = "true" if v else "false"
|
||||
elif isinstance(v, (int, float)):
|
||||
sv = str(int(v)) if isinstance(v, float) and v == int(v) else str(v)
|
||||
elif isinstance(v, str):
|
||||
sv = _serialize_for_ocaml(v)
|
||||
elif isinstance(v, (list, dict)):
|
||||
sv = _serialize_for_ocaml(v)
|
||||
else:
|
||||
# Component, Lambda, etc — skip, already in kernel
|
||||
continue
|
||||
bindings.append(f"({k} {sv})")
|
||||
|
||||
if not bindings:
|
||||
return body
|
||||
return f"(let ({' '.join(bindings)}) {body})"
|
||||
|
||||
|
||||
async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||
"""Evaluate a page slot expression and return an sx source string.
|
||||
|
||||
@@ -188,6 +229,15 @@ async def _eval_slot(expr: Any, env: dict, ctx: Any) -> str:
|
||||
the result as SX wire format, not HTML.
|
||||
"""
|
||||
import os
|
||||
if os.environ.get("SX_USE_OCAML") == "1":
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize
|
||||
bridge = await get_bridge()
|
||||
# Wrap expression with let-bindings for env values that pages
|
||||
# inject (URL params, data results, etc.)
|
||||
sx_text = _wrap_with_env(expr, env)
|
||||
service = ctx.get("_helper_service", "") if isinstance(ctx, dict) else ""
|
||||
return await bridge.aser_slot(sx_text, ctx={"_helper_service": service})
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_slot_to_sx
|
||||
else:
|
||||
@@ -248,12 +298,19 @@ async def execute_page(
|
||||
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval
|
||||
from .page import get_template_context
|
||||
from .helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from .layouts import get_layout
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
_use_ocaml = os.environ.get("SX_USE_OCAML") == "1"
|
||||
if _use_ocaml:
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize, parse_all
|
||||
from .ocaml_bridge import _serialize_for_ocaml
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
if url_params is None:
|
||||
url_params = {}
|
||||
|
||||
@@ -275,7 +332,19 @@ async def execute_page(
|
||||
# Evaluate :data expression if present
|
||||
_multi_stream_content = None
|
||||
if page_def.data_expr is not None:
|
||||
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||
if _use_ocaml:
|
||||
bridge = await get_bridge()
|
||||
sx_text = _wrap_with_env(page_def.data_expr, env)
|
||||
ocaml_ctx = {"_helper_service": service_name}
|
||||
raw = await bridge.eval(sx_text, ctx=ocaml_ctx)
|
||||
# Parse result back to Python dict/value
|
||||
if raw:
|
||||
parsed = parse_all(raw)
|
||||
data_result = parsed[0] if parsed else {}
|
||||
else:
|
||||
data_result = {}
|
||||
else:
|
||||
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||
if hasattr(data_result, '__aiter__'):
|
||||
# Multi-stream: consume generator, eval :content per chunk,
|
||||
# combine into shell with resolved suspense slots.
|
||||
@@ -358,7 +427,18 @@ async def execute_page(
|
||||
k = raw[i]
|
||||
if isinstance(k, SxKeyword) and i + 1 < len(raw):
|
||||
raw_val = raw[i + 1]
|
||||
resolved = await async_eval(raw_val, env, ctx)
|
||||
if _use_ocaml:
|
||||
bridge = await get_bridge()
|
||||
sx_text = _wrap_with_env(raw_val, env)
|
||||
ocaml_ctx = {"_helper_service": service_name}
|
||||
raw_result = await bridge.eval(sx_text, ctx=ocaml_ctx)
|
||||
if raw_result:
|
||||
parsed = parse_all(raw_result)
|
||||
resolved = parsed[0] if parsed else None
|
||||
else:
|
||||
resolved = None
|
||||
else:
|
||||
resolved = await async_eval(raw_val, env, ctx)
|
||||
layout_kwargs[k.name.replace("-", "_")] = resolved
|
||||
i += 2
|
||||
else:
|
||||
|
||||
@@ -21,10 +21,6 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(query_def.closure)
|
||||
@@ -38,6 +34,26 @@ async def execute_query(query_def: QueryDef, params: dict[str, str]) -> Any:
|
||||
val = int(val)
|
||||
env[param] = val
|
||||
|
||||
if os.environ.get("SX_USE_OCAML") == "1":
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize, parse_all
|
||||
from .pages import _wrap_with_env
|
||||
bridge = await get_bridge()
|
||||
sx_text = _wrap_with_env(query_def.body, env)
|
||||
ctx = {"_helper_service": ""}
|
||||
raw = await bridge.eval(sx_text, ctx=ctx)
|
||||
if raw:
|
||||
parsed = parse_all(raw)
|
||||
result = parsed[0] if parsed else None
|
||||
else:
|
||||
result = None
|
||||
return _normalize(result)
|
||||
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
ctx = _get_request_context()
|
||||
result = await async_eval(query_def.body, env, ctx)
|
||||
return _normalize(result)
|
||||
@@ -50,10 +66,6 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
import os
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(action_def.closure)
|
||||
@@ -64,6 +76,26 @@ async def execute_action(action_def: ActionDef, payload: dict[str, Any]) -> Any:
|
||||
val = payload.get(param, payload.get(snake, NIL))
|
||||
env[param] = val
|
||||
|
||||
if os.environ.get("SX_USE_OCAML") == "1":
|
||||
from .ocaml_bridge import get_bridge
|
||||
from .parser import serialize, parse_all
|
||||
from .pages import _wrap_with_env
|
||||
bridge = await get_bridge()
|
||||
sx_text = _wrap_with_env(action_def.body, env)
|
||||
ctx = {"_helper_service": ""}
|
||||
raw = await bridge.eval(sx_text, ctx=ctx)
|
||||
if raw:
|
||||
parsed = parse_all(raw)
|
||||
result = parsed[0] if parsed else None
|
||||
else:
|
||||
result = None
|
||||
return _normalize(result)
|
||||
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval
|
||||
else:
|
||||
from .async_eval import async_eval
|
||||
|
||||
ctx = _get_request_context()
|
||||
result = await async_eval(action_def.body, env, ctx)
|
||||
return _normalize(result)
|
||||
|
||||
Reference in New Issue
Block a user