Skip to content

Latest commit

 

History

History
1443 lines (1142 loc) · 48.5 KB

File metadata and controls

1443 lines (1142 loc) · 48.5 KB

@offline-protocol/mesh-sdk

Offline-first mesh networking SDK for React Native. Enables peer-to-peer messaging over BLE, WiFi Direct, Internet, Reticulum, and Nostr relays with intelligent transport switching.

Dual-licensed: use it under AGPL-3.0-only, or buy a commercial license, your call. The npm manifest can only name AGPL-3.0-only because SPDX has no identifier for the commercial offer — see License for the full breakdown.

Upgrading an existing app? Read docs/UPGRADING.md first — this release changes storage, adds new input rejections, and is not safely downgradable.

Table of Contents


Requirements

Platform Version
React Native >= 0.70.0
iOS >= 13.0
Android >= API 24 (Android 7.0)
Node.js >= 16

Installation

npm install @offline-protocol/mesh-sdk

iOS

cd ios && pod install

Autolinking picks the SDK up — no pod entry, :modular_headers, or post_install hook of your own is required. The pre-built native binary ships as an XCFramework, so CocoaPods selects the right slice per build SDK and device and simulator builds both work with no linker configuration.

Upgrading from below 0.20.0: delete any manual pod 'MeshSdk', ... line (and any post_install hook that set OTHER_LDFLAGS/LIBRARY_SEARCH_PATHS for it) from your Podfile before running pod install. See docs/UPGRADING.md.

Android

Pre-built native libraries are included. No additional setup required.


Platform Setup

iOS Permissions

Add to Info.plist:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>Required for offline mesh communication</string>

<key>NSBluetoothPeripheralUsageDescription</key>
<string>Required for offline mesh communication</string>

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <string>bluetooth-peripheral</string>
</array>

Android Permissions

Add to AndroidManifest.xml:

