loading...

Practical Rust Web Development - Front-End

werner profile image Werner Echezuría ・9 min read

In this post I'll show you how to create a front end application in Rust using wasm, I have to recognize it was not a happy road, there are a lot of drawbacks probably because it is too early for wasm in Rust. So, my recommendation is that you should wait before using it in production, specially documentation because there are a few things not too intuitive about it.

Frameworkless

The first thing anyone with experience in web development would try to do is investigate any framework that makes the job easier. There are a few to consider, however there are problems too that makes me want to do it frameworkless, as I said in the previous paragraph, the lack of updated documentation makes everything harder, too much changing and not having an stabilized library, specially for the frameworks available.

The good thing about going frameworkless is that I can understand how I should work with wasm_bindgen and learnt some of its drawbacks, that would help me in the future if I decided to use it in production.

If you can manage to use a framework, you should, it will be a better way to handle state and templates.

I'm sure making a framework implies a lot of work and the people behind them are working hard in them, however, I have a few issues with most of them.

Yew is a popular one, but the lack of a Router (integrated in the framework) and the use of an unofficial crate like stdweb makes me think twice before using it.

Seed seems pretty cool, use wasm_bindgen and have a Router, but for some reason I still don't understand the fetch API doesn't work.

Percy works on nightly, and I prefer stable Rust.

So, I decided to go frameworkless for my pet project, It's not a big deal but I guess there are more stable ways to have something in production for a SPA application and sadly Rust is not one of them for the moment.

However let's ignore all of that and say we are brave enough to use it for our project.

Basics

One tip that might help you is you never forget you're working with Javascript too, what does it mean? In few places like making an ajax request using fetch, you should return a Promise not a Future, then work with the promise as you would in Javascript, but in Rust. I'll show you an example later.

We're going to start with the basics to get it running.

We're going to add a webpack.config.js and a package.json files in the project.

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");

module.exports = {
    entry: './index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'index.js',
    },
    devServer: {
        historyApiFallback: true //This is important for our client Router
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: 'index.html'
        }),
        new WasmPackPlugin({
            crateDirectory: path.resolve(__dirname, ".")
        }),
        // Have this example work in Edge which doesn't ship `TextEncoder` or
        // `TextDecoder` at this time.
        new webpack.ProvidePlugin({
          TextDecoder: ['text-encoding', 'TextDecoder'],
          TextEncoder: ['text-encoding', 'TextEncoder']
        })
    ],
    mode: 'development'
};

package.json:

{
  "scripts": {
    "build": "webpack",
    "serve": "webpack-dev-server"
  },
  "devDependencies": {
    "@wasm-tool/wasm-pack-plugin": "0.4.2",
    "text-encoding": "^0.7.0",
    "html-webpack-plugin": "^3.2.0",
    "webpack": "^4.29.4",
    "webpack-cli": "^3.1.1",
    "webpack-dev-server": "^3.1.0"
  },
  "dependencies": {
    "bootstrap": "^4.3.1"
  }
}

And let's not forget our index page and index.js file:

index.html:

<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
    <link rel="stylesheet" href="node_modules/bootstrap/dist/css/bootstrap.min.css">
  </head>
  <title>My Store</title>
  <body>
    <div id="app"></div>
    <script src="node_modules/bootstrap/dist/js/bootstrap.min.js"></script>
  </body>
</html>

ìndex.js:

const rust = import('./pkg/front_raw_mystore');

rust.catch(console.error);

Finally, add an empty lib.rs file to the project, then we can run the project with the commands:

cargo build
npm install
npm run serve

If everything works we can have our server up and running.

Router

We're going to implement our client router too, to accomplish this we need to handle the state in the History and makes a little aggregation to our webpack configuration (remember we are working with Javascript too).

We'll begin with an empty cargo project and add the next crates in Cargo.toml:

[lib]
crate-type = ["cdylib"]

