DEV Community

David Mohl
David Mohl

Posted on • Originally published at david.coffee on

Venturing into WASM on React Native and Golang

Gopher Programming

Appeared first on my personal blog

I recently had the idea to create a little react native app for FastMails MaskedEmails, to quickly add new ones on my device, so I don’t have to open the FastMail app all the time.

I wanted to do this with as little code duplication as possible, so instead of rewriting a client from scratch, I decided to re-use my maskedemail-cli somehow. I could have probably compiled it down to a shared library and pulled it into Swift, but I am no iOS developer and know React already, so why not do it in React Native with WASM?

I’ve never actually used wasm and was looking for an excuse to play around with it. Here’s what I learned.

You can find the app I worked on during this writeup at https://maskedemailmanager.david.coffee

(Spoiler: You can’t use native WASM within JSC on React Native yet)

Golang and WASM, how does it actually work?

Golang supports compiling to wasm since version 1.11, with further improvements happening in 1.13. The basic gist of making it spit out wasm is

GOOS=js GOARCH=wasm go build -o main.wasm

Enter fullscreen mode Exit fullscreen mode

Main interactions from Golang with JavaScript-land happen through the js/syscall package. For example, if we want to create a js function, we can do it like so:

cb = js.FuncOf(func(this js.Value, args []js.Value) any {
    fmt.Println("Hello World")
    return nil
})

Enter fullscreen mode Exit fullscreen mode

The first argument is always this, and the second argument is always an array of actual arguments passed to the function.

We can also use js.Global() to get a ref to the global object and call into js:

js.Global().Call("alert", "Hello from wasm!")

Enter fullscreen mode Exit fullscreen mode

… and of course, we can also make our functions available on the global object:

js.Global().Set("myCoolFunc", cb)

Enter fullscreen mode Exit fullscreen mode

Using Golang WASM from JavaScript

Golang comes with some glue code that you need to run wasm files built by Golang properly, that’s this copy statement you see in most guides:

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

Enter fullscreen mode Exit fullscreen mode

Besides setting up the environment, this code defines the globalThis.Go object that we’ll use to run our wasm code:

const go = new Go();
const res = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
go.run(res.instance);

Enter fullscreen mode Exit fullscreen mode

(instantiateStreaming supports fetch; that’s why everything is so compact. we can also use WebAssembly.instantiate(arrayBuffer, go.importObject) directly without fetch).

go.importObject provides values to be imported into the new instance, which defines all the supported syscalls

Preparing maskedemail-cli for WASM - async code and Promises

First, we need to create glue code that exports the necessary functions from maskedemail-cli to JavaScript, in Golang.

func main() {
    done := make(chan struct{}, 0)
    js.Global().Set("maskedemailList", js.FuncOf(list))
    <-done
}

func list(this js.Value, args []js.Value) interface{} {
    // (...) get maskedemails here (...)
    return maskedEmailArray
}

Enter fullscreen mode Exit fullscreen mode

Now here’s a problem: We can’t block the main goroutine with things like HTTP requests. It would be kinda weird if we do an ajax request in Golang and the browser freezes while our functions get executed…

Instead, async code has to go into separate goroutines, which means in JavaScript… Promises!!

Specifically, to make our code compatible with js promises from Golang, we have to use the Promise constructor through js.Global(), effectively creating a Promise from scratch:

func list(this js.Value, args []js.Value) interface{} {
    handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        resolve := args[0]
        reject := args[1]
        go func() {
            // (...) get maskedemails here (...)
            resolve.Invoke("Hi there")
        }
    })
    promise := js.Global().Get("Promise")
    return promise.New(handler)
}

Enter fullscreen mode Exit fullscreen mode

js.Global().Get("Promise") is getting the global Promise object, then calling New onto it with a custom handler.

Running main() from above would now populate globalThis.list, and it’s signature would look like this: list(): Promise<string>.

Creating a NPM package that’s using WASM

Next, I wanted to publish an NPM package to pack away the complexity, but most guides on Golang + wasm were mainly focused on the browser, not NodeJS.

