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:
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Mizan Edge Manifest Generator.
|
||||
Mizan Edge Manifest Generator (Django adapter surface).
|
||||
|
||||
Generates the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params, consumed by Mizan Edge at deploy time for CDN
|
||||
cache invalidation. Independent from the Mizan IR; the IR drives
|
||||
codegen, the manifest drives CDN purging.
|
||||
The manifest derivation is AFI-common and lives in `mizan_core.manifest`;
|
||||
Django exposes it through `python manage.py export_edge_manifest` and this
|
||||
re-export. The manifest maps contexts to URL patterns and params, consumed by
|
||||
Mizan Edge at deploy time for CDN cache invalidation. It is independent of the
|
||||
Mizan IR: the IR drives codegen, the manifest drives CDN purging.
|
||||
|
||||
Usage:
|
||||
from mizan.export import generate_edge_manifest, generate_edge_manifest_json
|
||||
@@ -12,145 +13,10 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_registry
|
||||
from mizan_core.manifest import generate_edge_manifest, generate_edge_manifest_json
|
||||
|
||||
|
||||
__all__ = [
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the Edge manifest — a static JSON mapping contexts to URL
|
||||
patterns and params for CDN cache purging.
|
||||
|
||||
The manifest is consumed by Mizan Edge at deploy time. When Edge
|
||||
receives X-Mizan-Invalidate: user;user_id=5, it:
|
||||
1. Looks up 'user' in the manifest
|
||||
2. Resolves URL patterns with params: /profile/:user_id/ → /profile/5/
|
||||
3. Purges the resolved URLs + the context API endpoint
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default: /api/mizan)
|
||||
view_urls: Optional mapping of context names to URL patterns for
|
||||
view-path functions. These are URLs that Edge should
|
||||
also purge when a context is invalidated.
|
||||
|
||||
Returns:
|
||||
Manifest dict suitable for JSON serialization.
|
||||
"""
|
||||
_USER_SCOPED_PARAMS = {"user_id", "user", "owner_id", "account_id"}
|
||||
|
||||
groups = get_context_groups()
|
||||
registry = get_registry()
|
||||
all_functions = registry.get("functions", {})
|
||||
|
||||
manifest: dict[str, Any] = {"version": 1, "contexts": {}, "mutations": {}}
|
||||
|
||||
for ctx_name, fn_names in sorted(groups.items()):
|
||||
param_names: set[str] = set()
|
||||
functions_meta: list[dict[str, Any]] = []
|
||||
page_routes: list[str] = []
|
||||
|
||||
for fn_name in fn_names:
|
||||
fn_cls = all_functions.get(fn_name)
|
||||
if fn_cls is None:
|
||||
continue
|
||||
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
for param_name in input_cls.model_fields:
|
||||
param_names.add(param_name)
|
||||
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
route = meta.get("route")
|
||||
view_path = meta.get("view_path")
|
||||
|
||||
fn_entry: dict[str, Any] = {
|
||||
"name": fn_name,
|
||||
"path": "view" if view_path else "rpc",
|
||||
}
|
||||
if route:
|
||||
fn_entry["route"] = route
|
||||
fn_entry["methods"] = meta.get("methods", ["GET"])
|
||||
page_routes.append(route)
|
||||
if meta.get("rev"):
|
||||
fn_entry["rev"] = meta["rev"]
|
||||
if meta.get("cache") is not None and meta.get("cache") is not True:
|
||||
fn_entry["cache"] = meta["cache"]
|
||||
functions_meta.append(fn_entry)
|
||||
|
||||
sorted_params = sorted(param_names)
|
||||
user_scoped = any(p in _USER_SCOPED_PARAMS for p in param_names)
|
||||
|
||||
ctx_entry: dict[str, Any] = {
|
||||
"functions": functions_meta,
|
||||
"endpoints": [f"{base_url}/ctx/{ctx_name}/"],
|
||||
"params": sorted_params,
|
||||
"user_scoped": user_scoped,
|
||||
"render_strategy": "dynamic_cached" if user_scoped else "psr",
|
||||
}
|
||||
|
||||
if page_routes:
|
||||
ctx_entry["page_routes"] = page_routes
|
||||
if view_urls and ctx_name in view_urls:
|
||||
ctx_entry.setdefault("page_routes", []).extend(view_urls[ctx_name])
|
||||
|
||||
manifest["contexts"][ctx_name] = ctx_entry
|
||||
|
||||
for fn_name, fn_cls in sorted(all_functions.items()):
|
||||
meta = getattr(fn_cls, "_meta", {})
|
||||
if not meta.get("affects"):
|
||||
continue
|
||||
|
||||
affected_contexts = list({a["name"] for a in meta["affects"]})
|
||||
mutation: dict[str, Any] = {"affects": affected_contexts}
|
||||
|
||||
# Auto-scoped params — function params that match context params
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
fn_params = set(input_cls.model_fields.keys())
|
||||
auto_scoped: list[str] = []
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_param_names: set[str] = set()
|
||||
ctx_fns = groups.get(ctx_name, [])
|
||||
for ctx_fn_name in ctx_fns:
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls is None:
|
||||
continue
|
||||
ctx_input = getattr(ctx_fn_cls, "Input", None)
|
||||
if ctx_input is not None and hasattr(ctx_input, "model_fields"):
|
||||
ctx_param_names.update(ctx_input.model_fields.keys())
|
||||
for p in fn_params:
|
||||
if p in ctx_param_names and p not in auto_scoped:
|
||||
auto_scoped.append(p)
|
||||
if auto_scoped:
|
||||
mutation["auto_scoped_params"] = sorted(auto_scoped)
|
||||
|
||||
if meta.get("private"):
|
||||
mutation["private"] = True
|
||||
if meta.get("route"):
|
||||
mutation["route"] = meta["route"]
|
||||
mutation["methods"] = meta.get("methods", ["POST"])
|
||||
|
||||
manifest["mutations"][fn_name] = mutation
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def generate_edge_manifest_json(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
indent: int = 2,
|
||||
) -> str:
|
||||
"""JSON-serialize the Edge manifest."""
|
||||
return json.dumps(generate_edge_manifest(base_url, view_urls), indent=indent)
|
||||
|
||||
@@ -23,7 +23,7 @@ from django.template import TemplateDoesNotExist
|
||||
from django.template.backends.base import BaseEngine
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from .bridge import SSRBridge
|
||||
from mizan_core.ssr import SSRBridge
|
||||
|
||||
|
||||
class MizanTemplate:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
|
||||
Request: {"id": 1, "method": "render", "params": {"file": "/abs/path/Hello.tsx", "props": {...}}}
|
||||
Response: {"id": 1, "html": "<div>...</div>"}
|
||||
|
||||
The subprocess stays alive across requests. It is started on first use
|
||||
and restarted automatically if it crashes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("mizan.ssr")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""Result of an SSR render call."""
|
||||
html: str
|
||||
|
||||
|
||||
class SSRBridge:
|
||||
"""
|
||||
Manages a persistent Bun subprocess for server-side rendering.
|
||||
|
||||
Thread-safe. Multiple Django workers can call render() concurrently.
|
||||
Request-response matching via message IDs.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_path: str, timeout: float = 5.0) -> None:
|
||||
self._worker_path = worker_path
|
||||
self._timeout = timeout
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock() # Serializes stdin writes
|
||||
self._counter = 0
|
||||
self._pending: dict[int, threading.Event] = {}
|
||||
self._results: dict[int, dict] = {}
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
# Ensure cleanup on process exit
|
||||
atexit.register(self.shutdown)
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
"""Start the Bun subprocess if it's not running."""
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return
|
||||
|
||||
if self._proc is not None:
|
||||
logger.warning("Bun SSR worker died (exit code %s), restarting", self._proc.returncode)
|
||||
|
||||
self._ready.clear()
|
||||
self._proc = subprocess.Popen(
|
||||
["bun", "run", self._worker_path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_responses, daemon=True, name="mizan-ssr-reader",
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Wait for the "ready" signal from the worker
|
||||
if not self._ready.wait(timeout=self._timeout):
|
||||
logger.error("Bun SSR worker failed to start within %ss", self._timeout)
|
||||
self.shutdown()
|
||||
raise TimeoutError("SSR worker failed to start")
|
||||
|
||||
logger.info("Bun SSR worker started (pid %s)", self._proc.pid)
|
||||
|
||||
def _read_responses(self) -> None:
|
||||
"""Background thread that reads JSON responses from stdout."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8")
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Malformed JSON from SSR worker: %s", line[:200])
|
||||
continue
|
||||
|
||||
msg_id = msg.get("id")
|
||||
|
||||
# Ready signal (id=0)
|
||||
if msg_id == 0 and msg.get("ready"):
|
||||
self._ready.set()
|
||||
continue
|
||||
|
||||
if msg_id is not None and msg_id in self._pending:
|
||||
self._results[msg_id] = msg
|
||||
self._pending[msg_id].set()
|
||||
except Exception:
|
||||
logger.warning("SSR reader thread exited", exc_info=True)
|
||||
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
|
||||
Args:
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
RenderResult with the HTML string.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
self._counter += 1
|
||||
msg_id = self._counter
|
||||
|
||||
event = threading.Event()
|
||||
self._pending[msg_id] = event
|
||||
|
||||
request = json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "render",
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
# Serialize stdin writes to prevent interleaving from concurrent threads
|
||||
with self._write_lock:
|
||||
try:
|
||||
self._proc.stdin.write(request.encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
self._pending.pop(msg_id, None)
|
||||
raise RuntimeError(f"SSR worker pipe broken: {e}")
|
||||
|
||||
if not event.wait(self._timeout):
|
||||
self._pending.pop(msg_id, None)
|
||||
raise TimeoutError(
|
||||
f"SSR render of '{file}' timed out after {self._timeout}s"
|
||||
)
|
||||
|
||||
self._pending.pop(msg_id, None)
|
||||
result = self._results.pop(msg_id)
|
||||
|
||||
if "error" in result:
|
||||
raise RuntimeError(f"SSR render failed: {result['error']}")
|
||||
|
||||
return RenderResult(html=result["html"])
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Stop the Bun subprocess."""
|
||||
if self._proc is not None:
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
logger.info("Bun SSR worker stopped")
|
||||
Reference in New Issue
Block a user