diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64eed60..baf528b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ env: DENO_VERSION: "2.9.5" WASM_TOOLS_VERSION: "1.247.0" JUST_VERSION: "1.54.0" + WASMTIME_VERSION: "47.0.1" jobs: gates: @@ -67,7 +68,7 @@ jobs: - uses: taiki-e/install-action@v2 with: - tool: just@${{ env.JUST_VERSION }},wasm-tools@${{ env.WASM_TOOLS_VERSION }} + tool: just@${{ env.JUST_VERSION }},wasm-tools@${{ env.WASM_TOOLS_VERSION }},wasmtime@${{ env.WASMTIME_VERSION }} - uses: Swatinem/rust-cache@v2 @@ -97,6 +98,14 @@ jobs: - name: Build counter example component run: just example counter + # Prerender lane. `ssg-example` checks the same golden file natively and + # under `wasmtime run`; `serve-test` adds only the wasi:http wrapper. + - name: Prerender the counter example + run: just ssg-example counter + + - name: Serve the prerendered counter example + run: just serve-test counter + # cargo test (the interner unit tests; the writer names the generated # bindings and so is wasm32-only, covered host-side instead) + # deno task test. diff --git a/Cargo.lock b/Cargo.lock index 41b8c47..a85a843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "askama_escape" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" + [[package]] name = "async-trait" version = "0.1.92" @@ -200,6 +206,7 @@ version = "0.1.0" dependencies = [ "dioxus", "polyengine-dioxus", + "polyengine-dioxus-ssr", ] [[package]] @@ -624,6 +631,18 @@ dependencies = [ "warnings", ] +[[package]] +name = "dioxus-ssr" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d261c5c9907b84fb1ed52f59f46d68c84a4ae860a65cc5effd0cea740ee428af" +dependencies = [ + "askama_escape", + "dioxus-core", + "dioxus-core-types", + "rustc-hash 2.1.3", +] + [[package]] name = "dioxus-stores" version = "0.7.10" @@ -1541,6 +1560,16 @@ dependencies = [ "wit-bindgen 0.60.0", ] +[[package]] +name = "polyengine-dioxus-ssr" +version = "0.1.0" +dependencies = [ + "dioxus", + "dioxus-core", + "dioxus-ssr", + "wasip3", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2527,6 +2556,10 @@ name = "wit-bindgen" version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e473fd0095479f9689ac7d2a52c427cc96bb2b973ace50238dfcc1ab1cd52d93" +dependencies = [ + "bitflags", + "futures", +] [[package]] name = "wit-bindgen-core" diff --git a/Cargo.toml b/Cargo.toml index 34e0a17..3e3eb7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ dioxus = { version = "=0.7.10", default-features = false, features = [ ] } [workspace] -members = [".", "fixtures/surface-probe", "examples/counter", "examples/bench-rows", "examples/todomvc", "examples/components", "examples/primitives"] +members = [".", "ssr", "fixtures/surface-probe", "examples/counter", "examples/bench-rows", "examples/todomvc", "examples/components", "examples/primitives"] # `dioxus-sdk-time` waits by calling the browser's `setTimeout` through # wasm-bindgen, which on wasm32-wasip2 compiles to off-target stubs that abort diff --git a/examples/counter/Cargo.toml b/examples/counter/Cargo.toml index d990ed5..a3d977f 100644 --- a/examples/counter/Cargo.toml +++ b/examples/counter/Cargo.toml @@ -5,10 +5,29 @@ edition = "2021" publish = false [lib] -crate-type = ["cdylib"] +# `rlib` alongside the component's `cdylib` so the SSG binary below can depend +# on this crate for `App`. +crate-type = ["cdylib", "rlib"] + +# Prints the prerendered HTML to stdout as a `wasi:cli/command` component, so +# `wasmtime run` needs no flags to execute it. +[[bin]] +name = "counter-ssg" +path = "src/bin/ssg.rs" +required-features = ["ssg"] + +[features] +default = ["client"] +# The renderers are optional dependencies, not just optional code paths: each +# emits a `component-type` custom section, and two of those in one binary is +# not a component. Exactly one of these may be enabled at a time. +client = ["dep:polyengine-dioxus"] +ssg = ["dep:polyengine-dioxus-ssr"] +ssr = ["dep:polyengine-dioxus-ssr", "polyengine-dioxus-ssr/serve"] [dependencies] -polyengine-dioxus = { path = "../.." } +polyengine-dioxus = { path = "../..", optional = true } +polyengine-dioxus-ssr = { path = "../../ssr", optional = true } dioxus = { version = "=0.7.10", default-features = false, features = [ "macro", "html", diff --git a/examples/counter/golden.html b/examples/counter/golden.html new file mode 100644 index 0000000..da803ab --- /dev/null +++ b/examples/counter/golden.html @@ -0,0 +1 @@ +
0