[dependencies]
futures = { version = "0.1.20", features = ["use_std"] }
wasm-bindgen = { version = "0.2.45", features = ["serde-serialize"]  }
js-sys = "0.3.22"
wasm-bindgen-futures = "0.3.22"
serde = { version = "1.0.80", features = ["derive"] }
serde_derive = "^1.0.59"
serde_json = "1"
console_error_panic_hook = "0.1.6"

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Headers',
  'Request',
  'RequestInit',
  'RequestMode',
  'Response',
  'Window',
  'Document',
  'Element',
  'HtmlElement',
  'HtmlInputElement',
  'HtmlButtonElement',
  'HtmlFormElement',
  'HtmlCollection',
  'MouseEvent',
  'Node',
  'History',
  'Event',
  'EventTarget',
  'ErrorEvent',
  'Location',
  'console'
]

[profile.release]
debug = true

Every time you need something from the DOM Api, probably you're going to need to add it in [dependencies.web-sys].

src/router.rs:

use wasm_bindgen::prelude::*;
use web_sys::{ History, Location };

pub struct Router {
    pub history: History,
    pub location: Location
}

impl Router {
    pub fn new() -> Self {
        let window = web_sys::window().expect("no global `window` exists");
        let history = window.history().expect("no history");
        let document = window.document().expect("should have a document on window");
        let location = document.location().unwrap();

        Router { history, location }
    }

    pub fn go_to(&self, url: &str, state: &JsValue) -> Result<(), JsValue> {
        self.history.push_state_with_url(state, 
            url, Some(&format!("{}/{}", self.location.origin().unwrap(), url)))
    }
}

We need to push to state every time the user change the url. Now we're going to add all the routes we're going to need for our application, let's add a folder with a label, components and add a file called routes.rs.

src/components/routes.rs:

use std::collections::HashMap;
use std::sync::Arc;
use wasm_bindgen::JsValue;
use crate::components::component::Component;
use crate::components;
use crate::app::App;

// In this struct we will have registered all our routes.
pub struct Routes(HashMap<String, Box<Component>>);

impl Routes {
    // Every time we need a new component, we register our route here.
    pub fn new(app: Arc<App>) -> Routes {
        let mut routes = Routes(HashMap::new());
        routes.0.insert("/dashboard".to_string(),
            Box::new(components::dashboard::Dashboard::new("dashboard".to_string(), app.clone())));
        routes.0.insert("/login".to_string(),
            Box::new(components::login::Login::new("login".to_string(), app.clone())));
        routes.0.insert("/register".to_string(),
            Box::new(components::register::Register::new("register".to_string(), app.clone())));
        routes.0.insert("/home".to_string(),
            Box::new(components::home::Home::new("home".to_string(), app.clone())));
        routes.0.insert("/".to_string(),
            Box::new(components::home::Home::new("home".to_string(), app.clone())));
        routes
    }

    pub fn go_to(&self, url: String, state: &JsValue) {
        self.0.get(&url).expect("Component not created").render(state);
    }

    pub fn load_components(&self, url: String, state: &JsValue ) {
        self.0.get(&url).expect("Component not created").load_components(state);
    }
}

Fetch API

We're going to need a way to send http requests to a server, we can use the Javascript Fetch API, however remember we're working with Javascript, so we need every function to be annotated with #[wasm_bindgen] and return a Promise.

src/fetch.rs:

use futures::Future;
use js_sys::Promise;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::future_to_promise;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use serde::Serialize;

// This is the url for the server
const BASE_URL: &str = "http://localhost:8088";

#[wasm_bindgen]
pub fn fetch_request(url: &str,
                     method: &str,
                     body: Option<String>) -> Promise {
    let mut opts = RequestInit::new();
    opts.method(method);
    opts.mode(RequestMode::Cors);
    if let Some(body_string) = body {
        let js_value = JsValue::from_str(&body_string);
        opts.body(Some(&js_value));
    }

    let request = Request::new_with_str_and_init(&format!("{}/{}", BASE_URL, url), &opts).unwrap();

    request
        .headers()
        .set("Content-Type", "application/json").unwrap();

    let window = web_sys::window().ok_or_else(|| JsValue::from_str("Could not get a window object")).unwrap();
    let request_promise = 
        window
            .fetch_with_request(&request);

    let future = JsFuture::from(request_promise)
        .and_then(|resp_value| {
            assert!(resp_value.is_instance_of::<Response>());
            let resp: Response = resp_value.dyn_into()?;
            resp.json()
        })
        .and_then(|json_value: Promise| {
            JsFuture::from(json_value)
        });

    future_to_promise(future)
}

