mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +02:00
⭐ 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:
committed by
GitHub
parent
e67a8d2b32
commit
6e82eff9e2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user