@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.
package/dist/connect.d.ts CHANGED
@@ -4379,6 +4379,10 @@ declare class SpaceNamespace {
4379
4379
  private spaceCache?;
4380
4380
  /** Spaces built this session, by id — used to route offline send replays. */
4381
4381
  private readonly openSpaces;
4382
+ /** Unique id for THIS device/session — the P2P peer identity. A user's own
4383
+ * devices share a member id, so the mesh MUST distinguish them by this or a
4384
+ * device treats its siblings' signals as its own echo and never connects. */
4385
+ private readonly deviceId;
4382
4386
  constructor(deps: SpaceNamespaceDeps);
4383
4387
  /**
4384
4388
  * Create a new space: generate its keypair (public key = id), register
@@ -5392,15 +5396,20 @@ declare class PeerExchange implements PeerBlockSource {
5392
5396
  * to Space membership. The sender (`from`) is server-stamped and can't be
5393
5397
  * spoofed; the payload is opaque to the server.
5394
5398
  *
5395
- * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
5396
- * accelerator can optionally unicast peer-pair signaling instead of broadcasting
5397
- * (a small, backward-compatible `pub` enhancement); the client filters by `to`
5398
- * regardless.
5399
+ * Peers are identified by a unique **peer id** (per device/connection), carried
5400
+ * in the envelope's `from`/`to` — NOT by the server-stamped member id. This is
5401
+ * essential: one user's own devices share a member id, so the server's `from`
5402
+ * can't tell them apart (and would make each treat the others' signals as its
5403
+ * own echo). We therefore route entirely on the envelope peer ids and broadcast
5404
+ * to the whole Space, filtering client-side. The server `from` still guarantees
5405
+ * the sender is an authenticated Space member.
5399
5406
  */
5400
5407
  declare const P2P_SUBJECT = "__p2p__";
5401
5408
  type SignalKind = "hello" | "bye" | "offer" | "answer" | "ice";
5402
5409
  interface SignalEnvelope {
5403
- /** Target member id, or `"*"` to broadcast (discovery / departure). */
5410
+ /** Sender's unique PEER id (per device/connection). */
5411
+ from: string;
5412
+ /** Target peer id, or `"*"` to broadcast (discovery / departure). */
5404
5413
  to: string;
5405
5414
  kind: SignalKind;
5406
5415
  data?: unknown;
@@ -5424,7 +5433,9 @@ declare class SpaceSignaler {
5424
5433
  broadcast(kind: SignalKind, data?: unknown): void;
5425
5434
  /**
5426
5435
  * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
5427
- * echoes. Returns an unsubscribe function.
5436
+ * echoes. Returns an unsubscribe function. Routing is entirely by the envelope
5437
+ * peer id (`env.from`/`env.to`), so a user's own devices — which share the
5438
+ * server-stamped `e.from` — are correctly distinguished.
5428
5439
  */
5429
5440
  onSignal(cb: (from: string, kind: SignalKind, data: unknown) => void): () => void;
5430
5441
  }
@@ -5591,6 +5602,8 @@ interface WebRtcTransportOptions {
5591
5602
  iceServers?: RTCIceServer[];
5592
5603
  /** Cap on simultaneous peer connections (mesh size). Default 8. */
5593
5604
  maxPeers?: number;
5605
+ /** Log ICE/connection state transitions for diagnosing peer connectivity. */
5606
+ debug?: boolean;
5594
5607
  }
5595
5608
  declare class WebRtcTransport implements PeerTransport {
5596
5609
  private readonly signaler;
@@ -5601,8 +5614,10 @@ declare class WebRtcTransport implements PeerTransport {
5601
5614
  private readonly offSignal;
5602
5615
  private readonly iceServers;
5603
5616
  private readonly maxPeers;
5617
+ private readonly debug;
5604
5618
  private nextMsgId;
5605
5619
  constructor(signaler: SpaceSignaler, myId: PeerId, opts?: WebRtcTransportOptions);
5620
+ private log;
5606
5621
  peers(): PeerId[];
5607
5622
  /** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
5608
5623
  connectTo(peerId: PeerId): void;
@@ -5612,6 +5627,9 @@ declare class WebRtcTransport implements PeerTransport {
5612
5627
  onPeerChange(cb: (peers: PeerId[]) => void): () => void;
5613
5628
  close(): void;
5614
5629
  private newPc;
5630
+ /** Mark a peer's remote description as set and apply any ICE candidates that
5631
+ * arrived before it (dropping them, the old behavior, breaks LAN dials). */
5632
+ private markRemoteSet;
5615
5633
  private initiate;
5616
5634
  private bindChannel;
5617
5635
  private onSignal;
@@ -2976,7 +2976,7 @@ function base64UrlDecode(s) {
2976
2976
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
2977
2977
  * `package.json` `version`.
2978
2978
  */
2979
- const VERSION = "0.10.3-alpha.0";
2979
+ const VERSION = "0.10.5-alpha.0";
2980
2980
 
2981
2981
  /**
2982
2982
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -8470,10 +8470,13 @@ class PeerExchange {
8470
8470
  * to Space membership. The sender (`from`) is server-stamped and can't be
8471
8471
  * spoofed; the payload is opaque to the server.
8472
8472
  *
8473
- * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
8474
- * accelerator can optionally unicast peer-pair signaling instead of broadcasting
8475
- * (a small, backward-compatible `pub` enhancement); the client filters by `to`
8476
- * regardless.
8473
+ * Peers are identified by a unique **peer id** (per device/connection), carried
8474
+ * in the envelope's `from`/`to` — NOT by the server-stamped member id. This is
8475
+ * essential: one user's own devices share a member id, so the server's `from`
8476
+ * can't tell them apart (and would make each treat the others' signals as its
8477
+ * own echo). We therefore route entirely on the envelope peer ids and broadcast
8478
+ * to the whole Space, filtering client-side. The server `from` still guarantees
8479
+ * the sender is an authenticated Space member.
8477
8480
  */
8478
8481
  const P2P_SUBJECT = "__p2p__";
8479
8482
  class SpaceSignaler {
@@ -8485,11 +8488,11 @@ class SpaceSignaler {
8485
8488
  }
8486
8489
  /** Send a signal to one peer (or `"*"`). */
8487
8490
  send(to, kind, data) {
8488
- const env = { to, kind, data };
8489
- // Ask the server to unicast for a directed target; broadcast for "*".
8490
- // `to` also stays in the envelope so a broadcast-only server still works
8491
- // (the client filters by it).
8492
- this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
8491
+ const env = { from: this.myId, to, kind, data };
8492
+ // Always broadcast to the Space: the server can't unicast to a specific
8493
+ // DEVICE (a user's devices share a member id), so we route client-side on
8494
+ // the envelope peer id. Signaling frames are tiny.
8495
+ this.space.sendEphemeral(P2P_SUBJECT, env);
8493
8496
  }
8494
8497
  /** Announce/withdraw P2P availability to all Space members. */
8495
8498
  broadcast(kind, data) {
@@ -8497,18 +8500,22 @@ class SpaceSignaler {
8497
8500
  }
8498
8501
  /**
8499
8502
  * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
8500
- * echoes. Returns an unsubscribe function.
8503
+ * echoes. Returns an unsubscribe function. Routing is entirely by the envelope
8504
+ * peer id (`env.from`/`env.to`), so a user's own devices — which share the
8505
+ * server-stamped `e.from` — are correctly distinguished.
8501
8506
  */
8502
8507
  onSignal(cb) {
8503
8508
  return this.space.onEphemeral((e) => {
8504
- if (e.subject !== P2P_SUBJECT || e.from === this.myId)
8509
+ if (e.subject !== P2P_SUBJECT)
8505
8510
  return;
8506
8511
  const env = e.data;
8507
- if (!env || typeof env.kind !== "string")
8512
+ if (!env || typeof env.kind !== "string" || typeof env.from !== "string")
8508
8513
  return;
8514
+ if (env.from === this.myId)
8515
+ return; // our own device's echo
8509
8516
  if (env.to !== this.myId && env.to !== "*")
8510
8517
  return;
8511
- cb(e.from, env.kind, env.data);
8518
+ cb(env.from, env.kind, env.data);
8512
8519
  });
8513
8520
  }
8514
8521
  }
@@ -8543,14 +8550,20 @@ class WebRtcTransport {
8543
8550
  offSignal;
8544
8551
  iceServers;
8545
8552
  maxPeers;
8553
+ debug;
8546
8554
  nextMsgId = 1;
8547
8555
  constructor(signaler, myId, opts = {}) {
8548
8556
  this.signaler = signaler;
8549
8557
  this.myId = myId;
8550
8558
  this.iceServers = opts.iceServers ?? DEFAULT_ICE;
8551
8559
  this.maxPeers = opts.maxPeers ?? 8;
8560
+ this.debug = opts.debug ?? false;
8552
8561
  this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
8553
8562
  }
8563
+ log(...a) {
8564
+ if (this.debug)
8565
+ console.info("[muhkoo:p2p]", ...a);
8566
+ }
8554
8567
  peers() {
8555
8568
  return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
8556
8569
  }
@@ -8601,19 +8614,42 @@ class WebRtcTransport {
8601
8614
  // -- internals -----------------------------------------------------------
8602
8615
  newPc(peerId) {
8603
8616
  const pc = new RTCPeerConnection({ iceServers: this.iceServers });
8604
- const conn = { pc, reasm: new Map() };
8617
+ const conn = { pc, reasm: new Map(), remoteSet: false, pendingIce: [] };
8605
8618
  this.conns.set(peerId, conn);
8619
+ const short = String(peerId).slice(0, 8);
8606
8620
  pc.onicecandidate = (e) => {
8607
- if (e.candidate)
8621
+ if (e.candidate) {
8622
+ this.log(`${short} local candidate ${e.candidate.type ?? "?"} ${(e.candidate.address ?? e.candidate.candidate).toString().slice(0, 40)}`);
8608
8623
  this.signaler.send(peerId, "ice", e.candidate.toJSON());
8624
+ }
8609
8625
  };
8610
8626
  pc.onconnectionstatechange = () => {
8627
+ this.log(`${short} connection=${pc.connectionState}`);
8611
8628
  if (["failed", "closed", "disconnected"].includes(pc.connectionState))
8612
8629
  this.drop(peerId);
8613
8630
  };
8631
+ if (this.debug) {
8632
+ pc.oniceconnectionstatechange = () => this.log(`${short} ice=${pc.iceConnectionState}`);
8633
+ pc.onicegatheringstatechange = () => this.log(`${short} gathering=${pc.iceGatheringState}`);
8634
+ }
8614
8635
  pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
8615
8636
  return conn;
8616
8637
  }
8638
+ /** Mark a peer's remote description as set and apply any ICE candidates that
8639
+ * arrived before it (dropping them, the old behavior, breaks LAN dials). */
8640
+ async markRemoteSet(conn) {
8641
+ conn.remoteSet = true;
8642
+ const pending = conn.pendingIce;
8643
+ conn.pendingIce = [];
8644
+ for (const cand of pending) {
8645
+ try {
8646
+ await conn.pc.addIceCandidate(cand);
8647
+ }
8648
+ catch { /* stale/duplicate */ }
8649
+ }
8650
+ if (pending.length)
8651
+ this.log(`flushed ${pending.length} queued ICE candidate(s)`);
8652
+ }
8617
8653
  async initiate(peerId) {
8618
8654
  const conn = this.newPc(peerId);
8619
8655
  const dc = conn.pc.createDataChannel("blocks");
@@ -8635,22 +8671,32 @@ class WebRtcTransport {
8635
8671
  return; // mesh cap
8636
8672
  const conn = this.conns.get(from) ?? this.newPc(from);
8637
8673
  await conn.pc.setRemoteDescription(data);
8674
+ await this.markRemoteSet(conn);
8638
8675
  const answer = await conn.pc.createAnswer();
8639
8676
  await conn.pc.setLocalDescription(answer);
8640
8677
  this.signaler.send(from, "answer", answer);
8641
8678
  }
8642
8679
  else if (kind === "answer") {
8643
8680
  const conn = this.conns.get(from);
8644
- if (conn)
8681
+ if (conn) {
8645
8682
  await conn.pc.setRemoteDescription(data);
8683
+ await this.markRemoteSet(conn);
8684
+ }
8646
8685
  }
8647
8686
  else if (kind === "ice") {
8648
8687
  const conn = this.conns.get(from);
8649
8688
  if (conn && data) {
8689
+ // Queue candidates that arrive before setRemoteDescription — adding
8690
+ // them early throws and (previously) dropped them, which killed the
8691
+ // connection on fast LANs where ICE beats the SDP answer.
8692
+ if (!conn.remoteSet) {
8693
+ conn.pendingIce.push(data);
8694
+ return;
8695
+ }
8650
8696
  try {
8651
8697
  await conn.pc.addIceCandidate(data);
8652
8698
  }
8653
- catch { /* pre-SRD race */ }
8699
+ catch { /* stale */ }
8654
8700
  }
8655
8701
  }
8656
8702
  else if (kind === "bye") {
@@ -9119,6 +9165,7 @@ class PeerNetwork {
9119
9165
  new WebRtcTransport(this.signaler, opts.myId, {
9120
9166
  iceServers: opts.iceServers,
9121
9167
  maxPeers: opts.maxPeers,
9168
+ debug: opts.debug,
9122
9169
  });
9123
9170
  this.host = buildEngineHost(opts);
9124
9171
  // Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
@@ -9245,6 +9292,10 @@ class SpaceNamespace {
9245
9292
  spaceCache;
9246
9293
  /** Spaces built this session, by id — used to route offline send replays. */
9247
9294
  openSpaces = new Map();
9295
+ /** Unique id for THIS device/session — the P2P peer identity. A user's own
9296
+ * devices share a member id, so the mesh MUST distinguish them by this or a
9297
+ * device treats its siblings' signals as its own echo and never connects. */
9298
+ deviceId = `peer-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)}`;
9248
9299
  constructor(deps) {
9249
9300
  this.deps = deps;
9250
9301
  if (deps.offline?.enabled) {
@@ -9443,7 +9494,7 @@ class SpaceNamespace {
9443
9494
  if (this.deps.p2p?.enabled && isP2pCapable()) {
9444
9495
  space.attachPeerNetwork(new PeerNetwork({
9445
9496
  space,
9446
- myId: memberId,
9497
+ myId: this.deviceId, // per-device peer id, NOT the shared member id
9447
9498
  workerFactory: this.deps.p2p.workerFactory,
9448
9499
  iceServers: this.deps.p2p.iceServers,
9449
9500
  maxPeers: this.deps.p2p.maxPeers,