June 14th, 2024

Bridging React Native and Rust via JSI

Integrating React Native into the Ditto ecosystem was no small task. Here's how we leveraged our existing Rust Core and built our SDK using React Native’s New Architecture and JavaScript Interface (JSI)!

Teodor Ciuraru

Teodor Ciuraru

Engineer - SDKs - React Native

At Ditto, our mission is to help developers build incredible applications on any platform without having to worry about complex backend and networking issues. With this in mind, one of our main objectives is to find solutions for extending our core product (written in Rust) to as many platforms and frameworks as possible. When I joined, Ditto was already available to various branches of the mobile ecosystem: iOS, Android, C++, JavaScript, C#, and Rust. Given my previous mobile development experience, I was first tasked with creating the Ditto React Native SDK.

In this blog post, I’ll discuss how we approached building our React Native SDK and review the options we considered and ultimately landed on. I’ll then go into detail about how we approached bridging our Rust core logic with React Native via JSI. I’ll finally walk you through an example of creating a React Native module based on a template and setting it up to bridge a Rust library.

If you want to explore all the code in this article, you view the github repo here: rn-jsi-rust-bridging.

Plan of Action

The team’s first step was exploring and deciding on the architectural options. We needed a way to expose the Rust core to JavaScript and do it efficiently. And bonus points for allowing cross-platform expansion! I was already familiar with React Native’s legacy and new architecture (TurboModules & Fabric) and considered both viable options, but we had to weigh their pros and cons. We narrowed down our findings to two primary options:

1. SDK embedding design

Standard old architecture integration for React Native

Integrating the current iOS and Android SDKs under a native module (library) project would’ve given us the advantage of fast time-to-market and delegating development responsibilities to the underlying products. On the downside, it would’ve significantly increased the overhead due to the extra layers of indirection; moreover, it would’ve required more workarounds to extend to other platforms.

2. "New Architecture" design

JavaScript Interface (JSI) Integration for React Native

Instead of embedding a minimum of two other SDKs, given React Native’s New Architecture, we could now create a platform-agnostic C++ layer that has Rust interoperability out-of-the-box and enables several cross-platform extensions (iOS, Android, macOS, Windows) with minimal additional overhead.

The advantages of this approach are several:

  • You can leverage React Native’s New Architecture perks.
  • You gain direct control over the SDK’s integration with the core module, minimizing layers of indirection. This control enhances communication efficiency between the core and your application. Still, it increases your team’s responsibilities, implying closer collaboration with the core team on API design and deeper involvement with C++.
  • Cross-platform is readily accessible and easier to maintain due to the single source-of-truth C++ base.

The New Architecture approach was a clear winner but wasn’t free of tradeoffs. The primary disadvantage was that switching to TurboModules increased backward compatibility complexity with legacy architecture apps. Another disadvantage would be its learning curve, but we approached the switch as a long-term investment and decided that the time expenditure was worth it.


💡

To amend the backward compatibility issue, we decided to only use the JSI component of the new architecture in conjunction with legacy Native Modules. This allows us to keep most of the new architecture’s advantages and accept full architectural compatibility (Native Modules are forward-compatible 👌) while dismissing the TurboModules initialization time performance boost. This seems to be non-significant for our case.

Although the strategy was clear, the groundwork laid by the JavaScript (JS) SDK hinted at another advantageous improvement, uncovering an extra architectural option for us.

Leveraging the JavaScript SDK Implementation

JS SDK and RN SDK Partnership

The JS team was already bridging Rust logic through WebAssembly (`wasm-bindgen`) and node (`Node-API`). It was only natural for us to continue with this approach, treating the React Native project as another extension of the JavaScript SDK and reusing most of the codebase by building over the unified groundwork that was already in place.

By plugging in the React Native slice, we can access the JS SDK’s codebase (tests included!) while duplicating logic at a minimum. With the right abstractions and minimal bridging, we'll see that we can create a full-fledged React Native SDK only by typing in JSI C++ marshaling methods.

Creating a React Native SDK JSI Project

Scaffolding

You can quickly assemble a React Native library project starting point via:

npx create-react-native-library@latest react-native-jsi-module

Be sure to enter your project details and select the `Native Module > C++ for Android & iOS` template, as this is the closest to our intended setup.

Getting started with your React Native library project

We explored the folder structure and saw that it created the connections to call C++ code. Still, the project is not currently using JSI but calling the low-level code via the iOS and Android platforms’ interoperability features. iOS supports C++ through Objective-C, while Android uses Java Native Interface (JNI) to call into the contents of the `cpp` folder.

Android uses Java Native Interface (JNI) to call into the contents of the cpp folder

