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,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
|
+
}
|