@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.
@@ -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
- constructor(send: FtSend, emit: FtEmit, isLanPath?: FtIsLanPath, pathKind?: FtPathKindFn);
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) => void): void;
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
- // Bump when the wire capabilities change so peers can negotiate. v1 marks a peer
172
- // that carries this field AND supports the toxcore file-transfer channel
173
- // (PACKET_ID_FILE 80-82) for large files — a sender can prefer that over inline
174
- // bulkmsg (capped at CARRIER_MAX_APP_BULKMSG_LEN) when the peer's protoVersion>=1.
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.91";
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.#events.on("text", cb);
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.#events.emit("text", { pubkey: fromUserId, text, via: "offline" });
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.#events.emit("text", {
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);
@@ -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.111",
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",