VM aser-slot → sx-page-full: single-call page render, 0.55s warm
Compiler fixes: - Upvalue re-lookup returns own position (uv-index), not parent slot - Spec: cek-call uses (make-env) not (dict) — OCaml Dict≠Env - Bootstrap post-processes transpiler Dict→Env for cek_call VM runtime fixes: - compile_adapter evaluates constant defines (SPECIAL_FORM_NAMES etc.) via execute_module instead of wrapping as NativeFn closures - Native primitives: map-indexed, some, every? - Nil-safe HO forms: map/filter/for-each/some/every? accept nil as empty - expand-components? set in kernel env (not just VM globals) - unwrap_env diagnostic: reports actual type received sx-page-full command: - Single OCaml call: aser-slot body + render-to-html shell - Eliminates two pipe round-trips (was: aser-slot→Python→shell render) - Shell statics (component_defs, CSS, pages_sx) cached in Python, injected into kernel once, referenced by symbol in per-request command - Large blobs use placeholder tokens — Python splices post-render, pipe transfers ~51KB instead of 2MB Performance (warm): - Server total: 0.55s (was ~2s) - aser-slot VM: 0.3s, shell render: 0.01s, pipe: 0.06s - kwargs computation: 0.000s (cached) SX_STANDALONE mode for sx_docs dev (skips fragment fetches). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -792,6 +792,162 @@ def _sx_literal(v: object) -> str:
|
||||
|
||||
|
||||
|
||||
_cached_shell_static: dict[str, Any] | None = None
|
||||
_cached_shell_comp_hash: str | None = None
|
||||
|
||||
|
||||
def invalidate_shell_cache():
|
||||
"""Call on component hot-reload to recompute shell statics."""
|
||||
global _cached_shell_static, _cached_shell_comp_hash
|
||||
_cached_shell_static = None
|
||||
_cached_shell_comp_hash = None
|
||||
|
||||
|
||||
def _get_shell_static() -> dict[str, Any]:
|
||||
"""Compute and cache all shell kwargs that don't change per-request.
|
||||
|
||||
This is the expensive part: component dep scanning, serialization,
|
||||
CSS class scanning, rule lookup, pages registry. All stable until
|
||||
components are hot-reloaded.
|
||||
"""
|
||||
global _cached_shell_static, _cached_shell_comp_hash
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page, _component_env_hash
|
||||
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
|
||||
current_hash = _component_env_hash()
|
||||
if _cached_shell_static is not None and _cached_shell_comp_hash == current_hash:
|
||||
return _cached_shell_static
|
||||
|
||||
import time
|
||||
t0 = time.monotonic()
|
||||
|
||||
from quart import current_app as _ca
|
||||
from .jinja_bridge import client_components_tag, _COMPONENT_ENV, _CLIENT_LIBRARY_SOURCES
|
||||
from .jinja_bridge import _component_env_hash
|
||||
from .parser import serialize as _serialize
|
||||
|
||||
# Send ALL component definitions — the hash is stable per env so the
|
||||
# browser caches them across all pages. Server-side expansion handles
|
||||
# the per-page subset; the client needs the full set for client-side
|
||||
# routing to any page.
|
||||
parts = []
|
||||
for key, val in _COMPONENT_ENV.items():
|
||||
from .types import Island, Component, Macro
|
||||
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, pretty=True)})")
|
||||
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, pretty=True)})")
|
||||
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, pretty=True)})")
|
||||
all_parts = list(_CLIENT_LIBRARY_SOURCES) + parts
|
||||
component_defs = "\n".join(all_parts)
|
||||
component_hash = _component_env_hash()
|
||||
|
||||
# CSS: scan ALL components (not per-page) for the static cache
|
||||
sx_css = ""
|
||||
sx_css_classes = ""
|
||||
if registry_loaded():
|
||||
classes: set[str] = set()
|
||||
from .types import Island as _I, Component as _C
|
||||
for val in _COMPONENT_ENV.values():
|
||||
if isinstance(val, (_I, _C)) and val.css_classes:
|
||||
classes.update(val.css_classes)
|
||||
classes.update(["bg-stone-50", "text-stone-900"])
|
||||
rules = lookup_rules(classes)
|
||||
sx_css = get_preamble() + rules
|
||||
sx_css_classes = store_css_hash(classes)
|
||||
|
||||
pages_sx = _build_pages_sx(_ca.name)
|
||||
|
||||
_shell_cfg = _ca.config.get("SX_SHELL", {})
|
||||
|
||||
static = dict(
|
||||
component_hash=component_hash,
|
||||
component_defs=component_defs,
|
||||
pages_sx=pages_sx,
|
||||
sx_css=sx_css,
|
||||
sx_css_classes=sx_css_classes,
|
||||
sx_js_hash=_script_hash("sx-browser.js"),
|
||||
body_js_hash=_script_hash("body.js"),
|
||||
asset_url=_ca.config.get("ASSET_URL", "/static"),
|
||||
head_scripts=_shell_cfg.get("head_scripts"),
|
||||
inline_css=_shell_cfg.get("inline_css"),
|
||||
inline_head_js=_shell_cfg.get("inline_head_js"),
|
||||
init_sx=_shell_cfg.get("init_sx"),
|
||||
body_scripts=_shell_cfg.get("body_scripts"),
|
||||
)
|
||||
|
||||
t1 = time.monotonic()
|
||||
import logging
|
||||
logging.getLogger("sx.pages").info(
|
||||
"[shell-static] computed in %.3fs, comp_defs=%d css=%d pages=%d",
|
||||
t1 - t0, len(component_defs), len(sx_css), len(pages_sx))
|
||||
|
||||
_cached_shell_static = static
|
||||
_cached_shell_comp_hash = current_hash
|
||||
return static
|
||||
|
||||
|
||||
async def _build_shell_kwargs(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "",
|
||||
head_scripts: list[str] | None = None,
|
||||
inline_css: str | None = None,
|
||||
inline_head_js: str | None = None,
|
||||
init_sx: str | None = None,
|
||||
body_scripts: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Compute all shell kwargs for sx-page-shell.
|
||||
|
||||
Static parts (components, CSS, pages) are cached. Only per-request
|
||||
values (title, csrf) are computed fresh.
|
||||
"""
|
||||
static = _get_shell_static()
|
||||
|
||||
asset_url = get_asset_url(ctx) or static["asset_url"]
|
||||
title = ctx.get("base_title", "Rose Ash")
|
||||
csrf = _get_csrf_token()
|
||||
|
||||
kwargs: dict[str, Any] = dict(static)
|
||||
kwargs.update(
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
meta_html=meta_html,
|
||||
csrf=_html_escape(csrf),
|
||||
)
|
||||
|
||||
# Per-page CSS: scan THIS page's classes and add to cached CSS
|
||||
from .css_registry import scan_classes_from_sx, lookup_rules, registry_loaded
|
||||
if registry_loaded() and page_sx:
|
||||
page_classes = scan_classes_from_sx(page_sx)
|
||||
if page_classes:
|
||||
extra_rules = lookup_rules(page_classes)
|
||||
if extra_rules:
|
||||
kwargs["sx_css"] = static["sx_css"] + extra_rules
|
||||
|
||||
# Cookie-based component caching
|
||||
client_hash = _get_sx_comp_cookie()
|
||||
if not _is_dev_mode() and client_hash and client_hash == static["component_hash"]:
|
||||
kwargs["component_defs"] = ""
|
||||
|
||||
# Per-call overrides
|
||||
if head_scripts is not None:
|
||||
kwargs["head_scripts"] = head_scripts
|
||||
if inline_css is not None:
|
||||
kwargs["inline_css"] = inline_css
|
||||
if inline_head_js is not None:
|
||||
kwargs["inline_head_js"] = inline_head_js
|
||||
if init_sx is not None:
|
||||
kwargs["init_sx"] = init_sx
|
||||
if body_scripts is not None:
|
||||
kwargs["body_scripts"] = body_scripts
|
||||
return kwargs
|
||||
|
||||
|
||||
async def sx_page(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "",
|
||||
head_scripts: list[str] | None = None,
|
||||
@@ -799,109 +955,18 @@ async def sx_page(ctx: dict, page_sx: str, *,
|
||||
inline_head_js: str | None = None,
|
||||
init_sx: str | None = None,
|
||||
body_scripts: list[str] | None = None) -> str:
|
||||
"""Return a minimal HTML shell that boots the page from sx source.
|
||||
|
||||
The browser loads component definitions and page sx, then sx.js
|
||||
renders everything client-side. CSS rules are scanned from the sx
|
||||
source and component defs, then injected as a <style> block.
|
||||
|
||||
The shell is rendered from the ~shared:shell/sx-page-shell SX component
|
||||
(shared/sx/templates/shell.sx).
|
||||
"""
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page
|
||||
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
|
||||
# Per-page component bundle: this page's deps + all :data page deps
|
||||
from quart import current_app as _ca
|
||||
component_defs, component_hash = components_for_page(page_sx, service=_ca.name)
|
||||
|
||||
# Check if client already has this version cached (via cookie)
|
||||
# In dev mode, always send full source so edits are visible immediately
|
||||
client_hash = _get_sx_comp_cookie()
|
||||
if not _is_dev_mode() and client_hash and client_hash == component_hash:
|
||||
# Client has current components cached — send empty source
|
||||
component_defs = ""
|
||||
|
||||
# Scan for CSS classes — only from components this page uses + page source
|
||||
sx_css = ""
|
||||
sx_css_classes = ""
|
||||
sx_css_hash = ""
|
||||
if registry_loaded():
|
||||
classes = css_classes_for_page(page_sx, service=_ca.name)
|
||||
# Always include body classes
|
||||
classes.update(["bg-stone-50", "text-stone-900"])
|
||||
rules = lookup_rules(classes)
|
||||
sx_css = get_preamble() + rules
|
||||
sx_css_hash = store_css_hash(classes)
|
||||
sx_css_classes = sx_css_hash
|
||||
|
||||
asset_url = get_asset_url(ctx)
|
||||
title = ctx.get("base_title", "Rose Ash")
|
||||
csrf = _get_csrf_token()
|
||||
|
||||
# Dev mode: pretty-print page sx for readable View Source
|
||||
if _is_dev_mode() and page_sx and page_sx.startswith("("):
|
||||
from .parser import parse as _parse, serialize as _serialize
|
||||
try:
|
||||
page_sx = _serialize(_parse(page_sx), pretty=True)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("sx").warning("Pretty-print page_sx failed: %s", e)
|
||||
|
||||
# Page registry for client-side routing
|
||||
import logging
|
||||
_plog = logging.getLogger("sx.pages")
|
||||
from quart import current_app
|
||||
pages_sx = _build_pages_sx(current_app.name)
|
||||
_plog.debug("sx_page: pages_sx %d bytes for service %s", len(pages_sx), current_app.name)
|
||||
if pages_sx:
|
||||
_plog.debug("sx_page: pages_sx first 200 chars: %s", pages_sx[:200])
|
||||
|
||||
# Ensure page_sx is a plain str, not SxExpr — _build_component_ast
|
||||
# parses SxExpr back into AST, which _arender then evaluates as HTML
|
||||
# instead of passing through as raw content for the script tag.
|
||||
"""Return a minimal HTML shell that boots the page from sx source."""
|
||||
# Ensure page_sx is a plain str
|
||||
if isinstance(page_sx, SxExpr):
|
||||
page_sx = "".join([page_sx])
|
||||
|
||||
# Per-app shell config: check explicit args, then app config, then defaults
|
||||
from quart import current_app as _app
|
||||
_shell_cfg = _app.config.get("SX_SHELL", {})
|
||||
if head_scripts is None:
|
||||
head_scripts = _shell_cfg.get("head_scripts")
|
||||
if inline_css is None:
|
||||
inline_css = _shell_cfg.get("inline_css")
|
||||
if inline_head_js is None:
|
||||
inline_head_js = _shell_cfg.get("inline_head_js")
|
||||
if init_sx is None:
|
||||
init_sx = _shell_cfg.get("init_sx")
|
||||
if body_scripts is None:
|
||||
body_scripts = _shell_cfg.get("body_scripts")
|
||||
|
||||
shell_kwargs: dict[str, Any] = dict(
|
||||
title=_html_escape(title),
|
||||
asset_url=asset_url,
|
||||
meta_html=meta_html,
|
||||
csrf=_html_escape(csrf),
|
||||
component_hash=component_hash,
|
||||
component_defs=component_defs,
|
||||
pages_sx=pages_sx,
|
||||
page_sx=page_sx,
|
||||
sx_css=sx_css,
|
||||
sx_css_classes=sx_css_classes,
|
||||
sx_js_hash=_script_hash("sx-browser.js"),
|
||||
body_js_hash=_script_hash("body.js"),
|
||||
)
|
||||
if head_scripts is not None:
|
||||
shell_kwargs["head_scripts"] = head_scripts
|
||||
if inline_css is not None:
|
||||
shell_kwargs["inline_css"] = inline_css
|
||||
if inline_head_js is not None:
|
||||
shell_kwargs["inline_head_js"] = inline_head_js
|
||||
if init_sx is not None:
|
||||
shell_kwargs["init_sx"] = init_sx
|
||||
if body_scripts is not None:
|
||||
shell_kwargs["body_scripts"] = body_scripts
|
||||
return await render_to_html("shared:shell/sx-page-shell", **shell_kwargs)
|
||||
kwargs = await _build_shell_kwargs(
|
||||
ctx, page_sx, meta_html=meta_html,
|
||||
head_scripts=head_scripts, inline_css=inline_css,
|
||||
inline_head_js=inline_head_js, init_sx=init_sx,
|
||||
body_scripts=body_scripts)
|
||||
kwargs["page_sx"] = page_sx
|
||||
return await render_to_html("shared:shell/sx-page-shell", **kwargs)
|
||||
|
||||
|
||||
_SX_STREAMING_RESOLVE = """\
|
||||
|
||||
@@ -342,6 +342,8 @@ def reload_if_changed() -> None:
|
||||
_CLIENT_LIBRARY_SOURCES.clear()
|
||||
_dirs_from_cache.clear()
|
||||
invalidate_component_hash()
|
||||
from .helpers import invalidate_shell_cache
|
||||
invalidate_shell_cache()
|
||||
# Reload SX libraries first (e.g. z3.sx) so reader macros resolve
|
||||
for cb in _reload_callbacks:
|
||||
cb()
|
||||
@@ -360,6 +362,8 @@ def reload_if_changed() -> None:
|
||||
from .ocaml_bridge import _bridge
|
||||
if _bridge is not None:
|
||||
_bridge._components_loaded = False
|
||||
_bridge._shell_statics_injected = False
|
||||
_bridge._helpers_injected = False
|
||||
|
||||
# Recompute render plans for all services that have pages
|
||||
from .pages import _PAGE_REGISTRY, compute_page_render_plans
|
||||
|
||||
@@ -159,6 +159,102 @@ class OcamlBridge:
|
||||
await self._send(f'(aser-slot "{_escape(source)}")')
|
||||
return await self._read_until_ok(ctx)
|
||||
|
||||
_shell_statics_injected: bool = False
|
||||
|
||||
async def _inject_shell_statics_locked(self) -> None:
|
||||
"""Inject cached shell static data into kernel. MUST hold lock."""
|
||||
if self._shell_statics_injected:
|
||||
return
|
||||
from .helpers import _get_shell_static
|
||||
try:
|
||||
static = _get_shell_static()
|
||||
except Exception:
|
||||
return # not ready yet (no app context)
|
||||
# Define small values as kernel variables.
|
||||
# Large blobs (component_defs, pages_sx, init_sx) use placeholders
|
||||
# at render time — NOT injected here.
|
||||
for key in ("sx_css", "component_hash", "sx_css_classes", "asset_url",
|
||||
"sx_js_hash", "body_js_hash"):
|
||||
val = static.get(key, "")
|
||||
if val is None:
|
||||
val = ""
|
||||
var = f"__shell-{key.replace('_', '-')}"
|
||||
defn = f'(define {var} "{_escape(str(val))}")'
|
||||
try:
|
||||
await self._send(f'(load-source "{_escape(defn)}")')
|
||||
await self._read_until_ok(ctx=None)
|
||||
except OcamlBridgeError:
|
||||
pass
|
||||
# Also inject list/nil values
|
||||
for key in ("head_scripts", "inline_css", "inline_head_js", "body_scripts"):
|
||||
val = static.get(key)
|
||||
var = f"__shell-{key.replace('_', '-')}"
|
||||
if val is None:
|
||||
defn = f'(define {var} nil)'
|
||||
elif isinstance(val, list):
|
||||
items = " ".join(f'"{_escape(str(v))}"' for v in val)
|
||||
defn = f'(define {var} (list {items}))'
|
||||
else:
|
||||
defn = f'(define {var} "{_escape(str(val))}")'
|
||||
try:
|
||||
await self._send(f'(load-source "{_escape(defn)}")')
|
||||
await self._read_until_ok(ctx=None)
|
||||
except OcamlBridgeError:
|
||||
pass
|
||||
self._shell_statics_injected = True
|
||||
_logger.info("Injected shell statics into OCaml kernel")
|
||||
|
||||
async def sx_page_full(
|
||||
self,
|
||||
page_source: str,
|
||||
shell_kwargs: dict[str, Any],
|
||||
ctx: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Render full page HTML in one OCaml call: aser-slot + shell render.
|
||||
|
||||
Static data (component_defs, CSS, pages_sx) is pre-injected as
|
||||
kernel vars on first call. Per-request command sends only small
|
||||
values (title, csrf) + references to the kernel vars.
|
||||
"""
|
||||
await self._ensure_components()
|
||||
async with self._lock:
|
||||
await self._inject_helpers_locked()
|
||||
await self._inject_shell_statics_locked()
|
||||
# Large blobs (component_defs, pages_sx, init_sx) use placeholders.
|
||||
# OCaml renders the shell with short tokens; Python splices in
|
||||
# the real values. This avoids piping ~1MB through stdin/stdout.
|
||||
PLACEHOLDER_KEYS = {"component_defs", "pages_sx", "init_sx"}
|
||||
placeholders = {}
|
||||
static_keys = {"component_hash", "sx_css_classes", "asset_url",
|
||||
"sx_js_hash", "body_js_hash", "sx_css",
|
||||
"head_scripts", "inline_css", "inline_head_js", "body_scripts"}
|
||||
parts = [f'(sx-page-full "{_escape(page_source)}"']
|
||||
for key, val in shell_kwargs.items():
|
||||
k = key.replace("_", "-")
|
||||
if key in PLACEHOLDER_KEYS:
|
||||
token = f"__SLOT_{key.upper()}__"
|
||||
placeholders[token] = str(val) if val else ""
|
||||
parts.append(f' :{k} "{token}"')
|
||||
elif key in static_keys:
|
||||
parts.append(f' :{k} __shell-{k}')
|
||||
elif val is None:
|
||||
parts.append(f' :{k} nil')
|
||||
elif isinstance(val, bool):
|
||||
parts.append(f' :{k} {"true" if val else "false"}')
|
||||
elif isinstance(val, list):
|
||||
items = " ".join(f'"{_escape(str(v))}"' for v in val)
|
||||
parts.append(f' :{k} ({items})')
|
||||
else:
|
||||
parts.append(f' :{k} "{_escape(str(val))}"')
|
||||
parts.append(")")
|
||||
cmd = "".join(parts)
|
||||
await self._send(cmd)
|
||||
html = await self._read_until_ok(ctx)
|
||||
# Splice in large blobs
|
||||
for token, blob in placeholders.items():
|
||||
html = html.replace(token, blob)
|
||||
return html
|
||||
|
||||
async def _inject_helpers_locked(self) -> None:
|
||||
"""Inject page helpers into the kernel. MUST be called with lock held."""
|
||||
if self._helpers_injected:
|
||||
|
||||
Reference in New Issue
Block a user