@vdoninja/mcp 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -661
- package/README.md +59 -272
- package/assets/browser-agent/audio-worklet.js +17 -0
- package/assets/browser-agent/client.js +124 -0
- package/assets/browser-agent/index.html +50 -0
- package/assets/cloudflare/worker-proxy.js +3 -0
- package/docs/.nojekyll +0 -0
- package/docs/404.html +28 -0
- package/docs/advanced-diagnostics.html +101 -0
- package/docs/assets/apple-touch-icon.png +0 -0
- package/docs/assets/favicon.svg +7 -0
- package/docs/assets/how-it-works.svg +62 -0
- package/docs/assets/icon-192.png +0 -0
- package/docs/assets/icon-512.png +0 -0
- package/docs/assets/icon-maskable-512.png +0 -0
- package/docs/assets/icons.svg +42 -0
- package/docs/assets/logo.svg +7 -0
- package/docs/assets/og-image.png +0 -0
- package/docs/assets/site.css +735 -0
- package/docs/assets/site.js +289 -0
- package/docs/browser-agent.html +122 -0
- package/docs/browser-worker.html +165 -0
- package/docs/client-config-examples.html +254 -0
- package/docs/direction.html +175 -0
- package/docs/favicon.ico +0 -0
- package/docs/guide.html +872 -0
- package/docs/http-relay.html +154 -0
- package/docs/index.html +834 -0
- package/docs/mcp-tool-contract.html +226 -0
- package/docs/quickstart-and-compat.html +222 -0
- package/docs/site.webmanifest +16 -0
- package/docs/sitemap.xml +63 -0
- package/docs/ssapp-integration.html +594 -0
- package/docs/vdo-ninja-p2p-data-notes.html +225 -0
- package/package.json +51 -9
- package/references/advanced-diagnostics.md +2 -0
- package/references/browser-agent.md +53 -0
- package/references/browser-worker.md +142 -0
- package/references/client-config-examples.md +2 -0
- package/references/direction.md +88 -0
- package/references/http-relay.md +107 -0
- package/references/mcp-tool-contract.md +11 -1
- package/references/quickstart-and-compat.md +4 -1
- package/references/ssapp-integration.md +371 -0
- package/references/vdo-ninja-p2p-data-notes.md +2 -0
- package/scripts/demo-file-workflow.js +3 -0
- package/scripts/demo-message-workflow.js +3 -0
- package/scripts/install-mcp.js +5 -2
- package/scripts/lib/bridge-session.js +143 -26
- package/scripts/lib/bridge-utils.js +3 -0
- package/scripts/lib/browser-agent.js +255 -0
- package/scripts/lib/browser-media.js +79 -0
- package/scripts/lib/browser-routes.js +119 -0
- package/scripts/lib/browser-rpc.js +214 -0
- package/scripts/lib/browser-setup.js +133 -0
- package/scripts/lib/browser-worker.js +216 -0
- package/scripts/lib/fake-network-sdk.js +3 -0
- package/scripts/lib/http-relay-transport.js +294 -0
- package/scripts/lib/load-vdo-sdk.js +3 -0
- package/scripts/lib/mcp-stdio-parser.js +3 -0
- package/scripts/lib/ssapp-worker.js +198 -0
- package/scripts/lib/tool-profiles.js +3 -0
- package/scripts/vdo-data-bridge.js +3 -0
- package/scripts/vdo-mcp-browser-agent.js +132 -0
- package/scripts/vdo-mcp-browser.js +218 -0
- package/scripts/vdo-mcp-relay.js +207 -0
- package/scripts/vdo-mcp-server.js +50 -7
- package/scripts/vdo-mcp-ssapp.js +208 -0
- package/scripts/vdo-mcp-streamable-http.js +23 -4
- package/server.json +22 -4
- package/LICENSE-SDK-EXCEPTION +0 -54
package/scripts/install-mcp.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2026 Steve Seguin
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
2
5
|
'use strict';
|
|
3
6
|
|
|
4
7
|
const fs = require('node:fs');
|
|
@@ -239,7 +242,7 @@ function uninstallCodex(config, options) {
|
|
|
239
242
|
function installClaude(config, options) {
|
|
240
243
|
process.stdout.write(`Installing Claude MCP server '${config.name}' (scope=${config.claudeScope})...\n`);
|
|
241
244
|
if (claudeServerExists(config.name, options)) {
|
|
242
|
-
runCommand('claude', ['mcp', 'remove', config.name], options);
|
|
245
|
+
runCommand('claude', ['mcp', 'remove', config.name, '--scope', config.claudeScope], options);
|
|
243
246
|
}
|
|
244
247
|
const add = runCommand(
|
|
245
248
|
'claude',
|
|
@@ -256,7 +259,7 @@ function uninstallClaude(config, options) {
|
|
|
256
259
|
process.stdout.write('Claude MCP server not found; skipping.\n');
|
|
257
260
|
return;
|
|
258
261
|
}
|
|
259
|
-
const remove = runCommand('claude', ['mcp', 'remove', config.name], options);
|
|
262
|
+
const remove = runCommand('claude', ['mcp', 'remove', config.name, '--scope', config.claudeScope], options);
|
|
260
263
|
if (!remove.ok) throw new Error(`Failed to remove Claude MCP server '${config.name}'.`);
|
|
261
264
|
}
|
|
262
265
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Copyright (c) 2026 Steve Seguin
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
1
4
|
'use strict';
|
|
2
5
|
|
|
3
6
|
const crypto = require('node:crypto');
|
|
@@ -6,6 +9,7 @@ const EventEmitter = require('node:events');
|
|
|
6
9
|
const os = require('node:os');
|
|
7
10
|
const path = require('node:path');
|
|
8
11
|
const { loadVDONinjaSDK } = require('./load-vdo-sdk');
|
|
12
|
+
const { HttpRelayTransport, validateRelayConfig } = require('./http-relay-transport');
|
|
9
13
|
const {
|
|
10
14
|
intFromValue,
|
|
11
15
|
nonNegativeIntFromValue,
|
|
@@ -20,8 +24,6 @@ const {
|
|
|
20
24
|
generateShortId
|
|
21
25
|
} = require('./bridge-utils');
|
|
22
26
|
|
|
23
|
-
const VDONinjaSDK = loadVDONinjaSDK();
|
|
24
|
-
|
|
25
27
|
const PROTOCOL_MAGIC = 'vdo_mcp_bridge_v1';
|
|
26
28
|
const MAX_PROTOCOL_EVENTS = 4000;
|
|
27
29
|
const DEFAULT_FILE_CHUNK_BYTES = 64 * 1024;
|
|
@@ -103,6 +105,10 @@ class BridgeSession extends EventEmitter {
|
|
|
103
105
|
constructor(config, options = {}) {
|
|
104
106
|
super();
|
|
105
107
|
this.config = {
|
|
108
|
+
transport: config.transport || 'webrtc',
|
|
109
|
+
relayUrl: config.relayUrl,
|
|
110
|
+
relayToken: config.relayToken,
|
|
111
|
+
relayKey: config.relayKey,
|
|
106
112
|
host: config.host || 'wss://wss.vdo.ninja',
|
|
107
113
|
room: config.room,
|
|
108
114
|
streamID: config.streamID,
|
|
@@ -138,7 +144,14 @@ class BridgeSession extends EventEmitter {
|
|
|
138
144
|
};
|
|
139
145
|
|
|
140
146
|
this.id = options.id || null;
|
|
141
|
-
|
|
147
|
+
if (!['webrtc', 'http'].includes(this.config.transport)) throw new Error('invalid transport');
|
|
148
|
+
if (this.config.transport === 'http') validateRelayConfig(this.config);
|
|
149
|
+
this.sdkFactory = this.config.transport === 'http'
|
|
150
|
+
? (() => new HttpRelayTransport(this.config))
|
|
151
|
+
: (options.sdkFactory || ((sdkConfig) => {
|
|
152
|
+
const VDONinjaSDK = loadVDONinjaSDK();
|
|
153
|
+
return new VDONinjaSDK(sdkConfig);
|
|
154
|
+
}));
|
|
142
155
|
|
|
143
156
|
this.sdk = null;
|
|
144
157
|
this.running = false;
|
|
@@ -286,6 +299,9 @@ class BridgeSession extends EventEmitter {
|
|
|
286
299
|
if (parsed > current) {
|
|
287
300
|
this.stateActorClock.set(safeActor, parsed);
|
|
288
301
|
}
|
|
302
|
+
// The merge order compares clocks across actors, so local writes must
|
|
303
|
+
// advance beyond every observed clock, including snapshot metadata.
|
|
304
|
+
this.stateClock = Math.max(this.stateClock, parsed);
|
|
289
305
|
}
|
|
290
306
|
|
|
291
307
|
asStateEntry(key, value, actor, clock, updatedAt) {
|
|
@@ -328,8 +344,6 @@ class BridgeSession extends EventEmitter {
|
|
|
328
344
|
|
|
329
345
|
this.roomState.set(incoming.key, incoming);
|
|
330
346
|
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
347
|
|
|
334
348
|
this.queueEvent({
|
|
335
349
|
type: 'state_updated',
|
|
@@ -509,7 +523,8 @@ class BridgeSession extends EventEmitter {
|
|
|
509
523
|
room: this.config.room,
|
|
510
524
|
streamID: this.config.streamID,
|
|
511
525
|
targetStreamID: this.config.targetStreamID,
|
|
512
|
-
host: this.config.host,
|
|
526
|
+
host: this.config.transport === 'http' ? this.config.relayUrl : this.config.host,
|
|
527
|
+
transport: this.config.transport,
|
|
513
528
|
forceTURN: this.config.forceTURN
|
|
514
529
|
});
|
|
515
530
|
|
|
@@ -539,6 +554,8 @@ class BridgeSession extends EventEmitter {
|
|
|
539
554
|
}
|
|
540
555
|
this.boundListeners = [];
|
|
541
556
|
this.connectedPeers.clear();
|
|
557
|
+
for (const uuid of this.peerState.keys()) this.invalidatePeer(uuid);
|
|
558
|
+
this.sharedKeys.clear();
|
|
542
559
|
|
|
543
560
|
try {
|
|
544
561
|
this.sdk.disconnect();
|
|
@@ -552,16 +569,36 @@ class BridgeSession extends EventEmitter {
|
|
|
552
569
|
attachListeners() {
|
|
553
570
|
if (!this.sdk) return;
|
|
554
571
|
|
|
572
|
+
const sdk = this.sdk;
|
|
573
|
+
let acceptingEvents = true;
|
|
555
574
|
const bind = (eventName, handler) => {
|
|
556
|
-
|
|
557
|
-
|
|
575
|
+
const onEvent = (event) => {
|
|
576
|
+
if (!this.running || this.sdk !== sdk || !acceptingEvents) return;
|
|
577
|
+
if (eventName === 'disconnected') acceptingEvents = false;
|
|
578
|
+
handler(event);
|
|
579
|
+
};
|
|
580
|
+
sdk.addEventListener(eventName, onEvent);
|
|
581
|
+
this.boundListeners.push([eventName, onEvent]);
|
|
558
582
|
};
|
|
559
583
|
|
|
584
|
+
if (this.config.transport === 'http') {
|
|
585
|
+
bind('relayReceipt', (event) => this.queueEvent({ type: 'relay_receipt', ts: nowISO(), ...event.detail }));
|
|
586
|
+
bind('relayGap', (event) => this.queueEvent({ type: 'relay_history_lost', ts: nowISO(), ...event.detail }));
|
|
587
|
+
bind('relayRejected', (event) => this.queueEvent({ type: 'relay_message_rejected', ts: nowISO(), ...event.detail }));
|
|
588
|
+
bind('relayConnection', (event) => {
|
|
589
|
+
this.state = event.detail.connected ? 'connected' : 'reconnecting';
|
|
590
|
+
if (event.detail.connected) this.lastError = null;
|
|
591
|
+
this.queueEvent({ type: 'relay_connection', ts: nowISO(), ...event.detail });
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
560
595
|
bind('connected', () => {
|
|
561
596
|
this.queueEvent({ type: 'connected', ts: nowISO() });
|
|
562
597
|
});
|
|
563
598
|
|
|
564
599
|
bind('disconnected', () => {
|
|
600
|
+
for (const uuid of this.peerState.keys()) this.invalidatePeer(uuid);
|
|
601
|
+
this.sharedKeys.clear();
|
|
565
602
|
this.lastDisconnectedAt = nowISO();
|
|
566
603
|
this.state = 'disconnected';
|
|
567
604
|
this.queueEvent({ type: 'disconnected', ts: this.lastDisconnectedAt });
|
|
@@ -597,7 +634,8 @@ class BridgeSession extends EventEmitter {
|
|
|
597
634
|
detail: event.detail || null
|
|
598
635
|
});
|
|
599
636
|
if (uuid) {
|
|
600
|
-
this.touchPeer(uuid, null);
|
|
637
|
+
const peer = this.touchPeer(uuid, null);
|
|
638
|
+
if (peer.handshake_state === 'disconnected') peer.handshake_state = 'discovered';
|
|
601
639
|
this.sendSyncHello(uuid);
|
|
602
640
|
}
|
|
603
641
|
});
|
|
@@ -611,11 +649,7 @@ class BridgeSession extends EventEmitter {
|
|
|
611
649
|
uuid: uuid || null,
|
|
612
650
|
detail: event.detail || null
|
|
613
651
|
});
|
|
614
|
-
if (uuid
|
|
615
|
-
const peer = this.peerState.get(uuid);
|
|
616
|
-
peer.connected = false;
|
|
617
|
-
peer.last_seen_at = nowISO();
|
|
618
|
-
}
|
|
652
|
+
if (uuid) this.invalidatePeer(uuid);
|
|
619
653
|
});
|
|
620
654
|
|
|
621
655
|
bind('dataChannelOpen', (event) => {
|
|
@@ -657,12 +691,15 @@ class BridgeSession extends EventEmitter {
|
|
|
657
691
|
return;
|
|
658
692
|
}
|
|
659
693
|
|
|
694
|
+
if (!this.authorizeIncomingData(detail.uuid, detail.streamID, 'data')) return;
|
|
695
|
+
|
|
660
696
|
this.queueEvent({
|
|
661
697
|
type: 'data_received',
|
|
662
698
|
ts: nowISO(),
|
|
663
699
|
uuid: detail.uuid || null,
|
|
664
700
|
streamID: detail.streamID || null,
|
|
665
701
|
fallback: !!detail.fallback,
|
|
702
|
+
...(this.config.transport === 'http' ? { message_id: detail.message_id } : {}),
|
|
666
703
|
data,
|
|
667
704
|
binaryBase64
|
|
668
705
|
});
|
|
@@ -778,6 +815,41 @@ class BridgeSession extends EventEmitter {
|
|
|
778
815
|
return !!streamID && this.allowPeerStreamIDs.has(streamID);
|
|
779
816
|
}
|
|
780
817
|
|
|
818
|
+
invalidatePeer(uuid) {
|
|
819
|
+
const peer = this.peerState.get(uuid);
|
|
820
|
+
if (peer) {
|
|
821
|
+
peer.connected = false;
|
|
822
|
+
peer.auth_ok = false;
|
|
823
|
+
peer.handshake_state = 'disconnected';
|
|
824
|
+
peer.shared_key_ready = false;
|
|
825
|
+
peer.last_seen_at = nowISO();
|
|
826
|
+
}
|
|
827
|
+
this.sharedKeys.delete(uuid);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
authorizeIncomingData(uuid, streamID, kind) {
|
|
831
|
+
const peer = uuid ? this.peerState.get(uuid) : null;
|
|
832
|
+
const peerStreamID = streamID || (peer && peer.stream_id) || null;
|
|
833
|
+
const allowlisted = this.isPeerAllowed(peerStreamID);
|
|
834
|
+
const authenticated = !this.config.enforceJoinToken || !!(
|
|
835
|
+
peer && peer.auth_ok &&
|
|
836
|
+
(peer.handshake_state === 'hello_received' || peer.handshake_state === 'ready')
|
|
837
|
+
);
|
|
838
|
+
const revoked = (this.config.enforceJoinToken || this.allowPeerStreamIDs.size > 0) && peer &&
|
|
839
|
+
(peer.handshake_state === 'rejected' || peer.handshake_state === 'disconnected');
|
|
840
|
+
if (allowlisted && authenticated && !revoked) return true;
|
|
841
|
+
|
|
842
|
+
this.queueEvent({
|
|
843
|
+
type: 'protocol_auth_failed',
|
|
844
|
+
ts: nowISO(),
|
|
845
|
+
kind,
|
|
846
|
+
uuid: uuid || null,
|
|
847
|
+
stream_id: peerStreamID,
|
|
848
|
+
reason: allowlisted ? 'join_token_required' : 'peer_not_allowed'
|
|
849
|
+
});
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
|
|
781
853
|
maybeDeriveSharedKey(uuid, remotePubKeyBase64) {
|
|
782
854
|
if (!this.keyAgreement.enabled || !this.keyAgreement.privateKey) return false;
|
|
783
855
|
if (!uuid || !remotePubKeyBase64 || typeof remotePubKeyBase64 !== 'string') return false;
|
|
@@ -994,7 +1066,7 @@ class BridgeSession extends EventEmitter {
|
|
|
994
1066
|
|
|
995
1067
|
handleSyncMessage(fromUuid, fromStreamID, kind, payload) {
|
|
996
1068
|
const peer = this.touchPeer(fromUuid, fromStreamID);
|
|
997
|
-
if (
|
|
1069
|
+
if (!this.isPeerAllowed(fromStreamID || (peer && peer.stream_id))) {
|
|
998
1070
|
if (peer) {
|
|
999
1071
|
peer.handshake_state = 'rejected';
|
|
1000
1072
|
peer.auth_ok = false;
|
|
@@ -1067,6 +1139,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1067
1139
|
|
|
1068
1140
|
if (peer) {
|
|
1069
1141
|
peer.auth_ok = tokenCheck.ok;
|
|
1142
|
+
peer.rejected_reason = null;
|
|
1070
1143
|
peer.token_payload = tokenCheck.payload || null;
|
|
1071
1144
|
peer.handshake_state = kind === 'sync.hello_ack' ? 'ready' : 'hello_received';
|
|
1072
1145
|
peer.capabilities = payload && payload.capabilities ? payload.capabilities : null;
|
|
@@ -1200,6 +1273,8 @@ class BridgeSession extends EventEmitter {
|
|
|
1200
1273
|
transfer.completed_at = nowISO();
|
|
1201
1274
|
transfer.updated_at = transfer.completed_at;
|
|
1202
1275
|
|
|
1276
|
+
// A retry completes the same transfer again; retain each ID only once.
|
|
1277
|
+
this.completedIncomingOrder = this.completedIncomingOrder.filter((id) => id !== transfer.transfer_id);
|
|
1203
1278
|
this.completedIncomingOrder.push(transfer.transfer_id);
|
|
1204
1279
|
while (this.completedIncomingOrder.length > this.config.maxStoredFiles) {
|
|
1205
1280
|
const oldestId = this.completedIncomingOrder.shift();
|
|
@@ -1248,8 +1323,27 @@ class BridgeSession extends EventEmitter {
|
|
|
1248
1323
|
return;
|
|
1249
1324
|
}
|
|
1250
1325
|
|
|
1326
|
+
// A retained transfer belongs to one peer and one immutable offer.
|
|
1327
|
+
const existing = this.incomingTransfers.get(transferId);
|
|
1328
|
+
const incomingKinds = ['file.offer', 'file.chunk', 'file.complete', 'file.resume_req', 'file.cancel'];
|
|
1329
|
+
const ownerConflict = existing && incomingKinds.includes(kind) && existing.from_uuid !== (fromUuid || null);
|
|
1330
|
+
const offerConflict = existing && kind === 'file.offer' && (
|
|
1331
|
+
existing.total_bytes !== intFromValue(payload.total_bytes, 0) ||
|
|
1332
|
+
existing.total_chunks !== intFromValue(payload.total_chunks, 0) ||
|
|
1333
|
+
existing.chunk_bytes !== clampInt(payload.chunk_bytes, this.config.fileChunkBytes, MIN_CHUNK_BYTES, MAX_CHUNK_BYTES) ||
|
|
1334
|
+
existing.file_hash !== (payload.file_hash || null) ||
|
|
1335
|
+
existing.name !== (payload.name || null) ||
|
|
1336
|
+
existing.mime !== (payload.mime || 'application/octet-stream')
|
|
1337
|
+
);
|
|
1338
|
+
if (ownerConflict || offerConflict) {
|
|
1339
|
+
const reason = ownerConflict ? 'transfer_owner_conflict' : 'transfer_metadata_conflict';
|
|
1340
|
+
this.sendProtocol('file.nack', { transfer_id: transferId, reason }, fromUuid || null, { preferMac: true });
|
|
1341
|
+
this.queueEvent({ type: 'file_offer_rejected', ts: nowISO(), transfer_id: transferId, reason });
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1251
1345
|
if (kind === 'file.offer') {
|
|
1252
|
-
let incoming =
|
|
1346
|
+
let incoming = existing;
|
|
1253
1347
|
if (!incoming) {
|
|
1254
1348
|
try {
|
|
1255
1349
|
incoming = this.createIncomingTransfer(fromUuid, fromStreamID, payload || {});
|
|
@@ -1453,7 +1547,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1453
1547
|
|
|
1454
1548
|
if (kind === 'file.accept' || kind === 'file.ack' || kind === 'file.nack' || kind === 'file.complete_ack' || kind === 'file.resume_state') {
|
|
1455
1549
|
const outgoing = this.outgoingTransfers.get(transferId);
|
|
1456
|
-
if (outgoing) {
|
|
1550
|
+
if (outgoing && outgoing.target_uuid === fromUuid) {
|
|
1457
1551
|
outgoing.updated_at = nowISO();
|
|
1458
1552
|
|
|
1459
1553
|
if (kind === 'file.accept') {
|
|
@@ -1473,8 +1567,13 @@ class BridgeSession extends EventEmitter {
|
|
|
1473
1567
|
}
|
|
1474
1568
|
|
|
1475
1569
|
if (kind === 'file.complete_ack') {
|
|
1476
|
-
outgoing.
|
|
1477
|
-
|
|
1570
|
+
if (payload.file_hash !== outgoing.file_hash || payload.total_bytes !== outgoing.total_bytes) {
|
|
1571
|
+
outgoing.status = 'failed';
|
|
1572
|
+
outgoing.last_error = 'completion_metadata_mismatch';
|
|
1573
|
+
} else {
|
|
1574
|
+
outgoing.status = 'completed';
|
|
1575
|
+
outgoing.completed_at = nowISO();
|
|
1576
|
+
}
|
|
1478
1577
|
}
|
|
1479
1578
|
|
|
1480
1579
|
if (kind === 'file.resume_state') {
|
|
@@ -1559,6 +1658,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1559
1658
|
|
|
1560
1659
|
const isSync = kind.startsWith('sync.');
|
|
1561
1660
|
if (!isSync) {
|
|
1661
|
+
if (!this.authorizeIncomingData(fromUuid, fromStreamID, kind)) return;
|
|
1562
1662
|
const macOk = this.verifyEnvelopeMac(fromUuid, envelope);
|
|
1563
1663
|
if (!macOk) {
|
|
1564
1664
|
this.queueEvent({
|
|
@@ -1872,7 +1972,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1872
1972
|
};
|
|
1873
1973
|
}
|
|
1874
1974
|
|
|
1875
|
-
async requestResumeState(transfer, timeoutMs) {
|
|
1975
|
+
async requestResumeState(transfer, timeoutMs, requireCompleted = false) {
|
|
1876
1976
|
const cursor = this.protocolCursor;
|
|
1877
1977
|
this.sendProtocol('file.resume_req', {
|
|
1878
1978
|
transfer_id: transfer.transfer_id
|
|
@@ -1884,7 +1984,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1884
1984
|
event.from_uuid === transfer.target_uuid
|
|
1885
1985
|
), timeoutMs, cursor);
|
|
1886
1986
|
|
|
1887
|
-
if (!resume) return null;
|
|
1987
|
+
if (!resume || (requireCompleted && resume.payload.status !== 'completed')) return null;
|
|
1888
1988
|
return nonNegativeIntFromValue(resume.payload && resume.payload.next_seq, transfer.next_seq);
|
|
1889
1989
|
}
|
|
1890
1990
|
|
|
@@ -1908,7 +2008,7 @@ class BridgeSession extends EventEmitter {
|
|
|
1908
2008
|
}
|
|
1909
2009
|
|
|
1910
2010
|
const accepted = await this.waitForProtocolEvent((event) => (
|
|
1911
|
-
event.kind === 'file.accept' &&
|
|
2011
|
+
(event.kind === 'file.accept' || event.kind === 'file.nack') &&
|
|
1912
2012
|
event.transfer_id === transfer.transfer_id &&
|
|
1913
2013
|
event.from_uuid === transfer.target_uuid
|
|
1914
2014
|
), Math.max(ackTimeoutMs, 1000), offerCursor);
|
|
@@ -1917,6 +2017,12 @@ class BridgeSession extends EventEmitter {
|
|
|
1917
2017
|
throw new Error('timed out waiting for file.accept');
|
|
1918
2018
|
}
|
|
1919
2019
|
|
|
2020
|
+
if (accepted.kind === 'file.nack') {
|
|
2021
|
+
transfer.status = 'failed';
|
|
2022
|
+
transfer.last_error = accepted.payload.reason || 'file_offer_rejected';
|
|
2023
|
+
throw new Error(transfer.last_error);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
1920
2026
|
transfer.status = 'transferring';
|
|
1921
2027
|
transfer.next_seq = nonNegativeIntFromValue(accepted.payload && accepted.payload.next_seq, 0);
|
|
1922
2028
|
|
|
@@ -1965,7 +2071,9 @@ class BridgeSession extends EventEmitter {
|
|
|
1965
2071
|
const resumeSeq = await this.requestResumeState(transfer, ackTimeoutMs);
|
|
1966
2072
|
if (Number.isFinite(resumeSeq)) {
|
|
1967
2073
|
seq = Math.min(Math.max(resumeSeq, 0), transfer.total_chunks);
|
|
2074
|
+
// Restart the outer loop so the chunk and its hash match the resumed sequence.
|
|
1968
2075
|
transfer.next_seq = seq;
|
|
2076
|
+
break;
|
|
1969
2077
|
}
|
|
1970
2078
|
continue;
|
|
1971
2079
|
}
|
|
@@ -2000,8 +2108,14 @@ class BridgeSession extends EventEmitter {
|
|
|
2000
2108
|
event.from_uuid === transfer.target_uuid
|
|
2001
2109
|
), Math.max(ackTimeoutMs * 2, 1000), completeCursor);
|
|
2002
2110
|
|
|
2111
|
+
if (completeAck && (completeAck.payload.file_hash !== transfer.file_hash || completeAck.payload.total_bytes !== transfer.total_bytes)) {
|
|
2112
|
+
transfer.status = 'failed';
|
|
2113
|
+
transfer.last_error = 'completion_metadata_mismatch';
|
|
2114
|
+
throw new Error(transfer.last_error);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2003
2117
|
if (!completeAck) {
|
|
2004
|
-
const resumeSeq = await this.requestResumeState(transfer, ackTimeoutMs);
|
|
2118
|
+
const resumeSeq = await this.requestResumeState(transfer, ackTimeoutMs, true);
|
|
2005
2119
|
if (Number.isFinite(resumeSeq) && resumeSeq >= transfer.total_chunks) {
|
|
2006
2120
|
transfer.status = 'completed';
|
|
2007
2121
|
transfer.completed_at = nowISO();
|
|
@@ -2016,6 +2130,8 @@ class BridgeSession extends EventEmitter {
|
|
|
2016
2130
|
}
|
|
2017
2131
|
|
|
2018
2132
|
transfer.updated_at = nowISO();
|
|
2133
|
+
// A retry completes the same transfer again; retain each ID only once.
|
|
2134
|
+
this.completedOutgoingOrder = this.completedOutgoingOrder.filter((id) => id !== transfer.transfer_id);
|
|
2019
2135
|
this.completedOutgoingOrder.push(transfer.transfer_id);
|
|
2020
2136
|
while (this.completedOutgoingOrder.length > this.config.maxStoredFiles) {
|
|
2021
2137
|
const oldestId = this.completedOutgoingOrder.shift();
|
|
@@ -2249,13 +2365,13 @@ class BridgeSession extends EventEmitter {
|
|
|
2249
2365
|
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
2250
2366
|
|
|
2251
2367
|
if (transfer.spooled && transfer.spool_path) {
|
|
2252
|
-
fs.copyFileSync(transfer.spool_path, resolvedPath);
|
|
2368
|
+
fs.copyFileSync(transfer.spool_path, resolvedPath, overwrite ? 0 : fs.constants.COPYFILE_EXCL);
|
|
2253
2369
|
} else {
|
|
2254
2370
|
const data = this.getIncomingTransferBuffer(transfer);
|
|
2255
2371
|
if (!data) {
|
|
2256
2372
|
throw new Error(`transfer payload unavailable: ${transferId}`);
|
|
2257
2373
|
}
|
|
2258
|
-
fs.writeFileSync(resolvedPath, data);
|
|
2374
|
+
fs.writeFileSync(resolvedPath, data, { flag: overwrite ? 'w' : 'wx' });
|
|
2259
2375
|
}
|
|
2260
2376
|
|
|
2261
2377
|
const stat = fs.statSync(resolvedPath);
|
|
@@ -2304,10 +2420,11 @@ class BridgeSession extends EventEmitter {
|
|
|
2304
2420
|
session_id: this.id,
|
|
2305
2421
|
state: this.state,
|
|
2306
2422
|
connected: this.state === 'connected',
|
|
2423
|
+
transport: this.config.transport,
|
|
2307
2424
|
room: this.config.room,
|
|
2308
2425
|
stream_id: this.config.streamID,
|
|
2309
2426
|
target_stream_id: this.config.targetStreamID,
|
|
2310
|
-
host: this.config.host,
|
|
2427
|
+
host: this.config.transport === 'http' ? this.config.relayUrl : this.config.host,
|
|
2311
2428
|
force_turn: this.config.forceTURN,
|
|
2312
2429
|
peers: Array.from(this.connectedPeers),
|
|
2313
2430
|
peer_count: this.connectedPeers.size,
|