@vdoninja/sdk
Version:
AI-friendly P2P communication SDK for audio, video, and data. Includes WHIP/WHEP clients for publishing to Twitch, Meshcast, Cloudflare Stream
608 lines (523 loc) • 18.3 kB
JavaScript
// Copyright (c) 2025-2026 Steve Seguin
// SPDX-License-Identifier: MIT
// WHEP Client v1.0.0
// WebRTC-HTTP Egress Protocol client for consuming media streams
// Part of the VDO.Ninja SDK project - https://github.com/steveseguin/ninjasdk
// See LICENSE-MIT for details.
// CustomEvent polyfill for Node.js (native in Node 19+, browsers have it)
if (typeof CustomEvent === 'undefined') {
global.CustomEvent = class CustomEvent extends Event {
constructor(type, options = {}) {
super(type, options);
this.detail = options.detail || null;
}
};
}
/**
* WHEPClient - Consume media streams from WHEP-compatible endpoints
*
* WHEP (WebRTC-HTTP Egress Protocol) is an IETF standard for consuming
* media from WebRTC-based platforms using simple HTTP signaling.
*
* Compatible endpoints include:
* - Cloudflare Stream
* - Meshcast.io
* - Dolby.io
* - Any WHEP-compliant media server
*
* @example
* const client = new WHEPClient('https://example.com/whep/stream123');
* client.addEventListener('track', (e) => {
* video.srcObject = e.detail.streams[0];
* });
* await client.view();
* // Later...
* await client.stop();
*/
class WHEPClient extends EventTarget {
/**
* Create a new WHEP client
* @param {string} endpoint - The WHEP endpoint URL
* @param {Object} [options] - Configuration options
* @param {string} [options.authToken] - Bearer token for authentication
* @param {Object} [options.headers] - Additional HTTP headers
* @param {RTCConfiguration} [options.iceServers] - Custom ICE servers
* @param {boolean} [options.audio=true] - Request audio track
* @param {boolean} [options.video=true] - Request video track
* @param {boolean} [options.trickleIce=true] - Enable trickle ICE
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(endpoint, options = {}) {
super();
if (!endpoint) {
throw new Error('WHEP endpoint URL is required');
}
this.endpoint = endpoint;
this.options = {
authToken: null,
headers: {},
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' }
],
audio: true,
video: true,
trickleIce: true,
debug: false,
...options
};
this.pc = null;
this.resourceUrl = null;
this.etag = null;
this.state = 'idle'; // idle, connecting, connected, disconnected, error
this._pendingCandidates = [];
this._candidateTimer = null;
this._stream = null;
}
/**
* Log debug messages
* @private
*/
_log(...args) {
if (this.options.debug) {
console.log('[WHEPClient]', ...args);
}
}
/**
* Emit an event
* @private
*/
_emit(type, detail = {}) {
this.dispatchEvent(new CustomEvent(type, { detail }));
}
/**
* Start viewing the stream from the WHEP endpoint
* @returns {Promise<MediaStream>} The received media stream
*/
async view() {
if (this.state !== 'idle') {
throw new Error(`Cannot view in state: ${this.state}`);
}
this.state = 'connecting';
this._emit('connecting');
this._log('Connecting to WHEP endpoint:', this.endpoint);
try {
// Create peer connection
this.pc = new RTCPeerConnection({
iceServers: this.options.iceServers,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// Initialize stream upfront so callers always get a valid MediaStream
// even if tracks arrive after view() returns (common with slow WHEP endpoints)
this._stream = new MediaStream();
// Set up event handlers
this._setupPeerConnectionHandlers();
// Add transceivers for receiving media
if (this.options.audio) {
this.pc.addTransceiver('audio', { direction: 'recvonly' });
this._log('Added audio transceiver');
}
if (this.options.video) {
this.pc.addTransceiver('video', { direction: 'recvonly' });
this._log('Added video transceiver');
}
// Create offer
const offer = await this.pc.createOffer();
this._log('Created offer');
await this.pc.setLocalDescription(offer);
// Wait for ICE gathering if not using trickle ICE
let sdp = offer.sdp;
if (!this.options.trickleIce) {
await this._waitForIceGathering();
sdp = this.pc.localDescription.sdp;
}
// Send offer to WHEP endpoint
const response = await this._sendOffer(sdp);
// Set remote description
await this.pc.setRemoteDescription({
type: 'answer',
sdp: response.sdp
});
this._log('Remote description set');
// Wait for stream to be ready
await this._waitForStream();
this.state = 'connected';
this._emit('connected', { resourceUrl: this.resourceUrl, stream: this._stream });
this._log('Connected successfully');
return this._stream;
} catch (error) {
this.state = 'error';
this._emit('error', { error });
this._log('View failed:', error);
await this.stop();
throw error;
}
}
/**
* Set up peer connection event handlers
* @private
*/
_setupPeerConnectionHandlers() {
// Handle incoming tracks
this.pc.ontrack = (event) => {
this._log('Received track:', event.track.kind, event.track.id);
this._stream.addTrack(event.track);
this._emit('track', {
track: event.track,
streams: event.streams,
transceiver: event.transceiver
});
};
this.pc.onicecandidate = (event) => {
// Always buffer candidates when trickle ICE is enabled
// They will be sent once resourceUrl is available
if (event.candidate && this.options.trickleIce) {
this._queueCandidate(event.candidate);
}
};
this.pc.oniceconnectionstatechange = () => {
if (!this.pc) return;
this._log('ICE state:', this.pc.iceConnectionState);
this._emit('icestate', { state: this.pc.iceConnectionState });
if (this.pc.iceConnectionState === 'failed') {
this._emit('error', { error: new Error('ICE connection failed') });
}
if (this.pc.iceConnectionState === 'disconnected') {
this._emit('disconnected');
}
};
this.pc.onconnectionstatechange = () => {
if (!this.pc) return;
this._log('Connection state:', this.pc.connectionState);
this._emit('connectionstate', { state: this.pc.connectionState });
};
}
/**
* Wait for stream to have at least one track
* Resolves immediately when the first track arrives (don't wait for all requested tracks
* since many endpoints only provide video or audio, not both)
* @private
*/
_waitForStream() {
return new Promise((resolve) => {
// Resolve immediately if we already have any track
if (this._stream && this._stream.getTracks().length > 0) {
resolve();
return;
}
let resolved = false;
const done = () => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
resolve();
};
// Short timeout - if no tracks arrive quickly, proceed anyway
// (the connection may still be establishing or the endpoint may not have media yet)
const timeout = setTimeout(() => {
this._log('Stream wait timed out, proceeding');
done();
}, 3000);
// Resolve as soon as we get ANY track
const onTrack = () => {
this._log('First track received, stream ready');
this.removeEventListener('track', onTrack);
done();
};
this.addEventListener('track', onTrack);
});
}
/**
* Queue ICE candidate for batch sending
* @private
*/
_queueCandidate(candidate) {
this._pendingCandidates.push(candidate);
// Only start the send timer if we have a resource URL
// Otherwise candidates will be flushed when resourceUrl becomes available
if (this.resourceUrl) {
if (this._candidateTimer) {
clearTimeout(this._candidateTimer);
}
this._candidateTimer = setTimeout(() => {
this._sendCandidates();
}, 50);
}
}
/**
* Flush any buffered ICE candidates
* Called after resourceUrl becomes available
* @private
*/
_flushCandidates() {
if (this._pendingCandidates.length > 0 && this.resourceUrl) {
this._log('Flushing', this._pendingCandidates.length, 'buffered ICE candidates');
// Small delay to batch any candidates that arrive right after
if (this._candidateTimer) {
clearTimeout(this._candidateTimer);
}
this._candidateTimer = setTimeout(() => {
this._sendCandidates();
}, 50);
}
}
/**
* Send queued ICE candidates via PATCH
* @private
*/
async _sendCandidates() {
if (!this._pendingCandidates.length || !this.resourceUrl) {
return;
}
const candidates = this._pendingCandidates.splice(0);
this._log('Sending', candidates.length, 'ICE candidates');
// Format candidates as SDP fragment
let fragment = 'a=ice-ufrag:' + this._extractIceCredentials().ufrag + '\r\n';
fragment += 'a=ice-pwd:' + this._extractIceCredentials().pwd + '\r\n';
for (const candidate of candidates) {
fragment += 'a=' + candidate.candidate + '\r\n';
}
try {
const headers = {
'Content-Type': 'application/trickle-ice-sdpfrag',
...this._getAuthHeaders()
};
if (this.etag) {
headers['If-Match'] = this.etag;
}
const response = await fetch(this.resourceUrl, {
method: 'PATCH',
headers,
body: fragment
});
if (!response.ok && response.status !== 204) {
this._log('PATCH failed:', response.status);
}
} catch (error) {
this._log('Failed to send candidates:', error);
}
}
/**
* Extract ICE credentials from local description
* @private
*/
_extractIceCredentials() {
const sdp = this.pc.localDescription?.sdp || '';
const ufragMatch = sdp.match(/a=ice-ufrag:(.+)/);
const pwdMatch = sdp.match(/a=ice-pwd:(.+)/);
return {
ufrag: ufragMatch ? ufragMatch[1].trim() : '',
pwd: pwdMatch ? pwdMatch[1].trim() : ''
};
}
/**
* Wait for ICE gathering to complete
* @private
*/
_waitForIceGathering() {
return new Promise((resolve) => {
if (this.pc.iceGatheringState === 'complete') {
resolve();
return;
}
const checkState = () => {
if (this.pc.iceGatheringState === 'complete') {
this.pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
};
this.pc.addEventListener('icegatheringstatechange', checkState);
// Timeout after 5 seconds
setTimeout(() => {
this.pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}, 5000);
});
}
/**
* Get authentication headers
* @private
*/
_getAuthHeaders() {
const headers = { ...this.options.headers };
if (this.options.authToken) {
headers['Authorization'] = `Bearer ${this.options.authToken}`;
}
return headers;
}
/**
* Send SDP offer to WHEP endpoint
* @private
*/
async _sendOffer(sdp) {
this._log('Sending offer to', this.endpoint);
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
...this._getAuthHeaders()
},
body: sdp
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`WHEP request failed: ${response.status} ${response.statusText} ${errorText}`);
}
// Get resource URL from Location header
const location = response.headers.get('Location');
if (location) {
this.resourceUrl = new URL(location, this.endpoint).href;
this._log('Resource URL:', this.resourceUrl);
// Flush any ICE candidates that were buffered before resourceUrl was available
this._flushCandidates();
}
// Get ETag for subsequent requests
this.etag = response.headers.get('ETag');
// Parse Link header for ICE servers
const linkHeader = response.headers.get('Link');
if (linkHeader) {
this._parseIceServersFromLink(linkHeader);
}
const answerSdp = await response.text();
return { sdp: answerSdp };
}
/**
* Parse ICE servers from Link header
* @private
*/
_parseIceServersFromLink(linkHeader) {
const links = linkHeader.split(',');
const iceServers = [];
for (const link of links) {
const match = link.match(/<([^>]+)>.*rel="ice-server"/);
if (match) {
iceServers.push({ urls: match[1] });
}
}
if (iceServers.length > 0) {
this._log('Received ICE servers from Link header:', iceServers);
}
}
/**
* Get the received media stream
* @returns {MediaStream|null}
*/
getStream() {
return this._stream;
}
/**
* Get connection statistics
* @returns {Promise<RTCStatsReport>}
*/
async getStats() {
if (!this.pc) {
throw new Error('Not connected');
}
return this.pc.getStats();
}
/**
* Mute/unmute received audio locally
* @param {boolean} muted - Whether to mute
*/
muteAudio(muted) {
if (this._stream) {
for (const track of this._stream.getAudioTracks()) {
track.enabled = !muted;
}
}
}
/**
* Mute/unmute received video locally
* @param {boolean} muted - Whether to mute
*/
muteVideo(muted) {
if (this._stream) {
for (const track of this._stream.getVideoTracks()) {
track.enabled = !muted;
}
}
}
/**
* Stop viewing and clean up
* @returns {Promise<void>}
*/
async stop() {
this._log('Stopping');
// Clear candidate timer
if (this._candidateTimer) {
clearTimeout(this._candidateTimer);
this._candidateTimer = null;
}
// Send DELETE to resource URL
if (this.resourceUrl) {
try {
await fetch(this.resourceUrl, {
method: 'DELETE',
headers: this._getAuthHeaders()
});
this._log('Sent DELETE to resource URL');
} catch (error) {
this._log('DELETE failed:', error);
}
}
// Stop all tracks
if (this._stream) {
for (const track of this._stream.getTracks()) {
track.stop();
}
}
// Close peer connection
if (this.pc) {
this.pc.close();
this.pc = null;
}
this.resourceUrl = null;
this.etag = null;
this._pendingCandidates = [];
this._stream = null;
this.state = 'idle'; // Reset to idle so client can be reused
this._emit('stopped');
}
/**
* Restart ICE (if supported by the server)
* @returns {Promise<void>}
*/
async restartIce() {
if (!this.pc || !this.resourceUrl) {
throw new Error('Not connected');
}
this._log('Restarting ICE');
const offer = await this.pc.createOffer({ iceRestart: true });
await this.pc.setLocalDescription(offer);
if (!this.options.trickleIce) {
await this._waitForIceGathering();
}
const headers = {
'Content-Type': 'application/sdp',
...this._getAuthHeaders()
};
if (this.etag) {
headers['If-Match'] = this.etag;
}
const response = await fetch(this.resourceUrl, {
method: 'PATCH',
headers,
body: this.pc.localDescription.sdp
});
if (!response.ok) {
throw new Error(`ICE restart failed: ${response.status}`);
}
const answerSdp = await response.text();
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
this.etag = response.headers.get('ETag') || this.etag;
this._log('ICE restarted');
}
}
// Export for different module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = WHEPClient;
}
if (typeof window !== 'undefined') {
window.WHEPClient = WHEPClient;
}