2023-12-27

Adding Rust And WebAssembly To A Web App Hosted On GitHub Pages

Scope And Purpose Of This Post

These are some after-the-fact notes for my journey of adding WebAssembly (aka "wasm") to my ktcalc web app hosted on GitHub (source, app).

The ktcalc web app uses React and is written in TypeScript and now some Rust too.  The Rust is compiled to wasm.
 

Getting New Tools

You will want to install a rust toolchain, so probably rustup and do a `rustup target add wasm32-unknown-unknown` to add the needed target for web.  Also install wasm-pack.

Creating The Rust Package

The fairly official Rust And WebAssembly book has a section that recommends to use the wasm-pack-template GitHub repo as a starting point, but I abandoned that.  I've forgotten some of the details, but here are some of the things I did not like:
  • The Cargo.toml ...
    • Uses 2018 edition instead of more recent 2021 edition.
    • Uses an opt-level that optimizes for size rather than speed, and the whole point of me resorting to wasm was speed.
    • Is missing some package fields (license, description, etc), thus causing build warning messages. 
  • There is a `src/utils.rs` with a panic hook thingy that I don't think fully worked (or at the least, gives some long warning messages polluting your build messages that made me think it wasn't working).
  • A mild inconvenience is that the `cargo generate --git https://github.com/rustwasm/wasm-pack-template.git` creates a new git repo, and I want the typical thing of adding some rust to an existing git repo.

But, it does some crucial things okay:

  • The Cargo.toml has a good crate-type value and the essential wasm-bindgen and wasm-bindgen-test dependencies, but you probably want to update them to something more recent.
  • The `src/lib.rs` has some nice examples:
    • `use wasm_bindgen::prelude::*;` to `use` typical wasm_bindgen stuff.
    • An example of declaring and using a JS-land function (alert).
    • An example of exporting a function back to JS-land.

I think I ended up doing a `cargo new --lib` and deciding what to bring over from the wasm-pack-template and other examples (sorry, no recorded links).

My ktcalc DiceSim Cargo.toml has a few more dependencies than a barebones wasm-oriented Cargo.toml; the snippet below has some commentary...

[package]
name = "dice_sim" # not entirely sure if it should be snake case
version = "0.0.0"
edition = "2021"
authors = ["Jacob Egner <JacobEgner@example.com>"]
repository = "https://github.com/jmegner/KT21Calculator"
license = "unlicense"
description = "Monte Carlo dice simulator for damage calculations in skirmish games."

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


[dependencies]
getrandom = { version = "0.2", features = ["js"] } # needed for `rand` crate to work for wasm
js-sys = "0.3.66" # create/use JS things like Map
num = "0.4.1" # for Num trait, not wasm/JS stuff
rand = "0.8.5" # for Monte Carlo stuff
serde = { version = "1.0", features = ["derive"] } # useful for automating things that cross the JS-WASM boundary
tsify = "0.4.5" # one library to help generate good TypeScript types; ts-rs may be better
wasm-bindgen = "0.2.89" # necessary for all of this JS-WASM stuff

[dev-dependencies]
wasm-bindgen-test = "0.3.39" # I don't think I used it, but that is because I didn't do any testing

[profile.release]
opt-level = 3 # go fast, not small code size

For future wasm stuff, I think I would copy and modify my DiceSim Cargo.toml.

Sidenote: For understanding how Rust modules work, I found "Clear explanation of Rust’s module system" to be much more useful than the official doc (book, reference).


Building Wasm And TS Outputs

For my web app, I already had NodeJS and NPM.  For rust and wasm stuff, I used `rustup` to install a rust toolchain and installed wasm-pack.  I think there are some npm modules to do at least some of that, and maybe I should have gone that route, but I didn't.  I also had to change some of my npm scripts in my package.json:

"build": "npm run build:wasm && npm run build:react",
"build:react": "react-scripts build",
"build:wasm": "cd src/DiceSim && wasm-pack build --target web",

I think the "react-scripts" thing comes from Create-React-App, so you might have something different.

The `wasm-pack build --target web` creates a `pkg` subfolder with .{wasm, js, d.ts} outputs.  The most interesting one is your_rust_package_name.d.ts because it shows the typescript declarations of  stuff you can use.

To use the exported wasm stuff in your typescript code, I did `import { someMethod, someType, } from 'src/DiceSim/pkg';`.  That seems to work and plays nice with vscode intellisense.

I also ran across the suggestion of  `import { whatever, } from 'your_rust_package_name';` in your ts files and `"your_rust_package_name": "file:src/YourRustPackageName/pkg",` in your package.json, but vscode intellisense didn't like that.

Writing TypeScript/Rust Stuff To Cross JS-Wasm Boundary

Functions

Exporting rust functions is pretty easy, just put a `#[wasm_bindgen]` or `#[wasm_bindgen(js_name = RenamedFunction)]` above your function.  It's the data types for the arguments and return values that are tricky.

For importing functions from javascript land, you need to declare the javascript function inside a `extern "C"` block before using it.  I do not have guidance on how to find these functions and what their function signatures are.

In the rust code below, we import `alert` and export `greet`.

use wasm_bindgen::prelude::*;
 
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello from rust compiled to wasm.");
}


Your Classes

For a class that would be used by both TypeScript and Rust, you could define it in either language and reference it in the other language.  I decided for my common data types to be defined in Rust for a few reasons:
  • I have a firmer grasp on what is going on when a data type and its member functions are defined in Rust than TypeScript.  I don't have to worry about hidden details that impact performance, and the whole point of resorting to Rust is performance.
  • The Rust code is in a better place to be reused in other contexts.  If I want to use my Rust dice simulator for something else, it would be weird to have TypeScript baggage to bring along or replace.
  • The Rust code is the lower level thing, and the TypeScript web app is the higher level thing.  In general, it is better for higher level things to know about lower level things they are using then for lower level things to know about the current higher level thing that is using it. 

For defining a class to be used in JS-land, in theory something like the below would work...

use wasm_bindgen::prelude::*;
 
#[wasm_bindgen(js_name = RenamedClass)]
pub struct OriginalClass {
    #[wasm_bindgen(js_name = renamedField)]
    pub original_field: i32,
}

#[wasm_bindgen(js_class = RenamedClass)]
impl OriginalClass {
    #[wasm_bindgen(constructor)]
    pub fn new() -> OriginalClass {
        OriginalClass { original_field: 1 }
    }

    #[wasm_bindgen(js_name = renamedIncrement)]
    pub fn original_increment(&mut self, val: i32) -> i32 {
        self.original_field += val;
        self.original_field
    }
}

Note that the `impl` has a `js_class` thing, but other than `constructor`, the rest uses `js_name`.  You will get something like the below in your the_package_name.d.ts file.  Note the jsdoc comments (sometimes empty, see skip_jsdoc for more info) in addition to the TypeScript type annotations ...
 
