SSR: file-path rendering, no component registry
The worker receives a file path in the JSON message, dynamically
imports it, renders it. No registerComponent API, no app entry file,
no export maps. Django's template backend resolves the template name
to an absolute path against DIRS, same as every other template engine.
render(request, 'components/Hello.tsx', {'name': 'World'})
Verified working: curl http://localhost:8000/hello/ returns
<div id="mizan-root"><div>Hello, World!</div></div>
Changes:
- worker.tsx: receives file path, dynamic import with cache
- bridge.py: sends file path instead of component name
- backend.py: resolves template name against DIRS to absolute path
- Fix bridge.py:147 bug (referenced deleted 'component' variable)
- Example app: Hello.tsx component, /hello/ view, template config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,22 @@
|
||||
"""
|
||||
Mizan SSR Template Backend — Django template engine that renders React components.
|
||||
|
||||
Plugs into Django's TEMPLATES setting:
|
||||
Mizan SSR Template Backend — Django template engine that renders React via Bun.
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'mizan.ssr.MizanTemplates',
|
||||
'DIRS': [BASE_DIR / 'frontend'],
|
||||
'OPTIONS': {
|
||||
'worker_path': 'frontend/ssr-worker.tsx',
|
||||
'timeout': 5,
|
||||
'worker': 'path/to/mizan-ssr/src/worker.tsx',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Then render(request, 'ProfilePage', {'user_id': 5}) renders the React
|
||||
component via the Bun subprocess.
|
||||
Then: render(request, 'components/Hello.tsx', {'name': 'World'})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from django.template import TemplateDoesNotExist
|
||||
@@ -28,92 +25,68 @@ 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.
|
||||
"""Renders a .tsx/.jsx file 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
|
||||
def __init__(self, file_path: str, bridge: SSRBridge) -> None:
|
||||
self.file_path = file_path
|
||||
self.origin = None
|
||||
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)
|
||||
result = self._bridge.render(self.file_path, 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)
|
||||
return mark_safe(f'<div id="mizan-root">{result.html}</div>')
|
||||
|
||||
|
||||
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.
|
||||
Template names are file paths resolved against DIRS.
|
||||
Same model as Django's built-in template engines.
|
||||
"""
|
||||
|
||||
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._worker = options.get("worker")
|
||||
self._timeout = options.get("timeout", 5)
|
||||
self._bridge: SSRBridge | None = None
|
||||
|
||||
if not self._worker:
|
||||
raise ValueError(
|
||||
"MizanTemplates requires OPTIONS['worker'] — "
|
||||
"the path to mizan-ssr's worker.tsx"
|
||||
)
|
||||
|
||||
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,
|
||||
worker_path=self._worker,
|
||||
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())
|
||||
for dir_path in self.dirs:
|
||||
file_path = os.path.join(dir_path, template_name)
|
||||
if os.path.isfile(file_path):
|
||||
return MizanTemplate(
|
||||
os.path.abspath(file_path),
|
||||
self.get_bridge(),
|
||||
)
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
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."
|
||||
"MizanTemplates renders .tsx files, not template strings."
|
||||
)
|
||||
|
||||
@@ -105,12 +105,12 @@ class SSRBridge:
|
||||
except Exception:
|
||||
logger.warning("SSR reader thread exited", exc_info=True)
|
||||
|
||||
def render(self, component: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult:
|
||||
"""
|
||||
Render a React component to HTML.
|
||||
|
||||
Args:
|
||||
component: Component name (as registered in the Bun worker).
|
||||
file: Absolute path to the .tsx/.jsx file to render.
|
||||
props: Props to pass to the component.
|
||||
|
||||
Returns:
|
||||
@@ -118,7 +118,7 @@ class SSRBridge:
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the render takes longer than the configured timeout.
|
||||
RuntimeError: If the render fails (component not found, render error, etc).
|
||||
RuntimeError: If the render fails.
|
||||
"""
|
||||
with self._lock:
|
||||
self._ensure_running()
|
||||
@@ -131,7 +131,7 @@ class SSRBridge:
|
||||
request = json.dumps({
|
||||
"id": msg_id,
|
||||
"method": "render",
|
||||
"params": {"component": component, "props": props or {}},
|
||||
"params": {"file": file, "props": props or {}},
|
||||
}) + "\n"
|
||||
|
||||
try:
|
||||
@@ -144,7 +144,7 @@ class SSRBridge:
|
||||
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"
|
||||
f"SSR render of '{file}' timed out after {self._timeout}s"
|
||||
)
|
||||
|
||||
self._pending.pop(msg_id, None)
|
||||
|
||||
Reference in New Issue
Block a user