Skip to content

Repository files navigation

react-server-wasm

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

Why this shape

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

Running it

pnpm install
mise run setup-hooks   # once: points git at .hooks/
mise run dev           # builds in graph order, serves the demo on :5173

mise 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.

The wire format

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.

The plugin ABI

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.

Server actions

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.

What is not built

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.

About

Render React Server Components from a WASM sandbox — Rust plugins emit React's RSC flight wire format, the page renders them with React's own client

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages