social-kit / src / ardegazu / social / net.cljs
  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
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
;; ported-from: src/net/{index,bootstrap,node,turn}.ts @ v1.2.0
;;
;; A standalone libp2p node for apps that don't already run one (the hub).
;; bootstrap/node/turn are the games' vendored net layer, unchanged; the
;; createSocialNode entry just assembles them and hands back the pubsub
;; surface the social agent needs. Apps that DO run a node (games, chat,
;; board) skip this entry entirely and inject their own
;; `node.services.pubsub` — that keeps one relay reservation per tab.
;;
;; Importing "ardegazu-social-kit/net" requires the libp2p v3 dependency
;; family (see the package README); the main entry needs none of it. Every
;; libp2p package stays an EXTERNAL import so those optional peerDependencies
;; stay optional.
(ns ardegazu.social.net
  (:require ["libp2p" :refer (createLibp2p)]
            ["@libp2p/websockets" :refer (webSockets)]
            ["@libp2p/webrtc" :refer (webRTC)]
            ["@libp2p/circuit-relay-v2" :refer (circuitRelayTransport)]
            ["@chainsafe/libp2p-noise" :refer (noise)]
            ["@chainsafe/libp2p-yamux" :refer (yamux)]
            ["@libp2p/gossipsub" :refer (gossipsub)]
            ["@libp2p/pubsub-peer-discovery" :refer (pubsubPeerDiscovery)]
            ["@libp2p/identify" :refer (identify)]
            ["@multiformats/multiaddr" :refer (multiaddr)]
            [shadow.cljs.modern :refer (defclass js-await)]))

;; ---- bootstrap: relay endpoint discovery -----------------------------------
;; Fetch the p2p app's /.well-known/ap2p descriptor so a relay identity
;; rotation never needs a client release. Falls back to the compiled-in relay
;; info when the fetch fails or times out.

(defn fetch-relay-info [descriptor-url fallback timeout-ms]
  (let [timeout (if (identical? timeout-ms js/undefined) 3000 timeout-ms)]
    (-> (js/Promise.resolve nil)
        (.then
         (fn [_]
           (js-await [res (js/fetch descriptor-url (js-obj "signal" (js/AbortSignal.timeout timeout)))]
             (when-not ^boolean (.-ok ^js res)
               (throw (js/Error. (str "descriptor " (.-status ^js res)))))
             (js-await [d (.json ^js res)]
               (let [relay (unchecked-get d "relay")
                     peer-id (when (some? relay) (unchecked-get relay "peer_id"))
                     maddrs (when (some? relay)
                              (let [ms (unchecked-get relay "multiaddrs")]
                                (when (js/Array.isArray ms)
                                  (.filter ^js ms (fn [m] (and (string? m) (pos? (.-length m))))))))]
                 (when (or (not ^boolean (js* "!!(~{})" peer-id))
                           (nil? maddrs)
                           (zero? (.-length ^js maddrs)))
                   (throw (js/Error. "descriptor missing relay block")))
                 (js-obj "peerId" peer-id "multiaddrs" maddrs))))))
        (.catch
         (fn [err]
           (js/console.warn "ap2p descriptor unavailable, using compiled-in relay info" err)
           fallback)))))

;; ---- turn: ephemeral TURN credentials (coturn REST scheme) -----------------
;; Builds RTCConfiguration; caches until 5 minutes before expiry.

(defclass IceConfig
  (constructor [this endpoint force-relay]
    (unchecked-set this "_endpoint" endpoint)
    (unchecked-set this "_forceRelay" (if (identical? force-relay js/undefined) false force-relay))
    (unchecked-set this "_cached" nil)
    (unchecked-set this "_validUntil" 0)
    (unchecked-set this "_inflight" nil)))

(defn- ice-fetch [self]
  (-> (js/Promise.resolve nil)
      (.then
       (fn [_]
         (js-await [res (js/fetch (unchecked-get self "_endpoint"))]
           (when-not ^boolean (.-ok ^js res)
             (throw (js/Error. (str "turn-credentials " (.-status ^js res)))))
           (js-await [c (.json ^js res)]
             (let [cfg (js-obj "iceServers"
                               #js [(js-obj "urls" (unchecked-get c "urls")
                                            "username" (unchecked-get c "username")
                                            "credential" (unchecked-get c "credential"))])]
               (when ^boolean (unchecked-get self "_forceRelay")
                 (unchecked-set cfg "iceTransportPolicy" "relay"))
               (unchecked-set self "_cached" cfg)
               (unchecked-set self "_validUntil"
                              (- (+ (js/Date.now) (* (unchecked-get c "ttl") 1000)) 300000))
               cfg)))))
      (.catch
       (fn [err]
         ;; Credential fetch failing shouldn't block LAN/host-candidate connections.
         (js/console.warn "turn credentials unavailable, continuing without ICE servers" err)
         (when (nil? (unchecked-get self "_cached"))
           (unchecked-set self "_cached"
                          (if ^boolean (unchecked-get self "_forceRelay")
                            (js-obj "iceTransportPolicy" "relay")
                            (js-obj))))
         (unchecked-set self "_validUntil" (+ (js/Date.now) 30000)) ; retry soon, but don't storm
         (unchecked-get self "_cached")))))