Next, we will explore how to model the project structure to integrate JSI for enhanced performance (reducing the bridging serialization bottleneck, unified memory management), synchronous calls, and aligning your app for the New Architecture. To do this, we will write our sample `multiply()` function in C++, configure JSI for iOS and Android subprojects, and see the results in JavaScript.

C++

Inside `cpp/react-native-jsi-module.h` we will expose `bridgeJSIFunctions()`:

// cpp/react-native-jsi-module.h

#ifndef JSIMODULE_H
#define JSIMODULE_H

// iOS has the JSI module included out-of-the-box through Pods, 
// while Android will include it using `CMakeLists.txt`.
#include <jsi/jsi.h>

using namespace facebook::jsi;

namespace jsimodule {
  void bridgeJSIFunctions(Runtime &jsi);
}

#endif /* JSIMODULE_H */

We then modify the default `multiply` function and rewrite it under JSI parlance in the implementation file:

// cpp/react-native-jsi-module.cpp

#include "react-native-jsi-module.h"

namespace jsimodule {

// Temporary C++ that we want to be imported from Rust.
double cpp_multiply(double a, double b)
{
    return a * b;
}
  
void bridgeJSIFunctions(Runtime &jsi) {
  // Define `multiplyJSI` using JSI idioms.
  auto multiplyJSI = Function::createFromHostFunction(
    jsi,
    PropNameID::forAscii(jsi, "multiplyJSI"),
    2, // number of arguments
    [](Runtime &runtime, const Value &thisValue, const Value *arguments, size_t count) -> Value {
      if (count < 2) {
        throw JSError(runtime, "multiply() expects 2 arguments");
      }

      double a = arguments[0].asNumber();
      double b = arguments[1].asNumber();

      double ret = cpp_multiply(a, b);
      
      return Value(ret);
    }
  );

  // Export `multiply` to React Native's global object
  jsi.global().setProperty(jsi, "multiply", std::move(multiplyJSI));
}

} // namespace jsimodule

We draw several observations:

  • We have created the umbrella function, `bridgeJSIFunctions()`, which will be called on app start and expose all its underlying lambda functions as properties on the RN `global` object. You usually create multiple JSI functions and `setProperty()` pairs through your projects; therefore, when this file gets unwieldy, you might want to extract each of these functions into other files.
  • `multiply()` now adheres to the JSI boilerplate and gives us access to the following parameters:
    • `runtime` refers to the underlying JavaScript engine's runtime, usually Hermes or JavaScriptCore. You can learn more about switching between the two here. Any interaction with JSI has to go through this handle.
    • `thisValue` represents the value of `this` within the scope of the JavaScript function call.
    • `arguments` is an array of `jsi::Value` instances representing the arguments passed to the function.
    • `count` is the number of arguments provided to the function.

Everything seems settled, except we can’t call this method directly from JS unless we alter the platform code.

iOS

Getting inside the `ios` folder, we’ll first remove the new architecture `Spec` logic, as we will not be using codegen for this project, and leave only the default Native Modules boilerplate:

// ios/JsiModule.h

#ifdef __cplusplus
#import "react-native-jsi-module.h"
#endif

#import <React/RCTBridgeModule.h>
@interface JsiModule : NSObject <RCTBridgeModule>

@end

Inside the implementation file, we aim to get a synchronous, main-queue handle on JSI via the React Native’s `RCTBridge` object – this will eventually install the JSI functions by calling the `C++ bridgeJSIFunctions()`. The reason for the thread meddling logic is that JSI is the foundational building block for our module, and we don’t want to allow any JavaScript execution until it doesn’t get configured correctly from the start.

Let’s delete the old serialization code inside `ios/JsiModule.mm` and replace it with logic that calls `bridgeJSIFunctions()`. Optionally, we can create a `bridgePlatformFunctions()` that bridges iOS-specific logic as JSI functions. An example would be exposing the device name, which requires iOS platform code for retrieval and couldn’t be done (or maybe not easily) with a shared C++ JSI function.

// ios/JsiModule.mm

#import "JsiModule.h"
#import "react-native-jsi-module.h"

#import <React/RCTBridge+Private.h>
#import <jsi/jsi.h>

using namespace facebook::jsi;

@implementation JsiModule

RCT_EXPORT_MODULE()

// 👇 Add these methods:

// Convenient method to ensure JSI functions will be installed on the main thread.
+ (BOOL)requiresMainQueueSetup {
  return YES;
}

