p2p-cljs

p2p-cljs/

ird-p2p-cljs

ClojureScript peer SDK for the ird p2p service on Node.js (>= 18) — a sibling of the Swift SDK ird-p2p-swift, byte-for-byte interoperable with it.

An ird p2p app gives you a hostname (<name>.ap2p.ro or your own domain) that serves a blind signaling relay, STUN/TURN, and an offline mailbox. This SDK 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.

Designed for ClojureScript rather than ported: core.async channels instead of blocking calls or promises, errors as values, injectable transports instead of dynamic vars. Bytes never cross the public API as JavaScript objects — they travel as base64 strings, the same form they take on the wire.

Runtime dependencies: ClojureScript and core.async (from deps.edn), plus ws from npm for the default relay socket. Node 18+ (global fetch, crypto.subtle, TextEncoder).

Install

A source library, consumed as a git dependency — no registry, no build step of its own. The usual setup is shadow-cljs reading deps.edn:

;; deps.edn
{:paths ["src"]
 :deps {ro.ird/ird-p2p-cljs {:git/url "https://ird.cod-sursa.ro/p2p-cljs.git" :git/sha "…"}
        thheller/shadow-cljs {:mvn/version "3.5.0"}}}
;; shadow-cljs.edn
{:deps true
 :builds {:app {:target :node-script :main my.app/main :output-to "out/app.js"}}}

with ws in your package.json dependencies. Pin :git/sha to the latest commit (or use :git/tag with its sha prefix):

git ls-remote https://ird.cod-sursa.ro/p2p-cljs.git HEAD

The landing page at https://ird.cod-sursa.ro/p2p-cljs/ shows the current release coordinates ready to paste, and https://ird.cod-sursa.ro/releases.json carries them for scripts and agents (head, tag, tag_sha per repo).

Quickstart

