@decentnetwork/peer 0.1.86 → 0.1.88
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.
- package/dist/compat/packet.d.ts +38 -0
- package/dist/compat/packet.js +66 -3
- package/dist/crypto/rendezvous.d.ts +7 -0
- package/dist/crypto/rendezvous.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/peer.js +381 -237
- package/dist/store/friends.d.ts +8 -0
- package/dist/types/peer.d.ts +22 -0
- package/package.json +3 -1
package/dist/compat/packet.d.ts
CHANGED
|
@@ -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;
|
package/dist/compat/packet.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.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.88";
|
|
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();
|
|
@@ -1816,6 +1837,10 @@ export class Peer {
|
|
|
1816
1837
|
// colliding numbers). Drop them.
|
|
1817
1838
|
state.sendArray = undefined;
|
|
1818
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;
|
|
1819
1844
|
}
|
|
1820
1845
|
else {
|
|
1821
1846
|
state.sendPacketNumber ??= 0;
|
|
@@ -2020,244 +2045,36 @@ export class Peer {
|
|
|
2020
2045
|
incrementNonce(state.peerBaseNonce);
|
|
2021
2046
|
}
|
|
2022
2047
|
}
|
|
2023
|
-
const kind = opened.payload[0];
|
|
2048
|
+
const kind = opened.payload[0] ?? -1;
|
|
2024
2049
|
const inner = opened.payload.slice(1);
|
|
2025
|
-
//
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
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 a reliable message (chat 64, bulkmsg, invites,
|
|
2061
|
+
// profile, control): deliver immediately and advance the ack point.
|
|
2062
|
+
//
|
|
2063
|
+
// NOTE: a strict in-order recv_array (buffer-ahead + retransmit-request)
|
|
2064
|
+
// was tried here to fix reordered multi-fragment inline files, but it
|
|
2065
|
+
// STALLED the whole reliable stream against native peers whose retransmit
|
|
2066
|
+
// didn't recover our gaps — breaking chat + files in BOTH directions
|
|
2067
|
+
// (the stalled ack point starved the peer's send window). Reverted to the
|
|
2068
|
+
// forgiving skip-the-gap behavior: a lost fragment corrupts one message
|
|
2069
|
+
// (rare) instead of wedging the session. Large files should use the
|
|
2070
|
+
// toxcore file-transfer channel (sendFile), which has its own reliability.
|
|
2041
2071
|
const consumesReliableNumber = kind !== PACKET_ID_REQUEST &&
|
|
2042
|
-
|
|
2072
|
+
kind < 192 &&
|
|
2043
2073
|
kind !== this.#opts.bulkDataPacketId;
|
|
2044
2074
|
if (consumesReliableNumber) {
|
|
2045
2075
|
state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
|
|
2046
2076
|
}
|
|
2047
|
-
|
|
2048
|
-
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
2049
|
-
return;
|
|
2050
|
-
if (kind === PACKET_ID_ONLINE) {
|
|
2051
|
-
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
2052
|
-
this.#debugLog(`crypto online packet received from ${friendId}`);
|
|
2053
|
-
// First time the friend signals ONLINE, push our profile
|
|
2054
|
-
// (nickname + status message) and any configured greeting so the
|
|
2055
|
-
// other side updates "unknown" to a real name. Toxcore semantics:
|
|
2056
|
-
// these are sent on every reconnect by the friend_connection layer.
|
|
2057
|
-
this.#sendProfileAndGreeting(friendId);
|
|
2058
|
-
return;
|
|
2059
|
-
}
|
|
2060
|
-
if (kind === PACKET_ID_OFFLINE) {
|
|
2061
|
-
this.#setFriendOffline(friendId);
|
|
2062
|
-
this.#debugLog(`crypto offline packet received from ${friendId}`);
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
if (kind === PACKET_ID_KILL) {
|
|
2066
|
-
this.#friendSessions.delete(friendId);
|
|
2067
|
-
this.#setFriendOffline(friendId);
|
|
2068
|
-
this.#debugLog(`crypto kill received from ${friendId}, session torn down`);
|
|
2069
|
-
return;
|
|
2070
|
-
}
|
|
2071
|
-
if (kind === PACKET_ID_ALIVE) {
|
|
2072
|
-
// Pure liveness signal; lastPingRecvMs already updated above.
|
|
2073
|
-
this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
|
|
2074
|
-
return;
|
|
2075
|
-
}
|
|
2076
|
-
if (kind === PACKET_ID_UDP_ENDPOINT) {
|
|
2077
|
-
// Peer told us their public UDP endpoint — hole-punch to it.
|
|
2078
|
-
this.#handleUdpEndpointOffer(friendId, inner);
|
|
2079
|
-
return;
|
|
2080
|
-
}
|
|
2081
|
-
if (kind === PACKET_ID_REQUEST) {
|
|
2082
|
-
// Lossless retransmission request (toxcore handle_request_packet).
|
|
2083
|
-
// Bytes are 1-based deltas walking our send window from
|
|
2084
|
-
// sendBufferStartNum: a matching delta marks that packet number
|
|
2085
|
-
// REQUESTED (peer is missing it — resend); every walked number
|
|
2086
|
-
// that doesn't match is implicitly ACKED (peer has it — drop from
|
|
2087
|
-
// the buffer). 0 is a +255 filler.
|
|
2088
|
-
this.#handleRetransmitRequest(friendId, state, inner);
|
|
2089
|
-
return;
|
|
2090
|
-
}
|
|
2091
|
-
if (kind === PACKET_ID_SHARE_RELAYS) {
|
|
2092
|
-
// Peer is sharing TCP relay endpoints they're connected to. We
|
|
2093
|
-
// don't have a TCP relay client yet, so cache nothing — just log.
|
|
2094
|
-
this.#debugVerboseLog(`crypto share-relays from ${friendId} (TCP relay client not implemented)`);
|
|
2095
|
-
return;
|
|
2096
|
-
}
|
|
2097
|
-
if (kind === PACKET_ID_NICKNAME) {
|
|
2098
|
-
const name = decodeUtf8Best(inner);
|
|
2099
|
-
const friend = this.#friends.get(friendId);
|
|
2100
|
-
if (friend && name && friend.name !== name) {
|
|
2101
|
-
this.#friends.set(friendId, { ...friend, name });
|
|
2102
|
-
this.#persistFriends();
|
|
2103
|
-
this.#events.emit("friendInfo", {
|
|
2104
|
-
pubkey: friendId,
|
|
2105
|
-
userid: friend.userid ?? friendId,
|
|
2106
|
-
name,
|
|
2107
|
-
description: friend.description
|
|
2108
|
-
});
|
|
2109
|
-
}
|
|
2110
|
-
this.#debugLog(`crypto nickname received from ${friendId}: "${name}"`);
|
|
2111
|
-
return;
|
|
2112
|
-
}
|
|
2113
|
-
if (kind === PACKET_ID_STATUSMESSAGE) {
|
|
2114
|
-
// Carrier C SDK wraps the user-profile in a FlatBuffers
|
|
2115
|
-
// PACKET_TYPE_USERINFO packet here (name + descr + gender + phone
|
|
2116
|
-
// + email + region + has_avatar). Some non-Carrier toxcore peers
|
|
2117
|
-
// may still send raw UTF-8, so try the FB decode first and fall
|
|
2118
|
-
// back to plain UTF-8 for compatibility.
|
|
2119
|
-
let userInfoName;
|
|
2120
|
-
let userInfoDescr;
|
|
2121
|
-
try {
|
|
2122
|
-
const decoded = decodeCarrierPacket(inner);
|
|
2123
|
-
if (decoded.type === PACKET_TYPE_USERINFO) {
|
|
2124
|
-
userInfoName = decoded.name;
|
|
2125
|
-
userInfoDescr = decoded.descr;
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
catch {
|
|
2129
|
-
// Not a Carrier userinfo packet — fall through.
|
|
2130
|
-
}
|
|
2131
|
-
if (userInfoName !== undefined || userInfoDescr !== undefined) {
|
|
2132
|
-
const friend = this.#friends.get(friendId);
|
|
2133
|
-
const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
|
|
2134
|
-
const newDescr = userInfoDescr ?? friend?.description;
|
|
2135
|
-
if (friend && (friend.name !== newName || friend.description !== newDescr)) {
|
|
2136
|
-
this.#friends.set(friendId, { ...friend, name: newName, description: newDescr });
|
|
2137
|
-
this.#persistFriends();
|
|
2138
|
-
this.#events.emit("friendInfo", {
|
|
2139
|
-
pubkey: friendId,
|
|
2140
|
-
userid: friend.userid ?? friendId,
|
|
2141
|
-
name: newName,
|
|
2142
|
-
description: newDescr
|
|
2143
|
-
});
|
|
2144
|
-
}
|
|
2145
|
-
this.#debugLog(`crypto userinfo received from ${friendId}: name="${userInfoName ?? ""}" descr="${userInfoDescr ?? ""}"`);
|
|
2146
|
-
}
|
|
2147
|
-
else {
|
|
2148
|
-
const status = decodeUtf8Best(inner);
|
|
2149
|
-
const friend = this.#friends.get(friendId);
|
|
2150
|
-
if (friend && status && friend.description !== status) {
|
|
2151
|
-
this.#friends.set(friendId, { ...friend, description: status });
|
|
2152
|
-
this.#persistFriends();
|
|
2153
|
-
}
|
|
2154
|
-
this.#debugVerboseLog(`crypto status message (raw) received from ${friendId}: "${status}"`);
|
|
2155
|
-
}
|
|
2156
|
-
return;
|
|
2157
|
-
}
|
|
2158
|
-
if (kind === PACKET_ID_USERSTATUS) {
|
|
2159
|
-
this.#debugVerboseLog(`crypto user-status received from ${friendId} (${inner[0] ?? "?"})`);
|
|
2160
|
-
return;
|
|
2161
|
-
}
|
|
2162
|
-
if (kind === PACKET_ID_TYPING) {
|
|
2163
|
-
this.#debugVerboseLog(`crypto typing received from ${friendId} (${inner[0] ?? "?"})`);
|
|
2164
|
-
return;
|
|
2165
|
-
}
|
|
2166
|
-
if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
|
|
2167
|
-
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
2168
|
-
// Decode the Carrier FlatBuffers wrapper when present. A BULKMSG
|
|
2169
|
-
// fragment (Carrier C splits any message >1024 bytes this way —
|
|
2170
|
-
// e.g. iOS Beagle images, which are FileModel JSON envelopes)
|
|
2171
|
-
// goes to the reassembler and only emits once complete; before
|
|
2172
|
-
// this, every fragment surfaced as a separate garbage "text".
|
|
2173
|
-
let text;
|
|
2174
|
-
let carrier;
|
|
2175
|
-
try {
|
|
2176
|
-
carrier = decodeCarrierPacket(inner);
|
|
2177
|
-
}
|
|
2178
|
-
catch { /* raw utf-8 peer */ }
|
|
2179
|
-
// Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST/RESPONSE = 34/35)
|
|
2180
|
-
// rides this same PACKET_ID_MESSAGE channel — it's what the iOS/Android
|
|
2181
|
-
// WebRTC SDK uses for call signaling via the "carrier" extension. Large
|
|
2182
|
-
// signals (SDP offers) fragment by tid like bulkmsg; reassemble then
|
|
2183
|
-
// surface as an "invite" event (NOT chat text).
|
|
2184
|
-
if (carrier?.type === PACKET_TYPE_INVITE_REQUEST) {
|
|
2185
|
-
const complete = this.#assembleInvite(friendId, carrier);
|
|
2186
|
-
if (!complete)
|
|
2187
|
-
return; // more fragments pending
|
|
2188
|
-
this.#events.emit("invite", {
|
|
2189
|
-
pubkey: friendId,
|
|
2190
|
-
ext: carrier.ext,
|
|
2191
|
-
bundle: carrier.bundle,
|
|
2192
|
-
data: complete
|
|
2193
|
-
});
|
|
2194
|
-
this.#debugLog(`invite from ${friendId}: ext="${carrier.ext ?? ""}" (${complete.length} bytes)`);
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
if (carrier?.type === PACKET_TYPE_INVITE_RESPONSE) {
|
|
2198
|
-
const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier) : new Uint8Array();
|
|
2199
|
-
if (carrier.status === 0 && !complete)
|
|
2200
|
-
return; // more fragments pending
|
|
2201
|
-
this.#events.emit("inviteResponse", {
|
|
2202
|
-
pubkey: friendId,
|
|
2203
|
-
ext: carrier.ext,
|
|
2204
|
-
bundle: carrier.bundle,
|
|
2205
|
-
status: carrier.status,
|
|
2206
|
-
reason: carrier.reason,
|
|
2207
|
-
data: complete ?? new Uint8Array()
|
|
2208
|
-
});
|
|
2209
|
-
this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
|
|
2210
|
-
return;
|
|
2211
|
-
}
|
|
2212
|
-
if (carrier?.type === PACKET_TYPE_BULKMSG) {
|
|
2213
|
-
const complete = this.#assembleBulkMsg(friendId, carrier);
|
|
2214
|
-
if (!complete)
|
|
2215
|
-
return; // more fragments pending
|
|
2216
|
-
text = decodeUtf8Best(complete);
|
|
2217
|
-
this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
|
|
2218
|
-
}
|
|
2219
|
-
else if (carrier?.type === PACKET_TYPE_MESSAGE) {
|
|
2220
|
-
text = new TextDecoder().decode(carrier.data);
|
|
2221
|
-
}
|
|
2222
|
-
else {
|
|
2223
|
-
text = decodeUtf8Best(inner);
|
|
2224
|
-
}
|
|
2225
|
-
// Carrier C peers terminate strings with a NUL — strip it so chat
|
|
2226
|
-
// text doesn't carry an invisible trailing byte.
|
|
2227
|
-
text = text?.replace(/\0+$/u, "");
|
|
2228
|
-
if (!text) {
|
|
2229
|
-
this.#debugLog(`crypto message packet decode failed from ${friendId}`);
|
|
2230
|
-
return;
|
|
2231
|
-
}
|
|
2232
|
-
// iOS Beagle sends files inline as a FileModel JSON envelope
|
|
2233
|
-
// ({data: base64, fileExtension, fileName, type}) — surface those
|
|
2234
|
-
// as files, not as a wall of base64 text.
|
|
2235
|
-
if (this.#tryEmitInlineFile(friendId, text, "online"))
|
|
2236
|
-
return;
|
|
2237
|
-
this.#events.emit("text", {
|
|
2238
|
-
pubkey: friendId,
|
|
2239
|
-
text,
|
|
2240
|
-
via: "online"
|
|
2241
|
-
});
|
|
2242
|
-
this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
|
|
2243
|
-
return;
|
|
2244
|
-
}
|
|
2245
|
-
if (kind >= 160 && kind <= 254) {
|
|
2246
|
-
// Application custom packet (toxcore lossless 160-191 / lossy
|
|
2247
|
-
// 192-254). SDK-reserved IDs (160 UDP_ENDPOINT, 254 NAT_PING, …)
|
|
2248
|
-
// are handled by earlier branches and never reach here; anything
|
|
2249
|
-
// else belongs to the application layer (e.g. decentlan IP traffic
|
|
2250
|
-
// on 192, control on 161/162). Receiving one proves the friend is
|
|
2251
|
-
// reachable, so mark online.
|
|
2252
|
-
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
2253
|
-
this.#events.emit("customPacket", {
|
|
2254
|
-
pubkey: friendId,
|
|
2255
|
-
id: kind,
|
|
2256
|
-
data: inner
|
|
2257
|
-
});
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
|
|
2077
|
+
this.#deliverLosslessPayload(friendId, state, kind, inner, remote);
|
|
2261
2078
|
return;
|
|
2262
2079
|
}
|
|
2263
2080
|
// Reverted: we used to DELETE a "desynced" session here to force a clean
|
|
@@ -3349,6 +3166,323 @@ export class Peer {
|
|
|
3349
3166
|
});
|
|
3350
3167
|
}
|
|
3351
3168
|
}
|
|
3169
|
+
/** Deliver ONE decrypted lossless payload to the application layer. Split
|
|
3170
|
+
* out of the datagram handler so the receive reorder buffer can replay
|
|
3171
|
+
* buffered packets through the exact same dispatch. `kind` is the first
|
|
3172
|
+
* payload byte (never undefined here — the caller defaults it). */
|
|
3173
|
+
#deliverLosslessPayload(friendId, state, kind, inner, remote) {
|
|
3174
|
+
// Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
|
|
3175
|
+
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
3176
|
+
return;
|
|
3177
|
+
if (kind === PACKET_ID_ONLINE) {
|
|
3178
|
+
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
3179
|
+
this.#debugLog(`crypto online packet received from ${friendId}`);
|
|
3180
|
+
// First time the friend signals ONLINE, push our profile
|
|
3181
|
+
// (nickname + status message) and any configured greeting so the
|
|
3182
|
+
// other side updates "unknown" to a real name. Toxcore semantics:
|
|
3183
|
+
// these are sent on every reconnect by the friend_connection layer.
|
|
3184
|
+
this.#sendProfileAndGreeting(friendId);
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
if (kind === PACKET_ID_OFFLINE) {
|
|
3188
|
+
this.#setFriendOffline(friendId);
|
|
3189
|
+
this.#debugLog(`crypto offline packet received from ${friendId}`);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
if (kind === PACKET_ID_KILL) {
|
|
3193
|
+
this.#friendSessions.delete(friendId);
|
|
3194
|
+
this.#setFriendOffline(friendId);
|
|
3195
|
+
this.#debugLog(`crypto kill received from ${friendId}, session torn down`);
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (kind === PACKET_ID_ALIVE) {
|
|
3199
|
+
// Pure liveness signal; lastPingRecvMs already updated above.
|
|
3200
|
+
this.#debugVerboseLog(`crypto alive (keepalive) received from ${friendId}`);
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
if (kind === PACKET_ID_UDP_ENDPOINT) {
|
|
3204
|
+
// Peer told us their public UDP endpoint — hole-punch to it.
|
|
3205
|
+
this.#handleUdpEndpointOffer(friendId, inner);
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
if (kind === PACKET_ID_REQUEST) {
|
|
3209
|
+
// Lossless retransmission request (toxcore handle_request_packet).
|
|
3210
|
+
// Bytes are 1-based deltas walking our send window from
|
|
3211
|
+
// sendBufferStartNum: a matching delta marks that packet number
|
|
3212
|
+
// REQUESTED (peer is missing it — resend); every walked number
|
|
3213
|
+
// that doesn't match is implicitly ACKED (peer has it — drop from
|
|
3214
|
+
// the buffer). 0 is a +255 filler.
|
|
3215
|
+
this.#handleRetransmitRequest(friendId, state, inner);
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
if (kind === PACKET_ID_SHARE_RELAYS) {
|
|
3219
|
+
// Peer is sharing TCP relay endpoints they're connected to. We
|
|
3220
|
+
// don't have a TCP relay client yet, so cache nothing — just log.
|
|
3221
|
+
this.#debugVerboseLog(`crypto share-relays from ${friendId} (TCP relay client not implemented)`);
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
if (kind === PACKET_ID_NICKNAME) {
|
|
3225
|
+
const name = decodeUtf8Best(inner);
|
|
3226
|
+
const friend = this.#friends.get(friendId);
|
|
3227
|
+
if (friend && name && friend.name !== name) {
|
|
3228
|
+
this.#friends.set(friendId, { ...friend, name });
|
|
3229
|
+
this.#persistFriends();
|
|
3230
|
+
this.#events.emit("friendInfo", {
|
|
3231
|
+
pubkey: friendId,
|
|
3232
|
+
userid: friend.userid ?? friendId,
|
|
3233
|
+
name,
|
|
3234
|
+
description: friend.description
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
this.#debugLog(`crypto nickname received from ${friendId}: "${name}"`);
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
if (kind === PACKET_ID_STATUSMESSAGE) {
|
|
3241
|
+
// Carrier C SDK wraps the user-profile in a FlatBuffers
|
|
3242
|
+
// PACKET_TYPE_USERINFO packet here (name + descr + gender + phone
|
|
3243
|
+
// + email + region + has_avatar). Some non-Carrier toxcore peers
|
|
3244
|
+
// may still send raw UTF-8, so try the FB decode first and fall
|
|
3245
|
+
// back to plain UTF-8 for compatibility.
|
|
3246
|
+
let userInfoName;
|
|
3247
|
+
let userInfoDescr;
|
|
3248
|
+
let clientMeta;
|
|
3249
|
+
try {
|
|
3250
|
+
const decoded = decodeCarrierPacket(inner);
|
|
3251
|
+
if (decoded.type === PACKET_TYPE_USERINFO) {
|
|
3252
|
+
userInfoName = decoded.name;
|
|
3253
|
+
userInfoDescr = decoded.descr;
|
|
3254
|
+
// AgentNet appended fields — present only for updated peers.
|
|
3255
|
+
if (decoded.protoVersion || decoded.platform || decoded.appVersion) {
|
|
3256
|
+
clientMeta = {
|
|
3257
|
+
protoVersion: decoded.protoVersion,
|
|
3258
|
+
platform: decoded.platform,
|
|
3259
|
+
osVersion: decoded.osVersion,
|
|
3260
|
+
appVersion: decoded.appVersion
|
|
3261
|
+
};
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
catch {
|
|
3266
|
+
// Not a Carrier userinfo packet — fall through.
|
|
3267
|
+
}
|
|
3268
|
+
if (userInfoName !== undefined || userInfoDescr !== undefined) {
|
|
3269
|
+
const friend = this.#friends.get(friendId);
|
|
3270
|
+
const newName = userInfoName && userInfoName.length > 0 ? userInfoName : friend?.name;
|
|
3271
|
+
const newDescr = userInfoDescr ?? friend?.description;
|
|
3272
|
+
const metaChanged = friend != null && clientMeta != null &&
|
|
3273
|
+
(friend.protoVersion !== clientMeta.protoVersion ||
|
|
3274
|
+
friend.platform !== clientMeta.platform ||
|
|
3275
|
+
friend.osVersion !== clientMeta.osVersion ||
|
|
3276
|
+
friend.appVersion !== clientMeta.appVersion);
|
|
3277
|
+
if (friend && (friend.name !== newName || friend.description !== newDescr || metaChanged)) {
|
|
3278
|
+
this.#friends.set(friendId, { ...friend, name: newName, description: newDescr, ...(clientMeta ?? {}) });
|
|
3279
|
+
this.#persistFriends();
|
|
3280
|
+
this.#events.emit("friendInfo", {
|
|
3281
|
+
pubkey: friendId,
|
|
3282
|
+
userid: friend.userid ?? friendId,
|
|
3283
|
+
name: newName,
|
|
3284
|
+
description: newDescr,
|
|
3285
|
+
...(clientMeta ?? {})
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
this.#debugLog(`crypto userinfo received from ${friendId}: name="${userInfoName ?? ""}" descr="${userInfoDescr ?? ""}"${clientMeta ? ` proto=${clientMeta.protoVersion} platform=${clientMeta.platform} app=${clientMeta.appVersion}` : ""}`);
|
|
3289
|
+
}
|
|
3290
|
+
else {
|
|
3291
|
+
const status = decodeUtf8Best(inner);
|
|
3292
|
+
const friend = this.#friends.get(friendId);
|
|
3293
|
+
if (friend && status && friend.description !== status) {
|
|
3294
|
+
this.#friends.set(friendId, { ...friend, description: status });
|
|
3295
|
+
this.#persistFriends();
|
|
3296
|
+
}
|
|
3297
|
+
this.#debugVerboseLog(`crypto status message (raw) received from ${friendId}: "${status}"`);
|
|
3298
|
+
}
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
if (kind === PACKET_ID_USERSTATUS) {
|
|
3302
|
+
this.#debugVerboseLog(`crypto user-status received from ${friendId} (${inner[0] ?? "?"})`);
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
if (kind === PACKET_ID_TYPING) {
|
|
3306
|
+
this.#debugVerboseLog(`crypto typing received from ${friendId} (${inner[0] ?? "?"})`);
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
if (kind === PACKET_ID_MESSAGE || kind === PACKET_ID_ACTION) {
|
|
3310
|
+
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
3311
|
+
// Decode the Carrier FlatBuffers wrapper when present. A BULKMSG
|
|
3312
|
+
// fragment (Carrier C splits any message >1024 bytes this way —
|
|
3313
|
+
// e.g. iOS Beagle images, which are FileModel JSON envelopes)
|
|
3314
|
+
// goes to the reassembler and only emits once complete; before
|
|
3315
|
+
// this, every fragment surfaced as a separate garbage "text".
|
|
3316
|
+
let text;
|
|
3317
|
+
let carrier;
|
|
3318
|
+
try {
|
|
3319
|
+
carrier = decodeCarrierPacket(inner);
|
|
3320
|
+
}
|
|
3321
|
+
catch { /* raw utf-8 peer */ }
|
|
3322
|
+
// Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST/RESPONSE = 34/35)
|
|
3323
|
+
// rides this same PACKET_ID_MESSAGE channel — it's what the iOS/Android
|
|
3324
|
+
// WebRTC SDK uses for call signaling via the "carrier" extension. Large
|
|
3325
|
+
// signals (SDP offers) fragment by tid like bulkmsg; reassemble then
|
|
3326
|
+
// surface as an "invite" event (NOT chat text).
|
|
3327
|
+
if (carrier?.type === PACKET_TYPE_INVITE_REQUEST) {
|
|
3328
|
+
const complete = this.#assembleInvite(friendId, carrier);
|
|
3329
|
+
if (!complete)
|
|
3330
|
+
return; // more fragments pending
|
|
3331
|
+
this.#events.emit("invite", {
|
|
3332
|
+
pubkey: friendId,
|
|
3333
|
+
ext: carrier.ext,
|
|
3334
|
+
bundle: carrier.bundle,
|
|
3335
|
+
data: complete
|
|
3336
|
+
});
|
|
3337
|
+
this.#debugLog(`invite from ${friendId}: ext="${carrier.ext ?? ""}" (${complete.length} bytes)`);
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
if (carrier?.type === PACKET_TYPE_INVITE_RESPONSE) {
|
|
3341
|
+
const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier) : new Uint8Array();
|
|
3342
|
+
if (carrier.status === 0 && !complete)
|
|
3343
|
+
return; // more fragments pending
|
|
3344
|
+
this.#events.emit("inviteResponse", {
|
|
3345
|
+
pubkey: friendId,
|
|
3346
|
+
ext: carrier.ext,
|
|
3347
|
+
bundle: carrier.bundle,
|
|
3348
|
+
status: carrier.status,
|
|
3349
|
+
reason: carrier.reason,
|
|
3350
|
+
data: complete ?? new Uint8Array()
|
|
3351
|
+
});
|
|
3352
|
+
this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
if (carrier?.type === PACKET_TYPE_BULKMSG) {
|
|
3356
|
+
const complete = this.#assembleBulkMsg(friendId, carrier);
|
|
3357
|
+
if (!complete)
|
|
3358
|
+
return; // more fragments pending
|
|
3359
|
+
text = decodeUtf8Best(complete);
|
|
3360
|
+
this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
|
|
3361
|
+
}
|
|
3362
|
+
else if (carrier?.type === PACKET_TYPE_MESSAGE) {
|
|
3363
|
+
text = new TextDecoder().decode(carrier.data);
|
|
3364
|
+
}
|
|
3365
|
+
else {
|
|
3366
|
+
text = decodeUtf8Best(inner);
|
|
3367
|
+
}
|
|
3368
|
+
// Carrier C peers terminate strings with a NUL — strip it so chat
|
|
3369
|
+
// text doesn't carry an invisible trailing byte.
|
|
3370
|
+
text = text?.replace(/\0+$/u, "");
|
|
3371
|
+
if (!text) {
|
|
3372
|
+
this.#debugLog(`crypto message packet decode failed from ${friendId}`);
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
// iOS Beagle sends files inline as a FileModel JSON envelope
|
|
3376
|
+
// ({data: base64, fileExtension, fileName, type}) — surface those
|
|
3377
|
+
// as files, not as a wall of base64 text.
|
|
3378
|
+
if (this.#tryEmitInlineFile(friendId, text, "online"))
|
|
3379
|
+
return;
|
|
3380
|
+
this.#events.emit("text", {
|
|
3381
|
+
pubkey: friendId,
|
|
3382
|
+
text,
|
|
3383
|
+
via: "online"
|
|
3384
|
+
});
|
|
3385
|
+
this.#debugLog(`crypto ${kind === PACKET_ID_ACTION ? "action" : "message"} from ${friendId}: "${text}"`);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
if (kind >= 160 && kind <= 254) {
|
|
3389
|
+
// Application custom packet (toxcore lossless 160-191 / lossy
|
|
3390
|
+
// 192-254). SDK-reserved IDs (160 UDP_ENDPOINT, 254 NAT_PING, …)
|
|
3391
|
+
// are handled by earlier branches and never reach here; anything
|
|
3392
|
+
// else belongs to the application layer (e.g. decentlan IP traffic
|
|
3393
|
+
// on 192, control on 161/162). Receiving one proves the friend is
|
|
3394
|
+
// reachable, so mark online.
|
|
3395
|
+
this.#setFriendOnline(friendId, remote.address, remote.port);
|
|
3396
|
+
this.#events.emit("customPacket", {
|
|
3397
|
+
pubkey: friendId,
|
|
3398
|
+
id: kind,
|
|
3399
|
+
data: inner
|
|
3400
|
+
});
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
this.#debugVerboseLog(`unknown messenger packet kind ${kind} from ${friendId}`);
|
|
3404
|
+
}
|
|
3405
|
+
/** Drain the receive reorder buffer forward from receiveBufferStart while it
|
|
3406
|
+
* holds the next contiguous packet, delivering each in strict order. */
|
|
3407
|
+
#drainRecvBufferContiguous(friendId, state, remote) {
|
|
3408
|
+
const buf = state.recvBuffer;
|
|
3409
|
+
if (!buf)
|
|
3410
|
+
return;
|
|
3411
|
+
for (let held = buf.get(state.receiveBufferStart ?? 0); held; held = buf.get(state.receiveBufferStart ?? 0)) {
|
|
3412
|
+
buf.delete(state.receiveBufferStart ?? 0);
|
|
3413
|
+
state.receiveBufferStart = ((state.receiveBufferStart ?? 0) + 1) >>> 0;
|
|
3414
|
+
this.#deliverLosslessPayload(friendId, state, held.kind, held.inner, remote);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
/** Safety valve: the reorder buffer grew past the window, so a packet below
|
|
3418
|
+
* it is (almost certainly) a genuine loss that will never retransmit. Jump
|
|
3419
|
+
* the low-water mark to the lowest buffered number — abandoning the gap
|
|
3420
|
+
* (partial messages are dropped gracefully by the reassemblers) — rather
|
|
3421
|
+
* than wedging every later packet (incl. chat) forever. */
|
|
3422
|
+
#forceAdvanceRecvBuffer(friendId, state, remote) {
|
|
3423
|
+
const buf = state.recvBuffer;
|
|
3424
|
+
if (!buf || buf.size === 0)
|
|
3425
|
+
return;
|
|
3426
|
+
const expected = state.receiveBufferStart ?? 0;
|
|
3427
|
+
let lowest = expected;
|
|
3428
|
+
let bestDist = Infinity;
|
|
3429
|
+
for (const n of buf.keys()) {
|
|
3430
|
+
const d = (n - expected) >>> 0;
|
|
3431
|
+
if (d < bestDist) {
|
|
3432
|
+
bestDist = d;
|
|
3433
|
+
lowest = n;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
this.#debugLog(`recv reorder overflow from ${friendId}: abandoning gap [${expected}..${lowest}), ${buf.size} held`);
|
|
3437
|
+
state.receiveBufferStart = lowest;
|
|
3438
|
+
this.#drainRecvBufferContiguous(friendId, state, remote);
|
|
3439
|
+
}
|
|
3440
|
+
/** Ask the sender to retransmit the packets missing below our highest
|
|
3441
|
+
* buffered number. Encodes the toxcore PACKET_ID_REQUEST byte stream
|
|
3442
|
+
* (the exact inverse of #handleRetransmitRequest): walk [low, high] and
|
|
3443
|
+
* emit the running gap-counter at each MISSING number. Rate-limited. */
|
|
3444
|
+
#requestMissingReliablePackets(friendId, state) {
|
|
3445
|
+
const buf = state.recvBuffer;
|
|
3446
|
+
if (!buf || buf.size === 0)
|
|
3447
|
+
return;
|
|
3448
|
+
const now = Date.now();
|
|
3449
|
+
if (state.lastRecvRequestMs && now - state.lastRecvRequestMs < RECV_REQUEST_MIN_INTERVAL_MS)
|
|
3450
|
+
return;
|
|
3451
|
+
const low = state.receiveBufferStart ?? 0;
|
|
3452
|
+
let high = low;
|
|
3453
|
+
let bestDist = -1;
|
|
3454
|
+
for (const n of buf.keys()) {
|
|
3455
|
+
const d = (n - low) >>> 0;
|
|
3456
|
+
if (d > bestDist) {
|
|
3457
|
+
bestDist = d;
|
|
3458
|
+
high = n;
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
const bytes = encodeRetransmitRequest(low, high, (n) => buf.has(n));
|
|
3462
|
+
if (bytes.length === 0)
|
|
3463
|
+
return;
|
|
3464
|
+
state.lastRecvRequestMs = now;
|
|
3465
|
+
void this.#sendRequestPacket(friendId, state, bytes).catch(() => { });
|
|
3466
|
+
}
|
|
3467
|
+
/** Send a PACKET_ID_REQUEST. Like a retransmit (#resendReliablePacket) it
|
|
3468
|
+
* rides the CURRENT nonce but does NOT consume a reliable send number —
|
|
3469
|
+
* toxcore sends requests at buffer_end without storing them. */
|
|
3470
|
+
async #sendRequestPacket(friendId, state, requestBytes) {
|
|
3471
|
+
if (!state.established || !state.sessionSharedKey || !state.ourBaseNonce)
|
|
3472
|
+
return;
|
|
3473
|
+
if (!state.remote && !state.hasTcpRoute)
|
|
3474
|
+
return;
|
|
3475
|
+
const payload = concatBytes([Uint8Array.of(PACKET_ID_REQUEST), requestBytes]);
|
|
3476
|
+
const encrypted = createCryptoDataPacket({
|
|
3477
|
+
sessionSharedKey: state.sessionSharedKey,
|
|
3478
|
+
sentNonce: state.ourBaseNonce,
|
|
3479
|
+
bufferStart: state.receiveBufferStart ?? 0,
|
|
3480
|
+
packetNumber: state.sendPacketNumber ?? 0,
|
|
3481
|
+
payload
|
|
3482
|
+
});
|
|
3483
|
+
incrementNonce(state.ourBaseNonce);
|
|
3484
|
+
await this.#sendToFriend(friendId, encrypted, state, false, false);
|
|
3485
|
+
}
|
|
3352
3486
|
/** Append a Carrier BULKMSG fragment; returns the full reassembled
|
|
3353
3487
|
* message when the last fragment lands, undefined while pending.
|
|
3354
3488
|
* Mirrors the C SDK's handle_friend_bulkmsg: totalsz on the first
|
|
@@ -3562,8 +3696,11 @@ export class Peer {
|
|
|
3562
3696
|
session.sendBufferStartNum ??= packetNumber;
|
|
3563
3697
|
session.sendArray.set(packetNumber, payload);
|
|
3564
3698
|
// Bound the buffer: beyond this window the peer has either acked via
|
|
3565
|
-
// REQUEST packets or the session is beyond saving anyway.
|
|
3566
|
-
|
|
3699
|
+
// REQUEST packets or the session is beyond saving anyway. Sized to match
|
|
3700
|
+
// the receive reorder window (8192) so a large multi-fragment bulk send
|
|
3701
|
+
// (16 MB inline file ≈ 16k fragments) can retransmit any fragment the peer
|
|
3702
|
+
// is still missing within the window rather than evicting it too early.
|
|
3703
|
+
while (session.sendArray.size > 8192 && session.sendBufferStartNum !== session.sendPacketNumber) {
|
|
3567
3704
|
session.sendArray.delete(session.sendBufferStartNum);
|
|
3568
3705
|
session.sendBufferStartNum = (session.sendBufferStartNum + 1) >>> 0;
|
|
3569
3706
|
}
|
|
@@ -3775,10 +3912,17 @@ export class Peer {
|
|
|
3775
3912
|
await sleep(250);
|
|
3776
3913
|
const userInfo = encodeUserInfoPacket({
|
|
3777
3914
|
name: nick,
|
|
3778
|
-
descr
|
|
3915
|
+
descr,
|
|
3916
|
+
// AgentNet client metadata (appended userinfo fields 7-10). Old
|
|
3917
|
+
// peers ignore them; updated peers negotiate capabilities + display
|
|
3918
|
+
// the friend's platform/build.
|
|
3919
|
+
protoVersion: AGENTNET_PROTO_VERSION,
|
|
3920
|
+
platform: this.#opts.platform ?? process.platform,
|
|
3921
|
+
osVersion: this.#opts.osVersion ?? `node-${process.versions?.node ?? ""}`,
|
|
3922
|
+
appVersion: this.#opts.appVersion ?? `peer-${PEER_PKG_VERSION}`
|
|
3779
3923
|
});
|
|
3780
3924
|
await this.#sendMessengerPacket(friendId, PACKET_ID_STATUSMESSAGE, userInfo);
|
|
3781
|
-
this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}")`);
|
|
3925
|
+
this.#debugLog(`userinfo sent to ${friendId} (name="${nick}", descr="${descr}", proto=${AGENTNET_PROTO_VERSION}, platform=${this.#opts.platform ?? process.platform})`);
|
|
3782
3926
|
}
|
|
3783
3927
|
catch (error) {
|
|
3784
3928
|
this.#debugLog(`send profile failed for ${friendId}: ${error.message}`);
|
package/dist/store/friends.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.88",
|
|
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",
|