@decentnetwork/peer 0.1.18 → 0.1.20

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 +294 -42
  2. package/package.json +1 -1
package/dist/peer.js CHANGED
@@ -17,6 +17,15 @@ import { base58ToBytes } from "./utils/base58.js";
17
17
  import { UdpTransport } from "./transport/udp.js";
18
18
  import { bytesToHex, concatBytes, randomBytes } from "./utils/bytes.js";
19
19
  import { buildBindingRequest, decodeStun, decodeXorMappedAddress, findAttr, STUN_ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_SUCCESS } from "./stun.js";
20
+ import { TurnClient } from "./turn.js";
21
+ import { createSocket as createDgramSocket } from "dgram";
22
+ // Dedicated TURN relay servers — fixed public relays used as a stable
23
+ // fallback path when direct UDP hole-punch fails or flaps (symmetric NAT,
24
+ // NAT remaps, lossy direct path). A relay endpoint never NAT-flaps, so the
25
+ // path stays put even when the peers' NATs churn. Static long-term creds.
26
+ const TURN_RELAY_SERVERS = [
27
+ { host: "tokyo.fi.chat", port: 3478, username: "allcom", password: "allcompass" }
28
+ ];
20
29
  const ANNOUNCE_WAIT_TIMEOUT_MS = readEnvInt("DECENT_ANNOUNCE_WAIT_TIMEOUT_MS", 4000);
21
30
  const MAX_FRIEND_ROUTE_ATTEMPTS = readEnvInt("DECENT_FRIEND_ROUTE_MAX_ATTEMPTS", 12);
22
31
  // How many in-flight announce step1 requests to fan out at once. Toxcore