// Ensures no JavaScript will be executed until JSI is set up.
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(bridgeJSIFunctions) {
  RCTBridge* bridge = [RCTBridge currentBridge];
  RCTCxxBridge* cxxBridge = (RCTCxxBridge*)bridge;
  Runtime *jsi = (Runtime *) cxxBridge.runtime;

  jsimodule::bridgeJSIFunctions(*jsi);

  // Optional: bridge iOS-platform specific logic
  bridgePlatformFunctions(*jsi);

  return @true;
}

// Optional:
static void bridgePlatformFunctions(Runtime &jsi) {
  auto getDeviceName = Function::createFromHostFunction(jsi,
                                                        PropNameID::forAscii(jsi, "getDeviceName"),
                                                        0,
                                                        [](Runtime &runtime,
                                                           const Value &thisValue,
                                                           const Value *arguments,
                                                           size_t count) -> Value {

    return String::createFromUtf8(runtime, [[[UIDevice currentDevice] name] UTF8String]);
  });

  jsi.global().setProperty(jsi, "getDeviceName", std::move(getDeviceName));
}

@end

Android

Integrating JSI

JSI doesn’t come bundled with the Android project as it was on iOS, so we need to drag it in. We will do so via `CMakeLists.txt` :

// android/CMakeLists.txt

cmake_minimum_required(VERSION 3.4.1)
project(JsiModule)

set(CMAKE_VERBOSE_MAKEFILE ON)
set(CMAKE_CXX_STANDARD 14)

add_library(react-native-jsi-module SHARED
    ../cpp/react-native-jsi-module.cpp
    cpp-adapter.cpp
)

# Specifies a path to native header files.
include_directories(
    ../cpp
)

// 👇 Add these lines:
find_package(ReactAndroid REQUIRED CONFIG)
target_link_libraries(
    react-native-jsi-module
    ReactAndroid::jsi
    android
)

We continue accommodating the rest of the app to work with the new setup. Add these new lines to your `android/build.gradle` :

// android/build.gradle

