By Radosław Miernik · Published on · Comment on reddit
While working on my PhD, I had to create a rather sizable set of tools for a new general game playing language. It includes a parser, a bunch of optimizing and validating passes, transformations to different formats, and a tree-walking interpreter. All of this was done in my primary language of choice – TypeScript.
Such a project takes a lot of experimenting, and having a fully-fledged IDE with a way to visualize them is basically a must. I’m not there yet, but a basic syntax highlighter, a type checker with somewhat helpful error messages, and a visual representation are enough to work with. All of that is in your browser and accessible on all devices with no setup!
Then, as the games got more and more complex, it started to take a while to run all of the tests. While the small ones take less than 50ms to analyze, the more complex ones are at 3s and counting. I decided to do something about it and…
Started rewriting it part by part in Rust. That’s what people do, right?
Let’s sum up what the project looked like before. The core of it is located in the
lib directory, and it’s not tied to the UI or CLI – it exports a couple of functions, and that’s it. It’s highly modular by design and has a lot of pluggable parts, e.g., the optimization passes are configured with flags.
I use Vitest for testing. Nothing crazy; actually, 99% of tests are snapshot tests, i.e., call a function and check if the result matches a given value. Basically, a
=== test with a smart diff and automatic regeneration of the expected value.
The CLI uses
commander to parse arguments, reads files from the disk, and calls the
lib functions. The UI is a React app using the Blueprint toolkit. Both are built using Parcel, including the local server with hot reloading.
To summarize, you can
npm start to run a local server,
npm test to run the tests, and
npm run build to build the CLI and UI apps. I wanted to keep these three both because it’s easy to pick up for other people and because it keeps the CI simple. (Yes, there’s a CI deploying the UI to GitHub Pages.)
I decided to start with the interpreter – it had virtually no dependencies and required a little bit of everything: passing things between TypeScript and Rust, a complex data structure, and a potentially CPU-heavy operation.
The first version was even smaller – run the CLI to output something that the Rust program can consume and run. On the TypeScript side, it was as easy as one
JSON.stringify call. On the Rust side, I replicated the type definitions and used
serde to automatically build them from JSON. So far, so good.
I can’t share any of the code yet, but I can say it has something to do with automata, and the Rust interpreter was a lot faster. Like, three-fold faster. Sure, it was a manual process to run two programs instead of one, but it’s not something a small Bash script wouldn’t help with.
I had some prior Web Assembly experience; I even wrote a
.wat program or two back in 2018 (note to self: never use iframes in slides). While looking for the state-of-the-art of Rust to WASM, I found this guide on MDN and decided to go with
wasm-pack, which uses
wasm-bindgen underneath. It took me less than an hour to get up and running with some example calls.
The idea was to reuse the existing Rust code as much as possible, including the
serde-based parser. It required zero extra code, and the entire communication was based on JSON strings. Here’s the gist of it:
// Expose this function in WASM.
# Build the WASM module and place it directly in the node project.
; // If you see an error here, make sure to build the Rust module first! ; // This function is synchronous, but it won't work before the module // will be initialized (i.e., `initPromise` resolves). ; // Parcel inlines the WASM module in the browser and references it from // the disk in the Node.js bundle. ; ;
Such a setup works well with the default Parcel config, as it simply inlines the
.wasm module for the browser but leaves the
readFileSync in the Node.js bundle. Thanks to the fact we build it into the
src, it automatically refreshes the local server too. If you saw a problem regarding the
crypto module in Node.js, try the following:
// Node.js requires a crypto polyfill. Importing it directly inlines it // in the browser too, but we don't need it there. Yep, this is a nasty // `eval` trick. Sorry for that! if typeof crypto === 'undefined'
Now the last part is to make it work with Vitest. It’s easy to wait explicitly for the
initPromise in both the CLI and React app somewhere, but we don’t want to add an explicit
await in every single test. Thankfully, we can configure a
globalSetup with the following contents:
Actually, it’s possible to initialize the module fully synchronously too, using the
initSync function generated by
wasm-pack. It’ll work in Safari and Node.js but will throw an error in Chrome:
RangeError: WebAssembly.Instance is disallowed on the main thread, if the buffer size is larger than 4KB. Use WebAssembly.instantiate.
The above setup requires an asynchronous module initialization1, but once it’s done, all of the operations remain synchronous. This is great because it requires no changes in the existing code using it. However, if the operation takes more time, it blocks the UI – just like the TypeScript version.
To solve this problem, we can create a Web Worker and execute the WASM module there. The communication will be fully asynchronous, based on the
postMessage API. Let’s create a basic, RPC-like worker:
; ; // Node.js requires a crypto polyfill. Importing it directly inlines it // in the browser too, but we don't need it there. Yep, this is a nasty // `eval` trick. Sorry for that! if typeof crypto === 'undefined' // Parcel inlines the WASM module in the browser and references it from // the disk in the Node.js bundle. __dirname + '/module/index_bg.wasm'; ; 'message',;
The worker was easy, now we have to communicate with it. To keep it simple, there’s an execution queue of limit 1, i.e., only one call happens at the same time. And because we love TypeScript, everything is typed as it should be.
; ; // Node.js requires a Worker polyfill. if typeof Worker === 'undefined' // Parcel will bundle the `worker.ts` file. ; // A queue to call at most one function at a time. ; ; // Expose a nice async interface on top of the queue.
This approach executes all of the operations in a separate thread, keeping the UI responsive at all times. You can send additional messages in between to indicate the operation progress, e.g., the number of processed entries.
Keep in mind, that the entire API is now asynchronous, and it may require a lot of work to handle it everywhere. In my case, the CLI was easy, but the UI took some extra work (and loaders).
Finally, we have to take care of tests. Vitest has problems with my Web Worker polyfill, but adding the
@vitest/web-worker just worked – no configuration needed. Of course, we can remove the
It’s been a couple of weeks now, and I really liked the entire setup. Sure, Rust compilation takes significantly more than reloading a TypeScript module (1s vs 200ms), but the performance gains were massive.
Also, because the entire TypeScript setup was based on discriminated unions, i.e., all of the types had a unique
kind field, it was extremely easy to migrate the code – replace all
switch statements with
match and call it a day.
There are also downsides. As I moved more and more functionalities, I had to use more and more crates (Rust packages). It increased the compilation times even further (2-5s incremental build) and made the
.wasm bundle rather big (>1MB). I decided to implement some logic myself, saving roughly 45% of it.
At first, I was a little worried that some crates I was using won’t work with
wasm-bindgen. It turned out almost everything I used so far just worked:
std::time::Instant didn’t, but I didn’t want to install an additional crate to support it, so I got rid of it. The only thing I had to do was to enable the
js feature in
Would I do it again? Sure! I even plan to use such a setup in some commercial projects too. It took me some time to figure things out, especially to make it work in the browser, CLI, and tests at the same time, but it was worth it.
I wrote On Rust in Webdev more than a year ago, and it aged surprisingly well; the ecosystem is solid, the community vivid, and the tooling works like a charm. If you haven’t tried Rust yet and you work with TypeScript a lot, give it a try!
I’m heading back to work now – there’s still some code to move…