/**
* Fetches ephemeral TURN credentials from the signaling host (coturn REST
* scheme) and builds RTCConfiguration. Caches until 5 minutes before expiry.
*/
interface TurnCreds {
username: string;
credential: string;
ttl: number;
urls: string[];
}
export class IceConfig {
#endpoint: string;
#forceRelay: boolean;
#cached: RTCConfiguration | null = null;
#validUntil = 0; // refetch after this timestamp
#inflight: Promise<RTCConfiguration> | null = null;
constructor(endpoint: string, forceRelay = false) {
this.#endpoint = endpoint;
this.#forceRelay = forceRelay;
}
get(): Promise<RTCConfiguration> {
if (this.#cached && Date.now() < this.#validUntil) return Promise.resolve(this.#cached);
this.#inflight ??= this.#fetch().finally(() => (this.#inflight = null));
return this.#inflight;
}
async #fetch(): Promise<RTCConfiguration> {
try {
const res = await fetch(this.#endpoint);
if (!res.ok) throw new Error(`turn-credentials ${res.status}`);
const c: TurnCreds = await res.json();
this.#cached = {
iceServers: [{ urls: c.urls, username: c.username, credential: c.credential }],
...(this.#forceRelay ? { iceTransportPolicy: "relay" as RTCIceTransportPolicy } : {}),
};
this.#validUntil = Date.now() + c.ttl * 1000 - 300_000; // renew 5 min before expiry
} catch (err) {
// Credential fetch failing shouldn't block LAN/host-candidate connections.
console.warn("turn credentials unavailable, continuing without ICE servers", err);
if (!this.#cached) this.#cached = this.#forceRelay ? { iceTransportPolicy: "relay" } : {};
this.#validUntil = Date.now() + 30_000; // retry soon, but don't storm
}
return this.#cached!;
}
}