Writing a Node Library in Rust

Writing a Node Library in Rust

Metlo is an open source API security tool you can setup in < 15 minutes that inventories your endpoints, detects bad actors and blocks malicious traffic in real time. We do this by shipping an agent that integrates with any tech stack that analyzes and blocks API traffic as its coming in.

The issue is that we needed to make our agent support a ton of platforms (like Nginx, Node, Kubernetes, Go, AWS Traffic-Mirroring, etc...). So either we could rewrite our agent, or we could be smart about it.

In came rust, with its ability to create a C-compatible library that could then be consumed using various FFI libraries on the required language, or using low latency communication systems like GRPC or pipes.

Here we’ll explore how we did this for Node.js, using a rust-based library called Neon and the performance improvement Rust had for a few different types of tasks.

What is Neon?

As described by Neon itself,

“Neon is a library and toolchain for embedding Rust in your Node.js apps and libraries.”

So, essentially, Neon enables us to run Rust code along with, and inside our Node.js code.

Let’s look at a tiny example here:‌

Setting up the project

npm init neon hello_world -y

This sets up an npm package with some starter code for setting up a Rust library. Here is the built Cargo file:

Cargo.toml ‌
[package]
name = "hello_world"
version = "0.1.0"
license = "ISC"
edition = "2021"
exclude = ["index.node"]

[lib]
crate-type = ["cdylib"] 

[dependencies]

[dependencies.neon]
version = "0.10"
default-features = false
features = ["napi-6"] 

Nothing too interesting here, but take notice that the 'crate-type' is not the standard 'bin' or 'lib', but rather 'cdylib'. What this means is that Rust will create a C-compatible dynamic library. In addition, there’s a dependency already inserted for Neon, targeting the node-api v6, so this module will run fine on node v10.20.0 onwards. The full compatibility matrix is available here: https://nodejs.org/api/n-api.html#node-api-version-matrix

What about the starter code that got generated?

src/lib.rs ‌
use neon::prelude::*;

fn hello(mut cx: FunctionContext) -> JsResult<JsString> {   
    Ok(cx.string("hello node"))
}

#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> { 
    cx.export_function("hello", hello)?; Ok(()) 
} 

So we have a function main that has the '#[neon::main]' attribute. Note that the function that’s attributed as such doesn’t necessarily need to have the name 'main'. That has one parameter, 'cx' of type ModuleContext. This is essentially making an equivalent of a node module here. And then within that module, we can export functions as we would in a node project. Here, we do that with the function 'hello'.

The 'hello' function itself takes in a mutable param cx of type FunctionContext that exports a JsResult of type JsString. In that function, we return a Result containing a JsString. This result automatically gets cast to a JsResult. Also, note that the JsString is constructed within the context of the parameter 'cx', so the string constructed here is a valid js string, not necessarily a Rust style string.

Let’s build this project.

npm run build 

This command generates an artifact by the name of 'index.node'. This contains all of the exported Rust code and what we’ll be using within Node to communicate with.

Now that we have the artifact built, let’s add an index.js file, which we’ll use to test our code.

const hello = require("./index.node").hello;
console.log(hello()); 

And as we can see, it outputs as:

~/work/neon-hello-world$ node
Welcome to Node.js v18.12.1.
Type ".help" for more information.
> const hello = require("./index.node").hello
undefined
> console.log(hello())
hello node
undefined 

A more complex example

So that was a rather toyish example. How about, we try to fetch some data from the AnimeChan API?

