From 1b5dca5ab352e4815aef67641d20b58c8b80b2ab Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Tue, 7 Apr 2026 03:33:01 -0400 Subject: [PATCH] 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
Hello, World!
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) --- examples/django-react-site/backend/db.sqlite3 | Bin 0 -> 118784 bytes .../backend/testapp/settings.py | 13 +++ .../django-react-site/backend/testapp/urls.py | 8 ++ .../frontend/components/Hello.tsx | 3 + .../django-react-site/harness/vite.config.ts | 2 +- .../mizan-django/src/mizan/ssr/backend.py | 87 ++++++---------- packages/mizan-django/src/mizan/ssr/bridge.py | 10 +- packages/mizan-ssr/src/index.ts | 3 +- packages/mizan-ssr/src/worker.tsx | 98 +++++------------- 9 files changed, 89 insertions(+), 135 deletions(-) create mode 100644 examples/django-react-site/backend/db.sqlite3 create mode 100644 examples/django-react-site/frontend/components/Hello.tsx diff --git a/examples/django-react-site/backend/db.sqlite3 b/examples/django-react-site/backend/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..52ff378fcc3e55fd8272b6b76602246f7edf54bc GIT binary patch literal 118784 zcmeI5Uu@&ZeaA(KlKfAi|LyT|zU|W5^X(GejU`dCtUD}nzFpm}abEA)YqxQWl%XZs z%R5=}it^qr?haQe+m{q=AFfT%m;M2o6ln93hoTQ{&=x_FwkXi1Xo|MLwGX|A1SpU+ zNQ)Ntkj@M#k(4M)c60V(@inkqa^^R``Taig8HItX9A!8;&hk_3m;RNXM~T(zCF4z^7-N49Qtnb zPb1$6e>XhFzY_YnV1_GmKjxQuDS8^x8{?Bvadwt#yj9nB%DQ$~zo+iis)q;8-o*8d z!t!Q8+FX8hwIC&&jD&PKVU!Y*QK{>DdQDopvnj1@t*%OMY^>Z~-q@C2FKkQ8Tbp-Q z)`*JRg|*EqQlg^m>j~+BRx94qYL}OivQunpZDoC{AS+W5@#ZWSFe)Yep?SYdVnHR9 zR@pJ#vn^Cp&i-Wcg^8#*KhJ&PeaDXjy|!~Rtcd> zU9CSl&{bbz8->>j8-=y&g}YLs^rlwXtE%oown?&dgUW5NhFDo!*<4v(UEQ`53pZFz zCEZInm2}x6H*Sqb#j3(J-eEL-s#3Ls1GTK}=w)@kS|Z!r;!a5^DLd+6#klWNAa297 zT2ES8yHWV8uMb-(TmN6D%0(x>={V z^nJ}JADVhiWgDksnyRx;x{;dI@;h2StMo58W<#-qvwwjeHCaL0zc_{Lu{9w#_C})O zd&Jq;pw2oRh_9)1DW@kh>Hc+(9@|&nijL!|TR}o@zBoK2HZBrZizjl`15VAAmXgVI z);j(4_kP6o-g`FcU(OYEg!U08oR|ygDIT>XB$T13s9ob4TRk|j3$bZkk7>Z`sZ2Vj zWOlpVu_TB4HGMCIy_~Y-X*9DvdhL=MpNU3AgIKK}ZM8cnR4u=|m|sj5ds#j-pyj?w z`&;iWMm(rSNPvc~am`D|aG^y&)pv7gCAXVl7sOr;MF)&SyG7gvy4u@a(=ojy;nuR4 zbjf$aQE`2pdt78!k#3_q&$Rs|BDRC}U19$MC%W*sxg?|=V~^Zxwl5%(`>v_W0K2~a z_e|bi*?l+buBGEfaX*o3VQS=y7Eud zCI^~nzFn=6d#2VfDV1LG+;WvQv#yq_q&K+ltH{uc^m@Zo%|jB?v`dpNQ>~WU9fstx z4&^9LXETms$+~FTI_lc)Zg+X2T`Sg&2V@NAQ7tbABjVOVuTfhU7&X~mabp(W@1=2RdyibLJSr$V85uHz>)(-jlz|4*e&Q3(iu00@8p z2!H?xfB*=900@8p2%ISajQ`IRKPm?S5C8!X009sH0T2KI5C8!X0D)5>fZzY03T9LS z0w4eaAOHd&00JNY0w4eaAOHeqN&w^kGsTa}K>!3m00ck)1V8`;KmY_l00cnbR0v@F ze=3+!2?&4y2!H?xfB*=900@8p2!H?xoGAf}|IZXZDhB}&009sH0T2KI5C8!X009sH zfm0!X@&Bn{MkOEs0w4eaAOHd&00JNY0w4eaAaJGxF#bPN{HPoRKmY_l00ck)1V8`; zKmY_l00d5j0R8@-Fg6|#z9qaTtO~*SH{$QabFm-AelMoRo*Dc0*sqQiNH+dJ00ck) z1V8`;KmY_l00f>w0*Vk0oS$1Zb<;Ge6*HMkrc|S1)D5kiPNo;HCNo!)IVqWYDV=*s zQ9hkr%;%T#x8mWzxu03q4(s=bd|K7Yb-kt@nq;$U)XchC(e`z9Uwf#Q^~zrTp1&qV z$)#V35luH+no{cFK}oCY>Ormg@R53;*Y*v|E?*7VrKNoC`WVr0yQLv%+p<@y9v%#2 zMpnsXQ#VJ6k}X?FGVgY%tPSW-CZEjYUK=5b?zR*yxfM}7gK)`Y78g^m4--9~Yw5|^ zdLC$HgYc==OjRqDRJ~TK*3^B{blRS7`Xb6C)4AmG5K*?$Qnskp^!tZKO;?N6N?oti z>yHj}Qzb2*_En)|v#HFhQKI7YmWr&~F9xVw-7_j`<*@7@HA;FhnR_KdbhO$!nQ`lA zv#B1`{T)f?a=E4JVWOec)=9;!LEqPma=(yED@rDJiziB2!JBLauM=wnMNMicpUEtQ zh^Dy~l%xAiN=at3w}Roo+|6Y-cZ!`f$bR)fyFFCeAj}Hi_DH6e7PC2yXqxNPbfgKH ze0njpMCbp*!j}TV4~2gf{#^JP*~K3SfB*=900@8p2!H?xfB*=900@A?r_4%m|n`amQ+&7Ys)T2JQe|M}+HM=W#|b#-9_1X&QSb zM#ult@t+R}Ulx8*FomMjaCJ0-?y z4l#AE!SmR&JeT4^wn|sVsFh)>b!Cj45I}9_$L8ikQ$@3!5EpS0wlan5@(rtZCdXS)$hl5nBlStS9hlO7c z2>&U3DEyi52V@t2AOHd&00JNY0w4eaAOHd&00JNY0;fS>I?M-VE_BZSPw{-<{EYq3 z!nFUN3Gsosi>}8FrfYvP$Oq2NdguRR97z+~^Z#`GAHeVXPoq^)2MB-w2!H?xfB*=9 z00@8p2!H?xoEZVv`~N4-%m`Er0w4eaAOHd&00JNY0w4eaAOHd&@QD(@{QoD47s^2Z z1V8`;KmY_l00ck)1V8`;K;VfWK*#?hkq-m$zlb;E$+3SP{n?Rw!(ScxLG)XZ55qqo zuN=G;dN26X+&8&j30&(kxVbhR74v!SajvfIly&-}0_%kZt``iL{;5;fHww#}1!;5n z)zyNO@E1x*mlH-QAsOVm6nlD2TD!9;t!=HYN^fke++N<;mR>JxOUqlEcUIPjy4!`d z%_~xZy)i-cEw)j3t*}v8yI#2KuFyO}y=b3^ zF#T#tLVBRp$SWplmzR>V(=c0WE9+YYx%t9GRGgpZzVN_wSV|}&B>3*i`R8G!%YF5kdX!)$tzu=e+#SX##1$xwEg<}8W z6tZJCJwwZly^*N+9&t7{sI%?Y5m8@L=~7NlX43uZ9zC|Nz7-wERkwnK+i3Z1272oc`rpQAcPWVZw>Ike=dEOF}{!ii+AbuCdjF z6T5vd?b{e)mY&L_b4q5n+Z{`CxL?!vQrOEWOP)qE+p_}sGtsDM5Ucg0t#${6s^xbV z^NY!1FUyApwA@!|f9u`F2={7)1Zemg*SvHL7g_{VeK(g@a=RIJ+3V#{big>YTf}Xk ztG(Sd9n(t^ZY_&RmwY!I71!6f$3=D(={B0a%FGq@W`Bu@?Vx?r+`qtyERBC;C38#51!mD z=nN4CYdZOjP=wytZ!@U&2*o7d57a9~z2lJgAneGzOz&W>kdpFpFd}X(^cwSYw@<1o zDb!rz*bUp`3;u20RU+syEzy;KqGmnNO!Mt(jofy%hDNFMlIO0?`81;Y){G2j$%(@- zRr8Qstms*R^tx)b-0pKEm-R_UaXOoE6ie21*Va+jc6YnW6YW~DZag64HjiqVz5ieM z<$&;!@NdGu2;UJt7XC~4zVJQaZ-u`SzA1b|_#@%#Y&$`>{5abRY#V3W7~O`(*mjg{N7#0l z4-IoebQ7hU2;GEfPLOBY5Zea%5H}o*MgKi89{RO_Fcr_m-WvPu;m=3@J8~=hOG8Ve zf6ae1x;PZ#=STjR2;dI{K;TpeG^S^x;$4|*6`-@KeEgIW^mY3R<16J?CTcpc-9vcNL-LJ2qJ}l=IIL#>99R~yA(l;bWsd%ijM99!Y_m}4h{ zj`;34oUv)R7yCIz(rEHshIP~`zs}xs^fZ@!0&*9!YwbT(StRSOyvuDm7ZZ7NJ&SQ< zERpmq-0Yfr>;AZog{@m>R2NAvJ?^T$i1c=y;m$XkQ_Ur7+yRl3q*58LVX-Ia6baq;3;vm0xfM1V8`;KmY_l z00ck)1V8`;KmY_lz#)L~zrzB1AOHd&00JNY0w4eaAOHd&00JQJv=YGh|7mSm_yqzW z00JNY0w4eaAOHd&00JNY0uBNC`~T^&e+&r!B>cYcuCO6wg~|B$<9`?bO1vJw9)BkG zquAfXemC|DvF+HEu^$sb{DA-nfB*=900@8p2!H?xfWQ+);L>zBz{yJZfuXyHlfwqJD(cD4leW5SS83zaNcy;w!=xf5+afdxu9*0lR;%| zlbpmWK}*^;%*mkAwslV8iyV=bQ$cE@lS)OYp-u|s|DUK67mNV`5C8!X009sH0T2KI r5C8!X0D(`00LK5H2v#Ts0T2KI5C8!X009sH0T2KI5CDNEiopK?Rv03E literal 0 HcmV?d00001 diff --git a/examples/django-react-site/backend/testapp/settings.py b/examples/django-react-site/backend/testapp/settings.py index 63beaea..db22859 100644 --- a/examples/django-react-site/backend/testapp/settings.py +++ b/examples/django-react-site/backend/testapp/settings.py @@ -34,6 +34,19 @@ MIDDLEWARE = [ ROOT_URLCONF = "testapp.urls" +TEMPLATES = [ + { + "BACKEND": "mizan.ssr.MizanTemplates", + "DIRS": [os.path.join(os.path.dirname(__file__), "..", "..", "frontend")], + "OPTIONS": { + "worker": os.path.join( + os.path.dirname(__file__), "..", "..", "..", "..", + "packages", "mizan-ssr", "src", "worker.tsx", + ), + }, + }, +] + DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", diff --git a/examples/django-react-site/backend/testapp/urls.py b/examples/django-react-site/backend/testapp/urls.py index 6610f6e..7ee0f58 100644 --- a/examples/django-react-site/backend/testapp/urls.py +++ b/examples/django-react-site/backend/testapp/urls.py @@ -1,5 +1,13 @@ +from django.http import HttpResponse +from django.shortcuts import render from django.urls import include, path + +def hello_view(request): + return render(request, "components/Hello.tsx", {"name": "World"}) + + urlpatterns = [ path("api/mizan/", include("mizan.urls")), + path("hello/", hello_view), ] diff --git a/examples/django-react-site/frontend/components/Hello.tsx b/examples/django-react-site/frontend/components/Hello.tsx new file mode 100644 index 0000000..3045e52 --- /dev/null +++ b/examples/django-react-site/frontend/components/Hello.tsx @@ -0,0 +1,3 @@ +export default function Hello({ name }: { name: string }) { + return
Hello, {name}!
+} diff --git a/examples/django-react-site/harness/vite.config.ts b/examples/django-react-site/harness/vite.config.ts index f501af3..6ce9d03 100644 --- a/examples/django-react-site/harness/vite.config.ts +++ b/examples/django-react-site/harness/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -const reactPkg = path.resolve(__dirname, '../../react/src') +const reactPkg = path.resolve(__dirname, '../../../packages/mizan-react/src') export default defineConfig({ plugins: [react()], diff --git a/packages/mizan-django/src/mizan/ssr/backend.py b/packages/mizan-django/src/mizan/ssr/backend.py index 267e43c..7f87384 100644 --- a/packages/mizan-django/src/mizan/ssr/backend.py +++ b/packages/mizan-django/src/mizan/ssr/backend.py @@ -1,25 +1,22 @@ """ -Mizan SSR Template Backend — Django template engine that renders React components. - -Plugs into Django's TEMPLATES setting: +Mizan SSR Template Backend — Django template engine that renders React via Bun. TEMPLATES = [ { 'BACKEND': 'mizan.ssr.MizanTemplates', + 'DIRS': [BASE_DIR / 'frontend'], 'OPTIONS': { - 'worker_path': 'frontend/ssr-worker.tsx', - 'timeout': 5, + 'worker': 'path/to/mizan-ssr/src/worker.tsx', }, }, ] -Then render(request, 'ProfilePage', {'user_id': 5}) renders the React -component via the Bun subprocess. +Then: render(request, 'components/Hello.tsx', {'name': 'World'}) """ from __future__ import annotations -import logging +import os from typing import Any from django.template import TemplateDoesNotExist @@ -28,92 +25,68 @@ 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. + """Renders a .tsx/.jsx file 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 + def __init__(self, file_path: str, bridge: SSRBridge) -> None: + self.file_path = file_path + self.origin = None 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) + result = self._bridge.render(self.file_path, props) - # Wrap in a hydration-ready container - html = ( - f'
' - f'{result.html}' - f'
' - ) - - return mark_safe(html) + return mark_safe(f'
{result.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. + Template names are file paths resolved against DIRS. + Same model as Django's built-in template engines. """ 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._worker = options.get("worker") self._timeout = options.get("timeout", 5) self._bridge: SSRBridge | None = None + if not self._worker: + raise ValueError( + "MizanTemplates requires OPTIONS['worker'] — " + "the path to mizan-ssr's worker.tsx" + ) + 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, + worker_path=self._worker, 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()) + for dir_path in self.dirs: + file_path = os.path.join(dir_path, template_name) + if os.path.isfile(file_path): + return MizanTemplate( + os.path.abspath(file_path), + self.get_bridge(), + ) + raise TemplateDoesNotExist(template_name) 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." + "MizanTemplates renders .tsx files, not template strings." ) diff --git a/packages/mizan-django/src/mizan/ssr/bridge.py b/packages/mizan-django/src/mizan/ssr/bridge.py index 3d7394d..0fb28e1 100644 --- a/packages/mizan-django/src/mizan/ssr/bridge.py +++ b/packages/mizan-django/src/mizan/ssr/bridge.py @@ -105,12 +105,12 @@ class SSRBridge: except Exception: logger.warning("SSR reader thread exited", exc_info=True) - def render(self, component: str, props: dict[str, Any] | None = None) -> RenderResult: + def render(self, file: str, props: dict[str, Any] | None = None) -> RenderResult: """ Render a React component to HTML. Args: - component: Component name (as registered in the Bun worker). + file: Absolute path to the .tsx/.jsx file to render. props: Props to pass to the component. Returns: @@ -118,7 +118,7 @@ class SSRBridge: Raises: TimeoutError: If the render takes longer than the configured timeout. - RuntimeError: If the render fails (component not found, render error, etc). + RuntimeError: If the render fails. """ with self._lock: self._ensure_running() @@ -131,7 +131,7 @@ class SSRBridge: request = json.dumps({ "id": msg_id, "method": "render", - "params": {"component": component, "props": props or {}}, + "params": {"file": file, "props": props or {}}, }) + "\n" try: @@ -144,7 +144,7 @@ class SSRBridge: 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" + f"SSR render of '{file}' timed out after {self._timeout}s" ) self._pending.pop(msg_id, None) diff --git a/packages/mizan-ssr/src/index.ts b/packages/mizan-ssr/src/index.ts index aedc96f..9d8aeed 100644 --- a/packages/mizan-ssr/src/index.ts +++ b/packages/mizan-ssr/src/index.ts @@ -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. diff --git a/packages/mizan-ssr/src/worker.tsx b/packages/mizan-ssr/src/worker.tsx index 82f4981..848f449 100644 --- a/packages/mizan-ssr/src/worker.tsx +++ b/packages/mizan-ssr/src/worker.tsx @@ -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": "
...
"} - * - * 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>() +const cache = 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) +function respond(msg: Record): void { + Bun.write(Bun.stdout, JSON.stringify(msg) + '\n') } -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 { +async function handleMessage(msg: any): Promise { 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 { const reader = Bun.stdin.stream().getReader() const decoder = new TextDecoder() @@ -93,24 +56,17 @@ async function processInput(): Promise { 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()