Exploring Javascript-Rust interoperability with napi-rs

()
#Javascript#Rust
Back to home

I've been working with Javascript as my daily job for a few years now.
Lately, I started fiddling with Rust, to learn new concepts and understand what the hype is all about (see the Stack Overflow Survey 2023)

It looks like more and more JS tools are written in Rust lately:

Why would someone want to do this?

Putting both of them together is a way to get the best of both word: develop in an easy to learn and widespread language, Javascript, and optimize critical parts with Rust.

So, how can Rust and Javascript be brought together in a codebase? Let's have a look at napi-rs!

Starting a new project with napi-rs

npx @napi-rs/cli new

The default project defines the following src/lib.rs file:

#![deny(clippy::all)]

#[macro_use]
extern crate napi_derive;

#[napi]
pub fn sum(a: i32, b: i32) -> i32 {
  a + b
}

Running yarn build will create:

Using the sum function is straight-forward:

const { sum } = require("./index.js");

console.log(sum(40, 2));

JSON.parse vs Serde

Serde is a Rust library that allows serializing to and deserializing from various data formats, including JSON.
Let's compare how fast we can go with Serde + napi-rs compared to JS default JSON parsing.

For this test, we'll parse objects with the following shape:

{
  name: string
  phoneNumbers: string[]
}

The Rust code for the Serde-based parse function looks like this:

#![deny(clippy::all)]

#[macro_use]
extern crate napi_derive;

use serde::{Deserialize, Serialize};

#[napi(constructor)]
#[derive(Serialize, Deserialize)]
pub struct Person {
	pub name: String,
	pub phones: Vec<String>,
}

#[napi]
pub fn parse(data: String) -> napi::Result<Person> {
	Ok(serde_json::from_str(&data)?)
}

Some configuration has to be done in Cargo.toml on both serde and napi to make this code work:

  • Add serde-json to the list of features for napi:
napi = { ..., features = ["napi4","serde-json",] }
  • Add the derive feature to serde:
serde = { ..., features = ["derive"] }

Error handling

Rust and JS have very different error handling systems.

napi::Result<T> allows to turn a Rust Error into a JS exception automatically.
Running the parse function from a JS file with an invalid JSON throws an exception:

const index = require("./index.js");
const parsedValue = index.parse("Invalid JSON");

console.log(parsedValue);
const parsedValue = index.parse("Invalid JSON");
                          ^

Error: expected value at line 1 column 1
    at ... {
  code: 'InvalidArg'
}

parse also throws an exception if the received data doesn't have the right shape:

const index = require("./index.js");

const parsedValue = index.parse('{"name": "John Doe"}');
console.log(parsedValue);
const parsedValue = index.parse('{"name": "John Doe"}');
                          ^

Error: missing field `phones` at line 1 column 20
    at ... {
  code: 'InvalidArg'
}

A quick micro-benchmark

Since Serde does parsing AND validation, here is the function parse will be compared against:

const yup = require("yup");

const schema = yup.object({
  name: yup.string(),
  phones: yup.array(yup.string()),
});

const parseJs = (data) => {
  const d = JSON.parse(data);
  return schema.validateSync(d);
};

And the benchmark function:

const benchmark = (name, f, size) => {
  console.time(name);

  for (let i = 0; i < size; i++) {
    f(`{
        "name": "John Doe",
        "phones": [
            "+33 123456789"
        ]
      }`);
  }

  console.timeEnd(name);
};

On 1M calls, the results are as follows:

JS: 8.356s
Rust: 2.966s

Running tests on 1000 iterations with objects of different sizes:

phones array size Rust (ms) JS (ms)
1 4,758 51,946
10 9,334 41,266
100 61,586 220,485
1000 499,942 1987
10000 5338 22685

In this benchmark, the Rust parsing function seems to be 4x faster than the JS one.

Conclusion on this experiment

napi-rs allows to easily use the power of Rust in a Javascript codebase:

The code I used for this experiment can be found on this github repository

Back to home