<!-- Bluetooth (Android 12+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

<!-- Bluetooth (Android 11 and below) -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- WiFi Direct -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />

<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET" />

Quick Start

import { OfflineProtocol, MessagePriority } from '@offline-protocol/mesh-sdk';

const protocol = new OfflineProtocol({
  appId: 'my-app',
  profile: 'user123',
  // Encryption is enabled by default - MLS is auto-initialized on start()
});

protocol.on('message_received', (event) => {
  console.log(`From ${event.sender}: ${event.content}`);
  // event.encrypted indicates if the message was encrypted
});

await protocol.start(); // MLS auto-initialized with secure storage

// Messages are automatically encrypted when possible!
const messageId = await protocol.sendMessage({
  recipient: 'user456',
  content: 'Hello!',  // Automatically encrypted
  priority: MessagePriority.High,
});

await protocol.stop();
await protocol.destroy();

Built-in secure and restartable-state stores are isolated by both appId and profile. Changing either value selects a fresh storage namespace.

Upgrading an install that predates namespacing keeps both halves of its state, by two different mechanisms.

Its MLS identity is adopted by read-through: the first account to launch claims the old, unscoped secure store and inherits its identity, sessions, and TOFU pins on demand. That store was shared by every account on the install, so only one can inherit it — a second account starts from a fresh identity and reports it as an error diagnostic (Legacy secure store belongs to another account…).

Its restartable delivery state — outbox, pending queue, session and Welcome lifecycles, peer key packages and capabilities, media descriptors, blocked users, the Lamport clock — is swept out of the credential store into the app container on the first launch of this release, and the credential-store copy is deleted once the move is durable. The sweep is resumable and one-shot.

An account that loses the claim above gets neither: no identity and no delivery state, so it comes up with an empty outbox and an empty block list, every previously blocked peer unblocked. That is what the error diagnostic is telling you.

Because the sweep deletes the credential-store copy, downgrading is not a rollback — an older build reads the old location and finds none of it. Roll forward, not back.

Erasing an account on logout

destroy() releases the in-memory instance and nothing else: the outbox, pending queue, block list, and MLS identity stay on disk, so signing back in re-drives undelivered messages, and on iOS — where the Keychain outlives the app container — the account survives an uninstall and is adopted again after a reinstall. To erase it:

await protocol.destroy();
await protocol.wipePersistedState(appId, profile);

Wipe after destroy, never under a live instance (the native side rejects that), and pass the same appId/profile the protocol was created with — destroy() clears the config the namespace is derived from, which is why the identity is an argument. It is irreversible, it rotates the account's MLS and Nostr identities, and it leaves other accounts on the device untouched. Full semantics, including what happens to a pre-namespace store shared with another account: UPGRADING §10.

End-to-End Encryption

The SDK provides automatic end-to-end encryption using MLS (RFC 9420):

const protocol = new OfflineProtocol({
  appId: 'my-app',
  profile: 'alice',
  encryption: {
    enabled: true,           // Auto-encrypt messages (default)
    autoKeyExchange: true,   // Exchange keys on peer discovery (default)
    storePending: true,      // Queue messages until session ready (default)
  },
});

await protocol.start(); // MLS auto-initialized when encryption.enabled is true

// That's it! Messages are now automatically encrypted/decrypted.
// Key packages are exchanged automatically when peers are discovered.

Four runtime kill switches (all default true) let you disable a negotiated format or the recovery path without an SDK release:

Flag Disables
binaryWireEnabled (top level) Compact binary mesh framing, back to the JSON floor
encryption.compactEnvelopeEnabled Compact MLS envelope, back to the JSON floor
encryption.richPayloadEnabled Sealing rich extras — they drop rather than going cleartext
encryption.cryptoRecoveryEnabled Epoch-desync healing, back to legacy drop-and-ACK

Each degrades independently, and inbound parsing of every format stays on regardless, so a disabled fleet still interoperates with an enabled one. See Wire Format Kill Switches and Crypto-Failure Recovery.

See the MLS Integration Guide for advanced usage.


Protocol Lifecycle

Complete Flow Example

import { 
  OfflineProtocol, 
  MessagePriority,
  ProtocolEvent,
  MessageReceivedEvent,
  MessageDeliveredEvent,
  NeighborDiscoveredEvent,
} from '@offline-protocol/mesh-sdk';

// 1. CREATE PROTOCOL INSTANCE
const protocol = new OfflineProtocol({
  appId: 'my-chat-app',
  profile: 'alice-device-001',
});

// 2. REGISTER EVENT LISTENERS (before starting)

// Track discovered peers
const discoveredPeers = new Map<string, number>(); // peerId -> rssi

protocol.on('neighbor_discovered', (event: NeighborDiscoveredEvent) => {
  console.log(`[PEER FOUND] ${event.peer_id} via ${event.transport}, RSSI: ${event.rssi}`);
  discoveredPeers.set(event.peer_id, event.rssi ?? -100);
});

protocol.on('neighbor_lost', (event) => {
  console.log(`[PEER LOST] ${event.peer_id}`);
  discoveredPeers.delete(event.peer_id);
});

// Track outgoing messages
const pendingMessages = new Map<string, { recipient: string; content: string }>();

protocol.on('message_sent', (event) => {
  console.log(`[SENT] Message ${event.message_id} to ${event.recipient}`);
  pendingMessages.set(event.message_id, {
    recipient: event.recipient,
    content: event.content,
  });
});

protocol.on('message_delivered', (event: MessageDeliveredEvent) => {
  console.log(`[DELIVERED] Message ${event.message_id} in ${event.latency_ms}ms, ${event.hop_count} hops`);
  pendingMessages.delete(event.message_id);
});

protocol.on('message_failed', (event) => {
  console.log(`[FAILED] Message ${event.message_id}: ${event.reason} (${event.retry_count} retries)`);
  pendingMessages.delete(event.message_id);
});

// Handle incoming messages
protocol.on('message_received', (event: MessageReceivedEvent) => {
  console.log(`[RECEIVED] From ${event.sender}: ${event.content}`);
  console.log(`  - Message ID: ${event.message_id}`);
  console.log(`  - Hop count: ${event.hop_count}`);
  console.log(`  - Transport: ${event.transport}`);
  
  // Process the message in your app
  handleIncomingMessage(event);
});

// Monitor transport changes
protocol.on('transport_switched', (event) => {
  console.log(`[TRANSPORT] Switched from ${event.from} to ${event.to}: ${event.reason}`);
});

// 3. START THE PROTOCOL
await protocol.start();
// At this point:
// - BLE scanning begins (discovers nearby devices)
// - BLE advertising begins (makes this device discoverable)
// - neighbor_discovered events will start firing as peers are found

// 4. WAIT FOR PEERS (optional helper)
async function waitForPeer(peerId: string, timeoutMs = 30000): Promise<boolean> {
  if (discoveredPeers.has(peerId)) return true;
  
  return new Promise((resolve) => {
    const timeout = setTimeout(() => resolve(false), timeoutMs);
    
    const handler = (event: NeighborDiscoveredEvent) => {
      if (event.peer_id === peerId) {
        clearTimeout(timeout);
        protocol.off('neighbor_discovered', handler);
        resolve(true);
      }
    };
    
    protocol.on('neighbor_discovered', handler);
  });
}

// 5. SEND A MESSAGE
async function sendChatMessage(recipientId: string, text: string) {
  try {
    const messageId = await protocol.sendMessage({
      recipient: recipientId,
      content: text,
      priority: MessagePriority.High,
    });
    console.log(`Message queued with ID: ${messageId}`);
    return messageId;
  } catch (error) {
    console.error('Failed to send message:', error);
    throw error;
  }
}

// 6. CLEANUP ON APP EXIT
async function cleanup() {
  await protocol.stop();
  await protocol.destroy();
}

Event Sequence Timeline

┌─────────────────────────────────────────────────────────────────────┐
│                        PROTOCOL LIFECYCLE                           │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  new OfflineProtocol(config)                                        │
│       │                                                             │
│       ▼                                                             │
│  protocol.on('...', handler)  ← Register all event listeners        │
│       │                                                             │
│       ▼                                                             │
│  await protocol.start()                                             │
│       │                                                             │
│       ├──► BLE advertising starts (device becomes discoverable)    │
│       ├──► BLE scanning starts (looking for other devices)         │
│       │                                                             │
│       ▼                                                             │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  PEER DISCOVERY PHASE                                        │   │
│  │                                                              │   │
│  │  neighbor_discovered { peer_id, transport, rssi }           │   │
│  │  neighbor_discovered { peer_id, transport, rssi }           │   │
│  │  ...                                                         │   │
│  │                                                              │   │
│  │  MeshController evaluates peers, establishes connections     │   │
│  │  (MEMBER for same cluster, BRIDGE for different clusters)   │   │
│  └─────────────────────────────────────────────────────────────┘   │
│       │                                                             │
│       ▼                                                             │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │  MESSAGING PHASE                                             │   │
│  │                                                              │   │
│  │  protocol.sendMessage({ recipient, content, priority })     │   │
│  │       │                                                      │   │
│  │       ▼                                                      │   │
│  │  message_sent { message_id, recipient, content, ... }       │   │
│  │       │                                                      │   │
│  │       ├──► [SUCCESS] message_delivered { message_id, ... }  │   │
│  │       │                                                      │   │
│  │       └──► [FAILURE] message_failed { message_id, reason }  │   │
│  │                                                              │   │
│  │  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │   │
│  │                                                              │   │
│  │  INCOMING: message_received { sender, content, ... }        │   │
│  └─────────────────────────────────────────────────────────────┘   │
│       │                                                             │
│       │  (peers may come and go)                                    │
│       │                                                             │
│       ▼                                                             │
│  neighbor_lost { peer_id }                                          │
│  neighbor_discovered { peer_id, ... }  ← new peer appears           │
│       │                                                             │
│       ▼                                                             │
│  await protocol.stop()                                              │
│       │                                                             │
│       ├──► BLE scanning stops                                       │
│       ├──► BLE advertising stops                                    │
│       ├──► All connections closed                                   │
│       │                                                             │
│       ▼                                                             │
│  await protocol.destroy()  ← Clean up resources                     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

What Happens Under the Hood

On protocol.start()

  1. Protocol core starts in Rust
  2. BLE Manager initializes:
    • Starts scanning for devices advertising the Offline Protocol service UUID
    • Starts advertising this device with mesh metadata (degree, free slots, battery, uptime)
  3. Process timer starts - polls for outgoing fragments every 100ms

On Peer Discovery

  1. BLE scan detects advertisement from another device
  2. MeshController.shouldInitiateOutbound() evaluates the candidate:
    • Checks connection budget (default max: 4)
    • Calculates peer score (RSSI, availability, battery, uptime, stability, load)
    • Determines if this is a cluster bridge opportunity
  3. If accepted: BLE connection established, neighbor_discovered fires
  4. If at capacity: May evict a lower-scoring peer to make room

On protocol.sendMessage()

  1. Message created with unique ID, TTL, timestamp, priority
  2. message_sent event fires immediately
  3. Message queued for transmission
  4. DORS selects transport (BLE, WiFi Direct, or Internet)
  5. Message sent to connected peers
  6. ACK tracking begins (default 10s timeout)
  7. On ACK received: message_delivered event fires
  8. On timeout/max retries: message_failed event fires

On Incoming Message

  1. BLE fragment received from peer
  2. Deduplication check - skip if message ID already seen
  3. If addressed to this device: message_received event fires
  4. ACK sent back to sender
  5. Hop count incremented for metrics

On protocol.stop()

  1. BLE Manager stops scanning and advertising
  2. All peer connections closed
  3. neighbor_lost events fire for each disconnected peer
  4. Protocol core stops

Diagnostic Events

The SDK emits diagnostic events for debugging:

protocol.on('diagnostic', (event) => {
  console.log(`[${event.level.toUpperCase()}] ${event.message}`, event.context);
});

Configuration

ProtocolConfig

interface ProtocolConfig {
  appId: string;
  profile: string;
  transports?: TransportsConfig;
  binaryWireEnabled?: boolean;  // binary wire-codec kill switch (default: true)
  encryption?: EncryptionConfig;
  dors?: DorsConfig;
  network?: NetworkConfig;
  reliability?: ReliabilityConfig;
  fileTransfer?: FileTransferConfig;
}

TransportsConfig

interface TransportsConfig {
  ble?: {
    enabled: boolean;  // default: true
  };
  internet?: {
    enabled: boolean;           // default: false
    serverAddress?: string;     // WebSocket URL
    autoReconnect?: boolean;    // default: true
    reconnectDelay?: number;    // ms
  };
  wifiDirect?: {
    enabled: boolean;           // default: false (Android only)
    deviceName?: string;
    autoAccept?: boolean;
    groupOwnerIntent?: number;  // 0-15
  };
  reticulum?: {
    enabled: boolean;           // default: false (requires external daemon)
  };
  nostr?: {
    enabled: boolean;           // default: false
    relayUrls?: string[];       // wss:// relay URLs
    connectionTimeout?: number; // seconds, default: 30
    autoReconnect?: boolean;    // default: true
    reconnectDelay?: number;    // ms, default: 1000
    maxReconnectAttempts?: number; // default: 0 (infinite)
  };
}

DorsConfig

Controls transport switching behavior.

interface DorsConfig {
  preferOnline?: boolean;              // default: false
  switchHysteresis?: number;           // default: 15.0
  switchCooldownSecs?: number;         // default: 20
  bleToWifiRetryThreshold?: number;    // default: 2
  rssiSwitchThreshold?: number;        // default: -85 dBm
  congestionQueueThreshold?: number;   // default: 50
  stabilityWindowSecs?: number;        // default: 8
  poorSignalDurationSecs?: number;     // default: 10
  ttlEscalationThreshold?: number;     // default: 2
  congestionDurationSecs?: number;     // default: 10
  ttlEscalationHoldSecs?: number;      // default: 20
  historyWindowSize?: number;          // default: 10
  queueRecoveryRatio?: number;         // default: 0.5
}

EncryptionConfig

interface EncryptionConfig {
  enabled?: boolean;                 // default: true
  autoKeyExchange?: boolean;         // default: true
  storePending?: boolean;            // default: true
  requireEncryption?: boolean;       // default: true (fail-closed)
  compactEnvelopeEnabled?: boolean;  // default: true (kill switch)
  richPayloadEnabled?: boolean;      // default: true (kill switch)
  cryptoRecoveryEnabled?: boolean;   // default: true (kill switch)
  pendingQueue?: PendingQueueConfig;
}

interface PendingQueueConfig {
  maxPendingPerPeer?: number;  // default: 64
  maxPendingGlobal?: number;   // default: 4096
  pendingTtlMs?: number;       // default: 86400000 (24 h)
  overflowPolicy?: 'drop_oldest' | 'drop_newest';  // default: drop_oldest
}

pendingQueue bounds the inbound pending-decryption queue — messages that arrived before the MLS session was ready. The outbound pre-session queue is a different queue: it is bounded at a fixed 64 messages / 2 MiB per peer and 4096 / 16 MiB globally (not configurable), with a configurable lifetime via reliability.retry.pendingMessageMaxLifetimeMs.

NetworkConfig

interface NetworkConfig {
  initialTtl?: number;  // default: 8
}

ReliabilityConfig

interface ReliabilityConfig {
  ack?: {
    defaultTimeoutMs?: number;   // default: 10000
    maxPendingAcks?: number;     // default: 1000
  };
  retry?: {
    maxRetries?: number;         // default: 10
    initialDelayMs?: number;     // default: 1000
    maxDelayMs?: number;         // default: 300000
    backoffMultiplier?: number;  // default: 2.0
    outboxMaxLifetimeMs?: number; // default: 604800000
    pendingMessageMaxLifetimeMs?: number; // default: 604800000
  };
  dedup?: {
    maxTrackedMessages?: number; // default: 2000, must be > 0
    retentionTimeSecs?: number;  // default: 86400, must be > 0
  };
}

Both dedup fields are rejected at 0 — at maxTrackedMessages: 0 duplicate suppression is effectively off, which is a replay-defence gap the SDK used to accept in silence. A reliability block passed to the constructor is applied during start(), where a rejection is logged and swallowed (the SDK keeps its defaults), so grep for Failed to apply … configuration rather than assuming it took. A direct updateDedupConfig(...) / updateAckConfig(...) / updateRetryConfig(...) call rejects the promise instead.

FileTransferConfig

interface FileTransferConfig {
  chunkSize?: number;    // default: 32768 (32KB)
  maxFileSize?: number;  // default: 104857600 (100MB)
}

API Reference

Constructor

new OfflineProtocol(config: ProtocolConfig)

Lifecycle Methods

Method Returns Description
start() Promise<void> Start the protocol and all enabled transports
stop() Promise<void> Stop the protocol and disconnect all peers
pause() Promise<void> Pause for background mode
resume() Promise<void> Resume from paused state
destroy() Promise<void> Clean up all resources
getState() Promise<ProtocolState> Get current state (Stopped, Running, Paused)

Messaging

Method Returns Description
sendMessage(params: SendMessageParams) Promise<string> Send message, returns message ID
receiveMessage() Promise<MessageReceivedEvent | null> Poll for next received message
interface SendMessageParams {
  recipient: string;
  content: string;
  priority?: MessagePriority;  // default: Medium
}

enum MessagePriority {
  Low = 0,
  Medium = 1,
  High = 2,
  Critical = 3,
}

Connection Requests

Method Returns Description
sendConnectionRequest(params) Promise<string> Send a connection request, returns message ID
acceptConnectionRequest(params) Promise<string> Accept a received request
rejectConnectionRequest(params) Promise<string> Reject a received request
cancelConnectionRequest(params) Promise<string> Cancel a request you sent

recipient is always the target's canonical address (off1…) — the value they derived from their own identity key, which is exactly what neighbor_discovered reports as peer_id (see the identity contract under neighbor_discovered).

The message ID returned by sendConnectionRequest is the correlation key for the request's outcome events:

Outcome Event Correlate by
Recipient offline (relay delivery error) connection_request_undeliverable (reason starts with recipient_unreachable) message_id
Retry budget exhausted connection_request_undeliverable (reason: 'max_retries_exceeded') and message_failed message_id
Reached the recipient's device message_delivered message_id
Recipient accepted connection_accepted accepted_by (peer id)
Recipient rejected connection_rejected rejected_by (peer id)
Sender cancelled (recipient side) connection_request_cancelled cancelled_by (peer id)

connection_request_undeliverable is a status signal, not proof of permanent failure: the retry machinery may still deliver the original if the peer comes back online, so a user-initiated resend can duplicate the original request on the recipient's side.

Transport Management

Method Returns Description
getActiveTransports() Promise<TransportType[]> Get list of active transports
enableTransport(type, config?) Promise<void> Enable a transport
disableTransport(type) Promise<void> Disable a transport
forceTransport(type) Promise<void> Force specific transport (override DORS)
releaseTransportLock() Promise<void> Release forced transport, let DORS decide
getTransportMetrics(type) Promise<TransportMetrics | null> Get transport statistics
type TransportType = 'ble' | 'internet' | 'wifiDirect' | 'reticulum' | 'nostr';

interface TransportMetrics {
  packetsSent: number;
  packetsReceived: number;
  bytesSent: number;
  bytesReceived: number;
  errorRate: number;
  avgLatencyMs: number;
}

Bluetooth

Method Returns Description
isBluetoothEnabled() Promise<boolean> Check if Bluetooth is enabled
requestEnableBluetooth() Promise<boolean> Request to enable Bluetooth (Android only)
getBLePeerCount() Promise<number> Get number of discovered BLE peers

Network Topology

Method Returns Description
getTopology() Promise<NetworkTopology> Get network topology snapshot
getMessageStats() Promise<MessageDeliveryStats[]> Get message delivery statistics
getDeliverySuccessRate() Promise<number> Get delivery success rate (0-1)
getMedianLatency() Promise<number | null> Get median latency in ms
getMedianHops() Promise<number | null> Get median hop count

Battery

Method Returns Description
setBatteryLevel(level) Promise<void> Set battery level (0-100) for mesh decisions
getBatteryLevel() Promise<number | null> Get current battery level

Telemetry

Opt-in by key: the SDK collects, batches and uploads accepted events to the Offline Protocol ingest on its own background thread. Nothing reaches JavaScript per event, and the native module owns the lifecycle. What leaves the device is inventoried in docs/telemetry.md; the store disclosures are in docs/privacy.md.

Method Returns Description
enableTelemetry(config) Promise<void> Start with { apiKey, appId, appVersion } from the developer portal; rejects TelemetryConfigInvalid naming a refused field
disableTelemetry() Promise<void> Final flush (up to 3 s), then stop
flushTelemetry() Promise<void> Ask the uploader to send now
telemetryStats() Promise<TelemetryStats | null> Sent, accepted, dropped, buffered, session id, last error
endTelemetrySession() Promise<void> Summary, flush, fresh session id
setTelemetryEnabled(enabled) Promise<void> Runtime opt-out for a user setting; queued batches still drain
telemetryInstallId() Promise<string | null> The opaque per-install id, stamped on batches only with includeDeviceId

DORS Configuration

Method Returns Description
updateDorsConfig(config) Promise<void> Update DORS settings at runtime
getDorsConfig() Promise<DorsConfig> Get current DORS configuration
shouldEscalateToWifi() Promise<boolean> Check if DORS recommends WiFi escalation

Reliability Configuration

Method Returns Description
updateAckConfig(config) Promise<void> Update ACK settings
updateRetryConfig(config) Promise<void> Update retry settings
updateDedupConfig(config) Promise<void> Update deduplication settings
getDedupStats() Promise<DedupStats> Get deduplication statistics
getPendingAckCount() Promise<number> Get pending ACK count
getRetryQueueSize() Promise<number> Get retry queue size

Event Listeners

Method Returns Description
on(eventType, listener) this Register event listener
off(eventType, listener) this Remove event listener
once(eventType, listener) this Register one-time listener
removeAllListeners(eventType?) this Remove all listeners

Events

Message Events

message_sent

interface MessageSentEvent {
  type: 'message_sent';
  message_id: string;
  sender: string;
  recipient: string;
  content: string;
  priority: 'low' | 'medium' | 'high' | 'critical';
  requires_ack: boolean;
  timestamp: number;
}

message_received

interface MessageReceivedEvent {
  type: 'message_received';
  message_id: string;
  sender: string;
  recipient: string;
  content: string;
  hop_count: number;
  transport: string;
  timestamp: number;
}

message_delivered

interface MessageDeliveredEvent {
  type: 'message_delivered';
  message_id: string;
  latency_ms: number;
  hop_count: number;
  transport: string;
}

message_failed

interface MessageFailedEvent {
  type: 'message_failed';
  message_id: string;
  reason: string;
  retry_count: number;
}

Network Events

transport_switched

interface TransportSwitchedEvent {
  type: 'transport_switched';
  from: string | null;
  to: string;
  reason: string;
}

neighbor_discovered

interface NeighborDiscoveredEvent {
  type: 'neighbor_discovered';
  peer_id: string;
  transport: string;
  rssi?: number;
}

Identity contract: peer_id is the peer's canonical address (off1…) — the value that peer derived from its own identity key, regardless of which transport discovered them. Use it directly as recipient in sendMessage and sendConnectionRequest; there is no separate transport-level address to resolve. No app chooses its own address, so a peer claiming one can be checked by re-deriving it from the key it presents. Your own is localAddress().

neighbor_lost

interface NeighborLostEvent {
  type: 'neighbor_lost';
  peer_id: string;
}

network_metrics

interface NetworkMetricsEvent {
  type: 'network_metrics';
  neighbor_count: number;
  relay_count: number;
  delivery_ratio: number;
  avg_latency_ms: number;
}

Connection Events

See Connection Requests for the full outcome table.

connection_request_received

Fires on the recipient's side when a connection request arrives.

interface ConnectionRequestReceivedEvent {
  type: 'connection_request_received';
  sender: string;
  sender_name: string;
  timestamp: number;
  key_package?: number[];
  initial_message?: string;
}

connection_request_undeliverable

Fires on the sender's side when a request could not be delivered — the recipient is offline (reason starts with recipient_unreachable) or the retry budget ran out (reason: 'max_retries_exceeded', alongside a generic message_failed). Correlate via the message ID returned by sendConnectionRequest. This is a status signal, not proof of permanent failure.

interface ConnectionRequestUndeliverableEvent {
  type: 'connection_request_undeliverable';
  recipient: string;
  message_id: string;
  reason: string;
}

connection_accepted

interface ConnectionAcceptedEvent {
  type: 'connection_accepted';
  accepted_by: string;
  accepted_by_name: string;
  timestamp: number;
  key_package?: number[];
}

connection_rejected

interface ConnectionRejectedEvent {
  type: 'connection_rejected';
  rejected_by: string;
}

connection_request_cancelled

interface ConnectionRequestCancelledEvent {
  type: 'connection_request_cancelled';
  cancelled_by: string;
}

Presence Events

presence_updated

One unified stream for both presence channels, discriminated by source:

interface PresenceUpdatedEvent {
  type: 'presence_updated';
  peer_id: string;
  status: 'online' | 'away' | 'offline';
  timestamp: number;
  last_seen_ms?: number; // relay-sourced only, when the relay knows it
  source: 'internet' | 'reticulum' | 'peer';
}
  • source: 'internet' — relay-observed presence, produced by the SDK's automatic watch loop, an explicit checkInternetPresence(), or a failed send: when the relay reports a delivery error naming the recipient unreachable, the SDK emits an internet-sourced offline event for that peer (no last_seen_ms) — so headers can see an internet/offline event they never queried for. Carries last_seen_ms when the relay knows it (the relay can legitimately not know — e.g. the peer hasn't connected since the relay last restarted — so render "Last seen" defensively).
  • source: 'reticulum': a gateway's PresenceStatus answer over the Reticulum daemon contract, solicited by the SDK's watchlist or pushed when a watched peer's state changes, and the same offline on a failed send. Same standing and same decay as internet; the two are named apart only so a header can tell which carrier answered.
  • source: 'peer' — a peer-sent self-report (sendPresenceUpdate), arriving over any transport.

