sueta / client / src / lib / turn.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
 * 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!;
  }
}