diff --git a/examples/django-react-site/backend/db.sqlite3 b/examples/django-react-site/backend/db.sqlite3
new file mode 100644
index 0000000..52ff378
Binary files /dev/null and b/examples/django-react-site/backend/db.sqlite3 differ
diff --git a/examples/django-react-site/backend/testapp/settings.py b/examples/django-react-site/backend/testapp/settings.py
index 63beaea..db22859 100644
--- a/examples/django-react-site/backend/testapp/settings.py
+++ b/examples/django-react-site/backend/testapp/settings.py
@@ -34,6 +34,19 @@ MIDDLEWARE = [
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 = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
diff --git a/examples/django-react-site/backend/testapp/urls.py b/examples/django-react-site/backend/testapp/urls.py
index 6610f6e..7ee0f58 100644
--- a/examples/django-react-site/backend/testapp/urls.py
+++ b/examples/django-react-site/backend/testapp/urls.py
@@ -1,5 +1,13 @@
+from django.http import HttpResponse
+from django.shortcuts import render
from django.urls import include, path
+
+def hello_view(request):
+ return render(request, "components/Hello.tsx", {"name": "World"})
+
+
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
+ path("hello/", hello_view),
]
diff --git a/examples/django-react-site/frontend/components/Hello.tsx b/examples/django-react-site/frontend/components/Hello.tsx
new file mode 100644
index 0000000..3045e52
--- /dev/null
+++ b/examples/django-react-site/frontend/components/Hello.tsx
@@ -0,0 +1,3 @@
+export default function Hello({ name }: { name: string }) {
+ return
Hello, {name}!
+}
diff --git a/examples/django-react-site/harness/vite.config.ts b/examples/django-react-site/harness/vite.config.ts
index f501af3..6ce9d03 100644
--- a/examples/django-react-site/harness/vite.config.ts
+++ b/examples/django-react-site/harness/vite.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
-const reactPkg = path.resolve(__dirname, '../../react/src')
+const reactPkg = path.resolve(__dirname, '../../../packages/mizan-react/src')
export default defineConfig({
plugins: [react()],
diff --git a/packages/mizan-django/src/mizan/ssr/backend.py b/packages/mizan-django/src/mizan/ssr/backend.py
index 267e43c..7f87384 100644
--- a/packages/mizan-django/src/mizan/ssr/backend.py
+++ b/packages/mizan-django/src/mizan/ssr/backend.py
@@ -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''
- f'{result.html}'
- f'
'
- )
-
- return mark_safe(html)
+ return mark_safe(f'{result.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.
+ 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."
)
diff --git a/packages/mizan-django/src/mizan/ssr/bridge.py b/packages/mizan-django/src/mizan/ssr/bridge.py
index 3d7394d..0fb28e1 100644
--- a/packages/mizan-django/src/mizan/ssr/bridge.py
+++ b/packages/mizan-django/src/mizan/ssr/bridge.py
@@ -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)
diff --git a/packages/mizan-ssr/src/index.ts b/packages/mizan-ssr/src/index.ts
index aedc96f..9d8aeed 100644
--- a/packages/mizan-ssr/src/index.ts
+++ b/packages/mizan-ssr/src/index.ts
@@ -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.
diff --git a/packages/mizan-ssr/src/worker.tsx b/packages/mizan-ssr/src/worker.tsx
index 82f4981..848f449 100644
--- a/packages/mizan-ssr/src/worker.tsx
+++ b/packages/mizan-ssr/src/worker.tsx
@@ -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": "...
"}
- *
- * 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>()
+const cache = new Map()
-/**
- * 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): void {
- registry.set(name, component)
+function respond(msg: Record): void {
+ Bun.write(Bun.stdout, JSON.stringify(msg) + '\n')
}
-interface RenderRequest {
- id: number
- method: 'render' | 'ping'
- params?: {
- component: string
- props: Record
- }
-}
-
-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 {
+async function handleMessage(msg: any): Promise {
if (msg.method === 'ping') {
respond({ id: msg.id, pong: true })
return
}
if (msg.method === 'render') {
- const { component, props } = msg.params ?? {}
+ const { file, 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` })
+ if (!file) {
+ respond({ id: msg.id, error: 'Missing file path' })
return
}
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 ?? {}))
respond({ id: msg.id, html })
} catch (e: any) {
- respond({ id: msg.id, error: `Render error: ${e.message}` })
+ respond({ id: msg.id, error: e.message })
}
return
}
@@ -81,7 +45,6 @@ function handleMessage(msg: RenderRequest): void {
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
}
-// Read newline-delimited JSON from stdin
async function processInput(): Promise {
const reader = Bun.stdin.stream().getReader()
const decoder = new TextDecoder()
@@ -93,24 +56,17 @@ async function processInput(): Promise {
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)
-
+ let i: number
+ while ((i = buffer.indexOf('\n')) !== -1) {
+ const line = buffer.slice(0, i).trim()
+ buffer = buffer.slice(i + 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')
- }
+ try { await handleMessage(JSON.parse(line)) }
+ catch (e: any) { respond({ id: -1, error: e.message }) }
}
}
}
}
-// Signal readiness
-Bun.write(Bun.stdout, JSON.stringify({ id: 0, ready: true }) + '\n')
-
+respond({ id: 0, ready: true })
processInput()