A prototype: run the server half of React Server Components inside a WASM sandbox in the browser, and use it as a plugin system.
A React Server Component never ships its source to the client. The server
renders it and streams a serialization of the result — React's "flight" wire
format — which the client parses back into real elements. That split is
normally server/browser. Here it is sandbox/page: a plugin compiled to
wasm32-unknown-unknown plays the server, the page plays the client, and the
boundary between them is a byte stream instead of a network hop.
plugins/hello (Rust) ──cargo──▶ hello.wasm
│ no imports: no DOM, no fetch, no timers
▼
0:["$","$L1",null,{...}] the flight wire format
1:I["host:Button",[],"..."]
│
▼
react-server-dom-webpack/client.browser ──▶ real React elements
The plugin cannot execute anything in the page. It has no JS realm, no DOM
handle, and — because WebAssembly.instantiate is called with an empty import
object — no way to call back into the host at all. It allocates memory and
returns bytes.
What it can do is compose the host's own components. When a plugin renders
host:Button, the payload contains only that name; the host resolves it through
its registry (packages/react-server-wasm/src/registry.ts) to a real, interactive React
component. So plugins extend the UI using the host's design system, and the
registry is the capability allowlist — referencing anything not in it throws:
Plugin referenced unregistered client component "host:Malicious".
Registered: host:Button, host:Card, host:Counter
pnpm install
mise run setup-hooks # once: points git at .hooks/
mise run dev # builds in graph order, serves the demo on :5173mise tasks lists the rest with descriptions.
mise run check is what .hooks/pre-commit runs: cargo check for both the
host and wasm32-unknown-unknown targets with RUSTFLAGS=-D warnings, then
tsc --noEmit across every package. Warnings fail the commit but never block
local iteration, since the denial is applied at commit time rather than with a
crate-level #![deny(warnings)]. Bypass with git commit --no-verify.
mise run dev watches plugins/ and crates/ for .rs changes, recompiles the
wasm, and reloads the page — so editing a plugin shows up without a manual
rebuild (packages/demo/vite-plugin-wasm-plugins.ts). A full reload rather than
HMR, because the loaded-plugin cache lives at module scope. If the Rust fails to
compile, the error goes to the terminal and the browser overlay, so a failed
build is never silently the previous wasm still running.
Everything the encoder knows was derived by running React's own renderer
(packages/flight-spec/generate.js, React 19.2.8, production build) and reading
the bytes. Rows are <hex-id>:<tag?><json>\n:
1:I["host:Button",[],"default"] client reference: module id, chunks, export
4:"$Sreact.suspense" a symbol, since JSON has no spelling for one
0:["$","div",null,{"children":"$L5"}] root; "$L5" defers to row 5
5:["$","ul",null,{...}] ...which arrives in a later chunk
Elements are positional arrays ["$", type, key, props], not objects. Children
live under the children prop, appended last. Fragments collapse to plain
arrays. undefined is "$undefined", and a user string starting with $ is
escaped by doubling it.
crates/react-flight/tests/conformance.rs rebuilds each sample tree in Rust and
asserts byte equality against React's output. If React changes the format,
those tests fail rather than the host silently mis-rendering.
Deliberately tiny (crates/react-flight/src/abi.rs):
memory exported linear memory
alloc(len) -> ptr host writes props JSON here
dealloc(ptr, len)
render(ptr, len) -> (ptr<<32 | len) first chunk of the flight stream
poll(now_ms) -> (ptr<<32 | len) later chunks; empty when nothing is ready
pending() -> count unresolved Suspense slots; 0 means done
action(id, args) -> (ptr<<32 | len) dispatch a callback; empty = unknown id
set_dev_mode(flag) optional; see below
set_dev_mode exists because a development build of React reads a longer
element tuple — ["$", type, key, props, owner, stack, validated] — and re-runs
key validation on any element whose validated slot is missing. Fed a
production-shaped payload it warns about missing keys on children the plugin
wrote as static siblings. loadPlugin detects which build it is feeding and
calls set_dev_mode accordingly; pass development explicitly to override.
Production is the default, which is what keeps output byte-identical to
React's own.
A plugin implements the Plugin trait and calls export_plugin!:
impl Plugin for Hello {
fn render(&mut self, r: &mut Renderer, props: &Value) -> Node {
let slot = r.slot(); // reserve a Suspense slot
c(ClientRef::new("host:Card"), props! { "title" => "Hello" }, [
h("p", Props::new(), [Node::text("Rendered inside WASM.")]),
Node::suspense(h("p", Props::new(), [Node::text("Loading...")]),
Node::Slot(slot)),
])
}
fn poll(&mut self, r: &mut Renderer, now_ms: f64) -> String {
// ...when the work is done:
r.resolve(slot, &h("ul", Props::new(), items))
}
}
export_plugin!(Hello);now_ms is passed in because the sandbox has no clock of its own. The demo
plugin uses it to simulate ~900ms of work, so the Suspense boundary genuinely
streams: the shell paints immediately, the list arrives in a second chunk.
A plugin can hand the page a callback. It renders a ServerRef, which goes on
the wire as a name ("$h5"); React turns that into a real function, and calling
it dispatches back into the sandbox through action:
c(ClientRef::new("host:ActionButton"), props! {
"label" => "Add 5 in the sandbox",
// Bound arguments, so one handler can serve many call sites.
"onAction" => ServerRef::new("hello:bump").bind([json!(5)]),
}, [])fn action(&mut self, _r: &mut Renderer, id: &str, args: &[Value]) -> Option<Node> {
match id {
"hello:bump" => { self.tally += /* ... */; Some(h("p", Props::new(), [/* ... */])) }
_ => None, // the host turns this into a thrown error
}
}The return value is a node, serialized as its own payload, so an action can
hand back UI rather than just data. &mut self is the point: state mutated in
an action persists for the life of the module, so clicking the button three
times counts to 15 inside the sandbox.
Two things the response is not: it is not spliced into the original stream (it gets a fresh renderer, so row ids start at 0 again -- reusing them would collide with rows the client has already seen), and it has no poll loop behind it, so any Suspense slot left open is closed rather than left hanging.
Arguments cross as JSON. React hands callServer a plain array; anything that
will not serialize is rejected on the host side rather than arriving as null.
Deliberately absent:
- Streaming action responses. An action returns one complete payload; it cannot open a Suspense boundary and fill it later the way a render can.
- Host capabilities. The import object is empty by design, so a plugin has no I/O. Real async work (fetch, storage) means adding explicit, per-plugin host imports — that is where the interesting permission design lives.
- A manifest / multi-plugin loader. One plugin, one hardcoded URL.
- Memory limits and fuel. A plugin can allocate freely and can wedge its own stream; the host only guards with a 10s poll timeout.