Luckily the key steps are effectively the same:

  1. Use the wasm_exec.js glue script to populate globalThis.Go
  2. Load the wasm file through whatever method
  3. Run WebAssembly.instantiate or WebAssembly.instantiateStreaming, pass go.importObject
  4. Run go.run(instance)

In NodeJS, we can solve 1. by doing a dirty require("./wasm_exec.js"). The code runs fine in the nodejs runtime so no issues there. Too bad it’s polluting globalThis and doesn’t give us a nice-to-use package back, but we can live with that.

Point 2. is a tad trickier - we could of course, just load it with fs.readFileSync:

const go = new globalThis.Go()
const buffer = fs.readFileSync('./main.wasm');
const inst = await WebAssembly.instantiate(buffer, go.importObject);
go.run(inst.instance);
// run func
globalThis.list();

Enter fullscreen mode Exit fullscreen mode

… but given that this is JavaScript, usage of fs means that this is already unusable in a browser environment, which is not great.

What’s more, wasm_exec.js depends on a few browser-specific modules, mainly crypto and TextEncoder. So now we have some stuff that doesn’t work in the browser, and also some stuff that doesn’t work within node 😀 Let’s fix that with some polyfills:

if (!globalThis.crypto) {
  const crypto = require("crypto");
  globalThis.crypto = crypto;
}

if (!globalThis.TextEncoder) {
  const te = require("text-encoding-polyfill");
  globalThis.TextEncoder = te.TextEncoder;
  globalThis.TextDecoder = te.TextDecoder;
}

const go = new globalThis.Go()
const response = await fetch('./main.wasm');
const buffer = await response.arrayBuffer();
const module = await WebAssembly.compile(buffer);
const instance = await WebAssembly.instantiate(module);
go.run(inst.instance);
// run func 
globalThis.list()

Enter fullscreen mode Exit fullscreen mode

Instead of fs, we can use node-fetch (or the new NodeJS native one) and fetch to pull the wasm file, then continue instantiating like usual. Now it’s no longer node/browser or fetches implementation dependent.

My final solution

Because I wanted to package this for ESM and CJS simultaneously, I went with rollup as bundler. rollup-plugin-wasm can handle loading + instantiating of the actual wasm file similar to how we did above, so we can slim the code down like this:

// polyfills here 
require("./wasm_exec.js");
import wasmMain from "./static/main.wasm";

const go = new globalThis.Go();
const wasmObj = await wasmMain(go.importObject);
go.run(wasmObj.instance);
globalThis.list()

Enter fullscreen mode Exit fullscreen mode

Packaging into a usable module

To make everything a tad nicer, let’s export some functions that other modules can use:

interface Mod {
  list: (token: string, accountId: string) => Promise<MaskedEmail[]>;
}

const instantiatedPromise = new Promise<Mod>(async (resolve, reject) => {
  const go = new (globalThis as any).Go();
  const wasmObj = await wasmMain(go.importObject);
  go.run(wasmObj.instance);

  resolve({
    list: (globalThis as any)["maskedemailList"],
  });
});

export const list = async (token: string, accountId: string) => {
  const mod = await instantiatedPromise;
  return mod.list(token, accountId);
};

Enter fullscreen mode Exit fullscreen mode

We have some waiting to do before wasm is usable, so we package all of that into a new instantiatedPromise. The exported list() is then awaiting that promise before doing anything, then executing mod.list, our actual wasm function.

To make the API nicer to use I’ve added typescript type definitions for the module, that’s what the interface Mod is for. instantiatedPromise will resolve with Mod, so calling mod.list will have the same return signature as Mod.list

The package is available on npm, the code on my github

Investigating React Native + WASM

By now, we have packaged everything so neatly that it doesn’t matter that we used Golang to create the wasm binary. It’s all just an npm package that should be usable everywhere. (Ignore the big 7MB file size of the .wasm file for now 😛)

So all we have to do is yarn add maskedemail and that should be it… right? Right??

