Porting a JavaScript App to WebAssembly with Rust (Part 1)

20. 12. 2019

Rust

TL;DR

We will demonstrate how to do a complete port of a web application from React+Redux written in JavaScript to WebAssembly (WASM) with Rust.

This is the first part of a blog post series.
Here are Part 2 and Part 3.

Motivation

Maintaining software that is written in a dynamically typed language like JavaScript is costly. Maintaining a JavaScript frontend that is built with React, NPM, WebPack and Babel is even more expensive. Frequently we have had situations where we just wanted to upgrade a single dependency or a WebPack plugin resulting in hours of fixing compatibility issues. Moreover due to the characteristics of dynamically typed languages you'll never really know if the upgrade of a library causes errors during runtime.

In the past we didn't really have an alternative to JavaScript/NPM. Of course there is the wonderful purely functional elm language that offers the possibility to write reliable web frontends. But once you want to leave the functional elm world (e.g. to interact with the JavaScript API) you'll have to spent a lot of time to build "bridges".

The rise of WebAssembly (WASM) is a great opportunity to combine the power of the JavaScript world with the compile-time guarantees and runtime performance of Rust.

Within the last two years the Rust community created over 10 web frameworks that could be used to build web frontends with WASM. Most of them are proof-of-concepts but a few are serious projects.

We want to do a reality check of how far we could get using Rust as a frontend language. To do so we chose Seed as one of the more mature frameworks.

Moreover we want to demonstrate a real-world usecase instead of implementing just one more TODO list app. This is why we're going to port the complete frontend of kartevonmorgen.org to Rust. The Karte von morgen is an open source project for mapping sustainable initiatives and organisations.

Let's get started :)

Step 1: Prepare

We assume that you're already familiar with Rust and its ecosystem. Nevertheless we try to make it as easy as possible for newcomers and JavaScript developers. If you're used to Rust you can probably skip sections like this one.

Install Rust

For most users rustup should work. Windows users can download the rustup installer.

Within this guide we use Ubuntu Linux.

First install some basic tools:

sudo apt-get install git curl build-essential pkg-config libssl-dev

Then run the following in your terminal:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Rust Installation

Now check if Rust is installed successfully:

rustc -V
rustc 1.40.0 (73528e339 2019-12-16)

Install wasm-pack

To be able to pack our web project for the web we need wasm-pack:

cargo install wasm-pack

Install cargo-watch

During the development you might want to trigger the compiler whenever a file changes. This is what cargo-watch is for.

cargo install cargo-watch

Install microserver

To locally serve your web application you can use your favorite webserver. In this guide we're using microserver

cargo install microserver

Step 2: Initialize a Seed project

Within our legacy JavaScript project we're going to create a basic Rust project based on the Seed framework.

git clone https://github.com/kartevonmorgen/kartevonmorgen
cd kartevonmorgen/
git checkout -b rust
cargo init --lib

Now we need to modify the Cargo.toml file. Beside the name we have to add the dependencies and tell Rust that this library is a cdylib crate.

[package]
name = "kartevonmorgen"

[dependencies]
seed = "0.5"
wasm-bindgen = "0.2"

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

Then we move the existing index.html to the project's root and modify it.

mv src/index.html .

Because we won't use WebPack and its plugins anymore we have to replace all templating parts that match the pattern <%= ... %>:

- <title><%= htmlWebpackPlugin.options.title %></title>
+ <title>Porting JS to Rust</title>

Additionally ensure that the document's charset is set to utf-8:

<meta charset="utf-8" />

Now we have to append our script that initializes the WASM module. Usually there is already a defined app container like

 <div id="app"></div>

In this case add the following script tag:

  <div id="app"></div>
+ <script type="module">
+   import init from '/pkg/kartevonmorgen.js';
+   init('/pkg/kartevonmorgen_bg.wasm').catch(console.error);
+ </script>

Note: Older browsers don't support ES modules (here we're using Firefox 71).

To check if everything is working as expected we'll start with a simple hello world app.

#[macro_use]
extern crate seed;
use seed::prelude::*;

#[derive(Default)]
struct Mdl {
    // TODO
}

#[derive(Clone)]
enum Msg {
    // TODO
}

fn update(_: Msg, _: &mut Mdl, _: &mut impl Orders<Msg>) {
    // TODO
}

fn view(_: &Mdl) -> impl View<Msg> {
    div![h1!["Hello Rust"],]
}

#[wasm_bindgen(start)]
pub fn render() {
    seed::App::builder(update, view).build_and_start();
}

