AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
80
backends/mizan-fastapi/src/mizan_fastapi/ssr.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
SSR render path — FastAPI adapter surface over the shared Bun bridge.
|
||||
|
||||
The SSR subprocess lifecycle and JSON-RPC wire protocol live in
|
||||
`mizan_core.ssr.SSRBridge` (framework-agnostic). FastAPI has no template-engine
|
||||
backend, so instead of Django's `MizanTemplates` veneer this exposes an
|
||||
`SSRRenderer` whose `.render(...)` calls the same bridge — `renderToString` runs
|
||||
in the persistent Bun worker — and returns an `HTMLResponse` with the rendered
|
||||
markup plus the hydration payload the client reads on mount.
|
||||
|
||||
Usage:
|
||||
from mizan_fastapi.ssr import SSRRenderer
|
||||
|
||||
ssr = SSRRenderer(worker="path/to/mizan-ssr/src/worker.tsx", dirs=["frontend"])
|
||||
|
||||
@app.get("/profile/{user_id}")
|
||||
async def profile(user_id: int):
|
||||
return ssr.render("components/Profile.tsx", {"user_id": user_id})
|
||||
|
||||
`render` resolves the template name to an absolute file path against `dirs`
|
||||
(parity with Django's `DIRS`), then renders the component's default export. The
|
||||
hydration wrapping matches the Django backend byte-for-byte so the same client
|
||||
bundle hydrates either server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from mizan_core.ssr import SSRBridge
|
||||
|
||||
|
||||
class SSRRenderer:
|
||||
"""Render React `.tsx`/`.jsx` files via the shared Bun SSR bridge.
|
||||
|
||||
One renderer owns one persistent `SSRBridge`. Thread-safe (the bridge
|
||||
serializes worker I/O); a single renderer can be shared across the app.
|
||||
"""
|
||||
|
||||
def __init__(self, worker: str, dirs: list[str] | None = None, timeout: float = 5.0) -> None:
|
||||
self._dirs = list(dirs or [])
|
||||
self._bridge = SSRBridge(worker_path=worker, timeout=timeout)
|
||||
|
||||
def _resolve(self, template_name: str) -> str:
|
||||
"""Resolve a template name to an absolute file path against `dirs`.
|
||||
|
||||
An already-absolute, existing path is used directly; otherwise each `dirs`
|
||||
entry is tried in order (parity with Django's `DIRS` resolution).
|
||||
"""
|
||||
if os.path.isabs(template_name) and os.path.isfile(template_name):
|
||||
return template_name
|
||||
for dir_path in self._dirs:
|
||||
candidate = os.path.join(dir_path, template_name)
|
||||
if os.path.isfile(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
raise FileNotFoundError(
|
||||
f"SSR component '{template_name}' not found in dirs={self._dirs!r}"
|
||||
)
|
||||
|
||||
def render_to_string(self, template_name: str, props: dict[str, Any] | None = None) -> str:
|
||||
"""Render the component to an HTML string (markup + hydration script)."""
|
||||
props = dict(props or {})
|
||||
result = self._bridge.render(self._resolve(template_name), props)
|
||||
hydration_json = json.dumps(props, sort_keys=True, default=str)
|
||||
return (
|
||||
f'<div id="mizan-root">{result.html}</div>'
|
||||
f"<script>window.__MIZAN_SSR_DATA__={hydration_json}</script>"
|
||||
)
|
||||
|
||||
def render(self, template_name: str, props: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
|
||||
"""Render the component and return a FastAPI `HTMLResponse`."""
|
||||
return HTMLResponse(self.render_to_string(template_name, props), status_code=status_code)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the underlying Bun subprocess."""
|
||||
self._bridge.shutdown()
|
||||
Reference in New Issue
Block a user