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
[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
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:
#[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
.
{
"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 intarget/debug
cargo-cp-artifact
copies that output into the root of the project asindex.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:
{
...
"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:
// 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…
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:
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).
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:
<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:
const rustLib = require('hi-rust');
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('rustLib', rustLib)