Autumn is server-first: the default output is a maud-rendered HTML page, and htmx covers most interactivity without a JS build step. Occasionally, though, you want something htmx and server rendering genuinely cannot do: a widget that runs heavy compute and animates every frame, entirely on the client, with no server round-trip in the loop. This recipe shows how to drop one self-contained client-side widget, compiled from Rust to WebAssembly with Yew, into an otherwise server-rendered maud page.
The motivating example is "literary boids" — a Reynolds flocking simulation where each glyph-agent becomes the last character of Autumn's own source code it eats.
Status: spike / escape hatch, not a framework subsystem. There is no
autumn generate island, noIslandCx, no build-pipeline integration. You wire the pieces by hand. That is deliberate. A complete, working copy of everything here lives inexamples/island-flock/+examples/flock/src/main.rs(theGET /route). The crate is a peer of theflockexample and builds its wasm intoexamples/flock/static/islands/.
Why a WASM island (and not htmx)
htmx is a swap engine: an event fires, the browser asks the server for a chunk of HTML, the server renders it, the fragment is swapped in. That model is a poor fit when the interaction is its own compute:
- Per-frame O(N²) work. Literary boids runs three neighbour passes (separation, alignment, cohesion) over every pair of boids, ~30 times a second. With a few hundred boids that is tens of thousands of distance calculations per frame. Round-tripping that to the server 30×/s is absurd; it belongs on the client, and Rust→wasm runs it at near-native speed.
- Continuous animation. The canvas repaints on
requestAnimationFrame. There is no "event → fragment" story here; there is a running loop that owns a pixel buffer. - Rich local interaction. Pause/resume and reseed mutate in-memory simulation state instantly, with no network in the path.
When you have all three — heavy per-frame compute + animation + local interaction — a wasm island is the right tool. For everything else, reach for htmx first.
The concept
The boundary between server and client is a plain DOM element plus serialized props — nothing more:
- maud owns the page. Your handler renders the full HTML, including an
empty mount element and a
<script type="module">that loads the island. - The island mounts into that element with Yew's client-side renderer
(
Renderer::with_root_and_props). It manages only the DOM inside it. - No SSR, no hydration. The server never renders the widget's markup, so
there is no hydration-mismatch class of bug, and Yew's experimental SSR
surface is never touched. Props travel as
data-*attributes.
Because the two crates compile for different targets and never share a
compilation unit, maud's html! macro and Yew's html! macro never
collide — a concern that turns out to be a non-issue in this architecture.
┌────────────────────────── server (native, autumn-web) ───────────────────────────┐
│ #[get("/")] async fn index() -> Markup { │
│ html! { div id="flock" data-autumn-island="flock" data-count="120" {} │
│ script type="module" src=(asset_url("islands/flock-boot.js")) defer;} │
│ } │
└───────────────────────────────────────────────────────────────────────────────────┘
│ HTML + empty mount div + module script
▼
┌────────────────────────── browser (wasm32, yew CSR) ─────────────────────────────┐
│ flock-boot.js → init() (instantiate .wasm) → mount(el, 120) │
│ yew::Renderer::<Flock>::with_root_and_props(el, props).render() │
│ Flock owns a <canvas>; a requestAnimationFrame loop ticks the World + repaints │
└───────────────────────────────────────────────────────────────────────────────────┘
Why the mount point is a
<div>, not a<canvas>. TheFlockcomponent owns its own<canvas>(via aNodeRef) and a control strip (buttons + readout). A<canvas>'s child nodes are unsupported-fallback content — browsers never render them when canvas is available — so mounting the component into a<canvas>would leave its canvas + controls invisible. The mount point is therefore a plain container<div>; the component renders the real canvas inside it.
Crate layout
The island is a separate crate that only ever builds for
wasm32-unknown-unknown. It links yew and cannot compile for the host, so it
must be kept out of the native workspace build:
- Give it its own
[workspace]table (so it is its own workspace root), and - add it to
excludein the repo-rootCargo.toml.
Together these guarantee cargo build/clippy --workspace never tries to build
it natively.
examples/island-flock/Cargo.toml:
[workspace] # own workspace root — not a member of the repo workspace
[package]
name = "autumn-island-flock"
version = "0.0.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib", "rlib"] # cdylib → the .wasm; rlib → unit-testable
[dependencies]
yew = { version = "0.21", default-features = false, features = ["csr"] } # CSR only
wasm-bindgen = "0.2"
js-sys = "0.3" # Math::random() — the sim's only source of randomness
gloo-render = "0.2" # leak-free requestAnimationFrame wrapper
[dependencies.web-sys]
version = "0.3"
features = [ # 2D canvas text animation + DOM access from mount()
"Window", "Document", "Element", "HtmlElement",
"HtmlCanvasElement", "CanvasRenderingContext2d",
]
[profile.release] # small artifact
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
Repo-root Cargo.toml:
[workspace]
exclude = ["fuzz", "examples/island-flock"]
The inlined simulation
The flocking mechanic (ported verbatim from a skunkworks literary-boids
experiment's boid.rs / world.rs) lives entirely inside the island crate, in
src/sim.rs. It is plain, dependency-light Rust — Vec2 math, a Dna genome,
a Boid, and a World that ticks the ecosystem — with exactly two translations
for the browser:
- randomness goes through
js_sys::Math::random()(norandcrate), and - colour is a CSS colour string (no
ratatui::Color), drawn onto a canvas.
Every tick, World::update() does the O(N²) flocking pass, feeds boids
(adopting the eaten character as their glyph), reproduces well-fed boids up to a
population cap, and removes starved ones. "Syntax physics" nudges a boid's genome
by what kind of character it just ate — punctuation makes it skittish, digits
make it march in step, vowels widen its view — which is what gives a flock of
code its lively, legible texture.
The embedded corpus (Autumn's own source)
The food supply is real Autumn source code, embedded so the island crate is
fully self-contained and does not depend on the framework's on-disk layout at
compile time. src/corpus.rs is a one-liner:
/// Static snapshot of Autumn's own source, used as the flock's food supply.
pub const AUTUMN_SOURCE: &str = include_str!("corpus.txt");
corpus.txt is a static snapshot — representative, punctuation-dense excerpts
stitched together from autumn/src/lib.rs, autumn/src/prelude.rs, and the
macro-heavy autumn-macros/src/route.rs (roughly #[get("/")], html! { … },
pub use crate::…, -> TokenStream). World::new filters out whitespace and
serves the remaining characters cyclically as food; a boid that eats one becomes
that character. Because it is a snapshot, it will not track edits to those files
— regenerate it if you want it fresh.
The component + mount entry (src/lib.rs, abridged)
#[function_component(Flock)]
pub fn flock(props: &FlockProps) -> Html {
let canvas_ref = use_node_ref();
// ... pause / reseed state ...
use_effect_with((/* reseed gen */, props.count), move |_| {
// resolve the <canvas>, get its 2D context, build World::new(AUTUMN_SOURCE, count),
// and drive a self-rescheduling gloo-render requestAnimationFrame loop that
// (every ~30ms) ticks the world and repaints. Cleanup cancels the frame.
});
html! {
<div class="flock-island">
<div class="flock-controls">/* Pause/Resume, Reseed, live readout */</div>
<canvas id="flock-canvas" ref={canvas_ref} width="840" height="560" />
</div>
}
}
#[wasm_bindgen]
pub fn mount(el: web_sys::Element, count: u32) {
yew::Renderer::<Flock>::with_root_and_props(el, FlockProps { count }).render();
}
Each frame clears to a dark background, then draws every food glyph in dim green
and every boid glyph in its DNA colour with ctx.fill_text(...). World
coordinates [0, 200]² map onto the 840×560 canvas by a per-axis scale, with the
Y axis flipped (canvas Y grows downward; the sim's grows upward).
Build commands
We use bare wasm-bindgen --target web rather than trunk. --target web
emits an ES module you can import directly from a <script type="module">,
which drops cleanly into a maud-owned page (trunk wants to own an index.html).
Install the toolchain once. Pin wasm-bindgen-cli to the exact
wasm-bindgen library version your crate resolves to — a mismatch produces
cryptic runtime errors:
rustup target add wasm32-unknown-unknown
# check the resolved lib version: grep -A1 'name = "wasm-bindgen"' Cargo.lock
cargo install wasm-bindgen-cli --version 0.2.126
# optional size pass:
cargo install wasm-opt # or install binaryen
Then run the build script (examples/island-flock/build-island.sh),
which wraps the three steps:
cd examples/island-flock && ./build-island.sh
# 1. cargo build --target wasm32-unknown-unknown --release
# 2. wasm-bindgen --target web --no-typescript --out-dir ../flock/static/islands <crate>.wasm
# 3. wasm-opt -Oz (optional, only if wasm-opt is on PATH)
The build script honors
CARGO_TARGET_DIR. If it is unset, cargo writes to<crate>/targetand the paths change accordingly.
wasm-bindgen --target web writes two files into static/islands/:
autumn_island_flock.js— the ES-module glue (default exportinit, plus your#[wasm_bindgen]exports likemount)autumn_island_flock_bg.wasm— the module
For the flock these are ~29 KB of JS glue and ~212 KB of wasm before wasm-opt
(Yew carries a virtual-DOM runtime; a Leptos CSR island would be smaller — see
Limitations).
The loader
An external ES module — no inline script, so it works under script-src 'self' with no nonce (examples/flock/static/islands/flock-boot.js):
import init, { mount } from './autumn_island_flock.js';
await init(); // fetch + instantiate the .wasm
const el = document.querySelector('[data-autumn-island="flock"]');
if (el) mount(el, Number(el.dataset.count ?? '120')); // props via data-*
The await init() step is the one the CSP's 'wasm-unsafe-eval' token
authorizes — it compiles/instantiates the WebAssembly module. Reference the
loader from maud with asset_url(...) so the release fingerprint is picked up
automatically:
script type="module" src=(asset_url("islands/flock-boot.js")) defer {}
Props via data attributes
The mount <div> carries the island name and its initial props as data-*
attributes; the loader reads them and passes them into mount:
div id="flock" data-autumn-island="flock" data-count="120" {}
data-autumn-island="flock" is the selector the loader keys on; data-count
becomes the initial boid population. For richer props, embed a
<script type="application/json"> block next to the div and parse it at mount
time instead of packing everything into attributes.
The custom CSP (the app's choice, not a framework flag)
Instantiating WebAssembly in the browser is CSP-gated: it requires
'wasm-unsafe-eval' in script-src. Autumn's default CSP is strict and
does not include it, and Autumn ships no wasm flag or primitive — there
is no framework switch that relaxes the policy for you. Emitting a wasm-friendly
policy is entirely the application's decision.
The mechanism is the existing content_security_policy field under
[security.headers] (see autumn::security::config::HeadersConfig). When you
set it to an explicit string, Autumn emits that string verbatim as the
Content-Security-Policy header (and, per
autumn::security::headers, an explicit policy automatically opts out of nonce
injection — you own the policy end to end).
The flock example sets its policy to the framework default plus the single
'wasm-unsafe-eval' token in script-src — every other directive is
byte-for-byte the default (autumn::security::config::default_content_security_policy):
# examples/flock/autumn.toml
[security.headers]
content_security_policy = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'"
You can confirm it at runtime:
curl -sD - -o /dev/null http://127.0.0.1:3000/ | grep -i content-security-policy
# content-security-policy: default-src 'self'; … script-src 'self' 'wasm-unsafe-eval'; …
Same-origin .wasm fetch and the JS glue are already allowed by 'self', and
.wasm is already mapped to application/wasm by Autumn's asset middleware —
no other CSP or content-type change is needed.
Security notes
'wasm-unsafe-eval'is not'unsafe-eval'. It permits WebAssembly compilation only; it does not re-enable JavaScripteval()/new Function(). It is a far narrower relaxation than'unsafe-eval', but it is still a relaxation — scope it to apps that actually ship an island, and keep every other directive strict (as above, only the one token is added).- An island is trusted first-party code, not a sandbox. It runs with the
page's full authority: same-origin
fetch, cookies,localStorage, the whole DOM. There is no privilege boundary between the island and the rest of the page. Treat island source exactly like the rest of your first-party app code. - This is not a way to run untrusted / tenant code. If you need to execute
code you do not control (a customer's plugin, user-authored scripts), a wasm
island on the same origin is the wrong tool — it inherits full page
authority. Tenant code needs real origin isolation: serve it from a
separate subdomain inside a sandboxed
<iframe>, so the browser's same-origin policy — not a CSP token — is the containment boundary.
Serving and caching the .wasm
Drop the wasm-bindgen output under the example's static/islands/. Autumn
serves static/ at /static/ out of the box, so no route wiring is required.
- Dev (
cargo run):asset_url("islands/flock-boot.js")returns/static/islands/flock-boot.jsverbatim — edits are visible immediately. - Release (
autumn build --release): the asset pipeline fingerprints and long-caches files it knows about (public, max-age=31536000, immutable). The.wasm/.jspair flows through the existing manifest unchanged. The one wrinkle: the loader imports the glue by a relative name (./autumn_island_flock.js), so keep the glue+wasm pair addressed the way wasm-bindgen emitted them, and letasset_urlfingerprint the entryflock-boot.js.
Add your own island (walkthrough)
cp -r examples/island-flock my-app/widgets/my-islandand rename the crate in itsCargo.toml.- Add
"my-app/widgets/my-island"toexcludein the repo-rootCargo.toml(it already has its own[workspace]table). - Write your component + a
#[wasm_bindgen] pub fn mount(el: web_sys::Element, /* props */). - Copy
build-island.sh, updateCRATE_NAME, and also pointOUT_DIRat your own app'sstatic/islands(e.g.OUT_DIR="../<their-app>/static/islands") — it defaults to the flock example's dir and is relative to the island crate dir. Then run it; the artifacts land in thatOUT_DIR. - Copy
flock-boot.js→my-island-boot.js; update the import filename and thedata-autumn-islandselector. - In a maud handler, render the empty mount div + the module script
(
asset_url("islands/my-island-boot.js")). - In
autumn.toml, set an explicitcontent_security_policy= default +'wasm-unsafe-eval'inscript-src(copy the flock example's string). cargo run, open the page, confirm the widget mounts.
Limitations & honest caveats
- This is a spike. No generator, no
cargo runhook that rebuilds the wasm on.rschange (Autumn's poll-based live-reload does not know about the island crate), no version-bump integration. You runbuild-island.shby hand. The committedautumn_island_flock.{js,wasm}artifacts mean the demo runs on a fresh checkout without a wasm toolchain; the trade-off is that they can drift from the source until you rebuild. Wiring the island rebuild into the dev-loop watcher is the main piece a productionized version would need. - The corpus is a static snapshot.
corpus.txtdoes not track edits to the Autumn files it was excerpted from; regenerate it by hand if you want it fresh. - Yew was chosen for the cleanest "mount into someone else's div" story
(
with_root_and_props) and because CSR needs only thecsrfeature. It also has the least momentum of the Rust UI frameworks; Leptos (CSRmount_to) is a higher-momentum alternative with a smaller per-island runtime if this ever graduates. Dioxus is a poor fit for this shape. - Share
serdeDTOs, not#[model]types. Autumn's#[model]derive emits unconditional diesel/Preloadableimpls that are native-only; they will not compile for wasm32. A plainserdestruct in a shared crate is the correct wire boundary.