Phase 4: Client-side rendering of :data pages via abstract resolve-page-data

Spec layer (orchestration.sx):
- try-client-route now handles :data pages instead of falling back to server
- New abstract primitive resolve-page-data(name, params, callback) — platform
  decides transport (HTTP, IPC, cache, etc)
- Extracted swap-rendered-content and resolve-route-target helpers

Platform layer (bootstrap_js.py):
- resolvePageData() browser implementation: fetches /sx/data/<name>, parses
  SX response, calls callback. Other hosts provide their own transport.

Server layer (pages.py):
- evaluate_page_data() evaluates :data expr, serializes result as SX
- auto_mount_page_data() mounts /sx/data/ endpoint with per-page auth
- _build_pages_sx now computes component deps for all pages (not just pure)

Test page at /isomorphism/data-test exercises the full pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:46:30 +00:00
parent 9d0cffb84d
commit a657d0831c
10 changed files with 375 additions and 108 deletions

View File

@@ -318,12 +318,19 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
Pages must have absolute paths (from the service URL root).
Called once per service in app.py after setup_*_pages().
Also mounts the /sx/data/ endpoint for client-side data fetching.
"""
pages = get_all_pages(service_name)
for page_def in pages.values():
_mount_one_page(app, service_name, page_def)
logger.info("Auto-mounted %d defpages for %s", len(pages), service_name)
# Mount page data endpoint for client-side rendering of :data pages
has_data_pages = any(p.data_expr is not None for p in pages.values())
if has_data_pages:
auto_mount_page_data(app, service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
@@ -405,3 +412,126 @@ def _apply_cache(fn: Any, cache: dict) -> Any:
tag = cache.get("tag")
scope = cache.get("scope", "user")
return cache_page(ttl=ttl, tag=tag, scope=scope)(fn)
async def _check_page_auth(auth: str | list) -> Any | None:
"""Check auth for the data endpoint. Returns None if OK, or a response."""
from quart import g, abort as quart_abort
if auth == "public":
return None
user = g.get("user")
if auth == "login":
if not user:
quart_abort(401)
elif auth == "admin":
if not user or not user.get("rights", {}).get("admin"):
quart_abort(403)
elif isinstance(auth, list) and auth and auth[0] == "rights":
if not user:
quart_abort(401)
user_rights = set(user.get("rights", {}).keys())
required = set(auth[1:])
if not required.issubset(user_rights):
quart_abort(403)
return None
# ---------------------------------------------------------------------------
# Page data endpoint — evaluate :data expression, return SX
# ---------------------------------------------------------------------------
async def evaluate_page_data(
page_def: PageDef,
service_name: str,
url_params: dict[str, Any] | None = None,
) -> str:
"""Evaluate a defpage's :data expression and return result as SX.
This is the data-only counterpart to execute_page(). The client
fetches this when it has all component definitions but needs the
data bindings to render a :data page client-side.
Returns SX wire format (e.g. ``{:posts (list ...) :count 42}``),
parsed by the client's SX parser and merged into the eval env.
"""
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
from .parser import serialize
if page_def.data_expr is None:
return "nil"
if url_params is None:
url_params = {}
# Build environment (same as execute_page)
env = dict(get_component_env())
env.update(get_page_helpers(service_name))
env.update(page_def.closure)
for key, val in url_params.items():
kebab = key.replace("_", "-")
env[kebab] = val
env[key] = val
ctx = _get_request_context()
data_result = await async_eval(page_def.data_expr, env, ctx)
# Kebab-case dict keys (matching execute_page line 214-215)
if isinstance(data_result, dict):
data_result = {
k.replace("_", "-"): v for k, v in data_result.items()
}
# Serialize the result as SX
return serialize(data_result)
def auto_mount_page_data(app: Any, service_name: str) -> None:
"""Mount a single /sx/data/ endpoint that serves page data as SX.
For each defpage with :data, the client can GET /sx/data/<page-name>
(with URL params as query args) and receive the evaluated :data
result serialized as SX wire format (text/sx).
Auth is enforced per-page: the endpoint looks up the page's auth
setting and checks it before evaluating the data expression.
"""
from quart import make_response, request, abort as quart_abort
async def page_data_view(page_name: str) -> Any:
page_def = get_page(service_name, page_name)
if page_def is None:
quart_abort(404)
if page_def.data_expr is None:
quart_abort(404)
# Check auth — same enforcement as the page route itself
auth_error = await _check_page_auth(page_def.auth)
if auth_error is not None:
return auth_error
# Extract URL params from query string
url_params = dict(request.args)
result_sx = await evaluate_page_data(
page_def, service_name, url_params=url_params,
)
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
return resp
page_data_view.__name__ = "sx_page_data"
page_data_view.__qualname__ = "sx_page_data"
app.add_url_rule(
"/sx/data/<page_name>",
endpoint="sx_page_data",
view_func=page_data_view,
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)