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:
@@ -684,8 +684,10 @@ def _build_pages_sx(service: str) -> str:
|
||||
has_data = "true" if page_def.data_expr is not None else "false"
|
||||
|
||||
# Component deps needed to render this page client-side
|
||||
# Compute for all pages (including :data pages) — the client
|
||||
# renders both pure and data pages after fetching data
|
||||
deps: set[str] = set()
|
||||
if content_src and page_def.data_expr is None:
|
||||
if content_src:
|
||||
deps = components_needed(content_src, _COMPONENT_ENV)
|
||||
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2637,6 +2637,42 @@ PLATFORM_ORCHESTRATION_JS = """
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePageData(pageName, params, callback) {
|
||||
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
|
||||
// The spec only knows about resolve-page-data(name, params, callback) —
|
||||
// this function provides the concrete transport.
|
||||
var url = "/sx/data/" + encodeURIComponent(pageName);
|
||||
if (params && !isNil(params)) {
|
||||
var qs = [];
|
||||
var ks = Object.keys(params);
|
||||
for (var i = 0; i < ks.length; i++) {
|
||||
var v = params[ks[i]];
|
||||
if (v !== null && v !== undefined && v !== NIL) {
|
||||
qs.push(encodeURIComponent(ks[i]) + "=" + encodeURIComponent(v));
|
||||
}
|
||||
}
|
||||
if (qs.length) url += "?" + qs.join("&");
|
||||
}
|
||||
var headers = { "SX-Request": "true" };
|
||||
fetch(url, { headers: headers }).then(function(resp) {
|
||||
if (!resp.ok) {
|
||||
logWarn("sx:data resolve failed " + resp.status + " for " + pageName);
|
||||
return;
|
||||
}
|
||||
return resp.text().then(function(text) {
|
||||
try {
|
||||
var exprs = parse(text);
|
||||
var data = exprs.length === 1 ? exprs[0] : {};
|
||||
callback(data || {});
|
||||
} catch (e) {
|
||||
logWarn("sx:data parse error for " + pageName + ": " + (e && e.message ? e.message : e));
|
||||
}
|
||||
});
|
||||
}).catch(function(err) {
|
||||
logWarn("sx:data resolve error for " + pageName + ": " + (err && err.message ? err.message : err));
|
||||
});
|
||||
}
|
||||
|
||||
function urlPathname(href) {
|
||||
try {
|
||||
return new URL(href, location.href).pathname;
|
||||
|
||||
@@ -552,39 +552,66 @@
|
||||
;; No app-specific nav update here — apps handle sx:clientRoute event.
|
||||
|
||||
|
||||
(define swap-rendered-content
|
||||
(fn (target rendered pathname)
|
||||
;; Swap rendered DOM content into target and run post-processing.
|
||||
;; Shared by pure and data page client routes.
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(dom-dispatch target "sx:clientRoute"
|
||||
(dict "pathname" pathname))
|
||||
(log-info (str "sx:route client " pathname)))))
|
||||
|
||||
|
||||
(define resolve-route-target
|
||||
(fn (target-sel)
|
||||
;; Resolve a target selector to a DOM element, or nil.
|
||||
(if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil)))
|
||||
|
||||
|
||||
(define try-client-route
|
||||
(fn (pathname target-sel)
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; target-sel is the CSS selector for the swap target (from sx-boost value).
|
||||
;; Only works for pages without :data dependencies.
|
||||
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
||||
(if (get match "has-data")
|
||||
(do (log-info (str "sx:route server (has data) " pathname)) false)
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
(do (log-warn (str "sx:route no content for " pathname)) false)
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(let ((target (if (and target-sel (not (= target-sel "true")))
|
||||
(dom-query target-sel)
|
||||
nil)))
|
||||
(if (nil? target)
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params"))
|
||||
(page-name (get match "name")))
|
||||
(if (or (nil? content-src) (empty? content-src))
|
||||
(do (log-warn (str "sx:route no content for " pathname)) false)
|
||||
(let ((target (resolve-route-target target-sel)))
|
||||
(if (nil? target)
|
||||
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
||||
(if (get match "has-data")
|
||||
;; Data page: resolve data asynchronously, then render client-side
|
||||
(do
|
||||
(log-info (str "sx:route client+data " pathname))
|
||||
(resolve-page-data page-name params
|
||||
(fn (data)
|
||||
;; data is a dict — merge into env and render
|
||||
(let ((env (merge closure params data))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(log-warn (str "sx:route data eval failed for " pathname))
|
||||
(swap-rendered-content target rendered pathname)))))
|
||||
true)
|
||||
;; Pure page: render immediately
|
||||
(let ((env (merge closure params))
|
||||
(rendered (try-eval-content content-src env)))
|
||||
(if (nil? rendered)
|
||||
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
||||
(do
|
||||
(dom-set-text-content target "")
|
||||
(dom-append target rendered)
|
||||
(hoist-head-elements-full target)
|
||||
(process-elements target)
|
||||
(sx-hydrate-elements target)
|
||||
(dom-dispatch target "sx:clientRoute"
|
||||
(dict "pathname" pathname))
|
||||
(log-info (str "sx:route client " pathname))
|
||||
(swap-rendered-content target rendered pathname)
|
||||
true))))))))))))
|
||||
|
||||
|
||||
@@ -893,6 +920,9 @@
|
||||
;; === Client-side routing ===
|
||||
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
||||
;; (url-pathname href) → extract pathname from URL string
|
||||
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
||||
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
||||
;; when data is available. params is a dict of URL/route parameters.
|
||||
;;
|
||||
;; From boot.sx:
|
||||
;; _page-routes → list of route entries
|
||||
|
||||
Reference in New Issue
Block a user