SSR: file-path rendering, no component registry
The worker receives a file path in the JSON message, dynamically
imports it, renders it. No registerComponent API, no app entry file,
no export maps. Django's template backend resolves the template name
to an absolute path against DIRS, same as every other template engine.
render(request, 'components/Hello.tsx', {'name': 'World'})
Verified working: curl http://localhost:8000/hello/ returns
<div id="mizan-root"><div>Hello, World!</div></div>
Changes:
- worker.tsx: receives file path, dynamic import with cache
- bridge.py: sends file path instead of component name
- backend.py: resolves template name against DIRS to absolute path
- Fix bridge.py:147 bug (referenced deleted 'component' variable)
- Example app: Hello.tsx component, /hello/ view, template config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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": "<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>>()
|
||||
const cache = new Map<string, 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)
|
||||
function respond(msg: Record<string, any>): void {
|
||||
Bun.write(Bun.stdout, JSON.stringify(msg) + '\n')
|
||||
}
|
||||
|
||||
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 {
|
||||
async function handleMessage(msg: any): Promise<void> {
|
||||
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<void> {
|
||||
const reader = Bun.stdin.stream().getReader()
|
||||
const decoder = new TextDecoder()
|
||||
@@ -93,24 +56,17 @@ async function processInput(): Promise<void> {
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user