But life is not that simple, and adding that final lego piece was much harder than expected. Let’s go over what is making wasm + react native so hard?? (This was written by poking the simulator and debugging errors)

  • WebAssembly.instantiateStreaming / WebAssembly.compileStreaming only works with fetch if the server is returning application/wasm, which react-native doesn’t. In fact, using fetch to pull in resources the same way we do in NodeJS or the Browser is in RN flimsy at best
    • Expo has Asset.fromModule to make it possible to load arbitrary assets from disk
  • WebAssembly.instantiateStreaming / WebAssembly.compileStreaming doesn’t even exist in the react native JavaScriptCore runtime (WebAssembly exists, but those functions don’t. Only WebAssembly.instantiate and WebAssembly.compile do, which work with ArrayBuffers
  • Current asset loading functionality through Asset.fromModule or fetch implementations for react native can pull assets, but rn’s FileReader does not implement readAsArrayBuffer, so no fetch().then((res) => res.arrayBuffer()). No ArrayBuffer means no easy way to use WebAssembly.instantiate, and also no easy trickery to convert Blob into an ArrayBuffer
  • RN’s JavaScriptCore does not have a crypto implementation
  • Expos packager is trying to resolve all require and build and doesn’t support dynamic imports, so having require("crypto") in an if statement (like a env-dependent polyfill) will still make the packager throw an error that the package doesn’t exist, effectively meaning that our generic solution ain’t gonna work

I probably forgot a few points but these were the most noticeable issues.

I banged my head against FileReader.readAsArrayBuffer to make it possible to read the .wasm file from disk, tried a couple polyfills and re-implementations, but eventually gave up. There are fs.readFile implementations that could work, but expo (the tool I am using to bootstrap my RN application) does not support linking libraries, so we can’t easily add things that require editing of the application bundle.

To make things worse, the package resolving the issue I mentioned above also meant that all the glue code that rollup-plugin-wasm generated to switch based on environment for browser + nodejs doesn’t work either, because if there is a call to require("fs"), expo breaks. Same thing for attempts to polyfill crypto in the correct environment with crypto-browserify.

Fixing RN issues one by one

Looking at wasm_exec.js, we can see that it doesn’t need the entire crypto package, just one single function:

if (!globalThis.crypto) {
    throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}

Enter fullscreen mode Exit fullscreen mode

That’s much easier to polyfill with something like polyfill-crypto.getrandomvalues (though using Math.random() instead of proper entropy source…)

We can swap out our existing polyfill for this implementation:

import getRandomValues from "polyfill-crypto.getrandomvalues";
if (!globalThis.crypto) {
  globalThis.crypto = {
    getRandomValues,
  };
}

Enter fullscreen mode Exit fullscreen mode

Now the import no longer breaks nodejs, browser, or react-native JavaScriptCore :)

Loading the .wasm file is a different beast, and the polyfills for FileReader.readAsArrayBuffer caused expo to crash consistently. So, what do we do if we can’t load something from disk in the format we need? We play dirty: Instead of async loading the .wasm file like a good citizen, we base64 that chunker and stuff it into our main JavaScript file!

rollup-plugin-wasm even has an option to inline wasm:

wasm({
  sync: ["main.wasm"],
  targetEnv: "auto-inline",
  maxFileSize: 0,
}),

Enter fullscreen mode Exit fullscreen mode

The result will make any editor without proper optimizations for uber-large files crawl

Delicious base64 blobs

… but it does work inside the Simulator! We’re no longer dependent on asset loading logic anymore. It’s all just js now.

To fix remaining FileLoader issues, there is this patch available that polyfills the functionality. This patch alone doesn’t work to load the wasm file from disk and crashes the simulator, but it does work for smaller under-the-hood functionality that relies on readAsArrayBuffer.

With that out of the way, my app finally runs in the Simulator 🎉

I thought this would be the end of the story and continued chipping away on my app. After days of work, it was finally ready to run it on my iPhone to do a final test before release. Compiling everything… running…

Screen Shot 2022-10-02 at 23.14.08

You gotta be kidding. WebAssembly does not exist at all in JavaScriptCore when running on an actual device, but it does exist inside the Simulator. Apparently, Apple shut it down in recent iOS versions, but from reading through posts, this used to be available in the past.

… the end? Did we fail? What next?

I’ve been considering what next steps to take to get this working, but without proper WebAssembly support in JSC on devices, performance will always be subpar. Here are some thoughts and directions I investigated:

Option 1: Fallback to WKWebView

