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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/docs/OPTIMIZATION.md +165 -52
  3. package/package.json +1 -1
  4. package/src/MeshNetwork.js +16 -8
  5. package/src/crypto/AutoCrypto.js +14 -3
  6. package/src/crypto/providers/ExpoCryptoProvider.js +3 -2
  7. package/src/crypto/providers/QuickCryptoProvider.js +30 -6
  8. package/src/crypto/providers/TweetNaClProvider.js +1 -1
  9. package/src/hooks/AppStateManager.js +9 -1
  10. package/src/hooks/useMesh.js +17 -4
  11. package/src/hooks/useMessages.js +4 -4
  12. package/src/hooks/usePeers.js +13 -9
  13. package/src/mesh/dedup/BloomFilter.js +44 -57
  14. package/src/mesh/dedup/DedupManager.js +32 -1
  15. package/src/mesh/fragment/Fragmenter.js +1 -1
  16. package/src/mesh/monitor/ConnectionQuality.js +42 -17
  17. package/src/mesh/monitor/NetworkMonitor.js +58 -13
  18. package/src/mesh/peer/Peer.js +5 -2
  19. package/src/mesh/peer/PeerManager.js +15 -3
  20. package/src/mesh/router/MessageRouter.js +14 -6
  21. package/src/mesh/router/RouteTable.js +17 -7
  22. package/src/mesh/store/StoreAndForwardManager.js +13 -2
  23. package/src/protocol/deserializer.js +9 -10
  24. package/src/protocol/header.js +13 -7
  25. package/src/protocol/message.js +15 -3
  26. package/src/protocol/serializer.js +7 -10
  27. package/src/protocol/validator.js +23 -5
  28. package/src/service/BatteryOptimizer.js +4 -1
  29. package/src/service/HandshakeManager.js +12 -16
  30. package/src/service/SessionManager.js +12 -10
  31. package/src/service/text/TextManager.js +21 -15
  32. package/src/storage/MessageStore.js +55 -2
  33. package/src/transport/BLETransport.js +11 -2
  34. package/src/transport/MultiTransport.js +35 -10
  35. package/src/transport/WiFiDirectTransport.js +4 -3
  36. package/src/utils/EventEmitter.js +6 -9
  37. package/src/utils/bytes.js +12 -10
  38. package/src/utils/compression.js +5 -3
  39. package/src/utils/encoding.js +33 -8
@@ -16,6 +16,12 @@ const { randomBytes } = require('../utils/bytes');
16
16
  */
17
17
  const HEADER_SIZE = 48;
18
18
 
19
+ // Pre-computed hex lookup table (avoids Array.from().map().join() per call)
20
+ const HEX_TABLE = new Array(256);
21
+ for (let i = 0; i < 256; i++) {
22
+ HEX_TABLE[i] = (i < 16 ? '0' : '') + i.toString(16);
23
+ }
24
+
19
25
  /**
20
26
  * Message header class representing the 48-byte header structure.
21
27
  * @class MessageHeader
@@ -43,7 +49,6 @@ class MessageHeader {
43
49
  this.flags = options.flags ?? MESSAGE_FLAGS.NONE;
44
50
  this.hopCount = options.hopCount ?? 0;
45
51
  this.maxHops = options.maxHops ?? MESH_CONFIG.MAX_HOPS;
46
- this.reserved = new Uint8Array(3);
47
52
  this.messageId = options.messageId;
48
53
  this.timestamp = options.timestamp;
49
54
  this.expiresAt = options.expiresAt;
@@ -143,9 +148,8 @@ class MessageHeader {
143
148
  view.setUint16(40, header.payloadLength, false);
144
149
  buffer[42] = header.fragmentIndex;
145
150
  buffer[43] = header.fragmentTotal;
146
- // Calculate checksum over header without checksum field
147
- const checksumData = buffer.slice(0, 44);
148
- const checksum = crc32(checksumData);
151
+ // Calculate checksum over header without checksum field (subarray = zero-copy view)
152
+ const checksum = crc32(buffer.subarray(0, 44));
149
153
  view.setUint32(44, checksum, false);
150
154
  header.checksum = checksum;
151
155
 
@@ -204,9 +208,11 @@ function writeUint64BE(view, offset, value) {
204
208
  * @returns {string} Hex string
205
209
  */
