Expo with Rust
A recipe for how to integrate Rust in a React Native project with UniFFI and Expo
React Native + Rust with Expo and UniFFI
Motivation
Skip to Step-by-step if you want the meat and you want it raw.
I wrote this because I could not find anyone else who's done this on the internet. And this wouldn't be a good recipe page without me telling you my life story first.
This all began with the desire to add encryption to my overengineered shopping list/notes app to protect users from myself when sharing data through my server. I searched for solutions but Expo-"Crypto" only supports hashing functions and the SubtleCrypto API is not available in the Hermes JS engine. So I decided to use the platform native cryptography APIs in Kotlin and Swift powered by an Expo Module. However I gave up 90% through the Kotlin implementation of an ECIES encryption function as it was really frustrating to develop Kotlin in the Expo module because I was not familiar with the Kotlin tool ecosystem and had to blindly first-time develop Kotlin without any autocomplete, suggestions or other IDE tooling like errors and warnings before compilation (skill issue). This is probably easily solvable for more experienced developers in this field but I was not invested enough to find out as I wanted to get it done quickly. Developing with cryptographic functions was hard enough as this field is riddled with cryptic TLAs and every ecosystem comes with its own interpretation on how to expose them in the best way. The straw that finally broke the camel's back was when I could not control the format in which a public key was exported. And throughout the whole process I was already eyeing Rust. I am more familiar with Rust and the tooling is a bliss compared to the skill issues I had with Kotlin. So when I realized I had to write a manual implementation for exporting a public key in a foreign language with all the quirks that come with the API exposing the public key (insert rant about Java/Kotlin having a weird understanding of bytes and the EC public key having weird number of bytes) I decided that it would take the same amount of effort to get Rust running. Rust would also have the added benefit that I could use it in iOS and Android and other exciting project ideas using OpenMLS and Automerge. Then I'd only need to maintain Swift and Kotlin glue code. From some previous quick online search I knew it was theoretically possible use it.
So I dug deeper. From my previous work on the Kotlin implementation I knew that ideally I wanted to pass and return byte arrays from and to my encryption function. Passing UInt8Array
s from JS to Swifts Data
and Kotlin's ByteArray
was already handled well by Expo and React Native. What was left for me was to pass the data from Swift/Kotlin to Rusts Vec<u8>
(so many names for bytes). The existing examples on the internet sadly only used simple data types like integers and I encountered another skill issue in passing complex data like a byte array through the C FFI layer of Rust. In one of my attempts I managed to pass the data on Android using the JNA and JNI library to handle the conversion but I failed on iOS as I didn't have these utilites for Swift. I used Safe-FFI but found no explanation on how to generate their C Vector representation in Swift to pass through FFI to Rust. I assumed I needed to get the pointer to the data and it's length + capacity and pass those into the C struct to reconstruct it in Rust to a Rust Vector but without Swift knowledge nor any C skills I gave up on that. My past experience is in higher level languages before I learned Rust so my understanding of lower languages like C is only basic and theoretical. In the future I want to get better at my FFI and C understanding but that needed to wait for another day to not get completely sidetracked. A huge help was the Expo Rust demo by Daniel Cortes. Many of the parts below and in the repository are based on it like the scripts for automating these steps below. But this example too had the problem of only presenting a basic add function example with numbers using Rust's C FFI. This is where Mozilla's UniFFI comes into play and allowed me in my third attempt to pass byte arrays. It handles generating all the binding code to pass byte arrays for you.
Leftovers: There are examples on the internet on how to use iOS/Android + UniFFI and Expo + Rust but none with Expo + UniFFI so I created this article. Downsides
- Additional complexity
- More copying and allocation of data when passing between layers
Doing it manually would take as long as finding out how to integrate Rust, a more familiar language and ecosystem I had good experiences with. I'll spare the rant about Kotlin/Java weird understanding of bytes and the EC key having an odd number of bytes. I tried to use the platform native cryptography libraries in an Expo module but gave up on an almost done Kotlin implementation. I because I became frustrated that I could not control the format of the exported public key. This is based on the work by Daniel Cortes
Prerequisites
Add rust targets
Android
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
iOS
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
Install cargo-ndk for Android
cargo install cargo-ndk
Swiftformat and kotlinlint
You might want to add those too.
Step-by-step
-
Create expo project
npx create-expo-app -t expo-template-blank-typescript third-time
And change directory to the newly created project
cd third-time
Optionally, run the app to test if everything works.
-
Create local expo module
npx create-expo-module my-rust-module --local
This will be our native code for the specific platform that acts as glue to call the Rust code. It gets called by React Native to call the Rust code.
-
Create Rust project
cargo new --lib native_rust
-
Add UniFFI dependency to
native_rust/Cargo.toml
as build and normal dependencycd native_rust cargo add uniffi cargo add --build uniffi --features build
native_rust/Cargo.toml
should now contain something like this[dependencies] uniffi = "0.27.1" [build-dependencies] uniffi = { version = "0.27.1", features = ["build"] }
-
Set crate type to
cdylib
for Android andstaticlib
for iOS innative_rust/Cargo.toml
[lib] crate-type = ["staticlib", "cdylib"]
-
Add native_rust/src/math.udl
namespace math { i32 add(i32 a, i32 b); };
This is the definition for the Rust API we want to make available. It helps UniFFI to identify what APIs are available and generate the necessary bindings.
You can see that it uses signed 32-bit integers. I chose this because from my experience Java and therefore Kotlin is a bit weird with unsigned integers and it's a topic I don't want to get into right now. I might try unsigned integers again at some point but for now it is like this.
-
Add
native_rust/build.rs
fn main() { uniffi::generate_scaffolding("src/math.udl").unwrap(); }
This generates the UniFFI scaffolding Rust code based on the math.udl from the step before.
The code is put out into the Rust build script
OUT_DIR
(build time environment variable). You don't have to worry about what that is but if you are interested, you can find out the path for this variable with a tool like cargo-out-dir. If we didn't do this step we would have to run the scaffold manually through the UniFFI CLI. -
Update
native_rust/src/lib.rs
pub fn add(left: i32, right: i32) -> i32 { left + right } uniffi::include_scaffolding!("math");
This updates the library code to match the math.udl definition. The macro in the last line then includes the bindings code generated at build time from the
OUT_DIR
as described in the last step. -
Add UniFFI CLI binary
Add binary compile option to
native_rust/Cargo.toml
[[bin]] name = "uniffi-bindgen" path = "uniffi-bindgen.rs"
Add the
native_rust/uniffi-bindgen.rs
filefn main() { uniffi::uniffi_bindgen_main() }
This step allows us to call the UniFFI CLI to generate Swift and Kotlin bindings through our Rust project when it is compiled as a binary and not as a library. If you don't really get it, trust me I don't get it either. It has something to do with not being able to call the UniFFI CLI directly as one of our dependencies or this only being possible in Rust nightly. See the UniFFI book for their explanation.
-
Generate native platform projects
npx expo prebuild
These are required to use native code with our expo project as we can not use Expo Go anymore. See the Expo documentation for details.
If the android project does not compile when you try to run it (npx expo run:android
) after prebuild, try to open it in Android Studio.
open -a "/Applications/Android Studio.app" ./android
This happens on my system for every new Expo project but Android Studio seems to sort it out somehow. Definitely a point to look into in the future.
Android
-
Compile Rust code for Android
cargo ndk --target aarch64-linux-android --platform 21 -- build --release --lib
Notice the
--platform
argument. I think it might be important to be set to the minimum SDK version (21 in this case) of themodules/my-rust-module/android/build.gradle
file. This might have been an error I made in previous attempts. So if your app crashes after starting on Android without giving you any indication to what might have happened think about this.Also add
--lib
to compile the project as library as opposed to binary that runs the UniFFI CLI.This step needs to be repeated for every target platform your app should run on. We only target
aarch64-linux-android
in this example for simplicity. I recommend automating lots of this later. -
Generate Kotlin bindings
cargo run --features=uniffi/cli --bin uniffi-bindgen generate --library target/aarch64-linux-android/release/libnative_rust.so --language kotlin --out-dir generated/kotlin
Now we run our Rust project as binary to use the UniFFI CLI (see
--bin
). This generates the Kotlin bindings code. -
Create directories to for the compiled Rust library for Android
mkdir -p modules/my-rust-module/android/src/main/jniLibs/arm64-v8a
As with the compile step. This would need to be repeated for all other targets with the following target mapping to directory:
- aarch64-linux-android -> arm64-v8a
- armv7-linux-androideabi -> armeabi-v7a
- i686-linux-android -> x86
- x86_64-linux-android -> x86_64
-
Copy the compiled
.so
file fromtarget/aarch64-linux-android/release
to the jniLibs directorycp native_rust/target/aarch64-linux-android/release/libnative_rust.so modules/my-rust-module/android/src/main/jniLibs/arm64-v8a/libnative_rust.so
-
Copy generated Kotlin bindings to the Android project
cp -r generated/kotlin/uniffi modules/my-rust-module/android/src/main/java/uniffi
-
Add JNA dependency to
modules/my-rust-module/android/build.gradle
dependencies { //... implementation "net.java.dev.jna:jna:5.13.0@aar" }
The generated UniFFI bindings need this dependency.
-
Add import to bindings in
modules/my-rust-module/android/src/main/java/expo/modules/myrustmodule/MyRustModule.kt
import uniffi.math.add
-
Update code in
modules/my-rust-module/android/src/main/java/expo/modules/myrustmodule/MyRustModule.kt
to use Rust functionFunction("hello") { val result = add(3, 4) "Hello world! 👋 $result" }
iOS
The same as Android but simpler
-
Compile Rust code for iOS
cargo build --release --target aarch64-apple-ios-sim --lib
Or without
-sim
if targeting a real iOS device -
Generate Swift bindings
cargo run --features=uniffi/cli --bin uniffi-bindgen generate --library target/aarch64-apple-ios-sim/release/libnative_rust_lib.dylib --out-dir generated/swift/ --language swift
-
Generate rust subdirectory
mkdir modules/my-rust-module/ios/rust
This is just for organizing the generated artifacts in the ios directory.
-
Copy the compiled
.a
file to the iOS projectcp native_rust/target/aarch64-apple-ios-sim/release/libnative_rust.a modules/my-rust-module/ios/rust/
-
Copy the generated Swift bindings to the iOS project
cp -r native_rust/generated/ modules/my-rust-module/ios/rust
Notice the / after generated. It is important to copy the contents of the generated directory and not the directory itself.
-
Update
modules/my-rust-module/ios/MyRustModule.podspec
to include the Rust librarys.vendored_libraries = 'rust/libnative_rust.a'
-
Update the podfile (from the root directory)
pod install --project-directory=ios
-
Change the code in
modules/my-rust-module/ios/MyRustModule.swift
to use the Rust functionFunction("hello") { let result = add(a: 1, b: 2) return "Hello world! 👋 \(result)" }
Fun fact, you don't need to add an import in Swift for the function in
math.swift