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
alpha
beta
\ 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);
+}