@decentnetwork/peer 0.1.29 → 0.1.31

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.
Files changed (2) hide show
  1. package/dist/peer.js +105 -32
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -193,6 +193,10 @@ export class Peer {
193
193
  // unconnected friend (the "accepted friend that never connects" wedge —
194
194
  // requestRoute only fired once at startup and was never retried).
195
195
  #routeRequestCooldown = new Map();
196
+ // Per-friend cooldown for RE-SENDING the friend-request to a friend that's
197
+ // never been accepted (still "requested"). On reconnect / restart we must
198
+ // re-offer — the original request can be lost while the friend was offline.
199
+ #friendRequestResendCooldown = new Map();
196
200
  // Per-friend consecutive "no routes available" count for DHT-PK so the
197
201
  // backoff grows if a friend stays unreachable, instead of retrying every
198
202
  // 25s forever for stale persisted entries.
@@ -582,7 +586,18 @@ export class Peer {
582
586
  const routes = await this.#discoverFriendRoutes(friendAddress.publicKey);
583
587
  this.#debugLog(`friend route discovery done routes=${routes.length}`);
584
588
  if (routes.length === 0) {
585
- await this.#sendDirectCryptoFriendRequest(friendAddress.publicKey, friendReqPayload);
589
+ // The direct net_crypto send needs a known endpoint for the target; to
590
+ // a peer we've never connected to (a dora, an exit) it throws. The old
591
+ // code `await`ed it bare, so that throw skipped the express push below
592
+ // and the friend-request never went out by ANY channel — the exact
593
+ // reason callpass never reached dora-mac. Make it best-effort so the
594
+ // express fallback always runs.
595
+ try {
596
+ await this.#sendDirectCryptoFriendRequest(friendAddress.publicKey, friendReqPayload);
597
+ }
598
+ catch (error) {
599
+ this.#debugLog(`direct crypto friend-request to ${friendId} failed: ${error.message}`);
600
+ }
586
601
  this.#lastFriendRequestDispatch = {
587
602
  transport: "direct",
588
603
  routes: 0,
@@ -2077,46 +2092,67 @@ export class Peer {
2077
2092
  }
2078
2093
  this.#lastSelfAnnounceMs = now;
2079
2094
  const storedNodes = [];
2080
- const targets = dedupeNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes)
2081
- .filter((n) => !this.#isNodeBlacklisted(`${n.host}:${n.port}`))
2082
- .sort((a, b) => this.#nodeScore(`${b.host}:${b.port}`) - this.#nodeScore(`${a.host}:${a.port}`))
2083
- .slice(0, MAX_SELF_ANNOUNCE_TARGETS);
2095
+ // An onion announce only STORES on the nodes whose key is closest to OURS,
2096
+ // and toxcore reaches them by following the "here are closer nodes" lists
2097
+ // each node returns a converging walk. The old code instead sorted by
2098
+ // reliability score and did a single pass, so it announced to far-but-
2099
+ // reliable nodes that just replied isStored=0; it never iterated toward the
2100
+ // closest nodes, so nothing ever stored us (selfAnnounceStoredOn=0) and we
2101
+ // were unreachable in real time. Walk a distance-ordered queue toward our
2102
+ // own key, feeding back the closer nodes each wave discovers.
2103
+ const selfPk = this.#keyPair.publicKey;
2084
2104
  const zeroPing = new Uint8Array(32);
2085
- const candidates = [];
2086
- for (const node of targets) {
2087
- if (!node.pk)
2088
- continue;
2089
- let nodePk;
2090
- try {
2091
- nodePk = base58ToBytes(node.pk);
2092
- }
2093
- catch {
2094
- continue;
2105
+ const visited = new Set();
2106
+ const queue = [];
2107
+ const enqueueNodes = (nodes) => {
2108
+ for (const node of nodes) {
2109
+ if (!node.pk)
2110
+ continue;
2111
+ const id = `${node.host}:${node.port}`;
2112
+ if (visited.has(id) || this.#isNodeBlacklisted(id))
2113
+ continue;
2114
+ if (queue.some((q) => `${q.node.host}:${q.node.port}` === id))
2115
+ continue;
2116
+ let nodePk;
2117
+ try {
2118
+ nodePk = base58ToBytes(node.pk);
2119
+ }
2120
+ catch {
2121
+ continue;
2122
+ }
2123
+ if (nodePk.length !== 32)
2124
+ continue;
2125
+ queue.push({ node, nodePk, sendBack: randomBytes(8) });
2095
2126
  }
2096
- if (nodePk.length !== 32)
2097
- continue;
2098
- candidates.push({ node, nodePk, sendBack: randomBytes(8) });
2099
- }
2100
- // Walk in waves fire SELF_ANNOUNCE_BATCH_SIZE step1+step2 sequences
2101
- // in parallel per wave, then process. Toxcore's onion_client.c does
2102
- // up to MAX_ONION_CLIENTS_ANNOUNCE = 12 simultaneously, which lets it
2103
- // stabilize self-announce in ~10s on a healthy network. Old serial
2104
- // walk took (per-node-timeout × N targets × 2 steps) ≈ 16 × 5s × 2
2105
- // = 160s worst case before a single isStored=2 response.
2127
+ // Closest-to-self first, so each wave hits the nodes most likely to store
2128
+ // us or to point at even-closer ones.
2129
+ queue.sort((x, y) => xorCloser(selfPk, x.nodePk, y.nodePk));
2130
+ };
2131
+ enqueueNodes(this.#knownNodes.length > 0 ? this.#knownNodes : this.#opts.bootstrapNodes);
2106
2132
  // Reset counter at start so dhtHealth can distinguish "never ran" (-1)
2107
- // from "ran but stored on 0 nodes" (0). The latter means our UDP
2108
- // path is reaching bootstrap nodes but they're refusing to store
2109
- // us — that's a wire-level problem (auth, network) we want to see.
2133
+ // from "ran but stored on 0 nodes" (0).
2110
2134
  this.#lastSelfAnnounceStoredCount = 0;
2111
- for (let i = 0; i < candidates.length; i += SELF_ANNOUNCE_BATCH_SIZE) {
2135
+ const STORE_TARGET = 4;
2136
+ let waves = 0;
2137
+ while (queue.length > 0 && storedNodes.length < STORE_TARGET && waves < 16) {
2112
2138
  if (Date.now() >= deadlineMs) {
2113
2139
  this.#debugLog("self announce stopped at deadline");
2114
- // Even on early return, the count is live: storedNodes is what
2115
- // we got so far.
2116
2140
  this.#lastSelfAnnounceStoredCount = storedNodes.length;
2117
2141
  return storedNodes;
2118
2142
  }
2119
- const wave = candidates.slice(i, i + SELF_ANNOUNCE_BATCH_SIZE);
2143
+ waves += 1;
2144
+ // Pull the closest unvisited candidates for this wave.
2145
+ const wave = [];
2146
+ while (queue.length > 0 && wave.length < SELF_ANNOUNCE_BATCH_SIZE) {
2147
+ const c = queue.shift();
2148
+ const id = `${c.node.host}:${c.node.port}`;
2149
+ if (visited.has(id))
2150
+ continue;
2151
+ visited.add(id);
2152
+ wave.push(c);
2153
+ }
2154
+ if (wave.length === 0)
2155
+ break;
2120
2156
  // Fire all step1 requests in parallel.
2121
2157
  const step1Settled = await Promise.allSettled(wave.map((c) => this.#sendAnnounceAndWait({
2122
2158
  node: c.node,
@@ -2184,6 +2220,9 @@ export class Peer {
2184
2220
  const discovered = parsePackedNodes(final.nodes);
2185
2221
  if (discovered.length > 0) {
2186
2222
  this.#knownNodes = dedupeNodes([...this.#knownNodes, ...discovered]);
2223
+ // Feed the closer nodes this node pointed us at back into the walk —
2224
+ // this is the convergence that actually reaches a storing node.
2225
+ enqueueNodes(discovered);
2187
2226
  }
2188
2227
  }
2189
2228
  }
@@ -2482,6 +2521,22 @@ export class Peer {
2482
2521
  }
2483
2522
  catch { /* skip malformed pubkey */ }
2484
2523
  }
2524
+ // Re-SEND the friend-request to a friend we've never had accepted
2525
+ // (still "requested"). The original can be lost — the friend was
2526
+ // offline/restarting, a relay blipped, or onion had no return path.
2527
+ // When WE'RE online and they still haven't accepted, re-offer over the
2528
+ // live path (onion, then express fallback) on a cooldown. This is how
2529
+ // an always-online server (a dora, an exit) finally gets our request
2530
+ // after either side restarted, without anyone re-running a command.
2531
+ if (!friend.acceptedAt && friend.address) {
2532
+ const lastFr = this.#friendRequestResendCooldown.get(friendId) ?? 0;
2533
+ if (now - lastFr > 60_000) {
2534
+ this.#friendRequestResendCooldown.set(friendId, now);
2535
+ void this.sendFriendRequest(friend.address, friend.hello).catch((error) => {
2536
+ this.#debugLog(`re-send friend-request to ${friendId} failed: ${error.message}`);
2537
+ });
2538
+ }
2539
+ }
2485
2540
  const dhtPk = session?.friendDhtPublicKey;
2486
2541
  if (dhtPk) {
2487
2542
  const found = await this.#discoverAndCacheFriendEndpoint(friendId, dhtPk).catch(() => false);
@@ -4123,6 +4178,24 @@ function bytesEqual(a, b) {
4123
4178
  function createEphemeralKeyPair() {
4124
4179
  return nacl.box.keyPair();
4125
4180
  }
4181
+ /**
4182
+ * Toxcore DHT XOR-distance comparator: returns <0 if node pubkey `a` is CLOSER
4183
+ * to `target` than `b` (so an ascending sort puts the closest nodes first).
4184
+ * Onion announces only store on the nodes closest to your own key, and onion
4185
+ * lookups converge toward the nodes closest to the searched key — so candidate
4186
+ * selection MUST be by XOR distance, not by reliability score. Compares the
4187
+ * 32-byte keys big-endian-wise (most significant differing byte decides).
4188
+ */
4189
+ function xorCloser(target, a, b) {
4190
+ const n = Math.min(target.length, a.length, b.length);
4191
+ for (let i = 0; i < n; i++) {
4192
+ const da = (a[i] ^ target[i]) & 0xff;
4193
+ const db = (b[i] ^ target[i]) & 0xff;
4194
+ if (da !== db)
4195
+ return da - db;
4196
+ }
4197
+ return 0;
4198
+ }
4126
4199
  function isAllZero(bytes) {
4127
4200
  for (let i = 0; i < bytes.length; i++) {
4128
4201
  if (bytes[i] !== 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",