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, the Web, and Node.js.
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:
- for Hermes via JSI, and to generate the code to create turbo-modules.
- for WASM, using wasm-bindgen, and the WASM crate.
- for WASM, calling a
cdylibbuilt forwasm32-unknown-unknowndirectly, with no generated shim crate. Seewasm2support. - for Node.js, calling a compiled
cdylibthrough an N-API runtime.
New projects depend on the @ubjs runtime packages; see Packages for what to install. Code generated before those packages existed keeps working unchanged.
Packages
uniffi-bindgen-react-native is a command line tool, ubrn, that turns the [uniffi::export] proc-macros in your Rust crate into TypeScript (and, for React Native, C++). The generated code is specific to your library, and leans on a small, fixed runtime — the FFI converters, marshalling, callback dispatch and library loading — that is the same for every library and so is shipped separately.
What a new project installs
The runtime is published under the @ubjs scope. A freshly generated project depends on:
| Target | Install | Imported by |
|---|---|---|
| Any target | @ubjs/core | the generated TypeScript |
| Node.js | @ubjs/node (with @ubjs/core) | the generated TypeScript |
| React Native | uniffi-bindgen-react-native (with @ubjs/core) | CocoaPods / CMake, for the C++/JSI runtime |
@ubjs/coreis the TypeScript runtime (FFI converters,RustBuffer, polyfills), shared by every target. New generated code imports it.@ubjs/nodeis a single prebuilt N-API addon that loads your compiledcdylibat runtime and calls into it with libffi. Because UniFFI uses a small, fixed set of FFI types, one addon works with any UniFFI library — there is no per-library glue to compile. See the Node.js reference.- For React Native, the C++/JSI runtime ships inside the
uniffi-bindgen-react-nativepackage (theuniffi-bindgen-react-native.podspecandcpp/includes), which the generated turbo-module compiles against. Start with the React Native tutorial; the Web tutorial extends it.
The uniffi-bindgen-react-native package itself is the ubrn command line and the React Native build tooling. On React Native it is a regular dependency (for the C++ runtime above); for a Node.js-only project it is only needed at build time, as a dev dependency.
Existing projects keep working
@ubjs/core is new. Code generated before it existed imports the runtime from uniffi-bindgen-react-native instead, and that package still ships the same runtime bytes under its old name — so projects that haven’t regenerated need no changes. The two are version-locked.
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 with React Native
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-nativefor 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.42.3, 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
✔ 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 two packages to your project: uniffi-bindgen-react-native, which provides the ubrn command line and the C++/JSI runtime the turbo-module compiles against, and @ubjs/core, the runtime that the generated TypeScript imports at run-time.
yarn add uniffi-bindgen-react-native @ubjs/core
Both are regular dependencies for a React Native library: uniffi-bindgen-react-native ships the native runtime that gets compiled into the turbo-module, and @ubjs/core is imported by the generated bindings. (A Node.js-only project instead installs ubrn as a dev dependency and @ubjs/node alongside @ubjs/core; see the Node.js reference.)
Opening package.json add the following:
"scripts": {
+ "ubrn:ios": "ubrn build ios --and-generate && (cd example/ios && pod install)",
+ "ubrn:android": "ubrn build android --and-generate",
+ "ubrn:web": "ubrn build web",
+ "ubrn:checkout": "ubrn checkout",
+ "ubrn:clean": "rm -rfv cpp/ android/CMakeLists.txt android/src/main/java android/*.cpp ios/ src/Native* src/index.*ts* src/generated/",
"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.
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.
---
rust:
repo: https://github.com/jhugman/uniffi-starter.git
branch: jhugman/bump-uniffi-to-0.31
manifestPath: rust/foobar/Cargo.toml
Save this in a file at the root of your directory, called ubrn.config.yaml.
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
xcframeworkfor 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
Podfilein theexample/iosdirectory 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
gradlewto 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.
Building for Web will:
- Build the Rust crate for your machine
- Use the built library file to generate
- a WASM crate
- Typescript bindings
- Build the WASM crate for
wasm32-unknown-unknown - Use
wasm-bindgento connect the WASM crate to the typescript bindings
yarn ubrn:web
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 'my-rust-lib';
// 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();
Next, we need to update the timing of App registration.
We need to edit example/input.js:
import { AppRegistry } from 'react-native';
import App from './src/App';
import { name as appName } from './app.json';
+import { uniffiInitAsync } from "my-rust-lib";
+uniffiInitAsync().then(() => {
+ AppRegistry.registerComponent(appName, () => App);
+});
- AppRegistry.registerComponent(appName, () => App);
This is so WASM bundles can be loaded asynchronously.
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.
For the web, see the Getting Started, Web edition
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.
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.
Compiling for iOS gives an error 'UniffiCallInvoker.h' file not found
We’ve seen this where there have been problems with the *.podspec file for the library.
- if the dependency on
uniffi-bindgen-react-nativeisn’t listed, it might be the podspec file isn’t being generated at all. - if the dependency on
uniffi-bindgen-react-nativeis listed, check that the app’sPodfileisn’t also depending onuniffi-bindgen-react-native. Remove one of these dependencies. - there may be multiple podspec files in your library, both of which depending on
uniffi-bindgen-react-native. The name in theubrn.config.yamlfile can be deleted (where the podspec filename is derived from), as it should match the name derived from thepackage.jsonfile.
-
I have no idea what I’m saying. ↩
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.
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.
Add the WASM specific target
This command adds the backend for the Rust compiler to emit WebAssembly.
rustup target add \
wasm32-unknown-unknown
Install wasm-bindgen
This command rewrites a compiled
.wasmso that JavaScript can call it, resolving the imports thewasm-bindgencrate leaves behind for it at compile time.
cargo install wasm-bindgen-cli
The rewriter takes only the version of the wasm-bindgen crate your module was built against, so once your Cargo.lock has settled on one, install that:
cargo install wasm-bindgen-cli --version 0.2.127 # whatever your lock says
ubrn pins no version of its own, and names the one it wants when the binary it finds disagrees. Set UBRN_WASM_BINDGEN to a path when the right binary cannot go on PATH — a machine building two projects can need two of them.
Install nodejs
If nodejs isn’t already installed on your system, you should install it as per the nodejs.org install instructions.
This guide and related documentation assumes yarn as a package manager.
Getting started with WASM
This extends the Step-by-step tutorial with React Native. We’ve split this out running the library under WASM involves creating an expo app.
This page covers the web flavor, which builds a wasm-bindgen crate around your library. There is now a second way to run the same crate as WebAssembly, from a single build and with no generated crate: see WebAssembly (wasm2) support, and Moving from web to wasm2 once you have this working.
Preparing the library
If you came straight here without following the React Native tutorial, make sure your library lists @ubjs/core in its dependencies (yarn add @ubjs/core) — the generated web bindings import it too.
Add a script to the package.json, if you haven’t already:
"script": {
+ "ubrn:web": "ubrn web build"
},
Also, add the entrypoint for browsers:
+ "browser": "src/index.web.ts",
+ "react-native": "src/index.tsx",
You can ensure that the bindings get generated specifically for both react-native and the web, by changing the ubrn.config.yaml file.
rust:
repo: https://github.com/jhugman/uniffi-starter.git
branch: jhugman/bump-uniffi-to-0.31
manifestPath: rust/foobar/Cargo.toml
+ web:
+ ts: src/generated/web
+ bindings:
+ ts: src/generated/rn
Once these changes are done, then you can run:
yarn ubrn:web
This runs the wasm-bindgen command, which has to be the version your Cargo.lock resolves for the wasm-bindgen crate — see Before you start. The build names the version it wants when the binary it finds disagrees.
This does a number of things, but you end up with:
- an entrypoint file
src/index.web.ts - a bindings file called
src/generated/web/foobar.rs - some wasm-bindgen generated files in
src/generated/web/wasm-bindgen:index_bg.wasmindex.jsindex.d.tsindex_bg.wasm.d.ts
Now, you should be ready to write an example app.
Making the example app
I’m going to make use expo to make an example app in the directory next to our my-rust-lib directory.
export dir=my-wasm-app
yarn \
create \
expo-app \
--template blank-typescript \
--yes \
$dir
cd $dir
We’ll need to install the react-native-web libraries, and associated bits that converts the React Native JSX to Web JSX, which in turn converts to a sea of divs.
npx expo install \
react-dom \
react-native-web \
@expo/metro-runtime
Then, add our my-rust-lib library.
yarn add ../my-rust-lib
I don’t really understand how npm and yarn do linking or workspaces.
Doing yarn add ../my-rust-lib copies everything into the node_modules directory of the example, which is less than ideal.
If you know a better way, please open a PR. Help!
Write a demo
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 'my-rust-lib';
// 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();
Initializing the WASM in the web page
Next, we need to update the timing of App registration.
We need to edit example/input.js:
import { AppRegistry } from 'react-native';
import App from './src/App';
import { name as appName } from './app.json';
+import { uniffiInitAsync } from "my-rust-lib";
+uniffiInitAsync().then(() => {
+ AppRegistry.registerComponent(appName, () => App);
+});
- AppRegistry.registerComponent(appName, () => App);
You may also initialize the WASM in a useEffect block.
Teaching Metro about WASM files
You may have to show your bundler what to do with WASM file—just serve them as binary data files.
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
// Add wasm asset support
config.resolver.assetExts.push('wasm');
// Add COEP and COOP headers to support SharedArrayBuffer
config.server.enhanceMiddleware = (middleware) => {
return (req, res, next) => {
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
middleware(req, res, next);
};
};
module.exports = config;
The operative part of this configuration is marking wasm files as assets.
config.resolver.assetExts.push('wasm');
Running in a page
Running yarn web should now open a web page showing the result to be 42.
Getting started without React Native
Outside of the React Native ecosystem, the important two steps are:
- asynchronously initializing the WASM bundle, using
uniffiInitAsync - getting your bundler to allow asynchronous serving of WASM files.
For Webpack
The wasm example in the webpack repository is instructive here.
The operative step is to set experiments.asyncWebAssembly = true in your current WASM config.
I’m really not a real web developer, so would very much appreciate help with this documentation from someone who is.
Moving from web to wasm2
Both flavors run your crate as WebAssembly. The web flavor gets there by generating a Rust crate of #[wasm_bindgen] wrappers around your library and building that; wasm2 builds your library for wasm32-unknown-unknown and drives it from a runtime player, so there is no second crate and no per-function glue.
If you already have a working web build, this page is the diff.
You do not have to choose. The two flavors read different sections of the configuration file and can generate into different directories, so you can stand wasm2 up beside your existing web build, compare them, and delete the loser. The last section shows how.
What moves into your crate
The web flavor puts the wasm-specific requirements in the crate it generates for you, so your own Cargo.toml never sees them. wasm2 loads your crate directly, so they move to you.
[lib]
- crate-type = ["lib"]
+ crate-type = ["lib", "cdylib"]
+ [target.'cfg(target_arch = "wasm32")'.dependencies]
+ uniffi-runtime-wasm = "0.31.0-3"
+ uniffi_core = { version = "0.31", features = ["wasm-unstable-single-threaded"] }
// src/lib.rs
+ #[cfg(target_arch = "wasm32")]
+ extern crate uniffi_runtime_wasm as _;
Nothing here is new to your project — the generated wasm crate already depended on uniffi-runtime-javascript with its wasm32 feature, which is what enabled wasm-unstable-single-threaded on your behalf. The dependency has changed name and moved one crate closer to you.
The extern crate line is the one with no counterpart. uniffi-runtime-wasm exports the allocator and panic hook the player calls, and since nothing in your code references it, the linker would otherwise drop it.
ubrn build wasm2 checks the three manifest requirements and names whichever is missing. It cannot check the extern crate line. Leaving it out fails later, when opening the module reports required export "__ubrn_alloc" not found in wasm module.
What leaves ubrn.config.yaml
The whole web section goes, and in the common case nothing replaces it:
- web:
- manifestPath: rust_modules/wasm/Cargo.toml
- ts: src/generated/web
- entrypoint: src/index.web.ts
wasm2 puts its bindings wherever bindings/ts says, so a project that was happy with one output directory needs no section at all. Add a wasm2 section only when you want a different directory, or non-default cargo features.
Most of the web section described the crate that no longer exists. This is what happens to each key:
web key | Under wasm2 |
|---|---|
ts / tsBindings | same name, same meaning, and now where the .wasm is staged too — but only needed to override bindings/ts |
features, defaultFeatures | same names, applied only to your crate; there is no second manifest to copy them into |
cargoExtras | same |
manifestPath, wasmCrateName, workspace | gone; no crate is generated, so there is nothing to name or place |
manifestPatchFile | gone. It existed to patch the generated manifest — now you edit your own |
runtimeVersion | gone; the runtime is a dependency you declare |
target, wasmBindgenExtras | gone; wasm-bindgen is no longer invoked as a command |
entrypoint | gone; there is no generated entrypoint to place, which is what the next section is about |
What to add to package.json
Three things, one of which is easy to miss.
"scripts": {
- "ubrn:web": "ubrn build web",
+ "ubrn:web": "ubrn build wasm2",
},
- "browser": "src/index.web.ts",
+ "browser": "src/generated/index.ts",
"dependencies": {
"@ubjs/core": "^0.31.0-3",
+ "@ubjs/wasm": "^0.31.0-3"
}
ubrn build wasm2 generates by default, so there is no --and-generate to add.
The browser field is the one to watch. Under web it pointed at a file ubrn generated for you; ubrn build wasm2 writes only the bindings, so it has to point at something that still exists. The generated src/generated/index.ts re-exports every namespace and is a complete entrypoint — but it leaves naming the .wasm to the caller, which is what the next section covers.
```admonish warning title=“generate all still writes the web entrypoint”
ubrn generate all --flavor wasm2 writes src/index.web.ts and the wasm crate under rust_modules/ anyway: --flavor chooses the bindings generator, not the project files. The file it writes imports generated/wasm-bindgen/index.js, which wasm2 does not produce, so it is broken on arrival.
ubrn build wasm2 is scoped to this flavor and does not do that. If something in your pipeline calls generate all, exclude the stale files:
noOverwrite:
- src/index.web.ts
## What changes in the app
Under `web`, `ubrn` generates `src/index.web.ts` for you, and that file names the `.wasm` itself — which is why `uniffiInitAsync()` takes no arguments.
`ubrn build wasm2` writes no project entrypoint, because the bindgen already writes one: `src/generated/index.ts` re-exports every namespace and exports a `uniffiInitAsync` that takes the module. Naming the asset moves to the caller, since a bundler rewrites that name as it copies the file and only your host knows how.
Pointing `browser` at that generated file is the shortest migration, and moves the asset name into your app. Writing your own `src/index.web.ts` keeps it out of the app, at the cost of a file — the same path the `web` flavor used, except that now you own it:
```typescript
// src/index.web.ts
import { uniffiInitAsync as initBindings } from "./generated";
export * from "./generated";
export function uniffiInitAsync() {
return initBindings(new URL("./generated/my_crate.wasm", import.meta.url));
}
Keeping the same exported name means the app that consumed the web build needs no change at all:
import { uniffiInitAsync } from "my-rust-lib";
uniffiInitAsync().then(() => {
AppRegistry.registerComponent(appName, () => App);
});
Under Metro, new URL(...) is not how assets resolve. Use the asset registry instead, as the web tutorial already has you configure:
import { Asset } from "expo-asset";
export async function uniffiInitAsync() {
const asset = Asset.fromModule(require("./generated/my_crate.wasm"));
await asset.downloadAsync();
return initBindings(asset.uri);
}
Either way, remember to point browser at whichever file you chose.
What you can delete
- The generated wasm crate, wherever
web.manifestPathpointed — usuallyrust_modules/wasm/. Nothing generates or reads it now. - The
wasm-bindgenoutput directory under your bindings, holdingindex.js,index_bg.wasmand their.d.tsfiles.wasm2stages a single.wasmbeside the bindings instead. cargo install wasm-bindgen-clifrom your setup instructions and CI — but only if nothing in your crate’s dependency tree reaches wasm-bindgen. If something does, staging still runs the rewrite, and the binary has to be the version yourCargo.lockresolves.console_error_panic_hook, if you added it to see panics. The player installs a panic hook while opening the module, and prints[Rust panic] <message>with the JavaScript stack.- Any
noOverwriteglobs covering the generated web crate — nothing generates it now. Keep, or add, one forsrc/index.web.tsif you wrote your own and anything in your pipeline still callsgenerate all. - The COEP and COOP headers in
metro.config.js, if you added them only for thewebflavor. Those exist forSharedArrayBuffer, which the player does not use. KeepassetExts.push('wasm'), whichwasm2still needs.
On the npm side, add the player and keep the shared runtime:
yarn add @ubjs/wasm @ubjs/core
Running both while you migrate
Give each flavor its own output directory and its own script, and nothing collides:
web:
ts: src/generated/web
wasm2:
ts: src/generated/web2
"scripts": {
"ubrn:web": "ubrn build web",
+ "ubrn:wasm2": "ubrn build wasm2",
The crate changes in the first section are additive — a cdylib alongside your lib, and dependencies behind cfg(target_arch = "wasm32") — so the web build keeps working while you try the other. Point src/index.web.ts at one directory or the other to switch.
Once you are happy, delete the web section, the generated crate, and the losing directory.
What does not change
Your Rust API, the generated TypeScript API, and every line of app code that calls it. The bindings wasm2 generates for a namespace are the same bindings the other flavors generate — only the file beneath them, and the way it is loaded, is different.
Next
wasm2reference — the commands, the config, and what each error means.wasm2cookbook — bundler wiring, custom entrypoints, workers, shrinking the module.
Troubleshooting
The diversity of web stacks available mean that we cannot enumerate all the possible errors or problems you may encounter.
However, here are some that the authors have found, and how to fix them.
panic! not reporting, or RuntimeError: Unreachable executed
By default, WASM doesn’t report to the console when a panic occurs in the Rust.
wasm-bindgen provide a console_error_panic_hook crate.
You should add this to your target crate’s Cargo.toml,
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1.7"
and some place near your Rust startup run:
#![allow(unused)] fn main() { #[cfg(target_arch="wasm32")] console_error_panic_hook::set_once(); }
FinalizationRegistry not found
This occurs when type checking the generated code. It’s caused by Typescript not knowing about global classes introduced “recently”.
The fix is to update the tsconfig.json file’s target to something more recent than es2021.
"compilerOptions":
"target": "es2021" # or `esnext`
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.
To opt out of this behavior, there is a
corresponding key in uniffi.toml.
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 |
Ord | compareTo(other) | number (i8: −1, 0, or 1) |
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 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
moved 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
uniffiDestroymethod 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.
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");
Methods
Records can have methods defined via #[uniffi::export] impl:
#![allow(unused)] fn main() { #[derive(uniffi::Record)] pub struct Point { pub x: f64, pub y: f64, } #[uniffi::export] impl Point { pub fn distance_to(&self, other: &Point) -> f64 { let dx = self.x - other.x; let dy = self.y - other.y; (dx * dx + dy * dy).sqrt() } pub fn scale(&self, factor: f64) -> Point { Point { x: self.x * factor, y: self.y * factor } } } }
Since records are plain data objects with no class instances, methods become static-style functions on the companion factory object, alongside create and new. The self parameter becomes the first argument:
// Point has no defaulted fields, so create and new both accept all fields.
const p = Point.create({ x: 3.0, y: 4.0 });
const p2 = Point.new({ x: 3.0, y: 4.0 }); // synonym for create
const origin = Point.create({ x: 0.0, y: 0.0 });
Point.distanceTo(p, origin); // 5.0
Point.scale(p, 2.0); // { x: 6.0, y: 8.0 }
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(value, other) | boolean |
Hash | hashCode() | bigint |
Ord | compareTo(value, other) | number (i8: −1, 0, or 1) |
Note: since records have no class instance, equals and compareTo take the record value as their first argument: TraitRecord.equals(a, b) rather than a.equals(b).
These are declared on the record using the #[uniffi::export(...)] attribute:
#![allow(unused)] fn main() { #[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, uniffi::Record)] #[uniffi::export(Debug, Display, Eq, Hash, Ord)] pub struct TraitRecord { pub name: String, pub value: i32, } impl std::fmt::Display for TraitRecord { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "TraitRecord({}, {})", self.name, self.value) } } }
Unlike objects and enums (where these methods are instance methods), records are plain data objects with no class instances. Because of this, the trait methods become static-style methods on the companion factory object:
const r = { name: "hello", value: 42 };
TraitRecord.toString(r); // "TraitRecord(hello, 42)"
TraitRecord.toDebugString(r); // 'TraitRecord { name: "hello", value: 42 }'
const a = { name: "x", value: 1 };
const b = { name: "x", value: 1 };
const c = { name: "x", value: 2 };
TraitRecord.equals(a, b); // true
TraitRecord.equals(a, c); // false
TraitRecord.hashCode(r); // bigint
TraitRecord.compareTo(a, c); // negative (1 sorts before 2)
TraitRecord.compareTo(c, a); // positive
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,
}
Methods
Enums can have methods defined via #[uniffi::export] impl:
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] pub enum Direction { North, South, East, West } #[uniffi::export] impl Direction { pub fn opposite(&self) -> Direction { match self { Direction::North => Direction::South, Direction::South => Direction::North, Direction::East => Direction::West, Direction::West => Direction::East, } } } }
For flat enums, methods become static-style functions on the enum namespace. The self parameter becomes the first argument:
Direction.opposite(Direction.North); // Direction.South
Direction.opposite(Direction.East); // Direction.West
For tagged enums (enums with properties), methods follow the same pattern — static functions on the outer namespace, taking a variant instance as first argument:
#![allow(unused)] fn main() { #[derive(uniffi::Enum)] pub enum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, } #[uniffi::export] impl Shape { pub fn area(&self) -> f64 { … } } }
const circle = new Shape.Circle({ radius: 1.0 });
const rect = new Shape.Rectangle({ width: 3.0, height: 4.0 });
Shape.area(circle); // Math.PI
Shape.area(rect); // 12.0
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 |
Ord | compareTo(other) | number (i8: −1, 0, or 1) |
These are declared on the enum using the #[uniffi::export(...)] attribute:
#![allow(unused)] fn main() { #[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, uniffi::Enum)] #[uniffi::export(Debug, Display, Eq, Hash, Ord)] pub enum TraitEnum { Alpha, Beta { val: String }, } impl std::fmt::Display for TraitEnum { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TraitEnum::Alpha => write!(f, "Alpha"), TraitEnum::Beta { val } => write!(f, "Beta({})", val), } } } }
For enums with properties (tagged enums), these become instance methods on each variant class:
const a = new TraitEnum.Alpha();
const b = new TraitEnum.Beta({ val: "hello" });
a.toString(); // "Alpha"
b.toString(); // "Beta(hello)"
a.toDebugString(); // "Alpha"
a.equals(new TraitEnum.Alpha()); // true
a.equals(b); // false
a.compareTo(b); // negative (Alpha sorts before Beta)
b.compareTo(a); // positive
a.hashCode(); // bigint
For flat enums (variants with no data), the methods are generated as static functions in a namespace that merges with the enum. This keeps the enum variants as plain values:
#![allow(unused)] fn main() { #[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, uniffi::Enum)] #[uniffi::export(Debug, Display, Eq, Hash, Ord)] pub enum FlatTraitEnum { Alpha, Beta, Gamma, } }
// Variants are still plain enum values
const a = FlatTraitEnum.Alpha;
const b = FlatTraitEnum.Beta;
// Trait methods are static namespace functions
FlatTraitEnum.toString(a); // "Alpha"
FlatTraitEnum.toDebugString(a); // "Alpha"
FlatTraitEnum.equals(a, b); // false
FlatTraitEnum.equals(a, FlatTraitEnum.Alpha); // true
FlatTraitEnum.compareTo(a, b); // negative (Alpha sorts before Beta)
FlatTraitEnum.compareTo(b, a); // positive
FlatTraitEnum.hashCode(a); // bigint
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.
Implementing traits from external crates
A trait defined in a dependency crate — not your own — can also be implemented in TypeScript, with no extra configuration. This works for both proc-macro style (#[uniffi::export(with_foreign)]) and UDL style ([Trait, WithForeign]) traits.
Suppose a dependency crate (uniffi-one) exports a foreign trait:
#![allow(unused)] fn main() { // in the `uniffi-one` crate #[uniffi::export(with_foreign)] pub trait UniffiOneTrait: Send + Sync { fn hello(&self) -> String; } }
Or equivalently via UDL:
// in uniffi-one.udl
[Trait, WithForeign]
interface UniffiOneUDLTrait {
string hello();
};
Another crate (or your own app’s Rust layer) can then accept the trait as a parameter:
#![allow(unused)] fn main() { // in a second crate that depends on `uniffi-one` #[uniffi::export] fn call_trait_impl(t: Arc<dyn UniffiOneTrait>) -> String { t.hello() } }
On the TypeScript side, import the interface from the external crate’s generated bindings and implement it as usual:
import { UniffiOneTrait } from "../generated/uniffi_one_ns";
import { callTraitImpl } from "../generated/imported_types_sublib";
const tsImpl: UniffiOneTrait = {
hello(): string {
return "hello from TypeScript";
},
};
const result = callTraitImpl(tsImpl);
The generated UniffiOneTrait interface comes from uniffi-one’s bindings. Your crate’s bindings expose callTraitImpl, which accepts any object satisfying that interface — whether it was created in Rust or TypeScript. No special annotation or glue code is needed.
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 example, and the more complete futures fixture.
Promises from synchronous Rust
Rust that is not async can still be given a Promise surface in Typescript, with the forceAsync option in uniffi.toml. The call itself stays synchronous; only the signature changes. It is a migration aid — a way to get call sites into the shape that calling Rust off the main thread requires, without changing what the code does today.
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.
Threading
Javascript—and by extension, Typescript—is a single-threaded language.
Uniffi purposely does not get involved in threads, but does take its lead from the host language.
React Native
The Rust integration for React Native provides the potential for multi-threaded Rust.
Dispatching to a background thread is an exercise for the developer on the Rust side of the FFI.
But what happens for background threads calling into Javascript? In such cases, because any client callbacks:
- may return something
- may throw an error that is declared and expected
- may throw an unexpected error
the background thread on the Rust side must block, waiting for Javascript callback to complete.
This might lead to a non-obvious deadlock position, if a Mutex is held while the callback is being called, and then is contended by the foreground call into the Rust, e.g. by a update or repaint.
This can be mitigated by one of:
- ensuring that a Mutex is released before the callback is called
- making the callback async and scheduling the repaint on the next tick.
WASM
WASM is currently a single-threaded environment. At the time of writing, the migration path to a multi-threaded virtual machine is unclear.
uniffi-rs provides a wasm-unstable-single-threaded feature. This should be enabled in the target crate.
Additionally, the following may be helpful to adapt your Rust for running with uniffi and WASM.
Async trait:
You may have code using the async_trait crate:
#![allow(unused)] fn main() { #[uniffi::export] #[async_trait::async_trait] pub trait MyRustTrait { async fn do_something_on_the_background(&self, ms: u16, who: String) -> String; } }
If do_something_on_the_background in turn awaits something in the browser, e.g. a fetch or a timer, these things are not Send,
in which case, you should re-write the #[async_trait::async_trait] to not be Send for wasm32 targets.
#![allow(unused)] fn main() { #[uniffi::export] #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] pub trait MyRustTrait { async fn do_something_on_the_background(&self, ms: u16, who: String) -> String; } }
Alternatively, you can forgo async_trait altogether, in favor of removing the clippy lint about async functions in traits.
#![allow(unused)] fn main() { #[uniffi::export] #[allow(async_fn_in_trait)] pub trait MyRustTrait { async fn do_something_on_the_background(&self, ms: u16, who: String) -> String; } }
Local development of uniffi-bindgen-react-native
Pre-installation
This guide is in addition to the Pre-installation guides for React Native and WASM.
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.
All tests (fixture tests, framework tests, and Rust unit tests) are run with:
cargo test
Individual fixtures can be tested by package name:
cargo test -p uniffi-fixture-chronological
cargo test -p uniffi-fixture-arithmetic
Tests run under both JSI (Hermes) and WASM flavors. You can filter by flavor:
cargo test -p uniffi-fixture-arithmetic -- jsi
cargo test -p uniffi-fixture-arithmetic -- wasm
The typescript/tests directory contains Typescript-only framework tests. These have been useful to prototype generated Typescript before moving them into templates.
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
Before pushing a PR
Ensure that the following all run cleanly:
cargo xtask fmt
cargo test
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 Rinja 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/templatesdirectory. - Template configuration are in
codegen/mod.rsfile.
Adding a new template
- Add new template to the
codegen/templatesdirectory. - Add a new
RenderedFilestruct, which specifies the template, and its path to rightcodegen.rsfile (here are thecodegen.rsfiles for JSI Android, JSI iOS, JSI crossplatform and WASM). - 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.afile from the uniffi contents of the Rust library.ProjectConfigwhich is the in-memory representation of the YAML configuration file.CrateMetadatawhich 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++ bindings 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 Rinja templating library.
The WASM crate is generated with quote, in the gen_rust module.
There is a small-ish runtime per target:
typescript/src, with tests and polyfills. This is the TypeScript runtime, published to npm as@ubjs/coreand shared by all targets.- [’cpp/includes
][cpp-runtime], the C++/JSI runtime for React Native, published as theuniffi-bindgen-react-native.podspec`. runtimes/napi, the N-API runtime for Node.js, published as@ubjs/node. Its Rust core is inruntimes/core.
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:
cargo test
An individual fixture can be tested:
cargo test -p uniffi-fixture-$fixtureName
File Matching API
Overview
The File Matching API provides a convenient way to test file operations in your code. It allows you to:
- Record file writes during test execution
- Assert on file content using flexible matchers
- Match files by path suffix to handle variable paths
- Provide detailed error messages for failed assertions
Usage
Basic Example
#![allow(unused)] fn main() { use ubrn_cli_testing::{start_recording, stop_recording, File, assert_files}; use ubrn_common::write_file; #[test] fn test_my_function_writes_correct_files() { // Start recording file operations start_recording(); // Call your function that writes files my_function_that_writes_files(); // Assert on the files that were written assert_files(&[ // Match a file with exact path suffix File::new("output.json") .contains("\"status\": \"success\"") .does_not_contain("error"), // Match another file using path suffix File::new("/src/generated/types.kt") .contains("class User") ]); // Clean up recording state stop_recording(); } }
Available Matchers
The API provides two content matchers:
.contains(substring)- Asserts that the file content contains the specified substring.does_not_contain(substring)- Asserts that the file content does not contain the substring
Path Matching
File paths are matched using suffix matching, which means you only need to provide the unique part of the path:
#![allow(unused)] fn main() { // This will match any file ending with "/src/main.kt" File::new("/src/main.kt") // This will match any file named "config.json" File::new("config.json") }
Functions
start_recording()- Begins recording file operationsstop_recording()- Stops recording and clears recorded filesassert_files(files)- Asserts that files matching the provided patterns were writtenfiles_match(files)- Returns true if files match the patterns, false otherwise
Integration with Command Recording
The file matching API integrates seamlessly with the existing command recording functionality:
#![allow(unused)] fn main() { use ubrn_cli_testing::{start_recording, stop_recording, Command, assert_commands, File, assert_files}; #[test] fn test_function_generates_files_and_runs_commands() { start_recording(); my_function(); // Assert on commands assert_commands(&[ Command::new("npm").arg("install"), Command::new("cargo").arg("build") ]); // Assert on files assert_files(&[ File::new("package.json").contains("\"name\": \"my-package\""), File::new("src/index.js").contains("export default") ]); stop_recording(); } }
Cutting a Release
A release publishes seven artifacts to three registries, through six workflows. All of them are triggered automatically when a GitHub Release is published, so the bulk of cutting a release is: get the version numbers right, land the bump, then draft the release.
What gets published
| Artifact | Registry | Workflow | Source | Version comes from |
|---|---|---|---|---|
uniffi-bindgen-react-native | npm | npm.yml | repo root | package.json |
@ubjs/core | npm | npm-core.yml | typescript | typescript/package.json |
@ubjs/node + @ubjs/node-<platform> | npm | napi-publish.yml | runtimes/napi | runtimes/napi/package.json |
@ubjs/wasm | npm | npm-wasm.yml | runtimes/wasm | runtimes/wasm/package.json |
uniffi-runtime-javascript | crates.io | crates-io.yaml | crates/uniffi-runtime-javascript | crates/uniffi-runtime-javascript/Cargo.toml |
uniffi-runtime-wasm | crates.io | crates-io.yaml | runtimes/wasm/helper-crate | runtimes/wasm/helper-crate/Cargo.toml |
uniffi-bindgen-react-native (Pod) | CocoaPods | cocoapods.yml | uniffi-bindgen-react-native.podspec | package.json (the podspec reads package['version']) |
crates-io.yaml publishes both crates from one matrixed job, with
fail-fast: false — neither crate depends on the other, so one failure does
not cancel the other’s publish.
@ubjs/node is the N-API runtime. Its workflow first builds a native binary
for every supported target (macOS x64/arm64, Linux gnu/musl on x64/arm64,
Windows x64/arm64), publishes each as a platform package
(@ubjs/node-darwin-arm64, @ubjs/node-linux-x64-gnu, …), then publishes the
@ubjs/node root package whose optionalDependencies point at them. If the
build matrix fails for any target, the publish job does not run.
@ubjs/wasm is the wasm2 player runtime, and uniffi-runtime-wasm is the
helper crate a consuming cdylib links. @ubjs/wasm declares @ubjs/core as a
peerDependency, so that range has to move with the version too.
Steps
- Increment the version number, keeping all seven files in sync:
package.json(also drives the CocoaPod)crates/ubrn_cli/Cargo.tomlcrates/uniffi-runtime-javascript/Cargo.tomltypescript/package.json(the@ubjs/coreruntime)runtimes/napi/package.json(the@ubjs/noderuntime)runtimes/wasm/package.json(the@ubjs/wasmruntime — also its@ubjs/corepeerDependencyrange)runtimes/wasm/helper-crate/Cargo.toml(theuniffi-runtime-wasmcrate)
- Update the lockfiles to follow, rather than editing them by hand:
cargo metadata --offline > /dev/nullrefreshesCargo.locknpm install --package-lock-onlyin each oftypescript,runtimes/napiandruntimes/wasm
- Update the version references outside the manifests:
docs/src/reference/config-yaml.md— theruntimeVersiondefaultruntimes/wasm/helper-crate/README.md— theCargo.tomlsnippetcrates/ubrn_cli/fixtures/defaults/package.json— the@ubjs/coredependency- Leave statements dating a feature to the release that introduced it (e.g.
“As of
0.31.0-3” in the Node.js reference) alone — those are history.
- Update the CHANGELOG. If the CHANGELOG is up-to-date, then this should be minimal.
- Add a new version title at the top
- Update the Full Changelog link to go from new release to main
- Move the bottom of the “upcoming release” section to the top
- Update the Full Changelog link to go from previous release to new release
- Push as a PR as usual, with subject:
Release ${VERSION_NUMBER}. - (Optional but recommended) Run a dry-run of the publish workflows — see Testing a release before tagging.
- Once the PR has landed, draft a new release.
- Create a new tag (in the choose-a-tag dialog). The tag is the version
exactly as it appears in
package.json, with novand no abbreviation —0.31.0-5, neverv0.31.0-5and never0.31-5. Copy it, do not retype it:node -p "require('./package.json').version" - Use that same version with a
vprepended for the release title:v${VERSION_NUMBER}. Thevbelongs to the title only, never the tag. - Publish the release.
- Wait for the six publish workflows to go green:
- Verify the release landed.
- Tell your friends, make a song and dance, you’ve done a new release.
Testing a release before tagging
Five of the six publish workflows can be run manually from the Actions tab
(workflow_dispatch) with a dry-run input that defaults to true. Use this
to validate packaging — cargo publish --dry-run, npm publish --dry-run —
without pushing anything to a registry:
npm.yml—dry-runinputnpm-core.yml—dry-runinputnpm-wasm.yml—dry-runinputcrates-io.yaml—dry_runinputnapi-publish.yml—dry-runinput
cocoapods.yml
has no dry-run input. Triggering it manually runs a real pod trunk push.
To only validate the podspec, run pod spec lint uniffi-bindgen-react-native.podspec
locally instead.
A real release fires every workflow on the release: published event; the
dry-run path is reachable only through manual workflow_dispatch.
After publishing
Confirm each artifact actually went out:
- npm:
npm view <pkg> versionforuniffi-bindgen-react-native,@ubjs/core,@ubjs/nodeand@ubjs/wasm - crates.io: https://crates.io/crates/uniffi-runtime-javascript/versions and https://crates.io/crates/uniffi-runtime-wasm/versions
- CocoaPods:
pod trunk info uniffi-bindgen-react-native
If a workflow fails part-way, re-run just that workflow from the Actions tab
(workflow_dispatch, dry-run false) once the underlying problem is fixed —
you do not need to cut a new tag. npm and crates.io reject re-publishing a
version that already exists, so a re-run after a partial @ubjs/node publish
will skip the platform packages that already landed and publish the rest.
Version numbers
A release version is always exactly this shape:
MAJOR . MINOR . 0 - N
└────┬────┘ │ └── variant number, monotonic
│ └────── always literally 0
└──────────────── tracks the uniffi-rs release
MAJOR.MINORtracks theuniffi-rsrelease the bindings are built against.uniffi-rs0.31.xgives0.31.- The patch is always
0. We do not mirror theuniffi-rspatch level. Ifuniffi-rsgoes0.31.0→0.31.4, ourMAJOR.MINORis unchanged and onlyNmoves. The0is a placeholder: semver requires three numeric components, so we cannot write0.31-5(see below). Nincreases monotonically across releases and is not reset when theuniffi-rsversion changes. If the last release was0.30.0-1anduniffi-rsis bumped to0.31, the next release is0.31.0-2, not0.31.0-0.
Older releases (0.28.3-5, 0.29.3-1) do carry a non-zero patch, from when the
scheme mirrored the uniffi-rs patch level. They are history; do not copy them.
One string drives everything
The same string is the npm version, the crate version, the CocoaPod version and
the git tag, because the podspec derives its source tag from package.json:
s.version = package['version']
s.source = { :git => ..., :tag => s.version.to_s }
So a tag that does not match package.json exactly fails CocoaPods lint with
Remote branch <version> not found in upstream origin, and every other publish
workflow goes red alongside it.
These are semver prereleases
Anything after the - is a semver prerelease identifier, so 0.31.0-5 reads
as “a prerelease of 0.31.0” and sorts below 0.31.0. Every release we have
ever cut is, to npm and cargo, a prerelease. Two consequences:
npm publishmust pass--tag latest. npm 11 (bundled with node 24) refuses to publish a prerelease without an explicit dist-tag, rather than silently movinglatestonto it. All four npm publish workflows pass it on everynpm publishinvocation, dry-run included; a new one must too.- Consumers need an explicit version.
npm install uniffi-bindgen-react-nativeresolves via thelatestdist-tag, which we set — but a bare semver range like^0.31.0will not match0.31.0-5.
The patch cannot be dropped from the string
Tempting, but neither toolchain accepts it — semver mandates all three
components before a - suffix:
$ npm publish --dry-run # version = "0.31-5"
npm error Invalid version: "0.31-5"
$ cargo metadata # version = "0.31-5"
error: unexpected character '-' after minor version number
“Dropping the patch” is therefore a policy — we stop tracking the uniffi-rs
patch level — not a change to the string. The .0 stays.
Compatibility with other packages
Other versioning we should take care to note:
- React Native
create-react-native-library
Compatibility matrices are built by the nightly matrix (iOS + Android, the full date-derived window) and gated per-PR by the PR gate (latest RN only).
The matrix is date-derived: .github/scripts/compat-matrix.mjs picks, for
each React Native release published in the last 365 days, the
create-react-native-library version and CI runner image that were current at
that RN’s publish date. Runner images come from
.github/compat-runner-schedule.json — the one place to maintain. When
GitHub ships a new macOS/ubuntu generation, append a row
({ "since": "<GA date>", "ios": "<label>", "android": "<label>" }); when an
old label is retired, bump the oldest row to the oldest still-hosted label.
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
REPOargument and optional--branchargument. 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:
--configconfig file.--and-generatethis runs thegenerate allcommand immediately after building.--targetsa comma separated list of targets, specific to each platform. This overrides the values in the config file.--releasebuilds a release version of the library.--profileuses a specific build profile, overriding –release if necessary
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
-p, --profile <PROFILE>
Use a specific build profile
This overrides the -r / --release flag if both are specified.
--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
--native-bindings
Generate native Kotlin Bindings together with the JNI libraries
-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 jsi bindings and generate jsi 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"]
On Android you can decide if you want to use shared or static version of library. See the Android section of the configuration documentation for more information.
If you are building native bindings on Android:
- make sure that
--native-bindingsis also passed to thegeneratecommand. See more in the generate jsi turbo-module section. - each crates defines its own
uniffi.toml, looking like:
[bindings.kotlin]
cdylib_name = "<your_lib_name>"
- if you are using proguard, you will need to add proper rules to the
proguard-rules.profile, like so:
-keep class <crate_package_name>.** { *; }
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.
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.
--native-bindings
Generate native Swift Bindings together with the xcframework
-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
-p, --profile <PROFILE>
Use a specific build profile
This overrides the -r / --release flag if both are specified.
--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 jsi bindings and generate jsi 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"]
build web
Build the crate for use in a web page
Usage: uniffi-bindgen-react-native build web [OPTIONS] --config <CONFIG>
Options:
--config <CONFIG>
The configuration file for this build
--no-generate
Opts out of generating the bindings and wasm-crate
--no-wasm-pack
Opts out of generating running wasm-pack on the generated wasm-crate
--target <TARGET>
Target passed to wasm-pack/wasm-bindgen.
Overrides the setting in the config file.
If that is missing, then default to "web".
-r, --release
Build a release build
-p, --profile <PROFILE>
Use a specific build profile
This overrides the -r / --release flag if both are specified.
--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.
This command:
- builds the target Rust crate, for the target it’s being built on.
- uses that library to:
- generate a wasm-crate which depends on the target crate, with
wasm-bindgenannotated functions. - generate typescript bindings which call into the
wasm-bindgenfunctions - This can be configured with the
ubrn.config.yamlfile.
- generate a wasm-crate which depends on the target crate, with
- compiles the wasm-crate for the
wasm32-unknown-unknowntarget. - calls
wasm-bindgenCLI to generate the__wbgJS helper functions and put the WASM bundle in the correct place.
generate
This command is to generate code for:
jsi:- 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.
wasm: 1.
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:
jsi Commands to generate the JSI bindings and turbo-module code
wasm Commands to generate a WASM crate
help Print this message or the help of the given subcommand(s)
Options:
-h, --help
Print help (see a summary with '-h')
generate jsi 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-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 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 jsi 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 jsi 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 jsi turbo-module --config <CONFIG> [NAMESPACES]...
Arguments:
[NAMESPACES]... The namespaces that are generated by `generate jsi bindings`
Options:
--config <CONFIG>
The configuration file for this build
--native-bindings
This will add implementations required for native Android bindings to the generated `build.gradle` file.
-h, --help
Print help
The namespaces in the command 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 wasm bindings
Generate just the Typescript and wasm-bindgen bindings Rust files.
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.
This command will generate one typescript file and one Rust file per Uniffi namespace. These are: namespace.ts, namespace_module.rs, substituting namespace for names derived from the target Rust crate.
Usage: uniffi-bindgen-react-native generate wasm bindings [OPTIONS] --ts-dir <TS_DIR> --abi-dir <ABI_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
--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
--abi-dir <ABI_DIR> The directory in which to put the generated Rust
-h, --help Print help
generate wasm wasm-crate
Generate the Cargo.toml and entrypoints to the bindings.
Usage: uniffi-bindgen-react-native generate wasm wasm-crate --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 command 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.
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.
Node.js (N-API) support
As of 0.31.0-3, uniffi-bindgen-react-native can generate TypeScript bindings that run directly on Node.js, in addition to React Native and the Web.
This support is new and is currently scoped to generating bindings and running them against an already-compiled Rust library. Unlike the React Native and Web targets, there is not yet an end-to-end build command that compiles Rust, scaffolds a project, and wires everything together. If you are comfortable building your own cdylib and calling ubrn generate napi, this page describes what is available today.
How it works
Node.js support has two pieces:
- The bindgen generates TypeScript that describes your library’s FFI surface — the function signatures, structs, and callbacks that cross the boundary.
- The runtime, published to npm as
@ubjs/node, loads your compiled Rustcdylibat runtime, looks up its functions by name, and calls into them using libffi.
Because UniFFI uses a small, fixed set of FFI types, a single prebuilt native addon (@ubjs/node) works with any UniFFI library — there is no per-library glue code to compile. The generated TypeScript drives that addon with data.
The TypeScript runtime helpers (FFI converters, the RustBuffer type, and so on) are shared with the other targets and are published as @ubjs/core.
Generating bindings
Bindings are generated with the napi subcommand of ubrn generate (the node alias also works):
ubrn generate napi bindings \
--library path/to/libmy_crate.dylib \
--ts-dir path/to/generated/ts \
--lib-colocated
| Option | Description |
|---|---|
--library <PATH> | The compiled Rust cdylib to read the FFI metadata from. |
--ts-dir <DIR> | The directory the generated TypeScript is written to. |
--no-format | Skip formatting the generated code with prettier (which is run by default). |
One of the three library-resolution modes below is required. It controls how the generated TypeScript locates the cdylib at runtime.
Library resolution
The generated code needs to know where to find the cdylib when it runs. The mode you choose is baked into the bindings.
--lib-colocated
The binary sits next to the generated .js file at runtime. This is the simplest mode and is well suited to local development.
ubrn generate napi bindings --library ./libmy_crate.dylib --ts-dir ./ts --lib-colocated
--lib-absolute
The absolute path of --library is baked into the bindings as an override. The --library path must be absolute.
ubrn generate napi bindings --library /abs/path/libmy_crate.dylib --ts-dir ./ts --lib-absolute
--lib-package-base <BASE>
The cdylib is resolved via platform-specific npm packages using require.resolve, the same pattern used to distribute prebuilt native binaries on npm. Requires --library so the crate name can be derived.
The package name is formed from BASE and a target triple. If BASE ends in an alphanumeric character, a - separator is appended; otherwise the trailing punctuation is used as the literal separator:
BASE | Resolved package |
|---|---|
@scope/foo | @scope/foo-<triple> |
@scope/foo/ | @scope/foo/<triple> |
@scope/foo_ | @scope/foo_<triple> |
By default the triples are cargo-style (e.g. aarch64-apple-darwin). Pass --lib-node-triple to emit node-style triples instead (e.g. darwin-arm64, linux-x64-gnu, win32-x64-msvc):
ubrn generate napi bindings \
--library ./libmy_crate.dylib --ts-dir ./ts \
--lib-package-base @scope/foo --lib-node-triple
--lib-node-triple has no effect without --lib-package-base, and is rejected when combined with --lib-colocated or --lib-absolute.
Running the bindings
Add the runtime packages to your project and run the generated TypeScript on Node.js:
npm install @ubjs/node @ubjs/core
The generated bindings import from @ubjs/core and use @ubjs/node to open and call into your cdylib. With --lib-colocated, place the compiled cdylib next to the generated JavaScript.
Limitations
- No
ubrn build node/ scaffolding command yet — you compile thecdyliband runubrn generate napiyourself. - C++ bindings are not generated for this target; only TypeScript is produced.
See the @ubjs/node README for lower-level details on how the runtime marshals values, dispatches callbacks across threads, and loads libraries.
WebAssembly (wasm2) support
As of 0.31.0-3, uniffi-bindgen-react-native can generate TypeScript bindings that run your Rust crate as a WebAssembly module, from a single cargo build, with no generated Rust shim crate and no per-crate JavaScript glue.
This is the wasm2 flavor. It sits alongside the existing web flavor, which builds a wasm-bindgen crate around your library; the two do the same job by different means, and both are supported.
Hermes has no WebAssembly, so wasm2 does not run in a standard React Native app — use the JSI target there. Metro is still supported as a web bundler, for Expo Web and React Native Web.
How it works
wasm2 has three pieces:
- The bindgen compiles nothing and reads everything. UniFFI metadata is embedded in your crate as
UNIFFI_META_*symbols, and onwasm32-unknown-unknownthose become exported globals pointing into the module’s data segments. The bindgen reads them straight out of the.wasm, so the second, native-only build that other WASM tooling needs is not needed here. - The runtime, published to npm as
@ubjs/wasm, is a player: it reads a table of FFI signatures the bindgen emits beside your bindings, and calls the module through it. - The helper crate, published to crates.io as
uniffi-runtime-wasm, exports the two things the player needs and JavaScript cannot supply — an allocator for linear-memory scratch space, and a panic hook.
Because UniFFI uses a small, fixed set of FFI types, the signature table is data and the player is the same code for every library. That is the whole idea: the web flavor generates a Rust wrapper per FFI function into a shim crate and builds it; wasm2 generates a table instead.
The TypeScript runtime helpers — FFI converters, the RustBuffer type, and so on — are shared with the other targets and are published as @ubjs/core.
What your crate needs
Three lines in Cargo.toml, and one in lib.rs.
[lib]
crate-type = ["lib", "cdylib"]
[target.'cfg(target_arch = "wasm32")'.dependencies]
uniffi-runtime-wasm = "0.31.0-3"
uniffi_core = { version = "0.31", features = ["wasm-unstable-single-threaded"] }
#![allow(unused)] fn main() { #[cfg(target_arch = "wasm32")] extern crate uniffi_runtime_wasm as _; }
The player instantiates a WebAssembly module, so the crate has to link one. uniffi-runtime-wasm exports nothing you call, so nothing references it, so the linker would drop it — the extern crate line forces the link. Wasm is single-threaded, so without wasm-unstable-single-threaded UniFFI demands Send + Sync on every exported object and the crate will not compile for wasm32.
ubrn build wasm2 checks all three manifest requirements before it starts, and names whichever is missing. The extern crate line it cannot check; see the reference for what a missing one looks like.
Putting the dependencies behind cfg(target_arch = "wasm32") keeps them off your native builds, so the same crate still serves the JSI and Node.js targets unchanged.
Building
rustup target add wasm32-unknown-unknown
npm install @ubjs/wasm @ubjs/core
ubrn build wasm2 --config ubrn.config.yaml --release
That is one command doing three things: cargo build --lib --target wasm32-unknown-unknown, generate the TypeScript from the module cargo just wrote, and stage a copy of that module beside the TypeScript.
src/generated/
receipts.ts your API, in TypeScript
receipts-ffi.ts the signature table the player reads
index.ts the entrypoint
receipt_scanner.wasm your crate
Loading the module
Instantiating a WebAssembly module is asynchronous everywhere, so the generated index.ts exports uniffiInitAsync. Await it once, before the first call.
import { uniffiInitAsync, scanReceipt } from "./generated";
await uniffiInitAsync(new URL("./generated/receipt_scanner.wasm", import.meta.url));
const receipt = scanReceipt(imageBytes); // sync call into Rust
const total = await receipt.totalAsync(); // Rust future, JS promise
You name the .wasm yourself, because a bundler rewrites that URL when it copies the file and only your host knows how. See Loading the module for the spelling each environment wants.
How it compares
| Flavor | Host | Cargo builds | Generated per crate |
|---|---|---|---|
jsi | React Native / Hermes | one per target | TypeScript and C++ |
web | Browser, Node.js | two (native, then wasm32) | TypeScript and a Rust shim crate |
wasm2 | Browser, Node.js | one (wasm32) | TypeScript |
napi | Node.js | one (native) | TypeScript |
Limitations
- No Hermes, and so no standard React Native. Use the JSI target.
- No threads.
wasm32-unknown-unknownis single-threaded, and a Rust API that needs a host thread panics at the call rather than failing at the build. - No synchronous initialisation.
uniffiInitAsyncmust resolve before the first call into the module.
See also
- Moving from
webtowasm2— the diff, if you already have awebbuild. wasm2reference — every command, config key and runtime entry point.wasm2cookbook — bundlers, custom entrypoints, strict CSP, workers, shrinking the module.- The
wasm2player — how the runtime works, for contributors. - The
@ubjs/wasmREADME for the published package.
wasm2 reference
Everything the wasm2 flavor does, in the order you meet it: prepare the crate, build it, load it, call it. This page assumes you have read the overview.
The examples come from a made-up crate called receipt-scanner, whose library name is receipt_scanner and whose UniFFI namespace is receipts. Names like ReceiptStore and myScanner belong to that crate, not to uniffi-bindgen-react-native.
Prerequisites
| Piece | Comes from | What it does |
|---|---|---|
uniffi-bindgen-react-native | cargo or npm | the ubrn command line: builds, generates, stages |
uniffi-runtime-wasm | crates.io | the allocator and panic hook, exported from your cdylib |
@ubjs/wasm | npm | the player: reads the signature table, calls the module |
@ubjs/core | npm | the shared TypeScript runtime, a peer dependency of @ubjs/wasm |
wasm-bindgen | cargo, only if your crate needs it | rewrites the imports a wasm-bindgen-using dependency leaves behind |
rustup target add wasm32-unknown-unknown
npm install @ubjs/wasm @ubjs/core
The player runs wherever WebAssembly does: modern browsers, Node.js 18 or later, Bun, and web bundlers.
If anything in your crate’s dependency tree reaches wasm-bindgen — js-sys, web-sys, getrandom’s wasm backend, a HTTP client — the build needs the wasm-bindgen command too, at exactly the version your Cargo.lock resolves for the crate:
cargo install wasm-bindgen-cli --version 0.2.127 # whatever your lock says
ubrn pins no version of its own: it reads the one the module was built against out of the module, and names it if the binary it finds disagrees. Set UBRN_WASM_BINDGEN to a path when the right binary cannot go on PATH — a machine building two projects can need two of them.
Preparing the crate
The manifest
# receipt-scanner/Cargo.toml
[lib]
crate-type = ["lib", "cdylib"]
[dependencies]
uniffi = "0.31"
[target.'cfg(target_arch = "wasm32")'.dependencies]
uniffi-runtime-wasm = "0.31.0-3"
uniffi_core = { version = "0.31", features = ["wasm-unstable-single-threaded"] }
Each line earns its place. cdylib is what produces a WebAssembly module at all. uniffi-runtime-wasm supplies __ubrn_alloc, __ubrn_free and a panic hook, all of which the player calls and none of which JavaScript can provide. wasm-unstable-single-threaded drops UniFFI’s Send + Sync requirement on exported objects, which wasm32 cannot satisfy; spelling the feature on uniffi rather than uniffi_core works too, since uniffi re-exports it.
The [target.'cfg(target_arch = "wasm32")'] block keeps all of this off your native builds, so the same crate still serves the JSI and Node.js targets.
The one line of Rust
#![allow(unused)] fn main() { // receipt-scanner/src/lib.rs #[cfg(target_arch = "wasm32")] extern crate uniffi_runtime_wasm as _; }
uniffi-runtime-wasm exports nothing you call, so nothing in your code references it, so the linker drops it — and the module reaches the player without an allocator. The extern crate line forces the link.
ubrn build wasm2 reads your Cargo.toml, so it catches a missing dependency. It cannot see whether you referenced it. Omitting the line surfaces later, when opening the module fails with required export "__ubrn_alloc" not found in wasm module.
ubrn build wasm2
ubrn build wasm2 --config ubrn.config.yaml --release
One command, three steps: cargo build --lib --target wasm32-unknown-unknown, generate the TypeScript from the module cargo has just written, and stage a copy of that module beside the TypeScript, adding the exports the player needs.
Before any of that, it reads your manifest and rejects a crate the player cannot load, naming what is missing. Letting cargo fail would say less, and letting it succeed would defer the failure to your first call.
Usage: uniffi-bindgen-react-native build wasm2 [OPTIONS]
Options:
--config <CONFIG>
The configuration file for this project
--no-generate
Opts out of generating the bindings and wasm-crate
--no-wasm-build
Opts out of running cargo build for wasm32-unknown-unknown
-r, --release
Build a release build
-p, --profile <PROFILE>
Use a specific build profile
-g, --and-generate
Optionally generate the bindings and turbo-module code for the crate
ubrn build wasm2 generates by default, so the -g / --and-generate flag that the other platforms need does nothing here. Use --no-wasm-build — not the shared --no-cargo flag — to reuse a module already in target/.
The wasm2 section of the configuration file controls where the TypeScript lands and how the crate is built. A minimal one:
rust:
directory: ./rust
manifestPath: receipt-scanner/Cargo.toml
wasm2:
ts: src/generated
ubrn generate wasm2
Generating without a configuration file takes the .wasm and an output directory.
ubrn generate wasm2 bindings \
--library target/wasm32-unknown-unknown/release/receipt_scanner.wasm \
--ts-dir src/generated
| Option | Description |
|---|---|
--library | Treat <SOURCE> as a library and extract the UniFFI definitions from it. |
--ts-dir <DIR> | The directory the generated TypeScript is written to. |
--config <CONFIG> | The location of the uniffi.toml file. |
--no-format | Skip formatting the generated code with prettier, which is run by default. |
This writes TypeScript and nothing else. Copying the module beside it is still yours to do, which is what ubrn build wasm2 would have done for you.
Point --library at cargo’s own output, not at a staged copy. Staging may rewrite the module, and generation reads UniFFI metadata out of it. A staged module reports no UNIFFI_META_* exports found in WASM file.
The generate wasm2 wasm-crate subcommand exists for symmetry with the other flavors and renders no files: wasm2 needs no shim crate and no project entrypoint, because the bindgen’s own index.ts is the entrypoint.
```admonish warning title=“generate all --flavor wasm2 is not the same command”
--flavor on generate all chooses the bindings generator, not the project files. It still writes the JSI turbo-module scaffolding, the web flavor’s wasm crate under rust_modules/, and a src/index.web.ts that imports files wasm2 does not produce.
ubrn build wasm2 and ubrn generate wasm2 bindings are both scoped to this flavor. Prefer those, and use noOverwrite to fence off anything a generate all in your pipeline would write.
## What gets generated
src/generated/ receipts.ts the API: your functions, objects, records, errors receipts-ffi.ts the signature table, and TypeScript types for it index.ts the entrypoint: opens the module, registers namespaces receipt_scanner.wasm your crate, staged receipt_scanner_bg.js wasm-bindgen glue, only if your crate needs it
`receipts.ts` is what you import, and is the same code the JSI and Node.js targets generate.
`receipts-ffi.ts` holds the signature table — every FFI export, described by argument and return type — and exports it as `PLAYER_DEFINITIONS`. It imports nothing environment-specific, so it bundles for Node.js, browsers and React Native Web alike.
`index.ts` is the entrypoint, and the only generated file that opens the module.
## Loading the module
```typescript
import { uniffiInitAsync } from "./generated";
await uniffiInitAsync(source);
uniffiInitAsync is idempotent: a second call returns the first call’s promise. Call it once, at startup, before anything touches the API.
Naming the .wasm
source is the module, however your environment names it. There is no default, because naming an asset is the one thing only the host knows — a bundler rewrites the URL as it copies the file.
| Environment | source |
|---|---|
| Vite, webpack 5, Node.js ESM | new URL("./generated/receipt_scanner.wasm", import.meta.url) |
| Vite, explicitly | import url from "./generated/receipt_scanner.wasm?url" |
| Browser, hand-rolled | fetch("/assets/receipt_scanner.wasm") |
| Anywhere, from bytes | a Uint8Array or an ArrayBuffer |
| Already compiled | a WebAssembly.Module |
new URL(..., import.meta.url) is the portable spelling: Vite, webpack 5 and Node.js all rewrite or resolve it. The browser build also accepts a bare Promise<Response>, so a fetch can go straight in without an intervening await:
await uniffiInitAsync(fetch("/assets/receipt_scanner.wasm"));
Under Metro — Expo Web, React Native Web — .wasm goes through the asset registry rather than the URL resolver, exactly as it does for the web flavor. Register the extension in metro.config.js, then resolve the asset with Asset.fromModule(...).uri.
Calling before the module is open
Every generated call goes through a getter that throws with your crate’s name:
receipt-scanner: wasm module not initialised. Await `uniffiInitAsync(...)`
from the generated entrypoint before calling into this module.
If your app cannot await at import time, hold the promise and await it at the first call site; the cookbook shows a lazy entrypoint.
Calling Rust
Once initialised, the API behaves as it does under every other target. The mapping from Rust to TypeScript is documented separately; what follows is only how it looks under wasm2.
Functions, records and errors
#![allow(unused)] fn main() { #[derive(uniffi::Record)] pub struct Receipt { pub merchant: String, pub total_pence: u32 } #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum ScanError { #[error("the image was unreadable")] Unreadable, } #[uniffi::export] pub fn scan_receipt(image: Vec<u8>) -> Result<Receipt, ScanError> { /* ... */ } }
import { scanReceipt, ScanError } from "./generated";
try {
const receipt = scanReceipt(imageBytes);
console.log(receipt.merchant, receipt.totalPence);
} catch (e) {
if (ScanError.instanceOf(e)) {
// handle it
}
}
Records, strings and byte arrays cross the boundary as a serialised buffer. The generated code asks the player for wasm memory up front and writes the payload straight into it, rather than building a JavaScript buffer and copying it in; return values come back as a view over wasm memory and are freed in a finally, so a throwing conversion still releases them.
Objects
#![allow(unused)] fn main() { #[derive(uniffi::Object)] pub struct ReceiptStore { /* ... */ } #[uniffi::export] impl ReceiptStore { #[uniffi::constructor] pub fn new() -> Self { /* ... */ } pub fn save(&self, receipt: Receipt) -> u64 { /* ... */ } } }
const store = new ReceiptStore();
const id = store.save(receipt);
Objects are handles into a Rust-side registry, released through a FinalizationRegistry when the JavaScript object is collected — see Garbage Collection and the Drop trait. Where you need the drop to happen at a known moment, store.uniffiDestroy() does it now.
Async
Rust futures become JavaScript promises, with nothing extra to configure.
#![allow(unused)] fn main() { #[uniffi::export] impl ReceiptStore { pub async fn total_for_month(&self, month: u8) -> u32 { /* ... */ } } }
const total = await store.totalForMonth(3);
The player registers the continuation as a wasm function and hands Rust its index in the module’s function table; Rust calls it when the future can make progress. Each poll resolves one promise, and the loop runs until the future reports ready.
Cancellation rides on AbortSignal where the Rust API supports it:
const controller = new AbortController();
const total = store.totalForMonth(3, { signal: controller.signal });
controller.abort(); // rejects with an AbortError
Wasm32 has no threads. A hand-rolled Future that calls std::thread::spawn to wake itself panics on wasm32-unknown-unknown, and the panic leaves that module’s Rust state untrustworthy. Futures produced by async fn and driven by UniFFI’s own poll loop are fine.
Callback interfaces
A callback interface is a trait Rust calls and TypeScript implements. Under wasm2 this works exactly as it does elsewhere.
#![allow(unused)] fn main() { #[uniffi::export(callback_interface)] pub trait LedgerObserver: Send + Sync { fn on_receipt(&self, receipt: Receipt); } #[uniffi::export] pub fn watch_ledger(observer: Box<dyn LedgerObserver>) { /* ... */ } }
watchLedger({
onReceipt(receipt) {
console.log("saw", receipt.merchant);
},
});
Rust can only call a wasm function, so the player installs a small trampoline in the module’s function table for each method of the vtable, and routes the call back into your closure. The generated code builds each interface’s vtable once, at module scope, with instance identity travelling as an argument — so a thousand observers cost the same table space as one.
Async callback interfaces work the same way; Rust receives a foreign future and polls it, and your promise settling completes it.
Crates that reach wasm-bindgen
Plenty of crates pull in wasm-bindgen on wasm32 — anything wanting a clock, a random number, or fetch. Such a build imports a placeholder namespace that only wasm-bindgen’s own rewriter can resolve.
Staging notices this, runs the rewrite over your module, and leaves receipt_scanner_bg.js beside the .wasm. The generated index.ts imports that file statically:
import * as wasmBindgenGlue from "./receipt_scanner_bg.js";
A static import keeps the glue in your bundler’s graph. Fetching it at runtime would work too, and would be invisible to every bundler — so it is not what happens.
You need do nothing, but two things are worth knowing. The .wasm and the _bg.js beside it are a matched pair, so copy both or neither. And the rewriter must match the wasm-bindgen crate your module was built against; a version skew fails the build with a schema-version error rather than misbehaving later.
Several namespaces in one module
A crate that re-exports UniFFI types from its dependencies — a megazord — produces one .wasm with several namespaces. The generated index.ts opens the module once and registers each namespace against it:
import { uniffiInitAsync } from "./generated";
import { scanReceipt } from "./generated/receipts";
import { exportLedger } from "./generated/ledger";
await uniffiInitAsync(wasm); // opens once, registers both
index.ts also re-exports every namespace, so importing ./generated alone is enough when the names do not collide.
The runtime API
You rarely touch this. It matters when you write your own entrypoint — see Writing your own entrypoint.
@ubjs/wasm resolves to a browser or a Node.js build through the package’s exports conditions.
function openWasm(
source: WasmSource,
options?: { resolveModule?: ImportResolver },
): Promise<UniffiNativeModule>;
The browser build fetches a URL or a string and accepts a Response or a Promise<Response>; the Node.js build reads a URL or a path off disk. Both hand bytes, an ArrayBuffer or a WebAssembly.Module straight through.
resolveModule supplies the module’s own imports, which is how the wasm-bindgen glue above is delivered. Anything it declines to satisfy is filled with a stub that throws when called, so an unwired import fails loudly instead of quietly doing nothing.
class UniffiNativeModule {
readonly memory: Memory;
readonly exports: WebAssembly.Exports;
registerSync(
definitions: ModuleDefinitions,
opts?: { disableJit?: boolean },
): NativeModuleInterface;
}
registerSync turns a namespace’s PLAYER_DEFINITIONS into callable JavaScript, one function per FFI export, plus a rustbuffer_alloc and rustbuffer_free pair. It is synchronous and cheap, the module being already instantiated by the time you hold one of these.
disableJit forces the interpreted dispatcher. The player already falls back to it where new Function is unavailable, so the flag is for tests and for pinning behaviour deliberately.
The @ubjs/wasm/core subpath is the environment-neutral half: FfiType, UniffiNativeModule and the types. The generated receipts-ffi.ts imports only this, which is why it bundles anywhere.
Errors you may meet
| Message | Cause |
|---|---|
does not build a cdylib | [lib] crate-type is missing cdylib |
does not depend on uniffi-runtime-wasm | the manifest is missing the runtime crate |
resolves uniffi_core without the wasm-unstable-single-threaded feature | the feature is not enabled for wasm32 |
required export "__ubrn_alloc" not found in wasm module | the extern crate line is missing |
no UNIFFI_META_* exports found in WASM file | generating from a staged module instead of cargo’s output |
wasm module not initialised | a call landed before uniffiInitAsync resolved |
register: wasm export "<name>" not found | the bindings and the .wasm came from different builds |
host wasm does not export __indirect_function_table | the module was never staged, so it lacks the growable table export |
wasm import <mod>.<name> is a stub | the module wants glue nothing supplied, usually a missing _bg.js |
rustbuffer_free: view is detached | a buffer view was held across an operation that grew wasm memory |
No wasm module at <path> | --no-wasm-build, with nothing built yet |
What is not supported
- Hermes, and so standard React Native. Use the JSI target.
- Threads, and any Rust API that requires one.
- Synchronous initialisation. Instantiating wasm is asynchronous everywhere.
- Synchronous re-entry of one export. A callback that calls back into the same Rust function while the outer call is still on the stack would corrupt that call. UniFFI’s callback model does not generate this shape; see the player’s design for why the dispatcher is built this way.
wasm2 cookbook
Recipes that put several parts together. Each assumes your crate builds, the bindings generate, and uniffiInitAsync works — see the reference if not.
The running example is still receipt-scanner: library receipt_scanner, namespace receipts. Names like ensureReceipts and myWorkerPool are yours, not the project’s.
One crate, two runtimes: React Native and the web
wasm2 cannot run under Hermes, and JSI cannot run in a browser. A library that wants both generates both, into different directories, and lets the bundler choose.
rust:
directory: ./rust
manifestPath: receipt-scanner/Cargo.toml
bindings: # the JSI bindings land here
ts: src/generated
wasm2: # the wasm2 bindings land here
ts: src/generated-web
ubrn build ios --config ubrn.config.yaml --release --and-generate
ubrn build android --config ubrn.config.yaml --release --and-generate
ubrn build wasm2 --config ubrn.config.yaml --release
Two thin files pick a side:
// src/index.native.ts
export * from "./generated";
// src/index.web.ts
import { uniffiInitAsync } from "./generated-web";
export * from "./generated-web";
export const ready = uniffiInitAsync(
new URL("./generated-web/receipt_scanner.wasm", import.meta.url),
);
"main": "src/index.tsx",
+ "browser": "src/index.web.ts",
+ "react-native": "src/index.native.ts",
The asymmetry is real, and worth exposing rather than hiding: the web build has a ready promise and the native build does not. Consumers that must run on both await ready where it exists.
Your Rust source is untouched by any of this. The wasm2 requirements sit behind cfg(target_arch = "wasm32"), so the iOS and Android builds never see them.
Naming the .wasm for your bundler
uniffiInitAsync takes whatever your environment calls the file. The portable spelling works in Vite, webpack 5 and Node.js:
await uniffiInitAsync(
new URL("./generated/receipt_scanner.wasm", import.meta.url),
);
Vite also accepts an explicit URL import, which is clearer when the file is processed by a plugin:
import wasmUrl from "./generated/receipt_scanner.wasm?url";
await uniffiInitAsync(wasmUrl);
Metro is different: .wasm goes through the asset registry, not the URL resolver, so the extension has to be registered — the same step the web tutorial describes.
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.resolver.assetExts.push('wasm');
module.exports = config;
import { Asset } from "expo-asset";
const asset = Asset.fromModule(require("./generated/receipt_scanner.wasm"));
await asset.downloadAsync();
await uniffiInitAsync(asset.uri);
If your crate reaches wasm-bindgen, receipt_scanner_bg.js sits beside the .wasm and the generated index.ts imports it. Your bundler follows that import on its own; you only have to make sure the two files travel together when you copy them by hand.
Loading behind a route
A module used on one screen should not cost every user a download at boot. uniffiInitAsync is idempotent, so the guard is three lines.
// src/receipts.ts
let opening: Promise<typeof import("./generated-web")> | undefined;
export function ensureReceipts() {
opening ??= (async () => {
const mod = await import("./generated-web");
await mod.uniffiInitAsync(
new URL("./generated-web/receipt_scanner.wasm", import.meta.url),
);
return mod;
})();
return opening;
}
function ReceiptScreen({ image }: { image: Uint8Array }) {
const [receipt, setReceipt] = useState<Receipt>();
useEffect(() => {
ensureReceipts().then(({ scanReceipt }) => setReceipt(scanReceipt(image)));
}, [image]);
// ...
}
The dynamic import() splits the bindings and the .wasm into their own chunk. ensureReceipts returns the same promise every time, so a second screen mounting mid-download joins the first download rather than starting another.
Writing your own entrypoint
The generated index.ts covers the common case. Three exports let you replace it when you need to: PLAYER_DEFINITIONS and setNativeModule from receipts-ffi.ts, and initialize from the namespace’s default export.
import { openWasm, type WasmSource } from "@ubjs/wasm";
import { PLAYER_DEFINITIONS, setNativeModule } from "./generated/receipts-ffi";
import receipts from "./generated/receipts";
export async function openReceipts(source: WasmSource) {
const mod = await openWasm(source);
setNativeModule(mod.registerSync(PLAYER_DEFINITIONS));
receipts.initialize(); // verifies checksums, installs vtables
return mod; // keep it for `mod.memory`, `mod.exports`
}
initialize() is what catches a .wasm and a set of bindings that came from different builds: it compares a checksum per exported function, and names the first that disagrees.
If your crate reaches wasm-bindgen, hand the glue over too. This is exactly what the generated entrypoint does:
import * as wasmBindgenGlue from "./generated/receipt_scanner_bg.js";
const mod = await openWasm(source, {
resolveModule: async (name) =>
name.endsWith("receipt_scanner_bg.js") ? wasmBindgenGlue : undefined,
});
setNativeModule sets a module-level binding inside the generated file. Two instances of the same crate need two realms — two workers, two iframes — not two calls.
Running under a strict Content-Security-Policy
The player compiles a specialised dispatcher per exported function with new Function, which a page serving script-src without 'unsafe-eval' forbids. It probes for this once, at registration, and falls back to an interpreted dispatcher with identical behaviour. Under a strict CSP everything works; calls carry a little more overhead.
Where you would rather not depend on a probe — testing the fallback, or pinning behaviour across environments — write the entrypoint above and say so:
setNativeModule(mod.registerSync(PLAYER_DEFINITIONS, { disableJit: true }));
new Function is the only thing this affects. The callback path compiles small wasm modules of its own, but a browser gates all wasm compilation behind 'wasm-unsafe-eval' — so a page that can load your module at all can compile trampolines too.
Compile once, instantiate many
Compiling a .wasm is the expensive half; instantiating it is cheap. When you run the same crate in several workers, compile in one place and post the WebAssembly.Module — which is structured-cloneable — to each.
// main thread
const compiled = await WebAssembly.compileStreaming(
fetch("/assets/receipt_scanner.wasm"),
);
for (const worker of myWorkerPool) {
worker.postMessage({ kind: "receipts/module", compiled });
}
// worker
self.onmessage = async ({ data }) => {
if (data.kind === "receipts/module") {
const { uniffiInitAsync } = await import("./generated-web");
await uniffiInitAsync(data.compiled); // a WebAssembly.Module, no fetch
}
};
Each worker gets its own linear memory, its own Rust-side state, and its own copy of the bindings. Nothing is shared, which is the point: wasm here is single-threaded, and a worker is how you keep a long Rust call off the main thread.
Moving large byte payloads
Records, strings and Vec<u8> cross the boundary as a serialised buffer. The generated call sites ask the player for wasm memory up front and write the payload straight into it, so a Vec<u8> argument costs one copy — the write — rather than a JavaScript array followed by a copy into wasm. Returns work the same way in reverse: the player hands the generated code a view aliasing wasm memory, the converter reads it, and a finally frees the allocation even when the conversion throws.
Design your Rust API to take that path, and you get it for free:
#![allow(unused)] fn main() { #[uniffi::export] pub fn scan_receipt(image: Vec<u8>) -> Result<Receipt, ScanError> { /* ... */ } #[uniffi::export] pub fn render_thumbnail(receipt: &Receipt) -> Vec<u8> { /* ... */ } }
const thumbnail = renderThumbnail(receipt); // a Uint8Array, one copy out
The single rule, and the only way to get this wrong, is holding one of those views. They alias wasm linear memory, and growing that memory detaches them. If you write your own entrypoint and call rustbuffer_alloc directly, free the view before anything else can allocate:
const nativeModule = mod.registerSync(PLAYER_DEFINITIONS);
setNativeModule(nativeModule);
const view = nativeModule.rustbuffer_alloc(4096);
myEncoder.writeInto(view);
// ... hand it to a call, or free it. Do not keep it across another call.
Held across a growth, such a view reports a byteLength of zero, and freeing it throws rustbuffer_free: view is detached. The error is deliberate — silently leaking would be worse.
Testing the bindings with node --test
Test scripts read better when they import the API and nothing else. Put the loading in a preload module: Node.js runs --import modules to completion, including their top-level await, before the entry module.
// test/bootstrap.ts
import { uniffiInitAsync } from "../src/generated-web/index.js";
await uniffiInitAsync(
new URL("../src/generated-web/receipt_scanner.wasm", import.meta.url),
);
// test/receipts.test.ts — no loading, no awaiting init
import { test } from "node:test";
import assert from "node:assert";
import { scanReceipt } from "../src/generated-web/index.js";
test("reads the merchant off a receipt", () => {
assert.equal(scanReceipt(myFixtureImage).merchant, "Grocer");
});
node --import ./test/bootstrap.ts --test test/
The same script then runs unchanged against the JSI or Node.js bindings, whose loading happens elsewhere. That is how this project’s own fixture suite runs one set of test scripts across four flavors.
Making the module smaller
ubrn build wasm2 does not strip or optimise your crate. It could — but the keep-list would be a heuristic, and your cdylib may export symbols for a consumer the command line cannot see. Shrinking is yours, and worth doing: a release build carries far more than the calls you make.
Start with the profile:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = "debuginfo"
Then run wasm-opt over the staged module:
ubrn build wasm2 --config ubrn.config.yaml --release
wasm-opt -Oz --enable-bulk-memory \
src/generated-web/receipt_scanner.wasm \
-o src/generated-web/receipt_scanner.wasm
Enable whatever wasm features your toolchain emitted; wasm-opt names the one it choked on when you have missed one. Run the module once afterwards — a wrong flag shows up as a failed instantiation, not as a wrong answer.
The player needs memory, __indirect_function_table and the __ubrn_* exports; removing any of them breaks loading rather than saving much. Dropping the wasm name section saves a real share of a release module, and turns every Rust panic into hex.
Reading a Rust panic
The player installs a panic hook while opening the module and points it at a JavaScript function, so a panic inside Rust prints:
[Rust panic] called `Option::unwrap()` on a `None` value
Error
at scanReceipt (receipts.ts:118:12)
...
The Rust message comes from the hook; the stack below it is the JavaScript stack at the moment of the panic, which is usually the more useful half, because it names the call you made.
There are two ways to see nothing at all. If the runtime crate is not linked — the extern crate line — the module has no hook to install, and opening it fails earlier with required export "__ubrn_alloc" not found. And anything that panics in the module’s start section fires before the player has installed the hook, so it lands as an instantiation failure instead.
After a panic, that module’s Rust state is not trustworthy: a lock may still be held, an allocation half-made. Treat it as fatal for the instance and open a fresh one, which in a worker means restarting the worker.
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
manifestPath: 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
manifestPath: 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 manifestPath 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.
rust:
directory: ./rust
manifestPath: 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 manifestPath 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>
codegenOutputDir: <DERIVED FROM package.json>
useSharedLibrary: true
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. codegenOutputDir is the path under which Codegen stores its generated files. Both are 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.
To customize the codegenOutputDir, you should edit or add the entry at the path codegenConfig/outputDir/android in package.json.
Note that for Android the outputDir value in package.json needs to have a matching entry under dependency/platforms/android/cmakeListsPath in react-native.config.js. For example, if you set the Android output directory in package.json to android/tmp, the cmakeListsPath value in react-native.config.js needs to be set to tmp/jni/CMakeLists.txt.
useSharedLibrary is a boolean that controls if the Rust code is linked as a shared library or a static library. The default is false, which means that the Rust code is linked as a static library. If you want to linked it as a dynamic library, set this to true.
Note that when building as a shared library, you should ensure that Rust is configured to build dynamic library.
[lib]
crate-type = ["cdylib"]
Also, please keep in mind that with useSharedLibrary: true, you should not strip your library while building it. As this will break generating turbo module and native bindings. This should not affect app performance as Android will optimize it during app build.
[profile.your_profile]
strip = "none"
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
codegenOutputDir: <DERIVED FROM package.json>
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.
codegenOutputDir is the path under which Codegen stores its generated files. This is derived from the package.json file, and can almost always be left.
To customize the codegenOutputDir, you should edit or add the entry at the path codegenConfig/outputDir/ios in package.json.
web
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:
web:
manifestPath: rust_modules/wasm/Cargo.toml
manifestPatchFile: null
wasmCrateName: <DERIVED FROM package.json>
features: []
defaultFeatures: true
workspace: false
runtimeVersion: <DERIVED FROM UBRN>
cargoExtras: []
target: web
wasmBindgenExtras: []
entrypoint: <DERIVED FROM package.json> or "src/index.web.ts"
tsBindings: <SAME AS bindings/ts>
The manifestPath is the path to the generated wasm-crate. The location of paths for the generate wasm wasm-crate and for the rust files for generate wasm bindings are derived from this path.
The manifestPatchFile is a path to a TOML file that will be used to patch merge on top of the generated wasm-crate Cargo.toml. This is extremely useful when customizing the manifest. e.g. when overriding dependencies in the target crate.
The wasmCrateName is the name of the wasm-crate, derived from the package.json name property.
The features array is used to build the target crate, and then added to the wasm crate’s Cargo.toml.
The defaultFeatures flag pairs with the features array: it is used to build the target crate (toggling the --no-default-features command line option) and then added to the wasm crate’s Cargo.toml, as default-features.
The boolean workspace controls if the wasm crate is part of an existing Rust workspace. The default value assumes that the target crate doesn’t know anything about the wasm-crate, so the wasm-crate is in its own workspace. If the target crate is in a workspace, and that can be changed, then this setting can be changed to true. Tip: members can contain globs to point to crates that don’t yet exist.
runtimeVersion is the version of uniffi-runtime-javascript crate. By default this is the exact current version of uniffi-bindgen-react-native, so currently =0.31.0-5.
cargoExtras is a list of extra arguments passed directly to the cargo build command when building for wasm32-unknown-unknown.
target is the passed to wasm-bindgen or wasm-pack. Default is web. This is likely only useful if you’re customizing the way the WASM bundle is loaded.
wasmBindgenExtras is a list of extra arguments passed directly to wasm-bindgen.
entrypoint is the filepath of the file which loads and exports the bindings. By default this is taken from the browser property of package.json, or src/index.web.ts if that is missing.
tsBindings is the directory where the typescript bindings are generated. This overrides the bindings/ts directory.
Uniffi is unable to process WASM files directly, so has to use a lib.a file built for the build environment.
Any uniffi::export or uniffi derive macros should not be toggled on and off based on the target architecture. If you want wasm specific uniffi bindings, you should use a feature instead, and add it to the features list in this file.
wasm2
This configures the build steps for the wasm2 flavor, which runs your crate as a WebAssembly module without a generated wasm-bindgen shim crate. It is a much shorter section than web, because wasm2 generates no Rust crate and no project entrypoint — only the Typescript and the .wasm beside it.
This section can be omitted entirely. These are its members, with their defaults:
wasm2:
features: []
defaultFeatures: true
targets:
- wasm32-unknown-unknown
cargoExtras: []
rustflags: []
ts: <SAME AS bindings/ts>
ts is the directory where the Typescript bindings are generated, and where the built .wasm is staged beside them. This overrides the bindings/ts directory, which lets one project generate JSI bindings into one directory and wasm2 bindings into another. It is also spelled typescript or tsBindings.
The features array and the defaultFeatures flag are used to build your crate for wasm32-unknown-unknown, as --features and --no-default-features. Unlike the web section, there is no second crate for them to be copied into.
targets is a list of targets to build for. wasm32-unknown-unknown is the only supported value.
cargoExtras is a list of extra arguments passed directly to the cargo build command.
rustflags is a list of flags set as RUSTFLAGS for that build. It is an escape hatch for your own flags: the growable, exported function table the player needs is added to the built module afterwards, so you do not have to ask the linker for it.
The whole section is also spelled web2.
Unlike the web flavor, wasm2 reads UniFFI metadata out of the .wasm itself, so there is no second native build and no lib.a. That also means uniffi::export and the uniffi derive macros may be toggled by target architecture here — though a feature is still the clearer way to do it.
turboModule
This section configures the location of the Typescript and C++ files generated by the generate jsi turbo-module command.
If absent, the defaults will be used:
turboModule:
cpp: cpp
ts: <DERIVED FROM package.json>
spec: <DERIVED FROM package.json>
entrypoint: <DERIVED FROM package.json>
The default entrypoint is derived from the react-native entry in the package.json, and if missing, src/index.tsx.
The spec is the name of the Codegen spec, e.g. NativeModule. By default it is derived from the codegenConfig/name property in package.json.
The ts directory is used for the Codegen spec NativeModule.ts, and is the default is taken from the codegenConfig/jsSrcDir property in package.json.
The Typescript files are the index.tsx file, and the Codegen installer file.
By default, the index.tsx file is intended to be the entry point for your library.
If this is not the case—e.g. you want to do use the Rust as part of a larger library, then change the entrypoint to something other than the package.json value.
noOverwrite
This list of glob patterns of file that should not be generated or overwritten by the --and-generate flag, and the generate jsi turbo-module and generate wasm wasm-crate commands.
This is useful if you have customized one or more of the generated files, and do not want lose those changes.
For example, if you want to add C++ files to the library, you may want to change the build files.
noOverwrite:
- "*.podspec"
- CMakeLists.txt
You can generate the build files once then not overwrite them. Once you excluded the files, they can be safely edited.
The uniffi.toml file is a toml file used to customize the generation of C++ and Typescript.
To include the file when invoking ubrn, specify the path in the
corresponding key of the config.
As of time of writing, [bindings.typescript] supports logLevel, consoleImport, customTypes, strictObjectTypes, strictTypeChecking, strictByteArrays and forceAsync; [bindings.kotlin] supports cdylib_name and package_name. Each is described below.
Opting out of Interface generation
By default, ubrn generates Object interfaces for all objects. To opt out of
this behavior, set bindings.typescript.strictObjectTypes to true.
[bindings.typescript]
strictObjectTypes = true
Typescript strict byte arrays
By default, byte arrays in Rust (i.e. Vec<u8>) are translated into Typescript ArrayBuffer instances. This is advantageous when it is desirable to use a byte array to transport a sequence of more complex types.
However, not all projects use or want that functionality. To globally translate Vec<u8> into Uint8Array instead, set the strictByteArrays property of the typescript bindings to true.
[bindings.typescript]
strictByteArrays = true
Typescript strict type checking
By default, generated Typescript files begin with // @ts-nocheck, so that tsc skips them and downstream projects are not troubled by type errors in code they did not write.
To have tsc check the generated files, set strictTypeChecking to true. This is chiefly useful when working on the generated code itself.
[bindings.typescript]
strictTypeChecking = true
Forcing an async surface
forceAsync gives chosen types and functions an async/Promise surface in Typescript, without making them async in Rust. The call into Rust still runs synchronously, on the thread that made it.
[bindings.typescript]
forceAsync = true
The point is to let you migrate call sites ahead of time. Rust running off the main thread — whether as real Rust on a background thread, or as WASM on a worker — can only be called asynchronously, so every call site has to grow an await. forceAsync lets you make that change against a build whose behavior has not changed, and find out what it costs.
forceAsync moves no work off the main thread. An awaited call blocks the Javascript thread for exactly as long as the synchronous one did. All that changes is the shape of the call site.
The await at each call site is the shape you are migrating towards, and it is what any off-main-thread scheme will need. The rest of the generated surface is less settled: asyncToString is named that way only because the call underneath is still synchronous, and forced calls take no AbortSignal only because there is no Future to cancel. Expect both to be spelled differently once there is.
Set it to true to force everything in the crate, or to a list of names to force only those:
[bindings.typescript]
forceAsync = ["Widget", "makeFlatWidget"]
A name may be an object, a record, an enum, or a top-level function. Spelling doesn’t matter: make_flat_widget, makeFlatWidget and MakeFlatWidget all name the same function.
What changes in the generated Typescript
Methods, constructors, top-level functions and trait methods of a forced type return a Promise. Errors arrive as a rejected promise, so try/catch needs an await to catch anything:
// Without forceAsync.
const w = new Widget("yo");
const label = w.label();
// With forceAsync. The primary constructor is no longer a `constructor`, so it
// becomes a static factory — a Rust `new` is already named `create` in Typescript.
const w = await Widget.create("yo");
const label = await w.label();
The one method that cannot simply become async is toString: Javascript calls it to coerce an object into a string, and an async one hands back a Promise. So the Display trait is generated as asyncToString on a forced type, which no longer has a toString of its own. Coercing it — in a template literal, say — gets you the Javascript default of [object Object].
await w.asyncToString(); // "Widget(yo)", from the Display trait
await w.toDebugString(); // Debug, Eq, Hash and Ord keep their names
Forced calls take no { signal: AbortSignal } option bag. There is no Future on the Rust side to cancel — see task cancellation.
Callback interfaces and trait interfaces
A callback interface, or a [Trait, WithForeign] interface, is implemented in Typescript and called from Rust. forceAsync cannot change that direction of travel, so naming one has no effect on its surface. It is only checked: if any of its methods is synchronous, generation fails and names the offending methods.
Rust calls into these types through a vtable, and each slot is sync or async according to the Rust method. On the way out, forceAsync only has to wrap a return value in a resolved promise; on the way in, it would have to hand a promise to a synchronous slot, which has no way to wait for it. Make the methods async fn in Rust, or leave the interface out of the list.
The force-async and force-async-list fixtures exercise both forms.
Logging the FFI
The generated Typescript code can optionally be created to generate logging.
[bindings.typescript]
logLevel = "debug"
consoleImport = "@/hermes"
consoleImport is an optional string which is the location of a module from which a console will be imported. This is useful in environments where console do not exist.
Log level
Possible values:
none: The Uniffi generated Typescript produces no logging.debug: The generated Typescript records the call sites ofasyncfunctions.verbose: Asdebugbut also: all calls into Rust are logged to the console. This can be quite… verbose.
The recording of async call sites is also helpful for app development, so process.env.NODE_ENV !== "production" is checked at startup of runtime.
When process.env.NODE_ENV === "production", async errors detected by Rust are reported but not with a helpful Typescript stack trace. Recording the call sites has a performance cost so is turned off for production.
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];
}
})({})
"""
Kotlin cdylib_name
The cdylib_name is the name of the library that will be loaded by JNA in the runtime.
If the cdylib_name is different from output library name, JNA won’t be able to load the library and will fail silently.
[bindings.kotlin]
cdylib_name = "my_library_name"
Kotlin package_name
The package_name is the package name that will be used in the generated Kotlin code. All the generated native classes will be placed inside this package.
[bindings.kotlin]
package_name = "com.example.myapp.mycrate"
If you are using proguard it is important to add the appropriate classes to proguard-rules.pro. Otherwise, application in the release version may not work as it should.
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.tsxfile, 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.javaandModule.javafile, which receives the codegen’d install method calls, to get the HermesJavascriptRuntimeandCallInvokerHolderto pass it via JNI to - A
cpp-adapter.cppto receive the JNI calls, and converts those intojsi::Runtimeandreact::CallInvokerthen calls into generic C++ install code.
- A
- Generic C++ install code:
- A turbo-module installation
.hand.cppwhich catches the calls from Android and iOS and registers the bindings C++ with the Hermesjsi::Runtime.
- A turbo-module installation
- For iOS:
- a
Module.handModule.mmfile which receives the codegen’d install method calls, and digs around to find thejsi::Runtimeandreact::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.txtfile to configure the Android specific tool chain for all the generated C++ files. - The
build.gradlefile which tells keeps the codegen package name in-sync and configurescmake. (note to self, this could be done from within theCMakeLists.txtfile).
- 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 theEqandPartialEqtraithashCode: a method generated corresponding to theHashtraittoDebugString: a method generated corresponding to theDebugtraittoString: a method generated corresponding to theDisplaytraituniffiDestroy: 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 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:
myPropertyis 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
innerobject.
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 toArrayBuffers,numbers andbigint. - Javascript calls into C++, through
{namespace}-ffi.ts - In C++, in
{namespace}.cpp: lower the JSInumber,bigintandArrayBufferfurther, into C equivalents, e.g.uint32_tanduint8_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
ArrayBufferusingDataViewandUint8TypedArray.- This is done with a series of
FfiConverters. 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.tsfile; it is implemented by the{namespace}.cppfile.
- This is represented by a
- extract the
int32_t*from thejsi::ArrayBufferand 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
installRustCratetypescript method is called. This is in the input file for Codegen, theNative{namespace}.tsfile. - this invokes the corresponding machinery generated by
Codegen, in Objective C and Java. - once in Objective C and Java, we find the
jsi::Runtimeand thefacebook::react::CallInvokerfrom the- Objective C and
- Java.
- this then passes the
RuntimeandCallInvokerto the C++ Turbo-Module proper. - This then calls into the generated
cpp/bindings/{namespace}.cppwhich 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.
The wasm2 player
This page is for someone about to change the wasm2 runtime. It builds from the ABI upwards: what UniFFI produces, what WebAssembly permits, and the one idea that connects them. For using the flavor, see WebAssembly (wasm2) support.
What UniFFI’s ABI needs
UniFFI compiles a Rust crate into a cdylib of extern "C" functions, and every signature is drawn from a small vocabulary: the integer and float widths, plus a 64-bit Handle that indexes a Rust-side registry of objects; a RustBuffer, which is a (capacity, len, dataPtr) triple over bytes the Rust allocator owns, and through which records, strings, enums and options all travel; a RustCallStatus out-parameter, one status byte and a RustBuffer for the error; and function pointers, in vtables, for the calls Rust makes back into the foreign language.
Every UniFFI backend does the same three things with that vocabulary — lower the arguments, make the call, lift the result. See Lifting, lowering and serialization for the TypeScript side of it.
What WebAssembly changes
Four differences drive nearly every decision in this runtime.
There are no shared pointers. Wasm has linear memory: one ArrayBuffer the module owns. Anything Rust reads must be bytes at an offset inside it, written by JavaScript. A RustBuffer argument is not a struct you pass — it is 24 bytes you write somewhere, plus the offset you wrote them at.
Aggregates travel by pointer. The wasm C ABI passes and returns structs through memory, so a function returning a RustBuffer compiles to one taking a hidden first argument: the address to write it to. The runtime has to know which functions those are.
Rust reaches JavaScript only through the function table. A Rust fn pointer on wasm32 is an index into __indirect_function_table, and call_indirect is the only instruction that can call one. A JavaScript closure is not a wasm function and cannot go in that table, so something must stand in for it.
Memory can move. WebAssembly.Memory.grow allocates a new backing buffer and detaches the old one, and any Uint8Array view over the old buffer becomes zero-length. Every view is short-lived by construction, or it is a bug.
Wasm32 is also single-threaded, which is why UniFFI’s wasm-unstable-single-threaded feature is mandatory: without it, UniFFI demands Send + Sync on exported objects.
The idea: a table, not a shim
The obvious way to bridge those two sections is to generate the bridging code, which is what the web flavor does — for each FFI function it emits a Rust #[wasm_bindgen] wrapper into a generated shim crate, and builds it. The result is correct, and it costs a generated crate, a second cargo build, and a pile of JavaScript glue per project.
wasm2 observes that those wrappers differ only in their signatures. Make the signature list data, and one runtime can drive every module. That runtime is the player.
So the bindgen emits two things: TypeScript that reads like your API, and a table describing every FFI export. It emits no glue.
interface ModuleDefinitions {
symbols: { rustbuffer_alloc: string; rustbuffer_free: string; rustbuffer_from_bytes: string };
functions: Record<string, FunctionDef>; // args, ret, hasRustCallStatus
callbacks: Record<string, CallbackDef>; // as above, plus outReturn
structs: Record<string, FieldDesc[]>; // vtables and result structs
}
The type vocabulary is the ABI’s, made explicit in ffi-type.ts:
type FfiTypeDesc =
| { tag: "UInt8" } | { tag: "Int8" }
| { tag: "UInt16" } | { tag: "Int16" }
| { tag: "UInt32" } | { tag: "Int32" }
| { tag: "UInt64" } | { tag: "Int64" }
| { tag: "Float32" } | { tag: "Float64" }
| { tag: "Handle" }
| { tag: "RustBuffer" }
| { tag: "ForeignBytes" }
| { tag: "RustCallStatus" }
| { tag: "VoidPointer" }
| { tag: "Void" }
| { tag: "Callback"; name: string }
| { tag: "Struct"; name: string }
| { tag: "Reference"; inner: FfiTypeDesc }
| { tag: "MutReference"; inner: FfiTypeDesc };
Callback and Struct name entries in the other two maps, so the table is self-contained. That is the whole interface between the bindgen and the runtime.
The same shape already served the Node.js target, which drives a native cdylib through libffi from an equivalent table. wasm2 is the second consumer, and the two share the bindgen’s IR:
#![allow(unused)] fn main() { pub enum AbiFlavor { Jsi, // C++ turbo module, generated per crate Napi, // player over libffi Wasm, // generated wasm-bindgen shim crate Wasm2, // player over WebAssembly } }
Building: one cargo invocation
A UniFFI generator normally reads metadata out of a native library’s symbol table, which is why the web flavor builds twice — once natively for metadata, once for wasm32 for the artifact.
There is no need. When rustc compiles a UniFFI crate to wasm, each UNIFFI_META_* symbol becomes an exported i32 global whose value is an address in linear memory, and the bytes at that address are the same self-describing blob a native build would hold. Reading them takes a wasm parser and three steps — collect the globals, find the exports whose names begin UNIFFI_META, resolve each address inside the active data segments — which is what wasm_metadata.rs does.
So the pipeline is:
cargo build --lib --target wasm32-unknown-unknown.- Read
UNIFFI_META_*out of the resulting module, and generate the TypeScript from it. - Stage a copy of that module beside the TypeScript.
Generate from cargo’s output, and stage a copy. Staging rewrites the module, and one of its rewrites can remove the metadata exports. Keeping the two inputs distinct is what stops that from mattering.
Staging
Three things happen to the copy, in ubrn_common::wasm.
wasm-bindgen runs, if the module needs it. A crate whose dependencies reach wasm-bindgen links imports against a placeholder namespace only wasm-bindgen’s rewriter can resolve. Staging asks the import section rather than scanning the file for the name — which also appears in the name section and in data segments — and if the answer is yes, runs wasm-bindgen against the bundler target. That leaves a _bg.wasm, renamed into place, and a _bg.js of glue. wasm-bindgen’s own entry module is deleted: it instantiates the wasm for you, which is the player’s job, and its filename would shadow the generated bindings under a bundler.
The function table is exported, and made growable. The player adds trampolines to __indirect_function_table, so the module must export it without an upper bound. wasm-ld will do that given --export-table --growable-table, but link arguments belong to the cdylib’s own link step, which a dependency cannot reach — every consumer would need RUSTFLAGS. Rewriting after the link works whatever built the module.
Dead code is eliminated, for this project’s fixtures only. Stripping exports the player cannot reach lets a garbage-collection pass reclaim what they held, but the keep-list is a heuristic, and a user’s cdylib may export symbols for a consumer the command line cannot see. So it runs on fixtures and never on your crate.
Opening a module
UniffiNativeModule.open compiles and instantiates. Two details are load-bearing.
The import object is built from the module’s own import section. Anything the caller did not supply, and the optional resolver could not, is filled with a stub — a function that throws when called, a zero global, a one-page memory. An unwired import then fails at the call that needs it, naming it, rather than failing at instantiation with a message about a name you have never seen, or worse, silently doing nothing.
env.__ubrn_dispatch is always supplied. It is the single import through which every JavaScript callback is reached, and it closes over a UniffiNativeModule that does not exist yet at instantiation time — so the closure checks, and throws if the module calls back during its own start section.
After instantiation the player checks for __ubrn_alloc, __ubrn_free and memory, carves a 64 KB scratch arena out of the first, and installs the panic hook.
Registering: one function per export
registerSync(definitions) walks definitions.functions and builds one JavaScript function per FFI export. Building happens once; the per-call work is what is left.
For each export the player computes a call layout — one contiguous region of wasm memory holding, in order, the RustCallStatus if the function has one, the struct-return slot if the return travels by pointer, and one slot per argument that needs lowering into memory. That region is reserved once, at registration, from the scratch arena, and reused by every call. It is the single biggest reason calls are cheap, and the single biggest constraint on the design — see Trade-offs.
Then the player picks a dispatcher, in call.ts.
Where every argument and the return type are scalars or RustBuffer, it string-builds the dispatcher body with the offsets baked in as constants and compiles it with new Function. There is no loop over argument descriptors at call time and no closure indirection: the body reads like hand-written code, which is what lets an engine monomorphise it.
Everything else — callback arguments, vtable structs — runs a per-argument prepare closure. The player also takes this interpreted path when new Function is unavailable, which it probes once at registration, so a page under a strict Content-Security-Policy gets working bindings rather than an error.
The two paths must agree exactly, down to details like coercing small integers with | 0 and skipping the return read on the error path. They are written next to each other for that reason, and nothing but tests enforces it.
A call, end to end
Take scan_receipt(image: Vec<u8>) -> Result<Receipt, ScanError>, whose FFI signature takes a RustBuffer and a RustCallStatus, and returns a RustBuffer through a hidden pointer.
- The generated code calls
FfiConverterBytes.lower(image, rustbuffer_alloc).rustbuffer_alloc(n)returns aUint8Arrayaliasing wasm memory, and the converter writes into it — no JavaScript-side buffer is built. - The dispatcher zeroes the status region.
- It writes the 24-byte
RustBufferstruct into the argument slot. Seeing that the view already aliases wasm memory, it forwards the view’s byte offset as the data pointer: no second allocation, no copy. - It calls the export with
(sretAddress, argSlotAddress, statusAddress). - It reads the status byte. On success it reads the returned
(capacity, len, dataPtr)and hands back aUint8Arrayview over the payload. On failure it copies the error buffer out, frees it, and returns nothing — Rust has not written the return slot, and the caller is about to throw. - The generated code lifts the view into a
Receiptinside atry, and frees it in thefinally, so a throwing conversion still releases the memory.
Ownership, stated once: bytes going into Rust are Rust’s, because the payload is allocated by the same global allocator Rust uses for Vec<u8>, so Vec::from_raw_parts reclaims it and the player must not. Bytes coming out are the caller’s to free, through the view handed to it. Where Rust over-allocated — capacity above length — the true capacity rides on the view as a symbol-keyed property, because the allocator’s Layout contract needs the size it was given.
Callbacks: shapes and trampolines
Rust can only call a wasm function, so for every JavaScript closure the player emits one.
A shape is one wasm-level signature, say (i64, i32) -> (). For each, trampoline.ts hand-encodes a wasm module — under a hundred bytes, built byte by byte in TypeScript — of exactly this form:
(module
(type $trampoline (func (param ...) (result ...)))
(type $dispatch (func (param i32 ...) (result ...)))
(import "env" "__ubrn_dispatch" (func $dispatch (type $dispatch)))
(func $trampoline (type $trampoline)
i32.const <shape_id> ;; who am I
local.get 0 ... local.get N ;; forward everything
call $dispatch)
(export "trampoline" (func $trampoline)))
The trampoline prepends a shape id and forwards its arguments to a JavaScript function. That is all it does. Installing it means growing the module’s function table by one and writing the export into the new slot, and the slot index is what Rust receives as the function pointer.
On the way back in, __ubrn_dispatch(shapeId, ...args) finds the closure and calls it, lifting each argument the mirror of the way the call path lowered them: a RustBuffer pointer becomes a Uint8Array and its wasm allocation is freed, Rust having handed ownership over; a nested function-table index becomes a callable that lowers its arguments and dispatches back through the table. Vtable methods return by out-parameter, so the dispatcher unwraps what the generated closure returned and writes the value and the status back through the pointers Rust supplied.
Each distinct closure-and-signature pair costs one shape and one table slot, never reclaimed. That would be alarming if closures were per-instance — but the generated code builds each callback interface’s vtable once, at module scope, with instance identity travelling as a Handle argument, so an interface costs its method count plus two however many objects exist. Repeated installs of the same closure hit a cache and return the same slot.
A second dispatch path lives in callback.ts: shapes shared by signature, with closures registered against them by handle. It is unit-tested and unused — today’s generated code takes the per-closure path exclusively. Know that before you go looking for its callers.
Async, and panics
Async needs no machinery of its own. UniFFI’s poll-style ABI passes a continuation as a function pointer, so it is a Callback argument like any other: the shared runtime hands the player a JavaScript closure, the player installs it as a trampoline, and Rust calls it when the future can make progress. Each poll resolves one promise, the loop runs until the future reports ready, and a complete export then yields the value. Cancellation is a cancel export, wired to AbortSignal.
Panics cannot be seen from JavaScript by default — a wasm trap arrives with no message. So the helper crate installs a Rust panic hook that formats the panic and calls a function pointer, and the player installs a JavaScript logger at that pointer through the same trampoline machinery.
The indirection through the function table is deliberate. An env-namespaced import would be simpler, but then every cdylib linking the helper crate would declare an import nothing can resolve, which breaks loaders that refuse unrecognised env modules. A table slot costs nothing and belongs to the module.
Where the code lives
| Path | What |
|---|---|
runtimes/wasm/core/src/module.ts | open, instantiate, register; owns the arena and the table |
runtimes/wasm/core/src/call.ts | argument plans, call layout, both dispatchers |
runtimes/wasm/core/src/callback.ts | shapes, dispatch routing, argument lift on the way in |
runtimes/wasm/core/src/trampoline.ts | the hand-rolled wasm encoder |
runtimes/wasm/core/src/marshal.ts | struct layouts, RustBuffer and RustCallStatus reads |
runtimes/wasm/core/src/{memory,scratch}.ts | views over linear memory; the bump arena |
runtimes/wasm/{browser,node}/src | how bytes are fetched; nothing else differs |
runtimes/wasm/helper-crate | uniffi-runtime-wasm: alloc, free, panic hook |
crates/ubrn_bindgen/src/wasm_metadata.rs | UniFFI metadata out of a .wasm |
crates/ubrn_bindgen/src/bindings/gen_typescript/ffi_module_player/ | the IR the signature table renders from |
crates/ubrn_common/src/wasm.rs | staging: wasm-bindgen, table export, dead-code elimination |
crates/ubrn_cli/src/wasm2/ | ubrn build wasm2, ubrn generate wasm2 |
crates/ubrn_fixture_testing/src/wasm2.rs | the fixture harness |
The player package excludes itself from the cargo workspace and carries its own package-lock.json, because it publishes to npm as @ubjs/wasm and must build against a published @ubjs/core rather than against the checkout.
Trade-offs
Where this design gives something up, it gives it up for a reason, and the reasons are worth knowing before you change one of them.
Reserved scratch means no synchronous re-entry. Every call to one export reuses one region, so a callback that synchronously calls back into the same export while the outer call is still on the stack overwrites that call’s status byte, return slot and lowered arguments — silently. UniFFI’s callback model does not generate that shape, and both dispatchers say so in a comment. The arena supports per-call push and pop for the day one does.
The arena is 64 KB, and reservations are permanent. Every registered function holds its layout for the module’s life. A large megazord is the case that could exhaust it; the failure is loud and names the shortfall.
Callback slots are never reclaimed. Bounded in practice, as above, but a future codegen that installed per-instance closures would leak table slots.
Two dispatchers must stay in step. The compiled path is a meaningful win on the hot path and a real maintenance cost.
Views alias wasm memory. The alternative — copying every buffer to the JavaScript heap — would be safe and slower. The player takes the fast path and throws a specific error when the contract is broken, rather than reading garbage.
The wasm-bindgen rewriter is version-locked. It takes only the version of the wasm-bindgen crate the module was built against, and that version belongs to the crate’s dependency tree, not to us. So staging shells out to whatever binary the project provides and this workspace pins no version at all; when the two disagree, the version read from the module’s own descriptor section names the one to install.
Metadata extraction reads module structure. Exported i32 globals pointing into active data segments is how rustc and lld lay this out today, not a guarantee. A test extracts metadata from every fixture wasm to catch a change early.
No threads, and no Hermes. Both follow from the platform: the first shows up as a panic in Rust APIs that need a host thread, and the second means React Native proper stays with the JSI target.
Testing
Four layers, each catching what the one below cannot.
Unit tests in runtimes/wasm/core/tests, run by npm test, drive the player against a hand-assembled minimal wasm module: enough exports to open, enough to call. They cover the arena, the encoder, struct layouts and both dispatchers without building a Rust crate.
Fixture tests, run by cargo test -- wasm2::, build a real fixture for wasm32, generate bindings from it, stage the module, and run the shared test script under Node.js. Eighteen of this project’s twenty-one shared fixtures run this way, including callbacks, async, trait interfaces and external types. The futures fixture is excluded for a documented reason: its hand-rolled TimerFuture spawns a host thread.
A build test, cargo test -p uniffi-bindgen-react-native --test wasm2_build, runs ubrn build wasm2 in a temporary project and asserts the files line up — that the name the bindings import is the name that was staged, that the generated wrapper stayed environment-neutral, that the staged module carries the table export. Nothing below this layer can catch a mismatch between two artifacts.
Codegen tests, in crates/ubrn_bindgen/tests/wasm2_codegen.rs, assert that the shared templates take the right branch for each flavor in both directions: that wasm2 does not emit the Node.js loading path, and that Node.js still does.