The benefit of being able to share domain models and algorithms across various teams and technical stacks is massive: it prevents drift, requires less code overall, and keeps business logic in one, well-tested source of truth. For teams working in Rust, the ability to easily compile Rust to WebAssembly and provide bindings for JavaScript means that sharing code across the stack is within relatively easy reach. In addition, using WebAssembly for CPU-intensive operations has the potential to provide significant performance gains in both node and the browser.

With that said, WebAssembly is still relatively new as far as web tech goes, and support for it through the labyrinthian frontend tooling ecosystem is continuously evolving. This post aims to guide you through the process of making some Rust code available in JavaScript from a pragmatic perspective.

It seems like most of the official documentation is aimed either at using a Rust project directly nestled within a JavaScript project and only used in that context (this is where you’ll see use of the wasm-pack webpack plugin), ignores the case of needing to expose a project to both node and the browser, or avoids more complex cases like React and Typescript. We’ll cover all of that here.

We also created a public repository that you can check out to see these examples in action here: https://gitlab.com/spectrust-inc/awesome-wasm

Given the constant movement in the space, this will be a living document: as things change and better methods emerge, we will update as needed. If you have any examples you’d like to see added, please open a PR in our example repository.

Compiling Rust to WebAssembly

See an example here.

This is the easy part! You can actually compile your Rust to WebAssembly with no tools other than cargo, by setting wasm32-unknown-unknown as the target. However, raw WebAssembly is low-level and difficult to work with directly. The hero in this story is therefore wasm-bindgen, which you’ll want to add to your Cargo.toml. wasm-bindgen takes care of doing all of the hard work to expose a JavaScript interface to the compiled WASM that matches the interface in your Rust code.

To make this happen, add the wasm_bindgen macro to any function you’d like to expose to JavaScript:

#[wasm_bindgen]
pub fn hello_world() -> String {
    "Hello, World, from Rust!".to_owned()
}

When compiled with wasm-bindgen, this automatically generates all of the low-level code needed to provide a hello_world() function in javascript that returns a string.

In addition to supporting sending Rust to JavaScript, wasm-bindgen also provides the ability to utilize Rust code to access native node/web APIs, to manipulate the DOM, and so on. In addition, the wasm-bindgen macro takes care of ensuring that your function interface is safe: it only allows passing of types that it knows how to transfer between WASM and JavaScript. See their docs for more information, including how to pass arbitrary types back and forth.

Generally, to actually compile our Rust, we’ll use the higher level wasm-pack, rather than using wasm-bindgen directly. An example invocation looks like:

$ wasm-pack build

Note that scoping (prefixing your package name with a namespace like @mycompany) is also supported:

$ wasm-pack build --scope mycompany

This will build a JavaScript project in a directory called pkg with the same project name as is in your Cargo.toml. This project is by default bundler-oriented, ready to include in browser code via webpack or another bundler. We’ll go into more detail about the various invocations of wasm-pack and how to incorporate the resulting code into JS projects below.

In general, compiling for one of node or the browser is straightforward, compiling for both is a little tricky, and compiling for bundled node is difficult.

TypeScript

For any typescript project, whether node or browser, you will need to set module to esnext in the compilerOptions in your tsconfig.json:

{
  "compilerOptions": {
    "outDir": "./dist/",
    "sourceMap": true,
    "noImplicitAny": true,
    // this must be esnext
    "module": "esnext",
    "target": "es5",
    "allowJs": true,
    "moduleResolution": "node"
  }
}

Browser

In raw browser land, WASM must be imported asynchronously, which adds a little bit of complication to importing a module containing WebAssembly. There are two options: the first is to use a dynamic asynchronous import:

const my_wasm_pkg = await import("my_wasm_package");
my_wasm_pkg.do_stuff();

// or
import("my_wasm_package").then((my_wasm_package) => {
  my_wasm_package.do_stuff();
})

The second is to use regular imports throughout your codebase, and then add to add a simple “bootstrap” entrypoint, which we’ve called bootstrap.ts in our example repository. This introduces an “async breakpoint”, which allows webpack to compile everything appropriately.