count is 0

submitted 0 time(s)

\ No newline at end of file diff --git a/examples/counter/src/bin/ssg.rs b/examples/counter/src/bin/ssg.rs new file mode 100644 index 0000000..6962313 --- /dev/null +++ b/examples/counter/src/bin/ssg.rs @@ -0,0 +1,13 @@ +//! Prerenders the counter app to stdout. +//! +//! Built for `wasm32-wasip2` this is a `wasi:cli/command` component that +//! `wasmtime run` executes with no flags — the cheapest end-to-end proof that +//! the renderer works inside a component. Built natively it prints the same +//! bytes, so one golden file covers both. + +use std::io::{BufWriter, stdout}; + +fn main() { + polyengine_dioxus_ssr::render_to(counter_example::App, BufWriter::new(stdout().lock())) + .expect("prerender to stdout"); +} diff --git a/examples/counter/src/lib.rs b/examples/counter/src/lib.rs index d405433..2b40883 100644 --- a/examples/counter/src/lib.rs +++ b/examples/counter/src/lib.rs @@ -12,7 +12,7 @@ use dioxus::prelude::*; #[allow(non_snake_case)] -fn App() -> Element { +pub fn App() -> Element { let mut count = use_signal(|| 0i32); let mut draft = use_signal(String::new); let mut items = use_signal(|| vec!["alpha".to_string(), "beta".to_string()]); @@ -86,4 +86,8 @@ fn App() -> Element { } } +#[cfg(feature = "client")] polyengine_dioxus::launch!(App); + +#[cfg(feature = "ssr")] +polyengine_dioxus_ssr::launch_ssr!(App); diff --git a/justfile b/justfile index d954db5..c788487 100644 --- a/justfile +++ b/justfile @@ -40,10 +40,18 @@ deps: check: cargo check --workspace --target wasm32-wasip2 cargo clippy --workspace --target wasm32-wasip2 -- -D warnings + # The workspace pass above sees every crate at DEFAULT features, so it + # never compiles the prerenderer's `serve` module or the `launch_ssr!` + # expansion. Checking the example covers both. + cargo clippy -p counter-example --no-default-features --features ssr \ + --target wasm32-wasip2 -- -D warnings deno task check test: cargo test + # The workspace root is a package, so plain `cargo test` above is + # `-p polyengine-dioxus` and nothing else. + cargo test -p polyengine-dioxus-ssr deno task test # Build the surface-probe fixture component into fixtures/build/. The @@ -82,6 +90,82 @@ example name: examples/build/{{name}}.component.wasm \ -o examples/build/{{name}}.plan.json +# Prerender the example to HTML, as a `wasi:cli/command` component. +# +# Checked twice against one golden file: natively, then as a component under +# `wasmtime run`. Same source, same bytes — so a divergence between the two +# is itself the signal, and neither check needs HTTP, ports or p3. +ssg-example name: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p examples/build + cargo run -q -p {{name}}-example --no-default-features --features ssg \ + --bin {{name}}-ssg | diff - examples/{{name}}/golden.html + cargo build -p {{name}}-example --no-default-features --features ssg \ + --bin {{name}}-ssg --target wasm32-wasip2 --release + cp target/wasm32-wasip2/release/{{name}}-ssg.wasm \ + examples/build/{{name}}.ssg.component.wasm + wasm-tools validate --features component-model examples/build/{{name}}.ssg.component.wasm + wasmtime run examples/build/{{name}}.ssg.component.wasm | diff - examples/{{name}}/golden.html + +# Build the example as a `wasi:http/service` component (prerender per request). +ssr-example name: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p examples/build + cargo build -p {{name}}-example --no-default-features --features ssr --lib \ + --target wasm32-wasip2 --release + cp "target/wasm32-wasip2/release/$(echo {{name}} | tr - _)_example.wasm" \ + examples/build/{{name}}.ssr.component.wasm + wasm-tools validate --features component-model,cm-async \ + examples/build/{{name}}.ssr.component.wasm + +# Serve the prerendered example. +# +# `-S cli` is not optional: 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. That one flag adds +# both the p2 and the wasi:cli@0.3.x tracks. +serve name: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f examples/build/{{name}}.ssr.component.wasm ]; then + just ssr-example {{name}} + fi + wasmtime serve -S cli examples/build/{{name}}.ssr.component.wasm + +# Smoke-test the served component: it answers, with the golden HTML. +# +# HTML correctness is already settled by `just test` and `just ssg-example`; +# what this covers is only the ~20-line HTTP wrapper. Binds port 0 and reads +# the real port back from wasmtime's own output, so parallel checkouts cannot +# collide, and kills by PID rather than by port. +serve-test name: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f examples/build/{{name}}.ssr.component.wasm ]; then + just ssr-example {{name}} + fi + log=$(mktemp) + wasmtime serve -S cli --addr 127.0.0.1:0 \ + examples/build/{{name}}.ssr.component.wasm > "$log" 2>&1 & + pid=$! + trap 'kill $pid 2>/dev/null || true; rm -f "$log"' EXIT + port="" + for _ in $(seq 100); do + # No match yet is the normal case while wasmtime is still starting, and + # `set -euo pipefail` would otherwise abort the recipe on it. + port=$(grep -o 'http://127\.0\.0\.1:[0-9]*' "$log" | head -1 | cut -d: -f3 || true) + if [ -n "$port" ]; then break; fi + sleep 0.1 + done + if [ -z "$port" ]; then + echo "serve never reported a port:" >&2 + cat "$log" >&2 + exit 1 + fi + curl -sS --fail "http://127.0.0.1:$port/" | diff - examples/{{name}}/golden.html + # Real-browser (Chromium via Playwright) E2E lane for the counter example. # First run: `cd e2e && npm install && npx playwright install chromium --with-deps`. # GitHub-Pages-ready static site for the TodoMVC example, assembled flat diff --git a/ssr/Cargo.toml b/ssr/Cargo.toml new file mode 100644 index 0000000..eadab5c --- /dev/null +++ b/ssr/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "polyengine-dioxus-ssr" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Server-side prerendering for Dioxus components, as a wasm32-wasip2 component" +publish = false + +[dependencies] +dioxus-core = "=0.7.10" +# Depends only on askama_escape / dioxus-core / dioxus-core-types / rustc-hash: +# no tokio, no net, no wasm-bindgen. Compiles to wasm32-wasip2 unpatched. +dioxus-ssr = "=0.7.9" +# Only for the `serve` artifact. Optional so the SSG artifact links no HTTP +# bindings at all — and, more to the point, so this crate and +# `polyengine-dioxus` (world `polymorph:dioxus/app`) can never contribute two +# `component-type` custom sections to one binary. +# +# `async-spawn` is not optional here: `wasi:http/types.response.new` hands the +# body's READ end to the host, which cannot read it until `handle` returns the +# response. Writing the body before that would park forever, so the render has +# to run in a task spawned to continue past `task.return`. +wasip3 = { version = "0.8", optional = true, features = ["async-spawn"] } + +[features] +serve = ["dep:wasip3"] + +[dev-dependencies] +# Native-only: `rsx!` for the golden-HTML tests. +dioxus = { version = "=0.7.10", default-features = false, features = [ + "macro", + "html", + "signals", + "hooks", +] } diff --git a/ssr/src/lib.rs b/ssr/src/lib.rs new file mode 100644 index 0000000..a195bb4 --- /dev/null +++ b/ssr/src/lib.rs @@ -0,0 +1,102 @@ +//! Server-side prerendering for the same components the client renderer drives. +//! +//! This is a second rendering path, not a second renderer: it hands the app's +//! root component to `dioxus-ssr`, which walks the built `VirtualDom` and +//! writes HTML. The mutation protocol in `wit/world.wit` is not involved, so +//! nothing here has to agree byte-for-byte with what `host/src/applier.ts` +//! produces in a browser — `applier.ts` sets `value`/`checked`/`selected` as +//! JS *properties*, which have no serialized form at all. dioxus-web lives +//! with the same split; hydration binds by markers, not by diffing output. +//! +//! 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` needs no +//! flags to execute it, which makes it the cheap end-to-end gate. +//! - **serve**: a `wasi:http/service` component behind the `serve` feature, +//! wired by [`launch_ssr!`]. See [`serve`] for why the render streams into +//! the response body rather than buffering a `String`. + +use std::fmt; +use std::io; + +use dioxus_core::{Element, VirtualDom}; +use dioxus_ssr::Renderer; + +#[cfg(all(feature = "serve", target_arch = "wasm32"))] +pub mod serve; + +/// Adapts a byte sink to the `std::fmt::Write` that `Renderer::render_to` +/// wants. +/// +/// The whole reason this exists is that `fmt::Error` is a unit type: the +/// underlying `io::Error` would be destroyed at the boundary, so it is stashed +/// here and recovered by [`render_to`]. Everything else about buffering is +/// `std::io::BufWriter`'s problem, one layer down. +struct FmtBridge { + inner: W, + err: Option, +} + +impl fmt::Write for FmtBridge { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.inner.write_all(s.as_bytes()).map_err(|err| { + self.err = Some(err); + fmt::Error + }) + } +} + +/// Renders `root` to HTML, writing it into `out`. +/// +/// `out` is written incrementally as the renderer walks the tree, and it is +/// flushed before this returns. Wrap it in a [`std::io::BufWriter`] when its +/// writes are expensive — for a component-model `stream` each one costs a +/// host context switch. +pub fn render_to(root: fn() -> Element, out: W) -> io::Result<()> { + let mut dom = VirtualDom::new(root); + dom.rebuild_in_place(); + + let mut sink = FmtBridge { + inner: out, + err: None, + }; + + match Renderer::new().render_to(&mut sink, &dom) { + // `BufWriter`'s own `Drop` flush swallows errors, so flush here where + // the result can still be reported. + Ok(()) => sink.inner.flush(), + // `FmtBridge` is the only source of `fmt::Error` on this path: the + // renderer's other writes are infallible `write!`s into it. + Err(fmt::Error) => Err(sink + .err + .take() + .unwrap_or_else(|| io::Error::other("render failed"))), + } +} + +/// Wires an app crate's root component up as a `wasi:http/service` component. +/// +/// Expands to a unit type implementing the generated `Guest` trait plus the +/// `export!` invocation, so the app crate never names `wasip3`. +#[cfg(all(feature = "serve", target_arch = "wasm32"))] +#[macro_export] +macro_rules! launch_ssr { + ($root:path) => { + #[doc(hidden)] + struct __PolyengineDioxusSsr; + + impl $crate::serve::wasip3::exports::http::handler::Guest for __PolyengineDioxusSsr { + async fn handle( + _request: $crate::serve::wasip3::http::types::Request, + ) -> ::core::result::Result< + $crate::serve::wasip3::http::types::Response, + $crate::serve::wasip3::http::types::ErrorCode, + > { + $crate::serve::respond($root).await + } + } + + $crate::serve::wasip3::http::service::export!(__PolyengineDioxusSsr); + }; +} diff --git a/ssr/src/serve.rs b/ssr/src/serve.rs new file mode 100644 index 0000000..eb71223 --- /dev/null +++ b/ssr/src/serve.rs @@ -0,0 +1,86 @@ +//! `wasi:http/service` wrapper: prerender straight into the response body. +//! +//! Two component-model facts shape this module. +//! +//! **The render must run after `handle` returns.** `response.new` takes the +//! body's *read* end and hands it to the host, which cannot read it until it +//! has the `Response` — so a write issued before returning parks forever. +//! (The same hazard `wit/world.wit` documents for `run`.) Hence +//! [`wasip3::spawn_local`], which wit-bindgen provides for exactly this: +//! continuing to execute after `task.return`, before the task exits. +//! +//! **The render itself may block.** `Renderer::render_to` is synchronous, so +//! the sink cannot `.await`; it calls `wit_bindgen::block_on`, which drives +//! the write on `waitable-set.wait`. That is legal here because wasmtime gates +//! blocking on `may_block = task.async_function || task.returned_or_cancelled()` +//! (`wasmtime/src/runtime/component/concurrent.rs`) and `wasi:http/handler.handle` +//! is an `async func` — both disjuncts hold. It is *not* legal from a +//! synchronous export, which is why this trick does not generalise. +//! +//! Blocking this way does not cost concurrency, which is worth recording +//! because it is not obvious: measured against `wasmtime serve` 47 at stock +//! settings (16 concurrent requests per instance), six rate-limited 341 KB +//! responses completed in the wall time of one, so a task parked in +//! `waitable-set.wait` does not hold off its instance-mates. No +//! `--max-instance-concurrent-reuse-count` tuning is needed. Sharing an +//! instance across requests is safe here for the separate reason that +//! [`respond`] keeps everything on the stack — no process globals, one +//! `VirtualDom` per request. + +use std::io; + +use dioxus_core::Element; +use wasip3::http::types::{ErrorCode, Fields, Response}; +use wasip3::wit_bindgen::{StreamWriter, block_on}; +use wasip3::{wit_future, wit_stream}; + +/// Re-exported so [`crate::launch_ssr!`] can name the generated bindings +/// without the app crate depending on `wasip3` itself. +pub use wasip3; + +/// `std::io::Write` over a component-model `stream`. +struct StreamSink(StreamWriter); + +impl io::Write for StreamSink { + fn write(&mut self, buf: &[u8]) -> io::Result { + // `write_all` hands back whatever it could not deliver; a non-empty + // remainder means the read end is gone, i.e. the client hung up. + if block_on(self.0.write_all(buf.to_vec())).is_empty() { + Ok(buf.len()) + } else { + Err(io::ErrorKind::BrokenPipe.into()) + } + } + + fn flush(&mut self) -> io::Result<()> { + // Every `write` above has already reached the host. + Ok(()) + } +} + +/// Answers a request by prerendering `root` into the response body. +/// +/// The request is ignored: routing, props and static assets are out of scope +/// for this artifact. +pub async fn respond(root: fn() -> Element) -> Result { + let headers = Fields::new(); + headers + .append("content-type", b"text/html; charset=utf-8") + .expect("fresh fields are mutable and this header is not oversized"); + + let (body_tx, body_rx) = wit_stream::new(); + let (trailers_tx, trailers_rx) = wit_future::new(|| Ok(None)); + let (response, _transmit) = Response::new(headers, Some(body_rx), trailers_rx); + // No trailers: dropping the writer resolves the future to the default + // above. + drop(trailers_tx); + + wasip3::spawn_local(async move { + // `StreamSink` is the only fallible part of the render, and its only + // failure is the client hanging up — nothing to report and nobody left + // to report it to. + let _ = crate::render_to(root, io::BufWriter::new(StreamSink(body_tx))); + }); + + Ok(response) +} diff --git a/ssr/tests/render.rs b/ssr/tests/render.rs new file mode 100644 index 0000000..8ee5b51 --- /dev/null +++ b/ssr/tests/render.rs @@ -0,0 +1,94 @@ +//! Gate 1: HTML correctness, natively. No wasm, no wasmtime, no ports. +//! +//! `Vec` is an `io::Write`, so these drive the exact same code path the +//! `serve` artifact does — only the sink differs. + +use std::io::{self, BufWriter, Write}; + +use dioxus::prelude::*; +use polyengine_dioxus_ssr::render_to; + +#[allow(non_snake_case)] +fn Page() -> Element { + let count = use_signal(|| 3i32); + rsx! { + div { class: "app", + h1 { "hello" } + p { class: if count() % 2 == 0 { "even" } else { "odd" }, "count is {count}" } + ul { + for n in 0..3 { + li { key: "{n}", "item-{n}" } + } + } + input { value: "draft", disabled: false } + } + } +} + +const EXPECTED: &str = concat!( + r#"
"#, + "

