react-native-ble-mesh 1.1.1 → 2.0.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 (65) hide show
  1. package/README.md +288 -172
  2. package/docs/IOS-BACKGROUND-BLE.md +231 -0
  3. package/docs/OPTIMIZATION.md +70 -0
  4. package/docs/SPEC-v2.1.md +308 -0
  5. package/package.json +1 -1
  6. package/src/MeshNetwork.js +659 -465
  7. package/src/constants/index.js +1 -0
  8. package/src/crypto/AutoCrypto.js +79 -0
  9. package/src/crypto/CryptoProvider.js +99 -0
  10. package/src/crypto/index.js +15 -63
  11. package/src/crypto/providers/ExpoCryptoProvider.js +125 -0
  12. package/src/crypto/providers/QuickCryptoProvider.js +134 -0
  13. package/src/crypto/providers/TweetNaClProvider.js +124 -0
  14. package/src/crypto/providers/index.js +11 -0
  15. package/src/errors/MeshError.js +2 -1
  16. package/src/expo/withBLEMesh.js +102 -0
  17. package/src/hooks/useMesh.js +30 -9
  18. package/src/hooks/useMessages.js +2 -0
  19. package/src/index.js +23 -8
  20. package/src/mesh/dedup/DedupManager.js +36 -10
  21. package/src/mesh/fragment/Assembler.js +5 -0
  22. package/src/mesh/index.js +1 -1
  23. package/src/mesh/monitor/ConnectionQuality.js +408 -0
  24. package/src/mesh/monitor/NetworkMonitor.js +327 -316
  25. package/src/mesh/monitor/index.js +7 -3
  26. package/src/mesh/peer/PeerManager.js +6 -1
  27. package/src/mesh/router/MessageRouter.js +26 -15
  28. package/src/mesh/router/RouteTable.js +7 -1
  29. package/src/mesh/store/StoreAndForwardManager.js +295 -297
  30. package/src/mesh/store/index.js +1 -1
  31. package/src/service/BatteryOptimizer.js +282 -278
  32. package/src/service/EmergencyManager.js +224 -214
  33. package/src/service/HandshakeManager.js +167 -13
  34. package/src/service/MeshService.js +72 -6
  35. package/src/service/SessionManager.js +77 -2
  36. package/src/service/audio/AudioManager.js +8 -2
  37. package/src/service/file/FileAssembler.js +106 -0
  38. package/src/service/file/FileChunker.js +79 -0
  39. package/src/service/file/FileManager.js +307 -0
  40. package/src/service/file/FileMessage.js +122 -0
  41. package/src/service/file/index.js +15 -0
  42. package/src/service/text/broadcast/BroadcastManager.js +16 -0
  43. package/src/transport/BLETransport.js +131 -9
  44. package/src/transport/MockTransport.js +1 -1
  45. package/src/transport/MultiTransport.js +305 -0
  46. package/src/transport/WiFiDirectTransport.js +295 -0
  47. package/src/transport/adapters/NodeBLEAdapter.js +34 -0
  48. package/src/transport/adapters/RNBLEAdapter.js +56 -1
  49. package/src/transport/index.js +6 -0
  50. package/src/utils/compression.js +291 -291
  51. package/src/crypto/aead.js +0 -189
  52. package/src/crypto/chacha20.js +0 -181
  53. package/src/crypto/hkdf.js +0 -187
  54. package/src/crypto/hmac.js +0 -143
  55. package/src/crypto/keys/KeyManager.js +0 -271
  56. package/src/crypto/keys/KeyPair.js +0 -216
  57. package/src/crypto/keys/SecureStorage.js +0 -219
  58. package/src/crypto/keys/index.js +0 -32
  59. package/src/crypto/noise/handshake.js +0 -410
  60. package/src/crypto/noise/index.js +0 -27
  61. package/src/crypto/noise/session.js +0 -253
  62. package/src/crypto/noise/state.js +0 -268
  63. package/src/crypto/poly1305.js +0 -113
  64. package/src/crypto/sha256.js +0 -240
  65. package/src/crypto/x25519.js +0 -154
@@ -8,6 +8,7 @@
8
8
  const EventEmitter = require('../utils/EventEmitter');
9
9
  const { HandshakeError } = require('../errors');
