For the complete documentation index, see llms.txt. This page is also available as Markdown.

React Native

This section covers installing and integrating the DeRec protocol on iOS and Android with the @derec-alliance/react-native package.


React Native SDK Overview

The DeRec React Native SDK provides a managed surface over the same DeRecProtocol orchestrator the Rust SDK exposes. Unlike the Node.js and Web packages, it is not a WASM build: it ships a compiled C++/JSI native module wrapping the Rust derec-library core over its C ABI — an iOS XCFramework and per-ABI Android static libraries. All cryptography, protobuf handling, and protocol validation run in native code; the JavaScript side only marshals arguments and results.

The API surface deliberately mirrors @derec-alliance/nodejs method-for-method: the same DeRecProtocol / DeRecProtocolBuilder classes, the same DeRecEvent union, the same five store interfaces and Transport. Code written against the Node package usually ports with only an import change — the handful of places where it does not are listed under Differences from the Node package.

The SDK allows applications to:

  • Distribute Secret shares across trusted Helpers using a threshold secret-sharing scheme

  • Verify Helpers still hold shares

  • Recover the Secret from a threshold of Helpers

  • Optionally extend the Owner identity across multiple devices via the replica feature, with automatic Secret synchronization between them

Three things differ from the TypeScript (Node.js / Web) packages. Protocol calls run on a dedicated worker thread, not the JavaScript thread. free() drains in-flight work before releasing the native handle. And the package cannot run inside Expo Go, because Expo Go is a fixed binary that contains only the native modules Expo shipped with it. Each is covered below.


Installation

npm install @derec-alliance/react-native@0.0.8
# or: yarn add @derec-alliance/react-native@0.0.8

These chapters document lib-derec 0.0.8.

iOS additionally requires a pod install:

Autolinking picks up the DeRec.podspec and the Android Gradle module automatically via react-native.config.js — no manual native project edits are needed on either platform. There is no init() call and no WASM asset for a bundler to resolve: the native module installs the host object itself (on iOS before any JavaScript runs, on Android on first use).

Package size

This package is large, and it is worth knowing that before npm install runs.

Download (gzipped tarball)

~40 MB

Unpacked in node_modules

~125 MB

The bulk is the statically linked Rust core, shipped for all six supported architectures: ios/DeRecFFI.xcframework (~55 MB) and android/src/main/jniLibs (~68 MB). There is no per-architecture package to install instead.

This is not what it adds to your app. These are static archives — the linker pulls in only the objects the build actually references, and each binary is built for one architecture. Your shipped .ipa / .aab grows by a fraction of the figures above. What the ~125 MB does affect is checkout size, CI cache size, and cold npm install time.

Requirements

react-native

>= 0.76.0, New Architecture

react

>= 18.2.0

iOS deployment target

13.4+

Android minSdk

24

Supported platforms

The compiled Rust core ships as a vendored DeRecFFI.xcframework on iOS and as per-ABI static libraries on Android.

Platform
Slices

iOS

device arm64; simulator arm64 + x86_64

Android

arm64-v8a, armeabi-v7a, x86_64


Quick Start

Implement the five store interfaces, a transport, and drive the orchestrator. The example below is the minimum scaffold; the flow chapters show how each protocol round-trip flows through process(...).

The builder requires withChannelStore, withShareStore, withSecretStore, withUserSecretStore, withStateStore, withTransport, and withOwnTransports. A missing store fails fast at build() with a JavaScript error naming it (StoreBindings: missing store "channelStore"). A missing or empty own-transport list fails in the Rust core with a DeRecError of code invalid_input (category input); two entries of the same protocol fail with transport_invalid. Optional setters (withThreshold, withTimeouts, withCommunicationInfo, withAutoRespondOnFailure, withUnpairAck, withAutoReplyTo, withAutoAccept, withUnsafeConnection, withReplicaId, withParameterRange) tune flow behaviour, transport policy, and replica participation. Values you never set are omitted from the config entirely, so the library's own default applies rather than one frozen into the binding.

