@decentnetwork/peer 0.1.18 → 0.1.19

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 +267 -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;
@@ -398,6 +417,17 @@ export class Peer {
398
417
  }
399
418
  this.#udp.off("datagram", this.#onDatagram);
400
419
  await this.#udp.stop();
420
+ // Release the TURN allocation + its dedicated socket.
421
+ try {
422
+ this.#turnClient?.close();
423
+ this.#turnSocket?.close();
424
+ }
425
+ catch {
426
+ // best-effort
427
+ }
428
+ this.#turnClient = undefined;
429
+ this.#turnSocket = undefined;
430
+ this.#ourRelayAddr = undefined;
401
431
  this.#started = false;
402
432
  }
403
433
  pubkey() {
@@ -893,7 +923,7 @@ export class Peer {
893
923
  #remoteIsTcp(remote) {
894
924
  return remote.address.startsWith("tcp:");
895
925
  }
896
- #onDatagram = ({ data, remote }) => {
926
+ #onDatagram = ({ data, remote, viaRelay }) => {
897
927
  if (!this.#keyPair) {
898
928
  return;
899
929
  }
@@ -1191,13 +1221,26 @@ export class Peer {
1191
1221
  isNewSession &&
1192
1222
  state.sessionEstablishedAtMs !== undefined) {
1193
1223
  const sinceEstablished = Date.now() - state.sessionEstablishedAtMs;
1194
- if (sinceEstablished > 1000) {
1224
+ // Only reject the fresh handshake if our CURRENT session is
1225
+ // actually alive — i.e. carrying data. A wedged "established"
1226
+ // session that's receiving nothing must NOT block a re-handshake,
1227
+ // or a peer that dropped + restarted can never reconnect: it keeps
1228
+ // sending fresh handshakes and we keep rejecting them against a
1229
+ // corpse. (This is the all-exits-outage bug: mac held a dead
1230
+ // "established" session for cn and ignored every reconnect
1231
+ // attempt.) If we haven't received a packet within FRIEND_TIMEOUT_MS
1232
+ // the current session is dead — fall through and accept the new one.
1233
+ const lastAlive = state.lastPingRecvMs ?? state.sessionEstablishedAtMs;
1234
+ const currentSessionAlive = Date.now() - lastAlive < FRIEND_TIMEOUT_MS;
1235
+ if (sinceEstablished > 1000 && currentSessionAlive) {
1195
1236
  // Verbose only — peers retransmit handshakes aggressively, so
1196
1237
  // this line fires dozens of times per minute on a stuck pair
1197
1238
  // 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)`);
1239
+ this.#debugVerboseLog(`hs_recv ignored friend=${friendId} (new session pubkey on live established session, ${sinceEstablished}ms after establish)`);
1199
1240
  return;
1200
1241
  }
1242
+ this.#debugLog(`hs_recv accepting re-handshake friend=${friendId} (current session wedged/dead, ` +
1243
+ `lastPingRecv=${state.lastPingRecvMs ? Date.now() - state.lastPingRecvMs + "ms ago" : "never"})`);
1201
1244
  }
1202
1245
  state.peerSessionPublicKey = hs.sessionPublicKey;
1203
1246
  state.peerBaseNonce = hs.baseNonce.slice();
@@ -1329,6 +1372,15 @@ export class Peer {
1329
1372
  if (this.#remoteIsTcp(remote)) {
1330
1373
  state.hasTcpRoute = true;
1331
1374
  }
1375
+ else if (viaRelay) {
1376
+ // Arrived over the TURN relay. Track relay freshness separately;
1377
+ // do NOT set state.remote (that's the direct endpoint). The relay
1378
+ // address it came from is already in state.relayRemote.
1379
+ if (!state.lastRelayRecvMs) {
1380
+ this.#debugLog(`relay_confirmed friend=${friendId} via=${remote.address}:${remote.port} (TURN relay path live)`);
1381
+ }
1382
+ state.lastRelayRecvMs = Date.now();
1383
+ }
1332
1384
  else {
1333
1385
  state.remote = { host: remote.address, port: remote.port };
1334
1386
  if (!state.lastUdpRecvMs) {
@@ -2122,10 +2174,33 @@ export class Peer {
2122
2174
  const session = this.#friendSessions.get(friendId);
2123
2175
  // Established session: keepalive + timeout monitoring
2124
2176
  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)`);
2177
+ // Liveness: time out a session that hasn't received ANY packet
2178
+ // within FRIEND_TIMEOUT_MS measured from the last received ping
2179
+ // OR, if we've never received one, from when the session was
2180
+ // established. The previous `lastPingRecvMs &&` guard skipped the
2181
+ // check entirely when lastPingRecvMs was undefined, so a session
2182
+ // that established but never carried a single packet (the path
2183
+ // died at/just-after handshake) lived forever reporting
2184
+ // transport=both while IP forwarding was 100% loss, and never tore
2185
+ // down to re-handshake. Tearing it down here drops it to the
2186
+ // non-established branch below, which re-initiates a fresh
2187
+ // connection — turning a permanent wedge into a ~timeout self-heal.
2188
+ const lastAlive = session.lastPingRecvMs ?? session.sessionEstablishedAtMs ?? now;
2189
+ if (now - lastAlive > FRIEND_TIMEOUT_MS) {
2190
+ this.#debugLog(`session timeout for ${friendId} (no ping in ${now - lastAlive}ms; ` +
2191
+ `lastPingRecv=${session.lastPingRecvMs ?? "never"}) — tearing down to re-handshake`);
2127
2192
  this.#friendSessions.delete(friendId);
2128
2193
  this.#setFriendOffline(friendId);
2194
+ // Re-assert the relay route so the friend-connection bootstrap
2195
+ // (CONNECTION_NOTIFICATION -> #initiateSession) can re-fire on
2196
+ // re-establishment. requestRoute is idempotent, so this is a
2197
+ // no-op if the route is still live.
2198
+ try {
2199
+ const pk = base58ToBytes(friend.pubkey);
2200
+ if (pk.length === 32)
2201
+ this.#tcpRelays?.requestRoute(pk);
2202
+ }
2203
+ catch { /* skip malformed */ }
2129
2204
  continue;
2130
2205
  }
2131
2206
  if (!session.lastPingSentMs || now - session.lastPingSentMs > FRIEND_PING_INTERVAL_MS) {
@@ -2163,14 +2238,33 @@ export class Peer {
2163
2238
  // before the punch is proven, so using it here would stop the
2164
2239
  // offer/punch retry prematurely and deadlock a half-punched pair.
2165
2240
  // 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;
2241
+ // >4s matched to #sendToFriend's 4s freshness window, so the
2242
+ // moment bulk data starts bridging over the relay we're already
2243
+ // re-offering + re-punching to restore the direct path (e.g. after
2244
+ // a NAT port remap). A healthy path refreshes lastUdpRecvMs every
2245
+ // ~1s via keepalive/data, so this never fires while UDP works.
2246
+ // Relay keepalive runs even when the direct path is perfectly
2247
+ // healthy, so the TURN relay stays ready as an instant fallback.
2248
+ // Without this the relay's permission (and the candidate exchange)
2249
+ // silently lapse after ~5min and a later direct-path failure falls
2250
+ // all the way back to the slow tcp-relay. Re-send our offer (so the
2251
+ // peer refreshes ITS permission for our relay) and re-permit the
2252
+ // peer's relay on our own allocation. Cheap, every 2min.
2253
+ if (session.established && session.hasTcpRoute) {
2254
+ const RELAY_KEEPALIVE_MS = 120_000;
2255
+ if (now - (session.lastRelayKeepaliveMs ?? 0) > RELAY_KEEPALIVE_MS) {
2256
+ session.lastRelayKeepaliveMs = now;
2257
+ void this.#sendUdpEndpointOffer(friendId).catch(() => undefined);
2258
+ if (session.relayRemote && this.#turnClient) {
2259
+ const rr = session.relayRemote;
2260
+ void this.#turnClient
2261
+ .createPermission({ family: 4, address: rr.host, port: rr.port })
2262
+ .then(() => { session.relayPermittedAtMs = Date.now(); })
2263
+ .catch(() => undefined);
2264
+ }
2265
+ }
2266
+ }
2267
+ const udpConfirmed = session.lastUdpRecvMs !== undefined && now - session.lastUdpRecvMs < 4_000;
2174
2268
  if (!udpConfirmed && session.hasTcpRoute) {
2175
2269
  // Aggressive re-punch cadence: the relay path is lossy, so a few
2176
2270
  // offers may drop before one lands and both sides punch in the
@@ -2280,10 +2374,18 @@ export class Peer {
2280
2374
  });
2281
2375
  }
2282
2376
  }
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;
2377
+ // No active session: try to establish one if we know the friend's
2378
+ // endpoint. A friend reachable over the TCP relay counts the cookie
2379
+ // request + handshake go over the relay OOB (the log shows
2380
+ // `cookie_sent udp=0 tcp=1`). Without including hasTcpRoute here, a
2381
+ // relay-only friend whose handshake didn't complete on the first try
2382
+ // (peer's handshake-back was lost) was NEVER retried — the cookie
2383
+ // retry below was gated on a UDP endpoint — so the session stayed
2384
+ // stuck forever at "friend_online but never established". That is the
2385
+ // post-restart reconnection wedge that took down cn/callpass: relay
2386
+ // bridged them, cookie+handshake fired once, didn't complete, and
2387
+ // nothing re-tried it. Counting the relay route lets the retry fire.
2388
+ const haveEndpoint = (friend.remoteHost && friend.remotePort) || session?.remote || session?.hasTcpRoute;
2287
2389
  if (!haveEndpoint) {
2288
2390
  const dhtPk = session?.friendDhtPublicKey;
2289
2391
  if (dhtPk) {
@@ -2391,7 +2493,22 @@ export class Peer {
2391
2493
  let tcpOk = false;
2392
2494
  let firstError;
2393
2495
  const realUdpRemote = s?.remote && !s.remote.host?.startsWith("tcp:") && s.remote.port !== 0;
2394
- if (s?.remote) {
2496
+ // "Fresh" = we've received UDP from this peer within the last few
2497
+ // seconds, so the direct endpoint is currently live. When the peer's
2498
+ // NAT remaps its port (observed periodically on residential/cloud
2499
+ // NATs) the old endpoint goes dark and lastUdpRecvMs goes stale.
2500
+ const udpFresh = !!realUdpRemote &&
2501
+ s?.lastUdpRecvMs !== undefined &&
2502
+ Date.now() - s.lastUdpRecvMs < 4_000;
2503
+ // Bulk IP data rides the direct path ONLY while it's fresh. When it
2504
+ // goes stale (likely a NAT remap mid-stream), we do NOT keep firing
2505
+ // into the dead endpoint and we do NOT duplicate onto UDP — we bridge
2506
+ // over the relay until the re-punch restores UDP (which refreshes
2507
+ // lastUdpRecvMs). This converts a multi-second blackout into a few
2508
+ // seconds of higher relay latency. Control packets always probe UDP
2509
+ // (to keep the NAT mapping warm) so we still try UDP for them.
2510
+ const tryUdp = realUdpRemote && (isBulkData ? udpFresh : true);
2511
+ if (tryUdp && s?.remote) {
2395
2512
  try {
2396
2513
  await this.#sendPacket(packet, s.remote);
2397
2514
  udpOk = true;
@@ -2400,28 +2517,24 @@ export class Peer {
2400
2517
  firstError = error;
2401
2518
  }
2402
2519
  }
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) {
2520
+ // Fresh direct path send over UDP only: no relay fan-out, no
2521
+ // duplicates, no relay backlog. Applies to both data and control.
2522
+ if (udpFresh && udpOk) {
2413
2523
  return;
2414
2524
  }
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;
2525
+ // Tier 2: TURN relay (tokyo) a fast (~280ms) stable fallback that
2526
+ // never NAT-flaps. Used when the direct path isn't fresh (remap, loss,
2527
+ // symmetric NAT). For bulk data, once the relay path is *confirmed*
2528
+ // (we've received over it recently) it carries data on its own — no
2529
+ // tcp-relay fan-out, no duplicates. Until confirmed, we still also try
2530
+ // tcp below so a packet isn't lost while the relay path warms up.
2531
+ let relayOk = false;
2532
+ if (s?.relayRemote && s.relayPermitted) {
2533
+ relayOk = this.#sendViaRelay(packet, s.relayRemote);
2534
+ const relayConfirmed = s.lastRelayRecvMs !== undefined && Date.now() - s.lastRelayRecvMs < 8_000;
2535
+ if (isBulkData && relayOk && relayConfirmed) {
2536
+ return;
2537
+ }
2425
2538
  }
2426
2539
  if (this.#tcpRelays) {
2427
2540
  // The TCP relay routes by the friend's DHT pubkey (= the pubkey
@@ -2443,7 +2556,7 @@ export class Peer {
2443
2556
  }
2444
2557
  }
2445
2558
  }
2446
- if (!udpOk && !tcpOk) {
2559
+ if (!udpOk && !relayOk && !tcpOk) {
2447
2560
  throw firstError ?? new Error(`no transport accepted send for ${friendId}`);
2448
2561
  }
2449
2562
  }
@@ -2570,7 +2683,12 @@ export class Peer {
2570
2683
  * Returns undefined if no bootnode answers within the timeout.
2571
2684
  */
2572
2685
  async #gatherOwnSrflx() {
2573
- const CACHE_MS = 60_000;
2686
+ // Short cache: when our NAT remaps the socket's external port, we must
2687
+ // re-learn it quickly so the re-punch offers the *new* endpoint. A
2688
+ // long cache would keep us advertising a dead port for its full
2689
+ // lifetime, stalling recovery. Gather is only called during
2690
+ // (re)establishment/re-punch, so a short cache costs little.
2691
+ const CACHE_MS = 5_000;
2574
2692
  const now = Date.now();
2575
2693
  if (this.#srflxCache && now - this.#srflxCache.atMs < CACHE_MS) {
2576
2694
  return this.#srflxCache.addr;
@@ -2623,6 +2741,74 @@ export class Peer {
2623
2741
  * endpoint discovery. Both peers do this symmetrically; on receipt each
2624
2742
  * feeds the other's endpoint into endpointCandidates and punches.
2625
2743
  */
2744
+ /**
2745
+ * Lazily allocate our node-level TURN relay on its own dedicated UDP
2746
+ * socket. Returns our public relay address (on the TURN server) which we
2747
+ * advertise to peers so they can reach us via the relay even when direct
2748
+ * UDP can't be punched. Relayed data is injected into #onDatagram tagged
2749
+ * `viaRelay` so it updates the relay freshness, not the direct endpoint.
2750
+ */
2751
+ async #ensureTurnRelay() {
2752
+ if (this.#ourRelayAddr)
2753
+ return this.#ourRelayAddr;
2754
+ if (this.#turnAllocating)
2755
+ return this.#turnAllocating;
2756
+ this.#turnAllocating = (async () => {
2757
+ for (const srv of TURN_RELAY_SERVERS) {
2758
+ try {
2759
+ const sock = createDgramSocket("udp4");
2760
+ await new Promise((resolve, reject) => {
2761
+ sock.once("error", reject);
2762
+ sock.bind(0, () => {
2763
+ sock.off("error", reject);
2764
+ resolve();
2765
+ });
2766
+ });
2767
+ const client = new TurnClient({
2768
+ sock,
2769
+ creds: { host: srv.host, port: srv.port, realm: "", username: srv.username, password: srv.password }
2770
+ });
2771
+ const alloc = await client.allocate();
2772
+ client.onData((peer, data) => {
2773
+ // Re-inject relayed payload as if it arrived directly from the
2774
+ // peer's relay address. viaRelay keeps it off the direct path.
2775
+ this.#onDatagram({
2776
+ data: Buffer.from(data),
2777
+ remote: { address: peer.address, port: peer.port },
2778
+ viaRelay: true
2779
+ });
2780
+ });
2781
+ this.#turnSocket = sock;
2782
+ this.#turnClient = client;
2783
+ this.#ourRelayAddr = { host: alloc.relayedAddress.address, port: alloc.relayedAddress.port };
2784
+ this.#debugLog(`turn relay allocated on ${srv.host}: ${this.#ourRelayAddr.host}:${this.#ourRelayAddr.port}`);
2785
+ return this.#ourRelayAddr;
2786
+ }
2787
+ catch (error) {
2788
+ this.#debugLog(`turn relay allocate failed on ${srv.host}: ${error.message}`);
2789
+ }
2790
+ }
2791
+ return undefined;
2792
+ })();
2793
+ const result = await this.#turnAllocating;
2794
+ this.#turnAllocating = undefined;
2795
+ return result;
2796
+ }
2797
+ /** Wrap + send a packet to a peer's TURN relay address via our own
2798
+ * allocation. Mirrors #sendPacket's carrier-magic framing so the peer's
2799
+ * #onDatagram processes it identically to a direct datagram. */
2800
+ #sendViaRelay(packet, relay) {
2801
+ if (!this.#turnClient)
2802
+ return false;
2803
+ const wrapped = concatBytes([Uint8Array.of(0x69, 0x76, 0x65, 0x67), packet]);
2804
+ try {
2805
+ this.#turnClient.sendTo({ family: 4, address: relay.host, port: relay.port }, wrapped);
2806
+ return true;
2807
+ }
2808
+ catch {
2809
+ return false;
2810
+ }
2811
+ }
2626
2812
  async #sendUdpEndpointOffer(friendId) {