A direct-chat header that should behave like classic relay presence ("Online" / "Last seen …") must filter on source === 'internet'; mixing in peer self-reports makes nearby mesh contact flip a header that users read as server-observed reachability.

The SDK never dedupes unchanged statuses: every relay answer re-emits this event, so a manual check on chat open always produces a fresh event to render from. Exceptions: presence for blocked peers and your own user id is suppressed entirely.

Checking presence on demand:

// Chat open / app focus: force parks the query through the socket's
// resume window (up to ~8s) instead of failing fast.
const sent = await protocol.checkInternetPresence(peerId, { force: true });
// The answer arrives as presence_updated with source: 'internet'.

Subscribe to presence_updated before calling — the answer is an event, not the promise value, and events are not replayed.

Migrating from an app-side presence layer: if your app previously wrapped the SDK with its own throttle/dedupe/cache, note that the SDK itself never throttles or dedupes manual checks — every accepted call sends a fresh relay query (only a client-side mirror of the relay's rate budget can defer it, which force waits out). Delete the wrapper, filter on source === 'internet', and use last_seen_ms from the event as the only "last seen" input.

File Events

file_progress

interface FileProgressEvent {
  type: 'file_progress';
  file_id: string;
  chunks_sent: number;
  total_chunks: number;
  percentage: number;
}

file_received

interface FileReceivedEvent {
  type: 'file_received';
  file_id: string;
  file_name: string;
  file_size: number;
  sender: string;
}

Diagnostic Events

interface DiagnosticEvent {
  type: 'diagnostic';
  level: 'info' | 'warning' | 'error';
  message: string;
  context?: Record<string, unknown>;
}

Types

NetworkTopology

interface NetworkTopology {
  timestamp: number;
  /** This device's `off1…` address; empty string before `initializeMls`. */
  local_user_id: string;
  nodes: NetworkNode[];
  links: NetworkLink[];
  stats: NetworkStats;
}

interface NetworkNode {
  user_id: string;
  role: string;  // 'Normal' or 'Relay' from topology API
  connection_count: number;
  battery_level?: number;
  last_seen: number;
  transports: TransportType[];
}

interface NetworkLink {
  from: string;
  to: string;
  quality: number;  // 0.0 - 1.0
  transport: TransportType;
  rssi?: number;
}

interface NetworkStats {
  total_nodes: number;
  relay_nodes: number;  // Count of nodes with 'Relay' role in topology
  total_connections: number;
  avg_link_quality: number;
  network_diameter?: number;
}

MessageDeliveryStats

interface MessageDeliveryStats {
  message_id: string;
  sender: string;
  recipient: string;
  sent_at: number;
  delivered_at?: number;
  hop_count: number;
  transport?: TransportType;
  retry_count: number;
  latency_ms?: number;
}

DedupStats

interface DedupStats {
  totalTracked: number;
  recentTracked: number;
  capacityUsedPercent: number;
  mode: 'HashMap' | 'BloomFilter';
}

FileProgress

interface FileProgress {
  file_id: string;
  file_name: string;
  file_size: number;
  chunks_completed: number;
  total_chunks: number;
  percentage: number;
}

ProtocolState

enum ProtocolState {
  Stopped = 0,
  Running = 1,
  Paused = 2,
}

DORS (Dynamic Offline Relay Switch)

DORS automatically selects the optimal transport based on real-time conditions.

Scoring Factors

Factor Description
Signal Strength RSSI for BLE/WiFi (-50 to -100 dBm)
Proximity Hop count to destination
Bandwidth Transport throughput capability
Congestion Queue depth and backlog
Energy Battery impact of transport
Reliability Historical delivery success rate
Load Current processing capacity

Transport Weights

BLE: Optimized for energy efficiency and mesh scenarios

  • Signal: 30%, Energy: 30%, Congestion: 15%, Proximity: 15%

WiFi Direct: Optimized for high throughput

  • Bandwidth: 35%, Proximity: 20%, Congestion: 20%, Reliability: 15%

Internet: Optimized for server connectivity

  • Bandwidth: 35%, Reliability: 30%, Congestion: 15%, Energy: 10%

Reticulum: Optimized for resilience and long-range fallback

  • Reliability: 30%, Energy: 25%, Proximity: 20%, Congestion: 15%, Signal: 5%, Bandwidth: 5%

Nostr: Optimized for censorship-resistant fallback over WebSocket relays

  • Reliability: 35%, Bandwidth: 20%, Congestion: 20%, Energy: 15%, Load: 10%

Switching Safeguards

Safeguard Default Description
Hysteresis 15 points Minimum score improvement to switch
Cooldown 20 seconds Wait time between switches
Stability Window 8 seconds Transport must be stable before switching

Mesh Networking

Cluster Architecture

Devices organize into clusters (groups of nearby connected peers). Connections between clusters are handled by bridge connections.

Connection Roles:

  • MEMBER - Intra-cluster connection (devices in same neighborhood)
  • BRIDGE - Inter-cluster connection (bridges different neighborhoods)

How It Works

  1. Discovery: Devices broadcast BLE advertisements with mesh metadata (degree, free slots, battery, uptime)
  2. Cluster Detection: Each device computes a cluster signature from connected peer hashes
  3. Connection Decisions: MeshController evaluates candidates - prioritizes bridging different clusters
  4. Rebalancing: Periodically swaps lower-quality peers for better candidates or bridge opportunities
  5. Delivery: Messages sent to connected peers

Connection Budget

  • Default: 4 connections per device
  • Minimum: 1 connection maintained
  • Connections are scored and rebalanced every ~15 seconds
  • Bridge candidates get priority when clusters need unifying

Peer Scoring

Factor Weight Description
RSSI 35% Signal strength to peer
Availability 20% Free connection slots
Uptime 15% How long peer has been active
Battery 15% Peer's battery level
Stability 10% Connection reliability history
Load 5% Current processing load

Bridge Favor: Candidates from different clusters get a score bonus (bridgeFavor: 0.1) to encourage network unification.

Message TTL

  • Default: 8 hops
  • Messages are dropped when TTL reaches 0
  • Prevents infinite message circulation

Group Messaging (MLS-Encrypted)

Create and manage encrypted groups over the mesh:

// Create a group (you become admin automatically)
const group = await protocol.meshCreateGroup('Project Team');

// Invite a member (admin only)
await protocol.meshInviteToGroup(group.groupId, 'bob');

// Send an encrypted group message
await protocol.meshSendGroupMessage(group.groupId, 'Hello team!');

// Remove a member (admin only)
await protocol.meshRemoveFromGroup(group.groupId, 'bob');

// Leave a group
await protocol.meshLeaveGroup(group.groupId);

Group Roles

Groups use role-based access control with two roles: Admin and Member.

Method Description
meshSetMemberRole(groupId, userId, role) Change a member's role ("admin" or "member") — admin only
meshGetMemberRole(groupId, userId) Get a member's current role
meshGetGroupRoles(groupId) Get all roles as { userId: role }
// Promote a member to admin
await protocol.meshSetMemberRole(groupId, 'bob', 'admin');

// Check a member's role
const role = await protocol.meshGetMemberRole(groupId, 'bob'); // "admin"

// Get all roles
const roles = await protocol.meshGetGroupRoles(groupId);
// { alice: "admin", bob: "admin", charlie: "member" }

// Listen for role changes
protocol.on('group_role_changed', (event) => {
  console.log(`${event.user_id} is now ${event.new_role}`);
});

Security invariants:

  • The group creator is automatically Admin
  • Only admins can invite, remove, or change roles
  • The last admin cannot be demoted, removed, or leave (prevents orphaned groups)
  • If the last admin disconnects unexpectedly, a deterministic election promotes the next admin

Reliability Layer

Acknowledgments

  • Messages require ACK for delivery confirmation
  • Default timeout: 5 seconds
  • message_delivered event fires on ACK receipt

Retry Queue

  • Failed messages are retried with exponential backoff
  • Initial delay: 1 second
  • Maximum delay: 30 seconds
  • Maximum retries: 5

Deduplication

Prevents duplicate message processing:

  • Bloom Filter Mode: Space-efficient, ~1% false positive rate
  • HashMap Mode: Exact tracking, configurable capacity

File Transfer

Sending Files

const fileId = await protocol.sendFile({
  filePath: '/path/to/file.pdf',
  recipient: 'user456',
  fileName: 'document.pdf',  // optional
});

protocol.on('file_progress', (event) => {
  console.log(`${event.percentage}% complete`);
});

Managing Transfers

const progress = await protocol.getFileProgress(fileId);
await protocol.cancelFileTransfer(fileId);

Troubleshooting

Messages Not Delivering

  1. Verify both devices have protocol started
  2. Check they're within BLE range (~10-30m)
  3. Ensure TTL is sufficient for network size
  4. Monitor message_failed events for retry information

No Peers Discovered

  1. Verify Bluetooth is enabled: await protocol.isBluetoothEnabled()
  2. Check permissions are granted
  3. Ensure background modes enabled (iOS)
  4. Verify devices are within range

Frequent Disconnections

  1. Check signal strength via neighbor_discovered RSSI
  2. Increase stabilityWindowSecs in DORS config
  3. Reduce rebalanceInterval frequency
  4. Check for BLE interference

High Battery Drain

  1. Reduce connection count (native mesh config)
  2. Verify DORS is selecting BLE over WiFi Direct
  3. Check for excessive retry activity
  4. Use setBatteryLevel() to inform mesh decisions

Transport Not Switching

  1. Verify transport is enabled in config
  2. Check hysteresis threshold isn't too high
  3. Ensure cooldown period has elapsed
  4. Use forceTransport() to test manually

Linking Error

If you see the linking error message:

  1. Remove any leftover manual pod 'MeshSdk', ... line from your ios/Podfile. Autolinking supplies the pod as of 0.20.0; a line pointing into the package's ios/ directory fails pod install outright ("no podspec found"), and one pointing elsewhere can shadow the autolinked pod.
  2. Confirm pod install output lists MeshSdk under "Auto-linking React Native modules". If it doesn't, npx react-native config should show a platforms.ios.podspecPath ending in MeshSdk.podspec for @offline-protocol/mesh-sdk.
  3. Run pod install (iOS)
  4. Rebuild the app after installing — a JS-only reload will not pick up native changes
  5. Verify not using Expo Go (native modules required)

iOS Simulator Build Fails to Link

Simulator builds are fully supported from 0.20.0 on. Undefined symbols or building for iOS Simulator, but linking in object file built for iOS means something is still linking the SDK's binary by hand. Remove any post_install hook that manipulates OTHER_LDFLAGS or LIBRARY_SEARCH_PATHS for MeshSdk (including flags naming offline_protocol_uniffi_sim/_device — those archives no longer exist), then pod install again. CocoaPods selects the correct XCFramework slice on its own.


License

Copyright © 2025-2026 Offline Protocol, Inc.

The Offline Protocol SDK is dual-licensed:

  • AGPL-3.0-only — see LICENSE. Free for use in projects that comply with AGPL-3.0, including its network-use source-disclosure requirement (section 13).
  • Commercial License — for proprietary apps that cannot or do not wish to comply with the AGPL. See LICENSE-COMMERCIAL.md (contact legal@offlineprotocol.com).

You may use the SDK under either license; you do not need both.