Wasm All the Way - From Client to Server With Leptos, Rust and Spin
Ivan Towlson
leptos
rust
spin
full-stack
Leptos is an intriguing entry into the ranks of Rust Web frameworks. As well as providing snappy browser-side updates via client-side WebAssembly and fine-grained responses to reactive signals, it provides for super-convenient interaction with server services via “isomorphic” server functions, making remote API calls look as simple as Rust function calls. And it’s that server-side integration which makes it compelling to run in Spin. If you want to see what that looks like, or just want to groan at some really lame user interface design, read on.
Introducing Leptos
If you’ve written sites using React, Dioxus, or similar frameworks, a lot of Leptos’ front end will look familiar: components written as functions, angle bracket syntax, and so on. But that isn’t the only string to Leptos’ bow.
Leptos describes itself as a “full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces.” That might seem like a lot of five-dollar words, but the maintainers do a great job of explaining them. Roughly, Leptos embraces both the front end and the back end. As well as cleverly optimizing how the front end updates as data changes, Leptos enables you to write the touchpoints between the front and back ends in a way that looks the same on both ends.
Client-Side and Server-Side Rendering
There are two ways of building Leptos sites: client-side rendering (CSR) and server-side rendering (SSR).
In client-side rendering, the whole application, except for a tiny bootstrap, is built as a WebAssembly (Wasm) module that runs in the browser. This Wasm module creates the user interface, handles events, and updates the page as events happen or data changes. From the server’s point of view, a CSR site is just a bunch of static files - a HTML file, a Wasm file, some CSS and JavaScript. No matter how dynamic the application, once the server has delivered those files to the client, its work is done.
In server-side rendering, Leptos still runs a Wasm application in the browser to manage the user interface and interactivity, but you can also run server functions for the browser to call back to. Server functions are isomorphic, meaning they have the same ‘shape’ on the client and server. For example, suppose you need to persist the result of an interaction to a database. Of course, you could hand-craft an API and write code to call it. But Leptos lets you write a Rust function to do the save, but still call that function on the client. Thanks to some macro sorcery, the same function when built for the server turns into an API for writing to the database, and when built for the client turns into a call to that server API.
Leptos and Spin
It’s this second case that’s interesting from a Spin point of view. Sure, Spin can serve CSR apps, because they’re static files and Spin can serve static files like a boss. But in the SSR case, Spin not only serves the pages dynamically, but it runs those callback functions too - meaning your Leptos application can take advantage of built-in facilities like key-value storage or AI inferencing.
Let’s see what that looks like.
At the time of writing, Leptos-Spin integration is at an early, experimental stage. I’m going to show a simple application on the happy path. But right now there are a number of options and modes in Leptos that we just don’t support. See the Leptos-Spin integration GitHub repo for the current status.
Get Leptos
The easiest way to build a server-side Leptos application is the cargo-leptos
tool. Although this doesn’t yet build Spin servers, it does encapsulate all the stuff needed to build the front end. (Yes, we’re using the server-side build tool to build the client side.) Install it using cargo install cargo-leptos
.
Next up is the Spin template for a server-side rendered Leptos application. Install this using spin templates install --git https://github.com/fermyon/leptos-spin --upgrade
.
Finally, because Leptos builds a browser Wasm module, you’ll need the Rust browser Wasm target as well as the usual WASI one. Install this using rustup target add wasm32-unknown-unknown
.
Hello, World Traditional Counter Sample
The Spin template for server-side rendered applications is called leptos-ssr
. Run it as follows:
spin new -t leptos-ssr leptos-test --accept-defaults
cd leptos-test
If you’ve got this far, you’ve earned some instant gratification, so try running it:
spin up --build
If you see build errors, check that you have cargo-leptos
installed, and that you have both the wasm32-wasi
and wasm32-unknown-unknown
Rust targets. If you see runtime errors, check that nothing else is listening on port 3000.
For Leptos SSR applications, spin build
runs two commands. The syntax used for this works on Linux and Mac, but not on Windows. If you’re a Windows user, you’ll need to copy the two commands (separated by &&
) out of spin.toml
and run them separately by hand.
Otherwise, open the link to localhost:3000 in your browser, and you should see the traditional “hello world” of reactive Web frameworks:
Click the button a few times to see the counter increment… but as you do so, keep an eye on the Spin logs. You’ll see it printing messages, indicating that the button isn’t just updating the user interface, but also making requests back to the server:
Saving value 1
Saving value 2
Saving value 3
Inside the Application
Enough instant gratification! Back into the learning mines - I want to walk you through what code the template has generated and how it works. Here are the files the template created for us. As you can see, there’s a few more than a normal Rust Spin project:
.
├── Cargo.toml
├── README.md
├── spin.toml
├── src
│ ├── app.rs
│ ├── lib.rs
│ ├── main.rs
│ └── server.rs
└── style
└── main.scss
The two main files of interest are app.rs
and server.rs
. (lib.rs
is glue code, and main.rs
is only there to stop cargo leptos build
getting mad.)
server.rs
contains the actual Spin component. Like lib.rs
, this is mostly boilerplate, and you shouldn’t need to do too much to it. Its basic job is to register some stuff that Leptos can’t automatically register in a Wasm environment, after which it hands off to a library function to do the actual processing. The main thing you need to know about is this line:
crate::app::SaveCount::register_explicit().unwrap();
This relates to those server functions I mentioned earlier. The Leptos framework needs to know about all your server functions so it can map API routes to them. In native code, this happens automatically thanks to some deep Rust magic. Unfortunately, the library Leptos uses for this doesn’t work in Wasm, so we have to manually call register_explicit()
for each such function. This will be your main reason for ever going into server.rs
.
app.rs
is more interesting. This is where the application logic lives. (For larger applications, of course, you’ll end up with multiple files instead of a single app.rs
. Rust and Leptos don’t care - there’s nothing special about the file.)
The entry point for the application is the App
Leptos component. (The term ‘component’ is wildly overloaded. The whole application forms a single Spin component. That Spin component contains several Leptos components, which are functions with a specific signature.) App
sets up some context then hands off to a router:
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/leptos_test.css"/>
<Title text="Welcome to Leptos"/>
<Router>
<main>
<Routes>
<Route path="" view=HomePage/>
<Route path="/*any" view=NotFound/>
</Routes>
</main>
</Router>
}
}
We are closing in. HomePage
and NotFound
are also Leptos components found in the app.rs
file. Here is HomePage
, the one that actually renders the button that was so satisfying to click earlier:
#[component]
fn HomePage() -> impl IntoView {
let (count, set_count) = create_signal(0);
let on_click = move |_| {
set_count.update(|count| *count += 1);
spawn_local(async move {
save_count(count.get()).await.unwrap();
});
};
view! {
<h1>"Welcome to Leptos - served from Spin!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}
Reading from bottom to top, this function returns the HTML defined by the view!
macro. That HTML references a couple of things defined earlier in the function. In the button caption, count
is a signal, a gadget that contains a value and, well, signals when the contained value changes. This allows things watching the signal to respond to changes and update their user interfaces. The button’s click
event is wired up to on_click
, a closure which increases count
and calls save_count
with the new value. (There is some plumbing in there about spawn_local
; that’s there to make the async work right.)
The upshot of all this is what you saw earlier: a button which displays the current count, increments the count when clicked, and saves the updated count.
But wait. The count gets saved on the server. Seems like we need to dig into save_count
.
Server Functions and Isomorphism
on_click
is a button event handler, so it’s running in the browser. But saving the count happens on the server. How does save_count
get the data to the server?
#[server(SaveCount, "/api")]
pub async fn save_count(count: u32) -> Result<(), ServerFnError> {
println!("Saving value {count}");
let store = spin_sdk::key_value::Store::open_default()?;
store.set_json("leptos_test_count", &count).map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(())
}
Wait, that’s Spin SDK code! That can’t be running in the browser, yet there’s no network call there. Somehow, in the course of making the function call to save_count
, we’ve crossed from client to server.
This is the magic of isomorphism - functions which have the same shape on client and server. The #[server]
macro expands differently when built for the browser and the server. On the server, it keeps the content as written, the print statement and Spin SDK calls, and embeds them in an API layer to handle marshalling and unmarshalling HTTP data such as JSON. On the client, it ditches the body of the function, and replaces it with code that makes an HTTP request to… the very same API that the server expanded into. So when on_click
calls the browser build of save_count
, that calls the server build of save_count
, and the Spin key-value code runs. But the single source for the client and server ensures compile-time type checking on the client, on the server, and between the two.
How the Application Gets Built
There are two files we haven’t looked at yet. (Fine. There are four.) True to form, they too are a bit more complicated than a vanilla Spin application.
First up is spin.toml
:
[[trigger.http]]
route = "/..."
component = "leptos-test"
[component.leptos-test]
source = "target/wasm32-wasi/release/leptos_test.wasm"
allowed_outbound_hosts = []
key_value_stores = ["default"]
[component.leptos-test.build]
command = "cargo leptos build --release && LEPTOS_OUTPUT_NAME=leptos_test cargo build --lib --target wasm32-wasi --release --no-default-features --features ssr"
watch = ["src/**/*.rs", "Cargo.toml"]
[[trigger.http]]
route = "/pkg/..."
component = "pkg"
[component.pkg]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.1.0/spin_static_fs.wasm", digest = "sha256:96c76d9af86420b39eb6cd7be5550e3cb5d4cc4de572ce0fd1f6a29471536cb4" }
files = [{ source = "target/site/pkg", destination = "/" }]
Unlike the standard Rust template, we have two components, not one! The first is the normal “this program built as Wasm” which contains our custom application logic. But the second serves static files from the target/site/pkg
directory. What’s in there?
target/site/pkg/
├── leptos_test.css
├── leptos_test.js
└── leptos_test.wasm
Where did these come from? Why is there another leptos_test.wasm
?
Remember how Leptos applications get compiled to browser Wasm? Well, that’s still the case for server-side rendered apps. The initial pages may be rendered on the server, but all the updates, all the interactivity: that’s all Wasm. And that also explains the .js
file: the browser needs JavaScript to load the Wasm and to act as an intermediary for non-primitive data such as strings. cargo leptos build
creates those client-side files. (As a convenience, it also builds style/main.scss
file that I’ve been resolutely ignoring because front ends scare me.) Spin serves them at the well-known path /pkg
.
cargo leptos build
gets run as part of spin build
, but it’s not the only part. The command line in [component.leptos-test.build]
contains two commands, cargo leptos build
to generate the client-side files, and cargo build --target wasm32-wasi --features ssr
to generate the server-side application. Normally cargo leptos build
builds both client and server, but it doesn’t know about Spin yet, so at the time of writing we do these as two separate commands. The infrastructure which controls the different builds for client and server is expressed in the Cargo.toml
file via the csr
, hydrate
, and ssr
features - and in particular how those propagate to the Leptos crates.
At the time of writing, you’ll see Cargo.toml
having git
references to the Leptos crates, instead of crates.io
references. This is to pick up a fix that’s merged to main, but isn’t in a release yet.
What Next?
We’ve explored the Leptos starter application from soup to nuts, from the App
to the reactive signals to the isomorphic server function. Remember, you can try it yourself by installing the Leptos template and tools as mentioned above.
Just one thing remains. Fire up the application again and–
What the heck? The counter is zero again! But we were so sure that we saved it! We wrote code and everything!
Here’s the good news: we did save it. Here’s the bad news: we didn’t load it. However, this blog has gone on long enough already. In the next exciting installment, we’ll actually – gasp! – edit the starter files, and build something truly persistent.
Want to get your Leptos application in the cloud? There’s no easier way than Fermyon Cloud. Build your application then run spin cloud deploy
to be live in 60 seconds.