@vdoninja/ninja-p2p 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/ninja-p2p/SKILL.md +102 -2
- package/.claude/skills/ninja-p2p/SKILL.md +209 -130
- package/.codex/skills/ninja-p2p/SKILL.md +102 -2
- package/CHANGELOG.md +113 -0
- package/README.md +1077 -775
- package/dashboard.html +370 -50
- package/dist/agent-state.js +9 -0
- package/dist/cli-lib.d.ts +40 -0
- package/dist/cli-lib.js +165 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +501 -10
- package/dist/demo.d.ts +37 -0
- package/dist/demo.js +186 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.js +219 -0
- package/dist/file-transfer.d.ts +15 -2
- package/dist/file-transfer.js +249 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/message-bus.d.ts +15 -0
- package/dist/message-bus.js +32 -3
- package/dist/peer-registry.js +7 -0
- package/dist/protocol.d.ts +78 -1
- package/dist/protocol.js +42 -6
- package/dist/shared-folders.js +20 -1
- package/dist/social-stream.d.ts +100 -0
- package/dist/social-stream.js +254 -0
- package/dist/swarm-manager.d.ts +164 -0
- package/dist/swarm-manager.js +957 -0
- package/dist/swarm-session.d.ts +197 -0
- package/dist/swarm-session.js +465 -0
- package/dist/swarm-wire.d.ts +48 -0
- package/dist/swarm-wire.js +86 -0
- package/dist/swarm.d.ts +258 -0
- package/dist/swarm.js +694 -0
- package/dist/vdo-bridge.d.ts +62 -1
- package/dist/vdo-bridge.js +273 -23
- package/dist/vdoninja-sdk-types.d.ts +75 -0
- package/dist/vdoninja-sdk-types.js +10 -0
- package/dist/wake.d.ts +83 -0
- package/dist/wake.js +206 -0
- package/docs/protocol-and-reliability.md +231 -38
- package/docs/sdk-wishlist.md +236 -0
- package/docs/security.md +197 -0
- package/docs/social-stream-bridge.md +390 -0
- package/package.json +125 -116
package/dist/vdo-bridge.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { EventEmitter } from "node:events";
|
|
|
11
11
|
import { MessageBus, type MessageBusOptions } from "./message-bus.js";
|
|
12
12
|
import { PeerRegistry } from "./peer-registry.js";
|
|
13
13
|
import { type AgentProfile, type FileOfferPayload, type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity } from "./protocol.js";
|
|
14
|
+
import type { VDONinja } from "./vdoninja-sdk-types.js";
|
|
14
15
|
export type VDOBridgeOptions = {
|
|
15
16
|
room: string;
|
|
16
17
|
streamId: string;
|
|
@@ -44,10 +45,24 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
44
45
|
private version;
|
|
45
46
|
private agentProfile;
|
|
46
47
|
private readonly viewedStreamIds;
|
|
48
|
+
private disconnecting;
|
|
49
|
+
/** Set while the SDK is re-establishing signalling and replaying its intent. */
|
|
50
|
+
private restoring;
|
|
51
|
+
private restoringUntil;
|
|
47
52
|
constructor(options: VDOBridgeOptions);
|
|
48
53
|
connect(): Promise<void>;
|
|
49
54
|
disconnect(): Promise<void>;
|
|
50
55
|
isConnected(): boolean;
|
|
56
|
+
/** Whether the SDK is mid-reconnect, or just finished and still settling. */
|
|
57
|
+
isRestoring(): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Resolve once the SDK has genuinely finished tearing down.
|
|
60
|
+
*
|
|
61
|
+
* Fallback for an overridden pre-1.4.1 SDK or a teardown promise that rejects.
|
|
62
|
+
* The early socket-phase "disconnected" event is not completion, so observe
|
|
63
|
+
* the state the cleanup clears and cap the wait.
|
|
64
|
+
*/
|
|
65
|
+
private waitForSdkTeardown;
|
|
51
66
|
private emitBridgeError;
|
|
52
67
|
/** Update skills and broadcast the change. */
|
|
53
68
|
updateSkills(skills: string[]): void;
|
|
@@ -71,6 +86,35 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
71
86
|
sendImage(targetStreamId: string, filePath: string): FileOfferPayload;
|
|
72
87
|
/** Send raw data through the underlying SDK without envelope wrapping. */
|
|
73
88
|
sendRaw(data: unknown, targetStreamId?: string): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether this build can put raw bytes on the wire.
|
|
91
|
+
*
|
|
92
|
+
* False on SDK versions before 1.4.1, where everything was JSON-stringified
|
|
93
|
+
* before it reached the data channel. Callers fall back to base64 rather than
|
|
94
|
+
* failing: a swarm with a mix of old and new peers still works, just slower
|
|
95
|
+
* with the old ones.
|
|
96
|
+
*/
|
|
97
|
+
supportsBinary(): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Send raw bytes to one peer, bypassing JSON entirely.
|
|
100
|
+
*
|
|
101
|
+
* Resolves false rather than throwing when the peer is unknown or the SDK is
|
|
102
|
+
* too old, so a caller can treat it as "try binary, else base64" in one line.
|
|
103
|
+
*/
|
|
104
|
+
sendBinaryTo(targetStreamId: string, bytes: Uint8Array): Promise<boolean>;
|
|
105
|
+
/**
|
|
106
|
+
* Bytes still queued for a peer on the binary lane, or null if unknown.
|
|
107
|
+
*
|
|
108
|
+
* Note this is the binary lane specifically. Reading the control channel
|
|
109
|
+
* while bulk traffic queues on `x-bin` reports a permanent zero, which is
|
|
110
|
+
* exactly how the SDK's own docs concluded that the value never moves under
|
|
111
|
+
* `@roamhq/wrtc` — see docs/sdk-wishlist.md.
|
|
112
|
+
*/
|
|
113
|
+
bufferedBytesFor(targetStreamId: string): number | null;
|
|
114
|
+
/** Negotiated SCTP message limit for a peer, or null if not reported. */
|
|
115
|
+
maxMessageSizeFor(targetStreamId: string): number | null;
|
|
116
|
+
/** Smallest limit any connected peer reports, or null if none report one. */
|
|
117
|
+
smallestMaxMessageSize(): number | null;
|
|
74
118
|
/** Reply to a received message using its sender as the target. */
|
|
75
119
|
reply(message: MessageEnvelope, type: MessageType, payload: unknown): MessageEnvelope;
|
|
76
120
|
/** Acknowledge receipt of a received message. */
|
|
@@ -80,11 +124,28 @@ export declare class VDOBridge extends EventEmitter {
|
|
|
80
124
|
/** Ask a peer to replay recent message history to this bridge. */
|
|
81
125
|
requestHistory(targetStreamId: string, count?: number): MessageEnvelope;
|
|
82
126
|
/** Access the underlying VDO.Ninja SDK instance for advanced media workflows. */
|
|
83
|
-
getSDK():
|
|
127
|
+
getSDK(): VDONinja | null;
|
|
84
128
|
private wireSDKEvents;
|
|
129
|
+
/**
|
|
130
|
+
* The SDK types enumerate the stable public events but the bridge also
|
|
131
|
+
* listens to long-standing compatibility aliases. Keep the cast at this one
|
|
132
|
+
* boundary rather than weakening every handler.
|
|
133
|
+
*/
|
|
134
|
+
private addSDKEventListener;
|
|
85
135
|
private startHeartbeat;
|
|
86
136
|
private stopHeartbeat;
|
|
87
137
|
private respondPong;
|
|
88
138
|
private broadcastSkillUpdate;
|
|
139
|
+
/**
|
|
140
|
+
* Tear down and rebuild the connection to one peer.
|
|
141
|
+
*
|
|
142
|
+
* A peer connection can report `open` on the sending side while nothing
|
|
143
|
+
* actually crosses it — measured after a signalling blip, where chunk
|
|
144
|
+
* requests arrived, the sender's `send()` returned success, and the bytes
|
|
145
|
+
* never landed. Nothing below us notices, because every layer believes it is
|
|
146
|
+
* fine. The only evidence is at the application layer: a peer that keeps
|
|
147
|
+
* failing to deliver. This is how that evidence gets acted on.
|
|
148
|
+
*/
|
|
149
|
+
revivePeer(streamId: string): boolean;
|
|
89
150
|
private maybeViewPeer;
|
|
90
151
|
}
|
package/dist/vdo-bridge.js
CHANGED
|
@@ -7,11 +7,35 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Zero dependencies on stevesbot internals.
|
|
9
9
|
*/
|
|
10
|
+
import { createRequire } from "node:module";
|
|
10
11
|
import { EventEmitter } from "node:events";
|
|
12
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
11
13
|
import { sendFileFromPath } from "./file-transfer.js";
|
|
12
14
|
import { MessageBus } from "./message-bus.js";
|
|
13
15
|
import { PeerRegistry } from "./peer-registry.js";
|
|
14
16
|
import { createEnvelope, createInstanceId, envelopeToWire, parseEnvelope, } from "./protocol.js";
|
|
17
|
+
/**
|
|
18
|
+
* The version advertised to peers.
|
|
19
|
+
*
|
|
20
|
+
* Read from package.json rather than typed in. It was a literal, and had
|
|
21
|
+
* already drifted from the released version — peers were told whatever someone
|
|
22
|
+
* last remembered to edit, which is worse than no version at all because it
|
|
23
|
+
* looks authoritative.
|
|
24
|
+
*/
|
|
25
|
+
function readPackageVersion() {
|
|
26
|
+
try {
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const pkg = require("../package.json");
|
|
29
|
+
return pkg.version ?? "0.0.0";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "0.0.0";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Cap on how long a shutdown waits for the SDK, so it can never wedge an exit. */
|
|
36
|
+
const TEARDOWN_TIMEOUT_MS = 5000;
|
|
37
|
+
/** Quiet period after a reconnect, while the SDK replays its own view intent. */
|
|
38
|
+
const RESTORE_GRACE_MS = 8000;
|
|
15
39
|
// ── Class ────────────────────────────────────────────────────────────────────
|
|
16
40
|
export class VDOBridge extends EventEmitter {
|
|
17
41
|
peers;
|
|
@@ -24,9 +48,13 @@ export class VDOBridge extends EventEmitter {
|
|
|
24
48
|
skills;
|
|
25
49
|
status = "idle";
|
|
26
50
|
statusDetail = "";
|
|
27
|
-
version =
|
|
51
|
+
version = readPackageVersion();
|
|
28
52
|
agentProfile;
|
|
29
53
|
viewedStreamIds = new Set();
|
|
54
|
+
disconnecting = false;
|
|
55
|
+
/** Set while the SDK is re-establishing signalling and replaying its intent. */
|
|
56
|
+
restoring = false;
|
|
57
|
+
restoringUntil = 0;
|
|
30
58
|
constructor(options) {
|
|
31
59
|
super();
|
|
32
60
|
this.options = options;
|
|
@@ -88,6 +116,8 @@ export class VDOBridge extends EventEmitter {
|
|
|
88
116
|
async disconnect() {
|
|
89
117
|
if (!this.connected || !this.sdk)
|
|
90
118
|
return;
|
|
119
|
+
const sdk = this.sdk;
|
|
120
|
+
this.disconnecting = true;
|
|
91
121
|
this.stopHeartbeat();
|
|
92
122
|
// Send a leaving event to all peers
|
|
93
123
|
try {
|
|
@@ -95,15 +125,40 @@ export class VDOBridge extends EventEmitter {
|
|
|
95
125
|
this.sdk.sendData(envelopeToWire(envelope));
|
|
96
126
|
}
|
|
97
127
|
catch { /* best effort */ }
|
|
128
|
+
// Every viewed peer holds an RTCPeerConnection. Under @roamhq/wrtc those
|
|
129
|
+
// are native handles that keep Node's event loop alive, so a CLI that only
|
|
130
|
+
// dropped the bookkeeping set would connect, do its work, and then hang
|
|
131
|
+
// forever instead of exiting.
|
|
132
|
+
for (const streamId of this.viewedStreamIds) {
|
|
133
|
+
try {
|
|
134
|
+
this.sdk.stopViewing(streamId);
|
|
135
|
+
}
|
|
136
|
+
catch { /* best effort */ }
|
|
137
|
+
}
|
|
98
138
|
try {
|
|
99
139
|
this.sdk.leaveRoom();
|
|
100
140
|
}
|
|
101
141
|
catch { /* best effort */ }
|
|
142
|
+
// Tearing the process down before the SDK has finished closing peer
|
|
143
|
+
// connections kills the native WebRTC module mid-cleanup, so wait for real
|
|
144
|
+
// completion. The 1.4.1 floor resolves exactly then; the state-poll fallback
|
|
145
|
+
// keeps forced older installs and a rejected teardown from exiting halfway.
|
|
102
146
|
try {
|
|
103
|
-
|
|
147
|
+
const result = sdk.disconnect();
|
|
148
|
+
if (result && typeof result.then === "function") {
|
|
149
|
+
await Promise.race([result, delay(TEARDOWN_TIMEOUT_MS)]);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await this.waitForSdkTeardown(sdk);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// A disconnect that throws has not cleaned up, so fall back to observing
|
|
157
|
+
// the state rather than exiting into a half-torn-down native module.
|
|
158
|
+
await this.waitForSdkTeardown(sdk);
|
|
104
159
|
}
|
|
105
|
-
catch { /* best effort */ }
|
|
106
160
|
this.connected = false;
|
|
161
|
+
this.disconnecting = false;
|
|
107
162
|
this.sdk = null;
|
|
108
163
|
this.viewedStreamIds.clear();
|
|
109
164
|
this.peers.clear();
|
|
@@ -113,6 +168,28 @@ export class VDOBridge extends EventEmitter {
|
|
|
113
168
|
isConnected() {
|
|
114
169
|
return this.connected;
|
|
115
170
|
}
|
|
171
|
+
/** Whether the SDK is mid-reconnect, or just finished and still settling. */
|
|
172
|
+
isRestoring() {
|
|
173
|
+
return this.restoring || Date.now() < this.restoringUntil;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Resolve once the SDK has genuinely finished tearing down.
|
|
177
|
+
*
|
|
178
|
+
* Fallback for an overridden pre-1.4.1 SDK or a teardown promise that rejects.
|
|
179
|
+
* The early socket-phase "disconnected" event is not completion, so observe
|
|
180
|
+
* the state the cleanup clears and cap the wait.
|
|
181
|
+
*/
|
|
182
|
+
async waitForSdkTeardown(sdk, timeoutMs = TEARDOWN_TIMEOUT_MS) {
|
|
183
|
+
const state = sdk;
|
|
184
|
+
const deadline = Date.now() + timeoutMs;
|
|
185
|
+
while (Date.now() < deadline) {
|
|
186
|
+
const connectionsClosed = (state.connections?.size ?? 0) === 0;
|
|
187
|
+
const signalingClosed = !state.signaling;
|
|
188
|
+
if (connectionsClosed && signalingClosed)
|
|
189
|
+
return;
|
|
190
|
+
await delay(50);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
116
193
|
emitBridgeError(err) {
|
|
117
194
|
if (this.listenerCount("error") > 0) {
|
|
118
195
|
this.emit("error", err);
|
|
@@ -197,6 +274,88 @@ export class VDOBridge extends EventEmitter {
|
|
|
197
274
|
return false;
|
|
198
275
|
}
|
|
199
276
|
}
|
|
277
|
+
// ── Binary lane ──────────────────────────────────────────────────────────
|
|
278
|
+
/**
|
|
279
|
+
* Whether this build can put raw bytes on the wire.
|
|
280
|
+
*
|
|
281
|
+
* False on SDK versions before 1.4.1, where everything was JSON-stringified
|
|
282
|
+
* before it reached the data channel. Callers fall back to base64 rather than
|
|
283
|
+
* failing: a swarm with a mix of old and new peers still works, just slower
|
|
284
|
+
* with the old ones.
|
|
285
|
+
*/
|
|
286
|
+
supportsBinary() {
|
|
287
|
+
return typeof this.sdk?.sendBinary === "function";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Send raw bytes to one peer, bypassing JSON entirely.
|
|
291
|
+
*
|
|
292
|
+
* Resolves false rather than throwing when the peer is unknown or the SDK is
|
|
293
|
+
* too old, so a caller can treat it as "try binary, else base64" in one line.
|
|
294
|
+
*/
|
|
295
|
+
async sendBinaryTo(targetStreamId, bytes) {
|
|
296
|
+
const sdk = this.sdk;
|
|
297
|
+
if (!sdk || typeof sdk.sendBinary !== "function")
|
|
298
|
+
return false;
|
|
299
|
+
const uuid = this.peers.getPeer(targetStreamId)?.uuid;
|
|
300
|
+
if (!uuid)
|
|
301
|
+
return false;
|
|
302
|
+
try {
|
|
303
|
+
return await sdk.sendBinary(bytes, uuid);
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
this.emitBridgeError(err);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Bytes still queued for a peer on the binary lane, or null if unknown.
|
|
312
|
+
*
|
|
313
|
+
* Note this is the binary lane specifically. Reading the control channel
|
|
314
|
+
* while bulk traffic queues on `x-bin` reports a permanent zero, which is
|
|
315
|
+
* exactly how the SDK's own docs concluded that the value never moves under
|
|
316
|
+
* `@roamhq/wrtc` — see docs/sdk-wishlist.md.
|
|
317
|
+
*/
|
|
318
|
+
bufferedBytesFor(targetStreamId) {
|
|
319
|
+
const sdk = this.sdk;
|
|
320
|
+
if (!sdk || typeof sdk.getBufferedAmount !== "function")
|
|
321
|
+
return null;
|
|
322
|
+
const uuid = this.peers.getPeer(targetStreamId)?.uuid;
|
|
323
|
+
if (!uuid)
|
|
324
|
+
return null;
|
|
325
|
+
try {
|
|
326
|
+
return sdk.getBufferedAmount(uuid, "bin");
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** Negotiated SCTP message limit for a peer, or null if not reported. */
|
|
333
|
+
maxMessageSizeFor(targetStreamId) {
|
|
334
|
+
const sdk = this.sdk;
|
|
335
|
+
if (!sdk || typeof sdk.getMaxMessageSize !== "function")
|
|
336
|
+
return null;
|
|
337
|
+
const uuid = this.peers.getPeer(targetStreamId)?.uuid;
|
|
338
|
+
if (!uuid)
|
|
339
|
+
return null;
|
|
340
|
+
try {
|
|
341
|
+
return sdk.getMaxMessageSize(uuid);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Smallest limit any connected peer reports, or null if none report one. */
|
|
348
|
+
smallestMaxMessageSize() {
|
|
349
|
+
let smallest = null;
|
|
350
|
+
for (const peer of this.peers.getConnectedPeers()) {
|
|
351
|
+
const limit = this.maxMessageSizeFor(peer.streamId);
|
|
352
|
+
if (limit === null)
|
|
353
|
+
continue;
|
|
354
|
+
if (smallest === null || limit < smallest)
|
|
355
|
+
smallest = limit;
|
|
356
|
+
}
|
|
357
|
+
return smallest;
|
|
358
|
+
}
|
|
200
359
|
/** Reply to a received message using its sender as the target. */
|
|
201
360
|
reply(message, type, payload) {
|
|
202
361
|
return this.bus.send(message.from.streamId, type, payload);
|
|
@@ -237,14 +396,14 @@ export class VDOBridge extends EventEmitter {
|
|
|
237
396
|
if (!this.sdk)
|
|
238
397
|
return;
|
|
239
398
|
// Peer connected (WebRTC connection established)
|
|
240
|
-
this.
|
|
399
|
+
this.addSDKEventListener("peerConnected", (event) => {
|
|
241
400
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
242
401
|
const streamId = event.detail?.streamID ?? uuid;
|
|
243
402
|
this.peers.addPeer(streamId, uuid);
|
|
244
403
|
this.emit("peer:connected", { streamId, uuid });
|
|
245
404
|
});
|
|
246
405
|
// Data channel opened — send our announce
|
|
247
|
-
this.
|
|
406
|
+
this.addSDKEventListener("dataChannelOpen", (event) => {
|
|
248
407
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
249
408
|
const mappedStreamId = this.peers.streamIdForUuid(uuid);
|
|
250
409
|
const eventStreamId = event.detail?.streamID;
|
|
@@ -252,7 +411,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
252
411
|
// Send announce to this specific peer
|
|
253
412
|
const announce = createEnvelope(this.identity, "announce", this.getAnnouncePayload(), { to: streamId });
|
|
254
413
|
try {
|
|
255
|
-
this.sdk.sendData(envelopeToWire(announce), {
|
|
414
|
+
this.sdk.sendData(envelopeToWire(announce), { uuid });
|
|
256
415
|
}
|
|
257
416
|
catch { /* peer may have disconnected */ }
|
|
258
417
|
// Flush any queued offline messages for this peer
|
|
@@ -263,7 +422,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
263
422
|
this.emit("datachannel:open", { streamId, uuid });
|
|
264
423
|
});
|
|
265
424
|
// Data received — parse and route through MessageBus
|
|
266
|
-
this.
|
|
425
|
+
this.addSDKEventListener("dataReceived", (event) => {
|
|
267
426
|
const raw = event.detail?.data;
|
|
268
427
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
269
428
|
const envelope = parseEnvelope(raw);
|
|
@@ -274,7 +433,20 @@ export class VDOBridge extends EventEmitter {
|
|
|
274
433
|
}
|
|
275
434
|
// If we don't have a streamId mapping for this uuid, use the envelope's from
|
|
276
435
|
const senderStreamId = envelope.from.streamId;
|
|
277
|
-
|
|
436
|
+
const mappedSender = this.peers.streamIdForUuid(uuid);
|
|
437
|
+
const claimedPeer = this.peers.getPeer(senderStreamId);
|
|
438
|
+
if ((mappedSender && mappedSender !== uuid && mappedSender !== senderStreamId) ||
|
|
439
|
+
(claimedPeer && claimedPeer.uuid !== uuid && claimedPeer.connected)) {
|
|
440
|
+
if (this.options.debug) {
|
|
441
|
+
console.warn(`[P2P] Rejected identity switch on ${uuid}: ` +
|
|
442
|
+
`${mappedSender ?? "unmapped"} -> ${senderStreamId}`);
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (claimedPeer && claimedPeer.uuid !== uuid && !claimedPeer.connected) {
|
|
447
|
+
this.peers.addPeer(senderStreamId, uuid);
|
|
448
|
+
}
|
|
449
|
+
else if (!claimedPeer) {
|
|
278
450
|
const orphanPeer = this.peers.getPeer(uuid);
|
|
279
451
|
if (orphanPeer && orphanPeer.streamId === uuid) {
|
|
280
452
|
this.peers.rekeyPeer(uuid, senderStreamId);
|
|
@@ -301,15 +473,26 @@ export class VDOBridge extends EventEmitter {
|
|
|
301
473
|
// Just update last-seen (already done in bus.handleIncoming)
|
|
302
474
|
break;
|
|
303
475
|
case "history_request": {
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
476
|
+
// A peer may see broadcasts and its own conversation with us, but
|
|
477
|
+
// never direct messages exchanged with somebody else. Filtering
|
|
478
|
+
// after taking the last N entries could let unrelated traffic crowd
|
|
479
|
+
// out every eligible message, so filter the bounded history ring
|
|
480
|
+
// first and slice second.
|
|
481
|
+
const requested = typeof envelope.payload === "object" && envelope.payload !== null
|
|
482
|
+
? envelope.payload.count
|
|
483
|
+
: undefined;
|
|
484
|
+
const count = Number.isInteger(requested) && requested > 0
|
|
485
|
+
? Math.min(requested, 200)
|
|
307
486
|
: 50;
|
|
308
|
-
const history = this.bus.getHistory(
|
|
487
|
+
const history = this.bus.getHistory()
|
|
488
|
+
.filter((message) => !message.to ||
|
|
489
|
+
message.to === senderStreamId ||
|
|
490
|
+
message.from.streamId === senderStreamId)
|
|
491
|
+
.slice(-count);
|
|
309
492
|
for (const msg of history) {
|
|
310
493
|
const replay = createEnvelope(this.identity, "history_replay", msg, { to: senderStreamId });
|
|
311
494
|
try {
|
|
312
|
-
this.sdk.sendData(envelopeToWire(replay), {
|
|
495
|
+
this.sdk.sendData(envelopeToWire(replay), { uuid });
|
|
313
496
|
}
|
|
314
497
|
catch { /* best effort */ }
|
|
315
498
|
}
|
|
@@ -320,7 +503,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
320
503
|
this.bus.handleIncoming(envelope);
|
|
321
504
|
});
|
|
322
505
|
// Peer disconnected
|
|
323
|
-
this.
|
|
506
|
+
this.addSDKEventListener("peerDisconnected", (event) => {
|
|
324
507
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
325
508
|
const streamId = event.detail?.streamID ?? this.peers.streamIdForUuid(uuid) ?? uuid;
|
|
326
509
|
this.viewedStreamIds.delete(streamId);
|
|
@@ -328,17 +511,48 @@ export class VDOBridge extends EventEmitter {
|
|
|
328
511
|
console.log(`[P2P] Peer disconnected: ${streamId}`);
|
|
329
512
|
this.emit("peer:disconnected", { streamId, uuid });
|
|
330
513
|
});
|
|
514
|
+
// Raw bytes from a peer's sendBinary(). No envelope, no routing beyond the
|
|
515
|
+
// sender, so whatever framing the payload needs lives inside the bytes.
|
|
516
|
+
this.addSDKEventListener("binaryReceived", (event) => {
|
|
517
|
+
const raw = event.detail?.bytes;
|
|
518
|
+
if (!(raw instanceof Uint8Array))
|
|
519
|
+
return;
|
|
520
|
+
const uuid = event.detail?.uuid ?? "unknown";
|
|
521
|
+
const streamId = this.peers.streamIdForUuid(uuid) ?? event.detail?.streamID ?? uuid;
|
|
522
|
+
this.emit("binary", { streamId, uuid, bytes: raw });
|
|
523
|
+
});
|
|
331
524
|
// SDK-level connection events
|
|
332
|
-
this.
|
|
333
|
-
|
|
334
|
-
|
|
525
|
+
this.addSDKEventListener("disconnected", (event) => {
|
|
526
|
+
// From SDK v1.4.1 the event says whether a reconnect is actually coming.
|
|
527
|
+
// Older builds say nothing, so fall back to our own shutdown flag rather
|
|
528
|
+
// than claiming a reconnect that will never happen.
|
|
529
|
+
const detail = event?.detail;
|
|
530
|
+
const willReconnect = typeof detail?.willReconnect === "boolean" ? detail.willReconnect : !this.disconnecting;
|
|
531
|
+
if (willReconnect) {
|
|
532
|
+
console.log("[P2P] WebSocket disconnected, SDK will attempt reconnect...");
|
|
533
|
+
}
|
|
534
|
+
this.emit("ws:disconnected", { willReconnect, phase: detail?.phase });
|
|
535
|
+
});
|
|
536
|
+
this.addSDKEventListener("reconnecting", () => {
|
|
537
|
+
this.restoring = true;
|
|
335
538
|
});
|
|
336
|
-
this.
|
|
539
|
+
this.addSDKEventListener("reconnected", () => {
|
|
337
540
|
console.log("[P2P] WebSocket reconnected.");
|
|
541
|
+
// The SDK replays its own view intent right after this. Rebuilding a
|
|
542
|
+
// connection on top of that replay races it — both sides manipulate the
|
|
543
|
+
// same pending-view state — so hold off briefly and let it finish.
|
|
544
|
+
this.restoring = false;
|
|
545
|
+
this.restoringUntil = Date.now() + RESTORE_GRACE_MS;
|
|
546
|
+
// Every peer connection was established under the old socket. The SDK
|
|
547
|
+
// replays its own view intent, but our bookkeeping said "already viewing"
|
|
548
|
+
// for all of them, so anything the replay missed could never be
|
|
549
|
+
// re-established by us — the set was only ever cleared on shutdown.
|
|
550
|
+
// Forget it, and let the fresh room listing decide what to view.
|
|
551
|
+
this.viewedStreamIds.clear();
|
|
338
552
|
this.emit("ws:reconnected");
|
|
339
553
|
});
|
|
340
554
|
// Room listing (existing peers when we join)
|
|
341
|
-
this.
|
|
555
|
+
this.addSDKEventListener("listing", (event) => {
|
|
342
556
|
const list = event.detail?.list ?? [];
|
|
343
557
|
for (const entry of list) {
|
|
344
558
|
if (entry.streamID && entry.streamID !== this.options.streamId) {
|
|
@@ -346,18 +560,26 @@ export class VDOBridge extends EventEmitter {
|
|
|
346
560
|
}
|
|
347
561
|
}
|
|
348
562
|
});
|
|
349
|
-
this.
|
|
563
|
+
this.addSDKEventListener("videoaddedtoroom", (event) => {
|
|
350
564
|
this.maybeViewPeer(event.detail?.streamID);
|
|
351
565
|
});
|
|
352
|
-
this.
|
|
566
|
+
this.addSDKEventListener("streamAdded", (event) => {
|
|
353
567
|
this.maybeViewPeer(event.detail?.streamID);
|
|
354
568
|
});
|
|
355
569
|
// Error handling
|
|
356
|
-
this.
|
|
570
|
+
this.addSDKEventListener("error", (event) => {
|
|
357
571
|
console.error("[P2P] SDK error:", event.detail?.error);
|
|
358
572
|
this.emitBridgeError(event.detail?.error);
|
|
359
573
|
});
|
|
360
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* The SDK types enumerate the stable public events but the bridge also
|
|
577
|
+
* listens to long-standing compatibility aliases. Keep the cast at this one
|
|
578
|
+
* boundary rather than weakening every handler.
|
|
579
|
+
*/
|
|
580
|
+
addSDKEventListener(name, handler) {
|
|
581
|
+
this.sdk?.addEventListener(name, handler);
|
|
582
|
+
}
|
|
361
583
|
// ── Heartbeat ────────────────────────────────────────────────────────────
|
|
362
584
|
startHeartbeat() {
|
|
363
585
|
const interval = this.options.heartbeatMs ?? 30_000;
|
|
@@ -391,7 +613,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
391
613
|
const peer = this.peers.getPeer(targetStreamId);
|
|
392
614
|
if (peer) {
|
|
393
615
|
try {
|
|
394
|
-
this.sdk.sendData(envelopeToWire(pong), {
|
|
616
|
+
this.sdk.sendData(envelopeToWire(pong), { uuid: peer.uuid });
|
|
395
617
|
}
|
|
396
618
|
catch { /* best effort */ }
|
|
397
619
|
}
|
|
@@ -406,6 +628,34 @@ export class VDOBridge extends EventEmitter {
|
|
|
406
628
|
agent: this.agentProfile,
|
|
407
629
|
});
|
|
408
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Tear down and rebuild the connection to one peer.
|
|
633
|
+
*
|
|
634
|
+
* A peer connection can report `open` on the sending side while nothing
|
|
635
|
+
* actually crosses it — measured after a signalling blip, where chunk
|
|
636
|
+
* requests arrived, the sender's `send()` returned success, and the bytes
|
|
637
|
+
* never landed. Nothing below us notices, because every layer believes it is
|
|
638
|
+
* fine. The only evidence is at the application layer: a peer that keeps
|
|
639
|
+
* failing to deliver. This is how that evidence gets acted on.
|
|
640
|
+
*/
|
|
641
|
+
revivePeer(streamId) {
|
|
642
|
+
if (!this.sdk || streamId === this.options.streamId)
|
|
643
|
+
return false;
|
|
644
|
+
// Never while the SDK is restoring: it is already rebuilding these very
|
|
645
|
+
// connections, and doing it underneath was measured as slower than doing
|
|
646
|
+
// nothing. With signalling healthy, a rebuild restores a path in ~260ms.
|
|
647
|
+
if (this.isRestoring())
|
|
648
|
+
return false;
|
|
649
|
+
try {
|
|
650
|
+
this.sdk.stopViewing(streamId);
|
|
651
|
+
}
|
|
652
|
+
catch { /* it may already be gone; rebuilding is still worth trying */ }
|
|
653
|
+
this.viewedStreamIds.delete(streamId);
|
|
654
|
+
this.peers.markDisconnected(streamId);
|
|
655
|
+
console.log(`[P2P] Rebuilding the connection to ${streamId}`);
|
|
656
|
+
this.maybeViewPeer(streamId);
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
409
659
|
maybeViewPeer(streamId) {
|
|
410
660
|
if (!this.sdk || !streamId || streamId === this.options.streamId || this.viewedStreamIds.has(streamId)) {
|
|
411
661
|
return;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow compatibility types for @vdoninja/sdk.
|
|
3
|
+
*
|
|
4
|
+
* SDK 1.4.1's package metadata points at `vdoninja-sdk.d.ts`, but the npm
|
|
5
|
+
* tarball published on 2026-07-25 does not contain that file. Keep this local
|
|
6
|
+
* interface until the declared SDK floor resolves to a registry package that
|
|
7
|
+
* actually ships its declarations. It is compiled into ninja-p2p's own public
|
|
8
|
+
* types, so consumers do not inherit the broken declaration reference.
|
|
9
|
+
*/
|
|
10
|
+
export type VDONinjaOptions = {
|
|
11
|
+
host?: string;
|
|
12
|
+
room?: string;
|
|
13
|
+
password?: string | false;
|
|
14
|
+
salt?: string;
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
turnServers?: unknown;
|
|
17
|
+
forceTURN?: boolean;
|
|
18
|
+
maxReconnectAttempts?: number;
|
|
19
|
+
};
|
|
20
|
+
export interface VDONinja {
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
disconnect(): void | Promise<void>;
|
|
23
|
+
joinRoom(options?: {
|
|
24
|
+
room?: string;
|
|
25
|
+
password?: string | false;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
leaveRoom(): void;
|
|
28
|
+
announce(options?: {
|
|
29
|
+
streamID?: string;
|
|
30
|
+
label?: string;
|
|
31
|
+
meta?: string;
|
|
32
|
+
}): Promise<void>;
|
|
33
|
+
view(streamID: string, options?: {
|
|
34
|
+
audio?: boolean;
|
|
35
|
+
video?: boolean;
|
|
36
|
+
label?: string;
|
|
37
|
+
}): Promise<unknown>;
|
|
38
|
+
stopViewing(streamID: string): void;
|
|
39
|
+
sendData(data: unknown, target?: unknown): boolean;
|
|
40
|
+
sendPing(uuid: string): void;
|
|
41
|
+
sendBinary?(data: ArrayBuffer | ArrayBufferView, uuid: string, options?: {
|
|
42
|
+
ordered?: boolean;
|
|
43
|
+
maxRetransmits?: number;
|
|
44
|
+
maxPacketLifeTime?: number;
|
|
45
|
+
waitForDrain?: boolean;
|
|
46
|
+
}): Promise<boolean>;
|
|
47
|
+
openChannel?(uuid: string, label: string, options?: {
|
|
48
|
+
ordered?: boolean;
|
|
49
|
+
maxRetransmits?: number;
|
|
50
|
+
maxPacketLifeTime?: number;
|
|
51
|
+
timeout?: number;
|
|
52
|
+
}): Promise<unknown>;
|
|
53
|
+
getChannel?(uuid: string, label: string): unknown;
|
|
54
|
+
getBufferedAmount?(uuid: string, label?: string): number | null;
|
|
55
|
+
getMaxMessageSize?(uuid: string): number | null;
|
|
56
|
+
getPeerQuality?(uuid: string): Promise<{
|
|
57
|
+
rttMs: number | null;
|
|
58
|
+
lossRate: number | null;
|
|
59
|
+
candidatePairType: string | null;
|
|
60
|
+
relayed: boolean | null;
|
|
61
|
+
availableOutgoingBitrate: number | null;
|
|
62
|
+
bytesSent: number;
|
|
63
|
+
bytesReceived: number;
|
|
64
|
+
} | null>;
|
|
65
|
+
publish(stream: unknown, options?: Record<string, unknown>): Promise<void>;
|
|
66
|
+
stopPublishing(): void;
|
|
67
|
+
getStats(uuid?: string): Promise<unknown>;
|
|
68
|
+
addEventListener(event: string, handler: EventListenerOrEventListenerObject): void;
|
|
69
|
+
removeEventListener(event: string, handler: EventListenerOrEventListenerObject): void;
|
|
70
|
+
on(event: string, handler: EventListenerOrEventListenerObject): void;
|
|
71
|
+
off(event: string, handler: EventListenerOrEventListenerObject): void;
|
|
72
|
+
once(event: string, handler: EventListenerOrEventListenerObject): void;
|
|
73
|
+
debug: boolean;
|
|
74
|
+
}
|
|
75
|
+
export type VDONinjaConstructor = new (options?: VDONinjaOptions) => VDONinja;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow compatibility types for @vdoninja/sdk.
|
|
3
|
+
*
|
|
4
|
+
* SDK 1.4.1's package metadata points at `vdoninja-sdk.d.ts`, but the npm
|
|
5
|
+
* tarball published on 2026-07-25 does not contain that file. Keep this local
|
|
6
|
+
* interface until the declared SDK floor resolves to a registry package that
|
|
7
|
+
* actually ships its declarations. It is compiled into ninja-p2p's own public
|
|
8
|
+
* types, so consumers do not inherit the broken declaration reference.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|