Build the project with

wasm-pack build --target web

and serve it with

microserver

This is what you should see:

Hello Rust

Congratulations!

Step 3: Move existing code and clean up

Because we don't want to rewrite the frontend from scratch but port the existing code we first have to rename all JavaScript (.js) and JSX (.jsx) files to Rust (.rs) files. Instead of doing this manually you could write and run a little helper script:

#!/bin/bash
for f in `find src/ -type f \( -iname \*.js -o -iname \*.jsx \)`
do
  git mv `echo $f` `echo $f | sed -e "s/\.jsx\?/\.rs/g"`
done
./rename-js-and-jsx-to-rs.sh

You can also remove obsolete files like .eslintrc, package-lock.json etc. Within the package.json file we can find information that we can reuse or at least keep as a reminder.

Most metadata can be directly moved to Cargo.toml like the following fields:

  • name
  • version
  • description
  • repository
  • author
  • license
  • homepage

Other information like dependencies or scripts can't be reused. But instead of throwing them away we move them as comments into the Cargo.toml, marked as TODO. They will serve as a reminder later to decide which equivalent Rust libraries we might need as a replacement for those JS dependencies.

Here is an example how it could look like:

[dependencies]
seed = "0.5"
wasm-bindgen = "*"

### JS DEPENDENCIES ###

# TODO: "@fortawesome/react-fontawesome": "^0.1.3",
# TODO: "i18next": "^10.6.0",
# TODO: "leaflet": "^1.4.0",
# TODO: "normalize.css": "^8.0.1",
# TODO: "purecss": "^1.0.0",
# TODO: "react": "^16.8.2",
# TODO: "react-dom": "^16.8.2",
# TODO: "react-i18next": "^7.13.0",
# TODO: "react-leaflet": "^2.2.0",
# TODO: "react-redux": "^6.0.0",
# TODO: "redux": "^4.0.1",
# TODO: "redux-form": "^8.1.0",
# TODO: "redux-thunk": "^2.3.0",

When we find a matching Rust library we can replace the TODO with the actual dependency or otherwise drop it entirely if it is not required for the new Rust implementation, e.g. babel, webpack, etc.

In our case all the scripts were obsolete:

  • lint is now cargo fmt
  • test is now cargo test
  • watch-test is now cargo watch -xt
  • pack is now wasm-pack build --release --target web

Don't forget to update your README.md and CONTRIBUTING.md.

Step 4: Create modules

In the beginning we will keep the structure and names of the original JS app. Resist the temptation to rename the files and modules according to Rust's naming conventions, i.e. replacing CamelCase with snake_case. Eventually we will do this, but not now.

First comment out the entire contents of all source files so that we can safely import them without getting build errors.

#!/bin/bash
for f in `find src/ -type f -iname \*.rs`
  do
    awk -i inplace '{print "// TODO: " $0}' $f
    git add $f
done
./comment-out-rust-files.sh

Then we recursively define our modules. Within lib.rs we declare the top-level modules:

mod Actions;
mod GeoLocation;
mod Store;
mod WebAPI;
mod components;
mod constants;
mod i18n;
mod index;
mod rating;
mod reducers;
mod route;
mod util;
mod widgets;

In each folder we have to create a mod.rs file that lists all contained submodules.

As an example our src/components/mod.rs looks like this:

pub mod App;
pub mod EntryForm;
pub mod Flower;
pub mod LandingPage;
pub mod Map;
pub mod SearchBar;
pub mod SelectTags;
pub mod Sidebar;
pub mod pure;
pub mod stories;
pub mod styling;

And this is the resulting file structure:

$ tree src
src
├── Actions
   ├── client.rs
   ├── mod.rs
   └── server.rs
├── components
   ├── App.rs
   ├── EntryForm.rs
   ├── Flower
   │   ├── FlowerLeaf.rs
   │   ├── index.rs
   │   └── mod.rs
   ├── LandingPage.rs
   ├── Map.rs
   ├── mod.rs
   ├── pure
   │   ├── AddressLine.rs
   │   ├── BusinessCard.rs
   │   ├── CityList.rs
   │   ├── Contact.rs
   │   ├── EntryDetails.rs

Summary and next steps

We have created a basic Seed web application within an existing JavaScript project. We moved existing JavaScript code to commented-out Rust files. We defined Rust modules that initially mirror the legacy JavaScript application structure.

In the next series we'll see how to translate the legacy JavaScript code to working Rust code.

Tags: rust, react, javascript, frontend, wasm