@vdoninja/mcp 0.4.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.
@@ -0,0 +1,2331 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const EventEmitter = require('node:events');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const { loadVDONinjaSDK } = require('./load-vdo-sdk');
9
+ const {
10
+ intFromValue,
11
+ nonNegativeIntFromValue,
12
+ clampInt,
13
+ nowISO,
14
+ sha256Hex,
15
+ hmacSha256Hex,
16
+ createJoinToken,
17
+ verifyJoinToken,
18
+ ensureStringArray,
19
+ safeJsonParse,
20
+ generateShortId
21
+ } = require('./bridge-utils');
22
+
23
+ const VDONinjaSDK = loadVDONinjaSDK();
24
+
25
+ const PROTOCOL_MAGIC = 'vdo_mcp_bridge_v1';
26
+ const MAX_PROTOCOL_EVENTS = 4000;
27
+ const DEFAULT_FILE_CHUNK_BYTES = 64 * 1024;
28
+ const DEFAULT_FILE_MAX_BYTES = 64 * 1024 * 1024;
29
+ const DEFAULT_FILE_ACK_TIMEOUT_MS = 1200;
30
+ const DEFAULT_FILE_MAX_RETRIES = 6;
31
+ const DEFAULT_JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
32
+ const DEFAULT_FILE_SPOOL_THRESHOLD_BYTES = 4 * 1024 * 1024;
33
+ const MAX_CHUNK_BYTES = 256 * 1024;
34
+ const MIN_CHUNK_BYTES = 1024;
35
+ const DEFAULT_STATE_MAX_KEYS = 2048;
36
+ const DEFAULT_STATE_MAX_SNAPSHOT_ENTRIES = 4096;
37
+
38
+ function asBuffer(data) {
39
+ if (Buffer.isBuffer(data)) return data;
40
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
41
+ return Buffer.from(String(data), 'utf8');
42
+ }
43
+
44
+ function nextMissingSeq(record) {
45
+ for (let i = 0; i < record.total_chunks; i += 1) {
46
+ if (!record.received_chunks.has(i)) return i;
47
+ }
48
+ return record.total_chunks;
49
+ }
50
+
51
+ function isProtocolEnvelope(data) {
52
+ return !!(
53
+ data &&
54
+ typeof data === 'object' &&
55
+ !Array.isArray(data) &&
56
+ data.__vdo_mcp === PROTOCOL_MAGIC &&
57
+ typeof data.kind === 'string'
58
+ );
59
+ }
60
+
61
+ function encodePublicKey(keyObject) {
62
+ return keyObject.export({ type: 'spki', format: 'der' }).toString('base64');
63
+ }
64
+
65
+ function decodePublicKey(base64) {
66
+ return crypto.createPublicKey({
67
+ key: Buffer.from(base64, 'base64'),
68
+ type: 'spki',
69
+ format: 'der'
70
+ });
71
+ }
72
+
73
+ function signaturePayload(envelope) {
74
+ return JSON.stringify({
75
+ kind: envelope.kind,
76
+ ts: envelope.ts,
77
+ nonce: envelope.nonce,
78
+ room: envelope.room,
79
+ from_stream_id: envelope.from_stream_id,
80
+ payload: envelope.payload || {}
81
+ });
82
+ }
83
+
84
+ function safeFileToken(value) {
85
+ return String(value || 'x')
86
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
87
+ .replace(/^_+|_+$/g, '')
88
+ .slice(0, 120) || 'x';
89
+ }
90
+
91
+ function stateShouldApply(existing, incoming) {
92
+ if (!existing) return true;
93
+ const incomingClock = intFromValue(incoming.clock, 0);
94
+ const existingClock = intFromValue(existing.clock, 0);
95
+ if (incomingClock > existingClock) return true;
96
+ if (incomingClock < existingClock) return false;
97
+ const incomingActor = String(incoming.actor || '');
98
+ const existingActor = String(existing.actor || '');
99
+ return incomingActor > existingActor;
100
+ }
101
+
102
+ class BridgeSession extends EventEmitter {
103
+ constructor(config, options = {}) {
104
+ super();
105
+ this.config = {
106
+ host: config.host || 'wss://wss.vdo.ninja',
107
+ room: config.room,
108
+ streamID: config.streamID,
109
+ targetStreamID: config.targetStreamID || null,
110
+ password: config.password,
111
+ label: config.label || 'mcp_bridge',
112
+ forceTURN: !!config.forceTURN,
113
+ debug: !!config.debug,
114
+ reconnectDelayMs: intFromValue(config.reconnectDelayMs, 3000),
115
+ maxReconnectDelayMs: intFromValue(config.maxReconnectDelayMs, 30000),
116
+ heartbeatMs: intFromValue(config.heartbeatMs, 15000),
117
+ maxQueueSize: intFromValue(config.maxQueueSize, 2000),
118
+ joinToken: typeof config.joinToken === 'string' && config.joinToken.trim() ? config.joinToken.trim() : null,
119
+ joinTokenSecret: typeof config.joinTokenSecret === 'string' && config.joinTokenSecret.trim()
120
+ ? config.joinTokenSecret.trim()
121
+ : null,
122
+ joinTokenTtlMs: intFromValue(config.joinTokenTtlMs, DEFAULT_JOIN_TOKEN_TTL_MS),
123
+ enforceJoinToken: !!config.enforceJoinToken,
124
+ allowPeerStreamIDs: ensureStringArray(config.allowPeerStreamIDs),
125
+ fileChunkBytes: clampInt(config.fileChunkBytes, DEFAULT_FILE_CHUNK_BYTES, MIN_CHUNK_BYTES, MAX_CHUNK_BYTES),
126
+ fileMaxBytes: intFromValue(config.fileMaxBytes, DEFAULT_FILE_MAX_BYTES),
127
+ fileAckTimeoutMs: intFromValue(config.fileAckTimeoutMs, DEFAULT_FILE_ACK_TIMEOUT_MS),
128
+ fileMaxRetries: intFromValue(config.fileMaxRetries, DEFAULT_FILE_MAX_RETRIES),
129
+ maxStoredFiles: intFromValue(config.maxStoredFiles, 12),
130
+ requireSessionMac: !!config.requireSessionMac,
131
+ fileSpoolDir: (typeof config.fileSpoolDir === 'string' && config.fileSpoolDir.trim())
132
+ ? config.fileSpoolDir.trim()
133
+ : path.join(os.tmpdir(), 'vdo-ninja-mcp-spool'),
134
+ fileSpoolThresholdBytes: intFromValue(config.fileSpoolThresholdBytes, DEFAULT_FILE_SPOOL_THRESHOLD_BYTES),
135
+ keepSpoolFiles: !!config.keepSpoolFiles,
136
+ stateMaxKeys: intFromValue(config.stateMaxKeys, DEFAULT_STATE_MAX_KEYS),
137
+ stateMaxSnapshotEntries: intFromValue(config.stateMaxSnapshotEntries, DEFAULT_STATE_MAX_SNAPSHOT_ENTRIES)
138
+ };
139
+
140
+ this.id = options.id || null;
141
+ this.sdkFactory = options.sdkFactory || ((sdkConfig) => new VDONinjaSDK(sdkConfig));
142
+
143
+ this.sdk = null;
144
+ this.running = false;
145
+ this.connecting = false;
146
+ this.state = 'idle';
147
+ this.connectedPeers = new Set();
148
+ this.boundListeners = [];
149
+ this.reconnectTimer = null;
150
+ this.heartbeatTimer = null;
151
+ this.currentReconnectDelayMs = this.config.reconnectDelayMs;
152
+ this.reconnectCount = 0;
153
+ this.lastError = null;
154
+ this.lastConnectedAt = null;
155
+ this.lastDisconnectedAt = null;
156
+ this.lastHeartbeatAt = null;
157
+ this.eventQueue = [];
158
+ this.lastReadyAt = null;
159
+ this.syncTick = 0;
160
+
161
+ this.peerState = new Map();
162
+ this.protocolEvents = [];
163
+ this.protocolCursor = 0;
164
+ this.outgoingTransfers = new Map();
165
+ this.incomingTransfers = new Map();
166
+ this.completedIncomingOrder = [];
167
+ this.completedOutgoingOrder = [];
168
+ this.spoolReady = false;
169
+
170
+ this.allowPeerStreamIDs = new Set(this.config.allowPeerStreamIDs);
171
+
172
+ this.stateClock = 0;
173
+ this.roomState = new Map();
174
+ this.stateActorClock = new Map();
175
+
176
+ this.keyAgreement = {
177
+ enabled: false,
178
+ publicKeyBase64: null,
179
+ privateKey: null
180
+ };
181
+ this.sharedKeys = new Map();
182
+ this.initializeKeyAgreement();
183
+ }
184
+
185
+ initializeKeyAgreement() {
186
+ try {
187
+ const pair = crypto.generateKeyPairSync('x25519');
188
+ this.keyAgreement = {
189
+ enabled: true,
190
+ publicKeyBase64: encodePublicKey(pair.publicKey),
191
+ privateKey: pair.privateKey
192
+ };
193
+ } catch (error) {
194
+ this.keyAgreement = {
195
+ enabled: false,
196
+ publicKeyBase64: null,
197
+ privateKey: null
198
+ };
199
+ }
200
+ }
201
+
202
+ ensureSpoolDirSync() {
203
+ if (this.spoolReady) return;
204
+ fs.mkdirSync(this.config.fileSpoolDir, { recursive: true });
205
+ this.spoolReady = true;
206
+ }
207
+
208
+ createSpoolFileSync(transferId, prefix) {
209
+ this.ensureSpoolDirSync();
210
+ const fileName = `${safeFileToken(prefix || 'transfer')}_${safeFileToken(transferId)}_${Date.now()}_${generateShortId('spool')}.bin`;
211
+ const spoolPath = path.join(this.config.fileSpoolDir, fileName);
212
+ const fd = fs.openSync(spoolPath, 'w');
213
+ return { spoolPath, fd };
214
+ }
215
+
216
+ closeTransferFd(transfer) {
217
+ if (!transfer || !Number.isInteger(transfer.spool_fd)) return;
218
+ try {
219
+ fs.closeSync(transfer.spool_fd);
220
+ } catch (error) {
221
+ // Ignore FD close races during cleanup.
222
+ }
223
+ transfer.spool_fd = null;
224
+ }
225
+
226
+ removeTransferSpoolFile(transfer) {
227
+ if (!transfer || !transfer.spool_path || this.config.keepSpoolFiles) return;
228
+ try {
229
+ fs.unlinkSync(transfer.spool_path);
230
+ } catch (error) {
231
+ // Ignore unlink errors; file may already be gone.
232
+ }
233
+ transfer.spool_path = null;
234
+ }
235
+
236
+ cleanupTransferStorage(transfer) {
237
+ this.closeTransferFd(transfer);
238
+ this.removeTransferSpoolFile(transfer);
239
+ }
240
+
241
+ cleanupAllTransferStorage() {
242
+ for (const transfer of this.incomingTransfers.values()) {
243
+ this.cleanupTransferStorage(transfer);
244
+ }
245
+ for (const transfer of this.outgoingTransfers.values()) {
246
+ this.cleanupTransferStorage(transfer);
247
+ }
248
+ }
249
+
250
+ streamHashSync(filePath) {
251
+ const hash = crypto.createHash('sha256');
252
+ const fd = fs.openSync(filePath, 'r');
253
+ const chunk = Buffer.alloc(64 * 1024);
254
+ let total = 0;
255
+ try {
256
+ while (true) {
257
+ const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null);
258
+ if (!bytesRead) break;
259
+ hash.update(chunk.subarray(0, bytesRead));
260
+ total += bytesRead;
261
+ }
262
+ } finally {
263
+ fs.closeSync(fd);
264
+ }
265
+ return {
266
+ hash: hash.digest('hex'),
267
+ totalBytes: total
268
+ };
269
+ }
270
+
271
+ readChunkFromPathSync(filePath, offset, length) {
272
+ const fd = fs.openSync(filePath, 'r');
273
+ try {
274
+ const out = Buffer.alloc(length);
275
+ const bytesRead = fs.readSync(fd, out, 0, length, offset);
276
+ return out.subarray(0, bytesRead);
277
+ } finally {
278
+ fs.closeSync(fd);
279
+ }
280
+ }
281
+
282
+ markStateActorClock(actor, clock) {
283
+ const safeActor = String(actor || this.config.streamID);
284
+ const parsed = intFromValue(clock, 0);
285
+ const current = intFromValue(this.stateActorClock.get(safeActor), 0);
286
+ if (parsed > current) {
287
+ this.stateActorClock.set(safeActor, parsed);
288
+ }
289
+ }
290
+
291
+ asStateEntry(key, value, actor, clock, updatedAt) {
292
+ return {
293
+ key: String(key),
294
+ value,
295
+ actor: String(actor || this.config.streamID),
296
+ clock: intFromValue(clock, 0),
297
+ updated_at: updatedAt || nowISO()
298
+ };
299
+ }
300
+
301
+ applyStatePatch(patch, source) {
302
+ if (!patch || typeof patch !== 'object') {
303
+ return { applied: false, reason: 'invalid_patch' };
304
+ }
305
+ const key = String(patch.key || '').trim();
306
+ if (!key) {
307
+ return { applied: false, reason: 'missing_key' };
308
+ }
309
+ const incoming = this.asStateEntry(
310
+ key,
311
+ patch.value,
312
+ patch.actor || this.config.streamID,
313
+ patch.clock,
314
+ patch.updated_at || nowISO()
315
+ );
316
+ if (incoming.clock <= 0) {
317
+ return { applied: false, reason: 'invalid_clock' };
318
+ }
319
+
320
+ const existing = this.roomState.get(incoming.key) || null;
321
+ if (!stateShouldApply(existing, incoming)) {
322
+ return { applied: false, reason: 'stale_patch', current: existing };
323
+ }
324
+
325
+ if (!existing && this.roomState.size >= this.config.stateMaxKeys) {
326
+ return { applied: false, reason: 'state_key_limit_reached' };
327
+ }
328
+
329
+ this.roomState.set(incoming.key, incoming);
330
+ this.markStateActorClock(incoming.actor, incoming.clock);
331
+ const localActorClock = intFromValue(this.stateActorClock.get(this.config.streamID), 0);
332
+ this.stateClock = Math.max(this.stateClock, localActorClock);
333
+
334
+ this.queueEvent({
335
+ type: 'state_updated',
336
+ ts: nowISO(),
337
+ source: source || 'remote',
338
+ key: incoming.key,
339
+ actor: incoming.actor,
340
+ clock: incoming.clock,
341
+ value: incoming.value
342
+ });
343
+
344
+ return { applied: true, entry: incoming };
345
+ }
346
+
347
+ setLocalState(key, value, options = {}) {
348
+ const safeKey = String(key || '').trim();
349
+ if (!safeKey) {
350
+ throw new Error('state key is required');
351
+ }
352
+
353
+ this.stateClock += 1;
354
+ const entry = this.asStateEntry(safeKey, value, this.config.streamID, this.stateClock, nowISO());
355
+ this.roomState.set(entry.key, entry);
356
+ this.markStateActorClock(entry.actor, entry.clock);
357
+
358
+ const shouldBroadcast = options.broadcast !== false;
359
+ if (shouldBroadcast) {
360
+ this.sendProtocol('state.patch', entry, options.target || null, { preferMac: true });
361
+ }
362
+
363
+ this.queueEvent({
364
+ type: 'state_updated',
365
+ ts: nowISO(),
366
+ source: 'local',
367
+ key: entry.key,
368
+ actor: entry.actor,
369
+ clock: entry.clock,
370
+ value: entry.value
371
+ });
372
+
373
+ return entry;
374
+ }
375
+
376
+ listStateEntries() {
377
+ return Array.from(this.roomState.values())
378
+ .sort((a, b) => a.key.localeCompare(b.key))
379
+ .map((entry) => ({ ...entry }));
380
+ }
381
+
382
+ getStateSnapshot() {
383
+ const entries = this.listStateEntries();
384
+ return {
385
+ room: this.config.room,
386
+ stream_id: this.config.streamID,
387
+ entries: entries.slice(0, this.config.stateMaxSnapshotEntries),
388
+ actor_clock: Object.fromEntries(this.stateActorClock.entries()),
389
+ generated_at: nowISO()
390
+ };
391
+ }
392
+
393
+ requestStateSnapshot(target) {
394
+ return this.sendProtocol('state.snapshot_req', {
395
+ room: this.config.room,
396
+ requester_stream_id: this.config.streamID
397
+ }, target || null, { preferMac: true });
398
+ }
399
+
400
+ sendStateSnapshot(target) {
401
+ return this.sendProtocol('state.snapshot', this.getStateSnapshot(), target || null, { preferMac: true });
402
+ }
403
+
404
+ getStateValue(key, includeMeta) {
405
+ if (key === undefined || key === null || key === '') {
406
+ if (includeMeta) {
407
+ return {
408
+ entries: this.listStateEntries(),
409
+ actor_clock: Object.fromEntries(this.stateActorClock.entries())
410
+ };
411
+ }
412
+ const obj = {};
413
+ for (const entry of this.roomState.values()) {
414
+ obj[entry.key] = entry.value;
415
+ }
416
+ return obj;
417
+ }
418
+
419
+ const safeKey = String(key);
420
+ const entry = this.roomState.get(safeKey) || null;
421
+ if (!entry) return null;
422
+ if (includeMeta) return { ...entry };
423
+ return entry.value;
424
+ }
425
+
426
+ async start() {
427
+ if (this.running) {
428
+ return this.getStatus();
429
+ }
430
+ this.running = true;
431
+ this.state = 'starting';
432
+ await this.connectSession();
433
+ this.startHeartbeat();
434
+ return this.getStatus();
435
+ }
436
+
437
+ async stop() {
438
+ this.running = false;
439
+ this.connecting = false;
440
+ this.state = 'stopped';
441
+
442
+ if (this.reconnectTimer) {
443
+ clearTimeout(this.reconnectTimer);
444
+ this.reconnectTimer = null;
445
+ }
446
+ if (this.heartbeatTimer) {
447
+ clearInterval(this.heartbeatTimer);
448
+ this.heartbeatTimer = null;
449
+ }
450
+
451
+ await this.teardownSdk();
452
+ this.cleanupAllTransferStorage();
453
+ this.queueEvent({
454
+ type: 'stopped',
455
+ ts: nowISO()
456
+ });
457
+ }
458
+
459
+ async connectSession() {
460
+ if (!this.running || this.connecting) return;
461
+ this.connecting = true;
462
+ if (this.state !== 'reconnecting') {
463
+ this.state = 'connecting';
464
+ }
465
+
466
+ try {
467
+ await this.teardownSdk();
468
+
469
+ this.sdk = this.sdkFactory({
470
+ host: this.config.host,
471
+ room: this.config.room,
472
+ password: this.config.password,
473
+ debug: this.config.debug,
474
+ forceTURN: this.config.forceTURN,
475
+ maxReconnectAttempts: 0,
476
+ reconnectDelay: this.config.reconnectDelayMs,
477
+ autoPingViewer: false
478
+ });
479
+
480
+ this.attachListeners();
481
+
482
+ await this.sdk.connect();
483
+ await this.sdk.joinRoom({
484
+ room: this.config.room,
485
+ password: this.config.password
486
+ });
487
+ await this.sdk.announce({
488
+ streamID: this.config.streamID,
489
+ label: this.config.label
490
+ });
491
+
492
+ if (this.config.targetStreamID && this.config.targetStreamID !== this.config.streamID) {
493
+ await this.sdk.view(this.config.targetStreamID, {
494
+ audio: false,
495
+ video: false,
496
+ label: `${this.config.label}_viewer`
497
+ });
498
+ }
499
+
500
+ this.state = 'connected';
501
+ this.lastConnectedAt = nowISO();
502
+ this.lastReadyAt = this.lastConnectedAt;
503
+ this.lastError = null;
504
+ this.currentReconnectDelayMs = this.config.reconnectDelayMs;
505
+
506
+ this.queueEvent({
507
+ type: 'ready',
508
+ ts: this.lastReadyAt,
509
+ room: this.config.room,
510
+ streamID: this.config.streamID,
511
+ targetStreamID: this.config.targetStreamID,
512
+ host: this.config.host,
513
+ forceTURN: this.config.forceTURN
514
+ });
515
+
516
+ this.sendSyncHello(null);
517
+ } catch (error) {
518
+ this.lastError = error.message;
519
+ this.queueEvent({
520
+ type: 'connect_error',
521
+ ts: nowISO(),
522
+ message: error.message
523
+ });
524
+ this.scheduleReconnect('connect_error');
525
+ } finally {
526
+ this.connecting = false;
527
+ }
528
+ }
529
+
530
+ async teardownSdk() {
531
+ if (!this.sdk) return;
532
+
533
+ for (const [eventName, handler] of this.boundListeners) {
534
+ try {
535
+ this.sdk.removeEventListener(eventName, handler);
536
+ } catch (error) {
537
+ // Ignore listener cleanup failures.
538
+ }
539
+ }
540
+ this.boundListeners = [];
541
+ this.connectedPeers.clear();
542
+
543
+ try {
544
+ this.sdk.disconnect();
545
+ } catch (error) {
546
+ // Ignore disconnect race failures.
547
+ }
548
+
549
+ this.sdk = null;
550
+ }
551
+
552
+ attachListeners() {
553
+ if (!this.sdk) return;
554
+
555
+ const bind = (eventName, handler) => {
556
+ this.sdk.addEventListener(eventName, handler);
557
+ this.boundListeners.push([eventName, handler]);
558
+ };
559
+
560
+ bind('connected', () => {
561
+ this.queueEvent({ type: 'connected', ts: nowISO() });
562
+ });
563
+
564
+ bind('disconnected', () => {
565
+ this.lastDisconnectedAt = nowISO();
566
+ this.state = 'disconnected';
567
+ this.queueEvent({ type: 'disconnected', ts: this.lastDisconnectedAt });
568
+ this.scheduleReconnect('disconnected');
569
+ });
570
+
571
+ bind('connectionFailed', (event) => {
572
+ this.queueEvent({
573
+ type: 'connection_failed',
574
+ ts: nowISO(),
575
+ detail: event.detail || null
576
+ });
577
+ this.scheduleReconnect('connection_failed');
578
+ });
579
+
580
+ bind('error', (event) => {
581
+ const message = event.detail && event.detail.error ? event.detail.error : 'sdk_error';
582
+ this.lastError = message;
583
+ this.queueEvent({
584
+ type: 'sdk_error',
585
+ ts: nowISO(),
586
+ detail: event.detail || null
587
+ });
588
+ });
589
+
590
+ bind('peerConnected', (event) => {
591
+ const uuid = event.detail && event.detail.uuid;
592
+ if (uuid) this.connectedPeers.add(uuid);
593
+ this.queueEvent({
594
+ type: 'peer_connected',
595
+ ts: nowISO(),
596
+ uuid: uuid || null,
597
+ detail: event.detail || null
598
+ });
599
+ if (uuid) {
600
+ this.touchPeer(uuid, null);
601
+ this.sendSyncHello(uuid);
602
+ }
603
+ });
604
+
605
+ bind('peerDisconnected', (event) => {
606
+ const uuid = event.detail && event.detail.uuid;
607
+ if (uuid) this.connectedPeers.delete(uuid);
608
+ this.queueEvent({
609
+ type: 'peer_disconnected',
610
+ ts: nowISO(),
611
+ uuid: uuid || null,
612
+ detail: event.detail || null
613
+ });
614
+ if (uuid && this.peerState.has(uuid)) {
615
+ const peer = this.peerState.get(uuid);
616
+ peer.connected = false;
617
+ peer.last_seen_at = nowISO();
618
+ }
619
+ });
620
+
621
+ bind('dataChannelOpen', (event) => {
622
+ const uuid = event.detail && event.detail.uuid;
623
+ this.queueEvent({
624
+ type: 'data_channel_open',
625
+ ts: nowISO(),
626
+ detail: event.detail || null
627
+ });
628
+ if (uuid) {
629
+ this.touchPeer(uuid, null);
630
+ this.sendSyncHello(uuid);
631
+ }
632
+ });
633
+
634
+ bind('dataChannelClose', (event) => {
635
+ this.queueEvent({
636
+ type: 'data_channel_close',
637
+ ts: nowISO(),
638
+ detail: event.detail || null
639
+ });
640
+ });
641
+
642
+ bind('dataReceived', (event) => {
643
+ const detail = event.detail || {};
644
+ let data = detail.data;
645
+ let binaryBase64 = null;
646
+
647
+ if (data instanceof ArrayBuffer) {
648
+ binaryBase64 = Buffer.from(data).toString('base64');
649
+ data = null;
650
+ } else if (Buffer.isBuffer(data)) {
651
+ binaryBase64 = data.toString('base64');
652
+ data = null;
653
+ }
654
+
655
+ if (data && isProtocolEnvelope(data)) {
656
+ this.handleProtocolMessage(detail, data);
657
+ return;
658
+ }
659
+
660
+ this.queueEvent({
661
+ type: 'data_received',
662
+ ts: nowISO(),
663
+ uuid: detail.uuid || null,
664
+ streamID: detail.streamID || null,
665
+ fallback: !!detail.fallback,
666
+ data,
667
+ binaryBase64
668
+ });
669
+ });
670
+ }
671
+
672
+ scheduleReconnect(reason) {
673
+ if (!this.running) return;
674
+ if (this.reconnectTimer) return;
675
+
676
+ const delay = this.currentReconnectDelayMs;
677
+ this.state = 'reconnecting';
678
+ this.queueEvent({
679
+ type: 'reconnect_scheduled',
680
+ ts: nowISO(),
681
+ reason,
682
+ inMs: delay
683
+ });
684
+
685
+ this.reconnectTimer = setTimeout(async () => {
686
+ this.reconnectTimer = null;
687
+ this.reconnectCount += 1;
688
+ this.currentReconnectDelayMs = Math.min(
689
+ this.currentReconnectDelayMs * 2,
690
+ this.config.maxReconnectDelayMs
691
+ );
692
+ await this.connectSession();
693
+ }, delay);
694
+ }
695
+
696
+ startHeartbeat() {
697
+ if (!this.running || this.heartbeatTimer) return;
698
+ this.heartbeatTimer = setInterval(() => {
699
+ this.sendHeartbeat(false);
700
+ }, this.config.heartbeatMs);
701
+ }
702
+
703
+ buildJoinToken() {
704
+ if (this.config.joinToken) return this.config.joinToken;
705
+ if (!this.config.joinTokenSecret) return null;
706
+
707
+ const exp = Date.now() + this.config.joinTokenTtlMs;
708
+ return createJoinToken({
709
+ room: this.config.room,
710
+ stream_id: this.config.streamID,
711
+ exp,
712
+ nonce: generateShortId('join')
713
+ }, this.config.joinTokenSecret);
714
+ }
715
+
716
+ validateJoinToken(token, streamID) {
717
+ if (!this.config.joinTokenSecret) {
718
+ if (this.config.enforceJoinToken && !token) {
719
+ return { ok: false, error: 'join token required' };
720
+ }
721
+ return { ok: true, payload: null };
722
+ }
723
+
724
+ if (!token) {
725
+ if (this.config.enforceJoinToken) {
726
+ return { ok: false, error: 'join token required' };
727
+ }
728
+ return { ok: true, payload: null };
729
+ }
730
+
731
+ const verified = verifyJoinToken(token, this.config.joinTokenSecret);
732
+ if (!verified.ok) {
733
+ return verified;
734
+ }
735
+
736
+ const payload = verified.payload || {};
737
+ if (payload.room && payload.room !== this.config.room) {
738
+ return { ok: false, error: 'join token room mismatch' };
739
+ }
740
+ if (payload.stream_id && streamID && payload.stream_id !== streamID) {
741
+ return { ok: false, error: 'join token stream mismatch' };
742
+ }
743
+ if (payload.exp && Number.isFinite(payload.exp) && Date.now() > payload.exp) {
744
+ return { ok: false, error: 'join token expired' };
745
+ }
746
+
747
+ return { ok: true, payload };
748
+ }
749
+
750
+ touchPeer(uuid, streamID) {
751
+ if (!uuid) return null;
752
+ let peer = this.peerState.get(uuid);
753
+ if (!peer) {
754
+ peer = {
755
+ uuid,
756
+ stream_id: streamID || null,
757
+ connected: true,
758
+ last_seen_at: nowISO(),
759
+ last_heartbeat_at: null,
760
+ handshake_state: 'discovered',
761
+ auth_ok: true,
762
+ rejected_reason: null,
763
+ shared_key_ready: false,
764
+ capabilities: null,
765
+ token_payload: null
766
+ };
767
+ this.peerState.set(uuid, peer);
768
+ }
769
+
770
+ peer.connected = true;
771
+ peer.last_seen_at = nowISO();
772
+ if (streamID) peer.stream_id = streamID;
773
+ return peer;
774
+ }
775
+
776
+ isPeerAllowed(streamID) {
777
+ if (!this.allowPeerStreamIDs.size) return true;
778
+ return !!streamID && this.allowPeerStreamIDs.has(streamID);
779
+ }
780
+
781
+ maybeDeriveSharedKey(uuid, remotePubKeyBase64) {
782
+ if (!this.keyAgreement.enabled || !this.keyAgreement.privateKey) return false;
783
+ if (!uuid || !remotePubKeyBase64 || typeof remotePubKeyBase64 !== 'string') return false;
784
+
785
+ try {
786
+ const remotePublic = decodePublicKey(remotePubKeyBase64);
787
+ const shared = crypto.diffieHellman({
788
+ privateKey: this.keyAgreement.privateKey,
789
+ publicKey: remotePublic
790
+ });
791
+ this.sharedKeys.set(uuid, shared);
792
+ const peer = this.touchPeer(uuid, null);
793
+ if (peer) peer.shared_key_ready = true;
794
+ return true;
795
+ } catch (error) {
796
+ return false;
797
+ }
798
+ }
799
+
800
+ computeEnvelopeMac(envelope, keyBuffer) {
801
+ if (!keyBuffer) return null;
802
+ return hmacSha256Hex(keyBuffer, signaturePayload(envelope));
803
+ }
804
+
805
+ buildProtocolEnvelope(kind, payload, targetUuid, opts = {}) {
806
+ const envelope = {
807
+ __vdo_mcp: PROTOCOL_MAGIC,
808
+ kind,
809
+ ts: Date.now(),
810
+ nonce: generateShortId('p'),
811
+ room: this.config.room,
812
+ from_stream_id: this.config.streamID,
813
+ payload: payload || {}
814
+ };
815
+
816
+ if (kind === 'sync.hello' || kind === 'sync.hello_ack') {
817
+ envelope.payload = Object.assign({}, envelope.payload, {
818
+ capabilities: {
819
+ sync_v1: true,
820
+ file_transfer_v1: true,
821
+ state_v1: true,
822
+ resume_v1: true,
823
+ session_mac_v1: this.keyAgreement.enabled
824
+ },
825
+ public_key: this.keyAgreement.publicKeyBase64,
826
+ join_token: this.buildJoinToken()
827
+ });
828
+ }
829
+
830
+ const sharedKey = targetUuid ? this.sharedKeys.get(targetUuid) : null;
831
+ const shouldMac = opts.preferMac && sharedKey;
832
+ if (shouldMac) {
833
+ envelope.mac = this.computeEnvelopeMac(envelope, sharedKey);
834
+ }
835
+
836
+ return envelope;
837
+ }
838
+
839
+ verifyEnvelopeMac(uuid, envelope) {
840
+ if (!envelope || typeof envelope !== 'object') return false;
841
+ const hasMac = typeof envelope.mac === 'string' && envelope.mac.length > 0;
842
+ const sharedKey = uuid ? this.sharedKeys.get(uuid) : null;
843
+
844
+ if (!hasMac) {
845
+ return !this.config.requireSessionMac;
846
+ }
847
+ if (!sharedKey) {
848
+ return false;
849
+ }
850
+
851
+ const expected = this.computeEnvelopeMac(envelope, sharedKey);
852
+ return envelope.mac === expected;
853
+ }
854
+
855
+ recordProtocolEvent(event) {
856
+ const stamped = Object.assign({
857
+ ts: nowISO(),
858
+ cursor: ++this.protocolCursor
859
+ }, event || {});
860
+ this.protocolEvents.push(stamped);
861
+ if (this.protocolEvents.length > MAX_PROTOCOL_EVENTS) {
862
+ this.protocolEvents.splice(0, this.protocolEvents.length - MAX_PROTOCOL_EVENTS);
863
+ }
864
+ this.emit('protocol', stamped);
865
+ return stamped;
866
+ }
867
+
868
+ findProtocolEvent(predicate, minCursor) {
869
+ const min = Number.isFinite(minCursor) ? minCursor : 0;
870
+ for (const event of this.protocolEvents) {
871
+ if (event.cursor <= min) continue;
872
+ if (predicate(event)) return event;
873
+ }
874
+ return null;
875
+ }
876
+
877
+ waitForProtocolEvent(predicate, timeoutMs, minCursor) {
878
+ const existing = this.findProtocolEvent(predicate, minCursor);
879
+ if (existing) {
880
+ return Promise.resolve(existing);
881
+ }
882
+
883
+ return new Promise((resolve) => {
884
+ const timer = setTimeout(() => {
885
+ this.removeListener('protocol', onProtocol);
886
+ resolve(null);
887
+ }, Math.max(1, intFromValue(timeoutMs, this.config.fileAckTimeoutMs)));
888
+
889
+ const onProtocol = (event) => {
890
+ if (event.cursor <= (Number.isFinite(minCursor) ? minCursor : 0)) {
891
+ return;
892
+ }
893
+ if (!predicate(event)) {
894
+ return;
895
+ }
896
+ clearTimeout(timer);
897
+ this.removeListener('protocol', onProtocol);
898
+ resolve(event);
899
+ };
900
+
901
+ this.on('protocol', onProtocol);
902
+ });
903
+ }
904
+
905
+ sendProtocol(kind, payload, target, opts = {}) {
906
+ if (!this.sdk) return false;
907
+ const envelope = this.buildProtocolEnvelope(kind, payload, typeof target === 'string' ? target : null, opts);
908
+ return this.send(envelope, target);
909
+ }
910
+
911
+ sendSyncHello(targetUuid) {
912
+ const ok = this.sendProtocol('sync.hello', {}, targetUuid || null, { preferMac: false });
913
+ this.queueEvent({
914
+ type: 'sync_hello_sent',
915
+ ts: nowISO(),
916
+ ok,
917
+ target_uuid: targetUuid || null
918
+ });
919
+ return ok;
920
+ }
921
+
922
+ sendHeartbeat(explicit) {
923
+ if (!this.sdk) return false;
924
+ const hasPeers = this.connectedPeers.size > 0;
925
+ const hasOpenChannels = this.hasOpenDataChannel();
926
+ const payload = {
927
+ type: 'bridge.keepalive',
928
+ ts: Date.now(),
929
+ streamID: this.config.streamID
930
+ };
931
+ const sent = hasPeers && hasOpenChannels ? this.sdk.sendData(payload) : false;
932
+ if (hasPeers && hasOpenChannels) {
933
+ this.sendProtocol('sync.heartbeat', {
934
+ room: this.config.room,
935
+ stream_id: this.config.streamID
936
+ }, null, { preferMac: false });
937
+ }
938
+
939
+ this.lastHeartbeatAt = nowISO();
940
+
941
+ for (const uuid of this.connectedPeers) {
942
+ if (!this.hasOpenDataChannel(uuid)) continue;
943
+ try {
944
+ this.sdk.sendPing(uuid);
945
+ } catch (error) {
946
+ // Ignore ping errors and rely on reconnect handling.
947
+ }
948
+ }
949
+
950
+ this.syncTick += 1;
951
+ if (this.syncTick % 4 === 0 && hasOpenChannels) {
952
+ this.sendSyncHello(null);
953
+ }
954
+
955
+ if (explicit) {
956
+ this.queueEvent({
957
+ type: 'heartbeat_sent',
958
+ ts: this.lastHeartbeatAt,
959
+ ok: !!sent
960
+ });
961
+ }
962
+ return !!sent;
963
+ }
964
+
965
+ resolvePeerTarget(target) {
966
+ if (target && typeof target === 'object' && typeof target.uuid === 'string' && target.uuid.trim()) {
967
+ return { ok: true, uuid: target.uuid.trim() };
968
+ }
969
+
970
+ if (typeof target === 'string' && target.trim()) {
971
+ const safe = target.trim();
972
+ if (this.peerState.has(safe) || this.connectedPeers.has(safe)) {
973
+ return { ok: true, uuid: safe };
974
+ }
975
+
976
+ for (const peer of this.peerState.values()) {
977
+ if (peer.stream_id === safe) {
978
+ return { ok: true, uuid: peer.uuid, stream_id: peer.stream_id };
979
+ }
980
+ }
981
+
982
+ return { ok: true, uuid: safe };
983
+ }
984
+
985
+ const peers = Array.from(this.connectedPeers);
986
+ if (peers.length === 1) {
987
+ return { ok: true, uuid: peers[0] };
988
+ }
989
+ if (peers.length === 0) {
990
+ return { ok: false, error: 'no connected peers' };
991
+ }
992
+ return { ok: false, error: 'ambiguous target: multiple peers connected' };
993
+ }
994
+
995
+ handleSyncMessage(fromUuid, fromStreamID, kind, payload) {
996
+ const peer = this.touchPeer(fromUuid, fromStreamID);
997
+ if (fromStreamID && !this.isPeerAllowed(fromStreamID)) {
998
+ if (peer) {
999
+ peer.handshake_state = 'rejected';
1000
+ peer.auth_ok = false;
1001
+ peer.rejected_reason = 'peer not on allowlist';
1002
+ }
1003
+ this.queueEvent({
1004
+ type: 'sync_peer_rejected',
1005
+ ts: nowISO(),
1006
+ uuid: fromUuid || null,
1007
+ stream_id: fromStreamID || null,
1008
+ reason: 'peer_not_allowed'
1009
+ });
1010
+ this.recordProtocolEvent({
1011
+ kind: 'sync.reject',
1012
+ from_uuid: fromUuid || null,
1013
+ payload: { reason: 'peer_not_allowed' }
1014
+ });
1015
+ return;
1016
+ }
1017
+
1018
+ if (kind === 'sync.heartbeat') {
1019
+ if (peer) {
1020
+ peer.last_heartbeat_at = nowISO();
1021
+ peer.handshake_state = peer.handshake_state === 'ready' ? 'ready' : peer.handshake_state;
1022
+ }
1023
+ return;
1024
+ }
1025
+
1026
+ if (kind !== 'sync.hello' && kind !== 'sync.hello_ack' && kind !== 'sync.reject') {
1027
+ return;
1028
+ }
1029
+
1030
+ if (kind === 'sync.reject') {
1031
+ if (peer) {
1032
+ peer.handshake_state = 'rejected';
1033
+ peer.auth_ok = false;
1034
+ peer.rejected_reason = payload && payload.reason ? String(payload.reason) : 'rejected_by_peer';
1035
+ }
1036
+ this.queueEvent({
1037
+ type: 'sync_peer_rejected',
1038
+ ts: nowISO(),
1039
+ uuid: fromUuid || null,
1040
+ stream_id: fromStreamID || null,
1041
+ reason: peer && peer.rejected_reason ? peer.rejected_reason : 'rejected_by_peer'
1042
+ });
1043
+ this.recordProtocolEvent({ kind, from_uuid: fromUuid || null, payload: payload || {} });
1044
+ return;
1045
+ }
1046
+
1047
+ const token = payload && typeof payload.join_token === 'string' ? payload.join_token : null;
1048
+ const tokenCheck = this.validateJoinToken(token, fromStreamID);
1049
+
1050
+ if (!tokenCheck.ok && this.config.enforceJoinToken) {
1051
+ if (peer) {
1052
+ peer.handshake_state = 'rejected';
1053
+ peer.auth_ok = false;
1054
+ peer.rejected_reason = tokenCheck.error;
1055
+ }
1056
+ this.sendProtocol('sync.reject', { reason: tokenCheck.error }, fromUuid || null, { preferMac: false });
1057
+ this.queueEvent({
1058
+ type: 'sync_peer_rejected',
1059
+ ts: nowISO(),
1060
+ uuid: fromUuid || null,
1061
+ stream_id: fromStreamID || null,
1062
+ reason: tokenCheck.error
1063
+ });
1064
+ this.recordProtocolEvent({ kind: 'sync.reject', from_uuid: fromUuid || null, payload: { reason: tokenCheck.error } });
1065
+ return;
1066
+ }
1067
+
1068
+ if (peer) {
1069
+ peer.auth_ok = tokenCheck.ok;
1070
+ peer.token_payload = tokenCheck.payload || null;
1071
+ peer.handshake_state = kind === 'sync.hello_ack' ? 'ready' : 'hello_received';
1072
+ peer.capabilities = payload && payload.capabilities ? payload.capabilities : null;
1073
+ }
1074
+
1075
+ if (payload && payload.public_key) {
1076
+ this.maybeDeriveSharedKey(fromUuid, payload.public_key);
1077
+ }
1078
+
1079
+ this.queueEvent({
1080
+ type: 'sync_peer_updated',
1081
+ ts: nowISO(),
1082
+ uuid: fromUuid || null,
1083
+ stream_id: fromStreamID || null,
1084
+ handshake_state: peer ? peer.handshake_state : 'unknown',
1085
+ auth_ok: peer ? peer.auth_ok : false,
1086
+ shared_key_ready: peer ? peer.shared_key_ready : false
1087
+ });
1088
+
1089
+ this.recordProtocolEvent({ kind, from_uuid: fromUuid || null, payload: payload || {} });
1090
+
1091
+ if (kind === 'sync.hello') {
1092
+ this.sendProtocol('sync.hello_ack', {}, fromUuid || null, { preferMac: true });
1093
+ this.requestStateSnapshot(fromUuid || null);
1094
+ return;
1095
+ }
1096
+
1097
+ if (kind === 'sync.hello_ack') {
1098
+ this.requestStateSnapshot(fromUuid || null);
1099
+ }
1100
+ }
1101
+
1102
+ createIncomingTransfer(fromUuid, fromStreamID, payload) {
1103
+ const totalBytes = intFromValue(payload.total_bytes, 0);
1104
+ const totalChunks = intFromValue(payload.total_chunks, 0);
1105
+ const chunkBytes = clampInt(payload.chunk_bytes, this.config.fileChunkBytes, MIN_CHUNK_BYTES, MAX_CHUNK_BYTES);
1106
+
1107
+ if (!payload.transfer_id || typeof payload.transfer_id !== 'string') {
1108
+ throw new Error('invalid transfer_id');
1109
+ }
1110
+ if (totalBytes <= 0 || totalBytes > this.config.fileMaxBytes) {
1111
+ throw new Error(`total_bytes must be 1..${this.config.fileMaxBytes}`);
1112
+ }
1113
+ if (totalChunks <= 0 || totalChunks > Math.ceil(this.config.fileMaxBytes / MIN_CHUNK_BYTES)) {
1114
+ throw new Error('invalid total_chunks');
1115
+ }
1116
+
1117
+ const useSpool = totalBytes >= this.config.fileSpoolThresholdBytes;
1118
+ let spoolPath = null;
1119
+ let spoolFd = null;
1120
+ if (useSpool) {
1121
+ const spool = this.createSpoolFileSync(payload.transfer_id, 'incoming');
1122
+ spoolPath = spool.spoolPath;
1123
+ spoolFd = spool.fd;
1124
+ }
1125
+
1126
+ const transfer = {
1127
+ transfer_id: payload.transfer_id,
1128
+ direction: 'incoming',
1129
+ status: 'receiving',
1130
+ from_uuid: fromUuid || null,
1131
+ from_stream_id: fromStreamID || null,
1132
+ name: payload.name || null,
1133
+ mime: payload.mime || 'application/octet-stream',
1134
+ total_bytes: totalBytes,
1135
+ total_chunks: totalChunks,
1136
+ chunk_bytes: chunkBytes,
1137
+ file_hash: payload.file_hash || null,
1138
+ received_chunks: new Set(),
1139
+ chunks: new Array(totalChunks),
1140
+ received_bytes: 0,
1141
+ spooled: useSpool,
1142
+ spool_path: spoolPath,
1143
+ spool_fd: spoolFd,
1144
+ created_at: nowISO(),
1145
+ updated_at: nowISO(),
1146
+ completed_at: null,
1147
+ complete_received: false,
1148
+ last_error: null,
1149
+ data: null
1150
+ };
1151
+
1152
+ this.incomingTransfers.set(transfer.transfer_id, transfer);
1153
+ return transfer;
1154
+ }
1155
+
1156
+ maybeFinalizeIncomingTransfer(transfer, fromUuid) {
1157
+ if (!transfer) return false;
1158
+ if (transfer.received_chunks.size !== transfer.total_chunks) {
1159
+ return false;
1160
+ }
1161
+
1162
+ let computed = null;
1163
+ if (transfer.spooled && transfer.spool_path) {
1164
+ this.closeTransferFd(transfer);
1165
+ const hashed = this.streamHashSync(transfer.spool_path);
1166
+ if (hashed.totalBytes !== transfer.total_bytes) {
1167
+ transfer.status = 'failed';
1168
+ transfer.last_error = `byte size mismatch expected=${transfer.total_bytes} got=${hashed.totalBytes}`;
1169
+ return false;
1170
+ }
1171
+ computed = hashed.hash;
1172
+ } else {
1173
+ const parts = [];
1174
+ for (let i = 0; i < transfer.total_chunks; i += 1) {
1175
+ const chunk = transfer.chunks[i];
1176
+ if (!chunk) {
1177
+ return false;
1178
+ }
1179
+ parts.push(chunk);
1180
+ }
1181
+
1182
+ const fullData = Buffer.concat(parts);
1183
+ if (fullData.length !== transfer.total_bytes) {
1184
+ transfer.status = 'failed';
1185
+ transfer.last_error = `byte size mismatch expected=${transfer.total_bytes} got=${fullData.length}`;
1186
+ return false;
1187
+ }
1188
+
1189
+ computed = sha256Hex(fullData);
1190
+ transfer.data = fullData;
1191
+ }
1192
+
1193
+ if (transfer.file_hash && computed !== transfer.file_hash) {
1194
+ transfer.status = 'failed';
1195
+ transfer.last_error = 'file hash mismatch';
1196
+ return false;
1197
+ }
1198
+
1199
+ transfer.status = 'completed';
1200
+ transfer.completed_at = nowISO();
1201
+ transfer.updated_at = transfer.completed_at;
1202
+
1203
+ this.completedIncomingOrder.push(transfer.transfer_id);
1204
+ while (this.completedIncomingOrder.length > this.config.maxStoredFiles) {
1205
+ const oldestId = this.completedIncomingOrder.shift();
1206
+ if (!oldestId) continue;
1207
+ const stale = this.incomingTransfers.get(oldestId);
1208
+ if (stale) {
1209
+ this.cleanupTransferStorage(stale);
1210
+ this.incomingTransfers.delete(oldestId);
1211
+ }
1212
+ }
1213
+
1214
+ this.queueEvent({
1215
+ type: 'file_received',
1216
+ ts: nowISO(),
1217
+ transfer_id: transfer.transfer_id,
1218
+ from_uuid: transfer.from_uuid,
1219
+ from_stream_id: transfer.from_stream_id,
1220
+ name: transfer.name,
1221
+ mime: transfer.mime,
1222
+ total_bytes: transfer.total_bytes,
1223
+ file_hash: computed,
1224
+ spooled: !!transfer.spooled,
1225
+ spool_path: transfer.spool_path || null
1226
+ });
1227
+
1228
+ this.sendProtocol('file.complete_ack', {
1229
+ transfer_id: transfer.transfer_id,
1230
+ file_hash: computed,
1231
+ total_bytes: transfer.total_bytes
1232
+ }, fromUuid || null, { preferMac: true });
1233
+
1234
+ return true;
1235
+ }
1236
+
1237
+ handleFileMessage(fromUuid, fromStreamID, kind, payload) {
1238
+ const transferId = payload && typeof payload.transfer_id === 'string' ? payload.transfer_id : null;
1239
+
1240
+ if (!transferId && kind !== 'file.resume_state') {
1241
+ this.queueEvent({
1242
+ type: 'file_protocol_error',
1243
+ ts: nowISO(),
1244
+ reason: 'missing_transfer_id',
1245
+ kind,
1246
+ from_uuid: fromUuid || null
1247
+ });
1248
+ return;
1249
+ }
1250
+
1251
+ if (kind === 'file.offer') {
1252
+ let incoming = this.incomingTransfers.get(transferId);
1253
+ if (!incoming) {
1254
+ try {
1255
+ incoming = this.createIncomingTransfer(fromUuid, fromStreamID, payload || {});
1256
+ } catch (error) {
1257
+ this.sendProtocol('file.nack', {
1258
+ transfer_id: transferId,
1259
+ expected_seq: 0,
1260
+ reason: error.message
1261
+ }, fromUuid || null, { preferMac: true });
1262
+ this.queueEvent({
1263
+ type: 'file_offer_rejected',
1264
+ ts: nowISO(),
1265
+ transfer_id: transferId,
1266
+ reason: error.message
1267
+ });
1268
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { reason: error.message } });
1269
+ return;
1270
+ }
1271
+ }
1272
+
1273
+ incoming.updated_at = nowISO();
1274
+ const nextSeq = nextMissingSeq(incoming);
1275
+ this.sendProtocol('file.accept', {
1276
+ transfer_id: transferId,
1277
+ next_seq: nextSeq
1278
+ }, fromUuid || null, { preferMac: true });
1279
+
1280
+ this.queueEvent({
1281
+ type: 'file_offer_received',
1282
+ ts: nowISO(),
1283
+ transfer_id: transferId,
1284
+ from_uuid: fromUuid || null,
1285
+ from_stream_id: fromStreamID || null,
1286
+ total_bytes: incoming.total_bytes,
1287
+ total_chunks: incoming.total_chunks,
1288
+ next_seq: nextSeq,
1289
+ name: incoming.name,
1290
+ mime: incoming.mime
1291
+ });
1292
+ this.recordProtocolEvent({ kind: 'file.accept', from_uuid: fromUuid || null, transfer_id: transferId, payload: { next_seq: nextSeq } });
1293
+ return;
1294
+ }
1295
+
1296
+ if (kind === 'file.chunk') {
1297
+ const incoming = this.incomingTransfers.get(transferId);
1298
+ if (!incoming) {
1299
+ this.sendProtocol('file.nack', {
1300
+ transfer_id: transferId,
1301
+ expected_seq: 0,
1302
+ reason: 'unknown_transfer'
1303
+ }, fromUuid || null, { preferMac: true });
1304
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { expected_seq: 0 } });
1305
+ return;
1306
+ }
1307
+
1308
+ const seq = nonNegativeIntFromValue(payload.seq, -1);
1309
+ if (seq < 0 || seq >= incoming.total_chunks) {
1310
+ const expectedSeq = nextMissingSeq(incoming);
1311
+ this.sendProtocol('file.nack', {
1312
+ transfer_id: transferId,
1313
+ expected_seq: expectedSeq,
1314
+ reason: 'out_of_range_seq'
1315
+ }, fromUuid || null, { preferMac: true });
1316
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { expected_seq: expectedSeq } });
1317
+ return;
1318
+ }
1319
+
1320
+ let chunkBuffer;
1321
+ try {
1322
+ chunkBuffer = Buffer.from(String(payload.data_base64 || ''), 'base64');
1323
+ } catch (error) {
1324
+ chunkBuffer = null;
1325
+ }
1326
+
1327
+ if (!chunkBuffer || chunkBuffer.length === 0) {
1328
+ const expectedSeq = nextMissingSeq(incoming);
1329
+ this.sendProtocol('file.nack', {
1330
+ transfer_id: transferId,
1331
+ expected_seq: expectedSeq,
1332
+ reason: 'invalid_chunk_data'
1333
+ }, fromUuid || null, { preferMac: true });
1334
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { expected_seq: expectedSeq } });
1335
+ return;
1336
+ }
1337
+
1338
+ const chunkHash = sha256Hex(chunkBuffer);
1339
+ if (payload.chunk_hash && payload.chunk_hash !== chunkHash) {
1340
+ const expectedSeq = nextMissingSeq(incoming);
1341
+ this.sendProtocol('file.nack', {
1342
+ transfer_id: transferId,
1343
+ expected_seq: expectedSeq,
1344
+ reason: 'chunk_hash_mismatch'
1345
+ }, fromUuid || null, { preferMac: true });
1346
+ this.queueEvent({
1347
+ type: 'file_chunk_rejected',
1348
+ ts: nowISO(),
1349
+ transfer_id: transferId,
1350
+ seq,
1351
+ reason: 'chunk_hash_mismatch'
1352
+ });
1353
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { expected_seq: expectedSeq, reason: 'chunk_hash_mismatch' } });
1354
+ return;
1355
+ }
1356
+
1357
+ if (!incoming.received_chunks.has(seq)) {
1358
+ incoming.received_chunks.add(seq);
1359
+ if (incoming.spooled && Number.isInteger(incoming.spool_fd)) {
1360
+ const offset = seq * incoming.chunk_bytes;
1361
+ fs.writeSync(incoming.spool_fd, chunkBuffer, 0, chunkBuffer.length, offset);
1362
+ } else {
1363
+ incoming.chunks[seq] = chunkBuffer;
1364
+ }
1365
+ incoming.received_bytes += chunkBuffer.length;
1366
+ }
1367
+
1368
+ incoming.updated_at = nowISO();
1369
+ const nextSeq = nextMissingSeq(incoming);
1370
+ this.sendProtocol('file.ack', {
1371
+ transfer_id: transferId,
1372
+ seq,
1373
+ next_seq: nextSeq,
1374
+ received_bytes: incoming.received_bytes
1375
+ }, fromUuid || null, { preferMac: true });
1376
+
1377
+ this.queueEvent({
1378
+ type: 'file_chunk_received',
1379
+ ts: nowISO(),
1380
+ transfer_id: transferId,
1381
+ seq,
1382
+ next_seq: nextSeq,
1383
+ received_bytes: incoming.received_bytes,
1384
+ total_bytes: incoming.total_bytes
1385
+ });
1386
+
1387
+ if (incoming.complete_received && nextSeq >= incoming.total_chunks) {
1388
+ this.maybeFinalizeIncomingTransfer(incoming, fromUuid);
1389
+ }
1390
+ return;
1391
+ }
1392
+
1393
+ if (kind === 'file.complete') {
1394
+ const incoming = this.incomingTransfers.get(transferId);
1395
+ if (!incoming) {
1396
+ this.sendProtocol('file.nack', {
1397
+ transfer_id: transferId,
1398
+ expected_seq: 0,
1399
+ reason: 'unknown_transfer'
1400
+ }, fromUuid || null, { preferMac: true });
1401
+ this.recordProtocolEvent({ kind: 'file.nack', from_uuid: fromUuid || null, transfer_id: transferId, payload: { expected_seq: 0 } });
1402
+ return;
1403
+ }
1404
+
1405
+ incoming.complete_received = true;
1406
+ incoming.updated_at = nowISO();
1407
+ const completed = this.maybeFinalizeIncomingTransfer(incoming, fromUuid);
1408
+ if (!completed) {
1409
+ const expectedSeq = nextMissingSeq(incoming);
1410
+ this.sendProtocol('file.nack', {
1411
+ transfer_id: transferId,
1412
+ expected_seq: expectedSeq,
1413
+ reason: incoming.last_error || 'missing_chunks'
1414
+ }, fromUuid || null, { preferMac: true });
1415
+ this.recordProtocolEvent({
1416
+ kind: 'file.nack',
1417
+ from_uuid: fromUuid || null,
1418
+ transfer_id: transferId,
1419
+ payload: { expected_seq: expectedSeq, reason: incoming.last_error || 'missing_chunks' }
1420
+ });
1421
+ }
1422
+ return;
1423
+ }
1424
+
1425
+ if (kind === 'file.resume_req') {
1426
+ const incoming = this.incomingTransfers.get(transferId);
1427
+ const nextSeq = incoming ? nextMissingSeq(incoming) : 0;
1428
+ this.sendProtocol('file.resume_state', {
1429
+ transfer_id: transferId,
1430
+ next_seq: nextSeq,
1431
+ status: incoming ? incoming.status : 'unknown_transfer'
1432
+ }, fromUuid || null, { preferMac: true });
1433
+ this.recordProtocolEvent({ kind: 'file.resume_state', from_uuid: fromUuid || null, transfer_id: transferId, payload: { next_seq: nextSeq } });
1434
+ return;
1435
+ }
1436
+
1437
+ if (kind === 'file.cancel') {
1438
+ const incoming = this.incomingTransfers.get(transferId);
1439
+ if (incoming) {
1440
+ incoming.status = 'cancelled';
1441
+ incoming.updated_at = nowISO();
1442
+ this.cleanupTransferStorage(incoming);
1443
+ }
1444
+ this.queueEvent({
1445
+ type: 'file_transfer_cancelled',
1446
+ ts: nowISO(),
1447
+ transfer_id: transferId,
1448
+ from_uuid: fromUuid || null
1449
+ });
1450
+ this.recordProtocolEvent({ kind, from_uuid: fromUuid || null, transfer_id: transferId, payload: payload || {} });
1451
+ return;
1452
+ }
1453
+
1454
+ if (kind === 'file.accept' || kind === 'file.ack' || kind === 'file.nack' || kind === 'file.complete_ack' || kind === 'file.resume_state') {
1455
+ const outgoing = this.outgoingTransfers.get(transferId);
1456
+ if (outgoing) {
1457
+ outgoing.updated_at = nowISO();
1458
+
1459
+ if (kind === 'file.accept') {
1460
+ outgoing.status = 'transferring';
1461
+ outgoing.next_seq = nonNegativeIntFromValue(payload.next_seq, 0);
1462
+ }
1463
+
1464
+ if (kind === 'file.ack') {
1465
+ const seq = nonNegativeIntFromValue(payload.seq, -1);
1466
+ if (seq >= 0) outgoing.acked_chunks.add(seq);
1467
+ outgoing.next_seq = nonNegativeIntFromValue(payload.next_seq, outgoing.next_seq + 1);
1468
+ }
1469
+
1470
+ if (kind === 'file.nack') {
1471
+ outgoing.next_seq = nonNegativeIntFromValue(payload.expected_seq, outgoing.next_seq);
1472
+ outgoing.last_error = payload.reason || 'nack_received';
1473
+ }
1474
+
1475
+ if (kind === 'file.complete_ack') {
1476
+ outgoing.status = 'completed';
1477
+ outgoing.completed_at = nowISO();
1478
+ }
1479
+
1480
+ if (kind === 'file.resume_state') {
1481
+ outgoing.next_seq = nonNegativeIntFromValue(payload.next_seq, outgoing.next_seq);
1482
+ }
1483
+ }
1484
+
1485
+ this.recordProtocolEvent({ kind, from_uuid: fromUuid || null, transfer_id: transferId, payload: payload || {} });
1486
+ return;
1487
+ }
1488
+
1489
+ this.queueEvent({
1490
+ type: 'file_protocol_unhandled',
1491
+ ts: nowISO(),
1492
+ transfer_id: transferId,
1493
+ kind
1494
+ });
1495
+ }
1496
+
1497
+ handleStateMessage(fromUuid, fromStreamID, kind, payload) {
1498
+ if (kind === 'state.patch') {
1499
+ const result = this.applyStatePatch(payload, 'remote');
1500
+ this.recordProtocolEvent({
1501
+ kind,
1502
+ from_uuid: fromUuid || null,
1503
+ payload: result.applied ? { key: payload && payload.key, clock: payload && payload.clock } : { error: result.reason }
1504
+ });
1505
+ return;
1506
+ }
1507
+
1508
+ if (kind === 'state.snapshot_req') {
1509
+ this.sendProtocol('state.snapshot', this.getStateSnapshot(), fromUuid || null, { preferMac: true });
1510
+ this.recordProtocolEvent({ kind: 'state.snapshot', from_uuid: fromUuid || null, payload: { entries: this.roomState.size } });
1511
+ return;
1512
+ }
1513
+
1514
+ if (kind === 'state.snapshot') {
1515
+ const entries = payload && Array.isArray(payload.entries) ? payload.entries.slice(0, this.config.stateMaxSnapshotEntries) : [];
1516
+ let appliedCount = 0;
1517
+ for (const entry of entries) {
1518
+ const result = this.applyStatePatch(entry, 'remote_snapshot');
1519
+ if (result.applied) appliedCount += 1;
1520
+ }
1521
+ const actorClock = payload && payload.actor_clock && typeof payload.actor_clock === 'object' ? payload.actor_clock : {};
1522
+ for (const [actor, clock] of Object.entries(actorClock)) {
1523
+ this.markStateActorClock(actor, clock);
1524
+ }
1525
+ this.recordProtocolEvent({
1526
+ kind,
1527
+ from_uuid: fromUuid || null,
1528
+ payload: { applied: appliedCount, offered: entries.length }
1529
+ });
1530
+ this.queueEvent({
1531
+ type: 'state_snapshot_merged',
1532
+ ts: nowISO(),
1533
+ from_uuid: fromUuid || null,
1534
+ from_stream_id: fromStreamID || null,
1535
+ applied: appliedCount,
1536
+ offered: entries.length
1537
+ });
1538
+ return;
1539
+ }
1540
+
1541
+ this.queueEvent({
1542
+ type: 'state_protocol_unhandled',
1543
+ ts: nowISO(),
1544
+ kind,
1545
+ from_uuid: fromUuid || null,
1546
+ from_stream_id: fromStreamID || null
1547
+ });
1548
+ }
1549
+
1550
+ handleProtocolMessage(detail, envelope) {
1551
+ const fromUuid = detail.uuid || null;
1552
+ const fromStreamID = detail.streamID || null;
1553
+ const kind = envelope.kind;
1554
+ const payload = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {};
1555
+
1556
+ if (fromUuid) {
1557
+ this.touchPeer(fromUuid, fromStreamID);
1558
+ }
1559
+
1560
+ const isSync = kind.startsWith('sync.');
1561
+ if (!isSync) {
1562
+ const macOk = this.verifyEnvelopeMac(fromUuid, envelope);
1563
+ if (!macOk) {
1564
+ this.queueEvent({
1565
+ type: 'protocol_auth_failed',
1566
+ ts: nowISO(),
1567
+ kind,
1568
+ uuid: fromUuid,
1569
+ stream_id: fromStreamID
1570
+ });
1571
+ this.recordProtocolEvent({ kind: 'protocol_auth_failed', from_uuid: fromUuid || null, payload: { kind } });
1572
+ return;
1573
+ }
1574
+ }
1575
+
1576
+ if (kind.startsWith('sync.')) {
1577
+ this.handleSyncMessage(fromUuid, fromStreamID, kind, payload);
1578
+ return;
1579
+ }
1580
+
1581
+ if (kind.startsWith('file.')) {
1582
+ this.handleFileMessage(fromUuid, fromStreamID, kind, payload);
1583
+ return;
1584
+ }
1585
+
1586
+ if (kind.startsWith('state.')) {
1587
+ this.handleStateMessage(fromUuid, fromStreamID, kind, payload);
1588
+ return;
1589
+ }
1590
+
1591
+ this.queueEvent({
1592
+ type: 'protocol_unknown',
1593
+ ts: nowISO(),
1594
+ kind,
1595
+ uuid: fromUuid,
1596
+ stream_id: fromStreamID
1597
+ });
1598
+ }
1599
+
1600
+ connectionHasOpenDataChannel(connectionSet) {
1601
+ if (!connectionSet || typeof connectionSet !== 'object') return false;
1602
+
1603
+ if (connectionSet.dataChannel && connectionSet.dataChannel.readyState === 'open') {
1604
+ return true;
1605
+ }
1606
+
1607
+ for (const role of ['viewer', 'publisher']) {
1608
+ const conn = connectionSet[role];
1609
+ if (conn && conn.dataChannel && conn.dataChannel.readyState === 'open') {
1610
+ return true;
1611
+ }
1612
+ }
1613
+
1614
+ for (const value of Object.values(connectionSet)) {
1615
+ if (!value || typeof value !== 'object') continue;
1616
+ if (value.dataChannel && value.dataChannel.readyState === 'open') {
1617
+ return true;
1618
+ }
1619
+ if (value.readyState === 'open' && typeof value.send === 'function') {
1620
+ return true;
1621
+ }
1622
+ }
1623
+
1624
+ return false;
1625
+ }
1626
+
1627
+ hasOpenDataChannel(targetUuid = null) {
1628
+ if (!this.sdk) return false;
1629
+
1630
+ if (typeof this.sdk.hasOpenDataChannel === 'function') {
1631
+ try {
1632
+ return !!this.sdk.hasOpenDataChannel(targetUuid || null);
1633
+ } catch (error) {
1634
+ // Fall through to local inspection.
1635
+ }
1636
+ }
1637
+
1638
+ const connections = this.sdk.connections;
1639
+ const isMapLike = !!(
1640
+ connections &&
1641
+ typeof connections.get === 'function' &&
1642
+ typeof connections.values === 'function'
1643
+ );
1644
+ if (isMapLike) {
1645
+ if (targetUuid) {
1646
+ return this.connectionHasOpenDataChannel(connections.get(targetUuid));
1647
+ }
1648
+ for (const connectionSet of connections.values()) {
1649
+ if (this.connectionHasOpenDataChannel(connectionSet)) {
1650
+ return true;
1651
+ }
1652
+ }
1653
+ return false;
1654
+ }
1655
+
1656
+ if (connections && typeof connections === 'object') {
1657
+ if (targetUuid) {
1658
+ return this.connectionHasOpenDataChannel(connections[targetUuid]);
1659
+ }
1660
+ for (const connectionSet of Object.values(connections)) {
1661
+ if (this.connectionHasOpenDataChannel(connectionSet)) {
1662
+ return true;
1663
+ }
1664
+ }
1665
+ return false;
1666
+ }
1667
+
1668
+ if (targetUuid) {
1669
+ return this.connectedPeers.has(targetUuid);
1670
+ }
1671
+ return this.connectedPeers.size > 0;
1672
+ }
1673
+
1674
+ send(data, target) {
1675
+ if (!this.sdk) {
1676
+ this.queueEvent({
1677
+ type: 'send_rejected',
1678
+ ts: nowISO(),
1679
+ reason: 'not_connected'
1680
+ });
1681
+ return false;
1682
+ }
1683
+
1684
+ let resolvedTarget = target;
1685
+ if (typeof target === 'string') {
1686
+ for (const peer of this.peerState.values()) {
1687
+ if (peer.stream_id === target) {
1688
+ resolvedTarget = peer.uuid;
1689
+ break;
1690
+ }
1691
+ }
1692
+ }
1693
+
1694
+ const targetUuid = resolvedTarget && typeof resolvedTarget === 'object'
1695
+ ? (typeof resolvedTarget.uuid === 'string' ? resolvedTarget.uuid : null)
1696
+ : (typeof resolvedTarget === 'string' ? resolvedTarget : null);
1697
+ const allowFallback = !!(
1698
+ resolvedTarget &&
1699
+ typeof resolvedTarget === 'object' &&
1700
+ resolvedTarget.allowFallback
1701
+ );
1702
+ if (!allowFallback && !this.hasOpenDataChannel(targetUuid)) {
1703
+ this.queueEvent({
1704
+ type: 'send_rejected',
1705
+ ts: nowISO(),
1706
+ reason: 'no_open_data_channel',
1707
+ target: targetUuid
1708
+ });
1709
+ return false;
1710
+ }
1711
+
1712
+ try {
1713
+ return resolvedTarget ? !!this.sdk.sendData(data, resolvedTarget) : !!this.sdk.sendData(data);
1714
+ } catch (error) {
1715
+ this.lastError = error.message;
1716
+ this.queueEvent({
1717
+ type: 'send_error',
1718
+ ts: nowISO(),
1719
+ message: error.message
1720
+ });
1721
+ return false;
1722
+ }
1723
+ }
1724
+
1725
+ buildOutgoingTransfer(options) {
1726
+ const transferId = typeof options.transferId === 'string' && options.transferId.trim()
1727
+ ? options.transferId.trim()
1728
+ : generateShortId('file');
1729
+
1730
+ const chunkBytes = clampInt(
1731
+ options.chunkBytes,
1732
+ this.config.fileChunkBytes,
1733
+ MIN_CHUNK_BYTES,
1734
+ MAX_CHUNK_BYTES
1735
+ );
1736
+
1737
+ let totalBytes = 0;
1738
+ let totalChunks = 0;
1739
+ let fileHash = null;
1740
+ let chunks = null;
1741
+ let chunkHashes = [];
1742
+ let source = 'memory';
1743
+ let filePath = null;
1744
+
1745
+ if (typeof options.filePath === 'string' && options.filePath.trim()) {
1746
+ source = 'path';
1747
+ filePath = options.filePath.trim();
1748
+ const stat = fs.statSync(filePath);
1749
+ if (!stat.isFile()) {
1750
+ throw new Error(`file path is not a regular file: ${filePath}`);
1751
+ }
1752
+ totalBytes = stat.size;
1753
+ if (totalBytes <= 0) {
1754
+ throw new Error('file data must not be empty');
1755
+ }
1756
+ if (totalBytes > this.config.fileMaxBytes) {
1757
+ throw new Error(`file exceeds max ${this.config.fileMaxBytes} bytes`);
1758
+ }
1759
+
1760
+ const hash = crypto.createHash('sha256');
1761
+ const fd = fs.openSync(filePath, 'r');
1762
+ try {
1763
+ for (let offset = 0; offset < totalBytes; offset += chunkBytes) {
1764
+ const len = Math.min(chunkBytes, totalBytes - offset);
1765
+ const chunk = Buffer.alloc(len);
1766
+ const bytesRead = fs.readSync(fd, chunk, 0, len, offset);
1767
+ const used = chunk.subarray(0, bytesRead);
1768
+ hash.update(used);
1769
+ chunkHashes.push(sha256Hex(used));
1770
+ }
1771
+ } finally {
1772
+ fs.closeSync(fd);
1773
+ }
1774
+ totalChunks = chunkHashes.length;
1775
+ fileHash = hash.digest('hex');
1776
+ } else {
1777
+ const dataBuffer = asBuffer(options.dataBuffer);
1778
+ if (dataBuffer.length <= 0) {
1779
+ throw new Error('file data must not be empty');
1780
+ }
1781
+ if (dataBuffer.length > this.config.fileMaxBytes) {
1782
+ throw new Error(`file exceeds max ${this.config.fileMaxBytes} bytes`);
1783
+ }
1784
+
1785
+ chunks = [];
1786
+ for (let offset = 0; offset < dataBuffer.length; offset += chunkBytes) {
1787
+ const chunk = dataBuffer.subarray(offset, Math.min(offset + chunkBytes, dataBuffer.length));
1788
+ chunks.push(chunk);
1789
+ chunkHashes.push(sha256Hex(chunk));
1790
+ }
1791
+ totalBytes = dataBuffer.length;
1792
+ totalChunks = chunks.length;
1793
+ fileHash = sha256Hex(dataBuffer);
1794
+ }
1795
+
1796
+ const transfer = {
1797
+ transfer_id: transferId,
1798
+ direction: 'outgoing',
1799
+ status: 'offered',
1800
+ created_at: nowISO(),
1801
+ updated_at: nowISO(),
1802
+ completed_at: null,
1803
+ name: options.name || null,
1804
+ mime: options.mime || 'application/octet-stream',
1805
+ total_bytes: totalBytes,
1806
+ total_chunks: totalChunks,
1807
+ chunk_bytes: chunkBytes,
1808
+ target_uuid: options.targetUuid,
1809
+ target_stream_id: options.targetStreamID || null,
1810
+ file_hash: fileHash,
1811
+ chunks,
1812
+ chunk_hashes: chunkHashes,
1813
+ source,
1814
+ file_path: filePath,
1815
+ next_seq: 0,
1816
+ acked_chunks: new Set(),
1817
+ retries_by_seq: new Map(),
1818
+ retries_total: 0,
1819
+ last_error: null,
1820
+ wait_timeout_ms: intFromValue(options.timeoutMs, this.config.fileAckTimeoutMs)
1821
+ };
1822
+
1823
+ this.outgoingTransfers.set(transfer.transfer_id, transfer);
1824
+ return transfer;
1825
+ }
1826
+
1827
+ getOutgoingChunk(transfer, seq) {
1828
+ if (!transfer) return null;
1829
+ if (seq < 0 || seq >= transfer.total_chunks) return null;
1830
+
1831
+ if (transfer.source === 'path' && transfer.file_path) {
1832
+ const offset = seq * transfer.chunk_bytes;
1833
+ const length = Math.min(transfer.chunk_bytes, transfer.total_bytes - offset);
1834
+ if (length <= 0) return null;
1835
+ return this.readChunkFromPathSync(transfer.file_path, offset, length);
1836
+ }
1837
+
1838
+ if (Array.isArray(transfer.chunks)) {
1839
+ return transfer.chunks[seq] || null;
1840
+ }
1841
+ return null;
1842
+ }
1843
+
1844
+ summarizeTransfer(transfer) {
1845
+ if (!transfer) return null;
1846
+ return {
1847
+ transfer_id: transfer.transfer_id,
1848
+ direction: transfer.direction,
1849
+ status: transfer.status,
1850
+ name: transfer.name || null,
1851
+ mime: transfer.mime || null,
1852
+ total_bytes: transfer.total_bytes,
1853
+ total_chunks: transfer.total_chunks,
1854
+ chunk_bytes: transfer.chunk_bytes,
1855
+ next_seq: transfer.next_seq,
1856
+ retries_total: transfer.retries_total || 0,
1857
+ acked_chunks: transfer.acked_chunks ? transfer.acked_chunks.size : transfer.received_chunks ? transfer.received_chunks.size : 0,
1858
+ source: transfer.source || (transfer.spooled ? 'spool' : 'memory'),
1859
+ spooled: !!transfer.spooled,
1860
+ spool_path: transfer.spool_path || null,
1861
+ source_path: transfer.file_path || null,
1862
+ target_uuid: transfer.target_uuid || null,
1863
+ target_stream_id: transfer.target_stream_id || null,
1864
+ from_uuid: transfer.from_uuid || null,
1865
+ from_stream_id: transfer.from_stream_id || null,
1866
+ file_hash: transfer.file_hash || null,
1867
+ created_at: transfer.created_at,
1868
+ updated_at: transfer.updated_at,
1869
+ completed_at: transfer.completed_at || null,
1870
+ last_error: transfer.last_error || null,
1871
+ has_data: !!transfer.data
1872
+ };
1873
+ }
1874
+
1875
+ async requestResumeState(transfer, timeoutMs) {
1876
+ const cursor = this.protocolCursor;
1877
+ this.sendProtocol('file.resume_req', {
1878
+ transfer_id: transfer.transfer_id
1879
+ }, transfer.target_uuid, { preferMac: true });
1880
+
1881
+ const resume = await this.waitForProtocolEvent((event) => (
1882
+ event.kind === 'file.resume_state' &&
1883
+ event.transfer_id === transfer.transfer_id &&
1884
+ event.from_uuid === transfer.target_uuid
1885
+ ), timeoutMs, cursor);
1886
+
1887
+ if (!resume) return null;
1888
+ return nonNegativeIntFromValue(resume.payload && resume.payload.next_seq, transfer.next_seq);
1889
+ }
1890
+
1891
+ async transmitOutgoingTransfer(transfer, options = {}) {
1892
+ const maxRetries = intFromValue(options.maxRetries, this.config.fileMaxRetries);
1893
+ const ackTimeoutMs = intFromValue(options.ackTimeoutMs, this.config.fileAckTimeoutMs);
1894
+
1895
+ const offerCursor = this.protocolCursor;
1896
+ const offerOk = this.sendProtocol('file.offer', {
1897
+ transfer_id: transfer.transfer_id,
1898
+ name: transfer.name,
1899
+ mime: transfer.mime,
1900
+ total_bytes: transfer.total_bytes,
1901
+ total_chunks: transfer.total_chunks,
1902
+ chunk_bytes: transfer.chunk_bytes,
1903
+ file_hash: transfer.file_hash
1904
+ }, transfer.target_uuid, { preferMac: true });
1905
+
1906
+ if (!offerOk) {
1907
+ throw new Error('failed to send file.offer');
1908
+ }
1909
+
1910
+ const accepted = await this.waitForProtocolEvent((event) => (
1911
+ event.kind === 'file.accept' &&
1912
+ event.transfer_id === transfer.transfer_id &&
1913
+ event.from_uuid === transfer.target_uuid
1914
+ ), Math.max(ackTimeoutMs, 1000), offerCursor);
1915
+
1916
+ if (!accepted) {
1917
+ throw new Error('timed out waiting for file.accept');
1918
+ }
1919
+
1920
+ transfer.status = 'transferring';
1921
+ transfer.next_seq = nonNegativeIntFromValue(accepted.payload && accepted.payload.next_seq, 0);
1922
+
1923
+ let seq = transfer.next_seq;
1924
+ while (seq < transfer.total_chunks) {
1925
+ const chunk = this.getOutgoingChunk(transfer, seq);
1926
+ const chunkHash = transfer.chunk_hashes[seq];
1927
+ if (!chunk || !chunk.length) {
1928
+ transfer.status = 'failed';
1929
+ transfer.last_error = `missing chunk data at seq ${seq}`;
1930
+ throw new Error(transfer.last_error);
1931
+ }
1932
+ let delivered = false;
1933
+
1934
+ while (!delivered) {
1935
+ const retriesForSeq = transfer.retries_by_seq.get(seq) || 0;
1936
+ if (retriesForSeq > maxRetries) {
1937
+ transfer.status = 'failed';
1938
+ transfer.last_error = `max retries exceeded at chunk ${seq}`;
1939
+ throw new Error(transfer.last_error);
1940
+ }
1941
+
1942
+ const cursor = this.protocolCursor;
1943
+ const sent = this.sendProtocol('file.chunk', {
1944
+ transfer_id: transfer.transfer_id,
1945
+ seq,
1946
+ data_base64: chunk.toString('base64'),
1947
+ chunk_hash: chunkHash
1948
+ }, transfer.target_uuid, { preferMac: true });
1949
+
1950
+ if (!sent) {
1951
+ transfer.status = 'failed';
1952
+ transfer.last_error = `send failed at chunk ${seq}`;
1953
+ throw new Error(transfer.last_error);
1954
+ }
1955
+
1956
+ const response = await this.waitForProtocolEvent((event) => (
1957
+ event.transfer_id === transfer.transfer_id &&
1958
+ event.from_uuid === transfer.target_uuid &&
1959
+ (event.kind === 'file.ack' || event.kind === 'file.nack')
1960
+ ), ackTimeoutMs, cursor);
1961
+
1962
+ if (!response) {
1963
+ transfer.retries_by_seq.set(seq, retriesForSeq + 1);
1964
+ transfer.retries_total += 1;
1965
+ const resumeSeq = await this.requestResumeState(transfer, ackTimeoutMs);
1966
+ if (Number.isFinite(resumeSeq)) {
1967
+ seq = Math.min(Math.max(resumeSeq, 0), transfer.total_chunks);
1968
+ transfer.next_seq = seq;
1969
+ }
1970
+ continue;
1971
+ }
1972
+
1973
+ if (response.kind === 'file.nack') {
1974
+ transfer.retries_by_seq.set(seq, retriesForSeq + 1);
1975
+ transfer.retries_total += 1;
1976
+ seq = nonNegativeIntFromValue(response.payload && response.payload.expected_seq, seq);
1977
+ transfer.next_seq = seq;
1978
+ delivered = true;
1979
+ break;
1980
+ }
1981
+
1982
+ transfer.acked_chunks.add(seq);
1983
+ const nextSeq = nonNegativeIntFromValue(response.payload && response.payload.next_seq, seq + 1);
1984
+ seq = Math.max(seq + 1, nextSeq);
1985
+ transfer.next_seq = Math.min(seq, transfer.total_chunks);
1986
+ delivered = true;
1987
+ }
1988
+ }
1989
+
1990
+ const completeCursor = this.protocolCursor;
1991
+ this.sendProtocol('file.complete', {
1992
+ transfer_id: transfer.transfer_id,
1993
+ total_bytes: transfer.total_bytes,
1994
+ file_hash: transfer.file_hash
1995
+ }, transfer.target_uuid, { preferMac: true });
1996
+
1997
+ const completeAck = await this.waitForProtocolEvent((event) => (
1998
+ event.kind === 'file.complete_ack' &&
1999
+ event.transfer_id === transfer.transfer_id &&
2000
+ event.from_uuid === transfer.target_uuid
2001
+ ), Math.max(ackTimeoutMs * 2, 1000), completeCursor);
2002
+
2003
+ if (!completeAck) {
2004
+ const resumeSeq = await this.requestResumeState(transfer, ackTimeoutMs);
2005
+ if (Number.isFinite(resumeSeq) && resumeSeq >= transfer.total_chunks) {
2006
+ transfer.status = 'completed';
2007
+ transfer.completed_at = nowISO();
2008
+ } else {
2009
+ transfer.status = 'failed';
2010
+ transfer.last_error = 'timed out waiting for file.complete_ack';
2011
+ throw new Error(transfer.last_error);
2012
+ }
2013
+ } else {
2014
+ transfer.status = 'completed';
2015
+ transfer.completed_at = nowISO();
2016
+ }
2017
+
2018
+ transfer.updated_at = nowISO();
2019
+ this.completedOutgoingOrder.push(transfer.transfer_id);
2020
+ while (this.completedOutgoingOrder.length > this.config.maxStoredFiles) {
2021
+ const oldestId = this.completedOutgoingOrder.shift();
2022
+ if (!oldestId) continue;
2023
+ const candidate = this.outgoingTransfers.get(oldestId);
2024
+ if (candidate && candidate.status === 'completed') {
2025
+ this.cleanupTransferStorage(candidate);
2026
+ this.outgoingTransfers.delete(oldestId);
2027
+ }
2028
+ }
2029
+
2030
+ this.queueEvent({
2031
+ type: 'file_transfer_complete',
2032
+ ts: nowISO(),
2033
+ transfer_id: transfer.transfer_id,
2034
+ target_uuid: transfer.target_uuid,
2035
+ total_bytes: transfer.total_bytes,
2036
+ total_chunks: transfer.total_chunks,
2037
+ retries_total: transfer.retries_total,
2038
+ file_hash: transfer.file_hash
2039
+ });
2040
+
2041
+ return this.summarizeTransfer(transfer);
2042
+ }
2043
+
2044
+ async sendFile(options = {}) {
2045
+ if (!this.sdk) {
2046
+ throw new Error('not connected');
2047
+ }
2048
+
2049
+ const target = this.resolvePeerTarget(options.target || options.uuid || null);
2050
+ if (!target.ok || !target.uuid) {
2051
+ throw new Error(target.error || 'unable to resolve file transfer target');
2052
+ }
2053
+
2054
+ const transfer = this.buildOutgoingTransfer({
2055
+ transferId: options.transferId,
2056
+ dataBuffer: options.dataBuffer,
2057
+ chunkBytes: options.chunkBytes,
2058
+ targetUuid: target.uuid,
2059
+ targetStreamID: target.stream_id || null,
2060
+ name: options.name,
2061
+ mime: options.mime,
2062
+ timeoutMs: options.timeoutMs
2063
+ });
2064
+
2065
+ const result = await this.transmitOutgoingTransfer(transfer, {
2066
+ maxRetries: options.maxRetries,
2067
+ ackTimeoutMs: options.ackTimeoutMs
2068
+ });
2069
+
2070
+ return result;
2071
+ }
2072
+
2073
+ async sendFileFromPath(options = {}) {
2074
+ if (!this.sdk) {
2075
+ throw new Error('not connected');
2076
+ }
2077
+
2078
+ if (!options.filePath || typeof options.filePath !== 'string') {
2079
+ throw new Error('filePath is required');
2080
+ }
2081
+
2082
+ const target = this.resolvePeerTarget(options.target || options.uuid || null);
2083
+ if (!target.ok || !target.uuid) {
2084
+ throw new Error(target.error || 'unable to resolve file transfer target');
2085
+ }
2086
+
2087
+ const transfer = this.buildOutgoingTransfer({
2088
+ transferId: options.transferId,
2089
+ filePath: options.filePath,
2090
+ chunkBytes: options.chunkBytes,
2091
+ targetUuid: target.uuid,
2092
+ targetStreamID: target.stream_id || null,
2093
+ name: options.name || path.basename(options.filePath),
2094
+ mime: options.mime,
2095
+ timeoutMs: options.timeoutMs
2096
+ });
2097
+
2098
+ const result = await this.transmitOutgoingTransfer(transfer, {
2099
+ maxRetries: options.maxRetries,
2100
+ ackTimeoutMs: options.ackTimeoutMs
2101
+ });
2102
+
2103
+ return result;
2104
+ }
2105
+
2106
+ async resumeOutgoingTransfer(transferId, options = {}) {
2107
+ if (!transferId || typeof transferId !== 'string') {
2108
+ throw new Error('transfer_id is required');
2109
+ }
2110
+
2111
+ const transfer = this.outgoingTransfers.get(transferId);
2112
+ if (!transfer) {
2113
+ throw new Error(`unknown transfer_id: ${transferId}`);
2114
+ }
2115
+
2116
+ if (transfer.status === 'completed') {
2117
+ return this.summarizeTransfer(transfer);
2118
+ }
2119
+
2120
+ if (options.target) {
2121
+ const target = this.resolvePeerTarget(options.target);
2122
+ if (!target.ok || !target.uuid) {
2123
+ throw new Error(target.error || 'unable to resolve target');
2124
+ }
2125
+ transfer.target_uuid = target.uuid;
2126
+ }
2127
+
2128
+ if (Number.isFinite(options.startSeq)) {
2129
+ transfer.next_seq = Math.min(Math.max(nonNegativeIntFromValue(options.startSeq, transfer.next_seq), 0), transfer.total_chunks);
2130
+ } else {
2131
+ const resumeSeq = await this.requestResumeState(transfer, intFromValue(options.ackTimeoutMs, this.config.fileAckTimeoutMs));
2132
+ if (Number.isFinite(resumeSeq)) {
2133
+ transfer.next_seq = Math.min(Math.max(resumeSeq, 0), transfer.total_chunks);
2134
+ }
2135
+ }
2136
+
2137
+ return this.transmitOutgoingTransfer(transfer, {
2138
+ maxRetries: options.maxRetries,
2139
+ ackTimeoutMs: options.ackTimeoutMs
2140
+ });
2141
+ }
2142
+
2143
+ listSyncPeers() {
2144
+ const now = Date.now();
2145
+ const staleMs = Math.max(this.config.heartbeatMs * 4, 60000);
2146
+ return Array.from(this.peerState.values()).map((peer) => {
2147
+ const lastSeenMs = peer.last_seen_at ? new Date(peer.last_seen_at).getTime() : 0;
2148
+ const stale = lastSeenMs > 0 ? (now - lastSeenMs) > staleMs : true;
2149
+ return {
2150
+ uuid: peer.uuid,
2151
+ stream_id: peer.stream_id,
2152
+ connected: peer.connected,
2153
+ handshake_state: peer.handshake_state,
2154
+ auth_ok: peer.auth_ok,
2155
+ rejected_reason: peer.rejected_reason,
2156
+ shared_key_ready: !!peer.shared_key_ready,
2157
+ last_seen_at: peer.last_seen_at,
2158
+ last_heartbeat_at: peer.last_heartbeat_at,
2159
+ stale,
2160
+ capabilities: peer.capabilities || null
2161
+ };
2162
+ });
2163
+ }
2164
+
2165
+ listTransfers(direction) {
2166
+ const incoming = Array.from(this.incomingTransfers.values()).map((transfer) => this.summarizeTransfer(transfer));
2167
+ const outgoing = Array.from(this.outgoingTransfers.values()).map((transfer) => this.summarizeTransfer(transfer));
2168
+
2169
+ if (direction === 'incoming') {
2170
+ return { incoming, outgoing: [] };
2171
+ }
2172
+ if (direction === 'outgoing') {
2173
+ return { incoming: [], outgoing };
2174
+ }
2175
+ return { incoming, outgoing };
2176
+ }
2177
+
2178
+ getIncomingTransferBuffer(transfer) {
2179
+ if (!transfer) return null;
2180
+ if (Buffer.isBuffer(transfer.data)) return transfer.data;
2181
+ if (transfer.spooled && transfer.spool_path) {
2182
+ return fs.readFileSync(transfer.spool_path);
2183
+ }
2184
+ return null;
2185
+ }
2186
+
2187
+ readIncomingTransfer(transferId, encoding) {
2188
+ const transfer = this.incomingTransfers.get(transferId);
2189
+ if (!transfer) {
2190
+ throw new Error(`unknown transfer_id: ${transferId}`);
2191
+ }
2192
+ if (transfer.status !== 'completed') {
2193
+ throw new Error(`transfer not complete: ${transferId}`);
2194
+ }
2195
+
2196
+ const transferBuffer = this.getIncomingTransferBuffer(transfer);
2197
+ if (!transferBuffer) {
2198
+ throw new Error(`transfer payload unavailable: ${transferId}`);
2199
+ }
2200
+
2201
+ const mode = (encoding || 'base64').toLowerCase();
2202
+ const response = {
2203
+ transfer: this.summarizeTransfer(transfer),
2204
+ encoding: mode,
2205
+ data_base64: null,
2206
+ data_text: null,
2207
+ data_json: null
2208
+ };
2209
+
2210
+ if (mode === 'base64') {
2211
+ response.data_base64 = transferBuffer.toString('base64');
2212
+ return response;
2213
+ }
2214
+
2215
+ if (mode === 'utf8' || mode === 'text') {
2216
+ response.data_text = transferBuffer.toString('utf8');
2217
+ return response;
2218
+ }
2219
+
2220
+ if (mode === 'json') {
2221
+ const text = transferBuffer.toString('utf8');
2222
+ const parsed = safeJsonParse(text, null);
2223
+ if (parsed === null) {
2224
+ throw new Error('completed transfer payload is not valid JSON');
2225
+ }
2226
+ response.data_json = parsed;
2227
+ return response;
2228
+ }
2229
+
2230
+ throw new Error(`unsupported encoding: ${encoding}`);
2231
+ }
2232
+
2233
+ saveIncomingTransferToPath(transferId, outputPath, overwrite) {
2234
+ const transfer = this.incomingTransfers.get(transferId);
2235
+ if (!transfer) {
2236
+ throw new Error(`unknown transfer_id: ${transferId}`);
2237
+ }
2238
+ if (transfer.status !== 'completed') {
2239
+ throw new Error(`transfer not complete: ${transferId}`);
2240
+ }
2241
+ if (!outputPath || typeof outputPath !== 'string') {
2242
+ throw new Error('output path is required');
2243
+ }
2244
+
2245
+ const resolvedPath = path.resolve(outputPath);
2246
+ if (!overwrite && fs.existsSync(resolvedPath)) {
2247
+ throw new Error(`output exists: ${resolvedPath}`);
2248
+ }
2249
+ fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
2250
+
2251
+ if (transfer.spooled && transfer.spool_path) {
2252
+ fs.copyFileSync(transfer.spool_path, resolvedPath);
2253
+ } else {
2254
+ const data = this.getIncomingTransferBuffer(transfer);
2255
+ if (!data) {
2256
+ throw new Error(`transfer payload unavailable: ${transferId}`);
2257
+ }
2258
+ fs.writeFileSync(resolvedPath, data);
2259
+ }
2260
+
2261
+ const stat = fs.statSync(resolvedPath);
2262
+ return {
2263
+ transfer: this.summarizeTransfer(transfer),
2264
+ output_path: resolvedPath,
2265
+ bytes_written: stat.size
2266
+ };
2267
+ }
2268
+
2269
+ queueEvent(event) {
2270
+ this.eventQueue.push(event);
2271
+ if (this.eventQueue.length > this.config.maxQueueSize) {
2272
+ this.eventQueue.splice(0, this.eventQueue.length - this.config.maxQueueSize);
2273
+ }
2274
+ this.emit('event', event);
2275
+ this.emit('eventQueued');
2276
+ }
2277
+
2278
+ pollEvents(maxEvents, waitMs) {
2279
+ const safeMax = intFromValue(maxEvents, 50);
2280
+ const safeWait = Math.min(intFromValue(waitMs, 0), 30000);
2281
+ if (this.eventQueue.length > 0 || safeWait <= 0) {
2282
+ return Promise.resolve(this.eventQueue.splice(0, safeMax));
2283
+ }
2284
+
2285
+ return new Promise((resolve) => {
2286
+ const timer = setTimeout(() => {
2287
+ this.removeListener('eventQueued', onEvent);
2288
+ resolve(this.eventQueue.splice(0, safeMax));
2289
+ }, safeWait);
2290
+
2291
+ const onEvent = () => {
2292
+ clearTimeout(timer);
2293
+ this.removeListener('eventQueued', onEvent);
2294
+ resolve(this.eventQueue.splice(0, safeMax));
2295
+ };
2296
+
2297
+ this.on('eventQueued', onEvent);
2298
+ });
2299
+ }
2300
+
2301
+ getStatus() {
2302
+ const transfers = this.listTransfers('all');
2303
+ return {
2304
+ session_id: this.id,
2305
+ state: this.state,
2306
+ connected: this.state === 'connected',
2307
+ room: this.config.room,
2308
+ stream_id: this.config.streamID,
2309
+ target_stream_id: this.config.targetStreamID,
2310
+ host: this.config.host,
2311
+ force_turn: this.config.forceTURN,
2312
+ peers: Array.from(this.connectedPeers),
2313
+ peer_count: this.connectedPeers.size,
2314
+ sync_peer_count: this.peerState.size,
2315
+ reconnect_count: this.reconnectCount,
2316
+ reconnect_delay_ms: this.currentReconnectDelayMs,
2317
+ last_ready_at: this.lastReadyAt,
2318
+ last_connected_at: this.lastConnectedAt,
2319
+ last_disconnected_at: this.lastDisconnectedAt,
2320
+ last_heartbeat_at: this.lastHeartbeatAt,
2321
+ last_error: this.lastError,
2322
+ state_keys: this.roomState.size,
2323
+ outgoing_transfers: transfers.outgoing.length,
2324
+ incoming_transfers: transfers.incoming.length
2325
+ };
2326
+ }
2327
+ }
2328
+
2329
+ module.exports = {
2330
+ BridgeSession
2331
+ };