In bootstrap.ts (or .js), all you do is import your regular entrypoint:

import("./index").then(err => alert(`Something went wrong: ${err}`))

Set the bootstrap file as your entrypoint in your webpack config, and you’re good to go.

For a typescript project, this requires that the isolatedModules option be false, which is the default, or, if isolated modules is enabled, that you add any import or export statement to the boostrap file. For react, which forces isolatedModules to be true even if you try to set it explicitly, you will need to use the approach of adding an import or an export, e.g.:

import("./main").catch(err => alert(`Something went wrong: ${err}`))

// required to avoid typescript compilation issues with --isolatedModules
export { }

Browser Bundles

This is probably the most common use case, and so is the one you’ll find the most documentation for online. You’ll want to compile your Rust with:

$ wasm-pack build --scope mycompany

which uses the bundler target by default, or you can specify the bundler target explicitly like:

$ wasm-pack build --scope mycompany --target bundler

Including this bundled package in your browser bundle will vary a little bit depending on your situation.

You’ll want to ensure that this generated package is available in your environment. If you’re using yarn workspaces, add it to your top-level package.json file. Alternatively, you can use npm link or similar.

Ensure that you also follow the general instructions for all browser projects, above.

Webpack 5

See an example here.

This is easy. Add your WASM package as a dependency in package.json and add the following to your webpack config:

experiments: {
    asyncWebAssembly: true
}

Webpack should automatically handle bundling the WASM files appropriately.

Webpack 4

See an example here.

Here you don’t need any changes to your webpack file at all. Provided you’ve set up your imports and typescript config as described above, everything should Just Work ™

create-react-app (CRA)

See an example here.

This one’s a bit more complicated, because we need to disable the automatic processing of WASM files by one of CRA’s builtin webpack rules. A huge thanks to this blog, which is where I finally found this solution after much trial and travail.

You’ll need to use something like react-app-rewind so that you can make changes to your webpack config without ejecting (or you can eject and make these changes directly). The config overrides you need look like:

modle.exports = {
    webpack: function (config) {
    const wasmExtensionRegExp = /\.wasm$/;

    // Ensure that file-loader ignores WASM files.
    // Sourced from https://prestonrichey.com/blog/react-rust-wasm/
    config.module.rules.forEach((rule) => {
        (rule.oneOf || []).forEach((oneOf) => {
        if (oneOf.loader && oneOf.loader.indexOf("file-loader") >= 0) {
            // Make file-loader ignore WASM files
            oneOf.exclude.push(wasmExtensionRegExp);
        }
        });
    });

    // Use wasm-loader for WASM files
    config.module.rules.push({
        test: wasmExtensionRegExp,
        // I'm going to level with you: I copied this in from the example, but I
        // have no idea why it's necessary. If it's not here, it breaks, though.
        include: path.resolve(__dirname, "src"),
        use: [{ loader: require.resolve("wasm-loader"), options: {} }],
    });
    }
}

If using typescript, be sure to check out the note about --isolatedModules above for how to structure your entrypoint.

Node

See an example here.

Deploying to node is a little simpler in the general case than deploying to the browser. You’ll want to compile your package with:

wasm-pack build --scope mycompany --target node

From there, you can include your project in your yarn workspace, install it normally, or link it, and then it should work like any other package:

import * as my_wasm_pkg from "@mycompany/my_wasm_pkg";

my_wasm_pkg.do_stuff();

Bundled Node

See an example here.

This is a bit more complicated, because neither the node output nor the bundler output is set up for it. You’ll want to use the node target for wasm-pack, but I haven’t been able to find a way to effectively include the WebAssembly in the bundle.

So, as far as I know, the easiest way to do this is to set up your webpack configuration to simply not bundle your generated package. If you’re in a monorepo context, you can use the CopyPlugin to include the package in a node_modules directory next to your generated bundle in your dist directory. When you deploy, deploy both the node_modules directory and the generated bundle, so that node on the server can find your package. If you’re not in a monorepo context, you will need to ensure the WebAssembly package is installed and available wherever you’re running node.

