From e4091dfbe8e21dc80f4bba83ed2189a94bd1836b Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Sun, 5 Jul 2026 19:43:16 -0400 Subject: [PATCH] SSR docs declare the embedded-V8 architecture; surface re-chartered per appeal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/SSR_ARCHITECTURE.md now specifies the decided shape: Django template backend rendering through the PyO3-bound SsrEngine (cores/mizan-rust-ssr, deno_core + deno_web), bundles built by mizan-generate, props crossing as parsed values, no external JS runtime serving requests, no_rsc guard. docs/AFI_ARCHITECTURE.md follows (mizan-rust-ssr in the cores layout, Bun worker delisted). OWED_SURFACE.md regenerated via appeal-surface: the Bun→PyO3 cutover, the PyO3 binding surface, and the mizan-generate SSR bundling step are now chartered owed mechanisms instead of an orphaned engine crate. Co-Authored-By: Claude Fable 5 --- OWED_SURFACE.md | 32 ++++++++----- docs/AFI_ARCHITECTURE.md | 26 +++++----- docs/SSR_ARCHITECTURE.md | 100 +++++++++++++++++++++++---------------- 3 files changed, 93 insertions(+), 65 deletions(-) diff --git a/OWED_SURFACE.md b/OWED_SURFACE.md index 6595c33..9991590 100644 --- a/OWED_SURFACE.md +++ b/OWED_SURFACE.md @@ -115,16 +115,18 @@ Cross-function graph checks (fail at IR-build time, before any client is emitted ## Unit: mizan-rust-ssr (`cores/mizan-rust-ssr`) -**Charter.** The embedded-V8 SSR engine and the anti-RSC guard. It owns rendering a build-time JS bundle to HTML in-process via `deno_core`, and the structural guarantee that the SSR surface never imports an RSC/Flight runtime. It does not own the Django template backend (that is `mizan-django/ssr`). +**Charter.** The embedded-V8 SSR engine, its PyO3 binding, and the anti-RSC guard. It owns rendering a build-time JS bundle to HTML in-process via `deno_core`, exposing that engine to the Python side across an in-process FFI boundary, and the structural guarantee that the SSR surface never imports an RSC/Flight runtime. It does not own the Django template backend (that is `mizan-django/ssr`) or the bundling that produces the JS bundle (that is `mizan-generate`). **Claims substantiated here.** - SSR is hand-rolled; no frontend adapter imports an SSR runtime or meta-framework (Next/Nuxt/SvelteKit/RSC/Flight) — the CVE-2025-55182 pre-auth-RCE deserialization class (HOLOMORPHICS/Mizan project note; MEMORY: mizan-ssr-no-framework-runtimes; enforced by `cores/mizan-rust-ssr/tests/no_rsc.rs`). - SSR renders synchronously from props, injected as validated data (the AFI provides the typed one-way version). +- The Python side binds the engine through PyO3 — in-process FFI, no subprocess, no JSON-RPC framing; behavior lives in the binary, languages bind to it (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary; ROADMAP.md § Core Consolidation — SSR in the binary). **Owed behavioral mechanisms.** - the engine composes a real `deno_web` web-platform layer (TextEncoder/Decoder, MessagePort, timers) rather than a partial shim, evals the trusted bundle once, and renders per request — observable: the fixture bundle renders `Hello, World!`; a missing global would fail loudly at render, not silently pass (the doc's "partial polyfill is silent-failure-shaped" concern is discharged by using deno_web's real impls). - props cross as a `v8::json::parse`d value passed as a function argument, never spliced into evaluated source — observable: the injection test feeds a prop string crafted to break out of a string-built call; it renders as inert text and does not set a global, so code injection is structurally absent. - the no-RSC guard scans authored SSR source and dependencies for the forbidden token set (`react-server-dom`, `renderToReadableStream`, `renderToPipeableStream`, `createFromReadableStream`/`Fetch`, `use server`, `next/`, `nuxt`, `@sveltejs/kit`) and fails on presence — observable: adding any RSC/Flight/meta-framework import to the scanned fixtures turns `no_rsc.rs` red; absence alone is not the guarantee — re-entry is loud. +- OWED (unbuilt): a PyO3 binding exposes `SsrEngine` to the Python side as an in-process extension — construct-from-bundle and `render(props_json) -> HTML` cross the FFI boundary with no process spawn, honoring V8's one-isolate-per-engine / non-`Send` constraint so the caller holds one engine per (worker thread, bundle) — observable when built: the Django backend imports the engine and calls `render(props)` in-process, and a prop still crosses as a parsed value (the injection guarantee survives the FFI hop). Today `cores/mizan-rust-ssr/src/lib.rs` exposes `SsrEngine` only as a Rust API with Rust-native `#[tokio::test]` coverage and carries no `#[pyclass]`/`#[pymodule]`, so the PyO3 surface the AFI-boundary table names is unsubstantiated. --- @@ -238,18 +240,22 @@ Return-type branching + origin cache: ## Unit: mizan-django SSR (`backends/mizan-django/src/mizan/ssr`) -**Charter.** The SSR product's Django half: a template backend that renders `.tsx`/`.jsx` component files through a persistent Bun subprocess, wrapping output with a hydration payload. It owns the Django-template-engine integration and the Bun subprocess lifecycle; it does not own the JS render (that is the Bun worker). +**Charter.** The SSR product's Django half: a Django template backend that renders `.tsx`/`.jsx` component files by resolving each to its built bundle and driving the PyO3-bound `SsrEngine` in-process, wrapping output with a hydration payload. It owns the Django-template-engine integration, component-to-bundle resolution, prop gathering, and the per-(worker-thread, bundle) engine lifecycle; it does not own the V8 render (that is `cores/mizan-rust-ssr`'s `SsrEngine`) or the bundling that produces the render bundle (that is `mizan-generate`). **Claims substantiated here.** - SSR is a Django template backend replacing the rendering engine; the template name IS a `.tsx`/`.jsx` file path; context dict becomes props; output wrapped in `
` + `window.__MIZAN_SSR_DATA__` hydration (docs/SSR_ARCHITECTURE.md). -- SSR bridge: Django template backend → persistent Bun subprocess via JSON-RPC; worker resolves by file path (`import(file)` + `renderToString`); auto-restarts on crash, thread-safe, correlates by message id (ROADMAP.md § Done; docs/SSR_ARCHITECTURE.md § Implementation surface). +- The render engine is embedded-V8 inside the Mizan Rust binary, bound via PyO3 — in-process FFI, no external JS runtime serving requests, no subprocess, no JSON-RPC framing (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary — Backend adapter row). +- The component path resolves against `DIRS` to its built bundle in `OPTIONS['bundles']` (produced by mizan-generate's SSR bundling step); the backend gathers props and wraps output for hydration (docs/SSR_ARCHITECTURE.md § AFI boundary — Backend adapter row; the `TEMPLATES` config block). +- One engine per (worker thread, bundle) — V8's Locker constraint makes an engine non-`Send`, so the Django side never shares one across threads (docs/SSR_ARCHITECTURE.md § The engine). - SSR is orthogonal to RPC and composable; first paint carries data (INVARIANTS.md § SSR). **Owed behavioral mechanisms.** -- `MizanTemplates` implements Django's template-backend interface: `get_template(name)` resolves `name` as a file path under `DIRS` and returns a `MizanTemplate` wrapping the absolute path; `render` strips `request`/`csrf_token` and passes the remaining context as props — observable: `render(request, 'components/Hello.tsx', ctx)` renders that file's component with `ctx` as props; `from_string` raises (it renders files, not strings); a missing file raises `TemplateDoesNotExist`. +- `MizanTemplates` implements Django's template-backend interface: `get_template(name)` resolves `name` as a `.tsx`/`.jsx` file path under `DIRS` and returns a `MizanTemplate` for the resolved component; `render` strips `request`/`csrf_token` and passes the remaining context as props — observable: `render(request, 'components/Hello.tsx', ctx)` renders that component with `ctx` as props; `from_string` raises (it renders files, not strings); a missing file raises `TemplateDoesNotExist`. +- component-to-bundle resolution: the backend resolves each component file to its self-contained render bundle in `OPTIONS['bundles']` (the `mizan-generate`-produced bundle assigning `globalThis.renderApp`) and hands the bundle to the engine, rather than reading component source directly — observable: `render` of `components/Hello.tsx` loads that component's built bundle from the configured `bundles` directory; a component with no built bundle raises rather than rendering stale or empty HTML. - rendered output is wrapped for client hydration — observable: output contains `
` plus ``, so first paint carries the props the client hydrates from. -- `SSRBridge` holds one persistent `bun run ` subprocess, correlates requests by message id over newline-delimited JSON-RPC, serializes stdin writes, is thread-safe under concurrent renders, waits for a ready signal on start, and auto-restarts on crash — observable: five concurrent renders return five correct results with no interleaving; killing the subprocess mid-life and rendering again transparently restarts it; a render exceeding the timeout raises `TimeoutError` rather than hanging. -- OWED (partial, docs/PSR_VS_EDGE.md § Current state): the render-on-mutation orchestration (mutation → trigger local render → store HTML), driven by the manifest's `render_strategy`, wiring the bridge to the PSR path — observable when built: a public-context mutation triggers a local re-render and stores HTML; today the bridge renders on request and the manifest records the strategy, but the mutation→render→store wiring is absent, so PSR-on-mutation is unsubstantiated. +- engine lifecycle: the backend constructs one `SsrEngine` per (worker thread, bundle) pair and reuses it across requests, never sharing a single engine across threads (the engine is non-`Send`) — observable: concurrent renders on different worker threads each use their own thread-local engine and return correct results with no interleaving; the engine is built once per bundle, not per render. +- OWED (unbuilt — the PyO3 cutover): the Django backend binds the engine through PyO3 and renders in-process — no `bun run` subprocess, no newline-delimited JSON-RPC, no `SSRBridge`, no ready-signal / auto-restart machinery, and `OPTIONS` carries `bundles` (a directory of built bundles) rather than `worker` (a JS entry file) — observable when built: a render spawns no external JS runtime process and calls the PyO3-bound `SsrEngine.render(props)` directly; `ssr/bridge.py` and the `workers/mizan-ssr` worker are retired. Today this is entirely unsubstantiated: `ssr/bridge.py` spawns `bun run ` and correlates requests over JSON-RPC, `ssr/backend.py` wires `OPTIONS['worker']` to that bridge and renders via `SSRBridge.render`, and the docstrings still describe a "persistent Bun subprocess" — the subprocess architecture the docs (§ The engine, § AFI boundary) no longer describe. +- OWED (partial, docs/PSR_VS_EDGE.md § Current state): the render-on-mutation orchestration (mutation → trigger local render → store HTML), driven by the manifest's `render_strategy`, wiring the engine to the PSR path — observable when built: a public-context mutation triggers a local re-render and stores HTML; today the engine renders on request and the manifest records the strategy, but the mutation→render→store wiring is absent, so PSR-on-mutation is unsubstantiated. --- @@ -300,7 +306,7 @@ Return-type branching + origin cache: - the suite exercises the real HTTP stack (Django test client / LiveServer) — not just RequestFactory — for dispatch, three-tier invalidation, merge, view-path branching, and cache HIT/MISS/scoped-purge — observable: `HTTPIntegrationTests` and `CacheIntegrationTests` assert the JSON body and `X-Mizan-Invalidate` header agree, that a scoped mutation preserves other users' cached entries, and that a second identical fetch is a HIT. - the auth suite covers every axis (JWT valid/invalid/expired, MWT, session, staff/superuser/callable/PermissionError) and asserts the body never runs on failure — observable: an invalid token returns 401 without session fall-through; an anonymous call to an auth-required function returns before the body; a callable's `PermissionError` message surfaces verbatim. - the Edge-compatibility suite asserts the properties a CDN cares about (deterministic byte-identical bodies, sorted JSON keys, URL-encoded delimiter-safe headers, `no-store` on errors/mutations, header↔body invalidation agreement, auth-differentiated responses for the same URL) — observable: these tests go red if any of those properties regress, so "Edge caching is possible" is checkable without a CDN. -- the SSR suite verifies the bridge and template backend end-to-end when Bun is present (ping, render, missing-component error, crash recovery, concurrent renders, hydration wrapper) and skips gracefully otherwise — observable: a killed worker transparently restarts on the next render; five concurrent renders return five correct results. +- the SSR suite verifies the engine-based render path — template-backend resolution, bundle-driven in-process render, the hydration wrapper, and concurrent renders — and asserts that no external JS runtime process is spawned during the suite (the PyO3-bound `SsrEngine` renders in-process) — observable: a render produces the `
` + `__MIZAN_SSR_DATA__` wrapper from the resolved bundle, concurrent renders across worker threads each use their own engine and return correct results, and the suite spawns no `bun`/`node` subprocess. The Bun ping / crash-recovery / auto-restart assertions are retired with the subprocess bridge they exercised; today `test_ssr.py` still drives `SSRBridge` (Bun subprocess, JSON-RPC, killed-worker restart), so this engine-path verification is owed alongside the mizan-django SSR PyO3 cutover. - the benchmark suite measures HTTP-vs-executor overhead and throughput with correctness assertions on each path — observable: every benchmark also asserts the function's numeric output, so a green benchmark run is also a correctness run. --- @@ -401,12 +407,14 @@ Return-type branching + origin cache: **Claims substantiated here.** - Every frontend adapter is a thin idiomatic wrapper over one shared kernel; the kernel owns `ContextState = {data,status,error}`, `registerContext`, `mizanCall`/`mizanFetch`, server-driven merge/invalidate, `initSession`, and a pluggable `MizanTransport` (HTTP default, Tauri/webview swap via `configure`) (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model). +- Shared parameters elevate to required provider props; non-shared params elevate to optional props with per-function override (INVARIANTS.md § Named Contexts; MIZAN.md §2 param elevation / `specify` resolution order). - Codegen targets the adapter surface, never the raw kernel; React devs get hooks, Vue composables, Svelte stores, same kernel underneath (docs/AFI_ARCHITECTURE.md § Kernel model). - Vue and Svelte ship as v1 alongside React (docs/AFI_ARCHITECTURE.md § Launch surface). - Cross-language HMAC pin: `deriveCacheKey` in `mizan-ts` matches the Python key byte-for-byte (docs/CACHE_KEYING.md; README.md § Adapters — TypeScript is the protocol-reference adapter). **Owed behavioral mechanisms.** - `@mizan/base` owns the single reconciled view: `ContextState`, the context registry, `mizanCall`/`mizanFetch`, server-driven `merge`/`invalidate`, `initSession`, over a `MizanTransport` interface — observable: the same behaviors the `mizan-rust` port pins (stable-key cache identity, merge-splice, scoped-vs-broad refetch, retry, dual-envelope error parse) hold in TS; the Rust port exists precisely to mirror this file. +- the generated provider elevates a context's shared params to required props and its non-shared params to optional props, and resolves each member's effective params by overlaying per-function overrides onto the provider props at fetch time (INVARIANTS.md § Named Contexts; MIZAN.md §2 resolution order) — observable: a two-function `user` context where both take `user_id` and only one takes `page` generates a provider with required `user_id` and optional `page`; a per-function override supplies a different `page` for that one member while the shared `user_id` still covers both, and a member still missing a required param at fetch time is a runtime error, not a silent undefined. - `deriveCacheKey` (mizan-ts) reproduces the Python HMAC key byte-for-byte — observable: the pinned vectors in `cores/mizan-python/tests/test_keys.py::test_cross_language_pin` (`ctx:user:605a1ca5…`, `ctx:user:30fc08eb…`) are asserted against the TS output; any normalization drift (bool/None stringification, key ordering) breaks the pin, which the doc marks a security vulnerability. - adapters subscribe to the kernel and render in their own idiom without keeping a parallel copy of the truth — observable: a React hook and a Vue composable over the same context read the same kernel entry; mutating in one path updates both because the truth lives once in the kernel. - OWED (unbuilt, ISSUES.md § Open / ROADMAP.md § Next): `frontends/mizan-vue` and `frontends/mizan-svelte` are runtime kernel-adapter packages — the codegen emits their clients (byte-parity-tested) but no runtime package or live-backend example exists — observable when built: a Vue composable / Svelte store subscribes to `@mizan/base` and refreshes on invalidation against a live backend; today only React has full integration verification, so the "Vue and Svelte ship as v1" claim is unsubstantiated at the runtime layer. @@ -417,23 +425,23 @@ Return-type branching + origin cache: ## Unit: mizan-codegen (`protocol/mizan-codegen`) -**Charter.** The single Rust codegen binary that reads KDL IR and emits typed clients for every target (stage1, react, vue, svelte, channels, python, rust), plus the source-fetching that spawns each backend's IR-export command and the Pydantic-pre-step. It owns the IR→client transform; it does not emit IR (backends do). +**Charter.** The single Rust codegen binary that reads KDL IR and emits typed clients for every target (stage1, react, vue, svelte, channels, python, rust), plus the source-fetching that spawns each backend's IR-export command and the Pydantic-pre-step, plus the SSR bundling step that compiles each SSR entry component into a self-contained render bundle. It owns the IR→client transform and the SSR bundle production; it does not emit IR (backends do) or run the render engine (that is `cores/mizan-rust-ssr`). **Claims substantiated here.** - Codegen reads KDL directly — no OpenAPI envelope, no `openapi-typescript`, no per-backend converter; the former JS two-stage codegen is deleted (docs/AFI_ARCHITECTURE.md § Forward-direction primitives). - Every frontend client is generated from the IR; each target is byte-parity-tested (INVARIANTS.md § Canonical IR & Codegen; ROADMAP.md § Rust codegen). - The codegen drives the backend's IR-export command as a subprocess and parses the KDL it writes (docs/AFI_ARCHITECTURE.md; backends/*/README.md § Generate the frontend). +- Stage 1 (typed `callXxx`/`fetchXxx`) + Stage 2 (`` provider, per-context providers, `use{Hook}()`) emission (backends/*/README.md § Generate the frontend). - Pydantic + Rust DX: a decoru pre-step authors Rust types from Pydantic before the cargo IR bin runs; a generic `[source.script]` source spawns any command emitting KDL (backends/mizan-tauri/README.md § Pydantic; config.rs). +- mizan-generate's SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle assigning `globalThis.renderApp`, written to the `bundles` directory — the only place node/bun run in the SSR path (docs/SSR_ARCHITECTURE.md § The engine; § AFI boundary — Build step row). **Owed behavioral mechanisms.** - `fetch.rs` spawns the configured source's export command (FastAPI `-m mizan_fastapi.ir`, Django `manage.py export_mizan_ir`, Rust `cargo run --bin`, or a generic script) and parses stdout as KDL — no OpenAPI/converter anywhere in the path — observable: a codegen run against a live FastAPI backend consumes only the KDL the CLI writes; the Rust source runs the cargo bin and the optional decoru pre-step first. - the KDL parser reconstructs the full typed IR (types with struct/list/enum/alias shapes, functions with input/output/nullable/context/affects/merge/form, contexts with param elevation, channels) — observable: `ir_deserialization.rs` reads the AFI fixture back into typed structs and asserts the function set, per-function fields, param elevation, and named-type presence. - each target emits deterministically and is byte-parity-tested against a committed baseline — observable: `stage1_parity.rs`, `react_parity.rs`, `rust_parity.rs`, `python_parity.rs`, `vue_svelte_parity.rs`, and `channels_smoke.rs` diff emitter output against baselines and fail on any byte drift; two different runs produce identical output. -- the emitters produce genuinely different, correct artifacts per target — not one shape behind distinct labels — observable: the react target emits `` + per-context providers + `use{Hook}()` reading React context; vue emits composables; svelte emits stores; python emits a Pydantic-typed facade over the PyO3 kernel; rust emits a full crate depending on `mizan-rust` — each byte-checked against its own baseline, and stage1 is auto-included whenever a framework target is requested. +- the emitters produce genuinely different, correct artifacts per target — not one shape behind distinct labels — observable: the react target emits `` + per-context providers + `use{Hook}()` reading React context (Stage 2), Stage 1 emits the framework-agnostic typed `callXxx`/`fetchXxx`; vue emits composables; svelte emits stores; python emits a Pydantic-typed facade over the PyO3 kernel; rust emits a full crate depending on `mizan-rust` — each byte-checked against its own baseline, and stage1 is auto-included whenever a framework target is requested. - the codegen tree-shakes and canonicalizes types to match the backend emitters, and hoists inline enums into named Rust/TS types — observable: an unreferenced type is not emitted; an inline `field { enum … }` becomes a top-level Rust enum the struct field references; the channels target emits zero files when the IR carries no channels. - ---- - +- OWED (unbuilt): an SSR bundling step compiles each SSR entry component together with `react-dom/server.browser` into a self-contained bundle that assigns `globalThis.renderApp`, emitted into the configured `bundles` directory — this is the only place node/bun run in the SSR path — observable when built: a `mizan-generate` run produces one render bundle per SSR entry component, each evaluable standalone by the embedded-V8 engine (assigning `renderApp` at eval time and rendering from a JSON-parsed props argument). Today the codegen binary emits typed clients (stage1/react/vue/svelte/channels/python/rust) but carries no SSR bundling target in `src/emit/` and no bundling path in `fetch.rs`/`config.rs`, so the bundling step the docs place with `mizan-generate` is unsubstantiated. ## Unit: AFI conformance (`tests/afi`) **Charter.** The cross-adapter conformance gate: one fixture registered identically in Django, FastAPI, and a Rust app, asserting all three emit byte-identical KDL. It is the executable form of "the IR is the only contract"; it authors no production mechanism. diff --git a/docs/AFI_ARCHITECTURE.md b/docs/AFI_ARCHITECTURE.md index d6d1f34..3a80919 100644 --- a/docs/AFI_ARCHITECTURE.md +++ b/docs/AFI_ARCHITECTURE.md @@ -28,20 +28,21 @@ cores/ shared language-level primitives mizan-python/ @client decorator, registry, MWT, HMAC cache keys mizan-rust/ Rust core — IR build (build_ir()), registry mizan-rust-macros/ #[derive(Mizan)] / #[mizan::client] proc-macros + mizan-rust-ssr/ embedded-V8 SSR engine (deno_core + deno_web); evals the + build-time bundle, renders per request; no_rsc guard protocol/ protocol-level tooling mizan-codegen/ codegen — Rust binary (crate `mizan-codegen`); reads KDL IR, emits typed clients. Targets: stage1, react, vue, svelte, channels, python, rust. Askama templates under templates/. mizan-generate/ thin npm-package launcher (bin/launcher.mjs) dispatching to the compiled mizan-codegen binary per platform -workers/ runtime workers / bridges - mizan-ssr/ Bun subprocess used by the Django template backend ``` ## Two orthogonal products - **RPC** — typed client generation via codegen -- **SSR** — server rendering via the Bun bridge +- **SSR** — server rendering via the embedded-V8 engine in the Rust + binary (see `docs/SSR_ARCHITECTURE.md`) Independent and composable. Either ships standalone; together they compose. @@ -68,9 +69,9 @@ contexts, types, and invalidation graph. Every codegen target consumes KDL. KDL is the contract; everything else (REST envelopes, OpenAPI documents, framework idioms) is sediment around it. -The IR must be validated against multiple adapters before it is -considered stable. Single-adapter validation hides assumptions — -divergence between adapters is what the IR exists to prevent. +The IR is validated against multiple adapters — single-adapter +validation hides assumptions, and divergence between adapters is what +the IR exists to prevent. Forward-direction primitives: @@ -85,17 +86,16 @@ Forward-direction primitives: - `protocol/mizan-codegen/src/fetch.rs` spawns the configured source command and parses the KDL it writes. - Codegen reads KDL directly — no OpenAPI envelope, no - `openapi-typescript`, no per-backend converter divergence. The - former JavaScript/Node two-stage codegen (`openapi-typescript` plus - `.mjs` adapters) has been deleted; codegen is now the single Rust - binary. + `openapi-typescript`, no per-backend converter divergence; codegen + is a single Rust binary. - Edge manifest, MWT claims, and other protocol artifacts derive from the same registry/IR. -## Launch surface +## Authoring surface -Python (Django) + React. Vue and Svelte ship as v1 alongside React. -TypeScript backend (`mizan-ts`) proves the protocol is portable. +Python (Django) + React is the reference stack. Vue and Svelte are +co-equal codegen targets over the same kernel. The TypeScript backend +(`mizan-ts`) proves the protocol is portable. ## Why the AFI shape diff --git a/docs/SSR_ARCHITECTURE.md b/docs/SSR_ARCHITECTURE.md index 36b453f..ecd6d1c 100644 --- a/docs/SSR_ARCHITECTURE.md +++ b/docs/SSR_ARCHITECTURE.md @@ -1,10 +1,9 @@ # SSR Architecture -*Decided 2026-04-07.* - -Mizan's SSR adapter is a **Django template backend**. It plugs into -Django's existing `TEMPLATES` setting, replacing the template -rendering engine. +Mizan's SSR adapter is a **Django template backend** rendering through an +**embedded V8 engine inside the Mizan Rust binary** (`cores/mizan-rust-ssr`). +No external JS runtime serves requests — node and bun are build-time tools +only, and no frontend adapter imports an SSR runtime or meta-framework. ```python TEMPLATES = [ @@ -12,53 +11,74 @@ TEMPLATES = [ 'BACKEND': 'mizan.ssr.MizanTemplates', 'DIRS': [BASE_DIR / 'frontend'], 'OPTIONS': { - 'worker': 'path/to/mizan-ssr/src/worker.tsx', + 'bundles': BASE_DIR / 'frontend' / '.mizan' / 'ssr', 'timeout': 5, }, } ] ``` -Then `render(request, 'components/Hello.tsx', context)` calls the Bun -subprocess bridge instead of rendering a Django/Jinja2 template. -**The template name IS a `.tsx`/`.jsx` file path**, resolved against -`DIRS`; `get_template` returns a `MizanTemplate` wrapping the absolute -file path. The context dict becomes the component's props (`request` -and `csrf_token` stripped). Rendered output is wrapped in -`
` plus a -`` hydration payload. +`render(request, 'components/Hello.tsx', context)` renders a React component +instead of a Django/Jinja2 template. **The template name IS a `.tsx`/`.jsx` +file path**, resolved against `DIRS`; `get_template` returns a +`MizanTemplate` wrapping the resolved component. The context dict becomes +the component's props (`request` and `csrf_token` stripped). Rendered output +is wrapped in `
` plus a +`` hydration +payload the Mizan kernel hydrates from — server-validated data crossing +one way, as props. + +## The engine + +`SsrEngine` (`cores/mizan-rust-ssr`) embeds a `deno_core` V8 runtime +composed with `deno_web`, so the web-platform globals react-dom touches +(`TextEncoder`/`TextDecoder`, `MessagePort`, timers) are real +implementations, not shims — a partial polyfill is silent-failure-shaped; +it passes until a render path hits the gap. + +- **The bundle is built, the engine evals it once.** `mizan-generate`'s SSR + bundling step compiles each SSR entry component together with + `react-dom/server.browser` into a self-contained bundle that assigns + `globalThis.renderApp`. The engine evals the trusted bundle at + construction and calls `renderApp(props)` per request. +- **Props never enter evaluated source.** Per-render data crosses as a + `v8::json::parse`d value passed as a function argument, so a prop string + has no source to break out of — code injection is structurally absent, + not filtered. +- **One isolate per engine, one engine per worker thread.** V8's Locker + constraint means an engine is not `Send`; the Django side holds one + engine per (worker thread, bundle) pair. ## AFI boundary | Side | Responsibility | |---|---| -| Backend adapter (`SSRBridge`) | Manages the Bun subprocess lifecycle; gathers props | -| Bun worker (`worker.tsx`) | `import()`s the file path, `renderToString(createElement(Component, props))` | -| stdin/stdout JSON-RPC | Newline-delimited; `{id, method:"render", params:{file, props}}` → `{id, html}` / `{id, error}`; `ping` → `{id, pong:true}` | +| Backend adapter (`mizan/ssr`) | Django template-backend interface; resolves the component path to its built bundle; gathers props; wraps output for hydration | +| Rust binary (`SsrEngine`, via PyO3) | Holds the evaled bundle; parses props to a V8 value; `renderApp(props)` → HTML string | +| Build step (`mizan-generate`) | Bundles component + `react-dom/server.browser` into the `bundles` directory; the only place node/bun run | + +The Python side binds the engine through PyO3 — in-process FFI, no +subprocess, no JSON-RPC framing. This is the Core-Consolidation shape: +behavior lives in the binary; languages bind to it. + +## No framework runtimes + +No frontend adapter imports an SSR runtime or meta-framework — Next, Nuxt, +SvelteKit, React Server Components / Flight. Those add the +client-payload-deserialization step behind the pre-auth RCE class +(CVE-2025-55182); the AFI already provides the typed, Pydantic-validated, +one-way version they would otherwise import unsafely. + +The guarantee is enforced, not assumed: `cores/mizan-rust-ssr/tests/no_rsc.rs` +scans the authored SSR source and its dependencies for the forbidden token +set (`react-server-dom`, `renderToReadableStream`, `renderToPipeableStream`, +`createFromReadableStream`/`Fetch`, `use server`, `next/`, `nuxt`, +`@sveltejs/kit`) and fails on presence — re-entry is loud. ## Why template backend -- Django's template system is swappable by design (batteries - included, but replaceable). -- Django developers already use `render(request, template, context)` - — no new API to learn. +- Django's template system is swappable by design (batteries included, but + replaceable). +- Django developers already use `render(request, template, context)` — no + new API to learn. - URL routing, views, middleware, auth — all unchanged. - -> A `templatetags/` package exists for a future `{% mizan_render %}` -> convenience tag (base.html shell with Mizan components inside), but -> it is currently empty — no tag is implemented yet. - -## Implementation surface - -The SSR backend (`mizan/ssr/backend.py`) implements Django's template -backend interface: - -- `MizanTemplates(BaseEngine)` — requires `OPTIONS['worker']` (path to - `worker.tsx`); `get_template(name)` resolves a file under `DIRS` -- `MizanTemplate` with `.render(context, request)` → calls the bridge -- `SSRBridge` (`bridge.py`) — spawns `bun run `, holds the - persistent subprocess, correlates requests by message id, thread-safe, - auto-restarts on crash, waits for the worker's ready signal - -Everything Django expects from a template backend, but the actual -rendering routes to Bun.