UNPKG

@vdoninja/sdk

Version:

AI-friendly P2P communication SDK for audio, video, and data

1,143 lines (1,002 loc) 212 kB
// VDO.Ninja SDK v1.3.9 /** * VDO.Ninja SDK - OFFICIAL SDK FOR VDO.NINJA WEBSOCKET API * Copyright (C) 2025 Steve Seguin and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * IMPORTANT USAGE NOTICE: * ======================= * * 1. Direct WebSocket API access is NOT APPROVED and may result in blocking * 2. Always use this SDK - it ensures proper usage patterns and handles API updates * 3. The WebSocket API may change without notice, breaking direct integrations * 4. Rate limiting is enforced - excessive requests will be throttled or blocked * 5. The service follows a serverless philosophy - no state management or data relay * 6. Sending non-handshake data through the WebSocket server is prohibited * 7. Rooms are limited to ~80 connections, viewer connections may also be limited * 8. Higher rate limits available on request for legitimate use cases * * By using this SDK, you agree to respect these guidelines to keep the service * free and available for everyone. Abuse hurts the entire ecosystem. * * WebSocket Client-Server Communication Protocol for VDO.Ninja * * This document outlines the structure of messages sent between the client (e.g., web browser) * and the VDO.Ninja signaling server. All messages are JSON objects. * * UUIDs are used for direct client-to-client messaging within the signaling server. * When a client sends a message that is routed to another client, the 'UUID' field * in the outgoing message indicates the original sender's UUID. */ // Global state tracking (server-side context for understanding message logic): // clients: { [clientUUID]: WebSocketConnection } // streams: { [streamID]: clientUUID } // Tracks the original publisher of a stream // streamIDs: { [clientUUID]: streamID } // Inverse of 'streams' for quick lookup by client // myRooms: { [clientUUID]: roomID } // Which room a client is currently in // roomList: { [roomID]: [clientUUID, ...] } // List of clients in a room // roomStreams: { [roomID]: { [streamID]: clientUUID } } // NEW: Stream IDs active within a specific room /** * INCOMING CLIENT REQUESTS (Messages from Client to Server) * ========================================================= */ /** * 1. Direct Message Routing (Default/Catch-all) * Used for WebRTC signaling data (ICE candidates, SDP answers/offers that are not initial 'play' or 'seed'). * The server acts as a pass-through for these messages. */ /* { // No explicit 'request' field for direct routing. // The server checks for 'request' field first. If not present, it assumes direct routing. "UUID": "targetClientUUID", // REQUIRED: The UUID of the client this message is intended for. // ... other WebRTC signaling data (e.g., "sdp", "candidate", "type") ... // Example: // "sdp": "v=0\r\no=- 12345...", // "type": "answer" } */ /** * 2. 'play' Request * A client requests to view a stream. The server will attempt to find a seeder for the stream * and instruct the seeder to send an offer SDP to the requesting client. */ /* { "request": "play", // REQUIRED: Indicates a request to play a stream. "streamID": "desiredStreamID" // REQUIRED: The identifier of the stream the client wants to view. } */ /** * 3. 'seed' Request * A client announces that it is publishing a stream with a given stream ID. * This registers the client as the source for that stream. * The server will validate for conflicts within rooms and globally. */ /* { "request": "seed", // REQUIRED: Indicates the client is seeding a stream. "streamID": "yourStreamID" // REQUIRED: The unique identifier for the stream this client is publishing. } */ /** * 4. 'joinroom' Request * A client requests to join a specific room. This updates the client's room membership * and triggers a listing of existing members and their streams in that room. * It also handles claiming director status for the room. */ /* { "request": "joinroom", // REQUIRED: Indicates a request to join a room. "roomid": "roomIdentifier", // REQUIRED: The identifier of the room to join. "claim"?: true // OPTIONAL: If present and true, attempts to claim director status for the room. // If the room is already claimed, the server will alert the client. } */ /** * 5. 'migrate' Request (Director-only) * A room director can use this request to transfer another user from their current room * to a different target room. */ /* { "request": "migrate", // REQUIRED: Indicates a request to migrate a user. "roomid": "targetRoomID", // REQUIRED: The ID of the room to transfer the target user to. "target": "targetClientUUID" // REQUIRED: The UUID of the client to be migrated. } */ /** * OUTGOING SERVER RESPONSES (Messages from Server to Client) * ========================================================== */ /** * 1. 'offerSDP' Request (Server initiated to a seeder) * Sent by the server to a stream seeder, instructing them to send an SDP offer * to the specified 'UUID' (the client requesting to view the stream). */ /* { "request": "offerSDP", // Indicates a request for an SDP offer. "UUID": "viewerClientUUID" // REQUIRED: The UUID of the client who wants to receive the SDP offer. // This is the 'from' UUID for the seeder to send their offer to. } */ /** * 2. 'alert' Response * General purpose alert messages from the server to the client. */ /* { "request": "alert", // Indicates an alert message. "message": "A descriptive message about the alert, e.g., 'Stream ID is already in use.'" // REQUIRED: The alert message. } */ /** * 3. 'videoaddedtoroom' Response * Sent to clients in a room when another client in that room starts seeding a new stream. */ /* { "request": "videoaddedtoroom", // Indicates a new video stream has been added to the room. "UUID": "publisherClientUUID", // REQUIRED: The UUID of the client who started publishing. "streamID": "newStreamID" // REQUIRED: The stream ID that was added. } */ /** * 4. 'error' Response * Sent when a specific error condition occurs, often with a unique error code. */ /* { "request": "error", // Indicates an error message. "message": "A descriptive message about the error.", // REQUIRED: The error message. "code": "ERROR_CODE" // OPTIONAL: A specific error code for programmatic handling (e.g., "STREAMID_IN_USE", "TRANSFER_STREAMID_CONFLICT"). } */ /** * 5. 'listing' Response * Sent to a client upon successfully joining a room, providing a list of other * members in that room and their associated stream IDs. */ /* { "request": "listing", // Indicates a listing of room members. "list": [ // REQUIRED: An array of objects, each representing a room member. { "UUID": "memberClientUUID", // The UUID of a member in the room. "streamID"?: "memberStreamID" // OPTIONAL: The stream ID published by this member, if any. }, // ... more member objects ... ], "director"?: "directorClientUUID", // OPTIONAL: The UUID of the current director of the room, if one exists. "claim"?: true | false // OPTIONAL: Only present if the 'joinroom' request included 'claim: true'. // True if the client successfully claimed director status, false otherwise. } */ /** * 6. 'someonejoined' Response * Sent to existing members of a room when a new client joins that room. */ /* { "request": "someonejoined", // Indicates a new client has joined the room. "UUID": "newClientUUID", // REQUIRED: The UUID of the client who just joined. "director"?: true, // OPTIONAL: True if the joining client is also the new director of the room. "streamID"?: "newClientStreamID" // OPTIONAL: The stream ID published by the joining client, if any. } */ /** * 7. 'transferred' Response (To the transferred user) * Sent to a client who has been successfully migrated to a new room by a director. * Similar to a 'listing' response for the new room. */ /* { "request": "transferred", // Indicates the client has been transferred to a new room. "list": [ // REQUIRED: An array of objects, each representing a member in the new room. { "UUID": "memberClientUUID", "streamID"?: "memberStreamID" }, // ... more member objects ... ], "director"?: "newRoomDirectorUUID" // OPTIONAL: The UUID of the director of the new room, if one exists. } */ /** * 8. 'roomclaimed' Response (OBSOLETE in V17.3, replaced by 'joinroom' with 'claim') * Sent to clients in a room when a director successfully claims director status for that room. */ /* { "request": "roomclaimed", // Indicates a room has been claimed by a director. "director": "directorClientUUID" // REQUIRED: The UUID of the client who claimed director status. } */ /** * 9. 'sendroom' Response (OBSOLETE in v17.2) * Provides room information to the requesting client. */ /* { "request": "sendroom", // Indicates room information. "director": true | false // True if the requesting client is the director, false otherwise. // ... other room-specific data, likely related to member list, etc. } */ /** * VDO.Ninja SDK - STILL IN DEVELOPMENT AND MAY CONTAIN BUGS * * This version incorporates all fixes from vdoninja-sdk-fix-final.js directly into the SDK. * * ICE Candidate Type Field Routing: * When you have both viewing and publishing connections to the same peer UUID, VDO.Ninja uses * the 'type' field in ICE candidate messages to route them to the correct connection: * * - type: "remote" = FROM viewer TO publisher (routes to publisher's pcs connection) * - type: "local" = FROM publisher TO viewer (routes to viewer's rpcs connection) * * Example: If you're viewing a stream, you send ICE candidates with type:"remote" to the publisher. * The publisher receives these and routes them to their pcs[UUID] connection. * * Key features: * 1. NO streamID in joinroom message * 2. NO password field in joinroom (password is used to hash roomid) * 3. Seed sent AFTER room join completes (after listing received) * 4. StreamID hashed correctly with 6-char suffix * 5. Proper message ordering: connect → joinroom → listing → seed * 6. Data channel track negotiation - viewers specify audio/video preferences * 7. Publishers respect viewer preferences when attaching tracks * 8. Session management - each WebRTC connection has a unique session ID * 9. All initialization fixes for missing properties * 10. Proper event emission for listing and videoaddedtoroom * 11. Fixed _createConnection to handle both signatures * 12. Full encryption/decryption support for SDP and ICE candidates * 13. Mesh networking support - multiple connections per peer UUID * 14. rpcs/pcs targeting for data channel messages * * Session Management: * - Publishers generate a new 8-character session ID for each WebRTC connection * - Session IDs are included in offers, answers, and ICE candidates * - Viewers echo back the publisher's session ID in their responses * - When a new offer arrives with a different session, old connections are closed * - Messages with mismatched sessions are ignored to prevent stale signaling * * Encryption: * - Default password is "someEncryptionKey123" for undefined, null, or empty string * - Password must be explicitly set to false to disable encryption * - Custom passwords are used when any non-empty string is provided * - Salt is determined by hostname (vdo.ninja for official domains) * - SDP descriptions and ICE candidates are encrypted with AES-CBC * - Messages with 'vector' field are automatically decrypted * - ICE candidates are bundled for efficiency with exponential backoff * * Mesh Networking & Data Channel Targeting: * - Supports multiple connections per peer UUID (publisher + viewer) * - Uses nested Map structure: UUID → { viewer: connection, publisher: connection } * - sendData support type-based targeting: * - { type: "viewer" } → Send to viewer connections only * - { type: "publisher" } → Send to publisher connections only * - Examples: * sdk.sendData(data, { uuid: "abc123", type: "viewer" }) // To specific viewer connection * sdk.sendData(data, { type: "publisher" }) // To all publisher connections * sdk.sendData(data, { streamID: "user1", type: "viewer" }) // To viewers of stream * * @author Steve Seguin * @license AGPLv3 */ (function (global) { 'use strict'; class VDONinjaSDK extends EventTarget { /** * SDK Version * @static * @returns {string} Current SDK version */ static get VERSION() { return '1.3.9'; } /** * Sanitize stream ID * @private * @param {string} streamID - Stream ID to sanitize * @returns {string} Sanitized stream ID */ _sanitizeStreamID(streamID) { if (!streamID || typeof streamID !== 'string') { streamID = this._generateStreamID(); this._log('No streamID provided, generated:', streamID); return streamID; } streamID = streamID.trim(); if (streamID.length < 1) { streamID = this._generateStreamID(); this._log('Empty streamID provided, generated:', streamID); return streamID; } const streamID_sanitized = streamID.replace(/[\W]+/g, "_"); if (streamID !== streamID_sanitized) { this._log('StreamID contained non-alphanumeric characters, sanitized:', streamID_sanitized); } if (streamID_sanitized.length > 64) { const truncated = streamID_sanitized.substring(0, 64); this._log('StreamID too long, truncated to 64 characters:', truncated); return truncated; } return streamID_sanitized; } /** * Sanitize room name * @private * @param {string} roomid - Room ID to sanitize * @returns {string|false} Sanitized room ID or false if explicitly false */ _sanitizeRoomName(roomid) { if (roomid === false || roomid === null || roomid === undefined) { return false; } if (typeof roomid !== 'string') { roomid = String(roomid); } roomid = roomid.trim(); if (roomid === "") { return ""; } const sanitized = roomid.replace(/[\W]+/g, "_"); if (roomid !== sanitized) { this._log('Room name contained non-alphanumeric characters, sanitized:', sanitized); } if (sanitized.length > 30) { const truncated = sanitized.substring(0, 30); this._log('Room name too long, truncated to 30 characters:', truncated); return truncated; } return sanitized; } /** * Sanitize label * @private * @param {string} label - Label to sanitize * @returns {string} Sanitized label */ _sanitizeLabel(label) { if (!label || typeof label !== 'string') { return ""; } // Remove any HTML/script tags for security const temp = document.createElement("div"); temp.innerText = label; let sanitized = temp.textContent || temp.innerText || ""; // Truncate to 100 characters sanitized = sanitized.substring(0, Math.min(sanitized.length, 100)); return sanitized.trim(); } /** * Sanitize password * @private * @param {string|boolean|null} password - Password to sanitize * @returns {string|boolean|null} Sanitized password */ _sanitizePassword(password) { if (password === false || password === null) { return password; } if (password === undefined || password === "") { return ""; } if (typeof password !== 'string') { password = String(password); } password = password.trim(); if (password.length < 1) { this._log('Empty password provided'); return ""; } // Encode special characters for safe transmission const sanitized = encodeURIComponent(password); return sanitized; } /** * Determine the effective password to use for hashing/encryption * - false or null: explicitly disabled -> return null * - undefined or empty string: use default "someEncryptionKey123" * - otherwise: use current password string * @private * @returns {string|null} Effective password or null if disabled */ _getEffectivePassword() { if (this.password === false || this.password === null) { return null; // explicitly disabled } if (this.password === undefined || this.password === "") { return "someEncryptionKey123"; } // Ensure string and trimmed let pwd = this.password; if (typeof pwd !== 'string') { pwd = String(pwd); } pwd = pwd.trim(); if (pwd.length < 1) { return "someEncryptionKey123"; } return pwd; } /** * Create a new VDONinjaSDK instance * @param {Object} options - Configuration options * @param {string} options.host - WebSocket signaling server URL (default: 'wss://wss.vdo.ninja') * @param {string} options.room - Room name to join (optional) * @param {string|false} options.password - Room password (default: "someEncryptionKey123", false to disable) * @param {boolean} options.debug - Enable debug logging (default: false) * @param {Array|false|null} options.turnServers - TURN server configuration: * - null/undefined: Auto-fetch optimal TURN servers from API (default) * - false: Disable TURN servers, use only STUN * - Array: Custom TURN server configuration * @param {boolean} options.forceTURN - Force relay mode through TURN servers for privacy (default: false) * @param {number} options.turnCacheTTL - TURN server cache time-to-live in minutes (default: 5) * @param {Array} options.stunServers - STUN server configuration (default: Google & VDO.Ninja STUN) * @param {number} options.maxReconnectAttempts - Maximum reconnection attempts (default: 5) * @param {number} options.reconnectDelay - Initial reconnection delay in ms (default: 1000) * @param {boolean} options.autoPingViewer - Enable viewer-side auto ping (default: false) * @param {number} options.autoPingInterval - Auto ping interval in ms (default: 10000) */ constructor(options = {}) { super(); // SDK Version this.version = VDONinjaSDK.VERSION; // Core configuration this.host = options.host || options.wss || 'wss://wss.vdo.ninja'; this.room = this._sanitizeRoomName(options.room || null); // Handle password: false explicitly disables, undefined/null/empty uses default if (options.password === false) { this.password = false; } else if (options.password === undefined || options.password === null || options.password === '') { this.password = this._sanitizePassword("someEncryptionKey123"); } else { this.password = this._sanitizePassword(options.password); } this.debug = options.debug || false; // Store options that might be incorrectly set as properties by LLMs this._pendingStreamID = options.streamID || null; this._pendingLabel = options.label || null; // Optional publisher info fields to send on DC open this._pendingInfo = {}; if (options.label) this._pendingInfo.label = this._sanitizeLabel(options.label); if (options.meta) this._pendingInfo.meta = this._sanitizeLabel(options.meta); if (options.order) this._pendingInfo.order = this._sanitizeLabel(options.order); if (typeof options.broadcast === 'boolean') this._pendingInfo.broadcast = !!options.broadcast; if (typeof options.allowdrawing === 'boolean') this._pendingInfo.allowdrawing = !!options.allowdrawing; if (typeof options.iframe === 'boolean') this._pendingInfo.iframe = !!options.iframe; if (typeof options.widget === 'boolean') this._pendingInfo.widget = !!options.widget; if (typeof options.allowmidi === 'boolean') this._pendingInfo.allowmidi = !!options.allowmidi; if (typeof options.allowresources === 'boolean') this._pendingInfo.allowresources = !!options.allowresources; if (typeof options.allowchunked === 'boolean' || typeof options.allowchunked === 'number') this._pendingInfo.allowchunked = options.allowchunked; // Also accept a nested info object for convenience if (options.info && typeof options.info === 'object') { const inf = options.info; if (inf.label) { this._pendingInfo.label = this._sanitizeLabel(inf.label); this._pendingLabel = this._pendingInfo.label; } if (inf.meta) this._pendingInfo.meta = this._sanitizeLabel(inf.meta); if (inf.order) this._pendingInfo.order = this._sanitizeLabel(inf.order); if (typeof inf.broadcast === 'boolean') this._pendingInfo.broadcast = !!inf.broadcast; if (typeof inf.allowdrawing === 'boolean') this._pendingInfo.allowdrawing = !!inf.allowdrawing; if (typeof inf.iframe === 'boolean') this._pendingInfo.iframe = !!inf.iframe; if (typeof inf.widget === 'boolean') this._pendingInfo.widget = !!inf.widget; if (typeof inf.allowmidi === 'boolean') this._pendingInfo.allowmidi = !!inf.allowmidi; if (typeof inf.allowresources === 'boolean') this._pendingInfo.allowresources = !!inf.allowresources; if (typeof inf.allowchunked === 'boolean' || typeof inf.allowchunked === 'number') this._pendingInfo.allowchunked = inf.allowchunked; } this._pendingRoomID = options.roomid || options.roomID || null; // Support both cases // State management this.state = { connected: false, room: null, streamID: null, uuid: null, roomJoined: false, publishing: false }; // Connection management - Initialize all required properties this.signaling = null; this.connections = new Map(); // UUID -> { viewer: connection, publisher: connection } this._pendingViews = new Map(); this._failedViewerConnections = new Map(); // Track failed connections for retry this._intentionalDisconnect = false; // Flag for intentional disconnections this._passwordHash = null; // Cached hash for streamID this._viewHandlers = new Map(); this._sessionIDs = {}; this._remoteSessionIDs = {}; this._streamToUUID = {}; this.messageHandlers = new Map(); // Stream tracking this.streams = new Map(); // streamID -> { firstSeen, lastSeen, uuid, state } // Media management this.localStream = null; this.videoElement = options.videoElement || null; // Configuration this.turnServers = options.turnServers !== undefined ? options.turnServers : null; // null = auto fetch, false = none, array = custom this.forceTURN = options.forceTURN || false; // Force relay mode for privacy this.turnCacheTTL = options.turnCacheTTL || 5; // TURN cache time-to-live in minutes this.stunServers = options.stunServers || [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.cloudflare.com:3478' } ]; // Will be populated with TURN servers this.configuration = options.configuration || { iceServers: this.stunServers.slice() // Start with STUN servers }; this._turnPromise = null; // For caching TURN server fetch this._turnList = null; // Cached TURN servers // Reconnection settings this._reconnectAttempts = 0; this._maxReconnectAttempts = options.maxReconnectAttempts || 5; this._reconnectDelay = options.reconnectDelay || 1000; this._reconnectTimer = null; // Internal flags this._isReconnecting = false; this._intentionalDisconnect = false; // View retry mechanism this._viewRetryTimers = new Map(); this._viewRetryInterval = 15 * 60 * 1000; // 15 minutes // Initialize salt before setting up crypto this.salt = options.salt || "vdo.ninja"; this._saltProvidedViaOptions = !!options.salt; // Auto-ping settings (viewer-only) this.autoPingViewer = options.autoPingViewer || false; this.autoPingInterval = options.autoPingInterval || 10000; // Setup crypto utilities this._setupCryptoUtils(); this._log('SDK initialized with host:', this.host); // Add property setters to help LLMs use the SDK correctly this._addPropertyHelpers(); } /** * Add property setters that guide users to correct usage * These properties are commonly misused by LLMs trying to set them directly * @private */ _addPropertyHelpers() { // Define property descriptors for commonly misused properties Object.defineProperty(this, 'streamID', { get: function() { return this.state.streamID; }, set: function(value) { console.warn(`[VDONinja SDK] Setting streamID as a property is not recommended.\n` + `Please use the streamID option in publish() or announce() methods:\n` + ` await vdo.publish(stream, { streamID: '${value}' })\n` + ` await vdo.announce({ streamID: '${value}' })\n` + `The streamID has been stored and will be used if not specified in the method call.`); this._pendingStreamID = value; }, configurable: true }); Object.defineProperty(this, 'roomid', { get: function() { return this.room; }, set: function(value) { console.warn(`[VDONinja SDK] Setting roomid as a property is not recommended.\n` + `Please use the room option in connect() or joinRoom() methods:\n` + ` await vdo.connect({ room: '${value}' })\n` + ` await vdo.joinRoom({ room: '${value}' })\n` + `The room has been stored and will be used if not specified in the method call.`); this._pendingRoomID = value; this.room = this._sanitizeRoomName(value); }, configurable: true }); Object.defineProperty(this, 'label', { get: function() { return this._pendingLabel; }, set: function(value) { console.warn(`[VDONinja SDK] Setting label as a property is not recommended.\n` + `Please use the label option in publish() or announce() methods:\n` + ` await vdo.publish(stream, { label: '${value}' })\n` + ` await vdo.announce({ label: '${value}' })\n` + `The label has been stored and will be used if not specified in the method call.`); this._pendingLabel = value; }, configurable: true }); } // ============================================================================ // CONNECTION MANAGEMENT // ============================================================================ /** * Connect to the signaling server * @param {Object} options - Connection options * @returns {Promise} Resolves when connected */ async connect(options = {}) { // Initialize required properties if missing if (!this.connections) this.connections = new Map(); if (!this.state) this.state = {}; if (!this._viewHandlers) this._viewHandlers = new Map(); if (!this._sessionIDs) this._sessionIDs = {}; if (!this._remoteSessionIDs) this._remoteSessionIDs = {}; if (!this._streamToUUID) this._streamToUUID = {}; if (!this._pendingViews) this._pendingViews = new Map(); if (!this.messageHandlers) this.messageHandlers = new Map(); if (!this.streams) this.streams = new Map(); if (!this._viewRetryTimers) this._viewRetryTimers = new Map(); if (this.state.connected) { this._log('Already connected'); return; } this._intentionalDisconnect = false; // Merge options if (options.host) this.host = options.host; if (options.room) this.room = this._sanitizeRoomName(options.room); else if (this._pendingRoomID) this.room = this._sanitizeRoomName(this._pendingRoomID); // Sanitize and apply password only if explicitly provided if (options.password !== undefined) { if (options.password === false) { this.password = false; } else if (options.password === null || options.password === '') { this.password = this._sanitizePassword("someEncryptionKey123"); } else { this.password = this._sanitizePassword(options.password); } } return new Promise((resolve, reject) => { try { this.signaling = new WebSocket(this.host); this.signaling.onopen = () => { this._log('WebSocket connected'); this.state.connected = true; this._reconnectAttempts = 0; this._emit('connected'); resolve(); }; this.signaling.onmessage = async (event) => { try { const msg = JSON.parse(event.data); this._logMessage('IN', msg, 'WebSocket'); await this._handleSignalingMessage(msg); } catch (error) { this._log('Error parsing message:', error); } }; this.signaling.onerror = (error) => { this._log('WebSocket error:', error); this._emit('error', { error: 'WebSocket error', details: error }); reject(error); }; this.signaling.onclose = () => { this._log('WebSocket closed'); this.state.connected = false; // Reset per-connection states this.state.roomJoined = false; this.state.publishing = false; this._emit('disconnected'); if (!this._intentionalDisconnect && this._reconnectAttempts < this._maxReconnectAttempts) { this._attemptReconnect(); } }; } catch (error) { this._log('Connection error:', error); reject(error); } }); } /** * Disconnect from the signaling server */ disconnect() { this._log('Disconnecting...'); this._intentionalDisconnect = true; // Clear reconnect timer if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } // Send bye message to all connected peers via data channels this._log('Connections count:', this.connections.size); const byePromises = []; for (const [uuid, connections] of this.connections) { for (const type of ['viewer', 'publisher']) { const connection = connections[type]; if (!connection) continue; this._log(`Connection ${uuid}:${type}: dataChannel=${!!connection.dataChannel}, state=${connection.dataChannel?.readyState}`); if (connection.dataChannel && connection.dataChannel.readyState === 'open') { try { const byeMsg = { bye: true }; // Send directly through the data channel connection.dataChannel.send(JSON.stringify(byeMsg)); this._log('Sent bye message to:', uuid, 'type:', type); // Create a promise that resolves when bufferedAmount reaches 0 const flushPromise = new Promise((resolve) => { const checkBuffer = () => { if (!connection.dataChannel || connection.dataChannel.readyState !== 'open' || connection.dataChannel.bufferedAmount === 0) { resolve(); } else { setTimeout(checkBuffer, 10); } }; checkBuffer(); }); // Add timeout to prevent hanging const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 100)); byePromises.push(Promise.race([flushPromise, timeoutPromise])); } catch (error) { this._log('Error sending bye message:', error); } } } } // Wait for all bye messages to be sent or timeout Promise.all(byePromises).then(() => { // Close all peer connections for (const [uuid, connections] of this.connections) { for (const type of ['viewer', 'publisher']) { const connection = connections[type]; if (connection) { // Stop ping monitoring this._stopPingMonitoring(connection); // Close peer connection if (connection.pc) { connection.pc.close(); } } } } this.connections.clear(); // Clear all retry timers for (const [streamID, timer] of this._viewRetryTimers) { clearTimeout(timer); } this._viewRetryTimers.clear(); // Close WebSocket if (this.signaling) { this.signaling.close(); this.signaling = null; } // Reset state this.state = { connected: false, room: null, streamID: null, uuid: null, roomJoined: false, publishing: false }; this._emit('disconnected'); }); } /** * Attempt to reconnect to the signaling server * @private */ async _attemptReconnect() { if (this._isReconnecting) return; this._isReconnecting = true; this._reconnectAttempts++; const delay = Math.min(this._reconnectDelay * Math.pow(2, this._reconnectAttempts - 1), 30000); this._log(`Attempting reconnection ${this._reconnectAttempts}/${this._maxReconnectAttempts} in ${delay}ms`); this._emit('reconnecting', { attempt: this._reconnectAttempts, maxAttempts: this._maxReconnectAttempts }); this._reconnectTimer = setTimeout(async () => { try { await this.connect(); // Rejoin room if we were in one if (this.state.room) { await this.joinRoom({ room: this.state.room, password: this.password }); } // Re-publish if we were publishing if (this.state.publishing && this.localStream) { await this.publish(this.localStream, { streamID: this.state.streamID }); } this._emit('reconnected'); this._isReconnecting = false; } catch (error) { this._log('Reconnection failed:', error); this._isReconnecting = false; if (this._reconnectAttempts >= this._maxReconnectAttempts) { this._emit('reconnectFailed'); } else { this._attemptReconnect(); } } }, delay); } // ============================================================================ // STREAM TRACKING // ============================================================================ /** * Get all tracked streams * @returns {Array} Array of stream objects with metadata */ getStreams() { const streams = []; const now = Date.now(); for (const [streamID, data] of this.streams) { streams.push({ streamID, firstSeen: data.firstSeen, lastSeen: data.lastSeen, timeSinceLastSeen: now - data.lastSeen, uuid: data.uuid, state: data.state, viewRequestTime: data.viewRequestTime, waitingTime: data.state === 'pending' && data.viewRequestTime ? now - data.viewRequestTime : null }); } return streams.sort((a, b) => b.lastSeen - a.lastSeen); } /** * Get info about a specific stream * @param {string} streamID - Stream ID to look up * @returns {Object|null} Stream info or null if not found */ getStreamInfo(streamID) { const data = this.streams.get(streamID); if (!data) return null; const now = Date.now(); return { streamID, firstSeen: data.firstSeen, lastSeen: data.lastSeen, timeSinceLastSeen: now - data.lastSeen, uuid: data.uuid, state: data.state, viewRequestTime: data.viewRequestTime, waitingTime: data.state === 'pending' && data.viewRequestTime ? now - data.viewRequestTime : null }; } // ============================================================================ // ROOM MANAGEMENT // ============================================================================ /** * Join a room * @param {Object} options - Room options * @param {string} options.room - Room name * @param {string|null} options.password - Room password (null to disable hashing) * @returns {Promise} Resolves when room is joined */ async joinRoom(options = {}) { if (!this.state.connected) { throw new Error('Not connected to signaling server'); } // Prevent multiple room joins per connection if (this.state.roomJoined) { this._log('Already joined a room on this connection'); return Promise.resolve(); } const room = this._sanitizeRoomName(options.room || this.room); const password = this._sanitizePassword(options.password !== undefined ? options.password : this.password); if (!room) { throw new Error('Room name is required'); } // Store password for later use, converting empty string to default if (password === '') { this.password = this._sanitizePassword("someEncryptionKey123"); } else { this.password = password; } // Hash room name if password is not explicitly false let hashedRoom = room; const __effectivePasswordForRoom = this._getEffectivePassword(); if (__effectivePasswordForRoom !== null) { hashedRoom = await this._hashRoom(room, __effectivePasswordForRoom); } this._log('Joining room:', room, 'with hash:', hashedRoom); // Join room without streamID in the message const joinMessage = { request: "joinroom", roomid: hashedRoom }; // Add claim if specified if (options.claim) { joinMessage.claim = true; } this._sendMessageWS(joinMessage); // Wait for room join confirmation (listing message) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Room join timeout')); }, 10000); const handleListing = (event) => { clearTimeout(timeout); this.removeEventListener('_roomJoined', handleListing); this.state.room = room; this.state.roomJoined = true; this._emit('roomJoined', { room }); resolve(); }; this.addEventListener('_roomJoined', handleListing); }); } /** * Leave the current room */ leaveRoom() { if (!this.state.room) { this._log('Not in a room'); return; } // Send leave message this._sendMessageWS({ leave: true }); const previousRoom = this.state.room; this.state.room = null; this.state.roomJoined = false; this._emit('roomLeft', { room: previousRoom }); } // ============================================================================ // PUBLISHING // ============================================================================ /** * Publish a media stream * @param {MediaStream} stream - The media stream to publish * @param {Object} options - Publishing options * @returns {Promise} Resolves when publishing starts */ async publish(stream, options = {}) { if (!this.state.connected) { throw new Error('Not connected to signaling server'); } if (!stream || !(stream instanceof MediaStream)) { throw new Error('Valid MediaStream required'); } // Prevent multiple publishes per connection if (this.state.publishing) { throw new Error('Already publishing on this connection. Disconnect and reconnect to publish a different stream.'); } this.localStream = stream; // Use provided streamID, fall back to pending value from constructor/property, then generate const streamID = this._sanitizeStreamID(options.streamID || this._pendingStreamID) || this._generateStreamID(); // Persist label if provided for downstream DC open if (options.label) this._pendingLabel = this._sanitizeLabel(options.label); // Capture optional info fields for publisher this._pendingInfo = this._pendingInfo || {}; if (options.label) this._pendingInfo.label = this._sanitizeLabel(options.label); if (options.meta) this._pendingInfo.meta = this._sanitizeLabel(options.meta); if (options.order) this._pendingInfo.order = this._sanitizeLabel(options.order); if (typeof options.broadcast === 'boolean') this._pendingInfo.broadcast = !!options.broadcast; if (typeof options.allowdrawing === 'boolean') this._pendingInfo.allowdrawing = !!options.allowdrawing; if (typeof options.iframe === 'boolean') this._pendingInfo.iframe = !!options.iframe; if (typeof options.widget === 'boolean') this._pendingInfo.widget = !!options.widget; if (typeof options.allowmidi === 'boolean') this._pendingInfo.allowmidi = !!options.allowmidi; if (typeof options.allowresources === 'boolean') this._pendingInfo.allowresources = !!options.allowresources; if (typeof options.allowchunked === 'boolean' || typeof options.allowchunked === 'number') this._pendingInfo.allowchunked = options.allowchunked; if (options.info && typeof options.info === 'object') { const inf = options.info; if (inf.label) { this._pendingInfo.label = this._sanitizeLabel(inf.label); this._pendingLabel = this._pendingInfo.label; } if (inf.meta) this._pendingInfo.meta = this._sanitizeLabel(inf.meta); if (inf.order) this._pendingInfo.order = this._sanitizeLabel(inf.order); if (typeof inf.broadcast === 'boolean') this._pendingInfo.broadcast = !!inf.broadcast; if (typeof inf.allowdrawing === 'boolean') this._pendingInfo.allowdrawing = !!inf.allowdrawing; if (typeof inf.iframe === 'boolean') this._pendingInfo.iframe = !!inf.iframe; if (typeof inf.widget === 'boolean') this._pendingInfo.widget = !!inf.widget; if (typeof inf.allowmidi === 'boolean') this._pendingInfo.allowmidi = !!inf.allowmidi; if (typeof inf.allowresources === 'boolean') this._pendingInfo.allowresources = !!inf.allowresources; if (typeof inf.allowchunked === 'boolean' || typeof inf.allowchunked === 'number') this._pendingInfo.allowchunked = inf.allowchunked; } // Handle room join if needed if (!this.state.roomJoined && options.room) { await this.joinRoom({ room: options.room, password: options.password !== undefined ? options.