Skip to content

Commit 4f993a4

Browse files
committed
Fixed whip test that is broken because of library change.
1 parent 11cd0e2 commit 4f993a4

File tree

2 files changed

+214
-3
lines changed

2 files changed

+214
-3
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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=ice-ufrag:(.*)$/m);
71+
const sessPwd = sdp.match(/^a=ice-pwd:(.*)$/m);
72+
if (sessUfrag && sessPwd) {
73+
ufrag = sessUfrag[1].trim();
74+
pwd = sessPwd[1].trim();
75+
} else {
76+
const mu = sdp.match(/^a=ice-ufrag:(.*)$/m);
77+
const mp = sdp.match(/^a=ice-pwd:(.*)$/m);
78+
if (mu && mp) { ufrag = mu[1].trim(); pwd = mp[1].trim(); }
79+
}
80+
const mids = [];
81+
const midRegex = /^a=mid:([^\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 && !/^https?:/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*rel="ice-server"(?:;\s*username="?([^";]+)"?)?(?:;\s*credential="?([^";]+)"?)?/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+
}

src/main/webapp/whip.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030

3131
<script type="module">
3232

33-
//we started to import the WHIPClient this way because module file is causing problem
34-
import { WHIPClient } from "https://esm.sh/@eyevinn/whip-web-client/dist/whip-client.modern.js";
33+
// Using custom ./js/SimpleWhipClient.js because @eyevinn library is now broken in browser-only context
34+
// import { WHIPClient } from "https://esm.sh/@eyevinn/whip-web-client/dist/whip-client.modern.js";
35+
import { WhipClient } from "./js/SimpleWhipClient.js";
36+
3537

3638
import {getQueryParameter, generateRandomString} from "./js/utility.js"
3739
import {getUrlParameter} from "./js/fetch.stream.js"
@@ -65,7 +67,7 @@
6567
else {
6668
streamId = "stream" + generateRandomString(6);
6769
}
68-
client = new WHIPClient({
70+
client = new WhipClient({
6971
endpoint: whipEndpoint + streamId,
7072
opts: {
7173
debug: true,

0 commit comments

Comments
 (0)