Own transports

withOwnTransports(Endpoint[]) lists every endpoint this application serves, in preference order, at most one per protocol. Endpoint is { uri, protocol }, where protocol is "https" or "grpc" and the URI scheme is one of https://, http://, grpcs://, grpc://. Every listed endpoint must actually be served: delivery is push-only, so an endpoint that is advertised but not served makes pairing succeed and replies vanish. Change the list at runtime with await protocol.setOwnTransports([...]), which validates every entry before replacing the whole list; see Channel management.

withUnsafeConnection(true) accepts plaintext http:// and grpc:// endpoints, to any host, including endpoints a peer advertises. Default false; a loopback endpoint of your own works without it. On a physical device talking to a laptop over the LAN neither side is loopback, so both sides need the flag — a common first stumble in mobile development. See Transport endpoints and plaintext connections.

withParameterRange(Partial<ParameterRange>) declares the bounds this node advertises at pairing; a peer whose range does not intersect is refused with incompatible_parameter_range. Every bound is optional; an unset bound is no constraint.

Transport

Transport.send(endpoints, message) receives every endpoint the peer advertised, in the peer's own order, never empty. The library does not rank them. Deliver once — stop at the first endpoint that accepts the message — and reject only when none did. Rather than writing send by hand, write a SendOne ((endpoint, message) => Promise<void>) and wrap it:

  • sequentialFailover(dialer) — tries each endpoint in order, stops at the first success. The right default.

  • singleEndpointTransport(dialer) — uses the first endpoint only. Use it when failover was considered and declined; indexing endpoints[0] inline hides that decision.

free() and instance lifetime

DeRecProtocol owns a native handle and a worker thread. free() releases both; it is safe to call more than once, and it drains any work still in flight before releasing, so freeing while a call is pending completes rather than crashing. A call issued after free() fails with "DeRec: protocol instance has been freed" rather than queueing onto a worker that will never run it. [Symbol.dispose]() calls free(), for runtimes that support using.

Not calling free() leaks the handle and its thread for the life of the process. Tie it to whatever owns the instance — a React effect cleanup, a service-object teardown, a navigation unmount.

Timeouts

withTimeouts configures four independent budgets. Every field is optional — omit one to keep the library default for it; omit the call entirely and no timeouts object is sent at all.

inbound_message_secs is a security boundary — it bounds how stale a message may be and still be accepted, so it has to tolerate transport latency and clock skew. A message older than it is dropped and reported as { type: "MessageIgnored", reason: "Expired" }. On mobile this matters more than on a server: a device that was asleep, backgrounded, or on a flaky connection routinely delivers messages later than a datacentre peer would. The other three are liveness budgets. See Concurrency and scheduling.

tick()

Timeouts advance only inside process and tick. A publish whose peers all go quiet has nothing left to close it: the round stays open and no SharingComplete is ever emitted.

tick() is idempotent and safe on an idle protocol — with nothing in flight it touches no state and returns an empty array. It mutates the same round state an inbound response does, but the binding serialises everything onto one worker, so no extra locking is needed here. Note that a JavaScript timer does not fire while the app is backgrounded on either platform: on resume, run one tick() before assuming a round is still open. See Channel management and Timeouts and tick().

Utility functions

  • generate_replica_id() — a fresh random replica id (bigint, never 0). Generate it once per device, persist it, and pass the same value to withReplicaId on every start. See Pairing.

  • protocol_version() — the DeRec protocol version this build writes into every envelope, { major, minor }. Not the package version. See Versioning.

  • channelFilterMatches(filter, id, status, role) — evaluates a HelperFilter / ReplicaFilter for a store that lists and filters in memory. See Implementing a persistent store.

  • advertisedEndpoints(message) — the supported_transports a decoded peer message advertises, in the peer's order, unvalidated.


