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,392 @@
1
+ export class Connection {
2
+ get isClosed() { return this._isClosed; }
3
+ constructor(id, localNodeId, deviceId, writer, reader, options = {}) {
4
+ this.listeners = [];
5
+ this.closeListeners = [];
6
+ // We'll keep track of the internal stream
7
+ this.writer = null;
8
+ this.reader = null;
9
+ this._isClosed = false;
10
+ // Buffer for incoming data
11
+ this.receiveBuffer = new Uint8Array(0);
12
+ this.writeMutex = Promise.resolve();
13
+ this.mediaListeners = [];
14
+ // --- Native WebRTC Upgrade ---
15
+ this.pc = null;
16
+ this.dc = null;
17
+ this.upgradeState = 'none';
18
+ this.pendingCandidates = [];
19
+ this.trackListeners = [];
20
+ this.id = id;
21
+ this.localNodeId = localNodeId;
22
+ this.deviceId = deviceId;
23
+ this.writer = writer;
24
+ this.reader = reader;
25
+ this.options = options;
26
+ this.readLoop();
27
+ // Auto-upgrade if configured
28
+ if (this.options.rtcConfig) {
29
+ this.attemptUpgrade();
30
+ }
31
+ }
32
+ /**
33
+ * Send a message to the other peer.
34
+ * Messages can be strings or objects (JSON stringified).
35
+ */
36
+ async send(message) {
37
+ return this.sendTyped(0, message);
38
+ }
39
+ /**
40
+ * Send media data (video/audio header + payload)
41
+ * Internal use for now.
42
+ */
43
+ async sendMedia(type, data) {
44
+ const typeId = type === 'video' ? 1 : 2;
45
+ return this.sendTyped(typeId, data);
46
+ }
47
+ async sendTyped(typeId, message) {
48
+ // Upgrade Routing
49
+ if (this.dc && this.dc.readyState === 'open' && this.upgradeState === 'upgraded') {
50
+ if (typeId === 0) {
51
+ try {
52
+ const data = (typeof message === 'object' && !(message instanceof Uint8Array) && !(message instanceof ArrayBuffer))
53
+ ? JSON.stringify(message)
54
+ : message;
55
+ this.dc.send(data);
56
+ return; // Success!
57
+ }
58
+ catch (e) {
59
+ console.warn("[Connection] ⚠️ Native Send Failed (Fallback to Iroh):", e);
60
+ // Fallthrough to Iroh
61
+ }
62
+ }
63
+ }
64
+ if (this.isClosed || !this.writer) {
65
+ throw new Error('Connection is closed');
66
+ }
67
+ let payload;
68
+ if (typeof message === 'string') {
69
+ payload = new TextEncoder().encode(message);
70
+ }
71
+ else if (message instanceof Uint8Array) {
72
+ payload = message;
73
+ }
74
+ else if (message instanceof ArrayBuffer) {
75
+ payload = new Uint8Array(message);
76
+ }
77
+ else {
78
+ payload = new TextEncoder().encode(JSON.stringify(message));
79
+ }
80
+ // Framing: 4 bytes length (Big Endian) + 1 byte Type + payload
81
+ // Total frame size = 4 + 1 + payload.length
82
+ const totalLen = 4 + 1 + payload.length;
83
+ const frame = new Uint8Array(totalLen);
84
+ const view = new DataView(frame.buffer);
85
+ view.setUint32(0, 1 + payload.length, false); // Length includes Type byte
86
+ frame[4] = typeId; // Type byte
87
+ frame.set(payload, 5);
88
+ // Mutex to ensure atomic writes to the stream (prevents interleaving of audio/video chunks)
89
+ const previousMutex = this.writeMutex;
90
+ let releaseMutex;
91
+ this.writeMutex = new Promise((resolve) => {
92
+ releaseMutex = resolve;
93
+ });
94
+ try {
95
+ await previousMutex; // Wait for previous write
96
+ if (this.writer) {
97
+ await this.writer.write(frame);
98
+ }
99
+ }
100
+ catch (err) {
101
+ console.error('[Connection] Send error:', err);
102
+ this.close(`Send Error: ${err}`);
103
+ throw err;
104
+ }
105
+ finally {
106
+ releaseMutex();
107
+ }
108
+ }
109
+ /**
110
+ * Disconnect the connection
111
+ */
112
+ async disconnect() {
113
+ this.close('Explicit Disconnect');
114
+ }
115
+ /**
116
+ * Listen for incoming messages
117
+ */
118
+ onMessage(callback) {
119
+ this.listeners.push(callback);
120
+ }
121
+ /**
122
+ * Listen for incoming media
123
+ */
124
+ onMedia(callback) {
125
+ this.mediaListeners.push(callback);
126
+ }
127
+ /**
128
+ * Listen for disconnection
129
+ */
130
+ onDisconnect(callback) {
131
+ this.closeListeners.push(callback);
132
+ }
133
+ close(reason) {
134
+ if (this._isClosed)
135
+ return;
136
+ console.log(`[Connection] Closing connection ${this.id}. Reason: ${reason || 'Unknown'}`);
137
+ this._isClosed = true;
138
+ // Close writer/reader
139
+ this.writer?.close().catch(() => { });
140
+ this.reader?.cancel().catch(() => { });
141
+ this.writer = null;
142
+ this.reader = null;
143
+ // Notify listeners
144
+ this.closeListeners.forEach(cb => cb());
145
+ }
146
+ async readLoop() {
147
+ if (!this.reader)
148
+ return;
149
+ try {
150
+ while (!this.isClosed) {
151
+ const { done, value } = await this.reader.read();
152
+ if (done) {
153
+ this.close('Read Done (Remote Closed)');
154
+ break;
155
+ }
156
+ if (value) {
157
+ this.handleData(value);
158
+ }
159
+ }
160
+ }
161
+ catch (err) {
162
+ console.error('[Connection] Read loop error:', err);
163
+ this.close(`Read Error: ${err}`);
164
+ }
165
+ }
166
+ handleData(chunk) {
167
+ // Append to buffer
168
+ const newBuffer = new Uint8Array(this.receiveBuffer.length + chunk.length);
169
+ newBuffer.set(this.receiveBuffer);
170
+ newBuffer.set(chunk, this.receiveBuffer.length);
171
+ this.receiveBuffer = newBuffer;
172
+ // Process messages
173
+ while (true) {
174
+ if (this.receiveBuffer.length < 4)
175
+ break; // Need at least length header
176
+ const view = new DataView(this.receiveBuffer.buffer, this.receiveBuffer.byteOffset, this.receiveBuffer.byteLength);
177
+ const length = view.getUint32(0, false);
178
+ if (this.receiveBuffer.length < 4 + length)
179
+ break; // Wait for full message
180
+ // Extract message
181
+ // First byte of body is Type
182
+ // Body is technically at index 4, length includes Type
183
+ const body = this.receiveBuffer.slice(4, 4 + length);
184
+ // However, we need to handle Legacy fallback?
185
+ // If we upgrade, we assume all traffic is upgraded.
186
+ // If the user connects to an old client, the old client sends [Len][Data].
187
+ // We interpret [Data[0]] as Type. This is RISKY if old client sends binary starting with 0, 1, 2.
188
+ // But since old client sends JSON strings usually, '{' is 123.
189
+ // Strings start with chars.
190
+ // We can try to heuristic?
191
+ // Actually, if we control both ends (deployment), we enforce new protocol.
192
+ // Assuming this is a breaking change OR we are careful.
193
+ // New Protocol: Length = N (where N includes the type byte).
194
+ // So if payload is 10 bytes, N=11.
195
+ if (body.length > 0) {
196
+ const typeId = body[0];
197
+ const payload = body.slice(1);
198
+ // console.log(`[Connection] Packet received. Type: ${typeId}, Payload Len: ${payload.length}`);
199
+ if (typeId === 1) {
200
+ this.emitMedia('video', payload);
201
+ }
202
+ else if (typeId === 2) {
203
+ this.emitMedia('audio', payload);
204
+ }
205
+ else {
206
+ // Type 0 or unknown -> Treat as Data
207
+ this.processDataPayload(payload);
208
+ }
209
+ }
210
+ this.receiveBuffer = this.receiveBuffer.slice(4 + length);
211
+ }
212
+ }
213
+ processDataPayload(messageData) {
214
+ // Check if it's text/JSON
215
+ try {
216
+ const text = new TextDecoder().decode(messageData);
217
+ // Try to parse JSON
218
+ try {
219
+ const json = JSON.parse(text);
220
+ // Intercept Internal Signals
221
+ if (json && typeof json === 'object' && json.type === '#pluto-signal') {
222
+ this.handleInternalSignal(json);
223
+ return;
224
+ }
225
+ if (typeof json === 'object' && json !== null) {
226
+ this.emitMessage(json);
227
+ }
228
+ else {
229
+ this.emitMessage(text);
230
+ }
231
+ }
232
+ catch {
233
+ this.emitMessage(text);
234
+ }
235
+ }
236
+ catch {
237
+ this.emitMessage(messageData);
238
+ }
239
+ }
240
+ emitMedia(type, data) {
241
+ this.mediaListeners.forEach(cb => cb(type, data));
242
+ }
243
+ emitMessage(msg) {
244
+ this.listeners.forEach(cb => cb(msg));
245
+ }
246
+ // Public API for adding tracks (used by PlutoPeerConnection)
247
+ addTrack(track) {
248
+ if (!this.pc)
249
+ return null;
250
+ console.log(`[Connection] Adding track ${track.kind} to internal PC`);
251
+ return this.pc.addTrack(track);
252
+ }
253
+ addTransceiver(trackOrKind, init) {
254
+ if (!this.pc)
255
+ return null;
256
+ console.log(`[Connection] Adding transceiver ${typeof trackOrKind === 'string' ? trackOrKind : trackOrKind.kind}`);
257
+ return this.pc.addTransceiver(trackOrKind, init);
258
+ }
259
+ // Allow external access to PC events (ontrack)
260
+ onTrack(callback) {
261
+ this.trackListeners.push(callback);
262
+ }
263
+ async attemptUpgrade() {
264
+ if (this.upgradeState !== 'none')
265
+ return;
266
+ this.upgradeState = 'upgrading';
267
+ const config = this.options.rtcConfig;
268
+ console.log(`[Connection] Initializing RTCPeerConnection for ${this.deviceId.substring(0, 6)}`);
269
+ this.pc = new RTCPeerConnection(config);
270
+ // Tie-breaker: Local > Remote ? Initiator : Responder
271
+ const isInitiator = this.localNodeId > this.deviceId;
272
+ console.log(`[Connection] Role: ${isInitiator ? 'Initiator' : 'Responder'}`);
273
+ // Standardize m-line order: Always create DataChannel first (id=0)
274
+ // Using negotiated: true ensures it matches on both sides without "magic" signaling
275
+ const dc = this.pc.createDataChannel("pluto-dc", { negotiated: true, id: 0 });
276
+ this.setupDataChannel(dc);
277
+ if (isInitiator) {
278
+ // Negotiation needed will trigger Offer logic below
279
+ }
280
+ else {
281
+ // Responder waits for Offer
282
+ }
283
+ this.setupPCHandlers(isInitiator);
284
+ }
285
+ setupPCHandlers(isInitiator) {
286
+ if (!this.pc)
287
+ return;
288
+ this.pc.onicecandidate = (ev) => {
289
+ if (ev.candidate) {
290
+ this.sendInternalSignal({ type: 'candidate', candidate: ev.candidate });
291
+ }
292
+ };
293
+ this.pc.onnegotiationneeded = async () => {
294
+ // Strict Role: Only Initiator starts negotiation to prevent glare/loops
295
+ if (!isInitiator) {
296
+ console.log("[Connection] Negotiation needed (Responder) - Ignoring (waiting for offer)");
297
+ return;
298
+ }
299
+ if (this.pc?.signalingState !== 'stable') {
300
+ console.log(`[Connection] Negotiation needed (Initiator) - Ignored (state: ${this.pc?.signalingState})`);
301
+ return;
302
+ }
303
+ console.log("[Connection] Negotiation Needed (Initiator). Creating Offer.");
304
+ try {
305
+ const offer = await this.pc.createOffer();
306
+ await this.pc.setLocalDescription(offer);
307
+ this.sendInternalSignal({ type: 'sdp', sdp: offer });
308
+ }
309
+ catch (e) {
310
+ console.error("Offer failed", e);
311
+ }
312
+ };
313
+ // Note: ondatachannel won't fire for negotiated channels!
314
+ // We already setup the DC in attemptUpgrade.
315
+ this.pc.ontrack = (ev) => {
316
+ console.log(`[Connection] Peer -> Track: ${ev.track.kind}`);
317
+ this.trackListeners.forEach(cb => cb(ev));
318
+ };
319
+ this.pc.onconnectionstatechange = () => {
320
+ const state = this.pc?.connectionState;
321
+ console.log(`[Connection] 🌐 Native PC State: ${state}`);
322
+ if (state === 'connected') {
323
+ this.upgradeState = 'upgraded';
324
+ console.log("%c[Connection] 🚀 UPGRADE COMPLETE: Switched to Native WebRTC", "color: green; font-weight: bold;");
325
+ }
326
+ else if (state === 'failed' || state === 'disconnected') {
327
+ this.upgradeState = 'failed';
328
+ console.warn("%c[Connection] ⚠️ Native Connection Failed. Falling back to Iroh Relay.", "color: orange; font-weight: bold;");
329
+ // Do not close PC immediately to allow for potential restart or inspection?
330
+ // Actually, if failed, we should probably close to cleanup resources.
331
+ // But for now, just marking as failed ensures sendTyped uses Iroh.
332
+ }
333
+ };
334
+ }
335
+ setupDataChannel(dc) {
336
+ this.dc = dc;
337
+ dc.onopen = () => console.log("[Connection] DC OPEN");
338
+ dc.onmessage = (ev) => {
339
+ // Treat as regular message
340
+ // We might need to handle the framing?
341
+ // If it's pure string/json from DC, just emit.
342
+ // If we want to support binary media over DC later, we need framing.
343
+ // For now: Text/JSON is Message.
344
+ // Binary... let's assume it's data payload.
345
+ this.processDataPayload(typeof ev.data === 'string' ? new TextEncoder().encode(ev.data) : new Uint8Array(ev.data));
346
+ };
347
+ }
348
+ async sendInternalSignal(msg) {
349
+ // Send via Iroh with special type
350
+ // internal type 255? or just a wrapped JSON
351
+ const envelope = {
352
+ type: '#pluto-signal',
353
+ content: msg
354
+ };
355
+ // Use Low-Level sendTyped or high level send?
356
+ // High level send uses JSON.
357
+ // We need to differentiate this from User Messages.
358
+ // Current User Messages are "Any JSON".
359
+ // Use a reserved property?
360
+ return this.send(envelope);
361
+ }
362
+ handleInternalSignal(msg) {
363
+ if (!this.pc) {
364
+ // We might be responder who hasn't initialized yet (if Init happens on first signal?)
365
+ // But constructor init should have happened if config was passed.
366
+ return;
367
+ }
368
+ const content = msg.content;
369
+ if (content.type === 'candidate') {
370
+ if (this.pc.remoteDescription) {
371
+ this.pc.addIceCandidate(content.candidate).catch(e => console.error("AddICE failed", e));
372
+ }
373
+ else {
374
+ this.pendingCandidates.push(content.candidate);
375
+ }
376
+ }
377
+ else if (content.type === 'sdp') {
378
+ const sdp = content.sdp;
379
+ this.pc.setRemoteDescription(sdp).then(async () => {
380
+ if (this.pendingCandidates.length) {
381
+ this.pendingCandidates.forEach(c => this.pc?.addIceCandidate(c));
382
+ this.pendingCandidates = [];
383
+ }
384
+ if (sdp.type === 'offer') {
385
+ const answer = await this.pc.createAnswer();
386
+ await this.pc.setLocalDescription(answer);
387
+ this.sendInternalSignal({ type: 'sdp', sdp: answer });
388
+ }
389
+ }).catch(e => console.error("SDP Error", e));
390
+ }
391
+ }
392
+ }
@@ -0,0 +1,29 @@
1
+ import { Signaling } from './Signaling';
2
+ import { Connection } from './Connection';
3
+ import { Client } from './Client';
4
+ export interface JoinRequest {
5
+ id: string;
6
+ userId: string;
7
+ ticket: string;
8
+ state: 'pending' | 'accepted' | 'rejected';
9
+ }
10
+ export declare class RoomManager {
11
+ private signaling;
12
+ private client;
13
+ private heartbeatInterval;
14
+ constructor(client: Client, signaling: Signaling);
15
+ private get db();
16
+ private get tag();
17
+ /**
18
+ * Creates a new room (or joins the special 'demo' room if specified)
19
+ */
20
+ createRoom(): Promise<string>;
21
+ /**
22
+ * Joins a room by ID.
23
+ */
24
+ joinRoom(roomId: string): Promise<Connection[]>;
25
+ leaveRoom(roomId: string): Promise<void>;
26
+ private startHeartbeat;
27
+ stopHeartbeat(): void;
28
+ private pruneStaleMembers;
29
+ }