@decentnetwork/peer 0.1.85 → 0.1.87

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.
@@ -20,6 +20,18 @@ export type UserInfoPacket = {
20
20
  gender: string;
21
21
  email: string;
22
22
  region: string;
23
+ /** AgentNet wire-protocol version (userinfo field 7, appended). 0 = a legacy
24
+ * peer that predates the field (native C SDK before the extension, or an old
25
+ * JS peer). Non-zero lets peers negotiate capabilities (e.g. toxcore file
26
+ * transfer for large files vs. inline bulkmsg). */
27
+ protoVersion: number;
28
+ /** Client platform (field 8): "ios" | "android" | "js" | "linux" | "darwin"
29
+ * | "win32" | "" (unknown/legacy). */
30
+ platform: string;
31
+ /** OS/runtime version string (field 9), e.g. "17.5", "14", "node-24.2.0". */
32
+ osVersion: string;
33
+ /** Application/SDK version (field 10), e.g. "beagle-2.3.1", "lan-0.1.166". */
34
+ appVersion: string;
23
35
  };
24
36
  export type FriendRequestPacket = {
25
37
  type: typeof PACKET_TYPE_FRIEND_REQUEST;
@@ -87,6 +99,13 @@ export type CarrierPacket = UserInfoPacket | FriendRequestPacket | FriendMessage
87
99
  * field 4 = gender : string
88
100
  * field 5 = email : string
89
101
  * field 6 = region : string
102
+ *
103
+ * AgentNet extends the table with appended fields (FlatBuffers is forward/
104
+ * backward compatible — a peer that predates a field just reads its default):
105
+ * field 7 = proto_version : uint (AgentNet wire-protocol version, 0=legacy)
106
+ * field 8 = platform : string ("ios"/"android"/"js"/"darwin"/"linux"/…)
107
+ * field 9 = os_version : string
108
+ * field 10 = app_version : string
90
109
  */
91
110
  export declare function encodeUserInfoPacket(opts: {
92
111
  avatar?: boolean;
@@ -96,6 +115,10 @@ export declare function encodeUserInfoPacket(opts: {
96
115
  gender?: string;
97
116
  email?: string;
98
117
  region?: string;
118
+ protoVersion?: number;
119
+ platform?: string;
120
+ osVersion?: string;
121
+ appVersion?: string;
99
122
  }): Uint8Array;
100
123
  export declare function encodeFriendRequestPacket(opts: {
101
124
  name?: string;
@@ -144,3 +167,18 @@ export declare function encodeInviteRspPacket(opts: {
144
167
  data?: Uint8Array;
145
168
  }): Uint8Array;
146
169
  export declare function decodeCarrierPacket(bytes: Uint8Array): CarrierPacket;
170
+ /**
171
+ * Encode a toxcore net_crypto PACKET_ID_REQUEST payload requesting retransmit
172
+ * of the packets MISSING in the receive window `[low, high]` (inclusive). This
173
+ * is the exact inverse of the decoder in peer.ts `#handleRetransmitRequest`:
174
+ * walk each number from `low`, keep a running counter `n` (starts at 1), and
175
+ * emit `n` at every MISSING number (then reset it), inserting a 0 filler byte
176
+ * whenever the counter reaches 255. `have(num)` returns true if we already hold
177
+ * that packet number. `low` is always treated as missing by construction (it is
178
+ * the low-water mark — the first not-yet-delivered number). Numbers are 32-bit
179
+ * and wrap; the window is small so the walk is bounded.
180
+ *
181
+ * Returned bytes do NOT include the leading PACKET_ID_REQUEST kind byte — the
182
+ * caller prepends it.
183
+ */
184
+ export declare function encodeRetransmitRequest(low: number, high: number, have: (num: number) => boolean): Uint8Array;
@@ -10,7 +10,12 @@ export const PACKET_TYPE_BULKMSG = 36;
10
10
  // of <=1024 raw bytes sharing one tid (totalsz set on the FIRST fragment
11
11
  // only), reassembled in arrival order by the receiver.
12
12
  export const CARRIER_MAX_APP_MESSAGE_LEN = 1024;
13
- export const CARRIER_MAX_APP_BULKMSG_LEN = 5 * 1024 * 1024;
13
+ // Raised 5 MB -> 16 MB (matches the native SDK) so inline file sends carry
14
+ // ~11 MB real files. The receive reorder buffer + retransmit (peer.ts) keep the
15
+ // many fragments in order; the reliable window still bounds lossy-path
16
+ // throughput — very large / streaming transfers should use sendFile() (toxcore
17
+ // messenger file transfer), not inline bulkmsg.
18
+ export const CARRIER_MAX_APP_BULKMSG_LEN = 16 * 1024 * 1024;
14
19
  // Carrier friend-invite constants (carrier.c / carrier.h). An invite carries
15
20
  // up to 8192 bytes of application data, fragmented into INVITE_DATA_UNIT-byte
16
21
  // chunks sharing one tid — the first fragment carries totalsz (and the
@@ -48,6 +53,13 @@ const ANYBODY_BULKMSG = 6;
48
53
  * field 4 = gender : string
49
54
  * field 5 = email : string
50
55
  * field 6 = region : string
56
+ *
57
+ * AgentNet extends the table with appended fields (FlatBuffers is forward/
58
+ * backward compatible — a peer that predates a field just reads its default):
59
+ * field 7 = proto_version : uint (AgentNet wire-protocol version, 0=legacy)
60
+ * field 8 = platform : string ("ios"/"android"/"js"/"darwin"/"linux"/…)
61
+ * field 9 = os_version : string
62
+ * field 10 = app_version : string
51
63
  */
52
64
  export function encodeUserInfoPacket(opts) {
53
65
  const builder = new Builder(256);
@@ -57,8 +69,21 @@ export function encodeUserInfoPacket(opts) {
57
69
  const gender = builder.createString(opts.gender ?? "");
58
70
  const email = builder.createString(opts.email ?? "");
59
71
  const region = builder.createString(opts.region ?? "");
60
- builder.startObject(7);
72
+ // Only emit the AgentNet extension strings when set (an all-legacy userinfo
73
+ // stays byte-identical to what the C SDK sends).
74
+ const platform = opts.platform ? builder.createString(opts.platform) : 0;
75
+ const osVersion = opts.osVersion ? builder.createString(opts.osVersion) : 0;
76
+ const appVersion = opts.appVersion ? builder.createString(opts.appVersion) : 0;
77
+ builder.startObject(11);
61
78
  // Add high field numbers first so vtable reflects all slots.
79
+ if (appVersion)
80
+ builder.addFieldOffset(10, appVersion, 0);
81
+ if (osVersion)
82
+ builder.addFieldOffset(9, osVersion, 0);
83
+ if (platform)
84
+ builder.addFieldOffset(8, platform, 0);
85
+ if (opts.protoVersion)
86
+ builder.addFieldInt32(7, opts.protoVersion >>> 0, 0);
62
87
  builder.addFieldOffset(6, region, 0);
63
88
  builder.addFieldOffset(5, email, 0);
64
89
  builder.addFieldOffset(4, gender, 0);
@@ -205,7 +230,12 @@ export function decodeCarrierPacket(bytes) {
205
230
  phone: readStringField(body, 3) ?? "",
206
231
  gender: readStringField(body, 4) ?? "",
207
232
  email: readStringField(body, 5) ?? "",
208
- region: readStringField(body, 6) ?? ""
233
+ region: readStringField(body, 6) ?? "",
234
+ // Appended AgentNet fields — absent (default) from legacy peers.
235
+ protoVersion: readUint32Field(body, 7),
236
+ platform: readStringField(body, 8) ?? "",
237
+ osVersion: readStringField(body, 9) ?? "",
238
+ appVersion: readStringField(body, 10) ?? ""
209
239
  };
210
240
  }
211
241
  if (type === PACKET_TYPE_FRIEND_REQUEST && bodyType === ANYBODY_FRIENDREQ) {
@@ -312,3 +342,36 @@ function readByteVectorField(table, field) {
312
342
  const length = table.bb.__vector_len(table.bb_pos + offset);
313
343
  return table.bb.bytes().slice(vector, vector + length);
314
344
  }
345
+ /**
346
+ * Encode a toxcore net_crypto PACKET_ID_REQUEST payload requesting retransmit
347
+ * of the packets MISSING in the receive window `[low, high]` (inclusive). This
348
+ * is the exact inverse of the decoder in peer.ts `#handleRetransmitRequest`:
349
+ * walk each number from `low`, keep a running counter `n` (starts at 1), and
350
+ * emit `n` at every MISSING number (then reset it), inserting a 0 filler byte
351
+ * whenever the counter reaches 255. `have(num)` returns true if we already hold
352
+ * that packet number. `low` is always treated as missing by construction (it is
353
+ * the low-water mark — the first not-yet-delivered number). Numbers are 32-bit
354
+ * and wrap; the window is small so the walk is bounded.
355
+ *
356
+ * Returned bytes do NOT include the leading PACKET_ID_REQUEST kind byte — the
357
+ * caller prepends it.
358
+ */
359
+ export function encodeRetransmitRequest(low, high, have) {
360
+ const out = [];
361
+ let n = 1;
362
+ const end = (high + 1) >>> 0;
363
+ for (let i = low >>> 0; i !== end; i = (i + 1) >>> 0) {
364
+ if (!have(i)) {
365
+ out.push(n & 0xff);
366
+ n = 0;
367
+ }
368
+ if (n === 255) {
369
+ out.push(0);
370
+ n = 1;
371
+ }
372
+ else {
373
+ n++;
374
+ }
375
+ }
376
+ return Uint8Array.from(out);
377
+ }
@@ -0,0 +1,7 @@
1
+ export declare const RENDEZVOUS_AUTH_KEY_DOMAIN = "RSP-CARRIER-AUTH-KEY/1";
2
+ export declare const RENDEZVOUS_AUTH_PROOF_LENGTH = 32;
3
+ /**
4
+ * Create an RSP/1 Carrier identity proof without starting Carrier discovery.
5
+ * The secret key remains caller-owned and temporary shared material is wiped.
6
+ */
7
+ export declare function createCarrierRendezvousAuthProof(identitySecretKey: Uint8Array, serverEphemeralPublicKey: Uint8Array, authTranscript: Uint8Array): Uint8Array;
@@ -0,0 +1,32 @@
1
+ import { createHmac } from "node:crypto";
2
+ import nacl from "tweetnacl";
3
+ export const RENDEZVOUS_AUTH_KEY_DOMAIN = "RSP-CARRIER-AUTH-KEY/1";
4
+ export const RENDEZVOUS_AUTH_PROOF_LENGTH = 32;
5
+ /**
6
+ * Create an RSP/1 Carrier identity proof without starting Carrier discovery.
7
+ * The secret key remains caller-owned and temporary shared material is wiped.
8
+ */
9
+ export function createCarrierRendezvousAuthProof(identitySecretKey, serverEphemeralPublicKey, authTranscript) {
10
+ if (identitySecretKey.length !== nacl.box.secretKeyLength) {
11
+ throw new RangeError(`identitySecretKey must be ${nacl.box.secretKeyLength} bytes`);
12
+ }
13
+ if (serverEphemeralPublicKey.length !== nacl.box.publicKeyLength) {
14
+ throw new RangeError(`serverEphemeralPublicKey must be ${nacl.box.publicKeyLength} bytes`);
15
+ }
16
+ if (authTranscript.length === 0)
17
+ throw new RangeError("authTranscript must not be empty");
18
+ const rawShared = nacl.scalarMult(identitySecretKey, serverEphemeralPublicKey);
19
+ const isLowOrder = rawShared.every((byte) => byte === 0);
20
+ rawShared.fill(0);
21
+ if (isLowOrder)
22
+ throw new Error("serverEphemeralPublicKey is low order");
23
+ const shared = nacl.box.before(serverEphemeralPublicKey, identitySecretKey);
24
+ const authKey = createHmac("sha256", Buffer.from(shared)).update(RENDEZVOUS_AUTH_KEY_DOMAIN, "utf8").digest();
25
+ shared.fill(0);
26
+ try {
27
+ return Uint8Array.from(createHmac("sha256", authKey).update(authTranscript).digest());
28
+ }
29
+ finally {
30
+ authKey.fill(0);
31
+ }
32
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_MESSAGE, PACKET_TYPE_INVITE_REQ
4
4
  export { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, CRYPTO_PACKET_NAT_PING, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
5
5
  export { NET_PACKET_ONION_ANNOUNCE_REQUEST, NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_REQUEST, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID } from "./compat/tox-onion.js";
6
6
  export { signDetached, verifyDetached, SIGNATURE_LENGTH } from "./crypto/sign.js";
7
+ export { createCarrierRendezvousAuthProof, RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTH } from "./crypto/rendezvous.js";
7
8
  export { base58ToBytes, bytesToBase58 } from "./utils/base58.js";
8
9
  export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
9
10
  export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
package/dist/index.js CHANGED
@@ -4,5 +4,6 @@ export { PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_MESSAGE, PACKET_TYPE_INVITE_REQ
4
4
  export { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, CRYPTO_PACKET_NAT_PING, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
5
5
  export { NET_PACKET_ONION_ANNOUNCE_REQUEST, NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_REQUEST, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID } from "./compat/tox-onion.js";
6
6
  export { signDetached, verifyDetached, SIGNATURE_LENGTH } from "./crypto/sign.js";
7
+ export { createCarrierRendezvousAuthProof, RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTH } from "./crypto/rendezvous.js";
7
8
  export { base58ToBytes, bytesToBase58 } from "./utils/base58.js";
8
9
  export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
package/dist/peer.d.ts CHANGED
@@ -45,6 +45,11 @@ export declare class Peer {
45
45
  selfAnnounceStoredOn: number;
46
46
  udpLocalPort: number | null;
47
47
  tcpRelayConnected: number;
48
+ /** TCP-relay onion diagnostics: requests handed to relays / responses
49
+ * routed back. If sent>0 but recv=0, relays aren't returning onion
50
+ * responses (relay doesn't forward, or wire mismatch). */
51
+ tcpOnionSent: number;
52
+ tcpOnionRecv: number;
48
53
  /** The specific relay endpoints this peer has live connections to.
49
54
  * Lets us compare two peers' relay sets — if there's NO overlap,
50
55
  * net_crypto handshake packets between them have nowhere to route
package/dist/peer.js CHANGED
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import nacl from "tweetnacl";
6
6
  import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
7
7
  import { LegacyBootstrapClient } from "./compat/bootstrap.js";
8
- import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, PACKET_TYPE_INVITE_REQUEST, PACKET_TYPE_INVITE_RESPONSE, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket, encodeInviteReqPacket } from "./compat/packet.js";
8
+ import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, PACKET_TYPE_INVITE_REQUEST, PACKET_TYPE_INVITE_RESPONSE, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket, encodeInviteReqPacket, encodeRetransmitRequest } from "./compat/packet.js";
9
9
  import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, createOnionRequest0Tcp, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
10
10
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
11
11
  import { NET_PACKET_PING_REQUEST, NET_PACKET_PING_RESPONSE, NET_PACKET_GET_NODES, NET_PACKET_SEND_NODES, encodeDhtRpc, decodeDhtRpc, buildPingPlain, parsePingPlain, buildGetNodesPlain, parseGetNodesPlain, buildSendNodesPlain, parseSendNodesNodeBytes, packUdpNodeV4 } from "./compat/dht-rpc.js";
@@ -165,6 +165,16 @@ const LAN_DISCOVERY_PORTS = (process.env.DECENT_LAN_DISCOVERY_PORTS ?? "33445,33
165
165
  const PEER_NICKNAME = process.env.DECENT_PEER_NAME ?? "@decentnetwork/peer";
166
166
  const PEER_STATUS_MESSAGE = process.env.DECENT_PEER_STATUS_MESSAGE ?? "decent peer";
167
167
  const GREETING_TEXT = process.env.DECENT_GREETING_TEXT ?? "";
168
+ // AgentNet wire-protocol version advertised in the userinfo profile (field 7).
169
+ // Bump when the wire capabilities change so peers can negotiate. v1 marks a peer
170
+ // that carries this field AND supports the toxcore file-transfer channel
171
+ // (PACKET_ID_FILE 80-82) for large files — a sender can prefer that over inline
172
+ // bulkmsg (capped at CARRIER_MAX_APP_BULKMSG_LEN) when the peer's protoVersion>=1.
173
+ const AGENTNET_PROTO_VERSION = 1;
174
+ // Peer package version, advertised as the default appVersion when the embedder
175
+ // doesn't override it. Read lazily so a bundler that inlines this file doesn't
176
+ // need the package.json at runtime.
177
+ const PEER_PKG_VERSION = "0.1.87";
168
178
  // Toxcore Messenger.h packet IDs (live inside encrypted 0x1b crypto data plain payload)
169
179
  const PACKET_ID_PADDING = 0;
170
180
  const PACKET_ID_REQUEST = 1; // request retransmission of unreceived packets
@@ -186,6 +196,17 @@ const PACKET_ID_ACTION = 65; // /me action message
186
196
  // understand this ID simply fall through and ignore it. Payload is 6
187
197
  // bytes: 4-byte IPv4 + 2-byte big-endian port.
188
198
  const PACKET_ID_UDP_ENDPOINT = 160;
199
+ // Receive reorder buffer bound. A reliable lossless packet more than this many
200
+ // numbers AHEAD of the contiguous low-water mark forces the stream forward
201
+ // (skipping the gap) rather than buffering unboundedly — a safety valve against
202
+ // a genuinely-lost packet that never retransmits wedging the whole stream (and
203
+ // against memory blowup). Sized generously (8192 * ~1KB ≈ 8 MB headroom) so a
204
+ // large multi-fragment inline file (16 MB bulkmsg ≈ 16k fragments) has ample
205
+ // grace for a lost fragment to be retransmitted before we give up on it.
206
+ const RECV_REORDER_WINDOW = 8192;
207
+ // Minimum gap between PACKET_ID_REQUEST packets we send for our own receive gaps
208
+ // (toxcore paces requests ~ once per RTT; this floor avoids flooding on a burst).
209
+ const RECV_REQUEST_MIN_INTERVAL_MS = 200;
189
210
  export class Peer {
190
211
  #opts;
191
212
  #events = new EventEmitter();
@@ -280,6 +301,12 @@ export class Peer {
280
301
  // 25s forever for stale persisted entries.
281
302
  #dhtPkConsecutiveFailures = new Map();
282
303
  #lastSelfAnnounceStoredCount = -1;
304
+ // Diagnostics for the TCP-relay onion path (announce/discovery over TCP).
305
+ // Surfaced in dhtHealth so `agentnet diag` shows whether it's active without
306
+ // needing verbose logs: sent = onion requests handed to relays, recv = onion
307
+ // responses routed back over TCP.
308
+ #diagTcpOnionSent = 0;
309
+ #diagTcpOnionRecv = 0;
283
310
  // Per-friend last-logged route count from #discoverFriendRoutes so we
284
311
  // only emit a "routes=N for friend=…" debug line when it changes.
285
312
  #lastLoggedRoutesForFriend = new Map();
@@ -521,6 +548,8 @@ export class Peer {
521
548
  this.#tcpRelays.on("onionResponse", (packet) => {
522
549
  if (packet.length === 0)
523
550
  return;
551
+ this.#diagTcpOnionRecv += 1;
552
+ this.#debugVerboseLog(`tcp onion response received (${packet.length} bytes, type=${packet[0]})`);
524
553
  this.#udp.emit("datagram", {
525
554
  data: Buffer.from(packet),
526
555
  remote: { address: "tcp-relay", port: 0 },
@@ -686,6 +715,8 @@ export class Peer {
686
715
  selfAnnounceStoredOn: this.#lastSelfAnnounceStoredCount,
687
716
  udpLocalPort: this.#udp?.localPort() ?? null,
688
717
  tcpRelayConnected: relays.length,
718
+ tcpOnionSent: this.#diagTcpOnionSent,
719
+ tcpOnionRecv: this.#diagTcpOnionRecv,
689
720
  tcpRelayEndpoints: relays.map((r) => ({ host: r.host, port: r.port })),
690
721
  };
691
722
  }
@@ -1806,6 +1837,10 @@ export class Peer {
1806
1837
  // colliding numbers). Drop them.
1807
1838
  state.sendArray = undefined;
1808
1839
  state.sendBufferStartNum = undefined;
1840
+ // Receive reorder buffer is keyed by the OLD stream's packet numbers —
1841
+ // discard it so stale fragments can't drain into the fresh stream.
1842
+ state.recvBuffer = undefined;
1843
+ state.lastRecvRequestMs = undefined;
1809
1844
  }
1810
1845
  else {
1811
1846
  state.sendPacketNumber ??= 0;
@@ -2010,244 +2045,62 @@ export class Peer {
2010
2045
  incrementNonce(state.peerBaseNonce);
2011
2046
  }
2012
2047
  }
2013
- const kind = opened.payload[0];
2048
+ const kind = opened.payload[0] ?? -1;
2014
2049
  const inner = opened.payload.slice(1);
2015
- // Advance our ack point ONLY for kinds that actually consume a
2016
- // reliable packet number on the sender. Toxcore sends REQUEST(1)
2017
- // and lossy (>=192) packets with num = send_array.buffer_end the
2018
- // NEXT UNUSED number, not stored in its send array. Counting those
2019
- // advanced our receiveBufferStart to buffer_end+1, i.e. we then
2020
- // acked one more packet than the peer ever sent and toxcore's
2021
- // handle_data_packet_core REJECTS any packet whose buffer_start is
2022
- // out of its send window (clear_buffer_until -> return -1). Result:
2023
- // from the peer's FIRST retransmit request onward, every packet we
2024
- // sent was discarded whole (fresh and retransmitted alike) and the
2025
- // peer went deaf until it re-keyed the final piece of the iOS
2026
- // session churn. The JS droppable bulk channel (bulkDataPacketId)
2027
- // reuses the current number without consuming it, so it must not
2028
- // advance the ack point either (also fixes reliable packets being
2029
- // dropped as duplicates after a droppable burst under the same
2030
- // number).
2050
+ // Route the decrypted packet by delivery class:
2051
+ // - REQUEST(1), lossy (>=192) and the app's droppable bulk channel
2052
+ // (bulkDataPacketId) do NOT consume a reliable send number on the
2053
+ // peer (toxcore sends them at buffer_end without storing them), so
2054
+ // they must NOT advance our ack point deliver immediately.
2055
+ // - Single-transport reliable kinds toxcore file transfer (80-82)
2056
+ // carry their own reliability and are never dual-sent, so they also
2057
+ // deliver immediately (a reorder buffer would only add latency and
2058
+ // would wait forever for retransmits that arrive at fresh numbers);
2059
+ // they still advance the ack point eagerly, exactly as before.
2060
+ // - Everything else is the DUAL-SENT reliable message stream (chat 64,
2061
+ // bulkmsg, invites, profile, control): delivered STRICTLY in order
2062
+ // through a toxcore-style recv_array (below) so a reordered or
2063
+ // retransmitted fragment is neither delivered early nor dropped as a
2064
+ // duplicate. Advancing receiveBufferStart past a gap (the old bug)
2065
+ // ACKed a packet we never delivered, so the sender never retransmit —
2066
+ // corrupting every multi-fragment message (large inline files from
2067
+ // native peers surfaced as "bulkmsg totalsz 0 dropped").
2031
2068
  const consumesReliableNumber = kind !== PACKET_ID_REQUEST &&
2032
- !(kind !== undefined && kind >= 192) &&
2069
+ kind < 192 &&
2033
2070
  kind !== this.#opts.bulkDataPacketId;
2034
- if (consumesReliableNumber) {
2035
- state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
2036
- }
2037
- // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
2038
- if (this.#fileTransfer.handlePacket(friendId, kind, inner))
2039
- return;
2040
- if (kind === PACKET_ID_ONLINE) {
2041
- this.#setFriendOnline(friendId, remote.address, remote.port);
2042
- this.#debugLog(`crypto online packet received from ${friendId}`);
2043
- // First time the friend signals ONLINE, push our profile
2044
- // (nickname + status message) and any configured greeting so the
2045
- // other side updates "unknown" to a real name. Toxcore semantics:
2046
- // these are sent on every reconnect by the friend_connection layer.
2047
- this.#sendProfileAndGreeting(friendId);
2048
- return;
2049
- }
2050
- if (kind === PACKET_ID_OFFLINE) {
2051
- this.#setFriendOffline(friendId);
2052
- this.#debugLog(`crypto offline packet received from ${friendId}`);
2053
- return;
2054
- }
2055
- if (kind === PACKET_ID_KILL) {
2056
- this.#friendSessions.delete(friendId);
2057
- this.#setFriendOffline(friendId);
2058
- this.#debugLog(`crypto kill received from ${friendId}, session torn down`);
2059
- return;
2060
- }
2061
- if (kind === PACKET_ID_ALIVE) {
2062
- // Pure liveness signal; lastPingRecvMs already updated above.
2063
- this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
2064
- return;
2065
- }
2066
- if (kind === PACKET_ID_UDP_ENDPOINT) {
2067
- // Peer told us their public UDP endpoint — hole-punch to it.
2068
- this.#handleUdpEndpointOffer(friendId, inner);
2069
- return;
2070
- }
2071
- if (kind === PACKET_ID_REQUEST) {
2072
- // Lossless retransmission request (toxcore handle_request_packet).
2073
- // Bytes are 1-based deltas walking our send window from
2074
- // sendBufferStartNum: a matching delta marks that packet number
2075
- // REQUESTED (peer is missing it — resend); every walked number
2076
- // that doesn't match is implicitly ACKED (peer has it — drop from
2077
- // the buffer). 0 is a +255 filler.
2078
- this.#handleRetransmitRequest(friendId, state, inner);
2079
- return;
2080
- }
2081
- if (kind === PACKET_ID_SHARE_RELAYS) {
2082
- // Peer is sharing TCP relay endpoints they're connected to. We
2083
- // don't have a TCP relay client yet, so cache nothing — just log.
2084
- this.#debugVerboseLog(`crypto share-relays from ${friendId} (TCP relay client not implemented)`);
2085
- return;
2086
- }
2087
- if (kind === PACKET_ID_NICKNAME) {
2088
- const name = decodeUtf8Best(inner);
2089
- const friend = this.#friends.get(friendId);
2090
- if (friend && name && friend.name !== name) {
2091
- this.#friends.set(friendId, { ...friend, name });
2092
- this.#persistFriends();
2093
- this.#events.emit("friendInfo", {
2094
- pubkey: friendId,
2095
- userid: friend.userid ?? friendId,
2096
- name,
2097
- description: friend.description
2098
- });
2071
+ if (!consumesReliableNumber || isSingleTransportKind) {
2072
+ if (consumesReliableNumber) {
2073
+ state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
2099
2074
  }
2100
- this.#debugLog(`crypto nickname received from ${friendId}: "${name}"`);
2075
+ this.#deliverLosslessPayload(friendId, state, kind, inner, remote);
2101
2076
  return;
2102
2077
  }
2103
- if (kind === PACKET_ID_STATUSMESSAGE) {
2104
- // Carrier C SDK wraps the user-profile in a FlatBuffers
2105
- // PACKET_TYPE_USERINFO packet here (name + descr + gender + phone
2106
- // + email + region + has_avatar). Some non-Carrier toxcore peers
2107
- // may still send raw UTF-8, so try the FB decode first and fall
2108
- // back to plain UTF-8 for compatibility.
2109
- let userInfoName;
2110
- let userInfoDescr;
2111
- try {
2112
- const decoded = decodeCarrierPacket(inner);
2113
- if (decoded.type === PACKET_TYPE_USERINFO) {
2114
- userInfoName = decoded.name;
2115
- userInfoDescr = decoded.descr;
2116
- }
2117
- }
2118
- catch {
2119
- // Not a Carrier userinfo packet — fall through.
2120
- }
2121
- if (userInfoName !== undefined || userInfoDescr !== undefined) {
2122
- const friend = this.#friends.get(friendId);
2123
- const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
2124
- const newDescr = userInfoDescr ?? friend?.description;
2125
- if (friend && (friend.name !== newName || friend.description !== newDescr)) {
2126
- this.#friends.set(friendId, { ...friend, name: newName, description: newDescr });
2127
- this.#persistFriends();
2128
- this.#events.emit("friendInfo", {
2129
- pubkey: friendId,
2130
- userid: friend.userid ?? friendId,
2131
- name: newName,
2132
- description: newDescr
2133
- });
2134
- }
2135
- this.#debugLog(`crypto userinfo received from ${friendId}: name="${userInfoName ?? ""}" descr="${userInfoDescr ?? ""}"`);
2136
- }
2137
- else {
2138
- const status = decodeUtf8Best(inner);
2139
- const friend = this.#friends.get(friendId);
2140
- if (friend && status && friend.description !== status) {
2141
- this.#friends.set(friendId, { ...friend, description: status });
2142
- this.#persistFriends();
2143
- }
2144
- this.#debugVerboseLog(`crypto status message (raw) received from ${friendId}: "${status}"`);
2145
- }
2078
+ const expected = state.receiveBufferStart ?? 0;
2079
+ const ahead = (opened.packetNumber - expected) >>> 0;
2080
+ if (ahead >= 0x80000000) {
2081
+ // Below the low-water mark already delivered. Duplicate; drop.
2146
2082
  return;
2147
2083
  }
2148
- if (kind === PACKET_ID_USERSTATUS) {
2149
- this.#debugVerboseLog(`crypto user-status received from ${friendId} (${inner[0] ?? "?"})`);
2084
+ if (ahead === 0) {
2085
+ // In order: deliver, advance, then drain any now-contiguous buffered.
2086
+ state.receiveBufferStart = (expected + 1) >>> 0;
2087
+ this.#deliverLosslessPayload(friendId, state, kind, inner, remote);
2088
+ this.#drainRecvBufferContiguous(friendId, state, remote);
2150
2089
  return;
2151
2090
  }
2152
- if (kind === PACKET_ID_TYPING) {
2153
- this.#debugVerboseLog(`crypto typing received from ${friendId} (${inner[0] ?? "?"})`);
2154
- return;
2091
+ // A gap sits below this packet. Hold it and drive recovery: ask the
2092
+ // sender to retransmit the missing number(s). If the buffer outgrows the
2093
+ // reorder window the gap is a genuine loss that won't come back — force
2094
+ // the stream forward rather than wedge every later packet forever.
2095
+ const buf = (state.recvBuffer ??= new Map());
2096
+ if (!buf.has(opened.packetNumber))
2097
+ buf.set(opened.packetNumber, { kind, inner });
2098
+ if (buf.size > RECV_REORDER_WINDOW) {
2099
+ this.#forceAdvanceRecvBuffer(friendId, state, remote);
2155
2100
  }
2156
- if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
2157
- this.#setFriendOnline(friendId, remote.address, remote.port);
2158
- // Decode the Carrier FlatBuffers wrapper when present. A BULKMSG
2159
- // fragment (Carrier C splits any message >1024 bytes this way —
2160
- // e.g. iOS Beagle images, which are FileModel JSON envelopes)
2161
- // goes to the reassembler and only emits once complete; before
2162
- // this, every fragment surfaced as a separate garbage "text".
2163
- let text;
2164
- let carrier;
2165
- try {
2166
- carrier = decodeCarrierPacket(inner);
2167
- }
2168
- catch { /* raw utf-8 peer */ }
2169
- // Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST/RESPONSE = 34/35)
2170
- // rides this same PACKET_ID_MESSAGE channel — it's what the iOS/Android
2171
- // WebRTC SDK uses for call signaling via the "carrier" extension. Large
2172
- // signals (SDP offers) fragment by tid like bulkmsg; reassemble then
2173
- // surface as an "invite" event (NOT chat text).
2174
- if (carrier?.type === PACKET_TYPE_INVITE_REQUEST) {
2175
- const complete = this.#assembleInvite(friendId, carrier);
2176
- if (!complete)
2177
- return; // more fragments pending
2178
- this.#events.emit("invite", {
2179
- pubkey: friendId,
2180
- ext: carrier.ext,
2181
- bundle: carrier.bundle,
2182
- data: complete
2183
- });
2184
- this.#debugLog(`invite from ${friendId}: ext="${carrier.ext ?? ""}" (${complete.length} bytes)`);
2185
- return;
2186
- }
2187
- if (carrier?.type === PACKET_TYPE_INVITE_RESPONSE) {
2188
- const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier) : new Uint8Array();
2189
- if (carrier.status === 0 && !complete)
2190
- return; // more fragments pending
2191
- this.#events.emit("inviteResponse", {
2192
- pubkey: friendId,
2193
- ext: carrier.ext,
2194
- bundle: carrier.bundle,
2195
- status: carrier.status,
2196
- reason: carrier.reason,
2197
- data: complete ?? new Uint8Array()
2198
- });
2199
- this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
2200
- return;
2201
- }
2202
- if (carrier?.type === PACKET_TYPE_BULKMSG) {
2203
- const complete = this.#assembleBulkMsg(friendId, carrier);
2204
- if (!complete)
2205
- return; // more fragments pending
2206
- text = decodeUtf8Best(complete);
2207
- this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
2208
- }
2209
- else if (carrier?.type === PACKET_TYPE_MESSAGE) {
2210
- text = new TextDecoder().decode(carrier.data);
2211
- }
2212
- else {
2213
- text = decodeUtf8Best(inner);
2214
- }
2215
- // Carrier C peers terminate strings with a NUL — strip it so chat
2216
- // text doesn't carry an invisible trailing byte.
2217
- text = text?.replace(/\0+$/u, "");
2218
- if (!text) {
2219
- this.#debugLog(`crypto message packet decode failed from ${friendId}`);
2220
- return;
2221
- }
2222
- // iOS Beagle sends files inline as a FileModel JSON envelope
2223
- // ({data: base64, fileExtension, fileName, type}) — surface those
2224
- // as files, not as a wall of base64 text.
2225
- if (this.#tryEmitInlineFile(friendId, text, "online"))
2226
- return;
2227
- this.#events.emit("text", {
2228
- pubkey: friendId,
2229
- text,
2230
- via: "online"
2231
- });
2232
- this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
2233
- return;
2234
- }
2235
- if (kind >= 160 && kind <= 254) {
2236
- // Application custom packet (toxcore lossless 160-191 / lossy
2237
- // 192-254). SDK-reserved IDs (160 UDP_ENDPOINT, 254 NAT_PING, …)
2238
- // are handled by earlier branches and never reach here; anything
2239
- // else belongs to the application layer (e.g. decentlan IP traffic
2240
- // on 192, control on 161/162). Receiving one proves the friend is
2241
- // reachable, so mark online.
2242
- this.#setFriendOnline(friendId, remote.address, remote.port);
2243
- this.#events.emit("customPacket", {
2244
- pubkey: friendId,
2245
- id: kind,
2246
- data: inner
2247
- });
2248
- return;
2101
+ else {
2102
+ this.#requestMissingReliablePackets(friendId, state);
2249
2103
  }
2250
- this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
2251
2104
  return;
2252
2105
  }
2253
2106
  // Reverted: we used to DELETE a "desynced" session here to force a clean
@@ -3339,6 +3192,323 @@ export class Peer {
3339
3192
  });
3340
3193
  }
3341
3194
  }
3195
+ /** Deliver ONE decrypted lossless payload to the application layer. Split
3196
+ * out of the datagram handler so the receive reorder buffer can replay
3197
+ * buffered packets through the exact same dispatch. `kind` is the first
3198
+ * payload byte (never undefined here — the caller defaults it). */
3199
+ #deliverLosslessPayload(friendId, state, kind, inner, remote) {
3200
+ // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
3201
+ if (this.#fileTransfer.handlePacket(friendId, kind, inner))
3202
+ return;
3203
+ if (kind === PACKET_ID_ONLINE) {
3204
+ this.#setFriendOnline(friendId, remote.address, remote.port);
3205
+ this.#debugLog(`crypto online packet received from ${friendId}`);
3206
+ // First time the friend signals ONLINE, push our profile
3207
+ // (nickname + status message) and any configured greeting so the
3208
+ // other side updates "unknown" to a real name. Toxcore semantics:
3209
+ // these are sent on every reconnect by the friend_connection layer.
3210
+ this.#sendProfileAndGreeting(friendId);
3211
+ return;
3212
+ }
3213
+ if (kind === PACKET_ID_OFFLINE) {
3214
+ this.#setFriendOffline(friendId);
3215
+ this.#debugLog(`crypto offline packet received from ${friendId}`);
3216
+ return;
3217
+ }
3218
+ if (kind === PACKET_ID_KILL) {
3219
+ this.#friendSessions.delete(friendId);
3220
+ this.#setFriendOffline(friendId);
3221
+ this.#debugLog(`crypto kill received from ${friendId}, session torn down`);
3222
+ return;
3223
+ }
3224
+ if (kind === PACKET_ID_ALIVE) {
3225
+ // Pure liveness signal; lastPingRecvMs already updated above.
3226
+ this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
3227
+ return;
3228
+ }
3229
+ if (kind === PACKET_ID_UDP_ENDPOINT) {
3230
+ // Peer told us their public UDP endpoint — hole-punch to it.
3231
+ this.#handleUdpEndpointOffer(friendId, inner);
3232
+ return;
3233
+ }
3234
+ if (kind === PACKET_ID_REQUEST) {
3235
+ // Lossless retransmission request (toxcore handle_request_packet).
3236
+ // Bytes are 1-based deltas walking our send window from
3237
+ // sendBufferStartNum: a matching delta marks that packet number
3238
+ // REQUESTED (peer is missing it — resend); every walked number
3239
+ // that doesn't match is implicitly ACKED (peer has it — drop from
3240
+ // the buffer). 0 is a +255 filler.
3241
+ this.#handleRetransmitRequest(friendId, state, inner);
3242
+ return;
3243
+ }
3244
+ if (kind === PACKET_ID_SHARE_RELAYS) {
3245
+ // Peer is sharing TCP relay endpoints they're connected to. We
3246
+ // don't have a TCP relay client yet, so cache nothing — just log.
3247
+ this.#debugVerboseLog(`crypto share-relays from ${friendId} (TCP relay client not implemented)`);
3248
+ return;
3249
+ }
3250
+ if (kind === PACKET_ID_NICKNAME) {
3251
+ const name = decodeUtf8Best(inner);
3252
+ const friend = this.#friends.get(friendId);
3253
+ if (friend && name && friend.name !== name) {
3254
+ this.#friends.set(friendId, { ...friend, name });
3255
+ this.#persistFriends();
3256
+ this.#events.emit("friendInfo", {
3257
+ pubkey: friendId,
3258
+ userid: friend.userid ?? friendId,
3259
+ name,
3260
+ description: friend.description
3261
+ });
3262
+ }
3263
+ this.#debugLog(`crypto nickname received from ${friendId}: "${name}"`);
3264
+ return;
3265
+ }
3266
+ if (kind === PACKET_ID_STATUSMESSAGE) {
3267
+ // Carrier C SDK wraps the user-profile in a FlatBuffers
3268
+ // PACKET_TYPE_USERINFO packet here (name + descr + gender + phone
3269
+ // + email + region + has_avatar). Some non-Carrier toxcore peers
3270
+ // may still send raw UTF-8, so try the FB decode first and fall
3271
+ // back to plain UTF-8 for compatibility.
3272
+ let userInfoName;
3273
+ let userInfoDescr;
3274
+ let clientMeta;
3275
+ try {
3276
+ const decoded = decodeCarrierPacket(inner);
3277
+ if (decoded.type === PACKET_TYPE_USERINFO) {
3278
+ userInfoName = decoded.name;
3279
+ userInfoDescr = decoded.descr;
3280
+ // AgentNet appended fields — present only for updated peers.
3281
+ if (decoded.protoVersion || decoded.platform || decoded.appVersion) {
3282
+ clientMeta = {
3283
+ protoVersion: decoded.protoVersion,
3284
+ platform: decoded.platform,
3285
+ osVersion: decoded.osVersion,
3286
+ appVersion: decoded.appVersion
3287
+ };
3288
+ }
3289
+ }
3290
+ }
3291
+ catch {
3292
+ // Not a Carrier userinfo packet — fall through.
3293
+ }
3294
+ if (userInfoName !== undefined || userInfoDescr !== undefined) {
3295
+ const friend = this.#friends.get(friendId);
3296
+ const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
3297
+ const newDescr = userInfoDescr ?? friend?.description;
3298
+ const metaChanged = friend != null && clientMeta != null &&
3299
+ (friend.protoVersion !== clientMeta.protoVersion ||
3300
+ friend.platform !== clientMeta.platform ||
3301
+ friend.osVersion !== clientMeta.osVersion ||
3302
+ friend.appVersion !== clientMeta.appVersion);
3303
+ if (friend && (friend.name !== newName || friend.description !== newDescr || metaChanged)) {
3304
+ this.#friends.set(friendId, { ...friend, name: newName, description: newDescr, ...(clientMeta ?? {}) });
3305
+ this.#persistFriends();
3306
+ this.#events.emit("friendInfo", {
3307
+ pubkey: friendId,
3308
+ userid: friend.userid ?? friendId,
3309
+ name: newName,
3310
+ description: newDescr,
3311
+ ...(clientMeta ?? {})
3312
+ });
3313
+ }
3314
+ this.#debugLog(`crypto userinfo received from ${friendId}: name="${userInfoName ?? ""}" descr="${userInfoDescr ?? ""}"${clientMeta ? ` proto=${clientMeta.protoVersion} platform=${clientMeta.platform} app=${clientMeta.appVersion}` : ""}`);
3315
+ }
3316
+ else {
3317
+ const status = decodeUtf8Best(inner);
3318
+ const friend = this.#friends.get(friendId);
3319
+ if (friend && status && friend.description !== status) {
3320
+ this.#friends.set(friendId, { ...friend, description: status });
3321
+ this.#persistFriends();
3322
+ }
3323
+ this.#debugVerboseLog(`crypto status message (raw) received from ${friendId}: "${status}"`);
3324
+ }
3325
+ return;
3326
+ }
3327
+ if (kind === PACKET_ID_USERSTATUS) {
3328
+ this.#debugVerboseLog(`crypto user-status received from ${friendId} (${inner[0] ?? "?"})`);
3329
+ return;
3330
+ }
3331
+ if (kind === PACKET_ID_TYPING) {
3332
+ this.#debugVerboseLog(`crypto typing received from ${friendId} (${inner[0] ?? "?"})`);
3333
+ return;
3334
+ }
3335
+ if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
3336
+ this.#setFriendOnline(friendId, remote.address, remote.port);
3337
+ // Decode the Carrier FlatBuffers wrapper when present. A BULKMSG
3338
+ // fragment (Carrier C splits any message >1024 bytes this way —
3339
+ // e.g. iOS Beagle images, which are FileModel JSON envelopes)
3340
+ // goes to the reassembler and only emits once complete; before
3341
+ // this, every fragment surfaced as a separate garbage "text".
3342
+ let text;
3343
+ let carrier;
3344
+ try {
3345
+ carrier = decodeCarrierPacket(inner);
3346
+ }
3347
+ catch { /* raw utf-8 peer */ }
3348
+ // Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST/RESPONSE = 34/35)
3349
+ // rides this same PACKET_ID_MESSAGE channel — it's what the iOS/Android
3350
+ // WebRTC SDK uses for call signaling via the "carrier" extension. Large
3351
+ // signals (SDP offers) fragment by tid like bulkmsg; reassemble then
3352
+ // surface as an "invite" event (NOT chat text).
3353
+ if (carrier?.type === PACKET_TYPE_INVITE_REQUEST) {
3354
+ const complete = this.#assembleInvite(friendId, carrier);
3355
+ if (!complete)
3356
+ return; // more fragments pending
3357
+ this.#events.emit("invite", {
3358
+ pubkey: friendId,
3359
+ ext: carrier.ext,
3360
+ bundle: carrier.bundle,
3361
+ data: complete
3362
+ });
3363
+ this.#debugLog(`invite from ${friendId}: ext="${carrier.ext ?? ""}" (${complete.length} bytes)`);
3364
+ return;
3365
+ }
3366
+ if (carrier?.type === PACKET_TYPE_INVITE_RESPONSE) {
3367
+ const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier) : new Uint8Array();
3368
+ if (carrier.status === 0 && !complete)
3369
+ return; // more fragments pending
3370
+ this.#events.emit("inviteResponse", {
3371
+ pubkey: friendId,
3372
+ ext: carrier.ext,
3373
+ bundle: carrier.bundle,
3374
+ status: carrier.status,
3375
+ reason: carrier.reason,
3376
+ data: complete ?? new Uint8Array()
3377
+ });
3378
+ this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
3379
+ return;
3380
+ }
3381
+ if (carrier?.type === PACKET_TYPE_BULKMSG) {
3382
+ const complete = this.#assembleBulkMsg(friendId, carrier);
3383
+ if (!complete)
3384
+ return; // more fragments pending
3385
+ text = decodeUtf8Best(complete);
3386
+ this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
3387
+ }
3388
+ else if (carrier?.type === PACKET_TYPE_MESSAGE) {
3389
+ text = new TextDecoder().decode(carrier.data);
3390
+ }
3391
+ else {
3392
+ text = decodeUtf8Best(inner);
3393
+ }
3394
+ // Carrier C peers terminate strings with a NUL — strip it so chat
3395
+ // text doesn't carry an invisible trailing byte.
3396
+ text = text?.replace(/\0+$/u, "");
3397
+ if (!text) {
3398
+ this.#debugLog(`crypto message packet decode failed from ${friendId}`);
3399
+ return;
3400
+ }
3401
+ // iOS Beagle sends files inline as a FileModel JSON envelope
3402
+ // ({data: base64, fileExtension, fileName, type}) — surface those
3403
+ // as files, not as a wall of base64 text.
3404
+ if (this.#tryEmitInlineFile(friendId, text, "online"))
3405
+ return;
3406
+ this.#events.emit("text", {
3407
+ pubkey: friendId,
3408
+ text,
3409
+ via: "online"
3410
+ });
3411
+ this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
3412
+ return;
3413
+ }
3414
+ if (kind >= 160 && kind <= 254) {
3415
+ // Application custom packet (toxcore lossless 160-191 / lossy
3416
+ // 192-254). SDK-reserved IDs (160 UDP_ENDPOINT, 254 NAT_PING, …)
3417
+ // are handled by earlier branches and never reach here; anything
3418
+ // else belongs to the application layer (e.g. decentlan IP traffic
3419
+ // on 192, control on 161/162). Receiving one proves the friend is
3420
+ // reachable, so mark online.
3421
+ this.#setFriendOnline(friendId, remote.address, remote.port);
3422
+ this.#events.emit("customPacket", {
3423
+ pubkey: friendId,
3424
+ id: kind,
3425
+ data: inner
3426
+ });
3427
+ return;
3428
+ }
3429
+ this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
3430
+ }
3431
+ /** Drain the receive reorder buffer forward from receiveBufferStart while it
3432
+ * holds the next contiguous packet, delivering each in strict order. */
3433
+ #drainRecvBufferContiguous(friendId, state, remote) {
3434
+ const buf = state.recvBuffer;
3435
+ if (!buf)
3436
+ return;
3437
+ for (let held = buf.get(state.receiveBufferStart ?? 0); held; held = buf.get(state.receiveBufferStart ?? 0)) {
3438
+ buf.delete(state.receiveBufferStart ?? 0);
3439
+ state.receiveBufferStart = ((state.receiveBufferStart ?? 0) + 1) >>> 0;
3440
+ this.#deliverLosslessPayload(friendId, state, held.kind, held.inner, remote);
3441
+ }
3442
+ }
3443
+ /** Safety valve: the reorder buffer grew past the window, so a packet below
3444
+ * it is (almost certainly) a genuine loss that will never retransmit. Jump
3445
+ * the low-water mark to the lowest buffered number — abandoning the gap
3446
+ * (partial messages are dropped gracefully by the reassemblers) — rather
3447
+ * than wedging every later packet (incl. chat) forever. */
3448
+ #forceAdvanceRecvBuffer(friendId, state, remote) {
3449
+ const buf = state.recvBuffer;
3450
+ if (!buf || buf.size === 0)
3451
+ return;
3452
+ const expected = state.receiveBufferStart ?? 0;
3453
+ let lowest = expected;
3454
+ let bestDist = Infinity;
3455
+ for (const n of buf.keys()) {
3456
+ const d = (n - expected) >>> 0;
3457
+ if (d < bestDist) {
3458
+ bestDist = d;
3459
+ lowest = n;
3460
+ }
3461
+ }
3462
+ this.#debugLog(`recv reorder overflow from ${friendId}: abandoning gap [${expected}..${lowest}), ${buf.size} held`);
3463
+ state.receiveBufferStart = lowest;
3464
+ this.#drainRecvBufferContiguous(friendId, state, remote);
3465
+ }
3466
+ /** Ask the sender to retransmit the packets missing below our highest
3467
+ * buffered number. Encodes the toxcore PACKET_ID_REQUEST byte stream
3468
+ * (the exact inverse of #handleRetransmitRequest): walk [low, high] and
3469
+ * emit the running gap-counter at each MISSING number. Rate-limited. */
3470
+ #requestMissingReliablePackets(friendId, state) {
3471
+ const buf = state.recvBuffer;
3472
+ if (!buf || buf.size === 0)
3473
+ return;
3474
+ const now = Date.now();
3475
+ if (state.lastRecvRequestMs && now - state.lastRecvRequestMs < RECV_REQUEST_MIN_INTERVAL_MS)
3476
+ return;
3477
+ const low = state.receiveBufferStart ?? 0;
3478
+ let high = low;
3479
+ let bestDist = -1;
3480
+ for (const n of buf.keys()) {
3481
+ const d = (n - low) >>> 0;
3482
+ if (d > bestDist) {
3483
+ bestDist = d;
3484
+ high = n;
3485
+ }
3486
+ }
3487
+ const bytes = encodeRetransmitRequest(low, high, (n) => buf.has(n));
3488
+ if (bytes.length === 0)
3489
+ return;
3490
+ state.lastRecvRequestMs = now;
3491
+ void this.#sendRequestPacket(friendId, state, bytes).catch(() => { });
3492
+ }
3493
+ /** Send a PACKET_ID_REQUEST. Like a retransmit (#resendReliablePacket) it
3494
+ * rides the CURRENT nonce but does NOT consume a reliable send number —
3495
+ * toxcore sends requests at buffer_end without storing them. */
3496
+ async #sendRequestPacket(friendId, state, requestBytes) {
3497
+ if (!state.established || !state.sessionSharedKey || !state.ourBaseNonce)
3498
+ return;
3499
+ if (!state.remote && !state.hasTcpRoute)
3500
+ return;
3501
+ const payload = concatBytes([Uint8Array.of(PACKET_ID_REQUEST), requestBytes]);
3502
+ const encrypted = createCryptoDataPacket({
3503
+ sessionSharedKey: state.sessionSharedKey,
3504
+ sentNonce: state.ourBaseNonce,
3505
+ bufferStart: state.receiveBufferStart ?? 0,
3506
+ packetNumber: state.sendPacketNumber ?? 0,
3507
+ payload
3508
+ });
3509
+ incrementNonce(state.ourBaseNonce);
3510
+ await this.#sendToFriend(friendId, encrypted, state, false, false);
3511
+ }
3342
3512
  /** Append a Carrier BULKMSG fragment; returns the full reassembled
3343
3513
  * message when the last fragment lands, undefined while pending.
3344
3514
  * Mirrors the C SDK's handle_friend_bulkmsg: totalsz on the first
@@ -3552,8 +3722,11 @@ export class Peer {
3552
3722
  session.sendBufferStartNum ??= packetNumber;
3553
3723
  session.sendArray.set(packetNumber, payload);
3554
3724
  // Bound the buffer: beyond this window the peer has either acked via
3555
- // REQUEST packets or the session is beyond saving anyway.
3556
- while (session.sendArray.size > 1024 && session.sendBufferStartNum !== session.sendPacketNumber) {
3725
+ // REQUEST packets or the session is beyond saving anyway. Sized to match
3726
+ // the receive reorder window (8192) so a large multi-fragment bulk send
3727
+ // (16 MB inline file ≈ 16k fragments) can retransmit any fragment the peer
3728
+ // is still missing within the window rather than evicting it too early.
3729
+ while (session.sendArray.size > 8192 && session.sendBufferStartNum !== session.sendPacketNumber) {
3557
3730
  session.sendArray.delete(session.sendBufferStartNum);
3558
3731
  session.sendBufferStartNum = (session.sendBufferStartNum + 1) >>> 0;
3559
3732
  }
@@ -3765,10 +3938,17 @@ export class Peer {
3765
3938
  await sleep(250);
3766
3939
  const userInfo = encodeUserInfoPacket({
3767
3940
  name: nick,
3768
- descr
3941
+ descr,
3942
+ // AgentNet client metadata (appended userinfo fields 7-10). Old
3943
+ // peers ignore them; updated peers negotiate capabilities + display
3944
+ // the friend's platform/build.
3945
+ protoVersion: AGENTNET_PROTO_VERSION,
3946
+ platform: this.#opts.platform ?? process.platform,
3947
+ osVersion: this.#opts.osVersion ?? `node-${process.versions?.node ?? ""}`,
3948
+ appVersion: this.#opts.appVersion ?? `peer-${PEER_PKG_VERSION}`
3769
3949
  });
3770
3950
  await this.#sendMessengerPacket(friendId, PACKET_ID_STATUSMESSAGE, userInfo);
3771
- this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}")`);
3951
+ this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}", proto=${AGENTNET_PROTO_VERSION}, platform=${this.#opts.platform ?? process.platform})`);
3772
3952
  }
3773
3953
  catch (error) {
3774
3954
  this.#debugLog(`send profile failed for ${friendId}: ${error.message}`);
@@ -5279,7 +5459,11 @@ export class Peer {
5279
5459
  nodeDPort: nodeD.port,
5280
5460
  payloadForNodeD
5281
5461
  });
5282
- this.#tcpRelays.sendOnionRequest(tcpPacket);
5462
+ const sent = this.#tcpRelays.sendOnionRequest(tcpPacket);
5463
+ if (sent > 0) {
5464
+ this.#diagTcpOnionSent += 1;
5465
+ this.#debugVerboseLog(`tcp onion request sent via ${sent} relay(s) to ${nodeD.host}:${nodeD.port} (B=${path.nodeB.host} C=${path.nodeC.host})`);
5466
+ }
5283
5467
  }
5284
5468
  catch {
5285
5469
  /* malformed path node key — the UDP attempt above still stands */