hello

", + r#"

count is 3

"#, + "
  • item-0
  • item-1
  • item-2
", + r#""#, + "
", +); + +fn render(root: fn() -> Element) -> String { + let mut out = Vec::new(); + render_to(root, &mut out).expect("render"); + String::from_utf8(out).expect("utf-8") +} + +#[test] +fn renders_a_component_tree_to_html() { + assert_eq!(render(Page), EXPECTED); +} + +/// A sink that accepts one byte per call, under a buffer far smaller than the +/// document: the `serve` path's writes are chunked by `BufWriter` and split by +/// stream backpressure, so byte-level fragmentation must not change the output. +#[test] +fn output_is_independent_of_chunking() { + #[derive(Debug)] + struct Dribble(Vec); + impl Write for Dribble { + fn write(&mut self, buf: &[u8]) -> io::Result { + match buf.first() { + Some(&b) => { + self.0.push(b); + Ok(1) + } + None => Ok(0), + } + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let mut sink = BufWriter::with_capacity(8, Dribble(Vec::new())); + render_to(Page, &mut sink).expect("render"); + let out = sink.into_inner().expect("flushed").0; + + assert_eq!(String::from_utf8(out).expect("utf-8"), EXPECTED); +} + +/// `fmt::Error` carries nothing, so a sink failure would vanish at the +/// boundary if `render_to` did not stash the real error. On the `serve` path +/// this is how a client hanging up mid-render is distinguished from success. +#[test] +fn sink_failure_surfaces_as_the_original_io_error() { + struct Broken; + impl Write for Broken { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let err = render_to(Page, Broken).expect_err("sink always fails"); + assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); +}