cautpartener / client / src / core / livemesh.ts
  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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
/**
 * LiveMesh (M3b): connection lifecycle for the RoomHub's ONE shared libp2p
 * node. The hub assembles the node with the full browser transport set
 * (websockets + webRTC + circuit relay, modeled on lib/net.ts); this class
 * owns everything that makes it LIVE:
 *
 *   - the relay websocket connection (dial, jittered-backoff redial, ping
 *     liveness against mobile half-open TCP — net.ts's exact shapes);
 *   - WebRTC upgrades to peers surfaced by pubsub-peer-discovery, so
 *     gossipsub (which never runs on limited circuit connections) meshes on
 *     direct member-to-member links and OrbitDB sync replicates live;
 *   - mobile wake kicks (visibilitychange/pageshow/online) and a battery
 *     valve: hidden for >5 min ⇒ hang up every connection; transports resume
 *     on the next wake kick.
 *
 * Trust model differences from net.ts, on purpose: net.ts gates WebRTC
 * upgrades behind a room-membership beacon because ONE Net == ONE room.
 * The hub's node serves ALL rooms at once, and room trust lives entirely in
 * the E2E layer (sealed OrbitDB entries + the hardened access controller +
 * the app-level author allow-lists) — a connected stranger can subscribe to
 * an opaque topic id and receive ciphertext it can never read, exactly the
 * mailbox node's position. Discovery-topic peers are other users of the same
 * relay, bounded by maxConnections.
 *
 * LIVE IS AN ENHANCEMENT, never load-bearing: if the relay multiaddr is
 * unparseable or the relay is down, everything still flows through the
 * mailbox (the ZDR architecture contract). start() is lazy — the hub calls
 * it on the first opened conversation / Conversații tab, never at boot.
 */

import type { GossipsubEvents } from "@chainsafe/libp2p-gossipsub";
import type { PeerId, PubSub } from "@libp2p/interface";
import type { PingService } from "@libp2p/ping";
import { multiaddr, type Multiaddr } from "@multiformats/multiaddr";
import type { Libp2p } from "libp2p";

export type HubNode = Libp2p<{ identify: unknown; ping: PingService; pubsub: PubSub<GossipsubEvents> }>;

const PING_MS = 25_000; // relay liveness probe (mobile TCP half-open)
const DIAL_THROTTLE_MS = 15_000; // per-peer upgrade attempts (discovery re-fires ~5 s)
const REDIAL_BASE_MS = 500;
const REDIAL_CAP_MS = 15_000;
const HIDDEN_STOP_MS = 5 * 60_000; // hidden this long ⇒ drop transports (battery)

export class LiveMesh {
  #node: HubNode;
  #relayAddr: string;
  #relayPeer: string;
  #relayMa: Multiaddr | null = null; // null ⇒ live permanently disabled (bad config)
  #started = false;
  #suspended = false;
  #stopped = false;
  #relayUp = false;
  #redialTimer: ReturnType<typeof setTimeout> | null = null;
  #redialAttempt = 0;
  #hiddenTimer: ReturnType<typeof setTimeout> | null = null;
  #timers: ReturnType<typeof setInterval>[] = [];
  #windowListeners: [string, () => void][] = [];
  #lastDialAt = new Map<string, number>();

  /** Relay connectivity changes (a UI status hook; optional). */
  onStatus: (up: boolean) => void = () => {};

  constructor(node: HubNode, relayMultiaddr: string) {
    this.#node = node;
    this.#relayAddr = relayMultiaddr;
    this.#relayPeer = relayMultiaddr.split("/p2p/").pop() ?? "";
    try {
      // validate ONCE: harness/self-host configs may carry placeholder
      // multiaddrs — live silently stays off instead of a redial storm
      this.#relayMa = this.#relayPeer.length > 0 ? multiaddr(relayMultiaddr) : null;
    } catch {
      this.#relayMa = null;
    }
  }

  get enabled(): boolean {
    return this.#relayMa !== null;
  }

  get started(): boolean {
    return this.#started;
  }

  get relayUp(): boolean {
    return this.#relayUp;
  }