android {
	// rest of block
	
  // 👇 Enable Prefab for C++ library integration for JSI.
  buildFeatures {
    prefab true
  }
  
  defaultConfig {
    externalNativeBuild {
      cmake {
        cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all"
        // Solves: `User is using a static STL but library requires a 
        // shared STL [//ReactAndroid/fabricjni]`:
        arguments "-DANDROID_STL=c++_shared" // 👈
      }
    }

💡

Optional: if running on the legacy architecture (`newArchEnabled=false`), you will bump into an error similar to `Execution failed for task ':app:mergeDebugNativeLibs’ [...] 2 files found with path 'lib//libjsi.so'. To solve this, let’s enforce the packaging order of the shared object in `example/android/app/build.gradle`:

// example/android/app/build.gradle

android {
    packagingOptions {
        pickFirst 'lib/x86/libjsi.so'
        pickFirst 'lib/x86_64/libjsi.so'
        pickFirst 'lib/armeabi-v7a/libjsi.so'
        pickFirst 'lib/arm64-v8a/libjsi.so'
    }
    // rest of block
}

Installing JSI

Now that we’ve added JSI to the Android project, we’ll need to continue with platform steps just as we did with iOS. Unfortunately, Android doesn’t directly interface with C++, so we must add intermediate steps through JNI (Java Native Interface). The good part is that this scaffolding is already part of the boilerplate code – we’ll need to adjust it. Let’s remember how we did it for iOS, exporting the `bridgeJSIFunctions()` C++ function, but now for JNI:

// android/cpp-adapter.cpp

#include <jni.h>
#include <jsi/jsi.h>
#include "react-native-jsi-module.h"

using namespace facebook::jsi;

// Optional:
void bridgePlatformFunctions(Runtime &jsi);

extern "C" JNIEXPORT void JNICALL
Java_com_jsimodule_JsiModuleModule_jniBridgeJSIFunctions(JNIEnv *env, jobject thiz, jobject context,
                                                         jlong jsi_pointer)
{
    Runtime *jsi = reinterpret_cast<Runtime *>(jsi_pointer);
    if (jsi)
    {
        jsimodule::bridgeJSIFunctions(*jsi);

        // Optional: bridge platform-specific logic too.
        bridgePlatformFunctions(*jsi);
    }
}

// Optional:
void bridgePlatformFunctions(Runtime &jsi)
{
    auto getDeviceName = Function::createFromHostFunction(jsi,
                                                          PropNameID::forAscii(jsi, "getDeviceName"),
                                                          0,
                                                          [](Runtime &runtime,
                                                             const Value &thisValue,
                                                             const Value *arguments,
                                                             size_t count) -> Value
                                                          {
                                                              // JNI logic for another time!
                                                              return Value(runtime, String::createFromAscii(runtime, "Not yet implemented."));
                                                          });

    jsi.global().setProperty(jsi, "getDeviceName", std::move(getDeviceName));
}

Let’s call this method from our Java implementation now:

// android/src/main/com/jsimodule/JsiModuleModule.java

package com.jsimodule;

import androidx.annotation.NonNull;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.module.annotations.ReactModule;

@ReactModule(name = JsiModuleModule.NAME)
public class JsiModuleModule extends ReactContextBaseJavaModule {
  public static final String NAME = "JsiModule";

	// 👇 Add this line:
  private native void jniBridgeJSIFunctions(ReactApplicationContext context, long jsiPtr);

  public JsiModuleModule(ReactApplicationContext reactContext) {
    super(reactContext);
  }

  @Override
  @NonNull
  public String getName() {
    return NAME;
  }

  // 👇 Same synchronous strategy as with iOS: making it synchronous.
  @ReactMethod(isBlockingSynchronousMethod = true)
  public boolean bridgeJSIFunctions() {
    try {
      System.loadLibrary("react-native-jsi-module");

      ReactApplicationContext context = getReactApplicationContext();
      jniBridgeJSIFunctions(
          context,
          context.getJavaScriptContextHolder().get());
      return true;
    } catch (Exception exception) {
      return false;
    }
  }
}

One observation is that we don’t force the JSI installation to happen on the main (UI) thread as we did with iOS. Android provides flexibility with threading, and React Native's architecture reduces the need for explicit main thread usage – however, we still want it to happen synchronously.

JavaScript

Now that we’ve set up the shared C++ base and JSI on platforms, we can finally call our `multiply()` method from JS. The first action point is to install the JSI bindings on the app start:

// src/index.tsx

import { NativeModules, Platform } from 'react-native';

const LINKING_ERROR =
  `The package 'react-native-jsi-module' doesn't seem to be linked. Make sure: \n\n` +
  Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
  '- You rebuilt the app after installing the package\n' +
  '- You are not using Expo Go\n';

const JsiModule =
  NativeModules.JsiModule ??
  new Proxy(
    {},
    {
      get() {
        throw new Error(LINKING_ERROR);
      },
    }
  );

const jsiCore = global as unknown as {
  multiply(a: number, b: number): number;
};

JsiModule.bridgeJSIFunctions();

export function multiply(a: number, b: number): number {
  return jsiCore.multiply(a, b);
}

The last JSI integration step is modifying the app to switch from the asynchronous `Promise`:

// example/src/App.tsx

// ... rest of page

React.useEffect(() => {
    setResult(multiply(3, 7));
  }, []);

Bridging Rust

We’ll now create a small, `multiply` Rust library and show you how to integrate it with a JSI project.

Create the Rust Executable

Follow these steps for the Rust project one-time setup.

1. Make sure to have Rust installed

curl https://sh.rustup.rs -sSf | sh 
  • You can choose a `minimal` profile, and the `stable` toolchain.

2. Add support for cross-compilation to the mobile architectures.

We will only target iOS devices and arm64 Android emulators for this example:

rustup target add aarch64-apple-ios aarch64-linux-android

3. Create your Rust project under the JSI project folder:

cargo new --lib rust_multiply_lib

4. Adjust the `rust_multiply_lib/Cargo.toml` so that `cargo build` commands produce a C static library

# rust_multiply_lib/Cargo.toml

[package]
name = "rust_multiply_lib"
version = "0.1.0"
edition = "2021"

# 👇 Add this:
[lib]
crate-type = [
    "staticlib", # Generates a C-compatible static library.
    # Optional: Also compile as a Rust library to enable Cargo features 
    # like `tests/`, doc tests, and `examples/`
    "lib",
]

5. We must ensure the library generates all the required files for each possible architecture. We will configure this using `rustflags` in the `.cargo/config.toml.`

cd rust_multiply_lib
mkdir -p .cargo 
touch .cargo/config.toml
# rust_multiply_lib/.cargo/config.toml

# Convenience shorthands.
[alias]
build-ios = ["build", "--target", "aarch64-apple-ios"]
build-android = ["build", "--target", "aarch64-linux-android"]

# Linker configuration settings.
[target.aarch64-apple-ios]
rustflags = [
  "-C", "link-arg=-isysroot", "-C", "link-arg=$(xcrun --sdk iphoneos --show-sdk-path)",
  "-C", "link-arg=-arch", "-C", "link-arg=arm64",
  "-C", "link-arg=-mios-version-min=10.0"
]

[target.aarch64-linux-android]
linker = "aarch64-linux-android21-clang"
ar = "aarch64-linux-android-ar"
  • Rust Code

Let's add a `rust_multiply()` function to `rust_multiply_lib/src/lib.rs`. The `pub extern "C"` directive allows us to export to C/FFI.

// rust_multiply_lib/src/lib.rs

#[no_mangle] pub extern "C"
fn rust_multiply(a: f64, b: f64) -> f64 {
    a * b
}
  • Build and compilation

From the root of our Rust project, let’s compile the Rust code into the C static libraries: `librust_multiply_lib.a`:

cd rust_multiply_lib
cargo build-ios --release
cargo build-android --release

ℹ️

Use the `--release flag` to create an optimized binary suitable for production under `rust_multiply_lib/target/<PLATFORM>/release`. For debugging purposes, omit this flag and the binary artifacts will be generated in the `rust_multiply_lib/target/<PLATFORM>/debug directory`, allowing for easier tracing and debugging.

Assuming we target iOS simulators for this test, we will build and move them to our React Native project’s `ios` and `android` folders for convenient manipulation:

# iOS
cp \
	rust_multiply_lib/target/aarch64-apple-ios/release/librust_multiply_lib.a \
	react-native-jsi-module/ios/ 

# Android:
cp \
	rust_multiply_lib/target/aarch64-linux-android/release/librust_multiply_lib.a \
	react-native-jsi-module/android/

Integrate it into the JSI Project

First, we need to ensure the project includes the binary file. We can do that through our `Podspec` and `CMakeLists.txt`.

// react-native-jsi-module.podspec

// ...rest of file
 
// 👇 Add this line:
s.vendored_libraries = 'ios/librust_multiply_lib.a'

Be sure to reinstall pods to propagate the changes with `cd example/ios; RCT_NEW_ARCH_ENABLED=1 pod install`.

Android

// CMakeLists.txt

// 👇
add_library(rust_multiply STATIC IMPORTED)
set_target_properties(rust_multiply PROPERTIES IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/librust_multiply_lib.a")

find_package(ReactAndroid REQUIRED CONFIG)
target_link_libraries(
    react-native-jsi-module
    ReactAndroid::jsi
    android
    rust_multiply // 👈
)

For this particular example, temporarily remove the other architectures to make it build. Be sure to re-add them in production when you export all the required binaries:

// example/gradle.properties

// 👇 Removed `armeabi-v7a`, `x86` and `x86_64`:
reactNativeArchitectures=arm64-v8a

C++

Let’s now modify our JSI codebase to import the Rust code through the `extern "C"` declarations so that the C++ code knows their existence.

#include "react-native-jsi-module.h"

// 👇 Declaration of the Rust function(s)
extern "C"
{
  // For instance, C++'s `double` type is the equivalent of Rust's `f64`
  double rust_multiply(double a, double b); 
}
// 👆
//
// 💡 If you don't know how the Rust signatures are translated to
// extern "C" declarations, you can use the `cbindgen` tool to
// generate these for you (to a separate file (which you could `#include` here)
// or to the stdout).

namespace jsimodule
{
  void bridgeJSIFunctions(Runtime &jsi)
  {
    auto multiplyJSI = Function::createFromHostFunction(
        jsi,
        PropNameID::forAscii(jsi, "multiplyJSI"),
        2, // number of arguments
        [](Runtime &runtime, const Value &thisValue, const Value *arguments, size_t count) -> Value
        {
          if (count < 2)
          {
            throw JSError(runtime, "multiply() expects 2 arguments");
          }

          double a = arguments[0].asNumber();
          double b = arguments[1].asNumber();

          double ret = rust_multiply(a, b); // 👈

          return Value(ret);
        });

    jsi.global().setProperty(jsi, "multiply", std::move(multiplyJSI));
  }

} // namespace jsimodule

Be sure to run the app on the corresponding architectures and modes – in this example, iOS devices and Android arm64 emulators.

Next steps

As time passed, we integrated multiple improvements at Ditto worth considering. Here’s a list of challenges that were solved or are works in progress and that can be explained in future posts:

  • Targeting all architectures for iOS (using a `lipo`-ed `.xcframework`).
  • Integrating dynamic libraries for Android (`.so`'s).
  • Generating and including C header files for the Rust lib.
  • Bridging Rust processes that need to run on background threads.
  • Memory management over FFI. Implementing advanced JS constructs like `WeakRef` and `FinalizationRegistry`.
  • Hooking into the JavaScript SDK’s source files, unit tests, and CI automation.
  • Moving as much platform code possible to JSI.
  • Switching from a Legacy Module to a TurboModule.

Check out the Repo

rn-jsi-rust-bridging

Resources

Get posts in your inbox

Subscribe to updates and we'll send you occasional emails with posts that we think you'll like.