At this point, I've written over 5,000 lines of Leptos code. Leptos being the Rust framework that I'm using to build my web UI using Wasm.
And this is what it's like to build a real-world Leptos application.
Let's start off easy with styling. Styling in the Rust Adventure admin application is accomplished using Tailwind.
@tailwind base;
@tailwind components;
@tailwind utilities;
module.exports = {
content: {
files: ["*.html", "./src/**/*.rs"],
},
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};
I don't need a package.json
, although I do have one. And I'm using NPX to execute the Tailwind CLI. Any CSS solution will work here.
{
"name": "rust-adventure-wasm",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@mux/mux-player": "^1.10.1",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"tailwindcss": "^3.3.2"
}
}
I'm using Just as a command runner, much like NPM run or a Makefile or similar.
watch-tailwind:
npx tailwindcss -i ./input.css -o ./style/output.css --watch
So in this case, I run just watch-tailwind
' and that watches my Tailwind input file and just dumps the output file into style output.css, which brings us to cargo-leptos.
cargo-leptos
Cargo Leptos is a Cargo subcommand that abstracts Cargo to build the front end and server applications for your Leptos application.
The server in this case, for me, is running locally because this is an admin application. It's not really meant to be deployed anywhere. And the client is bundled as wasm and delivered to your browser.
To compile both of these applications, you use cargo leptos watch
.
I'm using OP run no masking because I'm using one password to store all my environment variables because I can just show you kind of like the secrets that I have here.
op run --no-masking -- cargo leptos watch
In this case, this is the location of a one password ID that stores the database URL. And in this way, basically, I can show what's in my .envrc
and I'm using direnv
to get those environment variables into my terminal. I don't need to worry about exposing service credentials when I'm recording videos or writing blog posts like this one.
export RUST_LOG=info
export DATABASE_URL="op://rust-adventure-service-credentials/crab-rave-001/database-url"
Which means that op run --no-masking
allows me to take those environment variables that are IDs to the one password and then ask one password to unmask them or unencrypt them for me.
After running cargo leptos watch
it's now listening on port 3002 on 127.0.0.1, so localhost.
The application
This is the application. It's not terribly interesting as an application goes.
I've got a side navigation so I can click on the collections here.
I've got some data here. This is the new infrastructure for Rust Adventure. So not all the data is here yet, but I can find a workshop, say 2048.
I can find the versions of that workshop. So in this case, this is the Bevy 0.10 version. So I'll click on that and it'll load in. And now I'm looking at 2048 with Bevy ECS with the sort of thumbnail that you see if you see the collections page and a list of all the lessons in order that I can then go in and edit.
So for example, I can click on one of these and it'll load. And on the right-hand side, we see a bunch of metadata playback ID description for the lesson, some other details, the display name for the lesson, which is what people see. There's diffs for every lesson. So the change in code that happens between one lesson and another, the amount of time it takes for the video to run so that people know how long a video takes and things like that. And then this kind of poorly formatted like the lesson contents, which is entirely markdown, which is kind of like the written portion of the lesson. So if you want to watch the video, you watch the video. If you want to read, you can read. And this is the admin application. So you can see that there's different displays. So there's diff, there's video.
And that's all this is. The application itself isn't terribly difficult, but it is quite a bit of HTML and CSS web UI. The UIs are fairly standard.
The actions I need to take for these forms is clicking save and the request goes and it saves. And you're done.
But it is important to show so that you understand what's going on when I show you the code. Let's get rid of the terminal.
I'm using some main commit for Leptos, which is kind of what I often use. I don't use the released versions. I use the whatever's in the main branch, but I don't want that to change from day to day. So I end up using the built-in ability for cargo to use a GitHub URL revision.
And then there's a bunch of dependencies. A lot of these are labeled optional
because some of them only need to be compiled for say the server, for example. You can see sqlx here is labeled as optional because we don't want sqlx to be compiled to WASM. It's not something we're going to be using from inside the browser.
sqlx = { version = "0.6.3", features = [
"mysql",
"runtime-tokio-rustls",
"time",
"json",
], optional = true }
When we enable different features such as hydrate
, which would mean the client or ssr
, which would mean the server, we can specify which dependencies we actually want to include in that binary. axum, tokio, sqlx, all things that run on the server and the things that run on the client are kind of like Leptos hydrate different features that Leptos offers.
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tokio",
"dep:tower",
"dep:tower-http",
"dep:leptos_axum",
"dep:sqlx",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:tracing",
]
Now, because we use cargo-leptos, there's quite a bit of configuration here for different kinds of things. I'm not going to go over them all. You can go read the comments if you want to.
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "rust-adventure-admin"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/output.css"
...
Server Functions
Let's get on with server functions because I think those are the most interesting part for the admin application itself because the admin application is basically here's a bunch of CRUD operations that you can do to update the data in the database without having to open a MySQL shell.
Starting with what it looks like to use one a server function.
#[component]
fn LessonForm(cx: Scope, lesson: Lesson) -> impl IntoView {
let update_lesson = create_server_action::<UpdateLesson>(cx);
let (lesson_id_clone, _) = create_signal(cx, lesson.id);
view! {cx,
<ActionForm action=update_lesson>
<input
id="lesson_id"
name="lesson_id"
type="hidden"
value=lesson_id_clone.clone()
/>
...
}
}
In this case, we're using a server Action
that calls an UpdateLesson
server function or creates a server action using the update lesson server function. And then we use ActionForm
with that action to actually let it work.
What this does is allows us to build a regular form with all of your typical HTML names and IDs and whatever that will get serialized and sent to the server whenever we submit this form.
So the key things here are we're using ActionForm
with some server action. We can use server functions to create a server action that we use as that form action. And that form action is then like all the serialization of this form data is handled for us, which is really nice in the context of an admin app.
update_lesson
here takes quite a few arguments.
Now, one of the things you're supposed to be able to do is co-locate these server functions with the code that's calling them. I haven't fully come around to that yet, and I'll tell you why that is in a second.
So I've got this lessons.rs
file, which has a bunch of the server functions that I'm using in different places. I've got data structures for the data as it exists in the database.
#[cfg(feature = "ssr")]
#[derive(Serialize, Debug)]
pub struct SqlLesson {
pub id: RaId,
pub created_at: time::OffsetDateTime,
pub updated_at: time::OffsetDateTime,
pub workshop: RaId,
pub slug: String,
...
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Lesson {
pub id: String,
pub created_at: String,
pub updated_at: String,
pub workshop_id: String,
pub slug: String,
...
}
I've got data structures that represent the serialized in browser version of the data structure. And then I have an From
implementation that takes the database structure and converts it into what I want to use in the browser.
Normally you wouldn't need to do this because you would just be serializing it to JSON. But in this case, what we're doing is actually handling the data on the server as the SQL data structure. And then we're passing it to the client, which is also written in Rust.
So what we actually want here is a data structure that we can use and manipulate on the client as well as a data structure that we can use and manipulate on the server. And those are going to have different feature sets, basically.
For example, on the server, I use this RaId
type, which is a ksuid. And on the client, we treat those as just strings.
So back to our update lesson server function, we use the server macro to create a server function.
#[server(UpdateLesson, "/api")]
pub async fn update_lesson(
cx: Scope,
lesson_id: String,
playback_id: Option<String>,
description: String,
display_name: String,
slug: String,
diff_link: Option<String>,
) -> Result<(), ServerFnError> {
use ksuid::Ksuid;
use sqlx::{MySql, Pool};
let lesson_id =
RaId::from_base62(&lesson_id).map_err(|e| {
ServerFnError::ServerError(
"poorly formatted ksuid".to_string(),
)
})?;
let Some(pool) = use_context::<Pool<MySql>>(cx) else {
return Err(ServerFnError::ServerError("no mysql pool available".to_string()));
};
let mut conn = pool.acquire().await.map_err(|e| {
ServerFnError::ServerError(e.to_string())
})?;
sqlx::query_file!(
"src/sql/lesson_update.sql",
playback_id,
description,
display_name,
slug,
diff_link,
lesson_id
)
.execute(&mut conn)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
.rows_affected();
Ok(())
}
This server function happens to hit a URL at /api
. And the name of the function is something like update_lesson
and then a hash.
So this macro takes this async function and turns it into something you can call on the client as well as something you can call on the server. The body of this function will only ever run on the server.
We can pass the same arguments to the function on the server or on the client, which makes it kind of easier to use whenever we're going to use it in any given place. So this will do the compilation for the server version of this and the client version of this and allows us to use it as if we're just calling like update_lesson()
.
All of these fields here are fields in the action form that we looked at earlier. So we didn't have to write any extra code to serialize this data. It just happened for us because Leptos is dealing with pulling the information out of the form, serializing it and sending it to us.
So, for example, we get a lesson_id
, which is a String
, which we need to turn back into an RaId
to interact with sqlx.
We need to get a connection to, for example, our database. In this case, I'm pulling the MySQL pool from context, getting a connection from that pool and then using sqlx with the SQL file and whatever got passed in to update that lesson.
And this is the way that the entire admin app has been built.
So these action forms and server functions have been such an incredible productive for me, honestly, and this actually works really well.
Registering Server Functions
Now, there's two really important things to note. One of which is that when we declare this server function, the code generation happens here and we need to tell the Leptos application on the server about this somehow.
And this gets into why I don't necessarily co-locate these with the component code that's using them, because we need to run this sort of register function, right?
So somewhere in our main server, I'm using Axum so when I'm booting my Axum server, we do things like create the connection pool and then we need to register our server functions. And then we can deal with the application setup.
But if we go to the definition of this function, you'll see that for all of the server functions I've created for collections, for workshops, for lessons, for the contents of lessons, for articles, etc. You need to register those and theoretically, all those could fail. So you have to unwrap them or you have to use the result, really. So you end up with this like giant swath of server functions that you need to register. And it's just easier for me to not have these scattered all across my application when I am dealing with this file.
#[cfg(feature = "ssr")]
pub fn register_sql_server_fns() {
collections::FetchAll::register().unwrap();
collections::FetchCollection::register().unwrap();
workshop_version::FetchAllForCollection::register()
.unwrap();
workshop_version::FetchVersion::register().unwrap();
workshop_version::CreateVersion::register().unwrap();
workshop_version::UpdateVersion::register().unwrap();
lessons::FetchAllForVersion::register().unwrap();
...
}
If you don't register them, the server functions will fail at runtime when you try to use it in your client code. So for me, it's a lot easier to have this extra file. So you can see all of my server functions are defined in these modules. And any time that I am working in the SQL folder, any time I create one of these server functions, I go back into the SQL file and I register this function.
I'm not super happy about needing to do this registration manually, at least. And I hope that at some point in the future, this will get automated, but it's also not too bad.
One thing that you will find out is that if you register these server functions and you do a new deployment, but let's say somebody hasn't hit refresh yet and you've redeployed the URLs for these. The URLs for these server functions are something like fetch_lesson3290572356
.
But this number will change with every like change to a server function or every deployment. So what you end up with is if somebody hasn't refreshed and loaded the new WASM code, then this URL won't exist in the new deployment.
Not a huge deal for me with this admin app. A big of a bigger deal in the production Rust Adventure site, but overall not something I'm terribly worried about at the moment.
The other really important thing for server functions is that we do need to provide, say, the MySQL pool as context. And to do that, we need to use these wrapper functions. Here I've got a function called server function handler with these are kind of scattered all across my application right now. These only compile this item if it's the server. So this is how you can make sure that something isn't being compiled into the client.
We get the extension that contains the MySQL pool and then we have to call this handle server functions with context. And that's where we provide the MySQL pool as context to the leptos application. So this is what allows us to go into these server functions and use context on this MySQL pool and get the actual MySQL pool so that we can get a connection to the database.
#[cfg(feature = "ssr")]
async fn server_fn_handler(
Extension(pool): Extension<Pool<MySql>>,
path: Path<String>,
headers: HeaderMap,
raw_query: RawQuery,
request: Request<AxumBody>,
) -> impl IntoResponse {
handle_server_fns_with_context(
path,
headers,
raw_query,
move |cx| {
provide_context(cx, pool.clone());
},
request,
)
.await
}
#[cfg(feature = "ssr")]
async fn leptos_routes_handler(
Extension(pool): Extension<Pool<MySql>>,
State(options): State<Arc<LeptosOptions>>,
req: Request<AxumBody>,
) -> Response {
let handler =
leptos_axum::render_app_to_stream_with_context(
(*options).clone(),
move |cx| {
provide_context(cx, pool.clone());
},
|cx| view! { cx, <App/> },
);
handler(req).await.into_response()
}
And that's pretty much it. It's a lot of using this component
macro, writing a function, using scope, taking some props, creating server actions to interact with forms, or just rendering things out.
#[component]
fn WorkshopHeader(
cx: Scope,
collection: Collection,
version: WorkshopVersion,
) -> impl IntoView {
view! {cx,
<div class="overflow-hidden bg-gray-900 py-10 border border-gray-800">
<div class="mx-auto max-w-7xl px-3 lg:px-4">
<div class="mx-auto grid max-w-2xl grid-cols-1 gap-x-8 gap-y-16 sm:gap-y-20 lg:mx-0 lg:max-w-none lg:grid-cols-2">
<div class="lg:pr-8 lg:pt-4">
<div class="lg:max-w-lg">
<h2 class="text-base font-semibold leading-7 text-orange-400">"Workshop Version"</h2>
<p class="mt-2 text-3xl font-bold tracking-tight text-white sm:text-4xl">{version.display_name}</p>
<p class="mt-6 text-lg leading-8 text-gray-300">{version.description}</p>
</div>
</div>
<img src=collection.thumbnail alt="opengraph image" class="w-[48rem] max-w-none rounded-xl shadow-xl ring-1 ring-white/10 sm:w-[57rem] md:-ml-4 lg:-ml-0" width="2432" height="1442"/>
</div>
</div>
</div>
}
}
So for example, we've got this workshop header component and it takes a scope because every component takes a scope and then we've got a collection or a version for props. Components all return types that implement IntoView
.
These HTML blocks aren't auto-formatable yet. I think there's a project inside of the leptos organization that is trying to work on that, but I don't have it currently working.
So you end up with say image source equals collection thumbnail. You don't need braces around these attributes, but you do need braces around the content of these tags or the children of these tags. But other than that, it's basically just writing HTML, passing in these props or passing in the data that you want and the locations that you need it. And it's just like writing any other HTML UI.
Resources
Loading is also interesting. We talked about server functions, but let's talk a little bit about Resources.
#[component]
pub fn Lessons(cx: Scope) -> impl IntoView {
let (edit_lesson_id, set_edit_lesson_id) =
create_signal::<Option<String>>(cx, None);
let params = use_params_map(cx);
let resources = create_resource(
cx,
move || {
(
params()
.get("slug")
.cloned()
.unwrap_or_default(),
params()
.get("version")
.cloned()
.unwrap_or_default(),
)
},
move |(slug, version)| async move {
futures::join!(
collections::fetch_collection(
cx,
slug.clone()
),
workshop_version::fetch_version(
cx,
slug.clone(),
version.clone(),
),
lessons::fetch_all_for_version(
cx,
slug.clone(),
version.clone(),
)
)
},
);
view! {
cx,
<div>
<Suspense fallback=move || view! { cx, <p>"Loading..."</p> }>
{move || match resources.read(cx) {
None => None,
Some((Ok(collection), Ok(version), _)) => {
Some(view! { cx,
<>
<WorkshopHeader collection=collection version=version/>
</>
})
}
Some(_) => Some(view! { cx,
<>
<div class="px-4 sm:px-6 lg:px-8">"Failed to Load"</div>
</>
}),
}}
</Suspense>
...
So here on the lessons page, for example, we've got a component that takes the scope and returns some view. I'm creating a signal here to determine whether we should be editing a lesson or not.
So we create a signal with an option string starting out as None
. So None
is selected. And if there's a Some
, then there is a lesson selected. The leptos_router
crate allows us to use a params map.
<Router>
...
<Routes>
<Route path="collections/:slug/version/:version" view=|cx| view! { cx, <Lessons/> }/>
...
</Routes>
...
</Router>
So for Lessons
here, if we go into our app, there's a Router
as you would probably expect. This is pretty typical these days. And then we can see the lessons component being used here for the route collections/:slug/version/:version
. And these two slugs are going to be the names of the params in the path that we can access.
So inside of this lessons component, we can use params map and then we can params.get
with that name, clone it and unwrap it or default it. In this case, that would mean that we get a empty string.
But that brings us into talking about Resources.
Resources are how I've been using server functions to fetch any data from the database whenever the page loads or a specific component loads.
let resources = create_resource(
cx,
move || {...},
move |_| {...},
);
We create a resource with the current scope. The first closure tells us when to refetch this basically. So in this case, whenever slug or version change in this case, they don't change for the component because we're on a URL and whatever. But we get slug inversion as arguments to the second function or second closure. And I'm using the futures crate to join a couple of these server functions together. So we've got fetch collection, fetch version and fetch all for version, which are all server functions and they each take the slug. Some of them take the version and this will give us back a tuple. So a three tuple of the results. The really interesting thing that we can do with Resources. So this is let resources equals create resource is we can use these suspense components to automatically handle rendering a fallback or rendering data. So in this case, we've got suspense. We've got fallback, which is just loading for now and this resources.read is a reactive read. So this will update any time resources changes. In this case, if there's no resource, then I'm not rendering anything. If there's some resource, then we have that three tuple in here. So collection version and whatever the third one was, I think it was lessons and we just use a regular match statement to return some view views. Always take the scope and we've got workshop header here as the component and a div in this other one. So the match arms have to match, which is why I'm using these fragments. So the fragments get the types to match and then workshop headers, the component that we saw earlier. It takes collection and version as props and that's it. So the resource will handle kind of dealing with checking to see if something is loaded or not render a fallback if it's not. And if we have our data, then we can render whatever we want. So in this case, I've got to I've got the workshop header kind of suspense component and the lessons suspense component. And if I click through here again after I click on the version, you'll see two loading indicators in the top left, which are both of those loading indicators for the suspense components and then everything renders in. In this case, all of the data is being fetched at the same time. So it's not like they come in at different times or anything, but they could I could have different resources for each of the data. So overall very productive experience. I will say that if we look at the app, the style sheet has an idea of leptos here. So the compiled CSS file is being hot loaded. Actually, the entire page here is being reloaded anytime I update. So I don't need to worry about coming in here and doing manual refreshes or whatnot. I can change whatever I want and it'll just show up.
So overall a pretty productive experience. This is what it's like to work with Leptos. I hope you enjoyed this short dive into this admin app that I'm building.
Have a great rest of your day.