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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 02:18:05 -04:00
parent e5f8fafc01
commit 4147679e6b
9 changed files with 710 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { registerComponent } from './worker'

View File

@@ -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 <div>Hello, {name}!</div>
}
// Component that renders a list
function UserProfile({ user_id, name }: { user_id: number; name: string }) {
return (
<div className="profile">
<h1>{name}</h1>
<span>ID: {user_id}</span>
</div>
)
}
// Component that throws during render
function Broken() {
throw new Error('Intentional render error')
}
registerComponent('Hello', Hello)
registerComponent('UserProfile', UserProfile)
registerComponent('Broken', Broken)

View File

@@ -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": "<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 { createElement } from 'react'
import type { ComponentType } from 'react'
const registry = new Map<string, ComponentType<any>>()
/**
* 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<any>): void {
registry.set(name, component)
}
interface RenderRequest {
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') {
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<void> {
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()