/** * SSR Bridge — manages a persistent Bun subprocess for React server-rendering. * * TypeScript port of `mizan-django/src/mizan/ssr/bridge.py`. Same wire * protocol: newline-delimited JSON-RPC over the worker's stdin/stdout, with * message-id correlation so concurrent renders don't cross. * * → { "id": 1, "method": "render", "params": { "file": "/abs/Hello.tsx", "props": { ... } } } * ← { "id": 1, "html": "
...
" } * ← { "id": 1, "error": "..." } (on failure) * * The worker (`workers/mizan-ssr/src/worker.tsx`) `import()`s the component file * and calls `renderToString` — no registry. It announces readiness with * `{ "id": 0, "ready": true }`; the bridge waits for that before accepting * renders, and restarts the worker if it exits. */ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' export interface SSRBridgeOptions { /** Absolute path to the worker entry (workers/mizan-ssr/src/worker.tsx). */ worker: string /** Per-render + startup timeout, seconds. Default 5. */ timeout?: number /** Runtime to launch the worker. Default 'bun'. */ runtime?: string /** * Args passed to the runtime before the worker path. Default `['run']` * (the Bun/`bun run ` convention). Set `[]` for a runtime like * `node` that takes the script path directly. */ runtimeArgs?: string[] } export interface RenderResult { html: string } interface Pending { resolve: (msg: any) => void reject: (err: Error) => void timer: ReturnType } export class SSRBridge { private readonly worker: string private readonly timeoutMs: number private readonly runtime: string private readonly runtimeArgs: string[] private proc: ChildProcessWithoutNullStreams | null = null private counter = 0 private buffer = '' private readonly pending = new Map() private readyPromise: Promise | null = null private readyResolve: (() => void) | null = null private readyReject: ((err: Error) => void) | null = null constructor(options: SSRBridgeOptions) { this.worker = options.worker this.timeoutMs = (options.timeout ?? 5) * 1000 this.runtime = options.runtime ?? 'bun' this.runtimeArgs = options.runtimeArgs ?? ['run'] } private ensureRunning(): Promise { if (this.proc !== null && this.proc.exitCode === null && this.readyPromise !== null) { return this.readyPromise } let settled = false this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = () => { if (!settled) { settled = true resolve() } } this.readyReject = (err) => { if (!settled) { settled = true reject(err) } } }) const proc = spawn(this.runtime, [...this.runtimeArgs, this.worker], { stdio: ['pipe', 'pipe', 'pipe'], }) this.proc = proc proc.stdout.setEncoding('utf-8') proc.stdout.on('data', (chunk: string) => this.onStdout(chunk)) // Only react to THIS proc's exit — a stale exit event (from a worker we // already replaced) must not null out the freshly-spawned one. proc.on('exit', () => this.onExit(proc)) proc.on('error', (err) => { this.readyReject?.(new Error(`SSR worker failed to spawn: ${err.message}`)) }) const startTimer = setTimeout(() => { this.readyReject?.(new Error(`SSR worker failed to start within ${this.timeoutMs}ms`)) this.shutdown() }, this.timeoutMs) // Clear the start timer once ready settles (either way). this.readyPromise.then( () => clearTimeout(startTimer), () => clearTimeout(startTimer), ) return this.readyPromise } private onStdout(chunk: string): void { this.buffer += chunk let nl: number while ((nl = this.buffer.indexOf('\n')) !== -1) { const line = this.buffer.slice(0, nl).trim() this.buffer = this.buffer.slice(nl + 1) if (!line) continue let msg: any try { msg = JSON.parse(line) } catch { continue // malformed line — ignore, matches the Python reader } this.onMessage(msg) } } private onMessage(msg: any): void { // Ready signal (id=0). if (msg.id === 0 && msg.ready) { this.readyResolve?.() return } const id = msg.id if (typeof id === 'number' && this.pending.has(id)) { const p = this.pending.get(id)! this.pending.delete(id) clearTimeout(p.timer) p.resolve(msg) } } private onExit(proc: ChildProcessWithoutNullStreams): void { // Ignore exit events from a worker we've already replaced. if (this.proc !== null && this.proc !== proc) return // Fail any in-flight requests; the next call re-spawns a fresh worker. const err = new Error('SSR worker exited') for (const [, p] of this.pending) { clearTimeout(p.timer) p.reject(err) } this.pending.clear() this.readyReject?.(err) this.proc = null this.readyPromise = null } private request(method: string, params: Record): Promise { const id = ++this.counter const frame = JSON.stringify({ id, method, params }) + '\n' return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id) reject(new Error(`SSR ${method} timed out after ${this.timeoutMs}ms`)) }, this.timeoutMs) this.pending.set(id, { resolve, reject, timer }) try { this.proc!.stdin.write(frame) } catch (e: any) { this.pending.delete(id) clearTimeout(timer) reject(new Error(`SSR worker pipe broken: ${e?.message ?? e}`)) } }) } /** Render a React component file to HTML. Spawns the worker on first use. */ async render(file: string, props: Record = {}): Promise { await this.ensureRunning() const msg = await this.request('render', { file, props }) if (msg.error !== undefined) throw new Error(`SSR render failed: ${msg.error}`) return { html: msg.html } } /** Health check — resolves true when the worker answers a ping. */ async ping(): Promise { await this.ensureRunning() const msg = await this.request('ping', {}) return msg.pong === true } /** Stop the Bun subprocess. */ shutdown(): void { if (this.proc !== null) { try { this.proc.stdin.end() } catch { /* already closed */ } try { this.proc.kill() } catch { /* already gone */ } this.proc = null this.readyPromise = null } } }