Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

README.md

compute — Rust that is compiled, not transpiled

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.

The Rust side

#[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 build fails if wasm/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 undefined you 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.

Client only, and why

There is no server half. solid-rs renders on the server inside an embedded QuickJS, and QuickJS implements no WebAssembly object at alltypeof 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.

The cost of await init()

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.

Run it

# 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:3000

solid-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.

wasm-bindgen versions

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.

Tests

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 test

The 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 test asserts — 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.js imports the glue by URL and inlines neither it nor the binary, that server.js carries the guard and no glue whatsoever, and that index.html prerendered with all four outputs unrun.