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:
161
cores/mizan-python/src/mizan_core/manifest.py
Normal file
161
cores/mizan-python/src/mizan_core/manifest.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Edge-manifest derivation — the AFI-common source of truth.
|
||||
|
||||
The Edge manifest is a static JSON mapping contexts to URL patterns, params, and
|
||||
cache/render policy. Mizan Edge reads it at deploy time to drive CDN cache
|
||||
purging: when it receives `X-Mizan-Invalidate: user;user_id=5` it looks up
|
||||
`user` in the manifest, resolves the page routes with the params, and purges
|
||||
both the resolved URLs and the context endpoint.
|
||||
|
||||
The manifest is *derived from the registry* — the same `@client` metadata every
|
||||
adapter populates — so its derivation is AFI-common, not framework-bound. It
|
||||
lives here in the core; each adapter exposes it (a callable, a CLI entry) over
|
||||
its own surface. Django's `export_edge_manifest` command and the FastAPI
|
||||
console entry both call `generate_edge_manifest`; there is one derivation.
|
||||
|
||||
`render_strategy` is computed here too: a context whose params overlap
|
||||
`USER_SCOPED_PARAMS` is `dynamic_cached` (per-user at the edge); one whose
|
||||
params don't is `psr` (one shared pre-rendered artifact, re-rendered on
|
||||
mutation). That single rule is what the `psr` capability checks for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from mizan_core.registry import get_context_groups, get_function, get_all_functions
|
||||
|
||||
|
||||
__all__ = [
|
||||
"USER_SCOPED_PARAMS",
|
||||
"generate_edge_manifest",
|
||||
"generate_edge_manifest_json",
|
||||
]
|
||||
|
||||
|
||||
# A context is per-user (and so must be `dynamic_cached` at the edge) when any of
|
||||
# its params identifies a user. A context with no such param renders one shared
|
||||
# artifact — `psr`. This set is the entire `render_strategy` decision.
|
||||
USER_SCOPED_PARAMS: frozenset[str] = frozenset({"user_id", "user", "owner_id", "account_id"})
|
||||
|
||||
|
||||
def _input_param_names(fn_cls: Any) -> set[str]:
|
||||
"""The declared input field names of a registered function (empty if none)."""
|
||||
input_cls = getattr(fn_cls, "Input", None)
|
||||
if input_cls is not None and hasattr(input_cls, "model_fields"):
|
||||
return set(input_cls.model_fields.keys())
|
||||
return set()
|
||||
|
||||
|
||||
def generate_edge_manifest(
|
||||
base_url: str = "/api/mizan",
|
||||
view_urls: dict[str, list[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Derive the Edge manifest from the function registry.
|
||||
|
||||
Args:
|
||||
base_url: The Mizan API mount point (default ``/api/mizan``).
|
||||
view_urls: Optional extra page routes per context for Edge to purge,
|
||||
beyond the routes declared on view-path functions.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable manifest: ``{"version", "contexts", "mutations"}``.
|
||||
"""
|
||||
groups = get_context_groups()
|
||||
all_functions = get_all_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
|
||||
|
||||
param_names |= _input_param_names(fn_cls)
|
||||
|
||||
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)
|
||||
|
||||
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(param_names),
|
||||
"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 a param of an affected
|
||||
# context. These are the keys Edge can resolve to scope the purge.
|
||||
fn_params = _input_param_names(fn_cls)
|
||||
if fn_params:
|
||||
auto_scoped: list[str] = []
|
||||
for ctx_name in affected_contexts:
|
||||
ctx_param_names: set[str] = set()
|
||||
for ctx_fn_name in groups.get(ctx_name, []):
|
||||
ctx_fn_cls = all_functions.get(ctx_fn_name)
|
||||
if ctx_fn_cls is not None:
|
||||
ctx_param_names |= _input_param_names(ctx_fn_cls)
|
||||
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 | None = 2,
|
||||
) -> str:
|
||||
"""JSON-serialize the Edge manifest (keys sorted for deterministic output)."""
|
||||
return json.dumps(
|
||||
generate_edge_manifest(base_url, view_urls), indent=indent, sort_keys=True
|
||||
)
|
||||
@@ -5,12 +5,11 @@ sub-registry (channels/WebSocket, forms, shapes) to plug into.
|
||||
|
||||
This is the framework-agnostic registry. The extension points
|
||||
(channels, forms, websockets, shapes) are AFI-common: every adapter owes
|
||||
a binding for each, and registers it here so the unified schema export
|
||||
sees it. Django binds all of them today; the other adapters' unbound
|
||||
extensions are gaps tracked by the capability-parity suite in
|
||||
`tests/afi/`, not framework-specific features. The binding is per-stack
|
||||
(Django Channels vs. native WebSocket, Django Forms vs. Pydantic); the
|
||||
capability is common.
|
||||
a binding for each, on its own stack — Django Channels or a native
|
||||
WebSocket route; Django Forms or Pydantic; django-readers or the project's
|
||||
ORM. The capability is common; the binding is per-stack. Each adapter wires
|
||||
its binding so the unified schema export sees it; an unwired one is a gap on
|
||||
the capability-parity board (`tests/afi/`), not a framework-specific feature.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
12
cores/mizan-python/src/mizan_core/ssr/__init__.py
Normal file
12
cores/mizan-python/src/mizan_core/ssr/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
mizan_core.ssr — framework-agnostic server-side rendering.
|
||||
|
||||
`SSRBridge` manages a persistent Bun subprocess that renders React components to
|
||||
HTML over JSON-RPC. It is the single source for the SSR subprocess lifecycle;
|
||||
adapters wrap it over their own surface (Django's `MizanTemplates`, FastAPI's
|
||||
`SSRRenderer`).
|
||||
"""
|
||||
|
||||
from mizan_core.ssr.bridge import RenderResult, SSRBridge
|
||||
|
||||
__all__ = ["SSRBridge", "RenderResult"]
|
||||
186
cores/mizan-python/src/mizan_core/ssr/bridge.py
Normal file
186
cores/mizan-python/src/mizan_core/ssr/bridge.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
SSR Bridge — manages a persistent Bun subprocess for React rendering.
|
||||
|
||||
Framework-agnostic (no web-framework imports): the bridge spawns the Bun worker,
|
||||
speaks the JSON-RPC protocol, and returns rendered HTML. Each adapter wraps it
|
||||
over its own surface — Django's `MizanTemplates` template backend, FastAPI's SSR
|
||||
render path — so the subprocess lifecycle and wire protocol are authored once.
|
||||
|
||||
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 worker threads 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