Architecture Experiment: Moving a front-end to Rust and WASM - Part 1

Currently, the Designer is written in vanilla web languages.

That wasn't a conscious choice. Instead the current format of the editor is the consequences of a series of experiments on laying-out the desired interaction on a web page with modern browser features.

And my conclusion form those experiments is that the modern web is going in a pretty good direction!

CSS is getting great, and the DOM is quite expressive nowadays. Doing things in vanilla web tech has never been so easy... and I'll just leave JavaScript out of the conclusion.

I started those experiments expecting JavaScript to break at some point. As expected, it did, but in an unexpected way. Now that it is broken and I know how, it's time to fix things.

This is a multi-part article. Here at the first part we will get the problem description and a hello world wasm project working.

The second part has an attempted solution and the description of the problems found integrating Rust generated wasm with the DOM.

The third part has the conclusion and the final code.

By the way, WASM here is the acronym form Web Assembly.

Nowadays every browser comes with a builtin virtual machine that can execute compiled programs instead of JavaScript. The binary format of those programs is called Web Assembly.

Let's face the issue

OK, here is the problem, I've made about 10% of the way of my first release's TODO list, and most of the code modules and structure that will ever be there are already created. The next 90% of the work consists on changing that code.

JavaScript beared writing the code much better than expected. There are very good idioms one can adopt on modern JavaScript. There are some language inconsistencies and footguns, but they aren't a overwhelming problem.

It's not writing code that is the issue. The problem is that changing code is much harder than in most languages.

And by "much" I mean really much.

Everything points that if I insist on vanilla JavaScript, I will end with testing paralysis, where fixing broken tests will clearly become my dominant time sink.

So, I must use something else. And that something else avoid the need for most tests.

The one thing that will solve my problem is a good static and strict type system. And the obvious way to get that for the web is by migrating into Typescript.

Knwoing that Typescript would work, I don't need an experiment for it. Instead, I will try an uncertain option that is expected to eliminate the JavaScript annoyances (as much as they are less annoying than I expected, they are still annoying) and, since diagramming things is full of integer optimization problems, bring better CPU usage and native support for those integer values I'll optimize.

The experiment

Let's pick the simplest JavaScript module I have on the designer code. This creates a tabbed panel that one can assign into containers:


const disp = 'display';

export class Tab {
    constructor(name, panel){
	let tab = document.createElement('span');
	tab.innerText = name;
	this.tab = tab;
	tab.dataset.tabname = name;
	this.panel = panel;
	panel.dataset.tabname = name;
	this.lastFocus = null;
    }

    /// Assigns the tab to that panel
    assignContainer(container) {
	// Inserts the elements
	const tabs = document.getElementById(container.dataset.tabsid);
	tabs.append(this.tab);
	container.append(this.panel);

	// If no tab is selected, select this
	const d = Array.from(tabs.children).
		filter((x) => x.classList.contains(disp));
	if(d.length == 0) {
	    this.tab.classList.add(disp);
	    this.panel.classList.add(disp);
	}

	// Selection handling
	this.tab.onclick = ((e) => {
	    this.select();
	    this.lastFocus = null;
	});
    }

    // Selects this tab on the container that contains it
    select() {
	let sel = Array.from(this.tab.parentElement.children).
		filter(x => x.classList.contains(disp));
	if(sel[0]) {
	    this.lastFocus = sel[0].dataset.tabname;
	}
	Array.from(this.tab.parentElement.children).
		forEach((e) => e.classList.remove(disp));
	this.tab.classList.add(disp);
	Array.from(this.panel.parentElement.children).
		forEach((e) => e.classList.remove(disp));
	this.panel.classList.add(disp);
    }

    // Goes back one step on the focus stack
    unselect() {
	if(this.lastFocus) {
	    let o = this.lastFocus;
	    Array.from(this.tab.parentElement.children).
			forEach(
				(e) => 
				e.dataset.tabname != o? e.classList.remove(disp):
				e.classList.add(disp)
			);
	    Array.from(this.panel.parentElement.children).
			forEach(
				(e) => 
				e.dataset.tabname != o? e.classList.remove(disp):
				e.classList.add(disp)
			);
	    this.lastFocus = null;
	}
    }
}

This is a very small module, that makes almost no processing and heavily uses the DOM interface. Or, in other words, this is a low-risk module for experimenting that is also a worst case for wasm translation.

It could use some improvements too. Both some low-gain self-contained ones that won't break anything and some high-gain ones that have a good chance of impacting other modules. Doing any of them is out of the scope here.

What is in a WASM?

On the Rust front, things have evolved a lot since the early days of wasm.

