uniffi-rs
is a suite of projects to allow Rust to be used from other languages. It was started at Mozilla to facilitate building cross-platform components in Rust which could be run on Android and iOS.
It has since grown to support for other languages not in use at Mozilla.
+
uniffi-bindgen-react-native
is the project that houses the bindings generators for react-native.
It supports all language features that uniffi-rs
supports, including:
- calling functions from Typescript to Rust, synchronous and asynchronous.
- calling functions from Rust to Typescript, synchronous and asynchronous.
- objects with methods, including:
- garbage collection integration.
- uniffi traits
- custom types
It contains tooling to generate bindings from Hermes via JSI, and to generate the code to create turbo-modules.
Before you start
Better resources are available than this site for installing these dependencies.
Below are a list of the dependencies, and a non-comprehensive instructions on how to get them onto your system.
Set up React Native environment
Make sure you have a functional React Native environment including Node.js, Android Studio and Xcode. The official documentation contains steps to achieve this for different platforms.
uniffi-bindgen-react-native
is designed to integrate with projects created with react-native-builder-bob.
react-native-builder-bob assumes that you have yarn
installed. If you don’t already, you can install it by following the official documentation.
Install Rust
If Rust isn’t already installed on your system, you should install it as per the rust-lang.org install instructions.
This will add cargo
and rustup
to your path, which are the main entry points into Rust.
Install C++ tooling
These commands will add the tooling needed to compile and run the generated C++ code.
Optionally, clang-format
can be installed to format the generated C++ code.
For MacOS, using homebrew:
brew install cmake ninja clang-format
For Debian flavoured Linux:
apt-get install cmake ninja clang-format
For generared Typescript, the existing prettier
installation is detected and your configuration is used.
Android
Add the Android specific targets
This command adds the backends for the Rust compiler to emit machine code for different Android architectures.
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
i686-linux-android \
x86_64-linux-android
Install cargo-ndk
This cargo extension handles all the environment configuration needed for successfully building libraries for Android from a Rust codebase, with support for generating the correct jniLibs directory structure.
cargo install cargo-ndk
iOS
Ensure xcodebuild
is available
This command checks if Xcode command line tools are available, and if not, will start the installation process.
xcode-select --install
Add the iOS specific targets
This command adds the backends for the Rust compiler to emit machine code for different iOS architectures.
rustup target add \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
Step-by-step tutorial
This tutorial will get you started, by taking an existing Rust crate, and building a React Native library from it.
By the end of this tutorial you will:
- have a working turbo-module library,
- an example app, running in both Android and iOS,
- seen how to set up
uniffi-bindgen-react-native
for your library.
Step 1: Start with builder-bob
We first use create-react-native-library
to generate our basic turbo-module library.
npx create-react-native-library@latest my-rust-lib
create-react-native-library
has changed a few things around recently.
These steps have been tested with 0.35.1
and 0.41.2
, which at time of writing, is the latest
.
react-native
also changes from time to time.
These steps have been tested with versions 0.75
and 0.76
, which at time of writing is the latest
.
The important bits are:
✔ What type of library do you want to develop? › Turbo module
✔ Which languages do you want to use? › C++ for Android & iOS
✔ What type of example app do you want to create? › Vanilla
For following along, here are the rest of my answers.
✔ What is the name of the npm package? … react-native-my-rust-lib
✔ What is the description for the package? … My first React Native library in Rust
✔ What is the name of package author? … James Hugman
✔ What is the email address for the package author? … james@nospam.fm
✔ What is the URL for the package author? … https://github.com/jhugman
✔ What is the URL for the repository? … https://github.com/jhugman/react-native-my-rust-lib
✔ What type of library do you want to develop? › Turbo module
✔ Which languages do you want to use? › C++ for Android & iOS
✔ What type of example app do you want to create? › Vanilla
✔ Project created successfully at my-rust-lib!
Most of the rest of the command line guide will be done within the directory this has just created.
cd my-rust-lib
Verify everything works before adding Rust:
For iOS:
yarn
(cd example/ios && pod install)
yarn example start
Then i
for iOS.
After that has launched, then you can hit the a
key to launch Android.
We should, if all has gone to plan, see Result: 21
on screen.
Step 2: Add uniffi-bindgen-react-native
to the project
Using yarn
add the uniffi-bindgen-react-native
package to your project.
yarn add uniffi-bindgen-react-native
While this is before the first release, we’re installing straight from github.
yarn add uniffi-bindgen-react-native@https://github.com/jhugman/uniffi-bindgen-react-native
Opening package.json
add the following:
"scripts": {
+ "ubrn:ios": "ubrn build ios --config ubrn.config.yaml --and-generate && (cd example/ios && pod install)",
+ "ubrn:android": "ubrn build android --config ubrn.config.yaml --and-generate",
+ "ubrn:checkout": "ubrn checkout --config ubrn.config.yaml",
+ "ubrn:clean": "rm -Rf cpp/ android/src/main/java ios/ src/Native* src/generated/ src/index.ts*",
"example": "yarn workspace react-native-my-rust-lib-example",
"test": "jest",
"typecheck": "tsc",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
"prepare": "bob build",
"release": "release-it"
},
You can call the config file whatever you want, I have called it ubrn.config.yaml
in this example.
For now, let’s just clean the files out we don’t need:
yarn ubrn:clean
If you’re going to be using the uniffi-bindgen-react-native
command direct from the command line, it may be worth setting up an alias. In bash you can do this:
alias ubrn=$(yarn ubrn --path)
There is a guide to the ubrn
command here.
While this is before the first release, we’re installing straight from local node_modules
.
After release, the C++ runtime will be published to Cocoa Pods.
Until then, you need to add the dependency to the app’s Podfile, in this case example/ios/Podfile
:
use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
+ # We need to specify this here in the app because we can't add a local dependency within
+ # the react-native-matrix-rust-sdk
+ pod 'uniffi-bindgen-react-native', :path => '../../node_modules/uniffi-bindgen-react-native'
Step 3: Create the ubrn.config.yaml
file
Full documentation on how to configure your library can be found in the YAML configuration file page of this book.
For now, we just want to get started; let’s start with an existing Rust crate that has uniffi bindings.
---
name: MyRustLib
rust:
repo: https://github.com/ianthetechie/uniffi-starter
branch: main
manifestPath: rust/foobar/Cargo.toml
Step 4: Checkout the Rust code
Now, you should be able to checkout the Rust into the library.
yarn ubrn:checkout
This will checkout the uniffi-starter
repo into the rust_modules
directory within your project.
You may want to add to .gitignore
at this point:
+# From uniffi-bindgen-react-native
+rust_modules/
+*.a
Step 4: Build the Rust
Building for iOS will:
- Build the Rust crate for iOS, including the uniffi scaffolding in Rust.
- Build an
xcframework
for Xcode to pick up. - Generate the typescript and C++ bindings between Hermes and the Rust.
- Generate the files to set up the JS -> Objective C -> C++ installation flow for the turbo-module.
- Re-run the
Podfile
in theexample/ios
directory so Xcode can see the C++ files.
yarn ubrn:ios
Building for Android will:
- Build the Rust crate for Android, including the uniffi scaffolding in Rust.
- Copy the files into the correct place in for
gradlew
to pick them up. - Generate the files to set up the JS -> Java -> C++ installation flow for the turbo-module.
- Generate the files to make a turbo-module from the C++.
yarn ubrn:android
You can change the targets that get built by adding a comma separated list to the ubrn build android
and ubrn build ios
commands.
yarn ubrn:android --targets aarch64-linux-android,armv7-linux-androideabi
This won’t happen with the uniffi-starter
library, however a common error is to not enable a staticlib
crate type in the project’s Cargo.toml
. Instructions on how to do this are given here.
Step 5: Write an example app exercising the Rust API
Here, we’re editing the app file at example/src/App.tsx
.
First we delete the starter code given to us by create-react-native-library
:
import { StyleSheet, View, Text } from 'react-native';
-import { multiply } from 'react-native-my-rust-lib';
-
-const result = multiply(3, 7);
export default function App() {
Next, add the following lines in place of the lines we just deleted:
import { Calculator, type BinaryOperator, SafeAddition, ComputationResult } from '../../src';
// A Rust object
const calculator = new Calculator();
// A Rust object implementing the Rust trait BinaryOperator
const addOp = new SafeAddition();
// A Typescript class, implementing BinaryOperator
class SafeMultiply implements BinaryOperator {
perform(lhs: bigint, rhs: bigint): bigint {
return lhs * rhs;
}
}
const multOp = new SafeMultiply();
// bigints
const three = 3n;
const seven = 7n;
// Perform the calculation, and to get an object
// representing the computation result.
const computation: ComputationResult = calculator
.calculate(addOp, three, three)
.calculateMore(multOp, seven)
.lastResult()!;
// Unpack the bigint value into a string.
const result = computation.value.toString();
Step 6: Run the example app
Now you can run the apps on Android and iOS:
yarn example start
As with the starter app from create-react-native-library
, there is very little to look at.
We should, if all has gone to plan, see Result: 42
on screen.
Step 7: Make changes in the Rust
We can edit the Rust, in this case in rust_modules/uniffi-starter/rust/foobar/src/lib.rs
.
If you’re already familiar with Rust, you will notice that there is very little unusual about this file, apart from a few uniffi
proc macros scattered here or there.
If you’re not familiar with Rust, you might add a function to the Rust:
#![allow(unused)] fn main() { #[uniffi::export] pub fn greet(who: String) -> String { format!("Hello, {who}!") } }
Then run either yarn ubrn:ios
or yarn ubrn:android
.
Once either of those are run, you should be able to import the greet
function into App.tsx
.
Appendix: the Rust
The Rust library is presented here for comparison with the App.tsx
above.
All credit should go to the author, ianthetechie.
#![allow(unused)] fn main() { use std::sync::Arc; use std::time::{Duration, Instant}; // You must call this once uniffi::setup_scaffolding!(); // What follows is an intentionally ridiculous whirlwind tour of how you'd expose a complex API to UniFFI. #[derive(Debug, PartialEq, uniffi::Enum)] pub enum ComputationState { /// Initial state with no value computed Init, Computed { result: ComputationResult }, } #[derive(Copy, Clone, Debug, PartialEq, uniffi::Record)] pub struct ComputationResult { pub value: i64, pub computation_time: Duration, } #[derive(Debug, PartialEq, thiserror::Error, uniffi::Error)] pub enum ComputationError { #[error("Division by zero is not allowed.")] DivisionByZero, #[error("Result overflowed the numeric type bounds.")] Overflow, #[error("There is no existing computation state, so you cannot perform this operation.")] IllegalComputationWithInitState, } /// A binary operator that performs some mathematical operation with two numbers. #[uniffi::export(with_foreign)] pub trait BinaryOperator: Send + Sync { fn perform(&self, lhs: i64, rhs: i64) -> Result<i64, ComputationError>; } /// A somewhat silly demonstration of functional core/imperative shell in the form of a calculator with arbitrary operators. /// /// Operations return a new calculator with updated internal state reflecting the computation. #[derive(PartialEq, Debug, uniffi::Object)] pub struct Calculator { state: ComputationState, } #[uniffi::export] impl Calculator { #[uniffi::constructor] pub fn new() -> Self { Self { state: ComputationState::Init } } pub fn last_result(&self) -> Option<ComputationResult> { match self.state { ComputationState::Init => None, ComputationState::Computed { result } => Some(result) } } /// Performs a calculation using the supplied binary operator and operands. pub fn calculate(&self, op: Arc<dyn BinaryOperator>, lhs: i64, rhs: i64) -> Result<Calculator, ComputationError> { let start = Instant::now(); let value = op.perform(lhs, rhs)?; Ok(Calculator { state: ComputationState::Computed { result: ComputationResult { value, computation_time: start.elapsed() } } }) } /// Performs a calculation using the supplied binary operator, the last computation result, and the supplied operand. /// /// The supplied operand will be the right-hand side in the mathematical operation. pub fn calculate_more(&self, op: Arc<dyn BinaryOperator>, rhs: i64) -> Result<Calculator, ComputationError> { let ComputationState::Computed { result } = &self.state else { return Err(ComputationError::IllegalComputationWithInitState); }; let start = Instant::now(); let value = op.perform(result.value, rhs)?; Ok(Calculator { state: ComputationState::Computed { result: ComputationResult { value, computation_time: start.elapsed() } } }) } } #[derive(uniffi::Object)] struct SafeAddition {} // Makes it easy to construct from foreign code #[uniffi::export] impl SafeAddition { #[uniffi::constructor] fn new() -> Self { SafeAddition {} } } #[uniffi::export] impl BinaryOperator for SafeAddition { fn perform(&self, lhs: i64, rhs: i64) -> Result<i64, ComputationError> { lhs.checked_add(rhs).ok_or(ComputationError::Overflow) } } }
Troubleshooting
Working with React Native can sometimes feel like casting spells: when it works, it’s magic; but when you don’t get the incantations in the right order, or the moon is in the wrong phase when retrograde to Mercury1, then it can feel somewhat inscrutable.
This is not a comprehensive guide to debugging or troubleshooting your app or React Native setup.
These are things that contributors have encountered, and how they were resolved.
The most resiliant parts of the project is the generation of bindings between hermes and Rust.
The most fragile parts of the project are the interactions with the wider React Native project.
Currently, there is very little explicit React Native expertise in the project.
Please feel free to contribute to this page, either by organizing it, or by adding to it.
The best contributions would be pointing to other places on the internet with definitive advice.
I have no idea what I’m saying.
iOS
The build hangs shortly after yarn example start
Things I tried:
Running the app from Xcode
This workaround worked until I updated Xcode.
After updating Xcode, I saw build errors in Xcode (in the Report Navigator):
Run custom shell script 'Invoke Codgen'
/var/folders/sh/4_9lff8d37j8wvn1dn3gdb1r0000gp/T/SchemeScriptAction-2JFsLd.sh: line 2: npx: command not found
Exited with status code 127
I fixed this from the terminal before opening Xcode:
defaults write com.apple.dt.Xcode UseSanitizedBuildSystemEnvironment -bool NO
The problem here was that npx
was being called during a Build Phase, but npx
wasn’t on the PATH
.
A simulator isn’t launched because more than one is available
success Successfully built the app
--- xcodebuild: WARNING: Using the first of multiple matching destinations:
{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }
{ platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006001-000A68400245801E, name:My Mac }
{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }
{ platform:iOS Simulator, id:4C1B86D9-3622-404F-83CA-410D9D909C7F, OS:17.0.1, name:iPad (10th generation) }
I have fixed this by launching a Simulator either from Spotlight (Cmd+Space, then typing Simulator) or by typing into a terminal:
udid=$(xcrun simctl list --json devices | jq -r '.devices[][] | select(.isAvailable == true) | .udid')
xcrun simctl boot "$udid"
A simulator isn’t launched because it’s trying to launch on a device
The error can be found by opening the xcworkspace
file in Xcode with the open
command.
error Signing for "RustOrBustExample" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'RustOrBustExample' from project 'RustOrBustExample')
error Failed to build ios project. "xcodebuild" exited with error code '65'. To debug build logs further, consider building your app with Xcode.app, by opening 'RustOrBustExample.xcworkspace'.
This can be fixed either by selecting a Simulator rather than a device (it’s next to the Play button), or by following the error message and adding a development team in the Signings & Capabilities editor.
Publishing your library project
I haven’t had any experience of publishing libraries for React Native.
I would love some help with this document.
Binary builds
You likely don’t want to track pre-built binaries in your git repository but you may want to include them in published packages. If so, you will have to work around npm
’s behaviour of factoring in .gitignore
when picking files to include in packages.
One way to do this is by using the files
array in package.json
. The steps to achieve this will depend on the particular contents of your repository. For illustration purposes, let’s assume you’re ignoring binaries in .gitignore
with
build/
*.a
To include the libraries in /build/$library.xcframework
and /android/src/main/jniLibs/$target/$library.a
in your npm package, you can add the following to package.json
:
"files": [
+ "android",
+ "build",
Another option is to create an .npmignore
file. This will require you to duplicate most of the contents of .gitignore
though and might create issues if you forget to duplicate entries as you add them later.
In either case, it’s good practice to run npm pack --dry-run
and verify the package contents before publishing.
Source packages
If asking your users to compile Rust source is acceptable, then adding a postinstall
script to package.json
may be enough.
If you’ve kept the scripts from the Getting Started guide, then adding:
scripts: {
"scripts": {
"ubrn:ios": "ubrn build ios --config ubrn.config.yaml --and-generate && (cd example/ios && pod install)",
"ubrn:android": "ubrn build android --config ubrn.config.yaml --and-generate",
"ubrn:checkout": "ubrn checkout --config ubrn.config.yaml",
+ "postinstall": "yarn ubrn:checkout && yarn ubrn:android --release && yarn ubrn:ios --release",
Add uniffi-bindgen-react-native
to your README.md
If you publish your source code anywhere, it would be lovely if you could add something to your README.md. For example:
Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
+ and [uniffi-bindgen-react-native](https://github.com/jhugman/uniffi-bindgen-react-native)
Add your project to the uniffi-bindgen-react-native
README.md
Once your project is published and would like some cross-promotion, perhaps you’d like to raise a PR to add it to the uniffi-bindgen-react-native
README.
Working with multiple crates in one library
Some teams arrange their Rust library in to multiple crates, or multiple teams from one organization combine their efforts into one library.
This might be for better code organization, or to reduce shipping multiple copies of the same dependencies.
The combined library from multiple crates, in Mozilla vernacular, is known as a Megazord.
uniffi-rs
and uniffi-bindgen-react-native
both work well with Megazords.
uniffi-bindgen-react-native
produces a cluster of files per crate. For example, generating files from the library libmymegazord.a
might contain two crates, crate1
and crate2
. The library directory would look like this:
cpp
├── generated
│ ├── crate1.cpp
│ ├── crate1.hpp
│ ├── crate2.cpp
│ └── crate2.hpp
├── react-native-my-megazord.cpp
└── react-native-my-megazord.h
src
├── NativeMyMegazord.ts
├── generated
│ ├── crate1.ts
│ ├── crate1-ffi.ts
│ ├── crate2.ts
│ └── crate2-ffi.ts
└── index.tsx
In index.tsx
, the types are re-exported from crate1.ts
and crate2.ts
.
In this extended example, crate1.ts
might declare a Crate1Type
and crate2.ts
a Crate2Type
.
In this case, your library’s client code would import Crate1Type
and Crate2Type
like this:
import { Crate1Type, Crate2Type } from "react-native-my-megazord";
Alternatively, they can use the default export:
import megazord from "react-native-my-megazord";
const { Crate1Type } = megazord.crate1;
const { Crate2Type } = megazord.crate2;
Due to Swift’s large granular module sytem, crates in the same megazord cannot have types of the same name.
This may be solved in Swift at some point— e.g. by adding prefixes— but until then, duplicate identifiers will cause a Typescript compilation error as the types are smooshed together in index.tsx
.
For usage in Rust on how to use uniffi’s proc-macros, see the uniffi-rs
book for Procedural Macros: Attributes and Derives.
This section is about how the generated Typescript maps onto the Rust idioms available.
A useful way of organizing this is via the types that can be passed across the FFI.
Simple scalar types
Rust | Typescript | ||
---|---|---|---|
Unsigned integers | u8 , u16 , u32 | number | Positive numbers only |
Signed integers | i8 , i16 , i32 | number | |
Floating point | f32 , f64 | number | |
64 bit integers | u64 , i64 | bigint | MDN |
Strings | String | string | UTF-8 encoded |
Other simple types
Rust | Typescript | ||
---|---|---|---|
Byte array | Vec<u8> | ArrayBuffer | MDN |
Timestamp | std::time::SystemTime | Date | aliased to UniffiTimestamp |
Duration | std::time::Duration | number ms | aliased to UniffiDuration |
Structural types
Rust | Typescript | ||
---|---|---|---|
Optional | Option<T> | T | undefined | |
Sequences | Vec<T> | Array<T> | Max length is 2**31 - 1 |
Maps | HashMap<K, V> BTreeMap<K, V> | Map<K, V> | Max length is 2**31 - 1 |
Enumerated types
Rust | Typescript | ||
---|---|---|---|
Enums | enum | enum | Flat enums |
Tagged Union Types) | enum | Tagged unions | Enums with properties |
Error enums | enums | Error |
Struct types
Rust | Typescript | ||
---|---|---|---|
Objects | struct Foo {} | class Foo | class objects with methods |
Records | struct Bar {} | type Bar = {} | objects without methods |
Error objects | struct Baz {} | Error | object is a property of the Error |
Objects
Objects are structs with methods. They are passed-by-reference across the FFI.
#![allow(unused)] fn main() { #[derive(uniffi::Object)] struct MyObject { my_property: u32, } #[uniffi::export] impl MyObject { fn new(num: u32) -> Self { Self { my_property: num, } } #[uniffi::constructor(name = "create")] fn secondary_constructor(num: u32) -> Self { Self::new(num) } fn my_method(&self) -> String { format!("my property is {}", self.my_property) } } }
This produces Typescript with the following shape:
interface MyObjectInterface {
myMethod(): string;
}
class MyObject implements MyObjectInterace {
public constructor(num: number) {
// …
// call into the `new` function.
}
public static create(num: number): MyObjectInterface {
// … secondary constructor
// call into `secondary_constructor` function.
}
myMethod(): string {
// call into the `my_method` method.
}
}
Object interfaces
A supporting interface is constructed for each object, with the naming pattern: ${OBJECT_NAME}Interface
.
This is used for return values and arguments elsewhere in the generated code.
e.g. a Rust function, my_object
that returns a MyObject
is written in Typescript as:
function myObject(): MyObjectInterface
This is to support mocking of Rust objects.
Uniffi traits
Implementing the following traits in Rust causes the corresponding methods to be generated in Typescript:
Trait | Typescript method | Return |
---|---|---|
Display | toString() | string |
Debug | toDebugString() | string |
Eq | equals(other) | boolean |
Hash | hashCode() | bigint |
Garbage collection
When the object is garbage collected, the Rust native peer is dropped.
If the Rust object needs to be explicitly dropped, use the uniffiDestroy()
method.
This will cause the reference to the object to be freed. If this is the last reference to be freed, then the object itself is dropped.
Hermes Garbage Collection and Rust Drop
In Rust, the compile-time memory management is fairly sophisticated: ownership and borrowing is a first class concept and a whole subsystem of the compilation process is called the borrow-checker. When an structure’s ownership is not passed on, then it is dropped. When dropped, if it implements the Drop
trait, the drop
function can be run. In addition, any members of a dropped object will also be dropped.
In this manner, resources can be closed and memory can be reclaimed. Rust uses the Resource Acquisition Is Initialization idiom, and its opposite: resource reclamation is deinitialization.
In Javascript, there is a garbage collector. More concretely it uses a mark-and-sweep garbage collector.
At the boundary between the Rust code and Javascript code, Uniffi has to worry about marrying the two programming models.
The Javascript programmer is handling a Javascript object which is facade onto a Rust object. In the literature this is known as a native-peer. For the JS programmer, the mental model would be that once the object becomes unreachable, then sometime in the future, the GC will reclaim the memory.
But for the Rust, things do not get dropped, and cleanup operations don’t get done.
There are several possible approaches:
- Let the Javascript programmer explicitly tell Rust when they are done with a native peer. This is least convenient for the programmer: if they forget to do this, then a potential memory leak occurs.
- Somehow persuade the garbage collector to tell Rust that something has fallen out of usage. This is convenient for the programmer, but the GC is not guaranteed to be run.
- Do both: get the GC to do easy things automatically, to avoid memory leaks, but also provide explicit API to destroy the native peer.
Current status
Garbage collected objects trigger a drop call into Rust
The simplest route for this would be to use a FinalizationRegistry
.
Unfortunately, this is not yet supported by hermes. (New issue)
Instead, for every Javascript object constructor called, we call create a DestructibleObject
in C++, that is represented in Javascript but has a C++ destructor.
At the end of this [C++] object’s lifetime, the destructor is called.
The assumptions here are:
- GC reclaims the memory through destruction of the C++ object
- the same C++ is used throughout the JS lifetime, i.e. memory compaction doesn’t exist, or if it does, then objects are
move
d rather than cloned.
Additionally, we observe that:
- Garbage collection may happen later than you think, if at all; especially in short running tests or apps.
- Garbage collection may happen sooner than you think, especially in Release.
- If your Rust object depends on a drop function being called, then you should call its
uniffiDestroy
method before losing it.
Explicit API for destroying the native peer
For every object, there is a uniffiDestroy
method. This can be called more than once. Once it is called, calling any methods on that object results in an error.
To make calling this more automatic, in some circumstances it may be useful to use the uniffiUse
method:
const result = new MyObject().uniffiUse((obj) => {
obj.callSomeMethod();
return obj.callAnotherMethod();
});
Future work
If there is any movement on hermes’ FinalizationRegistry
support) we may well consider moving to this method.
Records
Uniffi records are data objects whose fields are serialized and passed over the FFI i.e. pass by value. They do not have methods.
In UDL, they may be specified with the dictionary
keyword:
dictionary MyRecord {
string mandatory_property;
string defaulted_property = "Specified by UDL or Rust";
};
Alternatively, they are specified using a Rust proc-macro:
#![allow(unused)] fn main() { #[derive(uniffi::Record)] struct MyRecord { mandatory_property: String, #[uniffi(default = "Specified by UDL or Rust")] defaulted_property: String, } }
They are implemented as bare objects in Javascript, with a type
declaration in Typescript.
type MyRecord = {
mandatoryProperty: string,
defaultedProperty: string,
};
Using this scheme alone however, Typescript cannot represent the default values provided by the UDL, or Rust.
To correct this, uniffi-bindgen-react-native
generates a companion factory object.
const MyRecord = {
create(fields: Missing<MyRecord>) { … },
defaults(): Partial<MyRecord> { … },
new: create // a synonym for `create`.
};
The Missing<MyRecord>
type above is a little bit hand-wavy, but it’s defined as the union of non-defaulted fields, and the partial of the defaulted fields.
So, continuing with our example, the factory will be minimally happy with:
const myRecord = MyRecord.create({
mandatoryProperty: "Specified in Typescript"
});
assert(myRecord.mandatoryProperty === "Specified in Typescript");
assert(myRecord.defaultProperty === "Specified by UDL or Rust");
Enums without properties
Enums with variants that have no properties are said to be “flat enums”.
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] enum MyAnimal { Cat, Dog, } }
These are represented by a similar enum in Typescript:
enum MyAnimal {
Cat,
Dog,
}
Constructing these in Typescript is done as usual:
const dog = MyAnimal.Dog;
const cat = MyAnimal.Cat;
Enums with properties
Rust enums variants optionally have properties. These may be name or unnamed.
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] enum MyShape { Point, Circle(f64), Rectangle { length: f64, width: f64, colour: String }, } }
These may be constructed like so:
#![allow(unused)] fn main() { let p = MyShape::Point; let c = MyShape::Circle(2.0); let r = MyShape::Rectangle { length: 1.0, width: 1.0, colour: "blue".to_string(), }; }
These can be used in pattern matching, for example:
#![allow(unused)] fn main() { fn area(shape: MyShape) -> f64 { match shape { MyShape::Point => 0.0, MyShape::Circle(radius) => PI * radius * radius, MyShape::Rectangle { length, width, .. } => length * width, } } }
Such enums are in all sorts of places in Rust: Option
, Result
and Errors all use this language feature.
In Typescript, we don’t have enums with properties, but we can simulate them:
enum MyShape_Tags { Point, Circle, Rectangle };
type MyShape =
{ tag: MyShape_Tags.Point } |
{ tag: MyShape_Tags.Circle, inner: [number] } |
{ tag: MyShape_Tags.Rectangle, inner: { length: number, width: number, colour: string }};
In order to make them easier to construct, a helper object containing sealed classes implementing the tag/inner:
const point = new MyShape.Point();
const circle = new MyShape.Circle(2.0);
const rectangle = new MyShape.Circle({ length: 1.0, width: 1.0, colour: "blue" });
These are arranged so that the Typescript compiler can derive the types when you match on the tag
:
function area(shape: MyShape): number {
switch (shape.tag) {
case MyShape_Tags.Point:
return 0.0;
case MyShape_Tags.Circle: {
const [radius] = shape.inner;
return Math.PI * radius ** 2;
}
case MyShape_Tags.Rectangle: {
const [length, width] = shape.inner;
return length * width;
}
}
}
instanceOf
methods
Both the enum
and each variant have instanceOf
methods. These may be useful when you don’t need to match
/switch
on all variants in the Enum.
function colour(shape: MyShape): string | undefined {
if (MyShape.Rectangle.instanceOf(shape)) {
// We know what the type inner is.
return shape.inner.colour;
}
return undefined;
}
Adding one or more properties to one or more variants moves these flat enums to being “non-flat”, as above.
To help switch between the two, the classes representing the variants have a static method new
. For example, adding a property to the MyAnimal enum above:
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] enum MyAnimal { Cat, Dog(String), } }
This would mean changing the typescript construction to:
const dog = new MyAnimal.Dog("Fido");
const cat = new MyAnimal.Cat();
The variants each have a static new
method to have a smaller diff:
const dog = MyAnimal.Dog.new("Fido");
const cat = MyAnimal.Cat.new();
Enums with explicit discriminants
Both Rust and Typescript allow you to specify discriminants to enum variants. As in other bindings for uniffi-rs, this is supported by uniffi-bindgen-react-native
. For example,
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] pub enum MyEnum { Foo = 3, Bar = 4, } }
will cause this Typescript to be generated:
enum MyEnum {
Foo = 3,
Bar = 4,
}
Errors
In Javascript, errors are thrown when an error condition is found.
When calling code which can throw, it is good practice to wrap that code in a try
/catch
block:
try {
const result = divide(42, 0); // throws
} catch (e: any) {
// do something with the error.
}
In other languages, e.g. Java or Swift, the method that can throw must declare it on the method signature. e.g.
In Java:
float divide(float top, float bottom) throws MathException {}
while in Swift:
func divide(top: Float, bottom: Float) throws -> Float {}
In Rust, instead of throwing with try/catch, a method returns a Result
enum.
#![allow(unused)] fn main() { #[derive(uniffi::Error)] pub enum MathError { DivideByZero, NumberOverflow, } #[uniffi::export] fn divide(top: f64, bottom: f64) -> Result<f64, MathError> { if bottom == 0.0 { Err(MathError::DivideByZero) } else { Ok(top / bottom) } } }
Enums as Errors
Notice that MathError
is not itself a special kind of object. In idiomatic Rust, this is usually an enum.
uniffi-bindgen-react-native
converts these types of enums-as-errors in to JS Errors. Due to a limitation in babel
, subclasses of Error
do not evaluate instanceof
as expected. For this reason, each variant has its own instanceOf
static method.
try {
divide(x, y);
} catch (e: any) {
if (MathError.instanceOf(e)) {
e instanceof Error; // true
e instanceof MathError; // false
}
if (MathError.DivideByZero.instanceOf(e)) {
// handle divide by zero
}
}
Such enums as errors, without properties also have a companion _Tags
enum.
Using a switch
on the error’s tag
property is a convenient way of handling all cases:
try {
divide(x, y);
} catch (e: any) {
if (MathError.instanceOf(e)) {
switch (e.tag) {
case MathError_Tags.DivideByZero: {
// handle divide by zero
break;
}
case MathError_Tahs.NumberOverflow: {
// handle overflow
break;
}
}
}
}
Enums with properties as Errors
Enums-as-errors may also have properties. These are exactly the same as other enums with properties, except they subclass Error
.
e.g.
#![allow(unused)] fn main() { enum MyRequestError { UrlParsing(String), Timeout { timeout: u32 }, ConnectionLost, } #[uniffi::export] fn make_request() -> Result<String, MyRequestError> { // dummy implmentation. return Err(MyRequestError::ConnectionLost); } }
In typescript:
try {
makeRequest();
} catch (e: any) {
if (MyRequestError.instanceOf(e)) {
switch (e.tag) {
case MyRequestError_Tags.UrlParsing: {
console.error(`Url is bad ${e.inner[0]}!`);
break;
}
case MyRequestError_Tags.Timeout: {
const { timeout } = e.inner;
console.error(`Timeout after ${timeout} seconds!`);
break;
}
case MyRequestError_Tags.ConnectionLost {
console.error(`Connection lost!`);
break;
}
}
}
}
Flat errors
A common pattern in Rust is to convert enum properties to a message. Uniffi calls these error enums flat_errors
.
In this example, a MyError::InvalidDataError
has no properties but gets the message "Invalid data"
, ParseError
converts its properties in to a message, and JSONError
takes any serde_json::Error
to make a JSONError
, which then gets converted to a string.
In this case, the conversion is being managed by the thiserror
crate’s macros.
#![allow(unused)] fn main() { #[derive(Debug, thiserror::Error, uniffi::Error)] #[uniffi(flat_error)] pub enum MyError { // A message from a variant with no properties #[error("Invalid data")] InvalidDataError, // A message from a variant with named properties #[error("Parse error at line {line}, column {col}")] ParseError { line: usize, col: usize }, // A message from an JSON error, converted into a MyError #[error("JSON Error: {0}")] JSONError(#[from] serde_json::Error), } }
Unlike flat enums, flat errors have a tag
property and a companion MyError_Tags
enum.
These can be handled in typescript like so:
try {
// … do sometihng that throws
} catch (err: any) {
if (MyError.instanceOf(err)) {
switch (err.tag) {
case MyError_Tags.InvalidDataError: {
// e.message will be "MyError.InvalidDataError: Invalid data"
break;
}
case MyError_Tags.ParseError: {
// e.message will be paramterized, e.g.
// "MyError.ParseError: Parse error at line 12, column 4"
break;
}
case MyError_Tags.JSONError: {
// e.message will be a wrapped serde_json error, e.g.
// "MyError.JSONError: Expected , \" or \]"
break;
}
}
}
}
Objects as Errors
As you may have gathered, in Rust errors can be anything including objects. In the rare occasions this may be useful:
#![allow(unused)] fn main() { #[derive(uniffi::Object)] pub struct MyErrorObject { e: String, } #[uniffi::export] impl MyErrorObject { fn message_from_rust(&self) -> String { self.e.clone() } } #[uniffi::export] fn throw_object(message: String) -> Result<(), MyErrorObject> { Err(MyErrorObject { e: message }) } }
This is used in Typescript, the error itself is not the object.
try {
throwObject("a message")
} catch (e: any) {
if (MyErrorObject.instanceOf(e)) {
// NOPE
}
if (MyErrorObject.hasInner(e)) {
const error = MyErrorObject.getInner(e);
MyErrorObject.instanceOf(error); // true
console.error(error.messageFromRust())
}
}
Rust Error
is renamed as Exception
in typescript
To avoid collisions with the ECMAScript standard Error
, any Rust enums, objects and records called Error
are renamed Exception
.
Callback interfaces
Callbacks and function literals are not directly supported by uniffi-rs
.
However, callback interfaces are, that is: instances of Typescript classes can be passed to Rust. The Typescript methods of those objects may then be called from Rust.
#![allow(unused)] fn main() { #[uniffi::export(callback_interface)] pub trait MyLogger { fn is_enabled() -> bool; fn error(message: string); fn log(message: string); } #[uniffi::export] fn greet_with_logger(who: String, logger: Box<dyn MyLogger>) { if logger.is_enabled() { logger.log(format!("Hello, {who}!")); } } }
In Typescript, this can be used:
class ConsoleLogger implements MyLogger {
isEnabled(): boolean {
return true;
}
error(message: string) {
console.error(messgae);
}
log(message: string) {
console.log(messgae);
}
}
greetWithLogger(new ConsoleLogger(), "World");
So-called Foreign Traits can also be used. These are traits that can be implemented by either Rust or a foreign language: from the Typescript point of view, these are exactly the same as callback interfaces. They differ on the Rust side, using Rc<>
instead of Box<>
.
#![allow(unused)] fn main() { #[uniffi::export(with_foreign)] pub trait MyLogger { fn error(message: string); fn log(message: string); } #[uniffi::export] fn greet_with_logger(who: String, logger: Arc<dyn MyLogger>) { logger.log(format!("Hello, {who}!")); } }
These trait objects can be implemented by Rust or Typescript, and can be passed back and forth between the two sides of the FFI.
Errors
Errors are propagated from Typescript to Rust:
#![allow(unused)] fn main() { #[derive(uniffi::Error)] enum MyError { LoggingDisabled, } #[uniffi::export(callback_interface)] pub trait MyLogger { fn is_enabled() -> bool; fn log(message: string) -> Result<(), MyError>; } #[uniffi::export] fn greet_with_logger(who: String, logger: Box<dyn MyLogger>) -> Result<(), MyError> { logger.log(format!("Hello, {who}!")); } }
If an error is thrown in Typescript, it ends up in Rust:
class ConsoleLogger implements MyLogger {
isEnabled(): boolean {
return false;
}
log(message: string) {
if (!this.isEnabled()) {
throw new MyError.LoggingDisabled();
}
console.log(message);
}
}
try {
greetWithLogger(new ConsoleLogger(), "World");
} catch (e: any) {
if (MyError.instanceOf(e)) {
switch (e.tag) {
case MyError_Tags.LoggingDisabled: {
// handle the logging disabled error.
break;
}
}
}
}
Promise / Futures
uniffi-bindgen-react-native
provides support of Future
s/async fn
. These are mapped to Javascript Promise
s. More information can be found in the uniffi book.
This example is taken from the above link:
#![allow(unused)] fn main() { use std::time::Duration; use async_std::future::{timeout, pending}; /// Async function that says something after a certain time. #[uniffi::export] pub async fn say_after(ms: u64, who: String) -> String { let never = pending::<()>(); timeout(Duration::from_millis(ms), never).await.unwrap_err(); format!("Hello, {who}!") } }
It can be called from Typescript:
// Wait 1 second for Hello, World!
const message = await sayAfter(1000n, "World");
You can see this in action in the futures-example
fixture, and the more complete futures
fixture.
Passing Promises across the FFI
There is no support for passing a Promise
or Future
as an argument or error, in either direction.
Task cancellation
Internally, uniffi-rs
generates a cancel function for each Future. On calling it, the Future
is dropped.
This is accessible for every function that returns a Promise
by passing an optional { signal: AbortSignal }
option bag as the final argument.
Using the same Rust as above:
#![allow(unused)] fn main() { use std::time::Duration; use async_std::future::{timeout, pending}; /// Async function that says something after a certain time. #[uniffi::export] pub async fn say_after(ms: u64, who: String) -> String { let never = pending::<()>(); timeout(Duration::from_millis(ms), never).await.unwrap_err(); format!("Hello, {who}!") } }
It can be used from Typescript, either without an AbortSignal
as above, or with one passed as the final argument:
const abortController = new AbortController();
setTimeout(() => abortController.abort(), 1000);
try {
// Wait 1 hour for Hello, World!
const message = await sayAfter(60 * 60 * 1000, "World", { signal: abortController.signal });
console.log(message);
} catch (e: any) {
e instanceof Error; // true
e.name === "AbortError"; // true
}
This example calls into the say_after
function, and the Rust would wait for 1 hour before returning. However, the abortController
has its abort
method called after 1 second.
Task cancellation for one language is… complicated. For FFIs, it is a small but important source of impedence mismatches between languages.
The uniffi-rs
docs suggest that:
You should build your cancellation in a separate, library specific channel; for example, exposing a
cancel()
method that sets a flag that the library checks periodically.
While uniffi-rs
is recommending this, uniffi-bindgen-react-native
— as a foreign-language binding to the uniffi-rs code— does so too.
However, while uniffi-rs
exposes rust_future_cancel
function, uniffi-bindgen-react-native
— as a foreign-language binding to the uniffi-rs code— does so too.
Async Callback interfaces
Callback interfaces and foreign traits can expose methods which are asynchronous. A toy example here:
#![allow(unused)] fn main() { #[uniffi::export(with_foreign)] #[async_trait::async_trait] trait MyFetcher { async get(url: String) -> String; } fetch_with_fetcher(url: String, fetcher: Arc<dyn MyFetcher>) -> String { fetcher.fetch(url).await } }
Used from Typescript:
class TsFetcher implements MyFetcher {
async get(url: string): Promise<string> {
return await fetch(url).text()
}
}
fetchWithFetcher("https://example.com", new TsFetcher());
You can see this in action in the futures
fixture.
Task cancellation
When the Rust Future is completed, it is dropped, and Typescript is informed. If the Future is dropped before it has completed, it has been cancelled. uniffi-bindgen-react-native
can use this information to call the async callback to cancel, using the standard AbortController
and AbortSignal
machinery.
uniffi-bindgen-react-native
generates an optional argument for each async callback method, which is an options bag containing an AbortSignal
.
It is up to the implementer of each method whether they want to use it or not.
Using exactly the same MyFetcher
trait from above, this example passes the signal straight to the fetch
API.
class TsFetcher implements MyFetcher {
async get(url: string, asyncOptions?: { signal: AbortSignal }): Promise<string> {
return await fetch(url, asyncOptions).text()
}
}
fetchWithFetcher("https://example.com", new TsFetcher());
Options and Nullables
In keeping with uniffi guidelines, we have made an effort to map core Rust concepts into their Typescript equivalents wherever possible, in order to allow the Typescript code to be as idiomatic and easy to work with as possible. A returned Result<T, E>
from Rust will result in a flat type of T
or a thrown E
, while an Option<T>
will become T | undefined
. We believed that flattened types like this would be easier to work with than wrappers at every point for such common Rust primitives. This is in keeping with the approach taken by the core uniffi team for other languages (Kotlin turns Option<T>
into T
?).
Note that this flattening does mean certain types which are perfectly legal (if ill advised) in Rust are not representable on the Uniffi layer. An Option<Option<T>>
for example, could be represented in Rust, though we would assert it may be a poor stylistic choice even there. If you need to represent a tri-state, an enum with three variants feels like a clearer choice, and one that has first class support. We also note that the core uniffi project shares these limitations in some of its bindings (Kotlin), and it has not proven overly burdensome to date.
undefined vs null
Typescript of course has two alternatives for ‘absent’ values, undefinded
and null
. Rust on the other hand has only one (Option). Deciding whether we should represent Options as either undefined or null was ultimately a choice made during development of this library.
We settled on undefined
largely due to the Typescript language guidelines. The undefined
keyword has better language level support in Typescript, and Microsofts own guidelines forbid the use of null
in favor of undefined
throughout their own projects. Moving away from null
seems to the the direction the language is going, and we have chosen to follow suit.
While it is true that using both null
and undefined
lets you represent some unique ideas, such as a directive to clear a field (null
) vs take no action on a field (undefined
), those types would be ultimately unrepresentable in idiomatic Rust.
Local development of uniffi-bindgen-react-native
Pre-installation
This guide is in addition to the Pre-installation guide.
git clone https://github.com/jhugman/uniffi-bindgen-react-native
cd uniffi-bindgen-react-native
Now you need to run the bootstrap xtask:
cargo xtask bootstrap
The first time you run this will take some time: it clones the main branch of facebook/hermes
and builds it.
By default, it checks out the main
branch, but this can be customized:
cargo xtask bootstrap hermes --branch rn/0.76-stable
It also builds the cpp/test-harness
which is the Javascript runtime which can accept .so
files written in C++ and Rust.
You can force a re-setup with:
cargo xtask bootstrap --force
Tests to see if a bootstrap step can be skipped is fairly rudimentary: mostly just the existence of a directory, so switching to a new branch of hermes
would be done:
cargo xtask clean
cargo xtask bootstrap hermes --branch rn/0.76-stable
cargo xtask bootstrap
Running tests
Most of the testing for uniffi-bindgen-react-native
is done in the fixtures
directory by testing the generated Typescript and C++ against a Rust crate.
These can be run with:
./scripts/run-tests.sh
One or more fixtures may be run using the -f
flag.
./scripts/run-tests.sh -f chronological -f arithmetic
This, in turn, uses the run
xtask.
The run-tests.sh
script also runs the Typescript-only tests in the typescript/tests
directory.
These have been useful to prototype generated Typescript before moving them into templates.
Running rust only unit tests
Rust unit tests are encouraged! They can be run as usual with:
cargo test
Formatting and linting
Pre-commit, you should ensure that the code is formatted.
The fmt
xtask will run cargo fmt
, cargo clippy
on the Rust, prettier
on Typescript and clang-tidy
on C++ files.
cargo xtask fmt
Running with the --check
does not change the files, just finishes abnormally if any of the formatters find something it would like changed.
cargo xtask fmt --check
Adding or changing turbo-module templates
In addition to generating the bindings between Hermes and Rust, uniffi-bindgen-react-native
generates the files needed to run this as a turbo-module. The list of files are documented elsewhere in this book.
Templates are written for Askama templating library.
Changing the templates for these files is relatively simple. This PR is a good example of adding a file.
- Template files are in the
codegen/templates
directory. - Template configuration are in
codegen/mod.rs
file.
Adding a new template
- Add new template to the
codegen/templates
directory. - Add a new
RenderedFile
struct, which specifies the template, and its path to thefiles
module incodegen/mod.rs
. - Add an entry to the list of generated files in this book.
The template context will have quite a lot of useful information data-structures about the project; the most prominent:
ModuleMetadata
, which is generated from thelib.a
file from the uniffi contents of the Rust library.ProjectConfig
which is the in-memory representation of the YAML configuration file.CrateMetadata
which is data about the crate derived fromcargo metadata
.
Testing changes
The ./scripts/test-turbo-modules.sh
script runs a suite of tests, simulating the steps in the Getting Started tutorial:
- do the names of the ubrn generated files match up with the files builder-bob generated files?
- do generated identifiers in the ubrn generated files match up with those generated by builder-bob?
- does an Android app compile
- does an iOS app compile
If you want to test a particular configuration, you can use the command with a series of options:
Usage: ./scripts/test-turbo-modules.sh [options] [PROJECT_DIR]
Options:
-A, --android Build for Android.
-I, --ios Build for iOS.
-C, --ubrn-config Use a ubrn config file.
-T, --app-tsx Use a App.tsx file.
-s, --slug PROJECT_SLUG Specify the project slug (default: my-test-library).
-i, --ios-name IOS_NAME Specify the iOS project name (default: MyTestLibrary).
-u, --builder-bob-version VERSION Specify the version of builder-bob to use (default: latest).
-k, --keep-directory-on-exit Keep the PROJECT_DIR directory even if an error does not occur.
-f, --force-new-directory If PROJECT_DIR directory exist, remove it first.
-h, --help Display this help message.
Arguments:
PROJECT_DIR Specify the root directory for the project (default: my-test-library).
For example, to test if a configuration builds then runs:
fixtures=./integration/fixtures/turbo-module-testing
directory=/tmp/my-test-library
./scripts/test-turbo-modules.sh \
--ubrn-config $fixtures/ubrn.config.yaml \
--app-tsx $fixtures/App.tsx \
--ios \
--android \
--keep-directory-on-exit \
--force-new-directory \
"$directory"
cd "$directory"
yarn example start
Contributing or reviewing documentation
A project is only as good as its docs!
The documentation is in markdown, and lives in the docs/src
directory.
You can edit the files directly with a text editor.
Before you start
The following assumes you have checked out the uniffi-bindgen-react-native
project and that Rust is installed.
Install mdbook
The docs are produced by mdbook
, a static-site generator written for documenting Rust projects.
uniffi-bindgen-react-native
uses this with a few plugins. You can install it by opening the terminal and using cd
to navigate to the project directory, then running the following command:
./scripts/run-bootstrap-docs.sh
Run mdbook serve
mdbook
can now be run from the docs
directory.
From within the project directory, run the following:
cd docs
mdbook serve
This will produce output like:
2024-10-14 12:59:35 [INFO] (mdbook::book): Book building has started
2024-10-14 12:59:35 [INFO] (mdbook::book): Running the html backend
2024-10-14 12:59:35 [INFO] (mdbook::book): Running the linkcheck backend
2024-10-14 12:59:35 [INFO] (mdbook::renderer): Invoking the "linkcheck" renderer
2024-10-14 12:59:36 [INFO] (mdbook::cmd::serve): Serving on: http://localhost:3000
2024-10-14 12:59:36 [INFO] (warp::server): Server::run; addr=[::1]:3000
2024-10-14 12:59:36 [INFO] (warp::server): listening on http://[::1]:3000
Make some changes
You can edit pages with your text editor.
New pages should be added to the SUMMARY.md
file so that a) mdbook
knows about them and b) they ends up in the table of contents.
You can now navigate your browser to localhost:3000 to see the changes you’ve made.
Pushing these changes back into the project
A normal Pull Request flow is used to push these changes back into the project.
Changing generated Typescript or C++ templates
The central workings of a uniffi-bingen
are its templates.
uniffi-bindgen-react-native
templates are in the following directories:
Templates are written for Askama templating library.
There is a small-ish runtime for both languages:
typescript/src
, with tests and polyfills.- ’cpp/includes`.
This is intended to allow developers from outside the project to contribute more easily.
Making a change to the templates should be accompanied by an additional test, either in an existing test fixture, or a new one.
Running the tests can be done with:
./scripts/run-tests.sh
An individual test can be run:
./scripts/run-tests.sh -f $fixtureName
Cutting a Release
Version numbers
Uniffi has a semver
versioning scheme. At time of writing, the current version of uniffi-rs
is 0.28.3
uniffi-bindgen-react-native
uses this version number with prepended with a -
and a variant number, starting at 0
.
Thus, at first release, the version of uniffi-bindgen-react-native
will be 0.28.3-0
.
Compatibility with other packages
Other versioning we should take care to note:
- React Native
create-react-native-library
uniffi-bindgen-react-native
the command line utility that ties together much of the building of Rust, and the generating the bindings and turbo-modules. It is also available called ubrn
.
Most commands take a --config FILE
option. This is a YAML file which collects commonly used options together, and is documented here.
Both spellings of the command ubrn
and uniffi-bindgen-react-native
are NodeJS scripts.
This makes ubrn
available to other scripts in package.json
.
If you find yourself running commands from the command line, you can alias the command
alias ubrn=$(yarn uniffi-bindgen-react-native --path)
allows you to run the command from the shell as ubrn
, which is simpler to type. From hereon, commands will be given as ubrn
commands.
The ubrn
command
Running ubrn --help
gives the following output:
Usage: uniffi-bindgen-react-native <COMMAND>
Commands:
checkout Checkout a given Github repo into `rust_modules`
build Build (and optionally generate code) for Android or iOS
generate Generate bindings or the turbo-module glue code from the Rust
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
checkout
Checkout a given Git repo into rust_modules
.
Usage: uniffi-bindgen-react-native checkout [OPTIONS] <REPO>
Arguments:
<REPO> The repository where to get the crate
Options:
--config <CONFIG>
--branch <BRANCH> The branch or tag which to checkout [default: main]
-h, --help Print help
The checkout command can be operated in two ways, either:
- with a
REPO
argument and optional--branch
argument. OR - with a config file which may specify a repo and branch, or just a
directory
.
If the config file is set to a repo, then the repo is cloned in to ./rust_modules/${NAME}
.
build
This takes care of the work of compiling the Rust, ready for generating bindings. Each variant takes a:
--config
config file.--and-generate
this runs thegenerate all
command immediately after building.--targets
a comma separated list of targets, specific to each platform. This overrides the values in the config file.--release
builds a release version of the library.
build android
Build the crate for use on an Android device or emulator, using cargo ndk
, which in turn uses Android Native Development Kit.
Usage: uniffi-bindgen-react-native build android [OPTIONS] --config <CONFIG>
Options:
--config <CONFIG>
The configuration file for this build
-t, --targets <TARGETS>...
Comma separated list of targets, that override the values in the `config.yaml` file.
Android: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android i686-linux-android,
Synonyms for: arm64-v8a, armeabi-v7a, x86_64, x86
-r, --release
Build a release build
--no-cargo
If the Rust library has been built for at least one target, then don't re-run cargo build.
This may be useful if you are using a pre-built library or are managing the build process yourself.
-g, --and-generate
Optionally generate the bindings and turbo-module code for the crate
--no-jniLibs
Suppress the copying of the Rust library into the JNI library directories
-h, --help
Print help (see a summary with '-h')
--release
sets the release profile for cargo
.
--and-generate
is a convenience option to pass the built library file to generate bindings
and generate turbo-module
for Android and common files.
This is useful as some generated files use the targets specified in this command.
Once the library files (one for each target) are created, they are copied into the jniLibs
specified by the YAML configuration.
React Native requires that the Rust library be built as a static library. The CMake based build will combine the C++ with the static library into a shared object.
To configure Rust to build a static library, you should ensure staticlib
is in the crate-type
list in the [lib]
section of the Cargo.toml
file. Minimally, this should be in the Cargo.toml
manifest file:
[lib]
crate-type = ["staticlib"]
We also need to make sure that we were linking to the correct NDK.
This changes from RN version to version, but in our usage we had to set an ANDROID_NDK_HOME
variable in our script for this to pick up the appropriate version. For example:
export ANDROID_NDK_HOME=${ANDROID_SDK_ROOT}/ndk/26.1.10909125/
You can find the version you need in your react-native android/build.gradle
file in the ndkVersion
variable.
build ios
Build the crate for use on an iOS device or simulator.
Build the crate for use on an iOS device or simulator
Usage: uniffi-bindgen-react-native build ios [OPTIONS] --config <CONFIG>
Options:
--config <CONFIG>
The configuration file for this build
--sim-only
Only build for the simulator
--no-sim
Exclude builds for the simulator
--no-xcodebuild
Does not perform the xcodebuild step to generate the xcframework
The xcframework will need to be generated externally from this tool. This is useful when adding extra bindings (e.g. Swift) to the project.
-t, --targets <TARGETS>...
Comma separated list of targets, that override the values in the `config.yaml` file.
iOS: aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios
-r, --release
Build a release build
--no-cargo
If the Rust library has been built for at least one target, then don't re-run cargo build.
This may be useful if you are using a pre-built library or are managing the build process yourself.
-g, --and-generate
Optionally generate the bindings and turbo-module code for the crate
-h, --help
Print help (see a summary with '-h')
The configuration file refers to the YAML configuration.
--sim-only
and --no-sim
restricts the targets to targets with/without sim
in the target triple.
--and-generate
is a convenience option to pass the built library file to generate bindings
and generate turbo-module
for iOS and common files.
This is useful as some generated files use the targets specified in this command.
Once the target libraries are compiled, and a config file is specified, they are passed to xcodebuild -create-xcframework
to generate an xcframework
.
React Native requires that the Rust library be built as a static library. The xcodebuild
based build will combine the C++ with the static library .xcframework
file.
To configure Rust to build a static library, you should ensure staticlib
is in the crate-type
list in the [lib]
section of the Cargo.toml
file. Minimally, this should be in the Cargo.toml
manifest file:
[lib]
crate-type = ["staticlib"]
generate
This command is to generate code for:
- turbo-modules: installing the Rust crate into a running React Native app
- bindings: the code needed to actually bridge between Javascript and the Rust library.
All subcommands require a configuration file.
If you’re already using --and-generate
, then you don’t need to know how to invoke this command.
Generate bindings or the turbo-module glue code from the Rust.
These steps are already performed when building with `--and-generate`.
Usage: uniffi-bindgen-react-native generate <COMMAND>
Commands:
bindings Generate just the Typescript and C++ bindings
turbo-module Generate the TurboModule code to plug the bindings into the app
all Generate the Bindings and TurboModule code from a library file and a YAML config file
help Print this message or the help of the given subcommand(s)
Options:
-h, --help
Print help (see a summary with '-h')
generate bindings
Generate just the bindings. In most cases, this command should not be called directly, but with the build, with --and-generate
.
This command follows the command line format of other uniffi-bindgen
commands. Most arguments are passed straight to uniffi-bindgen::library_mode::generate_bindings
.
For more/better documentation, please see the linked docs.
Because this mirrors other uniffi-bindgen
s, the --config
option here is asking for a uniffi.toml
file.
This command will generate two typescript files and two C++ files per Uniffi namespace. These are: namespace.ts
, namespace-ffi.ts
, namespace.h
, namespace.cpp
, substituting namespace
for names derived from the Rust crate.
The string namespace within which this API should be presented to the caller.
This string would typically be used to prefix function names in the FFI, to build a package or module name for the foreign language, etc.
It may also be thought of as a crate or sub-crate which exports uniffi API.
The C++ files will be put into the --cpp-dir
and the typescript files into the --ts-dir
.
The C++ files can register themselves with the Hermes runtime.
Usage: uniffi-bindgen-react-native generate bindings [OPTIONS] --ts-dir <TS_DIR> --cpp-dir <CPP_DIR> <SOURCE>
Arguments:
<SOURCE>
A UDL file or library file
Options:
--lib-file <LIB_FILE>
The path to a dynamic library to attempt to extract the definitions from and extend the component interface with
--crate <CRATE_NAME>
Override the default crate name that is guessed from UDL file path.
In library mode, this
--config <CONFIG>
The location of the uniffi.toml file
--library
Treat the input file as a library, extracting any Uniffi definitions from that
--no-format
By default, bindgen will attempt to format the code with prettier and clang-format
--ts-dir <TS_DIR>
The directory in which to put the generated Typescript
--cpp-dir <CPP_DIR>
The directory in which to put the generated C++
-h, --help
Print help (see a summary with '-h')
generate turbo-module
Generate the TurboModule code to plug the bindings into the app.
More details about the files generated is shown here.
Usage: uniffi-bindgen-react-native generate turbo-module --config <CONFIG> [NAMESPACES]...
Arguments:
[NAMESPACES]... The namespaces that are generated by `generate bindings`
Options:
--config <CONFIG> The configuration file for this build
-h, --help Print help
The namespaces in the commmand line are derived from the crate that has had its bindings created.
The locations of the files are derived from the configuration file and the project’s package.json` file.
The relationships between files are preserved– e.g. where one file points to another via a relative path, the relative path is calculated from these locations.
generate all
This command performs the generation of both bindings
and turbo-module
, using a lib.a
file.
This is a convenience method for users who do not or cannot use the ubrn build
commands.
Generate the Bindings and TurboModule code from a library file and a YAML config file.
This is the second step of the `--and-generate` option of the build command.
Usage: uniffi-bindgen-react-native generate all --config <CONFIG> <LIB_FILE>
Arguments:
<LIB_FILE>
A path to staticlib file
Options:
--config <CONFIG>
The configuration file for this project
-h, --help
Print help (see a summary with '-h')
help
Prints the help message.
Usage: uniffi-bindgen-react-native <COMMAND>
Commands:
checkout Checkout a given Github repo into `rust_modules`
build Build (and optionally generate code) for Android or iOS
generate Generate bindings or the turbo-module glue code from the Rust
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
You can add --help
to any command to get more information about that command.
Configuration for uniffi-bindgen-react-native
The configuration yaml file is a collection of configuration options used in one or more commands.
The file is designed to be easy to start. A minimal configuation would be:
rust:
directory: ./rust
manifest-file: Cargo.toml
Getting started from here would require a command to start the Rust:
cargo init --lib ./rust
cd ./rust
cargo add uniffi
YAML entries
rust
rust:
repo: https://github.com/example/my-rust-sdk
branch: main
manifest-path: crates/my-api/Cargo.toml
In this case, the ubrn checkout
command will clone the given repo with the branch/ref into the rust_modules
directory of the project. Note that instead of branch
you can also use rev
or ref
.
If run a second time, no overwriting will occur.
The manifest-path
is the path relative to the root of the Rust workspace directory. In this case, the manifest is expected to be, relative to your React Native library project: ./rust_modules/my-rust-sdk/crates/my-api/Cargo.tml
.
crate:
directory: ./rust
manifest-path: crates/my-api/Cargo.toml
In this case, the ./rust
directory tells ubrn
where the Rust workspace is, relative to your React Native library project. The manifest-path
is the relative path from the workspace file to the crate which will be used to build bindings.
bindings
This section governs the generation of the bindings— the nitty-gritty of the Rust API translated into Typescript. This is mostly the location on disk of where these files will end up, but also has a second configuration file.
bindings:
cpp: cpp/bindings
ts: ts/bindings
uniffiToml: ./uniffi.toml
The uniffi.toml
file configures custom types, to further customize the conversion into Typescript data-types.
If missing, the defaults will be used:
bindings:
cpp: cpp/generated
ts: ts/generated
android
This is to configure the build steps for the Rust, the bindings, and the turbo-module code for Android.
This section can be omitted entirely, as sensible defaults are provided. If you do want to edit the defaults, these are the members of the android
section with their defaults:
android:
directory: ./android
cargoExtras: []
targets:
- arm64-v8a
- armeabi-v7a
- x86
- x86_64
apiLevel: 21
jniLibs: src/main/jniLibs
packageName: <DERIVED FROM package.json>
The directory
is the location of the Android project, relative to the root of the React Native library project.
targets
is a list of targets to build for. The Rust source code is built once per target.
cargoExtras
is a list of extra arguments passed directly to the cargo build
command.
apiLevel
is the minimum API level to target: this is passed to the cargo ndk
command as a --platform
argument.
packageName
is the name of the Android package that Codegen used to generate the TurboModule. This is derived from the package.json
file, and can almost always be left.
To customize the packageName
, you should edit or add the entry at the path codegenConfig
/android
/javaPackageName
in package.json
.
ios
This is to configure the build steps for the Rust, the bindings, and the turbo-module code for iOS.
This section can be omitted entirely, as sensible defaults are provided. If you do want to edit the defaults, these are the members of the ios
section with their defaults:
ios:
directory: ios
cargoExtras:: []
targets:
- aarch64-apple-ios
- aarch64-apple-ios-sim
xcodebuildExtras: []
frameworkName: build/MyFramework
The directory
is the location of the iOS project, relative to the root of the React Native library project.
targets
is a list of targets to build for. The Rust source code is built once per target.
cargoExtras
is a list of extra arguments passed directly to the cargo build
command.
xcodebuildExtras
is a list of extra arguments passed directly to the xcodebuild
command.
turboModule
This section configures the location of the Typescript and C++ files generated by the generate turbo-module
command.
If absent, the defaults will be used:
turboModule:
ts: src
cpp: cpp
The Typescript files are the index.ts
file, and the Codegen
installer file.
By default, the index.ts
file is intended to be the entry point for your library.
In this case, changing the location of the ts
directory will require changing the main
or react-native
entry in the package.json
file.
noOverwrite
This list of glob patterns of file that should not be generated or overwritten by the --and-generate
flag, and the generate turbo-module
command.
This is useful if you have customized one or more of the generated files, and do not want lose those changes.
The uniffi.toml
file is a toml file used to customize the generation of C++ and Typescript.
As of time of writing, only typescript
bindings generation exposes any options for customization, and only for customTypes
.
Typescript custom types
From the uniffi-rs manual:
Custom types allow you to extend the UniFFI type system to support types from your Rust crate or 3rd party libraries. This works by converting to and from some other UniFFI type to move data across the FFI.
This table customizes how a type called MillisSinceEpoch
comes out of Rust.
We happen to know that it crosses the FFI as a Rust i64
, which
converts to a JS bigint
, but we can do better.
[bindings.typescript.customTypes.MillisSinceEpoch]
# Name of the type in the Typescript code.
typeName = "Date"
# Expression to lift from `bigint` to the higher-level representation `Date`.
lift = 'new Date(Number({}))'
# Expression to lower from `Date` to the low-level representation, `bigint`.
lower = "BigInt({}.getTime())"
This table customizes how a type called Url
comes out of Rust.
We happen to know that it crosses the FFI as a string
.
[bindings.typescript.customTypes.Url]
# We want to use our own Url class; because it's also called
# Url, we don't need to specify a typeName.
# Import the Url class from ../src/converters
imports = [ [ "Url", "../src/converters" ] ]
# Expressions to convert between strings and URLs.
# The `{}` is substituted for the value.
lift = "new Url({})"
lower = "{}.toString()"
We can provide zero or more imports which are slotted into a JS import statement. This allows us to import type
and from modules in node_modules
.
The next example is a bit contrived, but allows us to see how to customize a generated type that came from Rust.
The EnumWrapper
is defined in Rust as:
#![allow(unused)] fn main() { pub struct EnumWrapper(MyEnum); uniffi::custom_newtype!(EnumWrapper, MyEnum); }
In the uniffi.toml
file, we want to convert the wrapped MyEnum
into a string
. In this case, the string
is the custom type, and we need to provide code to convert to and from the custom type.
[bindings.typescript.customTypes.EnumWrapper]
typeName = "string"
# An expression to get from the custom (a string), to the underlying enum.
lower = "{}.indexOf('A') >= 0 ? new MyEnum.A({}) : new MyEnum.B({})"
# An expression to get from the underlying enum to the custom string.
# It has to be an expression, so we use an immediately executing anonymous function.
lift = """((v: MyEnum) => {
switch (v.tag) {
case MyEnum_Tags.A:
return v.inner[0];
case MyEnum_Tags.B:
return v.inner[0];
}
})({})
"""
Generating Turbo Module files to install the bindings
The bindings of the Rust library consist of several C++ files and several typescript files.
There is a host of smaller files that need to be configured with these namespaces, and with configuration from the config YAML file.
These include:
- For Javascript:
- An
index.tsx
file, to call into the installation process, initialize the bindings for each namespace, and re-export the generated bindings for client code. - A Codegen file, to generates install methods from Javascript to Java and Objective C.
- An
- For Android:
- A
Package.java
andModule.java
file, which receives the codegen’d install method calls, to get the HermesJavascriptRuntime
andCallInvokerHolder
to pass it via JNI to - A
cpp-adapter.cpp
to receive the JNI calls, and converts those intojsi::Runtime
andreact::CallInvoker
then calls into generic C++ install code.
- A
- Generic C++ install code:
- A turbo-module installation
.h
and.cpp
which catches the calls from Android and iOS and registers the bindings C++ with the Hermesjsi::Runtime
.
- A turbo-module installation
- For iOS:
- a
Module.h
andModule.mm
file which receives the codegen’d install method calls, and digs around to find thejsi::Runtime
andreact::CallInvoker
. It then calls into the generic C++ install code.
- a
- To build for iOS:
- A podspec file to tell Xcode about the generated files, and the framework name/location of the compiled Rust library.
- To build for Android
- A
CMakeLists.txt
file to configure the Android specific tool chain for all the generated C++ files. - The
build.gradle
file which tells keeps the codegen package name in-sync and configurescmake
. (note to self, this could be done from within theCMakeLists.txt
file).
- A
An up-to-date list can be found in ubrn_cli/src/codegen/templates
.
Reserved words
Typescript Reserved Words
The following words are reserved words in Typescript.
If the Rust API uses any of these words on their own, the generated typescript is appended with an underscore (_
).
Reserved Words | Strict Mode Reserved Words |
---|---|
break | as |
case | implements |
catch | interface |
class | let |
const | package |
continue | private |
debugger | protected |
default | public |
delete | static |
do | yield |
else | |
enum | |
export | |
extends | |
false | |
finally | |
for | |
function | |
if | |
import | |
in | |
instanceof | |
new | |
null | |
return | |
super | |
switch | |
this | |
throw | |
true | |
try | |
typeof | |
var | |
void | |
while | |
with |
e.g.
#![allow(unused)] fn main() { #[uniffi::export] fn void() {} }
generates valid Typescript:
function void_() {
// … call into Rust.
}
Error
is mapped to Exception
Due to the relative prevalence in idiomatic Rust of an error enum called Error
, and the built-in Error
class in Javascript, an Error
enum is renamed to Exception
.
Uniffi Reserved words
In your Rust code, avoid using identifiers beginning with the word uniffi
or Uniffi
.
Potential collisions
Uniffi adding to your API
Both uniffi, and uniffi-bindgen-react-native
tries to stay away from polluting the API with its own identifiers: one of the design goals of the library is to make your Rust library usable in the same way as an idiomatic library.
However, sometimes this is unavoidable.
The following are generated on your behalf, even if you did not specify them:
Methods which may collide, because you can define methods in the same namespace
equals
: a method generated corresponding to theEq
andPartialEq
traithashCode
: a method generated corresponding to theHash
traittoDebugString
: a method generated corresponding to theDebug
traittoString
: a method generated corresponding to theDisplay
traituniffiDestroy
: a method in every object to aid garbage collection.
Interfaces which may be declared, and collide with other types
${NAME}Interface
: may collide with another type.
e.g.
#![allow(unused)] fn main() { #[derive(uniffi::Object)] struct Foo {} #[derive(uniffi::Record)] struct FooInterface {} }
The naming of the tags enums for tagged union enums deliberately contains an underscore.
Class and enum class names go through a camel casing which makes this impossible to collide when naming a Rust enum something that collides with the generated Typescript.
e.g.
#![allow(unused)] fn main() { // This enum will have a tags enum called MyEnum_Tags #[derive(uniffi::Enum)] enum MyEnum {} // This record will be called MyEnumTags in Typescript. #[derive(uniffi::Record)] struct MyEnum_Tags {} }
Non-collisions
These are methods or members that will not collide under any circumstances because they are defined at a level where user-generated members are not.
Types versus Objects
Records and Enums are generated with both a Typescript type
and a Javascript object
, of the same name.
These object
s of the same name will be referred to as factory objects or helper objects.
Records, i.e. objects without methods
type MyRecord = {
myProperty: string;
};
const MyRecord = {
defaults(): Partial<MyRecord>,
create(missingMembers: Partial<MyRecord>): MyRecord,
new: MyRecord.create,
};
defaults
, create
and new
will never collide with myProperty
because:
myProperty
is a member of an object of typeMyRecord
. It is never a member of the object calledMyRecord
.
Enums with values
Enums define their shape types, with a utility object to hold the variant classes.
To a first approximation, the generated code is drawn:
enum MyShape_Tags { Circle, Rectangle }
type MyShape =
{ tag: MyShape_Tags.Circle, inner: [number;]} |
{ tag: MyShape_Tags.Rectangle, inner: { length: number; width: number; }}
const MyShape = {
Circle: class Circle { constructor(
public tag: MyShape_Tags.Circle,
public inner: [radius: number]) {}
static instanceOf(obj: any): obj is Circle {}
static new(…): Circle {}
},
Rectangle: class Rectangle { constructor(
public tag: MyShape_Tags.Rectangle,
public inner: { length: number; width: number; }) {}
static instanceOf(obj: any): obj is Rectangle {}
static new(…): Rectangle {}
},
instanceOf(obj: any): obj is MyShape {}
};
This allows us to construct variants with:
const variant: MyShape = new MyShape.Circle(2.0);
MyShape.instanceOf(variant);
MyShape.Circle.instanceOf(variant);
The type MyShape
is different to the const MyShape
, and typescript can tell the difference based upon the context.
tag
, inner
and instanceOf
do not collide with:
- variant names, which are all CamelCased.
- variant value names, which are isolated in the
inner
object.
Static methods
create
: a static method in a Record Factory. User defined property, so will never be ablehasInner
: a static method added to object as Error classes.getInner
: a static method added to object as Error classes.instanceOf
: a static method added to Object, Enum, Enum variant and Error classes.new
: a static method in record factory object
Lifting, lowering and serialization
This page is based upon the corresponding uniffi-rs
page.
UniFFI is able to transfer rich data types back-and-forth between the Rust code and the Typescript code via a process we refer to as “lowering” and “lifting”.
Recall that UniFFI interoperates between different languages by defining a C-style FFI layer which operates in terms of primitive data types and plain functions. To transfer data from one side of this layer to the other, the sending side “lowers” the data from a language-specific data type into one of the primitive types supported by the FFI-layer functions, and the receiving side “lifts” that primitive type into its own language-specific data type.
Lifting and lowering simple types such as integers is done by directly casting the value to and from an appropriate type. For complex types such as optionals and records we currently implement lifting and lowering by serializing into a byte buffer, but this is an implementation detail that may change in future.
Three layers
In many languages, there are mechanisms to talk to the C-style FFI layer directly. Javascript has no such facilities.
Instead, we perform serialization to an ArrayBuffer
in Typescript, then pass it to C++ and then on to Rust.
This can be sketched as:
- In Typescript, in
{namespace}.ts
: Lowering and serializing from higher level Typescript types toArrayBuffer
s,number
s andbigint
. - Javascript calls into C++, through
{namespace}-ffi.ts
- In C++, in
{namespace}.cpp
: lower the JSInumber
,bigint
andArrayBuffer
further, into C equivalents, e.g.uint32_t
anduint8_t*
- Pass these C equivalents to Rust through a C style ABI.
- Rust lifts the low level types, then calls into handwritten Rust.
In more detail, for most types:
- do the serialization (and deserialization) step in Typescript into an
ArrayBuffer
usingDataView
andUint8TypedArray
.- This is done with a series of
FfiConverter
s. These are generated for complex types, but many can be seen inffi-converters.ts
.
- This is done with a series of
- call into generated C++ with the
ArrayBuffer
.- This is represented by a
jsi::ArrayBuffer
. - The JS/C++ interface is defined on the Typescript side by the
{namespace}-ffi.ts
file; it is implemented by the{namespace}.cpp
file.
- This is represented by a
- extract the
int32_t*
from thejsi::ArrayBuffer
and copy into aRustBuffer
, a C-style struct shared by both Rust and C++. - call into Rust with the RustBuffer.
Primitives
For more primitive types, the lifting and lowering is also done in two stages: for example, if Rust is expecting an i32
, the Javascript number
is passed into C++. The C++ then extracts the int32_t
from the jsi::Value::Number
.
Strings
For Strings, we would want to use a TextEncoder
. Unfortunately these aren’t currently available for hermes; see hermes issues for TextEncoder
and TextDecoder
.
In this case, we use C++ again. When a string needs serializing to an ArrayBuffer
, the FfiConverterString
:
- calls passes the string from Typescript to C++, these are represented as
jsi::Value::String
. - In C++
UniffiString.h
:- get a C++ String using the
utf8()
method ofjsi::String
- the copy the bytes into a
jsi::ArrayBuffer
.
- get a C++ String using the
- Return the ArrayBuffer to Javascript so it can be:
- added to the serialization of a complex type OR
- passed to Rust as an
ArrayBuffer
, as above.
NativeModule.ts and Codegen
React Native provides its own Codegen to route calls to C++ TurboModules, via Objective-C and Java/JNI.
uniffi-bindgen-react-native
uses this to “install” the C++ in to the jsi::Runtime
.
The install flow is sketched as follows:
- when the first time the package is imported, the
installRustCrate
typescript method is called. This is in the input file for Codegen, theNative{namespace}.ts
file. - this invokes the corresponding machinery generated by
Codegen
, in Objective C and Java. - once in Objective C and Java, we find the
jsi::Runtime
and thefacebook::react::CallInvoker
from the- Objective C and
- Java.
- this then passes the
Runtime
andCallInvoker
to the C++ Turbo-Module proper. - This then calls into the generated
cpp/bindings/{namespace}.cpp
which implements thesrc/bindings/{namespace}-ffi.ts
.
Every other call from JS goes directly to this C++, rather than via Objective-C and Java.
This pattern of using the Codegen
just for the installation flow for the bindings allowed for testing outside React Native, and could then be relatively simply templated.