feat: Migrate from WebSocket to libp2p for peer-to-peer connectivity (#286)

## Description
Whew, some stuff is still not re-implemented, but it's working!

Rabbit's gonna explode with the amount of changes I reckon 😅



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a peer-to-peer relay system using libp2p with enhanced
stream forwarding, room state synchronization, and mDNS peer discovery.
- Added decentralized room and participant management, metrics
publishing, and safe, size-limited, concurrent message streaming with
robust framing and callback dispatching.
- Implemented asynchronous, callback-driven message handling over custom
libp2p streams replacing WebSocket signaling.
- **Improvements**
- Migrated signaling and stream protocols from WebSocket to libp2p,
improving reliability and scalability.
- Simplified configuration and environment variables, removing
deprecated flags and adding persistent data support.
- Enhanced logging, error handling, and connection management for better
observability and robustness.
- Refined RTP header extension registration and NAT IP handling for
improved WebRTC performance.
- **Bug Fixes**
- Improved ICE candidate buffering and SDP negotiation in WebRTC
connections.
  - Fixed NAT IP and UDP port range configuration issues.
- **Refactor**
- Modularized codebase, reorganized relay and server logic, and removed
deprecated WebSocket-based components.
- Streamlined message structures, removed obsolete enums and message
types, and simplified SafeMap concurrency.
- Replaced WebSocket signaling with libp2p stream protocols in server
and relay components.
- **Chores**
- Updated and cleaned dependencies across Go, Rust, and JavaScript
packages.
  - Added `.gitignore` for persistent data directory in relay package.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Philipp Neumann <3daquawolf@gmail.com>
This commit is contained in:
Kristian Ollikainen
2025-06-06 16:48:49 +03:00
committed by GitHub
parent e67a8d2b32
commit 6e82eff9e2
48 changed files with 4741 additions and 2787 deletions

View File

@@ -11,6 +11,18 @@
"@bufbuild/protoc-gen-es": "^2.2.3"
},
"dependencies": {
"@bufbuild/protobuf": "^2.2.3"
"@bufbuild/protobuf": "^2.2.3",
"@chainsafe/libp2p-noise": "^16.1.3",
"@chainsafe/libp2p-yamux": "^7.0.1",
"@libp2p/identify": "^3.0.32",
"@libp2p/interface": "^2.10.2",
"@libp2p/ping": "^2.0.32",
"@libp2p/websockets": "^9.2.13",
"@multiformats/multiaddr": "^12.4.0",
"it-length-prefixed": "^10.0.1",
"it-pipe": "^3.0.1",
"libp2p": "^2.8.8",
"uint8arraylist": "^2.4.8",
"uint8arrays": "^5.1.0"
}
}

View File

