@decentnetwork/peer 0.1.111 → 0.1.113
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/filetransfer.d.ts +8 -1
- package/dist/compat/filetransfer.js +53 -1
- package/dist/index.d.ts +1 -1
- package/dist/peer.d.ts +14 -2
- package/dist/peer.js +378 -18
- package/dist/types/peer.d.ts +15 -0
- package/package.json +1 -1
|
@@ -43,13 +43,20 @@ export type FtEmit = (event: string, payload: Record<string, unknown>) => void;
|
|
|
43
43
|
export type FtIsLanPath = (friendId: string) => boolean;
|
|
44
44
|
export type FtPathKind = "lan" | "udp-direct" | "relay";
|
|
45
45
|
export type FtPathKindFn = (friendId: string) => FtPathKind;
|
|
46
|
+
/** Fired every watchdog tick while the zero-delivery breaker is engaged: the
|
|
47
|
+
* current bulk path is delivering NOTHING (no ack progress). The embedder
|
|
48
|
+
* should fail the transfer over to its reliable path (e.g. pin file traffic
|
|
49
|
+
* to the TCP relay) — inbound-UDP freshness does NOT prove our outbound UDP
|
|
50
|
+
* reaches the peer, and a one-way path looks exactly like this. */
|
|
51
|
+
export type FtDeadPathFn = (friendId: string) => void;
|
|
46
52
|
export declare class FileTransferManager {
|
|
47
53
|
#private;
|
|
48
54
|
private readonly send;
|
|
49
55
|
private readonly emit;
|
|
50
56
|
private readonly isLanPath;
|
|
51
57
|
private readonly pathKind;
|
|
52
|
-
|
|
58
|
+
private readonly onDeadPath;
|
|
59
|
+
constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath, pathKind?: FtPathKindFn, onDeadPath?: FtDeadPathFn);
|
|
53
60
|
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
54
61
|
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
55
62
|
setResumeDir(dir: string): void;
|
|
@@ -136,6 +136,17 @@ const LAN_BDP_RTT_FLOOR_MS = 50;
|
|
|
136
136
|
const LAN_CWND_INIT_CHUNKS = Math.ceil((LAN_PACE_INIT_BPS * (LAN_BDP_RTT_FLOOR_MS / 1000) * 2) / MAX_FILE_DATA_SIZE);
|
|
137
137
|
const WATCHDOG_MS = 150; // stall poll cadence / base no-progress patience
|
|
138
138
|
const WATCHDOG_MAX_MS = 8000; // cap the RTT-adaptive stall patience (must exceed a slow path's RTT so acks aren't mistaken for a stall)
|
|
139
|
+
// Zero-delivery circuit breaker: after this many consecutive stalls with NO ack
|
|
140
|
+
// progress, stop go-back-N re-blasting the window and drop to a single probe
|
|
141
|
+
// chunk per patience tick. File chunks are RELIABLE on the relay (never dropped
|
|
142
|
+
// under backpressure) while IP/ping packets are droppable, so re-blasting a
|
|
143
|
+
// window into a path that is delivering nothing monopolizes the shared relay
|
|
144
|
+
// socket and starves ping/IP for the whole SEND_GIVEUP_MS minute (observed
|
|
145
|
+
// mac→lili: 0 acks in 60 s, ping 100% loss while sending, recovers on giveup).
|
|
146
|
+
// A healthy path clears `stalls` on every ack advance and never reaches this;
|
|
147
|
+
// a genuinely lost hole is probed with exactly the missing chunk, which is
|
|
148
|
+
// strictly more precise than re-sending the whole window anyway.
|
|
149
|
+
const STALL_PROBE_THRESHOLD = 3;
|
|
139
150
|
const FAST_RT_MIN_MS = 40; // min gap between fast-retransmits of the same hole (≈1 RTT)
|
|
140
151
|
const ACK_THROTTLE_MS = 15; // coalesce receiver acks (fast enough to drive fast-retransmit)
|
|
141
152
|
const REACK_MS = 200; // receiver keep-alive re-ack cadence
|
|
@@ -210,16 +221,18 @@ export class FileTransferManager {
|
|
|
210
221
|
emit;
|
|
211
222
|
isLanPath;
|
|
212
223
|
pathKind;
|
|
224
|
+
onDeadPath;
|
|
213
225
|
#sending = new Map();
|
|
214
226
|
#receiving = new Map();
|
|
215
227
|
// When set, incoming transfers persist their contiguous prefix here so a
|
|
216
228
|
// dropped/restarted transfer resumes instead of restarting (断点续传).
|
|
217
229
|
#resumeDir;
|
|
218
|
-
constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay")) {
|
|
230
|
+
constructor(send, emit, isLanPath = () => false, pathKind = (friendId) => (this.isLanPath(friendId) ? "lan" : "relay"), onDeadPath = () => { }) {
|
|
219
231
|
this.send = send;
|
|
220
232
|
this.emit = emit;
|
|
221
233
|
this.isLanPath = isLanPath;
|
|
222
234
|
this.pathKind = pathKind;
|
|
235
|
+
this.onDeadPath = onDeadPath;
|
|
223
236
|
}
|
|
224
237
|
/** Enable resumable receives: partials are persisted under `dir` and matched
|
|
225
238
|
* on re-offer by content-hash fileId. Unset = in-memory only (no resume). */
|
|
@@ -767,6 +780,12 @@ export class FileTransferManager {
|
|
|
767
780
|
st.acked = Math.min(ackedOffset, st.size);
|
|
768
781
|
st.lastAckAdvanceMs = nowT;
|
|
769
782
|
st.stalls = 0;
|
|
783
|
+
// Once a transfer hit the zero-delivery breaker, keep the embedder's
|
|
784
|
+
// dead-path failover armed for its remainder — recovery happened ON the
|
|
785
|
+
// failover path, so letting the pin lapse would swing DATA back onto the
|
|
786
|
+
// path that was delivering nothing.
|
|
787
|
+
if (st.deadPathTripped)
|
|
788
|
+
this.onDeadPath(friendId);
|
|
770
789
|
// Resume: if the receiver's ack jumps ahead of where we've sent (it had a
|
|
771
790
|
// persisted partial), skip the send cursor past the bytes it already has
|
|
772
791
|
// instead of re-transmitting them.
|
|
@@ -1051,6 +1070,26 @@ export class FileTransferManager {
|
|
|
1051
1070
|
st.parityHighBlock = ackBlock - 1;
|
|
1052
1071
|
st.lastLossMs = nowMs(); // keep FEC ON so the re-emit actually fires
|
|
1053
1072
|
}
|
|
1073
|
+
// Zero-delivery probe: exactly one chunk at the ack frontier, no window
|
|
1074
|
+
// re-blast, no parity. Keeps a dead/black-holed path's cost near zero
|
|
1075
|
+
// (~1.4 KB per patience tick) so the shared relay socket still carries
|
|
1076
|
+
// ping/IP, while still being the precise retransmit for a lost hole.
|
|
1077
|
+
async #sendProbeChunk(friendId, st) {
|
|
1078
|
+
const off = st.acked;
|
|
1079
|
+
const end = Math.min(off + MAX_FILE_DATA_SIZE, st.size);
|
|
1080
|
+
if (end <= off)
|
|
1081
|
+
return;
|
|
1082
|
+
if (st.nextSend < end)
|
|
1083
|
+
st.nextSend = end; // pump resumes AFTER the probed chunk on recovery
|
|
1084
|
+
try {
|
|
1085
|
+
await this.send(friendId, PACKET_ID_FILE_DATA, encodeFileData(st.fileNumber, off, st.data.subarray(off, end)));
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* transport down — the watchdog re-probes on its next tick */ }
|
|
1088
|
+
if (process.env.DECENT_DEBUG || process.env.DECENT_DEBUG_VERBOSE) {
|
|
1089
|
+
console.error(`[file-ft] dead-path-probe friend=${friendId.slice(0, 8)} num=${st.fileNumber} ` +
|
|
1090
|
+
`stalls=${st.stalls} acked=${st.acked}/${st.size}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1054
1093
|
// Single send loop. Awaits each send so net_crypto / the UDP socket applies
|
|
1055
1094
|
// backpressure (fire-and-forget blasted the whole window into the macOS UDP
|
|
1056
1095
|
// buffer at once → systematic overflow/drops the retransmit couldn't dig out
|
|
@@ -1094,6 +1133,19 @@ export class FileTransferManager {
|
|
|
1094
1133
|
st.cwnd = Math.max(CWND_MIN_CHUNKS, Math.floor(st.cwnd / 2));
|
|
1095
1134
|
st.lastAckAdvanceMs = nowMs();
|
|
1096
1135
|
st.lastResendMs = nowMs();
|
|
1136
|
+
if (st.stalls >= STALL_PROBE_THRESHOLD) {
|
|
1137
|
+
// Path is delivering nothing — probe with one chunk instead of
|
|
1138
|
+
// re-blasting the window, so the shared relay keeps carrying
|
|
1139
|
+
// ping/IP. Any ack advance resets `stalls` and resumes the pump.
|
|
1140
|
+
// Also tell the embedder to fail DATA over to its reliable path:
|
|
1141
|
+
// the classic cause here is one-way UDP (their packets keep our
|
|
1142
|
+
// udpFresh alive while ours are black-holed), which no amount of
|
|
1143
|
+
// probing on the same path can fix.
|
|
1144
|
+
st.deadPathTripped = true;
|
|
1145
|
+
this.onDeadPath(friendId);
|
|
1146
|
+
void this.#sendProbeChunk(friendId, st);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1097
1149
|
this.#rewindRetransmit(st); // go-back-N + re-emit tail parity
|
|
1098
1150
|
void this.#pump(friendId, st);
|
|
1099
1151
|
}, WATCHDOG_MS);
|
package/dist/index.d.ts
CHANGED
|
@@ -10,4 +10,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
|
|
|
10
10
|
export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
|
|
11
11
|
export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
|
|
12
12
|
export type { CarrierAddressParts } from "./compat/address.js";
|
|
13
|
-
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, TextMessage } from "./types/peer.js";
|
|
13
|
+
export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
package/dist/peer.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type BootstrapResult } from "./compat/bootstrap.js";
|
|
2
2
|
import type { FriendRecord } from "./store/friends.js";
|
|
3
|
-
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, TextMessage } from "./types/peer.js";
|
|
3
|
+
import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
|
|
4
4
|
export declare class Peer {
|
|
5
5
|
#private;
|
|
6
6
|
private constructor();
|
|
@@ -84,9 +84,21 @@ export declare class Peer {
|
|
|
84
84
|
*/
|
|
85
85
|
removeFriend(pubkey: string): boolean;
|
|
86
86
|
sendText(pubkey: string, text: string): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Send text and keep retransmitting until the peer explicitly ACKs it.
|
|
89
|
+
*
|
|
90
|
+
* The receiving peer sends that ACK only after every onText handler returns
|
|
91
|
+
* successfully. If a handler persists to an inbox, return its write Promise
|
|
92
|
+
* from the handler; then this method resolves only after that durable write
|
|
93
|
+
* completed on the far side. Pass a stable deliveryId when retrying an item
|
|
94
|
+
* from an application outbox across process restarts.
|
|
95
|
+
*/
|
|
96
|
+
sendTextUntilAck(pubkey: string, text: string, opts?: SendTextUntilAckOptions): Promise<{
|
|
97
|
+
deliveryId: string;
|
|
98
|
+
}>;
|
|
87
99
|
waitForFriendConnected(pubkey: string, timeoutMs?: number): Promise<boolean>;
|
|
88
100
|
onFriendRequest(cb: (req: FriendRequest) => void): void;
|
|
89
|
-
onText(cb: (msg: TextMessage) =>
|
|
101
|
+
onText(cb: (msg: TextMessage) => unknown | Promise<unknown>): void;
|
|
90
102
|
/** Files received inline over (bulk)messages — the iOS/C Carrier apps'
|
|
91
103
|
* native way of sending images/audio online (FileModel JSON envelope). */
|
|
92
104
|
onInlineFile(cb: (evt: InlineFileEvent) => void): void;
|
package/dist/peer.js
CHANGED
|
@@ -160,6 +160,24 @@ const LAN_SWEEP_EXTRA_HOSTS = (process.env.DECENT_LAN_SWEEP_EXTRA_HOSTS ?? "")
|
|
|
160
160
|
.split(",")
|
|
161
161
|
.map((s) => s.trim())
|
|
162
162
|
.filter((s) => s.length > 0);
|
|
163
|
+
// Same-host probe: on cookie-request RETRIES, also send the request to our
|
|
164
|
+
// OWN physical IPv4 addresses (plus loopback) at the native default ports.
|
|
165
|
+
// This is the iOS-Simulator-on-this-Mac case: the sim shares the host's
|
|
166
|
+
// IP, so when the host's DHCP address changes BOTH sides keep retrying the
|
|
167
|
+
// dead stored endpoint forever, and DHT/onion refresh can't rescue a
|
|
168
|
+
// same-host peer (router hairpin). Unlike the full /24 sweep (default OFF
|
|
169
|
+
// — its burst saturated the NAT table and killed onion announces), these
|
|
170
|
+
// few unicast packets to ourselves never leave the machine, so this is
|
|
171
|
+
// safe to keep always-on. DECENT_LAN_SELF_PROBE=0 disables.
|
|
172
|
+
const LAN_SELF_PROBE_ENABLED = readEnvInt("DECENT_LAN_SELF_PROBE", 1) !== 0;
|
|
173
|
+
// toxcore NET_PACKET_LAN_DISCOVERY: [0x21][sender DHT pk (32)] — plaintext.
|
|
174
|
+
// A native node that receives one DHT-bootstraps back to the source, i.e. it
|
|
175
|
+
// sends us a getnodes/ping carrying its CURRENT (per-run ephemeral) DHT pk in
|
|
176
|
+
// the clear. That round-trip is the only way to re-learn a native friend's
|
|
177
|
+
// DHT key without onion announces: cookie requests encrypted to a stale DHT
|
|
178
|
+
// key are silently undecryptable (observed live: iPhone answered mini but
|
|
179
|
+
// ignored 780+ cookie retries from mac-dev — same endpoint, stale key).
|
|
180
|
+
const NET_PACKET_LAN_DISCOVERY = 0x21;
|
|
163
181
|
const LAN_DISCOVERY_PORTS = (process.env.DECENT_LAN_DISCOVERY_PORTS ?? "33445,33446,33447,33448,33449")
|
|
164
182
|
.split(",")
|
|
165
183
|
.map((s) => Number.parseInt(s.trim(), 10))
|
|
@@ -168,15 +186,18 @@ const PEER_NICKNAME = process.env.DECENT_PEER_NAME ?? "@decentnetwork/peer";
|
|
|
168
186
|
const PEER_STATUS_MESSAGE = process.env.DECENT_PEER_STATUS_MESSAGE ?? "decent peer";
|
|
169
187
|
const GREETING_TEXT = process.env.DECENT_GREETING_TEXT ?? "";
|
|
170
188
|
// AgentNet wire-protocol version advertised in the userinfo profile (field 7).
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
const AGENTNET_PROTO_VERSION = 1;
|
|
189
|
+
// v1 marks a peer that supports the toxcore file-transfer channel. v2 adds
|
|
190
|
+
// application-level text delivery ACKs: senders keep outbox entries until the
|
|
191
|
+
// receiver's onText handler has returned successfully.
|
|
192
|
+
const AGENTNET_PROTO_VERSION = 2;
|
|
176
193
|
// Peer package version, advertised as the default appVersion when the embedder
|
|
177
194
|
// doesn't override it. Read lazily so a bundler that inlines this file doesn't
|
|
178
195
|
// need the package.json at runtime.
|
|
179
|
-
const PEER_PKG_VERSION = "0.1.
|
|
196
|
+
const PEER_PKG_VERSION = "0.1.112";
|
|
197
|
+
const TEXT_ACK_PREFIX = "\x1eDNPACK1:";
|
|
198
|
+
const TEXT_ACK_TIMEOUT_MS = readEnvInt("DECENT_TEXT_ACK_TIMEOUT_MS", 300_000);
|
|
199
|
+
const TEXT_ACK_RETRY_MS = readEnvInt("DECENT_TEXT_ACK_RETRY_MS", 5_000);
|
|
200
|
+
const TEXT_AUTO_ACK_TIMEOUT_MS = readEnvInt("DECENT_TEXT_AUTO_ACK_TIMEOUT_MS", 15_000);
|
|
180
201
|
// Toxcore Messenger.h packet IDs (live inside encrypted 0x1b crypto data plain payload)
|
|
181
202
|
const PACKET_ID_PADDING = 0;
|
|
182
203
|
const PACKET_ID_REQUEST = 1; // request retransmission of unreceived packets
|
|
@@ -240,6 +261,21 @@ export class Peer {
|
|
|
240
261
|
Date.now() - session.lastUdpRecvMs < 4_000)
|
|
241
262
|
return "udp-direct";
|
|
242
263
|
return "relay";
|
|
264
|
+
}, (friendId) => {
|
|
265
|
+
// Zero-delivery breaker tripped: the bulk path is delivering nothing.
|
|
266
|
+
// The classic cause is one-way UDP — the peer's inbound packets keep
|
|
267
|
+
// udpFresh alive while our outbound UDP is black-holed (their ACCEPT
|
|
268
|
+
// arrived over the TCP relay, which proves the SESSION, not the UDP
|
|
269
|
+
// path). Pin this friend's file traffic to the reliable relay so the
|
|
270
|
+
// probes — and the resumed pump once acks return — actually arrive.
|
|
271
|
+
// Rolling window: re-armed every tick while dead; expires after the
|
|
272
|
+
// transfer finishes or gives up, so a later transfer re-tries UDP.
|
|
273
|
+
const session = this.#friendSessions.get(friendId);
|
|
274
|
+
const lan = !!session?.lanRemoteHost && session.remote?.host === session.lanRemoteHost;
|
|
275
|
+
if (!lan) {
|
|
276
|
+
this.#fileRelayNegotiationUntil.set(friendId, Date.now() + 30_000);
|
|
277
|
+
this.#debugLog(`file dead-path failover: pinning ${friendId} file traffic to relay for 30s`);
|
|
278
|
+
}
|
|
243
279
|
});
|
|
244
280
|
#keyPair;
|
|
245
281
|
#udp = new UdpTransport();
|
|
@@ -273,6 +309,10 @@ export class Peer {
|
|
|
273
309
|
#nodeBlacklist = new Map();
|
|
274
310
|
#pendingFriendRequests = new Map();
|
|
275
311
|
#friends = new Map();
|
|
312
|
+
#textHandlers = new Set();
|
|
313
|
+
#pendingTextAcks = new Map();
|
|
314
|
+
#deliveredTextIds = new Set();
|
|
315
|
+
#deliveredTextOrder = [];
|
|
276
316
|
#friendStoreFile;
|
|
277
317
|
#persistSeq = 0; // makes atomic friend-store temp filenames unique per write
|
|
278
318
|
#cookieSymmetricKey;
|
|
@@ -296,6 +336,10 @@ export class Peer {
|
|
|
296
336
|
#selfAnnounceTimer;
|
|
297
337
|
#friendConnectionTimer;
|
|
298
338
|
#lanDiscoveryTimer;
|
|
339
|
+
/** Recent LAN-discovery probe targets ("host:port" → friend we probed for).
|
|
340
|
+
* When a DHT packet later arrives from one of these sources, its sender pk
|
|
341
|
+
* is that friend's CURRENT DHT key — see #refreshFriendDhtKeyFromDht. */
|
|
342
|
+
#lanProbeTargets = new Map();
|
|
299
343
|
#dhtMaintenanceTimer;
|
|
300
344
|
// Per-friend last DHT-PK send time, keyed by friendId, used even when no
|
|
301
345
|
// session entry exists yet so the connection loop does not flood DHT-PK
|
|
@@ -614,6 +658,13 @@ export class Peer {
|
|
|
614
658
|
}
|
|
615
659
|
}
|
|
616
660
|
}
|
|
661
|
+
// Run the friend-connection loop from start(), not only after a
|
|
662
|
+
// successful joinNetwork(): when every bootstrap is unreachable
|
|
663
|
+
// (offline LAN, walled network), persisted same-LAN/same-host friends
|
|
664
|
+
// must still get their cookie retries + rescue probes — otherwise a
|
|
665
|
+
// daemon with dead bootstraps never reconnects to peers sitting right
|
|
666
|
+
// next to it. Idempotent with the joinNetwork() call.
|
|
667
|
+
this.#ensureFriendConnectionLoop();
|
|
617
668
|
this.#started = true;
|
|
618
669
|
}
|
|
619
670
|
/** Lazy session shell used when we want to attach state before any handshake. */
|
|
@@ -1059,6 +1110,16 @@ export class Peer {
|
|
|
1059
1110
|
}
|
|
1060
1111
|
}
|
|
1061
1112
|
async sendText(pubkey, text) {
|
|
1113
|
+
if (text.length > 0 && !text.startsWith(TEXT_ACK_PREFIX) && this.#shouldRequireTextAck(pubkey)) {
|
|
1114
|
+
await this.sendTextUntilAck(pubkey, text, {
|
|
1115
|
+
timeoutMs: TEXT_AUTO_ACK_TIMEOUT_MS,
|
|
1116
|
+
retryIntervalMs: Math.min(TEXT_ACK_RETRY_MS, TEXT_AUTO_ACK_TIMEOUT_MS)
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
await this.#sendTextPlain(pubkey, text);
|
|
1121
|
+
}
|
|
1122
|
+
async #sendTextPlain(pubkey, text) {
|
|
1062
1123
|
const friend = this.#friends.get(pubkey);
|
|
1063
1124
|
if (!friend) {
|
|
1064
1125
|
throw new Error(`Not a friend: ${pubkey}`);
|
|
@@ -1175,6 +1236,75 @@ export class Peer {
|
|
|
1175
1236
|
}
|
|
1176
1237
|
throw new Error("friend is offline and no express node is configured");
|
|
1177
1238
|
}
|
|
1239
|
+
#shouldRequireTextAck(pubkey) {
|
|
1240
|
+
const friend = this.#friends.get(pubkey);
|
|
1241
|
+
if ((friend?.protoVersion ?? 0) >= 2)
|
|
1242
|
+
return true;
|
|
1243
|
+
// decentlan data-plane peers run with expressControlPlaneOnly so an
|
|
1244
|
+
// accepted local send must not be treated as final delivery. A short ACK
|
|
1245
|
+
// timeout makes the caller keep its outbox item and retry on reconnect.
|
|
1246
|
+
return this.#opts.expressControlPlaneOnly === true;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Send text and keep retransmitting until the peer explicitly ACKs it.
|
|
1250
|
+
*
|
|
1251
|
+
* The receiving peer sends that ACK only after every onText handler returns
|
|
1252
|
+
* successfully. If a handler persists to an inbox, return its write Promise
|
|
1253
|
+
* from the handler; then this method resolves only after that durable write
|
|
1254
|
+
* completed on the far side. Pass a stable deliveryId when retrying an item
|
|
1255
|
+
* from an application outbox across process restarts.
|
|
1256
|
+
*/
|
|
1257
|
+
async sendTextUntilAck(pubkey, text, opts = {}) {
|
|
1258
|
+
const deliveryId = opts.deliveryId ?? createTextDeliveryId();
|
|
1259
|
+
const retryIntervalMs = Math.max(250, opts.retryIntervalMs ?? TEXT_ACK_RETRY_MS);
|
|
1260
|
+
const timeoutMs = Math.max(retryIntervalMs, opts.timeoutMs ?? TEXT_ACK_TIMEOUT_MS);
|
|
1261
|
+
const envelope = encodeTextAckEnvelope({ t: "msg", id: deliveryId, text });
|
|
1262
|
+
const started = Date.now();
|
|
1263
|
+
let lastError;
|
|
1264
|
+
while (Date.now() - started < timeoutMs) {
|
|
1265
|
+
const remaining = Math.max(1, timeoutMs - (Date.now() - started));
|
|
1266
|
+
const waitMs = Math.min(retryIntervalMs, remaining);
|
|
1267
|
+
const ackPromise = this.#waitForTextAck(deliveryId, waitMs);
|
|
1268
|
+
try {
|
|
1269
|
+
await this.#sendTextPlain(pubkey, envelope);
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
this.#cancelTextAckWait(deliveryId);
|
|
1273
|
+
lastError = error;
|
|
1274
|
+
await sleep(waitMs);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (await ackPromise) {
|
|
1278
|
+
return { deliveryId };
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
this.#cancelTextAckWait(deliveryId);
|
|
1282
|
+
throw lastError ?? new Error(`text delivery ACK timed out for ${pubkey}`);
|
|
1283
|
+
}
|
|
1284
|
+
#waitForTextAck(deliveryId, timeoutMs) {
|
|
1285
|
+
return new Promise((resolve) => {
|
|
1286
|
+
const timer = setTimeout(() => {
|
|
1287
|
+
this.#pendingTextAcks.delete(deliveryId);
|
|
1288
|
+
resolve(false);
|
|
1289
|
+
}, timeoutMs);
|
|
1290
|
+
timer.unref?.();
|
|
1291
|
+
this.#pendingTextAcks.set(deliveryId, {
|
|
1292
|
+
resolve: () => {
|
|
1293
|
+
clearTimeout(timer);
|
|
1294
|
+
this.#pendingTextAcks.delete(deliveryId);
|
|
1295
|
+
resolve(true);
|
|
1296
|
+
},
|
|
1297
|
+
reject: () => {
|
|
1298
|
+
clearTimeout(timer);
|
|
1299
|
+
this.#pendingTextAcks.delete(deliveryId);
|
|
1300
|
+
resolve(false);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
#cancelTextAckWait(deliveryId) {
|
|
1306
|
+
this.#pendingTextAcks.get(deliveryId)?.reject(new Error("text delivery ACK wait cancelled"));
|
|
1307
|
+
}
|
|
1178
1308
|
waitForFriendConnected(pubkey, timeoutMs = 30000) {
|
|
1179
1309
|
return this.#waitForFriendConnected(pubkey, timeoutMs);
|
|
1180
1310
|
}
|
|
@@ -1202,7 +1332,65 @@ export class Peer {
|
|
|
1202
1332
|
this.#events.on("friendRequest", cb);
|
|
1203
1333
|
}
|
|
1204
1334
|
onText(cb) {
|
|
1205
|
-
this.#
|
|
1335
|
+
this.#textHandlers.add(cb);
|
|
1336
|
+
}
|
|
1337
|
+
async #dispatchTextMessage(msg) {
|
|
1338
|
+
const envelope = decodeTextAckEnvelope(msg.text);
|
|
1339
|
+
if (envelope?.t === "ack") {
|
|
1340
|
+
this.#pendingTextAcks.get(envelope.id)?.resolve();
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
let deliveryId;
|
|
1344
|
+
let text = msg.text;
|
|
1345
|
+
if (envelope?.t === "msg") {
|
|
1346
|
+
deliveryId = envelope.id;
|
|
1347
|
+
text = envelope.text;
|
|
1348
|
+
if (this.#deliveredTextIds.has(deliveryId)) {
|
|
1349
|
+
await this.#sendTextAck(msg.pubkey, deliveryId);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (this.#textHandlers.size === 0) {
|
|
1354
|
+
if (deliveryId)
|
|
1355
|
+
this.#debugLog(`text delivery ${deliveryId} from ${msg.pubkey} has no onText handlers; not ACKing`);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const ack = deliveryId
|
|
1359
|
+
? async () => { await this.#sendTextAck(msg.pubkey, deliveryId); }
|
|
1360
|
+
: undefined;
|
|
1361
|
+
const delivered = { ...msg, text, deliveryId, ack };
|
|
1362
|
+
try {
|
|
1363
|
+
for (const handler of this.#textHandlers) {
|
|
1364
|
+
await handler(delivered);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
catch (error) {
|
|
1368
|
+
this.#debugLog(`text handler failed for ${msg.pubkey}: ${error.message}`);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
if (deliveryId) {
|
|
1372
|
+
this.#rememberDeliveredTextId(deliveryId);
|
|
1373
|
+
await ack?.();
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
async #sendTextAck(pubkey, deliveryId) {
|
|
1377
|
+
try {
|
|
1378
|
+
await this.sendText(pubkey, encodeTextAckEnvelope({ t: "ack", id: deliveryId }));
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
this.#debugLog(`text ACK send failed for ${pubkey}: ${error.message}`);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
#rememberDeliveredTextId(deliveryId) {
|
|
1385
|
+
if (this.#deliveredTextIds.has(deliveryId))
|
|
1386
|
+
return;
|
|
1387
|
+
this.#deliveredTextIds.add(deliveryId);
|
|
1388
|
+
this.#deliveredTextOrder.push(deliveryId);
|
|
1389
|
+
while (this.#deliveredTextOrder.length > 4096) {
|
|
1390
|
+
const old = this.#deliveredTextOrder.shift();
|
|
1391
|
+
if (old)
|
|
1392
|
+
this.#deliveredTextIds.delete(old);
|
|
1393
|
+
}
|
|
1206
1394
|
}
|
|
1207
1395
|
/** Files received inline over (bulk)messages — the iOS/C Carrier apps'
|
|
1208
1396
|
* native way of sending images/audio online (FileModel JSON envelope). */
|
|
@@ -1546,6 +1734,22 @@ export class Peer {
|
|
|
1546
1734
|
catch { /* best-effort */ }
|
|
1547
1735
|
}).catch(() => { });
|
|
1548
1736
|
}
|
|
1737
|
+
// toxcore LAN discovery (0x21, plaintext [id][sender dht pk]): answer like
|
|
1738
|
+
// native DHT_bootstrap does — DHT-ping the announcer. Our ping reveals OUR
|
|
1739
|
+
// current DHT pk + live endpoint to them, and their ping/response feeds
|
|
1740
|
+
// #refreshFriendDhtKeyFromDht on their side. This is the reply half of the
|
|
1741
|
+
// stranded-friend rescue probes (see #initiateSession).
|
|
1742
|
+
if (packet[0] === NET_PACKET_LAN_DISCOVERY &&
|
|
1743
|
+
packet.length === 33 &&
|
|
1744
|
+
this.#keyPair &&
|
|
1745
|
+
!this.#remoteIsTcp(remote)) {
|
|
1746
|
+
const announcedPk = packet.slice(1, 33);
|
|
1747
|
+
if (!Buffer.from(announcedPk).equals(Buffer.from(this.#keyPair.publicKey))) {
|
|
1748
|
+
const announcerId = carrierIdFromPublicKey(announcedPk);
|
|
1749
|
+
void this.#sendDhtPing({ host: remote.address, port: remote.port, pk: announcerId, isTcp: false }).catch(() => undefined);
|
|
1750
|
+
}
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1549
1753
|
// Classic toxcore DHT RPC (ping / get_nodes / send_nodes). Answering these
|
|
1550
1754
|
// is what makes us *findable* over UDP: a native iOS/Android peer searches
|
|
1551
1755
|
// the DHT for our key, a neighbour that holds our address returns it, then
|
|
@@ -2620,7 +2824,7 @@ export class Peer {
|
|
|
2620
2824
|
// (iOS posts the whole thing as one express MESSAGE packet).
|
|
2621
2825
|
if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
|
|
2622
2826
|
return;
|
|
2623
|
-
this.#
|
|
2827
|
+
void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
|
|
2624
2828
|
}
|
|
2625
2829
|
catch {
|
|
2626
2830
|
// Ignore invalid offline payloads.
|
|
@@ -3607,7 +3811,7 @@ export class Peer {
|
|
|
3607
3811
|
// as files, not as a wall of base64 text.
|
|
3608
3812
|
if (this.#tryEmitInlineFile(friendId, text, "online"))
|
|
3609
3813
|
return;
|
|
3610
|
-
this.#
|
|
3814
|
+
void this.#dispatchTextMessage({
|
|
3611
3815
|
pubkey: friendId,
|
|
3612
3816
|
text,
|
|
3613
3817
|
via: "online"
|
|
@@ -4322,7 +4526,13 @@ export class Peer {
|
|
|
4322
4526
|
}
|
|
4323
4527
|
if (session.lanRemoteHost &&
|
|
4324
4528
|
session.remote?.host === session.lanRemoteHost &&
|
|
4325
|
-
host !== session.lanRemoteHost
|
|
4529
|
+
host !== session.lanRemoteHost &&
|
|
4530
|
+
// Same-host exception: a peer on THIS machine (iOS Simulator) moves
|
|
4531
|
+
// with us when our DHCP address changes — the old LAN lock target is
|
|
4532
|
+
// dead and the peer now answers from our own current address (or
|
|
4533
|
+
// loopback). Refusing that move pins the session to the dead IP
|
|
4534
|
+
// forever. isOwnAddress is cached; no syscall on the hot path.
|
|
4535
|
+
!(host === "127.0.0.1" || isOwnAddress(host))) {
|
|
4326
4536
|
return; // stay locked on the physical-LAN path
|
|
4327
4537
|
}
|
|
4328
4538
|
session.remote = { host, port };
|
|
@@ -4933,12 +5143,79 @@ export class Peer {
|
|
|
4933
5143
|
// Keep best-effort behavior.
|
|
4934
5144
|
}
|
|
4935
5145
|
}
|
|
5146
|
+
// Rescue probes (retries only). Two stale-state failure modes keep a
|
|
5147
|
+
// native friend unreachable forever even though it is alive nearby:
|
|
5148
|
+
// 1. Stale ENDPOINT, same host: an iOS Simulator shares this
|
|
5149
|
+
// machine's IP, so after a DHCP change both sides hold the dead
|
|
5150
|
+
// old IP and hairpin blocks every refresh path — but the sim is
|
|
5151
|
+
// always reachable at our current own IP.
|
|
5152
|
+
// 2. Stale DHT KEY, live endpoint: a native's DHT keypair rotates
|
|
5153
|
+
// every app restart; cookie requests encrypted to the old key
|
|
5154
|
+
// are silently dropped (iPhone ignored 780+ retries from
|
|
5155
|
+
// mac-dev while answering mini from the same endpoint).
|
|
5156
|
+
// So on each retry, (a) fan the cookie request out to our own
|
|
5157
|
+
// addresses at the native ports (fixes 1 when the key is current),
|
|
5158
|
+
// and (b) send plaintext LAN-discovery probes to every candidate and
|
|
5159
|
+
// own-host target — a native answers with a DHT packet that carries
|
|
5160
|
+
// its CURRENT DHT key, which #refreshFriendDhtKeyFromDht picks up
|
|
5161
|
+
// (fixes 2, and 1+2 combined). All local/LAN unicast, a handful of
|
|
5162
|
+
// packets per retry cycle.
|
|
5163
|
+
let selfSent = 0;
|
|
5164
|
+
if (LAN_SELF_PROBE_ENABLED && (this.#cookieRetryCount.get(friendId) ?? 0) >= 1) {
|
|
5165
|
+
const ourLocalPort = this.#udp.localPort();
|
|
5166
|
+
const lanDiscovery = new Uint8Array(33);
|
|
5167
|
+
lanDiscovery[0] = NET_PACKET_LAN_DISCOVERY;
|
|
5168
|
+
lanDiscovery.set(this.#keyPair.publicKey, 1);
|
|
5169
|
+
const probeNow = Date.now();
|
|
5170
|
+
// Prune stale probe bookkeeping so the map stays bounded.
|
|
5171
|
+
for (const [k, v] of this.#lanProbeTargets) {
|
|
5172
|
+
if (probeNow - v.sentMs > 300_000)
|
|
5173
|
+
this.#lanProbeTargets.delete(k);
|
|
5174
|
+
}
|
|
5175
|
+
const probe = async (host, port, alsoCookie) => {
|
|
5176
|
+
const key = `${host}:${port}`;
|
|
5177
|
+
this.#lanProbeTargets.set(key, { friendId, sentMs: probeNow });
|
|
5178
|
+
try {
|
|
5179
|
+
await this.#sendPacket(lanDiscovery, { host, port });
|
|
5180
|
+
if (alsoCookie) {
|
|
5181
|
+
await this.#sendPacket(packet, { host, port });
|
|
5182
|
+
selfSent += 1;
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
catch {
|
|
5186
|
+
// best-effort
|
|
5187
|
+
}
|
|
5188
|
+
};
|
|
5189
|
+
const tried = new Set();
|
|
5190
|
+
// (b) known candidates — refresh a rotated DHT key at a live endpoint.
|
|
5191
|
+
for (const candidate of connectCandidates) {
|
|
5192
|
+
const key = `${candidate.host}:${candidate.port}`;
|
|
5193
|
+
if (tried.has(key))
|
|
5194
|
+
continue;
|
|
5195
|
+
tried.add(key);
|
|
5196
|
+
await probe(candidate.host, candidate.port, false); // cookie already sent above
|
|
5197
|
+
}
|
|
5198
|
+
// (a)+(b) own-host targets — find a same-host peer after an IP change.
|
|
5199
|
+
for (const host of [...getLocalIpv4Addresses(), "127.0.0.1"]) {
|
|
5200
|
+
if (isOwnVirtualAddress(host))
|
|
5201
|
+
continue; // never probe into the overlay
|
|
5202
|
+
for (const port of LAN_SWEEP_PORTS) {
|
|
5203
|
+
if (port === ourLocalPort)
|
|
5204
|
+
continue; // our own socket
|
|
5205
|
+
const key = `${host}:${port}`;
|
|
5206
|
+
if (tried.has(key))
|
|
5207
|
+
continue;
|
|
5208
|
+
tried.add(key);
|
|
5209
|
+
await probe(host, port, true);
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
5212
|
+
}
|
|
4936
5213
|
// Also send via TCP relay if available, in parallel. Whichever
|
|
4937
5214
|
// arrives at the friend first triggers their cookie response.
|
|
4938
5215
|
if (tcpAvailable && this.#tcpRelays) {
|
|
4939
5216
|
tcpSent = this.#tcpRelays.sendToFriend(friendRealPk, packet);
|
|
4940
5217
|
}
|
|
4941
|
-
if (sent === 0 && tcpSent === 0) {
|
|
5218
|
+
if (sent === 0 && tcpSent === 0 && selfSent === 0) {
|
|
4942
5219
|
throw new Error("no cookie request packet was sent");
|
|
4943
5220
|
}
|
|
4944
5221
|
// Each unmatched attempt grows the per-friend backoff. Resets when a
|
|
@@ -4948,13 +5225,13 @@ export class Peer {
|
|
|
4948
5225
|
const primaryDesc = connectCandidates.length > 0
|
|
4949
5226
|
? `${connectCandidates[0].host}:${connectCandidates[0].port}`
|
|
4950
5227
|
: `tcp-relay`;
|
|
4951
|
-
const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}`;
|
|
5228
|
+
const cookieKey = `${primaryDesc}|udp=${sent}|tcp=${tcpSent}|self=${selfSent}`;
|
|
4952
5229
|
if (this.#lastCookieSentKey.get(friendId) !== cookieKey) {
|
|
4953
|
-
this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc}`);
|
|
5230
|
+
this.#debugLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc}`);
|
|
4954
5231
|
this.#lastCookieSentKey.set(friendId, cookieKey);
|
|
4955
5232
|
}
|
|
4956
5233
|
else {
|
|
4957
|
-
this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
|
|
5234
|
+
this.#debugVerboseLog(`cookie_sent friend=${friendId} udp=${sent} tcp=${tcpSent} self=${selfSent} primary=${primaryDesc} (retry ${this.#cookieRetryCount.get(friendId)})`);
|
|
4958
5235
|
}
|
|
4959
5236
|
return true;
|
|
4960
5237
|
}
|
|
@@ -5003,9 +5280,9 @@ export class Peer {
|
|
|
5003
5280
|
// default so this is invisible for real-device tests, opt-in for
|
|
5004
5281
|
// loopback or other known-fixed targets via env var.
|
|
5005
5282
|
for (const host of LAN_SWEEP_EXTRA_HOSTS) {
|
|
5006
|
-
if (ourLocalIps.includes(host))
|
|
5007
|
-
continue;
|
|
5008
5283
|
for (const port of LAN_SWEEP_PORTS) {
|
|
5284
|
+
// Skip only our own socket — a same-host peer (iOS Simulator) lives
|
|
5285
|
+
// at our own IP on a different port, so own-IP hosts must be probed.
|
|
5009
5286
|
if (port === ourLocalPort && ourLocalIps.includes(host))
|
|
5010
5287
|
continue;
|
|
5011
5288
|
try {
|
|
@@ -5026,9 +5303,9 @@ export class Peer {
|
|
|
5026
5303
|
continue;
|
|
5027
5304
|
for (let addr = (network + 1) >>> 0; addr < broadcast; addr = (addr + 1) >>> 0) {
|
|
5028
5305
|
const host = `${(addr >>> 24) & 0xff}.${(addr >>> 16) & 0xff}.${(addr >>> 8) & 0xff}.${addr & 0xff}`;
|
|
5029
|
-
if (ourLocalIps.includes(host))
|
|
5030
|
-
continue;
|
|
5031
5306
|
for (const port of LAN_SWEEP_PORTS) {
|
|
5307
|
+
// Skip only our own socket, not our whole IP — the iOS Simulator
|
|
5308
|
+
// shares this host's address on its own port.
|
|
5032
5309
|
if (ourLocalPort === port && ourLocalIps.includes(host))
|
|
5033
5310
|
continue;
|
|
5034
5311
|
try {
|
|
@@ -5102,6 +5379,10 @@ export class Peer {
|
|
|
5102
5379
|
// keeps mutual reachability alive across the mesh.
|
|
5103
5380
|
if (remote.port > 0 && !this.#remoteIsTcp(remote)) {
|
|
5104
5381
|
this.addKnownNodes([{ host: remote.address, port: remote.port, pk: senderId, isTcp: false }]);
|
|
5382
|
+
// A DHT packet carries the sender's CURRENT DHT pk in the clear — if
|
|
5383
|
+
// this source answers one of our LAN-discovery rescue probes, harvest
|
|
5384
|
+
// the key: it un-strands friends whose ephemeral DHT key rotated.
|
|
5385
|
+
this.#refreshFriendDhtKeyFromDht(decoded.senderPublicKey, senderId, remote.address, remote.port);
|
|
5105
5386
|
}
|
|
5106
5387
|
if (decoded.type === NET_PACKET_PING_REQUEST) {
|
|
5107
5388
|
const ping = parsePingPlain(decoded.plain);
|
|
@@ -5173,6 +5454,58 @@ export class Peer {
|
|
|
5173
5454
|
return;
|
|
5174
5455
|
}
|
|
5175
5456
|
}
|
|
5457
|
+
/** A DHT packet arrived from `host:port` with `senderDhtPk` in the clear.
|
|
5458
|
+
* If that source matches a LAN-discovery rescue probe we sent (or an
|
|
5459
|
+
* endpoint candidate of a stranded friend), adopt the pk as the friend's
|
|
5460
|
+
* CURRENT DHT key. Natives rotate their DHT keypair every app restart, so
|
|
5461
|
+
* a peer that only holds the old key sends cookie requests the friend
|
|
5462
|
+
* cannot decrypt — correct endpoint, silent drop, stuck forever.
|
|
5463
|
+
*
|
|
5464
|
+
* Safety: only unestablished sessions are touched (a live session's key
|
|
5465
|
+
* is by definition correct), and if the adopted key turns out to belong
|
|
5466
|
+
* to a different node, the crypto handshake cannot complete against the
|
|
5467
|
+
* friend's REAL key — the rescue just fails and normal retries continue. */
|
|
5468
|
+
#refreshFriendDhtKeyFromDht(senderDhtPk, senderId, host, port) {
|
|
5469
|
+
const key = `${host}:${port}`;
|
|
5470
|
+
let friendId;
|
|
5471
|
+
const probed = this.#lanProbeTargets.get(key);
|
|
5472
|
+
if (probed && Date.now() - probed.sentMs < 300_000) {
|
|
5473
|
+
friendId = probed.friendId;
|
|
5474
|
+
}
|
|
5475
|
+
else {
|
|
5476
|
+
// No probe bookkeeping — still match a stranded friend's known
|
|
5477
|
+
// endpoint candidates (the friend may DHT-ping us spontaneously).
|
|
5478
|
+
for (const [fid, session] of this.#friendSessions) {
|
|
5479
|
+
if (session.established)
|
|
5480
|
+
continue;
|
|
5481
|
+
if (session.endpointCandidates?.some((c) => c.host === host && c.port === port)) {
|
|
5482
|
+
friendId = fid;
|
|
5483
|
+
break;
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
5487
|
+
if (!friendId)
|
|
5488
|
+
return;
|
|
5489
|
+
// The sender identifies as an existing DIFFERENT friend (JS peers use
|
|
5490
|
+
// their stable real key as DHT key) — don't cross-wire.
|
|
5491
|
+
const byDht = this.#friendByDhtPk(senderId);
|
|
5492
|
+
if (byDht && byDht.friendId !== friendId)
|
|
5493
|
+
return;
|
|
5494
|
+
if (senderId === friendId)
|
|
5495
|
+
return; // already keyed by real pk — nothing to refresh
|
|
5496
|
+
const session = this.#friendSessions.get(friendId);
|
|
5497
|
+
if (!session || session.established)
|
|
5498
|
+
return;
|
|
5499
|
+
const current = session.friendDhtPublicKey;
|
|
5500
|
+
if (current && Buffer.from(current).equals(Buffer.from(senderDhtPk)))
|
|
5501
|
+
return;
|
|
5502
|
+
this.#debugLog(`dht_key_refreshed friend=${friendId} via=${key} old=${current ? carrierIdFromPublicKey(current) : "none"} new=${senderId}`);
|
|
5503
|
+
this.#cacheFriendRemote(friendId, host, port, undefined, senderDhtPk);
|
|
5504
|
+
// Drop the backoff so the next cookie request (now decryptable) goes out
|
|
5505
|
+
// promptly instead of waiting out minutes of accumulated failures.
|
|
5506
|
+
this.#cookieRetryCount.delete(friendId);
|
|
5507
|
+
void this.#initiateSession(friendId).catch(() => undefined);
|
|
5508
|
+
}
|
|
5176
5509
|
async #sendDhtGetNodes(node, targetPublicKey) {
|
|
5177
5510
|
if (!this.#keyPair || !node.pk || node.isTcp)
|
|
5178
5511
|
return;
|
|
@@ -6057,6 +6390,33 @@ function decodeUtf8Best(payload) {
|
|
|
6057
6390
|
return "";
|
|
6058
6391
|
}
|
|
6059
6392
|
}
|
|
6393
|
+
function encodeTextAckEnvelope(envelope) {
|
|
6394
|
+
return TEXT_ACK_PREFIX + Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url");
|
|
6395
|
+
}
|
|
6396
|
+
function decodeTextAckEnvelope(text) {
|
|
6397
|
+
if (!text.startsWith(TEXT_ACK_PREFIX))
|
|
6398
|
+
return undefined;
|
|
6399
|
+
try {
|
|
6400
|
+
const raw = Buffer.from(text.slice(TEXT_ACK_PREFIX.length), "base64url").toString("utf8");
|
|
6401
|
+
const parsed = JSON.parse(raw);
|
|
6402
|
+
if (parsed.t === "ack" && typeof parsed.id === "string" && parsed.id.length > 0) {
|
|
6403
|
+
return { t: "ack", id: parsed.id };
|
|
6404
|
+
}
|
|
6405
|
+
if (parsed.t === "msg" &&
|
|
6406
|
+
typeof parsed.id === "string" &&
|
|
6407
|
+
parsed.id.length > 0 &&
|
|
6408
|
+
typeof parsed.text === "string") {
|
|
6409
|
+
return { t: "msg", id: parsed.id, text: parsed.text };
|
|
6410
|
+
}
|
|
6411
|
+
}
|
|
6412
|
+
catch {
|
|
6413
|
+
return undefined;
|
|
6414
|
+
}
|
|
6415
|
+
return undefined;
|
|
6416
|
+
}
|
|
6417
|
+
function createTextDeliveryId() {
|
|
6418
|
+
return `${Date.now().toString(36)}-${Buffer.from(randomBytes(12)).toString("base64url")}`;
|
|
6419
|
+
}
|
|
6060
6420
|
function tryDecodeCarrierMessagePacket(payload) {
|
|
6061
6421
|
try {
|
|
6062
6422
|
const decoded = decodeCarrierPacket(payload);
|
package/dist/types/peer.d.ts
CHANGED
|
@@ -122,11 +122,26 @@ export type FriendInfoEvent = {
|
|
|
122
122
|
export type TextMessage = {
|
|
123
123
|
pubkey: string;
|
|
124
124
|
text: string;
|
|
125
|
+
/** Stable id for SDK-level acknowledged delivery. Present only for messages
|
|
126
|
+
* sent with sendTextUntilAck(). Receivers can persist this id for dedupe. */
|
|
127
|
+
deliveryId?: string;
|
|
128
|
+
/** Send the delivery ACK. The SDK calls this automatically after all onText
|
|
129
|
+
* handlers return successfully; handlers that need durable inbox semantics
|
|
130
|
+
* should return a Promise that resolves only after the inbox write is done. */
|
|
131
|
+
ack?: () => Promise<void>;
|
|
125
132
|
/** Delivery path: "online" = live net_crypto session (direct/relay), "offline"
|
|
126
133
|
* = express store-and-forward. Lets the UI color the two differently so a user
|
|
127
134
|
* can see when online delivery is failing and only offline messages land. */
|
|
128
135
|
via?: "online" | "offline";
|
|
129
136
|
};
|
|
137
|
+
export type SendTextUntilAckOptions = {
|
|
138
|
+
/** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
|
|
139
|
+
deliveryId?: string;
|
|
140
|
+
/** How long to keep retrying before rejecting. Defaults to 5 minutes. */
|
|
141
|
+
timeoutMs?: number;
|
|
142
|
+
/** Delay between retransmit attempts while no ACK has arrived. Defaults to 5s. */
|
|
143
|
+
retryIntervalMs?: number;
|
|
144
|
+
};
|
|
130
145
|
/**
|
|
131
146
|
* An application-defined custom packet received from a friend, over the
|
|
132
147
|
* toxcore custom packet ranges: lossless `160–191` (reliable, ordered) or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decentnetwork/peer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.113",
|
|
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",
|