solid-rs is a transpiler. The Rust in src/ is read by syn, turned into JSX,
and thrown away; rustc never sees it, and the browser runs JavaScript. That is
the right trade for a UI and the wrong one for a sieve.
This example is the other door: wasm/analyze/, an ordinary wasm-bindgen crate
that rustc really compiles, reached from the app through a #[wasm("analyze")]
block.
src/ transpiled → JSX → Solid compiler → dist/main.js
wasm/analyze/ compiled → cargo → wasm-bindgen → dist/wasm/analyze/
Both halves are Rust. Only one of them is Rust in the sense of being type checked, borrow checked, and able to depend on crates.io.
#[wasm("analyze")]
extern "C" {
fn primes_below(n: i32) -> i32;
fn mandelbrot(width: i32, height: i32, max_iter: i32) -> i32;
fn checksum(text: String) -> String;
fn longest_word(text: String) -> String;
}
let count = primes_below(1000000);The same block shape as #[js("pkg")] for npm, pointed at a crate directory
instead of a package. As there, the signatures are documentation — nothing is
checked against the crate. What is checked:
solid-rs buildfails ifwasm/analyze/does not exist, and says so against the Rust you wrote rather than as a bundler resolve error. A near-miss gets a "did you mean" against the directories that are there.- Each name is imported individually, so declaring an export the crate does not
have is a build error rather than an
undefinedyou discover by calling it. - A
#[wasm(…)]name may not collide with an npm import, a component, a const, or a Solid API — the same app-global rule the npm form follows.
There is no server half. solid-rs renders on the server inside an embedded
QuickJS, and QuickJS implements no WebAssembly object at all — typeof WebAssembly there is "undefined". So the codegen emits two loaders per crate,
and the build pipeline's existing .client.jsx substitution picks between them:
| module | read by | contents |
|---|---|---|
__wasm_analyze.client.jsx |
the browser build | import init, { … } from "/wasm/analyze/analyze.js", then a top-level await init() |
__wasm_analyze.jsx |
the server build | the same names, as stubs that throw with the export's name and the fix |
Three consequences worth knowing:
- The server bundle contains no glue at all, not merely unused glue. The client build is never given the base module and the server build is never given the client one, so there is nothing for a bundler flag to get wrong.
- A page that uses wasm still prerenders. Importing a wasm export is fine
anywhere; only calling one during the server render is not. That is why this
example is
mode = "ssr"— it is the interesting thing to prove. - A
#[server]body calling wasm is a compile error, not a runtime one. A component may or may not reach the call, so a throwing stub is right there; a server function runs on the server and nowhere else, so the call is unreachable by construction and there is nothing to guard. It also has a better answer available: a server function is already Rust running natively.
The natural place for a wasm call is therefore an event handler, which only ever
runs in the browser — which is what every button in src/analyze.rs does, with
no guard written, because none is needed. For a call in a component body, use
is_server.
wasm-bindgen's --target web output does not instantiate itself: the default
export is an initialiser that must resolve before any export is callable.
Awaiting it once, at the top level of the generated loader, is what lets the
Rust call site stay an ordinary synchronous call.
Top-level await propagates, though: every module importing the loader waits for
the wasm to arrive, and so does the entry if the chain reaches it. For a large
module, reach for it from inside a lazy! component so the page paints first.
The glue is left external to the bundle. It finds analyze_bg.wasm relative
to its own import.meta.url, so bundling it would move it away from the binary
it loads — and esbuild cannot embed a .wasm anyway.
# from the repo root
cargo build -p solid-rs
cd examples/compute
solid-rs install # dev-dependencies (Tailwind, jsdom)
solid-rs build # cargo + wasm-bindgen + the JS pipeline
solid-rs preview # http://localhost:3000solid-rs build autodetects the crate: a wasm/<name>/Cargo.toml that exists
is an intent already expressed, and a second place to declare it would be a
second place to get it wrong. solid-rs wasm build compiles the crates alone,
and solid-rs wasm new <name> scaffolds one that builds on the first try.
The CLI and the crate's wasm-bindgen dependency must be the same version,
not merely compatible ones — they share a schema that changes between patch
releases. solid-rs checks this before invoking the CLI and prints both fixes:
the wasm-bindgen CLI is 0.2.108, but wasm/analyze resolved wasm-bindgen 0.2.127,
and the two must match exactly …
cargo install -f wasm-bindgen-cli --version 0.2.127
cargo update --manifest-path wasm/analyze/Cargo.toml -p wasm-bindgen --precise 0.2.108
This is why wasm/analyze/Cargo.lock is committed: the version that matters is
the one cargo resolved, so a wasm-bindgen = "0.2" that has worked for a year
breaks the first time the lockfile moves. The lock here is pinned to 0.2.108; if
your CLI is newer, run the second command above with your version.
The crate is ordinary Rust, so it is tested the ordinary way — on the host, with no wasm and no browser involved:
cd wasm/analyze && cargo testThe e2e is deliberately not a jsdom suite, unlike the other examples. jsdom
gives you a DOM, not a browser: the loader imports the glue by URL, which only
resolves against an HTTP origin, and the glue then locates its .wasm relative
to its own import.meta.url. A jsdom test would pass for reasons unrelated to
what is being tested. So each half is checked where it is real:
node e2e/compute.mjs- the wasm is instantiated and called in Node, where WebAssembly genuinely
exists, and its results are checked against the same values
cargo testasserts — the host test proves the Rust is right, this one proves the compile, the bindgen pass and the boundary crossing preserved it; - the wiring is checked by reading the build output: that
main.jsimports the glue by URL and inlines neither it nor the binary, thatserver.jscarries the guard and no glue whatsoever, and thatindex.htmlprerendered with all four outputs unrun.