pluto-rtc 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -0
- package/dist/Connection.d.ts +35 -0
- package/dist/Connection.js +146 -0
- package/dist/ConnectionManager.d.ts +38 -0
- package/dist/ConnectionManager.js +78 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +38 -0
- package/dist/api/PlutoPeerConnection.js +242 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +112 -0
- package/dist/api/PlutoWebTransport.d.ts +28 -0
- package/dist/api/PlutoWebTransport.js +88 -0
- package/dist/core/Client.d.ts +70 -0
- package/dist/core/Client.js +326 -0
- package/dist/core/Connection.d.ts +66 -0
- package/dist/core/Connection.js +392 -0
- package/dist/core/Room.d.ts +29 -0
- package/dist/core/Room.js +297 -0
- package/dist/core/Signaling.d.ts +37 -0
- package/dist/core/Signaling.js +199 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +34 -0
- package/package.json +47 -0
- package/wasm/pkg/README.md +218 -0
- package/wasm/pkg/iroh_wasm.d.ts +148 -0
- package/wasm/pkg/iroh_wasm.js +1382 -0
- package/wasm/pkg/iroh_wasm_bg.wasm +0 -0
- package/wasm/pkg/iroh_wasm_bg.wasm.d.ts +58 -0
- package/wasm/pkg/package.json +15 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Connection } from './Connection';
|
|
2
|
+
import { Signaling } from './Signaling';
|
|
3
|
+
import { RoomManager } from './Room';
|
|
4
|
+
export class Client {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.wasmLoaded = false;
|
|
7
|
+
this.wasmPkg = null;
|
|
8
|
+
this.node = null;
|
|
9
|
+
this.connections = new Map();
|
|
10
|
+
this.connectionListeners = [];
|
|
11
|
+
this.disconnectionListeners = [];
|
|
12
|
+
this.messageListeners = [];
|
|
13
|
+
this.roomRequestListeners = [];
|
|
14
|
+
this.streamListeners = [];
|
|
15
|
+
this.localNodeId = null;
|
|
16
|
+
this.listening = false;
|
|
17
|
+
// Prevent GC
|
|
18
|
+
this.pendingConnection = null;
|
|
19
|
+
// Presence loop
|
|
20
|
+
this.presenceInterval = null;
|
|
21
|
+
if (!options.tag)
|
|
22
|
+
throw new Error("Client requires a 'tag' option.");
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.signaling = new Signaling({
|
|
25
|
+
tag: options.tag,
|
|
26
|
+
storagePrefix: options.storagePrefix
|
|
27
|
+
});
|
|
28
|
+
this.rooms = new RoomManager(this, this.signaling);
|
|
29
|
+
this.signaling.onAuthChange((user) => {
|
|
30
|
+
if (user && this.listening && this.localNodeId) {
|
|
31
|
+
this.updatePresence();
|
|
32
|
+
this.signaling.cleanupStaleDevices();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// --- Helpers ---
|
|
37
|
+
get currentUser() {
|
|
38
|
+
return this.signaling.currentUser;
|
|
39
|
+
}
|
|
40
|
+
onAuthChange(callback) {
|
|
41
|
+
return this.signaling.onAuthChange(callback);
|
|
42
|
+
}
|
|
43
|
+
async signInAnonymously() {
|
|
44
|
+
return this.signaling.signInAnonymously();
|
|
45
|
+
}
|
|
46
|
+
signInWithPluto() {
|
|
47
|
+
return this.signaling.signInWithPluto();
|
|
48
|
+
}
|
|
49
|
+
async signOut() {
|
|
50
|
+
return this.signaling.signOut();
|
|
51
|
+
}
|
|
52
|
+
async searchDevices() {
|
|
53
|
+
return this.signaling.searchDevices();
|
|
54
|
+
}
|
|
55
|
+
onDevicesChange(callback) {
|
|
56
|
+
return this.signaling.onDevicesChange(callback);
|
|
57
|
+
}
|
|
58
|
+
async init() {
|
|
59
|
+
if (this.wasmLoaded)
|
|
60
|
+
return;
|
|
61
|
+
// @ts-ignore
|
|
62
|
+
this.wasmPkg = await import('../../wasm/pkg/iroh_wasm');
|
|
63
|
+
await this.wasmPkg.default();
|
|
64
|
+
const persistenceMode = this.options.nodeIdPersistence || 'persistent';
|
|
65
|
+
const storageKey = (this.options?.storagePrefix || '') + 'pluto_rtc_secret_key';
|
|
66
|
+
let secretKeyBytes;
|
|
67
|
+
// 1. Manual User Specified Key
|
|
68
|
+
if (this.options.secretKey) {
|
|
69
|
+
const match = this.options.secretKey.match(/.{1,2}/g);
|
|
70
|
+
if (match) {
|
|
71
|
+
secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 2. Persistent Mode (Default) - Try Load
|
|
75
|
+
else if (persistenceMode === 'persistent') {
|
|
76
|
+
const storedKeyHex = typeof localStorage !== 'undefined' ? localStorage.getItem(storageKey) : null;
|
|
77
|
+
if (storedKeyHex) {
|
|
78
|
+
const match = storedKeyHex.match(/.{1,2}/g);
|
|
79
|
+
if (match) {
|
|
80
|
+
secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 3. Ephemeral Mode - secretKeyBytes remains undefined (generates new)
|
|
85
|
+
this.node = await this.wasmPkg.IrohNode.spawn(secretKeyBytes);
|
|
86
|
+
this.wasmLoaded = true;
|
|
87
|
+
if (this.node) {
|
|
88
|
+
const keyBytes = this.node.secret_key;
|
|
89
|
+
const hexString = Array.from(keyBytes)
|
|
90
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
91
|
+
.join('');
|
|
92
|
+
// Save ONLY if Persistent mode AND no manual key was provided (if manual, no need to save, user manages it)
|
|
93
|
+
if (persistenceMode === 'persistent' && !this.options.secretKey && typeof localStorage !== 'undefined') {
|
|
94
|
+
localStorage.setItem(storageKey, hexString);
|
|
95
|
+
}
|
|
96
|
+
const addrJson = await this.node.node_addr();
|
|
97
|
+
const addr = JSON.parse(addrJson);
|
|
98
|
+
this.localNodeId = addr.node_id || addr.id;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async connect(ticket) {
|
|
102
|
+
if (!this.wasmLoaded)
|
|
103
|
+
await this.init();
|
|
104
|
+
if (!this.node)
|
|
105
|
+
throw new Error('Node not initialized');
|
|
106
|
+
// Parse ticket
|
|
107
|
+
let nodeId = ticket;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(ticket);
|
|
110
|
+
const extractedId = parsed.node_id || parsed.id;
|
|
111
|
+
if (extractedId) {
|
|
112
|
+
nodeId = extractedId;
|
|
113
|
+
let relayUrl = parsed.relay_url;
|
|
114
|
+
let directAddrs = parsed.direct_addresses || [];
|
|
115
|
+
if (Array.isArray(parsed.addrs)) {
|
|
116
|
+
for (const addr of parsed.addrs) {
|
|
117
|
+
if (addr.Relay)
|
|
118
|
+
relayUrl = addr.Relay;
|
|
119
|
+
if (addr.Direct)
|
|
120
|
+
directAddrs.push(addr.Direct);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (relayUrl || (directAddrs && directAddrs.length > 0)) {
|
|
124
|
+
try {
|
|
125
|
+
await this.node.addNodeAddr(nodeId, relayUrl, directAddrs);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { }
|
|
134
|
+
// Check if already connected
|
|
135
|
+
const connId = `${this.localNodeId}-${nodeId}`;
|
|
136
|
+
const existing = this.connections.get(connId);
|
|
137
|
+
if (existing) {
|
|
138
|
+
return existing;
|
|
139
|
+
}
|
|
140
|
+
// Initiate connection
|
|
141
|
+
try {
|
|
142
|
+
console.log(`Initiating connection to ${nodeId}...`);
|
|
143
|
+
// @ts-ignore
|
|
144
|
+
this.pendingConnection = this.node.connect(nodeId);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
console.warn("Connect trigger failed:", e);
|
|
148
|
+
}
|
|
149
|
+
// Retry open_bi loop
|
|
150
|
+
let biStream = null;
|
|
151
|
+
let retries = 0;
|
|
152
|
+
const maxRetries = 40; // 20 seconds
|
|
153
|
+
while (retries < maxRetries) {
|
|
154
|
+
try {
|
|
155
|
+
biStream = await this.node.open_bi(nodeId);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
retries++;
|
|
160
|
+
await new Promise(r => setTimeout(r, 500));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!biStream) {
|
|
164
|
+
throw new Error(`Failed to establish connection to ${nodeId} after ${maxRetries} attempts`);
|
|
165
|
+
}
|
|
166
|
+
const connection = new Connection(connId, this.localNodeId, // Local
|
|
167
|
+
nodeId, // Remote
|
|
168
|
+
biStream.send.getWriter(), biStream.recv.getReader(), {
|
|
169
|
+
rtcConfig: this.options.experimental?.nativeWebRTC
|
|
170
|
+
});
|
|
171
|
+
this.registerConnection(connection);
|
|
172
|
+
this.connectionListeners.forEach(cb => cb(connection));
|
|
173
|
+
connection.send({ type: 'handshake' }).catch(() => { });
|
|
174
|
+
return connection;
|
|
175
|
+
}
|
|
176
|
+
// --- Multiplexing API ---
|
|
177
|
+
async openBi(nodeId) {
|
|
178
|
+
if (!this.node)
|
|
179
|
+
throw new Error("Node not initialized");
|
|
180
|
+
const bi = await this.node.open_bi(nodeId);
|
|
181
|
+
return {
|
|
182
|
+
readable: bi.recv,
|
|
183
|
+
writable: bi.send
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async openUni(nodeId) {
|
|
187
|
+
if (!this.node)
|
|
188
|
+
throw new Error("Node not initialized");
|
|
189
|
+
return await this.node.open_uni(nodeId);
|
|
190
|
+
}
|
|
191
|
+
// --- Listener Management ---
|
|
192
|
+
getConnections() {
|
|
193
|
+
return Array.from(this.connections.values());
|
|
194
|
+
}
|
|
195
|
+
async getTicket() {
|
|
196
|
+
if (!this.wasmLoaded)
|
|
197
|
+
await this.init();
|
|
198
|
+
if (!this.node || !this.localNodeId)
|
|
199
|
+
throw new Error('Node not initialized');
|
|
200
|
+
// @ts-ignore
|
|
201
|
+
return await this.node.node_addr();
|
|
202
|
+
}
|
|
203
|
+
async getNodeId() {
|
|
204
|
+
if (!this.wasmLoaded)
|
|
205
|
+
await this.init();
|
|
206
|
+
if (!this.node || !this.localNodeId)
|
|
207
|
+
throw new Error('Node not initialized');
|
|
208
|
+
return this.localNodeId;
|
|
209
|
+
}
|
|
210
|
+
async startListening() {
|
|
211
|
+
if (this.listening)
|
|
212
|
+
return;
|
|
213
|
+
if (!this.wasmLoaded)
|
|
214
|
+
await this.init();
|
|
215
|
+
if (!this.node)
|
|
216
|
+
throw new Error('Node not initialized');
|
|
217
|
+
this.listening = true;
|
|
218
|
+
this.listenLoop();
|
|
219
|
+
this.updatePresence();
|
|
220
|
+
// Auto-update presence (heartbeat) every 1 minute
|
|
221
|
+
this.presenceInterval = setInterval(() => {
|
|
222
|
+
this.updatePresence();
|
|
223
|
+
}, 60000);
|
|
224
|
+
}
|
|
225
|
+
stopListening() {
|
|
226
|
+
this.listening = false;
|
|
227
|
+
if (this.presenceInterval) {
|
|
228
|
+
clearInterval(this.presenceInterval);
|
|
229
|
+
this.presenceInterval = null;
|
|
230
|
+
}
|
|
231
|
+
if (this.localNodeId) {
|
|
232
|
+
this.signaling.setOffline(this.localNodeId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async updatePresence() {
|
|
236
|
+
if (this.localNodeId) {
|
|
237
|
+
const ticket = await this.getTicket();
|
|
238
|
+
await this.signaling.updatePresence(this.localNodeId, ticket, true, this.options.deviceTTL);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async listenLoop() {
|
|
242
|
+
if (!this.node)
|
|
243
|
+
return;
|
|
244
|
+
try {
|
|
245
|
+
const streams = this.node.incoming_streams();
|
|
246
|
+
const reader = streams.getReader();
|
|
247
|
+
while (this.listening) {
|
|
248
|
+
const { done, value } = await reader.read();
|
|
249
|
+
if (done)
|
|
250
|
+
break;
|
|
251
|
+
if (value) {
|
|
252
|
+
this.handleIncomingStream(value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
console.error("Listen loop error", e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
handleIncomingStream(incoming) {
|
|
261
|
+
// Compatibility check: OLD behavior returned BiStream directly?
|
|
262
|
+
// New behavior returns { type, stream, endpointId }
|
|
263
|
+
// Normalize
|
|
264
|
+
let type = 'bi';
|
|
265
|
+
let stream = incoming;
|
|
266
|
+
let endpointId = incoming.endpoint_id;
|
|
267
|
+
if ('type' in incoming) {
|
|
268
|
+
// New structure
|
|
269
|
+
type = incoming.type;
|
|
270
|
+
stream = incoming.stream;
|
|
271
|
+
endpointId = incoming.endpointId;
|
|
272
|
+
}
|
|
273
|
+
// Notify raw stream listeners (for WebTransport)
|
|
274
|
+
this.streamListeners.forEach(cb => cb({ type: type, stream, remoteNodeId: endpointId }));
|
|
275
|
+
// Legacy Connection handling for FIRST Bidirectional Stream
|
|
276
|
+
// If we don't have a connection for this peer yet, create one using this stream.
|
|
277
|
+
// This assumes the first stream is the "main" signaling/control channel.
|
|
278
|
+
if (type === 'bi') {
|
|
279
|
+
const remoteId = endpointId;
|
|
280
|
+
const connId = `${this.localNodeId}-${remoteId}`;
|
|
281
|
+
if (!this.connections.has(connId)) {
|
|
282
|
+
// New connection!
|
|
283
|
+
const biStream = stream;
|
|
284
|
+
const connection = new Connection(connId, this.localNodeId, remoteId, biStream.send.getWriter(), biStream.recv.getReader(), {
|
|
285
|
+
rtcConfig: this.options.experimental?.nativeWebRTC
|
|
286
|
+
});
|
|
287
|
+
this.registerConnection(connection);
|
|
288
|
+
this.connectionListeners.forEach(cb => cb(connection));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
registerConnection(conn) {
|
|
293
|
+
this.connections.set(conn.id, conn);
|
|
294
|
+
conn.onMessage((msg) => {
|
|
295
|
+
this.handleMessage(conn, msg);
|
|
296
|
+
});
|
|
297
|
+
conn.onDisconnect(() => {
|
|
298
|
+
this.connections.delete(conn.id);
|
|
299
|
+
this.disconnectionListeners.forEach(cb => cb(conn));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
// --- Listeners ---
|
|
303
|
+
onConnection(callback) {
|
|
304
|
+
this.connectionListeners.push(callback);
|
|
305
|
+
}
|
|
306
|
+
onDisconnection(callback) {
|
|
307
|
+
this.disconnectionListeners.push(callback);
|
|
308
|
+
}
|
|
309
|
+
onMessage(callback) {
|
|
310
|
+
this.messageListeners.push(callback);
|
|
311
|
+
}
|
|
312
|
+
// New: listen for raw headers
|
|
313
|
+
onIncomingStream(callback) {
|
|
314
|
+
this.streamListeners.push(callback);
|
|
315
|
+
}
|
|
316
|
+
onRoomJoinRequest(callback) {
|
|
317
|
+
this.roomRequestListeners.push(callback);
|
|
318
|
+
}
|
|
319
|
+
// --- Signaling (for WebRTC Upgrade) ---
|
|
320
|
+
// Deprecated: Connection handles its own signaling via #pluto-signal messages.
|
|
321
|
+
// Keeping hooks if user wants to send custom signals (e.g. app specific)
|
|
322
|
+
handleMessage(conn, msg) {
|
|
323
|
+
// We no longer intercept #signal here, Connection does it before emitting message event.
|
|
324
|
+
this.messageListeners.forEach(cb => cb(conn, msg));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface ConnectionOptions {
|
|
2
|
+
reliable?: boolean;
|
|
3
|
+
rtcConfig?: RTCConfiguration;
|
|
4
|
+
}
|
|
5
|
+
export declare class Connection {
|
|
6
|
+
private listeners;
|
|
7
|
+
private closeListeners;
|
|
8
|
+
private writer;
|
|
9
|
+
private reader;
|
|
10
|
+
private _isClosed;
|
|
11
|
+
get isClosed(): boolean;
|
|
12
|
+
private receiveBuffer;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly deviceId: string;
|
|
15
|
+
readonly localNodeId: string;
|
|
16
|
+
private options;
|
|
17
|
+
private writeMutex;
|
|
18
|
+
constructor(id: string, localNodeId: string, deviceId: string, writer: WritableStreamDefaultWriter<Uint8Array>, reader: ReadableStreamDefaultReader<Uint8Array>, options?: ConnectionOptions);
|
|
19
|
+
private mediaListeners;
|
|
20
|
+
/**
|
|
21
|
+
* Send a message to the other peer.
|
|
22
|
+
* Messages can be strings or objects (JSON stringified).
|
|
23
|
+
*/
|
|
24
|
+
send(message: string | object | ArrayBuffer | Uint8Array): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Send media data (video/audio header + payload)
|
|
27
|
+
* Internal use for now.
|
|
28
|
+
*/
|
|
29
|
+
sendMedia(type: 'audio' | 'video', data: Uint8Array): Promise<void>;
|
|
30
|
+
private sendTyped;
|
|
31
|
+
/**
|
|
32
|
+
* Disconnect the connection
|
|
33
|
+
*/
|
|
34
|
+
disconnect(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Listen for incoming messages
|
|
37
|
+
*/
|
|
38
|
+
onMessage(callback: (message: any) => void): void;
|
|
39
|
+
/**
|
|
40
|
+
* Listen for incoming media
|
|
41
|
+
*/
|
|
42
|
+
onMedia(callback: (type: 'audio' | 'video', data: Uint8Array) => void): void;
|
|
43
|
+
/**
|
|
44
|
+
* Listen for disconnection
|
|
45
|
+
*/
|
|
46
|
+
onDisconnect(callback: () => void): void;
|
|
47
|
+
private close;
|
|
48
|
+
private readLoop;
|
|
49
|
+
private handleData;
|
|
50
|
+
private processDataPayload;
|
|
51
|
+
private emitMedia;
|
|
52
|
+
private emitMessage;
|
|
53
|
+
private pc;
|
|
54
|
+
private dc;
|
|
55
|
+
private upgradeState;
|
|
56
|
+
private pendingCandidates;
|
|
57
|
+
addTrack(track: MediaStreamTrack): RTCRtpSender | null;
|
|
58
|
+
addTransceiver(trackOrKind: MediaStreamTrack | string, init?: RTCRtpTransceiverInit): RTCRtpTransceiver | null;
|
|
59
|
+
onTrack(callback: (event: RTCTrackEvent) => void): void;
|
|
60
|
+
private trackListeners;
|
|
61
|
+
private attemptUpgrade;
|
|
62
|
+
private setupPCHandlers;
|
|
63
|
+
private setupDataChannel;
|
|
64
|
+
private sendInternalSignal;
|
|
65
|
+
private handleInternalSignal;
|
|
66
|
+
}
|