@vdoninja/ninja-p2p 0.1.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/README.md +138 -0
- package/dashboard.html +602 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/message-bus.d.ts +79 -0
- package/dist/message-bus.js +234 -0
- package/dist/peer-registry.d.ts +69 -0
- package/dist/peer-registry.js +200 -0
- package/dist/protocol.d.ts +57 -0
- package/dist/protocol.js +67 -0
- package/dist/vdo-bridge.d.ts +66 -0
- package/dist/vdo-bridge.js +318 -0
- package/package.json +78 -0
- package/skills/ninja-p2p/SKILL.md +74 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P Message Protocol
|
|
3
|
+
*
|
|
4
|
+
* Defines the wire format for all messages exchanged over VDO.Ninja
|
|
5
|
+
* WebRTC data channels. This module has zero dependencies on stevesbot
|
|
6
|
+
* internals — it can be extracted and reused by any bot.
|
|
7
|
+
*/
|
|
8
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
/** Generate a short unique message ID. */
|
|
11
|
+
export function createMessageId() {
|
|
12
|
+
return randomBytes(12).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
/** Generate a unique instance ID (per-process, distinguishes restarts). */
|
|
15
|
+
export function createInstanceId() {
|
|
16
|
+
return randomUUID().slice(0, 8);
|
|
17
|
+
}
|
|
18
|
+
/** Generate a cryptographically random room name. */
|
|
19
|
+
export function generateRoomName() {
|
|
20
|
+
return "clawd_" + randomBytes(16).toString("hex");
|
|
21
|
+
}
|
|
22
|
+
/** Create a full message envelope. */
|
|
23
|
+
export function createEnvelope(from, type, payload, options) {
|
|
24
|
+
return {
|
|
25
|
+
v: 1,
|
|
26
|
+
id: createMessageId(),
|
|
27
|
+
type,
|
|
28
|
+
from,
|
|
29
|
+
to: options?.to ?? null,
|
|
30
|
+
topic: options?.topic ?? null,
|
|
31
|
+
ts: Date.now(),
|
|
32
|
+
payload,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Validate that a received object looks like a MessageEnvelope. */
|
|
36
|
+
export function isValidEnvelope(data) {
|
|
37
|
+
if (typeof data !== "object" || data === null)
|
|
38
|
+
return false;
|
|
39
|
+
const d = data;
|
|
40
|
+
return (d.v === 1 &&
|
|
41
|
+
typeof d.id === "string" &&
|
|
42
|
+
typeof d.type === "string" &&
|
|
43
|
+
typeof d.from === "object" &&
|
|
44
|
+
d.from !== null &&
|
|
45
|
+
typeof d.from.streamId === "string" &&
|
|
46
|
+
typeof d.ts === "number");
|
|
47
|
+
}
|
|
48
|
+
/** Parse raw data received from a data channel into an envelope. Returns null on failure. */
|
|
49
|
+
export function parseEnvelope(raw) {
|
|
50
|
+
try {
|
|
51
|
+
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
52
|
+
if (isValidEnvelope(data))
|
|
53
|
+
return data;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Wrap a payload into a sendable JSON object.
|
|
62
|
+
* The VDO.Ninja SDK sends objects via sendData — this ensures our envelope
|
|
63
|
+
* is the top-level object sent over the wire.
|
|
64
|
+
*/
|
|
65
|
+
export function envelopeToWire(envelope) {
|
|
66
|
+
return { ...envelope };
|
|
67
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDO Bridge
|
|
3
|
+
*
|
|
4
|
+
* Wraps the @vdoninja/sdk to provide a high-level P2P communication layer.
|
|
5
|
+
* Manages the SDK lifecycle, peers, message routing, auto-announce,
|
|
6
|
+
* heartbeat pings, offline queue flushing, and graceful shutdown.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies on stevesbot internals.
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { MessageBus, type MessageBusOptions } from "./message-bus.js";
|
|
12
|
+
import { PeerRegistry } from "./peer-registry.js";
|
|
13
|
+
import { type AnnouncePayload, type MessageEnvelope, type PeerIdentity } from "./protocol.js";
|
|
14
|
+
export type VDOBridgeOptions = {
|
|
15
|
+
room: string;
|
|
16
|
+
streamId: string;
|
|
17
|
+
identity: Omit<PeerIdentity, "instanceId">;
|
|
18
|
+
password?: string | false;
|
|
19
|
+
host?: string;
|
|
20
|
+
forceTurn?: boolean;
|
|
21
|
+
debug?: boolean;
|
|
22
|
+
/** Skills to announce. */
|
|
23
|
+
skills?: string[];
|
|
24
|
+
/** Topics to subscribe to. */
|
|
25
|
+
topics?: string[];
|
|
26
|
+
/** Heartbeat interval in ms. Default: 30000. */
|
|
27
|
+
heartbeatMs?: number;
|
|
28
|
+
/** MessageBus options. */
|
|
29
|
+
busOptions?: MessageBusOptions;
|
|
30
|
+
};
|
|
31
|
+
export declare class VDOBridge extends EventEmitter {
|
|
32
|
+
readonly peers: PeerRegistry;
|
|
33
|
+
readonly bus: MessageBus;
|
|
34
|
+
readonly identity: PeerIdentity;
|
|
35
|
+
private sdk;
|
|
36
|
+
private readonly options;
|
|
37
|
+
private heartbeatTimer;
|
|
38
|
+
private connected;
|
|
39
|
+
private skills;
|
|
40
|
+
private status;
|
|
41
|
+
private statusDetail;
|
|
42
|
+
private version;
|
|
43
|
+
constructor(options: VDOBridgeOptions);
|
|
44
|
+
connect(): Promise<void>;
|
|
45
|
+
disconnect(): Promise<void>;
|
|
46
|
+
isConnected(): boolean;
|
|
47
|
+
/** Update skills and broadcast the change. */
|
|
48
|
+
updateSkills(skills: string[]): void;
|
|
49
|
+
/** Update status and broadcast the change. */
|
|
50
|
+
updateStatus(status: string, detail?: string): void;
|
|
51
|
+
/** Get current announce payload. */
|
|
52
|
+
getAnnouncePayload(): AnnouncePayload;
|
|
53
|
+
/** Send a chat message to everyone or a specific peer. */
|
|
54
|
+
chat(text: string, to?: string): MessageEnvelope;
|
|
55
|
+
/** Send a chat message to a topic. */
|
|
56
|
+
chatTopic(topic: string, text: string): MessageEnvelope;
|
|
57
|
+
/** Send a command to a specific peer. */
|
|
58
|
+
command(targetStreamId: string, command: string, args?: unknown): MessageEnvelope;
|
|
59
|
+
/** Publish an event to a topic. */
|
|
60
|
+
publishEvent(topic: string, kind: string, data?: unknown): MessageEnvelope;
|
|
61
|
+
private wireSDKEvents;
|
|
62
|
+
private startHeartbeat;
|
|
63
|
+
private stopHeartbeat;
|
|
64
|
+
private respondPong;
|
|
65
|
+
private broadcastSkillUpdate;
|
|
66
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VDO Bridge
|
|
3
|
+
*
|
|
4
|
+
* Wraps the @vdoninja/sdk to provide a high-level P2P communication layer.
|
|
5
|
+
* Manages the SDK lifecycle, peers, message routing, auto-announce,
|
|
6
|
+
* heartbeat pings, offline queue flushing, and graceful shutdown.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies on stevesbot internals.
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { MessageBus } from "./message-bus.js";
|
|
12
|
+
import { PeerRegistry } from "./peer-registry.js";
|
|
13
|
+
import { createEnvelope, createInstanceId, envelopeToWire, parseEnvelope, } from "./protocol.js";
|
|
14
|
+
// ── Class ────────────────────────────────────────────────────────────────────
|
|
15
|
+
export class VDOBridge extends EventEmitter {
|
|
16
|
+
peers;
|
|
17
|
+
bus;
|
|
18
|
+
identity;
|
|
19
|
+
sdk = null;
|
|
20
|
+
options;
|
|
21
|
+
heartbeatTimer = null;
|
|
22
|
+
connected = false;
|
|
23
|
+
skills;
|
|
24
|
+
status = "idle";
|
|
25
|
+
statusDetail = "";
|
|
26
|
+
version = "0.1.0";
|
|
27
|
+
constructor(options) {
|
|
28
|
+
super();
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.identity = {
|
|
31
|
+
streamId: options.streamId,
|
|
32
|
+
role: options.identity.role,
|
|
33
|
+
name: options.identity.name,
|
|
34
|
+
instanceId: createInstanceId(),
|
|
35
|
+
};
|
|
36
|
+
this.skills = options.skills ?? [];
|
|
37
|
+
this.peers = new PeerRegistry();
|
|
38
|
+
this.bus = new MessageBus(this.identity, this.peers, options.busOptions);
|
|
39
|
+
// Subscribe to requested topics
|
|
40
|
+
for (const topic of options.topics ?? []) {
|
|
41
|
+
this.bus.subscribe(topic);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
45
|
+
async connect() {
|
|
46
|
+
if (this.connected)
|
|
47
|
+
return;
|
|
48
|
+
// Dynamic import for CJS SDK in ESM context
|
|
49
|
+
const { createRequire } = await import("node:module");
|
|
50
|
+
const require = createRequire(import.meta.url);
|
|
51
|
+
const VDONinjaSDK = require("@vdoninja/sdk");
|
|
52
|
+
this.sdk = new VDONinjaSDK({
|
|
53
|
+
host: this.options.host ?? "wss://wss.vdo.ninja",
|
|
54
|
+
debug: this.options.debug ?? false,
|
|
55
|
+
forceTURN: this.options.forceTurn ?? false,
|
|
56
|
+
});
|
|
57
|
+
this.wireSDKEvents();
|
|
58
|
+
// Set the send function on the bus
|
|
59
|
+
this.bus.setSendDataFn((data, target) => {
|
|
60
|
+
if (!this.sdk)
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
this.sdk.sendData(data, target ?? undefined);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
this.emit("error", err);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
await this.sdk.connect();
|
|
70
|
+
// Build joinRoom options
|
|
71
|
+
const joinOpts = { room: this.options.room };
|
|
72
|
+
if (this.options.password !== undefined) {
|
|
73
|
+
joinOpts.password = this.options.password;
|
|
74
|
+
}
|
|
75
|
+
await this.sdk.joinRoom(joinOpts);
|
|
76
|
+
// Announce ourselves as a data-only publisher
|
|
77
|
+
await this.sdk.announce({ streamID: this.options.streamId });
|
|
78
|
+
this.connected = true;
|
|
79
|
+
this.startHeartbeat();
|
|
80
|
+
console.log(`[P2P] Connected to room "${this.options.room}" as "${this.options.streamId}"`);
|
|
81
|
+
this.emit("connected");
|
|
82
|
+
}
|
|
83
|
+
async disconnect() {
|
|
84
|
+
if (!this.connected || !this.sdk)
|
|
85
|
+
return;
|
|
86
|
+
this.stopHeartbeat();
|
|
87
|
+
// Send a leaving event to all peers
|
|
88
|
+
try {
|
|
89
|
+
const envelope = createEnvelope(this.identity, "event", { kind: "leaving" });
|
|
90
|
+
this.sdk.sendData(envelopeToWire(envelope));
|
|
91
|
+
}
|
|
92
|
+
catch { /* best effort */ }
|
|
93
|
+
try {
|
|
94
|
+
this.sdk.leaveRoom();
|
|
95
|
+
}
|
|
96
|
+
catch { /* best effort */ }
|
|
97
|
+
try {
|
|
98
|
+
this.sdk.disconnect();
|
|
99
|
+
}
|
|
100
|
+
catch { /* best effort */ }
|
|
101
|
+
this.connected = false;
|
|
102
|
+
this.sdk = null;
|
|
103
|
+
this.peers.clear();
|
|
104
|
+
console.log("[P2P] Disconnected.");
|
|
105
|
+
this.emit("disconnected");
|
|
106
|
+
}
|
|
107
|
+
isConnected() {
|
|
108
|
+
return this.connected;
|
|
109
|
+
}
|
|
110
|
+
// ── Identity Management ──────────────────────────────────────────────────
|
|
111
|
+
/** Update skills and broadcast the change. */
|
|
112
|
+
updateSkills(skills) {
|
|
113
|
+
this.skills = skills;
|
|
114
|
+
this.broadcastSkillUpdate();
|
|
115
|
+
}
|
|
116
|
+
/** Update status and broadcast the change. */
|
|
117
|
+
updateStatus(status, detail) {
|
|
118
|
+
this.status = status;
|
|
119
|
+
this.statusDetail = detail ?? "";
|
|
120
|
+
this.broadcastSkillUpdate();
|
|
121
|
+
}
|
|
122
|
+
/** Get current announce payload. */
|
|
123
|
+
getAnnouncePayload() {
|
|
124
|
+
return {
|
|
125
|
+
skills: this.skills,
|
|
126
|
+
status: this.status,
|
|
127
|
+
statusDetail: this.statusDetail,
|
|
128
|
+
version: this.version,
|
|
129
|
+
topics: this.bus.getSubscriptions(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ── Convenience Methods ──────────────────────────────────────────────────
|
|
133
|
+
/** Send a chat message to everyone or a specific peer. */
|
|
134
|
+
chat(text, to) {
|
|
135
|
+
if (to) {
|
|
136
|
+
return this.bus.send(to, "chat", { text });
|
|
137
|
+
}
|
|
138
|
+
return this.bus.broadcast("chat", { text });
|
|
139
|
+
}
|
|
140
|
+
/** Send a chat message to a topic. */
|
|
141
|
+
chatTopic(topic, text) {
|
|
142
|
+
return this.bus.publish(topic, "chat", { text });
|
|
143
|
+
}
|
|
144
|
+
/** Send a command to a specific peer. */
|
|
145
|
+
command(targetStreamId, command, args) {
|
|
146
|
+
return this.bus.send(targetStreamId, "command", { command, args });
|
|
147
|
+
}
|
|
148
|
+
/** Publish an event to a topic. */
|
|
149
|
+
publishEvent(topic, kind, data) {
|
|
150
|
+
return this.bus.publish(topic, "event", { kind, ...((data && typeof data === "object") ? data : { data }) });
|
|
151
|
+
}
|
|
152
|
+
// ── SDK Event Wiring ─────────────────────────────────────────────────────
|
|
153
|
+
wireSDKEvents() {
|
|
154
|
+
if (!this.sdk)
|
|
155
|
+
return;
|
|
156
|
+
// Peer connected (WebRTC connection established)
|
|
157
|
+
this.sdk.addEventListener("peerConnected", (event) => {
|
|
158
|
+
const uuid = event.detail?.uuid ?? "unknown";
|
|
159
|
+
const streamId = event.detail?.streamID ?? uuid;
|
|
160
|
+
this.peers.addPeer(streamId, uuid);
|
|
161
|
+
this.emit("peer:connected", { streamId, uuid });
|
|
162
|
+
});
|
|
163
|
+
// Data channel opened — send our announce
|
|
164
|
+
this.sdk.addEventListener("dataChannelOpen", (event) => {
|
|
165
|
+
const uuid = event.detail?.uuid ?? "unknown";
|
|
166
|
+
const mappedStreamId = this.peers.streamIdForUuid(uuid);
|
|
167
|
+
const eventStreamId = event.detail?.streamID;
|
|
168
|
+
const streamId = mappedStreamId ?? (eventStreamId && eventStreamId !== this.options.streamId ? eventStreamId : uuid);
|
|
169
|
+
// Send announce to this specific peer
|
|
170
|
+
const announce = createEnvelope(this.identity, "announce", this.getAnnouncePayload(), { to: streamId });
|
|
171
|
+
try {
|
|
172
|
+
this.sdk.sendData(envelopeToWire(announce), { UUID: uuid });
|
|
173
|
+
}
|
|
174
|
+
catch { /* peer may have disconnected */ }
|
|
175
|
+
// Flush any queued offline messages for this peer
|
|
176
|
+
const flushed = this.bus.flushOfflineQueue(streamId);
|
|
177
|
+
if (flushed.length > 0) {
|
|
178
|
+
console.log(`[P2P] Flushed ${flushed.length} queued messages to ${streamId}`);
|
|
179
|
+
}
|
|
180
|
+
this.emit("datachannel:open", { streamId, uuid });
|
|
181
|
+
});
|
|
182
|
+
// Data received — parse and route through MessageBus
|
|
183
|
+
this.sdk.addEventListener("dataReceived", (event) => {
|
|
184
|
+
const raw = event.detail?.data;
|
|
185
|
+
const uuid = event.detail?.uuid ?? "unknown";
|
|
186
|
+
const envelope = parseEnvelope(raw);
|
|
187
|
+
if (!envelope) {
|
|
188
|
+
// Not our protocol — emit as raw data for consumers who want it
|
|
189
|
+
this.emit("rawData", { data: raw, uuid });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// If we don't have a streamId mapping for this uuid, use the envelope's from
|
|
193
|
+
const senderStreamId = envelope.from.streamId;
|
|
194
|
+
if (!this.peers.getPeer(senderStreamId)) {
|
|
195
|
+
const orphanPeer = this.peers.getPeer(uuid);
|
|
196
|
+
if (orphanPeer && orphanPeer.streamId === uuid) {
|
|
197
|
+
this.peers.rekeyPeer(uuid, senderStreamId);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
this.peers.addPeer(senderStreamId, uuid);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Handle protocol-level messages
|
|
204
|
+
switch (envelope.type) {
|
|
205
|
+
case "announce":
|
|
206
|
+
this.peers.updateFromAnnounce(senderStreamId, envelope.from, envelope.payload);
|
|
207
|
+
this.emit("peer:announce", { streamId: senderStreamId, identity: envelope.from, announce: envelope.payload });
|
|
208
|
+
break;
|
|
209
|
+
case "skill_update":
|
|
210
|
+
this.peers.updateFromSkillUpdate(senderStreamId, envelope.payload);
|
|
211
|
+
break;
|
|
212
|
+
case "ping":
|
|
213
|
+
// Respond with pong
|
|
214
|
+
this.respondPong(senderStreamId, envelope);
|
|
215
|
+
break;
|
|
216
|
+
case "pong":
|
|
217
|
+
// Just update last-seen (already done in bus.handleIncoming)
|
|
218
|
+
break;
|
|
219
|
+
case "history_request": {
|
|
220
|
+
// Send recent history to the requesting peer
|
|
221
|
+
const count = typeof envelope.payload === "object" && envelope.payload !== null
|
|
222
|
+
? envelope.payload.count ?? 50
|
|
223
|
+
: 50;
|
|
224
|
+
const history = this.bus.getHistory(count);
|
|
225
|
+
for (const msg of history) {
|
|
226
|
+
const replay = createEnvelope(this.identity, "history_replay", msg, { to: senderStreamId });
|
|
227
|
+
try {
|
|
228
|
+
this.sdk.sendData(envelopeToWire(replay), { UUID: uuid });
|
|
229
|
+
}
|
|
230
|
+
catch { /* best effort */ }
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Route through MessageBus for application-level handling
|
|
236
|
+
this.bus.handleIncoming(envelope);
|
|
237
|
+
});
|
|
238
|
+
// Peer disconnected
|
|
239
|
+
this.sdk.addEventListener("peerDisconnected", (event) => {
|
|
240
|
+
const uuid = event.detail?.uuid ?? "unknown";
|
|
241
|
+
const streamId = event.detail?.streamID ?? this.peers.streamIdForUuid(uuid) ?? uuid;
|
|
242
|
+
this.peers.markDisconnected(streamId);
|
|
243
|
+
console.log(`[P2P] Peer disconnected: ${streamId}`);
|
|
244
|
+
this.emit("peer:disconnected", { streamId, uuid });
|
|
245
|
+
});
|
|
246
|
+
// SDK-level connection events
|
|
247
|
+
this.sdk.addEventListener("disconnected", () => {
|
|
248
|
+
console.log("[P2P] WebSocket disconnected, SDK will attempt reconnect...");
|
|
249
|
+
this.emit("ws:disconnected");
|
|
250
|
+
});
|
|
251
|
+
this.sdk.addEventListener("reconnected", () => {
|
|
252
|
+
console.log("[P2P] WebSocket reconnected.");
|
|
253
|
+
this.emit("ws:reconnected");
|
|
254
|
+
});
|
|
255
|
+
// Room listing (existing peers when we join)
|
|
256
|
+
this.sdk.addEventListener("listing", (event) => {
|
|
257
|
+
const list = event.detail?.list ?? [];
|
|
258
|
+
for (const entry of list) {
|
|
259
|
+
if (entry.streamID && entry.streamID !== this.options.streamId) {
|
|
260
|
+
// View each existing peer to establish data channels
|
|
261
|
+
this.sdk.view(entry.streamID, { audio: false, video: false });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
// Error handling
|
|
266
|
+
this.sdk.addEventListener("error", (event) => {
|
|
267
|
+
console.error("[P2P] SDK error:", event.detail?.error);
|
|
268
|
+
this.emit("error", event.detail?.error);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
// ── Heartbeat ────────────────────────────────────────────────────────────
|
|
272
|
+
startHeartbeat() {
|
|
273
|
+
const interval = this.options.heartbeatMs ?? 30_000;
|
|
274
|
+
this.heartbeatTimer = setInterval(() => {
|
|
275
|
+
if (!this.connected || !this.sdk)
|
|
276
|
+
return;
|
|
277
|
+
const ping = createEnvelope(this.identity, "ping", { ts: Date.now() });
|
|
278
|
+
try {
|
|
279
|
+
this.sdk.sendData(envelopeToWire(ping));
|
|
280
|
+
}
|
|
281
|
+
catch { /* connection may be lost */ }
|
|
282
|
+
}, interval);
|
|
283
|
+
// Don't block process exit
|
|
284
|
+
if (this.heartbeatTimer.unref)
|
|
285
|
+
this.heartbeatTimer.unref();
|
|
286
|
+
}
|
|
287
|
+
stopHeartbeat() {
|
|
288
|
+
if (this.heartbeatTimer) {
|
|
289
|
+
clearInterval(this.heartbeatTimer);
|
|
290
|
+
this.heartbeatTimer = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
respondPong(targetStreamId, pingEnvelope) {
|
|
294
|
+
if (!this.sdk)
|
|
295
|
+
return;
|
|
296
|
+
const pong = createEnvelope(this.identity, "pong", {
|
|
297
|
+
pingId: pingEnvelope.id,
|
|
298
|
+
pingTs: pingEnvelope.payload?.ts,
|
|
299
|
+
pongTs: Date.now(),
|
|
300
|
+
}, { to: targetStreamId });
|
|
301
|
+
const peer = this.peers.getPeer(targetStreamId);
|
|
302
|
+
if (peer) {
|
|
303
|
+
try {
|
|
304
|
+
this.sdk.sendData(envelopeToWire(pong), { UUID: peer.uuid });
|
|
305
|
+
}
|
|
306
|
+
catch { /* best effort */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
broadcastSkillUpdate() {
|
|
310
|
+
if (!this.connected)
|
|
311
|
+
return;
|
|
312
|
+
this.bus.broadcast("skill_update", {
|
|
313
|
+
skills: this.skills,
|
|
314
|
+
status: this.status,
|
|
315
|
+
statusDetail: this.statusDetail,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vdoninja/ninja-p2p",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable P2P agent communication module using VDO.Ninja WebRTC data channels",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./vdo-bridge": {
|
|
14
|
+
"types": "./dist/vdo-bridge.d.ts",
|
|
15
|
+
"import": "./dist/vdo-bridge.js"
|
|
16
|
+
},
|
|
17
|
+
"./message-bus": {
|
|
18
|
+
"types": "./dist/message-bus.d.ts",
|
|
19
|
+
"import": "./dist/message-bus.js"
|
|
20
|
+
},
|
|
21
|
+
"./peer-registry": {
|
|
22
|
+
"types": "./dist/peer-registry.d.ts",
|
|
23
|
+
"import": "./dist/peer-registry.js"
|
|
24
|
+
},
|
|
25
|
+
"./protocol": {
|
|
26
|
+
"types": "./dist/protocol.d.ts",
|
|
27
|
+
"import": "./dist/protocol.js"
|
|
28
|
+
},
|
|
29
|
+
"./dashboard.html": "./dashboard.html",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"dashboard.html",
|
|
35
|
+
"README.md",
|
|
36
|
+
"skills/ninja-p2p"
|
|
37
|
+
],
|
|
38
|
+
"sideEffects": false,
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "tsx --test tests/**/*.test.ts",
|
|
41
|
+
"prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
42
|
+
"build": "tsc -p tsconfig.json",
|
|
43
|
+
"prepare": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"keywords": ["p2p", "webrtc", "vdo.ninja", "agent", "pub-sub", "data-channel", "bot"],
|
|
46
|
+
"author": "Steve Seguin",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/steveseguin/ninja-p2p.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/steveseguin/ninja-p2p/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/steveseguin/ninja-p2p#readme",
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@vdoninja/sdk": "^1.3.18"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@roamhq/wrtc": "^0.8.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@roamhq/wrtc": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/node": "^22.0.0",
|
|
75
|
+
"tsx": "^4.19.0",
|
|
76
|
+
"typescript": "^5.8.0"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ninja-p2p
|
|
3
|
+
description: Build bot-to-bot communication over VDO.Ninja WebRTC data channels. Use this skill when the user wants agents or services to meet in a room, discover peers, send private messages, broadcast events, or keep lightweight P2P presence without a central relay.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ninja-p2p
|
|
7
|
+
|
|
8
|
+
Use this skill when a project needs room-based peer discovery and direct messaging between bots over WebRTC.
|
|
9
|
+
|
|
10
|
+
## What This Repo Provides
|
|
11
|
+
|
|
12
|
+
- `VDOBridge` for connection lifecycle, room join, announce, heartbeat, and SDK wiring
|
|
13
|
+
- `PeerRegistry` for presence, skills, topics, and status tracking
|
|
14
|
+
- `MessageBus` for broadcast, direct messages, topic-based fanout, history replay, and offline queueing
|
|
15
|
+
- `dashboard.html` for a browser-side room monitor and chat UI
|
|
16
|
+
|
|
17
|
+
## Install The Library In A Project
|
|
18
|
+
|
|
19
|
+
Install it from npm:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @vdoninja/ninja-p2p @roamhq/wrtc
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`@vdoninja/sdk` is installed transitively. `ws` comes from the SDK in Node environments.
|
|
26
|
+
|
|
27
|
+
## Add This Skill To Codex
|
|
28
|
+
|
|
29
|
+
Use Codex's built-in `skill-installer` helper and install the `skills/ninja-p2p` path from this repo, then restart Codex.
|
|
30
|
+
|
|
31
|
+
## Default Integration Pattern
|
|
32
|
+
|
|
33
|
+
1. Pick a shared `room` for the bots that should discover each other.
|
|
34
|
+
2. Give each bot a unique `streamId`.
|
|
35
|
+
3. Connect with `VDOBridge`.
|
|
36
|
+
4. Use `bridge.chat(text)` for room-wide messages.
|
|
37
|
+
5. Use `bridge.chat(text, targetStreamId)` or `bridge.command(targetStreamId, command, args)` for private messages.
|
|
38
|
+
6. Use `bridge.publishEvent(topic, kind, data)` for topic fanout.
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { VDOBridge } from "@vdoninja/ninja-p2p";
|
|
44
|
+
|
|
45
|
+
const bridge = new VDOBridge({
|
|
46
|
+
room: "agents_room",
|
|
47
|
+
streamId: "planner_bot",
|
|
48
|
+
identity: {
|
|
49
|
+
streamId: "planner_bot",
|
|
50
|
+
role: "agent",
|
|
51
|
+
name: "Planner",
|
|
52
|
+
},
|
|
53
|
+
password: false,
|
|
54
|
+
skills: ["plan", "chat"],
|
|
55
|
+
topics: ["events"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await bridge.connect();
|
|
59
|
+
|
|
60
|
+
bridge.chat("Planner online");
|
|
61
|
+
bridge.chat("sync now", "worker_bot");
|
|
62
|
+
bridge.publishEvent("events", "status_change", { status: "busy" });
|
|
63
|
+
|
|
64
|
+
bridge.bus.on("message:chat", (envelope) => {
|
|
65
|
+
console.log(`${envelope.from.name}: ${envelope.payload.text}`);
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
- `room` and `streamId` should be stable and human-readable, but unique enough to avoid collisions.
|
|
72
|
+
- Private messages target peer `streamId`s.
|
|
73
|
+
- Topic messages are broadcast and filtered on the receiver side.
|
|
74
|
+
- The browser dashboard can sit in the same room as the bots for live inspection.
|