Example of Using napi-rs with Electron

By Dave Ceddia

You can improve the performance of your Electron apps quite a bit by offloading intensive tasks to Rust.

There are 2 main libraries out there to help you do this: Neon and napi-rs. As it stands today, Neon is more popular, with over 5700 stars on Github, whereas napi-rs has only a little over 800.

That said, stars aren’t everything! For my use case (and as of this writing) napi-rs supports an important feature that Neon doesn’t have yet: the ability for Rust to call back to a JS callback function multiple times.

I went in search of a minimal starter project to get going with Electron + napi-rs, but couldn’t find anything. Hence this post :)

TL;DR: If you just want to clone the project you can find electron-napi-rs on Github.

The rest of this post explains how the pieces fit together.

(btw if you want to use Neon instead of napi-rs, check out Mike Barber’s electron-neon-rust, which is basically the Neon version of what I’m doing here)

A Minimal Project with Electron and napi-rs

I started with the official Electron starter from electron-quick-start. That’ll get an Electron app on the screen.

Then I added the Rust module. This is more or less a copy-paste from napi-rs’s napi-derive-example, with a few relative paths changed.

I’m putting the Rust module in a directory called hi-rust inside the Electron project. We only need to add 4 files:

Cargo.toml

hi-rust/Cargo.toml
[package]
authors = ["LongYinan <lynweklm@gmail.com>"]
edition = "2018"
name = "hi-rust"
version = "0.1.0"

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

[dependencies]
napi = "1.7.5"
napi-derive = "1.1.0"

[build-dependencies]
napi-build = "1.1.0"

(modified to use version numbers instead of relative paths for the [dependencies] and [build-dependencies])

build.rs

hi-rust/build.rs
extern crate napi_build;

fn main() {
  use napi_build::setup;

  setup();
}

(straight out of napi-derive-example)

This build.rs file is special to Rust. You can read more in the Build Scripts section of the Cargo book, but basically Rust will look for a build.rs file and run it before the build, if it’s present.

src/lib.rs

Then there’s the code itself, under the src folder:

hi-rust/src/lib.rs
#[macro_use]
extern crate napi_derive;

use napi::{CallContext, Error, JsNumber, JsObject, JsUnknown, Result, Status};
use std::convert::TryInto;

#[module_exports]
fn init(mut exports: JsObject) -> Result<()> {
  exports.create_named_method("testThrow", test_throw)?;
  exports.create_named_method("fibonacci", fibonacci)?;

  Ok(())
}

#[js_function]
fn test_throw(_ctx: CallContext) -> Result<JsUnknown> {
  Err(Error::from_status(Status::GenericFailure))
}

#[js_function(1)]
fn fibonacci(ctx: CallContext) -> Result<JsNumber> {
  let n = ctx.get::<JsNumber>(0)?.try_into()?;
  ctx.env.create_int64(fibonacci_native(n))
}

#[inline]
fn fibonacci_native(n: i64) -> i64 {
  match n {
    1 | 2 => 1,
    _ => fibonacci_native(n - 1) + fibonacci_native(n - 2),
  }
}

(also straight out of the napi-rs repo)

It exposes 2 Rust functions to JavaScript: test_throw and fibonacci are exposed as testThrow and fibonacci, respectively.

The init is effectively the “entry point” for the JS <-> Rust binding, and this file could call out to whatever Rust code you want.

package.json

Run npm init -y to initialize a default package.json, then add “build” and “install” scripts.

The build script depends on a package for copying out the built Rust binary, so install that with npm install -D cargo-cp-artifact.

hi-rust/package.json
{
  "name": "hi-rust",
  "version": "1.0.0",
  "description": "",
  "main": "index.node",
  "scripts": {
    "install": "npm run build",
    "build": "cargo-cp-artifact -nc index.node -- cargo build --message-format=json-render-diagnostics"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "cargo-cp-artifact": "^0.1.4"
  }
}

The build script effectively does 2 things:

  • cargo build compiles the Rust module and saves the compiled file in target/debug
  • cargo-cp-artifact copies that output into the root of the project as index.node

The install script just makes it easier to run. (You can npm i instead of npm run build)

Release Build

Cargo will compile a Debug build by default, which is slower and larger, but contains debugging symbols.

Make sure to compile a Release build if you want it to be faster and smaller! Append the --release flag to the end of the cargo build command if/when you want to do that.

I did this right away because my app was much slower in Debug mode.

Aside: index.js vs index.node?

An interesting thing happened when I was setting this up!

At first I didn’t change “main” at all, and left its value as the default index.js. Which… worked perfectly fine, even though there was only an index.node file present (no index.js).

I guess Node knows to look for index.node if it can’t find index.js?

Anyway, that was a little unnerving, so I changed the “main” key to point directly to index.node, and that worked fine too. I figure it’s better to point it at a file that actually exists 🤷 At the very least it’ll shave a couple cycles off the import, eh?

Build index.node

Running npm install inside the hi-rust directory will download the required packages and build the index.node file, which is our native Rust code, packaged up so that Node can require() it.

Add the Rust module as a dependency

Back in the top-level Electron project, add the Rust module as a dependency to package.json:

package.json
{
  ...

  "dependencies": {
    "hi-rust": "./hi-rust"
  }
}

