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()