@decentnetwork/peer 0.1.139 → 0.1.141
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/dnft1.d.ts +62 -0
- package/dist/compat/dnft1.js +146 -0
- package/dist/index.d.ts +1 -1
- package/dist/peer.d.ts +11 -2
- package/dist/peer.js +148 -4
- package/dist/types/peer.d.ts +17 -0
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** "\u{1E}DNFT1" — the leading bytes of every frame. */
|
|
2
|
+
export declare const DNFT1_MAGIC: Uint8Array<ArrayBuffer>;
|
|
3
|
+
/** magic(6) + packetId(1) + payloadLen(4). */
|
|
4
|
+
export declare const DNFT1_HEADER_LENGTH = 11;
|
|
5
|
+
/** Wrap one file-transfer packet payload in a DNFT1 frame. */
|
|
6
|
+
export declare function encodeDnft1Frame(packetId: number, payload: Uint8Array): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a DNFT1 frame out of a received message body.
|
|
9
|
+
*
|
|
10
|
+
* Returns null for anything that is not a file-transfer frame (ordinary chat
|
|
11
|
+
* data — the common case, so this must stay cheap), and for a frame truncated
|
|
12
|
+
* below its declared payload length: dropped rather than half-parsed, since a
|
|
13
|
+
* short read would hand the engine a chunk with missing bytes. Bytes past
|
|
14
|
+
* payloadLen are transport junk and are ignored.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseDnft1Frame(raw: Uint8Array): {
|
|
17
|
+
packetId: number;
|
|
18
|
+
payload: Uint8Array;
|
|
19
|
+
} | null;
|
|
20
|
+
/**
|
|
21
|
+
* Picks the transport for each friend's file packets: raw messenger packets
|
|
22
|
+
* (a JS peer, which is faster — no envelope, no message-channel framing) or
|
|
23
|
+
* DNFT1 frames (a native peer, the only thing that reaches its app layer).
|
|
24
|
+
*
|
|
25
|
+
* There is no reliable capability handshake to key this off — appVersion is
|
|
26
|
+
* absent for older builds and lies across forks — so the mode is LEARNED from
|
|
27
|
+
* what actually works, which needs no negotiation and self-corrects:
|
|
28
|
+
*
|
|
29
|
+
* - The engine already retransmits an unanswered OFFER until the receiver
|
|
30
|
+
* responds. Every UNANSWERED_OFFERS_BEFORE_SWITCH of those with no inbound
|
|
31
|
+
* file packet flips the transport. Alternating (rather than a one-way
|
|
32
|
+
* downgrade) matters: an old JS peer that cannot parse DNFT1 must not be
|
|
33
|
+
* stranded there by one slow response, and a native peer must not be
|
|
34
|
+
* stranded on raw. Each transport keeps getting tried until one answers.
|
|
35
|
+
* - The first inbound file packet PINS the mode to the transport it arrived
|
|
36
|
+
* on and stops the alternation. That is ground truth about what the peer
|
|
37
|
+
* speaks, so later transfers to that friend start on the right one.
|
|
38
|
+
*
|
|
39
|
+
* At the engine's offer cadence (~450ms) a switch costs about 1.4s, well
|
|
40
|
+
* inside the 60s give-up window, so the wrong initial guess is invisible.
|
|
41
|
+
*/
|
|
42
|
+
export declare class Dnft1TransportPolicy {
|
|
43
|
+
#private;
|
|
44
|
+
/** Consecutive unanswered offers before trying the other transport. */
|
|
45
|
+
static readonly UNANSWERED_OFFERS_BEFORE_SWITCH = 3;
|
|
46
|
+
/** True when this friend's file packets should be sent as DNFT1 frames. */
|
|
47
|
+
useDnft1(friendId: string): boolean;
|
|
48
|
+
/** True once a real inbound file packet has settled the transport. */
|
|
49
|
+
isPinned(friendId: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Called for every outbound OFFER (packet 80). Returns true when this offer
|
|
52
|
+
* flipped the transport — the caller should send THIS offer on the new one.
|
|
53
|
+
*/
|
|
54
|
+
noteOfferSent(friendId: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Called for every inbound file packet, with the transport it arrived on.
|
|
57
|
+
* Pins the mode: the peer just proved which one it speaks.
|
|
58
|
+
*/
|
|
59
|
+
noteInbound(friendId: string, viaDnft1: boolean): void;
|
|
60
|
+
/** Drop all state for a friend (removed, or session torn down). */
|
|
61
|
+
forget(friendId: string): void;
|
|
62
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// DNFT1 — carrying file-transfer packets 80–83 inside ordinary friend messages.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the JS peer speaks toxcore messenger packets 80–83 natively,
|
|
4
|
+
// but a native app never sees them. The precompiled Carrier C SDK consumes
|
|
5
|
+
// 80/81/82 internally (they feed its own file state machine, which registers no
|
|
6
|
+
// app callbacks) and discards 83 (FEC — it does not know the id). A P0 probe on
|
|
7
|
+
// iOS confirmed it: raw packets never reach the mobile app layer, so JS↔iOS
|
|
8
|
+
// file transfer could not work at all.
|
|
9
|
+
//
|
|
10
|
+
// The fix keeps the SDK untouched: both ends carry the SAME packet payloads
|
|
11
|
+
// inside ordinary friend messages, wrapped in a frame no chat text can produce.
|
|
12
|
+
// iOS implemented it first (FileTransferWire.swift in the Beagle app); this is
|
|
13
|
+
// the byte-exact JS counterpart — read that file before changing anything here.
|
|
14
|
+
//
|
|
15
|
+
// [0x1E]["DNFT1"][packetId u8][payloadLen u32 BE][payload]
|
|
16
|
+
//
|
|
17
|
+
// - 0x1E (record separator) leads, mirroring the "\u{1E}DNPACK1:" text-ack
|
|
18
|
+
// envelope convention — a one-byte reject for ordinary chat text.
|
|
19
|
+
// - payloadLen is explicit because the carrier/express path can append junk
|
|
20
|
+
// past the real payload. Trailing junk after a binary chunk would corrupt the
|
|
21
|
+
// file, so the frame carries its own length and anything past it is ignored.
|
|
22
|
+
//
|
|
23
|
+
// The payloads inside are UNCHANGED — the same bytes filetransfer.ts already
|
|
24
|
+
// builds. This module only adds and removes the envelope, so the engine, the
|
|
25
|
+
// FEC layer and the conformance fixtures stay transport-agnostic.
|
|
26
|
+
import { PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_FEC, } from "./filetransfer.js";
|
|
27
|
+
/** "\u{1E}DNFT1" — the leading bytes of every frame. */
|
|
28
|
+
export const DNFT1_MAGIC = Uint8Array.from([0x1e, 0x44, 0x4e, 0x46, 0x54, 0x31]);
|
|
29
|
+
/** magic(6) + packetId(1) + payloadLen(4). */
|
|
30
|
+
export const DNFT1_HEADER_LENGTH = 11;
|
|
31
|
+
/** Wrap one file-transfer packet payload in a DNFT1 frame. */
|
|
32
|
+
export function encodeDnft1Frame(packetId, payload) {
|
|
33
|
+
const out = new Uint8Array(DNFT1_HEADER_LENGTH + payload.length);
|
|
34
|
+
out.set(DNFT1_MAGIC, 0);
|
|
35
|
+
out[6] = packetId & 0xff;
|
|
36
|
+
const n = payload.length;
|
|
37
|
+
out[7] = (n >>> 24) & 0xff;
|
|
38
|
+
out[8] = (n >>> 16) & 0xff;
|
|
39
|
+
out[9] = (n >>> 8) & 0xff;
|
|
40
|
+
out[10] = n & 0xff;
|
|
41
|
+
out.set(payload, DNFT1_HEADER_LENGTH);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse a DNFT1 frame out of a received message body.
|
|
46
|
+
*
|
|
47
|
+
* Returns null for anything that is not a file-transfer frame (ordinary chat
|
|
48
|
+
* data — the common case, so this must stay cheap), and for a frame truncated
|
|
49
|
+
* below its declared payload length: dropped rather than half-parsed, since a
|
|
50
|
+
* short read would hand the engine a chunk with missing bytes. Bytes past
|
|
51
|
+
* payloadLen are transport junk and are ignored.
|
|
52
|
+
*/
|
|
53
|
+
export function parseDnft1Frame(raw) {
|
|
54
|
+
if (raw.length < DNFT1_HEADER_LENGTH)
|
|
55
|
+
return null;
|
|
56
|
+
for (let i = 0; i < DNFT1_MAGIC.length; i++) {
|
|
57
|
+
if (raw[i] !== DNFT1_MAGIC[i])
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const packetId = raw[6];
|
|
61
|
+
if (packetId < PACKET_ID_FILE_SENDREQUEST || packetId > PACKET_ID_FILE_FEC)
|
|
62
|
+
return null;
|
|
63
|
+
// >>> 0 keeps the length unsigned: a hostile 0xFFFFFFFF must compare as
|
|
64
|
+
// 4294967295 (and fail the bounds check), not as -1 (which would pass it).
|
|
65
|
+
const length = ((raw[7] << 24) | (raw[8] << 16) | (raw[9] << 8) | raw[10]) >>> 0;
|
|
66
|
+
if (raw.length - DNFT1_HEADER_LENGTH < length)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
packetId,
|
|
70
|
+
payload: raw.subarray(DNFT1_HEADER_LENGTH, DNFT1_HEADER_LENGTH + length),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Picks the transport for each friend's file packets: raw messenger packets
|
|
75
|
+
* (a JS peer, which is faster — no envelope, no message-channel framing) or
|
|
76
|
+
* DNFT1 frames (a native peer, the only thing that reaches its app layer).
|
|
77
|
+
*
|
|
78
|
+
* There is no reliable capability handshake to key this off — appVersion is
|
|
79
|
+
* absent for older builds and lies across forks — so the mode is LEARNED from
|
|
80
|
+
* what actually works, which needs no negotiation and self-corrects:
|
|
81
|
+
*
|
|
82
|
+
* - The engine already retransmits an unanswered OFFER until the receiver
|
|
83
|
+
* responds. Every UNANSWERED_OFFERS_BEFORE_SWITCH of those with no inbound
|
|
84
|
+
* file packet flips the transport. Alternating (rather than a one-way
|
|
85
|
+
* downgrade) matters: an old JS peer that cannot parse DNFT1 must not be
|
|
86
|
+
* stranded there by one slow response, and a native peer must not be
|
|
87
|
+
* stranded on raw. Each transport keeps getting tried until one answers.
|
|
88
|
+
* - The first inbound file packet PINS the mode to the transport it arrived
|
|
89
|
+
* on and stops the alternation. That is ground truth about what the peer
|
|
90
|
+
* speaks, so later transfers to that friend start on the right one.
|
|
91
|
+
*
|
|
92
|
+
* At the engine's offer cadence (~450ms) a switch costs about 1.4s, well
|
|
93
|
+
* inside the 60s give-up window, so the wrong initial guess is invisible.
|
|
94
|
+
*/
|
|
95
|
+
export class Dnft1TransportPolicy {
|
|
96
|
+
/** Consecutive unanswered offers before trying the other transport. */
|
|
97
|
+
static UNANSWERED_OFFERS_BEFORE_SWITCH = 3;
|
|
98
|
+
#dnft1 = new Set();
|
|
99
|
+
#pinned = new Set();
|
|
100
|
+
#unanswered = new Map();
|
|
101
|
+
/** True when this friend's file packets should be sent as DNFT1 frames. */
|
|
102
|
+
useDnft1(friendId) {
|
|
103
|
+
return this.#dnft1.has(friendId);
|
|
104
|
+
}
|
|
105
|
+
/** True once a real inbound file packet has settled the transport. */
|
|
106
|
+
isPinned(friendId) {
|
|
107
|
+
return this.#pinned.has(friendId);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Called for every outbound OFFER (packet 80). Returns true when this offer
|
|
111
|
+
* flipped the transport — the caller should send THIS offer on the new one.
|
|
112
|
+
*/
|
|
113
|
+
noteOfferSent(friendId) {
|
|
114
|
+
if (this.#pinned.has(friendId))
|
|
115
|
+
return false;
|
|
116
|
+
const n = (this.#unanswered.get(friendId) ?? 0) + 1;
|
|
117
|
+
if (n < Dnft1TransportPolicy.UNANSWERED_OFFERS_BEFORE_SWITCH) {
|
|
118
|
+
this.#unanswered.set(friendId, n);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
this.#unanswered.set(friendId, 0);
|
|
122
|
+
if (this.#dnft1.has(friendId))
|
|
123
|
+
this.#dnft1.delete(friendId);
|
|
124
|
+
else
|
|
125
|
+
this.#dnft1.add(friendId);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Called for every inbound file packet, with the transport it arrived on.
|
|
130
|
+
* Pins the mode: the peer just proved which one it speaks.
|
|
131
|
+
*/
|
|
132
|
+
noteInbound(friendId, viaDnft1) {
|
|
133
|
+
this.#unanswered.delete(friendId);
|
|
134
|
+
this.#pinned.add(friendId);
|
|
135
|
+
if (viaDnft1)
|
|
136
|
+
this.#dnft1.add(friendId);
|
|
137
|
+
else
|
|
138
|
+
this.#dnft1.delete(friendId);
|
|
139
|
+
}
|
|
140
|
+
/** Drop all state for a friend (removed, or session torn down). */
|
|
141
|
+
forget(friendId) {
|
|
142
|
+
this.#dnft1.delete(friendId);
|
|
143
|
+
this.#pinned.delete(friendId);
|
|
144
|
+
this.#unanswered.delete(friendId);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,4 +14,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
|
|
|
14
14
|
export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
|
|
15
15
|
export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
16
16
|
export type { CarrierAddressParts } from "./compat/address.js";
|
|
17
|
-
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
17
|
+
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
package/dist/peer.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type BootstrapResult } from "./compat/bootstrap.js";
|
|
2
2
|
import type { FriendRecord } from "./store/friends.js";
|
|
3
3
|
import { type CarrierTurnServerInfo, type RtcIceServer } from "./ice-servers.js";
|
|
4
|
-
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
4
|
+
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
5
5
|
export declare class Peer {
|
|
6
6
|
#private;
|
|
7
7
|
private constructor();
|
|
@@ -140,7 +140,16 @@ export declare class Peer {
|
|
|
140
140
|
name: string;
|
|
141
141
|
data: Uint8Array;
|
|
142
142
|
fileType?: "image" | "audio" | "text" | "unknown";
|
|
143
|
-
|
|
143
|
+
deliveryTimeoutMs?: number;
|
|
144
|
+
}): Promise<InlineSendResult>;
|
|
145
|
+
/**
|
|
146
|
+
* True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
|
|
147
|
+
* DHT/relay key differs from its identity key, where a JS peer announces its
|
|
148
|
+
* identity key as its DHT key. Natives cannot see the toxcore file-transfer
|
|
149
|
+
* protocol (sendFile) — the inline envelope is their only file path — and
|
|
150
|
+
* they never speak the JS text-ACK scheme.
|
|
151
|
+
*/
|
|
152
|
+
isNativeFriend(pubkey: string): boolean;
|
|
144
153
|
/** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
|
|
145
154
|
* This is the channel the iOS/Android WebRTC SDK uses for call signaling —
|
|
146
155
|
* each RtcSignal arrives as an "invite" with ext="carrier" and `data` the
|
package/dist/peer.js
CHANGED
|
@@ -13,6 +13,7 @@ import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONI
|
|
|
13
13
|
import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
14
14
|
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";
|
|
15
15
|
import { FileTransferManager, PACKET_ID_FILE_SENDREQUEST, PACKET_ID_FILE_CONTROL, PACKET_ID_FILE_DATA, PACKET_ID_FILE_FEC } from "./compat/filetransfer.js";
|
|
16
|
+
import { Dnft1TransportPolicy, encodeDnft1Frame, parseDnft1Frame } from "./compat/dnft1.js";
|
|
16
17
|
import { NET_PACKET_CRYPTO_DATA, NET_PACKET_CRYPTO_HS, NET_PACKET_COOKIE_REQUEST, NET_PACKET_COOKIE_RESPONSE, createCookieRequest, createCookieResponse, createCryptoDataPacket, createCryptoHandshake, openCookieRequest, openCookieResponse, openCryptoDataPacket, openCryptoHandshake, incrementNonce } from "./compat/net-crypto.js";
|
|
17
18
|
import { LegacyExpressClient } from "./compat/express.js";
|
|
18
19
|
import { TcpRelayPool } from "./compat/tcp-relay-pool.js";
|
|
@@ -295,12 +296,22 @@ export class Peer {
|
|
|
295
296
|
* old peer returns ACCEPT/ACK over its reliable TCP relay. */
|
|
296
297
|
#fileRelayNegotiationUntil = new Map();
|
|
297
298
|
// Toxcore-standard file transfer (wire-compatible with native Carrier/toxcore).
|
|
299
|
+
/** Which transport carries each friend's file packets — raw messenger
|
|
300
|
+
* packets (JS peers) or DNFT1 frames inside friend messages (native peers,
|
|
301
|
+
* whose Carrier SDK swallows 80-82 and drops 83). See compat/dnft1.ts. */
|
|
302
|
+
#dnft1 = new Dnft1TransportPolicy();
|
|
298
303
|
#fileTransfer = new FileTransferManager((friendId, packetId, payload) => {
|
|
299
304
|
if (packetId === PACKET_ID_FILE_SENDREQUEST) {
|
|
300
305
|
const session = this.#friendSessions.get(friendId);
|
|
301
306
|
const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
|
|
302
307
|
if (!lan)
|
|
303
308
|
this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 6_000);
|
|
309
|
+
// Every unanswered offer is evidence the peer cannot hear this
|
|
310
|
+
// transport; the policy alternates until one of them answers.
|
|
311
|
+
this.#dnft1.noteOfferSent(friendId);
|
|
312
|
+
}
|
|
313
|
+
if (this.#dnft1.useDnft1(friendId)) {
|
|
314
|
+
return this.#sendDnft1Frame(friendId, packetId, payload);
|
|
304
315
|
}
|
|
305
316
|
return this.#sendMessengerPacket(friendId, packetId, payload);
|
|
306
317
|
}, (event, payload) => { this.#events.emit(event, payload); }, (friendId) => {
|
|
@@ -1180,6 +1191,7 @@ export class Peer {
|
|
|
1180
1191
|
const existed = this.#friends.delete(friendId);
|
|
1181
1192
|
this.#friendSessions.delete(friendId);
|
|
1182
1193
|
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
1194
|
+
this.#dnft1.forget(friendId);
|
|
1183
1195
|
this.#pendingFriendRequests.delete(friendId);
|
|
1184
1196
|
this.#cookieRetryCount.delete(friendId);
|
|
1185
1197
|
this.#lastCookieSentKey.delete(friendId);
|
|
@@ -1240,6 +1252,9 @@ export class Peer {
|
|
|
1240
1252
|
}
|
|
1241
1253
|
await this.#sendTextPlain(pubkey, text);
|
|
1242
1254
|
}
|
|
1255
|
+
/** What #sendTextPlain actually did with the message — callers that promise
|
|
1256
|
+
* delivery semantics (inline files) need to know the path and, for the live
|
|
1257
|
+
* path, where the send window stood after the last fragment. */
|
|
1243
1258
|
async #sendTextPlain(pubkey, text) {
|
|
1244
1259
|
const friend = this.#friends.get(pubkey);
|
|
1245
1260
|
if (!friend) {
|
|
@@ -1307,7 +1322,10 @@ export class Peer {
|
|
|
1307
1322
|
for (const p of livePackets) {
|
|
1308
1323
|
await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, p);
|
|
1309
1324
|
}
|
|
1310
|
-
|
|
1325
|
+
// Every fragment got a packet number BELOW the counter's position now —
|
|
1326
|
+
// when the peer's implicit acks advance the window past it, the whole
|
|
1327
|
+
// message is confirmed on their device.
|
|
1328
|
+
return { via: "online", endPacketNumber: this.#friendSessions.get(pubkey)?.sendPacketNumber };
|
|
1311
1329
|
}
|
|
1312
1330
|
catch (error) {
|
|
1313
1331
|
const emsg = error.message;
|
|
@@ -1343,7 +1361,7 @@ export class Peer {
|
|
|
1343
1361
|
// no session is simply a no-op.
|
|
1344
1362
|
if (text.length === 0) {
|
|
1345
1363
|
this.#debugLog(`sendText: empty connection-kick to ${pubkey} with no live session — dropping (not flooding express)`);
|
|
1346
|
-
return;
|
|
1364
|
+
return { via: "online" };
|
|
1347
1365
|
}
|
|
1348
1366
|
// Offline / fallback path via Carrier express HTTP store-and-forward.
|
|
1349
1367
|
// Skipped entirely in control-plane-only mode (decentlan's data plane):
|
|
@@ -1353,7 +1371,7 @@ export class Peer {
|
|
|
1353
1371
|
if (this.#express?.hasNodes() && !this.#opts.expressControlPlaneOnly) {
|
|
1354
1372
|
await this.#express.sendOfflineText(pubkey, packet);
|
|
1355
1373
|
this.#debugLog(`sendText: queued via express for ${pubkey}`);
|
|
1356
|
-
return;
|
|
1374
|
+
return { via: "offline" };
|
|
1357
1375
|
}
|
|
1358
1376
|
throw new Error("friend is offline and no express node is configured");
|
|
1359
1377
|
}
|
|
@@ -1568,7 +1586,77 @@ export class Peer {
|
|
|
1568
1586
|
if (envelope.length > CARRIER_MAX_APP_BULKMSG_LEN) {
|
|
1569
1587
|
throw new Error(`inline file too large for the message channel (${envelope.length} bytes encoded, max ${CARRIER_MAX_APP_BULKMSG_LEN})`);
|
|
1570
1588
|
}
|
|
1571
|
-
|
|
1589
|
+
// The confirmation window scales with the payload: a floor for handshake
|
|
1590
|
+
// latency plus time to move the envelope at a conservative 50 KB/s, capped
|
|
1591
|
+
// so a dead session can't pin the caller for ages.
|
|
1592
|
+
const deliveryTimeoutMs = opts.deliveryTimeoutMs ??
|
|
1593
|
+
Math.min(180_000, 20_000 + Math.ceil(envelope.length / 50));
|
|
1594
|
+
if (this.#shouldRequireTextAck(pubkey)) {
|
|
1595
|
+
// JS peer: the app-level text-ACK is the strongest confirmation there is
|
|
1596
|
+
// — the receiving app parsed the envelope. The retry interval must be
|
|
1597
|
+
// longer than one full send of the payload, or a slow path gets the
|
|
1598
|
+
// whole multi-MB envelope re-blasted mid-flight.
|
|
1599
|
+
await this.sendTextUntilAck(pubkey, envelope, {
|
|
1600
|
+
timeoutMs: deliveryTimeoutMs,
|
|
1601
|
+
retryIntervalMs: Math.max(TEXT_ACK_RETRY_MS, Math.ceil(envelope.length / 100))
|
|
1602
|
+
});
|
|
1603
|
+
return { delivery: "acked" };
|
|
1604
|
+
}
|
|
1605
|
+
// Native peer (iOS/Android/C): it will never speak our text-ACK envelope,
|
|
1606
|
+
// but its toxcore DOES acknowledge every reliable packet — the very signal
|
|
1607
|
+
// its own SDK surfaces to the app as "Delivered". Wait for the send window
|
|
1608
|
+
// to drain past our fragments.
|
|
1609
|
+
const outcome = await this.#sendTextPlain(pubkey, envelope);
|
|
1610
|
+
if (outcome.via === "offline")
|
|
1611
|
+
return { delivery: "offline" };
|
|
1612
|
+
if (outcome.endPacketNumber === undefined)
|
|
1613
|
+
return { delivery: "accepted" };
|
|
1614
|
+
const acked = await this.#awaitTransportAck(pubkey, outcome.endPacketNumber, deliveryTimeoutMs);
|
|
1615
|
+
this.#debugLog(`inline file "${opts.name}" to ${pubkey}: transport ${acked ? "ACKED" : `NOT confirmed within ${deliveryTimeoutMs}ms`}`);
|
|
1616
|
+
return { delivery: acked ? "acked" : "accepted" };
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* True once the friend's implicit acks (PACKET_ID_REQUEST walks) have moved
|
|
1620
|
+
* our reliable send window past `endPacketNumber` — i.e. every packet we had
|
|
1621
|
+
* sent by that point is confirmed received on their device. False when the
|
|
1622
|
+
* window doesn't drain in time or the session dies/rekeys under us (a new
|
|
1623
|
+
* session restarts the numbering, so the old position proves nothing).
|
|
1624
|
+
*/
|
|
1625
|
+
async #awaitTransportAck(pubkey, endPacketNumber, timeoutMs) {
|
|
1626
|
+
const watched = this.#friendSessions.get(pubkey);
|
|
1627
|
+
if (!watched)
|
|
1628
|
+
return false;
|
|
1629
|
+
const deadline = Date.now() + timeoutMs;
|
|
1630
|
+
for (;;) {
|
|
1631
|
+
const session = this.#friendSessions.get(pubkey);
|
|
1632
|
+
if (session !== watched || !session.established)
|
|
1633
|
+
return false;
|
|
1634
|
+
const start = session.sendBufferStartNum;
|
|
1635
|
+
if (start !== undefined) {
|
|
1636
|
+
// Wraparound-safe: the window is ≤8192 packets, so a "distance" over
|
|
1637
|
+
// 2^31 means start has passed endPacketNumber.
|
|
1638
|
+
const pending = (endPacketNumber - start) >>> 0;
|
|
1639
|
+
if (pending === 0 || pending > 0x80000000)
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
else if (!session.sendArray || session.sendArray.size === 0) {
|
|
1643
|
+
return true; // nothing left unacknowledged at all
|
|
1644
|
+
}
|
|
1645
|
+
if (Date.now() >= deadline)
|
|
1646
|
+
return false;
|
|
1647
|
+
await sleep(250);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
|
|
1652
|
+
* DHT/relay key differs from its identity key, where a JS peer announces its
|
|
1653
|
+
* identity key as its DHT key. Natives cannot see the toxcore file-transfer
|
|
1654
|
+
* protocol (sendFile) — the inline envelope is their only file path — and
|
|
1655
|
+
* they never speak the JS text-ACK scheme.
|
|
1656
|
+
*/
|
|
1657
|
+
isNativeFriend(pubkey) {
|
|
1658
|
+
const friend = this.#friends.get(pubkey);
|
|
1659
|
+
return !!(friend?.dhtPubkey && friend.dhtPubkey !== friend.pubkey);
|
|
1572
1660
|
}
|
|
1573
1661
|
/** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
|
|
1574
1662
|
* This is the channel the iOS/Android WebRTC SDK uses for call signaling —
|
|
@@ -1675,6 +1763,37 @@ export class Peer {
|
|
|
1675
1763
|
onCustomPacket(cb) {
|
|
1676
1764
|
this.#events.on("customPacket", cb);
|
|
1677
1765
|
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Send one file-transfer packet as a DNFT1 frame inside a friend message —
|
|
1768
|
+
* the only route that reaches a native app's layer (its Carrier SDK
|
|
1769
|
+
* swallows packets 80-82 and drops 83). Payload bytes are unchanged; see
|
|
1770
|
+
* compat/dnft1.ts for the envelope and the iOS counterpart.
|
|
1771
|
+
*
|
|
1772
|
+
* Deliberately live-session only, and never express: express is
|
|
1773
|
+
* store-and-forward for chat, and 100 MB of file chunks has no business in
|
|
1774
|
+
* a mailbox. Control loss is already covered by the engine's offer/ack
|
|
1775
|
+
* retransmits, so a failed send here is a dropped packet the engine will
|
|
1776
|
+
* redo — not a lost transfer.
|
|
1777
|
+
*/
|
|
1778
|
+
async #sendDnft1Frame(friendId, packetId, payload) {
|
|
1779
|
+
const frame = encodeDnft1Frame(packetId, payload);
|
|
1780
|
+
// A full DATA frame (11 + 5 + 1367 = 1383 B) exceeds the 1024-byte
|
|
1781
|
+
// friendmsg unit, so it rides BULKMSG fragments — exactly what the C SDK
|
|
1782
|
+
// does when iOS hands the same frame to sendFriendMessage.
|
|
1783
|
+
if (frame.length <= CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
1784
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeFriendMessagePacket(frame));
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
const tid = (BigInt(Date.now()) << 20n) ^ BigInt(Math.floor(Math.random() * 0xfffff));
|
|
1788
|
+
for (let off = 0; off < frame.length; off += CARRIER_MAX_APP_MESSAGE_LEN) {
|
|
1789
|
+
const end = Math.min(off + CARRIER_MAX_APP_MESSAGE_LEN, frame.length);
|
|
1790
|
+
await this.#sendMessengerPacket(friendId, PACKET_ID_MESSAGE, encodeBulkMsgPacket({
|
|
1791
|
+
totalsz: off === 0 ? frame.length : 0,
|
|
1792
|
+
tid,
|
|
1793
|
+
data: frame.subarray(off, end)
|
|
1794
|
+
}));
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1678
1797
|
// ───────────────────── file transfer (toxcore-standard, native-ready) ─────
|
|
1679
1798
|
/**
|
|
1680
1799
|
* Offer a file to a friend (by userid). Returns the fileId (hex) or null if
|
|
@@ -3017,6 +3136,14 @@ export class Peer {
|
|
|
3017
3136
|
// the very same packFilePayload as the online path).
|
|
3018
3137
|
if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
|
|
3019
3138
|
return;
|
|
3139
|
+
// Neither end routes DNFT1 through express on purpose — a 100 MB stream
|
|
3140
|
+
// has no business in a store-and-forward mailbox, and the engine's
|
|
3141
|
+
// retransmits already cover control loss. But if a frame does surface
|
|
3142
|
+
// here, swallow it rather than rendering the binary as chat mojibake.
|
|
3143
|
+
if (parseDnft1Frame(decoded.data)) {
|
|
3144
|
+
this.#debugLog(`ignoring DNFT1 frame from ${fromUserId} on the express path`);
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3020
3147
|
if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
|
|
3021
3148
|
return;
|
|
3022
3149
|
void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
|
|
@@ -4045,6 +4172,11 @@ export class Peer {
|
|
|
4045
4172
|
if (kind === PACKET_ID_FILE_CONTROL)
|
|
4046
4173
|
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
4047
4174
|
// Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
|
|
4175
|
+
if (kind >= PACKET_ID_FILE_SENDREQUEST && kind <= PACKET_ID_FILE_FEC) {
|
|
4176
|
+
// A raw file packet arrived, so this peer speaks raw — pin it and stop
|
|
4177
|
+
// alternating toward DNFT1.
|
|
4178
|
+
this.#dnft1.noteInbound(friendId, false);
|
|
4179
|
+
}
|
|
4048
4180
|
if (this.#fileTransfer.handlePacket(friendId, kind, inner))
|
|
4049
4181
|
return;
|
|
4050
4182
|
if (kind === PACKET_ID_ONLINE) {
|
|
@@ -4243,6 +4375,18 @@ export class Peer {
|
|
|
4243
4375
|
else {
|
|
4244
4376
|
raw = inner;
|
|
4245
4377
|
}
|
|
4378
|
+
// DNFT1: a file-transfer packet carried inside a friend message (the
|
|
4379
|
+
// native-interop path — see compat/dnft1.ts). Like the Android binary
|
|
4380
|
+
// envelope below, this MUST run on the undecoded bytes: raw chunk bytes
|
|
4381
|
+
// do not survive a UTF-8 decode.
|
|
4382
|
+
const ft = parseDnft1Frame(raw);
|
|
4383
|
+
if (ft) {
|
|
4384
|
+
this.#dnft1.noteInbound(friendId, true);
|
|
4385
|
+
if (ft.packetId === PACKET_ID_FILE_CONTROL)
|
|
4386
|
+
this.#fileRelayNegotiationUntil.delete(friendId);
|
|
4387
|
+
this.#fileTransfer.handlePacket(friendId, ft.packetId, ft.payload);
|
|
4388
|
+
return;
|
|
4389
|
+
}
|
|
4246
4390
|
if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
|
|
4247
4391
|
return;
|
|
4248
4392
|
text = decodeUtf8Best(raw);
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -135,6 +135,23 @@ export type TextMessage = {
|
|
|
135
135
|
* can see when online delivery is failing and only offline messages land. */
|
|
136
136
|
via?: "online" | "offline";
|
|
137
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Outcome of an inline-file send — the caller's ONLY honest basis for a "sent"
|
|
140
|
+
* checkmark. "Bytes left this machine" is not delivery.
|
|
141
|
+
*/
|
|
142
|
+
export type InlineSendResult = {
|
|
143
|
+
/**
|
|
144
|
+
* - "acked": the peer CONFIRMED receipt — an app-level ACK from a JS peer,
|
|
145
|
+
* or the toxcore transport ACK from a native one (the same signal Android
|
|
146
|
+
* surfaces as ReceiptState.Delivered). Safe to mark "sent".
|
|
147
|
+
* - "accepted": the transport took the bytes but no confirmation arrived in
|
|
148
|
+
* the window. NOT delivered as far as anyone can prove — mark it failed or
|
|
149
|
+
* queued, never "sent".
|
|
150
|
+
* - "offline": stored on the express relay for the peer's next pull. Mark
|
|
151
|
+
* it queued.
|
|
152
|
+
*/
|
|
153
|
+
delivery: "acked" | "accepted" | "offline";
|
|
154
|
+
};
|
|
138
155
|
export type SendTextUntilAckOptions = {
|
|
139
156
|
/** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
|
|
140
157
|
deliveryId?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.141",
|
|
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",
|