2627
2813
  const session = this.#friendSessions.get(friendId);
2628
2814
  if (!session?.established)
@@ -2633,13 +2819,25 @@ export class Peer {
2633
2819
  const octets = srflx.host.split(".").map((s) => parseInt(s, 10));
2634
2820
  if (octets.length !== 4 || octets.some((o) => Number.isNaN(o)))
2635
2821
  return;
2636
- const payload = new Uint8Array(6);
2822
+ // Also advertise our TURN relay address (bytes 6-11) as a second
2823
+ // candidate. Best-effort: if the relay can't be allocated we just send
2824
+ // the 6-byte srflx-only offer (older peers ignore the extra bytes).
2825
+ const relay = await this.#ensureTurnRelay().catch(() => undefined);
2826
+ const relayOctets = relay ? relay.host.split(".").map((s) => parseInt(s, 10)) : undefined;
2827
+ const relayValid = relayOctets && relayOctets.length === 4 && !relayOctets.some((o) => Number.isNaN(o));
2828
+ const payload = new Uint8Array(relayValid ? 12 : 6);
2637
2829
  payload.set(octets, 0);
2638
2830
  payload[4] = (srflx.port >> 8) & 0xff;
2639
2831
  payload[5] = srflx.port & 0xff;
2832
+ if (relayValid && relay) {
2833
+ payload.set(relayOctets, 6);
2834
+ payload[10] = (relay.port >> 8) & 0xff;
2835
+ payload[11] = relay.port & 0xff;
2836
+ }
2640
2837
  try {
2641
2838
  await this.#sendMessengerPacket(friendId, PACKET_ID_UDP_ENDPOINT, payload);
2642
- this.#debugLog(`udp-endpoint offer sent to ${friendId}: ${srflx.host}:${srflx.port}`);
2839
+ this.#debugLog(`udp-endpoint offer sent to ${friendId}: direct=${srflx.host}:${srflx.port}` +
2840
+ (relayValid && relay ? ` relay=${relay.host}:${relay.port}` : ""));
2643
2841
  }
