@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.
Files changed (47) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -825
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +17 -1
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +17 -1
  21. package/dist/message-bus.js +55 -16
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +280 -30
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/images/agent-room-dashboard.png +0 -0
  43. package/docs/protocol-and-reliability.md +231 -0
  44. package/docs/sdk-wishlist.md +236 -0
  45. package/docs/security.md +197 -0
  46. package/docs/social-stream-bridge.md +390 -0
  47. package/package.json +125 -113
@@ -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(): InstanceType<typeof import("@vdoninja/sdk")> | null;
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
  }
@@ -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 = "0.1.2";
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
- this.sdk.disconnect();
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, { UUID: peer.uuid, allowFallback: true });
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.sdk.addEventListener("peerConnected", (event) => {
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.sdk.addEventListener("dataChannelOpen", (event) => {
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), { UUID: uuid });
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.sdk.addEventListener("dataReceived", (event) => {
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
- if (!this.peers.getPeer(senderStreamId)) {
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
- // Send recent history to the requesting peer
305
- const count = typeof envelope.payload === "object" && envelope.payload !== null
306
- ? envelope.payload.count ?? 50
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(count);
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), { UUID: uuid });
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.sdk.addEventListener("peerDisconnected", (event) => {
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.sdk.addEventListener("disconnected", () => {
333
- console.log("[P2P] WebSocket disconnected, SDK will attempt reconnect...");
334
- this.emit("ws:disconnected");
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.sdk.addEventListener("reconnected", () => {
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.sdk.addEventListener("listing", (event) => {
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.sdk.addEventListener("videoaddedtoroom", (event) => {
563
+ this.addSDKEventListener("videoaddedtoroom", (event) => {
350
564
  this.maybeViewPeer(event.detail?.streamID);
351
565
  });
352
- this.sdk.addEventListener("streamAdded", (event) => {
566
+ this.addSDKEventListener("streamAdded", (event) => {
353
567
  this.maybeViewPeer(event.detail?.streamID);
354
568
  });
355
569
  // Error handling
356
- this.sdk.addEventListener("error", (event) => {
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), { UUID: peer.uuid });
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 {};