This is the most promising but also a bit absurd. We create a WebView, inject our wasm binary into the webview and execute it there, then relay messages (with window.postMessage) back and forth with our main app.

In my tests, this actually worked pretty well, with the problem that making something generic that just works is a bit hard. Actually, polyfilling window.WebAssembly would mean we need to somehow bridge go.importObject into our webview. On top of that, we need to proxy the changes the wasm binary did (like modifying globalThis) back to our native app using a transparent message relay, so libraries that rely on it can just work.

Without a proper polyfill, window.WebAssembly won’t be available in global scope, so our maskedemail NPM package won’t be able to use it.

All in all, a lot of headache. Probably better to split native and bridged code altogether and write the glue code from scratch. (react-native-react-bridge tries to get around this by using a separate entrypoint that’s getting processed with babel)

Option 2: wasm2js

binaryen includes a tool called wasm2js that compiles wasm files back to JavaScript. I have tried this as well, but could not get it running with my library, probably due to complexity with some stuff like reflections and HTTP requests. Very promising but very hard to debug due to the way this tool spits out JavaScript. Either it works, or it doesn’t; for me, it didn’t. (Though the errors I got were actual Go errors related to HTTP, so it almost worked)

Option 3: gopherjs

Not a solution to wasm, but to make Go run within react-native. Instead of building a wasm binary, gopherjs can output functional JavaScript so we don’t have to deal with WebAssembly runtime at all.

Honestly, I think this might be the best option for code reusability until WebAssembly support is fully available in JSC.

What I ended up doing: WASM -> GopherJS

I’ve tried all options listed above, and the GopherJS variant was the nicest to work with. Even better: The Go API for GopherJS is almost identical to WASM! For example:

This is the WASM version:

func list(this js.Value, args []js.Value) interface{} {
    handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        resolve := args[0]
        reject := args[1]
        go func() {
            // (...) get maskedemails here (...)
            resolve.Invoke("Hi there")
        }
    })

    promiseConstructor := js.Global().Get("Promise")
    return promiseConstructor.New(handler)
}

Enter fullscreen mode Exit fullscreen mode

This is the gopherjs version:

func list(this *js.Object, args []*js.Object) interface{} {
    handler := js.MakeFunc(func(this *js.Object, args []*js.Object) interface{} {
        resolve := args[0]
        reject := args[1]
        go func() {
            // (...) get maskedemails here (...)
            resolve.Invoke(out)
        }()

        return nil
    })

    promiseConstructor := js.Global.Get("Promise")
    return promiseConstructor.New(handler)

Enter fullscreen mode Exit fullscreen mode

In fact, it’s so similar that I was able to flat-out generate the gopherjs main.go file with this script:

.PHONY: generate-gopherjs
generate-gopherjs:
    rm -rf cmd/gopherjs
    mkdir -p cmd/gopherjs
    cp main.go cmd/gopherjs/
    sed -i '' 's/syscall\/js/github\.com\/gopherjs\/gopherjs\/js/g' cmd/gopherjs/main.go
    sed -i '' 's/FuncOf/MakeFunc/g' cmd/gopherjs/main.go
    sed -i '' 's/js\.Value/\*js\.Object/g' cmd/gopherjs/main.go
    sed -i '' 's/js\.Global()/js\.Global/g' cmd/gopherjs/main.go

Enter fullscreen mode Exit fullscreen mode

With a bit of JavaScript, I was able to add an automatic fallback to the gopherjs version to maskedemail-js, so it’s now usable even when window.WebAssembly isn’t available (although ships with 2x 8MB files).

No WASM in react-native as of now

A bit sad I wasn’t able to get my goal of using real WASM working within React Native, but learned a good bit with this adventure.

For now, I’ll use GopherJS when I want to re-use Go code, but I hope we’ll see actual WebAssembly support in JSC on devices soon-ish. With all the WASM hype, I doubt it’ll take long.

There is also stuff like Wasmer that can embed WASM binaries into other languages like Swift, so instead of going the JSC route, it should be possible to embed it into native modules directly or non-RN apps - for when we really want to re-use the same binary at different places. But that’s also doable with Golang, without WASM.

The app is available at https://maskedemailmanager.david.coffee

Top comments (0)