10
10
  const { MESSAGE_TYPE, MESH_CONFIG, EVENTS } = require('../constants');
11
+ const { randomBytes, concat } = require('../utils/bytes');
11
12
 
12
13
  const STATE = Object.freeze({
13
14
  IDLE: 'idle', INITIATOR_WAITING: 'initiator_waiting',
@@ -51,9 +52,15 @@ class HandshakeManager extends EventEmitter {
51
52
 
52
53
  async handleIncomingHandshake(peerId, type, payload, transport) {
53
54
  try {
54
- if (type === MESSAGE_TYPE.HANDSHAKE_INIT) { return await this._onInit(peerId, payload, transport); }
55
- if (type === MESSAGE_TYPE.HANDSHAKE_RESPONSE) { return await this._onResponse(peerId, payload, transport); }
56
- if (type === MESSAGE_TYPE.HANDSHAKE_FINAL) { return await this._onFinal(peerId, payload); }
55
+ if (type === MESSAGE_TYPE.HANDSHAKE_INIT) {
56
+ return await this._onInit(peerId, payload, transport);
57
+ }
58
+ if (type === MESSAGE_TYPE.HANDSHAKE_RESPONSE) {
59
+ return await this._onResponse(peerId, payload, transport);
60
+ }
61
+ if (type === MESSAGE_TYPE.HANDSHAKE_FINAL) {
62
+ return await this._onFinal(peerId, payload);
63
+ }
57
64
  throw HandshakeError.handshakeFailed(peerId, null, { reason: 'Unknown type' });
58
65
  } catch (err) {
59
66
  this._fail(peerId, err);
@@ -81,23 +88,160 @@ class HandshakeManager extends EventEmitter {
81
88
  };
82
89
  }
83
90
 
84
- _createNoise() {
91
+ _createNoise(keyPair, isInitiator) {
92
+ // Get crypto provider from keyManager if available
93
+ const provider = this._keyManager.provider;
94
+
95
+ // Generate ephemeral key pair for this handshake
96
+ let ephemeralKeyPair;
97
+ if (provider && typeof provider.generateKeyPair === 'function') {
98
+ ephemeralKeyPair = provider.generateKeyPair();
99
+ } else {
100
+ // Fallback to random keys if no provider available
101
+ ephemeralKeyPair = { publicKey: randomBytes(32), secretKey: randomBytes(32) };
102
+ }
103
+
104
+ let remoteEphemeralPublic = null;
105
+ let sharedSecret = null;
106
+ let sessionKeys = null;
107
+ let complete = false;
108
+
109
+ const deriveSessionKeys = (secret) => {
110
+ // Derive send/receive keys from shared secret
111
+ // Use hash to derive two different keys from the secret
112
+ if (provider && typeof provider.hash === 'function') {
113
+ const h1 = provider.hash(concat(secret, new Uint8Array([0x01])));
114
+ const h2 = provider.hash(concat(secret, new Uint8Array([0x02])));
115
+ return isInitiator
116
+ ? { sendKey: h1, recvKey: h2 }
117
+ : { sendKey: h2, recvKey: h1 };
118
+ }
119
+ throw new Error('Crypto provider required for key derivation. Install tweetnacl: npm install tweetnacl');
120
+ };
121
+
85
122
  return {
86
- writeMessage1: () => new Uint8Array(32), readMessage1: () => {},
87
- writeMessage2: () => new Uint8Array(80), readMessage2: () => {},
88
- writeMessage3: () => new Uint8Array(48), readMessage3: () => {},
89
- isComplete: () => false, getSession: () => ({ encrypt: () => {}, decrypt: () => {} }),
90
- getRemotePublicKey: () => new Uint8Array(32)
123
+ writeMessage1: () => {
124
+ // Initiator sends ephemeral public key
125
+ return ephemeralKeyPair.publicKey;
126
+ },
127
+
128
+ readMessage1: (msg) => {
129
+ // Responder receives initiator's ephemeral public key
130
+ remoteEphemeralPublic = new Uint8Array(msg);
131
+ },
132
+
133
+ writeMessage2: () => {
134
+ // Responder sends ephemeral public key and derives shared secret
135
+ if (provider && typeof provider.sharedSecret === 'function') {
136
+ sharedSecret = provider.sharedSecret(ephemeralKeyPair.secretKey, remoteEphemeralPublic);
137
+ } else {
138
+ throw new Error('Crypto provider required for secure handshake. Install tweetnacl: npm install tweetnacl');
139
+ }
140
+ sessionKeys = deriveSessionKeys(sharedSecret);
141
+ return ephemeralKeyPair.publicKey;
142
+ },
143
+
144
+ readMessage2: (msg) => {
145
+ // Initiator receives responder's ephemeral public key and derives shared secret
146
+ remoteEphemeralPublic = new Uint8Array(msg);
147
+ if (provider && typeof provider.sharedSecret === 'function') {
148
+ sharedSecret = provider.sharedSecret(ephemeralKeyPair.secretKey, remoteEphemeralPublic);
149
+ } else {
150
+ throw new Error('Crypto provider required for secure handshake. Install tweetnacl: npm install tweetnacl');
151
+ }
152
+ sessionKeys = deriveSessionKeys(sharedSecret);
153
+ },
154
+
155
+ writeMessage3: () => {
156
+ // Initiator confirms handshake completion
157
+ complete = true;
158
+ return ephemeralKeyPair.publicKey;
159
+ },
160
+
161
+ readMessage3: (_msg) => {
162
+ // Responder confirms handshake completion
163
+ complete = true;
164
+ },
165
+
166
+ isComplete: () => complete,
167
+
168
+ getRemotePublicKey: () => remoteEphemeralPublic || new Uint8Array(32),
169
+
170
+ getSession: () => {
171
+ if (!sessionKeys) {
172
+ throw new Error('Handshake not complete');
173
+ }
174
+
175
+ const sendKey = sessionKeys.sendKey;
176
+ const recvKey = sessionKeys.recvKey;
177
+ let sendNonce = 0;
178
+ let recvNonce = 0;
179
+
180
+ return {
181
+ encrypt: (plaintext) => {
182
+ 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
+ // 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);
192
+ }
193
+ throw new Error('Crypto provider required for encryption');
194
+ },
195
+
196
+ decrypt: (ciphertext) => {
197
+ 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);
206
+ }
207
+ throw new Error('Crypto provider required for encryption');
208
+ },
209
+
210
+ export: () => ({
211
+ sendKey: Array.from(sendKey),
212
+ recvKey: Array.from(recvKey),
213
+ sendNonce,
214
+ recvNonce,
215
+ nonceSize: 24
216
+ })
217
+ };
218
+ }
91
219
  };
92
220
  }
93
221
 
94
222
  async _onInit(peerId, payload, transport) {
95
- if (this._pending.has(peerId)) { throw HandshakeError.alreadyInProgress(peerId); }
223
+ const existing = this._pending.get(peerId);
224
+
225
+ if (existing) {
226
+ // Tie-breaking: compare public keys, lower key becomes responder
227
+ const localKey = this._keyManager.getPublicKey();
228
+ const shouldYield = this._compareKeys(localKey, peerId) > 0;
229
+
230
+ if (shouldYield) {
231
+ // Cancel our outgoing handshake and accept incoming
232
+ this.cancelHandshake(peerId);
233
+ } else {
234
+ // Keep our outgoing, reject incoming
235
+ throw HandshakeError.alreadyInProgress(peerId);
236
+ }
237
+ }
238
+
96
239
  const hs = this._createState(peerId, false);
97
240
  this._pending.set(peerId, hs);
98
241
  this.emit(EVENTS.HANDSHAKE_STARTED, { peerId, role: 'responder' });
99
242
  hs.noise.readMessage1(payload);
100
- await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_RESPONSE, hs.noise.writeMessage2()));
243
+ const msg2 = hs.noise.writeMessage2();
244
+ await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_RESPONSE, msg2));
101
245
  hs.state = STATE.RESPONDER_WAITING;
