@decentnetwork/peer 0.1.103 → 0.1.105

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.
@@ -239,17 +239,23 @@ export class FileTransferManager {
239
239
  }
240
240
  return inner;
241
241
  }
242
- #freeNumber(friendId) {
242
+ #freeNumber(friendId, preferred = 0) {
243
243
  const inner = this.#map(this.#sending, friendId);
244
- for (let i = 0; i < MAX_CONCURRENT_FILE_PIPES; i++)
244
+ // Start at a content-derived slot. Older receivers retain an incomplete
245
+ // receive slot in memory until it finishes or is killed. After the sender
246
+ // restarts, always reusing slot 0 makes a DIFFERENT new file look like a
247
+ // retransmitted offer for that stale slot, so the receiver silently ACKs
248
+ // the old file and the new UI stays at 0%. The same content keeps the same
249
+ // preferred slot (resume), while different content naturally avoids stale
250
+ // collisions; circular search still supports concurrent transfers.
251
+ for (let n = 0; n < MAX_CONCURRENT_FILE_PIPES; n++) {
252
+ const i = (preferred + n) % MAX_CONCURRENT_FILE_PIPES;
245
253
  if (!inner.has(i))
246
254
  return i;
255
+ }
247
256
  return -1;
248
257
  }
249
258
  sendFile(friendId, data, opts) {
250
- const fileNumber = this.#freeNumber(friendId);
251
- if (fileNumber < 0)
252
- return null;
253
259
  // Content-addressed fileId (sha256): re-sending the SAME file after a
254
260
  // restart or a dropped peer yields the SAME id, so the receiver can match
255
261
  // it against its persisted partial and resume from where it left off — the
@@ -257,6 +263,9 @@ export class FileTransferManager {
257
263
  const fileId = data.length > 0
258
264
  ? new Uint8Array(createHash("sha256").update(data).digest()).slice(0, FILE_ID_LENGTH)
259
265
  : randomBytes(FILE_ID_LENGTH);
266
+ const fileNumber = this.#freeNumber(friendId, fileId[0] ?? 0);
267
+ if (fileNumber < 0)
268
+ return null;
260
269
  const req = encodeFileSendRequest(fileNumber, opts.kind ?? FILEKIND.DATA, BigInt(data.length), fileId, opts.name);
261
270
  const lanPath = this.isLanPath(friendId);
262
271
  const st = {
package/dist/peer.js CHANGED
@@ -210,8 +210,22 @@ const RECV_REQUEST_MIN_INTERVAL_MS = 200;
210
210
  export class Peer {
211
211
  #opts;
212
212
  #events = new EventEmitter();
213
+ /** While negotiating a non-LAN file, temporarily stop sending this friend
214
+ * UDP control traffic. Peer <=0.1.94 routes FILE_CONTROL UDP-only whenever
215
+ * it has seen recent inbound UDP, even when that public/NAT path is one-way.
216
+ * Repeated relay-only offers let that 4s freshness expire, after which the
217
+ * old peer returns ACCEPT/ACK over its reliable TCP relay. */
218
+ #fileRelayNegotiationUntil = new Map();
213
219
  // Toxcore-standard file transfer (wire-compatible with native Carrier/toxcore).
214
- #fileTransfer = new FileTransferManager((friendId, packetId, payload) => this.#sendMessengerPacket(friendId, packetId, payload), (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
220
+ #fileTransfer = new FileTransferManager((friendId, packetId, payload) => {
221
+ if (packetId === PACKET_ID_FILE_SENDREQUEST) {
222
+ const session = this.#friendSessions.get(friendId);
223
+ const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
224
+ if (!lan)
225
+ this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 6_000);
226
+ }
227
+ return this.#sendMessengerPacket(friendId, packetId, payload);
228
+ }, (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
215
229
  const session = this.#friendSessions.get(friendId);
216
230
  return !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
217
231
  });
@@ -604,6 +618,7 @@ export class Peer {
604
618
  clearTimeout(timer);
605
619
  this.#profileRetryTimers.clear();
606
620
  this.#profileRetryAttempts.clear();
621
+ this.#fileRelayNegotiationUntil.clear();
607
622
  if (this.#expressPollTimer) {
608
623
  clearInterval(this.#expressPollTimer);
609
624
  this.#expressPollTimer = undefined;
@@ -980,6 +995,7 @@ export class Peer {
980
995
  }
981
996
  const existed = this.#friends.delete(friendId);
982
997
  this.#friendSessions.delete(friendId);
998
+ this.#fileRelayNegotiationUntil.delete(friendId);
983
999
  this.#pendingFriendRequests.delete(friendId);
984
1000
  this.#cookieRetryCount.delete(friendId);
985
1001
  this.#lastCookieSentKey.delete(friendId);
@@ -2147,6 +2163,21 @@ export class Peer {
2147
2163
  // syscall (that was the CCTV CPU regression) — safe at CCTV packet
2148
2164
  // rates.
2149
2165
  this.#adoptRemote(state, remote.address, remote.port);
2166
+ // A successfully decrypted packet arriving from a private address in
2167
+ // one of our physical subnets is stronger LAN proof than an endpoint
2168
+ // hint: it demonstrates that this exact host:port already reaches us.
2169
+ // Lock it immediately. Without this, duplicate public/relay copies
2170
+ // arriving a moment later overwrite `remote`, so file DATA travels on
2171
+ // the LAN but ACKs hairpin through the public Internet (observed as
2172
+ // ~38KB/s between mac-dev and WSL on the same 10.0.0.0/24).
2173
+ if (!state.lanRemoteHost &&
2174
+ isPrivateAddress(remote.address) &&
2175
+ !isCgnatAddress(remote.address) &&
2176
+ getPhysicalLanSubnets().some((s) => isInIpv4Subnet(remote.address, s))) {
2177
+ state.lanRemoteHost = remote.address;
2178
+ this.#rememberEndpointCandidate(state, remote.address, remote.port);
2179
+ this.#debugLog(`lan_lock_confirmed friend=${friendId} host=${remote.address}:${remote.port} (decrypted UDP source)`);
2180
+ }
2150
2181
  if (!state.lastUdpRecvMs) {
2151
2182
  this.#debugLog(`udp_confirmed friend=${friendId} via=${remote.address}:${remote.port} (direct UDP path live)`);
2152
2183
  }
@@ -3345,6 +3376,11 @@ export class Peer {
3345
3376
  * buffered packets through the exact same dispatch. `kind` is the first
3346
3377
  * payload byte (never undefined here — the caller defaults it). */
3347
3378
  #deliverLosslessPayload(friendId, state, kind, inner, remote, packetNumber = 0) {
3379
+ // Any file response proves the receiver can hear this session. Release the
3380
+ // old-peer relay-only negotiation guard before the data pump chooses its
3381
+ // normal LAN/reliable-public path.
3382
+ if (kind === PACKET_ID_FILE_CONTROL)
3383
+ this.#fileRelayNegotiationUntil.delete(friendId);
3348
3384
  // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
3349
3385
  if (this.#fileTransfer.handlePacket(friendId, kind, inner))
3350
3386
  return;
@@ -3964,6 +4000,8 @@ export class Peer {
3964
4000
  const udpFresh = !!realUdpRemote &&
3965
4001
  s?.lastUdpRecvMs !== undefined &&
3966
4002
  Date.now() - s.lastUdpRecvMs < 4_000;
4003
+ const forceFileRelay = (this.#fileRelayNegotiationUntil.get(friendId) ?? 0) > Date.now() &&
4004
+ !(s?.lanRemoteHost && s.remote?.host === s.lanRemoteHost);
3967
4005
  // A freshly-received UDP packet proves only the peer -> us direction.
3968
4006
  // That is enough for lossy IP/video bulk, whose inner TCP stream can
3969
4007
  // recover a dropped packet, but not for reliable file data: returning
@@ -3980,7 +4018,8 @@ export class Peer {
3980
4018
  // lastUdpRecvMs). This converts a multi-second blackout into a few
3981
4019
  // seconds of higher relay latency. Control packets always probe UDP
3982
4020
  // (to keep the NAT mapping warm) so we still try UDP for them.
3983
- const tryUdp = realUdpRemote &&
4021
+ const tryUdp = !forceFileRelay &&
4022
+ realUdpRemote &&
3984
4023
  (isBulkData ? udpFresh && fileUdpOnlyConfirmed : true);
3985
4024
  if (tryUdp && s?.remote) {
3986
4025
  try {
@@ -4535,10 +4574,17 @@ export class Peer {
4535
4574
  if (payload.length >= 18) {
4536
4575
  const lanHost = `${payload[12]}.${payload[13]}.${payload[14]}.${payload[15]}`;
4537
4576
  const lanPort = ((payload[16] << 8) | payload[17]) >>> 0;
4577
+ // WSL sees only its 172.x virtual NIC, not the Windows host's physical
4578
+ // 10.x subnet. In that topology a peer on the same real LAN is still
4579
+ // safely identifiable because its server-reflexive address equals ours.
4580
+ // Permit the advertised private candidate when both peers share the same
4581
+ // public NAT; if it is a CGNAT/private collision and never validates, the
4582
+ // normal 12s stale-LAN-lock breaker restores the public/relay path.
4583
+ const samePublicNat = !!this.#srflxCache?.addr.host && this.#srflxCache.addr.host === host;
4538
4584
  const sameLan = lanPort !== 0 &&
4539
4585
  isPrivateAddress(lanHost) &&
4540
4586
  !isCgnatAddress(lanHost) &&
4541
- getPhysicalLanSubnets().some((s) => isInIpv4Subnet(lanHost, s)) &&
4587
+ (getPhysicalLanSubnets().some((s) => isInIpv4Subnet(lanHost, s)) || samePublicNat) &&
4542
4588
  !(getPhysicalLanAddresses().includes(lanHost) && this.#udp.localPort() === lanPort);
4543
4589
  if (sameLan) {
4544
4590
  this.#rememberEndpointCandidate(session, lanHost, lanPort);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.103",
3
+ "version": "0.1.105",
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",