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.
@@ -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
+ }