SSR migrated to Rust

This commit is contained in:
2026-06-04 21:37:06 -04:00
parent ae684a36cb
commit 587be8c4ab
12 changed files with 2825 additions and 472 deletions

View File

@@ -0,0 +1,133 @@
//! Mizan SSR engine.
//!
//! 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.
//!
//! 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.
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use deno_core::{v8, JsRuntime, RuntimeOptions};
use deno_web::{BlobStore, InMemoryBroadcastChannel};
/// Install the web-platform globals react-dom touches at module-init, pulled
/// from `deno_web`'s real implementations via the lazy-load op. `deno_web`
/// registers these as lazy modules and installs nothing on `globalThis` until
/// asked — this is the minimal bootstrap that asks.
const INSTALL_WEB_GLOBALS: &str = r#"{
const lazy = (s) => Deno.core.loadExtScript(s);
const mp = lazy("ext:deno_web/13_message_port.js");
globalThis.MessageChannel = mp.MessageChannel;
globalThis.MessagePort = mp.MessagePort;
const te = lazy("ext:deno_web/08_text_encoding.js");
globalThis.TextEncoder = te.TextEncoder;
globalThis.TextDecoder = te.TextDecoder;
}"#;
/// 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).
pub struct SsrEngine {
runtime: JsRuntime,
}
impl SsrEngine {
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
pub fn new(bundle: String) -> Result<Self> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(BlobStore::default()),
None, // maybe_location
false, // enable_css_parser_features
InMemoryBroadcastChannel::default(),
),
],
..Default::default()
});
runtime
.execute_script("[mizan:web-globals]", INSTALL_WEB_GLOBALS)
.context("installing web-platform globals")?;
runtime
.execute_script("[mizan:bundle]", bundle)
.context("evaluating the SSR bundle")?;
Ok(Self { runtime })
}
/// 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> {
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 recv = v8::undefined(scope).into();
let result = func
.call(scope, recv, &[props])
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
Ok(result.to_rust_string_lossy(scope))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn renders_react_bundle_in_embedded_v8() {
let bundle = 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");
let mut engine = SsrEngine::new(bundle).expect("engine init");
let html = engine.render(r#"{"name":"World"}"#).expect("render");
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
}
#[tokio::test]
async fn props_cannot_inject_code() {
// 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 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"));
}
}