Then run npm install and it’ll make a link to the project.

From here on, you can modify and rebuild the Rust project (inside hi-rust) without having to re-run npm install.

Expose the Rust module with preload.js

We have native code, it’s packaged and built as a module that Node can import. Now we need to import it inside the Electron app.

There’s 2 ways to do this: the insecure way, and the better way.

The insecure way is to set nodeIntegration: true so that we can require() node modules directly from our Electron renderer process. It makes for easier code, but the main downside is the massive security hole it opens up.

Why not to set nodeIntegration: true in Electron

With the insecure setup, any JS run by the renderer has full access to the user’s system. That means File APIs, Network APIs, Process APIs, etc, etc.

It can do anything the user can do. Like download and run some malicious program, or ransomware their home directory.

Writing the code with nodeIntegration: true makes for slightly less hassle at the expense of a gaping security hole.

Read more about the security behind this in the Electron docs.

The better way

The better way is to use Electron’s preload file to selectively expose functionality to the renderer process a.k.a. the “main world”, which is what we’ll do here.

In main.js, the Electron starter project sets up preload.js as the designated preload file. The preloader has access to both Node APIs and browser APIs, but the crucial difference is that it’s isolated: the renderer can’t reach in and call stuff from preload, unless preload has explicitly exposed it.

So we expose our Rust module from preload.js like so:

preload.js
// Import the Rust library and expose it globally as `rustLib`
// in the renderer (also accessible as `window.rustLib`)
const rustLib = require('hi-rust')
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('rustLib', rustLib)

Note this exposes the whole library! You’ll want to pause and reflect for a second whether this is a good idea from a security standpoint. If malicious code could call any of your library’s functions, what could happen?

As a potentially safer alternative, you can expose individual functions…

preload.js
contextBridge.exposeInMainWorld('rustLib', {
  fibonacci: rustLib.fibonacci
})

Or wrap the calls in a function, to ensure only certain arguments are allowed through, or do other checks:

preload.js
contextBridge.exposeInMainWorld('rustLib', {
  fibonacci: (num) => {
    if (num > 42) return;
    return rustLib.fibonacci(num);
  }
})

You can also use Electron’s IPC system to send requests back and forth between main and renderer processes.

Call the Rust code from Electron in renderer.js

Now we can finally call the Rust function from the renderer!

Once the DOM is ready, we call rustLib.fibonacci, referencing the exposed global rustLib that came from the preload script, and store the result in an element (that we still need to create).

renderer.js
window.addEventListener('DOMContentLoaded', () => {
  const result = rustLib.fibonacci(8);
  const content = document.querySelector('#rust-content');
  content.innerHTML = `This number came from Rust! <strong>${result}</strong>`;
});

If you run this now you’ll probably get an error like “Cannot access property innerHTML of null”, because the element doesn’t exist yet.

Let’s add a div with id="rust-content" to contain the result:

index.html
<html>
  <!-- snip -->
  <body>
    <!-- snip -->
    <div id="rust-content"></div>
  </body>
</html>

It works!

At this point you should be able to run npm start from the top-level (Electron) directory, and the app should pop up with a number computed by Rust :)

…synchronously!

One thing to note that this is a synchronous call to Rust. If the fibonacci function is super slow, or we were to call some other function that blocked, our app would freeze up.

You can try this yourself: try passing a big number like 1234 to fibonacci, instead of 8.

Help! Errors!

Here are a couple errors I hit along the way and how I fixed them. If you’re following along, you probably won’t hit these, but I’m listing them here just in case.

A missing package.json

I got this error when I forgot to create a package.json inside the Rust library’s directory:

Internal Error: Cannot find module '/Users/dceddia/Projects/electron-napi-rs/hi-rust/package.json'
Require stack:
- /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js
Require stack:
- /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js
    at Function.Module._resolveFilename (node:internal/modules/cjs/loader:933:15)
    at Function.Module._load (node:internal/modules/cjs/loader:778:27)
    at Module.require (node:internal/modules/cjs/loader:1005:19)
    at require (node:internal/modules/cjs/helpers:94:18)
    at getNapiConfig (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:23450:19)
    at BuildCommand.<lt;anonymous> (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:23579:30)
    at Generator.next (<lt;anonymous>)
    at /usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:65:61
    at new Promise (<lt;anonymous>)
    at __async (/usr/local/lib/node_modules/@napi-rs/cli/scripts/index.js:49:10)

The fix ended up being pretty simple: npm init -y created a package.json file and solved the error.

Exporting incorrectly from Electron’s preload.js

My first attempt to expose the Rust library to Electron’s renderer process was something like:

const rustLib = require('hi-rust');
window.rustLib = rustLib;

I was able to start Electron just fine, but it logged an error in the browser console, indicating that window.rustLib was undefined… which meant my line was being ignored.

Uncaught TypeError: Cannot read property 'fibonacci' of undefined

I think it’s because contextIsolation is ON by default, so anything added to the window object won’t be visible.

The fix was to use Electron’s contextBridge module, specifically the exposeInMainWorld function:

preload.js
const rustLib = require('hi-rust');
const { contextBridge } = require('electron');

contextBridge.exposeInMainWorld('rustLib', rustLib)