@fireflydb/expo-driver
v0.0.11
Published
Expo native module + drivers for FireflyDB. Bundles libfirefly and wires it through expo-sqlite, expo-secure-store, and React Native WebSocket.
Maintainers
Readme
@fireflydb/expo
Expo native module that bundles the Rust libfirefly SQLite extension and
wires it into expo-sqlite,
expo-secure-store,
and React Native WebSocket. Exposes a change-listener bridge so JS gets
notified of CRDT-relevant local writes.
This document focuses on how the native core is built and shipped. For
SDK usage, see packages/core.
Layout
packages/expo/
├── cpp/ # Cross-platform C++ shared by iOS + Android
│ ├── firefly_abi.h # Rust C-ABI declarations (firefly_add_change_listener, …)
│ ├── firefly_listener.h # Listener registry public surface
│ └── firefly_listener.cpp # Process-wide handle→dispatch registry
│
├── ios/
│ ├── Firefly.xcframework/ # ← produced by scripts/build-libfirefly.sh
│ ├── FireflyClient.podspec # Vends the xcframework + symlinks ../cpp into the pod
│ └── FireflyClientModule.swift
│
├── android/
│ ├── build.gradle # Wires jniLibs + externalNativeBuild (CMake)
│ └── src/main/
│ ├── cpp/CMakeLists.txt # Imports prebuilt libfirefly.so, builds libfirefly_jni.so
│ ├── cpp/firefly_jni.cpp # JNI shim
│ └── jniLibs/<abi>/libfirefly.so # ← produced by scripts/build-libfirefly.sh
│
├── scripts/
│ └── build-libfirefly.sh # Cross-compiles core/ for iOS + Android
│
└── src/ # TypeScript surface (drivers + module bindings)The single source of truth for the native core is the core/ Rust crate at
the repo root. Everything under ios/Firefly.xcframework/ and
android/src/main/jniLibs/ is build output (gitignored) — never hand-edit it.
Rebuilding the core
Run from this package directory:
pnpm build:libfirefly # both platforms
bash scripts/build-libfirefly.sh ios # iOS only
bash scripts/build-libfirefly.sh android # Android onlyThe script (scripts/build-libfirefly.sh)
cross-compiles core/ with --no-default-features --features
loadable_extension so the resulting binary is a SQLite loadable extension
(no bundled SQLite — both Expo and Android ship their own).
Prerequisites
| Tool | Used for | Auto-installed by script? |
|------|----------|---------------------------|
| rustup + Rust toolchain | All targets | No (must be on PATH) |
| iOS rust targets (aarch64-apple-ios, aarch64-apple-ios-sim, x86_64-apple-ios) | iOS | Yes (rustup target add) |
| Android rust targets (aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android, i686-linux-android) | Android | Yes |
| xcodebuild, lipo, install_name_tool | iOS framework assembly | No (Xcode CLT) |
| cargo-ndk | Android cross-compile | Yes (cargo install) |
| Android NDK r24+ (ANDROID_NDK_HOME exported) | Android | No |
When to rebuild
Rebuild after any change under core/ that affects the C-ABI surface
or runtime behavior. The .xcframework and .so files are gitignored build
output: the prepack hook rebuilds them on pnpm pack / pnpm publish, so
the published package ships binaries and consumers never need a Rust
toolchain. In a fresh clone, run pnpm build:libfirefly once before building
the example app.
The shared C++ in cpp/ (firefly_listener.{cpp,h}) is not built by
this script — it's compiled by the platform build systems (CocoaPods on
iOS, Gradle/CMake on Android) when the host app builds. Editing it requires
no rebuild script run; just rebuild the host app.
iOS pipeline
core/ ──cargo build──▶ target/<rust-target>/release/libfirefly.dylib
│
├─ aarch64-apple-ios ─┐
├─ aarch64-apple-ios-sim ─┼─lipo──▶ sim-universal
└─ x86_64-apple-ios ─┘
│
device-arm64.dylib sim-universal.dylib
│ │
ios_make_framework ios_make_framework
▼ ▼
Firefly.framework (device) Firefly.framework (sim-universal)
└──── xcodebuild -create-xcframework ────┘
▼
ios/Firefly.xcframework/What the script does, in order:
- Per-slice
cargo buildfor each iOS target into the workspacetarget/dir (the workspace lives at the repo root, not undercore/). lipothe two simulator slices (arm64-sim+x86_64) into a single fat dylib. iOS device stays as a singlearm64slice.ios_make_frameworkwraps each dylib in a properFirefly.frameworkbundle: copies the dylib in as the bundle binary (namedFirefly, no extension), rewrites itsLC_ID_DYLIBinstall name to@rpath/Firefly.framework/Fireflyviainstall_name_tool, and writes anInfo.plistwith the rightCFBundleSupportedPlatforms/MinimumOSVersion.xcodebuild -create-xcframeworkcombines the device and simulator-universal frameworks intoFirefly.xcframework. This is the canonical Apple distribution shape and the only one CocoaPods accepts vias.vendored_frameworksfor dynamic linking.nmverification that_sqlite3_firefly_initis exported in both slices — that's the entry pointexpo-sqlitecalls viasqlite3_load_extension.
FireflyClient.podspec then:
- Vendors
Firefly.xcframework(Xcode embeds the right slice into<App>.app/Frameworks/Firefly.framework/Fireflyand re-signs at archive time). - Pulls in the shared C++ via
ios/_shared/*.{cpp,h}(a committed directory of per-file symlinks pointing at../cpp/*). This avoids a separate.mmshim — Swift binds to the C-ABI symbols via@_silgen_name. See the long comment inFireflyClient.podspecfor why_shared/is committed instead of generated byprepare_command(CocoaPods skipsprepare_commandfor:path =>pods).
Android pipeline
core/ ──cargo ndk build──▶ target/<rust-target>/release/libfirefly.so
│
├─ aarch64-linux-android ─▶ jniLibs/arm64-v8a/libfirefly.so
├─ armv7-linux-androideabi ─▶ jniLibs/armeabi-v7a/libfirefly.so
├─ x86_64-linux-android ─▶ jniLibs/x86_64/libfirefly.so
└─ i686-linux-android ─▶ jniLibs/x86/libfirefly.so
(later, at app build time:)
android/src/main/cpp/firefly_jni.cpp + cpp/firefly_listener.cpp
│
CMake (externalNativeBuild)
▼
jniLibs/<abi>/libfirefly_jni.so (per ABI, links against libfirefly.so)What the script does:
cargo ndk --target <rust-target> --platform 24for each ABI. The--platform 24matchesminSdkVersion 24inandroid/build.gradle.cargo-ndkhandles wiring the NDK toolchain into Cargo so the Rust compiler emits ABI-correct shared objects.- Copies each
libfirefly.sointoandroid/src/main/jniLibs/<abi>/.
At app build time, Gradle (android/build.gradle):
- Picks up the prebuilt
libfirefly.sofromjniLibs/<abi>/and packages them into the APK/AAB —applicationInfo.nativeLibraryDirresolves them at runtime. - Compiles
libfirefly_jni.soper ABI viaexternalNativeBuild/ CMakeLists (android/src/main/cpp/CMakeLists.txt). CMake importslibfirefly.soas anIMPORTEDtarget and links the JNI shim against it plus the sharedcpp/firefly_listener.cpp. packagingOptions { doNotStrip "**/libfirefly.so" }— the Rust release profile already strips, and stripping again can drop thesqlite3_firefly_initexport.
Kotlin loads the native library with System.loadLibrary("firefly_jni"),
which transitively pulls in libfirefly.so because of the import
relationship in CMake.
Shared C++ (cpp/)
Both platforms include cpp/firefly_listener.cpp directly in their build
(firefly_listener.cpp). It's a single
process-wide registry mapping a caller-supplied int64_t handle to a
platform dispatcher (Swift sendEvent on iOS, JNI CallStaticVoidMethod
on Android). Keeping this in one place means the lifetime/race semantics
required by firefly_add_change_listener's contract are implemented
once, not twice.
Linking model:
- iOS: compiled into the
FireflyClientpod alongside the Swift module, with-fvisibility=hidden. Swift uses@_silgen_nameto bind tofirefly_listener_register/firefly_listener_unregister. - Android: compiled into
libfirefly_jni.soby CMake. JNI calls into it directly.
The C-ABI between this layer and Rust (firefly_add_change_listener,
firefly_remove_change_listener) is declared in
cpp/firefly_abi.h.
Troubleshooting
sqlite3_firefly_init not exported. The script verifies this on iOS
and aborts the build. If it ever fires, check that core/src/lib.rs still
declares the entry point with the sqlite_extension_init macro and that
release-profile strip = true isn't dropping the symbol on a new
toolchain.
Android NDK not found. Export ANDROID_NDK_HOME (e.g.
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.1.12297006). cargo-ndk
reads it.
linker 'cc' not found on Android targets. cargo-ndk injects the
right linker via env vars only inside its child cargo invocation. If you
ran cargo build --target aarch64-linux-android directly (not via
cargo ndk), you'll get this. Use the script.
Stale target/ cache after toolchain bump. cargo clean at the repo
root, then re-run the script. The targets are cross builds so they don't
share intermediate artifacts with desktop dev builds, but a corrupt cache
across a Rust version bump can manifest as missing symbols.
Undefined symbol: _firefly_listener_register at iOS link time. The
shared C++ in cpp/ is pulled into the pod via the committed
ios/_shared/ directory of per-file symlinks. Two CocoaPods quirks
combined to cause earlier breakage here:
- CocoaPods indexes pod sources via Ruby's
Find.find, which does NOT recurse into symlinked directories — so_sharedcannot itself be a symlink. It must be a real directory containing file-level symlinks (which Find DOES list). - CocoaPods does NOT run
prepare_commandfor development (:path =>) pods. Anything that expects to be set up atpod installtime silently no-ops.
So _shared/ is checked into git; if you add a file under cpp/,
mirror it with ln -sfn ../../cpp/<name> ios/_shared/<name> and commit.
After podspec or _shared/ changes, re-run pod install (or
npx expo prebuild --clean for Expo apps) so the Pods xcodeproj
regenerates with the new sources. The cannot link directly with
'SwiftUICore' line that often appears in the Expo CLI output is
unrelated noise from Xcode 26 — check
apps/<app>/.expo/xcodebuild.log for the real Undefined symbols
section.
