@vdoninja/ninja-p2p 0.1.2 → 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 -825
- package/dashboard.html +370 -50
- package/dist/agent-state.js +17 -1
- 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 +17 -1
- package/dist/message-bus.js +55 -16
- 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 +280 -30
- 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/images/agent-room-dashboard.png +0 -0
- package/docs/protocol-and-reliability.md +231 -0
- 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 -113
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;
|
|
@@ -62,12 +90,13 @@ export class VDOBridge extends EventEmitter {
|
|
|
62
90
|
// Set the send function on the bus
|
|
63
91
|
this.bus.setSendDataFn((data, target) => {
|
|
64
92
|
if (!this.sdk)
|
|
65
|
-
return;
|
|
93
|
+
return false;
|
|
66
94
|
try {
|
|
67
|
-
this.sdk.sendData(data, target ?? undefined);
|
|
95
|
+
return this.sdk.sendData(data, target ?? undefined) !== false;
|
|
68
96
|
}
|
|
69
97
|
catch (err) {
|
|
70
98
|
this.emitBridgeError(err);
|
|
99
|
+
return false;
|
|
71
100
|
}
|
|
72
101
|
});
|
|
73
102
|
await this.sdk.connect();
|
|
@@ -87,6 +116,8 @@ export class VDOBridge extends EventEmitter {
|
|
|
87
116
|
async disconnect() {
|
|
88
117
|
if (!this.connected || !this.sdk)
|
|
89
118
|
return;
|
|
119
|
+
const sdk = this.sdk;
|
|
120
|
+
this.disconnecting = true;
|
|
90
121
|
this.stopHeartbeat();
|
|
91
122
|
// Send a leaving event to all peers
|
|
92
123
|
try {
|
|
@@ -94,15 +125,40 @@ export class VDOBridge extends EventEmitter {
|
|
|
94
125
|
this.sdk.sendData(envelopeToWire(envelope));
|
|
95
126
|
}
|
|
96
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
|
+
}
|
|
97
138
|
try {
|
|
98
139
|
this.sdk.leaveRoom();
|
|
99
140
|
}
|
|
100
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.
|
|
101
146
|
try {
|
|
102
|
-
|
|
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);
|
|
103
159
|
}
|
|
104
|
-
catch { /* best effort */ }
|
|
105
160
|
this.connected = false;
|
|
161
|
+
this.disconnecting = false;
|
|
106
162
|
this.sdk = null;
|
|
107
163
|
this.viewedStreamIds.clear();
|
|
108
164
|
this.peers.clear();
|
|
@@ -112,6 +168,28 @@ export class VDOBridge extends EventEmitter {
|
|
|
112
168
|
isConnected() {
|
|
113
169
|
return this.connected;
|
|
114
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
|
+
}
|
|
115
193
|
emitBridgeError(err) {
|
|
116
194
|
if (this.listenerCount("error") > 0) {
|
|
117
195
|
this.emit("error", err);
|
|
@@ -181,23 +259,103 @@ export class VDOBridge extends EventEmitter {
|
|
|
181
259
|
return false;
|
|
182
260
|
try {
|
|
183
261
|
if (!targetStreamId) {
|
|
184
|
-
this.sdk.sendData(data, { allowFallback: true });
|
|
185
|
-
return true;
|
|
262
|
+
return this.sdk.sendData(data, { allowFallback: true }) !== false;
|
|
186
263
|
}
|
|
187
264
|
const peer = this.peers.getPeer(targetStreamId);
|
|
188
265
|
if (peer?.uuid) {
|
|
189
|
-
this.sdk.sendData(data, {
|
|
266
|
+
return this.sdk.sendData(data, { uuid: peer.uuid, allowFallback: true }) !== false;
|
|
190
267
|
}
|
|
191
268
|
else {
|
|
192
|
-
this.sdk.sendData(data, { streamID: targetStreamId, allowFallback: true });
|
|
269
|
+
return this.sdk.sendData(data, { streamID: targetStreamId, allowFallback: true }) !== false;
|
|
193
270
|
}
|
|
194
|
-
return true;
|
|
195
271
|
}
|
|
196
272
|
catch (err) {
|
|
197
273
|
this.emitBridgeError(err);
|
|
198
274
|
return false;
|
|
199
275
|
}
|
|
200
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
|
+
}
|
|
201
359
|
/** Reply to a received message using its sender as the target. */
|
|
202
360
|
reply(message, type, payload) {
|
|
203
361
|
return this.bus.send(message.from.streamId, type, payload);
|
|
@@ -238,14 +396,14 @@ export class VDOBridge extends EventEmitter {
|
|
|
238
396
|
if (!this.sdk)
|
|
239
397
|
return;
|
|
240
398
|
// Peer connected (WebRTC connection established)
|
|
241
|
-
this.
|
|
399
|
+
this.addSDKEventListener("peerConnected", (event) => {
|
|
242
400
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
243
401
|
const streamId = event.detail?.streamID ?? uuid;
|
|
244
402
|
this.peers.addPeer(streamId, uuid);
|
|
245
403
|
this.emit("peer:connected", { streamId, uuid });
|
|
246
404
|
});
|
|
247
405
|
// Data channel opened — send our announce
|
|
248
|
-
this.
|
|
406
|
+
this.addSDKEventListener("dataChannelOpen", (event) => {
|
|
249
407
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
250
408
|
const mappedStreamId = this.peers.streamIdForUuid(uuid);
|
|
251
409
|
const eventStreamId = event.detail?.streamID;
|
|
@@ -253,7 +411,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
253
411
|
// Send announce to this specific peer
|
|
254
412
|
const announce = createEnvelope(this.identity, "announce", this.getAnnouncePayload(), { to: streamId });
|
|
255
413
|
try {
|
|
256
|
-
this.sdk.sendData(envelopeToWire(announce), {
|
|
414
|
+
this.sdk.sendData(envelopeToWire(announce), { uuid });
|
|
257
415
|
}
|
|
258
416
|
catch { /* peer may have disconnected */ }
|
|
259
417
|
// Flush any queued offline messages for this peer
|
|
@@ -264,7 +422,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
264
422
|
this.emit("datachannel:open", { streamId, uuid });
|
|
265
423
|
});
|
|
266
424
|
// Data received — parse and route through MessageBus
|
|
267
|
-
this.
|
|
425
|
+
this.addSDKEventListener("dataReceived", (event) => {
|
|
268
426
|
const raw = event.detail?.data;
|
|
269
427
|
const uuid = event.detail?.uuid ?? "unknown";
|
|
270
428
|
const envelope = parseEnvelope(raw);
|
|
@@ -275,7 +433,20 @@ export class VDOBridge extends EventEmitter {
|
|
|
275
433
|
}
|
|
276
434
|
// If we don't have a streamId mapping for this uuid, use the envelope's from
|
|
277
435
|
const senderStreamId = envelope.from.streamId;
|
|
278
|
-
|
|
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) {
|
|
279
450
|
const orphanPeer = this.peers.getPeer(uuid);
|
|
280
451
|
if (orphanPeer && orphanPeer.streamId === uuid) {
|
|
281
452
|
this.peers.rekeyPeer(uuid, senderStreamId);
|
|
@@ -288,6 +459,7 @@ export class VDOBridge extends EventEmitter {
|
|
|
288
459
|
switch (envelope.type) {
|
|
289
460
|
case "announce":
|
|
290
461
|
this.peers.updateFromAnnounce(senderStreamId, envelope.from, envelope.payload);
|
|
462
|
+
this.bus.flushOfflineQueue(senderStreamId);
|
|
291
463
|
this.emit("peer:announce", { streamId: senderStreamId, identity: envelope.from, announce: envelope.payload });
|
|
292
464
|
break;
|
|
293
465
|
case "skill_update":
|
|
@@ -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 });
|
|
335
535
|
});
|
|
336
|
-
this.
|
|
536
|
+
this.addSDKEventListener("reconnecting", () => {
|
|
537
|
+
this.restoring = true;
|
|
538
|
+
});
|
|
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 {};
|