peer-kit / src / ardegazu / peer / node / env.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
;; ported-from: src/node/env.ts @ v1.3.0
;;
;; Node environment shims for the suite's origin-gated managed relay.
;;
;; The relay, TURN-credential and mailbox-mint endpoints admit requests by
;; browser Origin. Node sends none by default, so both the WebSocket dial and
;; the credential fetches would be refused. These installers patch the globals
;; once, scoped by hostname, so every kit module works unchanged.
;;
;; This namespace is genuinely STATEFUL module-level init and is ported as
;; such: `installed` remembers the origin and throws on a conflicting one, the
;; installers overwrite globalThis, and selfPeerIds hangs a Set off globalThis
;; so it is shared across kits in one process.
(ns ardegazu.peer.node.env
  (:require ["ws" :refer (WebSocket)]
            ["node:fs" :refer (mkdirSync readFileSync writeFileSync)]
            ["node:path" :refer (dirname)]
            [shadow.cljs.modern :refer (defclass)]))

;; Hosts that get the Origin header (the suite's signaling infrastructure).
(def ^:private DEFAULT-HOSTS #js [#"\.ardegazu\.ro$" #"\.ap2p\.ro$"])

(defn self-peer-ids
  "Every libp2p node this PROCESS runs, by peer id — shared across kits via a
   global so sibling nodes never dial each other. A bot easily runs several
   nodes (social + one per room); app-wide discovery would otherwise make them
   probe each other constantly, wasting relay resources and — worse — driving
   the native WebRTC layer through failed-upgrade teardowns that can abort the
   process (node-datachannel ThreadSafeCallback cancellation)."
  []
  (let [g js/globalThis]
    (or (unchecked-get g "__ardegazuSelfPeers")
        (let [s (js/Set.)]
          (unchecked-set g "__ardegazuSelfPeers" s)
          s))))

(defn- host-matches? [host allow]
  (.some ^js allow (fn [re] (.test ^js re host))))

;; the ONE origin these shims were installed for; a second, different origin
;; is a programming error (the process can only present as one app)
(defonce ^:private installed (volatile! nil))

;; The origin/allowlist the WebSocket subclass below reads. The TS original
;; built a fresh capturing class per installOriginWebSocket call; `defclass` is
;; top-level only, so the config lives here instead. Behaviour is the same:
;; only the most recently installed configuration is ever on globalThis, and
;; the constructor reads it per dial.
(defonce ^:private ws-origin (volatile! nil))
(defonce ^:private ws-hosts (volatile! DEFAULT-HOSTS))

(defclass OriginWebSocket
  (extends WebSocket)
  (constructor [this url protocols]
    (super url protocols
           (if (host-matches? (.-hostname (js/URL. url)) @ws-hosts)
             (js-obj "origin" @ws-origin)
             js/undefined))
    ;; the libp2p transport reads/writes browser-flavored fields; ws supports
    ;; both, but binaryType must be one the transport expects
    (set! (.-binaryType this) "arraybuffer")))

(defn install-origin-web-socket
  "Replace globalThis.WebSocket with a ws-backed class that sends the Origin
   header when dialing suite hosts. @libp2p/websockets@10 dials the *global*
   WebSocket with no options, so subclassing is the only injection point."
  ([origin] (install-origin-web-socket origin js/undefined))
  ([origin hosts]
   (vreset! ws-origin origin)
   (vreset! ws-hosts (if (identical? hosts js/undefined) DEFAULT-HOSTS hosts))
   (unchecked-set js/globalThis "WebSocket" OriginWebSocket)
   js/undefined))

(defn install-origin-fetch
  "Wrap globalThis.fetch to add the Origin header on suite-host requests."
  ([origin] (install-origin-fetch origin js/undefined))
  ([origin hosts]
   (let [allow (if (identical? hosts js/undefined) DEFAULT-HOSTS hosts)
         base (unchecked-get js/globalThis "fetch")]
     (unchecked-set
      js/globalThis "fetch"
      (fn [input init]
        (let [url (if (or (string? input) (instance? js/URL input))
                    (js/URL. input)
                    (js/URL. (unchecked-get input "url")))]
          (if-not (host-matches? (.-hostname url) allow)
            (base input init)
            (let [headers (js/Headers.
                           (or (when (some? init) (unchecked-get init "headers"))
                               (when (instance? js/Request input) (unchecked-get input "headers"))
                               js/undefined))]
              (when-not (.has headers "origin") (.set headers "origin" origin))
              (base input (let [o (js-obj)]
                            (when (and (some? init) (not (identical? init js/undefined)))
                              (js/Object.assign o init))
                            (unchecked-set o "headers" headers)
                            o))))))))))

(defn install-origin
  "Install both shims for the given origin (e.g. \"https://chat.ardegazu.ro\" —
   must be on the app's allowlist). Idempotent; call once at process start,
   before any libp2p node or credential fetch is created."
  ([origin] (install-origin origin js/undefined))
  ([origin hosts]
   (let [cur @installed]
     (when-not (identical? cur origin)
       ;; `if (installed) throw` — a JS truthiness check, so an empty-string
       ;; origin falls through exactly as it did in the TS original
       (when ^boolean (js* "!!(~{})" cur)
         (throw (js/Error. (str "origin shims already installed for " cur))))
       (vreset! installed origin)
       (install-origin-web-socket origin hosts)
       (install-origin-fetch origin hosts)))
   js/undefined))

(defn- create-file-storage
  "A `Pick<Storage, getItem|setItem|removeItem>` backed by one JSON file."
  [file]
  (let [data (volatile! (js-obj))]
    (when (some? file)
      (try
        (vreset! data (js/JSON.parse (readFileSync file "utf8")))
        (catch :default _ nil))) ; fresh store
    (let [flush (fn []
                  (when (some? file)
                    (try
                      (mkdirSync (dirname file) (js-obj "recursive" true))
                      (writeFileSync file (js/JSON.stringify @data))
                      (catch :default _ nil))))] ; best-effort persistence
      (js-obj
       "getItem" (fn [k] (if (js-in k @data) (unchecked-get @data k) nil))
       "setItem" (fn [k v] (unchecked-set @data k (js/String v)) (flush) js/undefined)
       "removeItem" (fn [k] (js-delete @data k) (flush) js/undefined)))))

(defn install-browser-globals
  "Minimal browser globals for the social layer, written for the DOM but only
   touching it shallowly: `window.setTimeout`, a visibility check that should
   always read \"visible\" in a headless peer, and `localStorage` for small
   persisted sets (receipts, friends, seen-rings, the self-sync marker).
   Storage is file-backed — pass a path under your state directory."
  ([] (install-browser-globals js/undefined))
  ([storage-file]
   (let [g js/globalThis
         noop (fn [] js/undefined)]
     (when-not ^boolean (js* "!!(~{})" (unchecked-get g "window"))
       (unchecked-set g "window"
                      (js-obj "setTimeout" js/setTimeout
                              "clearTimeout" js/clearTimeout
                              "setInterval" js/setInterval
                              "clearInterval" js/clearInterval
                              "addEventListener" noop
                              "removeEventListener" noop)))
     (when-not ^boolean (js* "!!(~{})" (unchecked-get g "document"))
       (unchecked-set g "document"
                      (js-obj "visibilityState" "visible"
                              "addEventListener" noop
                              "removeEventListener" noop)))
     (when (or (not (js-in "localStorage" g))
               (identical? js/undefined (unchecked-get g "localStorage")))
       (unchecked-set g "localStorage"
                      (create-file-storage (if (identical? storage-file js/undefined)
                                             nil
                                             storage-file))))
     js/undefined)))

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