#[wasm_bindgen]
pub fn post_request(url: &str, body: String) -> Promise {
    fetch_request(url, "POST", Some(body))
}

#[wasm_bindgen]
pub fn get_request(url: &str) -> Promise  {
    fetch_request(url, "GET", None)
}

#[wasm_bindgen]
pub fn delete_request(url: &str) -> Promise {
    fetch_request(url, "DELETE", None)
}

Components

We're going to implement the register component in this blog, the rest which is login, home page and dashboard will be available in the repository, I'll let the products page for later, however, once you can understand the basics you can go on with the products page if you want to.

We're going to need a trait that can abstract most functions required by components.

src/components/component.rs:

use std::sync::Arc;
use wasm_bindgen::JsValue;
use web_sys::{ HtmlInputElement, Document, Element };
use wasm_bindgen::JsCast;
use serde::{Deserialize, Serialize};
use crate::app::App;

#[derive(Debug, Serialize, Deserialize)]
pub struct FlashMessage {
    pub message: String
}

// Every component should implement these methods, except for render
// that will be the same for all components. 
pub trait Component {
    fn load_components(&self, data: &JsValue) -> Result<(), JsValue>;
    fn app(&self) -> Arc<App>;
    fn url(&self) -> String;
    fn render(&self, state: &JsValue) -> Result<(), JsValue> {
        self.app().div.set_inner_html("");
        self.load_components(state)?;
        self.app().go_to(&self.url(), state)
    }
}

// I'm using a struct to reduce boilerplate creating
// inputs and other things components might need, It's a 
// way to dry your code
pub struct InputComponent(pub Arc<Document>);

impl InputComponent {
    pub fn create_input(&self, id: &str, name: &str, ttype: &str, placeholder: &str) 
        -> Result<Element, JsValue> {
            let div = self.0.create_element("div")?;
            div.set_class_name("from-group");
            let input_element = self.0.create_element("input")?;
            input_element.set_id(id);
            let input = JsCast::dyn_ref::<HtmlInputElement>(&input_element)
                .ok_or(JsValue::from_str("Error casting input"))?;
            input.set_placeholder(placeholder);
            input.set_class_name("form-control");
            input.set_name(name);
            input.set_type(ttype);
            div.append_child(input);
            Ok(div)
    }

    pub fn value_by_id(&self, id: &str) -> String {
        let element = self.0.get_element_by_id(id).expect(&format!("No {}", id));
        JsCast::dyn_ref::<HtmlInputElement>(&element).expect("Error casting input").value()
    }
}

src/components/register.rs:

use std::sync::Arc;
use serde_json::json;
use wasm_bindgen::{ JsValue, JsCast };
use wasm_bindgen::closure::Closure;
use web_sys::{ HtmlButtonElement, EventTarget, ErrorEvent };
use serde::{Deserialize, Serialize};
use crate::app::App;
use crate::components::component::{ Component, InputComponent, FlashMessage };
use crate::fetch::post_request;
use crate::components;

#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterUser {
    pub email: String,
    pub company: String,
    pub password: String,
    pub password_confirmation: String
}

impl RegisterUser {
    pub fn new() -> Self {
        RegisterUser {
            email: "".to_string(),
            company: "".to_string(),
            password: "".to_string(),
            password_confirmation: "".to_string()
        }
    }
}

#[derive(Clone)]
pub struct Register {
    url: String,
    app: Arc<App>
}

impl Register {
    pub fn new(url: String, app: Arc<App>) -> Self {
        Register { url, app }
    }
}

impl Component for Register {
    fn app(&self) -> Arc<App> { self.app.clone() }

    fn url(&self) -> String { self.url.clone() }

