(ns ird.p2p.turn
"TURN credentials from `GET /turn-credentials`, cached and renewed: five
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). The result is the ICE configuration
handed to the peer-connection factory:
{:servers [{:urls [...] :username ... :credential ...}] :relay-only? false}"
(:require [cljs.core.async :as async :refer [go <!]]
[ird.p2p.async :as a]
[ird.p2p.errors :as err]
[ird.p2p.impl.http :as http]
[ird.p2p.impl.json :as json]
[ird.p2p.impl.node :as node]))
(def renew-before-ms 300000)
(def failure-retry-ms 30000)
(defn make-cache
"OPTS: :url (nil → no TURN), :origin, :http-fn, :now-fn, :relay-only?, :timeout-ms."
[{:keys [url origin http-fn now-fn relay-only? timeout-ms]}]
{:url url :origin origin :http-fn (or http-fn http/fetch-http) :now-fn (or now-fn node/now-ms)
:relay-only? (boolean relay-only?) :timeout-ms (or timeout-ms 10000)
:cached (atom nil) :inflight (atom nil)})
(defn- fetch-config [{:keys [url origin http-fn now-fn relay-only? timeout-ms cached]}]
(go
(if (nil? url)
(let [config {:servers [] :relay-only? relay-only?}]
(reset! cached {:config config :valid-until (+ (now-fn) 3600000)})
config)
(let [resp (<! (http-fn {:method :get :url url
:headers (cond-> {"Accept" "application/json"} origin (assoc "Origin" origin))
:timeout-ms timeout-ms}))
body (when (and (map? resp) (<= 200 (:status resp) 299)) (json/parse (:body resp)))]
(if (and (map? body) (string? (:username body)) (string? (:credential body)) (vector? (:urls body)))
(let [config {:servers [{:urls (:urls body) :username (:username body) :credential (:credential body)}]
:relay-only? relay-only?}
ttl (* 1000 (max 0 (or (:ttl body) 0)))
valid (if (> ttl renew-before-ms) (- ttl renew-before-ms) (quot ttl 2))]
(reset! cached {:config config :valid-until (+ (now-fn) valid)})
config)
(let [config {:servers [] :relay-only? relay-only?}]
(reset! cached {:config config :valid-until (+ (now-fn) failure-retry-ms)})
config))))))
(defn configuration
"The current ICE configuration (fetching when stale). Channel."
[{:keys [cached inflight now-fn] :as cache}]
(go
(let [c @cached]
(cond
(and c (< (now-fn) (:valid-until c))) (:config c)
@inflight (<! @inflight)
:else (let [ch (async/promise-chan)]
(reset! inflight ch)
(let [config (<! (fetch-config cache))]
(a/deliver! ch config)
(reset! inflight nil)
config))))))
(defn invalidate!
"Drop the cache so the next call fetches again (e.g. after an ICE failure)."
[{:keys [cached]}]
(reset! cached nil)
nil)