@@ -19,4 +19,12 @@ export type FriendRecord = {
19
19
  * can immediately park relay ROUTING_REQUESTs under the right key
20
20
  * instead of waiting for the friend's next DHT-PK announce. */
21
21
  dhtPubkey?: string;
22
+ /** Client metadata the friend advertised in its userinfo profile. Lets the
23
+ * app negotiate capabilities (e.g. large-file transfer support) and show
24
+ * the peer's platform/build. Undefined / protoVersion 0 for a legacy peer
25
+ * that predates the userinfo extension. */
26
+ protoVersion?: number;
27
+ platform?: string;
28
+ osVersion?: string;
29
+ appVersion?: string;
22
30
  };
@@ -68,6 +68,20 @@ export type PeerOptions = {
68
68
  nickname?: string;
69
69
  /** Status/description line shown alongside the nickname. */
70
70
  statusMessage?: string;
71
+ /**
72
+ * Client platform advertised to friends in the userinfo profile — e.g.
73
+ * "js", "darwin", "linux", "ios", "android". Defaults to the Node
74
+ * `process.platform` value. Native Beagle apps advertise "ios" / "android".
75
+ */
76
+ platform?: string;
77
+ /** OS/runtime version advertised in the profile (e.g. "node-24.2.0"). */
78
+ osVersion?: string;
79
+ /**
80
+ * Application/SDK version advertised in the profile (e.g. "lan-0.1.167").
81
+ * Lets peers see which build a friend is on. Defaults to the peer package
82
+ * version.
83
+ */
84
+ appVersion?: string;
71
85
  compatibilityMode?: CompatibilityMode;
72
86
  debugLabel?: string;
73
87
  };