2644
2842
  catch {
2645
2843
  // best-effort — the retry loop will try again
@@ -2663,6 +2861,33 @@ export class Peer {
2663
2861
  const session = this.#friendSessions.get(friendId);
2664
2862
  if (!session)
2665
2863
  return;
2864
+ // If the offer carries a TURN relay candidate (bytes 6-11), remember it
2865
+ // and permit it on our own allocation so data can flow peer→relay→us.
2866
+ if (payload.length >= 12) {
2867
+ const relayHost = `${payload[6]}.${payload[7]}.${payload[8]}.${payload[9]}`;
2868
+ const relayPort = ((payload[10] << 8) | payload[11]) >>> 0;
2869
+ if (relayPort !== 0) {
2870
+ const changed = session.relayRemote?.host !== relayHost || session.relayRemote?.port !== relayPort;
2871
+ session.relayRemote = { host: relayHost, port: relayPort };
2872
+ if (changed)
2873
+ session.relayPermitted = false;
2874
+ // (Re)create the permission on first sight, on address change, or
2875
+ // when the existing one is aging toward coturn's ~5min expiry.
2876
+ const permitAgeMs = Date.now() - (session.relayPermittedAtMs ?? 0);
2877
+ if (!session.relayPermitted || permitAgeMs > 90_000) {
2878
+ void this.#ensureTurnRelay()
2879
+ .then(() => this.#turnClient?.createPermission({ family: 4, address: relayHost, port: relayPort }))
2880
+ .then(() => {
2881
+ const first = !session.relayPermitted;
2882
+ session.relayPermitted = true;
2883
+ session.relayPermittedAtMs = Date.now();
2884
+ if (first)
2885
+ this.#debugLog(`turn permission created for ${friendId} relay ${relayHost}:${relayPort}`);
2886
+ })
2887
+ .catch(() => undefined);
2888
+ }
2889
+ }
2890
+ }
2666
2891
  // Don't punch toward ourselves.
2667
2892
  if (getLocalIpv4Addresses().includes(host) && this.#udp.localPort() === port)
2668
2893
  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.19",
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",