Threading model

The native module runs each DeRecProtocol instance's flow logic (start, process, tick, accept, reject, restore, …) on a dedicated worker thread, off the JavaScript thread — precisely because that logic calls back into your store and transport implementations, which are themselves JavaScript and can take an unbounded amount of time (disk I/O, network requests). Those methods return a Promise. The worker is serial, so calls on one instance queue rather than interleave; only distinct instances on the same secretId and stores need an application lock.


Protocol Flows

The SDK exposes every DeRec flow through a single protocol.start(flowKind, params) entry point plus the inbound process / accept / reject loop. FlowKind has nine members: Pairing, Discovery, ProtectSecret, VerifyShares, RecoverSecret, Unpair, UpdateChannelInfo, ReplicaDiscovery, UnpairReplica. The chapters below cover them; the protocol-level catalog lives in Protocol Specification / Flows.

Two FlowKinds are replica-only and require withReplicaId — and on mobile they are the common case, since a second phone or tablet holding the same Secret is exactly what the replica feature is for:

Chapter
Purpose

Establish secure communication between two participants — Owner ↔ Helper, or Replica pairings

Split the Secret and distribute shares to Helpers; sync the full Secret to every other replica group member

Verify Helpers still hold shares

Discover versions, recover shares, reconstruct the Secret, and restore

Unpair, UpdateChannelInfo, ReplicaDiscovery, UnpairReplica, rejecting requests, auto-accept, timeouts and housekeeping

Each flow follows the same shape: the initiator calls protocol.start(FlowKind.X, params); the library produces outbound envelopes through your Transport.send; the responder feeds inbound envelopes to protocol.process and answers any { type: "ActionRequired" } event with protocol.accept(action) or protocol.reject(action, status, memo).


Message Model

The SDK operates on serialized protocol envelopes rather than exposing protobuf types directly on the public API.

Secret format. The recoverable Secret — the bytes Helpers store and recovery reconstructs — is [ version byte ] · payload. The current version byte is 3; the decoder accepts 3 and 2 and rejects 1. The payload is gzip-compressed JSON (RFC 1952), with byte fields as standard-padded base64 (RFC 4648 §4) and u64 fields as decimal strings; version 3 carries each roster entry's endpoints as transports: [{ uri, protocol }]. This is internal to the SDK — you always pass and receive a typed Secret — and only matters for cross-app interop or primitive-layer use. See Concepts → Secret for the full schema. The replica-sync message and every other wire message stay protobuf.

  • Inbound and outbound envelopes are plain Uint8Array buffers on both sides of the orchestrator

  • These bytes represent:

    • A serialized DeRecMessage envelope, or

    • A plain serialized ContactMessage (the out-of-band bootstrap, see Pairing)

  • For encrypted protocol exchanges, the DeRecMessage envelope contains an encrypted inner protobuf message — the library performs the encryption, decryption, and framing for you

Applications are responsible for:

  • Delivering outbound Uint8Array envelopes — the library passes them to Transport.send(endpoints, message) — to the counterparty

  • Receiving inbound Uint8Array envelopes and passing them back through DeRecProtocol.process(...)

Hermes has no TextEncoder / TextDecoder

The Hermes engine React Native ships by default does not implement the WHATWG encoding globals. Node-targeted example code that writes new TextEncoder().encode("...") therefore fails at runtime on device. Supply your own UTF-8 helpers, or install a polyfill:

Every code sample in this section uses utf8Encode / utf8Decode where the Node chapters use TextEncoder / TextDecoder.


Error Handling

All library-side errors surface as a thrown DeRecError:

DeRecError.category identifies the layer or flow that produced the error; code is the specific reason, and one code means the same thing in every category. The exhaustive category list is exported as DEREC_ERROR_CATEGORIES:

Binding-side failures — a malformed argument crossing the C ABI — carry category ffi. The same (category, code) pair carries the same meaning across every language binding — see the cross-binding error catalog.