  /**
   * Go live (idempotent). Wires discovery/connection listeners, wake kicks
   * and the hidden-suspend valve, then dials the relay.
   */
  start(): void {
    if (this.#started || this.#stopped || !this.enabled) return;
    this.#started = true;
    const node = this.#node;

    node.addEventListener("peer:discovery", (e) => {
      const id = e.detail.id;
      const idStr = id.toString();
      if (idStr === this.#relayPeer || idStr === node.peerId.toString()) return;
      void this.#dialPeer(id);
    });
    node.addEventListener("connection:open", (e) => this.#onConnChange(e.detail.remotePeer));
    node.addEventListener("connection:close", (e) => this.#onConnChange(e.detail.remotePeer));

    // relay liveness: ping; two consecutive failures force a hang-up, which
    // lands in #onConnChange and schedules the redial (net.ts shape)
    let pingFails = 0;
    this.#timers.push(
      setInterval(() => {
        if (!this.#relayUp || this.#suspended || this.#stopped) return;
        const relay = this.#relayPeerId();
        if (!relay) return;
        this.#node.services.ping
          .ping(relay, { signal: AbortSignal.timeout(10_000) })
          .then(() => (pingFails = 0))
          .catch(async () => {
            if (++pingFails >= 2) {
              pingFails = 0;
              await this.#node.hangUp(relay).catch(() => {});
            }
          });
      }, PING_MS),
    );

    const kick = (): void => {
      if (this.#stopped) return;
      this.#clearHiddenTimer();
      this.#suspended = false;
      this.#redialAttempt = 0;
      this.#ensureRelay();
    };
    const onVisibility = (): void => {
      if (document.visibilityState === "visible") kick();
      else this.#armHiddenTimer();
    };
    this.#windowListeners = [
      ["visibilitychange", onVisibility],
      ["pageshow", kick],
      ["online", kick],
    ];
    for (const [evt, fn] of this.#windowListeners) window.addEventListener(evt, fn);
    if (document.visibilityState === "hidden") this.#armHiddenTimer();

    this.#ensureRelay();
  }

  // ---- relay connection management -----------------------------------------

  #relayPeerId(): PeerId | null {
    for (const c of this.#node.getConnections()) {
      if (c.remotePeer.toString() === this.#relayPeer) return c.remotePeer;
    }
    return null;
  }

  #ensureRelay(): void {
    if (!this.#started || this.#suspended || this.#stopped || !this.#relayMa) return;
    const connected = this.#node
      .getConnections()
      .some((c) => c.remotePeer.toString() === this.#relayPeer && c.status === "open");
    if (connected) {
      this.#setRelayUp(true);
      return;
    }
    this.#setRelayUp(false);
    void this.#node
      .dial(this.#relayMa, { signal: AbortSignal.timeout(15_000) })
      .then(() => {
        this.#redialAttempt = 0;
        this.#setRelayUp(true);
      })
      .catch(() => this.#scheduleRedial());
  }

  #scheduleRedial(): void {
    if (this.#redialTimer || this.#suspended || this.#stopped) return;
    // jittered exponential backoff — net.ts / v1 signaling shape
    const base = Math.min(REDIAL_CAP_MS, REDIAL_BASE_MS * 2 ** this.#redialAttempt++);
    const delay = base * (0.5 + Math.random());
    this.#redialTimer = setTimeout(() => {
      this.#redialTimer = null;
      this.#ensureRelay();
    }, delay);
  }

  #setRelayUp(up: boolean): void {
    if (this.#relayUp !== up) {
      this.#relayUp = up;
      try {
        this.onStatus(up);
      } catch {}
    }
  }

  #onConnChange(remotePeer: PeerId): void {
    if (!this.#started || this.#stopped) return;
    if (remotePeer.toString() !== this.#relayPeer) return;
    const stillUp = this.#node
      .getConnections()
      .some((c) => c.remotePeer.toString() === this.#relayPeer && c.status === "open");
    this.#setRelayUp(stillUp);
    if (!stillUp) this.#scheduleRedial();
  }

  // ---- peer upgrades -------------------------------------------------------

  /**
   * Dial a discovered peer's WebRTC upgrade through the relay circuit. The
   * connection carries only what the peer is entitled to anyway (gossipsub
   * ciphertext for shared topics, bitswap for ciphertext blocks); peers with
   * no shared room converge to zero traffic and age out of the connection
   * manager. Throttled — discovery re-announces every ~5 s.
   */
  async #dialPeer(id: PeerId): Promise<void> {
    if (!this.#started || this.#suspended || this.#stopped || !this.#relayUp) return;
    const idStr = id.toString();
    const conns = this.#node.getConnections().filter((c) => c.remotePeer.toString() === idStr && c.status === "open");
    if (conns.some((c) => c.remoteAddr.toString().includes("/webrtc"))) return; // upgraded already
    const last = this.#lastDialAt.get(idStr) ?? 0;
    if (Date.now() - last < DIAL_THROTTLE_MS) return;
    this.#lastDialAt.set(idStr, Date.now());
    if (this.#lastDialAt.size > 256) {
      // bounded book-keeping: drop the stalest half
      const entries = [...this.#lastDialAt.entries()].sort((a, b) => a[1] - b[1]);
      for (const [k] of entries.slice(0, 128)) this.#lastDialAt.delete(k);
    }
    const ma = multiaddr(`${this.#relayAddr}/p2p-circuit/webrtc/p2p/${idStr}`);
    await this.#node.dial(ma, { signal: AbortSignal.timeout(30_000) }).catch(() => {
      /* the other side may dial us instead */
    });
  }

  // ---- battery valve -------------------------------------------------------

  #armHiddenTimer(): void {
    if (this.#hiddenTimer || !this.#started || this.#stopped) return;
    this.#hiddenTimer = setTimeout(() => {
      this.#hiddenTimer = null;
      this.#suspend();
    }, HIDDEN_STOP_MS);
  }

  #clearHiddenTimer(): void {
    if (this.#hiddenTimer) {
      clearTimeout(this.#hiddenTimer);
      this.#hiddenTimer = null;
    }
  }

  /** Hidden too long: drop every connection; the next wake kick resumes. */
  #suspend(): void {
    if (this.#suspended || this.#stopped) return;
    this.#suspended = true;
    if (this.#redialTimer) {
      clearTimeout(this.#redialTimer);
      this.#redialTimer = null;
    }
    this.#setRelayUp(false);
    for (const c of this.#node.getConnections()) {
      void this.#node.hangUp(c.remotePeer).catch(() => {});
    }
  }

  // ---- app-plane gossipsub topics (M5 feed announce valve) -----------------

  #topicHandlers = new Map<string, (data: Uint8Array) => void>();
  #msgListenerOn = false;

  /**
   * Subscribe to a raw gossipsub topic and route its messages to `onMessage`.
   * Purely an ENHANCEMENT (feed head announcements): failures are swallowed,
   * nothing load-bearing may ever depend on a topic delivery.
   */
  joinTopic(topic: string, onMessage: (data: Uint8Array) => void): void {
    if (this.#stopped) return;
    this.#topicHandlers.set(topic, onMessage);
    if (!this.#msgListenerOn) {
      this.#msgListenerOn = true;
      this.#node.services.pubsub.addEventListener("message", (e) => {
        const handler = this.#topicHandlers.get(e.detail.topic);
        if (!handler) return;
        try {
          handler(e.detail.data);
        } catch {}
      });
    }
    try {
      this.#node.services.pubsub.subscribe(topic);
    } catch {}
  }

  leaveTopic(topic: string): void {
    this.#topicHandlers.delete(topic);
    try {
      this.#node.services.pubsub.unsubscribe(topic);
    } catch {}
  }

  /** Best-effort publish on an app topic (zero-peer publishes are allowed). */
  async publishTopic(topic: string, data: Uint8Array): Promise<void> {
    try {
      await this.#node.services.pubsub.publish(topic, data);
    } catch {}
  }

  // ---- presence ------------------------------------------------------------

  /**
   * How many OTHER devices are gossiping on `topic` (a room's OrbitDB address)
   * right now. Subscriptions only propagate over direct gossipsub links, so a
   * non-zero count means a same-room device is connected and live-replicating.
   * Honest limit: one of MY other devices in the room counts too (peer ids are
   * session-ephemeral by design — nothing maps them to identities).
   */
  subscribersOf(topic: string): number {
    try {
      return this.#node.services.pubsub.getSubscribers(topic).length;
    } catch {
      return 0;
    }
  }

  /** Debug/e2e visibility — no room ids, just transport state. */
  get debug(): { enabled: boolean; started: boolean; suspended: boolean; relayUp: boolean; conns: string[] } {
    return {
      enabled: this.enabled,
      started: this.#started,
      suspended: this.#suspended,
      relayUp: this.#relayUp,
      conns: this.#node.getConnections().map((c) => c.remoteAddr.toString()),
    };
  }

  /** Session teardown (the hub stops the node itself right after). */
  stop(): void {
    this.#stopped = true;
    for (const [evt, fn] of this.#windowListeners) window.removeEventListener(evt, fn);
    this.#windowListeners = [];
    for (const t of this.#timers) clearInterval(t);
    this.#timers = [];
    if (this.#redialTimer) clearTimeout(this.#redialTimer);
    this.#clearHiddenTimer();
  }
}

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