Phase 5: async IO rendering — components call IO primitives client-side

Wire async rendering into client-side routing: pages whose component
trees reference IO primitives (highlight, current-user, etc.) now
render client-side via Promise-aware asyncRenderToDom. IO calls proxy
through /sx/io/<name> endpoint, which falls back to page helpers.

- Add has-io flag to page registry entries (helpers.py)
- Remove IO purity filter — include IO-dependent components in bundles
- Extend try-client-route with 4 paths: pure, data, IO, data+IO
- Convert tryAsyncEvalContent to callback style, add platform mapping
- IO proxy falls back to page helpers (highlight works via proxy)
- Demo page: /isomorphism/async-io with inline highlight calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 08:12:42 +00:00
parent 04ff03f5d4
commit 79fa1411dc
10 changed files with 1502 additions and 264 deletions

View File

@@ -331,6 +331,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
if has_data_pages:
auto_mount_page_data(app, service_name)
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
mount_io_endpoint(app, service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
@@ -535,3 +538,73 @@ def auto_mount_page_data(app: Any, service_name: str) -> None:
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
def mount_io_endpoint(app: Any, service_name: str) -> None:
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
The client can call any allowed IO primitive or page helper via GET/POST.
Result is returned as SX wire format (text/sx).
Falls back to page helpers when the name isn't a global IO primitive,
so service-specific functions like ``highlight`` work via the proxy.
"""
import asyncio as _asyncio
from quart import make_response, request, abort as quart_abort
from .primitives_io import IO_PRIMITIVES, execute_io
from .jinja_bridge import _get_request_context
from .parser import serialize
# Allowlist of IO primitives + page helpers the client may call
_ALLOWED_IO = {
"highlight", "current-user", "request-arg", "request-path",
"htmx-request?", "app-url", "asset-url", "config",
}
async def io_proxy(name: str) -> Any:
if name not in _ALLOWED_IO:
quart_abort(403)
# Parse args from query string or JSON body
args: list = []
kwargs: dict = {}
if request.method == "GET":
for k, v in request.args.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
else:
data = await request.get_json(silent=True) or {}
args = data.get("args", [])
kwargs = data.get("kwargs", {})
# Try global IO primitives first
if name in IO_PRIMITIVES:
ctx = _get_request_context()
result = await execute_io(name, args, kwargs, ctx)
else:
# Fall back to page helpers (service-specific functions)
helpers = get_page_helpers(service_name)
helper_fn = helpers.get(name)
if helper_fn is None:
quart_abort(404)
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
if _asyncio.iscoroutine(result):
result = await result
result_sx = serialize(result) if result is not None else "nil"
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
return resp
io_proxy.__name__ = "sx_io_proxy"
io_proxy.__qualname__ = "sx_io_proxy"
app.add_url_rule(
"/sx/io/<name>",
endpoint="sx_io_proxy",
view_func=io_proxy,
methods=["GET", "POST"],
)
logger.info("Mounted IO proxy endpoint for %s at /sx/io/<name>", service_name)