    fn load_components(&self, data: &JsValue) -> Result<(), JsValue> {

        let main_div = self.app.document.create_element("div")?;
        main_div.set_class_name("container");
        let h2_title = self.app.document.create_element("h2")?;
        h2_title.set_text_content(Some("Register an User"));

        let form = self.app.document.create_element("form")?;

        let email_div = 
            InputComponent(self.app.document.clone())
                .create_input("email", "email", "text", "Email")?;

        let company_div = 
            InputComponent(self.app.document.clone())
                .create_input("company", "company", "text", "Company")?;

        let password_div = 
            InputComponent(self.app.document.clone())
                .create_input("password", "password", "password", "Password")?;

        let password_confirmation_div = 
            InputComponent(self.app.document.clone())
                .create_input("password_confirmation", "password_confirmation", "password", "Password Confirmation")?;

        let button_element = self.app.document.create_element("button")?;
        let button = JsCast::dyn_ref::<HtmlButtonElement>(&button_element)
            .ok_or(JsValue::from_str("Error casting input"))?;
        button.set_class_name("btn btn-primary");
        button.set_text_content(Some("Send"));
        button.set_type("Submit");

        form.append_child(&email_div)?;
        form.append_child(&company_div)?;
        form.append_child(&password_div)?;
        form.append_child(&password_confirmation_div)?;
        form.append_child(&button)?;

        main_div.append_child(&h2_title)?;
        main_div.append_child(&form)?;

        let button_et: EventTarget = button_element.into();

        let document = self.app.document.clone();
        // We need to access the app property from the struct
        // inside a closure, however we need to move everything we
        // need, the best way to do that is cloning through an Arc.
        // This way the cost of cloning is reduced. 
        let app_closure = self.app.clone();
        let form_closure = Arc::new(form);
        let handler = 
            Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
                event.prevent_default();
                event.stop_propagation();
                let register_user = RegisterUser{
                    email: InputComponent(document.clone()).value_by_id("email"),
                    company: InputComponent(document.clone()).value_by_id("company"),
                    password: InputComponent(document.clone()).value_by_id("password"),
                    password_confirmation: InputComponent(document.clone()).value_by_id("password_confirmation")
                };
                let serialized_register_user = json!(register_user).to_string();
                // Here we're cloning the app again because we're
                // gonna need it in another closure.
                let app_success_closure = app_closure.clone();
                let success_response = 
                    Closure::once(move |js_value: JsValue| {
                        let message = FlashMessage { message: "User Created".to_string() };
                        components::routes::Routes::new(app_success_closure)
                            .go_to("/home".to_string(), &JsValue::from_serde(&message).unwrap());
                    });
                let error_form_closure = form_closure.clone();
                let app_error_closure = app_closure.clone();
                let error_response = 
                    Closure::once(move |js_value: JsValue| {
                        let response: &ErrorEvent = js_value.as_ref().unchecked_ref();
                        let text = response.message();
                        let alert_error = app_error_closure.document.create_element("div")
                            .expect("Creating alert not possible");
                        alert_error.set_class_name("alert alert-danger");
                        alert_error.set_text_content(Some(&text));
                        error_form_closure.append_child(&alert_error);
                    });
                post_request("register", serialized_register_user)
                    .then(&success_response)
                    .catch(&error_response);
                error_response.forget();
                success_response.forget();
            }) as Box<dyn FnMut(_)>);

        button_et.add_event_listener_with_callback("click", handler.as_ref().unchecked_ref())?;

        handler.forget();

        self.app.div.append_child(&main_div)?;

        Ok(())
    }
}

As you can see in the previous code a framework with a proper template library can save a lot of work, I just hope we can have better options or more stable frameworks in the future.

You can take a look at full source code here.

Troubleshooting

Better errors in the browser

To have a better explanation of what is going on you can use console_error_panic_hook crate.

Error: closure invoked recursively or destroyed already

That means you're using a closure and you should add a forget method after using it, this is one of those things that gives me a little bit of anxiety, specially when you read in the documentation: this function will leak memory. It should be used sparingly to ensure the memory leak doesn't affect the program too much., however there is no other way for the closure to work.

Posted on by:

werner profile

Werner Echezuría

@werner

Ruby on Rails developer and Rust enthusiast.

Discussion

markdown guide
 

This is nice and clean, great example! Thanks.