That’s a solid block of code. But what are we doing here? Let’s look at the major components

  • struct AnimeChanResponseObject
    • Is the struct representing the response object from the API
    • We have an implementation on this to export the struct in a JS-accessible format.
  • fn runtime
    • Started up the Tokio runtime (https://tokio.rs/) and store it as a static object, so it can be reused.
  • async fn fetch_api
    • Fetches the response from the AnimeChan API (as of writing, the official AnimeChan API is down, so we’re using a mirror http://animechan.melosh.space).
  • fn get_anime_quote
    • Utilizes the Tokio API to run the async code and send the response back over a Node.js promise.
    • We also pass two parameters to this,
      • name: A required parameter
      • Page: An optional parameter.
  • fn main
    • Exports the module containing our Rust code and corresponding bindings as Node-accessible code.

And the corresponding code in node:

And here are the results:‌

~/work/neon-complex$ node index.js
Diff (rust) : 977
Diff (axios): 1030 

You can see that its a bit faster, going from 1030ms down to 977ms. It might be worth it the safety of rust but the speed definitely isn't worth it!  This difference is much more visible on CPU-heavy tasks, where Rust's inherently lean nature and ability to leverage multiple threads shine even more Let's try a few more examples.

Regexes

Let's create a similar package as we did above, and add this code for rust in the lib.rs file.

Also, add the regex crate

~/work/neon-regex$ cargo add regex

And use this for the javascript benchmark

The regexes were taken from rust-leipzig/regex-performance, and some code was adapted from mariomka/regex-benchmark.

Pattern Node Time Rust Time Improvement Ratio
Twain 1.098237 0.445926 2.462823428
(?i)Twain 7.273893 1.823619 3.988713103
[a-z]shing 6.11484 0.87526 6.986312639
Huck[a-zA-Z]+|Saw[a-zA-Z]+ 5.89834 1.204285 4.897794127
\b\w+nn\b 47.206398 88.812226 0.5315303999
[a-q][^u-z]{13}x 178.988168 1214.6896 0.1473530094
Tom|Sawyer|Huckleberry|Finn 15.087842 0.763358 19.76509318
(?i)Tom|Sawyer|Huckleberry|Finn 30.284651 1.856011 16.31706439
.{0,2}(Tom|Sawyer|Huckleberry|Finn) 38.097949 18.038199 2.112070556
.{2,4}(Tom|Sawyer|Huckleberry|Finn) 40.393531 18.038189 2.23933406
Tom.{10,25}river|river.{10,25}Tom 8.696396 1.523119 5.709597215
[a-zA-Z]+ing 75.122859 4.810666 15.6158958
\s[a-zA-Z]{0,12}ing\s 56.035154 20.528713 2.729598977
([A-Za-z]awyer|[A-Za-z]inn)\s 9.663153 17.703633 0.5458288138
["'][^"']{0,30}[?!.]["'] 10.881608 4.635093 2.347656886
\u221E|\u2713 5.79936 0.570572 10.16411601
\p{Sm} 126.242933 17.661804 7.147793793

That's a pretty interesting result card. The performance ranges from 0.14x to about 19.76x, with an average of 6.1x improvement. Also, 14 out of the 17 regexes show better performance on Rust compared to the Node regex engine.

An interesting thing to note is that this is largely a engine improvement, since the Node regex engine is itself a highly performant piece of code written largely in C++.

Pi digits

Let's do something which is a bit more implementation agnostic, and try to calculate upto the n digits on Pi.

These examples are adapted from benchmarks-game. The algorithm used is a variant of the sequential spigot algorithm.

We will be using the rug crate for working with arbitrary length integers.

~/work/neon-pi$ cargo add rug

Here's the Rust code:

And the wrapper for running the rust code in Node:

const x = require(".")
let start = new Date().getTime()
x.find_pi(10000);
let end = new Date().getTime()
console.log(`Time(ms): ${end - start}`)

And here's the Node code

How do we do here ?

~/work/neon-pi$ node test-rust.js
Time(ms): 381
~/work/neon-pi$ node test-node.js
Time(ms): 6909

That's an 18x improvement! Not too bad for a tiny bit extra effort.

More docs on Neon can be found here, along with some more examples on Neon's Github repo.

How we use the bindings at Metlo

These bindings make it possible to have a core library with battle-tested code, while still having native performance. This enabled us to support multiple frameworks, more or less, out of the box with minimal configuration required.

Express:

import { initExpress as metlo } from "metlo";
...
const app = express();
...

app.use(
    metlo({
        key: <YOUR_METLO_API_KEY>,
        host: "https://app.metlo.com:8081",   
    })
); 

Koa:

import { initKoa as metlo } from "metlo";
...
const app = new Koa();
...
app.use(
    metlo({
        key: <YOUR_METLO_API_KEY>,
        host: "https://app.metlo.com:8081",
    })
); 

Fastify:

import { initFastify as metlo } from "metlo";
...
const fastify = Fastify();
...
fastify.register(
    metlo({
        key: <YOUR_METLO_API_KEY>,
        host: "https://app.metlo.com:8081",
    })
); 
‌

At Metlo, we were able to support all three of these node frameworks, with less than a hundred lines of code dedicated to each since our core logic is now common to rust. And it's a pretty similar story for other languages, frameworks, and technologies. Using this common code we've built agents for Go, Python and Nginx as well! In addition to the code being shared, our rust agent is memory-safe, non-blocking, and adds latency on the order of hundreds of  microseconds at most.

You can see more about how you can set up Metlo for one of  these frameworks among many others on docs.metlo.com