Skip to content

Prerender to HTML under wasmtime, streaming into the response body - #20

Merged
lannbot merged 1 commit into
mainfrom
feat/prerender-wasmtime
Sep 4, 2026
Merged

Prerender to HTML under wasmtime, streaming into the response body#20
lannbot merged 1 commit into
mainfrom
feat/prerender-wasmtime

Conversation

@lannbot

@lannbot lannbot commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Prerenders a Dioxus app to HTML from a wasm component under wasmtime, with
no custom host: wasmtime run for the static artifact, wasmtime serve for
the HTTP one.

Approach

dioxus-ssr runs in the guest rather than replaying the mutation
protocol into a server-side DOM. That keeps exactly one implementation of
the mutation semantics (the existing WIT protocol + host/src/applier.ts)
instead of creating a second one that has to agree with it forever. It also
means pre_render's hydration markers and wait_for_suspense are one flag
and one .await away when those tracks come up.

dioxus-ssr 0.7.9 depends only on askama_escape / dioxus-core /
dioxus-core-types / rustc-hash — no tokio, no net, no wasm-bindgen — so
it compiles to wasm32-wasip2 unpatched.

Two artifacts

world run it with
SSG wasi:cli/command wasmtime run (no flags)
serve wasi:http/service wasmtime serve -S cli

They share render_to. The renderers are optional dependencies of the
example, not just optional code paths: polyengine-dioxus and
polyengine-dioxus-ssr each emit a component-type custom section and must
never link into one binary.

-S cli is not optional for the serve artifact: without it wasmtime links
only the proxy world, and a rustc wasm32-wasip2 component imports
wasi:cli/environment, wasi:filesystem/preopens and exit through
wasi-libc.

The serve path streams

Renderer::render_to writes through a BufWriter straight into the response
body's stream<u8> as it walks the tree — no intermediate String.

That is possible because component-model async is more permissive than Rust
async. The sink is a synchronous io::Write that calls
wit_bindgen::block_on; wasmtime permits blocking when
may_block = task.async_function || task.returned_or_cancelled(), and
wasi:http/handler.handle is an async func. The render runs inside
spawn_local after task.return, because response.new hands the body's
read end to the host and the host cannot read it until it has the
response — writing before returning would park forever.

Buffering is std::io::BufWriter's job. The only hand-written piece is an
8-line fmt::Writeio::Write bridge, which exists because fmt::Error
is a unit type and would otherwise destroy the real io::Error (that is how
a client hanging up mid-render is distinguished from success).

Measured, since it is the opposite of what "blocking" suggests: six
concurrent rate-limited 341 KB responses finish in the wall time of one at
stock wasmtime serve settings. A task parked in waitable-set.wait does
not hold off its instance-mates, so no --max-instance-concurrent-reuse-count
tuning is needed.

Gates

Each step adds exactly one layer; the expensive machinery is only at the top.

just test          cargo test -p polyengine-dioxus-ssr — HTML correctness,
                   natively. No wasm, no wasmtime, no ports.
just ssg-example   the same golden file natively AND under `wasmtime run`,
                   so a divergence between the two is itself the signal
just serve-test    only the ~20-line HTTP wrapper; ephemeral port parsed
                   from wasmtime's own output, killed by PID

Not a gate: equality with what host/src/applier.ts produces in a browser.
The applier sets value/checked/selected as JS properties, which have
no serialized form. dioxus-web lives with the same split because hydration
binds by markers, not by diffing output.

Sizes (counter example, release)

artifact size
client (polymorph:dioxus/app) 794 KB
SSG 381 KB
serve 662 KB

The 281 KB SSG→serve delta is the wasi:http/p3 async layer.

Out of scope

Hydration markers, suspense, server-data transport, routing, static assets.
Liveview still wants a custom host (websockets, persistent instance) but
shares nothing with this.

Adds `polyengine-dioxus-ssr`: the app's root component rendered by
`dioxus-ssr` rather than replayed through the mutation protocol, so
there is still exactly one implementation of the mutation semantics.
Two artifacts share `render_to`:

- SSG — a `wasi:cli/command` binary that writes HTML to stdout. No
  HTTP, no async, no component-model streams; `wasmtime run` executes
  it with no flags.
- serve — a `wasi:http/service` component behind the `serve` feature.

The serve path streams: `Renderer::render_to` writes through a
`BufWriter` into the response body's `stream<u8>` as it walks the tree.
That works because component-model async is more permissive than Rust
async — the sink is synchronous and calls `wit_bindgen::block_on`,
which wasmtime allows since `may_block` is
`task.async_function || task.returned_or_cancelled()` and
`wasi:http/handler.handle` is an `async func`. The render runs in a
`spawn_local` task after `task.return`, because `response.new` hands
the body's read end to the host and the host cannot read it until it
has the response.

Measured, since it is the opposite of what "blocking" suggests: six
concurrent rate-limited 341 KB responses finish in the wall time of
one at stock `wasmtime serve` settings, so no
`--max-instance-concurrent-reuse-count` tuning is needed.

The renderers are optional dependencies of the example, not just
optional code paths: `polyengine-dioxus` and `polyengine-dioxus-ssr`
each emit a `component-type` custom section and must never link into
one binary.

Gate ladder, each step adding one layer:

  just test          cargo test -p polyengine-dioxus-ssr — HTML
                     correctness, natively, no wasm or ports
  just ssg-example   the same golden file natively and under
                     `wasmtime run`
  just serve-test    only the wasi:http wrapper, on an ephemeral port

Hydration markers, suspense, server-data transport and routing are out
of scope; `pre_render` is one flag away but needs a client-side
binding walk to be worth anything.
@lannbot
lannbot enabled auto-merge September 4, 2026 15:43
@lannbot
lannbot merged commit 4ad3816 into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants