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:
2026-04-07 03:33:01 -04:00
parent 658cbebce1
commit 1b5dca5ab3
9 changed files with 89 additions and 135 deletions

Binary file not shown.

View File

@@ -34,6 +34,19 @@ MIDDLEWARE = [
ROOT_URLCONF = "testapp.urls" ROOT_URLCONF = "testapp.urls"
TEMPLATES = [
{
"BACKEND": "mizan.ssr.MizanTemplates",
"DIRS": [os.path.join(os.path.dirname(__file__), "..", "..", "frontend")],
"OPTIONS": {
"worker": os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..",
"packages", "mizan-ssr", "src", "worker.tsx",
),
},
},
]
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",

View File

@@ -1,5 +1,13 @@
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import include, path from django.urls import include, path
def hello_view(request):
return render(request, "components/Hello.tsx", {"name": "World"})
urlpatterns = [ urlpatterns = [
path("api/mizan/", include("mizan.urls")), path("api/mizan/", include("mizan.urls")),
path("hello/", hello_view),
] ]

View File

@@ -0,0 +1,3 @@
export default function Hello({ name }: { name: string }) {
return <div>Hello, {name}!</div>
}

View File

@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
const reactPkg = path.resolve(__dirname, '../../react/src') const reactPkg = path.resolve(__dirname, '../../../packages/mizan-react/src')
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],

View File

@@ -1,25 +1,22 @@
""" """
Mizan SSR Template Backend — Django template engine that renders React components. Mizan SSR Template Backend — Django template engine that renders React via Bun.
Plugs into Django's TEMPLATES setting:
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'mizan.ssr.MizanTemplates', 'BACKEND': 'mizan.ssr.MizanTemplates',
'DIRS': [BASE_DIR / 'frontend'],
'OPTIONS': { 'OPTIONS': {
'worker_path': 'frontend/ssr-worker.tsx', 'worker': 'path/to/mizan-ssr/src/worker.tsx',
'timeout': 5,
}, },
}, },
] ]
Then render(request, 'ProfilePage', {'user_id': 5}) renders the React Then: render(request, 'components/Hello.tsx', {'name': 'World'})
component via the Bun subprocess.
""" """
from __future__ import annotations from __future__ import annotations
import logging import os
from typing import Any from typing import Any
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
@@ -28,92 +25,68 @@ from django.utils.safestring import mark_safe
from .bridge import SSRBridge from .bridge import SSRBridge
logger = logging.getLogger("mizan.ssr")
class MizanTemplate: class MizanTemplate:
""" """Renders a .tsx/.jsx file via the SSR bridge."""
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, file_path: str, bridge: SSRBridge) -> None:
""" self.file_path = file_path
self.origin = None
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 self._bridge = bridge
def render(self, context: dict[str, Any] | None = None, request: Any = None) -> str: 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 {} props = dict(context) if context else {}
# Remove Django-internal context keys that aren't props
props.pop("request", None) props.pop("request", None)
props.pop("csrf_token", 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 return mark_safe(f'<div id="mizan-root">{result.html}</div>')
html = (
f'<div id="mizan-root" data-mizan-component="{self.component_name}">'
f'{result.html}'
f'</div>'
)
return mark_safe(html)
class MizanTemplates(BaseEngine): class MizanTemplates(BaseEngine):
""" """
Django template backend that renders React components via Bun. Django template backend that renders React components via Bun.
Component names are template names. No file lookup — the component Template names are file paths resolved against DIRS.
registry lives in the Bun worker process. Same model as Django's built-in template engines.
""" """
def __init__(self, params: dict[str, Any]) -> None: def __init__(self, params: dict[str, Any]) -> None:
options = params.pop("OPTIONS", {}) options = params.pop("OPTIONS", {})
# BaseEngine expects NAME, DIRS, APP_DIRS
params.setdefault("NAME", "mizan") params.setdefault("NAME", "mizan")
params.setdefault("DIRS", [])
params.setdefault("APP_DIRS", False) params.setdefault("APP_DIRS", False)
super().__init__(params) 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._timeout = options.get("timeout", 5)
self._bridge: SSRBridge | None = None 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: def get_bridge(self) -> SSRBridge:
"""Get or create the SSR bridge (lazy initialization)."""
if self._bridge is None: if self._bridge is None:
self._bridge = SSRBridge( self._bridge = SSRBridge(
worker_path=self._worker_path, worker_path=self._worker,
timeout=self._timeout, timeout=self._timeout,
) )
return self._bridge return self._bridge
def get_template(self, template_name: str) -> MizanTemplate: def get_template(self, template_name: str) -> MizanTemplate:
""" for dir_path in self.dirs:
Return a MizanTemplate for the given component name. file_path = os.path.join(dir_path, template_name)
if os.path.isfile(file_path):
The component must be registered in the Bun worker. If it's not, return MizanTemplate(
the error surfaces at render time (not at get_template time), os.path.abspath(file_path),
because the worker owns the component registry. self.get_bridge(),
""" )
return MizanTemplate(template_name, self.get_bridge()) raise TemplateDoesNotExist(template_name)
def from_string(self, template_code: str) -> MizanTemplate: def from_string(self, template_code: str) -> MizanTemplate:
"""Not supported — Mizan renders components, not template strings."""
raise TemplateDoesNotExist( raise TemplateDoesNotExist(
"MizanTemplates does not support from_string(). " "MizanTemplates renders .tsx files, not template strings."
"Use component names registered in the Bun worker."
) )

View File

@@ -105,12 +105,12 @@ class SSRBridge:
except Exception: except Exception:
logger.warning("SSR reader thread exited", exc_info=True) 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. Render a React component to HTML.
Args: 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. props: Props to pass to the component.
Returns: Returns:
@@ -118,7 +118,7 @@ class SSRBridge:
Raises: Raises:
TimeoutError: If the render takes longer than the configured timeout. 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: with self._lock:
self._ensure_running() self._ensure_running()
@@ -131,7 +131,7 @@ class SSRBridge:
request = json.dumps({ request = json.dumps({
"id": msg_id, "id": msg_id,
"method": "render", "method": "render",
"params": {"component": component, "props": props or {}}, "params": {"file": file, "props": props or {}},
}) + "\n" }) + "\n"
try: try:
@@ -144,7 +144,7 @@ class SSRBridge:
if not event.wait(self._timeout): if not event.wait(self._timeout):
self._pending.pop(msg_id, None) self._pending.pop(msg_id, None)
raise TimeoutError( 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) self._pending.pop(msg_id, None)

View File

@@ -1 +1,2 @@
export { registerComponent } from './worker' // The SSR package is a Bun worker subprocess (worker.tsx).
// It is not imported as a library. Django's SSRBridge spawns it.

View File

@@ -1,79 +1,43 @@
/**
* 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 { renderToString } from 'react-dom/server'
import { createElement } from 'react' import { createElement } from 'react'
import type { ComponentType } from 'react'
const registry = new Map<string, ComponentType<any>>() const cache = new Map<string, any>()
/** function respond(msg: Record<string, any>): void {
* Register a React component for SSR rendering. Bun.write(Bun.stdout, JSON.stringify(msg) + '\n')
* 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 { async function handleMessage(msg: any): Promise<void> {
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') { if (msg.method === 'ping') {
respond({ id: msg.id, pong: true }) respond({ id: msg.id, pong: true })
return return
} }
if (msg.method === 'render') { if (msg.method === 'render') {
const { component, props } = msg.params ?? {} const { file, props } = msg.params ?? {}
if (!component) { if (!file) {
respond({ id: msg.id, error: 'Missing component name' }) respond({ id: msg.id, error: 'Missing file path' })
return
}
const Component = registry.get(component)
if (!Component) {
respond({ id: msg.id, error: `Component '${component}' not registered` })
return return
} }
try { try {
let mod = cache.get(file)
if (!mod) {
mod = await import(file)
cache.set(file, mod)
}
const Component = mod.default || Object.values(mod)[0]
if (!Component) {
respond({ id: msg.id, error: `No component exported from ${file}` })
return
}
const html = renderToString(createElement(Component, props ?? {})) const html = renderToString(createElement(Component, props ?? {}))
respond({ id: msg.id, html }) respond({ id: msg.id, html })
} catch (e: any) { } catch (e: any) {
respond({ id: msg.id, error: `Render error: ${e.message}` }) respond({ id: msg.id, error: e.message })
} }
return return
} }
@@ -81,7 +45,6 @@ function handleMessage(msg: RenderRequest): void {
respond({ id: msg.id, error: `Unknown method: ${msg.method}` }) respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
} }
// Read newline-delimited JSON from stdin
async function processInput(): Promise<void> { async function processInput(): Promise<void> {
const reader = Bun.stdin.stream().getReader() const reader = Bun.stdin.stream().getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
@@ -93,24 +56,17 @@ async function processInput(): Promise<void> {
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
let newlineIdx: number let i: number
while ((newlineIdx = buffer.indexOf('\n')) !== -1) { while ((i = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIdx).trim() const line = buffer.slice(0, i).trim()
buffer = buffer.slice(newlineIdx + 1) buffer = buffer.slice(i + 1)
if (line) { if (line) {
try { try { await handleMessage(JSON.parse(line)) }
handleMessage(JSON.parse(line)) catch (e: any) { respond({ id: -1, error: e.message }) }
} 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 respond({ id: 0, ready: true })
Bun.write(Bun.stdout, JSON.stringify({ id: 0, ready: true }) + '\n')
processInput() processInput()