(let [proto (.-prototype IceConfig)]
  (unchecked-set proto "get"
                 (fn []
                   (this-as self
                     (if (and (some? (unchecked-get self "_cached"))
                              (< (js/Date.now) (unchecked-get self "_validUntil")))
                       (js/Promise.resolve (unchecked-get self "_cached"))
                       (or (unchecked-get self "_inflight")
                           (let [p (.finally ^js (ice-fetch self)
                                             (fn [] (unchecked-set self "_inflight" nil)))]
                             (unchecked-set self "_inflight" p)
                             p)))))))

;; ---- node: libp2p node factory ---------------------------------------------
;; Browser peers listen on the app relay (circuit-relay-v2) and announce
;; WebRTC; the relay carries gossipsub peer discovery and the first frames of
;; every pair until the direct WebRTC connection upgrades.

(defclass NetNode
  (constructor [this node relay]
    (unchecked-set this "node" node)
    (unchecked-set this "relayId" (unchecked-get relay "peerId"))
    (unchecked-set this "_relayMaddr" (multiaddr (aget (unchecked-get relay "multiaddrs") 0)))
    (unchecked-set this "_redialing" false)))

(defn- relay-connected? [self]
  (let [^js node (unchecked-get self "node")]
    (-> (.getConnections node)
        (.some (fn [c] (identical? (.toString (.-remotePeer ^js c))
                                   (unchecked-get self "relayId")))))))

(defn- redial-relay
  "Dial (or re-dial after a suspend) the relay; the circuit listener re-takes
  its reservation."
  [self]
  (if (or ^boolean (unchecked-get self "_redialing") (relay-connected? self))
    (js/Promise.resolve js/undefined)
    (let [^js node (unchecked-get self "node")]
      (unchecked-set self "_redialing" true)
      (-> (.dial node
                 (unchecked-get self "_relayMaddr")
                 (js-obj "signal" (js/AbortSignal.timeout 15000)))
          (.catch (fn [err] (js/console.warn "relay dial failed" err)))
          (.finally (fn [] (unchecked-set self "_redialing" false)))
          (.then (fn [_] js/undefined))))))

(defn- net-node-create [relay ice]
  (js-await [node (createLibp2p
                   (js-obj "addresses"
                           (js-obj "listen"
                                   #js [(str (aget (unchecked-get relay "multiaddrs") 0) "/p2p-circuit")
                                        "/webrtc"])
                           "transports"
                           #js [(webSockets)
                                (webRTC (js-obj "rtcConfiguration"
                                                (fn [] (.get ^js ice))))
                                (circuitRelayTransport)]
                           "connectionEncrypters" #js [(noise)]
                           "streamMuxers" #js [(yamux)]
                           "peerDiscovery" #js [(pubsubPeerDiscovery (js-obj "interval" 3000))]
                           "services" (js-obj "identify" (identify)
                                              "pubsub" (gossipsub))))]
    (let [nn (NetNode. node relay)]
      (js-await [_ (redial-relay nn)]
        nn))))

(unchecked-set NetNode "create" (fn [relay ice] (net-node-create relay ice)))

(let [proto (.-prototype NetNode)]
  (unchecked-set proto "relayConnected"
                 (fn [] (this-as self (relay-connected? self))))
  (unchecked-set proto "redialRelay"
                 (fn [] (this-as self (redial-relay self)))))

;; ---- the assembled social node ---------------------------------------------

(defn create-social-node [cfg]
  (js-await [relay (fetch-relay-info (unchecked-get cfg "descriptorUrl")
                                     (unchecked-get cfg "relayFallback")
                                     js/undefined)]
    (let [ice (IceConfig. (unchecked-get cfg "turnUrl") js/undefined)]
      (js-await [nn (net-node-create relay ice)]
        (let [^js node (unchecked-get nn "node")
              relay-id (unchecked-get nn "relayId")
              ;; gossipsub only meshes between CONNECTED peers, and the relay
              ;; doesn't forward arbitrary topics — so, like every suite app,
              ;; dial whoever the shared discovery topic surfaces
              ;; (noise-encrypted; pair topics stay sealed and strangers simply
              ;; share no topic). Best-effort, capped by libp2p's own
              ;; connection manager.
              dialing (js/Set.)]
          (.addEventListener node "peer:discovery"
                             (fn [e]
                               (let [peer (.-id (.-detail ^js e))
                                     pid (.toString ^js peer)]
                                 (when-not (or (identical? pid relay-id) (.has dialing pid))
                                   (when (zero? (.-length (.getConnections node peer)))
                                     (.add dialing pid)
                                     (-> (.dial node peer (js-obj "signal" (js/AbortSignal.timeout 15000)))
                                         (.catch (fn [_] nil))
                                         (.finally (fn [] (.delete dialing pid)))))))))

          ;; iOS suspend/resume and network flaps: revive the relay link
          (let [wake (fn [] (redial-relay nn))]
            (js/document.addEventListener "visibilitychange"
                                          (fn []
                                            (when (identical? (.-visibilityState js/document) "visible")
                                              (wake))))
            (js/window.addEventListener "pageshow" (fn [] (wake)))
            (js/window.addEventListener "online" (fn [] (wake))))

          (let [pubsub (unchecked-get (.-services node) "pubsub")]
            (js-obj "node" node
                    "pubsub" pubsub
                    "relayId" relay-id
                    "relayConnected" (fn [] (relay-connected? nn))
                    "close" (fn [] (.then (.stop node) (fn [_] js/undefined))))))))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/social-kit.git