@decentnetwork/peer 0.1.140 → 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.
@@ -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/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);
@@ -1751,6 +1763,37 @@ export class Peer {
1751
1763
  onCustomPacket(cb) {
1752
1764
  this.#events.on("customPacket", cb);
1753
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
+ }
1754
1797
  // ───────────────────── file transfer (toxcore-standard, native-ready) ─────
1755
1798
  /**
1756
1799
  * Offer a file to a friend (by userid). Returns the fileId (hex) or null if
@@ -3093,6 +3136,14 @@ export class Peer {
3093
3136
  // the very same packFilePayload as the online path).
3094
3137
  if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
3095
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
+ }
3096
3147
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
3097
3148
  return;
3098
3149
  void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
@@ -4121,6 +4172,11 @@ export class Peer {
4121
4172
  if (kind === PACKET_ID_FILE_CONTROL)
4122
4173
  this.#fileRelayNegotiationUntil.delete(friendId);
4123
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
+ }
4124
4180
  if (this.#fileTransfer.handlePacket(friendId, kind, inner))
4125
4181
  return;
4126
4182
  if (kind === PACKET_ID_ONLINE) {
@@ -4319,6 +4375,18 @@ export class Peer {
4319
4375
  else {
4320
4376
  raw = inner;
4321
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
+ }
4322
4390
  if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
4323
4391
  return;
4324
4392
  text = decodeUtf8Best(raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.140",
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",