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.

React Native Logo + Rust Logo

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.

Warning

This project is still in early development, and should not yet be used in production.

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.

Info

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:

  1. have a working turbo-module library,
  2. an example app, running in both Android and iOS,
  3. 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

Version drift

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

Pre-release

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

Hint

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.

Pre-release

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:

  1. Build the Rust crate for iOS, including the uniffi scaffolding in Rust.
  2. Build an xcframework for Xcode to pick up.
  3. Generate the typescript and C++ bindings between Hermes and the Rust.
  4. Generate the files to set up the JS -> Objective C -> C++ installation flow for the turbo-module.
  5. Re-run the Podfile in the example/ios directory so Xcode can see the C++ files.
yarn ubrn:ios

Building for Android will:

  1. Build the Rust crate for Android, including the uniffi scaffolding in Rust.
  2. Copy the files into the correct place in for gradlew to pick them up.
  3. Generate the files to set up the JS -> Java -> C++ installation flow for the turbo-module.
  4. Generate the files to make a turbo-module from the C++.
yarn ubrn:android

Hint

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

Troubleshooting

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.

Help wanted

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.

1

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

Help wanted

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;

Duplicated identifiers

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

RustTypescript
Unsigned integersu8, u16, u32numberPositive numbers only
Signed integersi8, i16, i32number
Floating pointf32, f64number
64 bit integersu64, i64bigintMDN
StringsStringstringUTF-8 encoded

Other simple types

RustTypescript
Byte arrayVec<u8>ArrayBufferMDN
Timestampstd::time::SystemTimeDatealiased to UniffiTimestamp
Durationstd::time::Durationnumber msaliased to UniffiDuration

Structural types

RustTypescript
OptionalOption<T>T | undefined
SequencesVec<T>Array<T>Max length is 2**31 - 1
MapsHashMap<K, V>
BTreeMap<K, V>
Map<K, V>Max length is 2**31 - 1

Enumerated types

RustTypescript
EnumsenumenumFlat enums
Tagged Union Types)enumTagged unionsEnums with properties
Error enumsenumsError

Struct types

RustTypescript
Objectsstruct Foo {}class Fooclass objects with methods
Recordsstruct Bar {}type Bar = {}objects without methods
Error objectsstruct Baz {}Errorobject 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:

TraitTypescript methodReturn
DisplaytoString()string
DebugtoDebugString()string
Eqequals(other)boolean
HashhashCode()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:

  1. 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.
  2. 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.
  3. 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:

  1. GC reclaims the memory through destruction of the C++ object
  2. the same C++ is used throughout the JS lifetime, i.e. memory compaction doesn’t exist, or if it does, then objects are moved rather than cloned.

Additionally, we observe that:

  1. Garbage collection may happen later than you think, if at all; especially in short running tests or apps.
  2. Garbage collection may happen sooner than you think, especially in Release.
  3. 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;
}

Tip

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 Futures/async fn. These are mapped to Javascript Promises. 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.

Warning

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.

Adding a new template

  1. Add new template to the codegen/templates directory.
  2. Add a new RenderedFile struct, which specifies the template, and its path to the files module in codegen/mod.rs.
  3. 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:

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

Help wanted

This does not yet run continuous integration.

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:

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:

  1. with a REPO argument and optional --branch argument. OR
  2. 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 the generate 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.

Note

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.

Note

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:

  1. turbo-modules: installing the Rust crate into a running React Native app
  2. 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.

Info

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.

Warning

Because this mirrors other uniffi-bindgens, 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 namespace is defined as:

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.

Info

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.

Tip

Reducing the number of targets to build for will speed up the edit-compile-run cycle.

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.

Info

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.
  • For Android:
    • A Package.java and Module.java file, which receives the codegen’d install method calls, to get the Hermes JavascriptRuntime and CallInvokerHolder to pass it via JNI to
    • A cpp-adapter.cpp to receive the JNI calls, and converts those into jsi::Runtime and react::CallInvoker then calls into generic C++ install code.
  • 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 Hermes jsi::Runtime.
  • For iOS:
    • a Module.h and Module.mm file which receives the codegen’d install method calls, and digs around to find the jsi::Runtime and react::CallInvoker. It then calls into the generic C++ install code.
  • 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 configures cmake. (note to self, this could be done from within the CMakeLists.txt file).

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 WordsStrict Mode Reserved Words
breakas
caseimplements
catchinterface
classlet
constpackage
continueprivate
debuggerprotected
defaultpublic
deletestatic
doyield
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

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 objects 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 type MyRecord. It is never a member of the object called MyRecord.

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 able
  • hasInner: 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:

  1. In Typescript, in {namespace}.ts: Lowering and serializing from higher level Typescript types to ArrayBuffers, numbers and bigint.
  2. Javascript calls into C++, through {namespace}-ffi.ts
  3. In C++, in {namespace}.cpp: lower the JSI number, bigint and ArrayBuffer further, into C equivalents, e.g. uint32_t and uint8_t*
  4. Pass these C equivalents to Rust through a C style ABI.
  5. Rust lifts the low level types, then calls into handwritten Rust.

In more detail, for most types:

  1. do the serialization (and deserialization) step in Typescript into an ArrayBuffer using DataView and Uint8TypedArray.
    • This is done with a series of FfiConverters. These are generated for complex types, but many can be seen in ffi-converters.ts.
  2. 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.
  3. extract the int32_t* from the jsi::ArrayBuffer and copy into a RustBuffer, a C-style struct shared by both Rust and C++.
  4. 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:

  1. calls passes the string from Typescript to C++, these are represented as jsi::Value::String.
  2. In C++ UniffiString.h:
    1. get a C++ String using the utf8() method of jsi::String
    2. the copy the bytes into a jsi::ArrayBuffer.
  3. Return the ArrayBuffer to Javascript so it can be:
    1. added to the serialization of a complex type OR
    2. 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:

  1. when the first time the package is imported, the installRustCrate typescript method is called. This is in the input file for Codegen, the Native{namespace}.ts file.
  2. this invokes the corresponding machinery generated by Codegen, in Objective C and Java.
  3. once in Objective C and Java, we find the jsi::Runtime and the facebook::react::CallInvoker from the
  4. this then passes the Runtime and CallInvoker to the C++ Turbo-Module proper.
  5. This then calls into the generated cpp/bindings/{namespace}.cpp which implements the src/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.