@@ -90,6 +104,14 @@ export type FriendInfoEvent = {
90
104
  userid?: string;
91
105
  name?: string;
92
106
  description?: string;
107
+ /** Peer's advertised client metadata (from the userinfo profile). Present
108
+ * only once the friend has sent a profile with these fields — a legacy
109
+ * peer (native C SDK before the extension, or an old JS build) leaves them
110
+ * undefined / protoVersion 0. */
111
+ protoVersion?: number;
112
+ platform?: string;
113
+ osVersion?: string;
114
+ appVersion?: string;
93
115
  };
94
116
  export type TextMessage = {
95
117
  pubkey: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
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",
@@ -68,6 +68,8 @@
68
68
  "demo:tox-dht-crypto": "pnpm run build && node scripts/tox-dht-crypto-demo.mjs",
69
69
  "test:compat": "pnpm run build && node scripts/compat-selftest.mjs",
70
70
  "test:invite": "pnpm run build && node scripts/invite-selftest.mjs",
71
+ "test:reorder": "pnpm run build && node scripts/reorder-selftest.mjs",
72
+ "test:userinfo": "pnpm run build && node scripts/userinfo-selftest.mjs",
71
73
  "test:tcp-onion": "pnpm run build && node scripts/tcp-onion-selftest.mjs",
72
74
  "smoke:join": "node scripts/smoke-join.mjs",
73
75
  "typecheck": "tsc -p tsconfig.json --noEmit",