(require '[cljs.core.async :refer [go <!]]
         '[ird.p2p.async :refer [<?]]        ; <! that rethrows an error value
         '[ird.p2p :as p2p]
         '[ird.p2p.errors :as err])

;; your app hostname, a salt that namespaces your rooms, and an Origin the
;; app's allowlist admits (see "Origins" below)
(def app (p2p/make-app {:host "myapp.ap2p.ro" :app-salt "myapp/v1"
                        :origin "https://myapp.example"}))

;; create a room and share its secret in an invite link…
(def secret (p2p/random-secret))
(def invite (p2p/invite-url "myapp://join" secret))
;; …or join from one
(go
  (let [room (<? (p2p/join app (p2p/invite-secret invite)))]
    ;; drain the room's events — each is a plain map
    (go (loop []
          (when-let [e (<! (p2p/events room))]
            (case (:type e)
              :peer-joined (println "member:" (:peer e))
              :peer-ready  (println (:peer e) "reachable" (:via e))   ; :relayed, then :direct
              :message     (println (:from e) "says" (get-in e [:message :value]))
              :peer-left   (println "left:" (:peer e))
              nil)
            (recur))))
    ;; send to everyone, or to one peer
    (<? (p2p/broadcast room (p2p/json {:text "salut"})))
    (<? (p2p/send room some-peer (p2p/data image-bytes-b64)))
    (<? (p2p/leave room))))

Every asynchronous function returns a core.async promise-chan that delivers exactly one value — the result, or an ex-info error value (see Errors). Nothing is thrown out of an SDK function; (<? ch) inside a go block rethrows an error value so ordinary try/catch applies, and ird.p2p.async/go-try is a go whose body may throw. room events are an ordinary channel to drain.

A message is (p2p/json <any-clojure-data>) or (p2p/data <base64-string>); on the way in, {:type :message} events carry {:message {:kind :json :value ...}} or {:message {:kind :data :payload "<base64>"}}. Messages of any size work: large ones are chunked, and anything above the relay's frame cap flows once a data channel is open or, on the relay, at the node's paced rate. Bytes are base64 the whole way; ird.p2p.impl.bytes has helpers (utf8->b64, b64->utf8, bytes->b64, …) if you have raw bytes to hand in.

One API, two relay protocols

The default transport is the node's /ws relay. The same room API runs over real libp2p gossipsub by passing a different transport:

(require '[ird.p2p.transport.libp2p :as lp])
(def app (p2p/make-app {:host "myapp.ap2p.ro" :app-salt "myapp/v1"
                        :transport (lp/transport)}))          ; gossipsub instead of /ws

The room becomes a pubsub topic derived from the room id; directed signal frames are reconstructed over pubsub carrying :from/:to in-band, so the envelope AAD stays exact and K_sig(from) works identically. Circuit-relay multiaddrs from the app descriptor are dialed when present. The relayed path is covered by the always-on loopback test ird.p2p.libp2p-node-test/relay-hop-two-nodes-signal-through-circuit-relay (two peers reserve on a third in-process node and run the full handshake through its /p2p-circuit hop), while validation against a production relay stays pending — the live smoke suite (npm run test:live, gated on IRD_P2P_HOST) is the harness for when a real relay host exists. The libp2p node is injectable (:node / :node-fn), so this stays testable offline and you can share one node across rooms.

To use the :libp2p transport, add the libp2p stack to your package.json. Use the interface-v3 line: libp2p 3.x together with @libp2p/gossipsub 17.x — the renamed successor of @chainsafe/libp2p-gossipsub, and the first gossipsub published against @libp2p/interface v3 — plus the @libp2p-scoped noise and yamux. The versions below are the newest of each package whose @libp2p/interface dependency is ^3 (they all dedupe onto one @libp2p/interface@3.3.x), and they are proven to deliver between two nodes: this SDK's npm test includes the always-on two-node loopback test ird.p2p.libp2p-node-test/two-real-nodes-signal-through-gossipsub.

"dependencies": {
  "libp2p": "^3.3.11",
  "@libp2p/gossipsub": "^17.1.1",
  "@libp2p/noise": "^17.0.2",
  "@libp2p/yamux": "^8.0.3",
  "@libp2p/identify": "^4.1.14",
  "@libp2p/tcp": "^11.0.28",
  "@libp2p/websockets": "^10.1.21",
  "@libp2p/circuit-relay-v2": "^4.2.13",
  "@multiformats/multiaddr": "^13.0.3"
}

The libp2p packages are ESM-only. Under a shadow-cljs :node-script/:node-library/:esm build the SDK imports them with import() natively; under :node-test define the import helper the SDK uses in a natively-loaded entry file (this repo's shadow-cljs.edn shows the one-line :prepend).

WebRTC data channels

Rooms are relay-only unless you give make-app a :pc-factory — an implementation of the ird.p2p.webrtc/PeerConnectionFactory protocol. The peer with the lexicographically lower 32-hex peer id offers a negotiated data channel (id 0, label ird, ordered, reliable); ICE candidates trickle; when a channel is lost the offerer retries after 2, 5 and 8 seconds while messages keep flowing over the relay.

The SDK ships an in-memory factory (ird.p2p.webrtc/in-memory-factory) that pairs two rooms inside one process — that is how the offline tests open direct channels with no network, and it is the default when no factory is supplied. TURN credentials are fetched and cached for you (p2p/ice-configuration).

For real WebRTC on Node the SDK ships an optional factory over node-datachannel (libdatachannel bindings with prebuilt binaries for the common platforms): ird.p2p.impl.wrtc/wrtc-factory. It lives under impl/ because it is the one place that touches the native module, so it is deliberately not re-exported from the pure ird.p2p facade; require it explicitly and add node-datachannel to your own dependencies (it is an optionalDependencies entry here, so the default relay-only path never needs it):

(require '[ird.p2p.impl.wrtc :as wrtc])
(def app (p2p/make-app {:host "myapp.ap2p.ro" :app-salt "myapp/v1"
                        :pc-factory (wrtc/wrtc-factory)}))            ; rooms now open real data channels

The factory translates the ICE configuration to libdatachannel servers (stun/turn/turns, udp/tcp/tls relays, :force-relay? → relay-only), emits candidates in the browser/Swift form and, because node-datachannel keeps the process alive while initialized, releases the library once the last connection closes. npm test includes an always-on test that connects two real data channels over loopback through the in-process relay (ird.p2p.wrtc-test); set IRD_P2P_NO_WRTC=1 to skip it where no prebuilt binary exists. The protocol stays open: any other library (werift, ...) can back a PeerConnectionFactory the same way.

Identity and invite links

Rooms work anonymously. To let peers recognise each other across sessions, give them an identity — a 32-byte Ed25519 seed:

(require '[ird.p2p.identity :as id])
(go
  (let [me (<? (id/generate {:name "Ana"}))                          ; or (id/from-seed seed opts)
        room (<? (p2p/join app secret {:identity me}))]
    ;; peers receive {:type :peer-identity :peer p :identity {...}} with a verified fingerprint
    (println (get-in me [:fingerprint :emoji]))))                     ; four emoji, e.g. 🐸📎🖍️📎

The identity signs a binding of the current session to the room; it never affects membership (only the room secret does). A fingerprint is SHA-256(publicKey) as a full lowercase hex digest and four emoji. This SDK deliberately does not set or surface the hue/glyph fields of a hello — they are unsigned display opinions — so an identity is {:public-key :fingerprint :name}; peers that announce them (the Swift SDK apps do) interoperate fully, the fields are simply dropped on receipt, and apps wanting avatars or colours carry them in message payloads (any JSON). Invite links carry the secret in the URL fragment, which URL loaders never send to a server; p2p/invite-secret parses it back.

Offline mailbox

When the app has its mailbox enabled, peers can leave sealed messages for members who are not online at the same time:

(require '[ird.p2p.mailbox :as mailbox])
(go
  (let [mbx (<? (p2p/open-mailbox app secret))]                       ; config error if the app has no mailbox
    (<? (mailbox/deposit mbx (p2p/json {:text "offline"})))           ; sealed under the room's mailbox key
    (let [{:keys [items cursor gap?]} (<? (mailbox/replay-all mbx {:after saved-cursor}))]
      (when gap? (println "history truncated")))))                    ; messages were evicted between visits

mailbox/enqueue + mailbox/flush-outbox queue deposits with retry and backoff (413 drops the item). Mailbox sequence numbers (:seq) are opaque ordered string tokens — never parse them into a number; mailbox/history-gap? compares them with BigInt. Deposits are sealed under the room-wide mailbox key (random IVs) and filed under a mailbox room id the node cannot correlate with the relay room; undecryptable entries are dropped silently.

Tokens come from the node's open mint by default, which requires an allowed Origin. If the open mint is off, inject a :token-fn that fetches tokens from your own backend:

(p2p/open-mailbox app secret {:token-fn (mailbox/static-token-fn (fn [room] (my-backend/mint room)))})

Origins

The app's origin allowlist gates the WebSocket upgrade, /turn-credentials and the mailbox open mint. It is a browser control, and a Node client sends no Origin header on its own — so with a non-empty allowlist a Node client is refused with 403 until you set :origin to a value the allowlist admits (any string of your choosing, added with ird p2p origins). With an empty allowlist any client may connect and no :origin is needed. Mailbox reads and writes with a valid token need no Origin at all; only minting does.

Errors

Nothing is thrown out of an SDK function; failures travel on the channel as ex-info values with a :type:

:type ex-data when
:ird.p2p/upgrade-rejected :status the relay refused the WebSocket: 403 Origin not allowed (or missing), 404 unknown host, 429 too many connections
:ird.p2p/relay-error :code the relay answered an error frame (room-full, too-many-rooms, rate-limited, …); delivered as a :relay-error room event too
:ird.p2p/http-error :status :code :response a mailbox / TURN / mint request failed; :code is the node's error code
:ird.p2p/peer-unknown :peer sending to a peer that is not a verified member
:ird.p2p/frame-too-large :bytes :limit one relay frame would exceed max_frame_bytes (the SDK chunks, so this means a misconfiguration)
:ird.p2p/config-error :detail a precondition failed before any request (blank room, mailbox disabled, closed mint without a token provider, bad message)
:ird.p2p/transport-error :detail the network layer failed (DNS, TLS, socket, non-JSON body)
:ird.p2p/timeout :detail a request or handshake passed its deadline
:ird.p2p/not-joined / :ird.p2p/closed — a send before joining / after leaving

Predicates and the types set live in ird.p2p.errors (upgrade-rejected?, http-error?, …, and error? for any error value). Remote tampering is never an error: envelopes that do not decrypt are dropped silently, by design (see PROTOCOL.md, "decrypt or drop").

Protocol and interoperability

PROTOCOL.md specifies the end-to-end bytes: the HKDF derivations (room id, per-sender K_sig/K_msg, mailbox room id, K_mbx), the session sealer (IV = 4 random epoch bytes ‖ 8-byte big-endian counter), the sealed JSON / binary / at-rest envelopes and their AAD strings, the inner messages, chunking, membership and WebRTC negotiation. The golden vectors under test/ird/p2p/vectors/ are copied verbatim from the Swift SDK and every one passes byte-for-byte, so the two SDKs interoperate.

Public API is pure ClojureScript

Every public namespace (src/ird/p2p/** except src/ird/p2p/impl/**) contains zero JavaScript interop: no ^js, no js/…, no property access, no #js. All interop — WebCrypto, ws, fetch, libp2p — lives under src/ird/p2p/impl/, and everything crossing a public boundary is plain ClojureScript data (maps, vectors, keywords, strings, numbers) plus core.async channels, with bytes as base64 strings. A gate test (ird.p2p.api-purity-test) scans the source and fails on any leak.

Why the interface-v3 libp2p line

The libp2p adapter follows the interface-v3 line of js-libp2p. For a while it had to stay on v2: the last @chainsafe/libp2p-gossipsub (14.1.2) declared @libp2p/interface@^2, and paired with libp2p v3 its topology never registered with the v3 registrar, so the pubsub RPC stream never opened and no frame was delivered between nodes — a failure compile success cannot detect. Gossipsub has since moved to @libp2p/gossipsub (17.x, @libp2p/interface@^3.3), which closes that gap. The set in package.json — libp2p 3.3.x, @libp2p/gossipsub 17.1.x, @libp2p/noise 17.x, @libp2p/yamux 8.x and the newest v3-interface versions of tcp, websockets, identify and circuit-relay-v2 — is coherent and demonstrably delivers frames between two real nodes: two-real-nodes-signal-through-gossipsub in ird.p2p.libp2p-node-test dials two in-process nodes over loopback tcp, runs the hello/verify handshake and exchanges directed and broadcast frames both ways, and it is part of every npm test. Do not mix the lines: any package still on @libp2p/interface@^2 (including the old @chainsafe/libp2p-gossipsub) silently breaks delivery.

Tests

npm install          # ws + shadow-cljs + the interface-v3 libp2p stack + node-datachannel (dev only; ClojureScript comes from deps.edn)
npm test             # offline: golden-vector interop gate, codec, replay window, chunking, descriptor,
                     # transport over an in-process relay, rooms + WebRTC over the in-memory factory,
                     # mailbox against scripted transports, the libp2p adapter over an in-process bus,
                     # two real libp2p nodes signalling through gossipsub over loopback tcp,
                     # two real node-datachannel data channels connecting over loopback,
                     # and the zero-interop API-purity gate
npm run test:live    # live smoke suite; needs IRD_P2P_HOST + IRD_P2P_ORIGIN (skips itself otherwise)

Tests inject fake transports (:http-fn, a :socket-fn fake relay, a :node-fn pubsub bus, the in-memory pc-factory) and a manual clock, so almost the whole suite runs with no network. The two exceptions are always-on loopback tests: the two-node libp2p test binds loopback tcp and settles in under a second (set IRD_P2P_NO_LIBP2P_NODE=1 to skip it in a sandbox that cannot bind sockets), and the real-WebRTC test connects two node-datachannel peers over loopback (set IRD_P2P_NO_WRTC=1 to skip it where node-datachannel has no prebuilt binary).

License

MIT.

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-cljs.git