Add SSR bridge: Django template backend + Bun subprocess renderer

Mizan's SSR is a Django template backend. Configure in TEMPLATES:

    TEMPLATES = [{
        'BACKEND': 'mizan.ssr.MizanTemplates',
        'OPTIONS': {'worker_path': 'frontend/ssr-worker.tsx'},
    }]

Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
component via a persistent Bun subprocess. The component name is the
template name. The context dict becomes props.

Architecture:
- Bun worker: stdin/stdout JSON-RPC, renderToString, component registry
- Django bridge: subprocess lifecycle, crash recovery, concurrent renders
- Template backend: implements Django's BaseEngine interface

This is the AFI's SSR boundary:
- Backend adapter implements mizan.ssr() (data gathering)
- Frontend adapter implements renderToHTML() (component rendering)
- Bun subprocess is the runtime hosting the frontend adapter

11 tests: ping, render, error handling, crash recovery, concurrent
renders (5 threads), template backend integration. All require Bun.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 02:18:05 -04:00
parent e5f8fafc01
commit 4147679e6b
9 changed files with 710 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
"""
mizan.ssr — Server-side rendering via Bun subprocess.
Mizan's SSR is a Django template backend. Configure it in TEMPLATES:
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
Then use Django's standard render():
return render(request, 'ProfilePage', {'user_id': 5})
The component name is the template name. The context dict becomes props.
"""
from .backend import MizanTemplates
__all__ = ["MizanTemplates"]

View File

@@ -0,0 +1,119 @@
"""
Mizan SSR Template Backend — Django template engine that renders React components.
Plugs into Django's TEMPLATES setting:
TEMPLATES = [
{
'BACKEND': 'mizan.ssr.MizanTemplates',
'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx',
'timeout': 5,
},
},
]
Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
component via the Bun subprocess.
"""
from __future__ import annotations
import logging
from typing import Any
from django.template import TemplateDoesNotExist
from django.template.backends.base import BaseEngine
from django.utils.safestring import mark_safe
from .bridge import SSRBridge
logger = logging.getLogger("mizan.ssr")
class MizanTemplate:
"""
A template that renders a React component via the SSR bridge.
The component name is the template name. The context dict becomes props.
"""
def __init__(self, component_name: str, bridge: SSRBridge) -> None:
self.component_name = component_name
self.origin = None # Required by Django's template interface
self._bridge = bridge
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str:
"""
Render the React component to an HTML string.
Args:
context: Template context dict — becomes React component props.
request: Django HttpRequest (available but not passed to the component).
Returns:
HTML string (marked safe for Django template output).
"""
props = dict(context) if context else {}
# Remove Django-internal context keys that aren't props
props.pop("request", None)
props.pop("csrf_token", None)
result = self._bridge.render(self.component_name, props)
# Wrap in a hydration-ready container
html = (
f'<div id="mizan-root" data-mizan-component="{self.component_name}">'
f'{result.html}'
f'</div>'
)
return mark_safe(html)
class MizanTemplates(BaseEngine):
"""
Django template backend that renders React components via Bun.
Component names are template names. No file lookup — the component
registry lives in the Bun worker process.
"""
def __init__(self, params: dict[str, Any]) -> None:
options = params.pop("OPTIONS", {})
# BaseEngine expects NAME, DIRS, APP_DIRS
params.setdefault("NAME", "mizan")
params.setdefault("DIRS", [])
params.setdefault("APP_DIRS", False)
super().__init__(params)
self._worker_path = options.get("worker_path", "ssr-worker.tsx")
self._timeout = options.get("timeout", 5)
self._bridge: SSRBridge | None = None
def get_bridge(self) -> SSRBridge:
"""Get or create the SSR bridge (lazy initialization)."""
if self._bridge is None:
self._bridge = SSRBridge(
worker_path=self._worker_path,
timeout=self._timeout,
)
return self._bridge
def get_template(self, template_name: str) -> MizanTemplate:
"""
Return a MizanTemplate for the given component name.
The component must be registered in the Bun worker. If it's not,
the error surfaces at render time (not at get_template time),
because the worker owns the component registry.
"""
return MizanTemplate(template_name, self.get_bridge())
def from_string(self, template_code: str) -> MizanTemplate:
"""Not supported — Mizan renders components, not template strings."""
raise TemplateDoesNotExist(
"MizanTemplates does not support from_string(). "
"Use component names registered in the Bun worker."
)

View File

