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!