- Published on
- -7 min read
Exposing a Rust Library to Node with Napi-rs
Table of Contents
I unapologetically shill Rust every chance I get, annoying my coworkers by insisting that everything would be better if we just used Rust. At this point, it has become a bit of a meme, so no one listens to me (rightfully so).
To finally put an end to the meme, I decided to research ways we could incorporate Rust into our systems. Ideally, we would create a new service in Rust, but since we are not yet big on microservices, the best approach would be to call Rust directly from our Node monolith.
Since Node is written in C++, it provides ways to call native code via addons. The napi-rs library helps with the boilerplate of exposing Rust code as a Node addon.
Napi-rs along with generating the node addon will also generate typescript type definitions and has a nice CLI to more easily make addons for all popular systems/architectures.
Why?
BECAUSE I CAN.
For real though, there are several reasons why creating a native Rust addon for a Node app could be beneficial:
- Native code can be much more performant than JavaScript for certain use cases (although the JIT does help a lot).
- Compared to C++ addons, building and deploying with napi is much easier. However, I have limited experience managing C++ addons.
- Many Rust libraries would be nice to reuse in Node.
- Subjectively, Rust is a great language to write in. It has been voted the most loved language for the last few years for a reason.
Setting up napi
I'm going to experiment with napi in the codebase from the build a db in rust series, branch with all the code here.
Most of the following is taken from the napi getting started docs
You first need to install the napi CLI, which can be done with your favorite node package manager of choice. While I could use npm/yarn/pnpm, I like to use nix flakes, so I added the following to my project's flake file
buildInputs = with pkgs; [
napi-rs-cli
];
The CLI generates a Rust crate with the appropriate build scripts to build the node addon, along with the necessary node boilerplate to load the addon with type definitions. Additionally, it includes some GitHub actions to build and publish the npm packages.
Run the CLI with
napi new
You will get an interactive prompt to give a package name and folder path (useful if you use cargo workspaces). The docs recommend prefixing the package name with a npm scope since napi will publish multiple packages for different architectures.
The generated rust will look like
// imports excluded
#[napi]
pub fn sum(a: i32, b: i32) -> i32 {
a + b
}
A generated test file that uses the node addon looks like
import test from 'ava'
import { sum } from '../index.js'
test('sum from native', (t) => {
t.is(sum(1, 2), 3)
})
The other main piece is the index.js
file at the package root which has all the logic for loading the right addon for each architecture.
you can then run the following to build the addon, generate TS types, and run tests
npm install # replace with your node package manager of choice
npm run build # builds the rust code into a node addon + add typescript definitons
npm run test # runs the sample js test file using the node addon
After the build step, you should see and index.d.ts
file that looks like
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export function sum(a: number, b: number): number;
Using napi
To explore more of napi, let's write a rust function that does something slightly more sophisticated with my SQL_JR db.
#[napi]
pub fn basic_query() -> Vec<Vec<String>> {
let mut exec = sql_jr_execution::Execution::new();
exec.parse_and_run(
"
CREATE TABLE foo (
col1 int,
col2 string
);
",
)
.expect("create works..");
exec.parse_and_run(
"
INSERT INTO foo
VALUES
1, 'aString';
",
)
.expect("insert 1 works..");
exec.parse_and_run(
"
INSERT INTO foo
VALUES
4, 'aDiffString with spaces';
",
)
.expect("insert 2 works..");
let res = exec
.parse_and_run(
"
SELECT
col1,
col2
FROM
foo;
",
)
.expect("select works");
match res {
ExecResponse::Select(table_iter) => {
let columns: Vec<String> = table_iter
.columns
.iter()
.map(|col| col.name.to_string())
.collect();
let rows: Vec<Vec<_>> = table_iter
.map(|row| {
columns
.iter()
.map(move |col| row.get(col).to_string())
.collect()
})
.collect();
rows
}
_ => unreachable!(),
}
}
The above code is very jank rust, but it will get the job done for exploring for now. After running the build we can use it in this test file
import test from "ava";
import { basicQuery } from "../index.js";
test("basic test", (t) => {
t.deepEqual(basicQuery(), [
["1", "aString"],
["4", "aDiffString with spaces"],
]);
});
One thing you'll notice is the casing of basic_query
in rust was changed to basicQuery
in JS.
Allowed values
When exposing a function with the napi macro you are limited to what types are supported in the arguments/returns types. The function doc page lists them here. The TLDR is most "primitive" rust types are supported and any struct you add the #[napi]
macro too. This could cause issues with third-party libraries, so you probably will need to make your own wrapper types to pass them between rust and node.
Exposing Classes
While the above works it's very restrictive and crappy rust, so let's expose a wrapper around sql_jr_execution::Execution
, so the node side can run arbitrary queries + track state.
Like we did with functions you can add the #[napi]
macro above structs to expose them as a JS class. While we could add that macro in the sql_jr_execution
crate on the Execution
struct itself I think it will be better to have a napi crate + explicit wrapper structs to keep the API of the node addon more stable. Also, as you will come to see there are some limitations on the exposed code that would be nice to not litter the rest of the code base with.
So let's make a NodeExec
struct like this
#[napi(js_name = "Execution")]
pub struct NodeExec {
execution: Execution,
}
#[napi]
impl NodeExec {
#[napi(constructor)]
pub fn new() -> Self {
Self {
execution: Execution::new(),
}
}
}
This will expose a JS class called Execution
with a constructor corresponding to the new
function.
Let's add a class method that will run a query and return an array of records when it was a select query.
/// A List of rows returned by the query.
/// Each row is a map of col => data as string
type QueryRes = Vec<HashMap<String, String>>;
#[napi]
impl NodeExec {
#[napi(ts_return_type = "Array<Record<string,string>>")]
pub fn query(&mut self, query: String) -> napi::Result<QueryRes> {
use napi::{Error, Status};
let res = self
.execution
.parse_and_run(&query)
// Probably a good idea to impl From<SqlError<_> for napi::Error in sql_jr_execution
// gated behind a napi feature flag
.map_err(|e| Error::new(Status::GenericFailure, format!("{}", e)))?;
Ok(match res {
ExecResponse::Select(table_iter) => {
let columns: Vec<String> = table_iter
.columns
.iter()
.map(|col| col.name.to_string())
.collect();
table_iter
.map(|row| {
columns
.iter()
.map(move |col| (col.clone(), row.get(col).to_string()))
.collect()
})
.collect()
}
_ => Vec::new(),
})
}
}
First, we need to manually set ts_return_type = "Array<Record<string,string>>"
since we used a type alias in the function signatures. This is due to some limitations in how proc macros in rust work.
Second we needed to convert the returned Error from parse_and_run
into a napi::Error
with .map_err(|e| Error::new(Status::GenericFailure, format!("{}", e)))
. This is not the best conversion since it would just stringify the execution error, but it's not the end of the world.
The generated typescript looks like
/* tslint:disable */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export function basicQuery(): Array<Array<string>>;
export type NodeExec = Execution;
export class Execution {
constructor();
query(query: string): Array<Record<string, string>>;
}
To use this we can add the following test.
import test from "ava";
import { Execution } from '../index.js';
test('exec struct', (t) => {
const exec = new Execution();
t.deepEqual(
exec.query(`
CREATE TABLE foo (
col1 int,
col2 string
);
`),
[]
);
t.deepEqual(
exec.query(`
INSERT INTO foo
VALUES
1, 'aString';
`),
[]
);
t.deepEqual(
exec.query(`
INSERT INTO foo
VALUES
4, 'aDiffString with spaces';
`),
[]
);
t.deepEqual(
exec.query(`
SELECT
col1,
col2
FROM
foo;
`),
[
{
col1: '1',
col2: 'aString',
},
{
col1: '4',
col2: 'aDiffString with spaces',
},
]
);
});
test('parse error', (t) => {
const exec = new Execution();
t.throws(() => exec.query(`sad`), { code: 'GenericFailure', message: 'Parse Error' });
});
In the success case, we get an array of records, in the failure case a JS error is thrown.
Async Support
My db does not have any async code yet so let's play with a dummy example.
To enable async support you can enable the tokio_rt
feature on the napi crate in the Cargo.toml
[dependencies]
napi = { version = "2.10.0", default-features = false, features = ["napi4", "tokio_rt"] }
Let's add a dummy function on the execution class that will accept a promise as a parameter that will resolve to the query to run
#[napi]
impl NodeExec {
/// # Safety
///
/// The execution struct should not be handled in multiple async functions
/// at a time.
#[napi(ts_return_type = "Promise<Array<Record<string,string>>>")]
pub async unsafe fn query_async(
&mut self,
query_promise: Promise<String>,
) -> napi::Result<QueryRes> {
let query = query_promise.await?; // awaits the js promise like any other rust future
self.query(query)
}
}
One thing you will notice is that we needed to mark the function as unsafe
since we take in &mut self
in an async
function. The issue is rust can not enforce its borrow checker rules across the boundary with node. In sync code, this is not an issue since node is single-threaded. However, in async contexts, another async task could mutate the Execution struct while a different task using that struct is suspended.
We can then test it like this
test('async function', async (t) => {
const exec = new Execution();
const get_query = async () => {
return `
CREATE TABLE foo (
col1 int,
col2 string
);
`;
};
t.deepEqual(await exec.queryAsync(get_query()), []);
});
napi limitations
One of the main issues with napi (or any rust FFI) is that some “rustisms” don't transfer well to JS/TS. For example, JS uses exceptions and not Result
so any napi function returning a result will throw an error in node. Similar issue for Option
which just becomes undefined | T
. Would be cool if napi had a feature flag to make rust results be exposed as a https://github.com/supermacro/neverthrow result or a similar lib.
The other main issue is there is overhead involved when passing large/complex types between node and rust. Rust needs to serialize/deserialize the JS types into a format it can use. There are workarounds like using buffers/typed arrays but that requires some manual work on both ends to manually serialize/deserialize the objects. In the napi v3 goals, they plan on adding ways to make working around this simpler.
Lastly, there is currently only experimental JS generator support (with no docs on it that I can find), and no support for async generators/node streams. While you could manually add JS code to support these would be much nicer if napi did it for you. Stream support is planned for v3, so hopefully the others will follow.
Wrap up
Overall napi is very useable and with my limited use of cross-language FFI tools, it has had the nicest user experience. While it has limitations/some issues overall nothing major is in your way from exposing very useable and performant code if you put in a little extra work.