@@ -1,37 +1,305 @@
import {LatencyTracker} from "./latency";
import { LatencyTracker } from "./latency";
import { Uint8ArrayList } from "uint8arraylist";
import { allocUnsafe } from "uint8arrays/alloc";
import { pipe } from "it-pipe";
import { decode, encode } from "it-length-prefixed";
import { Stream } from "@libp2p/interface";
export interface MessageBase {
payload_type: string;
latency?: LatencyTracker;
}
export interface MessageRaw extends MessageBase {
data: any;
}
export function NewMessageRaw(type: string, data: any): Uint8Array {
const msg = {
payload_type: type,
data: data,
};
return new TextEncoder().encode(JSON.stringify(msg));
}
export interface MessageICE extends MessageBase {
payload_type: "ice";
candidate: RTCIceCandidateInit;
}
export function NewMessageICE(
type: string,
candidate: RTCIceCandidateInit,
): Uint8Array {
const msg = {
payload_type: type,
candidate: candidate,
};
return new TextEncoder().encode(JSON.stringify(msg));
}
export interface MessageSDP extends MessageBase {
payload_type: "sdp";
sdp: RTCSessionDescriptionInit;
}
export enum JoinerType {
JoinerNode = 0,
JoinerClient = 1,
export function NewMessageSDP(
type: string,
sdp: RTCSessionDescriptionInit,
): Uint8Array {
const msg = {
payload_type: type,
sdp: sdp,
};
return new TextEncoder().encode(JSON.stringify(msg));
}
export interface MessageJoin extends MessageBase {
payload_type: "join";
joiner_type: JoinerType;
const MAX_SIZE = 1024 * 1024; // 1MB
const MAX_QUEUE_SIZE = 1000; // Maximum number of messages in the queue
// Custom 4-byte length encoder
export const length4ByteEncoder = (length: number) => {
const buf = allocUnsafe(4);
// Write the length as a 32-bit unsigned integer (4 bytes)
buf[0] = length >>> 24;
buf[1] = (length >>> 16) & 0xff;
buf[2] = (length >>> 8) & 0xff;
buf[3] = length & 0xff;
// Set the bytes property to 4
length4ByteEncoder.bytes = 4;
return buf;
};
length4ByteEncoder.bytes = 4;
// Custom 4-byte length decoder
export const length4ByteDecoder = (data: Uint8ArrayList) => {
if (data.byteLength < 4) {
// Not enough bytes to read the length
return -1;
}
// Read the length from the first 4 bytes
let length = 0;
length =
(data.subarray(0, 1)[0] >>> 0) * 0x1000000 +
(data.subarray(1, 2)[0] >>> 0) * 0x10000 +
(data.subarray(2, 3)[0] >>> 0) * 0x100 +
(data.subarray(3, 4)[0] >>> 0);
// Set bytes read to 4
length4ByteDecoder.bytes = 4;
return length;
};
length4ByteDecoder.bytes = 4;
interface PromiseMessage {
data: Uint8Array;
resolve: () => void;
reject: (error: Error) => void;
}
export enum AnswerType {
AnswerOffline = 0,
AnswerInUse,
AnswerOK
}
export class SafeStream {
private stream: Stream;
private callbacks: Map<string, ((data: any) => void)[]> = new Map();
private isReading: boolean = false;
private isWriting: boolean = false;
private closed: boolean = false;
private messageQueue: PromiseMessage[] = [];
private writeLock = false;
private readRetries = 0;
private writeRetries = 0;
private readonly MAX_RETRIES = 5;
export interface MessageAnswer extends MessageBase {
payload_type: "answer";
answer_type: AnswerType;
constructor(stream: Stream) {
this.stream = stream;
this.startReading();
this.startWriting();
}
private async startReading(): Promise<void> {
if (this.isReading || this.closed) return;
this.isReading = true;
try {
const source = this.stream.source;
const decodedSource = decode(source, {
maxDataLength: MAX_SIZE,
lengthDecoder: length4ByteDecoder,
});
for await (const chunk of decodedSource) {
if (this.closed) break;
this.readRetries = 0;
try {
const data = chunk.slice();
const message = JSON.parse(
new TextDecoder().decode(data),
) as MessageBase;
const msgType = message.payload_type;
if (this.callbacks.has(msgType)) {
const handlers = this.callbacks.get(msgType)!;
for (const handler of handlers) {
try {
handler(message);
} catch (err) {
console.error(`Error in message handler for ${msgType}:`, err);
}
}
}
} catch (err) {
console.error("Error processing message:", err);
}
}
} catch (err) {
console.error("Stream reading error:", err);
} finally {
this.isReading = false;
this.readRetries++;
// If not closed, try to restart reading
if (!this.closed && this.readRetries < this.MAX_RETRIES)
setTimeout(() => this.startReading(), 100);
else if (this.readRetries >= this.MAX_RETRIES)
console.error(
"Max retries reached for reading stream, stopping attempts",
);
}
}
public registerCallback(
msgType: string,
callback: (data: any) => void,
): void {
if (!this.callbacks.has(msgType)) {
this.callbacks.set(msgType, []);
}
this.callbacks.get(msgType)!.push(callback);
}
public removeCallback(msgType: string, callback: (data: any) => void): void {
if (this.callbacks.has(msgType)) {
const callbacks = this.callbacks.get(msgType)!;
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
if (callbacks.length === 0) {
this.callbacks.delete(msgType);
}
}
}
private async startWriting(): Promise<void> {
if (this.isWriting || this.closed) return;
this.isWriting = true;
try {
// Create an async generator for real-time message processing
const messageSource = async function* (this: SafeStream) {
while (!this.closed) {
// Check if we have messages to send
if (this.messageQueue.length > 0) {
this.writeLock = true;
try {
const message = this.messageQueue[0];
// Encode the message
const encoded = encode([message.data], {
maxDataLength: MAX_SIZE,
lengthEncoder: length4ByteEncoder,
});
for await (const chunk of encoded) {
yield chunk;
}
// Remove message after successful sending
this.writeRetries = 0;
const sentMessage = this.messageQueue.shift();
if (sentMessage)
sentMessage.resolve();
} catch (err) {
console.error("Error encoding or sending message:", err);
const failedMessage = this.messageQueue.shift();
if (failedMessage)
failedMessage.reject(new Error(`Failed to send message: ${err}`));
} finally {
this.writeLock = false;
}
} else {
// No messages to send, wait for a short period
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}.bind(this);
await pipe(messageSource(), this.stream.sink).catch((err) => {
console.error("Sink error:", err);
this.isWriting = false;
this.writeRetries++;
// Try to restart if not closed
if (!this.closed && this.writeRetries < this.MAX_RETRIES) {
setTimeout(() => this.startWriting(), 1000);
} else if (this.writeRetries >= this.MAX_RETRIES) {
console.error("Max retries reached for writing to stream sink, stopping attempts");
}
});
} catch (err) {
console.error("Stream writing error:", err);
this.isWriting = false;
this.writeRetries++;
// Try to restart if not closed
if (!this.closed && this.writeRetries < this.MAX_RETRIES) {
setTimeout(() => this.startWriting(), 1000);
} else if (this.writeRetries >= this.MAX_RETRIES) {
console.error("Max retries reached for writing stream, stopping attempts");
}
}
}
public async writeMessage(message: Uint8Array): Promise<void> {
if (this.closed) {
throw new Error("Cannot write to closed stream");
}
// Validate message size before queuing
if (message.length > MAX_SIZE) {
throw new Error("Message size exceeds maximum size limit");
}
// Check if the message queue is too large
if (this.messageQueue.length >= MAX_QUEUE_SIZE) {
throw new Error("Message queue is full, cannot write message");
}
// Create a promise to resolve when the message is sent
return new Promise((resolve, reject) => {
this.messageQueue.push({ data: message, resolve, reject } as PromiseMessage);
});
}
public close(): void {
this.closed = true;
this.callbacks.clear();
// Reject pending messages
for (const msg of this.messageQueue)
msg.reject(new Error("Stream closed"));
this.messageQueue = [];
this.readRetries = 0;
this.writeRetries = 0;
}
}

View File

@@ -1,18 +1,27 @@
import {
MessageBase,
MessageICE,
MessageJoin,
MessageSDP,
MessageAnswer,
JoinerType,
AnswerType,
NewMessageRaw,
NewMessageSDP,
NewMessageICE,
SafeStream,
} from "./messages";
import { webSockets } from "@libp2p/websockets";
import { createLibp2p, Libp2p } from "libp2p";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { multiaddr } from "@multiformats/multiaddr";
import { Connection } from "@libp2p/interface";
import { ping } from "@libp2p/ping";
//FIXME: Sometimes the room will wait to say offline, then appear to be online after retrying :D
// This works for me, with my trashy internet, does it work for you as well?
const NESTRI_PROTOCOL_STREAM_REQUEST = "/nestri-relay/stream-request/1.0.0";
export class WebRTCStream {
private _ws: WebSocket | undefined = undefined;
private _p2p: Libp2p | undefined = undefined;
private _p2pConn: Connection | undefined = undefined;
private _p2pSafeStream: SafeStream | undefined = undefined;
private _pc: RTCPeerConnection | undefined = undefined;
private _audioTrack: MediaStreamTrack | undefined = undefined;
private _videoTrack: MediaStreamTrack | undefined = undefined;
@@ -24,7 +33,11 @@ export class WebRTCStream {
private _isConnected: boolean = false; // Add flag to track connection state
currentFrameRate: number = 60;
constructor(serverURL: string, roomName: string, connectedCallback: (stream: MediaStream | null) => void) {
constructor(
serverURL: string,
roomName: string,
connectedCallback: (stream: MediaStream | null) => void,
) {
if (roomName.length <= 0) {
console.error("Room name not provided");
return;
@@ -33,120 +46,114 @@ export class WebRTCStream {
this._onConnected = connectedCallback;
this._serverURL = serverURL;
this._roomName = roomName;
this._setup(serverURL, roomName);
this._setup(serverURL, roomName).catch(console.error);
}
private _setup(serverURL: string, roomName: string) {
private async _setup(serverURL: string, roomName: string) {
// Don't setup new connection if already connected
if (this._isConnected) {
console.log("Already connected, skipping setup");
return;
}
console.log("Setting up WebSocket");
const wsURL = serverURL.replace(/^http/, "ws");
this._ws = new WebSocket(`${wsURL}/api/ws/${roomName}`);
this._ws.onopen = async () => {
console.log("WebSocket opened");
// Send join message
const joinMessage: MessageJoin = {
payload_type: "join",
joiner_type: JoinerType.JoinerClient
};
this._ws!.send(JSON.stringify(joinMessage));
}
console.log("Setting up libp2p");
let iceHolder: RTCIceCandidateInit[] = [];
this._p2p = await createLibp2p({
transports: [webSockets()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
connectionGater: {
denyDialMultiaddr: () => {
return false;
},
},
services: {
identify: identify(),
ping: ping(),
},
});
this._ws.onmessage = async (e) => {
// allow only JSON
if (typeof e.data === "object") return;
if (!e.data) return;
const message = JSON.parse(e.data) as MessageBase;
switch (message.payload_type) {
case "sdp":
this._p2p.addEventListener("peer:connect", async (e) => {
console.debug("Peer connected:", e.detail);
});
this._p2p.addEventListener("peer:disconnect", (e) => {
console.debug("Peer disconnected:", e.detail);
});
const ma = multiaddr(serverURL);
console.debug("Dialing peer at:", ma.toString());
this._p2pConn = await this._p2p.dial(ma);
if (this._p2pConn) {
console.log("Stream is being established");
let stream = await this._p2pConn
.newStream(NESTRI_PROTOCOL_STREAM_REQUEST)
.catch(console.error);
if (stream) {
this._p2pSafeStream = new SafeStream(stream);
console.log("Stream opened with peer");
let iceHolder: RTCIceCandidateInit[] = [];
this._p2pSafeStream.registerCallback("ice-candidate", (data) => {
if (this._pc) {
if (this._pc.remoteDescription) {
this._pc.addIceCandidate(data.candidate).catch((err) => {
console.error("Error adding ICE candidate:", err);
});
// Add held candidates
iceHolder.forEach((candidate) => {
this._pc!.addIceCandidate(candidate).catch((err) => {
console.error("Error adding held ICE candidate:", err);
});
});
iceHolder = [];
} else {
iceHolder.push(data.candidate);
}
} else {
iceHolder.push(data.candidate);
}
});
this._p2pSafeStream.registerCallback("offer", async (data) => {
if (!this._pc) {
// Setup peer connection now
this._setupPeerConnection();
}
console.log("Received SDP: ", (message as MessageSDP).sdp);
await this._pc!.setRemoteDescription((message as MessageSDP).sdp);
await this._pc!.setRemoteDescription(data.sdp);
// Create our answer
const answer = await this._pc!.createAnswer();
// Force stereo in Chromium browsers
answer.sdp = this.forceOpusStereo(answer.sdp!);
await this._pc!.setLocalDescription(answer);
this._ws!.send(JSON.stringify({
payload_type: "sdp",
sdp: answer
}));
break;
case "ice":
if (!this._pc) break;
if (this._pc.remoteDescription) {
try {
await this._pc.addIceCandidate((message as MessageICE).candidate);
// Add held ICE candidates
for (const ice of iceHolder) {
try {
await this._pc.addIceCandidate(ice);
} catch (e) {
console.error("Error adding held ICE candidate: ", e);
}
}
iceHolder = [];
} catch (e) {
console.error("Error adding ICE candidate: ", e);
}
} else {
iceHolder.push((message as MessageICE).candidate);
}
break;
case "answer":
switch ((message as MessageAnswer).answer_type) {
case AnswerType.AnswerOffline:
console.log("Room is offline");
// Call callback with null stream
if (this._onConnected)
this._onConnected(null);
// Send answer back
const answerMsg = NewMessageSDP("answer", answer);
await this._p2pSafeStream?.writeMessage(answerMsg);
});
break;
case AnswerType.AnswerInUse:
console.warn("Room is in use, we shouldn't even be getting this message");
break;
case AnswerType.AnswerOK:
console.log("Joining Room was successful");
break;
}
break;
default:
console.error("Unknown message type: ", message);
this._p2pSafeStream.registerCallback("request-stream-offline", (data) => {
console.warn("Stream is offline for room:", data.roomName);
this._onConnected?.(null);
});
// Send stream request
// marshal room name into json
const request = NewMessageRaw(
"request-stream-room",
roomName,
);
await this._p2pSafeStream.writeMessage(request);
}
}
this._ws.onclose = () => {
console.log("WebSocket closed, reconnecting in 3 seconds");
if (this._onConnected)
this._onConnected(null);
// Clear PeerConnection
this._cleanupPeerConnection()
this._handleConnectionFailure()
// setTimeout(() => {
// this._setup(serverURL, roomName);
// }, this._connectionTimeout);
}
this._ws.onerror = (e) => {
console.error("WebSocket error: ", e);
}
}
// Forces opus to stereo in Chromium browsers, because of course
private forceOpusStereo(SDP: string): string {
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
return SDP.replace(/(minptime=10;useinbandfec=1)/, "$1;stereo=1;sprop-stereo=1;");
return SDP.replace(
/(minptime=10;useinbandfec=1)/,
"$1;stereo=1;sprop-stereo=1;",
);
}
private _setupPeerConnection() {
@@ -158,43 +165,50 @@ export class WebRTCStream {
this._pc = new RTCPeerConnection({
iceServers: [
{
urls: "stun:stun.l.google.com:19302"
}
urls: "stun:stun.l.google.com:19302",
},
],
});
this._pc.ontrack = (e) => {
console.log("Track received: ", e.track);
if (e.track.kind === "audio")
this._audioTrack = e.track;
else if (e.track.kind === "video")
this._videoTrack = e.track;
console.debug("Track received: ", e.track);
if (e.track.kind === "audio") this._audioTrack = e.track;
else if (e.track.kind === "video") this._videoTrack = e.track;
this._checkConnectionState();
};
this._pc.onconnectionstatechange = () => {
console.log("Connection state changed to: ", this._pc!.connectionState);
console.debug("Connection state changed to: ", this._pc!.connectionState);
this._checkConnectionState();
};
this._pc.oniceconnectionstatechange = () => {
console.log("ICE connection state changed to: ", this._pc!.iceConnectionState);
console.debug(
"ICE connection state changed to: ",
this._pc!.iceConnectionState,
);
this._checkConnectionState();
};
this._pc.onicegatheringstatechange = () => {
console.log("ICE gathering state changed to: ", this._pc!.iceGatheringState);
console.debug(
"ICE gathering state changed to: ",
this._pc!.iceGatheringState,
);
this._checkConnectionState();
};
this._pc.onicecandidate = (e) => {
if (e.candidate) {
const message: MessageICE = {
payload_type: "ice",
candidate: e.candidate
};
this._ws!.send(JSON.stringify(message));
const iceMsg = NewMessageICE("ice-candidate", e.candidate);
if (this._p2pSafeStream) {
this._p2pSafeStream.writeMessage(iceMsg).catch((err) =>
console.error("Error sending ICE candidate:", err),
);
} else {
console.warn("P2P stream not established, cannot send ICE candidate");
}
}
};
@@ -207,26 +221,35 @@ export class WebRTCStream {
private _checkConnectionState() {
if (!this._pc) return;
console.log("Checking connection state:", {
console.debug("Checking connection state:", {
connectionState: this._pc.connectionState,
iceConnectionState: this._pc.iceConnectionState,
hasAudioTrack: !!this._audioTrack,
hasVideoTrack: !!this._videoTrack,
isConnected: this._isConnected
isConnected: this._isConnected,
});
if (this._pc.connectionState === "connected" && this._audioTrack !== undefined && this._videoTrack !== undefined) {
if (
this._pc.connectionState === "connected" &&
this._audioTrack !== undefined &&
this._videoTrack !== undefined
) {
this._clearConnectionTimer();
if (!this._isConnected) {
// Only trigger callback if not already connected
this._isConnected = true;
if (this._onConnected !== undefined) {
this._onConnected(new MediaStream([this._audioTrack, this._videoTrack]));
this._onConnected(
new MediaStream([this._audioTrack, this._videoTrack]),
);
// Continuously set low-latency target
this._pc.getReceivers().forEach((receiver: RTCRtpReceiver) => {
let intervalLoop = setInterval(async () => {
if (receiver.track.readyState !== "live" || (receiver.transport && receiver.transport.state !== "connected")) {
if (
receiver.track.readyState !== "live" ||
(receiver.transport && receiver.transport.state !== "connected")
) {
clearInterval(intervalLoop);
return;
} else {
@@ -239,9 +262,11 @@ export class WebRTCStream {
}
this._gatherFrameRate();
} else if (this._pc.connectionState === "failed" ||
} else if (
this._pc.connectionState === "failed" ||
this._pc.connectionState === "closed" ||
this._pc.iceConnectionState === "failed") {
this._pc.iceConnectionState === "failed"
) {
console.log("Connection failed or closed, attempting reconnect");
this._isConnected = false; // Reset connected state
this._handleConnectionFailure();
@@ -250,7 +275,8 @@ export class WebRTCStream {
private _handleConnectionFailure() {
this._clearConnectionTimer();
if (this._isConnected) { // Only notify if previously connected
if (this._isConnected) {
// Only notify if previously connected
this._isConnected = false;
if (this._onConnected) {
this._onConnected(null);
@@ -260,7 +286,7 @@ export class WebRTCStream {
// Attempt to reconnect only if not already connected
if (!this._isConnected && this._serverURL && this._roomName) {
this._setup(this._serverURL, this._roomName);
this._setup(this._serverURL, this._roomName).catch((err) => console.error("Reconnection failed:", err));
}
}
@@ -276,10 +302,8 @@ export class WebRTCStream {
if (this._audioTrack || this._videoTrack) {
try {
if (this._audioTrack)
this._audioTrack.stop();
if (this._videoTrack)
this._videoTrack.stop();
if (this._audioTrack) this._audioTrack.stop();
if (this._videoTrack) this._videoTrack.stop();
} catch (err) {
console.error("Error stopping media tracks:", err);
}
@@ -308,16 +332,18 @@ export class WebRTCStream {
private _setupDataChannelEvents() {
if (!this._dataChannel) return;
this._dataChannel.onclose = () => console.log('sendChannel has closed')
this._dataChannel.onopen = () => console.log('sendChannel has opened')
this._dataChannel.onmessage = e => console.log(`Message from DataChannel '${this._dataChannel?.label}' payload '${e.data}'`)
this._dataChannel.onclose = () => console.log("sendChannel has closed");
this._dataChannel.onopen = () => console.log("sendChannel has opened");
this._dataChannel.onmessage = (e) =>
console.log(
`Message from DataChannel '${this._dataChannel?.label}' payload '${e.data}'`,
);
}
private _gatherFrameRate() {
if (this._pc === undefined || this._videoTrack === undefined)
return;
if (this._pc === undefined || this._videoTrack === undefined) return;
const videoInfoPromise = new Promise<{ fps: number}>((resolve) => {
const videoInfoPromise = new Promise<{ fps: number }>((resolve) => {
// Keep trying to get fps until it's found
const interval = setInterval(async () => {
if (this._pc === undefined) {
@@ -329,7 +355,7 @@ export class WebRTCStream {
stats.forEach((report) => {
if (report.type === "inbound-rtp") {
clearInterval(interval);
resolve({ fps: report.framesPerSecond });
}
});
@@ -337,25 +363,26 @@ export class WebRTCStream {
});
videoInfoPromise.then((value) => {
this.currentFrameRate = value.fps
})
this.currentFrameRate = value.fps;
});
}
// Send binary message through the data channel
public sendBinary(data: Uint8Array) {
if (this._dataChannel && this._dataChannel.readyState === "open")
this._dataChannel.send(data);
else
console.log("Data channel not open or not established.");
else console.log("Data channel not open or not established.");
}
public disconnect() {
this._clearConnectionTimer();
this._cleanupPeerConnection();
if (this._ws) {
this._ws.close();
this._ws = undefined;
if (this._p2pConn) {
this._p2pConn
.close()
.catch((err) => console.error("Error closing P2P connection:", err));
this._p2pConn = undefined;
}
this._isConnected = false;
}
}
}