export class RenamedClass {
  free(): void;
/**
*/
  constructor();
/**
* @param {number} val
* @returns {number}
*/
  renamedIncrement(val: number): number;
/**
*/
  renamedField: number;
}

 
Stangely, most of the "Internal Design" section of  the wasm-bindgen guide seems helpful for users wasm-bindgen.  Section 2 (Reference) and especially 2.17 (Supported Types) and 2.18 (#[wasm_bindgen] Attributes) seem useful.  Read the pages that seem relevant to you.

Standard Classes

You might want to have standard classes cross the JS-wasm boundary.  For example, you are exporting a rust function that returns a `Hashmap<i32,f64>` and you want the TypeScript declaration to be a `Map<number,number>`.
 

The tsify Way

For stuff like that, you probably want to use tsify or some similar crate (maybe ts-rs, but its readme shows things that wasm-bindgen already does, I think).

I attempted the following, but it resulted in `Record<number,number>` being the generated TypeScript type:

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tsify::Tsify;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct ProbMap(pub HashMap<i32, f64>);
 
Note that I had to include the `derive` feature in the serde dependency in my Cargo.toml: `serde = { version = "1.0", features = ["derive"] }`.

I didn't want `Record<number,number>`.  Pull request 31 would solve this, but tsify does not seem to be actively maintained.

The js_sys Way

There is also a js-sys crate that provides "Bindings to JavaScript’s standard, built-in objects, including their methods and properties".  wasm-bindgen provides a lot of stuff, like JsValue (which models a JS object), and js-sys goes beyond to provide stuff like Map, which will be exported as a Map<any,any> to typescript land.

Here is what I did to make it fairly convenient to convert a rust HashMap<K,V> to a js_sys::Map ...

use std::collections::HashMap;

pub trait ToJsMap {
    fn to_js_map(&self) -> js_sys::Map;
}

impl<KeyType, ValType> ToJsMap for HashMap<KeyType, ValType>
where
    JsValue: From<KeyType> + From<ValType>,
    KeyType: Copy,
    ValType: Copy,
{
    fn to_js_map(&self) -> js_sys::Map {
        let js_map = js_sys::Map::new();
        for (key, val) in self.iter() {
            js_map.set(&JsValue::from(*key), &JsValue::from(*val));
        }
        js_map
    }
}
 

Quirks Of Generated .js And .ts Files

I got a lot of eslint warnings from the build-generated package_name.js (which generates JS code to re-implement the classes you wrote in Rust and some other wasm-interface stuff), so I added the following to my package.json ...

  "eslintConfig": {
    "extends": "react-app",
    "rules": {},
    "overrides": [
      {
        "files": [
          "**/pkg/*.js"
        ],
        "rules": {
          "eqeqeq": "off",
          "no-new-func": "off",
          "no-restricted-globals": "off",
          "no-undef": "off"
        }
      }
    ]
  },

Let me know if you end up needing to disable additional rules.

Very disappointingly, I had to disable a basic "does the app render without crashing" test because my version of Jest can't handle the wasm imports AND I made my web app with create-react-app, which is abandoned and certain no-longer-developed dependencies can not handle the latest versions of some other dependencies.
 
import * as Test from '@testing-library/react';
 
it('app renders without crashing', () => {
  Test.render(<BrowserRouter><App /></BrowserRouter>);
});

My recommendation: avoid create-react-app, probably use Vite.

Initializing/Loading The Wasm Thingy

Your web app needs to explicitly load the wasm module you made before you use execute code from it.  The default import from the module is an initialization promise.  You need to wait for completion of the initialization promise before using other stuff from the wasm module.
 
Some examples on the internet do the loading in a React component that uses the wasm, and they use `useEffect` to make sure wasm use comes after wasm loading.  I think the better way is to load the wasm module near the root of your app so that only one place needs to go to the effort of loading the wasm module and everything else can simply use the wasm module.

I did something like this in my index.tsx (you can use any name you want for the default import)...
 
import wasmBindgenInit from "src/DiceSim/pkg/dice_sim";

wasmBindgenInit().then((wasm) => {
  ReactDOM.render(
    <App/>,
    document.getElementById('root')
  );
});


My inspiration was this article.  I haven't read this article, but it seems to propose a useWasm hook that seems interesting.

Deploying To GitHub Pages Via GitHub Actions

The basic idea of deploying to a GitHub Pages page is that you have a git branch that holds needed build outputs rather than the source code, and the files on that branch are hosted like a web page by GitHub.  There is some official GitHub doc on the basics.

Once you have configured your repo to have a GitHub Pages page and to use the branch `gh-pages`, you will want your `.github/deploy.yml` build job steps to be something like this...

    steps:
      - uses: jetli/wasm-pack-action@v0.4.0
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build
        env:
          CI: false
      - run: git config user.name github-actions
      - run: git config user.email github-actions@github.com
      - run: git --work-tree build add --all
      - run: git commit -m "Automatic Deploy action run by github-actions"
      - run: git push origin HEAD:gh-pages --force

The `uses: jetli/wasm-pack-action@v0.4.0` is so the build agent installs wasm-pack.  The `run: npm run build` uses your npm build script, so be sure that builds your wasm too.  The rest is just basic stuff for basically every GitHub Actions deployment of a GitHub Pages page.

Your test action at `.github/test.yml` will be something like this ...
 
    steps:
      - uses: jetli/wasm-pack-action@v0.4.0
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build:wasm
      - run: npm test


Again, you need to add the `uses: jetli/wasm-pack-action@v0.4.0`.  Without wasm, you didn't need a build command at all, but now you need to do something like `run: npm run build:wasm` so that the wasm is built into JS for the tests to run.

Various Errors / Troubleshooting

"Uncaught Error: null pointer passed to rust"

I once was getting "Uncaught Error: null pointer passed to rust".  I tried to use the debugger with a very "early" breakpoint, but it still gave the error without hitting the breakpoint first.  Also, when the debugger would break on the error, the call stack was several system/react functions above the Render method in some error handling code, rather than being anywhere close to where the error was actually happening.

The cause was that a rust function took ownership of an input argument, which "moves" the value and sabotages further use of the variable in javascript land.  The fix was to change the rust function to merely borrow the input argument by changing the type to a reference.

// rust code...
 
#[wasm_bindgen(js_name = "takeOwnership")]
pub fn take_ownership(some_struct: SomeStruct) {}

#[wasm_bindgen(js_name = "borrowIt")]
pub fn borrow_it(some_struct: &SomeStruct) {}
 
// javascript/typescript code...
 
function worksFineAndIsSafe() {
  let s = new SomeStruct();
  borrowIt(s);
  console.log(s.someField);
}
 
function avoidsErrorBecauseDoesNotUseSabotagedObject() {
  let s = new SomeStruct();
  takeOwnership(s);
}
 
function causesErrorBecauseUsesSabotagedObject() {
  let s = new SomeStruct();
  takeOwnership(s);
  console.log(s.someField); // error!
}
 
Here is some of what I read to realize what was going on and how to fix it: one, two.

No comments:

Post a Comment