102
246
  hs.step = 2;
103
247
  this.emit(EVENTS.HANDSHAKE_PROGRESS, { peerId, step: 2, role: 'responder' });
@@ -107,9 +251,12 @@ class HandshakeManager extends EventEmitter {
107
251
 
108
252
  async _onResponse(peerId, payload, transport) {
109
253
  const hs = this._pending.get(peerId);
110
- if (!hs || !hs.isInitiator) { throw HandshakeError.invalidState(peerId, 2); }
254
+ if (!hs || !hs.isInitiator) {
255
+ throw HandshakeError.invalidState(peerId, 2);
256
+ }
111
257
  hs.noise.readMessage2(payload);
112
- await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_FINAL, hs.noise.writeMessage3()));
258
+ const msg3 = hs.noise.writeMessage3();
259
+ await transport.send(peerId, this._wrap(MESSAGE_TYPE.HANDSHAKE_FINAL, msg3));
113
260
  hs.step = 3;
114
261
  this.emit(EVENTS.HANDSHAKE_PROGRESS, { peerId, step: 3, role: 'initiator' });
115
262
  return this._complete(peerId, hs);
@@ -166,6 +313,13 @@ class HandshakeManager extends EventEmitter {
166
313
  });
167
314
  }
168
315
 
316
+ _compareKeys(localKey, remoteId) {
317
+ // Simple string/byte comparison for deterministic tie-breaking
318
+ const localStr = typeof localKey === 'string' ? localKey : Array.from(localKey).join(',');
319
+ const remoteStr = typeof remoteId === 'string' ? remoteId : Array.from(remoteId).join(',');
320
+ return localStr < remoteStr ? -1 : localStr > remoteStr ? 1 : 0;
321
+ }
322
+
169
323
  _wrap(type, payload) {
170
324
  const w = new Uint8Array(1 + payload.length);
171
325
  w[0] = type;
@@ -118,6 +118,14 @@ class MeshService extends EventEmitter {
118
118
  return this._textManager.sendBroadcast(content);
119
119
  }
120
120
  const messageId = this._generateMessageId();
121
+ // Actually send through the transport
122
+ if (this._transport) {
123
+ const payload = new TextEncoder().encode(content);
124
+ const data = new Uint8Array(1 + payload.length);
125
+ data[0] = MESSAGE_TYPE.TEXT;
126
+ data.set(payload, 1);
127
+ this._transport.broadcast(data);
128
+ }
121
129
  this.emit(EVENTS.BROADCAST_SENT, { messageId, content });
122
130
  return messageId;
123
131
  }
