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