Creative Bracket

How to load a WebAssembly file in Dart

WebAssembly defines a binary format with an assembly-style text format for executables used by web pages. This presents a game changing opportunity for JavaScript applications to run at native speeds.

A question was posed by Matthew Harris on the AngularDart group of Gitter.im, regarding how to load a WebAssembly file in Dart. It went as follows:

Hey! Does anyone know how to or can link to a blog on: how to include a wasm file in dart (for web). Right now, i’m messing with JS() interop, but since wasm needs to be loaded and built first, i wondered if that’s been ported over to dart yet?

Matthew Harris – April 27 at 22:18

Since there is no official Dart documentation on the WebAssembly API, the only other option is to use the js interop package to interface with it. To demonstrate this, I will base the solution on the introductory tutorial on the MDN website. So let’s begin!


1. Setup a Dart web project

Use the stagehand tool for this:

pub global activate stagehand # Install if you haven't
mkdir wa-dart && cd wa-dart # Create your working directory
stagehand web-simple # Then run `pub get` to update dependencies

Install the js package by updating the dependencies section of your pubspec.yaml file :

dependencies:
  js: ^0.6.1+1

Then run pub get to install.

2. Prepare the .wasm file

Go to the web folder of the generated project and create a simple.wasm file, holding the contents below:

;; web/simple.wasm

(module
  (type $t0 (func (param i32)))
  (type $t1 (func))
  (import "imports" "imported_func" (func $imports.imported_func (type $t0)))
  (func $exported_func (type $t1)
    i32.const 42
    call $imports.imported_func)
  (export "exported_func" (func $exported_func)))

This snippet here defines a method named exported_func(). This method expects to receive a method we will pass to it named imported_func(). It’s not compulsory to understand this file’s output entirely since you won’t be creating these from scratch anyway. You would write these in a language like C++ or Rust and compile that to the snippet you see above.

3. Load the WebAssembly file using the Future API

In the “Loading our wasm file without streaming” example on MDN, the fetch() method was used to load the .wasm file as an ArrayBuffer and passed as the input to the WebAssembly.initiate() method.

In web/main.dart let’s perform a HTTP request to retrieve our .wasm file as an ArrayBuffer:

import 'dart:html';

void main() async {
  var request = await HttpRequest.request(
    './simple.wasm',
    responseType: 'arraybuffer',
    mimeType: 'application/wasm',
  );

  // TODO: Initiate WA with response
}

The responseType and mimeType named arguments are set to the appropriate values to get the expected ArrayBufferresponse.

4. Initiate the WebAssembly instructions

Let’s create an interop file to interface with the WebAssembly API. Create a web/wa_interop.dart file with these contents:

@JS('WebAssembly')
library wa_interop;

import 'package:js/js.dart'; // contains the `@JS()` annotation
import 'package:js/js_util.dart'; // contains needed util methods

The first line sets the context for the JS interop by setting the 'WebAssembly' argument for the @JS() annotation. A library name is required for every interop file created. Look at the further reading materials below for a much detailed article on JS interop. For now let’s resume.

Add the interop logic for the initiate() method:

@JS()
external instantiate(dynamic bytes, dynamic importObj);

The first parameter of our initiate() method is the list of bytes from the .wasm file while the second is an object containing configuration information that is passed into the loaded .wasm file. The example on MDN looks like this:

{
  imports: {
    imported_func: (args) => window.console.log(args),
  }
}

The snippet contains the imported_func method expected by the WebAssembly file.

Return to web/main.dart replace the // TODO: Initiate WA with response line with the call to initiate():

var wa = await instantiate(
  request.response,
  {
    'imports': {
      'imported_func': (args) => window.console.log(args),
    }
  }
);

window.console.log(results);

The second argument contains the configuration object we passed through. In this case we have passed in a Map<String, dynamic> type. This however won’t run the way we expect it to, but we’ll fix that soon.

Open the terminal and run our local server:

webdev serve --hot-reload

Visiting http://localhost:8080 in the browser will throw this error in the console:

This is simply because Dart Map<String, dynamic> types are opaque in JavaScript. They don’t translate to native JS objects!

To resolve this we’ll use some utility methods to build a native JS object from our Map. These are newObject() which creates a native JavaScript object, and setProperty() which allow us to set a property with a value, providing the JS object in which to perform this mutation.

In web/wa_interop.dart let’s define this top-level function that’ll use those methods:

jsObj(Map<String, dynamic> dartMap) {
  var jsObject = newObject(); // Define our JS object

  dartMap.forEach((name, value) {
    setProperty(jsObject, name, value); // Add the name/value pairs from our `dartMap` variable
  });

  return jsObject; // Return the native JS object
}

Return to web/main.dart and wrap the configuration object as such:

jsObj({
  'imports': jsObj({
    'imported_func': (args) => window.console.log(args),
  })
});

This should log out a resolved Promise object. Since the Promise has been resolved, we cannot await for it this time. We have to use .then() to process the results.

wa.then((results) {
  window.console.log(results);
  // TODO: Extract `exported_func` from compiled .wasm file
});

Logging out results displays the following output:

What we care about is the exported_func prop from the .wasm file. To invoke this method let’s use the getProperty() method to retrieve our export:

wa.then((results) {
  var exportsObj = getProperty(results.instance, 'exports');
  Function exportedFn = getProperty(exportsObj, 'exported_func');
  exportedFn(); // Logs `42` to the console
});

This should now log 42 to the console. If that’s the case then we’re done!

Bundling for production

When building for production, use the --no-release flag. This uses the dartdevc tool to compile instead of dart2js. In other words this supports modern browsers running ES6.

EDIT: After further investigation, you can bundle to a working ES5 output. In web/wa_interop.dart, add an interop class for the JavaScript Promise API:

@JS()
class Promise {
  external then(Function callback);
}

In web/main.dart set the return type of the instantiate call:

Promise wa = await instantiate( // Replaced `var` with `Promise`
  ...

We need to wrap the functions we are passing using the allowInterop() top-level method from the js package utility:

'imported_func': allowInterop((args) => window.console.log(args)),

The last thing we need to do is to adjust the callback implementation for the instantiate promise result:

wa.then(allowInterop((result) {
  var instance = getProperty(result, 'instance');
  var exportsObj = getProperty(instance, 'exports');
  Function exportedFn = getProperty(exportsObj, 'exported_func');

  exportedFn();
}));

Alright. So that’s it for enabling support in the ES5 output.


Further reading

Jermaine Oppong

Hello 👋, I show programmers how to build full-stack web applications with the Dart SDK. I am passionate about teaching others, having received tremendous support on sites like dev.to and medium.com for my articles covering various aspects of the Dart language and ecosystem.