Building for the web, with Rust and WebAssembly

WebAssembly (WASM) has been around for a few years now. It’s a binary format that runs in the browser, allowing programmers to write code in Rust (or Java, Go, C/C++), compile it into WebAssembly and then execute the code in the browser.

I’ve been wanting to build something in WebAssembly for a while and just recently it hit me that I have a thing: mktoc! mktoc is a table of contents generator written in Rust, it comes as a Binary and a Library and can be compiled into WebAssembly with zero effort!

Quick (Technical) Overview

  • The Rust code gets compiled for the target
    wasm32-unknown-unknown
  • wasm-pack is used to compile and bundle the generated WASM code
  • Some JavaScript and a static HTML page is used to build the Front-end
  • The web app is deployed on GitHub pages

Compiling to WASM

Compiling to WASM is surprisingly straight forward. Rust has a target for WASM, wasm32-unknown-unknown, and a nice build tool for generating the “glue code” around the WASM binary: wasm-pack

wasm-pack build --release --target web

This command builds the code as an optimised release with web as the target platform – other targets are bundler, nodejs, web, no-modules bundler being the default here.

A few things more are required to get the Rust code to compile to WASM:

  • [lib] must be set to crate-type = ["cdylib"]
  • wasm-bindgen must be added as a dependency

So the Cargo.toml looks something like this:

[...]

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

[dependencies]
mktoc = { path = "../" }
wasm-bindgen = "0.2.87"

The mktoc dependency is loaded relatively because I included the WASM code as a sub-directory in the mktoc repository.

With the dependencies and crate-type in place it’s finally time for some Rust code!

use mktoc;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn make_toc(content: &str) -> String {
    mktoc::add_toc(
        content.to_string(),
        mktoc::generate_toc(content.to_string(), mktoc::Config::default()),
    )
}
#[wasm_bindgen]
pub fn make_toc_only(content: &str) -> String {
    mktoc::generate_toc(content.to_string(), mktoc::Config::default())
}

Here we create two functions, make_toc and make_toc_only, which are attributed with #[wasm_bindgen], which tells the compiler to make these functions available through WebAssembly.

And that’s it. The Rust code in mktoc doesn’t need to be updated at all!

For compiling, wasm-pack is used:

wasm-pack build --release --target web

The files will be written to the pkg directory by default and can be loaded via JavaScript.

<script type="module">
import init, { make_toc, make_toc_only } from "./pkg/webtoc.js";

init().then(() => {
// wasm code is loaded
// make_toc is the function defined in Rust
make_toc(`
# Sample

<!-- BEGIN mktoc -->

<!-- END mktoc -->

This is an example document.

## Sample heading 2

\`\`\`rust 
pub fn hello(name: &str) -> String {
    format!("Hello, {}!", name)
}
\`\`\``);
});
</script>

A full working example can also be found on CodePen https://codepen.io/kevingimbel/pen/GRwGrQZ – the code uses a packaged and released version from NPM at https://npmjs.com/webtoc via unpkg.

Using the WASM code in a Web App

The same code is available as a “web app” at https://kevingimbel.github.io/webtoc/ – you can paste or load your own Markdown files. When “uploading” a file there’s no need to actually transmit it to a browser: It’s accessible via JavaScript without being processed by a backend!

markdown_file.addEventListener("change", readFile);
function readFile() {
    const [file] = markdown_file.files;
    const reader = new FileReader();

    let dot_split = file.name.split(".");
    let file_ext = dot_split[dot_split.length - 1].toLowerCase()

    if (file_ext !== "md" && file_ext !== "markdown") {
        triggerError("Not a markdown file. Please add .md or .markdown file");
        // reset the file input
        markdown_file.value = "";
        return;
    }

    reader.addEventListener("load", () => {
        markdown_input.value = "";
        // pass the file to make_toc
        generate_toc(reader.result);
    });

    if (file) {
        reader.readAsText(file);
    }
}

Here’s how it’s done:

  • Line 1: markdown_file identifier is the Input element.
  • Line 2: readFile will be called on “change”: The change event is triggered when a file has been selected via the input
  • Line 3: markdown_file.files contains an array of selected files – we only care about one, so we load it with const [file]
  • Line 6-7: dot_split and file_ext are used to get the file extension from the file name – we won’t process files which aren’t markdown.
  • Line 9-14: I’ve decided that markdown files must end in .md or .markdown, if they do not an error is thrown
  • Line 16-20: adds a load event on the reader we created. This triggers once the file has been loaded by the browser – this happens BEFORE any form is submitted! Here we reset the input value (= remove the file from the element), then pass the file content to the generate_toc function
  • Line 22-24:Here the file is read – this will trigger the load event defined in line 16-20.

This setup is incredibly fast. A fairly large Markdown file is processed in milliseconds!

Takeaways

  • Compiling Rust to WASM is surprisingly easy!
    Without adjustments I was able to bring my Rust library to the web (and NPM!)
  • WASM enables Rust code to be used “Full Stack”
    The mktoc code can be used to preview a Markdown file in the browser, and write it to disk via a backend
  • Rust is awesome
    Not a takeaway, but I love Rust!

Code

All code used in this blog post is available on Github:

Further reading