When process fails

process() settles expired deadlines (sharing-round and unpair timeouts) before it handles the message, and those are never reported again. So when the message then fails, the thrown DeRecError carries them: events holds every event produced before the failure, and channel_id names the channel the message came from (absent when the bytes were not a decodable envelope). Handle the events as you would a successful call's, then the error:

See When process fails.


Differences from the Node package

@derec-alliance/react-native and @derec-alliance/nodejs are kept deliberately source-compatible. These are the places where they are not:

Difference
Why

removeExpiredChannels(olderThanSecs: number) (nodejs takes bigint | number | string)

The value is a whole number of seconds; React Native passes it to the native layer as a JavaScript number.

start(FlowKind.Discovery, params?) — params are optional (nodejs requires them)

Omitting them, or omitting target, sends no target and discovers across every paired Helper.

createContact accepts number as well as bigint for channelId and nonce

Both are converted to bigint before they reach the native layer.

The DeRecErrorCategory union differs

It omits "wasm", which a C-ABI binding can never emit, and adds "ok" and "ffi", which it can. A test pins the union to the crate's shared enum fixture so the two cannot drift.

Protocol calls run off the JavaScript thread

Both packages queue calls on one instance. Here the queue is a native worker thread, so a slow store or transport never blocks rendering. See Threading model.

A protocol call from inside a store callback is rejected with an error

In nodejs the same call deadlocks. Here the binding detects it and rejects instead of hanging.

free() drains in-flight work

In nodejs, free() throws while a call is in flight. Here it waits for the worker to finish, then releases the handle and its thread.

No init(); a native rebuild instead

There is no WASM asset to fetch and instantiate (the web package's init() has no counterpart). The cost moves to build time: adding or upgrading the package requires pod install / Gradle sync and a fresh native build.

Everything else — the builder, DeRecEvent, the store interfaces, the flow params, setCommunicationInfo / setOwnTransports (both Promise<void>), primitives.* — is identical.


API Reference

The package ships lib/index.d.ts alongside the JavaScript, and the TypeScript source under src/, so editors and type-checkers surface the full surface natively. Full field-level contracts are documented as TSDoc on each interface in src/types.ts; the published types are the authoritative reference for every method, parameter, and DeRecEvent variant documented in this section.

The package also ships the DeRec .proto schema under schema/ — the flat schema/proto/ files and a precompiled schema/derec_descriptor.bin — so code can be generated for any language without a lib-derec checkout. See Schema distribution.


SDK Architecture

The TypeScript layer turns host functions into typed async methods, marshals Uint8Array / bigint across the boundary, and shapes events into the DeRecEvent union. The C++ layer owns the worker thread and bridges store and transport callbacks back into JavaScript. Below it sits the same compiled derec-library core the .NET and Go SDKs reach through the same C ABI — every binding is a thin translation over the same core.

The host object is installed into the runtime's global scope as __DeRec rather than through React Native's codegen, because the data surface passes ArrayBuffers that TurboModule specs cannot express. On iOS the platform installs it before any JavaScript runs; on Android the first call into the SDK triggers a blocking synchronous install. You never touch __DeRec directly — but this is why the New Architecture is a hard requirement rather than a preference.


Reference implementation

A complete on-device integration lives in the monorepo at smoke-tests/react-native/. It is a plain React Native app that runs every flow — pairing (all three contact modes), sharing, verification, discovery, recovery, restore, unpair, update channel info, replica pairing and catch-up, auto-accept, expired-channel cleanup — against in-memory stores and a recording transport, on both a simulator and a device. src/protocol.ts carries the store implementations and the makeNode / processAll / drainOne / doPair / pumpAll helpers the examples in this section refer to; these are smoke-test helpers, not SDK API. src/scenarios.ts additionally covers the two lifetime hazards no other SDK has (freeing a handle with store callbacks outstanding, and calls issued after free()).

Last updated