Files
overlord/Overlord-Server/public/assets/whep.js
T
2026-05-13 13:44:33 -05:00

117 lines
3.7 KiB
JavaScript

// Tiny WHEP (WebRTC-HTTP Egress Protocol) client.
//
// Wires a <video> element to a server-proxied WHEP endpoint:
// 1. Create a recvonly video PeerConnection.
// 2. POST our SDP offer to whepPath, parse the SDP answer.
// 3. ontrack → attach MediaStream to the <video>.
//
// The Bun server proxies all signaling to MediaMTX, so this fetch inherits the
// existing JWT cookie. ICE/DTLS go straight to MediaMTX over UDP.
export class WhepClient {
/**
* @param {object} opts
* @param {string} opts.whepPath Path under the same origin, e.g. "/api/webrtc/agents/abc/whep"
* @param {HTMLVideoElement} opts.videoEl
* @param {(state: string) => void} [opts.onState]
*/
constructor(opts) {
this.whepPath = opts.whepPath;
this.videoEl = opts.videoEl;
this.onState = opts.onState || (() => {});
this.pc = null;
this.resourceURL = "";
}
async start() {
if (this.pc) await this.stop();
const pc = new RTCPeerConnection({});
this.pc = pc;
pc.addTransceiver("video", { direction: "recvonly" });
pc.ontrack = (ev) => {
if (ev.track.kind !== "video") return;
const stream = ev.streams[0] || new MediaStream([ev.track]);
this.videoEl.srcObject = stream;
this.videoEl.play().catch(() => {});
};
pc.onconnectionstatechange = () => {
this.onState(pc.connectionState);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceComplete(pc, 5000);
// The agent's WHIP publish and the operator's WHEP play start in parallel
// when "Start" is clicked. If the operator's WHEP arrives at MediaMTX
// before the agent's publish finishes setting up, MediaMTX returns 404
// "no stream is available on path ...". Retry briefly to absorb that
// race instead of immediately failing back to canvas.
const resp = await fetchWithRetry(this.whepPath, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
credentials: "include",
body: pc.localDescription.sdp,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`WHEP ${resp.status}: ${text || resp.statusText}`);
}
this.resourceURL = resp.headers.get("Location") || "";
const answerSDP = await resp.text();
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
}
async stop() {
const pc = this.pc;
const url = this.resourceURL;
this.pc = null;
this.resourceURL = "";
if (url) {
try {
await fetch(url, { method: "DELETE", credentials: "include" });
} catch {}
}
if (pc) {
try { pc.close(); } catch {}
}
if (this.videoEl) {
this.videoEl.srcObject = null;
}
}
}
async function fetchWithRetry(url, init, { attempts = 12, delayMs = 400 } = {}) {
let last;
for (let i = 0; i < attempts; i++) {
last = await fetch(url, init);
// Only retry the specific "publisher not yet ready" 404 from MediaMTX.
// Anything else (auth, network) should fail fast.
if (last.status !== 404) return last;
const text = await last.clone().text().catch(() => "");
if (!/no stream is available/i.test(text)) return last;
await new Promise((r) => setTimeout(r, delayMs));
}
return last;
}
function waitIceComplete(pc, timeoutMs) {
return new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
let done = false;
const finish = () => {
if (done) return;
done = true;
pc.removeEventListener("icegatheringstatechange", check);
resolve();
};
const check = () => {
if (pc.iceGatheringState === "complete") finish();
};
pc.addEventListener("icegatheringstatechange", check);
setTimeout(finish, timeoutMs);
});
}