Skip to content

rust-wasmpack-loader

Compiles a .rs file to WebAssembly at build time and returns JavaScript, so Rust imports like any other module across 8 build tools

rust-wasmpack-loader is an open-source package I wrote that lets a JavaScript or TypeScript project import a Rust file the same way it imports any other module. You point an import at a .rs file, and at build time the Rust gets compiled to WebAssembly and handed back to your code as plain JavaScript exports. It grew out of Express Soft, the taxi platform we planned at Mobilauto after Cabinet24.

In 2021, when we finished the plan for Express Soft, the team made some samples, and one part of the design stopped us. The dispatching leaned on geospatial math, run constantly while the platform matched drivers to orders: polygons, distances, checking a point against a segment. It got pretty clear that this kind of math doesn't go great on a common JavaScript engine. So the math went into Rust. A teammate wrote the geospatial crate itself. My part was the question that came right after it: how do you actually use Rust from inside Node.js?

One obvious way was a native C++ addon for Node.js. The problem with a native addon is that it needs a separate build for every operating system and every processor you want to run on. WebAssembly, which was really popular at that time, gives you one portable artifact that the same import can load anywhere. So we went with WebAssembly and started digging into it.

Getting Rust to WebAssembly is the easy half, of course. wasm-pack does the compiling: point it at a crate and you get a .wasm file plus the JavaScript bindings that call into it. The hard half is everything after that. Somebody has to run the tool, park the output where the bundler can see it, wire the bindings by hand, and then do all of it again on the next build. Every build tool has its own plugin interface too, so solving it for one tool solves it for exactly one tool. And TypeScript sees nothing at all on the far side of that import, because the exports come out of a language it doesn't read.

The loader was born as a simple script that compiled the Rust on the fly while the build ran. Then the amount of Rust in the project kept growing, a script wasn't enough anymore, and it got converted into a separate library and a separate project of its own. Inside Express Soft it ended up in the dispatcher application, and in the Rust build of the map service.

The one requirement behind the whole design was that a Rust import has to look like every other import line in the project. No special helper wrapped around it, no fetching a binary at runtime, no async init the caller has to remember before the first call. You import the .rs file and you call the function.

So everything happens at build time. When the build hits a .rs import, the loader walks up the folders to the nearest Cargo.toml, runs wasm-pack on that crate, and returns JavaScript that exposes whatever the Rust exports. Finding the crate on its own was deliberate. A per-project path in a config file is one more thing that quietly breaks the first time somebody moves a folder, and Cargo.toml is where a Rust project already keeps that answer anyway.

Types were the awkward part. Hand-written declarations work right until the Rust changes and the declarations don't, and the first sign of that drift is a runtime error in something unrelated, weeks later. So the loader generates TypeScript types for every .rs import through .d.rs.ts sidecar files, and there's a Language Service plugin for the editor on top of that. A Rust import carries real types in the editor and in a type check, the same as a TypeScript one.

flowchart LR RS[A .rs source file] --> BT[Build-time compile] BT --> W[WebAssembly binary] BT --> J[JavaScript bindings] BT --> T[TypeScript type sidecar] W & J & T --> IM[A plain import statement in 8 build tools]

The main target at the start was Node.js. After that the support expanded to the browser, and then to the build tools people actually build with. It ended up at 8 of them, all with the same level of support: Webpack, Rspack, Bun, esbuild, Rollup, Vite, Next.js and Electron. Each one has its own plugin shape and its own idea of when a file gets read.

Stack

  • Rust
  • WebAssembly
  • wasm-pack
  • TypeScript
  • Node.js
  • Webpack
  • Rspack
  • Bun
  • esbuild
  • Rollup
  • Vite
  • Next.js
  • Electron

Related work

So, what's broken?

Tell me the problem, a rough budget, and a timeline if you have one. I reply within 24h on weekdays.

Rough budgetoptional
* required