We’ve been using Rust in anger for a couple of years now in some sophisticated SaaS products, such as EqualTo Sheets, our “spreadsheet as a service” platform for developers. It’s been great to leverage a fast, memory-safe, and versatile programming language like Rust in our Python, Rust and Node.js code-bases. Here we’ve collected our insights so that others can benefit from our experience.
Accompanying this post is a GitHub repository with all the relevant code. This serves two purposes: to help you understand what’s going on as you read the post, and to give you a quick start with your own project.
In our case we want TypeScript to send an “input” object to Wasm, which Wasm will use to compute an “output” object that it returns to TypeScript. To make things a bit more concrete let’s say we are developing an application that deals with triangles. From the TypeScript perspective we will have definitions like:
Consider this line of code:
There are several ways to implement 1-5, with the best strategy determined by considering the trade-offs. In our particular case, we are serializing/deserializing “small” amounts of data, and relatively infrequently, so we will use the method most convenient from the perspective of implementation.
The following bindings allow for the most efficient serialization / deserialization of data:
It’s wise to divide the Rust code into multiple crates. The “core” Rust crate should have the functionality you want to provide to each target language. Additional “thin” binding crates should be provided for each target language we aim to support:
In an ideal world, the “thin” crates would be automatically generated for each target language. Unfortunately, this is not currently the case, so we must maintain the “thin” crates ourselves. Every time the main library is changed or updated, we must ensure that the API modifications are reflected in the “thin” crate. This is not ideal but workable, although it does violate the DRY principle.
Enough preliminaries. If something was unclear in the general discussion, I hope it will become clear with some code samples.
We are going to create a simple “Birthday Book” application, which lets you manage a list of users and their corresponding birthdays. The core of the Rust implementation looks something like this:
These two will only operate effectively in an environment with access to a file system. Although the exact API details are not essential right now, it’s worth noting that the API is incomplete: we need to add methods to delete, update, etc.
The above code depends on the following features, not available in Wasm:
Wasm is a first-class citizen when it comes to Rust, documentation, examples and possibilities are endless. It can be daunting to sift through all the material available online, so this post and its accompanying code should help you get started.
To facilitate the conversion of TypeScript objects into Rust and vice versa, we use tsify. Other alternatives such as ts-rs exist, but once you are able to exchange numbers and strings between the two languages, then additional tooling is not necessary.
In the case of our project, which consists of around 50k lines of code, the bindings for Rust and TypeScript (i.e. wrappers around each API call) total around 1,000 lines of code.
The general structure is:
The code should be self-explanatory; however, some key points are worth emphasizing. We decorate methods and classes to automatically generate glue code that serializes and deserializes data.
The code in our case looks something like:
Taking into account the documentation and the above, it should now be relatively easy to construct Python extensions for any Rust library.
Node.js is the most challenging of the three platforms to target. We will use napi-rs, which is currently more comprehensive and easier to manage than neon. The term ‘napi-rs’ originates from its use of the Node API, which is the latest Node.js abstraction.
Without further delay, the Node.js bindings for Rust:
If you have ever worked with Python extensions in C/C++ or Node.js add-ons using gyp or a related tool, you’ll be delighted to see how easy it is to interoperate with Rust. Although we haven’t discussed how to package the resulting code for production in this post, it is relatively straightforward and can be done either manually or with existing tools.
It is worth noting that these technologies are constantly evolving, so if you’re reading this in 2024 or later, there may be better ways to do some of the activities we have outlined here. For example, there are two potential alternatives,wasmer and wasmtime, which enable code written in any language that can be compiled to WebAssembly and run in virtually any other language. For more information, take a look at this video.
That’s all for today!