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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247 | (ns ird.p2p
"Peer SDK for the ird p2p service on Node.js. An app gives you a hostname
that serves a blind signaling relay, STUN/TURN and an offline mailbox;
this namespace turns that into rooms: peers who hold a room's secret find
each other through the relay, prove membership by decrypting each other's
envelopes, open a WebRTC data channel when a peer-connection factory is
provided and keep talking through the relay when they cannot, and leave
messages in the mailbox for peers who come later. Every payload is
end-to-end encrypted before it reaches the network; the node never holds
a key.
Everything is plain ClojureScript data in and out, and every asynchronous
call returns a core.async channel that delivers exactly one value — the
result or an ex-info error (ird.p2p.errors). Long-lived streams (room
events) are channels to drain.
(def app (p2p/make-app {:host \"myapp.ap2p.ro\" :app-salt \"myapp/v1\"
:origin \"https://myapp.example\"}))
(go (let [room (<? (p2p/join app secret))]
(loop [] (when-let [e (<! (p2p/events room))] (println e) (recur)))))"
(:require [clojure.string :as str]
[cljs.core.async :as async :refer [go <!]]
[ird.p2p.async :as a :refer [go-try <?]]
[ird.p2p.crypto :as crypto]
[ird.p2p.descriptor :as desc]
[ird.p2p.errors :as err]
[ird.p2p.identity :as ident]
[ird.p2p.impl.http :as http]
[ird.p2p.impl.node :as node]
[ird.p2p.mailbox :as mbx]
[ird.p2p.room :as room]
[ird.p2p.transport :as t]
[ird.p2p.transport.ws :as ws]
[ird.p2p.turn :as turn]
[ird.p2p.webrtc :as webrtc]))
;; ---- app ---------------------------------------------------------------------------------
(defn make-app
"Everything needed to talk to one p2p app. OPTS:
:host the app hostname (`<name>.ap2p.ro` or a custom domain) — required
:app-salt namespaces room ids and keys; never reuse another app's salt — required
:origin sent as the Origin header on the WebSocket upgrade and mints;
required when the app's origin allowlist is non-empty
:transport an ird.p2p.transport/Transport (default: the `/ws` relay,
ird.p2p.transport.ws; ird.p2p.transport.libp2p for gossipsub)
:pc-factory an ird.p2p.webrtc/PeerConnectionFactory (default: none —
rooms stay relayed; see the README for real WebRTC on Node)
:descriptor a descriptor map to use instead of fetching one
:endpoints overrides {:ws :turn-credentials :mailbox :mailbox-mint}
:token-fn mailbox tokens from your own backend (default: the open mint)
:http-fn :sleep-fn :now-fn injectable transports and clock (tests)
:force-relay? only TURN-relayed ICE candidates
:descriptor-timeout-ms (default 3000)
Throws a config error synchronously on invalid options."
[{:keys [host app-salt origin transport pc-factory descriptor endpoints token-fn
http-fn sleep-fn now-fn force-relay? descriptor-timeout-ms] :as opts}]
(when (str/blank? (str host)) (throw (err/config-error "make-app needs a :host" {:opts (dissoc opts :http-fn)})))
(when-not (string? app-salt) (throw (err/config-error "make-app needs an :app-salt string" {})))
(when (and (some? origin) (not (string? origin))) (throw (err/config-error ":origin must be a string" {})))
(let [http (or http-fn http/fetch-http)
sleep (or sleep-fn a/sleep)
now (or now-fn node/now-ms)]
{:host host
:app-salt app-salt
:origin origin
:transport (or transport (ws/transport {:sleep-fn sleep :now-fn now}))
:pc-factory pc-factory
:endpoints endpoints
:token-fn token-fn
:http-fn http
:sleep-fn sleep
:now-fn now
:force-relay? (boolean force-relay?)
:descriptor-timeout-ms (or descriptor-timeout-ms 3000)
:fixed-descriptor descriptor
:descriptor-cache (atom nil)
:descriptor-fetch (atom nil)
:ice-cache (atom nil)}))
(defn fetch-descriptor
"The app's `/.well-known/ap2p` descriptor; synthesized (:is-fallback true)
when unreachable. A fallback is retried after 30 s, a real descriptor
after an hour; OPTS {:refresh? true} forces a fetch. Channel."
([app] (fetch-descriptor app nil))
([{:keys [host origin http-fn now-fn descriptor-timeout-ms fixed-descriptor descriptor-cache descriptor-fetch]} {:keys [refresh?]}]
(go
(if fixed-descriptor
(assoc fixed-descriptor :is-fallback (boolean (:is-fallback fixed-descriptor)))
(let [cached @descriptor-cache
fresh? (and cached (not refresh?)
(< (- (now-fn) (:fetched-at cached)) (if (:is-fallback (:descriptor cached)) 30000 3600000)))]
(cond
fresh? (:descriptor cached)
@descriptor-fetch (<! @descriptor-fetch)
:else (let [ch (async/promise-chan)]
(reset! descriptor-fetch ch)
(let [d (<! (desc/fetch {:host host :origin origin :http-fn http-fn :timeout-ms descriptor-timeout-ms}))]
(reset! descriptor-cache {:descriptor d :fetched-at (now-fn)})
(a/deliver! ch d)
(reset! descriptor-fetch nil)
d))))))))
(defn resolved-endpoints
"The URLs this app talks to (overrides win over the descriptor). Channel."
[{:keys [endpoints] :as app}]
(go (desc/resolved-endpoints (<! (fetch-descriptor app)) endpoints)))
(defn ice-configuration
"TURN credentials for the peer-connection factory, cached and renewed;
an empty server list on failure. Channel."
[{:keys [origin http-fn now-fn force-relay? ice-cache] :as app}]
(go
(when (nil? @ice-cache)
(let [r (<! (resolved-endpoints app))]
(reset! ice-cache (turn/make-cache {:url (:turn-credentials r) :origin origin :http-fn http-fn
:now-fn now-fn :relay-only? force-relay?}))))
(<! (turn/configuration @ice-cache))))
;; ---- rooms ---------------------------------------------------------------------------------
(defn random-secret
"A fresh room secret: 32 random bytes as URL-safe base64."
[]
(crypto/random-secret))
(def invite-url
"`(invite-url base secret)`: BASE with the secret in its fragment."
ident/invite-url)
(def invite-secret
"`(invite-secret url)`: the secret in a link's fragment, or nil."
ident/invite-secret)
(defn join
"Join the room derived from SECRET. OPTS:
:identity an ird.p2p.identity map (from `ird.p2p.identity/from-seed`)
:peer-id this session's relay peer id (default random)
:options room options: :hello-timeout-ms (10000), :prefer-data-channel?
(true when a :pc-factory is set), :pacing? (true), :keepalive-ms (20000)
Channel of the room, or the error that refused the join (upgrade rejected:
403 wrong or missing Origin, 404 unknown host; room-full; ...)."
([app secret] (join app secret nil))
([{:keys [app-salt origin transport pc-factory endpoints sleep-fn now-fn] :as app} secret {:keys [identity peer-id options]}]
(go-try
(crypto/check-secret secret)
(let [d (<! (fetch-descriptor app))
r (desc/resolved-endpoints d endpoints)
limits (desc/limits d)
rc (<? (crypto/room-crypto {:secret secret :app-salt app-salt :peer-id (or peer-id (crypto/random-peer-id))}))
factory (or pc-factory (webrtc/in-memory-factory))]
(<? (room/join {:rc rc
:transport transport
:transport-opts (merge {:url (:ws r) :origin origin :limits limits
:pacing? (get options :pacing? true)
:keepalive-ms (get options :keepalive-ms 20000)}
(:transport-opts options))
:pc-factory factory
:ice-fn (fn [] (ice-configuration app))
:identity identity
:app-salt app-salt
:max-frame-bytes (:max_frame_bytes limits)
:options (merge {:prefer-data-channel? (some? pc-factory)} options)
:sleep-fn sleep-fn
:now-fn now-fn}))))))
(defn events
"The room's event channel (see ird.p2p.room); drain it."
[room]
(:events room))
(defn send
"Send MESSAGE to one verified peer (data channel when open, else the
relay). Channel of true or an error."
[room to message]
(room/send room to message))
(defn broadcast
"Send MESSAGE to every verified peer. Channel of true."
[room message]
(room/broadcast room message))
(defn leave
"Leave the room; the room cannot be rejoined. Channel that closes when done."
[room]
(room/leave room))
(defn wake
"Reconnect now, re-hello silent peers, retry lost data channels. Channel."
[room]
(room/wake room))
(def members
"`(members room)`: the verified members, sorted."
room/members)
(def transport-of
"`(transport-of room peer)`: :relayed, :direct, or nil when not a member."
room/transport-of)
(def identity-of
"`(identity-of room peer)`: the peer's verified identity, or nil."
room/identity-of)
(defn json
"A JSON message."
[value]
{:kind :json :value value})
(defn data
"A binary message; PAYLOAD-B64 is standard base64 of the bytes."
[payload-b64]
{:kind :data :payload payload-b64})
;; ---- mailbox ---------------------------------------------------------------------------------
(defn raw-mailbox
"The raw mailbox client (opaque bytes) of this app. OPTS {:token-fn}.
Channel of the client, or a config error when the app has no mailbox or
its open mint is off and no token provider was injected."
([app] (raw-mailbox app nil))
([{:keys [origin http-fn now-fn endpoints token-fn] :as app} opts]
(go-try
(let [d (<! (fetch-descriptor app))
r (desc/resolved-endpoints d endpoints)
endpoint (:mailbox r)
tokens (or (:token-fn opts) token-fn
(when-let [mint (:mailbox-mint r)]
(mbx/open-mint-token-fn {:mint-url mint :origin origin :http-fn http-fn :now-fn now-fn})))]
(when-not endpoint
(throw (err/config-error "the app has no mailbox enabled (no mailbox block in its descriptor)" {})))
(when-not tokens
(throw (err/config-error "the app's mailbox open mint is off: inject a :token-fn backed by your own backend" {})))
(mbx/make-client {:endpoint endpoint :token-fn tokens :http-fn http-fn :origin origin})))))
(defn open-mailbox
"The room-bound, end-to-end encrypted mailbox for SECRET. OPTS {:token-fn}.
Channel of the mailbox map (use with ird.p2p.mailbox/deposit, replay,
replay-all, enqueue, flush-outbox) or a config error."
([app secret] (open-mailbox app secret nil))
([{:keys [app-salt sleep-fn] :as app} secret opts]
(go-try
(crypto/check-secret secret)
(let [client (<? (raw-mailbox app opts))
rc (<? (crypto/room-crypto {:secret secret :app-salt app-salt :peer-id (crypto/random-peer-id)}))
mailbox-room-id (<! (crypto/mailbox-room-id secret app-salt))]
(mbx/make-mailbox {:rc rc :client client :mailbox-room-id mailbox-room-id :sleep-fn sleep-fn})))))
|