@muhkoo/connect 0.10.3-alpha.0 → 0.10.5-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5057,7 +5057,7 @@ function base64UrlDecode(s) {
5057
5057
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
5058
5058
  * `package.json` `version`.
5059
5059
  */
5060
- const VERSION = "0.10.3-alpha.0";
5060
+ const VERSION = "0.10.5-alpha.0";
5061
5061
 
5062
5062
  /**
5063
5063
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -8739,10 +8739,13 @@ class PeerExchange {
8739
8739
  * to Space membership. The sender (`from`) is server-stamped and can't be
8740
8740
  * spoofed; the payload is opaque to the server.
8741
8741
  *
8742
- * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
8743
- * accelerator can optionally unicast peer-pair signaling instead of broadcasting
8744
- * (a small, backward-compatible `pub` enhancement); the client filters by `to`
8745
- * regardless.
8742
+ * Peers are identified by a unique **peer id** (per device/connection), carried
8743
+ * in the envelope's `from`/`to` — NOT by the server-stamped member id. This is
8744
+ * essential: one user's own devices share a member id, so the server's `from`
8745
+ * can't tell them apart (and would make each treat the others' signals as its
8746
+ * own echo). We therefore route entirely on the envelope peer ids and broadcast
8747
+ * to the whole Space, filtering client-side. The server `from` still guarantees
8748
+ * the sender is an authenticated Space member.
8746
8749
  */
8747
8750
  const P2P_SUBJECT = "__p2p__";
8748
8751
  class SpaceSignaler {
@@ -8754,11 +8757,11 @@ class SpaceSignaler {
8754
8757
  }
8755
8758
  /** Send a signal to one peer (or `"*"`). */
8756
8759
  send(to, kind, data) {
8757
- const env = { to, kind, data };
8758
- // Ask the server to unicast for a directed target; broadcast for "*".
8759
- // `to` also stays in the envelope so a broadcast-only server still works
8760
- // (the client filters by it).
8761
- this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
8760
+ const env = { from: this.myId, to, kind, data };
8761
+ // Always broadcast to the Space: the server can't unicast to a specific
8762
+ // DEVICE (a user's devices share a member id), so we route client-side on
8763
+ // the envelope peer id. Signaling frames are tiny.
8764
+ this.space.sendEphemeral(P2P_SUBJECT, env);
8762
8765
  }
8763
8766
  /** Announce/withdraw P2P availability to all Space members. */
8764
8767
  broadcast(kind, data) {
@@ -8766,18 +8769,22 @@ class SpaceSignaler {
8766
8769
  }
8767
8770
  /**
8768
8771
  * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
8769
- * echoes. Returns an unsubscribe function.
8772
+ * echoes. Returns an unsubscribe function. Routing is entirely by the envelope
8773
+ * peer id (`env.from`/`env.to`), so a user's own devices — which share the
8774
+ * server-stamped `e.from` — are correctly distinguished.
8770
8775
  */
8771
8776
  onSignal(cb) {
8772
8777
  return this.space.onEphemeral((e) => {
8773
- if (e.subject !== P2P_SUBJECT || e.from === this.myId)
8778
+ if (e.subject !== P2P_SUBJECT)
8774
8779
  return;
8775
8780
  const env = e.data;
8776
- if (!env || typeof env.kind !== "string")
8781
+ if (!env || typeof env.kind !== "string" || typeof env.from !== "string")
8777
8782
  return;
8783
+ if (env.from === this.myId)
8784
+ return; // our own device's echo
8778
8785
  if (env.to !== this.myId && env.to !== "*")
8779
8786
  return;
8780
- cb(e.from, env.kind, env.data);
8787
+ cb(env.from, env.kind, env.data);
8781
8788
  });
8782
8789
  }
8783
8790
  }
@@ -8812,14 +8819,20 @@ class WebRtcTransport {
8812
8819
  offSignal;
8813
8820
  iceServers;
8814
8821
  maxPeers;
8822
+ debug;
8815
8823
  nextMsgId = 1;
8816
8824
  constructor(signaler, myId, opts = {}) {
8817
8825
  this.signaler = signaler;
8818
8826
  this.myId = myId;
8819
8827
  this.iceServers = opts.iceServers ?? DEFAULT_ICE;
8820
8828
  this.maxPeers = opts.maxPeers ?? 8;
8829
+ this.debug = opts.debug ?? false;
8821
8830
  this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
8822
8831
  }
8832
+ log(...a) {
8833
+ if (this.debug)
8834
+ console.info("[muhkoo:p2p]", ...a);
8835
+ }
8823
8836
  peers() {
8824
8837
  return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
8825
8838
  }
@@ -8870,19 +8883,42 @@ class WebRtcTransport {
8870
8883
  // -- internals -----------------------------------------------------------
8871
8884
  newPc(peerId) {
8872
8885
  const pc = new RTCPeerConnection({ iceServers: this.iceServers });
8873
- const conn = { pc, reasm: new Map() };
8886
+ const conn = { pc, reasm: new Map(), remoteSet: false, pendingIce: [] };
8874
8887
  this.conns.set(peerId, conn);
8888
+ const short = String(peerId).slice(0, 8);
8875
8889
  pc.onicecandidate = (e) => {
8876
- if (e.candidate)
8890
+ if (e.candidate) {
8891
+ this.log(`${short} local candidate ${e.candidate.type ?? "?"} ${(e.candidate.address ?? e.candidate.candidate).toString().slice(0, 40)}`);
8877
8892
  this.signaler.send(peerId, "ice", e.candidate.toJSON());
8893
+ }
8878
8894
  };
8879
8895
  pc.onconnectionstatechange = () => {
8896
+ this.log(`${short} connection=${pc.connectionState}`);
8880
8897
  if (["failed", "closed", "disconnected"].includes(pc.connectionState))
8881
8898
  this.drop(peerId);
8882
8899
  };
8900
+ if (this.debug) {
8901
+ pc.oniceconnectionstatechange = () => this.log(`${short} ice=${pc.iceConnectionState}`);
8902
+ pc.onicegatheringstatechange = () => this.log(`${short} gathering=${pc.iceGatheringState}`);
8903
+ }
8883
8904
  pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
8884
8905
  return conn;
8885
8906
  }
8907
+ /** Mark a peer's remote description as set and apply any ICE candidates that
8908
+ * arrived before it (dropping them, the old behavior, breaks LAN dials). */
8909
+ async markRemoteSet(conn) {
8910
+ conn.remoteSet = true;
8911
+ const pending = conn.pendingIce;
8912
+ conn.pendingIce = [];
8913
+ for (const cand of pending) {
8914
+ try {
8915
+ await conn.pc.addIceCandidate(cand);
8916
+ }
8917
+ catch { /* stale/duplicate */ }
8918
+ }
8919
+ if (pending.length)
8920
+ this.log(`flushed ${pending.length} queued ICE candidate(s)`);
8921
+ }
8886
8922
  async initiate(peerId) {
8887
8923
  const conn = this.newPc(peerId);
8888
8924
  const dc = conn.pc.createDataChannel("blocks");
@@ -8904,22 +8940,32 @@ class WebRtcTransport {
8904
8940
  return; // mesh cap
8905
8941
  const conn = this.conns.get(from) ?? this.newPc(from);
8906
8942
  await conn.pc.setRemoteDescription(data);
8943
+ await this.markRemoteSet(conn);
8907
8944
  const answer = await conn.pc.createAnswer();
8908
8945
  await conn.pc.setLocalDescription(answer);
8909
8946
  this.signaler.send(from, "answer", answer);
8910
8947
  }
8911
8948
  else if (kind === "answer") {
8912
8949
  const conn = this.conns.get(from);
8913
- if (conn)
8950
+ if (conn) {
8914
8951
  await conn.pc.setRemoteDescription(data);
8952
+ await this.markRemoteSet(conn);
8953
+ }
8915
8954
  }
8916
8955
  else if (kind === "ice") {
8917
8956
  const conn = this.conns.get(from);
8918
8957
  if (conn && data) {
8958
+ // Queue candidates that arrive before setRemoteDescription — adding
8959
+ // them early throws and (previously) dropped them, which killed the
8960
+ // connection on fast LANs where ICE beats the SDP answer.
8961
+ if (!conn.remoteSet) {
8962
+ conn.pendingIce.push(data);
8963
+ return;
8964
+ }
8919
8965
  try {
8920
8966
  await conn.pc.addIceCandidate(data);
8921
8967
  }
8922
- catch { /* pre-SRD race */ }
8968
+ catch { /* stale */ }
8923
8969
  }
8924
8970
  }
8925
8971
  else if (kind === "bye") {
@@ -9388,6 +9434,7 @@ class PeerNetwork {
9388
9434
  new WebRtcTransport(this.signaler, opts.myId, {
9389
9435
  iceServers: opts.iceServers,
9390
9436
  maxPeers: opts.maxPeers,
9437
+ debug: opts.debug,
9391
9438
  });
9392
9439
  this.host = buildEngineHost(opts);
9393
9440
  // Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
@@ -9514,6 +9561,10 @@ class SpaceNamespace {
9514
9561
  spaceCache;
9515
9562
  /** Spaces built this session, by id — used to route offline send replays. */
9516
9563
  openSpaces = new Map();
9564
+ /** Unique id for THIS device/session — the P2P peer identity. A user's own
9565
+ * devices share a member id, so the mesh MUST distinguish them by this or a
9566
+ * device treats its siblings' signals as its own echo and never connects. */
9567
+ deviceId = `peer-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)}`;
9517
9568
  constructor(deps) {
9518
9569
  this.deps = deps;
9519
9570
  if (deps.offline?.enabled) {
@@ -9712,7 +9763,7 @@ class SpaceNamespace {
9712
9763
  if (this.deps.p2p?.enabled && isP2pCapable()) {
9713
9764
  space.attachPeerNetwork(new PeerNetwork({
9714
9765
  space,
9715
- myId: memberId,
9766
+ myId: this.deviceId, // per-device peer id, NOT the shared member id
9716
9767
  workerFactory: this.deps.p2p.workerFactory,
9717
9768
  iceServers: this.deps.p2p.iceServers,
9718
9769
  maxPeers: this.deps.p2p.maxPeers,