import Foundation
/// One ICE server entry, ready for `RTCIceServer`.
public struct IceServer: Sendable, Hashable, Codable {
public var urls: [String]
public var username: String?
public var credential: String?
public init(urls: [String], username: String? = nil, credential: String? = nil) {
self.urls = urls
self.username = username
self.credential = credential
}
}
/// The ICE configuration for a peer connection.
public struct IceConfiguration: Sendable, Hashable {
public var servers: [IceServer]
/// Only relayed candidates (TURN) — for testing the fallback path.
public var relayOnly: Bool
public init(servers: [IceServer], relayOnly: Bool = false) {
self.servers = servers
self.relayOnly = relayOnly
}
public static let none = IceConfiguration(servers: [])
}
/// `GET /turn-credentials` response.
struct TurnCredentialsResponse: Decodable {
var username: String
var credential: String
var ttl: Int
var urls: [String]
}
/// Fetches and caches TURN credentials: renewed 5 minutes before expiry, one
/// in-flight fetch shared by all callers, failures cached for 30 seconds and
/// degraded to an empty server list (direct connections still work).
public actor IceServerCache {
public static let renewBefore: Duration = .seconds(300)
public static let failureRetryAfter: Duration = .seconds(30)
private let url: URL?
private let origin: String?
private let http: any HTTPTransport
private let clock: any P2PClock
private let relayOnly: Bool
private let timeout: TimeInterval
private var cached: (config: IceConfiguration, validUntil: Duration)?
private var inflight: Task<IceConfiguration, Never>?
public init(url: URL?, origin: String?, http: any HTTPTransport, clock: any P2PClock = SystemClock(), relayOnly: Bool = false, timeout: TimeInterval = 10) {
self.url = url
self.origin = origin
self.http = http
self.clock = clock
self.relayOnly = relayOnly
self.timeout = timeout
}
public func configuration() async -> IceConfiguration {
if let cached, clock.now < cached.validUntil { return cached.config }
if let inflight { return await inflight.value }
let task = Task { await self.fetch() }
inflight = task
let result = await task.value
inflight = nil
return result
}
/// Drops the cache so the next call fetches again (e.g. after an ICE failure).
public func invalidate() {
cached = nil
}
private func fetch() async -> IceConfiguration {
guard let url else {
let config = IceConfiguration(servers: [], relayOnly: relayOnly)
cached = (config, clock.now + .seconds(3600))
return config
}
do {
let response = try await http.send(.p2p(url, origin: origin, timeout: timeout))
guard response.isSuccess else { throw P2PError.http(status: response.status, code: nil, body: response.body) }
let creds = try JSONDecoder().decode(TurnCredentialsResponse.self, from: response.body)
let config = IceConfiguration(servers: [IceServer(urls: creds.urls, username: creds.username, credential: creds.credential)], relayOnly: relayOnly)
let ttl = Duration.seconds(max(0, creds.ttl))
let valid = ttl > IceServerCache.renewBefore ? ttl - IceServerCache.renewBefore : ttl / 2
cached = (config, clock.now + valid)
return config
} catch {
let config = IceConfiguration(servers: [], relayOnly: relayOnly)
cached = (config, clock.now + IceServerCache.failureRetryAfter)
return config
}
}
}