@@ -233,9 +241,10 @@ class MeshService extends EventEmitter {
233
241
  }
234
242
 
235
243
  async _sendRaw(peerId, data) {
236
- if (this._transport) {
237
- await this._transport.send(peerId, data);
244
+ if (this._state === SERVICE_STATE.DESTROYED || !this._transport) {
245
+ return; // Silently ignore sends after destroy
238
246
  }
247
+ await this._transport.send(peerId, data);
239
248
  }
240
249
 
241
250
  getStatus() {
@@ -266,10 +275,49 @@ class MeshService extends EventEmitter {
266
275
  _generateMessageId() { return `msg_${Date.now()}_${++this._messageCounter}`; }
267
276
 
268
277
  _createKeyManager() {
278
+ const { createProvider } = require('../crypto/AutoCrypto');
279
+ let provider;
280
+ let keyPair;
281
+
282
+ try {
283
+ provider = createProvider('auto');
284
+ keyPair = provider.generateKeyPair();
285
+ } catch (e) {
286
+ // If no crypto provider is available, return a minimal fallback
287
+ // that generates random keys using basic randomBytes
288
+ const { randomBytes } = require('../utils/bytes');
289
+ keyPair = { publicKey: randomBytes(32), secretKey: randomBytes(32) };
290
+ return {
291
+ getStaticKeyPair: () => keyPair,
292
+ getPublicKey: () => keyPair.publicKey,
293
+ exportIdentity: () => ({ publicKey: Array.from(keyPair.publicKey) }),
294
+ importIdentity: (id) => {
295
+ if (id && id.publicKey) {
296
+ keyPair.publicKey = new Uint8Array(id.publicKey);
297
+ }
298
+ if (id && id.secretKey) {
299
+ keyPair.secretKey = new Uint8Array(id.secretKey);
300
+ }
301
+ }
302
+ };
303
+ }
304
+
269
305
  return {
270
- getStaticKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }),
271
- getPublicKey: () => new Uint8Array(32),
272
- exportIdentity: () => ({}), importIdentity: () => {}
306
+ getStaticKeyPair: () => keyPair,
307
+ getPublicKey: () => keyPair.publicKey,
308
+ provider,
309
+ exportIdentity: () => ({
310
+ publicKey: Array.from(keyPair.publicKey),
311
+ secretKey: Array.from(keyPair.secretKey)
312
+ }),
313
+ importIdentity: (id) => {
314
+ if (id && id.publicKey) {
315
+ keyPair.publicKey = new Uint8Array(id.publicKey);
316
+ }
317
+ if (id && id.secretKey) {
318
+ keyPair.secretKey = new Uint8Array(id.secretKey);
319
+ }
320
+ }
273
321
  };
274
322
  }
275
323
 
@@ -295,7 +343,25 @@ class MeshService extends EventEmitter {
295
343
  if (this._textManager) {
296
344
  this._textManager.handleIncomingMessage(peerId, type, payload);
297
345
  } else {
298
- this._channelManager.handleChannelMessage({ channelId: '', senderId: peerId, content: payload });
346
+ // Try to parse channel ID from payload
347
+ let channelId = '';
348
+ let content = payload;
349
+ try {
350
+ const decoded = new TextDecoder().decode(payload);
351
+ const parsed = JSON.parse(decoded);
352
+ channelId = parsed.channelId || '';
353
+ content = parsed.content ? new TextEncoder().encode(parsed.content) : payload;
354
+ } catch (e) {
355
+ // If not JSON, try to extract channelId as length-prefixed string
356
+ if (payload.length > 1) {
357
+ const channelIdLen = payload[0];
358
+ if (channelIdLen > 0 && channelIdLen < payload.length) {
359
+ channelId = new TextDecoder().decode(payload.subarray(1, 1 + channelIdLen));
360
+ content = payload.subarray(1 + channelIdLen);
361
+ }
362
+ }
363
+ }
364
+ this._channelManager.handleChannelMessage({ channelId, senderId: peerId, content });
299
365
  }
300
366
  } else if (type >= MESSAGE_TYPE.VOICE_MESSAGE_START && type <= MESSAGE_TYPE.AUDIO_STREAM_END) {
301
367
  if (this._audioManager) {
@@ -7,6 +7,9 @@
7
7
 
8
8
  const { CryptoError } = require('../errors');
9
9
 
10
+ const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+ const MAX_MESSAGE_COUNT = 1000000; // 1 million messages before nonce exhaustion risk
12
+
10
13
  /**
11
14
  * Manages Noise Protocol sessions for secure peer communication.
12
15
  * @class SessionManager
@@ -39,6 +42,19 @@ class SessionManager {
39
42
  encryptFor(peerId, plaintext) {
40
43
  const entry = this._sessions.get(peerId);
41
44
  if (!entry) { throw CryptoError.encryptionFailed({ reason: 'Session not found', peerId }); }
45
+
46
+ // Check session expiry
47
+ if (Date.now() - entry.createdAt > MAX_SESSION_AGE_MS) {
48
+ this._sessions.delete(peerId);
49
+ throw CryptoError.encryptionFailed({ reason: 'Session expired', peerId });
50
+ }
51
+
52
+ // Check nonce exhaustion
53
+ if (entry.messageCount >= MAX_MESSAGE_COUNT) {
54
+ this._sessions.delete(peerId);
55
+ throw CryptoError.encryptionFailed({ reason: 'Session message limit reached, re-handshake required', peerId });
56
+ }
57
+
42
58
  try {
43
59
  const ciphertext = entry.session.encrypt(plaintext);
44
60
  entry.lastUsedAt = Date.now();
@@ -60,7 +76,7 @@ class SessionManager {
60
76
  }
61
77
  return plaintext;
62
78
  } catch (error) {
63
- return null;
79
+ throw CryptoError.decryptionFailed({ reason: error.message, peerId });
64
80
  }
65
81
  }
66
82
 
@@ -75,8 +91,67 @@ class SessionManager {
75
91
 
76
92
  importSession(peerId, state) {
77
93
  if (!state || !state.sessionData) { throw new Error('Invalid session state'); }
94
+
95
+ const data = state.sessionData;
96
+
97
+ // If sessionData already has encrypt/decrypt methods, use it directly
98
+ if (typeof data.encrypt === 'function' && typeof data.decrypt === 'function') {
99
+ this._sessions.set(peerId, {
100
+ session: data,
101
+ createdAt: state.createdAt || Date.now(),
102
+ lastUsedAt: state.lastUsedAt || Date.now(),
103
+ messageCount: state.messageCount || 0
104
+ });
105
+ return;
106
+ }
107
+
108
+ // Reconstruct session from exported key material
109
+ const sendKey = data.sendKey instanceof Uint8Array ? data.sendKey : new Uint8Array(data.sendKey);
110
+ const recvKey = data.recvKey instanceof Uint8Array ? data.recvKey : new Uint8Array(data.recvKey);
111
+ let sendNonce = data.sendNonce || 0;
112
+ let recvNonce = data.recvNonce || 0;
113
+
114
+ // Try to get crypto provider for real encrypt/decrypt
115
+ let provider = null;
116
+ try {
117
+ const { createProvider } = require('../crypto/AutoCrypto');
118
+ provider = createProvider('auto');
119
+ } catch (e) {
120
+ // No crypto provider available
121
+ }
122
+
123
+ const session = {
124
+ encrypt: (plaintext) => {
125
+ 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);
131
+ }
132
+ return plaintext;
133
+ },
134
+ decrypt: (ciphertext) => {
135
+ 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);
141
+ }
142
+ return ciphertext;
143
+ },
144
+ export: () => ({
145
+ sendKey: Array.from(sendKey),
146
+ recvKey: Array.from(recvKey),
147
+ sendNonce,
148
+ recvNonce,
149
+ nonceSize: 24
150
+ })
151
+ };
152
+
78
153
  this._sessions.set(peerId, {
79
- session: state.sessionData,
154
+ session,
80
155
  createdAt: state.createdAt || Date.now(),
81
156
  lastUsedAt: state.lastUsedAt || Date.now(),
82
157
  messageCount: state.messageCount || 0
@@ -121,9 +121,15 @@ class AudioManager extends EventEmitter {
121
121
  * @returns {Promise<void>}
122
122
  */
123
123
  async destroy() {
124
- // End all sessions
124
+ // End all sessions and wait for completion
125
+ const endPromises = [];
125
126
  for (const [, session] of this._sessions) {
126
- await session.end();
127
+ if (session && typeof session.end === 'function') {
128
+ endPromises.push(session.end().catch(() => {}));
129
+ }
130
+ }
131
+ if (endPromises.length > 0) {
132
+ await Promise.all(endPromises);
127
133
  }
128
134
  this._sessions.clear();
129
135
 
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview File assembler — reassembles chunks into complete files
5
+ * @module service/file/FileAssembler
6
+ */
7
+
8
+ /**
9
+ * Reassembles file chunks into a complete file.
10
+ * @class FileAssembler
11
+ */
12
+ class FileAssembler {
13
+ /**
14
+ * @param {string} transferId - Transfer ID
15
+ * @param {number} totalChunks - Expected total chunks
16
+ * @param {number} totalSize - Expected total file size
17
+ */
18
+ constructor(transferId, totalChunks, totalSize) {
19
+ this._transferId = transferId;
20
+ this._totalChunks = totalChunks;
21
+ this._totalSize = totalSize;
22
+ /** @type {Map<number, Uint8Array>} */
23
+ this._chunks = new Map();
24
+ this._receivedBytes = 0;
25
+ }
26
+
27
+ /**
28
+ * Adds a chunk
29
+ * @param {number} index - Chunk index
30
+ * @param {Uint8Array} data - Chunk data
31
+ * @returns {boolean} True if this was a new chunk
32
+ */
33
+ addChunk(index, data) {
34
+ if (index < 0 || index >= this._totalChunks) {
35
+ return false;
36
+ }
37
+ if (this._chunks.has(index)) {
38
+ return false; // duplicate
39
+ }
40
+ this._chunks.set(index, data);
41
+ this._receivedBytes += data.length;
42
+ return true;
43
+ }
44
+
45
+ /**
46
+ * Checks if all chunks have been received
47
+ * @returns {boolean}
48
+ */
49
+ isComplete() {
50
+ return this._chunks.size === this._totalChunks;
51
+ }
52
+
53
+ /**
54
+ * Gets progress percentage
55
+ * @returns {number} 0-100
56
+ */
57
+ get progress() {
58
+ if (this._totalChunks === 0) { return 100; }
59
+ return Math.round((this._chunks.size / this._totalChunks) * 100);
60
+ }
61
+
62
+ /**
63
+ * Gets number of received chunks
64
+ * @returns {number}
65
+ */
66
+ get receivedChunks() {
67
+ return this._chunks.size;
68
+ }
69
+
70
+ /**
71
+ * Assembles all chunks into a single Uint8Array
72
+ * @returns {Uint8Array} Complete file data
73
+ * @throws {Error} If not all chunks received
74
+ */
75
+ assemble() {
76
+ if (!this.isComplete()) {
77
+ throw new Error(
78
+ `Cannot assemble: received ${this._chunks.size}/${this._totalChunks} chunks`
79
+ );
80
+ }
81
+
82
+ const result = new Uint8Array(this._receivedBytes);
83
+ let offset = 0;
84
+
85
+ for (let i = 0; i < this._totalChunks; i++) {
86
+ const chunk = this._chunks.get(i);
87
+ result.set(chunk, offset);
88
+ offset += chunk.length;
89
+ }
90
+
91
+ // Free chunk memory immediately after assembly
92
+ this._chunks.clear();
93
+
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Clears all stored chunks
99
+ */
100
+ clear() {
101
+ this._chunks.clear();
102
+ this._receivedBytes = 0;
103
+ }
104
+ }
105
+
106
+ module.exports = FileAssembler;
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview File chunker — splits files into mesh-compatible chunks
5
+ * @module service/file/FileChunker
6
+ */
7
+
8
+ const DEFAULT_CHUNK_SIZE = 4096; // 4KB chunks
9
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB default max
10
+
11
+ /**
12
+ * Splits a file (Uint8Array) into chunks for mesh transfer.
13
+ * @class FileChunker
14
+ */
15
+ class FileChunker {
16
+ /**
17
+ * @param {Object} [options={}]
18
+ * @param {number} [options.chunkSize=4096] - Chunk size in bytes
19
+ * @param {number} [options.maxFileSize=10485760] - Max file size in bytes
20
+ */
21
+ constructor(options = {}) {
22
+ this._chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
23
+ this._maxFileSize = options.maxFileSize || MAX_FILE_SIZE;
24
+ }
25
+
26
+ /**
27
+ * Splits data into chunks
28
+ * @param {Uint8Array} data - File data
29
+ * @param {string} transferId - Transfer ID
30
+ * @returns {Object[]} Array of chunk objects
31
+ * @throws {Error} If data exceeds max file size
32
+ */
33
+ chunk(data, transferId) {
34
+ if (!(data instanceof Uint8Array)) {
35
+ throw new Error('Data must be a Uint8Array');
36
+ }
37
+ if (data.length > this._maxFileSize) {
38
+ throw new Error(`File size ${data.length} exceeds max ${this._maxFileSize} bytes`);
39
+ }
40
+ if (data.length === 0) {
41
+ return [];
42
+ }
43
+
44
+ const chunks = [];
45
+ const totalChunks = Math.ceil(data.length / this._chunkSize);
46
+
47
+ for (let i = 0; i < totalChunks; i++) {
48
+ const start = i * this._chunkSize;
49
+ const end = Math.min(start + this._chunkSize, data.length);
50
+ chunks.push({
51
+ transferId,
52
+ index: i,
53
+ totalChunks,
54
+ data: data.slice(start, end)
55
+ });
56
+ }
57
+
58
+ return chunks;
59
+ }
60
+
61
+ /**
62
+ * Gets the number of chunks needed for a given data size
63
+ * @param {number} dataSize - Data size in bytes
64
+ * @returns {number} Number of chunks
65
+ */
66
+ getChunkCount(dataSize) {
67
+ return Math.ceil(dataSize / this._chunkSize);
68
+ }
69
+
70
+ /**
71
+ * Gets the chunk size
72
+ * @returns {number}
73
+ */
74
+ get chunkSize() {
75
+ return this._chunkSize;
76
+ }
77
+ }
78
+
79
+ module.exports = FileChunker;