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:
37
packages/mizan-ssr/bun.lock
Normal file
37
packages/mizan-ssr/bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
20
packages/mizan-ssr/package.json
Normal file
20
packages/mizan-ssr/package.json
Normal file
@@ -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"
|
||||
}
|
||||
1
packages/mizan-ssr/src/index.ts
Normal file
1
packages/mizan-ssr/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { registerComponent } from './worker'
|
||||
29
packages/mizan-ssr/src/test-worker.tsx
Normal file
29
packages/mizan-ssr/src/test-worker.tsx
Normal 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)
|
||||
116
packages/mizan-ssr/src/worker.tsx
Normal file
116
packages/mizan-ssr/src/worker.tsx
Normal 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()
|
||||
Reference in New Issue
Block a user