Using Rust in react-native with jsi-rs
Preamble
Imagine you are working on a react-native mobile app in which you want to implement messaging with end-to-end encryption.
You either have to implement your own protocol or use an existing protocol.
But because you don’t have the resources to implement your protocol and want to make it secure, you decide to use an existing one.
Luckily, you find the signal protocol, a well-established and secure end-to-end encryption protocol with an open-source implementation.
You need to bind the library functions to your app, and then you’re done. Better said than done, and you find yourself digging into a rabbit hole of a month-long journey with many different approaches.
Libsignal
Let’s first get an overview of the libsignal library and the possible approaches to use it in a react-native app.
The libsignal library is written in Rust and already provides bindings for Java, Swift, and NodeJS, which replaces the deprecated libsignal-protocol-javascript library.
The libsignal-protocol-javascript could easily be integrated as it is all written in JavaScript, but it isn’t maintained anymore and doesn’t support the latest version of the signal protocol.
Alternatively, the libsignal-protocol-typescript can be used, a typescript port of the libsignal-protocol-javascript that seems more maintained.
The problem is that it relies on a third party (privacyresearchgroup) to maintain the library and implement new features securely, and some features need to be added to the library, e.g., kyber quantum resistant key exchange.
That means all that’s left is using the Rust libsignal library directly.
Embedding libraries in react-native
There are many different approaches to embedding and binding libraries in a react-native app.
RCT_EXPORT_MODULE - Create Swift, Objective-C, Java, and JavaScript bindings
The regular approach of linking libraries in react-native is to link the c-abi library and create Swift (iOS) and Java (Android) bindings. Those platform-specific bindings can then be exposed to the JavaScript runtime using the react-native bridge.
This is what exposing the encrypt function would look like using this approach:
Android Java method:
@ReactMethod
public String encrypt(String message) {
return nativeEncrypt(message);
}
Android Java JNI export:
extern "C"
JNIEXPORT jstring JNICALL
Java_com_libsignal_LibsignalModule_nativeEncrypt(JNIEnv *env, jclass type, jstring message) {
return signal_encrypt_message(messages);
}
iOS Swift implementation:
@objc(Libsignal)
class Libsignal: NSObject {
@objc(encrypt:message:)
func encrypt(message: String) -> String {
return signal_encrypt_message(message)
}
}
iOS Objective-C export:
@interface RCT_EXPORT_MODULE(Libsignal, NSObject)
RCT_EXTERN_METHOD(encrypt:(NSString *)message);
This approach has the advantage that the already existing swift and java bindings of the libsignal library can be reused.
The problem is that those pre-existing bindings don’t have the same API interface, and a glue layer has to be created in Swift and Java to expose them to the JavaScript runtime.
This glue layer would need to implement all 302 functions of the libsignal library, in both Swift and Java and an additional JavaScript wrapper, which also need to be kept up to date with the latest version of the libsignal library.
Not the best choice; let’s look at the other options:
Embed the node bindings of the Rust library
As the libsignal library already exposes bindings for nodejs, one could think that they can be used directly in a react-native app.
The advantage of this approach is that there is no need to create and maintain bindings, as all native and JS functions of the libsignal library can be reused.
However, as the react-native app uses the Hermes engine the node bindings can’t be used directly, as it doesn’t support the Node-API (NAPI).
The Hermes team is working on integrating the Node-API into the Hermes engine, but it’s not finished yet.
Luckily, Microsoft has already partially created the Node-API for the Hermes engine in their hermes-windows fork.
The problem is that the hermes-windows fork is missing some features (like promises for threadsafe functions) of the Node-API, which are needed by the libsignal library and can’t be used directly in a react-native JSI environment.
Gladly, there is a third approach:
Use the Hermes JavaScript Interface (JSI) from Rust
JSI is a C++ API by the Hermes JS engine that is able to call JS functions from native code and vice versa. It allows JavaScript code to call native functions, access native objects, and register native modules.
One benefit of this method is the ability to expose all functions from the Rust library to the JavaScript runtime once, without the need to create platform-specific bindings.
But for this approach to work the C++ JSI headers must be bound to Rust. At first, cxx and autocxx seem promising tools to bind the C++ headers to Rust. However, after a lot of testing, it turns out that cxx and autocxx don’t support some language features of the JSI headers, which are needed to expose the functions of the libsignal library.
Gladly Ibiyemi Abiodun made a package called jsi-rs, which links the JSI headers to Rust to be able to call the JSI api directly from Rust.
There might have also been other approaches, like using react-native’s turbomodules, using RPC with a separate node process, or using step one and only writing a few bindings for the necessary use case. However, this approach of using jsi-rs is a good alternative until the hermes engine supports the Node-API.
Writing bindings with jsi-rs
After following the setup instructions for jsi-rs and cloning the example app, one notices that only Android support is implemented.
To see how you can use jsi-rs for iOS have a look at this PR, which adds an iOS example.
Additionally, be aware that async
support is not fully supported yet and you have to resort to awaiting futures with futures::executor::block_on(result)?
, which blocks the js thread.
Also, there is no documentation available, so here are some examples of how to use jsi-rs:
Logging
pub fn console_log(message: &str, rt: &mut RuntimeHandle) -> anyhow::Result<()> {
let console = PropName::new("console", rt);
let console = rt.global().get(console, rt);
let console = jsi::JsiObject::from_value(&console, rt)
.ok_or(JsiDeserializeError::custom("Expected an object"))?;
let console_log = console.get(PropName::new("log", rt), rt);
let console_log = jsi::JsiObject::from_value(&console_log, rt)
.ok_or(JsiDeserializeError::custom("Expected an object"))?;
let console_log = jsi::JsiFn::from_object(&console_log, rt)
.ok_or(JsiDeserializeError::custom("Expected a function"))?;
console_log.call([jsi::JsiString::new(message, rt).into_value(rt)], rt)?;
Ok(())
}
console_log("Hello from Rust", &mut rt).ok();
Exporting a HostObject
let host_object = ModuleAPI;
let host_object = host_object.into_value(&mut rt);
rt.global().set(PropName::new("ModuleAPI", &mut rt), &host_object, &mut rt);
#[host_object()]
impl ModuleAPI {
}
Exporting functions
For some primitive types you can directly use the primitive parameter type. Here are some examples on how to explicitly use the JSI api.
#[host_object()]
impl ModuleAPI {
// assuming NodeJS buffers are available in the global scope
#[host_object(method as buffer_to_string)]
pub fn buffer_to_string<'rt>(&self, _rt: &mut RuntimeHandle<'rt>, buffer: JsiValue<'rt>) -> anyhow::Result<JsiValue<'rt>> {
if !buffer.is_object() {
return Err(anyhow!("Expected an Buffer"));
}
let buffer = JsiObject::from_value(&buffer, rt).ok_or(JsiDeserializeError::custom("Expected an Buffer"))?;
let buffer = buffer.get(PropName::new("buffer", rt), rt);
let buffer = JsiArrayBuffer::from_value(&buffer, rt).ok_or(JsiDeserializeError::custom("Expected an ArrayBuffer"))?;
let buffer = buffer.data(rt).to_vec();
let string = String::from_utf8(buffer).ok_or(JsiDeserializeError::custom("Expected a valid UTF-8 string"))?;
Ok(JsiString::new(&string, rt).into_value(rt))
}
#[host_object(method as string_to_buffer)]
pub fn string_to_buffer<'rt>(&self, _rt: &mut RuntimeHandle<'rt>, value: JsiValue<'rt>) -> anyhow::Result<JsiValue<'rt>> {
if !value.is_string() {
return Err(anyhow!("Expected a string"));
}
let value = JsiString::from_value(&value, rt)
.ok_or(JsiDeserializeError::custom("Expected a string"))?;
let mut output = String::new();
let mut formatter = fmt::Formatter::new(&mut output);
value.fmt(&mut formatter, rt)?;
let bytes = output.as_bytes();
let buffer_ctor = rt.global().get(PropName::new("Buffer", rt), rt);
let buffer_ctor: JsiFn = buffer_ctor
.try_into_js(rt)
.ok_or(JsiSerializeError::custom("Buffer constructor not found"))?;
let buffer = buffer_ctor
.call_as_constructor(vec![JsiValue::new_number(bytes.len() as f64)], rt)
.map_err(|e| JsiSerializeError::custom(format!("Buffer constructor failed: {:?}", e)))?;
let buffer = JsiObject::from_value(&buffer, rt)
.ok_or(JsiSerializeError::custom("Expected an object"))?;
let array_buffer = buffer.get(PropName::new("buffer", rt), rt);
let array_buffer: JsiArrayBuffer = array_buffer
.try_into_js(rt)
.ok_or(JsiSerializeError::custom("Expected an ArrayBuffer"))?;
array_buffer.data(rt).copy_from_slice(bytes);
Ok(buffer.into_value(rt))
}
#[host_object(method as bool_to_number)]
pub fn bool_to_number<'rt>(&self, _rt: &mut RuntimeHandle<'rt>, bool: JsiValue<'rt>) -> anyhow::Result<JsiValue<'rt>> {
if !value.is_bool() {
return Err(anyhow!("Expected a boolean"));
}
let bool = bool::from_value(&value, rt).ok_or(JsiDeserializeError::custom("Expected a boolean"))?
Ok(JsiValue::new_number(bool as i64 as f64, rt).into_value(rt))
}
}