Here is an example. The externals portion is what prevents WebAssembly from trying to bundle the project. The CopyPlugin will include the project in your distribution directory for zipping up and deploying.

module.exports = {
  plugins: [
    // Copy the generated node package into a node_modules directory in the
    // bundle output directory.
    new CopyPlugin({
      patterns: [
        {
          context: path.resolve("relative_path_to_dir_containing_generated_wasm_package"),
          from: "dir_in_context_containing_wasm_package",
          to: "node_modules/@mycompany/my_wasm_pkg",
        }
      ]
    })
  ],
  externals: {
    // do not bundle the wasm library, just assume it is available as a commonjs lib
    "@mycompany/my_wasm_pkg": "commonjs @mycompany/my_wasm_pkg",
  }
}

Bundler and Node

So, you want to use your Rust in both the browser and in node?

This is a bit more complicated, but the general idea is to build for both of your targets, and then use a top-level package.json to point dependencies to the right entrypoints.

So, your build might look like:

$ wasm-pack build --scope mycompany --target node --out-dir pkg/node
$ wasm-pack build --scope mycompany --target bundler --out-dir pkg/bundler

And then you add a package.json in pkg that looks like (see an example here):

{
  "name": "@mycompany/my_wasm_pkg",
  "description": "my awesome wasm library",
  "version": "0.1.0",
  "files": [
    "node/*",
    "bundler/*"
  ],
  // target for browser/bundler
  "module": "bundle/my_wasm_pkg.js",
  // target for node
  "main": "node/my_wasm_pkg.js",
  // this can be the types module in either of the two generated packages
  "types": "node/my_wasm_pkg.d.ts",
  "dependencies": {},
  "devDependencies": {},
  "sideEffects": false
}

You would then link to or include in your workspace the directory containing the top-level package.json.

Of course, this leads to a slightly larger package than otherwise, but it’s by far the easiest way to do things. You can mitigate this a little bit by removing the duplicate type definition files, generated package.json files, and other unneeded files in each built project. For example, via a build script that does:

TARGETS="nodejs bundler"

echo "Building WASM projects"
# Build WASM bundles
for target in $TARGETS; do
    wasm-pack build --scope mycompnay --target "$target" --out-dir "./pkg/$target"
done

# Remove extra files, including the package.json files in each project and
# duplicate type files. Move the type files out into their own directory,
# which we can point to from the top-level package.json

echo "Deduping type files"
# doesn't matter which type files we copy, they're all the same
mkdir -p ./pkg/types
cp ./pkg/bundler/my_wasm_pkg.d.ts ./pkg/types/my_wasm_pkg.d.ts

echo "Removing unneded files"
for target in $TARGETS; do
    rm -f "./pkg/$target/.gitignore" \
        "./pkg/$target/package.json" \
        "./pkg/$target/my_wasm_pkg.d.ts"
done

You would then also adjust the types target in your package.json to point to the de-duped file in types/my_wasm_pkg.d.ts.

Keeping Your Project Built

You can either check in your generated files or not (we generally do not), but either way you will need to be sure that your Rust project builds its JavaScript/WASM output whenever it’s needed.

The easiest solution here is probably to add a preinstall step to the package.json for your generated files that calls your build script. If you’re using yarn workspaces, though, preinstall scripts are not executed, so you’ll want to call your build from a prepare script in your top-level package.json. You can see both of these approaches in our example repository.

Summary

Hopefully this has helped you to avoid the several days of trial and error that it took us to get all of this figured out! Please don’t hesitate to let us know if there are any points of improvement, and like we said at the top, if you have any other examples you’d like to see added, feel free to open a PR adding them to our examples repository.

Could all of this be simpler? Oh you bet it could. Hopefully tooling will continue to evolve, and we will find ourselves within a few years in a world where everything is smooth and easy. It does seem to be getting there!