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:
25
packages/mizan-django/src/mizan/ssr/__init__.py
Normal file
25
packages/mizan-django/src/mizan/ssr/__init__.py
Normal 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"]
|
||||
119
packages/mizan-django/src/mizan/ssr/backend.py
Normal file
119
packages/mizan-django/src/mizan/ssr/backend.py
Normal 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."
|
||||
)
|
||||
201
packages/mizan-django/src/mizan/ssr/bridge.py
Normal file
201
packages/mizan-django/src/mizan/ssr/bridge.py
Normal 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")
|
||||
162
packages/mizan-django/src/mizan/tests/test_ssr.py
Normal file
162
packages/mizan-django/src/mizan/tests/test_ssr.py
Normal 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>")
|
||||
37
packages/mizan-ssr/bun.lock
Normal file
37
packages/mizan-ssr/bun.lock
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@mizan/ssr",
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
20
packages/mizan-ssr/package.json
Normal file
20
packages/mizan-ssr/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@mizan/ssr",
|
||||
"version": "0.1.0",
|
||||
"description": "Mizan SSR worker — renders React components to HTML via stdin/stdout JSON-RPC.",
|
||||
"type": "module",
|
||||
"main": "src/worker.tsx",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
1
packages/mizan-ssr/src/index.ts
Normal file
1
packages/mizan-ssr/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerComponent } from './worker'
|
||||
29
packages/mizan-ssr/src/test-worker.tsx
Normal file
29
packages/mizan-ssr/src/test-worker.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Test SSR worker — registers simple components for the test suite.
|
||||
*/
|
||||
|
||||
import { registerComponent } from './worker'
|
||||
|
||||
// Simple component that renders props
|
||||
function Hello({ name }: { name: string }) {
|
||||
return <div>Hello, {name}!</div>
|
||||
}
|
||||
|
||||
// Component that renders a list
|
||||
function UserProfile({ user_id, name }: { user_id: number; name: string }) {
|
||||
return (
|
||||
<div className="profile">
|
||||
<h1>{name}</h1>
|
||||
<span>ID: {user_id}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Component that throws during render
|
||||
function Broken() {
|
||||
throw new Error('Intentional render error')
|
||||
}
|
||||
|
||||
registerComponent('Hello', Hello)
|
||||
registerComponent('UserProfile', UserProfile)
|
||||
registerComponent('Broken', Broken)
|
||||
116
packages/mizan-ssr/src/worker.tsx
Normal file
116
packages/mizan-ssr/src/worker.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Mizan SSR Worker — Renders React components to HTML.
|
||||
*
|
||||
* Protocol: newline-delimited JSON-RPC over stdin/stdout.
|
||||
*
|
||||
* Request: {"id": 1, "method": "render", "params": {"component": "ProfilePage", "props": {...}}}
|
||||
* Response: {"id": 1, "html": "<div>...</div>"}
|
||||
*
|
||||
* Methods:
|
||||
* render — Render a registered component to HTML string
|
||||
* ping — Health check, returns {"id": N, "pong": true}
|
||||
*
|
||||
* The worker stays alive across requests. Django manages the subprocess.
|
||||
* Components are registered via registerComponent() before the worker starts reading.
|
||||
*/
|
||||
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
const registry = new Map<string, ComponentType<any>>()
|
||||
|
||||
/**
|
||||
* Register a React component for SSR rendering.
|
||||
* Call this before the worker starts processing (at module init time).
|
||||
*/
|
||||
export function registerComponent(name: string, component: ComponentType<any>): void {
|
||||
registry.set(name, component)
|
||||
}
|
||||
|
||||
interface RenderRequest {
|
||||
id: number
|
||||
method: 'render' | 'ping'
|
||||
params?: {
|
||||
component: string
|
||||
props: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderResponse {
|
||||
id: number
|
||||
html?: string
|
||||
pong?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function respond(msg: RenderResponse): void {
|
||||
const line = JSON.stringify(msg) + '\n'
|
||||
Bun.write(Bun.stdout, line)
|
||||
}
|
||||
|
||||
function handleMessage(msg: RenderRequest): void {
|
||||
if (msg.method === 'ping') {
|
||||
respond({ id: msg.id, pong: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.method === 'render') {
|
||||
const { component, props } = msg.params ?? {}
|
||||
|
||||
if (!component) {
|
||||
respond({ id: msg.id, error: 'Missing component name' })
|
||||
return
|
||||
}
|
||||
|
||||
const Component = registry.get(component)
|
||||
if (!Component) {
|
||||
respond({ id: msg.id, error: `Component '${component}' not registered` })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const html = renderToString(createElement(Component, props ?? {}))
|
||||
respond({ id: msg.id, html })
|
||||
} catch (e: any) {
|
||||
respond({ id: msg.id, error: `Render error: ${e.message}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
|
||||
}
|
||||
|
||||
// Read newline-delimited JSON from stdin
|
||||
async function processInput(): Promise<void> {
|
||||
const reader = Bun.stdin.stream().getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let newlineIdx: number
|
||||
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx).trim()
|
||||
buffer = buffer.slice(newlineIdx + 1)
|
||||
|
||||
if (line) {
|
||||
try {
|
||||
handleMessage(JSON.parse(line))
|
||||
} catch (e: any) {
|
||||
// Malformed JSON — respond with error if we can parse an id
|
||||
Bun.write(Bun.stdout, JSON.stringify({ id: -1, error: `Parse error: ${e.message}` }) + '\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal readiness
|
||||
Bun.write(Bun.stdout, JSON.stringify({ id: 0, ready: true }) + '\n')
|
||||
|
||||
processInput()
|
||||
Reference in New Issue
Block a user