1+ /*
2+ Based on @eyevinn/whip-web-client "whip-client.js" from
3+ https://cdn.jsdelivr.net/npm/@eyevinn/whip-web-client/dist/whip-client.modern.js
4+
5+ The @eyevinn/whip-web-client library version became broken in our usage context (module pulling Node deps)
6+ >This is modified variant to work with browsers
7+ */
8+
9+ export class WhipClient {
10+ constructor ( { endpoint, opts = { } } ) {
11+ this . endpoint = new URL ( endpoint , window . location . href ) . toString ( ) ;
12+ this . opts = {
13+ debug : ! ! opts . debug ,
14+ iceServers : opts . iceServers || [ { urls : "stun:stun.l.google.com:19302" } ] ,
15+ authkey : opts . authkey ,
16+ noTrickleIce : ! ! opts . noTrickleIce ,
17+ timeout : opts . timeout || 2000
18+ } ;
19+ this . peer = null ;
20+ this . resourceUrl = null ;
21+ this . eTag = null ;
22+ this . extensions = [ ] ;
23+ this . iceCredentials = null ;
24+ this . mediaMids = [ ] ;
25+ this . waitingForCandidates = false ;
26+ this . iceGatheringTimer = null ;
27+ this . _initPeer ( ) ;
28+ }
29+ log ( ...args ) { if ( this . opts . debug ) console . log ( "WHIPClient" , ...args ) ; }
30+ error ( ...args ) { console . error ( "WHIPClient" , ...args ) ; }
31+ _initPeer ( ) {
32+ this . peer = new RTCPeerConnection ( { iceServers : this . opts . iceServers } ) ;
33+ this . peer . addEventListener ( "iceconnectionstatechange" , ( ) => this . log ( "iceConnectionState" , this . peer . iceConnectionState ) ) ;
34+ this . peer . addEventListener ( "icecandidateerror" , ( e ) => this . log ( "iceCandidateError" , e ) ) ;
35+ this . peer . addEventListener ( "connectionstatechange" , async ( ) => {
36+ this . log ( "connectionState" , this . peer . connectionState ) ;
37+ if ( this . peer . connectionState === "failed" ) {
38+ await this . destroy ( ) ;
39+ }
40+ } ) ;
41+ this . peer . addEventListener ( "icegatheringstatechange" , ( ) => {
42+ if ( this . peer . iceGatheringState === "complete" && ! this . _supportsTrickle ( ) && this . waitingForCandidates ) {
43+ this . _onDoneWaitingForCandidates ( ) ;
44+ }
45+ } ) ;
46+ this . peer . addEventListener ( "icecandidate" , ( evt ) => this . _onIceCandidate ( evt ) ) ;
47+ }
48+ _supportsTrickle ( ) { return ! this . opts . noTrickleIce ; }
49+ supportTrickleIce ( ) { return this . _supportsTrickle ( ) ; }
50+ getICEConnectionState ( ) { return this . peer ?. iceConnectionState ; }
51+ async getResourceExtensions ( ) { return this . extensions ; }
52+ async _probePatchSupport ( ) {
53+ try {
54+ const headers = { } ;
55+ if ( this . opts . authkey ) headers [ "Authorization" ] = this . opts . authkey ;
56+ const res = await fetch ( this . endpoint , { method : "OPTIONS" , headers } ) ;
57+ if ( res && res . ok ) {
58+ const allow = res . headers . get ( "access-control-allow-methods" ) || res . headers . get ( "Allow" ) || "" ;
59+ const supportsPatch = allow . toUpperCase ( ) . split ( "," ) . map ( s => s . trim ( ) ) . includes ( "PATCH" ) ;
60+ this . opts . noTrickleIce = ! supportsPatch ;
61+ this . log ( "PATCH support:" , supportsPatch ) ;
62+ }
63+ } catch ( e ) {
64+ this . log ( "OPTIONS probe failed" , e ) ;
65+ }
66+ }
67+ _extractIceAndMidsFromLocalSDP ( ) {
68+ const sdp = this . peer . localDescription ?. sdp || "" ;
69+ let ufrag = null , pwd = null ;
70+ const sessUfrag = sdp . match ( / ^ a = i c e - u f r a g : ( .* ) $ / m) ;
71+ const sessPwd = sdp . match ( / ^ a = i c e - p w d : ( .* ) $ / m) ;
72+ if ( sessUfrag && sessPwd ) {
73+ ufrag = sessUfrag [ 1 ] . trim ( ) ;
74+ pwd = sessPwd [ 1 ] . trim ( ) ;
75+ } else {
76+ const mu = sdp . match ( / ^ a = i c e - u f r a g : ( .* ) $ / m) ;
77+ const mp = sdp . match ( / ^ a = i c e - p w d : ( .* ) $ / m) ;
78+ if ( mu && mp ) { ufrag = mu [ 1 ] . trim ( ) ; pwd = mp [ 1 ] . trim ( ) ; }
79+ }
80+ const mids = [ ] ;
81+ const midRegex = / ^ a = m i d : ( [ ^ \r \n ] + ) / gm;
82+ let m ;
83+ while ( ( m = midRegex . exec ( sdp ) ) !== null ) { mids . push ( m [ 1 ] ) ; }
84+ this . iceCredentials = ( ufrag && pwd ) ? { ufrag, pwd } : null ;
85+ this . mediaMids = mids ;
86+ }
87+ _buildTrickleSdpFrag ( candidate ) {
88+ if ( ! this . iceCredentials ) { this . error ( "No ICE creds for trickle" ) ; return null ; }
89+ const lines = [
90+ `a=ice-ufrag:${ this . iceCredentials . ufrag } ` ,
91+ `a=ice-pwd:${ this . iceCredentials . pwd } `
92+ ] ;
93+ const targetMids = candidate . sdpMid ? [ candidate . sdpMid ] : ( this . mediaMids . length ? this . mediaMids : [ "0" ] ) ;
94+ for ( const mid of targetMids ) {
95+ lines . push ( "m=audio 9 UDP/TLS/RTP/SAVPF 0" ) ;
96+ lines . push ( `a=mid:${ mid } ` ) ;
97+ lines . push ( `a=${ candidate . candidate } ` ) ;
98+ }
99+ return lines . join ( "\r\n" ) + "\r\n" ;
100+ }
101+ async _onIceCandidate ( evt ) {
102+ const cand = evt . candidate ;
103+ if ( ! cand ) return ;
104+ if ( ! this . _supportsTrickle ( ) || ! this . resourceUrl || ! this . eTag ) return ;
105+ const frag = this . _buildTrickleSdpFrag ( cand ) ;
106+ if ( ! frag ) return ;
107+ try {
108+ const res = await fetch ( this . resourceUrl , {
109+ method : "PATCH" ,
110+ headers : { "Content-Type" : "application/trickle-ice-sdpfrag" , "ETag" : this . eTag } ,
111+ body : frag
112+ } ) ;
113+ if ( ! res . ok ) {
114+ this . log ( "Trickle ICE not accepted, disabling" , res . status ) ;
115+ this . opts . noTrickleIce = true ;
116+ }
117+ } catch ( e ) {
118+ this . log ( "Trickle ICE patch failed" , e ) ;
119+ this . opts . noTrickleIce = true ;
120+ }
121+ }
122+ async _sendOffer ( ) {
123+ this . log ( "Sending offer" ) ;
124+ const headers = { "Content-Type" : "application/sdp" } ;
125+ if ( this . opts . authkey ) headers [ "Authorization" ] = this . opts . authkey ;
126+ const res = await fetch ( this . endpoint , { method : "POST" , headers, body : this . peer . localDescription . sdp } ) ;
127+ if ( ! res . ok ) {
128+ const msg = await res . text ( ) . catch ( ( ) => "" ) ;
129+ throw new Error ( `WHIP POST failed: ${ res . status } ${ res . statusText } ${ msg } ` ) ;
130+ }
131+ let loc = res . headers . get ( "Location" ) || res . headers . get ( "location" ) ;
132+ if ( loc && ! / ^ h t t p s ? : / i. test ( loc ) ) {
133+ loc = new URL ( loc , this . endpoint ) . toString ( ) ;
134+ }
135+ this . resourceUrl = loc || null ;
136+ this . eTag = res . headers . get ( "ETag" ) ;
137+ const link = res . headers . get ( "Link" ) ;
138+ if ( link ) this . extensions = link . split ( "," ) . map ( s => s . trim ( ) ) ;
139+ const answerSdp = await res . text ( ) ;
140+ await this . peer . setRemoteDescription ( { type : "answer" , sdp : answerSdp } ) ;
141+ }
142+ _onDoneWaitingForCandidates ( ) {
143+ clearTimeout ( this . iceGatheringTimer ) ;
144+ this . waitingForCandidates = false ;
145+ this . _sendOffer ( ) . catch ( e => this . error ( e ) ) ;
146+ }
147+ async _startSdpExchange ( ) {
148+ const offer = await this . peer . createOffer ( { offerToReceiveAudio : false , offerToReceiveVideo : false } ) ;
149+ await this . peer . setLocalDescription ( offer ) ;
150+ this . _extractIceAndMidsFromLocalSDP ( ) ;
151+ if ( this . _supportsTrickle ( ) ) {
152+ await this . _sendOffer ( ) ;
153+ } else {
154+ this . waitingForCandidates = true ;
155+ this . iceGatheringTimer = setTimeout ( ( ) => this . _onDoneWaitingForCandidates ( ) , this . opts . timeout ) ;
156+ }
157+ }
158+ async setIceServersFromEndpoint ( ) {
159+ if ( ! this . opts . authkey ) { this . error ( "No authkey provided for ICE fetch" ) ; return ; }
160+ try {
161+ const res = await fetch ( this . endpoint , { method : "OPTIONS" , headers : { "Authorization" : this . opts . authkey } } ) ;
162+ if ( ! res . ok ) return ;
163+ const ice = [ ] ;
164+ res . headers . forEach ( ( value , key ) => {
165+ if ( key . toLowerCase ( ) === "link" ) {
166+ // Parse Link headers for ice-server entries per WHIP recommendations
167+ value . split ( "," ) . forEach ( part => {
168+ const p = part . trim ( ) ;
169+ const m = p . match ( / < ( [ ^ > ] + ) > ; \s * r e l = " i c e - s e r v e r " (?: ; \s * u s e r n a m e = " ? ( [ ^ " ; ] + ) " ? ) ? (?: ; \s * c r e d e n t i a l = " ? ( [ ^ " ; ] + ) " ? ) ? / i) ;
170+ if ( m ) {
171+ const url = m [ 1 ] ;
172+ const username = m [ 2 ] ;
173+ const credential = m [ 3 ] ;
174+ const server = { urls : url } ;
175+ if ( username ) server . username = username ;
176+ if ( credential ) server . credential = credential ;
177+ ice . push ( server ) ;
178+ }
179+ } ) ;
180+ }
181+ } ) ;
182+ if ( ice . length ) this . peer . setConfiguration ( { iceServers : ice } ) ;
183+ } catch ( e ) {
184+ this . log ( "ICE servers fetch failed" , e ) ;
185+ }
186+ }
187+ async ingest ( mediaStream ) {
188+ if ( ! this . peer ) this . _initPeer ( ) ;
189+ mediaStream . getTracks ( ) . forEach ( track => this . peer . addTrack ( track , mediaStream ) ) ;
190+ if ( this . opts . noTrickleIce === false ) {
191+ await this . _probePatchSupport ( ) ;
192+ } else if ( this . opts . noTrickleIce === undefined ) {
193+ await this . _probePatchSupport ( ) ;
194+ }
195+ await this . _startSdpExchange ( ) ;
196+ }
197+ async destroy ( ) {
198+ try {
199+ if ( this . resourceUrl ) {
200+ await fetch ( this . resourceUrl , { method : "DELETE" } ) . catch ( ( ) => { } ) ;
201+ }
202+ } finally {
203+ try { this . peer ?. getSenders ( ) ?. forEach ( s => { try { s . track && s . track . stop ( ) ; } catch ( e ) { } } ) ; } catch ( e ) { }
204+ try { this . peer ?. close ( ) ; } catch ( e ) { }
205+ this . peer = null ;
206+ this . resourceUrl = null ;
207+ }
208+ }
209+ }
0 commit comments