@@ -128,6 +137,16 @@ export class Peer {
128
137
  #events = new EventEmitter();
129
138
  #keyPair;
130
139
  #udp = new UdpTransport();
140
+ // TURN relay (stable fallback path). One node-level allocation on its own
141
+ // dedicated UDP socket — kept separate from #udp so net_crypto/DHT and the
142
+ // TURN control protocol never share a socket (no demux). Inbound relayed
143
+ // data is injected into #onDatagram as if it arrived directly from the
144
+ // peer's relay address; outbound to a peer's relay address goes through
145
+ // #turnClient.sendTo. Lazily allocated on first need.
146
+ #turnClient;
147
+ #turnSocket;
148
+ #ourRelayAddr;
149
+ #turnAllocating;
131
150
  #bootstrap;
132
151
  #dht = new LegacyDhtClient();
133
152
  #knownNodes;
@@ -169,6 +188,10 @@ export class Peer {
169
188
  // session entry exists yet so the connection loop does not flood DHT-PK
170
189
  // requests when route discovery keeps failing.
171
190
  #dhtPkSendCooldown = new Map();
191
+ // Per-friend cooldown for re-asserting the TCP-relay route toward an
192
+ // unconnected friend (the "accepted friend that never connects" wedge —
193
+ // requestRoute only fired once at startup and was never retried).
194
+ #routeRequestCooldown = new Map();
172
195
  // Per-friend consecutive "no routes available" count for DHT-PK so the
173
196
  // backoff grows if a friend stays unreachable, instead of retrying every
174
197
  // 25s forever for stale persisted entries.
@@ -398,6 +421,17 @@ export class Peer {
398
421
  }
399
422
  this.#udp.off("datagram", this.#onDatagram);
400
423
  await this.#udp.stop();
424
+ // Release the TURN allocation + its dedicated socket.
425
+ try {
426
+ this.#turnClient?.close();
427
+ this.#turnSocket?.close();
428
+ }
429
+ catch {
430
+ // best-effort
431
+ }
432
+ this.#turnClient = undefined;
433
+ this.#turnSocket = undefined;
434
+ this.#ourRelayAddr = undefined;
401
435
  this.#started = false;
402
436
  }
403
437
  pubkey() {
@@ -893,7 +927,7 @@ export class Peer {
893
927
  #remoteIsTcp(remote) {
894
928
  return remote.address.startsWith("tcp:");
895
929
  }
896
- #onDatagram = ({ data, remote }) => {
930
+ #onDatagram = ({ data, remote, viaRelay }) => {
897
931
  if (!this.#keyPair) {
898
932
  return;
899
933
  }
@@ -1191,13 +1225,26 @@ export class Peer {
1191
1225
  isNewSession &&
1192
1226
  state.sessionEstablishedAtMs !== undefined) {
1193
1227
  const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
1194
- if (sinceEstablished > 1000) {
1228
+ // Only reject the fresh handshake if our CURRENT session is
1229
+ // actually alive — i.e. carrying data. A wedged "established"
1230
+ // session that's receiving nothing must NOT block a re-handshake,
1231
+ // or a peer that dropped + restarted can never reconnect: it keeps
1232
+ // sending fresh handshakes and we keep rejecting them against a
1233
+ // corpse. (This is the all-exits-outage bug: mac held a dead
1234
+ // "established" session for cn and ignored every reconnect
1235
+ // attempt.) If we haven't received a packet within FRIEND_TIMEOUT_MS
1236
+ // the current session is dead — fall through and accept the new one.
1237
+ const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
1238
+ const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
1239
+ if (sinceEstablished > 1000 && currentSessionAlive) {
1195
1240
  // Verbose only — peers retransmit handshakes aggressively, so
1196
1241
  // this line fires dozens of times per minute on a stuck pair
1197
1242
  // and drowns out signal. Visible with DECENT_DEBUG_VERBOSE=1.
1198
- this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on established session, ${sinceEstablished}ms after establish)`);
1243
+ this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
1199
1244
  return;
1200
1245
  }
1246
+ this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
1247
+ `lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
1201
1248
  }
1202
1249
  state.peerSessionPublicKey = hs.sessionPublicKey;
1203
1250
  state.peerBaseNonce = hs.baseNonce.slice();
@@ -1329,6 +1376,15 @@ export class Peer {
1329
1376
  if (this.#remoteIsTcp(remote)) {
1330
1377
  state.hasTcpRoute = true;
1331
1378
  }
1379
+ else if (viaRelay) {
1380
+ // Arrived over the TURN relay. Track relay freshness separately;
1381
+ // do NOT set state.remote (that's the direct endpoint). The relay
1382
+ // address it came from is already in state.relayRemote.
1383
+ if (!state.lastRelayRecvMs) {
1384
+ this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
1385
+ }
1386
+ state.lastRelayRecvMs = Date.now();
1387
+ }
1332
1388
  else {
1333
1389
  state.remote = { host: remote.address, port: remote.port };
1334
1390
  if (!state.lastUdpRecvMs) {
@@ -2122,10 +2178,33 @@ export class Peer {
2122
2178
  const session = this.#friendSessions.get(friendId);
2123
2179
  // Established session: keepalive + timeout monitoring
2124
2180
  if (session?.established) {
2125
- if (session.lastPingRecvMs && now - session.lastPingRecvMs > FRIEND_TIMEOUT_MS) {
2126
- this.#debugLog(`session timeout for ${friendId} (no ping in ${FRIEND_TIMEOUT_MS}ms)`);
2181
+ // Liveness: time out a session that hasn't received ANY packet
2182
+ // within FRIEND_TIMEOUT_MS measured from the last received ping
2183
+ // OR, if we've never received one, from when the session was
2184
+ // established. The previous `lastPingRecvMs &&` guard skipped the
2185
+ // check entirely when lastPingRecvMs was undefined, so a session
2186
+ // that established but never carried a single packet (the path
2187
+ // died at/just-after handshake) lived forever reporting
2188
+ // transport=both while IP forwarding was 100% loss, and never tore
2189
+ // down to re-handshake. Tearing it down here drops it to the
2190
+ // non-established branch below, which re-initiates a fresh
2191
+ // connection — turning a permanent wedge into a ~timeout self-heal.
2192
+ const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
2193
+ if (now - lastAlive > FRIEND_TIMEOUT_MS) {
2194
+ this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
2195
+ `lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
2127
2196
  this.#friendSessions.delete(friendId);
2128
2197
  this.#setFriendOffline(friendId);
2198
+ // Re-assert the relay route so the friend-connection bootstrap
2199
+ // (CONNECTION_NOTIFICATION -> #initiateSession) can re-fire on
2200
+ // re-establishment. requestRoute is idempotent, so this is a
2201
+ // no-op if the route is still live.
2202
+ try {
2203
+ const pk = base58ToBytes(friend.pubkey);
2204
+ if (pk.length === 32)
2205
+ this.#tcpRelays?.requestRoute(pk);
2206
+ }
2207
+ catch { /* skip malformed */ }
2129
2208
  continue;
2130
2209
  }
2131
2210
  if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
@@ -2163,14 +2242,33 @@ export class Peer {
2163
2242
  // before the punch is proven, so using it here would stop the
2164
2243
  // offer/punch retry prematurely and deadlock a half-punched pair.
2165
2244
  // Treat the UDP path as needing re-punch once it's been quiet for
2166
- // >7s. This is deliberately close to #sendToFriend's 10s
2167
- // relay-fallback threshold: if UDP stalls, we want the re-offer +
2168
- // re-punch to fire almost immediately so the direct path recovers
2169
- // before much traffic spills onto the slow relay (whose queue can
2170
- // back up 20s+ on the China↔US leg). A healthy path refreshes
2171
- // lastUdpRecvMs every ~1s via keepalive/data, so this never fires
2172
- // while UDP is working.
2173
- const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 7_000;
2245
+ // >4s matched to #sendToFriend's 4s freshness window, so the
2246
+ // moment bulk data starts bridging over the relay we're already
2247
+ // re-offering + re-punching to restore the direct path (e.g. after
2248
+ // a NAT port remap). A healthy path refreshes lastUdpRecvMs every
2249
+ // ~1s via keepalive/data, so this never fires while UDP works.
2250
+ // Relay keepalive runs even when the direct path is perfectly
2251
+ // healthy, so the TURN relay stays ready as an instant fallback.
2252
+ // Without this the relay's permission (and the candidate exchange)
2253
+ // silently lapse after ~5min and a later direct-path failure falls
2254
+ // all the way back to the slow tcp-relay. Re-send our offer (so the
2255
+ // peer refreshes ITS permission for our relay) and re-permit the
2256
+ // peer's relay on our own allocation. Cheap, every 2min.
2257
+ if (session.established && session.hasTcpRoute) {
2258
+ const RELAY_KEEPALIVE_MS = 120_000;
2259
+ if (now - (session.lastRelayKeepaliveMs ?? 0) > RELAY_KEEPALIVE_MS) {
2260
+ session.lastRelayKeepaliveMs = now;
2261
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
2262
+ if (session.relayRemote && this.#turnClient) {
2263
+ const rr = session.relayRemote;
2264
+ void this.#turnClient
2265
+ .createPermission({ family: 4, address: rr.host, port: rr.port })
2266
+ .then(() => { session.relayPermittedAtMs = Date.now(); })
2267
+ .catch(() => undefined);
2268
+ }
2269
+ }
2270
+ }
2271
+ const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 4_000;
2174
2272
  if (!udpConfirmed && session.hasTcpRoute) {
2175
2273
  // Aggressive re-punch cadence: the relay path is lossy, so a few
2176
2274
  // offers may drop before one lands and both sides punch in the
@@ -2280,11 +2378,42 @@ export class Peer {
2280
2378
  });
2281
2379
  }
2282
2380
  }
2283
- // No active session: try to establish one if we know the friend's endpoint.
2284
- // If we don't, the C++-initiated path can still bring up the session
2285
- // when peer reaches us so this is only one of two ways to recover.
2286
- const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote;
2381
+ // No active session: try to establish one if we know the friend's
2382
+ // endpoint. A friend reachable over the TCP relay counts the cookie
2383
+ // request + handshake go over the relay OOB (the log shows
2384
+ // `cookie_sent udp=0 tcp=1`). Without including hasTcpRoute here, a
2385
+ // relay-only friend whose handshake didn't complete on the first try
2386
+ // (peer's handshake-back was lost) was NEVER retried — the cookie
2387
+ // retry below was gated on a UDP endpoint — so the session stayed
2388
+ // stuck forever at "friend_online but never established". That is the
2389
+ // post-restart reconnection wedge that took down cn/callpass: relay
2390
+ // bridged them, cookie+handshake fired once, didn't complete, and
2391
+ // nothing re-tried it. Counting the relay route lets the retry fire.
2392
+ const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
2287
2393
  if (!haveEndpoint) {
2394
+ // No UDP endpoint AND no relay route yet. For a peer whose DHT
2395
+ // self-announce never stored (WSL2, symmetric NAT, restrictive
2396
+ // firewall), neither onion discovery nor UDP punch can ever find
2397
+ // them — the ONLY bootstrap is the TCP relay: ask our relays to
2398
+ // route to the friend's pubkey, and when they're also connected to
2399
+ // a shared relay the pool fires `friendOnline` -> #initiateSession.
2400
+ // requestRoute was only issued once at start(); if the friend or a
2401
+ // relay wasn't connected in that instant it was never retried, so an
2402
+ // accepted friend could sit at "requested" forever (the WSL-client-
2403
+ // never-gets-an-IP bug). Re-assert it here on a cooldown — cheap and
2404
+ // idempotent — so it eventually catches once both ends share a relay.
2405
+ const lastRouteReq = this.#routeRequestCooldown.get(friendId) ?? 0;
2406
+ if (this.#tcpRelays && now - lastRouteReq > 15_000) {
2407
+ try {
2408
+ const pk = base58ToBytes(friend.pubkey);
2409
+ if (pk.length === 32) {
2410
+ this.#tcpRelays.requestRoute(pk);
2411
+ this.#routeRequestCooldown.set(friendId, now);
2412
+ this.#debugLog(`re-requesting relay route for unconnected friend ${friendId}`);
2413
+ }
2414
+ }
2415
+ catch { /* skip malformed pubkey */ }
2416
+ }
2288
2417
  const dhtPk = session?.friendDhtPublicKey;
2289
2418
  if (dhtPk) {
2290
2419
  const found = await this.#discoverAndCacheFriendEndpoint(friendId, dhtPk).catch(() => false);
@@ -2391,7 +2520,22 @@ export class Peer {
2391
2520
  let tcpOk = false;
2392
2521
  let firstError;
2393
2522
  const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
2394
- if (s?.remote) {
2523
+ // "Fresh" = we've received UDP from this peer within the last few
2524
+ // seconds, so the direct endpoint is currently live. When the peer's
2525
+ // NAT remaps its port (observed periodically on residential/cloud
2526
+ // NATs) the old endpoint goes dark and lastUdpRecvMs goes stale.
2527
+ const udpFresh = !!realUdpRemote &&
2528
+ s?.lastUdpRecvMs !== undefined &&
2529
+ Date.now() - s.lastUdpRecvMs < 4_000;
2530
+ // Bulk IP data rides the direct path ONLY while it's fresh. When it
2531
+ // goes stale (likely a NAT remap mid-stream), we do NOT keep firing
2532
+ // into the dead endpoint and we do NOT duplicate onto UDP — we bridge
2533
+ // over the relay until the re-punch restores UDP (which refreshes
2534
+ // lastUdpRecvMs). This converts a multi-second blackout into a few
2535
+ // seconds of higher relay latency. Control packets always probe UDP
2536
+ // (to keep the NAT mapping warm) so we still try UDP for them.
2537
+ const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
2538
+ if (tryUdp && s?.remote) {
2395
2539
  try {
2396
2540
  await this.#sendPacket(packet, s.remote);
2397
2541
  udpOk = true;
@@ -2400,28 +2544,24 @@ export class Peer {
2400
2544
  firstError = error;
2401
2545
  }
2402
2546
  }
2403
- // UDP-confirmed-ever: we've successfully received at least one UDP
2404
- // packet from this peer, so the direct path has worked at some point.
2405
- const udpEverConfirmed = s?.lastUdpRecvMs !== undefined;
2406
- // Bulk IP-forwarding data: send UDP-ONLY once the direct path has ever
2407
- // been confirmed. Never spill onto the relay (its backlog is the
2408
- // source of the 20s-late duplicates). A transient UDP gap just drops a
2409
- // few packets; the re-punch loop (every 3s when UDP is quiet >7s)
2410
- // restores the path, and control traffic on the relay keeps the
2411
- // session alive meanwhile.
2412
- if (isBulkData && udpOk && realUdpRemote && udpEverConfirmed) {
2547
+ // Fresh direct path send over UDP only: no relay fan-out, no
2548
+ // duplicates, no relay backlog. Applies to both data and control.
2549
+ if (udpFresh && udpOk) {
2413
2550
  return;
2414
2551
  }
2415
- // Control traffic: once direct UDP is confirmed *recently* (<10s),
2416
- // also send UDP-only to avoid steady-state duplicates; otherwise fan
2417
- // out over UDP + relay so control reaches the peer even when the
2418
- // direct path is down (this is what lets the re-punch recover).
2419
- const udpLiveRecent = udpOk &&
2420
- realUdpRemote &&
2421
- s?.lastUdpRecvMs !== undefined &&
2422
- Date.now() - s.lastUdpRecvMs < 10_000;
2423
- if (udpLiveRecent) {
2424
- return;
2552
+ // Tier 2: TURN relay (tokyo) a fast (~280ms) stable fallback that
2553
+ // never NAT-flaps. Used when the direct path isn't fresh (remap, loss,
2554
+ // symmetric NAT). For bulk data, once the relay path is *confirmed*
2555
+ // (we've received over it recently) it carries data on its own — no
2556
+ // tcp-relay fan-out, no duplicates. Until confirmed, we still also try
2557
+ // tcp below so a packet isn't lost while the relay path warms up.
2558
+ let relayOk = false;
2559
+ if (s?.relayRemote && s.relayPermitted) {
2560
+ relayOk = this.#sendViaRelay(packet, s.relayRemote);
2561
+ const relayConfirmed = s.lastRelayRecvMs !== undefined && Date.now() - s.lastRelayRecvMs < 8_000;
2562
+ if (isBulkData && relayOk && relayConfirmed) {
2563
+ return;
2564
+ }
2425
2565
  }
2426
2566
  if (this.#tcpRelays) {
2427
2567
  // The TCP relay routes by the friend's DHT pubkey (= the pubkey
@@ -2443,7 +2583,7 @@ export class Peer {
2443
2583
  }
2444
2584
  }
2445
2585
  }
2446
- if (!udpOk && !tcpOk) {
2586
+ if (!udpOk && !relayOk && !tcpOk) {
2447
2587
  throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
2448
2588
  }
2449
2589
  }
@@ -2570,7 +2710,12 @@ export class Peer {
2570
2710
  * Returns undefined if no bootnode answers within the timeout.
2571
2711
  */
2572
2712
  async #gatherOwnSrflx() {
2573
- const CACHE_MS = 60_000;
2713
+ // Short cache: when our NAT remaps the socket's external port, we must
2714
+ // re-learn it quickly so the re-punch offers the *new* endpoint. A
2715
+ // long cache would keep us advertising a dead port for its full
2716
+ // lifetime, stalling recovery. Gather is only called during
2717
+ // (re)establishment/re-punch, so a short cache costs little.
2718
+ const CACHE_MS = 5_000;
2574
2719
  const now = Date.now();
2575
2720
  if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
2576
2721
  return this.#srflxCache.addr;
@@ -2623,6 +2768,74 @@ export class Peer {
2623
2768
  * endpoint discovery. Both peers do this symmetrically; on receipt each
2624
2769
  * feeds the other's endpoint into endpointCandidates and punches.
2625
2770
  */
2771
+ /**
2772
+ * Lazily allocate our node-level TURN relay on its own dedicated UDP
2773
+ * socket. Returns our public relay address (on the TURN server) which we
2774
+ * advertise to peers so they can reach us via the relay even when direct
2775
+ * UDP can't be punched. Relayed data is injected into #onDatagram tagged
2776
+ * `viaRelay` so it updates the relay freshness, not the direct endpoint.
2777
+ */
2778
+ async #ensureTurnRelay() {
2779
+ if (this.#ourRelayAddr)
2780
+ return this.#ourRelayAddr;
2781
+ if (this.#turnAllocating)
2782
+ return this.#turnAllocating;
2783
+ this.#turnAllocating = (async () => {
2784
+ for (const srv of TURN_RELAY_SERVERS) {
2785
+ try {
2786
+ const sock = createDgramSocket("udp4");
2787
+ await new Promise((resolve, reject) => {
2788
+ sock.once("error", reject);
2789
+ sock.bind(0, () => {
2790
+ sock.off("error", reject);
2791
+ resolve();
2792
+ });
2793
+ });
2794
+ const client = new TurnClient({
2795
+ sock,
2796
+ creds: { host: srv.host, port: srv.port, realm: "", username: srv.username, password: srv.password }
2797
+ });
2798
+ const alloc = await client.allocate();
2799
+ client.onData((peer, data) => {
2800
+ // Re-inject relayed payload as if it arrived directly from the
2801
+ // peer's relay address. viaRelay keeps it off the direct path.
2802
+ this.#onDatagram({
2803
+ data: Buffer.from(data),
2804
+ remote: { address: peer.address, port: peer.port },
2805
+ viaRelay: true
2806
+ });
2807
+ });
2808
+ this.#turnSocket = sock;
2809
+ this.#turnClient = client;
2810
+ this.#ourRelayAddr = { host: alloc.relayedAddress.address, port: alloc.relayedAddress.port };
2811
+ this.#debugLog(`turn relay allocated on ${srv.host}: ${this.#ourRelayAddr.host}:${this.#ourRelayAddr.port}`);
2812
+ return this.#ourRelayAddr;
2813
+ }
2814
+ catch (error) {
2815
+ this.#debugLog(`turn relay allocate failed on ${srv.host}: ${error.message}`);
2816
+ }
2817
+ }
2818
+ return undefined;
2819
+ })();
2820
+ const result = await this.#turnAllocating;
2821
+ this.#turnAllocating = undefined;
2822
+ return result;
2823
+ }
2824
+ /** Wrap + send a packet to a peer's TURN relay address via our own
2825
+ * allocation. Mirrors #sendPacket's carrier-magic framing so the peer's
2826
+ * #onDatagram processes it identically to a direct datagram. */
2827
+ #sendViaRelay(packet, relay) {
2828
+ if (!this.#turnClient)
2829
+ return false;
2830
+ const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
2831
+ try {
2832
+ this.#turnClient.sendTo({ family: 4, address: relay.host, port: relay.port }, wrapped);
2833
+ return true;
2834
+ }
2835
+ catch {
2836
+ return false;
2837
+ }
2838
+ }
2626
2839
  async #sendUdpEndpointOffer(friendId) {
2627
2840
  const session = this.#friendSessions.get(friendId);
2628
2841
  if (!session?.established)
@@ -2633,13 +2846,25 @@ export class Peer {
2633
2846
  const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
2634
2847
  if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
2635
2848
  return;
2636
- const payload = new Uint8Array(6);
2849
+ // Also advertise our TURN relay address (bytes 6-11) as a second
2850
+ // candidate. Best-effort: if the relay can't be allocated we just send
2851
+ // the 6-byte srflx-only offer (older peers ignore the extra bytes).
2852
+ const relay = await this.#ensureTurnRelay().catch(() => undefined);
2853
+ const relayOctets = relay ? relay.host.split(".").map((s) => parseInt(s, 10)) : undefined;
2854
+ const relayValid = relayOctets && relayOctets.length === 4 && !relayOctets.some((o) => Number.isNaN(o));
2855
+ const payload = new Uint8Array(relayValid ? 12 : 6);
2637
2856
  payload.set(octets, 0);
2638
2857
  payload[4] = (srflx.port >> 8) & 0xff;
2639
2858
  payload[5] = srflx.port & 0xff;
2859
+ if (relayValid && relay) {
2860
+ payload.set(relayOctets, 6);
2861
+ payload[10] = (relay.port >> 8) & 0xff;
2862
+ payload[11] = relay.port & 0xff;
2863
+ }
2640
2864
  try {
2641
2865
  await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
2642
- this.#debugLog(`udp-endpoint offer sent to ${friendId}: ${srflx.host}:${srflx.port}`);
2866
+ this.#debugLog(`udp-endpoint offer sent to ${friendId}: direct=${srflx.host}:${srflx.port}` +
2867
+ (relayValid && relay ? ` relay=${relay.host}:${relay.port}` : ""));
2643
2868
  }
2644
2869
  catch {
2645
2870
  // best-effort — the retry loop will try again
@@ -2663,6 +2888,33 @@ export class Peer {
2663
2888
  const session = this.#friendSessions.get(friendId);
2664
2889
  if (!session)
2665
2890
  return;
2891
+ // If the offer carries a TURN relay candidate (bytes 6-11), remember it
2892
+ // and permit it on our own allocation so data can flow peer→relay→us.
2893
+ if (payload.length >= 12) {
2894
+ const relayHost = `${payload[6]}.${payload[7]}.${payload[8]}.${payload[9]}`;
2895
+ const relayPort = ((payload[10] << 8) | payload[11]) >>> 0;
2896
+ if (relayPort !== 0) {
2897
+ const changed = session.relayRemote?.host !== relayHost || session.relayRemote?.port !== relayPort;
2898
+ session.relayRemote = { host: relayHost, port: relayPort };
2899
+ if (changed)
2900
+ session.relayPermitted = false;
2901
+ // (Re)create the permission on first sight, on address change, or
2902
+ // when the existing one is aging toward coturn's ~5min expiry.
2903
+ const permitAgeMs = Date.now() - (session.relayPermittedAtMs ?? 0);
2904
+ if (!session.relayPermitted || permitAgeMs > 90_000) {
2905
+ void this.#ensureTurnRelay()
2906
+ .then(() => this.#turnClient?.createPermission({ family: 4, address: relayHost, port: relayPort }))
2907
+ .then(() => {
2908
+ const first = !session.relayPermitted;
2909
+ session.relayPermitted = true;
2910
+ session.relayPermittedAtMs = Date.now();
2911
+ if (first)
2912
+ this.#debugLog(`turn permission created for ${friendId} relay ${relayHost}:${relayPort}`);
2913
+ })
2914
+ .catch(() => undefined);
2915
+ }
2916
+ }
2917
+ }
2666
2918
  // Don't punch toward ourselves.
2667
2919
  if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
2668
2920
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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",