UNPKG

@vdoninja/sdk

Version:

AI-friendly P2P communication SDK for audio, video, and data. Includes WHIP/WHEP clients for publishing to Twitch, Meshcast, Cloudflare Stream

1,072 lines (956 loc) 398 kB
/*! VDO.Ninja SDK v1.5.5 * Copyright (c) 2025-2026 Steve Seguin. All rights reserved. * SPDX-License-Identifier: MPL-2.0 * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ const MEDIA_STREAM_TRACK_ENABLED_DESCRIPTOR = (typeof MediaStreamTrack !== 'undefined' && MediaStreamTrack?.prototype) ? Object.getOwnPropertyDescriptor(MediaStreamTrack.prototype, 'enabled') : null; const OUTBOUND_VIDEO_STOP_MUTE_DELAY_MS = 500; /** * VDO.Ninja data-channel labels. * * VDO.Ninja opens up to four kinds of data channel per peer and routes incoming * channels by label (see webrtc.js `ondatachannel`). Only CONTROL carries the JSON * signaling/control protocol; the rest are binary side-channels. Any label that is * not CONTROL, CHUNKED or RESOURCES is treated as a file transfer, where the label * is the file ID. */ const VDON_CHANNEL_CONTROL = 'sendChannel'; const VDON_CHANNEL_CHUNKED = 'chunked'; const VDON_CHANNEL_RESOURCES = 'resources'; /** * Labels beginning with this prefix are reserved for third-party SDK channels. * * VDO.Ninja ignores them in both `ondatachannel` handlers rather than feeding them to * its file-transfer receiver (webrtc.js `isReservedChannelLabel`, since 2026-07-25). * The prefix is collision-proof because every label either side opens is alphanumeric — * `generateStreamID` here and there both exclude punctuation — or a known literal. * * The SDK must honour the same reservation: an `x-` channel is never a file transfer, * and a hosted file ID must never start with it. */ const VDON_RESERVED_CHANNEL_PREFIX = 'x-'; /** * Default lane for sendBinary(). * * Binary must never go on the control channel: VDO.Ninja treats any object payload there * as a WebP image frame and renders it into an <img> (webrtc.js:21219), so raw bytes would * visibly corrupt a viewer. A reserved label is ignored by VDO.Ninja instead. */ const VDON_CHANNEL_BINARY = 'x-bin'; /** Below this many bytes buffered, a channel is considered drained. */ const VDON_DEFAULT_BUFFER_LOW = 262144; // 256KB /** Above this many bytes buffered, senders should wait. */ const VDON_DEFAULT_BUFFER_HIGH = 1048576; // 1MB // VDO.Ninja's file transfer framing (lib.js `sendFile` / webrtc.js `recieveFile`). const VDON_FILE_CHUNK_SIZE = 16384; const VDON_FILE_EOF_COMPLETE = 'EOF1'; const VDON_FILE_EOF_CANCELLED = 'EOF2'; // VDO.Ninja's resource framing (lib.js `processResourceQueue`). const VDON_RESOURCE_CHUNK_SIZE = 16384; /** * VDO.Ninja SDK - OFFICIAL SDK FOR VDO.NINJA WEBSOCKET API * * HOSTED SERVICE USAGE NOTICE: * ============================ * These operational rules apply when using infrastructure or services operated * by Steve Seguin. They are separate from the MPL-2.0 software license. * * 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 * * Access to Steve-operated services is governed by the applicable Terms of * Service and operational policies. Abuse may result in access being limited, * suspended, or withdrawn. * * 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 MPL-2.0 */ (function (global) { 'use strict'; class VDONinjaSDK extends EventTarget { /** * SDK Version * @static * @returns {string} Current SDK version */ static get VERSION() { return '1.5.5'; } /** * 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 */ /** * Sanitize a publisher `meta` payload while preserving its shape. * * `meta` is not just a label. VDO.Ninja accepts it only when it is an **object** * (webrtc.js:22099) and stores it as `session.rpcs[UUID].meta`, keyed by template * name. The resources channel then fills in each entry's `value` with an object * URL. Passing meta through the string sanitizer collapsed objects to "", so * VDO.Ninja set `meta = false` and silently dropped every resource. * * Strings still sanitize exactly as before, so existing callers are unaffected. * * @private * @param {string|Object} meta * @param {number} [depth=0] * @returns {string|Object|null} */ _sanitizeMeta(meta, depth = 0) { if (typeof meta === 'string') return this._sanitizeLabel(meta); if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; if (depth > 1) return null; const out = {}; let fields = 0; for (const key of Object.keys(meta)) { if (fields++ >= 32) break; const safeKey = this._sanitizeLabel(key); if (!safeKey) continue; const value = meta[key]; if (typeof value === 'string') { out[safeKey] = this._sanitizeLabel(value); } else if (typeof value === 'number' || typeof value === 'boolean') { out[safeKey] = value; } else if (value && typeof value === 'object' && !Array.isArray(value)) { const nested = this._sanitizeMeta(value, depth + 1); if (nested && typeof nested === 'object') out[safeKey] = nested; } } return out; } _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) * @param {boolean} options.autoRecover - Recover failed peer directions automatically (default: true) * @param {boolean} options.autoRelay - Temporarily escalate failed direct paths to TURN (default: true) * @param {number} options.disconnectGracePeriod - ICE disconnected grace period in ms (default: 5000) * @param {number} options.connectionTimeout - Initial peer connection timeout in ms (default: 20000) * @param {number} options.recoveryTimeout - Wait between recovery phases in ms (default: 12000) * @param {number} options.relayRestoreDelay - Delay before restoring direct-first ICE policy (default: 45000) * @param {number} options.signalingQueueLimit - Newest HSS messages retained while offline (default: 30) * @param {number} options.pendingIceTTL - Maximum pre-PC ICE age in ms (default: 15000) * @param {number} options.pendingIceMaxPerPeer - Candidate cap per UUID/direction (default: 100) * @param {number} options.pendingIceMaxKeys - Global UUID/direction queue cap (default: 500) */ 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._sanitizeMeta(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._sanitizeMeta(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 // Preferred media configuration for outgoing WebRTC tracks this._publishMediaConfig = null; // Convenience event aliases for common patterns (Node-style) // sdk.on('event', handler), sdk.off('event', handler), sdk.once('event', handler) this.on = (evt, handler) => { try { this.addEventListener(evt, handler); } catch (e) {} return this; }; this.off = (evt, handler) => { try { this.removeEventListener(evt, handler); } catch (e) {} return this; }; this.once = (evt, handler) => { try { const wrap = (e) => { this.removeEventListener(evt, wrap); handler(e); }; this.addEventListener(evt, wrap); } catch (e) {} return this; }; // 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._teardownPromise = null; // In-flight or most recently completed explicit teardown this._passwordHash = null; // Cached hash for streamID this._passwordHashPromise = null; // Tracks in-flight hash computation this._passwordHashKey = null; // Password+salt signature for cached hash this._passwordHashPromiseKey = null; // Signature for in-flight hash 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; this._signalingQueue = []; this._signalingQueueLimit = Number.isFinite(options.signalingQueueLimit) ? Math.max(1, Math.floor(options.signalingQueueLimit)) : 30; // Match VDO.Ninja's bounded pre-PC ICE handling. Candidates can beat // their SDP over either WSS or an established data channel. this._pendingIceCandidates = new Map(); this._pendingIceLastSweep = 0; this._pendingIceSweepMinMs = 2000; this._pendingIceTTL = Number.isFinite(options.pendingIceTTL) ? Math.max(1000, options.pendingIceTTL) : 15000; this._pendingIceMaxPerPeer = Number.isFinite(options.pendingIceMaxPerPeer) ? Math.max(1, Math.floor(options.pendingIceMaxPerPeer)) : 100; this._pendingIceMaxKeys = Number.isFinite(options.pendingIceMaxKeys) ? Math.max(1, Math.floor(options.pendingIceMaxKeys)) : 500; // Desired application state is kept separately from transient transport // state. A WebSocket reconnect gets a new HSS UUID, so room, seed, and // play intent must be replayed using the existing VDO.Ninja protocol. this._connectionIntent = { room: null, publishing: null, views: new Map() }; this._stoppedViews = new Set(); this._restoringIntent = false; // Peer recovery is local-only and uses the existing SDP/ICE messages. this.autoRecover = options.autoRecover !== false; this.autoRelay = options.autoRelay !== false; this.disconnectGracePeriod = Number.isFinite(options.disconnectGracePeriod) ? Math.max(0, options.disconnectGracePeriod) : 5000; this.recoveryTimeout = Number.isFinite(options.recoveryTimeout) ? Math.max(1000, options.recoveryTimeout) : 12000; this.connectionTimeout = Number.isFinite(options.connectionTimeout) ? Math.max(1000, options.connectionTimeout) : 20000; this.relayRestoreDelay = Number.isFinite(options.relayRestoreDelay) ? Math.max(0, options.relayRestoreDelay) : 45000; // Internal flags this._isReconnecting = false; this._intentionalDisconnect = false; // View retry mechanism this._viewRetryTimers = new Map(); this._viewRetryInterval = 15 * 60 * 1000; // 15 minutes // Track monitoring for outbound video tracks this._outboundVideoMonitors = new Map(); this._pendingVideoMuteFinalizers = new Set(); // 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 = {}) { // An explicit disconnect owns the current socket and peer generation until // its close event has fired. Waiting here prevents a late close from the old // socket from marking a newly connected generation as disconnected. if (this._teardownPromise) { const pendingTeardown = this._teardownPromise; await pendingTeardown; if (this._teardownPromise === pendingTeardown) { this._teardownPromise = null; } } // 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._signalingQueue) this._signalingQueue = []; if (!this._pendingIceCandidates) this._pendingIceCandidates = new Map(); if (this.state.connected) { this._log('Already connected'); return; } this._intentionalDisconnect = false; // Merge options if (options.host || options.wss) this.host = options.host || options.wss; 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); } } // Handle common-but-ignored options gracefully with guidance if (options.datamode !== undefined) { console.warn('[VDONinja SDK] connect({ datamode }) is not used. Establish data channels via announce() (publisher) and/or view() (viewer).'); this._emit('alert', { message: 'connect({ datamode }) has no effect. Use announce()/view() to create data channels.' }); } return new Promise((resolve, reject) => { try { const signaling = new WebSocket(this.host); this.signaling = signaling; signaling.onopen = () => { if (this.signaling !== signaling) return; this._log('WebSocket connected'); this.state.connected = true; if (!this._isReconnecting) { this._reconnectAttempts = 0; } // VDO.Ninja flushes its bounded signaling queue before // reconnect-specific room/seed/play restoration. this._flushSignalingQueue(); this._emit('connected'); this._emitIframeCompatible('hss-connection', 'connected'); resolve(); }; signaling.onmessage = async (event) => { if (this.signaling !== signaling) return; try { const msg = JSON.parse(event.data); this._logMessage('IN', msg, 'WebSocket'); await this._handleSignalingMessage(msg); } catch (error) { this._log('Error parsing message:', error); } }; signaling.onerror = (error) => { if (this.signaling !== signaling) return; this._log('WebSocket error:', error); this._emit('error', { error: 'WebSocket error', details: error }); reject(error); }; signaling.onclose = () => { // A superseded socket must never mutate the state of the current // connection generation. This also makes a forced-close fallback // harmless if its close event arrives unusually late. if (this.signaling !== signaling) return; this._log('WebSocket closed'); this.state.connected = false; // These describe the current socket generation. Desired room, // publishing, and viewing state remains in _connectionIntent. this.state.roomJoined = false; this.state.publishing = false; const intentional = !!this._intentionalDisconnect; const willReconnect = !intentional && this._reconnectAttempts < this._maxReconnectAttempts; // This fires when the socket closes, which is not the same thing // as teardown being finished. Listeners that need "cleanup is // done" should use 'teardownComplete' instead. this._emit('disconnected', { intentional: intentional, reason: intentional ? 'local-disconnect' : 'socket-closed', willReconnect: willReconnect, phase: 'socket' }); this._emitIframeCompatible('hss-connection', 'closed'); if (willReconnect) { this._attemptReconnect(); } }; } catch (error) { this._log('Connection error:', error); reject(error); } }); } /** * Disconnect from the signaling server and tear down all peers. * * Returns a promise that resolves when cleanup has genuinely finished: bye * messages flushed, peer connections closed, timers cleared, socket closed. Exiting * the process before that resolves can crash the native WebRTC module mid-teardown. * * The returned promise is new in v1.5. Callers that ignore it behave exactly as * before. The `disconnected` event is not a completion signal — it also fires when * the socket closes, which happens partway through. Use the resolved promise or * the `teardownComplete` event. * * @returns {Promise<void>} Resolves once teardown is complete */ disconnect() { this._log('Disconnecting...'); // Calling disconnect() twice should not run teardown twice. if (this._teardownPromise) return this._teardownPromise; this._intentionalDisconnect = true; // disconnect() has always represented a full local teardown. Do not // restore room/publish/view intent after a later explicit connect(). if (this._connectionIntent) { this._connectionIntent.room = null; this._connectionIntent.publishing = null; if (this._connectionIntent.views) this._connectionIntent.views.clear(); } if (this._stoppedViews) this._stoppedViews.clear(); if (this._signalingQueue) this._signalingQueue.length = 0; if (this._pendingIceCandidates) this._pendingIceCandidates.clear(); if (this._failedViewerConnections) { for (const failed of this._failedViewerConnections.values()) { if (failed && failed.timer) clearTimeout(failed.timer); } this._failedViewerConnections.clear(); } // Clear reconnect timer if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._isReconnecting = false; this._restoringIntent = false; // 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]));