@@ -0,0 +1,201 @@
"""
SSR Bridge — Manages a persistent Bun subprocess for React rendering.
Protocol: newline-delimited JSON-RPC over stdin/stdout.
Request: {"id": 1, "method": "render", "params": {"component": "ProfilePage", "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 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._counter = 0
self._pending: dict[int, threading.Event] = {}
self._results: dict[int, dict] = {}
self._reader_thread: threading.Thread | None = None
self._ready = threading.Event()
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, component: str, props: dict[str, Any] | None = None) -> RenderResult:
"""
Render a React component to HTML.
Args:
component: Component name (as registered in the Bun worker).
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 (component not found, render error, etc).
"""
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": {"component": component, "props": props or {}},
}) + "\n"
try:
self._proc.stdin.write(request.encode("utf-8"))
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as e:
del self._pending[msg_id]
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 '{component}' 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 ping(self) -> bool:
"""Health check. Returns True if the worker responds."""
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": "ping"}) + "\n"
try:
self._proc.stdin.write(request.encode("utf-8"))
self._proc.stdin.flush()
except (BrokenPipeError, OSError):
del self._pending[msg_id]
return False
if not event.wait(self._timeout):
self._pending.pop(msg_id, None)
return False
self._pending.pop(msg_id, None)
result = self._results.pop(msg_id)
return result.get("pong", False)
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")

View File

@@ -0,0 +1,162 @@
"""
Tests for the Mizan SSR bridge and template backend.
Requires Bun installed and the test worker at packages/mizan-ssr/src/test-worker.tsx.
Tests skip gracefully if Bun is not available.
"""
import os
import shutil
import threading
from django.test import SimpleTestCase, RequestFactory
# Path to the test worker
_SSR_WORKER = os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "..", "..", # up to repo root
"packages", "mizan-ssr", "src", "test-worker.tsx",
)
_SSR_WORKER = os.path.normpath(_SSR_WORKER)
_BUN_AVAILABLE = shutil.which("bun") is not None
_SKIP_MSG = "Bun not available"
class SSRBridgeTests(SimpleTestCase):
"""Tests for the SSR bridge subprocess manager."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.bridge import SSRBridge
self.bridge = SSRBridge(worker_path=_SSR_WORKER, timeout=5.0)
def tearDown(self):
if hasattr(self, "bridge"):
self.bridge.shutdown()
def test_ping(self):
"""Worker starts and responds to ping."""
self.assertTrue(self.bridge.ping())
def test_render_simple(self):
"""Renders a simple component to HTML."""
result = self.bridge.render("Hello", {"name": "World"})
self.assertIn("Hello,", result.html)
self.assertIn("World", result.html)
def test_render_with_props(self):
"""Renders a component with multiple props."""
result = self.bridge.render("UserProfile", {"user_id": 42, "name": "Alice"})
self.assertIn("Alice", result.html)
self.assertIn("42", result.html)
def test_render_missing_component(self):
"""Rendering an unregistered component raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("NonExistent", {})
self.assertIn("not registered", str(ctx.exception))
def test_render_error(self):
"""Component that throws during render raises RuntimeError."""
with self.assertRaises(RuntimeError) as ctx:
self.bridge.render("Broken", {})
self.assertIn("Render error", str(ctx.exception))
def test_crash_recovery(self):
"""Bridge restarts the worker if it dies."""
# First render works
result = self.bridge.render("Hello", {"name": "Before"})
self.assertIn("Before", result.html)
# Kill the subprocess
self.bridge._proc.kill()
self.bridge._proc.wait()
# Next render should restart and work
result = self.bridge.render("Hello", {"name": "After"})
self.assertIn("After", result.html)
def test_concurrent_renders(self):
"""Multiple threads can render simultaneously."""
results = {}
errors = {}
def render_in_thread(name: str, idx: int):
try:
result = self.bridge.render("Hello", {"name": name})
results[idx] = result.html
except Exception as e:
errors[idx] = e
threads = []
for i in range(5):
t = threading.Thread(target=render_in_thread, args=(f"User{i}", i))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=10)
self.assertEqual(len(errors), 0, f"Errors in concurrent renders: {errors}")
self.assertEqual(len(results), 5)
for i in range(5):
self.assertIn(f"User{i}", results[i])
class SSRTemplateBackendTests(SimpleTestCase):
"""Tests for the MizanTemplates Django template backend."""
def setUp(self):
if not _BUN_AVAILABLE:
self.skipTest(_SKIP_MSG)
if not os.path.exists(_SSR_WORKER):
self.skipTest(f"Test worker not found at {_SSR_WORKER}")
from mizan.ssr.backend import MizanTemplates
self.engine = MizanTemplates({
"NAME": "mizan-test",
"DIRS": [],
"APP_DIRS": False,
"OPTIONS": {
"worker_path": _SSR_WORKER,
"timeout": 5,
},
})
self.factory = RequestFactory()
def tearDown(self):
if hasattr(self, "engine") and self.engine._bridge is not None:
self.engine._bridge.shutdown()
def test_get_template(self):
"""get_template returns a MizanTemplate."""
from mizan.ssr.backend import MizanTemplate
template = self.engine.get_template("Hello")
self.assertIsInstance(template, MizanTemplate)
self.assertEqual(template.component_name, "Hello")
def test_template_render(self):
"""MizanTemplate.render() produces HTML."""
template = self.engine.get_template("Hello")
html = template.render({"name": "Django"})
self.assertIn("Hello,", html)
self.assertIn("Django", html)
self.assertIn('data-mizan-component="Hello"', html)
def test_template_render_strips_django_internals(self):
"""Django-internal context keys (request, csrf_token) are not passed as props."""
template = self.engine.get_template("Hello")
request = self.factory.get("/")
html = template.render({"name": "Test", "request": request, "csrf_token": "abc"}, request)
self.assertIn("Test", html)
def test_from_string_raises(self):
"""from_string is not supported."""
from django.template import TemplateDoesNotExist
with self.assertRaises(TemplateDoesNotExist):
self.engine.from_string("<div>Not supported</div>")