diff --git a/packages/mizan-django/src/mizan/ssr/__init__.py b/packages/mizan-django/src/mizan/ssr/__init__.py
new file mode 100644
index 0000000..5580269
--- /dev/null
+++ b/packages/mizan-django/src/mizan/ssr/__init__.py
@@ -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"]
diff --git a/packages/mizan-django/src/mizan/ssr/backend.py b/packages/mizan-django/src/mizan/ssr/backend.py
new file mode 100644
index 0000000..267e43c
--- /dev/null
+++ b/packages/mizan-django/src/mizan/ssr/backend.py
@@ -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'
'
+ f'{result.html}'
+ f'
'
+ )
+
+ 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."
+ )
diff --git a/packages/mizan-django/src/mizan/ssr/bridge.py b/packages/mizan-django/src/mizan/ssr/bridge.py
new file mode 100644
index 0000000..3d7394d
--- /dev/null
+++ b/packages/mizan-django/src/mizan/ssr/bridge.py
@@ -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": "...
"}
+
+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")
diff --git a/packages/mizan-django/src/mizan/tests/test_ssr.py b/packages/mizan-django/src/mizan/tests/test_ssr.py
new file mode 100644
index 0000000..d6bf449
--- /dev/null
+++ b/packages/mizan-django/src/mizan/tests/test_ssr.py
@@ -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("Not supported
")
diff --git a/packages/mizan-ssr/bun.lock b/packages/mizan-ssr/bun.lock
new file mode 100644
index 0000000..efcf8c6
--- /dev/null
+++ b/packages/mizan-ssr/bun.lock
@@ -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=="],
+ }
+}
diff --git a/packages/mizan-ssr/package.json b/packages/mizan-ssr/package.json
new file mode 100644
index 0000000..174980d
--- /dev/null
+++ b/packages/mizan-ssr/package.json
@@ -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"
+}
diff --git a/packages/mizan-ssr/src/index.ts b/packages/mizan-ssr/src/index.ts
new file mode 100644
index 0000000..aedc96f
--- /dev/null
+++ b/packages/mizan-ssr/src/index.ts
@@ -0,0 +1 @@
+export { registerComponent } from './worker'
diff --git a/packages/mizan-ssr/src/test-worker.tsx b/packages/mizan-ssr/src/test-worker.tsx
new file mode 100644
index 0000000..7a71c08
--- /dev/null
+++ b/packages/mizan-ssr/src/test-worker.tsx
@@ -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 Hello, {name}!
+}
+
+// Component that renders a list
+function UserProfile({ user_id, name }: { user_id: number; name: string }) {
+ return (
+
+
{name}
+ ID: {user_id}
+
+ )
+}
+
+// Component that throws during render
+function Broken() {
+ throw new Error('Intentional render error')
+}
+
+registerComponent('Hello', Hello)
+registerComponent('UserProfile', UserProfile)
+registerComponent('Broken', Broken)
diff --git a/packages/mizan-ssr/src/worker.tsx b/packages/mizan-ssr/src/worker.tsx
new file mode 100644
index 0000000..82f4981
--- /dev/null
+++ b/packages/mizan-ssr/src/worker.tsx
@@ -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": "...
"}
+ *
+ * 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>()
+
+/**
+ * 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)
+}
+
+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 {
+ 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 {
+ 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()