OCaml evaluator for page dispatch + handler aser, 83/83 Playwright tests
Major architectural change: page function dispatch and handler execution
now go through the OCaml kernel instead of the Python bootstrapped evaluator.
OCaml integration:
- Page dispatch: bridge.eval() evaluates SX URL expressions (geography, marshes, etc.)
- Handler aser: bridge.aser() serializes handler responses as SX wire format
- _ensure_components loads all .sx files into OCaml kernel (spec, web adapter, handlers)
- defhandler/defpage registered as no-op special forms so handler files load
- helper IO primitive dispatches to Python page helpers + IO handlers
- ok-raw response format for SX wire format (no double-escaping)
- Natural list serialization in eval (no (list ...) wrapper)
- Clean pipe: _read_until_ok always sends io-response on error
SX adapter (aser):
- scope-emit!/scope-peek aliases to avoid CEK special form conflict
- aser-fragment/aser-call: strings starting with "(" pass through unserialized
- Registered cond-scheme?, is-else-clause?, primitive?, get-primitive in kernel
- random-int, parse-int as kernel primitives; json-encode, into via IO bridge
Handler migration:
- All IO calls converted to (helper "name" args...) pattern
- request-arg, request-form, state-get, state-set!, now, component-source etc.
- Fixed bare (effect ...) in island bodies leaking disposer functions as text
- Fixed lower-case → lower, ~search-results → ~examples/search-results
Reactive islands:
- sx-hydrate-islands called after client-side navigation swap
- force-dispose-islands-in for outerHTML swaps (clears hydration markers)
- clear-processed! platform primitive for re-hydration
Content restructuring:
- Design, event bridge, named stores, phase 2 consolidated into reactive overview
- Marshes split into overview + 5 example sub-pages
- Nav links use sx-get/sx-target for client-side navigation
Playwright test suite (sx/tests/test_demos.py):
- 83 tests covering hypermedia demos, reactive islands, marshes, spec explorer
- Server-side rendering, handler interactions, island hydration, navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -101,28 +101,26 @@ class OcamlBridge:
|
||||
"""Load an .sx file for side effects (defcomp, define, defmacro)."""
|
||||
async with self._lock:
|
||||
self._send(f'(load "{_escape(path)}")')
|
||||
kind, value = await self._read_response()
|
||||
if kind == "error":
|
||||
raise OcamlBridgeError(f"load {path}: {value}")
|
||||
value = await self._read_until_ok(ctx=None)
|
||||
return int(float(value)) if value else 0
|
||||
|
||||
async def load_source(self, source: str) -> int:
|
||||
"""Evaluate SX source for side effects."""
|
||||
async with self._lock:
|
||||
self._send(f'(load-source "{_escape(source)}")')
|
||||
kind, value = await self._read_response()
|
||||
if kind == "error":
|
||||
raise OcamlBridgeError(f"load-source: {value}")
|
||||
value = await self._read_until_ok(ctx=None)
|
||||
return int(float(value)) if value else 0
|
||||
|
||||
async def eval(self, source: str) -> str:
|
||||
"""Evaluate SX expression, return serialized result."""
|
||||
async def eval(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
||||
"""Evaluate SX expression, return serialized result.
|
||||
|
||||
Supports io-requests (helper calls, query, action, etc.) via the
|
||||
coroutine bridge, just like render().
|
||||
"""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
self._send(f'(eval "{_escape(source)}")')
|
||||
kind, value = await self._read_response()
|
||||
if kind == "error":
|
||||
raise OcamlBridgeError(f"eval: {value}")
|
||||
return value or ""
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def render(
|
||||
self,
|
||||
@@ -135,40 +133,84 @@ class OcamlBridge:
|
||||
self._send(f'(render "{_escape(source)}")')
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def aser(self, source: str, ctx: dict[str, Any] | None = None) -> str:
|
||||
"""Evaluate SX and return SX wire format, handling io-requests."""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
self._send(f'(aser "{_escape(source)}")')
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
async def _ensure_components(self) -> None:
|
||||
"""Load component definitions into the kernel on first use."""
|
||||
"""Load all .sx source files into the kernel on first use.
|
||||
|
||||
Errors during loading are handled gracefully — IO responses are
|
||||
always sent back to keep the pipe clean.
|
||||
"""
|
||||
if self._components_loaded:
|
||||
return
|
||||
self._components_loaded = True
|
||||
try:
|
||||
from .jinja_bridge import get_component_env, _CLIENT_LIBRARY_SOURCES
|
||||
from .parser import serialize
|
||||
from .types import Component, Island, Macro
|
||||
from .jinja_bridge import _watched_dirs, _dirs_from_cache
|
||||
import glob
|
||||
|
||||
env = get_component_env()
|
||||
parts: list[str] = list(_CLIENT_LIBRARY_SOURCES)
|
||||
for key, val in env.items():
|
||||
if isinstance(val, Island):
|
||||
ps = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
ps.extend(["&rest", "children"])
|
||||
parts.append(f"(defisland ~{val.name} ({' '.join(ps)}) {serialize(val.body)})")
|
||||
elif isinstance(val, Component):
|
||||
ps = ["&key"] + list(val.params)
|
||||
if val.has_children:
|
||||
ps.extend(["&rest", "children"])
|
||||
parts.append(f"(defcomp ~{val.name} ({' '.join(ps)}) {serialize(val.body)})")
|
||||
elif isinstance(val, Macro):
|
||||
ps = list(val.params)
|
||||
if val.rest_param:
|
||||
ps.extend(["&rest", val.rest_param])
|
||||
parts.append(f"(defmacro {val.name} ({' '.join(ps)}) {serialize(val.body)})")
|
||||
if parts:
|
||||
source = "\n".join(parts)
|
||||
await self.load_source(source)
|
||||
_logger.info("Loaded %d definitions into OCaml kernel", len(parts))
|
||||
# Skip patterns — files that use constructs not available in the kernel
|
||||
skip_names = {"boundary.sx", "forms.sx"}
|
||||
skip_dirs = {"tests"}
|
||||
|
||||
# Collect files to load
|
||||
all_files: list[str] = []
|
||||
|
||||
# Spec files needed by aser
|
||||
spec_dir = os.path.join(os.path.dirname(__file__), "../../spec")
|
||||
for spec_file in ["parser.sx", "render.sx"]:
|
||||
path = os.path.normpath(os.path.join(spec_dir, spec_file))
|
||||
if os.path.isfile(path):
|
||||
all_files.append(path)
|
||||
|
||||
# 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_dir = os.path.join(os.path.dirname(__file__), "../../web")
|
||||
if os.path.isdir(web_dir):
|
||||
for web_file in ["adapter-sx.sx"]:
|
||||
path = os.path.normpath(os.path.join(web_dir, web_file))
|
||||
if os.path.isfile(path):
|
||||
all_files.append(path)
|
||||
|
||||
for directory in sorted(all_dirs):
|
||||
files = sorted(
|
||||
glob.glob(os.path.join(directory, "**", "*.sx"), recursive=True)
|
||||
)
|
||||
for filepath in files:
|
||||
basename = os.path.basename(filepath)
|
||||
# Skip known-bad files
|
||||
if basename in skip_names:
|
||||
continue
|
||||
# Skip test and handler directories
|
||||
parts = filepath.replace("\\", "/").split("/")
|
||||
if any(d in skip_dirs for d in parts):
|
||||
continue
|
||||
all_files.append(filepath)
|
||||
|
||||
# Load all files under a single lock
|
||||
count = 0
|
||||
skipped = 0
|
||||
async with self._lock:
|
||||
for filepath in all_files:
|
||||
try:
|
||||
self._send(f'(load "{_escape(filepath)}")')
|
||||
value = await self._read_until_ok(ctx=None)
|
||||
# Response may be a number (count) or a value — just count files
|
||||
count += 1
|
||||
except OcamlBridgeError as e:
|
||||
skipped += 1
|
||||
_logger.warning("OCaml load skipped %s: %s",
|
||||
filepath, e)
|
||||
_logger.info("Loaded %d definitions from .sx files into OCaml kernel (%d skipped)",
|
||||
count, skipped)
|
||||
except Exception as e:
|
||||
_logger.error("Failed to load components into OCaml kernel: %s", e)
|
||||
_logger.error("Failed to load .sx files into OCaml kernel: %s", e)
|
||||
self._components_loaded = False # retry next time
|
||||
|
||||
async def reset(self) -> None:
|
||||
@@ -217,14 +259,19 @@ class OcamlBridge:
|
||||
"""Read lines until (ok ...) or (error ...).
|
||||
|
||||
Handles (io-request ...) by fulfilling IO and sending (io-response ...).
|
||||
ALWAYS sends a response to keep the pipe clean, even on error.
|
||||
"""
|
||||
while True:
|
||||
line = await self._readline()
|
||||
|
||||
if line.startswith("(io-request "):
|
||||
result = await self._handle_io_request(line, ctx)
|
||||
# Send response back to OCaml
|
||||
self._send(f"(io-response {_serialize_for_ocaml(result)})")
|
||||
try:
|
||||
result = await self._handle_io_request(line, ctx)
|
||||
self._send(f"(io-response {_serialize_for_ocaml(result)})")
|
||||
except Exception as e:
|
||||
# MUST send a response or the pipe desyncs
|
||||
_logger.warning("IO request failed, sending nil: %s", e)
|
||||
self._send("(io-response nil)")
|
||||
continue
|
||||
|
||||
kind, value = _parse_response(line)
|
||||
@@ -264,7 +311,15 @@ class OcamlBridge:
|
||||
return self._io_request_method()
|
||||
elif req_name == "ctx":
|
||||
return self._io_ctx(args, ctx)
|
||||
elif req_name == "helper":
|
||||
return await self._io_helper(args, ctx)
|
||||
else:
|
||||
# Fall back to registered IO handlers (set-response-status, sleep, etc.)
|
||||
from .primitives_io import _IO_HANDLERS, RequestContext
|
||||
io_handler = _IO_HANDLERS.get(req_name)
|
||||
if io_handler is not None:
|
||||
helper_args = [_to_python(a) for a in args]
|
||||
return await io_handler(helper_args, {}, ctx or RequestContext())
|
||||
raise OcamlBridgeError(f"Unknown io-request type: {req_name}")
|
||||
|
||||
async def _io_query(self, args: list) -> Any:
|
||||
@@ -309,6 +364,43 @@ class OcamlBridge:
|
||||
key = _to_str(args[0]) if args else ""
|
||||
return ctx.get(key)
|
||||
|
||||
async def _io_helper(self, args: list, ctx: dict[str, Any] | None) -> Any:
|
||||
"""Handle (io-request "helper" name arg1 arg2 ...).
|
||||
|
||||
Dispatches to registered page helpers — Python functions like
|
||||
read-spec-file, bootstrapper-data, etc. The helper service name
|
||||
is passed via ctx["_helper_service"].
|
||||
"""
|
||||
import asyncio
|
||||
from .pages import get_page_helpers
|
||||
from .primitives_io import _IO_HANDLERS, RequestContext
|
||||
|
||||
name = _to_str(args[0]) if args else ""
|
||||
helper_args = [_to_python(a) for a in args[1:]]
|
||||
|
||||
# Check page helpers first (application-level)
|
||||
service = (ctx or {}).get("_helper_service", "sx")
|
||||
helpers = get_page_helpers(service)
|
||||
fn = helpers.get(name)
|
||||
if fn is not None:
|
||||
result = fn(*helper_args)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
# Fall back to IO primitives (now, state-get, state-set!, etc.)
|
||||
io_handler = _IO_HANDLERS.get(name)
|
||||
if io_handler is not None:
|
||||
return await io_handler(helper_args, {}, RequestContext())
|
||||
|
||||
# Fall back to regular primitives (json-encode, into, etc.)
|
||||
from .primitives import get_primitive as _get_prim
|
||||
prim = _get_prim(name)
|
||||
if prim is not None:
|
||||
return prim(*helper_args)
|
||||
|
||||
raise OcamlBridgeError(f"Unknown helper: {name!r}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
@@ -344,6 +436,9 @@ def _parse_response(line: str) -> tuple[str, str | None]:
|
||||
line = line.strip()
|
||||
if line == "(ok)":
|
||||
return ("ok", None)
|
||||
if line.startswith("(ok-raw "):
|
||||
# Raw SX wire format — no unescaping needed
|
||||
return ("ok", line[8:-1])
|
||||
if line.startswith("(ok "):
|
||||
value = line[4:-1] # strip (ok and )
|
||||
# If the value is a quoted string, unquote it
|
||||
@@ -369,6 +464,16 @@ def _unescape(s: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _to_python(val: Any) -> Any:
|
||||
"""Convert an SX parsed value to a plain Python value."""
|
||||
from .types import NIL as _NIL
|
||||
if val is None or val is _NIL:
|
||||
return None
|
||||
if hasattr(val, "name"): # Symbol or Keyword
|
||||
return val.name
|
||||
return val
|
||||
|
||||
|
||||
def _to_str(val: Any) -> str:
|
||||
"""Convert an SX parsed value to a Python string."""
|
||||
if isinstance(val, str):
|
||||
|
||||
Reference in New Issue
Block a user