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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user