A channel's message slots are named from the client, on every backend

The IR called them react-message and django-message, so a FastAPI channel had
to declare a DjangoMessage. They are client-message and server-message now,
and the direction words hold wherever a channel is declared: Params /
ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and
friends so no backend names a type itself. Django's ReactChannel and
FastAPI's ReactChannel are both Channel.

mizan-fastapi never registered a channels extension, so build_ir() emitted no
channel at all and every payload type was invisible to codegen. It registers
one now. RegistryExtension is an ABC requiring all(), which is what the IR
reads — an extension that cannot enumerate its registrations no longer exists.

The gate that should have caught the rename could not: tests/afi registered no
channel because mizan-rust had no channel registry to register one in, so a
five-package rename of the wire contract passed byte-parity without a channel
byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a
#[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's
split; the AFI fixture now carries a channel with every slot and one with a
single slot, so all three backends prove the contract byte for byte.

MizanChannel held three Option<String> beside three has_*() predicates and
unwrapped them with defaults; it holds an ordered slot vector, so an absent
slot is absent rather than defaulted. The channels target emitted a React
hooks file that a stage1-only consumer could not compile — react emits that
now. The codegen's parity tests byte-compared emitted source against baselines
without ever compiling it: they compile the generated crate and run its tests,
import the generated Python package and call every method, and typecheck each
TypeScript target against a consumer.

Also fixed at source: app_visitor printed its import diagnostic to stdout, the
stream export_mizan_ir writes KDL to, so a failed import silently corrupted the
IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on
any non-JSON-serializable field default; Django and mizan-core derived Pascal
names two different ways, disagreeing on every dotted channel name.

ir.py builds a document and renders templates/ir/document.kdl.j2 rather than
appending KDL strings with hand-tracked indentation, and named types resolve to
a fixed point — a model reachable only through a union branch was referenced by
a ref that no type block ever defined.

The rest is the write-gate's own classifiers run over the standing tree:
relative imports, silent swallows, Protocol contracts that should be ABCs,
emitters hand-rendering target source, catch-all arms over closed enums, and
comments narrating the project rather than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:03:19 -04:00
parent 398c90fc8b
commit 3aafec6dd4
345 changed files with 11054 additions and 17359 deletions

View File

@@ -1,20 +1,14 @@
//! Mizan SSR engine.
//! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with
//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function
//! that bundle defines.
//!
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
//! global render function; the engine evals it once and calls it per request.
//! No external JS runtime — node and bun are build-time tools only.
//! `deno_web` supplies the web-platform globals a bare isolate lacks —
//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
//! implementations rather than partial shims.
//!
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
//! web-platform implementations, not shims (a partial polyfill is
//! silent-failure-shaped: it passes until a render path hits the gap).
//!
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
//! 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.
//! Only the bundle is ever `eval`'d. Per-render props enter through
//! `v8::json::parse` and are handed in as a call argument, so a prop string has
//! no surrounding source to break out of.
use std::sync::Arc;
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
globalThis.TextDecoder = te.TextDecoder;
}"#;
/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent
/// or not callable. The script therefore either fails — arriving in Rust as
/// the evaluator's own error — or produces a callable, which is what lets the
/// engine take it as a `v8::Function` without a second check.
const TAKE_RENDER_APP: &str = r#"(() => {
const f = globalThis.renderApp;
if (typeof f !== "function") {
throw new TypeError("the SSR bundle assigns no callable `renderApp`");
}
return f;
})()"#;
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
/// means an engine is not `Send`; hold one per worker thread).
///
/// `render_fn` is taken during construction, so a render calls a function this
/// engine already owns and repeats no lookup.
pub struct SsrEngine {
runtime: JsRuntime,
render_fn: v8::Global<v8::Function>,
}
impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
/// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`),
/// and take hold of that function.
pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
@@ -64,50 +75,64 @@ impl SsrEngine {
runtime
.execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?;
Ok(Self { runtime })
let render_app = runtime
.execute_script("[mizan:render-app]", TAKE_RENDER_APP)
.context("taking `renderApp` from the evaluated bundle")?;
let render_fn = {
deno_core::scope!(scope, &mut runtime);
let func = v8::Local::new(scope, render_app).cast::<v8::Function>();
v8::Global::new(scope, func)
};
Ok(Self { runtime, render_fn })
}
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
/// is a JSON object string; it is parsed to a V8 value and passed as an
/// argument — never spliced into evaluated source.
pub fn render(&mut self, props_json: &str) -> Result<String> {
let render_fn = self.render_fn.clone();
deno_core::scope!(scope, &mut self.runtime);
let context = scope.get_current_context();
let global = context.global(scope);
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
let func_val = global
.get(scope, key.into())
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
let func: v8::Local<v8::Function> = func_val
.try_into()
.map_err(|_| anyhow!("renderApp is not a function"))?;
let props_str = v8::String::new(scope, props_json).context("intern props")?;
let props = v8::json::parse(scope, props_str)
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
let func = v8::Local::new(scope, &render_fn);
let props = parse_props(scope, props_json)?;
let recv = v8::undefined(scope).into();
let result = func
let html = func
.call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope))
Ok(html.to_rust_string_lossy(scope))
}
}
/// The one crossing where untrusted request text becomes a value inside the
/// isolate. Both steps report that boundary's failure and nothing else: V8
/// refuses a string past its length limit, and its JSON grammar rejects
/// malformed input.
fn parse_props<'s>(
scope: &v8::PinScope<'s, '_>,
props_json: &str,
) -> Result<v8::Local<'s, v8::Value>> {
let text = v8::String::new(scope, props_json)
.ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?;
v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON"))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let bundle = std::fs::read_to_string(concat!(
fn fixture_bundle() -> String {
std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step")
}
let mut engine = SsrEngine::new(bundle).expect("engine init");
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
}
@@ -117,17 +142,19 @@ mod tests {
// A prop value that would break out of a string-built `renderApp(...)`
// call. Through the value-call path it is inert data: it reaches the
// component as a string, never as source.
let bundle = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixture/bundle.js"
))
.expect("fixture bundle");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
let html = engine
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
.expect("render");
// The payload rendered as text; it did not execute.
assert!(html.contains("__pwned"));
}
#[tokio::test]
async fn a_bundle_without_a_callable_render_app_is_rejected() {
let err = SsrEngine::new("globalThis.renderApp = 7;".to_string())
.map(|_| ())
.expect_err("a bundle whose renderApp is not callable must not build an engine");
assert!(err.to_string().contains("renderApp"), "unexpected: {err}");
}
}