@decentnetwork/peer 0.1.89 → 0.1.91

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.
Files changed (2) hide show
  1. package/dist/peer.js +90 -45
  2. package/package.json +2 -1
package/dist/peer.js CHANGED
@@ -174,7 +174,7 @@ const AGENTNET_PROTO_VERSION = 1;
174
174
  // Peer package version, advertised as the default appVersion when the embedder
175
175
  // doesn't override it. Read lazily so a bundler that inlines this file doesn't
176
176
  // need the package.json at runtime.
177
- const PEER_PKG_VERSION = "0.1.89";
177
+ const PEER_PKG_VERSION = "0.1.91";
178
178
  // Toxcore Messenger.h packet IDs (live inside encrypted 0x1b crypto data plain payload)
179
179
  const PACKET_ID_PADDING = 0;
180
180
  const PACKET_ID_REQUEST = 1; // request retransmission of unreceived packets
@@ -2032,8 +2032,27 @@ export class Peer {
2032
2032
  // De-duplicate: skip the DISPATCH + nonce advance for an already-seen
2033
2033
  // packet number. The transport confirmation above already ran, so a
2034
2034
  // duplicate still keeps the path fresh.
2035
+ //
2036
+ // EXCEPTION: multi-fragment reliable messages that reassemble by tid —
2037
+ // BULKMSG (inline files) and INVITE_REQUEST/RESPONSE (WebRTC call SDPs) —
2038
+ // can arrive REORDERED. The C SDK puts totalsz on the FIRST fragment, but
2039
+ // over a dual/UDP path a later fragment can land first, advancing the
2040
+ // low-water past the first fragment's number. Dropping the first fragment
2041
+ // here makes every fragment read totalsz 0 → the message is lost or (for
2042
+ // an SDP) scrambled, so a call gets stuck "connecting" and files vanish
2043
+ // ("bulkmsg totalsz 0 — dropped"). Let those through; #assembleBulkMsg /
2044
+ // #assembleInvite dedup AND order fragments by packet number.
2035
2045
  if (!isSingleTransportKind && opened.packetNumber < (state.receiveBufferStart ?? 0)) {
2036
- continue;
2046
+ let reorderable = false;
2047
+ if (innerKind === PACKET_ID_MESSAGE) {
2048
+ try {
2049
+ const t = decodeCarrierPacket(opened.payload.slice(1)).type;
2050
+ reorderable = t === PACKET_TYPE_BULKMSG || t === PACKET_TYPE_INVITE_REQUEST || t === PACKET_TYPE_INVITE_RESPONSE;
2051
+ }
2052
+ catch { /* not a carrier packet */ }
2053
+ }
2054
+ if (!reorderable)
2055
+ continue;
2037
2056
  }
2038
2057
  // Track the receive nonce from the EXACT nonce this packet used
2039
2058
  // (openCryptoDataPacket reconstructs it with full carry/borrow).
@@ -2078,7 +2097,7 @@ export class Peer {
2078
2097
  if (consumesReliableNumber) {
2079
2098
  state.receiveBufferStart = Math.max(state.receiveBufferStart ?? 0, (opened.packetNumber + 1) >>> 0);
2080
2099
  }
2081
- this.#deliverLosslessPayload(friendId, state, kind, inner, remote);
2100
+ this.#deliverLosslessPayload(friendId, state, kind, inner, remote, opened.packetNumber);
2082
2101
  return;
2083
2102
  }
2084
2103
  // Reverted: we used to DELETE a "desynced" session here to force a clean
@@ -3174,7 +3193,7 @@ export class Peer {
3174
3193
  * out of the datagram handler so the receive reorder buffer can replay
3175
3194
  * buffered packets through the exact same dispatch. `kind` is the first
3176
3195
  * payload byte (never undefined here — the caller defaults it). */
3177
- #deliverLosslessPayload(friendId, state, kind, inner, remote) {
3196
+ #deliverLosslessPayload(friendId, state, kind, inner, remote, packetNumber = 0) {
3178
3197
  // Toxcore file transfer (FILE_SENDREQUEST/CONTROL/DATA = 80/81/82).
3179
3198
  if (this.#fileTransfer.handlePacket(friendId, kind, inner))
3180
3199
  return;
@@ -3329,7 +3348,7 @@ export class Peer {
3329
3348
  // signals (SDP offers) fragment by tid like bulkmsg; reassemble then
3330
3349
  // surface as an "invite" event (NOT chat text).
3331
3350
  if (carrier?.type === PACKET_TYPE_INVITE_REQUEST) {
3332
- const complete = this.#assembleInvite(friendId, carrier);
3351
+ const complete = this.#assembleInvite(friendId, carrier, packetNumber);
3333
3352
  if (!complete)
3334
3353
  return; // more fragments pending
3335
3354
  this.#events.emit("invite", {
@@ -3342,7 +3361,7 @@ export class Peer {
3342
3361
  return;
3343
3362
  }
3344
3363
  if (carrier?.type === PACKET_TYPE_INVITE_RESPONSE) {
3345
- const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier) : new Uint8Array();
3364
+ const complete = carrier.status === 0 ? this.#assembleInvite(friendId, carrier, packetNumber) : new Uint8Array();
3346
3365
  if (carrier.status === 0 && !complete)
3347
3366
  return; // more fragments pending
3348
3367
  this.#events.emit("inviteResponse", {
@@ -3357,7 +3376,7 @@ export class Peer {
3357
3376
  return;
3358
3377
  }
3359
3378
  if (carrier?.type === PACKET_TYPE_BULKMSG) {
3360
- const complete = this.#assembleBulkMsg(friendId, carrier);
3379
+ const complete = this.#assembleBulkMsg(friendId, carrier, packetNumber);
3361
3380
  if (!complete)
3362
3381
  return; // more fragments pending
3363
3382
  text = decodeUtf8Best(complete);
@@ -3487,11 +3506,19 @@ export class Peer {
3487
3506
  incrementNonce(state.ourBaseNonce);
3488
3507
  await this.#sendToFriend(friendId, encrypted, state, false, false);
3489
3508
  }
3490
- /** Append a Carrier BULKMSG fragment; returns the full reassembled
3491
- * message when the last fragment lands, undefined while pending.
3492
- * Mirrors the C SDK's handle_friend_bulkmsg: totalsz on the first
3493
- * fragment sizes the buffer; fragments append in arrival order. */
3494
- #assembleBulkMsg(friendId, frag) {
3509
+ /** Append a Carrier BULKMSG fragment and return the full reassembled message
3510
+ * once every fragment has landed (undefined while pending).
3511
+ *
3512
+ * The C SDK's send_bulk_message puts totalsz on the FIRST fragment (index 1)
3513
+ * and sends the rest with totalsz 0, back-to-back with ascending reliable
3514
+ * packet numbers. Over a dual/UDP path those fragments can arrive REORDERED —
3515
+ * including the first (totalsz-bearing) one landing after later fragments. So
3516
+ * we key fragments by their PACKET NUMBER (dedup + order), record totalsz
3517
+ * whenever the first fragment shows up (in any order), and complete once we
3518
+ * hold every byte — then concatenate in packet-number order. This tolerates
3519
+ * reordering + duplicates WITHOUT stalling the reliable stream (a genuinely
3520
+ * lost fragment just expires this one message after 60s, nothing else). */
3521
+ #assembleBulkMsg(friendId, frag, packetNumber) {
3495
3522
  const now = Date.now();
3496
3523
  // Lazy expiry sweep (C uses a 60s assembly timeout).
3497
3524
  for (const [key, entry] of this.#bulkAssembly) {
@@ -3501,31 +3528,44 @@ export class Peer {
3501
3528
  const key = `${friendId}:${frag.tid.toString()}`;
3502
3529
  let entry = this.#bulkAssembly.get(key);
3503
3530
  if (!entry) {
3504
- if (!frag.totalsz || frag.totalsz > CARRIER_MAX_APP_BULKMSG_LEN) {
3505
- this.#debugLog(`bulkmsg from ${friendId} with invalid/missing totalsz ${frag.totalsz} — dropped`);
3531
+ entry = { total: 0, frags: new Map(), got: 0, expireAtMs: now + 60_000 };
3532
+ this.#bulkAssembly.set(key, entry);
3533
+ }
3534
+ // The first fragment carries totalsz; it may arrive out of order.
3535
+ if (frag.totalsz > 0) {
3536
+ if (frag.totalsz > CARRIER_MAX_APP_BULKMSG_LEN) {
3537
+ this.#debugLog(`bulkmsg from ${friendId} totalsz ${frag.totalsz} exceeds cap — dropped`);
3538
+ this.#bulkAssembly.delete(key);
3506
3539
  return undefined;
3507
3540
  }
3508
- entry = { total: frag.totalsz, chunks: [], got: 0, expireAtMs: now + 60_000 };
3509
- this.#bulkAssembly.set(key, entry);
3541
+ entry.total = frag.totalsz;
3510
3542
  }
3511
- if (frag.data.length === 0 || entry.got + frag.data.length > entry.total) {
3512
- this.#debugLog(`bulkmsg fragment from ${friendId} overflows totalsz — dropped`);
3513
- this.#bulkAssembly.delete(key);
3514
- return undefined;
3543
+ // Store by packet number; a duplicate (dual-send) fragment is ignored.
3544
+ if (frag.data.length > 0 && !entry.frags.has(packetNumber)) {
3545
+ entry.frags.set(packetNumber, frag.data);
3546
+ entry.got += frag.data.length;
3515
3547
  }
3516
- entry.chunks.push(frag.data);
3517
- entry.got += frag.data.length;
3518
- if (entry.got < entry.total)
3548
+ // Complete only once the totalsz-bearing fragment has arrived AND we hold
3549
+ // every byte. Until then, keep buffering.
3550
+ if (entry.total === 0 || entry.got < entry.total)
3519
3551
  return undefined;
3520
3552
  this.#bulkAssembly.delete(key);
3521
- return concatBytes(entry.chunks);
3522
- }
3523
- /** Append a Carrier invite fragment (INVITE_REQUEST/RESPONSE); returns the
3524
- * full reassembled payload when the last fragment lands, undefined while
3525
- * pending. Mirrors #assembleBulkMsg but caps at CARRIER_MAX_INVITE_DATA_LEN
3526
- * (8192) and uses a separate buffer. Single-fragment invites (the common
3527
- * case for small WebRTC signals) complete on the first call. */
3528
- #assembleInvite(friendId, frag) {
3553
+ if (entry.got > entry.total) {
3554
+ this.#debugLog(`bulkmsg from ${friendId} got ${entry.got} > total ${entry.total} — dropped`);
3555
+ return undefined;
3556
+ }
3557
+ // Concatenate in ascending packet-number order (the fragments' send order).
3558
+ const nums = [...entry.frags.keys()].sort((a, b) => a - b);
3559
+ return concatBytes(nums.map((n) => entry.frags.get(n)));
3560
+ }
3561
+ /** Append a Carrier invite fragment (INVITE_REQUEST/RESPONSE) and return the
3562
+ * full reassembled payload once every fragment has landed. Like
3563
+ * #assembleBulkMsg, fragments are keyed + ordered by PACKET NUMBER so a
3564
+ * reordered multi-fragment WebRTC SDP (offer/answer >1280 bytes) reassembles
3565
+ * correctly instead of scrambling — a scrambled SDP left calls stuck
3566
+ * "connecting". Caps at CARRIER_MAX_INVITE_DATA_LEN (8192). Single-fragment
3567
+ * invites (the common case) complete on the first call. */
3568
+ #assembleInvite(friendId, frag, packetNumber) {
3529
3569
  const now = Date.now();
3530
3570
  for (const [key, entry] of this.#inviteAssembly) {
3531
3571
  if (entry.expireAtMs < now)
@@ -3534,26 +3574,31 @@ export class Peer {
3534
3574
  const key = `${friendId}:${frag.tid.toString()}`;
3535
3575
  let entry = this.#inviteAssembly.get(key);
3536
3576
  if (!entry) {
3537
- // First fragment carries totalsz. A lone fragment whose data already
3538
- // equals totalsz completes immediately below.
3539
- if (!frag.totalsz || frag.totalsz > CARRIER_MAX_INVITE_DATA_LEN) {
3540
- this.#debugLog(`invite from ${friendId} with invalid/missing totalsz ${frag.totalsz} dropped`);
3577
+ entry = { total: 0, frags: new Map(), got: 0, expireAtMs: now + 60_000 };
3578
+ this.#inviteAssembly.set(key, entry);
3579
+ }
3580
+ // The first fragment carries totalsz; it may arrive out of order.
3581
+ if (frag.totalsz > 0) {
3582
+ if (frag.totalsz > CARRIER_MAX_INVITE_DATA_LEN) {
3583
+ this.#debugLog(`invite from ${friendId} totalsz ${frag.totalsz} exceeds cap — dropped`);
3584
+ this.#inviteAssembly.delete(key);
3541
3585
  return undefined;
3542
3586
  }
3543
- entry = { total: frag.totalsz, chunks: [], got: 0, expireAtMs: now + 60_000 };
3544
- this.#inviteAssembly.set(key, entry);
3587
+ entry.total = frag.totalsz;
3545
3588
  }
3546
- if (frag.data.length === 0 || entry.got + frag.data.length > entry.total) {
3547
- this.#debugLog(`invite fragment from ${friendId} overflows totalsz — dropped`);
3548
- this.#inviteAssembly.delete(key);
3549
- return undefined;
3589
+ if (frag.data.length > 0 && !entry.frags.has(packetNumber)) {
3590
+ entry.frags.set(packetNumber, frag.data);
3591
+ entry.got += frag.data.length;
3550
3592
  }
3551
- entry.chunks.push(frag.data);
3552
- entry.got += frag.data.length;
3553
- if (entry.got < entry.total)
3593
+ if (entry.total === 0 || entry.got < entry.total)
3554
3594
  return undefined;
3555
3595
  this.#inviteAssembly.delete(key);
3556
- return concatBytes(entry.chunks);
3596
+ if (entry.got > entry.total) {
3597
+ this.#debugLog(`invite from ${friendId} got ${entry.got} > total ${entry.total} — dropped`);
3598
+ return undefined;
3599
+ }
3600
+ const nums = [...entry.frags.keys()].sort((a, b) => a - b);
3601
+ return concatBytes(nums.map((n) => entry.frags.get(n)));
3557
3602
  }
3558
3603
  /** iOS Beagle sends files inline as a JSON envelope over (bulk)messages:
3559
3604
  * {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.89",
3
+ "version": "0.1.91",
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",
@@ -69,6 +69,7 @@
69
69
  "test:compat": "pnpm run build && node scripts/compat-selftest.mjs",
70
70
  "test:invite": "pnpm run build && node scripts/invite-selftest.mjs",
71
71
  "test:reorder": "pnpm run build && node scripts/reorder-selftest.mjs",
72
+ "test:bulkmsg": "pnpm run build && node scripts/bulkmsg-selftest.mjs",
72
73
  "test:userinfo": "pnpm run build && node scripts/userinfo-selftest.mjs",
73
74
  "test:tcp-onion": "pnpm run build && node scripts/tcp-onion-selftest.mjs",
74
75
  "smoke:join": "node scripts/smoke-join.mjs",