From 4147679e6bcce7abf3ee87792d6972a10d2c24cf Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 02:18:05 -0400 Subject: [PATCH] Add SSR bridge: Django template backend + Bun subprocess renderer Mizan's SSR is a Django template backend. Configure in TEMPLATES: TEMPLATES = [{ 'BACKEND': 'mizan.ssr.MizanTemplates', 'OPTIONS': {'worker_path': 'frontend/ssr-worker.tsx'}, }] Then render(request, 'ProfilePage', {'user_id': 5}) renders the React component via a persistent Bun subprocess. The component name is the template name. The context dict becomes props. Architecture: - Bun worker: stdin/stdout JSON-RPC, renderToString, component registry - Django bridge: subprocess lifecycle, crash recovery, concurrent renders - Template backend: implements Django's BaseEngine interface This is the AFI's SSR boundary: - Backend adapter implements mizan.ssr() (data gathering) - Frontend adapter implements renderToHTML() (component rendering) - Bun subprocess is the runtime hosting the frontend adapter 11 tests: ping, render, error handling, crash recovery, concurrent renders (5 threads), template backend integration. All require Bun. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mizan-django/src/mizan/ssr/__init__.py | 25 +++ .../mizan-django/src/mizan/ssr/backend.py | 119 +++++++++++ packages/mizan-django/src/mizan/ssr/bridge.py | 201 ++++++++++++++++++ .../mizan-django/src/mizan/tests/test_ssr.py | 162 ++++++++++++++ packages/mizan-ssr/bun.lock | 37 ++++ packages/mizan-ssr/package.json | 20 ++ packages/mizan-ssr/src/index.ts | 1 + packages/mizan-ssr/src/test-worker.tsx | 29 +++ packages/mizan-ssr/src/worker.tsx | 116 ++++++++++ 9 files changed, 710 insertions(+) create mode 100644 packages/mizan-django/src/mizan/ssr/__init__.py create mode 100644 packages/mizan-django/src/mizan/ssr/backend.py create mode 100644 packages/mizan-django/src/mizan/ssr/bridge.py create mode 100644 packages/mizan-django/src/mizan/tests/test_ssr.py create mode 100644 packages/mizan-ssr/bun.lock create mode 100644 packages/mizan-ssr/package.json create mode 100644 packages/mizan-ssr/src/index.ts create mode 100644 packages/mizan-ssr/src/test-worker.tsx create mode 100644 packages/mizan-ssr/src/worker.tsx 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()