Not only cross-compiling Rust got easier, there are complete abstraction layers one can use nowadays, and a lot of documentation. There is even an official book on the Rust site teaching how do wasm. It's not something that takes research anymore.

The documentation still isn't perfect. All of it suffers from the same lack of discoverability that is normal for Rust. But for sure, it looks complete.

So, let's get to it.

First steps, the site's tutorial uses a prebuilt template, so I'll take the important settings from it and get a working hello world application.

Creating the project...

$ cargo init designer

On the Cargo.toml, it uses the following:

[package]
name = "designer"
version = "0.1.0"
edition = "2021"

# Bellow this point, all comes directly from the WASM template
[lib]
crate-type = ["cdylib", "rlib"]

[features]
default = ["console_error_panic_hook"]

[dependencies]
wasm-bindgen = "0.2.63"

# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.6", optional = true }

# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
# compared to the default allocator's ~10K. It is slower than the default
# allocator, however.
#
# Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now.
wee_alloc = { version = "0.4.5", optional = true }

[dev-dependencies]
wasm-bindgen-test = "0.3.13"

[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

OK, if you have cross-compiled Rust before, it is surprising that there isn't any .cargo directory on the template. That's because the wasm is linked by a specialized tool.

That wasm-bindgen dependency does the FFI bindings for any JavaScript API into Rust and the Rust API into JavaScript...

Bingo! I got an entire row of acronyms!

Let expand them here on the case you never heard one or another:

  • FFI: Foreign Function Interface. That's how you call a function from one language in another language. A FFI bindings library has some native functions (here, Rust functions) that do nothing but call foreign functions (here, JavaScript ones).

  • API: Application Programming Interface. An interface that programs use, instead of people. Here, it's a set of functions.

Thus, that line means it's a library that allow you to run the relevant JavaScript functions in Rust or the Rust ones in JavaScript.

... And there's some functionality for displaying better error messages. Rust people do love their error messages (rightly so). Also there are settings that reduce the binary size, what is quite important on the web.

The template also has a file at src/util.rs with a function that you have to call to actually get the improved errors, and a hello world in src/lib.rs. I will just copy both verbatim.

For building, I'll need the wasm target:

$ rustup target add wasm32-unknown-unknown

And you can build for the correct architecture, link and pack everything on the same step:

$ cargo install wasm-pack # If you don't have it
$ wasm-pack build --target web

As that finished, it created a pkg directory inside the project that contains all the relevant files for npm to require it. And then comes a real bunch of magic.

The book and the tutorial instruct one into using npm and web-pack to use those files. But since the idea is getting ride of JavaScript annoyances, let's take a deeper look at them.

Looking at the generated files, one can see that most are about the npm project, Typescript interface, and other kinds of integration. The actually important ones are the wasm file (on my case designer_bg.wasm, no idea what that bg means) and the JavaScript loader (for me, designer.js).

The wasm is loaded as a module. And since it doesn't execute any code (only exports some), it must be imported by another module. On the interface, there is a default export that initializes the wasm code. So, to run the hello world, I will need a module like this:

import init_designer from "./designer.js";

let designer = await init_designer();
// Greet comes all the way form the lib.rs interface
designer.greet();

You can read about async-await at MDN.

The Rust primitives for concurrency are much harder to use wrongly, but async-await makes JavaScript about as usable even if error-prone.

There is actually a way to embedded code as executable on the loader, but it must be initialized anyway, so it can not be imported directly from the HTML.

Now I have an executable module that I can import into the HTML (considering it's named runwasm.js):

<!DOCTYPE html>
<script type="module" src="runwasm.js"></script>

But wait, I just loaded the HTML file, to import an executable JavaScript module, to import the wasm loader, to load the wasm. No wonder the docs go directly for web-pack, those are a lot of consecutive network accesses.

But I can also avoid the problem by prefetching everything, JavaScript tooling is not a hard requirement. For the designer it won't make any difference for a long time, because the first version is supposed to run from localhost, but it's a good thing to do anyway.

<!DOCTYPE html>
<head>
  <link rel="preload" href="/designer.js" as="script">
  <!-- That crossorigin is required, but not relevant here -->
  <link rel="preload" href="/designer_bg.wasm" as="fetch" crossorigin="anonymous">
</head>
<script type="module" src="runwasm.js"></script>

And well, that was easier than expected! Launch a local HTTP server where everything is located, like with:

$ touch favicon.ico # So we don't get 404 errors
$ python3 -m http.server

And it's alive!

Beware that the pkg directory is removed every time you build your project, so it you want to try it, it's better to move or link the files in a different one.

Next, on the second part there is some Rust that actually does something and problems that appeared.

Finally, on the third part there is the final version of the code.