Calling Rust From Node.js - FFI

Calling Rust From Node.js - FFI

Incrementally adopt Rust in your Node.js projects

I was recently bitten by the Rust bug. Its wild ideas have consumed me and I am forever changed. But unfortunately, most of what I do is better served by another language but I still want to dabble. I got much further down the rabbit hole than I intended with this endeavor. And it was a lot of fun. Let me share some of my findings with you!

While researching how I could call rust from node I came across the idea of using a foreign function interface. A foreign function interface or “FFI” is a way to call code in one language from another. I was surprised by how many ways I could write rust and call it in node. Let's dig into one of them!

The premise here is to find good ways to integrate rust into your existing projects. Code bases won't be rewritten overnight so a path to incremental adoption is essential. allowing existing projects to offload computationally heavy tasks seems like a good fit.

I come from Javascript land so I'm comfortable with node. Most of what you would use node for is well suited by the tools that are available with a few notable exceptions. And in this (somewhat contrived) example, we will simulate a computationally heavy task by writing a function that calculates Fibonacci to a specific place in the sequence.

The Setup

Let's start out in an empty directory and make a new folder named node. Check out the repo here if you have any questions. This is where we will keep our source files. in the node folder, initialize a new project with npm init -y and run npm install express && npm install ffi-napi &. This will install all the packages we need

Node.js & Express

import express from "express";

const app = express();

app.use(express.json());

function fibonacci(n) {
  if (n === 0) {
    return 0;
  } else if (n === 1) {
    return 1;
  }
  let a = 0,
    b = 1,
    c = n;

  for (let i = 2; i <= n; i++) {
    c = a + b;
    a = b;
    b = c;
  }

  return c;
}

app.get("/fibonacci", (req, res) => {
  if (typeof req.body.place === "number") {

    const data = fibonacci(req.body.place);
    return res.json({ data });
  }

  res.status(400).json({ errror: "missing place digit" });
});

app.listen(3000);

The “/fibonacci” route will calculate a given position in the Fibonacci Sequence by taking a number from the body of the request, ensuring it is a number, and then passing it off to our rust library for computation. Otherwise, we will throw a 400 HTTP error.

The Rusty Bits

Start a new rust project with cargo new —lib rust. You’ll need to have rust set up already. If you don’t you can find the instructions here. On macOS, I installed rust via Homebrew originally. This makes updating your rust version more difficult so I suggest the recommended method.

We need to update the Cargo.toml file. The [lib] section is where we have specific options for our library. The rlib option is to ensure we can still call our code in a rust source file and is not needed if you don’t want to use the library natively. cdylib will generate a C dynamic library we will be able to import into our Express app.

[lib]
name = "node_math"
crate_type = ['rlib', "cdylib"]

Then we have the code that we will be importing into our Express app. This is where our calculation takes place. We have some required syntax in order to make the code callable from node.

From the docs: We can use extern to create an interface that allows other languages to call rust functions. (…) we add the extern keyword and specify the ABI to use just before the fn keyword for the relevant function. We also need to add a #[no_mangle] annotation to tell the rust compiler not to mangle the name of this function.

What isn’t mentioned here is that we must specify the pub modifier to make the function public as rust defaults member visibility to private. which means we wouldn't be able to call our code in a different file. In our lib.rs

#[no_mangle]
pub extern "C" fn fibonacci(n: u8) -> u64 {
    if n == 0 {
        return 0;
    } else if n == 1 {
        return 1;
    }

    let mut sum = 0;
    let mut last = 0;
    let mut curr = 1;
    for _i in 1..n {
        sum = last + curr;
        last = curr;
        curr = sum;
    }
    sum
}

This code is almost identical to the Javascript with a few syntactical changes. We are typing our input as a u64 meaning that we will accept any nonnegative number up to 2^64 − 1. The function returns a u64. Read more about primitive types here

Also since rust is expression based, the last line of the block is returned by default and does not have a semi-colon. Call cargo build —release to build a production-ready version of our library.

Using ffi-napi

This module uses libffi to make native function calls on dynamic numbers and types of arguments. It relies on the ref, ref-struct, ref-array, and ref-union modules to provide conversions between Javascript and native types.

Let's bring in the ffi-napi package. Now we can call our library in our web server. We will be importing the C dynamic library we compiled from Rust. Specify which functions we want to call and what the inputs and outputs are. I have specified a relative path here to the rust sibling directory that we set up earlier.

import ffi from 'ffi-napi'

const lib = ffi.Library("../rust/target/release/libnode_math.dylib", {
  fibonacci: ["int", ["int"]],
});

And updated the GET route. We will be calling our imported rust library to do the calculations for us. Now our Javascript will take in a given number from a request body, pass it off to our rust library and give us the result back!

app.get("/fibonacci", (req, res) => {
  if (typeof req.body.place === "number") {
    const result = lib.fibonacci(req.body.place);
    return res.json({ data: result });
  }

  res.status(400).json({ errror: "missing place digit" });
});

This is huge! We crossed the barrier from Node.js into rust and back. A whole new world of opportunities opens up to us.

I wanna go fast!

gofast.webp

So now that we know how to call our rust code with an FFI we can start rewriting everything, right? You could but there are some caveats. The biggest one is the overhead associated with the call. The native hard-coded binding is orders of magnitude faster. This slowdown can be attributed to the dynamic nature of the work that must be done at invocation time for foreign functions.

As I alluded to in the intro, there are a few ways to write rust and then call it in node. The idea was to introduce the idea of calling code from one language to another. In the next post, we will look at writing native node modules with Neon, which will put us one step closer to getting our rust in production.