react-native-ble-mesh 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/OPTIMIZATION.md +165 -52
- package/package.json +1 -1
- package/src/MeshNetwork.js +16 -8
- package/src/crypto/AutoCrypto.js +14 -3
- package/src/crypto/providers/ExpoCryptoProvider.js +3 -2
- package/src/crypto/providers/QuickCryptoProvider.js +30 -6
- package/src/crypto/providers/TweetNaClProvider.js +1 -1
- package/src/hooks/AppStateManager.js +9 -1
- package/src/hooks/useMesh.js +17 -4
- package/src/hooks/useMessages.js +4 -4
- package/src/hooks/usePeers.js +13 -9
- package/src/mesh/dedup/BloomFilter.js +44 -57
- package/src/mesh/dedup/DedupManager.js +32 -1
- package/src/mesh/fragment/Fragmenter.js +1 -1
- package/src/mesh/monitor/ConnectionQuality.js +42 -17
- package/src/mesh/monitor/NetworkMonitor.js +58 -13
- package/src/mesh/peer/Peer.js +5 -2
- package/src/mesh/peer/PeerManager.js +15 -3
- package/src/mesh/router/MessageRouter.js +14 -6
- package/src/mesh/router/RouteTable.js +17 -7
- package/src/mesh/store/StoreAndForwardManager.js +13 -2
- package/src/protocol/deserializer.js +9 -10
- package/src/protocol/header.js +13 -7
- package/src/protocol/message.js +15 -3
- package/src/protocol/serializer.js +7 -10
- package/src/protocol/validator.js +23 -5
- package/src/service/BatteryOptimizer.js +4 -1
- package/src/service/HandshakeManager.js +12 -16
- package/src/service/SessionManager.js +12 -10
- package/src/service/text/TextManager.js +21 -15
- package/src/storage/MessageStore.js +55 -2
- package/src/transport/BLETransport.js +11 -2
- package/src/transport/MultiTransport.js +35 -10
- package/src/transport/WiFiDirectTransport.js +4 -3
- package/src/utils/EventEmitter.js +6 -9
- package/src/utils/bytes.js +12 -10
- package/src/utils/compression.js +5 -3
- package/src/utils/encoding.js +33 -8
package/README.md
CHANGED
|
@@ -558,7 +558,7 @@ react-native-ble-mesh/
|
|
|
558
558
|
│ └── hooks/ # React hooks
|
|
559
559
|
├── docs/ # Guides & specs
|
|
560
560
|
├── app.plugin.js # Expo plugin entry
|
|
561
|
-
└── __tests__/ #
|
|
561
|
+
└── __tests__/ # 433 tests, 0 failures ✅
|
|
562
562
|
```
|
|
563
563
|
|
|
564
564
|
---
|
|
@@ -579,7 +579,7 @@ react-native-ble-mesh/
|
|
|
579
579
|
## Testing
|
|
580
580
|
|
|
581
581
|
```bash
|
|
582
|
-
npm test # Run all
|
|
582
|
+
npm test # Run all 433 tests
|
|
583
583
|
npm run test:coverage # With coverage report
|
|
584
584
|
```
|
|
585
585
|
|
package/docs/OPTIMIZATION.md
CHANGED
|
@@ -1,70 +1,183 @@
|
|
|
1
|
-
# Optimization
|
|
1
|
+
# Performance Optimization Guide
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## v2.1.0 Performance Overhaul
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
The pure JavaScript cryptographic implementations (`src/crypto/`) have been **removed entirely**. This includes:
|
|
7
|
-
- X25519 key exchange (pure JS BigInt — extremely slow)
|
|
8
|
-
- ChaCha20-Poly1305 AEAD encryption
|
|
9
|
-
- SHA-256 hashing
|
|
10
|
-
- HMAC-SHA256
|
|
11
|
-
- HKDF key derivation
|
|
12
|
-
- Noise Protocol XX handshake
|
|
5
|
+
Version 2.1.0 is a comprehensive performance optimization release targeting React Native speed, reduced GC pressure, and elimination of memory leaks. All changes are non-breaking.
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
7
|
+
### Key Principles Applied
|
|
8
|
+
|
|
9
|
+
1. **Zero-copy where possible** — Use `Uint8Array.subarray()` instead of `slice()` for read-only views
|
|
10
|
+
2. **Cache singletons** — `TextEncoder`, `TextDecoder`, crypto providers, hex lookup tables
|
|
11
|
+
3. **Avoid allocation in hot paths** — Inline computations, reuse buffers, pre-compute constants
|
|
12
|
+
4. **O(1) over O(n)** — Circular buffers, running sums, pre-computed Sets
|
|
13
|
+
5. **Clean up resources** — Clear timers, remove event listeners, bound map growth
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Hot Path Optimizations
|
|
18
|
+
|
|
19
|
+
### Message Processing Pipeline
|
|
20
|
+
|
|
21
|
+
Every message flows through: transport → deserialize → dedup → route → serialize → transport. Each stage was optimized:
|
|
22
|
+
|
|
23
|
+
| Stage | Before | After | Improvement |
|
|
24
|
+
|-------|--------|-------|-------------|
|
|
25
|
+
| Deserialize header | 3x `slice()` copies | 0 copies (`subarray()`) | ~3x fewer allocations |
|
|
26
|
+
| Deserialize payload | 1x `slice()` copy | 0 copies (`subarray()`) | Zero-copy payload |
|
|
27
|
+
| CRC32 checksum | `slice(0, 44)` copy | `subarray(0, 44)` view | Zero-copy |
|
|
28
|
+
| BloomFilter check | `new TextEncoder()` + array alloc | Cached encoder, inlined positions | 3 fewer allocations/msg |
|
|
29
|
+
| BloomFilter fill ratio | O(n) bit scan | O(1) running counter | Constant time |
|
|
30
|
+
| Serialize header | `slice(0, 44)` for CRC | `subarray(0, 44)` view | Zero-copy |
|
|
31
|
+
| Hex conversion | `Array.from().map().join()` | Pre-computed lookup table | ~5x faster |
|
|
32
|
+
| UUID generation | 16 temp strings + join | Hex table + concatenation | ~3x faster |
|
|
33
|
+
| Encrypt/Decrypt nonce | `new Uint8Array(24)` per call | Pre-allocated per session | 0 allocations |
|
|
34
|
+
|
|
35
|
+
### TextEncoder/TextDecoder Caching
|
|
36
|
+
|
|
37
|
+
Before v2.1.0, `new TextEncoder()` was called on every:
|
|
38
|
+
- Message validation (byte length check)
|
|
39
|
+
- Message encoding (broadcast, private, channel)
|
|
40
|
+
- BloomFilter dedup check (string → bytes)
|
|
41
|
+
- File chunk encoding (per-chunk in loop!)
|
|
42
|
+
- Protocol serialization (string payloads)
|
|
43
|
+
- Read receipt handling (per-receipt in loop)
|
|
44
|
+
|
|
45
|
+
Now: **One singleton per module**, created once at import time.
|
|
46
|
+
|
|
47
|
+
### Crypto Provider Caching
|
|
48
|
+
|
|
49
|
+
- `AutoCrypto.detectProvider()` — Now caches the singleton result
|
|
50
|
+
- `QuickCryptoProvider` — Caches `require('tweetnacl')` result instead of calling per encrypt/decrypt
|
|
51
|
+
- DER header buffers for X25519 — Hoisted to module level (were re-created from hex per handshake)
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Memory Leak Fixes
|
|
56
|
+
|
|
57
|
+
### Timer Leaks
|
|
58
|
+
|
|
59
|
+
| Location | Issue | Fix |
|
|
60
|
+
|----------|-------|-----|
|
|
61
|
+
| `MeshNetwork.sendFile()` | Per-chunk timeout timers never cleared on success | `clearTimeout()` in both success/error paths |
|
|
62
|
+
| `DedupManager._resetBloomFilter()` | Grace period timer leaked on repeated resets | Store timer ID, clear on reset/destroy |
|
|
63
|
+
|
|
64
|
+
### Event Listener Leaks
|
|
65
|
+
|
|
66
|
+
| Location | Issue | Fix |
|
|
67
|
+
|----------|-------|-----|
|
|
68
|
+
| `MultiTransport._wireTransport()` | Handlers never removed across start/stop | Store references, remove in `stop()` |
|
|
69
|
+
| `useMesh.js` initialize | Listeners stacked on re-init | Store in refs, remove old before adding new |
|
|
70
|
+
| `AppStateManager` | `.bind()` per initialize, old subscription not removed | Bind once in constructor, remove old sub |
|
|
71
|
+
|
|
72
|
+
### Unbounded Growth
|
|
73
|
+
|
|
74
|
+
| Location | Issue | Fix |
|
|
75
|
+
|----------|-------|-----|
|
|
76
|
+
| `NetworkMonitor._pendingMessages` | Undelivered messages never removed | Cleanup entries older than `nodeTimeoutMs` |
|
|
77
|
+
| `ConnectionQuality._peers` | Timer runs with 0 peers | Stop timer when last peer removed |
|
|
78
|
+
| `BLETransport.disconnectFromPeer()` | Write queue/writing maps not cleaned | Added cleanup in disconnect |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Data Structure Improvements
|
|
83
|
+
|
|
84
|
+
### Circular Buffers (O(n) → O(1))
|
|
85
|
+
|
|
86
|
+
`Array.shift()` is O(n) because it re-indexes all elements. For sliding windows that shift on every sample, this was a significant cost.
|
|
87
|
+
|
|
88
|
+
**Replaced with circular buffers:**
|
|
89
|
+
- `NetworkMonitor._latencies` → `Float64Array` ring buffer with running sum
|
|
90
|
+
- `ConnectionQuality._rssiSamples` → `Float64Array` ring buffer with running sum
|
|
91
|
+
- `ConnectionQuality._latencySamples` → `Float64Array` ring buffer with running sum
|
|
18
92
|
|
|
19
|
-
|
|
20
|
-
- [`tweetnacl`](https://www.npmjs.com/package/tweetnacl) — Lightweight, audited, works everywhere (recommended)
|
|
21
|
-
- [`libsodium-wrappers`](https://www.npmjs.com/package/libsodium-wrappers) — Full-featured, WASM-based
|
|
22
|
-
- [`react-native-quick-crypto`](https://www.npmjs.com/package/react-native-quick-crypto) — Native crypto for RN (fastest)
|
|
93
|
+
Average computation is now O(1) via running sum instead of O(n) `reduce()`.
|
|
23
94
|
|
|
24
|
-
|
|
95
|
+
### Pre-Computed Sets (O(n) → O(1))
|
|
25
96
|
|
|
26
|
-
|
|
97
|
+
`Object.values(ENUM).includes(value)` creates an array and does linear scan on every call.
|
|
27
98
|
|
|
28
|
-
|
|
99
|
+
**Replaced with module-level `Set`s:**
|
|
100
|
+
- `Peer.setConnectionState()` — `CONNECTION_STATE_SET.has(state)`
|
|
101
|
+
- `BatteryOptimizer.setMode()` — `BATTERY_MODE_SET.has(mode)`
|
|
29
102
|
|
|
30
|
-
|
|
103
|
+
### Direct Map Iteration
|
|
31
104
|
|
|
32
|
-
|
|
105
|
+
`Array.from(map.values()).filter(...)` creates two arrays. Direct iteration creates one:
|
|
33
106
|
|
|
34
|
-
|
|
107
|
+
```js
|
|
108
|
+
// Before (2 arrays)
|
|
109
|
+
getConnectedPeers() { return this.getAllPeers().filter(p => p.isConnected()); }
|
|
35
110
|
|
|
36
|
-
|
|
111
|
+
// After (1 array)
|
|
112
|
+
getConnectedPeers() {
|
|
113
|
+
const result = [];
|
|
114
|
+
for (const peer of this._peers.values()) {
|
|
115
|
+
if (peer.isConnected()) result.push(peer);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
37
120
|
|
|
38
|
-
|
|
121
|
+
Applied to: `PeerManager.getConnectedPeers()`, `getSecuredPeers()`, `getDirectPeers()`
|
|
39
122
|
|
|
40
|
-
|
|
41
|
-
- **Added new test suites:**
|
|
42
|
-
- `__tests__/transport/BLETransport.test.js` — Lifecycle, scanning, connections, broadcast, timeout handling
|
|
43
|
-
- `__tests__/transport/MockTransport.test.js` — Linking, message passing, peer simulation
|
|
44
|
-
- `__tests__/mesh/MeshNetwork.unit.test.js` — Config merging, validation, lifecycle, restart
|
|
45
|
-
- `__tests__/service/BatteryOptimizer.test.js` — Mode switching, battery levels, cleanup
|
|
46
|
-
- `__tests__/service/MeshService.test.js` — Full lifecycle, identity, peers, messaging
|
|
47
|
-
- `__tests__/platform/ios.test.js` — Background mode, MTU fragmentation, state restoration
|
|
48
|
-
- `__tests__/platform/android.test.js` — Permissions, MTU (23/512), Doze mode, memory pressure, BloomFilter FP rate
|
|
123
|
+
---
|
|
49
124
|
|
|
50
|
-
|
|
125
|
+
## React Native Hook Optimizations
|
|
51
126
|
|
|
52
|
-
|
|
53
|
-
- BLE background mode behavior tested
|
|
54
|
-
- MTU 185 (BLE 4.2+) fragmentation verified
|
|
55
|
-
- Battery optimizer integration tested
|
|
56
|
-
- Store-and-forward for state restoration
|
|
127
|
+
### usePeers — Eliminated Double Re-render
|
|
57
128
|
|
|
58
|
-
|
|
59
|
-
- BLE permission denial handled gracefully
|
|
60
|
-
- MTU 23 (BLE 4.0) and 512 (BLE 5.0) fragmentation tested
|
|
61
|
-
- Doze mode with low-power settings verified
|
|
62
|
-
- LRU cache respects size limits under memory pressure
|
|
63
|
-
- BloomFilter false positive rate verified (<20% at reasonable capacity)
|
|
129
|
+
Before: Every peer event called both `setPeers()` and `setLastUpdate(Date.now())`, causing two re-renders (React may batch, but not always across async boundaries).
|
|
64
130
|
|
|
65
|
-
|
|
131
|
+
After: `lastUpdate` is a `useRef` (no re-render). Added shallow comparison to skip `setPeers()` when peers haven't actually changed.
|
|
132
|
+
|
|
133
|
+
Also: `getPeer()` now uses a `useMemo` Map for O(1) lookup instead of O(n) `Array.find()`.
|
|
134
|
+
|
|
135
|
+
### useMessages — Reduced Array Copies
|
|
136
|
+
|
|
137
|
+
Before: 3 array copies per incoming message when over `maxMessages` (`[msg, ...prev]` + `slice(maxMessages)` + `slice(0, maxMessages)`).
|
|
138
|
+
|
|
139
|
+
After: 1 array copy + in-place truncation via `updated.length = maxMessages`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Storage Optimization
|
|
144
|
+
|
|
145
|
+
### MessageStore Payload Encoding
|
|
146
|
+
|
|
147
|
+
Before: `Array.from(Uint8Array)` converted each byte to a boxed JS Number — **8x memory bloat** (1 byte → 8 bytes for Number object + array overhead).
|
|
148
|
+
|
|
149
|
+
After: Base64 encoding. A 10KB payload uses ~13.3KB as base64 string (1.33x) instead of ~80KB as Number array (8x).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Compression Optimization
|
|
154
|
+
|
|
155
|
+
### LZ4 Hash Table Reuse
|
|
156
|
+
|
|
157
|
+
Before: `new Int32Array(4096)` (16KB allocation) on every `_lz4Compress()` call.
|
|
158
|
+
|
|
159
|
+
After: Pre-allocated in constructor, reused with `.fill(-1)` reset per call.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## WiFi Direct Base64 Fix
|
|
164
|
+
|
|
165
|
+
### O(n^2) → O(n) String Building
|
|
166
|
+
|
|
167
|
+
Before: Character-by-character `binary += String.fromCharCode(bytes[i])` — O(n^2) due to string immutability.
|
|
168
|
+
|
|
169
|
+
After: Chunk-based `String.fromCharCode.apply(null, bytes.subarray(i, i+8192))` with final `join()` — O(n).
|
|
170
|
+
|
|
171
|
+
For a 1MB file transfer, this eliminates ~1 million intermediate string allocations.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Historical Changes (v2.0.0)
|
|
176
|
+
|
|
177
|
+
### Crypto Module Removed
|
|
178
|
+
The pure JavaScript cryptographic implementations (`src/crypto/`) were removed in v2.0.0. Pure JS BigInt field arithmetic for X25519 was orders of magnitude slower than native:
|
|
179
|
+
- ~100ms+ per key exchange (vs ~1ms with native)
|
|
180
|
+
- Battery drain from CPU-intensive crypto
|
|
181
|
+
- UI thread blocking on Hermes/JSC
|
|
66
182
|
|
|
67
|
-
|
|
68
|
-
2. **Consider TypeScript migration** — current JS codebase with JSDoc is good but TS would catch more errors
|
|
69
|
-
3. **Add integration tests with real BLE** — current tests use MockTransport; consider Detox/Appium for device testing
|
|
70
|
-
4. **Publish to npm** with proper semver (this is a breaking change → v2.0.0)
|
|
183
|
+
Replaced by the pluggable provider system: `tweetnacl`, `react-native-quick-crypto`, or `expo-crypto`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-ble-mesh",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "React Native Bluetooth Low Energy (BLE) mesh networking library with end-to-end encryption, offline messaging, peer-to-peer communication, and Noise Protocol security for iOS and Android",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
package/src/MeshNetwork.js
CHANGED
|
@@ -23,6 +23,11 @@ const { ValidationError, MeshError } = require('./errors');
|
|
|
23
23
|
const { FileManager } = require('./service/file');
|
|
24
24
|
const { SERVICE_STATE, EVENTS } = require('./constants');
|
|
25
25
|
|
|
26
|
+
/** @private Cached TextEncoder singleton – avoids per-call allocation */
|
|
27
|
+
const _encoder = new TextEncoder();
|
|
28
|
+
/** @private Cached TextDecoder singleton – avoids per-call allocation */
|
|
29
|
+
const _decoder = new TextDecoder();
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* Default MeshNetwork configuration
|
|
28
33
|
* @constant {Object}
|
|
@@ -571,13 +576,14 @@ class MeshNetwork extends EventEmitter {
|
|
|
571
576
|
|
|
572
577
|
// Send offer (JSON is OK for metadata, but use binary type marker)
|
|
573
578
|
const offerJson = JSON.stringify(transfer.offer);
|
|
574
|
-
const offerBytes =
|
|
579
|
+
const offerBytes = _encoder.encode(offerJson);
|
|
575
580
|
const offerPayload = new Uint8Array(1 + offerBytes.length);
|
|
576
581
|
offerPayload[0] = 0x01; // Binary type marker for OFFER
|
|
577
582
|
offerPayload.set(offerBytes, 1);
|
|
578
583
|
await this._service._sendRaw(peerId, offerPayload);
|
|
579
584
|
|
|
580
585
|
// Send chunks sequentially using binary protocol
|
|
586
|
+
const transferIdBytes = _encoder.encode(transfer.id);
|
|
581
587
|
for (let i = 0; i < transfer.chunks.length; i++) {
|
|
582
588
|
// Check if still running (handles app backgrounding)
|
|
583
589
|
if (this._state !== 'running') {
|
|
@@ -588,7 +594,6 @@ class MeshNetwork extends EventEmitter {
|
|
|
588
594
|
const chunk = transfer.chunks[i];
|
|
589
595
|
|
|
590
596
|
// Binary format: [type(1)] [transferIdLen(1)] [transferId(N)] [index(2)] [data(M)]
|
|
591
|
-
const transferIdBytes = new TextEncoder().encode(transfer.id);
|
|
592
597
|
const header = new Uint8Array(1 + 1 + transferIdBytes.length + 2);
|
|
593
598
|
let offset = 0;
|
|
594
599
|
header[offset++] = 0x02; // Binary type marker for CHUNK
|
|
@@ -605,13 +610,16 @@ class MeshNetwork extends EventEmitter {
|
|
|
605
610
|
|
|
606
611
|
// Add per-chunk timeout (10 seconds)
|
|
607
612
|
const sendPromise = this._service._sendRaw(peerId, payload);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
613
|
+
let timeoutId;
|
|
614
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
615
|
+
timeoutId = setTimeout(() => reject(new Error('Chunk send timeout')), 10000);
|
|
616
|
+
});
|
|
611
617
|
|
|
612
618
|
try {
|
|
613
619
|
await Promise.race([sendPromise, timeoutPromise]);
|
|
620
|
+
clearTimeout(timeoutId);
|
|
614
621
|
} catch (err) {
|
|
622
|
+
clearTimeout(timeoutId);
|
|
615
623
|
this._fileManager.cancelTransfer(transfer.id);
|
|
616
624
|
this.emit('fileTransferFailed', {
|
|
617
625
|
id: transfer.id, name: fileInfo.name, error: err.message
|
|
@@ -818,7 +826,7 @@ class MeshNetwork extends EventEmitter {
|
|
|
818
826
|
const sendFn = async (payload) => {
|
|
819
827
|
// Re-encrypt and send via the proper encrypted channel
|
|
820
828
|
try {
|
|
821
|
-
const text =
|
|
829
|
+
const text = _decoder.decode(payload);
|
|
822
830
|
await this._service.sendPrivateMessage(peerId, text);
|
|
823
831
|
} catch (e) {
|
|
824
832
|
// Fallback to raw send if encryption fails
|
|
@@ -892,7 +900,7 @@ class MeshNetwork extends EventEmitter {
|
|
|
892
900
|
* @private
|
|
893
901
|
*/
|
|
894
902
|
_encodeMessage(text) {
|
|
895
|
-
return
|
|
903
|
+
return _encoder.encode(text);
|
|
896
904
|
}
|
|
897
905
|
|
|
898
906
|
/**
|
|
@@ -936,7 +944,7 @@ class MeshNetwork extends EventEmitter {
|
|
|
936
944
|
}
|
|
937
945
|
|
|
938
946
|
// Check size limit (UTF-8 encoded)
|
|
939
|
-
const byteLength =
|
|
947
|
+
const byteLength = _encoder.encode(text).length;
|
|
940
948
|
if (byteLength > MeshNetwork.MAX_MESSAGE_SIZE) {
|
|
941
949
|
throw ValidationError.outOfRange('text', byteLength, {
|
|
942
950
|
min: 1,
|
package/src/crypto/AutoCrypto.js
CHANGED
|
@@ -14,25 +14,36 @@ const QuickCryptoProvider = require('./providers/QuickCryptoProvider');
|
|
|
14
14
|
const ExpoCryptoProvider = require('./providers/ExpoCryptoProvider');
|
|
15
15
|
const TweetNaClProvider = require('./providers/TweetNaClProvider');
|
|
16
16
|
|
|
17
|
+
/** @type {import('./CryptoProvider')|null} Cached singleton provider */
|
|
18
|
+
let _cachedProvider = null;
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* Detects and returns the best available crypto provider.
|
|
22
|
+
* The result is cached as a singleton for subsequent calls.
|
|
19
23
|
* @returns {import('./CryptoProvider')} Best available provider
|
|
20
24
|
* @throws {Error} If no crypto provider is available
|
|
21
25
|
*/
|
|
22
26
|
function detectProvider() {
|
|
27
|
+
if (_cachedProvider) {
|
|
28
|
+
return _cachedProvider;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
// 1. Native speed (react-native-quick-crypto)
|
|
24
32
|
if (QuickCryptoProvider.isAvailable()) {
|
|
25
|
-
|
|
33
|
+
_cachedProvider = new QuickCryptoProvider();
|
|
34
|
+
return _cachedProvider;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
// 2. Expo (expo-crypto + tweetnacl)
|
|
29
38
|
if (ExpoCryptoProvider.isAvailable()) {
|
|
30
|
-
|
|
39
|
+
_cachedProvider = new ExpoCryptoProvider();
|
|
40
|
+
return _cachedProvider;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
// 3. Universal (tweetnacl)
|
|
34
44
|
if (TweetNaClProvider.isAvailable()) {
|
|
35
|
-
|
|
45
|
+
_cachedProvider = new TweetNaClProvider();
|
|
46
|
+
return _cachedProvider;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
throw new Error(
|
|
@@ -98,14 +98,15 @@ class ExpoCryptoProvider extends CryptoProvider {
|
|
|
98
98
|
hash(data) {
|
|
99
99
|
// expo-crypto's digestStringAsync is async — for sync compat, use tweetnacl
|
|
100
100
|
const nacl = this._getNacl();
|
|
101
|
-
return nacl.hash(data).
|
|
101
|
+
return nacl.hash(data).subarray(0, 32);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** @inheritdoc */
|
|
105
105
|
randomBytes(length) {
|
|
106
106
|
const expoCrypto = this._getExpoCrypto();
|
|
107
107
|
if (expoCrypto.getRandomBytes) {
|
|
108
|
-
|
|
108
|
+
const bytes = expoCrypto.getRandomBytes(length);
|
|
109
|
+
return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
109
110
|
}
|
|
110
111
|
// Fallback to tweetnacl
|
|
111
112
|
const nacl = this._getNacl();
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
const CryptoProvider = require('../CryptoProvider');
|
|
12
12
|
|
|
13
|
+
/** DER header for PKCS8 private key wrapping (X25519) */
|
|
14
|
+
const PKCS8_HEADER = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
15
|
+
/** DER header for SPKI public key wrapping (X25519) */
|
|
16
|
+
const SPKI_HEADER = Buffer.from('302a300506032b656e032100', 'hex');
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Crypto provider using react-native-quick-crypto.
|
|
15
20
|
* Provides native-speed crypto on React Native (JSI binding).
|
|
@@ -21,6 +26,7 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
21
26
|
constructor(options = {}) {
|
|
22
27
|
super();
|
|
23
28
|
this._crypto = options.crypto || null;
|
|
29
|
+
this._nacl = null;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
get name() {
|
|
@@ -55,7 +61,7 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
55
61
|
const crypto = this._getCrypto();
|
|
56
62
|
const privKey = crypto.createPrivateKey({
|
|
57
63
|
key: Buffer.concat([
|
|
58
|
-
|
|
64
|
+
PKCS8_HEADER,
|
|
59
65
|
Buffer.from(secretKey)
|
|
60
66
|
]),
|
|
61
67
|
format: 'der',
|
|
@@ -63,7 +69,7 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
63
69
|
});
|
|
64
70
|
const pubKey = crypto.createPublicKey({
|
|
65
71
|
key: Buffer.concat([
|
|
66
|
-
|
|
72
|
+
SPKI_HEADER,
|
|
67
73
|
Buffer.from(publicKey)
|
|
68
74
|
]),
|
|
69
75
|
format: 'der',
|
|
@@ -73,11 +79,29 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
73
79
|
return new Uint8Array(shared);
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Lazily loads tweetnacl (cached)
|
|
84
|
+
* @returns {Object} nacl module
|
|
85
|
+
* @private
|
|
86
|
+
*/
|
|
87
|
+
_getNacl() {
|
|
88
|
+
if (!this._nacl) {
|
|
89
|
+
try {
|
|
90
|
+
this._nacl = require('tweetnacl');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'tweetnacl is required for QuickCryptoProvider encrypt/decrypt/hash. Install: npm install tweetnacl'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return this._nacl;
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
/** @inheritdoc */
|
|
77
101
|
encrypt(key, nonce, plaintext, _ad) {
|
|
78
102
|
// Use tweetnacl for encryption to ensure cross-provider compatibility
|
|
79
103
|
// QuickCrypto's advantage is in fast native key generation (X25519), not AEAD
|
|
80
|
-
const nacl =
|
|
104
|
+
const nacl = this._getNacl();
|
|
81
105
|
|
|
82
106
|
// Ensure 24-byte nonce for XSalsa20-Poly1305
|
|
83
107
|
let fullNonce = nonce;
|
|
@@ -91,7 +115,7 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
91
115
|
|
|
92
116
|
/** @inheritdoc */
|
|
93
117
|
decrypt(key, nonce, ciphertext, _ad) {
|
|
94
|
-
const nacl =
|
|
118
|
+
const nacl = this._getNacl();
|
|
95
119
|
|
|
96
120
|
// Ensure 24-byte nonce for XSalsa20-Poly1305
|
|
97
121
|
let fullNonce = nonce;
|
|
@@ -110,9 +134,9 @@ class QuickCryptoProvider extends CryptoProvider {
|
|
|
110
134
|
/** @inheritdoc */
|
|
111
135
|
hash(data) {
|
|
112
136
|
// Use SHA-512 truncated to 32 bytes for cross-provider compatibility
|
|
113
|
-
const nacl =
|
|
137
|
+
const nacl = this._getNacl();
|
|
114
138
|
const full = nacl.hash(data); // SHA-512
|
|
115
|
-
return full.
|
|
139
|
+
return full.subarray(0, 32);
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
/** @inheritdoc */
|
|
@@ -98,7 +98,7 @@ class TweetNaClProvider extends CryptoProvider {
|
|
|
98
98
|
const nacl = this._getNacl();
|
|
99
99
|
// tweetnacl provides SHA-512; we return first 32 bytes for SHA-256 compatibility
|
|
100
100
|
const full = nacl.hash(data);
|
|
101
|
-
return full.
|
|
101
|
+
return full.subarray(0, 32);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** @inheritdoc */
|
|
@@ -45,6 +45,8 @@ class AppStateManager {
|
|
|
45
45
|
this._initialized = false;
|
|
46
46
|
/** @private */
|
|
47
47
|
this._AppState = null;
|
|
48
|
+
/** @private */
|
|
49
|
+
this._boundHandleStateChange = this._handleStateChange.bind(this);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -64,9 +66,15 @@ class AppStateManager {
|
|
|
64
66
|
return false;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// Remove old subscription before creating new one
|
|
70
|
+
if (this._subscription) {
|
|
71
|
+
this._subscription.remove();
|
|
72
|
+
this._subscription = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
this._subscription = this._AppState.addEventListener(
|
|
68
76
|
'change',
|
|
69
|
-
this.
|
|
77
|
+
this._boundHandleStateChange
|
|
70
78
|
);
|
|
71
79
|
|
|
72
80
|
this._initialized = true;
|
package/src/hooks/useMesh.js
CHANGED
|
@@ -47,6 +47,8 @@ function useMesh(config = {}) {
|
|
|
47
47
|
// Create mesh instance ref (persists across renders)
|
|
48
48
|
const meshRef = useRef(null);
|
|
49
49
|
const mountedRef = useRef(true);
|
|
50
|
+
const stateHandlerRef = useRef(null);
|
|
51
|
+
const errorHandlerRef = useRef(null);
|
|
50
52
|
|
|
51
53
|
// State
|
|
52
54
|
const [state, setState] = useState('uninitialized');
|
|
@@ -71,18 +73,29 @@ function useMesh(config = {}) {
|
|
|
71
73
|
|
|
72
74
|
const mesh = getMesh();
|
|
73
75
|
|
|
76
|
+
// Remove old listeners if they exist (prevents accumulation on re-init)
|
|
77
|
+
if (stateHandlerRef.current) {
|
|
78
|
+
mesh.off('state-changed', stateHandlerRef.current);
|
|
79
|
+
}
|
|
80
|
+
if (errorHandlerRef.current) {
|
|
81
|
+
mesh.off('error', errorHandlerRef.current);
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
// Setup state change listener
|
|
75
|
-
|
|
85
|
+
stateHandlerRef.current = ({ newState }) => {
|
|
76
86
|
if (mountedRef.current) {
|
|
77
87
|
setState(newState);
|
|
78
88
|
}
|
|
79
|
-
}
|
|
89
|
+
};
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
errorHandlerRef.current = (err) => {
|
|
82
92
|
if (mountedRef.current) {
|
|
83
93
|
setError(err);
|
|
84
94
|
}
|
|
85
|
-
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
mesh.on('state-changed', stateHandlerRef.current);
|
|
98
|
+
mesh.on('error', errorHandlerRef.current);
|
|
86
99
|
|
|
87
100
|
// Initialize with storage
|
|
88
101
|
await mesh.initialize({
|
package/src/hooks/useMessages.js
CHANGED
|
@@ -58,11 +58,11 @@ function useMessages(mesh, options = {}) {
|
|
|
58
58
|
|
|
59
59
|
setMessages(prev => {
|
|
60
60
|
const updated = [msg, ...prev];
|
|
61
|
-
// Trim to max messages
|
|
62
61
|
if (updated.length > maxMessages) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
for (let i = maxMessages; i < updated.length; i++) {
|
|
63
|
+
messageIdRef.current.delete(updated[i].id);
|
|
64
|
+
}
|
|
65
|
+
updated.length = maxMessages;
|
|
66
66
|
}
|
|
67
67
|
return updated;
|
|
68
68
|
});
|
package/src/hooks/usePeers.js
CHANGED
|
@@ -35,18 +35,23 @@ function usePeers(mesh) {
|
|
|
35
35
|
throw new Error('usePeers requires React. Install react as a dependency.');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const { useState, useEffect, useCallback, useMemo } = React;
|
|
38
|
+
const { useState, useEffect, useCallback, useMemo, useRef } = React;
|
|
39
39
|
|
|
40
40
|
const [peers, setPeers] = useState([]);
|
|
41
|
-
const
|
|
41
|
+
const lastUpdateRef = useRef(Date.now());
|
|
42
42
|
|
|
43
43
|
// Update peers from mesh
|
|
44
44
|
const refreshPeers = useCallback(() => {
|
|
45
45
|
if (mesh) {
|
|
46
46
|
try {
|
|
47
47
|
const allPeers = mesh.getPeers();
|
|
48
|
-
setPeers(
|
|
49
|
-
|
|
48
|
+
setPeers(prev => {
|
|
49
|
+
if (prev.length === allPeers.length && prev.every((p, i) => p.id === allPeers[i]?.id && p.connectionState === allPeers[i]?.connectionState)) {
|
|
50
|
+
return prev;
|
|
51
|
+
}
|
|
52
|
+
return allPeers;
|
|
53
|
+
});
|
|
54
|
+
lastUpdateRef.current = Date.now();
|
|
50
55
|
} catch (e) {
|
|
51
56
|
// Mesh might not be ready
|
|
52
57
|
}
|
|
@@ -84,10 +89,9 @@ function usePeers(mesh) {
|
|
|
84
89
|
return peers.filter(p => p.connectionState === 'secured');
|
|
85
90
|
}, [peers]);
|
|
86
91
|
|
|
87
|
-
// Get single peer by ID
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
}, [peers]);
|
|
92
|
+
// Get single peer by ID (O(1) lookup via peerMap)
|
|
93
|
+
const peerMap = useMemo(() => new Map(peers.map(p => [p.id, p])), [peers]);
|
|
94
|
+
const getPeer = useCallback((peerId) => peerMap.get(peerId), [peerMap]);
|
|
91
95
|
|
|
92
96
|
// Check if peer is connected
|
|
93
97
|
const isConnected = useCallback((peerId) => {
|
|
@@ -104,7 +108,7 @@ function usePeers(mesh) {
|
|
|
104
108
|
getPeer,
|
|
105
109
|
isConnected,
|
|
106
110
|
refresh: refreshPeers,
|
|
107
|
-
lastUpdate
|
|
111
|
+
lastUpdate: lastUpdateRef.current
|
|
108
112
|
};
|
|
109
113
|
}
|
|
110
114
|
|