206
210
  function bytesToHex(bytes) {
207
- return Array.from(bytes)
208
- .map(b => b.toString(16).padStart(2, '0'))
209
- .join('');
211
+ let hex = '';
212
+ for (let i = 0; i < bytes.length; i++) {
213
+ hex += HEX_TABLE[bytes[i]];
214
+ }
215
+ return hex;
210
216
  }
211
217
 
212
218
  module.exports = {
@@ -9,6 +9,18 @@ const { MessageHeader, HEADER_SIZE, generateUuid } = require('./header');
9
9
  const { MESSAGE_FLAGS, MESH_CONFIG } = require('../constants');
10
10
  const { MessageError } = require('../errors');
11
11
 
12
+ // Cached TextEncoder/TextDecoder singletons (avoids per-call allocation)
13
+ let _encoder = null;
14
+ let _decoder = null;
15
+ function _getEncoder() {
16
+ if (!_encoder) { _encoder = new TextEncoder(); }
17
+ return _encoder;
18
+ }
19
+ function _getDecoder() {
20
+ if (!_decoder) { _decoder = new TextDecoder(); }
21
+ return _decoder;
22
+ }
23
+
12
24
  /**
13
25
  * Message class representing a complete mesh network message.
14
26
  * @class Message
@@ -44,7 +56,7 @@ class Message {
44
56
 
45
57
  // Convert string payload to bytes
46
58
  if (typeof payload === 'string') {
47
- payload = new TextEncoder().encode(payload);
59
+ payload = _getEncoder().encode(payload);
48
60
  }
49
61
 
50
62
  if (!(payload instanceof Uint8Array)) {
@@ -99,7 +111,7 @@ class Message {
99
111
  });
100
112
  }
101
113
 
102
- const payload = data.slice(HEADER_SIZE, HEADER_SIZE + header.payloadLength);
114
+ const payload = data.subarray(HEADER_SIZE, HEADER_SIZE + header.payloadLength);
103
115
 
104
116
  return new Message(header, payload);
105
117
  }
@@ -171,7 +183,7 @@ class Message {
171
183
  * @returns {string} Decoded payload content
172
184
  */
173
185
  getContent() {
174
- return new TextDecoder().decode(this.payload);
186
+ return _getDecoder().decode(this.payload);
175
187
  }
176
188
 
177
189
  /**
@@ -71,9 +71,8 @@ function serializeHeader(header) {
71
71
  // Byte 43: fragmentTotal
72
72
  buffer[43] = header.fragmentTotal ?? 1;
73
73
 
74
- // Bytes 44-47: checksum (calculated over bytes 0-43)
75
- const checksumData = buffer.slice(0, 44);
76
- const checksum = crc32(checksumData);
74
+ // Bytes 44-47: checksum (calculated over bytes 0-43, subarray = zero-copy view)
75
+ const checksum = crc32(buffer.subarray(0, 44));
77
76
  view.setUint32(44, checksum, false);
78
77
 
79
78
  return buffer;
@@ -110,7 +109,8 @@ function serialize(message) {
110
109
 
111
110
  // Convert string payload to bytes
112
111
  if (typeof payload === 'string') {
113
- payload = new TextEncoder().encode(payload);
112
+ if (!serialize._encoder) { serialize._encoder = new TextEncoder(); }
113
+ payload = serialize._encoder.encode(payload);
114
114
  }
115
115
 
116
116
  // Default to empty payload
@@ -122,13 +122,10 @@ function serialize(message) {
122
122
  throw MessageError.invalidFormat(null, { reason: 'Payload must be Uint8Array or string' });
123
123
  }
124
124
 
125
- // Update payloadLength in header
126
- const headerWithLength = {
127
- ...header,
128
- payloadLength: payload.length
129
- };
125
+ // Update payloadLength directly (avoids object spread allocation)
126
+ header.payloadLength = payload.length;
130
127
 
131
- const headerBytes = serializeHeader(headerWithLength);
128
+ const headerBytes = serializeHeader(header);
132
129
  const result = new Uint8Array(headerBytes.length + payload.length);
133
130
 
134
131
  result.set(headerBytes, 0);
@@ -15,6 +15,12 @@ const { PROTOCOL_VERSION, MESSAGE_TYPE, MESH_CONFIG } = require('../constants');
15
15
  */
16
16
  const VALID_MESSAGE_TYPES = new Set(Object.values(MESSAGE_TYPE));
17
17
 
18
+ /**
19
+ * Cached frozen result for valid validations to avoid repeated allocations.
20
+ * @type {{ valid: boolean, errors: string[] }}
21
+ */
22
+ const VALID_RESULT = Object.freeze({ valid: true, errors: Object.freeze([]) });
23
+
18
24
  /**
19
25
  * Validates a message header.
20
26
  *
@@ -102,8 +108,12 @@ function validateHeader(header) {
102
108
  errors.push(`Fragment index (${header.fragmentIndex}) >= total (${header.fragmentTotal})`);
103
109
  }
104
110
 
111
+ if (errors.length === 0) {
112
+ return VALID_RESULT;
113
+ }
114
+
105
115
  return {
106
- valid: errors.length === 0,
116
+ valid: false,
107
117
  errors
108
118
  };
109
119
  }
@@ -152,8 +162,12 @@ function validateMessage(message) {
152
162
  }
153
163
  }
154
164
 
165
+ if (errors.length === 0) {
166
+ return VALID_RESULT;
167
+ }
168
+
155
169
  return {
156
- valid: errors.length === 0,
170
+ valid: false,
157
171
  errors
158
172
  };
159
173
  }
@@ -171,7 +185,7 @@ function validateChecksum(headerBytes) {
171
185
 
172
186
  const view = new DataView(headerBytes.buffer, headerBytes.byteOffset, HEADER_SIZE);
173
187
  const storedChecksum = view.getUint32(44, false);
174
- const checksumData = headerBytes.slice(0, 44);
188
+ const checksumData = headerBytes.subarray(0, 44);
175
189
  const calculatedChecksum = crc32(checksumData);
176
190
 
177
191
  return {
@@ -242,7 +256,7 @@ function validateRawMessage(data) {
242
256
  }
243
257
 
244
258
  // Validate checksum
245
- const checksumResult = validateChecksum(data.slice(0, HEADER_SIZE));
259
+ const checksumResult = validateChecksum(data.subarray(0, HEADER_SIZE));
246
260
  if (!checksumResult.valid) {
247
261
  errors.push(
248
262
  `Checksum mismatch: expected 0x${checksumResult.expected.toString(16)}, ` +
@@ -259,8 +273,12 @@ function validateRawMessage(data) {
259
273
  errors.push(`Incomplete message: expected ${expectedTotal} bytes, got ${data.length}`);
260
274
  }
261
275
 
276
+ if (errors.length === 0) {
277
+ return VALID_RESULT;
278
+ }
279
+
262
280
  return {
263
- valid: errors.length === 0,
281
+ valid: false,
264
282
  errors
265
283
  };
266
284
  }
@@ -23,6 +23,9 @@ const BATTERY_MODE = Object.freeze({
23
23
  AUTO: 'auto'
24
24
  });
25
25
 
26
+ /** Pre-computed Set of valid battery modes for O(1) lookup */
27
+ const BATTERY_MODE_SET = new Set(Object.values(BATTERY_MODE));
28
+
26
29
  /**
27
30
  * Battery profile configuration
28
31
  * @typedef {Object} BatteryProfile
@@ -224,7 +227,7 @@ class BatteryOptimizer extends EventEmitter {
224
227
  * @returns {Promise<void>}
225
228
  */
226
229
  async setMode(mode) {
227
- if (!Object.values(BATTERY_MODE).includes(mode)) {
230
+ if (!BATTERY_MODE_SET.has(mode)) {
228
231
  throw new Error(`Invalid battery mode: ${mode}`);
229
232
  }
230
233
 
@@ -177,32 +177,28 @@ class HandshakeManager extends EventEmitter {
177
177
  let sendNonce = 0;
178
178
  let recvNonce = 0;
179
179
 
180
+ // Pre-allocate nonce buffers per direction to avoid per-call allocation
181
+ const sendNonceBuf = new Uint8Array(24);
182
+ const sendNonceView = new DataView(sendNonceBuf.buffer);
183
+ const recvNonceBuf = new Uint8Array(24);
184
+ const recvNonceView = new DataView(recvNonceBuf.buffer);
185
+
180
186
  return {
181
187
  encrypt: (plaintext) => {
182
188
  if (provider && typeof provider.encrypt === 'function') {
183
- // Use proper AEAD encryption with nonce
184
- const nonce = new Uint8Array(24); // tweetnacl uses 24-byte nonces
185
- const view = new DataView(nonce.buffer);
186
189
  // Store counter in last 8 bytes of nonce
187
- if (nonce.byteLength >= 8) {
188
- view.setUint32(nonce.byteLength - 8, 0, true);
189
- view.setUint32(nonce.byteLength - 4, sendNonce++, true);
190
- }
191
- return provider.encrypt(sendKey, nonce, plaintext);
190
+ sendNonceView.setUint32(16, 0, true);
191
+ sendNonceView.setUint32(20, sendNonce++, true);
192
+ return provider.encrypt(sendKey, sendNonceBuf, plaintext);
192
193
  }
193
194
  throw new Error('Crypto provider required for encryption');
194
195
  },
195
196
 
196
197
  decrypt: (ciphertext) => {
197
198
  if (provider && typeof provider.decrypt === 'function') {
198
- // Use proper AEAD decryption with nonce
199
- const nonce = new Uint8Array(24);
200
- const view = new DataView(nonce.buffer);
201
- if (nonce.byteLength >= 8) {
202
- view.setUint32(nonce.byteLength - 8, 0, true);
203
- view.setUint32(nonce.byteLength - 4, recvNonce++, true);
204
- }
205
- return provider.decrypt(recvKey, nonce, ciphertext);
199
+ recvNonceView.setUint32(16, 0, true);
200
+ recvNonceView.setUint32(20, recvNonce++, true);
201
+ return provider.decrypt(recvKey, recvNonceBuf, ciphertext);
206
202
  }
207
203
  throw new Error('Crypto provider required for encryption');
208
204
  },
@@ -120,24 +120,26 @@ class SessionManager {
120
120
  // No crypto provider available
121
121
  }
122
122
 
123
+ // Pre-allocate nonce buffers per direction to avoid per-call allocation
124
+ const sendNonceBuf = new Uint8Array(24);
125
+ const sendNonceView = new DataView(sendNonceBuf.buffer);
126
+ const recvNonceBuf = new Uint8Array(24);
127
+ const recvNonceView = new DataView(recvNonceBuf.buffer);
128
+
123
129
  const session = {
124
130
  encrypt: (plaintext) => {
125
131
  if (provider && typeof provider.encrypt === 'function') {
126
- const nonce = new Uint8Array(24);
127
- const view = new DataView(nonce.buffer);
128
- view.setUint32(nonce.byteLength - 8, 0, true);
129
- view.setUint32(nonce.byteLength - 4, sendNonce++, true);
130
- return provider.encrypt(sendKey, nonce, plaintext);
132
+ sendNonceView.setUint32(16, 0, true);
133
+ sendNonceView.setUint32(20, sendNonce++, true);
134
+ return provider.encrypt(sendKey, sendNonceBuf, plaintext);
131
135
  }
132
136
  return plaintext;
133
137
  },
134
138
  decrypt: (ciphertext) => {
135
139
  if (provider && typeof provider.decrypt === 'function') {
136
- const nonce = new Uint8Array(24);
137
- const view = new DataView(nonce.buffer);
138
- view.setUint32(nonce.byteLength - 8, 0, true);
139
- view.setUint32(nonce.byteLength - 4, recvNonce++, true);
140
- return provider.decrypt(recvKey, nonce, ciphertext);
140
+ recvNonceView.setUint32(16, 0, true);
141
+ recvNonceView.setUint32(20, recvNonce++, true);
142
+ return provider.decrypt(recvKey, recvNonceBuf, ciphertext);
141
143
  }
142
144
  return ciphertext;
143
145
  },
@@ -12,6 +12,13 @@ const TextMessage = require('./message/TextMessage');
12
12
  const { ChannelManager } = require('./channel');
13
13
  const { BroadcastManager } = require('./broadcast');
14
14
 
15
+ /**
16
+ * Cached TextEncoder/TextDecoder instances to avoid per-call allocation
17
+ * @private
18
+ */
19
+ const cachedEncoder = new TextEncoder();
20
+ const cachedDecoder = new TextDecoder();
21
+
15
22
  /**
16
23
  * Text manager states
17
24
  * @constant {Object}
@@ -420,8 +427,8 @@ class TextManager extends EventEmitter {
420
427
  _handleChannelMessagePayload(peerId, payload) {
421
428
  // First byte is channel ID length
422
429
  const channelIdLength = payload[0];
423
- const channelId = new TextDecoder().decode(payload.slice(1, 1 + channelIdLength));
424
- const messagePayload = payload.slice(1 + channelIdLength);
430
+ const channelId = cachedDecoder.decode(payload.subarray(1, 1 + channelIdLength));
431
+ const messagePayload = payload.subarray(1 + channelIdLength);
425
432
 
426
433
  this.handleChannelMessage(peerId, channelId, messagePayload);
427
434
  }
@@ -435,7 +442,7 @@ class TextManager extends EventEmitter {
435
442
  while (offset < payload.length) {
436
443
  const length = payload[offset];
437
444
  offset += 1;
438
- const messageId = new TextDecoder().decode(payload.slice(offset, offset + length));
445
+ const messageId = cachedDecoder.decode(payload.subarray(offset, offset + length));
439
446
  messageIds.push(messageId);
440
447
  offset += length;
441
448
  }
@@ -456,21 +463,20 @@ class TextManager extends EventEmitter {
456
463
  const messageIds = Array.from(this._pendingReadReceipts);
457
464
  this._pendingReadReceipts.clear();
458
465
 
459
- // Build read receipt payload
460
- const parts = messageIds.map(id => {
461
- const bytes = new TextEncoder().encode(id);
462
- const part = new Uint8Array(1 + bytes.length);
463
- part[0] = bytes.length;
464
- part.set(bytes, 1);
465
- return part;
466
- });
466
+ // Pre-calculate total size and allocate once
467
+ const encodedIds = messageIds.map(id => cachedEncoder.encode(id));
468
+ let totalLength = 0;
469
+ for (let i = 0; i < encodedIds.length; i++) {
470
+ totalLength += 1 + encodedIds[i].length;
471
+ }
467
472
 
468
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
469
473
  const payload = new Uint8Array(totalLength);
470
474
  let offset = 0;
471
- for (const part of parts) {
472
- payload.set(part, offset);
473
- offset += part.length;
475
+ for (let i = 0; i < encodedIds.length; i++) {
476
+ payload[offset] = encodedIds[i].length;
477
+ offset += 1;
478
+ payload.set(encodedIds[i], offset);
479
+ offset += encodedIds[i].length;
474
480
  }
475
481
 
476
482
  this.emit('read-receipts-sent', { messageIds, count: messageIds.length });
@@ -8,6 +8,52 @@
8
8
  const MemoryStorage = require('./MemoryStorage');
9
9
  const { MESH_CONFIG } = require('../constants');
10
10
 
11
+ /**
12
+ * Base64 encoding/decoding for compact Uint8Array serialization
13
+ * @private
14
+ */
15
+ const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
16
+
17
+ function uint8ArrayToBase64(bytes) {
18
+ let result = '';
19
+ const len = bytes.length;
20
+ for (let i = 0; i < len; i += 3) {
21
+ const b0 = bytes[i];
22
+ const b1 = i + 1 < len ? bytes[i + 1] : 0;
23
+ const b2 = i + 2 < len ? bytes[i + 2] : 0;
24
+ result += BASE64_CHARS[b0 >> 2];
25
+ result += BASE64_CHARS[((b0 & 3) << 4) | (b1 >> 4)];
26
+ result += (i + 1 < len) ? BASE64_CHARS[((b1 & 15) << 2) | (b2 >> 6)] : '=';
27
+ result += (i + 2 < len) ? BASE64_CHARS[b2 & 63] : '=';
28
+ }
29
+ return result;
30
+ }
31
+
32
+ const BASE64_LOOKUP = new Uint8Array(128);
33
+ for (let i = 0; i < BASE64_CHARS.length; i++) {
34
+ BASE64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
35
+ }
36
+
37
+ function base64ToUint8Array(str) {
38
+ const len = str.length;
39
+ let padding = 0;
40
+ if (str[len - 1] === '=') { padding++; }
41
+ if (str[len - 2] === '=') { padding++; }
42
+ const byteLen = (len * 3 / 4) - padding;
43
+ const bytes = new Uint8Array(byteLen);
44
+ let j = 0;
45
+ for (let i = 0; i < len; i += 4) {
46
+ const a = BASE64_LOOKUP[str.charCodeAt(i)];
47
+ const b = BASE64_LOOKUP[str.charCodeAt(i + 1)];
48
+ const c = BASE64_LOOKUP[str.charCodeAt(i + 2)];
49
+ const d = BASE64_LOOKUP[str.charCodeAt(i + 3)];
50
+ bytes[j++] = (a << 2) | (b >> 4);
51
+ if (j < byteLen) { bytes[j++] = ((b & 15) << 4) | (c >> 2); }
52
+ if (j < byteLen) { bytes[j++] = ((c & 3) << 6) | d; }
53
+ }
54
+ return bytes;
55
+ }
56
+
11
57
  /**
12
58
  * Message store for persisting and retrieving mesh network messages.
13
59
  * Provides message caching, deduplication support, and cleanup functionality.
@@ -69,8 +115,9 @@ class MessageStore {
69
115
  const storedMessage = {
70
116
  ...message,
71
117
  payload: message.payload
72
- ? Array.from(message.payload)
118
+ ? uint8ArrayToBase64(message.payload)
73
119
  : undefined,
120
+ payloadEncoding: message.payload ? 'base64' : undefined,
74
121
  storedAt: Date.now()
75
122
  };
76
123
 
@@ -96,7 +143,13 @@ class MessageStore {
96
143
 
97
144
  // Convert payload back to Uint8Array
98
145
  if (message.payload) {
99
- message.payload = new Uint8Array(message.payload);
146
+ if (message.payloadEncoding === 'base64') {
147
+ message.payload = base64ToUint8Array(message.payload);
148
+ } else if (Array.isArray(message.payload)) {
149
+ // Backwards compatibility with old Array.from() format
150
+ message.payload = new Uint8Array(message.payload);
151
+ }
152
+ delete message.payloadEncoding;
100
153
  }
101
154
 
102
155
  return message;
@@ -293,6 +293,15 @@ class BLETransport extends Transport {
293
293
  await this._adapter.disconnect(peerId);
294
294
  } finally {
295
295
  this._peers.delete(peerId);
296
+
297
+ // Clean up write queue and writing state
298
+ const queue = this._writeQueue.get(peerId);
299
+ if (queue) {
300
+ queue.forEach(({ reject }) => reject(new Error('Peer disconnected')));
301
+ this._writeQueue.delete(peerId);
302
+ }
303
+ this._writing.delete(peerId);
304
+
296
305
  this.emit('peerDisconnected', { peerId, reason: 'user_request' });
297
306
  }
298
307
  }
@@ -325,7 +334,7 @@ class BLETransport extends Transport {
325
334
 
326
335
  // Chunk data for BLE MTU compliance
327
336
  for (let offset = 0; offset < data.length; offset += chunkSize) {
328
- const chunk = data.slice(offset, Math.min(offset + chunkSize, data.length));
337
+ const chunk = data.subarray(offset, Math.min(offset + chunkSize, data.length));
329
338
  await this._queuedWrite(peerId, chunk);
330
339
  }
331
340
  }
@@ -414,7 +423,7 @@ class BLETransport extends Transport {
414
423
  * @private
415
424
  */
416
425
  _handleData(peerId, data) {
417
- this.emit('message', { peerId, data: new Uint8Array(data) });
426
+ this.emit('message', { peerId, data: data instanceof Uint8Array ? data : new Uint8Array(data) });
418
427
  }
419
428
 
420
429
  /**
@@ -54,6 +54,9 @@ class MultiTransport extends Transport {
54
54
 
55
55
  /** @type {Map<string, string>} peerId → preferred transport name */
56
56
  this._peerTransportMap = new Map();
57
+
58
+ /** @type {Array<{transport: Transport, event: string, handler: Function}>} */
59
+ this._wiredHandlers = [];
57
60
  }
58
61
 
59
62
  /**
@@ -122,6 +125,13 @@ class MultiTransport extends Transport {
122
125
  if (this._wifiTransport) { stopPromises.push(this._wifiTransport.stop().catch(() => {})); }
123
126
 
124
127
  await Promise.allSettled(stopPromises);
128
+
129
+ // Remove wired event handlers to prevent listener leaks
130
+ for (const { transport, event, handler } of this._wiredHandlers) {
131
+ transport.off(event, handler);
132
+ }
133
+ this._wiredHandlers = [];
134
+
125
135
  this._peers.clear();
126
136
  this._peerTransportMap.clear();
127
137
  this._setState(Transport.STATE.STOPPED);
@@ -272,13 +282,13 @@ class MultiTransport extends Transport {
272
282
  * @private
273
283
  */
274
284
  _wireTransport(transport, name) {
275
- transport.on('peerConnected', (info) => {
285
+ const onPeerConnected = (info) => {
276
286
  this._peerTransportMap.set(info.peerId, name);
277
287
  this._peers.set(info.peerId, { ...info, transport: name });
278
288
  this.emit('peerConnected', { ...info, transport: name });
279
- });
289
+ };
280
290
 
281
- transport.on('peerDisconnected', (info) => {
291
+ const onPeerDisconnected = (info) => {
282
292
  // Only remove if no other transport has this peer
283
293
  const otherTransport = this._getFallbackTransport(transport);
284
294
  if (!otherTransport || !otherTransport.isConnected(info.peerId)) {
@@ -286,19 +296,34 @@ class MultiTransport extends Transport {
286
296
  this._peerTransportMap.delete(info.peerId);
287
297
  this.emit('peerDisconnected', { ...info, transport: name });
288
298
  }
289
- });
299
+ };
290
300
 
291
- transport.on('message', (msg) => {
301
+ const onMessage = (msg) => {
292
302
  this.emit('message', { ...msg, transport: name });
293
- });
303
+ };
294
304
 
295
- transport.on('deviceDiscovered', (info) => {
305
+ const onDeviceDiscovered = (info) => {
296
306
  this.emit('deviceDiscovered', { ...info, transport: name });
297
- });
307
+ };
298
308
 
299
- transport.on('error', (err) => {
309
+ const onError = (err) => {
300
310
  this.emit('transportError', { transport: name, error: err });
301
- });
311
+ };
312
+
313
+ transport.on('peerConnected', onPeerConnected);
314
+ transport.on('peerDisconnected', onPeerDisconnected);
315
+ transport.on('message', onMessage);
316
+ transport.on('deviceDiscovered', onDeviceDiscovered);
317
+ transport.on('error', onError);
318
+
319
+ // Store references for cleanup
320
+ this._wiredHandlers.push(
321
+ { transport, event: 'peerConnected', handler: onPeerConnected },
322
+ { transport, event: 'peerDisconnected', handler: onPeerDisconnected },
323
+ { transport, event: 'message', handler: onMessage },
324
+ { transport, event: 'deviceDiscovered', handler: onDeviceDiscovered },
325
+ { transport, event: 'error', handler: onError }
326
+ );
302
327
  }
303
328
  }
304
329
 
@@ -284,10 +284,11 @@ class WiFiDirectTransport extends Transport {
284
284
 
285
285
  /** @private */
286
286
  _uint8ArrayToBase64(bytes) {
287
- let binary = '';
288
- for (let i = 0; i < bytes.length; i++) {
289
- binary += String.fromCharCode(bytes[i]);
287
+ const chunks = [];
288
+ for (let i = 0; i < bytes.length; i += 8192) {
289
+ chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + 8192, bytes.length))));
290
290
  }
291
+ const binary = chunks.join('');
291
292
  return typeof btoa !== 'undefined' ? btoa(binary) : Buffer.from(bytes).toString('base64');
292
293
  }
293
294
  }
@@ -116,10 +116,11 @@ class EventEmitter {
116
116
  return false;
117
117
  }
118
118
 
119
- const listeners = this._events.get(event).slice();
120
- const toRemove = [];
119
+ const listeners = this._events.get(event);
120
+ const hasOnce = listeners.some(e => e.once);
121
+ const iterList = hasOnce ? listeners.slice() : listeners;
121
122
 
122
- for (const entry of listeners) {
123
+ for (const entry of iterList) {
123
124
  try {
124
125
  entry.listener.apply(this, args);
125
126
  } catch (error) {
@@ -130,15 +131,11 @@ class EventEmitter {
130
131
  console.error('Error in error handler:', error);
131
132
  }
132
133
  }
133
-
134
- if (entry.once) {
135
- toRemove.push(entry);
136
- }
137
134
  }
138
135
 
139
136
  // Remove one-time listeners
140
- if (toRemove.length > 0) {
141
- const remaining = this._events.get(event).filter(e => !toRemove.includes(e));
137
+ if (hasOnce) {
138
+ const remaining = listeners.filter(e => !e.once);
142
139
  if (remaining.length === 0) {
143
140
  this._events.delete(event);
144
141
  } else {
@@ -11,9 +11,16 @@
11
11
  * @returns {Uint8Array} Concatenated array
12
12
  */
13
13
  function concat(...arrays) {
14
- // Filter out undefined/null and calculate total length
15
- const validArrays = arrays.filter(arr => arr !== null && arr !== undefined);
16
- const totalLength = validArrays.reduce((sum, arr) => sum + arr.length, 0);
14
+ // Single-pass: filter out undefined/null and calculate total length
15
+ const validArrays = [];
16
+ let totalLength = 0;
17
+ for (let i = 0; i < arrays.length; i++) {
18
+ const arr = arrays[i];
19
+ if (arr !== null && arr !== undefined) {
20
+ validArrays.push(arr);
21
+ totalLength += arr.length;
22
+ }
23
+ }
17
24
 
18
25
  const result = new Uint8Array(totalLength);
19
26
  let offset = 0;
@@ -119,10 +126,7 @@ function xor(a, b) {
119
126
  * @returns {Uint8Array} The filled array (same reference)
120
127
  */
121
128
  function fill(array, value) {
122
- const byte = value & 0xff;
123
- for (let i = 0; i < array.length; i++) {
124
- array[i] = byte;
125
- }
129
+ array.fill(value & 0xff);
126
130
  return array;
127
131
  }
128
132
 
@@ -132,9 +136,7 @@ function fill(array, value) {
132
136
  * @returns {Uint8Array} Copy of the array
133
137
  */
134
138
  function copy(array) {
135
- const result = new Uint8Array(array.length);
136
- result.set(array);
137
- return result;
139
+ return array.slice();
138
140
  }
139
141
 
140
142
  /**